diff --git a/README.md b/README.md index 0d1fa9e..6bbe932 100644 --- a/README.md +++ b/README.md @@ -85,20 +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` | -| 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 8418e2d..e2142b7 100644 --- a/src/converters/ffmpeg.ts +++ b/src/converters/ffmpeg.ts @@ -687,6 +687,141 @@ 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", +]); + +// 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). + * 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())) { + console.log(`Skipping CUDA detection for image format: ${fileType}`); + 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(); + 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); + return false; + } +} + export async function convert( filePath: string, fileType: string, @@ -698,6 +833,21 @@ 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"; + + // 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 = [ @@ -717,10 +867,18 @@ export async function convert( extraArgs.push("-c:v", "libaom-av1"); break; case "h264": - extraArgs.push("-c:v", "libx264"); + if (preferHardware && gpuAvailable) { + extraArgs.push("-c:v", "h264_nvenc"); + } else { + extraArgs.push("-c:v", "libx264"); + } break; case "h265": - extraArgs.push("-c:v", "libx265"); + if (preferHardware && gpuAvailable) { + extraArgs.push("-c:v", "hevc_nvenc"); + } else { + extraArgs.push("-c:v", "libx265"); + } break; case "h266": extraArgs.push("-c:v", "libx266"); @@ -730,6 +888,21 @@ 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 && gpuAvailable && !hasHardwareAccel) { + 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"); + } + } + 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 6f0afc6..5a03564 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,36 @@ 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 + 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", ""); } @@ -19,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 () => { @@ -121,6 +154,138 @@ 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; + + // 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; +}); + +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; + + // 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; +}); + +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("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 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; +}); + +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; + + // 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; +}); + +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; + + // 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[1]).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";