SiparioTEATRO · EST. MMXXVI

Deep dive · engineering

How real-time Dolby Vision Profile 7 to 8.1 conversion works

Dolby Vision Profile 7 is the dual-layer format on UHD Blu-ray. Android TV hardware decodes single-layer Dolby Vision. Bridging that gap while a film streams from a remote source, on a box with four small cores and 2 GB of RAM, turns out to be an interesting problem. This is how it works in Sipario.

What Profile 7 actually contains

A Profile 7 HEVC stream carries three things interleaved as NAL units. There is the base layer, which is ordinary HDR10-compatible HEVC video. There is the enhancement layer, which carries additional detail intended to be recombined with the base. And there is the RPU, the reference processing unit metadata that drives Dolby Vision's per-scene dynamic tone mapping.

In the bitstream these are distinguished by NAL unit type, which lives in bits 1 through 6 of each NAL's first header byte:

nal_type = (byte0 >> 1) & 0x3F

Type 62 is the RPU. Type 63 is the enhancement layer. Everything else is the video and it is none of our business. Profile 8.1 keeps the same base layer and the same RPU, in a form single-layer decoders understand, and has no enhancement layer at all. So the conversion is, in principle, three edits: rewrite each type 62, delete each type 63, and correct the descriptor in the container that advertises which profile this is.

In principle.

The RPU rewrite

The RPU is not a blob you can patch a version byte in. It is a bit-packed structure whose layout depends on the profile, and converting it means parsing it, changing its internal signalling, and re-serialising it. Sipario uses the same dolby_vision Rust crate that backs dovi_tool, in conversion mode 2, which is exactly what dovi_tool -m 2 convert --discard performs.

Using the same library as the reference implementation is a deliberate choice rather than a shortcut. It means the output can be checked against the reference byte-for-byte, and it means the messy edge cases the community has already found and fixed upstream are handled the same way here.

The container is the hard part

Matroska does not store HEVC the way you might expect. There are no 00 00 00 01 Annex-B start codes inside a frame. Each NAL unit is prefixed by a big-endian length field whose width comes from thehvcC descriptor in the track's CodecPrivate, essentially always four bytes. So walking a frame means reading a length, reading that many bytes, and repeating.

That framing is why the conversion cascades. Drop an enhancement-layer NAL and the frame gets shorter. A shorter frame means the block that contains it declares the wrong size. Matroska sizes are variable-length integers, so the correction can change the width of the size field itself, which changes the size of the cluster containing the block, which changes the size of the Segment containing the cluster.

A file-based tool solves this by seeking backwards and patching the lengths once it knows them. A streaming converter cannot: those bytes left the building. The way out is to re-emit the Segment with an unknown size, which Matroska explicitly permits and which live-streaming muxers use for exactly this reason. Nothing downstream needs a total it can only learn at the end.

What gets rewritten, and what does not

  • Tracks is re-emitted with the video track's dvcC descriptor patched from Profile 7 to 8.1. That descriptor is buried in a BlockAdditionMapping element, and the patch is deliberately made in place at the same byte length so no parent element's size has to change.
  • Clusters are rebuilt. Each video block gets per-frame NAL surgery and fresh size fields. Timestamps and the other cluster children are copied across as they were.
  • Audio and subtitle blocks are copied byte for byte. A TrueHD Atmos track passes through the converter untouched, which is the entire point: the conversion must not cost you the soundtrack.
  • SeekHead, Cues, Void and CRC32 are dropped. They are indexes of absolute byte offsets, and every one of those offsets is wrong the moment the first frame changes length. Emitting a stale index is worse than emitting none.
  • Info, Chapters and Tags are copied verbatim. Chapter marks survive the conversion and still drive the chapters menu.

Keeping up with the bitrate

Correctness is necessary but not sufficient. The conversion has to run faster than the film plays, on modest hardware, while the source arrives over the network. Two measurements shaped the design.

The first is that a single-threaded pump does not work. Reading from the socket and converting on the same thread makes the two costs additive, and measured on the box that dropped throughput to roughly 0.6× realtime, which is a stall. Splitting them into a reader thread and a converter thread makes the aggregate rate the converter's rate rather than the sum, because the reader spends nearly all its time parked on the socket. The converter runs at an elevated scheduler priority, though deliberately still below the audio threads, because starving the audio path is a worse failure than a late frame.

The second measurement is about the FFI boundary. The converter is Rust, called from Kotlin, and each crossing costs roughly a millisecond of fixed overhead. The HTTP stack hands back about 8 KB per read, so a naive one-read-per-call loop crosses the boundary about 4,096 times per 32 MB. That is four seconds of pure marshalling per 32 MB, to do work that itself takes well under a second. Coalescing reads into roughly 1 MB chunks cuts it to about 32 crossings, or some tens of milliseconds. The coalescing window is time-bounded as well as size-bounded, so a slow source flushes what it has instead of holding data back waiting for a full buffer.

