Convert Dolby Vision profile 7 to 8.1 on devices without a profile 7 decoder (#2017)

## Description

**On Amlogic boxes (S905X4 and similar) Dolby Vision Profile 7 does not
play as Dolby Vision.**
The server transcodes it, or, with "Direct play Dolby Vision Profile 7"
switched on by hand, the
decoder answers `NoSupport` and fallback ends in HDR10. Devices with a
native Profile 7 decoder,
such as the Shield, are not affected and nothing changes for them.

This PR converts Profile 7 to 8.1 on the fly. It has two halves and one
decision.

The preference `convertDolbyVisionProfile7` is proto field 6 in the
experimental settings, off by
default. In `WholphinRenderersFactory` the filter comes first in the
list, followed by the HDR10+
masking from #1876.

**What this PR does not do.** The enhancement layer is dropped; that is
the price of the
conversion, which is why devices with a native Profile 7 decoder are
left alone.
`BlockAdditional` (an enhancement layer outside the stream) is not read
— like the rest of
Wholphin today — so such a file stays HDR10, as it is now.

### Related issues

Closes #244

Builds on #1876 (the `BitstreamFilter` list). Related to #1540. @fanni42
asked for exactly this on
an S905X4 in #1876.

### Testing

**Hardware** — a debug build of this branch, Homatics box (Amlogic
S905X4, Android 14) + LG BX,
Jellyfin 12.1.0, a Profile 7 FEL remux with the enhancement layer in
band, played from the start
for 39 seconds. Only the new preference was switched on; manual "Direct
play Dolby Vision
Profile 7" was **off**:

* `DolbyVisionProfile7FilterTest` (14) — a fake rewriter on a direct
buffer: offset ≠ 0,
position/limit untouched, every "bytes stay as they were" branch,
three-byte start codes,
  `UnsatisfiedLinkError` tried once;
* `DolbyVisionProfile7ExtractorsFactoryTest` (5, mockk) — only the video
track is rewritten;
* `DolbyVisionProfile7ConversionTest` (6) — codec strings, DRM, `dvh1`.

CI in my fork ran on this branch plus a debug signing commit:
`pre-commit`,
`testDefaultDebugUnitTest` and `assembleDefaultDebug` are all green. The
hardware measurement
above is that build.

## Screenshots

The only UI change is one toggle in the experimental settings.

## AI or LLM usage

The code was written with Claude (Claude Code): the filter, the
extractor wrapper, the condition
and the tests. I reviewed every line and can explain it. It was tested
with the unit tests and
the hardware measurement above. This description was written by me in
Russian and translated
into English with the same assistant.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
SergeiAK 2026-10-28 22:16:42 +03:00 • committed by GitHub
parent 6d029086e9
commit 2f7804fd5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1038 additions and 14 deletions

View file

@ -0,0 +1,86 @@
package com.github.damontecres.wholphin.util
import androidx.media3.common.DrmInitData
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class DolbyVisionProfile7ConversionTest {
@Test
fun recognisesProfile7InBothSampleEntryForms() {
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06")))
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06")))
}
@Test
fun doesNotMistakeOtherProfilesOrCodecsForProfile7() {
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06")))
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06")))
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null)))
assertFalse(
DolbyVisionProfile7Conversion.isProfile7(
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs("dvhe.07.06")
.build(),
),
)
}
@Test
fun rewritesOnlyTheProfileOfTheCodecString() {
assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06"))
assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09"))
}
@Test
fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() {
val format = dolbyVision("dvhe.07.06")
val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
assertEquals("dvhe.08.06", advertised.codecs)
assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType)
assertEquals(format.width, advertised.width)
assertEquals(format.height, advertised.height)
assertEquals(format.initializationData, advertised.initializationData)
}
@Test
fun leavesTracksWhichAreNotProfile7AsTheSameInstance() {
val profile8 = dolbyVision("dvhe.08.06")
val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build()
assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8))
assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc))
}
@Test
fun leavesAnEncryptedProfile7TrackAlone() {
// Encrypted samples take the secure path, which the bitstream filter does not touch
val encrypted =
dolbyVision("dvhe.07.06")
.buildUpon()
.setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1))))
.build()
assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted))
}
private companion object {
fun dolbyVision(codecs: String?): Format =
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(codecs)
.setWidth(3840)
.setHeight(2160)
.setInitializationData(listOf(byteArrayOf(0x01, 0x02)))
.build()
}
}

View file

