diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index da9dd65a..29c14bef 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -88,6 +88,7 @@ import {
} from "@/utils/aspectRatioUtils";
import { ExtensionIcon } from "./ExtensionIcon";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
+import { resolveExportStatusModel } from "./exportStatusModel";
import { resolveMp4ExportRouting } from "./mp4ExportRouting";
import { resolveMp4ExportSettings } from "./mp4ExportSettings";
import { useNvidiaCudaExportOptIn } from "./useNvidiaCudaExportOptIn";
@@ -4826,94 +4827,31 @@ export default function VideoEditor() {
);
}, [t]);
- const isExportSaving = exportProgress?.phase === "saving";
- const isExportPreparing =
- isExporting && (!exportProgress || exportProgress.phase === "preparing");
- const isExportFinalizing = exportProgress?.phase === "finalizing";
- const isRenderingAudio =
- isExportFinalizing && typeof exportProgress?.audioProgress === "number";
- const exportFinalizingProgress = isExportFinalizing
- ? Math.min(
- typeof exportProgress?.renderProgress === "number"
- ? exportProgress.renderProgress
- : (exportProgress?.percentage ?? 100),
- 100,
- )
- : null;
- const exportFinalizingPercent = isExportFinalizing
- ? Math.round(exportFinalizingProgress ?? 100)
+ const {
+ isExportSaving,
+ isExportPreparing,
+ isExportFinalizing,
+ isRenderingAudio,
+ exportFinalizingProgress,
+ exportFinalizingPercent,
+ isExportFinalSaveIndeterminate,
+ isLightningExportInProgress,
+ shouldSuspendPreviewRendering,
+ isLegacyExportInProgress,
+ renderSpeedFps,
+ runtimeLabel: exportRuntimeLabel,
+ nativeSkipLabel: exportNativeSkipLabel,
+ } = resolveExportStatusModel({
+ isExporting,
+ exportProgress,
+ exportFormat,
+ exportPipelineModel,
+ });
+ const exportRenderSpeedLabel = renderSpeedFps
+ ? t("editor.exportStatus.renderSpeed", "Render speed {{fps}} FPS", {
+ fps: renderSpeedFps,
+ })
: null;
- const isExportMuxingAndSaving =
- isExportFinalizing &&
- exportFormat === "mp4" &&
- exportPipelineModel === "modern" &&
- !isRenderingAudio;
- const isExportFinalSaveIndeterminate =
- isExportMuxingAndSaving && (exportFinalizingPercent ?? 0) >= 98;
- const isLightningExportInProgress =
- exportFormat === "mp4" &&
- exportPipelineModel === "modern" &&
- (isExporting || exportProgress !== null);
- const shouldSuspendPreviewRendering =
- isExporting && exportFormat === "mp4" && exportPipelineModel === "modern";
- const isLegacyExportInProgress =
- exportFormat === "mp4" &&
- exportPipelineModel === "legacy" &&
- (isExporting || exportProgress !== null);
- const exportRenderSpeedLabel =
- !isExportPreparing &&
- !isExportFinalizing &&
- !isExportSaving &&
- typeof exportProgress?.renderFps === "number" &&
- Number.isFinite(exportProgress.renderFps) &&
- exportProgress.renderFps > 0
- ? t("editor.exportStatus.renderSpeed", "Render speed {{fps}} FPS", {
- fps: exportProgress.renderFps.toFixed(1),
- })
- : null;
- const exportRuntimeLabel = useMemo(() => {
- const renderBackend = exportProgress?.renderBackend;
- const encodeBackend = exportProgress?.encodeBackend;
- const encoderName = exportProgress?.encoderName;
-
- if (!renderBackend && !encodeBackend && !encoderName) {
- return null;
- }
-
- const rendererLabel =
- renderBackend === "webgpu" ? "WebGPU" : renderBackend === "webgl" ? "WebGL" : null;
- const encoderLabel =
- encodeBackend === "ffmpeg"
- ? "Breeze"
- : encodeBackend === "webcodecs"
- ? "WebCodecs"
- : null;
- const pathLabel =
- rendererLabel && encoderLabel
- ? `${rendererLabel} + ${encoderLabel}`
- : (rendererLabel ?? encoderLabel);
-
- if (!pathLabel) {
- return encoderName ?? null;
- }
-
- return encoderName ? `${pathLabel} (${encoderName})` : pathLabel;
- }, [exportProgress]);
- const exportNativeSkipReasons =
- exportProgress?.nativeStaticLayoutSkipReasons &&
- exportProgress.nativeStaticLayoutSkipReasons.length > 0
- ? exportProgress.nativeStaticLayoutSkipReasons
- : exportProgress?.nativeStaticLayoutSkipReason
- ? [exportProgress.nativeStaticLayoutSkipReason]
- : [];
- const exportNativeSkipLabel =
- exportNativeSkipReasons.length > 0
- ? `Native skipped: ${exportNativeSkipReasons[0]}${
- exportNativeSkipReasons.length > 1
- ? ` (+${exportNativeSkipReasons.length - 1} more)`
- : ""
- }`
- : null;
const exportPercentLabel = exportProgress
? isExportPreparing
? t("editor.exportStatus.preparing", "Preparing export...")
@@ -5335,7 +5273,7 @@ export default function VideoEditor() {
)}
diff --git a/src/components/video-editor/exportStatusModel.test.ts b/src/components/video-editor/exportStatusModel.test.ts
new file mode 100644
index 00000000..9cb3158a
--- /dev/null
+++ b/src/components/video-editor/exportStatusModel.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import type { ExportProgress } from "@/lib/exporter";
+import { resolveExportStatusModel } from "./exportStatusModel";
+
+function progress(overrides: Partial = {}): ExportProgress {
+ return {
+ currentFrame: 10,
+ totalFrames: 100,
+ percentage: 10,
+ estimatedTimeRemaining: 30,
+ ...overrides,
+ };
+}
+
+describe("resolveExportStatusModel", () => {
+ it("marks a modern MP4 export as preparing before the first progress event", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: null,
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(status.isExportPreparing).toBe(true);
+ expect(status.isLightningExportInProgress).toBe(true);
+ expect(status.shouldSuspendPreviewRendering).toBe(true);
+ expect(status.renderSpeedFps).toBeNull();
+ });
+
+ it("marks legacy MP4 progress separately from the modern path", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress(),
+ exportFormat: "mp4",
+ exportPipelineModel: "legacy",
+ });
+
+ expect(status.isLegacyExportInProgress).toBe(true);
+ expect(status.isLightningExportInProgress).toBe(false);
+ expect(status.shouldSuspendPreviewRendering).toBe(false);
+ });
+
+ it("derives finalizing progress from render progress and clamps the display value", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ phase: "finalizing",
+ percentage: 96,
+ renderProgress: 104.4,
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(status.exportFinalizingProgress).toBe(100);
+ expect(status.exportFinalizingPercent).toBe(100);
+ expect(status.isExportMuxingAndSaving).toBe(true);
+ expect(status.isExportFinalSaveIndeterminate).toBe(true);
+ });
+
+ it("sanitizes invalid finalizing progress before rounding", () => {
+ const negativeStatus = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ phase: "finalizing",
+ renderProgress: -12.4,
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+ const infiniteStatus = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ phase: "finalizing",
+ renderProgress: Number.POSITIVE_INFINITY,
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+ const nanStatus = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ phase: "finalizing",
+ renderProgress: Number.NaN,
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(negativeStatus.exportFinalizingProgress).toBe(0);
+ expect(negativeStatus.exportFinalizingPercent).toBe(0);
+ expect(infiniteStatus.exportFinalizingProgress).toBe(0);
+ expect(infiniteStatus.exportFinalizingPercent).toBe(0);
+ expect(nanStatus.exportFinalizingProgress).toBe(0);
+ expect(nanStatus.exportFinalizingPercent).toBe(0);
+ });
+
+ it("keeps audio finalization out of the muxing-and-saving state", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ phase: "finalizing",
+ audioProgress: 0.42,
+ renderProgress: 80,
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(status.isRenderingAudio).toBe(true);
+ expect(status.isExportMuxingAndSaving).toBe(false);
+ expect(status.isExportFinalSaveIndeterminate).toBe(false);
+ });
+
+ it("shows render speed only during active render progress", () => {
+ const renderingStatus = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({ renderFps: 123.456 }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+ const finalizingStatus = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({ phase: "finalizing", renderFps: 123.456 }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(renderingStatus.renderSpeedFps).toBe("123.5");
+ expect(finalizingStatus.renderSpeedFps).toBeNull();
+ });
+
+ it("builds runtime labels from render and encode backends", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ renderBackend: "webgpu",
+ encodeBackend: "ffmpeg",
+ encoderName: "h264_nvenc",
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(status.runtimeLabel).toBe("WebGPU + Breeze (h264_nvenc)");
+ });
+
+ it("falls back to the encoder name when backend labels are unavailable", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({ encoderName: "VideoEncoder" }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(status.runtimeLabel).toBe("VideoEncoder");
+ });
+
+ it("prefers multiple native skip reasons over the single legacy reason", () => {
+ const status = resolveExportStatusModel({
+ isExporting: true,
+ exportProgress: progress({
+ nativeStaticLayoutSkipReason: "legacy-reason",
+ nativeStaticLayoutSkipReasons: ["timeline-edits-present", "unsupported-background"],
+ }),
+ exportFormat: "mp4",
+ exportPipelineModel: "modern",
+ });
+
+ expect(status.nativeSkipReasons).toEqual([
+ "timeline-edits-present",
+ "unsupported-background",
+ ]);
+ expect(status.nativeSkipLabel).toBe("Native skipped: timeline-edits-present (+1 more)");
+ });
+});
diff --git a/src/components/video-editor/exportStatusModel.ts b/src/components/video-editor/exportStatusModel.ts
new file mode 100644
index 00000000..7641717a
--- /dev/null
+++ b/src/components/video-editor/exportStatusModel.ts
@@ -0,0 +1,134 @@
+import type { ExportFormat, ExportPipelineModel, ExportProgress } from "@/lib/exporter";
+
+export type ExportStatusModel = {
+ isExportSaving: boolean;
+ isExportPreparing: boolean;
+ isExportFinalizing: boolean;
+ isRenderingAudio: boolean;
+ exportFinalizingProgress: number | null;
+ exportFinalizingPercent: number | null;
+ isExportMuxingAndSaving: boolean;
+ isExportFinalSaveIndeterminate: boolean;
+ isLightningExportInProgress: boolean;
+ shouldSuspendPreviewRendering: boolean;
+ isLegacyExportInProgress: boolean;
+ renderSpeedFps: string | null;
+ runtimeLabel: string | null;
+ nativeSkipReasons: string[];
+ nativeSkipLabel: string | null;
+};
+
+export function resolveExportStatusModel({
+ isExporting,
+ exportProgress,
+ exportFormat,
+ exportPipelineModel,
+}: {
+ isExporting: boolean;
+ exportProgress: ExportProgress | null;
+ exportFormat: ExportFormat;
+ exportPipelineModel: ExportPipelineModel;
+}): ExportStatusModel {
+ const isExportSaving = exportProgress?.phase === "saving";
+ const isExportPreparing =
+ isExporting && (!exportProgress || exportProgress.phase === "preparing");
+ const isExportFinalizing = exportProgress?.phase === "finalizing";
+ const isRenderingAudio =
+ isExportFinalizing && typeof exportProgress?.audioProgress === "number";
+ const rawFinalizingProgress =
+ typeof exportProgress?.renderProgress === "number"
+ ? exportProgress.renderProgress
+ : (exportProgress?.percentage ?? 100);
+ const exportFinalizingProgress = isExportFinalizing
+ ? Math.max(
+ 0,
+ Math.min(100, Number.isFinite(rawFinalizingProgress) ? rawFinalizingProgress : 0),
+ )
+ : null;
+ const exportFinalizingPercent = isExportFinalizing
+ ? Math.round(exportFinalizingProgress ?? 100)
+ : null;
+ const isExportMuxingAndSaving =
+ isExportFinalizing &&
+ exportFormat === "mp4" &&
+ exportPipelineModel === "modern" &&
+ !isRenderingAudio;
+ const isExportFinalSaveIndeterminate =
+ isExportMuxingAndSaving && (exportFinalizingPercent ?? 0) >= 98;
+ const isLightningExportInProgress =
+ exportFormat === "mp4" &&
+ exportPipelineModel === "modern" &&
+ (isExporting || exportProgress !== null);
+ const shouldSuspendPreviewRendering =
+ isExporting && exportFormat === "mp4" && exportPipelineModel === "modern";
+ const isLegacyExportInProgress =
+ exportFormat === "mp4" &&
+ exportPipelineModel === "legacy" &&
+ (isExporting || exportProgress !== null);
+ const renderSpeedFps =
+ !isExportPreparing &&
+ !isExportFinalizing &&
+ !isExportSaving &&
+ typeof exportProgress?.renderFps === "number" &&
+ Number.isFinite(exportProgress.renderFps) &&
+ exportProgress.renderFps > 0
+ ? exportProgress.renderFps.toFixed(1)
+ : null;
+ const runtimeLabel = resolveRuntimeLabel(exportProgress);
+ const nativeSkipReasons =
+ exportProgress?.nativeStaticLayoutSkipReasons &&
+ exportProgress.nativeStaticLayoutSkipReasons.length > 0
+ ? exportProgress.nativeStaticLayoutSkipReasons
+ : exportProgress?.nativeStaticLayoutSkipReason
+ ? [exportProgress.nativeStaticLayoutSkipReason]
+ : [];
+ const nativeSkipLabel =
+ nativeSkipReasons.length > 0
+ ? `Native skipped: ${nativeSkipReasons[0]}${
+ nativeSkipReasons.length > 1 ? ` (+${nativeSkipReasons.length - 1} more)` : ""
+ }`
+ : null;
+
+ return {
+ isExportSaving,
+ isExportPreparing,
+ isExportFinalizing,
+ isRenderingAudio,
+ exportFinalizingProgress,
+ exportFinalizingPercent,
+ isExportMuxingAndSaving,
+ isExportFinalSaveIndeterminate,
+ isLightningExportInProgress,
+ shouldSuspendPreviewRendering,
+ isLegacyExportInProgress,
+ renderSpeedFps,
+ runtimeLabel,
+ nativeSkipReasons,
+ nativeSkipLabel,
+ };
+}
+
+function resolveRuntimeLabel(exportProgress: ExportProgress | null): string | null {
+ const renderBackend = exportProgress?.renderBackend;
+ const encodeBackend = exportProgress?.encodeBackend;
+ const encoderName = exportProgress?.encoderName;
+
+ if (!renderBackend && !encodeBackend && !encoderName) {
+ return null;
+ }
+
+ const rendererLabel =
+ renderBackend === "webgpu" ? "WebGPU" : renderBackend === "webgl" ? "WebGL" : null;
+ const encoderLabel =
+ encodeBackend === "ffmpeg" ? "Breeze" : encodeBackend === "webcodecs" ? "WebCodecs" : null;
+ const pathLabel =
+ rendererLabel && encoderLabel
+ ? `${rendererLabel} + ${encoderLabel}`
+ : (rendererLabel ?? encoderLabel);
+
+ if (!pathLabel) {
+ return encoderName ?? null;
+ }
+
+ return encoderName ? `${pathLabel} (${encoderName})` : pathLabel;
+}