mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(engine): support AMD AMF GPU encoding
This commit is contained in:
@@ -19,7 +19,7 @@ Requires: Docker installed and running.
|
||||
- `-w, --workers` — Parallel workers 1-8 (default: auto)
|
||||
- `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`)
|
||||
- `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`)
|
||||
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, VAAPI, QSV)
|
||||
- `--gpu` — Use GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV)
|
||||
- `--browser-gpu` / `--no-browser-gpu` — Force host GPU or software (SwiftShader) for Chrome/WebGL capture. Default for local renders is `auto` — probe WebGL availability on first launch and fall back to software if no GPU is reachable. Docker mode always uses software.
|
||||
- `-o, --output` — Custom output path
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user