Skip to content

Tag encoded video as BT.709 and convert to match - #173

Merged
ruccho merged 11 commits into
mainfrom
feature/color-primaries-bt709
Oct 19, 2026
Merged

ruccho merged 11 commits into
mainfrom
feature/color-primaries-bt709

Conversation

@ruccho

@ruccho ruccho commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #163.

Problem

The MP4 files produced by every backend carried no color information at all — no colr box and no colour description in the SPS VUI. Players have to guess at the color space, and some warn about it.

Two backends were also converting with the wrong matrix, so this was not purely a tagging gap:

  • VideoFrameBgra32::to_yuv420_planes (the readback path on Android and Windows) used BT.601 limited-range coefficients.
  • The FFmpeg backend let FFmpeg pick the matrix, which is BT.601 at the resolutions this library records. Measured with a pure-red frame at the default 1280x720: Y=81 (BT.601) before, Y=63 (BT.709) after.

Change

Tag the output as BT.709 limited range (primaries 1, transfer 1, matrix 1, limited range) on all backends, and make the conversions actually produce BT.709 so the tags describe the pixels.

  • Apple — color properties on the VTCompressionSession, plus matching format-description extensions in the muxer. AVAssetWriter writes the colr box from the format description it is handed, and since encoded frames travel back through the caller as opaque buffers, the muxer never sees the encoder's own.
  • Android — color-standard / color-transfer / color-range on the encoder format, restated on the muxer track format because codecs are not guaranteed to echo them back.
  • Windows — color attributes on both input and output media types, and a colr box added by the muxer after finalizing. Neither the MPEG-4 file sink nor the Microsoft software H.264 encoder writes any color description, whatever the media types say: the sink writes no colr box, and the encoder writes no video signal type into the VUI and documents no attribute or codec property for it. The sink writes moov as the last top-level box, so mux/colr.rs rewrites moov in place with an nclx box appended to the video sample entry; no media data or chunk offset moves. A file laid out any other way is left as written, with a warning. The media type attributes are kept for hardware encoders that may honor them.
  • FFmpeg — -vf setparams=.... The -color_primaries / -color_trc output options are not reliably forwarded to the encoder: on 8.0.1 only the matrix and range reach the VUI. setparams also selects the conversion matrix, fixing the BT.601 conversion above.
  • WebCodecs — convert to I420 in Rust with the same BT.709 conversion as the readback paths (VideoFrameBgra32::to_i420) and hand the browser an I420 frame tagged BT.709 limited range. Handing it a BGRA frame leaves the YUV conversion to the browser, and browsers disagree: Chrome 154 converts into whatever matrix and range the frame's color space declares, while the Chrome on the CI runner converted the same frame to BT.601 and tagged it as such. With no color space declared, Chrome tags the output as sRGB transfer and full range. muxide writes no colr box, so on the web the SPS VUI is the only declaration.

to_i420 shares its plane layout with to_yuv420_planes. This also fixes the chroma stride for odd widths, which was rounded down while the plane size was rounded up, so each chroma row after the first overwrote the end of the previous one.

Behavior change

This changes the color of existing output. The CPU conversion coefficients change from BT.601 to BT.709, and the FFmpeg backend's conversion changes likewise. Footage recorded after this change will not be pixel-identical to footage recorded before it. The new output is correct: previously the pixels were BT.601 but untagged, so players defaulted to BT.709 for HD content and rendered them with a color shift.

On the web, frames are now converted on the CPU in WebAssembly before encoding instead of by the browser. This adds a per-frame cost on the main thread.