@ -0,0 +1,119 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.TrackOutput
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* The factory has to reach the video track's format through three layers of media3 forwarding,
* rewrite it there and nowhere else, and let sample data through untouched.
*/
class DolbyVisionProfile7ExtractorsFactoryTest {
private val videoTrack = mockk<TrackOutput>(relaxed = true)
private val audioTrack = mockk<TrackOutput>(relaxed = true)
private val output =
mockk<ExtractorOutput>(relaxed = true) {
every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack
every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack
}
private val extractor = mockk<Extractor>(relaxed = true)
private val delegate =
mockk<ExtractorsFactory> {
every { createExtractors() } returns arrayOf(extractor)
}
@Test
fun advertisesTheVideoTrackAsProfile8() {
val video = initialisedVideoTrack()
video.format(dolbyVision("dvhe.07.06"))
val published = slot<Format>()
verify { videoTrack.format(capture(published)) }
assertEquals("dvhe.08.06", published.captured.codecs)
}
@Test
fun leavesOtherVideoFormatsAsTheyAre() {
val video = initialisedVideoTrack()
val hevc =
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs("hvc1.2.4.L153")
.build()
video.format(hevc)
verify { videoTrack.format(hevc) }
}
@Test
fun leavesTheAudioTrackUnwrapped() {
val wrapped = wrappedOutput()
assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO))
}
@Test
fun passesSampleDataStraightThrough() {
val video = initialisedVideoTrack()
val data = ParsableByteArray(byteArrayOf(1, 2, 3))
video.sampleData(data, 3)
video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null)
verify { videoTrack.sampleData(data, 3) }
verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) }
}
@Test
fun wrapsEveryExtractorOfTheDelegate() {
val another = mockk<Extractor>(relaxed = true)
every { delegate.createExtractors() } returns arrayOf(extractor, another)
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
assertEquals(2, extractors.size)
extractors[1].init(output)
verify { another.init(any()) }
}
private fun wrappedOutput(): ExtractorOutput {
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
extractors.single().init(output)
val wrapped = slot<ExtractorOutput>()
verify { extractor.init(capture(wrapped)) }
return wrapped.captured
}
private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO)
private companion object {
const val VIDEO_ID = 1
const val AUDIO_ID = 2
fun dolbyVision(codecs: String): Format =
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(codecs)
.build()
}
}

View file

