Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,20 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
# Uncomment the following lines for NVIDIA GPU hardware acceleration (NVENC/NVDEC)
# Requires: NVIDIA drivers with NVENC/NVDEC support + nvidia-docker runtime
# runtime: nvidia # Required: Enable NVIDIA container runtime
# group_add: # Optional: May be needed for GPU device access (not required in Unraid)
# - 226 # The render group GID on the host (check with: getent group render | cut -d: -f3)
environment:
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234 # will use randomUUID() if unset
# - HTTP_ALLOWED=true # uncomment this if accessing it over a non-https connection
# - FFMPEG_PREFER_HARDWARE=true # Optional: Enable hardware acceleration for video encoding/decoding
# - NVIDIA_VISIBLE_DEVICES=all # Optional: Defaults to 'all', use '0,1' for specific GPUs (get IDs with: nvidia-smi -L)
# - NVIDIA_DRIVER_CAPABILITIES=all # Optional: Comma-separated list (e.g., 'compute,video,utility'), 'all' for everything
volumes:
- ./data:/app/data
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro # May be needed: Mount nvidia-smi for GPU detection (not required in Unraid)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be better to add this under a separate header? I want to keep the example compose as general as possible to make it look easy. What do you think?

```

or
Expand All @@ -87,20 +96,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

Expand Down
177 changes: 175 additions & 2 deletions src/converters/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,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(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we have a list of all formats that potentially supports instead. It should only be all video container formats right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that at first but file types like .mkv can contain virtually any codec and .mp4 can contain H.264, H.265, MPEG-2, AV1 etc. so many formats we can not know without ffprobe if has a cuda supported codec.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes and then instead of skipping all image formats we skip everything that isn't a container. And we use ffprobe on mkv, mp4 etc.

Copy link
Author

@Rob-Otman Rob-Otman Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my current approach is preferable because it only skips definitively non-video formats (images) and lets ffprobe handle codec detection for all video files. File extensions can be changed/wrong but ffprobe reading the file headers is very accurate assurance that the file is supported.

I'm open to change to your approach if you want, this is just my thinking.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But FFmpeg support almost 500 file extensions and of them only a handful is a container format. I don't mean that we should remove FFprobe just changing it from an image blacklist to a container whitelist :)

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,
Expand All @@ -699,6 +834,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 = [
Expand All @@ -718,10 +868,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");
Expand All @@ -731,6 +889,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+/)
: [];
Expand Down
Loading