Add option to prefer Dolby Vision over HDR10+ on hybrid videos (#1876)

## Description

Adds a "Prefer Dolby Vision over HDR10+" switch (off by default) under
Experimental settings. That screen only shows up once "Experimental
settings" is enabled under Advanced, and until then the switch is read
as off: it goes through `experimentalPreferences.get {}`, so nothing is
wrapped and the playback path is untouched.

Most current 4K web releases carry both Dolby Vision (profile 8.1) RPUs
and HDR10+ dynamic metadata in the same HEVC stream. Some MediaTek based
devices (Philips and TCL TVs, some Fire TV models) latch onto the HDR10+
metadata on those files and never engage Dolby Vision, even though a
Dolby Vision decoder is selected.

I isolated this on my Philips 55OLED810 by playing the same episode
three ways, direct play in all three cases:

| | Stream | TV shows |
|---|---|---|
| A | MKV with DV 8.1 + HDR10+ | HDR10+ |
| B | Same MKV with the HDR10+ metadata removed using
[hdr10plus_tool](https://github.com/quietvoid/hdr10plus_tool), DV
untouched | Dolby Vision |
| C | Same stream remuxed to MP4, both kinds of metadata present |
HDR10+ |

The container doesn't matter and the Dolby Vision signaling is picked up
correctly. The only thing the app can do is keep the HDR10+ metadata
away from the decoder.

When the switch is on, and only when a Dolby Vision decoder was actually
selected, the HDR10+ SEI messages (SMPTE ST 2094-40, recognized by their
ITU-T T.35 header `B5 003C 0001`) are masked in each access unit before
it is queued to MediaCodec. The SEI payload type byte is overwritten
with an unused value (254), so the decoder skips the message. That is
one byte rewritten in place per message: the masking filter returns the
size it was given, so the access unit is queued exactly as long as it
came out of the extractor, which is what makes it safe with both the
synchronous and asynchronous MediaCodec adapters. media3 does the same
thing for HAGC metadata in `HevcSeiUtil`; this filter only targets ST
2094-40, so other T.35 SEI such as CEA-708 captions are not affected.
The filter was written from the H.265 spec, no Kodi or media3 code was
copied.

The hook is generic rather than HDR10+ specific, as asked in the thread.
`BitstreamFilteringCodecAdapterFactory` wraps the codec adapter with a
`List<BitstreamFilter>`, and a filter is `(ByteBuffer, offset, size) ->
Int` where the returned `Int` is the new size of the access unit: the
adapter queues the size the chain reports, and hands each filter the
size the previous one reported. A filter which only overwrites bytes
returns the size unchanged, which is what the HDR10+ masking does; a
filter which drops NAL units compacts what it keeps and reports the
shorter size. A reported size which is negative or larger than the one
the filter was given is ignored rather than queued. That is what lets
the Dolby Vision profile 7 to 8.1 conversion of #244 arrive later as one
more filter instead of a second copy of the adapter plumbing.

When the switch is off, the playback path is unchanged. On a device
without a Dolby Vision decoder (or a display without DV support) the
wrapper is never installed, so HDR10+ only devices keep their metadata.
Encrypted samples, codec config buffers and empty buffers are passed
through untouched.

Kodi has an equivalent option for the same reason: xbmc/xbmc@032cbfeb
and xbmc/xbmc@e2c54e7f.

### Related issues

Implements what was requested in #1855. The filter list is what #244
(Dolby Vision profile 7 direct play) needs, so that conversion can be
added as another filter. #1895 implemented the same masking
independently and was closed in favour of this PR; the device profile
part of it was not carried over, see the note under Testing. Upstream:
androidx/media#1895, where the maintainers say the bug is in the device
codecs and that they don't plan a workaround in ExoPlayer itself.

## AI or LLM usage

The implementation (bitstream filter, MediaCodecAdapter wiring, unit
tests) were written with AI assistance (Claude). I reviewed the changes
and validated the behavior myself on a real MediaTek TV (Philips
55OLED810), with the setting on and off, on hybrid and DV-only content.

---------

Co-authored-by: Jycreyn <github.2ilp7@passmail.com>
This commit is contained in:
Jycreyn 2026-09-29 18:41:40 +02:00 • committed by GitHub
parent 65954a99eb
commit a577ae9f1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 944 additions and 2 deletions

View file

@ -0,0 +1,136 @@
package com.github.damontecres.wholphin.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.ByteBuffer
class BitstreamFilterChainTest {
@Test
fun keepsTheSizeWhenThereIsNoFilter() {
assertEquals(DATA.size, applyBitstreamFilters(emptyList(), buffer(), 0, DATA.size))
}
@Test
fun keepsTheSizeOfAnInPlaceFilter() {
val inPlace =
BitstreamFilter { data, offset, size ->
data.put(offset, 0xFF.toByte())
size
}
val target = buffer()
val size = applyBitstreamFilters(listOf(inPlace), target, 0, DATA.size)
assertEquals(DATA.size, size)
assertArrayEquals(byteArrayOf(0xFF.toByte()) + DATA.copyOfRange(1, DATA.size), target.array())
}
@Test
fun reportsTheShorterSizeOfAFilterWhichDropsBytes() {
// What a Dolby Vision profile 7 to 8.1 conversion does: it removes the enhancement layer,
// compacts what it keeps and comes out shorter than it went in. The buffer is direct,
// like the decoder input buffer a filter is handed on a device: it has no backing array,
// so the bytes are moved with absolute gets and puts.
val dropsTheThirdAndFourthBytes =
BitstreamFilter { data, offset, size ->
for (i in 2 until size - 2) {
data.put(offset + i, data.get(offset + i + 2))
}
size - 2
}
val target = directBuffer()
val size = applyBitstreamFilters(listOf(dropsTheThirdAndFourthBytes), target, 0, DATA.size)
assertFalse("the test should exercise a buffer without a backing array", target.hasArray())
assertEquals(DATA.size - 2, size)
assertEquals(listOf<Byte>(1, 2, 5, 6, 7, 8), (0 until size).map { target.get(it) })
}
@Test
fun handsTheNextFilterTheSizeReportedByThePreviousOne() {
val seen = mutableListOf<Int>()
val dropTwo =
BitstreamFilter { _, _, size ->
seen.add(size)
size - 2
}
val dropOne =
BitstreamFilter { _, _, size ->
seen.add(size)
size - 1
}
val size = applyBitstreamFilters(listOf(dropTwo, dropOne), buffer(), 0, DATA.size)
assertEquals(listOf(DATA.size, DATA.size - 2), seen)
assertEquals(DATA.size - 3, size)
}
@Test
fun ignoresAFilterWhichReportsMoreBytesThanItWasGiven() {
val seen = mutableListOf<Int>()
val tooLong = BitstreamFilter { _, _, size -> size + 1 }
val recording =
BitstreamFilter { _, _, size ->
seen.add(size)
size
}
val size = applyBitstreamFilters(listOf(tooLong, recording), buffer(), 0, DATA.size)
assertEquals(DATA.size, size)
assertEquals(listOf(DATA.size), seen)
}
@Test
fun ignoresAFilterWhichReportsANegativeSize() {
val negative = BitstreamFilter { _, _, _ -> -1 }
assertEquals(DATA.size, applyBitstreamFilters(listOf(negative), buffer(), 0, DATA.size))
}
@Test
fun stopsOnceNothingIsLeftOfTheAccessUnit() {
var called = false
val dropsEverything = BitstreamFilter { _, _, _ -> 0 }
val next =
BitstreamFilter { _, _, size ->
called = true
size
}
val size = applyBitstreamFilters(listOf(dropsEverything, next), buffer(), 0, DATA.size)
assertEquals(0, size)
assertTrue("a filter should not be handed an empty access unit", !called)
}
@Test
fun filtersAreGivenTheOffsetTheyWereCalledWith() {
val offsets = mutableListOf<Int>()
val recording =
BitstreamFilter { _, offset, size ->
offsets.add(offset)
size
}
applyBitstreamFilters(listOf(recording, recording), buffer(), 2, DATA.size - 2)
assertEquals(listOf(2, 2), offsets)
}
companion object {
private val DATA = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8)
private fun buffer() = ByteBuffer.wrap(DATA.copyOf())
private fun directBuffer() =
ByteBuffer.allocateDirect(DATA.size).also { buffer ->
DATA.forEachIndexed { index, byte -> buffer.put(index, byte) }
}
}
}

View file

@ -0,0 +1,103 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.util
import android.media.MediaCodec
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Test
import java.nio.ByteBuffer
/**
* Covers the hand off to the decoder: the adapter has to queue the size the filters report, not
* the size it was called with, and it has to leave the buffers no filter may touch alone.
*/
class BitstreamFilteringCodecAdapterTest {
private val delegate = mockk<MediaCodecAdapter>(relaxed = true)
private val buffer = ByteBuffer.allocateDirect(SIZE)
@Test
fun queuesTheSizeTheFilterReports() {
every { delegate.getInputBuffer(INDEX) } returns buffer
val adapter = adapterWith(BitstreamFilter { _, _, size -> size - 2 })
adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0)
verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE - 2, PRESENTATION_TIME_US, 0) }
}
@Test
fun queuesTheSizeItWasGivenWhenTheFilterLeavesTheAccessUnitAlone() {
every { delegate.getInputBuffer(INDEX) } returns buffer
val adapter = adapterWith(BitstreamFilter { _, _, size -> size })
adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0)
verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) }
}
@Test
fun leavesCodecConfigBuffersAlone() {
every { delegate.getInputBuffer(INDEX) } returns buffer
val filter = RecordingFilter()
val adapter = adapterWith(filter)
adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)
assertEquals(0, filter.calls)
verify {
delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)
}
}
@Test
fun leavesEmptyBuffersAlone() {
every { delegate.getInputBuffer(INDEX) } returns buffer
val filter = RecordingFilter()
val adapter = adapterWith(filter)
adapter.queueInputBuffer(INDEX, OFFSET, 0, PRESENTATION_TIME_US, 0)
assertEquals(0, filter.calls)
verify { delegate.queueInputBuffer(INDEX, OFFSET, 0, PRESENTATION_TIME_US, 0) }
}
@Test
fun queuesTheSizeItWasGivenWhenThereIsNoInputBuffer() {
every { delegate.getInputBuffer(INDEX) } returns null
val filter = RecordingFilter()
val adapter = adapterWith(filter)
adapter.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0)
assertEquals(0, filter.calls)
verify { delegate.queueInputBuffer(INDEX, OFFSET, SIZE, PRESENTATION_TIME_US, 0) }
}
private fun adapterWith(filter: BitstreamFilter) = BitstreamFilteringCodecAdapter(delegate, listOf(filter))
private class RecordingFilter : BitstreamFilter {
var calls = 0
override fun filter(
data: ByteBuffer,
offset: Int,
size: Int,
): Int {
calls++
return size
}
}
companion object {
private const val INDEX = 3
private const val OFFSET = 1
private const val SIZE = 8
private const val PRESENTATION_TIME_US = 1234L
}
}

