feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing #847

Merged
timothy merged 1 commits from fix/735-ffmpeg-profile-bounds into main 2026-08-27 00:05:39 +02:00
Owner

Closes the #529 accepted residual and makes the #726 pacing values operator-tunable.

Problem 1 — a submitted value was silently transformed

The create/update handlers floored qsvExtraHardwareFrames on save, so a client that PUT 0 got a
200 and read back 64 — a substitution the OpenAPI document never advertised. FFmpegProfileBounds
now rejects a newly submitted out-of-range value with a 422 naming the bound and its
consequence
. The render-time floor in FFmpegState stays, which is what keeps this migration-free
for rows written before the validation existed.

An unchanged legacy value is still accepted on update: the SPA round-trips the whole profile, so
rejecting a stored-but-out-of-range value would make an old profile uneditable over a field the
operator never touched and — outside QSV — cannot even see. The SPA mirrors that exemption
(validate(draft, stored)), and the add/copy path passes no stored draft so it stays strict, exactly
like the create handler's stored: null.

Problem 2 — pacing was unreachable

-readrate / -readrate_catchup were hardcoded in PipelineBuilderBase. During #726 the only way
to try a different value was to rebuild the app and replay command lines by hand on the Docker host.
They are now nullable ReadRate / ReadRateCatchup profile fields, bounded 1.0–2.0 and 1.0–10.0
plus a cross-field rule that catchup must be greater than the read rate (equal is zero headroom —
functionally no catchup, while still reading as configured). null means unset and resolves to the
values the pipeline used before, so an untouched profile paces identically; catchup stays ON by
default and capability-gated in code.

Deliberately not exposed: -readrate_initial_burst (derived from the HLS segment length, not an
independent dial) and the concat wrapper's -readrate 1.0 (it reads ErsatzTV's own loopback output,
not a media file's demuxer). A raw-args passthrough remains #736's scope.

Verification

  • 4,469 .NET tests, 1,233 web tests, 989 script tests; typecheck / lint / check:api clean with no
    generated drift; format + BOM gate on the committed tree; decisions_validate OK.
  • Live-E2E against a real instance: create, update and clear-to-null round-trip through the read
    path; every rejection path returns 422 with the bound named and leaves stored state untouched.
  • New UI-E2E spec drives the actual screen (fields render, the cross-field bound disables Save,
    a valid pair survives a reload).
  • Every new clause is mutation-proved: ignoring the pacing override, disarming each bound,
    weakening <= to <, removing the legacy exemption, and re-adding the QSV gate each redden their
    own tests and nothing else.

Review

