From f2d9ab73b01597eedaa2f65c4b6ecd7690fca4b1 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Tue, 12 May 2026 20:33:07 +0700 Subject: [PATCH] Route Windows Lightning exports through NVIDIA native planner --- electron/ipc/export/native-video.test.ts | 73 +++++ electron/ipc/export/native-video.ts | 400 ++++++++++++++++++++--- src/lib/exporter/backendPolicy.test.ts | 52 +++ src/lib/exporter/backendPolicy.ts | 107 +++++- src/lib/exporter/modernVideoExporter.ts | 38 ++- 5 files changed, 624 insertions(+), 46 deletions(-) diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 6a1f18f2..4cd3048e 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,50 @@ 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 originalValue = process.env[envName]; + delete process.env[envName]; + + 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; + } + } + }); + + 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({ diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index 44ba69a1..3b425889 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; @@ -1866,6 +1927,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 +2077,178 @@ export function getNativeGpuCompositorStallTimeoutMs() { export async function getExperimentalNvidiaCudaExportSkipReason( options: NativeStaticLayoutExportOptions, ) { - if (process.platform !== "win32") { - return "not-windows"; - } + return (await probeExperimentalNvidiaCudaExportCapability(options)).skipReason; +} + +export async function probeExperimentalNvidiaCudaExportCapability( + options: NativeStaticLayoutExportOptions, +): Promise { const explicitCuda = isExplicitNvidiaCudaExportEnabled(); - const packagedAutoCandidate = isPackagedNvidiaCudaExportAutoCandidateEnabled(); - if (!explicitCuda && !packagedAutoCandidate) { - return "env-disabled"; - } - if (!options.experimentalWindowsGpuCompositor) { - return "windows-gpu-compositor-disabled"; - } - - if (packagedAutoCandidate && !explicitCuda) { - if (!(await resolveExperimentalNvidiaCudaExportScriptPath())) { - return "cuda-wrapper-unavailable"; - } - if (!(await hasNvidiaGpuForCudaExportCandidate())) { - return "nvidia-gpu-unavailable"; - } - } - - return getNvidiaCudaAudioExportSkipReason(options.audioOptions?.audioMode, { + 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: - packagedAutoCandidate || isNvidiaCudaForceVideoOnlyEnabled(), + packagedAutoCandidateEnabled || isNvidiaCudaForceVideoOnlyEnabled(), }); + let skipReason: string | null = null; + + if (process.platform !== "win32") { + 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; + } + + 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"; + } + + 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, + }, + }; + } + + 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, + }, + }; + } + + 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 +2385,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 +3436,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 +3566,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 +3597,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 +3632,12 @@ export async function exportNativeStaticLayoutVideo( }; } } + if ( + !shouldTryNvidiaCuda && + nativeRoutePlan.selectedRoute === "nvidia-cuda-compositor" + ) { + windowsD3D11FallbackReason = "nvidia-cuda-cursor-assets-unavailable"; + } if (shouldTryNvidiaCuda) { try { @@ -3439,6 +3722,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 +3734,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, @@ -3464,6 +3762,12 @@ export async function exportNativeStaticLayoutVideo( ); } await validateRenderedVideoOutput(); + 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 +3778,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, @@ -3496,9 +3808,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) { diff --git a/src/lib/exporter/backendPolicy.test.ts b/src/lib/exporter/backendPolicy.test.ts index 7c532a18..9a33701c 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 60f4f509..85be5209 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.ts b/src/lib/exporter/modernVideoExporter.ts index 55f47923..582de0c6 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, @@ -358,13 +363,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. @@ -2145,6 +2155,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;