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
@@ -256,6 +256,28 @@ describe("buildEncoderArgs GPU preset mapping", () => {
);
expect(presetArg(args)).toBe("medium");
});
it("uses AMD AMF encoder names and quality flags when selected", () => {
const h264Args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
inputArgs,
"out.mp4",
"amf",
);
expect(h264Args[h264Args.indexOf("-c:v") + 1]).toBe("h264_amf");
expect(h264Args[h264Args.indexOf("-qp_i") + 1]).toBe("23");
expect(h264Args).toContain("-bf");
expect(h264Args[h264Args.indexOf("-bf") + 1]).toBe("0");
const h265Args = buildEncoderArgs(
{ ...baseOptions, codec: "h265", preset: "medium", quality: 23, useGpu: true },
inputArgs,
"out.mp4",
"amf",
);
expect(h265Args[h265Args.indexOf("-c:v") + 1]).toBe("hevc_amf");
expect(h265Args[h265Args.indexOf("-qp_i") + 1]).toBe("23");
});
});
describe("buildEncoderArgs color space", () => {
@@ -529,7 +551,7 @@ describe("buildEncoderArgs lockGopForChunkConcat", () => {
it("true is a no-op on GPU encoders", () => {
// GPU encoders take a separate code path; lockGopForChunkConcat does not
// wire `-g` / `-keyint_min` into nvenc/qsv/vaapi.
// wire `-g` / `-keyint_min` into nvenc/amf/qsv/vaapi.
const args = buildEncoderArgs(
{
...baseOptions,
@@ -816,7 +838,7 @@ describe("buildEncoderArgs HDR color space", () => {
});
it("tags BT.2020 + transfer for HDR GPU H.265 (no mastering metadata via -x265-params)", () => {
// GPU encoders (nvenc, videotoolbox, qsv, vaapi) still emit the BT.2020
// GPU encoders (nvenc, videotoolbox, amf, qsv, vaapi) still emit the BT.2020
// color tags via the codec-level -colorspace/-color_primaries/-color_trc
// flags, but cannot accept x265-params, so HDR static mastering metadata
// (master-display, max-cll) is not embedded. Acceptable for previews,
+9 -2
View File
@@ -140,9 +140,13 @@ export function buildEncoderArgs(
if (bitrate) args.push("-b:v", bitrate);
else args.push("-global_quality", String(quality));
break;
case "amf":
if (bitrate) args.push("-b:v", bitrate);
else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
break;
}
// Same B-frame story as the SW branch below — nvenc emits B-frames
// Same B-frame story as the SW branch below — nvenc/amf emit B-frames
// by default (qsv via b_strategy, vaapi too), and the negative-DTS
// freeze hits the same downstream players. The unconditional
// `-avoid_negative_ts make_zero` near the bottom of this function
@@ -153,7 +157,10 @@ export function buildEncoderArgs(
// negative DTS in practice on macOS Sonoma+.
if (
codec === "h264" &&
(gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
(gpuEncoder === "nvenc" ||
gpuEncoder === "qsv" ||
gpuEncoder === "vaapi" ||
gpuEncoder === "amf")
) {
args.push("-bf", "0");
if (gpuEncoder === "qsv") {
@@ -265,6 +265,26 @@ describe("buildStreamingArgs", () => {
const args = buildStreamingArgs({ ...baseGpu, preset: "medium" }, "/tmp/out.mp4", "qsv");
expect(presetArg(args)).toBe("medium");
});
it("uses AMD AMF encoder names and quality flags when selected", () => {
const h264Args = buildStreamingArgs(
{ ...baseGpu, preset: "medium", quality: 23 },
"/tmp/out.mp4",
"amf",
);
expect(h264Args[h264Args.indexOf("-c:v") + 1]).toBe("h264_amf");
expect(h264Args[h264Args.indexOf("-qp_i") + 1]).toBe("23");
expect(h264Args).toContain("-bf");
expect(h264Args[h264Args.indexOf("-bf") + 1]).toBe("0");
const h265Args = buildStreamingArgs(
{ ...baseGpu, codec: "h265", preset: "medium", quality: 23 },
"/tmp/out.mp4",
"amf",
);
expect(h265Args[h265Args.indexOf("-c:v") + 1]).toBe("hevc_amf");
expect(h265Args[h265Args.indexOf("-qp_i") + 1]).toBe("23");
});
});
});
@@ -231,14 +231,21 @@ export function buildStreamingArgs(
if (bitrate) args.push("-b:v", bitrate);
else args.push("-global_quality", String(quality));
break;
case "amf":
if (bitrate) args.push("-b:v", bitrate);
else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
break;
}
// Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, qsv,
// vaapi) and produce the same negative-DTS freeze for downstream players.
// Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, amf,
// qsv, vaapi) and produce the same negative-DTS freeze for downstream players.
// See chunkEncoder.buildEncoderArgs for the full explanation.
if (
codec === "h264" &&
(gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
(gpuEncoder === "nvenc" ||
gpuEncoder === "qsv" ||
gpuEncoder === "vaapi" ||
gpuEncoder === "amf")
) {
args.push("-bf", "0");
if (gpuEncoder === "qsv") {
+68 -3
View File
@@ -1,6 +1,71 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mapPresetForGpuEncoder } from "./gpuEncoder.js";
import {
getCompiledGpuEncoders,
getGpuEncoderName,
mapPresetForGpuEncoder,
selectUsableGpuEncoder,
} from "./gpuEncoder.js";
afterEach(() => {
vi.useRealTimers();
});
describe("getCompiledGpuEncoders", () => {
it("recognizes AMD AMF in FFmpeg's encoder list", () => {
expect(
getCompiledGpuEncoders(`
V....D h264_nvenc NVIDIA NVENC H.264 encoder
V....D h264_amf AMD AMF H.264 Encoder
V....D h264_qsv H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video)
`),
).toEqual(["nvenc", "qsv", "amf"]);
});
});
describe("selectUsableGpuEncoder", () => {
it("runs probe checks concurrently while preserving candidate priority", async () => {
vi.useFakeTimers();
const started: string[] = [];
const usable = selectUsableGpuEncoder(["nvenc", "amf"], async (encoder) => {
started.push(encoder);
await new Promise((resolve) => setTimeout(resolve, encoder === "nvenc" ? 50 : 1));
return true;
});
await vi.advanceTimersByTimeAsync(1);
expect(started).toEqual(["nvenc", "amf"]);
await vi.advanceTimersByTimeAsync(49);
expect(await usable).toBe("nvenc");
});
it("falls through from compiled-but-unusable NVENC to usable AMD AMF", async () => {
const usable = await selectUsableGpuEncoder(["nvenc", "amf"], async (encoder) => {
return encoder === "amf";
});
expect(usable).toBe("amf");
});
it("treats rejected probe checks as unusable", async () => {
const usable = await selectUsableGpuEncoder(["nvenc", "amf"], async (encoder) => {
if (encoder === "nvenc") {
throw new Error("driver probe failed");
}
return encoder === "amf";
});
expect(usable).toBe("amf");
});
});
describe("getGpuEncoderName", () => {
it("maps AMD AMF to FFmpeg's h264 and hevc encoder names", () => {
expect(getGpuEncoderName("amf", "h264")).toBe("h264_amf");
expect(getGpuEncoderName("amf", "h265")).toBe("hevc_amf");
});
});
describe("mapPresetForGpuEncoder", () => {
describe("nvenc", () => {
@@ -49,7 +114,7 @@ describe("mapPresetForGpuEncoder", () => {
});
describe("other encoders", () => {
it.each(["videotoolbox", "vaapi"] as const)(
it.each(["videotoolbox", "vaapi", "amf"] as const)(
"passes preset through unchanged for %s",
(encoder) => {
expect(mapPresetForGpuEncoder(encoder, "medium")).toBe("medium");
+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) {