diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 6a1f18f22..908f72f59 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -421,6 +421,10 @@ describe("getExperimentalNvidiaCudaExportSkipReason", () => { process.env[exportEnvName] = "1"; process.env[forceEnvName] = "1"; delete process.env[allowAudioEnvName]; + fsMocks.access.mockResolvedValue(undefined); + electronAppMock.getGPUInfo.mockResolvedValue({ + gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }], + }); try { const reason = await getExperimentalNvidiaCudaExportSkipReason( @@ -446,6 +450,31 @@ describe("getExperimentalNvidiaCudaExportSkipReason", () => { } else { process.env[allowAudioEnvName] = originalAllowAudioEnv; } + electronAppMock.getGPUInfo.mockReset(); + electronAppMock.getGPUInfo.mockResolvedValue({ gpuDevice: [] }); + resetFsAccessMock(); + } + }); + + it("reports explicit lab CUDA as unavailable when the wrapper cannot be resolved", async () => { + const exportEnvName = "RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT"; + const originalExportEnv = process.env[exportEnvName]; + process.env[exportEnvName] = "1"; + + try { + const reason = await getExperimentalNvidiaCudaExportSkipReason( + createNvidiaCudaSkipOptions(), + ); + + expect(reason).toBe( + process.platform === "win32" ? "cuda-wrapper-unavailable" : "not-windows", + ); + } finally { + if (originalExportEnv === undefined) { + delete process.env[exportEnvName]; + } else { + process.env[exportEnvName] = originalExportEnv; + } } }); @@ -675,6 +704,58 @@ describe("buildExperimentalNvidiaCudaStaticLayoutArgs", () => { }); describe("buildExperimentalWindowsGpuStaticLayoutArgs", () => { + it("prefers the high-performance adapter by default for D3D11 fallback diagnostics", () => { + const envName = "RECORDLY_WINDOWS_GPU_EXPORT_ADAPTER_INDEX"; + const preferEnvName = "RECORDLY_WINDOWS_GPU_EXPORT_PREFER_HIGH_PERFORMANCE_ADAPTER"; + const originalValue = process.env[envName]; + const originalPreferValue = process.env[preferEnvName]; + delete process.env[envName]; + process.env[preferEnvName] = "1"; + + try { + const args = buildExperimentalWindowsGpuStaticLayoutArgs( + createNvidiaCudaSkipOptions(), + "output.mp4", + ); + + expect(args).toContain("--prefer-high-performance-adapter"); + expect(args).not.toContain("--adapter-index"); + } finally { + if (originalValue === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalValue; + } + if (originalPreferValue === undefined) { + delete process.env[preferEnvName]; + } else { + process.env[preferEnvName] = originalPreferValue; + } + } + }); + + it("passes an explicit D3D11 adapter index when configured", () => { + const envName = "RECORDLY_WINDOWS_GPU_EXPORT_ADAPTER_INDEX"; + const originalValue = process.env[envName]; + + try { + process.env[envName] = "2"; + const args = buildExperimentalWindowsGpuStaticLayoutArgs( + createNvidiaCudaSkipOptions(), + "output.mp4", + ); + + expect(args).toEqual(expect.arrayContaining(["--adapter-index", "2"])); + expect(args).not.toContain("--prefer-high-performance-adapter"); + } finally { + if (originalValue === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalValue; + } + } + }); + it("passes background blur to the D3D11 compositor", () => { const args = buildExperimentalWindowsGpuStaticLayoutArgs( createNvidiaCudaSkipOptions({ @@ -813,6 +894,16 @@ describe("buildNativeVideoAudioMuxArgs", () => { expect(args.join(";")).not.toContain("-c:a;copy"); }); + it("transcodes unknown source audio instead of copying unsafe codecs into MP4", () => { + const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.wav", "out.mp4", { + audioMode: "copy-source", + outputDurationSec: 60, + }); + + expect(args).toEqual(expect.arrayContaining(["-c:a", "aac", "-b:a", "192k"])); + expect(args.join(";")).not.toContain("-c:a;copy"); + }); + it("keeps filtered audio on the AAC encode path", () => { const args = buildNativeVideoAudioMuxArgs("video.mp4", "source.mp4", "out.mp4", { audioMode: "trim-source", @@ -863,6 +954,11 @@ describe("canCopyAudioCodecIntoMp4", () => { it("blocks Opus so native exports transcode it to AAC for MP4", () => { expect(canCopyAudioCodecIntoMp4("opus")).toBe(false); }); + + it("blocks unknown codecs so sidecar WAV/PCM audio is encoded for MP4", () => { + expect(canCopyAudioCodecIntoMp4(undefined)).toBe(false); + expect(canCopyAudioCodecIntoMp4("")).toBe(false); + }); }); describe("validateNativeVideoStreamStats", () => { @@ -1078,6 +1174,47 @@ describe("validateNvidiaCudaExportSummary", () => { expect(issues).toEqual([]); }); + it("rejects inline-audio CUDA output when the helper does not produce audio", () => { + const issues = validateNvidiaCudaExportSummary( + { + success: true, + targetFrames: 300, + durationSec: 10, + nativeSummary: { + success: true, + frames: 300, + sourceTimestampMode: "pts", + selectionStage: "timestamp-mapped-callback", + }, + outputVideo: { duration: "9.999900", nb_frames: "300" }, + }, + { durationSec: 10, targetFrames: 300, requiresTimelineSync: true }, + ); + + expect(issues).toEqual(["missing output audio stream"]); + }); + + it("rejects inline-audio CUDA output when the probed audio stream is empty", () => { + const issues = validateNvidiaCudaExportSummary( + { + success: true, + targetFrames: 300, + durationSec: 10, + nativeSummary: { + success: true, + frames: 300, + sourceTimestampMode: "pts", + selectionStage: "timestamp-mapped-callback", + }, + outputVideo: { duration: "9.999900", nb_frames: "300" }, + outputAudio: { duration: "0.000000" }, + }, + { durationSec: 10, targetFrames: 300, requiresTimelineSync: true }, + ); + + expect(issues).toEqual(["output audio duration is not positive"]); + }); + it("accepts audio CUDA output when the helper reports PTS-aligned selection", () => { const issues = validateNvidiaCudaExportSummary( { diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 44ba69a17..5a970a645 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -50,6 +50,10 @@ const NVIDIA_CUDA_AUTO_STALL_TIMEOUT_ENV = "RECORDLY_NVIDIA_CUDA_AUTO_STALL_TIME 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 WINDOWS_GPU_ADAPTER_INDEX_ENV = "RECORDLY_WINDOWS_GPU_EXPORT_ADAPTER_INDEX"; +const WINDOWS_GPU_PREFER_HIGH_PERFORMANCE_ADAPTER_ENV = + "RECORDLY_WINDOWS_GPU_EXPORT_PREFER_HIGH_PERFORMANCE_ADAPTER"; +const WINDOWS_GPU_NVENC_SDK_ENV = "RECORDLY_WINDOWS_GPU_EXPORT_NVENC_SDK"; 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; @@ -65,6 +69,63 @@ type ElectronGpuInfoLike = { gpuDevice?: ElectronGpuDeviceLike[]; }; +type NativeStaticLayoutSourceInput = { + inputPath: string; + elapsedMs: number; + sourceCodec: string; + proxyCodec?: string; + proxyCreated: boolean; +}; + +type NativeStaticLayoutRouteId = + | "nvidia-cuda-compositor" + | "windows-d3d11-compositor" + | "ffmpeg-static-layout"; + +type NativeStaticLayoutRouteDecision = { + route: NativeStaticLayoutRouteId; + status: "selected" | "fallback" | "rejected"; + reasons: string[]; +}; + +type NvidiaCudaExportCapabilityProbe = { + platform: NodeJS.Platform; + appPackaged: boolean; + explicitEnabled: boolean; + explicitDisabled: boolean; + packagedAutoCandidateEnabled: boolean; + packagedAutoCandidateActive: boolean; + windowsGpuCompositorEnabled: boolean; + wrapperPath: string | null; + hasNvidiaGpu: boolean | null; + audioMode: NativeVideoExportAudioMode; + audioSkipReason: string | null; + stallTimeoutMs: number | null; + skipReason: string | null; +}; + +type WindowsD3D11ExportCapabilityProbe = { + platform: NodeJS.Platform; + windowsGpuCompositorEnabled: boolean; + helperPath: string | null; + adapterIndexOverride: number | null; + preferHighPerformanceAdapter: boolean; + nvencSdkRequested: boolean; + skipReason: string | null; +}; + +type NativeStaticLayoutRoutePlan = { + selectedRoute: NativeStaticLayoutRouteId; + decisions: NativeStaticLayoutRouteDecision[]; + cuda: NvidiaCudaExportCapabilityProbe; + d3d11: WindowsD3D11ExportCapabilityProbe; + source: { + inputCodec: string; + proxyCodec?: string; + proxyCreated: boolean; + }; +}; + export type NativeVideoExportSession = { ffmpegProcess: ChildProcessByStdio; outputPath: string; @@ -430,6 +491,15 @@ export function validateNvidiaCudaExportSummary( if (!summary.outputVideo) { issues.push("missing output video probe"); } + if (expected.requiresTimelineSync) { + if (!summary.outputAudio) { + issues.push("missing output audio stream"); + } else if (outputAudioDurationSec === null) { + issues.push("missing output audio duration"); + } else if (outputAudioDurationSec <= 0) { + issues.push("output audio duration is not positive"); + } + } if (expected.requiresTimelineSync && !isNvidiaCudaTimestampAlignedSummary(summary)) { issues.push("CUDA timeline mode is not timestamp-aligned for audio export"); } @@ -455,6 +525,7 @@ export function validateNvidiaCudaExportSummary( } if ( outputAudioDurationSec !== null && + outputAudioDurationSec > 0 && Math.abs(outputAudioDurationSec - expectedDurationSec) > durationToleranceSec ) { issues.push( @@ -1866,6 +1937,24 @@ async function hasNvidiaGpuForCudaExportCandidate() { } } +function getWindowsGpuAdapterIndexOverride() { + const rawValue = process.env[WINDOWS_GPU_ADAPTER_INDEX_ENV]?.trim(); + if (!rawValue) { + return null; + } + + const parsed = Number(rawValue); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; +} + +function shouldPreferHighPerformanceWindowsGpuAdapter() { + return process.env[WINDOWS_GPU_PREFER_HIGH_PERFORMANCE_ADAPTER_ENV] !== "0"; +} + +function isWindowsGpuNvencSdkRequested() { + return process.env[WINDOWS_GPU_NVENC_SDK_ENV] === "1"; +} + function getNativeBinPlatformArch() { return process.arch === "arm64" ? "win32-arm64" : "win32-x64"; } @@ -1998,31 +2087,178 @@ export function getNativeGpuCompositorStallTimeoutMs() { export async function getExperimentalNvidiaCudaExportSkipReason( options: NativeStaticLayoutExportOptions, ) { + return (await probeExperimentalNvidiaCudaExportCapability(options)).skipReason; +} + +export async function probeExperimentalNvidiaCudaExportCapability( + options: NativeStaticLayoutExportOptions, +): Promise { + const explicitCuda = isExplicitNvidiaCudaExportEnabled(); + const packagedAutoCandidateEnabled = isPackagedNvidiaCudaExportAutoCandidateEnabled(); + const packagedAutoCandidateActive = isPackagedNvidiaCudaExportAutoCandidateActive(); + const shouldProbeHelper = explicitCuda || packagedAutoCandidateEnabled; + const wrapperPath = + process.platform === "win32" && shouldProbeHelper + ? await resolveExperimentalNvidiaCudaExportScriptPath() + : null; + const hasNvidiaGpu = + process.platform === "win32" && shouldProbeHelper && wrapperPath + ? await hasNvidiaGpuForCudaExportCandidate() + : null; + const audioMode = options.audioOptions?.audioMode ?? "none"; + const audioSkipReason = getNvidiaCudaAudioExportSkipReason(audioMode, { + allowValidatedFallbackCandidate: + packagedAutoCandidateActive || isNvidiaCudaForceVideoOnlyEnabled(), + }); + let skipReason: string | null = null; + if (process.platform !== "win32") { - return "not-windows"; + skipReason = "not-windows"; + } else if (!explicitCuda && !packagedAutoCandidateEnabled) { + skipReason = "env-disabled"; + } else if (!options.experimentalWindowsGpuCompositor) { + skipReason = "windows-gpu-compositor-disabled"; + } else if (!wrapperPath) { + skipReason = "cuda-wrapper-unavailable"; + } else if (hasNvidiaGpu === false) { + skipReason = "nvidia-gpu-unavailable"; + } else { + skipReason = audioSkipReason; } - const explicitCuda = isExplicitNvidiaCudaExportEnabled(); - const packagedAutoCandidate = isPackagedNvidiaCudaExportAutoCandidateEnabled(); - if (!explicitCuda && !packagedAutoCandidate) { - return "env-disabled"; + + return { + platform: process.platform, + appPackaged: app.isPackaged, + explicitEnabled: explicitCuda, + explicitDisabled: isExplicitNvidiaCudaExportDisabled(), + packagedAutoCandidateEnabled, + packagedAutoCandidateActive, + windowsGpuCompositorEnabled: options.experimentalWindowsGpuCompositor === true, + wrapperPath, + hasNvidiaGpu, + audioMode, + audioSkipReason, + stallTimeoutMs: getNvidiaCudaAutoStallTimeoutMs(packagedAutoCandidateActive), + skipReason, + }; +} + +async function probeExperimentalWindowsD3D11ExportCapability( + options: NativeStaticLayoutExportOptions, +): Promise { + const helperPath = + process.platform === "win32" && options.experimentalWindowsGpuCompositor === true + ? await resolveExperimentalWindowsGpuExporterPath() + : null; + let skipReason: string | null = null; + if (process.platform !== "win32") { + skipReason = "not-windows"; + } else if (options.experimentalWindowsGpuCompositor !== true) { + skipReason = "windows-gpu-compositor-disabled"; + } else if (!helperPath) { + skipReason = "windows-gpu-helper-unavailable"; } - if (!options.experimentalWindowsGpuCompositor) { - return "windows-gpu-compositor-disabled"; + + return { + platform: process.platform, + windowsGpuCompositorEnabled: options.experimentalWindowsGpuCompositor === true, + helperPath, + adapterIndexOverride: getWindowsGpuAdapterIndexOverride(), + preferHighPerformanceAdapter: shouldPreferHighPerformanceWindowsGpuAdapter(), + nvencSdkRequested: isWindowsGpuNvencSdkRequested(), + skipReason, + }; +} + +async function planNativeStaticLayoutRoutes( + options: NativeStaticLayoutExportOptions, + source: NativeStaticLayoutSourceInput, +): Promise { + const cuda = await probeExperimentalNvidiaCudaExportCapability(options); + const d3d11 = await probeExperimentalWindowsD3D11ExportCapability(options); + const decisions: NativeStaticLayoutRouteDecision[] = []; + + if (!cuda.skipReason) { + decisions.push({ + route: "nvidia-cuda-compositor", + status: "selected", + reasons: ["cuda-wrapper-and-nvidia-gpu-available"], + }); + decisions.push({ + route: "windows-d3d11-compositor", + status: d3d11.skipReason ? "rejected" : "fallback", + reasons: d3d11.skipReason + ? [d3d11.skipReason] + : ["documented-fallback-if-cuda-runtime-fails"], + }); + decisions.push({ + route: "ffmpeg-static-layout", + status: "fallback", + reasons: ["native-gpu-runtime-fallback"], + }); + return { + selectedRoute: "nvidia-cuda-compositor", + decisions, + cuda, + d3d11, + source: { + inputCodec: source.sourceCodec, + proxyCodec: source.proxyCodec, + proxyCreated: source.proxyCreated, + }, + }; } - if (packagedAutoCandidate && !explicitCuda) { - if (!(await resolveExperimentalNvidiaCudaExportScriptPath())) { - return "cuda-wrapper-unavailable"; - } - if (!(await hasNvidiaGpuForCudaExportCandidate())) { - return "nvidia-gpu-unavailable"; - } + decisions.push({ + route: "nvidia-cuda-compositor", + status: "rejected", + reasons: [cuda.skipReason], + }); + if (!d3d11.skipReason) { + decisions.push({ + route: "windows-d3d11-compositor", + status: "selected", + reasons: [`documented-fallback-after-cuda-skip:${cuda.skipReason}`], + }); + decisions.push({ + route: "ffmpeg-static-layout", + status: "fallback", + reasons: ["windows-d3d11-runtime-fallback"], + }); + return { + selectedRoute: "windows-d3d11-compositor", + decisions, + cuda, + d3d11, + source: { + inputCodec: source.sourceCodec, + proxyCodec: source.proxyCodec, + proxyCreated: source.proxyCreated, + }, + }; } - return getNvidiaCudaAudioExportSkipReason(options.audioOptions?.audioMode, { - allowValidatedFallbackCandidate: - packagedAutoCandidate || isNvidiaCudaForceVideoOnlyEnabled(), + decisions.push({ + route: "windows-d3d11-compositor", + status: "rejected", + reasons: [d3d11.skipReason], }); + decisions.push({ + route: "ffmpeg-static-layout", + status: "selected", + reasons: ["native-gpu-routes-unavailable"], + }); + return { + selectedRoute: "ffmpeg-static-layout", + decisions, + cuda, + d3d11, + source: { + inputCodec: source.sourceCodec, + proxyCodec: source.proxyCodec, + proxyCreated: source.proxyCreated, + }, + }; } export async function resolveExperimentalNvidiaCudaExportScriptPath() { @@ -2159,6 +2395,15 @@ export function buildExperimentalWindowsGpuStaticLayoutArgs( "--surface-pool-size", String(surfacePoolSize), ]; + const adapterIndexOverride = getWindowsGpuAdapterIndexOverride(); + if (adapterIndexOverride !== null) { + args.push("--adapter-index", String(adapterIndexOverride)); + } else if (shouldPreferHighPerformanceWindowsGpuAdapter()) { + args.push("--prefer-high-performance-adapter"); + } + if (isWindowsGpuNvencSdkRequested()) { + args.push("--nvenc-sdk"); + } if (options.backgroundImagePath) { args.push("--background-image", options.backgroundImagePath); @@ -3201,6 +3446,44 @@ export async function exportNativeStaticLayoutVideo( timelineMapPath, }; } + const nativeRoutePlan = await planNativeStaticLayoutRoutes(options, sourceInput); + console.info("[native-static-layout-export] Native route plan", { + selectedRoute: nativeRoutePlan.selectedRoute, + decisions: nativeRoutePlan.decisions, + cuda: { + platform: nativeRoutePlan.cuda.platform, + appPackaged: nativeRoutePlan.cuda.appPackaged, + explicitEnabled: nativeRoutePlan.cuda.explicitEnabled, + explicitDisabled: nativeRoutePlan.cuda.explicitDisabled, + packagedAutoCandidateEnabled: nativeRoutePlan.cuda.packagedAutoCandidateEnabled, + packagedAutoCandidateActive: nativeRoutePlan.cuda.packagedAutoCandidateActive, + windowsGpuCompositorEnabled: nativeRoutePlan.cuda.windowsGpuCompositorEnabled, + wrapperPath: nativeRoutePlan.cuda.wrapperPath, + hasNvidiaGpu: nativeRoutePlan.cuda.hasNvidiaGpu, + audioMode: nativeRoutePlan.cuda.audioMode, + audioSkipReason: nativeRoutePlan.cuda.audioSkipReason, + stallTimeoutMs: nativeRoutePlan.cuda.stallTimeoutMs, + skipReason: nativeRoutePlan.cuda.skipReason, + }, + d3d11: nativeRoutePlan.d3d11, + source: nativeRoutePlan.source, + output: { + width: options.width, + height: options.height, + frameRate: options.frameRate, + durationSec: options.durationSec, + }, + features: { + timelineMap: Boolean(options.timelineMapPath), + webcamOverlay: Boolean(options.webcamInputPath), + cursorOverlay: Boolean(options.cursorTelemetry?.length), + zoomOverlay: Boolean(options.zoomTelemetry?.length), + sourceCrop: hasNativeStaticLayoutSourceCrop(options), + backgroundImage: Boolean(options.backgroundImagePath), + backgroundBlurPx: options.backgroundBlurPx ?? 0, + audioMode: options.audioOptions?.audioMode ?? "none", + }, + }); const fullConfig: NativeStaticLayoutExportArgsConfig = { inputPath: options.inputPath, outputPath: videoOnlyPath, @@ -3293,9 +3576,17 @@ export async function exportNativeStaticLayoutVideo( } } let experimentalNvidiaCudaOptions = experimentalGpuOptions; - const nvidiaCudaSkipReason = - await getExperimentalNvidiaCudaExportSkipReason(options); - let shouldTryNvidiaCuda = nvidiaCudaSkipReason === null; + let shouldTryNvidiaCuda = + nativeRoutePlan.selectedRoute === "nvidia-cuda-compositor"; + let windowsD3D11FallbackReason = + nativeRoutePlan.decisions.find( + (decision) => + decision.route === "windows-d3d11-compositor" && + decision.status === "selected", + )?.reasons[0] ?? + (nativeRoutePlan.cuda.skipReason + ? `nvidia-cuda-skipped:${nativeRoutePlan.cuda.skipReason}` + : undefined); if ( shouldTryNvidiaCuda && (isPackagedNvidiaCudaExportAutoCandidateActive() || @@ -3316,24 +3607,20 @@ export async function exportNativeStaticLayoutVideo( }, ); } - const shouldLogNvidiaCudaSkip = - isExplicitNvidiaCudaExportEnabled() || - (isPackagedNvidiaCudaExportAutoCandidateEnabled() && - nvidiaCudaSkipReason !== "env-disabled"); if ( !shouldTryNvidiaCuda && - shouldLogNvidiaCudaSkip && - nvidiaCudaSkipReason !== "env-disabled" + (nativeRoutePlan.cuda.explicitEnabled || + nativeRoutePlan.cuda.packagedAutoCandidateEnabled) && + nativeRoutePlan.cuda.skipReason !== "env-disabled" ) { - console.warn( - "[native-static-layout-export] Skipping NVIDIA CUDA compositor; falling back to Windows GPU compositor", - { - reason: nvidiaCudaSkipReason, - audioMode: options.audioOptions?.audioMode ?? "none", - overrideEnv: NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV, - packagedAutoCandidate: isPackagedNvidiaCudaExportAutoCandidateEnabled(), - }, - ); + console.warn("[native-static-layout-export] Skipping NVIDIA CUDA compositor", { + reason: nativeRoutePlan.cuda.skipReason, + audioMode: nativeRoutePlan.cuda.audioMode, + overrideEnv: NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV, + wrapperPath: nativeRoutePlan.cuda.wrapperPath, + hasNvidiaGpu: nativeRoutePlan.cuda.hasNvidiaGpu, + selectedFallback: nativeRoutePlan.selectedRoute, + }); } if (shouldTryNvidiaCuda && options.cursorTelemetry?.length) { const cursorTelemetryPath = await prepareNvidiaCudaCursorTelemetry( @@ -3355,6 +3642,12 @@ export async function exportNativeStaticLayoutVideo( }; } } + if ( + !shouldTryNvidiaCuda && + nativeRoutePlan.selectedRoute === "nvidia-cuda-compositor" + ) { + windowsD3D11FallbackReason = "nvidia-cuda-cursor-assets-unavailable"; + } if (shouldTryNvidiaCuda) { try { @@ -3388,7 +3681,6 @@ export async function exportNativeStaticLayoutVideo( "Experimental NVIDIA CUDA compositor produced an empty output file", ); } - await validateRenderedVideoOutput(); console.info( "[native-static-layout-export] NVIDIA CUDA compositor completed", { @@ -3439,6 +3731,10 @@ export async function exportNativeStaticLayoutVideo( throw error; } metrics.fallbackChunkCount++; + windowsD3D11FallbackReason = + error instanceof Error + ? `nvidia-cuda-runtime-failed:${error.message}` + : "nvidia-cuda-runtime-failed"; console.warn( "[native-static-layout-export] Experimental NVIDIA CUDA compositor failed or produced invalid output; falling back to Windows GPU compositor:", error, @@ -3447,7 +3743,18 @@ export async function exportNativeStaticLayoutVideo( } } - if (!didRenderVideo) { + if (!didRenderVideo && !nativeRoutePlan.d3d11.skipReason) { + console.info( + "[native-static-layout-export] Starting Windows D3D11 compositor fallback", + { + fallbackReason: windowsD3D11FallbackReason, + helperPath: nativeRoutePlan.d3d11.helperPath, + adapterIndexOverride: nativeRoutePlan.d3d11.adapterIndexOverride, + preferHighPerformanceAdapter: + nativeRoutePlan.d3d11.preferHighPerformanceAdapter, + nvencSdkRequested: nativeRoutePlan.d3d11.nvencSdkRequested, + }, + ); const gpuResult = await runExperimentalWindowsGpuStaticLayoutExport( experimentalGpuOptions, videoOnlyPath, @@ -3463,7 +3770,18 @@ export async function exportNativeStaticLayoutVideo( `Experimental Windows GPU compositor produced an invalid output: ${gpuValidationIssues.join("; ")}`, ); } - await validateRenderedVideoOutput(); + const outputStat = await fs.stat(videoOnlyPath); + if (outputStat.size <= 0) { + throw new Error( + "Experimental Windows GPU compositor produced an empty output file", + ); + } + const verifiedNvidiaAdapter = isNvidiaVendorId( + gpuResult.summary.adapterVendorId, + ); + const verifiedNvencBackend = /nvenc/i.test( + gpuResult.summary.encoderBackend ?? "", + ); console.info("[native-static-layout-export] Windows GPU compositor completed", { elapsedMs: gpuResult.elapsedMs, width: gpuResult.summary.width, @@ -3474,7 +3792,15 @@ export async function exportNativeStaticLayoutVideo( surfacePoolSize: gpuResult.summary.surfacePoolSize, gpuDecodeSurface: gpuResult.summary.gpuDecodeSurface, adapterIndex: gpuResult.summary.adapterIndex, + adapterVendorId: gpuResult.summary.adapterVendorId, + adapterDeviceId: gpuResult.summary.adapterDeviceId, + adapterDedicatedVideoMemoryMB: + gpuResult.summary.adapterDedicatedVideoMemoryMB, encoderBackend: gpuResult.summary.encoderBackend, + verifiedNvidiaAdapter, + verifiedNvencBackend, + documentedFallback: !verifiedNvidiaAdapter || !verifiedNvencBackend, + fallbackReason: windowsD3D11FallbackReason, encoderTuningApplied: gpuResult.summary.encoderTuningApplied, readMs: gpuResult.summary.readMs, videoProcessMs: gpuResult.summary.videoProcessMs, @@ -3485,7 +3811,6 @@ export async function exportNativeStaticLayoutVideo( cursorAtlas: gpuResult.summary.cursorAtlas, zoomOverlay: gpuResult.summary.zoomOverlay, }); - const outputStat = await fs.stat(videoOnlyPath); metrics.chunkCount = 1; metrics.chunkDurationSec = options.durationSec; metrics.chunkExecMs += gpuResult.elapsedMs; @@ -3496,9 +3821,15 @@ export async function exportNativeStaticLayoutVideo( backend: "windows-d3d11-compositor", elapsedMs: gpuResult.elapsedMs, outputBytes: outputStat.size, + fallbackReason: windowsD3D11FallbackReason, windowsGpuSummary: gpuResult.summary, }); didRenderVideo = true; + } else if (!didRenderVideo && nativeRoutePlan.d3d11.skipReason) { + console.warn("[native-static-layout-export] Skipping Windows D3D11 fallback", { + reason: nativeRoutePlan.d3d11.skipReason, + fallbackReason: windowsD3D11FallbackReason, + }); } } catch (error) { if (session.terminating) { @@ -3894,7 +4225,7 @@ export async function resolveNativeVideoEncoder( export function canCopyAudioCodecIntoMp4(codec?: string | null) { const normalized = (codec ?? "").trim().toLowerCase(); if (!normalized) { - return true; + return false; } return ( diff --git a/electron/ipc/recording/diagnostics.ts b/electron/ipc/recording/diagnostics.ts index 77b34c45b..811e0920e 100644 --- a/electron/ipc/recording/diagnostics.ts +++ b/electron/ipc/recording/diagnostics.ts @@ -512,8 +512,7 @@ export async function getCompanionAudioFallbackInfo(videoPath: string) { new Set( companionCandidates.flatMap((candidate) => candidate.usablePaths.filter( - (companionPath) => - companionPath === candidate.micPath || companionPath === candidate.systemPath, + (companionPath) => companionPath === candidate.micPath, ), ), ), diff --git a/electron/ipc/recording/prune.ts b/electron/ipc/recording/prune.ts index f7ee5e271..d8004bd14 100644 --- a/electron/ipc/recording/prune.ts +++ b/electron/ipc/recording/prune.ts @@ -82,11 +82,11 @@ async function loadSavedProjectMediaPaths() { editor?: { webcam?: { sourcePath?: unknown } }; }>(await fs.readFile(projectPath, "utf-8")); } catch (error) { - console.warn("[prune] Skipping unreadable project while pruning recordings", { + console.warn("[prune] Aborting recording prune because a saved project is unreadable", { projectPath, error, }); - return; + throw error; } const candidatePaths = [ rawProject.videoPath, diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index bbcef557a..46148e8c0 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -26,10 +26,10 @@ }, "recordly-nvidia-cuda-compositor": { "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "a787531c07142de7c292d1726e0339c97dbce5073d9a0853d539a725265fd945", + "binarySha256": "3f087c65e054d748bb02d507d4925bbb0b495a9b0e0eae5a8ed118fa4637f34b", "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "528b599e9d576d81ec087d0d4dc93a79af1bbf30fdb969f44f773bef90146135", - "updatedAt": "2026-05-07T20:14:13.794Z" + "sourceFingerprint": "fc3ae59b700c9c25c5228e074f8976b7aa9ef20bc18af0732eca74e2e34e3f80", + "updatedAt": "2026-05-12T20:05:02.218Z" } } } diff --git a/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe index 0fd5f2732..3dca234ea 100644 Binary files a/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe and b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe differ diff --git a/electron/native/nvidia-cuda-compositor/src/main.cu b/electron/native/nvidia-cuda-compositor/src/main.cu index 6adcbcf70..a90c16e59 100644 --- a/electron/native/nvidia-cuda-compositor/src/main.cu +++ b/electron/native/nvidia-cuda-compositor/src/main.cu @@ -975,6 +975,37 @@ std::unique_ptr createWebcamStreamDecoder(CUcontext context return std::make_unique(context, options); } +__device__ unsigned char clampByteDevice(int value); + +__device__ float mapScaledCoordinate(float dstCoordinate, int srcSize, int dstSize); + +__device__ unsigned char samplePlaneBilinear( + const unsigned char* plane, + int pitch, + int width, + int height, + float x, + float y); + +__device__ unsigned char samplePlaneCubic( + const unsigned char* plane, + int pitch, + int width, + int height, + float x, + float y); + +__device__ void sampleNv12UvBilinear( + const unsigned char* src, + int srcPitch, + int srcSurfaceHeight, + int srcWidth, + int srcHeight, + float lumaX, + float lumaY, + unsigned char* outU, + unsigned char* outV); + __global__ void copyNv12Kernel( const unsigned char* src, int srcPitch, @@ -992,17 +1023,24 @@ __global__ void copyNv12Kernel( return; } - const int sx = min(srcWidth - 1, (x * srcWidth) / dstWidth); - const int sy = min(srcHeight - 1, (y * srcHeight) / dstHeight); - dst[y * dstPitch + x] = src[sy * srcPitch + sx]; - - if ((x % 2) == 0 && (y % 2) == 0) { - const int suvX = min(srcWidth - 2, ((x * srcWidth) / dstWidth) & ~1); - const int suvY = min((srcHeight / 2) - 1, (y * srcHeight / dstHeight) / 2); - const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX; - unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; - dstUv[0] = srcUv[0]; - dstUv[1] = srcUv[1]; + const float sx = mapScaledCoordinate(static_cast(x), srcWidth, dstWidth); + const float sy = mapScaledCoordinate(static_cast(y), srcHeight, dstHeight); + dst[y * dstPitch + x] = samplePlaneCubic(src, srcPitch, srcWidth, srcHeight, sx, sy); + + if ((x % 2) == 0 && (y % 2) == 0) { + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + const float suvX = mapScaledCoordinate(static_cast(x), srcWidth, dstWidth); + const float suvY = mapScaledCoordinate(static_cast(y), srcHeight, dstHeight); + sampleNv12UvBilinear( + src, + srcPitch, + srcSurfaceHeight, + srcWidth, + srcHeight, + suvX, + suvY, + &dstUv[0], + &dstUv[1]); } } @@ -1061,6 +1099,135 @@ __device__ bool isInsideRoundedRect( return dx * dx + dy * dy <= radius * radius; } +__device__ float mapScaledCoordinate(float dstCoordinate, int srcSize, int dstSize) { + return ((dstCoordinate + 0.5f) * static_cast(srcSize) / + static_cast(max(1, dstSize))) - + 0.5f; +} + +__device__ unsigned char samplePlaneBilinear( + const unsigned char* plane, + int pitch, + int width, + int height, + float x, + float y) { + if (width <= 1 || height <= 1) { + const int sx = max(0, min(width - 1, static_cast(floorf(x)))); + const int sy = max(0, min(height - 1, static_cast(floorf(y)))); + return plane[sy * pitch + sx]; + } + + const float clampedX = fminf(static_cast(width - 1), fmaxf(0.0f, x)); + const float clampedY = fminf(static_cast(height - 1), fmaxf(0.0f, y)); + const int x0 = max(0, min(width - 1, static_cast(floorf(clampedX)))); + const int y0 = max(0, min(height - 1, static_cast(floorf(clampedY)))); + const int x1 = min(width - 1, x0 + 1); + const int y1 = min(height - 1, y0 + 1); + const float tx = clampedX - static_cast(x0); + const float ty = clampedY - static_cast(y0); + + const float v00 = static_cast(plane[y0 * pitch + x0]); + const float v10 = static_cast(plane[y0 * pitch + x1]); + const float v01 = static_cast(plane[y1 * pitch + x0]); + const float v11 = static_cast(plane[y1 * pitch + x1]); + const float top = v00 + (v10 - v00) * tx; + const float bottom = v01 + (v11 - v01) * tx; + return clampByteDevice(static_cast(top + (bottom - top) * ty + 0.5f)); +} + +__device__ float cubicWeight(float x) { + const float ax = fabsf(x); + if (ax <= 1.0f) { + return (1.5f * ax - 2.5f) * ax * ax + 1.0f; + } + if (ax < 2.0f) { + return ((-0.5f * ax + 2.5f) * ax - 4.0f) * ax + 2.0f; + } + return 0.0f; +} + +__device__ unsigned char samplePlaneCubic( + const unsigned char* plane, + int pitch, + int width, + int height, + float x, + float y) { + if (width <= 2 || height <= 2) { + return samplePlaneBilinear(plane, pitch, width, height, x, y); + } + + const float clampedX = fminf(static_cast(width - 1), fmaxf(0.0f, x)); + const float clampedY = fminf(static_cast(height - 1), fmaxf(0.0f, y)); + const int baseX = static_cast(floorf(clampedX)); + const int baseY = static_cast(floorf(clampedY)); + float total = 0.0f; + float weightTotal = 0.0f; + + for (int oy = -1; oy <= 2; ++oy) { + const int sy = max(0, min(height - 1, baseY + oy)); + const float wy = cubicWeight(clampedY - static_cast(baseY + oy)); + for (int ox = -1; ox <= 2; ++ox) { + const int sx = max(0, min(width - 1, baseX + ox)); + const float weight = wy * cubicWeight(clampedX - static_cast(baseX + ox)); + total += static_cast(plane[sy * pitch + sx]) * weight; + weightTotal += weight; + } + } + + if (weightTotal > 0.0001f) { + total /= weightTotal; + } + return clampByteDevice(static_cast(total + 0.5f)); +} + +__device__ void sampleNv12UvBilinear( + const unsigned char* src, + int srcPitch, + int srcSurfaceHeight, + int srcWidth, + int srcHeight, + float lumaX, + float lumaY, + unsigned char* outU, + unsigned char* outV) { + const int chromaWidth = max(1, srcWidth / 2); + const int chromaHeight = max(1, srcHeight / 2); + const float chromaX = fminf( + static_cast(chromaWidth - 1), + fmaxf(0.0f, (lumaX - 0.5f) * 0.5f)); + const float chromaY = fminf( + static_cast(chromaHeight - 1), + fmaxf(0.0f, (lumaY - 0.5f) * 0.5f)); + const int x0 = max(0, min(chromaWidth - 1, static_cast(floorf(chromaX)))); + const int y0 = max(0, min(chromaHeight - 1, static_cast(floorf(chromaY)))); + const int x1 = min(chromaWidth - 1, x0 + 1); + const int y1 = min(chromaHeight - 1, y0 + 1); + const float tx = chromaX - static_cast(x0); + const float ty = chromaY - static_cast(y0); + const unsigned char* uvPlane = src + srcPitch * srcSurfaceHeight; + + const int x0Byte = x0 * 2; + const int x1Byte = x1 * 2; + const int y0Offset = y0 * srcPitch; + const int y1Offset = y1 * srcPitch; + const float u00 = static_cast(uvPlane[y0Offset + x0Byte]); + const float u10 = static_cast(uvPlane[y0Offset + x1Byte]); + const float u01 = static_cast(uvPlane[y1Offset + x0Byte]); + const float u11 = static_cast(uvPlane[y1Offset + x1Byte]); + const float v00 = static_cast(uvPlane[y0Offset + x0Byte + 1]); + const float v10 = static_cast(uvPlane[y0Offset + x1Byte + 1]); + const float v01 = static_cast(uvPlane[y1Offset + x0Byte + 1]); + const float v11 = static_cast(uvPlane[y1Offset + x1Byte + 1]); + const float uTop = u00 + (u10 - u00) * tx; + const float uBottom = u01 + (u11 - u01) * tx; + const float vTop = v00 + (v10 - v00) * tx; + const float vBottom = v01 + (v11 - v01) * tx; + *outU = clampByteDevice(static_cast(uTop + (uBottom - uTop) * ty + 0.5f)); + *outV = clampByteDevice(static_cast(vTop + (vBottom - vTop) * ty + 0.5f)); +} + __global__ void overlayContentRectNv12Kernel( const unsigned char* src, int srcPitch, @@ -1096,19 +1263,28 @@ __global__ void overlayContentRectNv12Kernel( const int cropHeight = max(1, min(sourceCropHeight > 0 ? sourceCropHeight : srcHeight, srcHeight - sourceCropY)); const int cropX = max(0, min(sourceCropX, srcWidth - 1)); const int cropY = max(0, min(sourceCropY, srcHeight - 1)); - const int srcX = min(srcWidth - 1, cropX + (localX * cropWidth) / contentWidth); - const int srcY = min(srcHeight - 1, cropY + (localY * cropHeight) / contentHeight); - dst[y * dstPitch + x] = src[srcY * srcPitch + srcX]; - - if ((x % 2) == 0 && (y % 2) == 0) { - const int localUvX = max(0, min(contentWidth - 1, localX + 1)); - const int localUvY = max(0, min(contentHeight - 1, localY + 1)); - const int srcUvX = min(srcWidth - 2, (cropX + ((localUvX * cropWidth) / contentWidth)) & ~1); - const int srcUvY = min((srcHeight / 2) - 1, (cropY + ((localUvY * cropHeight) / contentHeight)) / 2); - const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + srcUvY * srcPitch + srcUvX; - unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; - dstUv[0] = srcUv[0]; - dstUv[1] = srcUv[1]; + const float srcX = static_cast(cropX) + + mapScaledCoordinate(static_cast(localX), cropWidth, contentWidth); + const float srcY = static_cast(cropY) + + mapScaledCoordinate(static_cast(localY), cropHeight, contentHeight); + dst[y * dstPitch + x] = samplePlaneCubic(src, srcPitch, srcWidth, srcHeight, srcX, srcY); + + if ((x % 2) == 0 && (y % 2) == 0) { + unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; + const float srcUvLumaX = static_cast(cropX) + + mapScaledCoordinate(static_cast(localX), cropWidth, contentWidth); + const float srcUvLumaY = static_cast(cropY) + + mapScaledCoordinate(static_cast(localY), cropHeight, contentHeight); + sampleNv12UvBilinear( + src, + srcPitch, + srcSurfaceHeight, + srcWidth, + srcHeight, + srcUvLumaX, + srcUvLumaY, + &dstUv[0], + &dstUv[1]); } } @@ -1166,9 +1342,9 @@ __global__ void overlayContentTransformNv12Kernel( fminf(static_cast(contentHeight - 1), fmaxf(0.0f, layoutYf - contentY)); const int cropX = max(0, min(sourceCropX, srcWidth - 1)); const int cropY = max(0, min(sourceCropY, srcHeight - 1)); - const int sx = min(srcWidth - 1, cropX + __float2int_rd(localContentX * srcScaleX)); - const int sy = min(srcHeight - 1, cropY + __float2int_rd(localContentY * srcScaleY)); - dst[y * dstPitch + x] = src[sy * srcPitch + sx]; + const float sx = static_cast(cropX) + (localContentX + 0.5f) * srcScaleX - 0.5f; + const float sy = static_cast(cropY) + (localContentY + 0.5f) * srcScaleY - 0.5f; + dst[y * dstPitch + x] = samplePlaneCubic(src, srcPitch, srcWidth, srcHeight, sx, sy); if ((x % 2) == 0 && (y % 2) == 0 && x + 1 < dstWidth && y + 1 < dstHeight) { const float uvLayoutXf = (static_cast(x + 1) - zoomX) * invZoomScale; @@ -1187,14 +1363,19 @@ __global__ void overlayContentTransformNv12Kernel( fminf(static_cast(contentWidth - 1), fmaxf(0.0f, uvLayoutXf - contentX)); const float uvLocalContentY = fminf(static_cast(contentHeight - 1), fmaxf(0.0f, uvLayoutYf - contentY)); - const int suvX = - min(srcWidth - 2, (cropX + __float2int_rd(uvLocalContentX * srcScaleX)) & ~1); - const int suvY = - min((srcHeight / 2) - 1, (cropY + __float2int_rd(uvLocalContentY * srcScaleY)) / 2); - const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX; unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; - dstUv[0] = srcUv[0]; - dstUv[1] = srcUv[1]; + const float suvX = static_cast(cropX) + (uvLocalContentX + 0.5f) * srcScaleX - 0.5f; + const float suvY = static_cast(cropY) + (uvLocalContentY + 0.5f) * srcScaleY - 0.5f; + sampleNv12UvBilinear( + src, + srcPitch, + srcSurfaceHeight, + srcWidth, + srcHeight, + suvX, + suvY, + &dstUv[0], + &dstUv[1]); } } } @@ -1536,9 +1717,9 @@ __global__ void compositeStaticNv12Kernel( if (inside) { const float localX = fminf(static_cast(contentWidth - 1), fmaxf(0.0f, layoutXf - contentX)); const float localY = fminf(static_cast(contentHeight - 1), fmaxf(0.0f, layoutYf - contentY)); - const int sx = min(srcWidth - 1, cropX + static_cast((localX * cropWidth) / contentWidth)); - const int sy = min(srcHeight - 1, cropY + static_cast((localY * cropHeight) / contentHeight)); - outY = src[sy * srcPitch + sx]; + const float sx = static_cast(cropX) + mapScaledCoordinate(localX, cropWidth, contentWidth); + const float sy = static_cast(cropY) + mapScaledCoordinate(localY, cropHeight, contentHeight); + outY = samplePlaneCubic(src, srcPitch, srcWidth, srcHeight, sx, sy); } else { const bool shadowInside = shadowIntensityPct > 0 && @@ -1558,10 +1739,10 @@ __global__ void compositeStaticNv12Kernel( if (webcam && isInsideRoundedRect(x, y, webcamX, webcamY, webcamSize, webcamSize, webcamRadius)) { const int localX = max(0, min(webcamSize - 1, x - webcamX)); const int localY = max(0, min(webcamSize - 1, y - webcamY)); - const int sampleX = min(webcamFrameWidth - 1, (localX * webcamFrameWidth) / webcamSize); - const int sampleY = min(webcamFrameHeight - 1, (localY * webcamFrameHeight) / webcamSize); - const int mirroredX = webcamMirror ? webcamFrameWidth - 1 - sampleX : sampleX; - outY = webcam[sampleY * webcamFrameWidth + mirroredX]; + const float sampleX = mapScaledCoordinate(static_cast(localX), webcamFrameWidth, webcamSize); + const float sampleY = mapScaledCoordinate(static_cast(localY), webcamFrameHeight, webcamSize); + const float mirroredX = webcamMirror ? static_cast(webcamFrameWidth - 1) - sampleX : sampleX; + outY = samplePlaneCubic(webcam, webcamFrameWidth, webcamFrameWidth, webcamFrameHeight, mirroredX, sampleY); } unsigned char cursorYValue = 0; unsigned char cursorUValue = 128; @@ -1639,13 +1820,18 @@ __global__ void compositeStaticNv12Kernel( if (uvInside) { const float localX = fminf(static_cast(contentWidth - 1), fmaxf(0.0f, uvLayoutXf - contentX)); const float localY = fminf(static_cast(contentHeight - 1), fmaxf(0.0f, uvLayoutYf - contentY)); - const int suvX = - min(srcWidth - 2, (cropX + static_cast((localX * cropWidth) / contentWidth)) & ~1); - const int suvY = - min((srcHeight / 2) - 1, (cropY + static_cast(localY * cropHeight / contentHeight)) / 2); - const unsigned char* srcUv = src + srcPitch * srcSurfaceHeight + suvY * srcPitch + suvX; - dstUv[0] = srcUv[0]; - dstUv[1] = srcUv[1]; + const float suvX = static_cast(cropX) + mapScaledCoordinate(localX, cropWidth, contentWidth); + const float suvY = static_cast(cropY) + mapScaledCoordinate(localY, cropHeight, contentHeight); + sampleNv12UvBilinear( + src, + srcPitch, + srcSurfaceHeight, + srcWidth, + srcHeight, + suvX, + suvY, + &dstUv[0], + &dstUv[1]); } else { if (background) { const unsigned char* bgUv = background + dstWidth * dstHeight + (y / 2) * dstWidth + x; @@ -1667,15 +1853,19 @@ __global__ void compositeStaticNv12Kernel( webcamRadius)) { const int localX = max(0, min(webcamSize - 1, x + 1 - webcamX)); const int localY = max(0, min(webcamSize - 1, y + 1 - webcamY)); - const int sampleX = min(webcamFrameWidth - 1, (localX * webcamFrameWidth) / webcamSize); - const int sampleY = min(webcamFrameHeight - 1, (localY * webcamFrameHeight) / webcamSize); - const int mirroredX = webcamMirror ? webcamFrameWidth - 1 - sampleX : sampleX; - const int webcamUvX = min(webcamFrameWidth - 2, mirroredX & ~1); - const int webcamUvY = min((webcamFrameHeight / 2) - 1, sampleY / 2); - const unsigned char* webcamUv = - webcam + webcamFrameWidth * webcamFrameHeight + webcamUvY * webcamFrameWidth + webcamUvX; - dstUv[0] = webcamUv[0]; - dstUv[1] = webcamUv[1]; + const float sampleX = mapScaledCoordinate(static_cast(localX), webcamFrameWidth, webcamSize); + const float sampleY = mapScaledCoordinate(static_cast(localY), webcamFrameHeight, webcamSize); + const float mirroredX = webcamMirror ? static_cast(webcamFrameWidth - 1) - sampleX : sampleX; + sampleNv12UvBilinear( + webcam, + webcamFrameWidth, + webcamFrameHeight, + webcamFrameWidth, + webcamFrameHeight, + mirroredX, + sampleY, + &dstUv[0], + &dstUv[1]); } unsigned char cursorUvY = 0; unsigned char cursorUvU = 128; @@ -1770,26 +1960,37 @@ __global__ void overlayWebcamNv12Kernel( if (isInsideRoundedRect(x, y, webcamX, webcamY, webcamSize, webcamSize, webcamRadius)) { const int webcamLocalX = max(0, min(webcamSize - 1, x - webcamX)); const int webcamLocalY = max(0, min(webcamSize - 1, y - webcamY)); - const int sampleX = min(webcamFrameWidth - 1, (webcamLocalX * webcamFrameWidth) / webcamSize); - const int sampleY = min(webcamFrameHeight - 1, (webcamLocalY * webcamFrameHeight) / webcamSize); - const int mirroredX = webcamMirror ? webcamFrameWidth - 1 - sampleX : sampleX; - dst[y * dstPitch + x] = webcam[sampleY * webcamFrameWidth + mirroredX]; + const float sampleX = + mapScaledCoordinate(static_cast(webcamLocalX), webcamFrameWidth, webcamSize); + const float sampleY = + mapScaledCoordinate(static_cast(webcamLocalY), webcamFrameHeight, webcamSize); + const float mirroredX = webcamMirror ? static_cast(webcamFrameWidth - 1) - sampleX : sampleX; + dst[y * dstPitch + x] = + samplePlaneCubic(webcam, webcamFrameWidth, webcamFrameWidth, webcamFrameHeight, mirroredX, sampleY); } if ((x % 2) == 0 && (y % 2) == 0 && x + 1 < dstWidth && y + 1 < dstHeight && isInsideRoundedRect(x + 1, y + 1, webcamX, webcamY, webcamSize, webcamSize, webcamRadius)) { const int uvLocalX = max(0, min(webcamSize - 1, x + 1 - webcamX)); const int uvLocalY = max(0, min(webcamSize - 1, y + 1 - webcamY)); - const int uvSampleX = min(webcamFrameWidth - 1, (uvLocalX * webcamFrameWidth) / webcamSize); - const int uvSampleY = min(webcamFrameHeight - 1, (uvLocalY * webcamFrameHeight) / webcamSize); - const int uvMirroredX = webcamMirror ? webcamFrameWidth - 1 - uvSampleX : uvSampleX; - const int webcamUvX = min(webcamFrameWidth - 2, uvMirroredX & ~1); - const int webcamUvY = min((webcamFrameHeight / 2) - 1, uvSampleY / 2); - const unsigned char* webcamUv = - webcam + webcamFrameWidth * webcamFrameHeight + webcamUvY * webcamFrameWidth + webcamUvX; unsigned char* dstUv = dst + dstChromaOffset + (y / 2) * dstPitch + x; - dstUv[0] = webcamUv[0]; - dstUv[1] = webcamUv[1]; + const float uvSampleX = + mapScaledCoordinate(static_cast(uvLocalX), webcamFrameWidth, webcamSize); + const float uvSampleY = + mapScaledCoordinate(static_cast(uvLocalY), webcamFrameHeight, webcamSize); + const float uvMirroredX = webcamMirror + ? static_cast(webcamFrameWidth - 1) - uvSampleX + : uvSampleX; + sampleNv12UvBilinear( + webcam, + webcamFrameWidth, + webcamFrameHeight, + webcamFrameWidth, + webcamFrameHeight, + uvMirroredX, + uvSampleY, + &dstUv[0], + &dstUv[1]); } } diff --git a/package.json b/package.json index c0e306427..5d06568f7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "type": "module", "scripts": { "dev": "vite --config vite.config.ts", + "dev:nvidia-cuda": "node scripts/dev-nvidia-cuda.mjs", "postinstall": "node scripts/postinstall.mjs", "build": "npm run build:platform-native-helpers && tsc && vite build --config vite.config.ts && npm run normalize:electron-main-cjs && npm run smoke:electron-main-cjs && electron-builder", "lint": "biome check .", diff --git a/scripts/dev-nvidia-cuda.mjs b/scripts/dev-nvidia-cuda.mjs new file mode 100644 index 000000000..8ffeb5a6b --- /dev/null +++ b/scripts/dev-nvidia-cuda.mjs @@ -0,0 +1,51 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const env = { + ...process.env, + RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT: "1", + RECORDLY_NATIVE_EXPORT_DIAGNOSTICS: process.env.RECORDLY_NATIVE_EXPORT_DIAGNOSTICS ?? "always", + RECORDLY_NVIDIA_CUDA_EXPORT_DIAGNOSTICS: "1", + RECORDLY_NVIDIA_CUDA_EXPORT_HIGH_PRIORITY: "1", + RECORDLY_NVIDIA_CUDA_FORCE_VIDEO_ONLY: "1", + RECORDLY_NVIDIA_CUDA_SAMPLE_GPU: "1", +}; + +delete env.RECORDLY_NVIDIA_CUDA_ALLOW_AUDIO_EXPORT; + +const diagnosticsHint = + process.platform === "win32" && process.env.APPDATA + ? path.join(process.env.APPDATA, "Recordly-dev", "native-export-diagnostics") + : "the Recordly-dev native-export-diagnostics userData folder"; + +console.log("Starting Recordly dev with guarded NVIDIA CUDA/NVENC export enabled."); +console.log("CUDA mode: video-only native render, then shared app audio mux."); +console.log(`Diagnostics: ${diagnosticsHint}`); + +const child = + process.platform === "win32" + ? spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", "npm run dev"], { + cwd: repoRoot, + env, + stdio: "inherit", + }) + : spawn("npm", ["run", "dev"], { + cwd: repoRoot, + env, + stdio: "inherit", + }); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); + +child.on("error", (error) => { + console.error("Failed to launch Recordly dev:", error); + process.exit(1); +}); diff --git a/src/lib/exporter/audioEncoder.test.ts b/src/lib/exporter/audioEncoder.test.ts index 9e9deeec4..309ff8fe7 100644 --- a/src/lib/exporter/audioEncoder.test.ts +++ b/src/lib/exporter/audioEncoder.test.ts @@ -14,8 +14,8 @@ type OfflineRenderTestHarness = AudioProcessor & { sourceAudioFallbackPaths: string[], sourceAudioFallbackStartDelayMsByPath?: Record, ): Promise<{ - mainBuffer: AudioBuffer | null; - companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number }>; + mainBufferEntry: { buffer: AudioBuffer; gain: number } | null; + companionEntries: Array<{ buffer: AudioBuffer; startDelaySec: number; gain: number }>; }>; renderAndMuxOfflineAudio( videoUrl: string, @@ -55,9 +55,11 @@ describe("AudioProcessor offline render preparation", () => { ["/tmp/recording.mp4", "/tmp/recording.mic.wav"], ); - expect(prepared.mainBuffer).toBe(mainBuffer); + expect(prepared.mainBufferEntry?.buffer).toBe(mainBuffer); + expect(prepared.mainBufferEntry?.gain).toBe(1); expect(prepared.companionEntries).toHaveLength(1); expect(prepared.companionEntries[0]?.buffer).toBe(micBuffer); + expect(prepared.companionEntries[0]?.gain).toBe(1); expect(decodeAudioFromUrl).toHaveBeenCalledWith("file:///tmp/recording.mp4"); expect(decodeAudioFromUrl).toHaveBeenCalledWith("/tmp/recording.mic.wav"); expect(decodeAudioFromUrl).not.toHaveBeenCalledWith("/tmp/recording.mp4"); diff --git a/src/lib/exporter/backendPolicy.test.ts b/src/lib/exporter/backendPolicy.test.ts index 7c532a18f..9a33701cf 100644 --- a/src/lib/exporter/backendPolicy.test.ts +++ b/src/lib/exporter/backendPolicy.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import { getDefaultLightningRenderBackend, normalizeLightningRuntimePlatform, + planLightningExportRoutes, shouldPreferNativeAutoBackend, + shouldPreferNativeStaticLayoutBeforeBreeze, } from "./backendPolicy"; describe("backendPolicy", () => { @@ -24,4 +26,54 @@ describe("backendPolicy", () => { it("keeps Lightning exports on the stable WebGL renderer by default", () => { expect(getDefaultLightningRenderBackend()).toBe("webgl"); }); + + it("puts visually compatible Windows auto exports on native static layout before Breeze", () => { + expect(shouldPreferNativeStaticLayoutBeforeBreeze("win32", "auto")).toBe(true); + expect(shouldPreferNativeStaticLayoutBeforeBreeze("darwin", "auto")).toBe(false); + + expect( + planLightningExportRoutes({ + backendPreference: "auto", + platform: "win32", + nativeStaticLayoutAvailable: true, + }), + ).toMatchObject({ + selectedRoute: "native-static-layout", + decisions: [ + { route: "native-static-layout", status: "selected" }, + { route: "breeze-stream", status: "fallback" }, + { route: "webcodecs", status: "fallback" }, + ], + }); + }); + + it("documents the Breeze fallback when Windows static native is rejected", () => { + expect( + planLightningExportRoutes({ + backendPreference: "auto", + platform: "win32", + nativeStaticLayoutAvailable: true, + nativeStaticLayoutSkipReasons: ["unsupported-frame-overlay"], + }), + ).toEqual({ + selectedRoute: "breeze-stream", + decisions: [ + { + route: "native-static-layout", + status: "rejected", + reasons: ["unsupported-frame-overlay"], + }, + { + route: "breeze-stream", + status: "selected", + reasons: ["windows-native-static-fallback"], + }, + { + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }, + ], + }); + }); }); diff --git a/src/lib/exporter/backendPolicy.ts b/src/lib/exporter/backendPolicy.ts index 60f4f5097..85be52091 100644 --- a/src/lib/exporter/backendPolicy.ts +++ b/src/lib/exporter/backendPolicy.ts @@ -1,4 +1,4 @@ -import type { ExportRenderBackend } from "./types"; +import type { ExportBackendPreference, ExportRenderBackend } from "./types"; export type LightningRuntimePlatform = "darwin" | "win32" | "linux" | "unknown"; @@ -28,6 +28,111 @@ export function shouldPreferNativeAutoBackend(_platform: LightningRuntimePlatfor return _platform === "darwin" || _platform === "win32"; } +export type LightningExportRoute = "native-static-layout" | "breeze-stream" | "webcodecs"; + +export interface LightningExportRouteDecision { + route: LightningExportRoute; + status: "selected" | "fallback" | "rejected"; + reasons: string[]; +} + +export interface LightningExportRoutePlan { + selectedRoute: LightningExportRoute; + decisions: LightningExportRouteDecision[]; +} + +export function shouldPreferNativeStaticLayoutBeforeBreeze( + platform: LightningRuntimePlatform, + backendPreference: ExportBackendPreference, +): boolean { + return backendPreference === "auto" && platform === "win32"; +} + +export function planLightningExportRoutes(options: { + backendPreference: ExportBackendPreference; + platform: LightningRuntimePlatform; + nativeStaticLayoutAvailable: boolean; + nativeStaticLayoutSkipReasons?: string[]; +}): LightningExportRoutePlan { + const decisions: LightningExportRouteDecision[] = []; + const nativeStaticLayoutSkipReasons = options.nativeStaticLayoutSkipReasons ?? []; + const canUseNativeStaticLayout = + options.nativeStaticLayoutAvailable && nativeStaticLayoutSkipReasons.length === 0; + + const addNativeStaticLayoutDecision = (status: LightningExportRouteDecision["status"]) => { + decisions.push({ + route: "native-static-layout", + status, + reasons: canUseNativeStaticLayout + ? ["visually-compatible"] + : nativeStaticLayoutSkipReasons.length > 0 + ? nativeStaticLayoutSkipReasons + : ["native-static-unavailable"], + }); + }; + + if (options.backendPreference === "webcodecs") { + decisions.push({ + route: "webcodecs", + status: "selected", + reasons: ["user-selected-webcodecs"], + }); + return { selectedRoute: "webcodecs", decisions }; + } + + const preferStaticFirst = + options.backendPreference === "breeze" || + shouldPreferNativeStaticLayoutBeforeBreeze(options.platform, options.backendPreference); + + if (preferStaticFirst) { + addNativeStaticLayoutDecision(canUseNativeStaticLayout ? "selected" : "rejected"); + decisions.push({ + route: "breeze-stream", + status: canUseNativeStaticLayout ? "fallback" : "selected", + reasons: [ + options.backendPreference === "breeze" + ? "user-selected-breeze" + : "windows-native-static-fallback", + ], + }); + decisions.push({ + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }); + return { + selectedRoute: canUseNativeStaticLayout ? "native-static-layout" : "breeze-stream", + decisions, + }; + } + + if (options.backendPreference === "auto" && shouldPreferNativeAutoBackend(options.platform)) { + decisions.push({ + route: "breeze-stream", + status: "selected", + reasons: ["platform-prefers-native-streaming"], + }); + decisions.push({ + route: "webcodecs", + status: "fallback", + reasons: ["breeze-unavailable-fallback"], + }); + return { selectedRoute: "breeze-stream", decisions }; + } + + decisions.push({ + route: "webcodecs", + status: "selected", + reasons: ["default-webcodecs-first"], + }); + decisions.push({ + route: "breeze-stream", + status: "fallback", + reasons: ["webcodecs-software-or-unavailable-fallback"], + }); + return { selectedRoute: "webcodecs", decisions }; +} + export function getDefaultLightningRenderBackend(): ExportRenderBackend { return "webgl"; } diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index 80125266b..5e818bcea 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { ModernVideoExporter } from "./modernVideoExporter"; const mocks = vi.hoisted(() => { const videoInfo = { @@ -73,7 +74,6 @@ describe("ModernVideoExporter native fallback routing", () => { }); it("falls back to WebCodecs instead of surfacing a native error when Breeze is unavailable", async () => { - const { ModernVideoExporter } = await import("./modernVideoExporter"); const exporter = new ModernVideoExporter({ videoUrl: "file:///recording.mp4", width: 1920, @@ -116,5 +116,5 @@ describe("ModernVideoExporter native fallback routing", () => { expect(result.blob).toBeInstanceOf(Blob); expect(initializeEncoder).toHaveBeenCalledTimes(1); expect(mocks.muxerFinalize).toHaveBeenCalledTimes(1); - }, 15_000); + }, 30_000); }); diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 2d5d3c414..aee1b51d2 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -149,6 +149,21 @@ describe("ModernVideoExporter native static-layout eligibility", () => { }); }); + it("mixes companion sidecar audio when the source MP4 also has an audio track", () => { + const videoPath = "C:\\recordly\\recording.mp4"; + const micPath = "C:\\recordly\\recording.mic.wav"; + const exporter = createExporter({ + videoUrl: `file:///${videoPath.replace(/\\/g, "/")}`, + sourceAudioFallbackPaths: [micPath], + }); + + expect(exporter.buildNativeAudioPlan(videoInfo)).toMatchObject({ + audioMode: "edited-track", + strategy: "offline-render-fallback", + sourceAudioFallbackPaths: [expect.stringMatching(/recording\.mp4$/), micPath], + }); + }); + it("keeps timed companion audio on the offline render path", () => { const audioPath = "C:\\recordly\\recording.system.wav"; const speedRegions: SpeedRegion[] = [ @@ -167,9 +182,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => { audioCodec: undefined, audioSampleRate: undefined, }), - ).toEqual({ + ).toMatchObject({ audioMode: "edited-track", strategy: "offline-render-fallback", + sourceAudioFallbackPaths: [audioPath], }); }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 55f47923e..f4b50343c 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -8,8 +8,8 @@ import type { CursorStyle, CursorTelemetryPoint, Padding, - SpeedRegion, SourceAudioTrackSettings, + SpeedRegion, TrimRegion, WebcamOverlaySettings, ZoomMotionBlurTuning, @@ -49,7 +49,12 @@ import { isVideoWallpaperSource, } from "@/lib/wallpapers"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; -import { normalizeLightningRuntimePlatform, shouldPreferNativeAutoBackend } from "./backendPolicy"; +import { + normalizeLightningRuntimePlatform, + planLightningExportRoutes, + shouldPreferNativeAutoBackend, + shouldPreferNativeStaticLayoutBeforeBreeze, +} from "./backendPolicy"; import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy"; import { type ExportBackpressureProfile, @@ -74,6 +79,7 @@ import { import { VideoMuxer } from "./muxer"; import { roundNativeStaticLayoutContentSize } from "./nativeStaticLayoutGeometry"; import { buildNativeStaticLayoutCursorTelemetry } from "./nativeStaticLayoutTelemetry"; +import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback"; import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder"; import type { ExportConfig, @@ -160,6 +166,7 @@ type NativeAudioPlan = | { audioMode: "edited-track"; strategy: "offline-render-fallback"; + sourceAudioFallbackPaths?: string[]; } | { audioMode: "edited-track"; @@ -358,13 +365,18 @@ export class ModernVideoExporter { this.totalExportStartTimeMs = this.getNowMs(); const backendPreference = this.config.backendPreference ?? "auto"; const runtimePlatform = this.getRuntimePlatform(); + const preferNativeStaticLayoutBeforeBreeze = shouldPreferNativeStaticLayoutBeforeBreeze( + runtimePlatform, + backendPreference, + ); let useNativeEncoder = false; let triedNativeStaticLayoutWithProbe = false; - let shouldDeferNativeEncoderStart = backendPreference === "breeze"; + let shouldDeferNativeEncoderStart = + backendPreference === "breeze" || preferNativeStaticLayoutBeforeBreeze; this.lastNativeExportError = null; let stageStartedAt = this.getNowMs(); - if (backendPreference === "breeze") { + if (backendPreference === "breeze" || preferNativeStaticLayoutBeforeBreeze) { // Defer the streaming native encoder until after metadata is known. // Static-layout exports can then use the faster Windows D3D compositor // instead of unnecessarily rendering every frame through JS first. @@ -1147,6 +1159,26 @@ export class ModernVideoExporter { return buildNativeStaticLayoutTimelineSegments(sourceSegments); } + private getNativeAudioFallbackPaths(videoInfo: DecodedVideoInfo): string[] { + const sourceAudioFallbackPaths = (this.config.sourceAudioFallbackPaths ?? []).filter( + (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, + ); + const localVideoSourcePath = this.getNativeVideoSourcePath(); + if (!videoInfo.hasAudio || !localVideoSourcePath) { + return sourceAudioFallbackPaths; + } + + const { externalAudioPaths } = resolveSourceAudioFallbackPaths( + localVideoSourcePath, + sourceAudioFallbackPaths, + ); + if (externalAudioPaths.length === 0) { + return sourceAudioFallbackPaths; + } + + return [localVideoSourcePath, ...externalAudioPaths]; + } + private shouldUseNativeStaticLayoutTimelineMap( videoInfo: DecodedVideoInfo, effectiveDurationSec: number, @@ -1166,9 +1198,7 @@ export class ModernVideoExporter { private buildNativeAudioPlan(videoInfo: DecodedVideoInfo): NativeAudioPlan { const speedRegions = this.config.speedRegions ?? []; const audioRegions = this.config.audioRegions ?? []; - const sourceAudioFallbackPaths = (this.config.sourceAudioFallbackPaths ?? []).filter( - (audioPath) => typeof audioPath === "string" && audioPath.trim().length > 0, - ); + const sourceAudioFallbackPaths = this.getNativeAudioFallbackPaths(videoInfo); const hasTimedSourceAudioFallback = sourceAudioFallbackPaths.some( (audioPath) => (this.config.sourceAudioFallbackStartDelayMsByPath?.[audioPath] ?? 0) > 0, @@ -1261,6 +1291,7 @@ export class ModernVideoExporter { return { audioMode: "edited-track", strategy: "offline-render-fallback", + sourceAudioFallbackPaths, }; } @@ -1268,6 +1299,7 @@ export class ModernVideoExporter { return { audioMode: "edited-track", strategy: "offline-render-fallback", + sourceAudioFallbackPaths, }; } @@ -1815,6 +1847,7 @@ export class ModernVideoExporter { private async renderEditedAudioForNativeMux( description: string, onProgress: (progress: number) => void, + sourceAudioFallbackPaths = this.config.sourceAudioFallbackPaths, ) { this.audioProcessor = new AudioProcessor(); this.audioProcessor.setOnProgress(onProgress); @@ -1825,7 +1858,7 @@ export class ModernVideoExporter { this.config.trimRegions, this.config.speedRegions, this.config.audioRegions, - this.config.sourceAudioFallbackPaths, + sourceAudioFallbackPaths, this.config.sourceAudioFallbackStartDelayMsByPath, this.config.sourceAudioTrackSettings, this.config.clipRegions, @@ -1873,6 +1906,7 @@ export class ModernVideoExporter { "Native static-layout edited audio rendering", (progress) => this.reportProgress(0, totalFrames, "preparing", undefined, progress), + audioPlan.sourceAudioFallbackPaths, ); return { @@ -2145,6 +2179,26 @@ export class ModernVideoExporter { const skipReasons = skipReason ? this.getNativeStaticLayoutSkipReasons(audioPlan, videoInfo, effectiveDuration) : []; + const routePlan = planLightningExportRoutes({ + backendPreference: this.config.backendPreference ?? "auto", + platform: this.getRuntimePlatform(), + nativeStaticLayoutAvailable: true, + nativeStaticLayoutSkipReasons: skipReasons, + }); + console.info("[VideoExporter] Lightning route plan", { + selectedRoute: routePlan.selectedRoute, + decisions: routePlan.decisions, + audioMode: audioPlan.audioMode, + sourceCodec: videoInfo.codec, + sourceHasAudio: videoInfo.hasAudio, + width: this.config.width, + height: this.config.height, + frameRate: this.config.frameRate, + zoomRegions: this.config.zoomRegions?.length ?? 0, + speedRegions: this.config.speedRegions?.length ?? 0, + audioRegions: this.config.audioRegions?.length ?? 0, + experimentalNativeExport: this.config.experimentalNativeExport === true, + }); if (skipReason) { this.nativeStaticLayoutSkipReason = skipReason; this.nativeStaticLayoutSkipReasons = skipReasons; @@ -2658,6 +2712,7 @@ export class ModernVideoExporter { const renderedAudio = await this.renderEditedAudioForNativeMux( `${NATIVE_EXPORT_ENGINE_NAME} edited audio rendering`, (progress) => this.reportFinalizingProgress(this.processedFrameCount, 99, progress), + audioPlan.sourceAudioFallbackPaths, ); editedAudioBuffer = renderedAudio.editedAudioData; editedAudioMimeType = renderedAudio.editedAudioMimeType; @@ -2755,6 +2810,7 @@ export class ModernVideoExporter { const renderedAudio = await this.renderEditedAudioForNativeMux( "FFmpeg edited audio rendering", (progress) => this.reportFinalizingProgress(this.processedFrameCount, 99, progress), + audioPlan.sourceAudioFallbackPaths, ); editedAudioBuffer = renderedAudio.editedAudioData; editedAudioMimeType = renderedAudio.editedAudioMimeType;