diff --git a/packages/engine/src/services/chunkEncoder.test.ts b/packages/engine/src/services/chunkEncoder.test.ts index cdf04fd263..4190775d30 100644 --- a/packages/engine/src/services/chunkEncoder.test.ts +++ b/packages/engine/src/services/chunkEncoder.test.ts @@ -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 }, diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index f3a598a73f..86344140a1 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -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[] { 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, { diff --git a/packages/engine/src/services/streamingEncoder.test.ts b/packages/engine/src/services/streamingEncoder.test.ts index 8d60636b8e..a02f31df31 100644 --- a/packages/engine/src/services/streamingEncoder.test.ts +++ b/packages/engine/src/services/streamingEncoder.test.ts @@ -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"); diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 03e674072f..23e9f484ea 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -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[] { 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"], diff --git a/packages/engine/src/utils/gpuEncoder.test.ts b/packages/engine/src/utils/gpuEncoder.test.ts index 1b07b6ab8d..41b59a75e8 100644 --- a/packages/engine/src/utils/gpuEncoder.test.ts +++ b/packages/engine/src/utils/gpuEncoder.test.ts @@ -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 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 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 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 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); + }); +}); diff --git a/packages/engine/src/utils/gpuEncoder.ts b/packages/engine/src/utils/gpuEncoder.ts index 535a16508b..ecaf7e157e 100644 --- a/packages/engine/src/utils/gpuEncoder.ts +++ b/packages/engine/src/utils/gpuEncoder.ts @@ -61,9 +61,14 @@ export async function selectUsableGpuEncoder( return null; } -export async function detectGpuEncoder(): Promise { - 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 { + 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 { 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 { + 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 = { * - `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 { 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 = { + 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 { + 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=`. */ +export function parseEncoderPresetNames(helpText: string): ReadonlySet { + const names = new Set(); + 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> | 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> { + cachedNvencPresets ??= readNvencPresets(); + return cachedNvencPresets; +} + +async function readNvencPresets(): Promise> { + 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); +} diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 2a567bb5ec..93701119c8 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -13,7 +13,7 @@ }, "@hyperframes/engine": { "packed": 456397, - "unpacked": 1526873 + "unpacked": 1620913 }, "@hyperframes/gcp-cloud-run": { "packed": 49412,