Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
68 changes: 54 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,45 @@ or
docker run -p 3000:3000 -v ./data:/app/data ghcr.io/c4illin/convertx
```

### NVIDIA GPU Hardware Acceleration

For improved performance on NVIDIA GPUs with NVENC/NVDEC support, use this enhanced docker-compose configuration:

```yml
# docker-compose.yml (with NVIDIA GPU support)
services:
convertx:
image: ghcr.io/c4illin/convertx
container_name: convertx
restart: unless-stopped
ports:
- "3000:3000"
runtime: nvidia
group_add:
- 226 # The render group GID on the host (check with: getent group render | cut -d: -f3)
environment:
- JWT_SECRET=aLongAndSecretStringUsedToSignTheJSONWebToken1234
# - HTTP_ALLOWED=true # uncomment this if accessing it over a non-https connection
- FFMPEG_PREFER_HARDWARE=true # Enable hardware acceleration for video encoding/decoding
- NVIDIA_VISIBLE_DEVICES=all # Use 'all' for all GPUs, or '0,1' for specific GPUs (get IDs with: nvidia-smi -L)
- NVIDIA_DRIVER_CAPABILITIES=all # Comma-separated list (e.g., 'compute,video,utility'), 'all' for everything
volumes:
- ./data:/app/data
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro # Mount nvidia-smi for GPU detection
```

**Requirements:**

- NVIDIA drivers with NVENC/NVDEC support
- nvidia-docker runtime
- The render group GID (226) may differ on your system

**Notes:**

- `group_add` may not be needed in Unraid
- `nvidia-smi` volume mount may not be needed in Unraid
- Hardware acceleration requires: `FFMPEG_PREFER_HARDWARE=true`, NVIDIA GPU detection, and supported video codecs (H.264, H.265, VP9, VP8, MPEG-2, MPEG-4, AV1)

Then visit `http://localhost:3000` in your browser and create your account. Don't leave it unconfigured and open, as anyone can register the first account.

If you get unable to open database file run `chown -R $USER:$USER path` on the path you choose.
Expand All @@ -87,20 +126,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
180 changes: 178 additions & 2 deletions src/converters/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,144 @@ export const properties = {
},
};

// CUDA-supported codec names (as detected by ffprobe)
const cudaSupportedCodecs = new Set(["h264", "hevc", "vp9", "vp8", "mpeg2video", "mpeg4", "av1"]);

// Container formats that can contain video streams with unknown codecs (run ffprobe)
const containerFormats = new Set([
// Most common/popular video containers
"avi",
"mkv",
"mov",
"mp4",
"m4v", // MPEG-4 video
"m4a", // MPEG-4 audio
"webm",
"flv",
"wmv",
"vob", // DVD video
// MPEG transport streams
"m2ts",
"ts",
"mts",
// MPEG containers
"mpeg",
"mpg",
"m2v", // MPEG-2 video
// Mobile/legacy formats
"3gp",
"3g2",
// Other containers
"nut",
"ogv", // OGG video
"asf",
"mxf", // Professional video
"f4v", // Flash video
]);

// 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.
* Only probes container formats that can contain video streams with unknown codecs.
* 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> {
// Only run ffprobe on container formats that can contain video streams
if (!containerFormats.has(fileType.toLowerCase())) {
console.log(`Skipping CUDA detection for non-container 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 +837,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 +871,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 +892,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