Merge pull request #1 from Rob-Otman/add-hardware-encode-ffmpeg

Add hardware encode ffmpeg
This commit is contained in:
TheOperator 2025-12-17 16:09:03 -07:00 committed by GitHub
commit 53d576d2d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 356 additions and 17 deletions

View file

@ -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.
| 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 |
@ -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. 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 |

View file

@ -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<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).
* 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())) {
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<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();
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":
if (preferHardware && gpuAvailable) {
extraArgs.push("-c:v", "h264_nvenc");
} else {
extraArgs.push("-c:v", "libx264");
}
break;
case "h265":
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+/)
: [];

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