@ -0,0 +1,293 @@
package com.github.damontecres.wholphin.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.nio.ByteBuffer
/**
* The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under
* test is everything around it: which access units are touched at all, that the enhancement layer
* goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as
* it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer.
*/
class DolbyVisionProfile7FilterTest {
@Test
fun dropsTheEnhancementLayerAndRewritesTheRpu() {
val filter = filterWith(FakeRewriter(profile = 7))
val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU))
val size = filter.filter(target, 0, target.capacity())
assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
}
@Test
fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() {
// A dual layer remux whose enhancement layer lives in Matroska block additions, which the
// extractor discards: the RPU still needs its rewrite for the profile 8 decoder
val filter = filterWith(FakeRewriter(profile = 7))
val target = directBuffer(annexB(VCL, RPU))
val size = filter.filter(target, 0, target.capacity())
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
}
@Test
fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() {
val filter = filterWith(FakeRewriter(profile = 7))
val accessUnit = annexB(VCL, EL, RPU)
val prefix = byteArrayOf(0x7A, 0x7B)
val suffix = byteArrayOf(0x7C)
val target = directBuffer(prefix + accessUnit + suffix)
val size = filter.filter(target, prefix.size, accessUnit.size)
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size))
assertEquals(prefix.toList(), target.bytes(0, prefix.size))
assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size))
}
@Test
fun doesNotMoveThePositionOrLimitOfTheBuffer() {
val filter = filterWith(FakeRewriter(profile = 7))
val target = directBuffer(annexB(VCL, EL, RPU))
target.position(3)
target.limit(target.capacity() - 1)
filter.filter(target, 0, target.capacity())
assertEquals(3, target.position())
assertEquals(target.capacity() - 1, target.limit())
}
@Test
fun leavesAnAccessUnitWithoutAnRpuAlone() {
val rewriter = FakeRewriter(profile = 7)
val filter = filterWith(rewriter)
val accessUnit = annexB(VPS, VCL, EL)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
assertEquals(0, rewriter.conversions)
}
@Test
fun leavesAnRpuOfAnotherProfileAlone() {
val rewriter = FakeRewriter(profile = 8)
val filter = filterWith(rewriter)
val accessUnit = annexB(VCL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
assertEquals(0, rewriter.conversions)
}
@Test
fun leavesAnAccessUnitAloneWhenTheRewriteThrows() {
val filter = filterWith(FakeRewriter(profile = 7, throws = true))
val accessUnit = annexB(VCL, EL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
}
@Test
fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() {
// No enhancement layer to make room, and an RPU which comes out longer than it went in
val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 }))
val accessUnit = annexB(VCL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
}
@Test
fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() {
val longerRpu = RPU + byteArrayOf(0x55, 0x56)
val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu))
val target = directBuffer(annexB(VCL, EL, RPU))
val size = filter.filter(target, 0, target.capacity())
assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size))
}
@Test
fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() {
val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1))
val accessUnit = annexB(VCL, EL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
}
@Test
fun leavesDataWhichIsNotAnnexBAlone() {
val rewriter = FakeRewriter(profile = 7)
val filter = filterWith(rewriter)
val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C)
val target = directBuffer(data)
val size = filter.filter(target, 0, target.capacity())
assertEquals(data.size, size)
assertEquals(data.toList(), target.bytes(0, size))
assertEquals(0, rewriter.conversions)
}
@Test
fun walksThreeByteStartCodesToo() {
val filter = filterWith(FakeRewriter(profile = 7))
val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size))
}
@Test
fun givesUpForGoodWhenTheNativeLibraryIsMissing() {
var attempts = 0
val filter =
DolbyVisionProfile7Filter {
attempts++
throw UnsatisfiedLinkError("no libdovi_android for this ABI")
}
val accessUnit = annexB(VCL, EL, RPU)
val target = directBuffer(accessUnit)
val first = filter.filter(target, 0, target.capacity())
val second = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, first)
assertEquals(accessUnit.size, second)
assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size))
assertEquals("the library should be tried once, not on every access unit", 1, attempts)
}
@Test
fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() {
val rewriter = FakeRewriter(profile = 7)
val filter = filterWith(rewriter)
val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL))
filter.filter(target, 0, target.capacity())
assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame)
}
/**
* Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the
* Annex B start codes, and swaps its payload for [converted], moving whatever follows.
*/
private class FakeRewriter(
private val profile: Int,
private val converted: ByteArray = CONVERTED_RPU,
private val throws: Boolean = false,
private val reportedSize: Int? = null,
) : RpuRewriter {
var conversions = 0
var lastFrame: List<Byte> = emptyList()
override fun profileOf(
frame: ByteBuffer,
size: Int,
): Int {
assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray())
return profile
}
override fun convertToProfile8(
frame: ByteBuffer,
size: Int,
): Int {
conversions++
lastFrame = frame.bytes(0, size)
if (throws) {
throw IllegalArgumentException("Unable to parse Dolby Vision RPU")
}
val bytes = frame.bytes(0, size).toByteArray()
val rpuStart = indexOfNal(bytes, RPU)
val rpuEnd = nextStartCode(bytes, rpuStart + 1)
val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size)
check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" }
rewritten.forEachIndexed { index, byte -> frame.put(index, byte) }
return reportedSize ?: rewritten.size
}
private fun indexOfNal(
bytes: ByteArray,
nal: ByteArray,
): Int {
for (i in 0..bytes.size - nal.size) {
if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) {
return i
}
}
throw AssertionError("RPU not found in the frame handed to the rewriter")
}
private fun nextStartCode(
bytes: ByteArray,
from: Int,
): Int {
for (i in from..bytes.size - 3) {
if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) {
return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i
}
}
return bytes.size
}
}
companion object {
private val START_CODE = byteArrayOf(0, 0, 0, 1)
private val SHORT_START_CODE = byteArrayOf(0, 0, 1)
/** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */
private fun nal(
type: Int,
vararg payload: Byte,
): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload
private val VPS = nal(32, 0x0C, 0x01, 0x7F)
private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44)
private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E)
private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07)
private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08)
private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal }
private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter }
/** Like the decoder input buffer on a device: direct, with no backing array. */
private fun directBuffer(data: ByteArray): ByteBuffer =
ByteBuffer.allocateDirect(data.size).also { buffer ->
data.forEachIndexed { index, byte -> buffer.put(index, byte) }
assertFalse(buffer.hasArray())
}
private fun ByteBuffer.bytes(
from: Int,
count: Int,
): List<Byte> = (from until from + count).map { get(it) }
}
}