Repository navigation
feat(decode): Dolby Vision IPT→HDR10 on the GPU, and hardware decode for MV-HEVC - #39
Merged
Merged
Conversation
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
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.
Merged
13 of 22 tasks
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.
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 GPUProfiles 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:
AVAssetReaderandAVAssetImageGeneratorreturn raw IPT even from a correctly signalleddvh1track.VideoDecoder.delivernow passes these frames toDolbyVisionConverter, a Metal compute pass. Using the frame's RPU (AV_FRAME_DATA_DOVI_METADATA), it: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 VideoToolboxThe stream never decoded on hardware. FFmpeg's VideoToolbox glue puts the second view's SPS/PPS (
nuh_layer_id1) into the samehvcCas the base layer's; Apple expects them in a separatelhvC. 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.HEVCBaseLayerFilterremoves everynuh_layer_id > 0NAL unit before the codec sees it, in both packets and extradata (FFmpeg's MP4 demuxer mergeslhvCinto 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
isHardwareActivemeant "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, sointerlaced.tswas always decoded in software. The deinterlace tests now wait past that informational event instead of expecting.endOfStreamfirst.Overlap: #33 also covers DV IPT reshaping. This PR was built independently of it.
Type of change
fix— bug fixfeat— new capabilityperf— performance improvementrefactor— neither fixes a bug nor adds a featuredocs— documentation only (PLAN.md §6/§7 decision and spike result, AGENTS.md invariants)test— adding or fixing testsbuild/chore— build pipeline, tooling, or maintenanceInvariants
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.
Channel.TimestampUnwrapper.AVSampleBufferRenderSynchronizer(no new heuristics or clocks).as!,fatalError, orassertionFailurein 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.PlayerConfiguration. The derived HPE matrix is an immutablestatic let.av_logstrings.Verification
swift buildis warning-free. Nothing from this branch warns.Render/SystemRenderer.swift:63-64has two main-actor isolation warnings that are not touched here.swift testis green:DolbyVisionTests:AVDOVIMetadataDoublereference (polynomial + MMR, odd sizes, two chroma sitings): within 1 codeHEVCBaseLayerFilterTests: both packet framings, thehvcCrewrite, malformed input passing through untouched.TestStreams/DolbyVision/and skip where it's absent, CI included. The tests that run everywhere are the synthetic ones.Platforms verified
xcodebuild -destination generic/platform=iOS), not run on a deviceReal-world content
dvwC), 4K24, ~34 Mbps, local VOD. Before: pink/green, and stuttering on Apple TV (software decode). After: correct colour, VideoToolbox decode.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.