From 254783b15f5153aed2fe3edd1c82bc75ee894444 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 8 May 2026 00:39:14 +0700 Subject: [PATCH] fix(export): gate native speed timelines --- electron/ipc/export/native-video.test.ts | 29 ++++ electron/ipc/export/native-video.ts | 129 +++++++++++++++++- ...rnVideoExporter.nativeStaticLayout.test.ts | 40 ++++-- src/lib/exporter/modernVideoExporter.ts | 8 ++ 4 files changed, 189 insertions(+), 17 deletions(-) diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 37058ea7..ad4079c3 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -55,6 +55,7 @@ import { buildNativeVideoAudioMuxArgs, canCopyAudioCodecIntoMp4, getExperimentalNvidiaCudaExportSkipReason, + getNativeGpuCompositorStallTimeoutMs, getNativeStaticLayoutSourceProxyBitrate, getNvidiaCudaAudioExportSkipReason, getNvidiaCudaAutoStallTimeoutMs, @@ -333,6 +334,34 @@ describe("getNvidiaCudaAutoStallTimeoutMs", () => { }); }); +describe("getNativeGpuCompositorStallTimeoutMs", () => { + it("guards Windows GPU compositor stalls by default", () => { + expect(getNativeGpuCompositorStallTimeoutMs()).toBe(120_000); + }); + + it("allows the Windows GPU stall guard to be disabled or tuned", () => { + const envName = "RECORDLY_NATIVE_GPU_STALL_TIMEOUT_MS"; + const originalValue = process.env[envName]; + + try { + process.env[envName] = "0"; + expect(getNativeGpuCompositorStallTimeoutMs()).toBeNull(); + + process.env[envName] = "5000"; + expect(getNativeGpuCompositorStallTimeoutMs()).toBe(10_000); + + process.env[envName] = "45000"; + expect(getNativeGpuCompositorStallTimeoutMs()).toBe(45_000); + } finally { + if (originalValue === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalValue; + } + } + }); +}); + describe("hasNvidiaGpuDeviceInGpuInfo", () => { it("detects NVIDIA GPUs by vendor id or device strings", () => { expect( diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index bac98c42..7b304220 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -48,6 +48,8 @@ const NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV = "RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXP const NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV = "RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY"; const NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS"; const DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS = 120_000; +const NATIVE_GPU_STALL_TIMEOUT_ENV = "RECORDLY_NATIVE_GPU_STALL_TIMEOUT_MS"; +const DEFAULT_NATIVE_GPU_STALL_TIMEOUT_MS = 120_000; const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_REFERENCE_PIXEL_RATE = 1920 * 1080 * 30; const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_1080P30_BITRATE = 24_000_000; const NATIVE_STATIC_LAYOUT_SOURCE_PROXY_MAX_BITRATE = 80_000_000; @@ -597,10 +599,15 @@ function isNvidiaCudaTimestampAlignedSummary(summary: NvidiaCudaExportSummary) { }); } +function shouldPersistNativeExportDiagnostics() { + const rawValue = process.env.RECORDLY_NATIVE_EXPORT_DIAGNOSTICS?.trim().toLowerCase(); + return rawValue !== "0" && rawValue !== "off" && rawValue !== "false"; +} + function shouldPersistNvidiaCudaExportDiagnostics() { return ( - process.env.RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS === "1" || - process.env.RECORDLY_NATIVE_EXPORT_DIAGNOSTICS === "1" + shouldPersistNativeExportDiagnostics() || + process.env.RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS === "1" ); } @@ -698,6 +705,61 @@ async function persistNvidiaCudaExportDiagnostics(params: { } } +async function persistWindowsGpuExportDiagnostics(params: { + args: string[]; + code: number | null; + elapsedMs: number; + outputPath: string; + signal: NodeJS.Signals | null; + startedAtIso: string; + stderr: string; + stdout: string; + summary: WindowsGpuExportSummary | null; + timedOut: boolean; +}) { + if (!shouldPersistNativeExportDiagnostics()) { + return; + } + + const diagnosticsDirectory = path.join(app.getPath("userData"), "native-export-diagnostics"); + const filePrefix = `${Date.now()}-windows-d3d11-compositor`; + const manifest = { + backend: "windows-d3d11-compositor", + startedAt: params.startedAtIso, + completedAt: new Date().toISOString(), + elapsedMs: Number(params.elapsedMs.toFixed(2)), + outputPath: params.outputPath, + exitCode: params.code, + signal: params.signal, + timedOut: params.timedOut, + args: params.args, + summary: params.summary, + }; + + try { + await fs.mkdir(diagnosticsDirectory, { recursive: true }); + await Promise.allSettled([ + fs.writeFile( + path.join(diagnosticsDirectory, `${filePrefix}.manifest.json`), + `${JSON.stringify(manifest, null, 2)}\n`, + ), + fs.writeFile( + path.join(diagnosticsDirectory, `${filePrefix}.stdout.log`), + params.stdout, + ), + fs.writeFile( + path.join(diagnosticsDirectory, `${filePrefix}.stderr.log`), + params.stderr, + ), + ]); + } catch (error) { + console.warn( + "[native-static-layout-export] Failed to persist Windows GPU diagnostics", + error, + ); + } +} + export function parseWindowsGpuExportProgressLine( line: string, ): NativeStaticLayoutExportProgress | null { @@ -1868,7 +1930,7 @@ function isNvidiaCudaForceVideoOnlyEnabled() { export function getNvidiaCudaAutoStallTimeoutMs( autoCandidateActive = isPackagedNvidiaCudaExportAutoCandidateActive(), ) { - if (!autoCandidateActive) { + if (!autoCandidateActive && !isExplicitNvidiaCudaExportEnabled()) { return null; } @@ -1885,6 +1947,20 @@ export function getNvidiaCudaAutoStallTimeoutMs( return DEFAULT_NVIDIA_CUDA_AUTO_STALL_TIMEOUT_MS; } +export function getNativeGpuCompositorStallTimeoutMs() { + const rawValue = process.env[NATIVE_GPU_STALL_TIMEOUT_ENV]?.trim(); + if (rawValue === "0" || rawValue?.toLowerCase() === "off") { + return null; + } + + const parsed = Number(rawValue); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.max(10_000, Math.round(parsed)); + } + + return DEFAULT_NATIVE_GPU_STALL_TIMEOUT_MS; +} + export async function getExperimentalNvidiaCudaExportSkipReason( options: NativeStaticLayoutExportOptions, ) { @@ -2787,7 +2863,9 @@ async function runExperimentalWindowsGpuStaticLayoutExport( const args = buildExperimentalWindowsGpuStaticLayoutArgs(options, outputPath); const startedAt = getNowMs(); + const startedAtIso = new Date().toISOString(); const timeoutMs = Math.max(15 * 60 * 1000, options.durationSec * 1000); + const stallTimeoutMs = getNativeGpuCompositorStallTimeoutMs(); return await new Promise<{ elapsedMs: number; @@ -2806,17 +2884,39 @@ async function runExperimentalWindowsGpuStaticLayoutExport( let stdout = ""; let stderr = ""; let stderrLineBuffer = ""; + let stallTimedOut = false; let settled = false; const timeout = setTimeout(() => { if (settled) return; child.kill("SIGKILL"); }, timeoutMs); + let stallTimeout: ReturnType | null = null; + const clearStallTimeout = () => { + if (stallTimeout) { + clearTimeout(stallTimeout); + stallTimeout = null; + } + }; + const armStallTimeout = () => { + if (!stallTimeoutMs) { + return; + } + clearStallTimeout(); + stallTimeout = setTimeout(() => { + if (settled) return; + stallTimedOut = true; + child.kill("SIGKILL"); + }, stallTimeoutMs); + }; + armStallTimeout(); 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/); @@ -2846,28 +2946,45 @@ async function runExperimentalWindowsGpuStaticLayoutExport( session.currentProcess = null; } clearTimeout(timeout); + clearStallTimeout(); reject(error); }); - child.once("close", (code, signal) => { + child.once("close", async (code, signal) => { if (settled) return; settled = true; if (session.currentProcess === child) { session.currentProcess = null; } clearTimeout(timeout); + clearStallTimeout(); if (session.terminating) { reject(new Error("Native static layout export was cancelled")); return; } + const elapsedMs = getNowMs() - startedAt; const summary = parseWindowsGpuExportSummary(stdout); + await persistWindowsGpuExportDiagnostics({ + args, + code, + elapsedMs, + outputPath, + signal, + startedAtIso, + stderr, + stdout, + summary, + timedOut: stallTimedOut, + }); if (code !== 0 || !summary?.success) { const suffix = signal ? ` (signal ${signal})` : ""; reject( new Error( - stderr.trim() || + (stallTimedOut && stallTimeoutMs + ? `Experimental Windows GPU exporter stalled for ${stallTimeoutMs}ms without output` + : stderr.trim()) || stdout.trim() || `Experimental Windows GPU exporter exited with code ${code ?? "unknown"}${suffix}`, ), @@ -2876,7 +2993,7 @@ async function runExperimentalWindowsGpuStaticLayoutExport( } resolve({ - elapsedMs: getNowMs() - startedAt, + elapsedMs, stdout, stderr, summary, diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 3f295bc0..9db31fb1 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -256,6 +256,24 @@ describe("ModernVideoExporter native static-layout eligibility", () => { }); }); + it("reports video backgrounds before speed timeline gating", () => { + const exporter = createExporter({ + wallpaper: "file:///C:/Recordly/background.webm", + speedRegions: [{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }], + }); + + expect( + exporter.getNativeStaticLayoutSkipReason( + { + audioMode: "edited-track", + strategy: "offline-render-fallback", + }, + videoInfo, + 59, + ), + ).toBe("unsupported-background-video"); + }); + it("materializes uploaded data-url image backgrounds for native static-layout", async () => { const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBytes).toString("base64")}`; @@ -380,7 +398,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => { expect(exporter.getNativeStaticLayoutEffectiveDuration(videoInfo)).toBeCloseTo(59, 3); }); - it("allows native speed timelines when audio needs offline rendering", () => { + it("skips native speed timelines while compositor validation is pending", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, ]; @@ -395,7 +413,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 60, ), - ).toBeNull(); + ).toBe("native-speed-timeline-validation-pending"); }); it("rejects native static-layout when speed edits do not have a native timeline map", () => { @@ -413,10 +431,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 58, ), - ).toBe("unsupported-native-speed-timeline"); + ).toBe("native-speed-timeline-validation-pending"); }); - it("allows speed-only native static-layout when audio and video share filtergraph segments", () => { + it("keeps speed-only projects on the renderer path even when audio and video share filtergraph segments", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, ]; @@ -438,10 +456,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 59, ), - ).toBeNull(); + ).toBe("native-speed-timeline-validation-pending"); }); - it("allows slow-speed native static-layout when a timeline map is available", () => { + it("keeps slow-speed timelines on the renderer path until native duplication is revalidated", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 }, ]; @@ -463,10 +481,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 63, ), - ).toBeNull(); + ).toBe("native-speed-timeline-validation-pending"); }); - it("allows slow-speed native timelines with webcam when audio renders offline", () => { + it("keeps slow-speed webcam timelines on the renderer path while native source-time mapping is pending", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 }, ]; @@ -487,10 +505,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 63, ), - ).toBeNull(); + ).toBe("native-speed-timeline-validation-pending"); }); - it("allows native speed timelines with a resolvable webcam source", () => { + it("skips native speed timelines even with a resolvable webcam source", () => { const speedRegions: SpeedRegion[] = [ { id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 }, ]; @@ -518,6 +536,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 59, ), - ).toBeNull(); + ).toBe("native-speed-timeline-validation-pending"); }); }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 9019bcad..50a777c1 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1237,6 +1237,14 @@ export class ModernVideoExporter { } const speedRegions = this.config.speedRegions ?? []; + const configuredWallpaper = this.config.wallpaper?.trim() ?? ""; + if (isVideoWallpaperSource(configuredWallpaper)) { + return "unsupported-background-video"; + } + if (speedRegions.length > 0) { + return "native-speed-timeline-validation-pending"; + } + const hasZoomRegions = (this.config.zoomRegions ?? []).length > 0; const needsTimelineMap = this.shouldUseNativeStaticLayoutTimelineMap( videoInfo,