diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index ad4079c3..5960a3e4 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -59,6 +59,7 @@ import { getNativeStaticLayoutSourceProxyBitrate, getNvidiaCudaAudioExportSkipReason, getNvidiaCudaAutoStallTimeoutMs, + hasNativeStaticLayoutProgressAdvanced, hasNvidiaGpuDeviceInGpuInfo, mapNvidiaCudaWrapperProgressPercentage, muxExportedVideoAudioBuffer, @@ -1134,6 +1135,37 @@ describe("mapNvidiaCudaWrapperProgressPercentage", () => { }); }); +describe("hasNativeStaticLayoutProgressAdvanced", () => { + it("treats repeated preparation heartbeats as stalled until real progress arrives", () => { + const previous = { currentFrame: 0, percentage: 2.5 }; + + expect( + hasNativeStaticLayoutProgressAdvanced( + { currentFrame: 0, totalFrames: 100, percentage: 2.5, stage: "preparing" }, + previous, + ), + ).toBe(false); + expect( + hasNativeStaticLayoutProgressAdvanced( + { currentFrame: 0, totalFrames: 100, percentage: 2.7, stage: "preparing" }, + previous, + ), + ).toBe(true); + expect( + hasNativeStaticLayoutProgressAdvanced( + { currentFrame: 1, totalFrames: 100, percentage: 2.5 }, + previous, + ), + ).toBe(true); + expect( + hasNativeStaticLayoutProgressAdvanced( + { currentFrame: 100, totalFrames: 100, percentage: 97.25, stage: "finalizing" }, + { currentFrame: 100, percentage: 97.25 }, + ), + ).toBe(true); + }); +}); + describe("parseNativeVideoMetadataProbeOutput", () => { it("parses FFmpeg input metadata with video and audio streams", () => { const metadata = parseNativeVideoMetadataProbeOutput(` diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 7b304220..56cd2974 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -635,6 +635,7 @@ async function persistNvidiaCudaExportDiagnostics(params: { stderr: string; stdout: string; summary: NvidiaCudaExportSummary | null; + timedOut: boolean; }) { if (!shouldPersistNvidiaCudaExportDiagnostics()) { return; @@ -651,6 +652,7 @@ async function persistNvidiaCudaExportDiagnostics(params: { outputPath: params.outputPath, exitCode: params.code, signal: params.signal, + timedOut: params.timedOut, args: params.args, summary: params.summary, }; @@ -852,6 +854,24 @@ export function mapNvidiaCudaWrapperProgressPercentage(progress: NativeStaticLay return progress.percentage; } +export function hasNativeStaticLayoutProgressAdvanced( + progress: { currentFrame: number; percentage: number; stage?: string }, + previous: { currentFrame: number; percentage: number; stage?: string }, +) { + const currentFrame = Math.max(0, Math.floor(progress.currentFrame)); + const percentage = + typeof progress.percentage === "number" && Number.isFinite(progress.percentage) + ? progress.percentage + : 0; + if (currentFrame > previous.currentFrame) { + return true; + } + if (percentage > previous.percentage + 0.1) { + return true; + } + return progress.stage === "finalizing" && previous.stage !== "finalizing"; +} + function startNativeStaticLayoutExportPowerGuard() { try { const blockerId = powerSaveBlocker.start("prevent-app-suspension"); @@ -2706,6 +2726,11 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( let stderr = ""; let stderrLineBuffer = ""; let lastProgressPercentage = 0; + let lastProgressForStallGuard: { + currentFrame: number; + percentage: number; + stage?: string; + } = { currentFrame: -1, percentage: -1 }; let stallTimedOut = false; let settled = false; const timeout = setTimeout(() => { @@ -2734,11 +2759,9 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); - armStallTimeout(); }); child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString(); - armStallTimeout(); stderr += text; stderrLineBuffer += text; const lines = stderrLineBuffer.split(/\r?\n/); @@ -2759,6 +2782,20 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( : undefined; const mappedPercentage = mapNvidiaCudaWrapperProgressPercentage(progress); lastProgressPercentage = Math.max(lastProgressPercentage, mappedPercentage); + const progressForStallGuard = { + currentFrame: progress.currentFrame, + percentage: mappedPercentage, + stage: progress.stage, + }; + if ( + hasNativeStaticLayoutProgressAdvanced( + progressForStallGuard, + lastProgressForStallGuard, + ) + ) { + lastProgressForStallGuard = progressForStallGuard; + armStallTimeout(); + } onProgress?.({ ...progress, percentage: lastProgressPercentage, @@ -2825,13 +2862,14 @@ async function runExperimentalNvidiaCudaStaticLayoutExport( stderr, stdout, summary, + timedOut: stallTimedOut, }); if (code !== 0 || !summary?.success) { const suffix = signal ? ` (signal ${signal})` : ""; reject( new Error( (stallTimedOut && stallTimeoutMs - ? `Experimental NVIDIA CUDA exporter stalled for ${stallTimeoutMs}ms without output` + ? `Experimental NVIDIA CUDA exporter stalled for ${stallTimeoutMs}ms without progress` : stderr.trim()) || stdout.trim() || `Experimental NVIDIA CUDA exporter exited with code ${code ?? "unknown"}${suffix}`, @@ -2884,6 +2922,11 @@ async function runExperimentalWindowsGpuStaticLayoutExport( let stdout = ""; let stderr = ""; let stderrLineBuffer = ""; + let lastProgressForStallGuard: { + currentFrame: number; + percentage: number; + stage?: string; + } = { currentFrame: -1, percentage: -1 }; let stallTimedOut = false; let settled = false; const timeout = setTimeout(() => { @@ -2912,11 +2955,9 @@ async function runExperimentalWindowsGpuStaticLayoutExport( child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); - armStallTimeout(); }); child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString(); - armStallTimeout(); stderr += text; stderrLineBuffer += text; const lines = stderrLineBuffer.split(/\r?\n/); @@ -2926,6 +2967,14 @@ async function runExperimentalWindowsGpuStaticLayoutExport( if (!progress) { continue; } + if (hasNativeStaticLayoutProgressAdvanced(progress, lastProgressForStallGuard)) { + lastProgressForStallGuard = { + currentFrame: progress.currentFrame, + percentage: progress.percentage, + stage: progress.stage, + }; + armStallTimeout(); + } const elapsedMs = Math.max(0, getNowMs() - startedAt); onProgress?.({ ...progress, @@ -2983,7 +3032,7 @@ async function runExperimentalWindowsGpuStaticLayoutExport( reject( new Error( (stallTimedOut && stallTimeoutMs - ? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without output` + ? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without progress` : stderr.trim()) || stdout.trim() || `Experimental Windows GPU exporter exited with code ${code ?? "unknown"}${suffix}`, diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 1ea75f84..6350de2e 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -261,7 +261,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => { }); }); - it("reports video backgrounds before speed timeline gating", () => { + it("reports video backgrounds while speed can use native timeline maps", () => { const exporter = createExporter({ wallpaper: "file:///C:/Recordly/background.webm", speedRegions: [{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }], @@ -303,7 +303,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toEqual([ "odd-output-dimensions", "unsupported-background-video", - "native-speed-timeline-validation-pending", "unsupported-annotation-overlay", "unsupported-caption-overlay", "unsupported-webcam-source", @@ -427,6 +426,24 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toBe("native-timeline-requires-windows-gpu"); }); + it("requires the Windows GPU compositor for speed timelines", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, + ]; + const exporter = createExporter({ experimentalNativeExport: false, speedRegions }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 59, + ), + ).toBe("native-timeline-requires-windows-gpu"); + }); + it("uses speed timeline duration during native static-layout preflight", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, @@ -436,12 +453,59 @@ describe("ModernVideoExporter native static-layout eligibility", () => { expect(exporter.getNativeStaticLayoutEffectiveDuration(videoInfo)).toBeCloseTo(59, 3); }); - it("skips native speed timelines while compositor validation is pending", () => { + it("builds native timeline maps for the editor speed range endpoints", () => { + const speedRegions: SpeedRegion[] = [ + { id: "speed-1", startMs: 1_000, endMs: 2_000, speed: 0.25 }, + { id: "speed-2", startMs: 4_000, endMs: 5_000, speed: 30 }, + ]; + const exporter = createExporter({ speedRegions }); + + expect( + exporter + .buildNativeStaticLayoutVideoTimelineSegments(videoInfo) + .map((segment) => segment.speed), + ).toEqual([1, 0.25, 1, 30, 1]); + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 63.033, + ), + ).toBeNull(); + }); + + it("allows native speed timelines through the Windows GPU timeline map", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, ]; const exporter = createExporter({ speedRegions }); + expect(exporter.buildNativeStaticLayoutVideoTimelineSegments(videoInfo)).toEqual([ + { + sourceStartMs: 0, + sourceEndMs: 1_000, + outputStartMs: 0, + outputEndMs: 1_000, + speed: 1, + }, + { + sourceStartMs: 1_000, + sourceEndMs: 4_000, + outputStartMs: 1_000, + outputEndMs: 3_000, + speed: 1.5, + }, + { + sourceStartMs: 4_000, + sourceEndMs: 60_000, + outputStartMs: 3_000, + outputEndMs: 59_000, + speed: 1, + }, + ]); expect( exporter.getNativeStaticLayoutSkipReason( { @@ -451,12 +515,12 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 60, ), - ).toBe("native-speed-timeline-validation-pending"); + ).toBeNull(); }); - it("rejects native static-layout when speed edits do not have a native timeline map", () => { + it("rejects native static-layout when speed edits are outside the editor speed range", () => { const speedRegions: SpeedRegion[] = [ - { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 3 }, + { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 31 }, ]; const exporter = createExporter({ speedRegions }); @@ -469,10 +533,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 58, ), - ).toBe("native-speed-timeline-validation-pending"); + ).toBe("unsupported-native-speed-timeline"); }); - it("keeps speed-only projects on the renderer path even when audio and video share filtergraph segments", () => { + it("allows speed-only projects when audio and video share filtergraph segments", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, ]; @@ -494,10 +558,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 59, ), - ).toBe("native-speed-timeline-validation-pending"); + ).toBeNull(); }); - it("keeps slow-speed timelines on the renderer path until native duplication is revalidated", () => { + it("allows slow-speed timelines through native frame duplication", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 }, ]; @@ -519,10 +583,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 63, ), - ).toBe("native-speed-timeline-validation-pending"); + ).toBeNull(); }); - it("keeps slow-speed webcam timelines on the renderer path while native source-time mapping is pending", () => { + it("allows slow-speed webcam timelines through native source-time mapping", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 }, ]; @@ -543,10 +607,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 63, ), - ).toBe("native-speed-timeline-validation-pending"); + ).toBeNull(); }); - it("skips native speed timelines even with a resolvable webcam source", () => { + it("allows native speed timelines with a resolvable webcam source", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, ]; @@ -574,6 +638,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 59, ), - ).toBe("native-speed-timeline-validation-pending"); + ).toBeNull(); }); }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 6d8f92c9..fae0d21b 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -165,6 +165,8 @@ type NativeAudioPlan = }; const FILTERGRAPH_FALLBACK_AUDIO_SAMPLE_RATE = 48_000; +const MIN_NATIVE_STATIC_LAYOUT_SPEED = 0.25; +const MAX_NATIVE_STATIC_LAYOUT_SPEED = 30; type NativeStaticLayoutTimelineSegment = { sourceStartMs: number; @@ -174,6 +176,14 @@ type NativeStaticLayoutTimelineSegment = { speed: number; }; +function canUseNativeStaticLayoutSpeed(speed: number): boolean { + return ( + Number.isFinite(speed) && + speed >= MIN_NATIVE_STATIC_LAYOUT_SPEED && + speed <= MAX_NATIVE_STATIC_LAYOUT_SPEED + ); +} + function buildNativeStaticLayoutTimelineSegments( segments: Array<{ startMs: number; endMs: number; speed: number }>, ): NativeStaticLayoutTimelineSegment[] { @@ -1008,11 +1018,63 @@ export class ModernVideoExporter { } private buildNativeStaticLayoutSourceSegments(sourceDurationMs: number) { - return buildEditedTrackSourceSegments( - sourceDurationMs, - this.config.trimRegions ?? [], - this.config.speedRegions ?? [], - ); + if (!Number.isFinite(sourceDurationMs) || sourceDurationMs <= 0) { + return []; + } + + const speedRegions = this.config.speedRegions ?? []; + if ( + speedRegions.some( + (region) => + !Number.isFinite(region.startMs) || + !Number.isFinite(region.endMs) || + !canUseNativeStaticLayoutSpeed(region.speed), + ) + ) { + return []; + } + + const normalizedSpeedRegions = speedRegions + .map((region) => ({ + startMs: Math.max(0, Math.min(region.startMs, sourceDurationMs)), + endMs: Math.max(0, Math.min(region.endMs, sourceDurationMs)), + speed: region.speed, + })) + .filter((region) => region.endMs - region.startMs > 0.5); + const sourceSegments: Array<{ startMs: number; endMs: number; speed: number }> = []; + + for (const keptRange of this.buildNativeTrimSegments(sourceDurationMs)) { + const boundaries = new Set([keptRange.startMs, keptRange.endMs]); + for (const speedRegion of normalizedSpeedRegions) { + const startMs = Math.max(keptRange.startMs, speedRegion.startMs); + const endMs = Math.min(keptRange.endMs, speedRegion.endMs); + if (endMs - startMs > 0.5) { + boundaries.add(startMs); + boundaries.add(endMs); + } + } + + const orderedBoundaries = [...boundaries].sort((left, right) => left - right); + for (let index = 0; index < orderedBoundaries.length - 1; index += 1) { + const startMs = orderedBoundaries[index] ?? 0; + const endMs = orderedBoundaries[index + 1] ?? 0; + if (endMs - startMs <= 0.5) { + continue; + } + + const midpointMs = startMs + (endMs - startMs) / 2; + const speedRegion = normalizedSpeedRegions.find( + (region) => midpointMs >= region.startMs && midpointMs < region.endMs, + ); + sourceSegments.push({ + startMs, + endMs, + speed: speedRegion?.speed ?? 1, + }); + } + } + + return sourceSegments; } private buildNativeStaticLayoutVideoTimelineSegments( @@ -1244,9 +1306,6 @@ export class ModernVideoExporter { if (isVideoWallpaperSource(configuredWallpaper)) { reasons.push("unsupported-background-video"); } - if (speedRegions.length > 0) { - reasons.push("native-speed-timeline-validation-pending"); - } const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0; const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap( @@ -1257,11 +1316,14 @@ export class ModernVideoExporter { reasons.push("native-timeline-requires-windows-gpu"); } if ( - speedRegions.length === 0 && needsTimelineMap && this.buildNativeStaticLayoutVideoTimelineSegments(videoInfo).length === 0 ) { - reasons.push("unsupported-native-trim-timeline"); + reasons.push( + speedRegions.length > 0 + ? "unsupported-native-speed-timeline" + : "unsupported-native-trim-timeline", + ); } if (hasZoomRegions && this.config.experimentalNativeExport !== true) { reasons.push("native-zoom-requires-windows-gpu");