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

@ -315,6 +315,7 @@ dependencies {
implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.ui.compose) implementation(libs.androidx.media3.ui.compose)
implementation(libs.ass.media) implementation(libs.ass.media)
implementation(libs.exoplayerhdrutils)
implementation(libs.coil.core) implementation(libs.coil.core)
implementation(libs.coil.compose) implementation(libs.coil.compose)

View file

@ -22,3 +22,6 @@
-keep class androidx.media3.ui.SubtitlePainter { -keep class androidx.media3.ui.SubtitlePainter {
private final float outlineWidth; private final float outlineWidth;
} }
# Keep external/native functions
-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; }

View file

@ -186,6 +186,8 @@ class AppPreferencesSerializer
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
preferDolbyVisionOverHdr10Plus = preferDolbyVisionOverHdr10Plus =
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
convertDolbyVisionProfile7 =
ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
}.build() }.build()
}.build() }.build()

View file

@ -48,6 +48,17 @@ object ExperimentalPreference {
summary = R.string.prefer_dovi_over_hdr10_plus_summary, summary = R.string.prefer_dovi_over_hdr10_plus_summary,
) )
val ConvertDolbyVisionProfile7 =
AppSwitchPreference<AppPreferences>(
title = R.string.convert_dovi_profile_7,
defaultValue = false,
getter = { it.experimentalPreferences.convertDolbyVisionProfile7 },
setter = { prefs, value ->
prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value }
},
summary = R.string.convert_dovi_profile_7_summary,
)
val PreferAc3ForSurround = val PreferAc3ForSurround =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.prefer_ac3_for_surround, title = R.string.prefer_ac3_for_surround,
@ -93,6 +104,7 @@ val experimentalPreferences =
listOf( listOf(
ExperimentalPreference.VideoTunneling, ExperimentalPreference.VideoTunneling,
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus, ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
ExperimentalPreference.ConvertDolbyVisionProfile7,
ExperimentalPreference.PreferAc3ForSurround, ExperimentalPreference.PreferAc3ForSurround,
ExperimentalPreference.DisableAudioOffload, ExperimentalPreference.DisableAudioOffload,
), ),

View file

@ -4,10 +4,12 @@ import android.content.Context
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.AssPlaybackMode
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
import com.github.damontecres.wholphin.preferences.HdrFormat
import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
import com.github.damontecres.wholphin.util.profile.createDeviceProfile import com.github.damontecres.wholphin.util.profile.createDeviceProfile
@ -66,7 +68,17 @@ class DeviceProfileService
decodeAv1 = prefs.overrides.decodeAv1, decodeAv1 = prefs.overrides.decodeAv1,
preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround },
forceEnabledHdr = forceEnabledHdr =
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven), buildSet {
appPrefs
.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven)
.let(::addAll)
// A profile 7 track the player converts to 8.1 is one the server need not transcode
if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) {
HdrFormat.DOVI_PROFILE_7
.rangeTypeNames(newConfig.jellyfinTenEleven)
.let(::addAll)
}
},
forceDisabledHdr = forceDisabledHdr =
appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven),
jellyfinTenEleven = newConfig.jellyfinTenEleven, jellyfinTenEleven = newConfig.jellyfinTenEleven,

View file

