Independent cold review (Codex, no implementation role) found no Blocker and three real defects, all fixed here: HIGH — HDR was re-tagged bt709 only when the profile had NormalizeColors on. The colorspace filter sat behind desiredState.ColorsAreBt709, so an operator with normalization disabled got tonemapped SDR pixels still tagged bt2020 and the player converted them a second time. The guard is now "tonemapped || (ColorsAreBt709 && ...)". Deliberately NOT fixed by hoisting usesVppQsv out of the guard, which would force bt709 on scale-only non-HDR chains that legitimately opted out. MEDIUM — image subtitles stopped being resized. The subtitle canvas is scaled only when the video chain contains a recognized scale filter, and that predicate listed the QSV filters only; swapping ScaleQsvFilter for ScaleVaapiFilter left a 4K HDR + PGS source with a 720p video and a source-size subtitle overlay. VaapiPipelineBuilder already listed ScaleVaapiFilter; QsvPipelineBuilder does now. MEDIUM — anamorphic HDR now falls back to the software tonemap. ScaleQsvFilter is handed the SAR VideoStream calculates (it has a 0:0 fallback); ScaleVaapiFilter multiplies by ffmpeg's runtime `sar`, which differs when the decoded frame leaves SAR unspecified. Rather than ship a graph nobody has run, exclude anamorphic -- which leaves those sources exactly where they were before this change. LOW — tests now pin the exact validated graph as an ordered substring (the prior assertions would have passed with setFormat off, hwdownload dropped, or the wrong tonemap output format), assert against the vpp_qsv OPTION rather than a bare "tonemap=1" substring, and cover NormalizeColors=false, anamorphic and image subtitles. Each of the three fixes was negative-controlled: reverting it fails exactly one test, and no others. The remaining LOW (deriveDevice's defaulted bool is a future-call-site trap) is recorded as an accepted residual rather than fixed, since a named factory would push this diff into the VA-API pipeline for no behavior change. The record is 86 prose lines, over the 60-line ceiling. Declining to cut: every bullet is a distinct measured finding, which docs.corpus-size-signal names as a legitimate decline. Decisions-Edit: yes
8.3 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| ffmpeg.qsv-hdr-tonemap-opencl | 2026-07-26 — the QSV pipeline tonemaps HDR through OpenCL, never vpp_qsv (#505) | active | 2026-07-26 | none | none | the QSV pipeline never emits `vpp_qsv=tonemap=1`, which is a SILENT no-op on pre-Gen11 Intel graphics; HDR is tonemapped on the GPU via `hwupload=derive_device=vaapi` → `scale_vaapi` → `hwmap=derive_device=opencl` → `tonemap_opencl` when a VA-API device exists, the frames are still in software, and `tonemap_opencl` is available, and by the software `TonemapFilter` otherwise. The scale runs BEFORE the tonemap, and any hardware filter on the path forces the output to be re-tagged bt709. | QSV, HDR, tonemap, vpp_qsv, tonemap_opencl, hwmap, derive_device, smpte2084, bt2020, washed out HDR, silent no-op, Gen9.5/Gen11, iHD · paths: `QsvPipelineBuilder`, `TonemapOpenClQsvFilter`, `HardwareUploadVaapiFilter`, `ScaleVaapiFilter`, `TonemapFilter` · issues: #505, #498, #523, #529 | `QsvPipelineBuilder.UseOpenClTonemap`; `QsvPipelineBuilder.SetScaleVaapiForTonemap`; `TonemapOpenClQsvFilter`; `HardwareUploadVaapiFilter(setFormat, deriveDevice)`; the `usesVppQsv` predicate in `SetPixelFormat` |
-
vpp_qsv=tonemap=1does not tonemap — it returns the frame untouched, with no warning. Measured on the deployed FFmpeg 8.1.2 / iHD 25.1.4 / UHD 630 (i7-10700K, Gen9.5) against real HDR HEVC Main10 (bt2020nc/bt2020/smpte2084): a graph ending invpp_qsv=tonemap=1produced a frame byte-identical (same md5) to the same graph with no tonemap step at all. True with the explicithevc_qsvdecoder, on genuine QSV video-memory surfaces, with the filter before the scale, after the scale, and withformat=nv12. The output still carriedbt2020/smpte2084tags. QSV VPP tonemapping requires Gen11+; pre-Gen11 iHD ignores the option silently. Reference luma for the same frame: software tonemapYAVG=26.6, no tonemapYAVG=44.3,vpp_qsv44.3. -
So #505's premise was inverted, and the branch it wanted to extend was already broken. The issue asked to route the #498 native-decode path through
TonemapQsvFilterto save CPU; doing so would have shipped untonemapped HDR. Worse, the pre-existingDecoderHardwareAccelerationMode == Qsvbranch already did exactly that — so any operator who flippedQsvPreferNativeDecoderoff, which is precisely the escape hatch #498 and #523 recommend, got washed-out HDR. Prod was not affected (native-decode is the default, and it took the working software branch).TonemapQsvFilteris deleted rather than left in place: a filter that silently does nothing is worse than no filter, because it looks like coverage. -
OpenCL is the working GPU route, and it is genuinely faster — but only if the scale runs first. 300 frames, 3840x1608 HDR HEVC → 1280x720
h264_qsv, two reproducible rounds:arm user CPU total CPU wall (12.5s of content) tonemapped software zscale/tonemap(what #505 wanted to replace)33.2s 35.6s 10.25s yes vpp_qsv=tonemap=1— — — no (no-op) OpenCL, tonemap at full size then scale 13.4s 21.3s 15.5s yes OpenCL, scale_vaapifirst then tonemap9.9s 14.1s 9.25s yes no tonemap at all (floor) 13.1s 15.6s 9.2s no Scale-first cuts total CPU ~60% versus the software tonemap and lands at the no-tonemap wall-clock floor. Tonemapping at full size instead is slower than the software path it replaces (15.5s for 12.5s of content — below realtime), which is why the ordering is a correctness-adjacent requirement and not a micro-optimization. This is what forces the scale decision and the tonemap decision to be made together, up front, in
UseOpenClTonemap. -
A QSV surface is a dead end: it maps to neither OpenCL nor VA-API.
hwmap=derive_device=openclfrom QSV fails ("Media sharing must be enabled on context creation"), andhwmap=derive_device=vaapifrom QSV fails with-38(function not implemented). So the OpenCL route is only reachable while frames are still in software, which is why the gate excludes the QSV decoder (-hwaccel_output_format qsv) andShouldDeinterlace(deinterlace_qsvuploads first). Both fall back to the software tonemap — slower, but correct, which is the whole point. The upload must also sayderive_device=vaapiexplicitly: the QSV pipeline sets-filter_hw_device hw(the QSV device), so a barehwuploadwould land on a QSV surface and strand the frames. -
Tonemapping the pixels is only half the job; the stream has to stop claiming it is HDR. The first end-to-end run on the Intel host was correctly tonemapped (
YAVG=26.39, transferbt709) and still taggedcolor_primaries=bt2020/color_space=bt2020nc, inviting the player to convert it a second time. Cause:SetPixelFormat'susesVppQsvpredicate — which is really "did a hardware filter strip the color info" — listed only the QSV filters, and this path replaces them withScaleVaapiFilter/TonemapOpenClQsvFilter. Both are now in the predicate. The tell was reachable only fromffprobeon the real output; exit code 0 and a correct luma average both looked clean. -
Re-tagging follows the TONEMAP, not the normalization preference. The colorspace filter was originally reached only when
desiredState.ColorsAreBt709(the profile'sNormalizeColors) was on, so a profile with normalization off got tonemapped pixels still tagged bt2020 — the same double-conversion bug as above, just for a different operator setting. The guard is nowtonemapped || (ColorsAreBt709 && …). Deliberately not fixed by hoistingusesVppQsvout of the guard: a scale-only hardware chain on non-HDR content should still respect the preference. Converting the pixels to SDR is what obliges the stream to stop announcing HDR; nothing else does. -
A new scale filter has to be declared to every consumer that asks "was the video scaled". Swapping
ScaleQsvFilterforScaleVaapiFiltersilently broke image-subtitle burn-in: the subtitle canvas is resized only when the video chain contains a recognized scale filter, and that predicate listed the QSV ones only. A 4K HDR source with PGS subtitles scaled the video to 720p and left the subtitle at source size.ScaleVaapiFilteris now in the predicate (as it already was inVaapiPipelineBuilder). Generalizable: replacing a filter means grepping for everyis <OldFilter>type test, not just its construction site. -
Anamorphic sources stay on the software tonemap.
ScaleQsvFilteris handed the SAR thatVideoStreamcalculates (with a fallback for a missing or0:0SAR);ScaleVaapiFilterinstead multiplies by ffmpeg's runtimesar, which is a different value when the decoded frame leaves SAR unspecified. Rather than ship an anamorphic HDR graph nobody has run,IsAnamorphicis excluded from the gate — which leaves those sources exactly where they were before this change, so it costs them nothing. Revisit only with a real anamorphic HDR sample on the Intel host, asserting dimensions/SAR/DAR rather than just exit status.
Accepted residual: HardwareUploadVaapiFilter's deriveDevice is an optional bool defaulting
to false, which is behavior-preserving for all four existing VA-API call sites but is a trap for a
future QSV one: copying the familiar new HardwareUploadVaapiFilter(true) yields a bare hwupload,
which lands on the QSV device (-filter_hw_device hw) and makes the OpenCL mapping unreachable. A
named factory or an explicit device-target enum would be safer; it was not done here because it
would push this diff into the VA-API pipeline for no behavior change. The XML comment on the
parameter is the mitigation.
Accepted residual: on Gen11+ hardware, where vpp_qsv=tonemap=1 presumably does work, we now
use OpenCL instead. That is deliberate — we have no capability probe that can tell the two apart
(FFmpeg reports no error either way, which is the entire problem), and OpenCL is validated here and
is the route Jellyfin uses. The gate is therefore a reachability test (VA-API device present,
frames in software, tonemap_opencl compiled in), never a hardware-generation guess. If a Gen11+
box is ever available to measure, compare the two there before adding a generation check — do not
add one on inference.