Three adversarial rounds (cold, review-only, isolated worktree). Five confirmed defects in round 1,
two in round 2, zero in round 3 — all in durable artefacts (a record naming a test file that never
existed, a conventions bullet contradicted by the file it described, a comment stating a stricter
rule than its comparison, a help panel still teaching the retracted rule, and a duplicate ### 3c).
One low-severity UX residual is deliberately deferred and filed separately: when a legacy non-QSV
profile is copied, the 422 names a control the editor only renders under QSV.

fixes #735

Closes the #529 accepted residual and makes the #726 pacing values operator-tunable. ## Problem 1 — a submitted value was silently transformed The create/update handlers floored `qsvExtraHardwareFrames` on save, so a client that `PUT` 0 got a 200 and read back 64 — a substitution the OpenAPI document never advertised. `FFmpegProfileBounds` now rejects a **newly submitted** out-of-range value with a 422 naming the bound *and its consequence*. The render-time floor in `FFmpegState` stays, which is what keeps this migration-free for rows written before the validation existed. An **unchanged** legacy value is still accepted on update: the SPA round-trips the whole profile, so rejecting a stored-but-out-of-range value would make an old profile uneditable over a field the operator never touched and — outside QSV — cannot even see. The SPA mirrors that exemption (`validate(draft, stored)`), and the add/copy path passes no stored draft so it stays strict, exactly like the create handler's `stored: null`. ## Problem 2 — pacing was unreachable `-readrate` / `-readrate_catchup` were hardcoded in `PipelineBuilderBase`. During #726 the only way to try a different value was to rebuild the app and replay command lines by hand on the Docker host. They are now nullable `ReadRate` / `ReadRateCatchup` profile fields, bounded 1.0–2.0 and 1.0–10.0 plus a cross-field rule that catchup must be **greater than** the read rate (equal is zero headroom — functionally no catchup, while still reading as configured). `null` means unset and resolves to the values the pipeline used before, so an untouched profile paces identically; catchup stays ON by default and capability-gated in code. Deliberately **not** exposed: `-readrate_initial_burst` (derived from the HLS segment length, not an independent dial) and the concat wrapper's `-readrate 1.0` (it reads ErsatzTV's own loopback output, not a media file's demuxer). A raw-args passthrough remains #736's scope. ## Verification - 4,469 .NET tests, 1,233 web tests, 989 script tests; typecheck / lint / `check:api` clean with no generated drift; format + BOM gate on the committed tree; `decisions_validate` OK. - **Live-E2E** against a real instance: create, update and clear-to-null round-trip through the read path; every rejection path returns 422 with the bound named and leaves stored state untouched. - New **UI-E2E** spec drives the actual screen (fields render, the cross-field bound disables Save, a valid pair survives a reload). - Every new clause is **mutation-proved**: ignoring the pacing override, disarming each bound, weakening `<=` to `<`, removing the legacy exemption, and re-adding the QSV gate each redden their own tests and nothing else. ## Review Three adversarial rounds (cold, review-only, isolated worktree). Five confirmed defects in round 1, two in round 2, zero in round 3 — all in durable artefacts (a record naming a test file that never existed, a conventions bullet contradicted by the file it described, a comment stating a stricter rule than its comparison, a help panel still teaching the retracted rule, and a duplicate `### 3c`). One low-severity UX residual is deliberately deferred and filed separately: when a legacy non-QSV profile is copied, the 422 names a control the editor only renders under QSV. fixes #735
timothy added 1 commit 2026-08-26 23:35:47 +02:00
feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
review-verdict/h10 Review-verdict: MERGEABLE @ 5459e21 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Docs update reminder (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 27s
Review verdict / Set review-verdict status (pull_request_target) Successful in 12s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m7s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m50s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
5459e21e81
The write path used to accept an out-of-range `qsvExtraHardwareFrames`, store the floored value
instead and return 200 — the accepted residual #529 recorded, where a client PUTs 0 and reads back
64. `FFmpegProfileBounds` now rejects a newly submitted out-of-range value with a 422 naming the
bound and its consequence; the render-time floor in `FFmpegState` stays, which is what keeps the
change migration-free for rows written before this existed. On update an UNCHANGED legacy value is
still accepted, because the SPA round-trips the whole profile and rejecting it would make an old
profile uneditable over a field the operator never touched and, outside QSV, cannot see.

The `-readrate` / `-readrate_catchup` pacing that `PipelineBuilderBase` hardcoded becomes two
nullable profile fields with the same bounded treatment. During #726 the only way to try a
different pacing value was to rebuild the app and replay command lines by hand on the Docker host.
`null` means unset and resolves to the values the pipeline used before, so an untouched profile
paces identically; catchup stays on by default and capability-gated in code.

The draft helpers moved out of `FFmpegProfilesScreen.tsx` into `ffmpegProfileDraft.ts` so the
bounds can be tested directly — a component module that exports non-components trips
react-refresh, and the baseline was warning-free.

Mutation-checked: ignoring the pacing override, disarming each bound, and removing the
unchanged-legacy exemption each redden their own tests.

fixes #735

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ 5459e21

Three cold adversarial rounds (isolated worktree): 5 confirmed defects round 1, 2 round 2, 0 round 3. All fixes mutation-proved. Live-E2E + UI-E2E green.

Review-verdict: MERGEABLE @ 5459e21 Three cold adversarial rounds (isolated worktree): 5 confirmed defects round 1, 2 round 2, 0 round 3. All fixes mutation-proved. Live-E2E + UI-E2E green.
timothy merged commit ed8b602445 into main 2026-08-27 00:05:39 +02:00
timothy deleted branch fix/735-ffmpeg-profile-bounds 2026-08-27 00:05:41 +02:00
Sign in to join this conversation.