@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.MediaCodecVideoRenderer
import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.mpv.MpvPlayer
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
@ -32,7 +33,11 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.preferences.get
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.BitstreamFilter
import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion
import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory
import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter
import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -57,6 +62,7 @@ class PlayerFactory
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient,
private val deviceProfileService: DeviceProfileService,
) { ) {
@Volatile @Volatile
var currentPlayer: Player? = null var currentPlayer: Player? = null
@ -94,10 +100,16 @@ class PlayerFactory
val preferDolbyVision = val preferDolbyVision =
appPreferences.experimentalPreferences appPreferences.experimentalPreferences
.get { preferDolbyVisionOverHdr10Plus } ?: false .get { preferDolbyVisionOverHdr10Plus } ?: false
val convertDolbyVisionProfile7 =
DolbyVisionProfile7Conversion.isWanted(
appPreferences.experimentalPreferences,
deviceProfileService.mediaCodecCapabilitiesTest,
)
Timber.v( Timber.v(
"extensions=%s, assPlaybackMode=%s", "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s",
extensions, extensions,
prefs.overrides.assPlaybackMode, prefs.overrides.assPlaybackMode,
convertDolbyVisionProfile7,
) )
val rendererMode = val rendererMode =
when (extensions) { when (extensions) {
@ -108,9 +120,18 @@ class PlayerFactory
} }
val dataSourceFactory = DefaultDataSource.Factory(context) val dataSourceFactory = DefaultDataSource.Factory(context)
val extractorsFactory = createExtractorsFactory() val extractorsFactory = createExtractorsFactory()
// Applied outermost so that a profile 7 track is advertised as 8.1 whichever
// extractor, media3's own or libass's Matroska replacement, produced it
fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory =
if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this
var renderersFactory: RenderersFactory = var renderersFactory: RenderersFactory =
WholphinRenderersFactory(context, decodeAv1, preferDolbyVision) WholphinRenderersFactory(
.setEnableDecoderFallback(true) context,
decodeAv1,
preferDolbyVision,
convertDolbyVisionProfile7,
).setEnableDecoderFallback(true)
.setExtensionRendererMode(rendererMode) .setExtensionRendererMode(rendererMode)
val mediaSourceFactory = val mediaSourceFactory =
@ -126,15 +147,16 @@ class PlayerFactory
renderersFactory = AssRenderersFactory(assHandler, renderersFactory) renderersFactory = AssRenderersFactory(assHandler, renderersFactory)
DefaultMediaSourceFactory( DefaultMediaSourceFactory(
dataSourceFactory, dataSourceFactory,
extractorsFactory.withAssMkvSupport( extractorsFactory
assSubtitleParserFactory, .withAssMkvSupport(
assHandler, assSubtitleParserFactory,
), assHandler,
).forDolbyVision(),
).setSubtitleParserFactory(assSubtitleParserFactory) ).setSubtitleParserFactory(assSubtitleParserFactory)
} else { } else {
DefaultMediaSourceFactory( DefaultMediaSourceFactory(
dataSourceFactory, dataSourceFactory,
extractorsFactory, extractorsFactory.forDolbyVision(),
) )
} }
val disableAudioOffload = val disableAudioOffload =
@ -268,6 +290,7 @@ class WholphinRenderersFactory(
context: Context, context: Context,
private val av1Enabled: Boolean, private val av1Enabled: Boolean,
private val preferDolbyVisionOverHdr10Plus: Boolean = false, private val preferDolbyVisionOverHdr10Plus: Boolean = false,
private val convertDolbyVisionProfile7: Boolean = false,
) : DefaultRenderersFactory(context) { ) : DefaultRenderersFactory(context) {
@OptIn(ExperimentalApi::class) @OptIn(ExperimentalApi::class)
override fun buildVideoRenderers( override fun buildVideoRenderers(
@ -280,12 +303,16 @@ class WholphinRenderersFactory(
allowedVideoJoiningTimeMs: Long, allowedVideoJoiningTimeMs: Long,
out: ArrayList<Renderer>, out: ArrayList<Renderer>,
) { ) {
// The conversion goes first: it may shorten the access unit, and the masking then only
// has to walk what is left of it
val bitstreamFilters =
buildList<BitstreamFilter> {
if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter())
if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter())
}
val videoCodecAdapterFactory = val videoCodecAdapterFactory =
if (preferDolbyVisionOverHdr10Plus) { if (bitstreamFilters.isNotEmpty()) {
BitstreamFilteringCodecAdapterFactory( BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
codecAdapterFactory,
listOf(Hdr10PlusMaskingFilter()),
)
} else { } else {
codecAdapterFactory codecAdapterFactory
} }

View file

@ -0,0 +1,71 @@
@file:OptIn(UnstableApi::class)
package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import com.github.damontecres.wholphin.preferences.ExperimentalPreferences
import com.github.damontecres.wholphin.preferences.enabled
import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest
/**
* The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this
* device, and how a converted track is described.
*
* Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and
* 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not
* switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer.
* Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the
* single layer stream that decoder does switch for. Nothing is re-encoded.
*
* The conversion has two halves which have to agree:
* - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder;
* - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because
* media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would
* otherwise never be chosen for it.
*
* Both are switched on by [isWanted], as is direct play of profile 7 in the device profile.
*/
object DolbyVisionProfile7Conversion {
/**
* True when both the user has enabled the setting and this device is one it helps: its Dolby
* Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself
* is left alone, since it can use the enhancement layer the conversion would throw away, and
* a device without a profile 8 decoder has nothing to handle the converted stream
*/
fun isWanted(
experimental: ExperimentalPreferences,
mediaTest: MediaCodecCapabilitiesTest,
): Boolean =
experimental.enabled { convertDolbyVisionProfile7 } &&
!mediaTest.supportsHevcDolbyVisionEL() &&
mediaTest.supportsHevcDolbyVisionProfile8()
/** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */
fun isProfile7(format: Format): Boolean {
val codecs = format.codecs ?: return false
return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType &&
(codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07))
}
/**
* The [format] as the converted stream should be advertised: `dvhe.07.06` becomes
* `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they
* are: their samples reach the decoder through the secure path the filter does not touch, so
* advertising them as converted would be a lie.
*/
fun advertiseAsProfile8(format: Format): Format =
if (isProfile7(format) && format.drmInitData == null) {
format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build()
} else {
format
}
/** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */
fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7)
private const val CODECS_PREFIX_DVHE_07 = "dvhe.07"
private const val CODECS_PREFIX_DVH1_07 = "dvh1.07"
}

View file

@ -0,0 +1,79 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.util
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
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.ForwardingExtractor
import androidx.media3.extractor.ForwardingExtractorOutput
import androidx.media3.extractor.ForwardingExtractorsFactory
import androidx.media3.extractor.ForwardingTrackOutput
import androidx.media3.extractor.TrackOutput
import timber.log.Timber
/**
* Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what
* [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them.
*
* media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not
* profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on
* whichever decoder comes first in the list, with a warning that the format exceeds its
* capabilities. Rewriting the codec string at the extractor routes the track to the profile 8
* decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same
* string.
*
* Only the codec string changes. Sample data is not touched here: the two convenience overloads of
* [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted.
*/
class DolbyVisionProfile7ExtractorsFactory(
delegate: ExtractorsFactory,
) : ForwardingExtractorsFactory(delegate) {
override fun createExtractors(): Array<Extractor> = wrap(super.createExtractors())
override fun createExtractors(
uri: Uri,
responseHeaders: Map<String, List<String>>,
): Array<Extractor> = wrap(super.createExtractors(uri, responseHeaders))
private fun wrap(extractors: Array<Extractor>): Array<Extractor> =
Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) }
}
private class Profile8AdvertisingExtractor(
delegate: Extractor,
) : ForwardingExtractor(delegate) {
override fun init(output: ExtractorOutput) {
super.init(Profile8AdvertisingExtractorOutput(output))
}
}
private class Profile8AdvertisingExtractorOutput(
output: ExtractorOutput,
) : ForwardingExtractorOutput(output) {
override fun track(
id: Int,
type: Int,
): TrackOutput {
val trackOutput = super.track(id, type)
return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput
}
}
/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */
internal class Profile8AdvertisingTrackOutput(
delegate: TrackOutput,
) : ForwardingTrackOutput(delegate) {
override fun format(format: Format) {
val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
if (advertised !== format) {
Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs)
}
super.format(advertised)
}
}

View file

