mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
refactor(editor): extract export status model
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user