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; +}