Merge Wholphin v1.1.0 while preserving Wolfskin features #27
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(
|
||||
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<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