Skip to content

feat(decode): Dolby Vision IPT→HDR10 on the GPU, and hardware decode for MV-HEVC - #39

Merged
bilipp merged 2 commits into
mainfrom
feature/dolby-vision-hdr10
Sep 29, 2026
Merged

bilipp merged 2 commits into
mainfrom
feature/dolby-vision-hdr10

Conversation

@bilipp

@bilipp bilipp commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Summary

Dolby Vision profile 20 (DVprofile20.mp4, MV-HEVC 4K) had two user-visible problems. Both are fixed here, one commit each.

1. Pink/green picture — feat(decode): convert IPT-base-layer Dolby Vision to HDR10 on the GPU

Profiles 5, 10.0 and 20 (base-layer compatibility ID 0) carry IPTPQc2, not YCbCr. The renderer displayed it as YCbCr, which produces the tint. No colour tag can fix this:

  • FFmpeg drops the matrix attachment, because CoreVideo has no IPT matrix for decoded buffers.
  • The RPU reshaping changes per scene.
  • Apple's decoders don't apply it either: AVAssetReader and AVAssetImageGenerator return raw IPT even from a correctly signalled dvh1 track.

VideoDecoder.deliver now passes these frames to DolbyVisionConverter, a Metal compute pass. Using the frame's RPU (AV_FRAME_DATA_DOVI_METADATA), it:

  • applies the polynomial and MMR reshaping,
  • converts IPT to PQ-coded LMS,
  • linearizes, then goes LMS → BT.2020 through the RPU matrix and a crosstalk-free Hunt-Pointer-Estevez transform,
  • writes HDR10 into a tagged 10-bit video-range buffer.

The renderer tone-maps the result like any HDR10 stream. Hardware frames stay on the GPU (texture cache, zero-copy).

Detection is the stream's DV configuration record or a frame tagged IPT_C2, because many profile-5 encodes leave the VUI matrix unspecified. HDR10-compatible profiles (8.1 etc.) stay on the zero-copy path.

2. Heavy stutter on Apple TV — fix(decode): keep MV-HEVC's second view away from VideoToolbox

The stream never decoded on hardware. FFmpeg's VideoToolbox glue puts the second view's SPS/PPS (nuh_layer_id 1) into the same hvcC as the base layer's; Apple expects them in a separate lhvC. VideoToolbox rejects the session, and FFmpeg falls back to software without returning an error: 4K 10-bit HEVC on the CPU, about 20 fps on an M1 Pro.

