Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 26 additions & 88 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -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)}%`,
}}
/>
)}
Expand Down
176 changes: 176 additions & 0 deletions src/components/video-editor/exportStatusModel.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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)");
});
});
Loading