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
This commit is contained in:
parent
6cca431a76
commit
f2504519f0
3 changed files with 210 additions and 21 deletions
|
|
@ -86,7 +86,7 @@ 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.
|
All are optional, JWT_SECRET is recommended to be set.
|
||||||
|
|
||||||
| Name | Default | Description |
|
| 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 |
|
| 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 |
|
| ACCOUNT_REGISTRATION | false | Allow users to register accounts |
|
||||||
| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally |
|
| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally |
|
||||||
|
|
@ -95,7 +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/" |
|
| 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_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_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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users |
|
||||||
|
|
|
||||||
|
|
@ -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<boolean> {
|
||||||
|
// 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<string>((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(
|
export async function convert(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
fileType: string,
|
fileType: string,
|
||||||
|
|
@ -698,6 +779,10 @@ export async function convert(
|
||||||
let extraArgs: string[] = [];
|
let extraArgs: string[] = [];
|
||||||
let message = "Done";
|
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") {
|
if (convertTo === "ico") {
|
||||||
// Make sure image is 256x256 or smaller
|
// Make sure image is 256x256 or smaller
|
||||||
extraArgs = [
|
extraArgs = [
|
||||||
|
|
@ -712,10 +797,6 @@ export async function convert(
|
||||||
const split = convertTo.split(".");
|
const split = convertTo.split(".");
|
||||||
const codec_short = split[0];
|
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) {
|
switch (codec_short) {
|
||||||
case "av1":
|
case "av1":
|
||||||
extraArgs.push("-c:v", "libaom-av1");
|
extraArgs.push("-c:v", "libaom-av1");
|
||||||
|
|
@ -742,6 +823,18 @@ export async function convert(
|
||||||
|
|
||||||
// Parse FFMPEG_ARGS environment variable into array
|
// Parse FFMPEG_ARGS environment variable into array
|
||||||
const ffmpegArgs = process.env.FFMPEG_ARGS ? process.env.FFMPEG_ARGS.split(/\s+/) : [];
|
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
|
const ffmpegOutputArgs = process.env.FFMPEG_OUTPUT_ARGS
|
||||||
? process.env.FFMPEG_OUTPUT_ARGS.split(/\s+/)
|
? process.env.FFMPEG_OUTPUT_ARGS.split(/\s+/)
|
||||||
: [];
|
: [];
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,33 @@ function mockExecFile(
|
||||||
calls.push(args);
|
calls.push(args);
|
||||||
if (args.includes("fail.mov")) {
|
if (args.includes("fail.mov")) {
|
||||||
callback(new Error("mock failure"), "", "Fake stderr: fail");
|
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 {
|
} else {
|
||||||
callback(null, "Fake stdout", "");
|
callback(null, "Fake stdout", "");
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +162,8 @@ test("uses h264_nvenc for h264.mp4 when hardware preferred", async () => {
|
||||||
|
|
||||||
console.log = originalConsoleLog;
|
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");
|
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||||
|
|
||||||
delete process.env.FFMPEG_PREFER_HARDWARE;
|
delete process.env.FFMPEG_PREFER_HARDWARE;
|
||||||
|
|
@ -155,7 +183,8 @@ test("uses hevc_nvenc for h265.mp4 when hardware preferred", async () => {
|
||||||
|
|
||||||
console.log = originalConsoleLog;
|
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");
|
expect(loggedMessage).toBe("stdout: Fake stdout");
|
||||||
|
|
||||||
delete process.env.FFMPEG_PREFER_HARDWARE;
|
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;
|
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 () => {
|
test("respects FFMPEG_ARGS", async () => {
|
||||||
process.env.FFMPEG_ARGS = "-hide_banner -y";
|
process.env.FFMPEG_ARGS = "-hide_banner -y";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue