fix(moq-video): convert and label RGB captures in one color space - #2553
Merged
kixelated merged 3 commits intoJul 29, 2026
Merged
Conversation
Every RGB-to-I420 conversion hardcoded BT.601 limited range, and nothing wrote a VUI color description into the encoded bitstream. A 1080p screen capture on Windows or Linux was therefore converted with BT.601, encoded untagged, and decoded by any standards-compliant player using the usual size heuristic (BT.601 at 576 lines and below, BT.709 above). The player applied the BT.709 inverse to BT.601 samples, so saturated colors skewed: pure red arrived as (255, 24, 0). Grays sit on the achromatic axis and are unaffected, which is why it survived casual inspection. Fix both halves against one source of truth. Color::infer(size) already encoded the player's heuristic for the renderer, so the conversions now pick their matrix from it instead of hardcoding BT.601, and the encoders write that same answer into the VUI. The pixels and their label are derived from the same function for the same size, so they cannot drift. The two fixes are complementary rather than alternatives. Writing the VUI removes the guess for a compliant decoder, but Media Foundation treats the color attributes as a request a driver may ignore, and the VAAPI backend cannot express a color description at all (VAEncSequenceParameterBufferH264 has no colour fields; moq-vaapi would have to write it into its packed SPS). Picking the matrix by resolution is what keeps those paths correct, because it makes the untagged interpretation the right one. Also stop from_yuyv claiming a color space. It only resamples chroma, so it learns nothing about the space the camera's samples were already in, and inheriting BT.601 from pack() would mislabel a 1080p YUYV webcam. Regression tests read the color description back out of the emitted SPS rather than trusting our own config, so a backend that drops the VUI fails. Both openh264 and VideoToolbox are verified at 480p and 1080p; NVENC and Media Foundation are compile-checked only (Linux via podman, Windows not at all), and VAAPI is covered by the conversion half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- In
test_util::declared_color, panicking on unexpectedmatrix_coefficientsvalues (panic!("unexpected matrix_coefficients {other}")) makes this helper fragile to any future encoder/driver emitting other valid matrices; consider returningNoneor a dedicated error instead so tests can handle that gracefully. - The transfer characteristics used for BT.601 differ between backends (e.g., NVENC uses
SMPTE170Mwhile MediaFoundation/VideoToolbox use the 709 transfer curve); it may be worth aligning these choices or documenting the intentional mismatch so behavior is consistent across encoders.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `test_util::declared_color`, panicking on unexpected `matrix_coefficients` values (`panic!("unexpected matrix_coefficients {other}")`) makes this helper fragile to any future encoder/driver emitting other valid matrices; consider returning `None` or a dedicated error instead so tests can handle that gracefully.
- The transfer characteristics used for BT.601 differ between backends (e.g., NVENC uses `SMPTE170M` while MediaFoundation/VideoToolbox use the 709 transfer curve); it may be worth aligning these choices or documenting the intentional mismatch so behavior is consistent across encoders.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Review feedback on #2553. Two changes, both from the same root: the test helper folded the SPS's color description onto `Color`, and `Color` cannot represent the fields the backends were disagreeing on. The backends emitted different transfer characteristics for BT.601. openh264 (via `VuiConfig::bt601()`) and NVENC wrote SMPTE 170M (code point 6); VideoToolbox and Media Foundation wrote BT.709 (1). The two curves are defined identically, so nothing renders differently, but the same content encoded on two machines described itself two ways. Align on 1: CoreVideo's 170M transfer constant is deprecated and Media Foundation has none, so 1 is the only value all four backends can actually emit. BT.601 now goes out as SMPTE 170M primaries and matrix with the BT.709 curve, everywhere. The old test could not see this, because it compared `Color`s and `Color` names a matrix and a range, not a transfer curve. `declared_color` now returns the raw code points, and the two backend tests assert all four fields against shared constants. Mutation-checked: restoring openh264's `VuiConfig::bt601()` transfer now fails with `transfer: 6` vs `1`. That reshape also drops the `panic!` on an unrecognized matrix. Mapping to `Color` forced the helper to reject code points it could not represent; returning the raw description means an unexpected value just fails the comparison, with the actual triple in the assertion output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or space Review finding on #2553. The encoder derived its VUI from the config size, but `Frame::resize` carries the pixels' color space across untouched. Scaling an HD frame to a rung below 576 lines therefore produced BT.709 samples under a BT.601 label, which is the same mismatch this PR set out to fix, one layer up. Three parts. `Surface::color` reports the space a frame's samples are in, and is now public so a caller that resizes can pass it back to the encoder. On macOS it answers for a pixel buffer too, reading the matrix that VideoToolbox copies out of the stream's VUI onto every decoded buffer. A decoded frame now reports the space its own source declared rather than one guessed from its size, which is the plumbing the encoder side needed. `Encoder::encode` warns once when a frame declares a space other than the one the session wrote into the VUI, naming `Config::color` as the fix. A warning rather than a rejection: the VUI is fixed when the session opens, so the only alternatives are a mislabeled stream or a dropped one, and a gateway transcoding a source whose VUI disagrees with its resolution should keep serving. It is no worse than the untagged stream that came before. moq-transcode opens its encoders from the first decoded frame instead of at session start, passing that frame's color through, so a rung inherits the source's space rather than inventing one from its own size. Both the live and fetch paths hold a keyframe request until the encoder exists, which is the same thing the encoder already did internally with `pending_keyframe`. Also from the review: a missing blank line in color.rs, and an intra-doc link to `crate::render` that only resolved under `--all-features`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kixelated
added a commit
that referenced
this pull request
Jul 29, 2026
Review finding on #2552. #2553 taught `PixelBuffer` to read the matrix off the buffer's `kCVImageBufferYCbCrMatrixKey` attachment, which VideoToolbox copies out of the stream's VUI, and routed `download_i420` through it. The zero-copy Metal import kept inferring the matrix from the frame's size. So the same decoded buffer was read two different ways depending on which path it took: a 1080p source tagged BT.601 rendered correctly once it fell back to the CPU, and wrongly whenever the import succeeded. Which path a frame takes is supposed to be a question of cost, not of what color it comes out. The import now asks the buffer, falling back to the size guess only for the planar format, which carries no such attachment. Also drop the claim in `Color`'s docs that a decoded frame never knows its own space. That stopped being true when the attachment started being read, and the sentence pointed at `I420::color` rather than the `Surface::color` a caller actually reaches for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kixelated
added a commit
that referenced
this pull request
Jul 29, 2026
#2553 made the RGB conversions convert into `Color::infer`'s space, so the render test asserting they always produce BT.601 no longer held. CI could not catch it: the test needs a GPU adapter and is `#[ignore]`d. The stale assertion was also the whole point of the test. Now that conversion and inference agree by construction, checking one against the other proves nothing, so the test grows a case where they genuinely disagree: a frame converted at SD and scaled past 576 lines keeps its BT.601 samples while its size implies BT.709. Rendering that by size alone reproduces the original (255, 24, 0), confirmed by mutation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2552 (the
renderwork), which is whereColorandColor::infercome from. Base this onmainonce that lands.The bug
Every RGB-to-I420 conversion hardcoded BT.601 limited range, and nothing wrote a VUI color description into the encoded bitstream. So a 1080p screen capture on Windows or Linux was converted with BT.601, encoded untagged, and then decoded by any standards-compliant player using the usual size heuristic (BT.601 at 576 lines and below, BT.709 above). The player applies the BT.709 inverse to BT.601 samples.
Saturated colors skew. Pure red arrives as
(255, 24, 0), confirmed by round-tripping a 1080p frame throughI420::from_rgbaand back through the BT.709 inverse. Grays sit on the achromatic axis and are untouched, which is why this survived casual inspection.The fix
Both halves, against one source of truth.
Color::infer(size)already encoded the player's heuristic for the renderer, so:The pixels and the label are derived from the same function for the same size, so they cannot drift.
encode::Configgains an optionalcoloroverride for callers feeding frames the crate did not convert;NonemeansColor::infer.Why both, rather than just the VUI
These are complementary, not alternatives. Writing the VUI removes the guess for a compliant decoder, but it does not reach every path:
VuiConfigh264VUIParameters/hevcVUIParametersMF_MT_*attrs on both media typesVAEncSequenceParameterBufferH264has no colour description fields at all (itsvui_fieldscovers aspect ratio, timing, and bitstream restriction), so the VAAPI backend cannot tag its output today. Media Foundation treats the color attributes as a request a driver may ignore. Picking the matrix by resolution is what keeps both of those correct, because it makes the untagged interpretation the right one.Testing
The regression tests read the color description back out of the emitted SPS rather than trusting our own config, so a backend that silently drops the VUI fails rather than passing on bookkeeping.
h264-readeris a new dev-dependency for that, rather than hand-rolling exp-Golomb in a test.rgb_conversion_follows_the_size_heuristiccovers 480p/576p/720p/1080p. Mutation-checked both ways: reverting the matrix while keeping the label honest reproduces[255, 24, 0]exactly, and reverting the label alone also fails.the_sps_declares_the_color_space(openh264) andvideotoolbox_sps_declares_the_color_spaceparse the real bitstream at 480p and 1080p.cargo check --all-targetswithnvenc,vaapiis clean in a Fedora container;--features renderstill builds.Reviewer notes
IMFMediaTypeimport along the way), so the code builds against the real SDK. Nothing exercises it at runtime, since CI has no encoder hardware.moq-vaapiwrites its own packed SPS, so the colour description could go in there, but that is a separate repo and a version bump. Not urgent: the conversion half already makes VAAPI correct.Color::limited()moves back ontoColorfromrender/color.rs. feat(moq-video): render decoded frames on the GPU, and carry color spaces end to end #2552 made it a private render helper on the reasoning that deriving a matrix is the shader's business; the encoders now need it forvideo_full_range_flag, so it is no longer render-only.weights()stays inrender/.from_yuyvfix in feat(moq-video): render decoded frames on the GPU, and carry color spaces end to end #2552 (64bb705) and mine were the same change, arrived at independently. Kept feat(moq-video): render decoded frames on the GPU, and carry color spaces end to end #2552's version and dropped my duplicate test.hang's catalog should carry the color description is a real question, but it is redundant with the bitstream and needs adraft-lcurley-moq-hang.mdupdate, so it belongs in its own PR.Update: resized frames (review follow-up)
The encoder took its VUI from the config size, but
Frame::resizecarries thepixels' color space across. Scaling an HD frame down to a sub-576-line rung
produced BT.709 samples under a BT.601 label, the same mismatch one layer up.
Surface::coloris now public and answers for macOS pixel buffers by reading thematrix VideoToolbox copies out of the source VUI, so decoded frames report their
real space. moq-transcode opens its encoders from the first decoded frame and
passes that space through, so a rung inherits the source's rather than inventing
one.
Encoder::encodewarns once on a residual mismatch and namesConfig::coloras the fix; it warns rather than rejects because the VUI is fixedat session open, so the alternatives are a mislabeled stream or a dropped one,
and a gateway should keep serving.
(written by Opus 5)