Tests

  • End-to-end (unienc_testkit) — the harness now reads the color description from both the video sample entry's colr box and the VUI of the SPS in avcC, and requires each one present to state BT.709 limited range. They are checked separately because they come from different places (the muxer and the encoder) and players disagree on which one wins. A colr box is required on every target except the web, where the SPS VUI is required instead. This runs on every CI target: Linux (FFmpeg), macOS, iOS simulator, Android emulator, Windows and the browser.
  • Unit (unienc_common) — to_yuv420_planes / to_i420: limited-range end points, neutral chroma for every gray level, the BT.709 values of the primaries, agreement with the floating-point BT.709 definition across the RGB cube, padding, the odd-width chroma stride, and that to_i420 packs the same planes. Restoring the BT.601 coefficients or the old chroma stride fails them.
  • Unit (unienc_testkit::h264, unienc_windows_mf::mux::colr) — the SPS VUI reader against SPS units written by libx264, and the colr insertion including box sizes and the untouched-file cases.

Verification

All jobs of CI unienc pass on d6a149b (run 36963200242). The color descriptions the e2e harness found:

Target colr SPS VUI
Linux / FFmpeg BT.709 limited BT.709 limited
macOS / VideoToolbox BT.709 limited BT.709 limited
Android emulator BT.709 limited BT.709 limited
Windows / Media Foundation BT.709 limited none
WebAssembly / Chrome none (muxide) BT.709 limited

The checks fail where the declaration is missing or wrong. With the Apple backend reverted to main, the harness reported colr: none; SPS: none locally on macOS. Before the Windows and WebCodecs fixes, CI (run 36957393259) reported colr: none; SPS: none on Windows and SPS: primaries 6, transfer 6, matrix 6 on the runner's Chrome.

Not covered by CI:

  • The GPU blit paths on Apple and Android rely on the platform encoder honoring the requested matrix when converting BGRA itself. The harness feeds CPU frames only.
  • A Windows hardware encoder could write its own VUI. If it wrote anything other than BT.709 there, it would contradict the colr box. CI runs the software encoder only.
  • The e2e checks verify the declarations, not the decoded pixels. Pixel values were checked for WebCodecs by decoding in Chrome (white at Y=235, red at Y=63), and for the CPU conversion by the unit tests.

Transfer characteristics

The pixels reaching the encoder are sRGB-encoded in both Unity gamma and linear workflows. On the readback path, FramePreprocessor renders into an R8G8B8A8_UNorm target in gamma and an R8G8B8A8_SRGB target in linear. On the blit paths, is_gamma_workflow selects the sRGB render pipeline and destination format on Apple and the source view format on Android to the same end. The same tags therefore apply to both workflows.

Strictly, sRGB-encoded content is transfer characteristics 13 (IEC 61966-2-1) in ITU-T H.273, not 1. This change tags 1 (BT.709) anyway, following the common practice for SDR screen capture. Players generally render the two identically, while support for 13 is less consistent across players and hardware decoders.

Note on merge order

This touches CompressionSession::new in unienc_apple_vt/src/video/mod.rs and the MediaFormat setup in unienc_android_mc/src/video/mod.rs — the same places as #172 (configurable IDR interval). Depending on merge order one of the two will need conflict resolution.

Before merging

The native binaries under Packages/jp.co.cyberagent.instant-replay/UniEnc/Plugins/ must be rebuilt via the build-unienc.yml workflow (manual dispatch).

ruccho and others added 3 commits August 19, 2026 14:57
The MP4 files produced by every backend carried no color information at
all: no `colr` box and no colour description in the SPS VUI. Players have
to guess at the color space, and some warn about it.

Tag the output as BT.709 limited range on all backends, and make the
conversions actually produce BT.709 so the tags describe the pixels:

- `VideoFrameBgra32::to_yuv420_planes`, used by the readback paths on
  Android and Windows, used BT.601 limited range coefficients. Replace
  them with the BT.709 equivalents.
- The FFmpeg backend let FFmpeg pick the matrix, which is BT.601 at the
  resolutions this library records. Tag the frames so the conversion and
  the VUI both become BT.709.

Per backend:

- Apple: set the color properties on the VTCompressionSession, and add the
  matching format description extensions in the muxer. AVAssetWriter
  writes the `colr` box from the format description it is handed, and
  since encoded frames travel back through the caller as opaque buffers,
  the muxer never sees the encoder's own.
