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:
parent
6d029086e9
commit
2f7804fd5e
15 changed files with 1038 additions and 14 deletions
|
|
@ -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)
|
||||
|
|
|
|||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
|
@ -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 { *; }
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ class AppPreferencesSerializer
|
|||
disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue
|
||||
preferDolbyVisionOverHdr10Plus =
|
||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue
|
||||
convertDolbyVisionProfile7 =
|
||||
ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ object ExperimentalPreference {
|
|||
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 =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.prefer_ac3_for_surround,
|
||||
|
|
@ -93,6 +104,7 @@ val experimentalPreferences =
|
|||
listOf(
|
||||
ExperimentalPreference.VideoTunneling,
|
||||
ExperimentalPreference.PreferDolbyVisionOverHdr10Plus,
|
||||
ExperimentalPreference.ConvertDolbyVisionProfile7,
|
||||
ExperimentalPreference.PreferAc3ForSurround,
|
||||
ExperimentalPreference.DisableAudioOffload,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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<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 =
|
||||
if (preferDolbyVisionOverHdr10Plus) {
|
||||
BitstreamFilteringCodecAdapterFactory(
|
||||
codecAdapterFactory,
|
||||
listOf(Hdr10PlusMaskingFilter()),
|
||||
)
|
||||
if (bitstreamFilters.isNotEmpty()) {
|
||||
BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters)
|
||||
} else {
|
||||
codecAdapterFactory
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -503,6 +503,8 @@
|
|||
<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_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="request">Request</string>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue