Skip to content

feat(moq-video): render decoded frames on the GPU, and carry color spaces end to end - #2552

Merged
kixelated merged 7 commits into
mainfrom
claude/moq-2481-priorities-447fff
Jul 29, 2026
Merged

feat(moq-video): render decoded frames on the GPU, and carry color spaces end to end#2552
kixelated merged 7 commits into
mainfrom
claude/moq-2481-priorities-447fff

Conversation

@kixelated

@kixelated kixelated commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

First half of the render module from #2481's Wave 1. Adds a render role module to moq-video behind a non-default render feature: a wgpu pipeline that converts a decoded frame to RGB and hands back a texture the caller presents.

This is the egress half of the zero-copy story #2467 started. Surface::PixelBuffer has had a producer (capture, VideoToolbox decode) and no consumer since decoded frames began staying GPU-resident; this is the consumer.

Summary

  • render::Renderer takes the application's wgpu::Device/Queue and returns a plain wgpu::Texture from render(&Frame). That texture is the whole integration seam, so nothing here depends on windowing or a UI toolkit. The renderer caches its pipeline, plane textures, and output texture across frames.
  • Zero-copy Metal import: CVMetalTextureCache aliases the decoder's IOSurface planes as Metal textures, adopted through wgpu's hal seam. The CoreVideo wrapper rides in the texture's drop callback, because it is the wrapper (not the MTLTexture) that holds the source CVPixelBuffer open. Releasing it when the import returns lets the decoder's pool recycle the surface while a submitted draw is still sampling it: a torn frame under pool pressure and nothing at all when the pool is idle.
  • Universal fallback: any surface the fast paths do not recognize uploads through Surface::into_i420. An import path that keeps failing retires itself after three strikes rather than paying for the attempt at frame rate.
  • Color correctness: I420 now records the color space it was converted with, and only a conversion that actually picks a matrix labels its output. The RGB conversions emit BT.601 limited at all resolutions, so inferring BT.709 from a frame's size (what a player does with an untagged stream) applied the wrong inverse matrix above 576 lines and skewed saturated colors: red rendered as (255, 24, 0). Paths that merely move samples around leave the space open for the consumer to infer, which matters for V4L2 YUYV capture, where claiming BT.601 would pin a 720p camera to the wrong matrix instead of letting the heuristic get it right. The macOS download does carry the range through, since the pixel format names it.

Deliberately split: the Vulkan DMA-BUF and EGL/GLES importers land with Surface::DmaBuf, which they are the consumers for. A #[non_exhaustive] variant with neither a producer nor a consumer would be dead public API, so it ships alongside VAAPI/PipeWire in one hardware-validated series.

Public API changes

All additive; nothing renamed, removed, or signature-changed. Targets main per Branch Targeting.

  • New render cargo feature (off by default) and moq_video::render module: Renderer, Config, and a re-exported wgpu (a major bump of which is a breaking change for this crate, same contract as the existing objc2_core_video re-export).
  • New moq_video::Color enum (#[non_exhaustive]) with Color::infer.
  • New I420::color() and I420::with_color(). I420::new is unchanged and defaults the color to unknown.
  • New Error::Render variant on the already-#[non_exhaustive] error enum.
  • Size::validate_nonzero is pub(crate), split out of validate so an RGB render target is not held to I420's even-dimension rule.
  • From fix(moq-video): convert and label RGB captures in one color space #2553: Surface::color() reports a frame's color space where the crate knows it, and encode::Config::color overrides what the encoder writes into the VUI. h264-reader is added as a dev-dependency for the SPS-parsing tests.

Default moq-video, moq-relay, and moq-cli builds pull no wgpu; confirmed with cargo tree.

Test plan

  • just check clean (workspace, --all-features, on current main).
  • 70 moq-video tests pass, including the wgpu ones run with --ignored on an M-series Mac. The four GPU tests are #[ignore] because CI has no adapter; the color math they depend on is covered by tests that need no device and do run in CI.
  • Verified across {macOS, Linux} x {default features, --all-features} (clippy, rustdoc, and tests), the Linux half in a Fedora container. That matrix exists because an item is only dead where its consumers are compiled out: three helpers here are reachable solely from the macOS import or the non-default render feature, so a macOS-only check cannot see them. The container was confirmed to reproduce each failure before its fix rather than being trusted for going green.
  • The HD color regression was verified to fail against the previous behavior (got [255, 24, 0, 255], expected about [255, 0, 0, 255]) before the fix. The sibling render tests all run at 64x64, which sits below the 576-line threshold where the size guess happens to agree with the conversion, so only that test pins it down.
  • imports_survive_decoder_pool_recycling renders 24 frames through a fixed-size pool, dropping each immediately, asserting none fell back to the CPU. It is a race, so passing is evidence rather than proof.
  • the_metal_import_matches_the_cpu_path asserts the zero-copy output is pixel-identical to the upload path, and that strikes == 0 so a silent fallback cannot satisfy it.
  • yuyv_capture_keeps_its_color_space_open covers the V4L2 path; Linux-only, since that is where it compiles.
  • Re-verified on the combined head after fix(moq-video): convert and label RGB captures in one color space #2553 merged in: just check clean, and CI green on Linux, Windows, and macOS.
  • fix(moq-video): convert and label RGB captures in one color space #2553 changed what the RGB conversions produce, which stale-dated one of the #[ignore]d GPU tests that CI structurally cannot run. Fixed on the combined head, and strengthened: it now also renders a frame converted at SD and scaled past 576 lines, where the samples stay BT.601 while the size implies BT.709. Mutation-checked (renderer ignoring the declared space reproduces the original (255, 24, 0)).

Cross-package sync

  • No wire, catalog, or container change, so no draft update.
  • Skipped: the doc/ row. moq-video has no page under doc/ at all today, so this would mean authoring the crate's entire documentation presence rather than extending a page. The module and item docs cover the consumer surface via docs.rs. Worth doing as its own change.

Stacked change: the encode side, from #2553

The encode-side half of the same bug landed here rather than separately: #2553 was opened against this branch and merged into it, so this PR now carries both. Squash-merging produces one commit for the pair.

The bug it fixes was pre-existing and worse than the rendering one, because it reaches players we do not control: every RGB-to-I420 conversion was BT.601 at all resolutions, and nothing wrote a VUI color description, so Windows Desktop Duplication, PipeWire, and V4L2 capture above 576 lines shipped BT.601 pixels in an untagged stream that a compliant player decodes as BT.709.

  • The RGB conversions now convert into Color::infer's space for the frame's size rather than always BT.601, and label the result. Pixels and label are chosen together, so they cannot drift apart.
  • Every H.264 backend (VideoToolbox, Media Foundation, NVENC, openh264) writes the color description into the SPS VUI, so a decoder stops guessing.
  • encode::Config::color pins the space when frames come from somewhere the crate did not convert. A frame whose declared space disagrees with the encoder's warns once instead of failing, so a live gateway keeps serving rather than dropping the stream.
  • moq-transcode opens each rung's encoder from its first frame, since a rung scaled below 576 lines still carries the source's space and would otherwise be labeled by its own size.

The tests parse the color description back out of the encoded SPS rather than asserting on config, so a backend that drops the VUI (or a driver that ignores the request) fails instead of passing on our own bookkeeping.

Refs #2481, #2467, #2272, #1837.

(Written by Opus 5)

Adds a `render` role module behind a non-default `render` feature: a wgpu
pipeline that converts a decoded frame to RGB and hands back a texture the
caller presents. This is the egress half of the zero-copy story #2467 started,
and the consumer `Surface::PixelBuffer` has been missing since decoded frames
began staying GPU-resident.

A hardware-decoded frame is imported by aliasing the decoder's IOSurface as a
Metal texture through CVMetalTextureCache, adopted via wgpu's hal seam. The
CoreVideo wrapper rides in the texture's drop callback, since it is what holds
the pixel buffer open: releasing it early lets the decoder's pool recycle the
surface while a submitted draw is still sampling it. Anything the fast path
does not recognize falls back to a plane upload, and an import path that keeps
failing retires itself.

I420 now records the color space it was converted with rather than leaving it
to be guessed downstream. The crate's RGB conversions all emit BT.601 limited
at every resolution, so inferring BT.709 from a frame's size (what a player
does with an untagged stream) skewed saturated colors above 576 lines: red
rendered as (255, 24, 0). Passthrough paths still infer, since nothing names
the space those samples are in.

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 reviewed your changes and they look great!


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.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ba3f3f49-ca52-4d56-901f-a54f6462a278

📥 Commits

Reviewing files that changed from the base of the PR and between 18eecc8 and ddc8977.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • rs/moq-transcode/src/rung.rs
  • rs/moq-video/Cargo.toml
  • rs/moq-video/src/color.rs
  • rs/moq-video/src/encode/backend/mediafoundation.rs
  • rs/moq-video/src/encode/backend/mod.rs
  • rs/moq-video/src/encode/backend/nvenc.rs
  • rs/moq-video/src/encode/backend/openh264.rs
  • rs/moq-video/src/encode/backend/videotoolbox.rs
  • rs/moq-video/src/encode/encoder.rs
  • rs/moq-video/src/frame.rs
  • rs/moq-video/src/render/metal.rs
  • rs/moq-video/src/render/renderer.rs

Walkthrough

