refactor(editor): extract export status model

This commit is contained in:
wiiiii123
2026-05-22 22:10:13 +07:00
parent 6cc0712c54
commit ac24952560
3 changed files with 297 additions and 88 deletions
+26 -88
View File
@@ -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,
)
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 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 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() {
<div
className="h-full bg-[#2563EB] transition-all duration-300 ease-out"
style={{
width: `${Math.min(isRenderingAudio ? (exportProgress.audioProgress ?? 0) * 100 : (exportFinalizingProgress ?? exportProgress?.percentage ?? 8), 100)}%`,
width: `${Math.min(isRenderingAudio ? (exportProgress?.audioProgress ?? 0) * 100 : (exportFinalizingProgress ?? exportProgress?.percentage ?? 8), 100)}%`,
}}
/>
)}
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import type { ExportProgress } from "@/lib/exporter";
import { resolveExportStatusModel } from "./exportStatusModel";
function progress(overrides: Partial<ExportProgress> = {}): 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("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)");
});
});
@@ -0,0 +1,132 @@
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 exportFinalizingProgress = isExportFinalizing
? Math.min(
typeof exportProgress?.renderProgress === "number"
? exportProgress.renderProgress
: (exportProgress?.percentage ?? 100),
100,
)
: 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;
}