fix(engine): accept libx264 preset names with NVENC and QSV#442
Conversation
NVENC rejects the libx264 preset vocabulary (ultrafast / medium / slow /
...) with AVERROR(EINVAL) ("Error applying encoder options: Invalid
argument"), which surfaces as a bare `FFmpeg exited with code -22` from
spawn(). Because ENCODER_PRESETS passes these names straight through to
h264_nvenc / hevc_nvenc, every `--gpu` render using the `draft` tier
failed; `standard` (medium) and `high` (slow) only worked coincidentally
on ffmpeg builds that happened to accept those aliases. QSV has the same
problem on a narrower set (ultrafast / superfast / placebo).
Add `mapPresetForGpuEncoder` in utils/gpuEncoder.ts that translates the
libx264 vocabulary to each encoder's native names:
- nvenc: libx264 -> p1..p7 (already-native pN values pass through);
unknown values fall back to p4 (medium)
- qsv: ultrafast / superfast -> veryfast; placebo -> veryslow;
everything else passes through
- videotoolbox / vaapi / null: unchanged
Both buildEncoderArgs (chunkEncoder.ts) and buildStreamingArgs
(streamingEncoder.ts) now route through the helper before pushing
`-preset` to the ffmpeg arg vector.
To make the next encoder-options failure diagnosable without re-running
ffmpeg by hand, \`formatFfmpegError\` in utils/runFfmpeg.ts now appends
the last 15 non-empty stderr lines to the error string. The four call
sites that previously swallowed stderr (encodeFramesFromDir,
muxVideoWithAudio, applyFaststart, and the streaming encoder exit
handler) have been updated.
Tested end-to-end on an RTX 4080 with ffmpeg 8.1 NVENC across
\`--quality draft|standard|high\` plus \`--video-bitrate\` and \`--crf\`
overrides; the 6 renders were visually equivalent to the CPU baseline.
jrusso1020
left a comment
There was a problem hiding this comment.
Verdict: approve
Tight, surgical fix for a real user-visible bug. Root cause is correctly identified (NVENC rejects libx264 preset vocabulary — p1..p7 only — and QSV rejects a narrower set of three), the fix sits at the right boundary (translate once, before the arg vector), and the blast radius is GPU-path-only with CPU completely untouched.
What's right
- Translation at the correct layer.
mapPresetForGpuEncoderruns at the-presetarg push site in bothbuildEncoderArgs(chunkEncoder.ts:118) andbuildStreamingArgs(streamingEncoder.ts:200), not inside the encoder spawn. That keeps the internal preset model in libx264 vocabulary and translates once at the edge — exactly the right seam. - NVENC mapping is conventional.
ultrafast → p1 / medium → p4 / slow → p5 / veryslow → p7is the analog FFmpeg/Nvidia have established, and thepNpassthrough via/^p[1-7]$/ensures callers who already speak NVENC-native don't get clobbered. Unknown-preset fallback top4is the right conservative default — silent "best guess" instead of propagating a value that'll fail spawn. - QSV mapping is appropriately minimal. Only rewrites the three documented-unsupported names (
ultrafast,superfast,placebo); everything else passes through. Avoids building an exhaustive table that would need maintenance as QSV evolves. - Stderr tail helper is the diagnostic that would have made this findable in the first place.
formatFfmpegError(exitCode, stderr, 15)filters blank lines so real signal isn't hidden, caps the line count, and handles the null-exit-code spawn-failure case distinctly from a numeric exit. Adopted consistently at all four sites that previously produced bareFFmpeg exited with code N(encodeFramesFromDir,muxVideoWithAudio,applyFaststart, streaming exit handler). Not scope creep — it's the companion diagnostic that should have shipped alongside therunFfmpegabstraction originally. Happy to see it in the same PR. - Test coverage is excellent. 31 parametrized preset-mapping cases (
gpuEncoder.test.ts), both NVENC and QSV integration assertions onbuildEncoderArgs/buildStreamingArgsconfirming the correct-preset pN/-preset veryfastlands in the actual arg array, and edge cases on stderr tail (blank-line strip, line cap, null exit code, empty stderr).
Minor
hevc_nvencalso usesp1..p7and the fix correctly applies to both H.264 and H.265 NVENC paths — but the integration tests only covercodec: "h264". NVENC's preset vocabulary is codec-agnostic so the mapping is correct for both, but onecodec: "h265"case inchunkEncoder.test.tswould lock in "both codecs share the mapping" against a future refactor. Non-blocking.- Stderr tail default is 15 lines. Fine for the "Error applying encoder options" class of failures; for denser signals (HDR color-space, VFR fallback) callers can bump via the third arg. No action needed.
Risk
Trivial. Blast radius = GPU path only; CPU path untouched. pN passthrough + QSV-supported passthrough means existing working configs are unaffected. Revert is a one-line-per-site change. No schema / config / migration surface.
CI hasn't run on this fork PR yet (only the WIP marketplace check has ticked through) — worth confirming regression-shards, Tests, and Render on windows-latest pass once a maintainer CI-approves authorizes them to run before merging.
Review by hyperframes
hevc_nvenc uses the same p1..p7 preset vocabulary as h264_nvenc, so the mapping in `mapPresetForGpuEncoder` applies to both codecs. The initial regression suite only covered `codec: "h264"`, which left a gap: a future refactor that split the H.264 and H.265 NVENC paths could silently regress one codec without any test catching it. Add three-case loops (ultrafast → p1, medium → p4, veryslow → p7) under `codec: "h265"` to both `buildEncoderArgs` and `buildStreamingArgs` test blocks. Each case also asserts that `-c:v hevc_nvenc` is selected so the test fails loudly if the codec plumbing is broken, not just the preset translation. Follow-up to #442 per review comment from @jrusso1020. Co-authored-by: roi32 <75878108+roi32@users.noreply.github.com>
What
Fixes
hyperframes render --gpuwhen the selected quality tier uses a libx264 preset name that NVENC / QSV don't accept. In particular,--quality draft(which maps toultrafast) now works on NVENC; previously it failed withFFmpeg exited with code -22and no further diagnostics.Also surfaces ffmpeg stderr in encoder error messages so the next time a caller hands ffmpeg an arg it rejects, the error string actually says which arg.
Why
ENCODER_PRESETSinchunkEncoder.tspasses the libx264 preset names (ultrafast/medium/slow) straight through toh264_nvenc/hevc_nvenc. NVENC rejects the libx264 vocabulary — it only acceptsp1..p7— so spawn returnsAVERROR(EINVAL). On ffmpeg 8.1 that surfaces asexit code -22, andbuildEncoderArgs/buildStreamingArgspreviously swallowed stderr, so the ffmpeg-level message was invisible:So every
hyperframes render --gpu --quality draftfailed with a bareFFmpeg exited with code -22and no path forward. QSV has the same problem on a narrower set: it rejectsultrafast,superfast, andplacebo.How
Preset mapping (
packages/engine/src/utils/gpuEncoder.ts)New
mapPresetForGpuEncoder(encoder, preset)helper:nvenc: libx264 names →p1..p7. NativepNvalues pass through. Unknown values fall back top4(medium).qsv:ultrafast/superfast→veryfast;placebo→veryslow. Everything else passes through.videotoolbox/vaapi/null: unchanged (they either ignore-presetor accept the libx264 vocabulary).buildEncoderArgs(chunkEncoder.ts) andbuildStreamingArgs(streamingEncoder.ts) route through the helper before pushing-presetonto the ffmpeg arg vector.Stderr tail (
packages/engine/src/utils/runFfmpeg.ts)New
formatFfmpegError(exitCode, stderr)helper that appends the last 15 non-empty stderr lines to the error message. Adopted in the four call sites that previously producedFFmpeg exited with code Nwith no context:encodeFramesFromDir(chunkEncoder.ts)muxVideoWithAudio(chunkEncoder.ts)applyFaststart(chunkEncoder.ts)streamingEncoder.ts)If it turns out this second change feels out of scope, happy to split it into its own PR — it's orthogonal to the preset fix but it's the diagnostic that made the root cause findable in the first place.
Test plan
utils/gpuEncoder.test.ts(31) — all NVENC / QSV / passthrough mappings + fallbackutils/runFfmpeg.test.ts(6) — stderr tail formatting, blank-line handling, null exit codeservices/chunkEncoder.test.ts(+5) — asserts the correct-preset pN/-preset veryfastlands in the arg vector for each quality tier under NVENC and QSVservices/streamingEncoder.test.ts(+4) — same for streaming pathbun run --filter @hyperframes/engine test→ 359 pass / 0 fail / 3 skipped. (services/videoFrameExtractor.test.tsfails on my Windows box with a fontconfig error synthesizing the VFR fixture — same failure repros onmainat HEAD, unrelated to this PR.)bun run --filter @hyperframes/engine typecheckbun run lintoxfmt --checkpasses. (The fullbun run format:checkflags ~610 pre-existing files on main, untouched here.)--quality draft|standard|high,--video-bitrate 10M, and--crf 25. All rendered in 7–11s; GPU frames spot-checked and visually identical to the CPU baseline.