Adds color-space and range metadata to I420 frames, including inference for frame size and macOS NV12 formats. Introduces an optional render feature with wgpu-based NV12/I420 shader rendering, reusable output textures, CPU plane uploads, and zero-copy fallback handling. On macOS, decoded pixel buffers can be imported through Metal texture caches while retaining CoreVideo texture lifetimes. Adds public renderer configuration and APIs, render errors, documentation, and GPU-focused tests.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding GPU rendering for decoded moq-video frames.
Description check ✅ Passed The description is directly related and covers the render feature, zero-copy import, fallback uploads, and color metadata changes.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/moq-2481-priorities-447fff

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-video/src/frame.rs`:
- Around line 527-537: Update the packed-frame construction around pack and
from_yuyv so from_yuyv passes None for the color metadata, while RGB-derived
callers continue passing Some(Color::Bt601Limited). Preserve the input
color/range for BT.709 and full-range V4L2 YUYV frames, and add a regression
test verifying I420::color() takes precedence over resolution-based inference
and prevents incorrect conversion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: add985e2-8a33-434b-85a3-d0abe58fa3dc

📥 Commits

Reviewing files that changed from the base of the PR and between da45f8a and ca6ee35.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • rs/moq-video/Cargo.toml
  • rs/moq-video/src/capture/channel.rs
  • rs/moq-video/src/color.rs
  • rs/moq-video/src/error.rs
  • rs/moq-video/src/frame.rs
  • rs/moq-video/src/lib.rs
  • rs/moq-video/src/render/color.rs
  • rs/moq-video/src/render/metal.rs
  • rs/moq-video/src/render/mod.rs
  • rs/moq-video/src/render/renderer.rs
  • rs/moq-video/src/render/shader.wgsl
  • rs/moq-video/src/render/source.rs
  • rs/moq-video/src/size.rs

Comment thread rs/moq-video/src/frame.rs Outdated
The Linux CI job failed on dead code that a macOS build cannot see: an item is
only dead where its consumers are cfg'd or feature-gated out, and on macOS
those consumers all compile.

- `Layout::Nv12` and its pipeline are constructed only by the Metal importer,
  so they follow it. The shader still declares the entry point everywhere, so
  naga keeps validating it on every platform.
- `Color::with_range` serves surfaces whose pixel format names their range,
  which today is only CoreVideo's NV12.
- `Color::{weights, limited}` derive the shader's matrix and had no business on
  the shared enum: they move into the render module, which is the only caller
  and is itself behind a non-default feature.

Verified across {macOS, Linux} x {default, all-features}; the Linux half runs
in a Fedora container, confirmed to reproduce the original failure before the
fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rs/moq-video/src/color.rs`:
- Around line 46-51: Update the documentation near the matrix range method to
remove the speculative sentence about widening for future platform import paths,
keeping only the description of current range-specific behavior and the existing
NV12 case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 25e5704d-f0c4-4ed6-b00a-b791b2e29f2d

📥 Commits

Reviewing files that changed from the base of the PR and between ca6ee35 and 97a1a05.

📒 Files selected for processing (4)
  • rs/moq-video/src/color.rs
  • rs/moq-video/src/render/color.rs
  • rs/moq-video/src/render/renderer.rs
  • rs/moq-video/src/render/source.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • rs/moq-video/src/render/color.rs
  • rs/moq-video/src/render/source.rs
  • rs/moq-video/src/render/renderer.rs

Comment thread rs/moq-video/src/color.rs Outdated
kixelated and others added 2 commits July 28, 2026 12:37
…uced

`pack` hardcoded BT.601 limited for all four of its callers, but `from_yuyv`
is a 4:2:2 -> 4:2:0 chroma resample with no color conversion: V4L2 hands back
samples already in the camera's space. Tagging those BT.601 regressed the
capture path, since a 720p camera is usually BT.709 and the resolution
heuristic had been getting it right. `pack` now takes the color, so a caller
that picks a matrix states it and one that only moves samples leaves it open.

Also stop linking `Surface::PixelBuffer` from the render module's docs. The
variant is macOS-only, so the link dangles in the docs built for every other
platform and fails CI's `-D warnings` rustdoc pass.

Regression tests for both, the YUYV one gated to Linux where that path
compiles; verified in a Fedora container to fail without each fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `Color::with_range` no longer speculates about platforms that might gain an
  import path; it says why it is macOS-only.
- The note explaining why `PixelBuffer` is not a doc link is for whoever is
  tempted to add one, not for a consumer reading docs.rs, so it becomes a plain
  comment instead of doc prose.
- `render::Config::color` still claimed `None` infers from the frame's size.
  It has taken the frame's own `I420::color` first since that field landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kixelated and others added 2 commits July 29, 2026 10:29
)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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
kixelated enabled auto-merge (squash) July 29, 2026 18:58
#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>
@kixelated kixelated changed the title feat(moq-video): render decoded frames on the GPU feat(moq-video): render decoded frames on the GPU, and carry color spaces end to end Jul 29, 2026
@kixelated
kixelated merged commit d49fdfb into main Jul 29, 2026
4 checks passed
@kixelated
kixelated deleted the claude/moq-2481-priorities-447fff branch July 29, 2026 19:17
This was referenced Jul 29, 2026
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