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:
parent
65954a99eb
commit
a577ae9f1a
10 changed files with 944 additions and 2 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue