fix(engine): support AMD AMF GPU encoding

This commit is contained in:
Miguel Ángel
2026-05-26 13:35:55 -04:00
parent 9a7b3efa94
commit 0e052e42d2
12 changed files with 293 additions and 24 deletions
+155 -8
View File
@@ -7,7 +7,55 @@
import { spawn } from "child_process";
export type GpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | null;
export type ConcreteGpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | "amf";
export type GpuEncoder = ConcreteGpuEncoder | null;
const GPU_ENCODER_CANDIDATES: ConcreteGpuEncoder[] = [
"nvenc",
"videotoolbox",
"vaapi",
"qsv",
"amf",
];
const H264_ENCODER_BY_GPU: Record<ConcreteGpuEncoder, string> = {
nvenc: "h264_nvenc",
videotoolbox: "h264_videotoolbox",
vaapi: "h264_vaapi",
qsv: "h264_qsv",
amf: "h264_amf",
};
const GPU_PROBE_TIMEOUT_MS = 2000;
const GPU_PROBE_KILL_GRACE_MS = 1000;
export function getCompiledGpuEncoders(ffmpegEncodersStdout: string): ConcreteGpuEncoder[] {
return GPU_ENCODER_CANDIDATES.filter((encoder) =>
ffmpegEncodersStdout.includes(H264_ENCODER_BY_GPU[encoder]),
);
}
export async function selectUsableGpuEncoder(
candidates: readonly ConcreteGpuEncoder[],
isUsable: (encoder: ConcreteGpuEncoder) => Promise<boolean>,
): Promise<GpuEncoder> {
const results = await Promise.all(
candidates.map(async (encoder) => {
try {
return { encoder, usable: await isUsable(encoder) };
} catch {
return { encoder, usable: false };
}
}),
);
for (const result of results) {
if (result.usable) {
return result.encoder;
}
}
return null;
}
export async function detectGpuEncoder(): Promise<GpuEncoder> {
return new Promise((resolve) => {
@@ -21,11 +69,10 @@ export async function detectGpuEncoder(): Promise<GpuEncoder> {
});
ffmpeg.on("close", () => {
if (stdout.includes("h264_nvenc")) resolve("nvenc");
else if (stdout.includes("h264_videotoolbox")) resolve("videotoolbox");
else if (stdout.includes("h264_vaapi")) resolve("vaapi");
else if (stdout.includes("h264_qsv")) resolve("qsv");
else resolve(null);
const candidates = getCompiledGpuEncoders(stdout);
void selectUsableGpuEncoder(candidates, canUseGpuEncoder)
.then(resolve)
.catch(() => resolve(null));
});
ffmpeg.on("error", () => resolve(null));
@@ -52,11 +99,111 @@ export function getGpuEncoderName(encoder: GpuEncoder, codec: "h264" | "h265"):
return codec === "h264" ? "h264_vaapi" : "hevc_vaapi";
case "qsv":
return codec === "h264" ? "h264_qsv" : "hevc_qsv";
case "amf":
return codec === "h264" ? "h264_amf" : "hevc_amf";
default:
return codec === "h264" ? "libx264" : "libx265";
}
}
function getProbeArgs(encoder: ConcreteGpuEncoder): string[] {
const args = [
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=size=16x16:rate=1:duration=1",
"-frames:v",
"1",
"-an",
];
if (encoder === "vaapi") {
args.push("-vaapi_device", "/dev/dri/renderD128", "-vf", "format=nv12,hwupload");
}
args.push("-c:v", getGpuEncoderName(encoder, "h264"));
if (encoder === "amf") {
args.push("-rc", "cqp", "-qp_i", "28", "-qp_p", "28");
}
args.push("-f", "null", "-");
return args;
}
async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
let timedOut = false;
let killTimer: ReturnType<typeof setTimeout> | undefined;
let stderr = "";
const finish = (usable: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
resolve(usable);
};
const ffmpeg = spawn("ffmpeg", getProbeArgs(encoder), {
stdio: ["ignore", "ignore", "pipe"],
});
ffmpeg.stderr?.on("data", (data) => {
stderr += data.toString();
});
const timer = setTimeout(() => {
timedOut = true;
ffmpeg.kill("SIGTERM");
killTimer = setTimeout(() => {
ffmpeg.kill("SIGKILL");
finish(false);
}, GPU_PROBE_KILL_GRACE_MS);
}, GPU_PROBE_TIMEOUT_MS);
ffmpeg.on("close", (code, signal) => {
const usable = code === 0;
logGpuProbeFailure(encoder, { code, signal, stderr, timedOut });
finish(usable);
});
ffmpeg.on("error", (error) => {
logGpuProbeFailure(encoder, { error, timedOut });
finish(false);
});
});
}
function logGpuProbeFailure(
encoder: ConcreteGpuEncoder,
result: {
code?: number | null;
error?: Error;
signal?: NodeJS.Signals | null;
stderr?: string;
timedOut?: boolean;
},
): void {
if (!isGpuProbeDebugEnabled()) return;
if (result.code === 0 && !result.error && !result.timedOut) return;
const reason = result.error
? result.error.message
: result.timedOut
? `timed out after ${GPU_PROBE_TIMEOUT_MS}ms`
: `exit=${String(result.code)} signal=${String(result.signal ?? "")}`;
const stderr = result.stderr?.trim();
console.warn(`[gpuEncoder] ${encoder} probe failed: ${reason}${stderr ? `\n${stderr}` : ""}`);
}
function isGpuProbeDebugEnabled(): boolean {
const value = process.env.HYPERFRAMES_DEBUG_GPU_PROBE;
return value === "1" || value === "true";
}
// libx264 preset names (ultrafast/superfast/.../placebo) mapped to the
// equivalent NVENC p1..p7 preset. NVENC rejects libx264 names with
// AVERROR(EINVAL) ("Error applying encoder options: Invalid argument"),
@@ -93,8 +240,8 @@ const QSV_PRESET_MAP: Record<string, string> = {
* through unchanged. Unknown values fall back to `p4` (medium).
* - `qsv`: `ultrafast`/`superfast`/`placebo` → nearest supported name;
* everything else passes through.
* - `videotoolbox`, `vaapi`, `null`: no remap (they either ignore `-preset`
* entirely or accept the libx264 vocabulary).
* - `videotoolbox`, `vaapi`, `amf`, `null`: no remap (they either ignore
* `-preset` entirely or accept the libx264 vocabulary).
*/
export function mapPresetForGpuEncoder(encoder: GpuEncoder, preset: string): string {
switch (encoder) {