diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9fc3516..8e8b259f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,6 +315,7 @@ dependencies { implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) implementation(libs.ass.media) + implementation(libs.exoplayerhdrutils) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index cddfb043..b2a0c548 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,3 +22,6 @@ -keep class androidx.media3.ui.SubtitlePainter { private final float outlineWidth; } + +# Keep external/native functions +-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index cb47ca94..85f8aade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -186,6 +186,8 @@ class AppPreferencesSerializer disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue preferDolbyVisionOverHdr10Plus = ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue + convertDolbyVisionProfile7 = + ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue }.build() }.build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt index 0647a900..46c75636 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -48,6 +48,17 @@ object ExperimentalPreference { summary = R.string.prefer_dovi_over_hdr10_plus_summary, ) + val ConvertDolbyVisionProfile7 = + AppSwitchPreference( + 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 = AppSwitchPreference( title = R.string.prefer_ac3_for_surround, @@ -93,6 +104,7 @@ val experimentalPreferences = listOf( ExperimentalPreference.VideoTunneling, ExperimentalPreference.PreferDolbyVisionOverHdr10Plus, + ExperimentalPreference.ConvertDolbyVisionProfile7, ExperimentalPreference.PreferAc3ForSurround, ExperimentalPreference.DisableAudioOffload, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 939f2b72..13ef5f70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -4,10 +4,12 @@ import android.content.Context import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode 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.PlaybackOverrides import com.github.damontecres.wholphin.preferences.enabled 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.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -66,7 +68,17 @@ class DeviceProfileService decodeAv1 = prefs.overrides.decodeAv1, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, 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 = appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), jellyfinTenEleven = newConfig.jellyfinTenEleven, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 9b16718d..ff1309fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.mpv.MpvPlayer 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.get 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.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.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext @@ -57,6 +62,7 @@ class PlayerFactory constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val deviceProfileService: DeviceProfileService, ) { @Volatile var currentPlayer: Player? = null @@ -94,10 +100,16 @@ class PlayerFactory val preferDolbyVision = appPreferences.experimentalPreferences .get { preferDolbyVisionOverHdr10Plus } ?: false + val convertDolbyVisionProfile7 = + DolbyVisionProfile7Conversion.isWanted( + appPreferences.experimentalPreferences, + deviceProfileService.mediaCodecCapabilitiesTest, + ) Timber.v( - "extensions=%s, assPlaybackMode=%s", + "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", extensions, prefs.overrides.assPlaybackMode, + convertDolbyVisionProfile7, ) val rendererMode = when (extensions) { @@ -108,9 +120,18 @@ class PlayerFactory } val dataSourceFactory = DefaultDataSource.Factory(context) 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 = - WholphinRenderersFactory(context, decodeAv1, preferDolbyVision) - .setEnableDecoderFallback(true) + WholphinRenderersFactory( + context, + decodeAv1, + preferDolbyVision, + convertDolbyVisionProfile7, + ).setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) val mediaSourceFactory = @@ -126,15 +147,16 @@ class PlayerFactory renderersFactory = AssRenderersFactory(assHandler, renderersFactory) DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory.withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ), + extractorsFactory + .withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ).forDolbyVision(), ).setSubtitleParserFactory(assSubtitleParserFactory) } else { DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory, + extractorsFactory.forDolbyVision(), ) } val disableAudioOffload = @@ -268,6 +290,7 @@ class WholphinRenderersFactory( context: Context, private val av1Enabled: Boolean, private val preferDolbyVisionOverHdr10Plus: Boolean = false, + private val convertDolbyVisionProfile7: Boolean = false, ) : DefaultRenderersFactory(context) { @OptIn(ExperimentalApi::class) override fun buildVideoRenderers( @@ -280,12 +303,16 @@ class WholphinRenderersFactory( allowedVideoJoiningTimeMs: Long, out: ArrayList, ) { + // 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 { + if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter()) + if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter()) + } val videoCodecAdapterFactory = - if (preferDolbyVisionOverHdr10Plus) { - BitstreamFilteringCodecAdapterFactory( - codecAdapterFactory, - listOf(Hdr10PlusMaskingFilter()), - ) + if (bitstreamFilters.isNotEmpty()) { + BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters) } else { codecAdapterFactory } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt new file mode 100644 index 00000000..b4f8e15f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt @@ -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" +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt new file mode 100644 index 00000000..632f46c3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt @@ -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 = wrap(super.createExtractors()) + + override fun createExtractors( + uri: Uri, + responseHeaders: Map>, + ): Array = wrap(super.createExtractors(uri, responseHeaders)) + + private fun wrap(extractors: Array): Array = + 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) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt new file mode 100644 index 00000000..7cf50622 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt @@ -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 + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 6c184f2c..d5bf5701 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -241,6 +241,7 @@ message ExperimentalPreferences { bool prefer_ac3_surround = 3; bool disable_audio_offload = 4; bool prefer_dolby_vision_over_hdr10_plus = 5; + bool convert_dolby_vision_profile_7 = 6; } message AppPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e263261c..189bf82d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -503,6 +503,8 @@ Ignores device compatibility checks Prefer Dolby Vision over HDR10+ Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+ + Convert Dolby Vision Profile 7 to 8.1 + 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 Discover Request diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt new file mode 100644 index 00000000..483fa3ef --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt @@ -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() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt new file mode 100644 index 00000000..d888be15 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt @@ -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(relaxed = true) + private val audioTrack = mockk(relaxed = true) + private val output = + mockk(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(relaxed = true) + private val delegate = + mockk { + every { createExtractors() } returns arrayOf(extractor) + } + + @Test + fun advertisesTheVideoTrackAsProfile8() { + val video = initialisedVideoTrack() + + video.format(dolbyVision("dvhe.07.06")) + + val published = slot() + 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(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() + 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() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt new file mode 100644 index 00000000..b5c6189c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt @@ -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 = 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 = (from until from + count).map { get(it) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f7d895e..211d8e07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,8 @@ tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" 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" jellyfin-sdk = "1.7.1" 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-compose = { module = "androidx.media3:media3-ui-compose", 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-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }