fix(engine): GPU renders use the NVENC preset names the host's ffmpeg lists (#6930)

* fix(engine): GPU renders use the NVENC preset names the host's ffmpeg lists

* fix(engine): one ffmpeg stdout reader for encoder detection and preset listing

* ci(engine): raise the engine unpacked size budget for the NVENC preset reader
This commit is contained in:
Miguel Ángel
2026-11-01 15:46:57 +00:00
committed by GitHub
parent ef250cc9d0
commit 90f7fe624f
7 changed files with 192 additions and 12 deletions
@@ -1061,6 +1061,18 @@ describe("buildEncoderArgs GPU preset mapping", () => {
expect(presetArg(args)).toBe("p1");
});
it("gives an NVENC build that lists only legacy presets a legacy preset, not p4", () => {
const legacy = new Set(["default", "slow", "medium", "fast", "hp", "hq"]);
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 18, useGpu: true },
inputArgs,
"out.mp4",
"nvenc",
legacy,
);
expect(presetArg(args)).toBe("medium");
});
it("translates the standard medium preset to NVENC p4", () => {
const args = buildEncoderArgs(
{ ...baseOptions, codec: "h264", preset: "medium", quality: 18, useGpu: true },
+7 -3
View File
@@ -26,6 +26,7 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import {
type GpuEncoder,
getCachedGpuEncoder,
getCachedNvencPresets,
getGpuEncoderName,
buildVideoToolboxRateControlArgs,
mapPresetForGpuEncoder,
@@ -284,6 +285,7 @@ export function buildEncoderArgs(
inputArgs: string[],
outputPath: string,
gpuEncoder: GpuEncoder = null,
nvencPresets?: ReadonlySet<string>,
): string[] {
const {
fps,
@@ -320,7 +322,7 @@ export function buildEncoderArgs(
switch (gpuEncoder) {
case "nvenc":
args.push("-preset", mapPresetForGpuEncoder("nvenc", preset));
args.push("-preset", mapPresetForGpuEncoder("nvenc", preset, nvencPresets));
if (bitrate) args.push("-b:v", bitrate);
else args.push("-cq", String(quality));
break;
@@ -620,7 +622,8 @@ export async function encodeFramesFromDir(
const inputPath = frameSequencePath(framesDir, framePattern);
const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder, nvencPresets);
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
let framesRead = 0;
const result = await runFfmpeg(args, {
@@ -796,7 +799,8 @@ export async function encodeFramesChunkedConcat(
];
let gpuEncoder: GpuEncoder = null;
if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder, nvencPresets);
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
let framesRead = 0;
const processResult = await runFfmpeg(args, {
@@ -103,6 +103,18 @@ describe("buildStreamingArgs", () => {
expectSingleHvc1Tag(args);
});
it("gives an NVENC build that lists only legacy presets a legacy preset, not p4", () => {
const legacy = new Set(["default", "slow", "medium", "fast", "hp", "hq"]);
const args = buildStreamingArgs(
{ ...baseSdr, codec: "h265", useGpu: true, preset: "medium" },
"/tmp/out.mp4",
"nvenc",
legacy,
);
expect(args[args.indexOf("-preset") + 1]).toBe("medium");
});
it("keeps the software tag when a detected GPU is disabled", () => {
const args = buildStreamingArgs({ ...baseSdr, codec: "h265" }, "/tmp/out.mp4", "nvenc");
@@ -27,6 +27,7 @@ import { dirname } from "path";
import {
type GpuEncoder,
getCachedGpuEncoder,
getCachedNvencPresets,
getGpuEncoderName,
buildVideoToolboxRateControlArgs,
mapPresetForGpuEncoder,
@@ -233,6 +234,7 @@ export function buildStreamingArgs(
options: StreamingEncoderOptions,
outputPath: string,
gpuEncoder: GpuEncoder = null,
nvencPresets?: ReadonlySet<string>,
): string[] {
const {
fps,
@@ -312,7 +314,7 @@ export function buildStreamingArgs(
switch (gpuEncoder) {
case "nvenc":
args.push("-preset", mapPresetForGpuEncoder("nvenc", preset));
args.push("-preset", mapPresetForGpuEncoder("nvenc", preset, nvencPresets));
if (bitrate) args.push("-b:v", bitrate);
else args.push("-cq", String(quality));
break;
@@ -521,7 +523,8 @@ export async function spawnStreamingEncoder(
gpuEncoder = await getCachedGpuEncoder();
}
const args = buildStreamingArgs(options, outputPath, gpuEncoder);
const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
const args = buildStreamingArgs(options, outputPath, gpuEncoder, nvencPresets);
const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
stdio: ["pipe", "pipe", "pipe"],
@@ -6,6 +6,7 @@ import {
getGpuEncoderName,
getProbeArgs,
mapPresetForGpuEncoder,
parseEncoderPresetNames,
resetCachedGpuEncoder,
selectUsableGpuEncoder,
} from "./gpuEncoder.js";
@@ -97,6 +98,37 @@ describe("mapPresetForGpuEncoder", () => {
it("falls back to p4 for unknown preset values", () => {
expect(mapPresetForGpuEncoder("nvenc", "nonsense")).toBe("p4");
});
describe("on an ffmpeg build whose encoder lists only the legacy presets", () => {
const legacy = parseEncoderPresetNames(LEGACY_NVENC_HELP);
it.each([
["ultrafast", "hp"],
["superfast", "hp"],
["veryfast", "hp"],
["faster", "fast"],
["fast", "fast"],
["medium", "medium"],
["slow", "slow"],
["slower", "slow"],
["veryslow", "slow"],
["placebo", "slow"],
["p4", "medium"],
["nonsense", "medium"],
])("maps %s to the legacy preset %s", (input, expected) => {
expect(mapPresetForGpuEncoder("nvenc", input, legacy)).toBe(expected);
});
});
it("keeps the pN names when the encoder lists them", () => {
const modern = parseEncoderPresetNames(MODERN_NVENC_HELP);
expect(mapPresetForGpuEncoder("nvenc", "slow", modern)).toBe("p5");
expect(mapPresetForGpuEncoder("nvenc", "p7", modern)).toBe("p7");
});
it("keeps the pN names when the encoder's list could not be read", () => {
expect(mapPresetForGpuEncoder("nvenc", "slow", new Set())).toBe("p5");
});
});
describe("qsv", () => {
@@ -157,3 +189,51 @@ describe("getCachedGpuEncoder", () => {
expect(getCachedGpuEncoder()).toBe(first);
});
});
const LEGACY_NVENC_HELP = `Encoder h264_nvenc [NVIDIA NVENC H.264 encoder]:
General capabilities: dr1 delay hardware
h264_nvenc AVOptions:
-preset <int> E..V....... Set the encoding preset (from 0 to 11) (default medium)
default 0 E..V.......
slow 1 E..V....... hq 2 passes
medium 2 E..V....... hq 1 pass
fast 3 E..V....... hp 1 pass
hp 4 E..V.......
hq 5 E..V.......
bd 6 E..V.......
ll 7 E..V....... low latency
llhq 8 E..V....... low latency hq
llhp 9 E..V....... low latency hp
lossless 10 E..V.......
losslesshp 11 E..V.......
-profile <int> E..V....... Set the encoding profile (from 0 to 3) (default main)
baseline 0 E..V.......
main 1 E..V.......
`;
const MODERN_NVENC_HELP = `Encoder h264_nvenc [NVIDIA NVENC H.264 encoder]:
h264_nvenc AVOptions:
-preset <int> E..V....... Set the encoding preset (from 0 to 18) (default p4)
default 0 E..V.......
slow 1 E..V....... hq 2 passes
p1 12 E..V....... fastest (lowest quality)
p4 15 E..V....... medium (default)
p7 18 E..V....... slowest (best quality)
-tune <int> E..V....... Set the encoding tuning info (from 1 to 4) (default hq)
hq 1 E..V.......
`;
describe("parseEncoderPresetNames", () => {
it("lists the names under -preset and stops at the next option", () => {
expect([...parseEncoderPresetNames(LEGACY_NVENC_HELP)].sort()).toEqual(
"bd default fast hp hq ll llhp llhq lossless losslesshp medium slow".split(" "),
);
const modern = parseEncoderPresetNames(MODERN_NVENC_HELP);
expect(modern.has("p4")).toBe(true);
expect(modern.has("main")).toBe(false);
});
it("is empty for text with no -preset option", () => {
expect(parseEncoderPresetNames("Codec 'h264_nvenc' is not recognized by FFmpeg.").size).toBe(0);
});
});
+75 -6
View File
@@ -61,9 +61,14 @@ export async function selectUsableGpuEncoder(
return null;
}
export async function detectGpuEncoder(): Promise<GpuEncoder> {
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
stdio: ["pipe", "pipe", "pipe"],
/** ffmpeg's stdout, or null when it did not exit cleanly within the deadline. */
async function ffmpegStdout(
args: string[],
deadlineMs: number,
terminationGraceMs?: number,
): Promise<string | null> {
const ffmpeg = spawn(getFfmpegBinary(), args, {
stdio: ["ignore", "pipe", "pipe"],
// See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
windowsHide: true,
});
@@ -73,9 +78,15 @@ export async function detectGpuEncoder(): Promise<GpuEncoder> {
stdout += data.toString();
});
const outcome = await new ManagedChildProcess(ffmpeg, {
deadlineAtMs: Date.now() + 30_000,
deadlineAtMs: Date.now() + deadlineMs,
terminationGraceMs,
}).wait();
if (outcome.reason !== "exit" || outcome.exitCode !== 0) return null;
return outcome.reason === "exit" && outcome.exitCode === 0 ? stdout : null;
}
export async function detectGpuEncoder(): Promise<GpuEncoder> {
const stdout = await ffmpegStdout(["-encoders"], 30_000);
if (stdout === null) return null;
const candidates = getCompiledGpuEncoders(stdout);
return selectUsableGpuEncoder(candidates, canUseGpuEncoder).catch(() => null);
}
@@ -265,9 +276,15 @@ const QSV_PRESET_MAP: Record<string, string> = {
* - `videotoolbox`, `vaapi`, `amf`, `null`: no remap (they either ignore
* `-preset` entirely or accept the libx264 vocabulary).
*/
export function mapPresetForGpuEncoder(encoder: GpuEncoder, preset: string): string {
export function mapPresetForGpuEncoder(
encoder: GpuEncoder,
preset: string,
nvencPresets?: ReadonlySet<string>,
): string {
switch (encoder) {
case "nvenc":
if (nvencPresets && nvencPresets.size > 0 && !nvencPresets.has("p4"))
return legacyNvencPreset(preset, nvencPresets);
if (/^p[1-7]$/.test(preset)) return preset;
return NVENC_PRESET_MAP[preset] ?? "p4";
case "qsv":
@@ -276,3 +293,55 @@ export function mapPresetForGpuEncoder(encoder: GpuEncoder, preset: string): str
return preset;
}
}
// Builds on an older NVENC SDK list `default slow medium fast hp hq ...` and reject p1..p7.
const LEGACY_NVENC_PRESET_MAP: Record<string, string> = {
ultrafast: "hp",
superfast: "hp",
veryfast: "hp",
faster: "fast",
fast: "fast",
medium: "medium",
slow: "slow",
slower: "slow",
veryslow: "slow",
placebo: "slow",
};
function legacyNvencPreset(preset: string, accepted: ReadonlySet<string>): string {
if (accepted.has(preset) && !/^p[1-7]$/.test(preset)) return preset;
const mapped = LEGACY_NVENC_PRESET_MAP[preset] ?? "medium";
return accepted.has(mapped) ? mapped : "default";
}
/** The names an encoder lists under its `-preset` option in `ffmpeg -h encoder=<name>`. */
export function parseEncoderPresetNames(helpText: string): ReadonlySet<string> {
const names = new Set<string>();
let inPreset = false;
for (const line of helpText.split(/\r?\n/)) {
if (/^\s{1,3}-\S/.test(line)) {
inPreset = /^\s{1,3}-preset\s/.test(line);
continue;
}
const entry = inPreset ? /^\s{4,}([a-z][a-z0-9_]*)\s+-?\d+\s+[E.]\S{3}/.exec(line) : null;
if (entry) names.add(entry[1]!);
}
return names;
}
let cachedNvencPresets: Promise<ReadonlySet<string>> | undefined = undefined;
/** The presets this ffmpeg's h264_nvenc takes; empty when it cannot be read, which keeps the pN names. */
export function getCachedNvencPresets(): Promise<ReadonlySet<string>> {
cachedNvencPresets ??= readNvencPresets();
return cachedNvencPresets;
}
async function readNvencPresets(): Promise<ReadonlySet<string>> {
const help = await ffmpegStdout(
["-hide_banner", "-h", "encoder=h264_nvenc"],
GPU_PROBE_TIMEOUT_MS,
GPU_PROBE_KILL_GRACE_MS,
).catch(() => null);
return help === null ? new Set() : parseEncoderPresetNames(help);
}
+1 -1
View File
@@ -13,7 +13,7 @@
},
"@hyperframes/engine": {
"packed": 456397,
"unpacked": 1526873
"unpacked": 1620913
},
"@hyperframes/gcp-cloud-run": {
"packed": 49412,