- Android: set `color-standard`, `color-transfer` and `color-range` on the
  encoder format, and restate them on the muxer track format because
  codecs are not guaranteed to echo them back.
- Windows: set the color attributes on both the input and the output media
  type. The muxer inherits them, as media type attributes are serialized
  generically.
- FFmpeg: tag the frames with `setparams`. The `-color_primaries` and
  `-color_trc` output options are not reliably forwarded to the encoder.
- WebCodecs: declare the source frame's color space, the only lever the
  API offers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The end-to-end harness now reads the color description from both the
sample entry's colr box and the VUI of the SPS in avcC, and requires each
present one to state BT.709 primaries, transfer and matrix in limited
range. A colr box is required on every target except the web, whose muxer
(muxide) writes none; there the SPS VUI is required instead.

The CPU conversion in VideoFrameBgra32::to_yuv420_planes gains unit tests
that pin the limited-range end points, neutral chroma for grays, the
BT.709 values of the primaries, and agreement with the floating-point
BT.709 definition across the RGB cube.
@ruccho
ruccho marked this pull request as ready for review October 2, 2026 02:44
ruccho added 4 commits October 2, 2026 11:48
WebCodecs has no encoder option for the output color space. Chrome
converts an RGB VideoFrame into the matrix and range its color space
declares and copies all four fields into the SPS VUI. Declaring the BGRA
buffer as sRGB, as before, made the stream claim the sRGB transfer and
full range, unlike every other backend; Chrome does the same when no
color space is given.

Declaring BT.709 primaries, transfer and matrix in limited range instead
yields that exact VUI. Verified on Chrome 154 on macOS with both the
OpenH264 software encoder and the hardware encoder, including that the
decoded pixels are limited range (white at Y=235).
# Conflicts:
#	InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs
Handing the browser a BGRA frame leaves the YUV color space to it, and
browsers disagree. Chrome 154 converts into the matrix and range the
frame's color space names, while the Chrome on the CI runner, given the
same declaration, converted to BT.601 and tagged the stream as such.

The WebCodecs backend now converts with VideoFrameBgra32::to_i420, the
same BT.709 limited-range conversion the Android and Windows readback
paths use, and passes the browser I420 tagged accordingly. Dimensions
are padded to even values for I420 and cropped by the visible rectangle.

to_i420 writes the three planes into a single buffer, since a
VideoFrame takes one buffer with per-plane offsets. Sharing the plane
layout with to_yuv420_planes also fixes the chroma stride for odd
widths, which was rounded down while the plane size was rounded up, so
each chroma row after the first overwrote the end of the previous one.
The Windows e2e output carried no color description at all. The
MPEG-4 file sink writes no colr box although the BT.709 attributes are
set on the media type it is given, and the Microsoft software H.264
encoder writes no video signal type into the VUI; its documentation
lists no color attribute or codec property among those it supports.

After the sink has finalized the file, the muxer now appends a BT.709
limited-range nclx colr box to the video sample entry. The sink writes
moov as the last top-level box, so moov is rewritten in place and no
media data or chunk offset moves. A file laid out otherwise is left as
the sink wrote it, with a warning.

The color attributes on the encoder's media types are kept, since a
hardware encoder may honor them.
@ruccho
ruccho requested a review from hkmt-mmy October 2, 2026 04:40
# Conflicts:
#	InstantReplay.Externals/unienc/crates/unienc_windows_mf/src/mux/mod.rs
# Conflicts:
#	InstantReplay.Externals/unienc/crates/unienc_apple_vt/src/video/mod.rs
#	InstantReplay.Externals/unienc/crates/unienc_ffmpeg/src/video/mod.rs
#	InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs
#	InstantReplay.Externals/unienc/crates/unienc_webcodecs/src/video/mod.rs
@ruccho
ruccho merged commit 03d17b9 into main Oct 19, 2026
7 checks passed
@ruccho
ruccho deleted the feature/color-primaries-bt709 branch October 19, 2026 10:18
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.

Set Color Primaries (sRGB)

2 participants