feat: add NVIDIA GPU availability checking and hardware acceleration logging

- Add `checkNvidiaGpuAvailable()` function using nvidia-smi to detect GPU presence
- Add comprehensive logging for hardware acceleration decisions:
  - GPU detection status
  - Hardware vs software encoding/decoding choices
  - CUDA codec support detection
  - Encoder selection decisions
- Cache GPU availability results to avoid repeated checks
- Add `resetNvidiaGpuCache()` for testing
- Update tests to handle GPU availability mocking

This provides visibility into hardware acceleration decisions and ensures CUDA is only attempted when NVIDIA GPU hardware is actually available.
This commit is contained in:
rob_otman 2025-12-17 15:07:21 -07:00
parent f2504519f0
commit db3acff4f7
2 changed files with 85 additions and 9 deletions

View file

@ -719,6 +719,51 @@ const imageFormats = new Set([
"hdr",
]);
// Cache NVIDIA GPU availability to avoid repeated checks
let nvidiaGpuAvailable: boolean | null = null;
// Export for testing (allows resetting cache between tests)
export const resetNvidiaGpuCache = () => {
nvidiaGpuAvailable = null;
};
/**
* Checks if an NVIDIA GPU is available using nvidia-smi.
* Returns false if no GPU is available or nvidia-smi fails.
*/
async function checkNvidiaGpuAvailable(execFile: ExecFileFn = execFileOriginal): Promise<boolean> {
// Cache the result to avoid repeated checks
if (nvidiaGpuAvailable !== null) {
return nvidiaGpuAvailable;
}
try {
await new Promise<void>((resolve, reject) => {
execFile(
"nvidia-smi",
["-L"], // List GPUs (simple check that succeeds if GPU is available)
(error) => {
if (error) {
reject(error);
return;
}
resolve();
},
);
});
// If nvidia-smi succeeds, GPU is available
nvidiaGpuAvailable = true;
console.log("NVIDIA GPU detected - hardware acceleration available");
return true;
} catch (error) {
// nvidia-smi failed - no GPU available or not installed
console.warn("NVIDIA GPU not available - using software encoding/decoding:", error);
nvidiaGpuAvailable = false;
return false;
}
}
/**
* Uses ffprobe to detect if the video codec in a file is supported by CUDA hardware acceleration.
* Returns false for image formats without probing (performance optimization).
@ -731,6 +776,7 @@ async function isCudaSupportedCodec(
): Promise<boolean> {
// Skip ffprobe for known image formats (no video codec to detect)
if (imageFormats.has(fileType.toLowerCase())) {
console.log(`Skipping CUDA detection for image format: ${fileType}`);
return false;
}
@ -760,7 +806,15 @@ async function isCudaSupportedCodec(
}
const codecName = videoStream.codec_name.toLowerCase();
return cudaSupportedCodecs.has(codecName);
const isSupported = cudaSupportedCodecs.has(codecName);
if (isSupported) {
console.log(`CUDA-supported codec detected: ${codecName} in ${filePath}`);
} else {
console.log(
`Codec not CUDA-supported: ${codecName} in ${filePath} - using software decoding`,
);
}
return isSupported;
} catch (error) {
// If probing fails, fall back to conservative approach (no CUDA)
console.warn(`Failed to probe codec for ${filePath}:`, error);
@ -783,6 +837,17 @@ export async function convert(
const preferHardware =
process.env.FFMPEG_PREFER_HARDWARE === "true" || process.env.FFMPEG_PREFER_HARDWARE === "1";
// Check GPU availability if hardware is preferred
const gpuAvailable = preferHardware ? await checkNvidiaGpuAvailable(execFile) : false;
if (preferHardware && gpuAvailable) {
console.log("Hardware acceleration enabled for conversion");
} else if (preferHardware && !gpuAvailable) {
console.log("Hardware acceleration requested but GPU not available - using software");
} else {
console.log("Using software encoding/decoding (hardware not preferred)");
}
if (convertTo === "ico") {
// Make sure image is 256x256 or smaller
extraArgs = [
@ -832,6 +897,9 @@ export async function convert(
const supportsCuda = await isCudaSupportedCodec(filePath, fileType, execFile);
if (supportsCuda) {
ffmpegArgs.push("-hwaccel", "cuda");
console.log("Added CUDA hardware acceleration for input decoding");
} else {
console.log("CUDA not supported for input file - using software decoding");
}
}

View file

@ -1,5 +1,5 @@
import { beforeEach, expect, test } from "bun:test";
import { convert } from "../../src/converters/ffmpeg";
import { convert, resetNvidiaGpuCache } from "../../src/converters/ffmpeg";
let calls: string[][] = [];
@ -11,6 +11,9 @@ function mockExecFile(
calls.push(args);
if (args.includes("fail.mov")) {
callback(new Error("mock failure"), "", "Fake stderr: fail");
} else if (_cmd === "nvidia-smi") {
// Mock nvidia-smi - assume GPU is available for tests
callback(null, "GPU 0: NVIDIA GeForce RTX 3080 (UUID: GPU-12345678-1234-1234-1234-123456789012)\n", "");
} else if (_cmd === "ffprobe") {
// Mock ffprobe responses for codec detection
// Return H.264 codec for .mp4 files, no video stream for images
@ -46,6 +49,9 @@ function mockExecFile(
beforeEach(() => {
calls = [];
delete process.env.FFMPEG_ARGS;
delete process.env.FFMPEG_PREFER_HARDWARE;
// Reset the GPU availability cache between tests
resetNvidiaGpuCache();
});
test("converts a normal file", async () => {
@ -162,8 +168,8 @@ test("uses h264_nvenc for h264.mp4 when hardware preferred", async () => {
console.log = originalConsoleLog;
// calls[0] is ffprobe, calls[1] is ffmpeg
expect(calls[1]).toEqual(expect.arrayContaining(["-c:v", "h264_nvenc"]));
// calls[0] is nvidia-smi, calls[1] is ffprobe, calls[2] is ffmpeg
expect(calls[2]).toEqual(expect.arrayContaining(["-c:v", "h264_nvenc"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
delete process.env.FFMPEG_PREFER_HARDWARE;
@ -183,8 +189,8 @@ test("uses hevc_nvenc for h265.mp4 when hardware preferred", async () => {
console.log = originalConsoleLog;
// calls[0] is ffprobe, calls[1] is ffmpeg
expect(calls[1]).toEqual(expect.arrayContaining(["-c:v", "hevc_nvenc"]));
// calls[0] is nvidia-smi, calls[1] is ffprobe, calls[2] is ffmpeg
expect(calls[2]).toEqual(expect.arrayContaining(["-c:v", "hevc_nvenc"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
delete process.env.FFMPEG_PREFER_HARDWARE;
@ -225,8 +231,8 @@ test("adds CUDA hwaccel for video input when hardware preferred", async () => {
console.log = originalConsoleLog;
// calls[0] is ffprobe, calls[1] is ffmpeg
expect(calls[1]).toEqual(expect.arrayContaining(["-hwaccel", "cuda"]));
// calls[0] is nvidia-smi, calls[1] is ffprobe, calls[2] is ffmpeg
expect(calls[2]).toEqual(expect.arrayContaining(["-hwaccel", "cuda"]));
expect(loggedMessage).toBe("stdout: Fake stdout");
delete process.env.FFMPEG_PREFER_HARDWARE;
@ -268,8 +274,10 @@ test("does not add CUDA hwaccel if FFMPEG_ARGS already specifies hwaccel", async
console.log = originalConsoleLog;
// When FFMPEG_ARGS already has hwaccel, no ffprobe call is made
// calls[0] is nvidia-smi, calls[1] is ffmpeg
// Should use vaapi from FFMPEG_ARGS, not add cuda
expect(calls[0]).toEqual(expect.arrayContaining(["-hwaccel", "vaapi"]));
expect(calls[1]).toEqual(expect.arrayContaining(["-hwaccel", "vaapi"]));
expect(calls[0]).not.toEqual(expect.arrayContaining(["-hwaccel", "cuda"]));
expect(loggedMessage).toBe("stdout: Fake stdout");