Skip to content

fix(moq-video): convert and label RGB captures in one color space - #2553

Merged
kixelated merged 3 commits into
claude/moq-2481-priorities-447ffffrom
claude/moq-video-color-vui
Jul 29, 2026
Merged

fix(moq-video): convert and label RGB captures in one color space#2553
kixelated merged 3 commits into
claude/moq-2481-priorities-447ffffrom
claude/moq-video-color-vui

Conversation

@kixelated

@kixelated kixelated commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2552 (the render work), which is where Color and Color::infer come from. Base this on main once 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 through I420::from_rgba and 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 RGB conversions pick their matrix from it instead of hardcoding BT.601, and
  • the encoders write that same answer into the bitstream's VUI.

The pixels and the label are derived from the same function for the same size, so they cannot drift. encode::Config gains an optional color override for callers feeding frames the crate did not convert; None means Color::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:

Backend VUI Verified
openh264 VuiConfig SPS parsed back, 480p + 1080p
VideoToolbox 3 session properties SPS parsed back, 480p + 1080p
NVENC h264VUIParameters / hevcVUIParameters compiles (Linux, container)
Media Foundation 4 MF_MT_* attrs on both media types compiles on the Windows CI runner
VAAPI not expressible conversion half only

VAEncSequenceParameterBufferH264 has no colour description fields at all (its vui_fields covers 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-reader is a new dev-dependency for that, rather than hand-rolling exp-Golomb in a test.

  • rgb_conversion_follows_the_size_heuristic covers 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) and videotoolbox_sps_declares_the_color_space parse the real bitstream at 480p and 1080p.
  • 64 tests pass on macOS; cargo check --all-targets with nvenc,vaapi is clean in a Fedora container; --features render still builds.

Reviewer notes

  • Media Foundation compiles but is not run. The Windows CI job is green (it caught a missing IMFMediaType import along the way), so the code builds against the real SDK. Nothing exercises it at runtime, since CI has no encoder hardware.
  • VAAPI's VUI is the one gap. moq-vaapi writes 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 onto Color from render/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 for video_full_range_flag, so it is no longer render-only. weights() stays in render/.
  • The from_yuyv fix 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.
  • No wire or catalog change, so no draft update. Whether hang's catalog should carry the color description is a real question, but it is redundant with the bitstream and needs a draft-lcurley-moq-hang.md update, so it belongs in its own PR.

Update: resized frames (review follow-up)

The encoder took its VUI from the config size, but Frame::resize carries the
pixels' 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::color is now public and answers for macOS pixel buffers by reading the
matrix 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::encode warns once on a residual mismatch and names
Config::color as the fix; it warns rather than rejects because the VUI is fixed
at session open, so the alternatives are a mislabeled stream or a dropped one,
and a gateway should keep serving.

(written by Opus 5)

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>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • 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.
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.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

kixelated and others added 2 commits July 28, 2026 23:05
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
kixelated merged commit 560f125 into claude/moq-2481-priorities-447fff Jul 29, 2026
4 checks passed
@kixelated
kixelated deleted the claude/moq-video-color-vui branch July 29, 2026 17:29
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant