---
key: ffmpeg.qsv-extra-hw-frames-floor
title: 2026-07-21 — QSV hardware-frame headroom is a floor, not an operator preference (#529)
status: active
since: '2026-07-21'
supersedes: none
superseded-by: none
rule: a QSV upload never emits `extra_hw_frames` below `FFmpegState.MinimumQsvExtraHardwareFrames` (64); a stored `0` or negative value is treated as "no pool configured" rather than honored literally, because with no headroom any unthrottled read exhausts the pool and the transcode writes nothing at all.
signals: 'QSV, extra_hw_frames, hwupload, hardware frame pool, ENOMEM, "Could not open encoder before EOF", readrate as an incidental allocation bound · paths: `FFmpegState`, `QsvPipelineBuilder`, `HardwareUploadFilter`, `ScaleQsvFilter`, `DeinterlaceQsvFilter` · issues: #529, #350, #516, #519'
mechanics: '`FFmpegState.MinimumQsvExtraHardwareFrames`; `FFmpegState.QsvExtraHardwareFrames`; `QsvPipelineBuilder` logs once when a configured value is raised'
---

- **`extra_hw_frames=0` is not a valid pool size; it is a dead channel waiting for an unthrottled
  read.** `FFmpegState.QsvExtraHardwareFrames` honored a stored `0` literally, so
  `hwupload=extra_hw_frames=0` reached FFmpeg with no headroom for frames in flight through the
  filter graph. Measured against the deployed FFmpeg 8.1.2 on one real logged command (software
  mpeg4 decode → `hwupload` → `vpp_qsv` → `h264_qsv`): the graph fails with `-12 (Cannot allocate
  memory)`, `h264_qsv` reports "Could not open encoder before EOF", and **zero segments are
  written**. It now clamps to `MinimumQsvExtraHardwareFrames` (64), which is also the value
  `IfNone` already used for an unset profile and the seeded profile default.
- **Input throttling was the only thing hiding it, which is why this looked like a #350
  regression.** Truth table, same command, same binary, only the marked tokens differing:

  | readrate | extra_hw_frames | result |
  |---|---|---|
  | `1.05`, no burst | 0 | 14 segments, exit 0 |
  | `1.05` + burst 2 / 4 / 8 | 0 | **ENOMEM, 0 segments** |
  | no readrate at all | 0 | **ENOMEM, 0 segments** |
  | `1.05` + burst 8 | 64 | 14 segments, exit 0 |
  | no readrate at all | 64 | 14 segments, exit 0 |

  So the defect predates #350's burst: any work-ahead start (which takes no `-readrate`) on a
  pipeline that uploads to QSV was already failing on a profile with `0`. The burst did not
  introduce it — it removed the throttle on *every* realtime session, converting an intermittent
  failure into a near-deterministic one, which is how it finally got noticed.
- **`-readrate` was doing load-bearing work nobody had written down.** Its stated job is live-TV
  pacing; it was *also* incidentally bounding how fast decoded frames enter the filter graph. This
  is why #350's FFmpeg-level benchmark and #516's argument-generation tests were both green and
  neither could see it: the burst is bounded in **seconds of input**, which is not a bound on
  **memory or hardware surfaces**. Corrects the #350 entry above, which records the burst as
  bounded and safe and does not mention hardware frame pools.
- **A floor, not a clamp-to-default — and the floor is wider than the evidence.** Values above 64
  are honored unchanged; values below it are raised. We measured only `0` (fails) and `64` (works),
  so `1..63` are **untested, not known-bad**: we raise them rather than trust them, because the
  failure they risk is a channel that serves nothing at all. That is a deliberate over-reach, and
  it is not free — `extra_hw_frames` allocates *additional* surfaces (64 NV12 1080p surfaces ≈
  190 MiB, ≈760 MiB at 4K), so an operator who deliberately set a small pool on a memory-constrained
  iGPU silently gets a larger one. `QsvPipelineBuilder` therefore **logs a warning** naming both the
  configured and the applied value, so the override is discoverable rather than silent. If a smaller
  pool is ever measured safe, lower the floor rather than removing it.
- **Fixed at `FFmpegState.QsvExtraHardwareFrames`, the single point every QSV upload site reads.**
  `HardwareUploadFilter`, `HardwareUploadQsvFilter` and `WatermarkHardwareUploadFilter` read it
  directly; `ScaleQsvFilter` and `DeinterlaceQsvFilter` take values `QsvPipelineBuilder` passes down
  from it. One guard covers them all rather than five call sites that can drift apart. (A sixth
  formatter, `SubtitleScaleQsvFilter`, also emits `extra_hw_frames` but is currently dead code — no
  construction site exists in the solution — so it is *not* covered by this guard and would need the
  same value threading if it is ever revived.)

**Accepted residual:** the floor is applied at render time only, so a stored `0` keeps displaying as
`0` in the SPA and over `GET /api/v1/ffmpeg/profiles/{id}` while FFmpeg receives 64 — the config no
longer literally describes the behavior. Render-time was chosen deliberately: it fixes every existing
deployment with no DB edit and no migration, which matters because this bug is already failing
transcodes in production. New and updated profiles are normalized on save so stored rows converge on
the truth, and the warning log closes the discoverability gap for rows that predate it; a backfill
migration for old rows was judged not worth the dual-provider cost. Two consequences to know about:
the save-time normalization is **unconditional on `hardwareAcceleration`**, so a non-QSV profile's
stored value moves too (harmless — only the QSV path ever reads it — but it is a stored-state change
on a field the user didn't touch); and a machine client that `PUT`s `0` gets a `200` and then reads
back `64`, which is a silent transform of a submitted value that the OpenAPI description does not
advertise.
