From 6cca431a76a4a4ccf558ef4ade462e53983e3760 Mon Sep 17 00:00:00 2001 From: rob_otman <11605437+Rob-Otman@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:12:13 -0700 Subject: [PATCH 1/4] feat: add NVENC hardware encoding support for FFmpeg Add FFMPEG_PREFER_HARDWARE env var to enable hardware acceleration Use h264_nvenc and hevc_nvenc encoders when hardware preferred Fall back to software encoders (libx264/libx265) when disabled Add comprehensive tests for hardware/software encoding modes Update README with new environment variable documentation This enables GPU-accelerated video encoding for better performance on systems with NVIDIA GPUs. --- README.md | 1 + src/converters/ffmpeg.ts | 16 +++++++-- tests/converters/ffmpeg.test.ts | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d1fa9e..7a04fe9 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ All are optional, JWT_SECRET is recommended to be set. | WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" | | FFMPEG_ARGS | | Arguments to pass to the input file of ffmpeg, e.g. `-hwaccel vaapi`. See https://github.com/C4illin/ConvertX/issues/190 for more info about hw-acceleration. | | FFMPEG_OUTPUT_ARGS | | Arguments to pass to the output of ffmpeg, e.g. `-preset veryfast` | +| FFMPEG_PREFER_HARDWARE | false | Use hardware encoders (NVENC, VAAPI, etc.) when available instead of software encoders for h264/h265 formats | | HIDE_HISTORY | false | Hide the history page | | LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) | | UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users | diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 8418e2d..454c633 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -712,15 +712,27 @@ export async function convert( const split = convertTo.split("."); const codec_short = split[0]; + // Check if hardware encoding is preferred (NVENC, VAAPI, etc.) + const preferHardware = process.env.FFMPEG_PREFER_HARDWARE === "true" || + process.env.FFMPEG_PREFER_HARDWARE === "1"; + switch (codec_short) { case "av1": extraArgs.push("-c:v", "libaom-av1"); break; case "h264": - extraArgs.push("-c:v", "libx264"); + if (preferHardware) { + extraArgs.push("-c:v", "h264_nvenc"); + } else { + extraArgs.push("-c:v", "libx264"); + } break; case "h265": - extraArgs.push("-c:v", "libx265"); + if (preferHardware) { + extraArgs.push("-c:v", "hevc_nvenc"); + } else { + extraArgs.push("-c:v", "libx265"); + } break; case "h266": extraArgs.push("-c:v", "libx266"); diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index 6f0afc6..e190892 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -121,6 +121,66 @@ test("uses libx266 for h266.mp4", async () => { expect(loggedMessage).toBe("stdout: Fake stdout"); }); +test("uses h264_nvenc for h264.mp4 when hardware preferred", async () => { + process.env.FFMPEG_PREFER_HARDWARE = "true"; + + const originalConsoleLog = console.log; + + let loggedMessage = ""; + console.log = (msg) => { + loggedMessage = msg; + }; + + await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile); + + console.log = originalConsoleLog; + + expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "h264_nvenc"])); + expect(loggedMessage).toBe("stdout: Fake stdout"); + + delete process.env.FFMPEG_PREFER_HARDWARE; +}); + +test("uses hevc_nvenc for h265.mp4 when hardware preferred", async () => { + process.env.FFMPEG_PREFER_HARDWARE = "true"; + + const originalConsoleLog = console.log; + + let loggedMessage = ""; + console.log = (msg) => { + loggedMessage = msg; + }; + + await convert("in.mkv", "mkv", "h265.mp4", "out.mp4", undefined, mockExecFile); + + console.log = originalConsoleLog; + + expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "hevc_nvenc"])); + expect(loggedMessage).toBe("stdout: Fake stdout"); + + delete process.env.FFMPEG_PREFER_HARDWARE; +}); + +test("uses libx264 for h264.mp4 when hardware not preferred", async () => { + process.env.FFMPEG_PREFER_HARDWARE = "false"; + + const originalConsoleLog = console.log; + + let loggedMessage = ""; + console.log = (msg) => { + loggedMessage = msg; + }; + + await convert("in.mkv", "mkv", "h264.mp4", "out.mp4", undefined, mockExecFile); + + console.log = originalConsoleLog; + + expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "libx264"])); + expect(loggedMessage).toBe("stdout: Fake stdout"); + + delete process.env.FFMPEG_PREFER_HARDWARE; +}); + test("respects FFMPEG_ARGS", async () => { process.env.FFMPEG_ARGS = "-hide_banner -y"; From f2504519f061db1439986200fc221df4fb476729 Mon Sep 17 00:00:00 2001 From: rob_otman <11605437+Rob-Otman@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:53:20 -0700 Subject: [PATCH 2/4] feat: add CUDA hardware acceleration with ffprobe codec detection - Detect video codec using ffprobe instead of file extensions - Auto-enable CUDA hwaccel for supported codecs when FFMPEG_PREFER_HARDWARE=true - Skip probing for image formats to optimize performance - Add comprehensive tests for hardware acceleration logic --- README.md | 30 +++++----- src/converters/ffmpeg.ts | 101 ++++++++++++++++++++++++++++++-- tests/converters/ffmpeg.test.ts | 100 ++++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7a04fe9..6bbe932 100644 --- a/README.md +++ b/README.md @@ -85,21 +85,21 @@ If you get unable to open database file run `chown -R $USER:$USER path` on the p All are optional, JWT_SECRET is recommended to be set. -| Name | Default | Description | -| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| JWT_SECRET | when unset it will use the value from randomUUID() | A long and secret string used to sign the JSON Web Token | -| ACCOUNT_REGISTRATION | false | Allow users to register accounts | -| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally | -| ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally | -| AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable | -| WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" | -| FFMPEG_ARGS | | Arguments to pass to the input file of ffmpeg, e.g. `-hwaccel vaapi`. See https://github.com/C4illin/ConvertX/issues/190 for more info about hw-acceleration. | -| FFMPEG_OUTPUT_ARGS | | Arguments to pass to the output of ffmpeg, e.g. `-preset veryfast` | -| FFMPEG_PREFER_HARDWARE | false | Use hardware encoders (NVENC, VAAPI, etc.) when available instead of software encoders for h264/h265 formats | -| HIDE_HISTORY | false | Hide the history page | -| LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) | -| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users | -| MAX_CONVERT_PROCESS | 0 | Maximum number of concurrent conversion processes allowed. Set to 0 for unlimited. | +| Name | Default | Description | +| ---------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| JWT_SECRET | when unset it will use the value from randomUUID() | A long and secret string used to sign the JSON Web Token | +| ACCOUNT_REGISTRATION | false | Allow users to register accounts | +| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally | +| ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally | +| AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable | +| WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" | +| FFMPEG_ARGS | | Arguments to pass to the input file of ffmpeg, e.g. `-hwaccel vaapi`. See https://github.com/C4illin/ConvertX/issues/190 for more info about hw-acceleration. | +| FFMPEG_OUTPUT_ARGS | | Arguments to pass to the output of ffmpeg, e.g. `-preset veryfast` | +| FFMPEG_PREFER_HARDWARE | false | Use hardware encoders (NVENC, VAAPI, etc.) when available instead of software encoders for h264/h265 formats. Also enables CUDA hardware acceleration for video input decoding (not applied to image formats). | +| HIDE_HISTORY | false | Hide the history page | +| LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) | +| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users | +| MAX_CONVERT_PROCESS | 0 | Maximum number of concurrent conversion processes allowed. Set to 0 for unlimited. | ### Docker images diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 454c633..8edf7f1 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -687,6 +687,87 @@ export const properties = { }, }; +// CUDA-supported codec names (as detected by ffprobe) +const cudaSupportedCodecs = new Set(["h264", "hevc", "vp9", "vp8", "mpeg2video", "mpeg4", "av1"]); + +// Known image formats that should skip ffprobe (no video codec to detect) +const imageFormats = new Set([ + "jpg", + "jpeg", + "png", + "gif", + "bmp", + "webp", + "ico", + "tiff", + "tif", + "svg", + "avif", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "orf", + "sr2", + "arw", + "dng", + "psd", + "xcf", + "exr", + "hdr", +]); + +/** + * 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). + * Falls back to false if probing fails (safe default). + */ +async function isCudaSupportedCodec( + filePath: string, + fileType: string, + execFile: ExecFileFn = execFileOriginal, +): Promise { + // Skip ffprobe for known image formats (no video codec to detect) + if (imageFormats.has(fileType.toLowerCase())) { + return false; + } + + try { + // Wrap execFile callback in a Promise for async/await + const stdout = await new Promise((resolve, reject) => { + execFile( + "ffprobe", + ["-v", "quiet", "-print_format", "json", "-show_streams", filePath], + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); + + const probeData = JSON.parse(stdout); + const videoStream = probeData.streams?.find( + (s: { codec_type?: string }) => s.codec_type === "video", + ); + + if (!videoStream || !videoStream.codec_name) { + return false; + } + + const codecName = videoStream.codec_name.toLowerCase(); + return cudaSupportedCodecs.has(codecName); + } catch (error) { + // If probing fails, fall back to conservative approach (no CUDA) + console.warn(`Failed to probe codec for ${filePath}:`, error); + return false; + } +} + export async function convert( filePath: string, fileType: string, @@ -698,6 +779,10 @@ export async function convert( let extraArgs: string[] = []; let message = "Done"; + // Check if hardware encoding is preferred (NVENC, VAAPI, etc.) + const preferHardware = + process.env.FFMPEG_PREFER_HARDWARE === "true" || process.env.FFMPEG_PREFER_HARDWARE === "1"; + if (convertTo === "ico") { // Make sure image is 256x256 or smaller extraArgs = [ @@ -712,10 +797,6 @@ export async function convert( const split = convertTo.split("."); const codec_short = split[0]; - // Check if hardware encoding is preferred (NVENC, VAAPI, etc.) - const preferHardware = process.env.FFMPEG_PREFER_HARDWARE === "true" || - process.env.FFMPEG_PREFER_HARDWARE === "1"; - switch (codec_short) { case "av1": extraArgs.push("-c:v", "libaom-av1"); @@ -742,6 +823,18 @@ export async function convert( // Parse FFMPEG_ARGS environment variable into array const ffmpegArgs = process.env.FFMPEG_ARGS ? process.env.FFMPEG_ARGS.split(/\s+/) : []; + + // If hardware is preferred, check if the codec supports CUDA hardware acceleration + // This only applies if FFMPEG_ARGS doesn't already specify a hardware accelerator + const hasHardwareAccel = ffmpegArgs.includes("-hwaccel"); + + if (preferHardware && !hasHardwareAccel) { + const supportsCuda = await isCudaSupportedCodec(filePath, fileType, execFile); + if (supportsCuda) { + ffmpegArgs.push("-hwaccel", "cuda"); + } + } + const ffmpegOutputArgs = process.env.FFMPEG_OUTPUT_ARGS ? process.env.FFMPEG_OUTPUT_ARGS.split(/\s+/) : []; diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index e190892..73ced1b 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -11,6 +11,33 @@ function mockExecFile( calls.push(args); if (args.includes("fail.mov")) { callback(new Error("mock failure"), "", "Fake stderr: fail"); + } else if (_cmd === "ffprobe") { + // Mock ffprobe responses for codec detection + // Return H.264 codec for .mp4 files, no video stream for images + if (args.includes("in.mp4") || args.includes("in.mkv") || args.includes("in.avi")) { + callback(null, JSON.stringify({ + streams: [{ + codec_type: "video", + codec_name: "h264", + }], + }), ""); + } else if (args.includes("in.jpg") || args.includes("in.png")) { + // Image files have no video stream + callback(null, JSON.stringify({ + streams: [{ + codec_type: "audio", + codec_name: "pcm", + }], + }), ""); + } else { + // Default: assume H.264 for video files + callback(null, JSON.stringify({ + streams: [{ + codec_type: "video", + codec_name: "h264", + }], + }), ""); + } } else { callback(null, "Fake stdout", ""); } @@ -135,7 +162,8 @@ test("uses h264_nvenc for h264.mp4 when hardware preferred", async () => { console.log = originalConsoleLog; - expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "h264_nvenc"])); + // calls[0] is ffprobe, calls[1] is ffmpeg + expect(calls[1]).toEqual(expect.arrayContaining(["-c:v", "h264_nvenc"])); expect(loggedMessage).toBe("stdout: Fake stdout"); delete process.env.FFMPEG_PREFER_HARDWARE; @@ -155,7 +183,8 @@ test("uses hevc_nvenc for h265.mp4 when hardware preferred", async () => { console.log = originalConsoleLog; - expect(calls[0]).toEqual(expect.arrayContaining(["-c:v", "hevc_nvenc"])); + // calls[0] is ffprobe, calls[1] is ffmpeg + expect(calls[1]).toEqual(expect.arrayContaining(["-c:v", "hevc_nvenc"])); expect(loggedMessage).toBe("stdout: Fake stdout"); delete process.env.FFMPEG_PREFER_HARDWARE; @@ -181,6 +210,73 @@ test("uses libx264 for h264.mp4 when hardware not preferred", async () => { delete process.env.FFMPEG_PREFER_HARDWARE; }); +test("adds CUDA hwaccel for video input when hardware preferred", async () => { + process.env.FFMPEG_PREFER_HARDWARE = "true"; + delete process.env.FFMPEG_ARGS; + + const originalConsoleLog = console.log; + + let loggedMessage = ""; + console.log = (msg) => { + loggedMessage = msg; + }; + + await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile); + + console.log = originalConsoleLog; + + // calls[0] is ffprobe, calls[1] is ffmpeg + expect(calls[1]).toEqual(expect.arrayContaining(["-hwaccel", "cuda"])); + expect(loggedMessage).toBe("stdout: Fake stdout"); + + delete process.env.FFMPEG_PREFER_HARDWARE; +}); + +test("does not add CUDA hwaccel for image input when hardware preferred", async () => { + process.env.FFMPEG_PREFER_HARDWARE = "true"; + delete process.env.FFMPEG_ARGS; + + const originalConsoleLog = console.log; + + let loggedMessage = ""; + console.log = (msg) => { + loggedMessage = msg; + }; + + await convert("in.jpg", "jpg", "png", "out.png", undefined, mockExecFile); + + console.log = originalConsoleLog; + + expect(calls[0]).not.toEqual(expect.arrayContaining(["-hwaccel", "cuda"])); + expect(loggedMessage).toBe("stdout: Fake stdout"); + + delete process.env.FFMPEG_PREFER_HARDWARE; +}); + +test("does not add CUDA hwaccel if FFMPEG_ARGS already specifies hwaccel", async () => { + process.env.FFMPEG_PREFER_HARDWARE = "true"; + process.env.FFMPEG_ARGS = "-hwaccel vaapi"; + + const originalConsoleLog = console.log; + + let loggedMessage = ""; + console.log = (msg) => { + loggedMessage = msg; + }; + + await convert("in.mp4", "mp4", "avi", "out.avi", undefined, mockExecFile); + + console.log = originalConsoleLog; + + // Should use vaapi from FFMPEG_ARGS, not add cuda + expect(calls[0]).toEqual(expect.arrayContaining(["-hwaccel", "vaapi"])); + expect(calls[0]).not.toEqual(expect.arrayContaining(["-hwaccel", "cuda"])); + expect(loggedMessage).toBe("stdout: Fake stdout"); + + delete process.env.FFMPEG_PREFER_HARDWARE; + delete process.env.FFMPEG_ARGS; +}); + test("respects FFMPEG_ARGS", async () => { process.env.FFMPEG_ARGS = "-hide_banner -y"; From db3acff4f76457830db9dc7fef91b8fd866ca73c Mon Sep 17 00:00:00 2001 From: rob_otman <11605437+Rob-Otman@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:07:21 -0700 Subject: [PATCH 3/4] 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. --- src/converters/ffmpeg.ts | 70 ++++++++++++++++++++++++++++++++- tests/converters/ffmpeg.test.ts | 24 +++++++---- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index 8edf7f1..a33acc0 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -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 { + // Cache the result to avoid repeated checks + if (nvidiaGpuAvailable !== null) { + return nvidiaGpuAvailable; + } + + try { + await new Promise((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 { // 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"); } } diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index 73ced1b..f4aac26 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -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"); From 00931098c1b9ccda8912f3046899caa5b2717324 Mon Sep 17 00:00:00 2001 From: rob_otman <11605437+Rob-Otman@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:05:25 -0700 Subject: [PATCH 4/4] fix: ensure GPU availability is checked before using hardware acceleration - Check both preferHardware AND gpuAvailable before using NVENC codecs (h264_nvenc, hevc_nvenc) - Check gpuAvailable before adding CUDA hwaccel flag for input decoding - Fix test assertion to check correct call index for CUDA hwaccel verification This prevents hardware acceleration attempts when GPU is unavailable, addressing code review feedback. --- src/converters/ffmpeg.ts | 6 +++--- tests/converters/ffmpeg.test.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/converters/ffmpeg.ts b/src/converters/ffmpeg.ts index a33acc0..e2142b7 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -867,14 +867,14 @@ export async function convert( extraArgs.push("-c:v", "libaom-av1"); break; case "h264": - if (preferHardware) { + if (preferHardware && gpuAvailable) { extraArgs.push("-c:v", "h264_nvenc"); } else { extraArgs.push("-c:v", "libx264"); } break; case "h265": - if (preferHardware) { + if (preferHardware && gpuAvailable) { extraArgs.push("-c:v", "hevc_nvenc"); } else { extraArgs.push("-c:v", "libx265"); @@ -893,7 +893,7 @@ export async function convert( // This only applies if FFMPEG_ARGS doesn't already specify a hardware accelerator const hasHardwareAccel = ffmpegArgs.includes("-hwaccel"); - if (preferHardware && !hasHardwareAccel) { + if (preferHardware && gpuAvailable && !hasHardwareAccel) { const supportsCuda = await isCudaSupportedCodec(filePath, fileType, execFile); if (supportsCuda) { ffmpegArgs.push("-hwaccel", "cuda"); diff --git a/tests/converters/ffmpeg.test.ts b/tests/converters/ffmpeg.test.ts index f4aac26..5a03564 100644 --- a/tests/converters/ffmpeg.test.ts +++ b/tests/converters/ffmpeg.test.ts @@ -253,7 +253,8 @@ test("does not add CUDA hwaccel for image input when hardware preferred", async console.log = originalConsoleLog; - expect(calls[0]).not.toEqual(expect.arrayContaining(["-hwaccel", "cuda"])); + // calls[0] is nvidia-smi, calls[1] is ffprobe, calls[2] is ffmpeg + expect(calls[2]).not.toEqual(expect.arrayContaining(["-hwaccel", "cuda"])); expect(loggedMessage).toBe("stdout: Fake stdout"); delete process.env.FFMPEG_PREFER_HARDWARE;