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";