diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b13559c6..c72d5df0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -87,7 +87,12 @@ import { } from "@/utils/aspectRatioUtils"; import { planClipSpeedChange } from "./clipSpeedChange"; import { ExtensionIcon } from "./ExtensionIcon"; -import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions"; +import { + calculateMp4ExportDimensions, + calculateMp4SourceDimensions, + type Mp4SupportProbeSnapshot, + shouldDebounceMp4SupportProbe, +} from "./exportDimensions"; import { resolveSavingExportProgress } from "./exportProgressState"; import { resolveExportStartSettings } from "./exportStartSettings"; import { resolveExportStatusModel } from "./exportStatusModel"; @@ -256,6 +261,7 @@ type CancelableExporter = { }; const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; +const MP4_CROP_PROBE_DEBOUNCE_MS = 200; async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise { if ( @@ -701,6 +707,7 @@ export default function VideoEditor() { const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); const cropSnapshotRef = useRef(null); const mp4SupportRequestRef = useRef(0); + const previousMp4SupportProbeRef = useRef(null); const smokeExportStartedRef = useRef(false); const projectAutosaveTimeoutRef = useRef(null); const pendingProjectSaveDialogRef = useRef(null); @@ -1485,15 +1492,22 @@ export default function VideoEditor() { [gifSizePreset], ); - const desiredMp4SourceDimensions = useMemo(() => { + const mp4SourceDimensions = useMemo(() => { const sourceVideo = isPreviewReady ? videoPlaybackRef.current?.video : null; + return { + width: sourceVideo?.videoWidth || 1920, + height: sourceVideo?.videoHeight || 1080, + }; + }, [isPreviewReady]); + + const desiredMp4SourceDimensions = useMemo(() => { return calculateMp4SourceDimensions( - sourceVideo?.videoWidth || 1920, - sourceVideo?.videoHeight || 1080, + mp4SourceDimensions.width, + mp4SourceDimensions.height, aspectRatio, cropRegion, ); - }, [aspectRatio, cropRegion, isPreviewReady]); + }, [aspectRatio, cropRegion, mp4SourceDimensions.height, mp4SourceDimensions.width]); const mp4OutputDimensions = useMemo(() => { const baseWidth = supportedMp4SourceDimensions.encoderPath @@ -1533,21 +1547,6 @@ export default function VideoEditor() { ); } - setSupportedMp4SourceDimensions((current) => { - if ( - current.width === result.width && - current.height === result.height && - current.capped === result.capped && - current.encoderPath?.codec === result.encoderPath?.codec && - current.encoderPath?.hardwareAcceleration === - result.encoderPath?.hardwareAcceleration - ) { - return current; - } - - return result; - }); - return result; }, [desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width], @@ -1557,6 +1556,19 @@ export default function VideoEditor() { let cancelled = false; const requestId = mp4SupportRequestRef.current + 1; mp4SupportRequestRef.current = requestId; + const probeSnapshot: Mp4SupportProbeSnapshot = { + sourceWidth: mp4SourceDimensions.width, + sourceHeight: mp4SourceDimensions.height, + targetWidth: desiredMp4SourceDimensions.width, + targetHeight: desiredMp4SourceDimensions.height, + aspectRatio, + frameRate: mp4FrameRate, + }; + const shouldDebounce = shouldDebounceMp4SupportProbe( + previousMp4SupportProbeRef.current, + probeSnapshot, + ); + previousMp4SupportProbeRef.current = probeSnapshot; setSupportedMp4SourceDimensions({ width: desiredMp4SourceDimensions.width, height: desiredMp4SourceDimensions.height, @@ -1564,33 +1576,48 @@ export default function VideoEditor() { encoderPath: null, }); - void ensureSupportedMp4SourceDimensions(mp4FrameRate) - .then((result) => { - if (cancelled || requestId !== mp4SupportRequestRef.current) { - return; - } - setSupportedMp4SourceDimensions(result); - }) - .catch(() => { - if (cancelled || requestId !== mp4SupportRequestRef.current) { - return; - } - setSupportedMp4SourceDimensions({ - width: desiredMp4SourceDimensions.width, - height: desiredMp4SourceDimensions.height, - capped: false, - encoderPath: null, + const runProbe = () => { + void ensureSupportedMp4SourceDimensions(mp4FrameRate) + .then((result) => { + if (cancelled || requestId !== mp4SupportRequestRef.current) { + return; + } + setSupportedMp4SourceDimensions(result); + }) + .catch(() => { + if (cancelled || requestId !== mp4SupportRequestRef.current) { + return; + } + setSupportedMp4SourceDimensions({ + width: desiredMp4SourceDimensions.width, + height: desiredMp4SourceDimensions.height, + capped: false, + encoderPath: null, + }); }); - }); + }; + + const timeoutId = shouldDebounce + ? window.setTimeout(runProbe, MP4_CROP_PROBE_DEBOUNCE_MS) + : null; + if (timeoutId === null) { + runProbe(); + } return () => { cancelled = true; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } }; }, [ + aspectRatio, desiredMp4SourceDimensions.height, desiredMp4SourceDimensions.width, ensureSupportedMp4SourceDimensions, mp4FrameRate, + mp4SourceDimensions.height, + mp4SourceDimensions.width, ]); // Extension-contributed standalone section pages (no parentSection) diff --git a/src/components/video-editor/exportDimensions.test.ts b/src/components/video-editor/exportDimensions.test.ts index d939cf6a..2eae9c5b 100644 --- a/src/components/video-editor/exportDimensions.test.ts +++ b/src/components/video-editor/exportDimensions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions"; +import { + calculateMp4ExportDimensions, + calculateMp4SourceDimensions, + shouldDebounceMp4SupportProbe, +} from "./exportDimensions"; describe("calculateMp4SourceDimensions", () => { it("keeps native exports at the source dimensions", () => { @@ -82,3 +86,48 @@ describe("calculateMp4ExportDimensions", () => { }); }); }); + +describe("shouldDebounceMp4SupportProbe", () => { + const baseSnapshot = { + sourceWidth: 1920, + sourceHeight: 1080, + targetWidth: 1920, + targetHeight: 1080, + aspectRatio: "native" as const, + frameRate: 30 as const, + }; + + it("debounces only native crop-driven target changes", () => { + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + targetHeight: 864, + }), + ).toBe(true); + }); + + it("keeps non-crop probe changes immediate", () => { + expect(shouldDebounceMp4SupportProbe(null, baseSnapshot)).toBe(false); + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + frameRate: 60, + }), + ).toBe(false); + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + sourceWidth: 1280, + sourceHeight: 720, + targetWidth: 1280, + targetHeight: 720, + }), + ).toBe(false); + expect( + shouldDebounceMp4SupportProbe(baseSnapshot, { + ...baseSnapshot, + aspectRatio: "16:9", + }), + ).toBe(false); + }); +}); diff --git a/src/components/video-editor/exportDimensions.ts b/src/components/video-editor/exportDimensions.ts index 45e0f54e..2f9dcdc2 100644 --- a/src/components/video-editor/exportDimensions.ts +++ b/src/components/video-editor/exportDimensions.ts @@ -1,6 +1,36 @@ -import type { ExportQuality } from "@/lib/exporter"; +import type { ExportMp4FrameRate, ExportQuality } from "@/lib/exporter"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; +export type Mp4SupportProbeSnapshot = { + sourceWidth: number; + sourceHeight: number; + targetWidth: number; + targetHeight: number; + aspectRatio: AspectRatio; + frameRate: ExportMp4FrameRate; +}; + +export function shouldDebounceMp4SupportProbe( + previous: Mp4SupportProbeSnapshot | null, + current: Mp4SupportProbeSnapshot, +): boolean { + if ( + !previous || + current.aspectRatio !== "native" || + previous.aspectRatio !== current.aspectRatio || + previous.frameRate !== current.frameRate || + previous.sourceWidth !== current.sourceWidth || + previous.sourceHeight !== current.sourceHeight + ) { + return false; + } + + return ( + previous.targetWidth !== current.targetWidth || + previous.targetHeight !== current.targetHeight + ); +} + function normalizeEvenDimension(value: number): number { return Math.max(2, Math.floor(value / 2) * 2); }