@ -0,0 +1,313 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi
import timber.log.Timber
import java.nio.Buffer
import java.nio.ByteBuffer
/**
* Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is
* libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own.
*
* Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long.
* That is the shape the native code reads: it takes the buffer's address and capacity, not its
* position and limit.
*/
interface RpuRewriter {
/**
* The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is
* none it can parse.
*/
fun profileOf(
frame: ByteBuffer,
size: Int,
): Int
/**
* Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new
* size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's
* capacity must leave room past [size] for an RPU which comes out longer than it went in.
*/
fun convertToProfile8(
frame: ByteBuffer,
size: Int,
): Int
}
/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */
class LibDoviRpuRewriter : RpuRewriter {
private val libDovi = LibDovi()
override fun profileOf(
frame: ByteBuffer,
size: Int,
): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0
override fun convertToProfile8(
frame: ByteBuffer,
size: Int,
): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false)
private companion object {
/** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */
const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1
}
}
/**
* Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder.
*
* Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the
* picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU
* into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby
* Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a
* decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and
* shows the HDR10 base layer, which is the very symptom this filter exists to fix.
*
* Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is
* left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer
* and is copied back only once it has succeeded and fits, so a refused access unit reaches the
* decoder untouched rather than half rewritten.
*
* The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is
* [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is
* called from its playback thread only.
*/
class DolbyVisionProfile7Filter(
private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() },
) : BitstreamFilter {
private var rewriter: RpuRewriter? = null
private var rewriterUnavailable = false
/** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */
private var staging = ByteArray(0)
/** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */
private var scratch: ByteBuffer? = null
private var convertedCount = 0
private var passedThroughCount = 0
private var refusedCount = 0
override fun filter(
data: ByteBuffer,
offset: Int,
size: Int,
): Int {
if (!startsWithStartCode(data, offset, size)) {
return size
}
val rewriter = rewriter() ?: return size
val staging = staging(size)
window(data, offset, size).get(staging, 0, size)
if (!carriesRpu(staging, size)) {
passedThroughCount++
return size
}
val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES)
val kept = copyWithoutEnhancementLayer(staging, size, scratch)
val converted =
try {
if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) {
passedThroughCount++
return size
}
rewriter.convertToProfile8(scratch, kept)
} catch (e: RuntimeException) {
refuse("the RPU rewrite threw", e)
return size
}
if (converted <= 0 || converted > size) {
refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null)
return size
}
window(data, offset, converted).put(window(scratch, 0, converted))
convertedCount++
return converted
}
private fun rewriter(): RpuRewriter? {
if (rewriterUnavailable) {
return null
}
return rewriter ?: try {
newRewriter().also { rewriter = it }
} catch (e: LinkageError) {
// No libdovi for this ABI. Playback goes on exactly as it did without the filter.
Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is")
rewriterUnavailable = true
null
}
}
private fun refuse(
reason: String,
e: Throwable?,
) {
if (refusedCount == 0) {
Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason)
}
refusedCount++
}
private fun staging(size: Int): ByteArray {
if (staging.size < size) {
staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES))
}
return staging
}
private fun scratch(capacity: Int): ByteBuffer {
val current = scratch
val buffer =
if (current == null || current.capacity() < capacity) {
ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it }
} else {
current
}
(buffer as Buffer).clear()
return buffer
}
override fun toString(): String =
"Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " +
"$passedThroughCount passed through, $refusedCount refused)"
private companion object {
const val DOLBY_VISION_PROFILE_7 = 7
/** HEVC NAL unit type carrying the Dolby Vision RPU. */
const val NAL_UNIT_TYPE_RPU = 62
/** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */
const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63
/** Big enough for a UHD access unit without regrowing on every early frame. */
const val INITIAL_CAPACITY_BYTES = 512 * 1024
/**
* Room past the access unit for an RPU which comes out of the rewrite longer than it went
* in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose
* enhancement layer the extractor never delivered has none to free.
*/
const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024
private fun startsWithStartCode(
data: ByteBuffer,
offset: Int,
size: Int,
): Boolean {
if (size < 4) {
return false
}
return data.get(offset) == 0.toByte() &&
data.get(offset + 1) == 0.toByte() &&
(
data.get(offset + 2) == 1.toByte() ||
(data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte())
)
}
/** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */
private fun carriesRpu(
source: ByteArray,
length: Int,
): Boolean {
var unit = 0
while (unit < length) {
val payload = unit + startCodeLength(source, unit)
if (payload >= length) {
return false
}
if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) {
return true
}
unit = nextStartCode(source, payload, length)
}
return false
}
/**
* Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0,
* leaving out every enhancement layer NAL unit, and returns the number of bytes copied.
*/
fun copyWithoutEnhancementLayer(
source: ByteArray,
length: Int,
target: ByteBuffer,
): Int {
var unit = 0
var written = 0
while (unit < length) {
val payload = unit + startCodeLength(source, unit)
if (payload >= length) {
// A start code with nothing behind it. Not something to interpret; kept as it came.
target.put(source, unit, length - unit)
written += length - unit
break
}
val next = nextStartCode(source, payload, length)
if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) {
target.put(source, unit, next - unit)
written += next - unit
}
unit = next
}
return written
}
/**
* A view of `[offset, offset + length)` of [data] with a position and limit of its own, so
* that bulk copies leave the buffer's untouched. The position and limit calls go through
* [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23.
*/
private fun window(
data: ByteBuffer,
offset: Int,
length: Int,
): ByteBuffer {
val view = data.duplicate()
val buffer: Buffer = view
buffer.clear()
buffer.position(offset)
buffer.limit(offset + length)
return view
}
/** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */
private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F
/** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */
private fun startCodeLength(
data: ByteArray,
at: Int,
): Int = if (data[at + 2] == 1.toByte()) 3 else 4
/**
* Index of the next Annex B start code at or after [from], or [limit] if there is none. A
* fourth leading zero byte counts as part of the start code, which is the form media3's
* extractors emit; three byte codes are walked all the same.
*/
private fun nextStartCode(
data: ByteArray,
from: Int,
limit: Int,
): Int {
var i = from
while (i + 2 < limit) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) {
return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i
}
i++
}
return limit
}
}
}

