Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,19 @@ interface RendererNativeVideoMetadataProbe {
audioSampleRate?: number;
}

interface RendererNativeExportCapabilities {
platform: NodeJS.Platform;
nvidiaCuda: {
available: boolean;
skipReason: string | null;
hasNvidiaGpu: boolean | null;
hasWrapper: boolean;
explicitEnabled: boolean;
explicitDisabled: boolean;
userOptInRequired: boolean;
};
}

interface Window {
electronAPI: {
hudOverlaySetIgnoreMouse: (ignore: boolean) => void;
Expand Down Expand Up @@ -329,6 +342,11 @@ interface Window {
metadata?: RendererNativeVideoMetadataProbe;
error?: string;
}>;
getNativeExportCapabilities: () => Promise<{
success: boolean;
capabilities?: RendererNativeExportCapabilities;
error?: string;
}>;
nativeStaticLayoutExport: (options: {
sessionId?: string;
inputPath: string;
Expand Down Expand Up @@ -389,6 +407,7 @@ interface Window {
}>;
chunkDurationSec?: number;
experimentalWindowsGpuCompositor?: boolean;
experimentalNvidiaCudaExport?: boolean;
audioOptions?: {
audioMode?: "none" | "copy-source" | "trim-source" | "edited-track";
audioSourcePath?: string | null;
Expand Down
46 changes: 40 additions & 6 deletions electron/ipc/export/native-video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
buildNativeVideoAudioMuxArgs,
canCopyAudioCodecIntoMp4,
getExperimentalNvidiaCudaExportSkipReason,
getNativeExportCapabilities,
getNativeGpuCompositorStallTimeoutMs,
getNativeStaticLayoutSourceProxyBitrate,
getNvidiaCudaAudioExportSkipReason,
Expand Down Expand Up @@ -307,7 +308,7 @@ describe("getNvidiaCudaAudioExportSkipReason", () => {
});

describe("getNvidiaCudaAutoStallTimeoutMs", () => {
it("only applies the stall guard to packaged auto candidates by default", () => {
it("only applies the stall guard to active validated CUDA candidates by default", () => {
expect(getNvidiaCudaAutoStallTimeoutMs(false)).toBeNull();
expect(getNvidiaCudaAutoStallTimeoutMs(true)).toBe(120_000);
});
Expand Down Expand Up @@ -396,13 +397,41 @@ describe("hasNvidiaGpuDeviceInGpuInfo", () => {
});
});

describe("getNativeExportCapabilities", () => {
it("reports NVIDIA CUDA availability when the wrapper and an NVIDIA GPU are present", async () => {
const capabilities = await withPackagedCudaCandidate(
{ gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
() => getNativeExportCapabilities(),
);

expect(capabilities.nvidiaCuda.available).toBe(process.platform === "win32");
expect(capabilities.nvidiaCuda.hasWrapper).toBe(process.platform === "win32");
expect(capabilities.nvidiaCuda.hasNvidiaGpu).toBe(process.platform === "win32" ? true : null);
});
});

describe("getExperimentalNvidiaCudaExportSkipReason", () => {
it("auto-enables packaged CUDA candidates when the helper and an NVIDIA GPU are present", async () => {
it("requires user opt-in before packaged CUDA candidates run", async () => {
const reason = await withPackagedCudaCandidate(
{ gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
() =>
getExperimentalNvidiaCudaExportSkipReason(
createNvidiaCudaSkipOptions({
audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
}),
),
);

expect(reason).toBe(process.platform === "win32" ? "env-disabled" : "not-windows");
});

it("allows user opt-in CUDA candidates when the helper and an NVIDIA GPU are present", async () => {
const reason = await withPackagedCudaCandidate(
{ gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
() =>
getExperimentalNvidiaCudaExportSkipReason(
createNvidiaCudaSkipOptions({
experimentalNvidiaCudaExport: true,
audioOptions: { audioMode: "copy-source", audioSourcePath: "input.mp4" },
}),
),
Expand Down Expand Up @@ -492,23 +521,28 @@ describe("getExperimentalNvidiaCudaExportSkipReason", () => {
}
});

it("skips packaged CUDA auto-candidates when Electron reports no NVIDIA GPU", async () => {
it("skips user opt-in CUDA candidates when Electron reports no NVIDIA GPU", async () => {
const reason = await withPackagedCudaCandidate(
{ gpuDevice: [{ vendorId: 0x8086, deviceString: "Intel UHD Graphics" }] },
() => getExperimentalNvidiaCudaExportSkipReason(createNvidiaCudaSkipOptions()),
() =>
getExperimentalNvidiaCudaExportSkipReason(
createNvidiaCudaSkipOptions({ experimentalNvidiaCudaExport: true }),
),
);

expect(reason).toBe(
process.platform === "win32" ? "nvidia-gpu-unavailable" : "not-windows",
);
});

it("lets the packaged auto-candidate be explicitly disabled", async () => {
it("lets the user opt-in candidate be explicitly disabled", async () => {
const reason = await withPackagedCudaCandidate(
{ gpuDevice: [{ vendorId: 0x10de, deviceString: "NVIDIA GeForce GTX 1650" }] },
async () => {
process.env.RECORDLY_EXPERIMENTAL_NVIDIA_CUDA_EXPORT = "0";
return getExperimentalNvidiaCudaExportSkipReason(createNvidiaCudaSkipOptions());
return getExperimentalNvidiaCudaExportSkipReason(
createNvidiaCudaSkipOptions({ experimentalNvidiaCudaExport: true }),
);
},
);

Expand Down
104 changes: 86 additions & 18 deletions electron/ipc/export/native-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export interface NativeStaticLayoutExportOptions {
timelineMapPath?: string | null;
chunkDurationSec?: number;
experimentalWindowsGpuCompositor?: boolean;
experimentalNvidiaCudaExport?: boolean;
audioOptions?: NativeVideoExportFinishOptions;
nvidiaCudaForceVideoOnly?: boolean;
}
Expand Down Expand Up @@ -923,6 +924,19 @@ function setNativeStaticLayoutExportProcessPriority(pid: number | undefined, lab

export const nativeStaticLayoutExportSessions = new Map<string, NativeStaticLayoutExportSession>();

export interface NativeExportCapabilities {
platform: NodeJS.Platform;
nvidiaCuda: {
available: boolean;
skipReason: string | null;
hasNvidiaGpu: boolean | null;
hasWrapper: boolean;
explicitEnabled: boolean;
explicitDisabled: boolean;
userOptInRequired: boolean;
};
}

export function parseFfmpegDurationSeconds(value: string): number | null {
const parts = value.trim().split(":");
if (parts.length !== 3) {
Expand Down Expand Up @@ -1856,13 +1870,18 @@ export function hasNvidiaGpuDeviceInGpuInfo(gpuInfo: unknown) {
}

async function hasNvidiaGpuForCudaExportCandidate() {
const hasNvidiaGpu = await probeNvidiaGpuForCudaExportCandidate();
return hasNvidiaGpu ?? true;
}

async function probeNvidiaGpuForCudaExportCandidate(): Promise<boolean | null> {
const getGPUInfo = (
app as typeof app & {
getGPUInfo?: (infoType: "basic") => Promise<unknown>;
}
).getGPUInfo;
if (typeof getGPUInfo !== "function") {
return true;
return null;
}

try {
Expand All @@ -1872,7 +1891,7 @@ async function hasNvidiaGpuForCudaExportCandidate() {
"[native-static-layout-export] Unable to inspect GPU info before NVIDIA CUDA export; letting the helper decide",
error,
);
return true;
return null;
}
}

Expand Down Expand Up @@ -1959,20 +1978,20 @@ function isExplicitNvidiaCudaExportDisabled() {
return process.env[NVIDIA_CUDA_EXPORT_ENV] === "0";
}

function isPackagedNvidiaCudaExportAutoCandidateEnabled() {
return app.isPackaged && !isExplicitNvidiaCudaExportDisabled();
function isUserOptedInNvidiaCudaExport(options: NativeStaticLayoutExportOptions) {
return options.experimentalNvidiaCudaExport === true && !isExplicitNvidiaCudaExportDisabled();
}

function isPackagedNvidiaCudaExportAutoCandidateActive() {
return isPackagedNvidiaCudaExportAutoCandidateEnabled() && !isExplicitNvidiaCudaExportEnabled();
function isValidatedNvidiaCudaFallbackCandidate(options: NativeStaticLayoutExportOptions) {
return isUserOptedInNvidiaCudaExport(options) && !isExplicitNvidiaCudaExportEnabled();
}

function isNvidiaCudaForceVideoOnlyEnabled() {
return process.env[NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV] === "1";
}

export function getNvidiaCudaAutoStallTimeoutMs(
autoCandidateActive = isPackagedNvidiaCudaExportAutoCandidateActive(),
autoCandidateActive = false,
) {
if (!autoCandidateActive && !isExplicitNvidiaCudaExportEnabled()) {
return null;
Expand Down Expand Up @@ -2011,17 +2030,19 @@ export async function getExperimentalNvidiaCudaExportSkipReason(
if (process.platform !== "win32") {
return "not-windows";
}
if (isExplicitNvidiaCudaExportDisabled()) {
return "env-disabled";
}
const explicitCuda = isExplicitNvidiaCudaExportEnabled();
const packagedAutoCandidateEnabled = isPackagedNvidiaCudaExportAutoCandidateEnabled();
const packagedAutoCandidateActive = isPackagedNvidiaCudaExportAutoCandidateActive();
if (!explicitCuda && !packagedAutoCandidateEnabled) {
const userOptIn = isUserOptedInNvidiaCudaExport(options);
if (!explicitCuda && !userOptIn) {
return "env-disabled";
}
if (!options.experimentalWindowsGpuCompositor) {
return "windows-gpu-compositor-disabled";
}

if (packagedAutoCandidateActive) {
if (userOptIn) {
if (!(await resolveExperimentalNvidiaCudaExportScriptPath())) {
return "cuda-wrapper-unavailable";
}
Expand All @@ -2032,10 +2053,54 @@ export async function getExperimentalNvidiaCudaExportSkipReason(

return getNvidiaCudaAudioExportSkipReason(options.audioOptions?.audioMode, {
allowValidatedFallbackCandidate:
packagedAutoCandidateActive || isNvidiaCudaForceVideoOnlyEnabled(),
isValidatedNvidiaCudaFallbackCandidate(options) || isNvidiaCudaForceVideoOnlyEnabled(),
});
}

export async function getNativeExportCapabilities(): Promise<NativeExportCapabilities> {
if (process.platform !== "win32") {
return {
platform: process.platform,
nvidiaCuda: {
available: false,
skipReason: "not-windows",
hasNvidiaGpu: null,
hasWrapper: false,
explicitEnabled: isExplicitNvidiaCudaExportEnabled(),
explicitDisabled: isExplicitNvidiaCudaExportDisabled(),
userOptInRequired: true,
},
};
}

const explicitEnabled = isExplicitNvidiaCudaExportEnabled();
const explicitDisabled = isExplicitNvidiaCudaExportDisabled();
const wrapperPath = await resolveExperimentalNvidiaCudaExportScriptPath();
const hasNvidiaGpu = await probeNvidiaGpuForCudaExportCandidate();
const skipReason = explicitDisabled
? "env-disabled"
: !wrapperPath
? "cuda-wrapper-unavailable"
: hasNvidiaGpu === false
? "nvidia-gpu-unavailable"
: hasNvidiaGpu === null
? "nvidia-gpu-probe-unavailable"
: null;

return {
platform: process.platform,
nvidiaCuda: {
available: skipReason === null,
skipReason,
hasNvidiaGpu,
hasWrapper: Boolean(wrapperPath),
explicitEnabled,
explicitDisabled,
userOptInRequired: !explicitEnabled,
},
};
}

export async function resolveExperimentalNvidiaCudaExportScriptPath() {
if (process.platform !== "win32") {
return null;
Expand Down Expand Up @@ -2750,7 +2815,9 @@ async function runExperimentalNvidiaCudaStaticLayoutExport(
const startedAt = getNowMs();
const startedAtIso = new Date().toISOString();
const timeoutMs = Math.max(20 * 60 * 1000, options.durationSec * 2000);
const stallTimeoutMs = getNvidiaCudaAutoStallTimeoutMs();
const stallTimeoutMs = getNvidiaCudaAutoStallTimeoutMs(
isValidatedNvidiaCudaFallbackCandidate(options),
);
const ffmpegDirectory = path.dirname(ffmpegPath);
const pathKey = process.platform === "win32" ? "Path" : "PATH";
const env = {
Expand Down Expand Up @@ -3307,10 +3374,11 @@ export async function exportNativeStaticLayoutVideo(
const nvidiaCudaSkipReason =
await getExperimentalNvidiaCudaExportSkipReason(options);
let shouldTryNvidiaCuda = nvidiaCudaSkipReason === null;
const validatedCudaFallbackCandidate =
isValidatedNvidiaCudaFallbackCandidate(options);
if (
shouldTryNvidiaCuda &&
(isPackagedNvidiaCudaExportAutoCandidateActive() ||
isNvidiaCudaForceVideoOnlyEnabled()) &&
(validatedCudaFallbackCandidate || isNvidiaCudaForceVideoOnlyEnabled()) &&
(experimentalNvidiaCudaOptions.audioOptions?.audioMode ?? "none") !== "none"
) {
experimentalNvidiaCudaOptions = {
Expand All @@ -3323,13 +3391,13 @@ export async function exportNativeStaticLayoutVideo(
audioMode:
experimentalNvidiaCudaOptions.audioOptions?.audioMode ?? "none",
forcedByEnv: isNvidiaCudaForceVideoOnlyEnabled(),
packagedAutoCandidate: isPackagedNvidiaCudaExportAutoCandidateActive(),
userOptIn: options.experimentalNvidiaCudaExport === true,
},
);
}
const shouldLogNvidiaCudaSkip =
isExplicitNvidiaCudaExportEnabled() ||
(isPackagedNvidiaCudaExportAutoCandidateEnabled() &&
(options.experimentalNvidiaCudaExport === true &&
nvidiaCudaSkipReason !== "env-disabled");
if (
!shouldTryNvidiaCuda &&
Expand All @@ -3342,7 +3410,7 @@ export async function exportNativeStaticLayoutVideo(
reason: nvidiaCudaSkipReason,
audioMode: options.audioOptions?.audioMode ?? "none",
overrideEnv: NVIDIA_CUDA_ALLOW_AUDIO_EXPORT_ENV,
packagedAutoCandidate: isPackagedNvidiaCudaExportAutoCandidateEnabled(),
userOptIn: options.experimentalNvidiaCudaExport === true,
},
);
}
Expand Down
16 changes: 16 additions & 0 deletions electron/ipc/register/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
enqueueNativeVideoExportFrameWrites,
exportNativeStaticLayoutVideo,
flushNativeVideoExportPendingWriteRequests,
getNativeExportCapabilities,
getNativeVideoExportMaxQueuedWriteBytes,
getNativeVideoExportSessionError,
isHardwareAcceleratedVideoEncoder,
Expand Down Expand Up @@ -413,6 +414,21 @@ export function registerExportHandlers() {
}
});

ipcMain.handle("get-native-export-capabilities", async () => {
try {
return {
success: true,
capabilities: await getNativeExportCapabilities(),
};
} catch (error) {
console.warn("[native-export-capabilities] Failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
});

ipcMain.handle(
"native-static-layout-export",
async (event, options: NativeStaticLayoutExportOptions) => {
Expand Down
Loading