Files
ersatztv/docs/decisions/records/api/ffmpeg-profile-numeric-bounds.md
T
timothyandtimothy ed8b602445
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m23s
feat(735): bound the numeric FFmpeg profile fields with a 422, and expose readrate pacing (#847)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 22:05:38 +00:00

8.4 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
api.ffmpeg-profile-numeric-bounds 2026-08-26 — Consequential numeric FFmpeg profile fields are REJECTED out of range, not accepted and rewritten; readrate pacing becomes a bounded profile field (#735) active 2026-08-26 none none A write path that receives an out-of-range value for a consequential numeric FFmpeg profile field returns 422 naming the bound AND the consequence of exceeding it, instead of storing a substitute and returning 200. `FFmpegProfileBounds` (ErsatzTV.Application/FFmpegProfiles) is the single validator, called from both the create and the update handler, and it validates against constants declared on `FFmpegState` beside the render-time resolution rather than restating numbers — `MinimumQsvExtraHardwareFrames`, `Minimum/MaximumReadRate` and `MaximumReadRateCatchup` are read by BOTH the validator and the renderer, while `MinimumReadRateCatchup` is write-path-only (at render time the resolved base rate is always at least `MinimumReadRate`, so it can never be the binding floor). THE RENDER-TIME CLAMPS STAY: they cover rows written before this validation existed or out of band, and keeping them is what makes the change migration-free. ON UPDATE, only a NEWLY submitted out-of-range value is rejected — an UNCHANGED legacy value is written back as-is, because the SPA sends the whole profile on every edit and rejecting it would make an old row uneditable over a field the operator never touched and, when hardware acceleration is not QSV, cannot see. Separately, the readrate pacing that `PipelineBuilderBase` hardcoded is now two nullable profile fields, `ReadRate` and `ReadRateCatchup`; `null` means unset and resolves to the values the pipeline used before they were configurable, so an untouched profile paces identically. `-readrate_catchup` stays ON by default and capability-gated in code — this makes it tunable, not optional. silent transform of a submitted value · PUT 0 returns 200 and reads back 64 · 422 naming the bound · extra_hw_frames floor · readrate · readrate_catchup · pacing is not exposed to an operator · bounded numeric profile field · legacy row stays editable · paths: `ErsatzTV.Application/FFmpegProfiles/FFmpegProfileBounds.cs`, `ErsatzTV.FFmpeg/FFmpegState.cs`, `ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs`, `ErsatzTV/Controllers/Api/Requests/CreateFFmpegProfileRequest.cs`, `web/src/screens/ffmpegProfileDraft.ts`, `web/src/screens/FFmpegProfilesScreen.tsx` · issues: #735, #726, #529, #350 `FFmpegProfileBounds.Validate{QsvExtraHardwareFrames,ReadRate,ReadRateCatchup}` return `Validation<BaseError, Unit>` and are applied alongside the name/thread-count checks in both handlers; the payload is `Unit` because LanguageExt `Validation.Success` throws on a null-valued `Nullable<T>`. `FFmpegState.ReadRateFor(isStreamCopy)` / `ReadRateCatchupFor(isStreamCopy)` resolve and clamp at render time. Pinned by `FFmpegProfileHandlerTests` (rejection, the unchanged-legacy acceptance, and the persisted-null default), `PipelineBuilderBaseTests` (a configured value reaches the command line; an out-of-range one is clamped) and `web/src/screens/ffmpegProfileDraft.test.ts` (the SPA mirrors the same bounds and the same legacy exemption). The SPA constants are hand-mirrored literals with NO cross-language pin — see the record body.
  • The wart was the silence, not the floor. ffmpeg.qsv-extra-hw-frames-floor recorded, as an accepted residual, that a client which PUTs 0 gets a 200 and then reads back 64. The floor itself was right — extra_hw_frames=0 is a channel that serves nothing at all — but an accepted value that is stored as a different one leaves the config no longer describing the behavior, and the OpenAPI document never advertised the substitution. A rejection with a reason teaches the bound; a rewrite hides it. The floor is now the second line rather than the first.

  • Both halves are needed, and each has its own failure. Validation alone would leave every row written before it existed unbounded at render time; the render clamp alone is what produced the silent transform in the first place. So: validate on write, clamp on render, and test both paths. The clamp is also the reason no backfill migration is required, which was the deliberate #529 trade-off and still holds.

  • An unchanged legacy value is accepted — deliberately. The obvious strict rule ("reject any out-of-range value") makes an old profile uneditable: the SPA round-trips the whole profile, so renaming a pre-#529 profile would 422 over qsvExtraHardwareFrames, a field the editor only renders when hardware acceleration is QSV. The validator therefore compares against the stored value and rejects only a change. The cost is a contract that is not purely a function of the request — the same body can be accepted or rejected depending on stored state — and that is stated in the OpenAPI description rather than left to be discovered.

  • The pacing values were unreachable, and that cost a whole diagnosis. During #726 the only way to try a different -readrate was to rebuild the app; the diagnosis had to be done by replaying command lines by hand on the Docker host. They are now ReadRate / ReadRateCatchup on the profile. The defaults are unchanged and live on FFmpegState beside the bounds, so "what does an unset profile do" is answerable in one place.

  • Bounded, not free-form, and the ceilings are chosen rather than measured. The floors are evidence-backed: below realtime the process reads slower than a live client consumes and the channel stalls, which is #726 measured at an effective 0.53x, and a catchup below the base rate cannot let a lagging input recover at all. The ceilings (2.0 and 10.0) are chosen — they exist so the field cannot be used to effectively disable pacing, which is the unthrottled-read condition #529 measured to produce zero segments on a QSV pipeline. Say so rather than implying a cliff was found there.

  • What is deliberately NOT exposed. -readrate_initial_burst stays derived from the HLS segment length (OutputFormatHls.SegmentSeconds * 2) rather than becoming a third field: it is a function of the segmenter's own configuration, not an independent dial. The concat/wrap-segmenter wrapper's bare -readrate 1.0 also stays hardcoded: it reads ErsatzTV's own loopback output rather than a media file's demuxer, so it is not the input #726 was about. (FFmpegState.Concat takes no profile, but that is a consequence of the decision, not the reason for it — both call sites have channel.FFmpegProfile in scope and could pass it.) And a raw-args passthrough remains out of scope here (#736 tracks it as a separate advanced-gated feature with its own safety posture): these are known-dangerous knobs, which is exactly the case for bounding and explaining them.

  • Two residuals, stated rather than implied. (1) FFmpegProfileRepository.Copy clones every current value (CurrentValues.Clone()), including these fields, and CopyFFmpegProfileHandler validates only the name — so it can propagate a legacy out-of-range value into a new row. That is consistent with the unchanged-value exemption (Copy takes no operator-supplied number, so it can never introduce one), and the command has no route today: no controller action, no MCP tool, and the SPA's copy goes through POST to the validated create handler. If Copy is ever given a route, route it through FFmpegProfileBounds first. (2) The SPA's mirrored bounds are hand-written literals in ffmpegProfileDraft.ts with nothing pinning them to FFmpegState; raising a server bound leaves every test green while the form keeps enforcing the old one. Left unpinned on purpose: a C#-constant-versus-TS-literal guard is a string predicate over two languages, and the drift it would catch is a needlessly strict form, not a bad value reaching FFmpeg. Mirror the value AND the wording instead, and re-read this residual when a bound moves.

  • The SPA's numeric bound is checked whatever the acceleration is, matching the server. The editor only renders qsvExtraHardwareFrames under QSV, but validate() does not gate on that: copying a legacy QSV profile and switching acceleration to None would otherwise submit a draft the create handler rejects over a field the form is no longer showing. A conditionally-rendered field still needs its unconditional check.