HEVCBaseLayerFilter removes every nuh_layer_id > 0 NAL unit before the codec sees it, in both packets and extradata (FFmpeg's MP4 demuxer merges lhvC into the extradata). The engine only ever shows the base view, so nothing visible is lost. Pipeline throughput on the sample went from 19.6 to ~101 fps, on VideoToolbox.

This stayed hidden because isHardwareActive meant "a VT device is attached", not "frames come from VT". It now reads the frames' pixel format, and a silent fallback emits .downgradedToSoftware. That also exposes an existing case: VideoToolbox here refuses the field-coded H.264 fixture, so interlaced.ts was always decoded in software. The deinterlace tests now wait past that informational event instead of expecting .endOfStream first.

Overlap: #33 also covers DV IPT reshaping. This PR was built independently of it.

Type of change

  • fix — bug fix
  • feat — new capability
  • perf — performance improvement
  • refactor — neither fixes a bug nor adds a feature
  • docs — documentation only (PLAN.md §6/§7 decision and spike result, AGENTS.md invariants)
  • test — adding or fixing tests
  • build / chore — build pipeline, tooling, or maintenance

Invariants

Data-plane changes, all inside the decode stage. Both the conversion and the filtering run on the video decoder's own thread, which is allowed to block (PLAN.md D1); the GPU wait happens there, never on an actor.

  • No blocking FFmpeg call was added to an actor, and no stage bypasses Channel.
  • No rebuild-in-place: a new URL is still a new session object.
  • Raw container timestamps still stop at TimestampUnwrapper.
  • A/V sync is still owned by AVSampleBufferRenderSynchronizer (no new heuristics or clocks).
  • No force unwraps, as!, fatalError, or assertionFailure in engine paths; failures are typed. If Metal is missing, the session plays unconverted (tinted) rather than stopping. A GPU refusal (iOS, backgrounded app) skips conversion for that frame only.
  • No new global mutable state; configuration still flows through PlayerConfiguration. The derived HPE matrix is an immutable static let.
  • No control flow derived from av_log strings.

Verification

  • swift build is warning-free. Nothing from this branch warns. Render/SystemRenderer.swift:63-64 has two main-actor isolation warnings that are not touched here.
  • swift test is green:
Test run with 101 tests in 16 suites passed after 18.759 seconds.
  • I added or updated tests for this change.
    • DolbyVisionTests:
      • metadata extraction from a synthetic AVDOVIMetadata
      • GPU output against an independent Double reference (polynomial + MMR, odd sizes, two chroma sitings): within 1 code
      • HPE matrix against libplacebo's published constant
      • real-stream cases: profile 20 converted with natural colour on both the VT and software paths, and asserted to decode on VT; profile 8 left alone
    • HEVCBaseLayerFilterTests: both packet framings, the hvcC rewrite, malformed input passing through untouched.
    • Negative controls: with conversion disabled, the profile-20 test fails on every frame; with the layer filter disabled, it fails on the hardware-decode assertion.
  • New media fixtures: none. The DV samples can't be redistributed, so the real-stream tests use the gitignored TestStreams/DolbyVision/ and skip where it's absent, CI included. The tests that run everywhere are the synthetic ones.

Platforms verified

  • macOS: tests, plus the rendered engine frame inspected
  • iOS / iPadOS: compiles (xcodebuild -destination generic/platform=iOS), not run on a device
  • tvOS: Apple TV 4K through Lume. Correct colour, and smooth after the MV-HEVC fix.
  • visionOS: compiles, not run

Real-world content

  • MP4, HEVC Main 10 MV-HEVC (stereo), Dolby Vision profile 20 (compatibility ID 0, dvwC), 4K24, ~34 Mbps, local VOD. Before: pink/green, and stuttering on Apple TV (software decode). After: correct colour, VideoToolbox decode.
  • MP4, HEVC, Dolby Vision profile 8.1, 4K: unchanged (not converted, zero-copy).

Known limits: the output is static HDR10 (no DV L1/L2/L8 trims and no MDCV/CLL attached yet). Enhancement-layer profiles (7 FEL, 4) aren't handled, and only the base view of MV-HEVC is shown.

Dolby Vision profiles 5, 10.0 and 20 (base-layer signal compatibility
ID 0) carry IPTPQc2, not YCbCr. The renderer showed it as YCbCr, so
DVprofile20.mp4 played with a pink/green cast. No colour tag fixes
that: FFmpeg drops the matrix attachment (CoreVideo has none for IPT on
decoded buffers), and the RPU reshaping changes per scene. Apple's own
decoders don't apply it either — AVAssetReader and AVAssetImageGenerator
both return raw IPT from a correctly signalled dvh1 track.

VideoDecoder.deliver now hands such frames to DolbyVisionConverter, a
Metal compute pass that applies the frame's RPU (FFmpeg's
AV_FRAME_DATA_DOVI_METADATA): polynomial and MMR reshaping, IPT→L'M'S',
PQ decode, LMS→BT.2020 through the RPU matrix and a crosstalk-free
Hunt-Pointer-Estevez transform (derived from BT.2100's ICtCp matrix,
matching libplacebo's published constant), then HDR10 Y'CbCr into a
tagged 10-bit video-range buffer. The renderer tone-maps it like any
HDR10 stream. Hardware frames stay on the GPU via the texture cache.

Detection is the stream's DV configuration record or an IPT_C2 frame —
many P5 encodes leave the VUI matrix unspecified. HDR10-compatible
profiles (8.1 etc.) stay on the zero-copy path. A GPU refusal skips
conversion for that frame only (iOS refuses a backgrounded app); only a
converter that cannot exist gives up for the session, degrading to the
old tinted picture rather than stopping. Dynamic DV display management
(L1/L2/L8 trims) is not carried over; the output is static HDR10.

Tests: metadata extraction from a synthetic AVDOVIMetadata, GPU output
against an independent Double reference (poly + MMR, odd sizes, two
chroma sitings; within a code), and real-stream cases on the local,
gitignored DV samples (skipped where absent, CI included). The test
target now depends on CFFmpeg directly to build FFmpeg structs.
DVprofile20.mp4 stuttered badly on Apple TV. It was never on the
hardware decoder. The stream is MV-HEVC, and FFmpeg's HEVC decoder files
the second view's SPS/PPS (nuh_layer_id 1) alongside the base layer's;
its VideoToolbox glue then packs every parameter set into one hvcC,
where Apple expects the second layer's in a separate lhvC.
VTDecompressionSessionCreate fails ("VideoToolbox malfunction") and
FFmpeg falls back to software without returning an error — 4K 10-bit
HEVC on the CPU, ~20 fps on an M1 Pro. Stripping layer 1 from a 5 s
extract took ffmpeg's hwaccel run from 8.5 s to 0.15 s of CPU time.

HEVCBaseLayerFilter drops every nuh_layer_id > 0 NAL unit before the
codec sees it — from packets (length-prefixed or Annex B) and from the
extradata, into which FFmpeg's MP4 demuxer merges lhvC. The engine only
presents the base view, so nothing visible is lost; packets without
enhancement data pass through uncopied. Pipeline throughput on the
profile 20 sample: 19.6 → ~101 fps, on VideoToolbox.

What hid it: isHardwareActive reported "a VideoToolbox device is
attached", not where frames came from. It now reads the frames' pixel
format, and a silent fallback emits .downgradedToSoftware like any
other downgrade. That also surfaces an existing one: VideoToolbox
refuses the field-coded H.264 fixture here, so interlaced.ts was always
decoded in software — the deinterlace tests now wait past that
informational event instead of expecting .endOfStream first.

Verified on an Apple TV 4K (A15): smooth.
@bilipp
bilipp merged commit fcec5bd into main Sep 29, 2026
1 of 4 checks passed
@bilipp
bilipp deleted the feature/dolby-vision-hdr10 branch September 29, 2026 20:50
bilipp added a commit that referenced this pull request Sep 29, 2026
CI has been red since 069a818, and #39/#40 added two more failures of
the same kind. The workflow asks for Xcode_26.app, which the macos-15
image no longer has; the fallback selects Xcode 26.3 — Swift 6.2 —
while local development runs Xcode 27 (Swift 6.4). Code that 6.4
accepts at once, 6.2 rejects:

- ChannelDrain.value: a bare `result` inside the initializer of the
  local `result` binds to that local on 6.2 ("initializer for
  conditional binding must have Optional type"). Now `self.result`.
- Matrix3.multiply/inverse: nested `reduce(0)` closures over untyped
  literals exceed 6.2's solver ("unable to type-check this expression
  in reasonable time"). Plain loops with explicit types.
- DolbyVisionTests.extraction: `#expect(pieces == [.polynomial(...)])`
  took 6.4 2.4 s to type-check; the expected values are now typed
  locals. HEVCBaseLayerFilterTests' hvcC array builder (0.1 s) is
  a loop.

Found by compiling with -warn-long-expression-type-checking=25 on 6.4:
nothing is above 40 ms any more, the level the existing tests already
passed CI at. Behaviour is unchanged. The suite passes apart from the intermittent
SRT cue test, a real duplicate-cue bug fixed in the next commit.
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