There is a subtlety in how the output arrives. The converter emits per whole cluster, because it cannot rewrite a cluster until it has all of it. So the cost profile is lumpy: a run of near-free calls, then one call that produces a megabyte and a half and takes a few hundred milliseconds. The queue between the reader and the converter has to be deep enough to cover one of those flushes, or the reader stalls mid-cluster and the stall propagates all the way back to the network socket.

Seeking a stream that cannot seek

The converted stream is served as chunked output with no length and no byte-range support. The player cannot seek it, because a byte offset in the converted stream has no fixed relationship to anything.

Seeking therefore happens on the source side. Sipario parses the source's Cues, picks the cluster nearest the requested time, and opens a fresh conversion starting from there, synthesising a small container header so the output is a valid Matroska stream that happens to begin in the middle of a film. The index is cached on disk keyed by file length, so the parse cost is paid once rather than on every resume.

Starting mid-file introduces a decoder problem that took real hardware to find. You cannot begin decoding at an arbitrary frame; you need an intra random access point. The converter enforces this with an entry gate that drops video frames until it sees a genuine IRAP, and drops timed audio and subtitle blocks while the gate is shut so they cannot run ahead of the video timeline that was actually accepted.

The wrinkle is that not all IRAPs are equal. A CRA — clean random access — may be followed by leading pictures that reference frames before it, frames the decoder has never seen. Feeding a CRA as the first frame wedges the hardware decoder on the reference box. The fix is to flag a leading CRA for rewriting as a BLA, broken link access, which tells the decoder exactly what it needs to know: the link is broken, discard the leading pictures, start here.

Proving it works

A conversion that is subtly wrong is worse than one that fails, because it fails silently months later on one title. The conversion is held to four gates against a known Profile 7 source:

  1. ffprobe reports the output as dv_profile=8 with el_present=0. The profile actually changed and the enhancement layer is actually gone.
  2. The TrueHD audio track is still present and intact.
  3. The RPU extracted from the converted stream is byte-identical to what dovi_tool -m 2 convert --discard produces from the same source. This is the gate that matters most, because it compares against the reference implementation rather than against an opinion.
  4. A full ffmpeg -f null decode of the output exits clean, so the stream is not merely well-formed at the container level but decodable end to end.

Beyond that, playback is verified on the actual hardware rather than in a simulator, sampling the display's own presentation clock frame by frame. That is how the CRA problem surfaced, and it is not something a file-based test would ever have caught.

Knowing when to do nothing

The best version of this work is not running it. A title that is already single-layer Profile 8 needs no conversion, and putting it through the pipeline costs CPU, memory and the ability to seek natively, for no gain at all.

So the profile is read from the container's dvcC during indexing and remembered, and titles that turn out to be single-layer are played directly with the transformation removed from the path entirely. The converter engages only for the files that genuinely need it.

What this does not fix

Conversion needs a box whose hardware decodes Dolby Vision to begin with; it makes Profile 7 material playable as Dolby Vision on such hardware rather than adding Dolby Vision to a device without it. The enhancement layer is discarded rather than merged, the same trade the desktop tools make. And it does nothing for bandwidth: a 90 GB remux still has to arrive fast enough to play, because this is metadata surgery, not compression.

What you get in exchange is a disc remux that plays as Dolby Vision, from the original file, with its lossless audio intact, and no pre-processing step between you and pressing play.

Common questions

Is the converted output identical to dovi_tool?

For the RPU, yes, byte-for-byte. The conversion is validated against dovi_tool -m 2 convert --discard by extracting the RPU from the converted stream and comparing it to the file dovi_tool produces from the same source. The rest of the container is a different question, because the streaming rewrite drops the seek index and re-emits the Segment with an unknown size, which a file-based tool has no reason to do.

Does converting re-encode the video?

No. Every video coding NAL unit is copied through untouched. The only bytes that change are the RPU metadata NALs, the dvcC descriptor in the track header, and the Matroska size fields that have to be recomputed because the frames got shorter. Picture quality is bit-identical to the source's base layer.

Why drop the enhancement layer instead of using it?

Because no Android TV decoder will take it. The enhancement layer only means something to hardware built to combine two layers, which in practice means UHD Blu-ray players. Keeping it would mean shipping bytes nothing downstream can consume, at real bandwidth cost, on a box that is already streaming a high-bitrate remux.

What happens if the converter hits a file it cannot parse?

It latches an error and stops emitting rather than producing a corrupt stream, and the player surfaces a real failure instead of silently ending the film early. The conversion code is also written never to panic across the FFI boundary, with a catch_unwind wrapper on top, because an unwind into the JVM would take the whole app down.