Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
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-floorrecorded, as an accepted residual, that a client whichPUTs0gets a200and then reads back64. The floor itself was right —extra_hw_frames=0is 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
-readratewas to rebuild the app; the diagnosis had to be done by replaying command lines by hand on the Docker host. They are nowReadRate/ReadRateCatchupon the profile. The defaults are unchanged and live onFFmpegStatebeside 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_burststays 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.0also 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.Concattakes no profile, but that is a consequence of the decision, not the reason for it — both call sites havechannel.FFmpegProfilein 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.Copyclones every current value (CurrentValues.Clone()), including these fields, andCopyFFmpegProfileHandlervalidates 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 throughPOSTto the validated create handler. If Copy is ever given a route, route it throughFFmpegProfileBoundsfirst. (2) The SPA's mirrored bounds are hand-written literals inffmpegProfileDraft.tswith nothing pinning them toFFmpegState; 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
qsvExtraHardwareFramesunder QSV, butvalidate()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.