View file

@ -0,0 +1,277 @@
package com.github.damontecres.wholphin.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import java.nio.ByteBuffer
class Hdr10PlusSeiFilterTest {
@Test
fun masksSoleHdr10PlusMessage() {
val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal())
// Payload type byte follows the start code and the 2 byte NAL unit header
val payloadTypeIndex = START_CODE.size + 2
val (masked, result) = filter(accessUnit)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result)
}
@Test
fun masksWithThreeByteStartCodes() {
val accessUnit =
SHORT_START_CODE + seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)) + SHORT_START_CODE + vclNal()
val payloadTypeIndex = SHORT_START_CODE.size + 2
val (masked, result) = filter(accessUnit)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result)
}
@Test
fun masksOnlyHdr10PlusInMultiMessageNal() {
val masteringDisplay = seiMessage(137, MASTERING_DISPLAY_PAYLOAD)
val contentLightLevel = seiMessage(144, CONTENT_LIGHT_LEVEL_PAYLOAD)
val accessUnit =
annexB(
seiNal(masteringDisplay, seiMessage(4, HDR10_PLUS_PAYLOAD), contentLightLevel),
vclNal(),
)
val payloadTypeIndex = START_CODE.size + 2 + masteringDisplay.size
val (masked, result) = filter(accessUnit)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result)
}
@Test
fun skipsEmulationPreventionBytesInPrecedingPayload() {
// The escaped form of this payload is longer than its payload size, so naive byte
// counting would misparse the HDR10+ message that follows it
val zeroHeavyMessage = seiMessage(137, byteArrayOf(0, 0, 0, 0, 1))
val accessUnit =
annexB(seiNal(zeroHeavyMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal())
val payloadTypeIndex = START_CODE.size + 2 + escape(zeroHeavyMessage).size
val (masked, result) = filter(accessUnit)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result)
}
@Test
fun handlesMultiBytePayloadType() {
val bigTypeMessage = seiMessage(300, byteArrayOf(0x42))
val accessUnit =
annexB(seiNal(bigTypeMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal())
val payloadTypeIndex = START_CODE.size + 2 + bigTypeMessage.size
val (masked, result) = filter(accessUnit)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result)
}
@Test
fun ignoresOtherItuTT35Messages() {
val accessUnit = annexB(seiNal(seiMessage(4, CEA_708_PAYLOAD)), vclNal())
val (masked, result) = filter(accessUnit)
assertEquals(0, masked)
assertArrayEquals(accessUnit, result)
}
@Test
fun ignoresSeiNalUnitsAfterTheVclNalUnit() {
val accessUnit = annexB(vclNal(), seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)))
val (masked, result) = filter(accessUnit)
assertEquals(0, masked)
assertArrayEquals(accessUnit, result)
}
@Test
fun masksAcrossMultipleNonVclNalUnits() {
val accessUnit =
annexB(
audNal(),
seiNal(seiMessage(137, MASTERING_DISPLAY_PAYLOAD)),
seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)),
vclNal(),
)
val payloadTypeIndex =
START_CODE.size + audNal().size +
START_CODE.size + seiNal(seiMessage(137, MASTERING_DISPLAY_PAYLOAD)).size +
START_CODE.size + 2
val (masked, result) = filter(accessUnit)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(accessUnit, payloadTypeIndex), result)
}
@Test
fun leavesLengthPrefixedSamplesUntouched() {
val nal = seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD))
val accessUnit =
byteArrayOf(
(nal.size ushr 24).toByte(),
(nal.size ushr 16).toByte(),
(nal.size ushr 8).toByte(),
nal.size.toByte(),
) + nal
val (masked, result) = filter(accessUnit)
assertEquals(0, masked)
assertArrayEquals(accessUnit, result)
}
@Test
fun leavesTruncatedSeiNalUnitUntouched() {
// Payload size claims more bytes than the NAL unit contains
val brokenNal = byteArrayOf(0x4E, 0x01, 0x04, 0x30, 0xB5.toByte(), 0x00, 0x3C)
val accessUnit = annexB(brokenNal, vclNal())
val (masked, result) = filter(accessUnit)
assertEquals(0, masked)
assertArrayEquals(accessUnit, result)
}
@Test
fun leavesShortItuTT35MessageUntouched() {
val accessUnit = annexB(seiNal(seiMessage(4, byteArrayOf(0xB5.toByte(), 0x00, 0x3C))), vclNal())
val (masked, result) = filter(accessUnit)
assertEquals(0, masked)
assertArrayEquals(accessUnit, result)
}
@Test
fun respectsOffsetAndSize() {
val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal())
val prefix = byteArrayOf(0x55, 0x66)
val data = prefix + accessUnit
val payloadTypeIndex = prefix.size + START_CODE.size + 2
val buffer = ByteBuffer.wrap(data.copyOf())
val masked = Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(buffer, prefix.size, accessUnit.size)
assertEquals(1, masked)
assertArrayEquals(maskTypeByte(data, payloadTypeIndex), buffer.array())
}
@Test
fun masksMultipleHdr10PlusMessages() {
val firstMessage = seiMessage(4, HDR10_PLUS_PAYLOAD)
val accessUnit =
annexB(seiNal(firstMessage, seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal())
val expected =
maskTypeByte(
maskTypeByte(accessUnit, START_CODE.size + 2),
START_CODE.size + 2 + firstMessage.size,
)
val (masked, result) = filter(accessUnit)
assertEquals(2, masked)
assertArrayEquals(expected, result)
}
@Test
fun maskingFilterKeepsTheAccessUnitSize() {
val accessUnit = annexB(seiNal(seiMessage(4, HDR10_PLUS_PAYLOAD)), vclNal())
val buffer = ByteBuffer.wrap(accessUnit.copyOf())
val size = Hdr10PlusMaskingFilter().filter(buffer, 0, accessUnit.size)
assertEquals(accessUnit.size, size)
assertArrayEquals(maskTypeByte(accessUnit, START_CODE.size + 2), buffer.array())
}
companion object {
private val START_CODE = byteArrayOf(0, 0, 0, 1)
private val SHORT_START_CODE = byteArrayOf(0, 0, 1)
/** ITU-T T.35 header for SMPTE ST 2094-40 plus a few opaque payload bytes */
private val HDR10_PLUS_PAYLOAD =
byteArrayOf(0xB5.toByte(), 0x00, 0x3C, 0x00, 0x01, 0x04, 0x01, 0x12, 0x34)
/** ITU-T T.35 with the ATSC provider code (0x0031), as used for CEA-708 captions */
private val CEA_708_PAYLOAD =
byteArrayOf(0xB5.toByte(), 0x00, 0x31, 0x47, 0x41, 0x39, 0x34, 0x03)
private val MASTERING_DISPLAY_PAYLOAD = ByteArray(24) { (it + 1).toByte() }
private val CONTENT_LIGHT_LEVEL_PAYLOAD = byteArrayOf(0x03, 0xE8.toByte(), 0x01, 0xF4.toByte())
/** Encodes an SEI message with the ff-extended payload type and size coding of D.2.1 */
private fun seiMessage(
payloadType: Int,
payload: ByteArray,
): ByteArray {
val out = mutableListOf<Byte>()
var type = payloadType
while (type >= 255) {
out.add(0xFF.toByte())
type -= 255
}
out.add(type.toByte())
var size = payload.size
while (size >= 255) {
out.add(0xFF.toByte())
size -= 255
}
out.add(size.toByte())
payload.forEach { out.add(it) }
return out.toByteArray()
}
/** Inserts emulation prevention bytes as required by H.265 section 7.4.2 */
private fun escape(rbsp: ByteArray): ByteArray {
val out = mutableListOf<Byte>()
var zeroRun = 0
for (b in rbsp) {
if (zeroRun >= 2 && (b.toInt() and 0xFF) <= 0x03) {
out.add(0x03)
zeroRun = 0
}
out.add(b)
zeroRun = if (b == 0.toByte()) zeroRun + 1 else 0
}
return out.toByteArray()
}
/** Builds a prefix SEI NAL unit (type 39) containing the given messages */
private fun seiNal(vararg messages: ByteArray): ByteArray {
val rbsp = messages.fold(byteArrayOf()) { acc, message -> acc + message } + 0x80.toByte()
return byteArrayOf(0x4E, 0x01) + escape(rbsp)
}
/** A minimal VCL NAL unit (type 1, TRAIL_R) */
private fun vclNal(): ByteArray = byteArrayOf(0x02, 0x01, 0x11, 0x22, 0x33)
/** A minimal access unit delimiter NAL unit (type 35) */
private fun audNal(): ByteArray = byteArrayOf(0x46, 0x01, 0x50)
private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal }
private fun maskTypeByte(
data: ByteArray,
index: Int,
): ByteArray = data.copyOf().also { it[index] = 0xFE.toByte() }
private fun filter(data: ByteArray): Pair<Int, ByteArray> {
val buffer = ByteBuffer.wrap(data.copyOf())
val masked = Hdr10PlusSeiFilter.maskHdr10PlusSeiMessages(buffer, 0, data.size)
return masked to buffer.array()
}
}
}