View file

@ -241,6 +241,7 @@ message ExperimentalPreferences {
bool prefer_ac3_surround = 3; bool prefer_ac3_surround = 3;
bool disable_audio_offload = 4; bool disable_audio_offload = 4;
bool prefer_dolby_vision_over_hdr10_plus = 5; bool prefer_dolby_vision_over_hdr10_plus = 5;
bool convert_dolby_vision_profile_7 = 6;
} }
message AppPreferences { message AppPreferences {

View file

@ -503,6 +503,8 @@
<string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string> <string name="force_dovi_profile_7_summary">Ignores device compatibility checks</string>
<string name="prefer_dovi_over_hdr10_plus">Prefer Dolby Vision over HDR10+</string> <string name="prefer_dovi_over_hdr10_plus">Prefer Dolby Vision over HDR10+</string>
<string name="prefer_dovi_over_hdr10_plus_summary">Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+</string> <string name="prefer_dovi_over_hdr10_plus_summary">Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+</string>
<string name="convert_dovi_profile_7">Convert Dolby Vision Profile 7 to 8.1</string>
<string name="convert_dovi_profile_7_summary">Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7</string>
<string name="discover">Discover</string> <string name="discover">Discover</string>
<string name="request">Request</string> <string name="request">Request</string>

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) }
}
}

View file

@ -26,6 +26,8 @@ tvMaterial = "1.1.0-alpha01"
lifecycleRuntimeKtx = "2.11.0" lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
androidx-media3 = "1.11.1" androidx-media3 = "1.11.1"
# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT
exoplayerhdrutils = "0.4.1"
coil = "3.6.2" coil = "3.6.2"
jellyfin-sdk = "1.7.1" jellyfin-sdk = "1.7.1"
nav3Core = "1.1.7" nav3Core = "1.1.7"
@ -118,6 +120,7 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }