refactor: deduplicate Dolby Vision conversion code

- Extract shared processNal() for NAL classification/conversion logic
- Share DoviExtractorOutputWrapper between MKV and MP4 paths
- Deduplicate DV profile codec queries via deviceSupportsDvProfile()
- Collapse repeated doviTrackOutput access chain in stats
This commit is contained in:
edde746
2026-03-05 12:02:33 +01:00
parent 317d5dbdd9
commit 268f293704
5 changed files with 91 additions and 168 deletions
@@ -23,55 +23,30 @@ object DoviBridge {
fun isAvailable(): Boolean = nativeLoaded &&
runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
/**
* Check if the device has a hardware decoder that supports Dolby Vision Profile 7.
* Queries MediaCodecList for decoders supporting video/dolby-vision with
* DolbyVisionProfileDvheDtr (profile 7).
*/
val deviceSupportsDvProfile7: Boolean by lazy {
private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean {
try {
if (Build.VERSION.SDK_INT < minApi) return false
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val supported = codecList.codecInfos.any { info ->
return codecList.codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { pl ->
pl.profile == MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr
}
info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile }
}
}
Log.i(TAG, "Device DV Profile 7 support: $supported")
supported
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV7 support", e)
false
Log.w(TAG, "Failed to query DV profile $profile support", e)
return false
}
}
/**
* Check if the device has a hardware decoder that supports Dolby Vision Profile 8
* (DvheSt). DolbyVisionProfileDvheSt constant requires API 27+.
*/
val deviceSupportsDvProfile7: Boolean by lazy {
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
}
val deviceSupportsDvProfile8: Boolean by lazy {
try {
if (Build.VERSION.SDK_INT < 27) {
Log.i(TAG, "API < 27, cannot check DV Profile 8 support")
return@lazy false
}
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val supported = codecList.codecInfos.any { info ->
!info.isEncoder && info.supportedTypes.any { type ->
type.equals("video/dolby-vision", ignoreCase = true) &&
info.getCapabilitiesForType(type).profileLevels.any { pl ->
pl.profile == MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt
}
}
}
Log.i(TAG, "Device DV Profile 8 support: $supported")
supported
} catch (e: Exception) {
Log.w(TAG, "Failed to query DV8 support", e)
false
}
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
}
fun getConversionMode(): DvConversionMode = when {
@@ -165,14 +165,42 @@ class DoviConvertingTrackOutput(
// Sample counter for periodic logging
private var sampleCount = 0L
/**
* Process a single NAL: convert RPU (DV81), strip DV layers, or keep.
* Returns processed NAL data to write, or null if stripped.
*/
private fun processNal(nalData: ByteArray): ByteArray? {
if (nalData.size < 2) return nalData
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
convertedRpuCount++
converted
} else {
strippedNalCount++
null
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
null
}
else -> {
normalizeLayerId(nalData)
nalData
}
}
}
/**
* Process NAL units in the sample data. Auto-detects format:
* - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor
* - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor
*
* Strips UNSPEC62 RPU NALs, UNSPEC63 EL NALs, and any NAL with nuh_layer_id > 0.
* Normalizes nuh_layer_id to 0 on all retained NALs.
* Output uses the same format as input.
*/
private fun processNalUnits(data: ByteArray): ByteArray {
if (data.size < 4) return data
@@ -202,12 +230,10 @@ class DoviConvertingTrackOutput(
while (i < data.size - 2) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) {
if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) {
// 4-byte start code: 00 00 00 01
positions.add(Pair(i + 4, 4))
i += 4
continue
} else if (data[i + 2] == 1.toByte()) {
// 3-byte start code: 00 00 01
positions.add(Pair(i + 3, 3))
i += 3
continue
@@ -227,58 +253,25 @@ class DoviConvertingTrackOutput(
val startCodes = findAnnexBStartCodes(data)
if (startCodes.isEmpty()) {
sampleCount++
return data // No start codes found, pass through
return data
}
for (idx in startCodes.indices) {
val nalStart = startCodes[idx].first
val nalEnd = if (idx + 1 < startCodes.size) {
// NAL ends where next start code begins (subtract its start code length area)
// Find the start of the next start code pattern
startCodes[idx + 1].first - startCodes[idx + 1].second
} else {
data.size
}
if (nalEnd <= nalStart) continue
val nalData = data.copyOfRange(nalStart, nalEnd)
if (nalData.size >= 2) {
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
// Convert RPU NAL via libdovi instead of stripping
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
output.write(ANNEX_B_START_CODE)
output.write(converted)
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
stripped++
}
else -> {
normalizeLayerId(nalData)
// Write with 4-byte start code (consistent output)
output.write(ANNEX_B_START_CODE)
output.write(nalData)
kept++
}
}
} else {
val result = processNal(data.copyOfRange(nalStart, nalEnd))
if (result != null) {
output.write(ANNEX_B_START_CODE)
output.write(nalData)
output.write(result)
kept++
} else {
stripped++
}
}
@@ -287,7 +280,6 @@ class DoviConvertingTrackOutput(
Log.d(TAG, "Sample #$sampleCount (AnnexB): ${data.size}B -> ${output.size()}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
}
@@ -311,41 +303,12 @@ class DoviConvertingTrackOutput(
break
}
val nalStart = pos + 4
val nalData = data.copyOfRange(nalStart, nalStart + nalLen)
if (nalData.size >= 2) {
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
// Convert RPU NAL via libdovi instead of stripping
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
writeLengthPrefixedNal(output, converted)
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
stripped++
}
else -> {
normalizeLayerId(nalData)
writeLengthPrefixedNal(output, nalData)
kept++
}
}
} else {
writeLengthPrefixedNal(output, nalData)
val result = processNal(data.copyOfRange(pos + 4, pos + 4 + nalLen))
if (result != null) {
writeLengthPrefixedNal(output, result)
kept++
} else {
stripped++
}
pos += 4 + nalLen
@@ -356,7 +319,6 @@ class DoviConvertingTrackOutput(
Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${data.size}B -> ${output.size()}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
}
@@ -8,13 +8,34 @@ import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
/**
* ExtractorOutput wrapper that intercepts video track creation
* to insert DoviConvertingTrackOutput for DV processing.
* Shared by DoviExtractorWrapper (MP4) and DoviMatroskaExtractor (MKV).
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode)
onVideoTrackWrapped(wrapper)
return wrapper
}
return original
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
* Extractor decorator for Mp4/FragmentedMp4 containers.
* Wraps the video TrackOutput with DoviConvertingTrackOutput to perform
* DV Profile 7 → 8.1 conversion via inline NAL processing.
*
* For MP4, RPU (UNSPEC62) and EL (UNSPEC63) NALs are interleaved in sample data,
* so no BlockAdditions handling is needed.
*/
class DoviExtractorWrapper(
private val delegate: Extractor,
@@ -27,20 +48,7 @@ class DoviExtractorWrapper(
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(object : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = output.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode)
doviTrackOutput = wrapper
return wrapper
}
return original
}
override fun endTracks() = output.endTracks()
override fun seekMap(seekMap: SeekMap) = output.seekMap(seekMap)
})
delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int =
@@ -1,10 +1,8 @@
package com.edde746.plezy.exoplayer
import android.util.Log
import androidx.media3.common.C
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser
import io.github.peerless2012.ass.media.AssHandler
@@ -141,33 +139,11 @@ class DoviMatroskaExtractor(
val field = extractorOutputField ?: return
val output = field.get(this) as? ExtractorOutput ?: return
if (output is DoviExtractorOutputWrapper) return
field.set(this, DoviExtractorOutputWrapper(output, this))
field.set(this, DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
}
private fun clearAttachment() {
currentAttachmentName = null
currentAttachmentMime = null
}
/**
* ExtractorOutput wrapper that intercepts video track creation
* to insert DoviConvertingTrackOutput for DV processing.
*/
class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput,
private val extractor: DoviMatroskaExtractor,
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, extractor.dvMode)
extractor.doviTrackOutput = wrapper
return wrapper
}
return original
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: androidx.media3.extractor.SeekMap) = delegate.seekMap(seekMap)
}
}
@@ -1452,13 +1452,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"isPlaying" to player.isPlaying,
"playbackState" to player.playbackState,
// DV conversion (query extractor's track output, which is set during extraction)
"dvConversionActive" to ((activeDoviMkvExtractor?.doviTrackOutput?.conversionActive
?: activeDoviMp4Wrapper?.doviTrackOutput?.conversionActive) == true),
"dvConversionMode" to dvMode.name,
"dvStrippedNals" to ((activeDoviMkvExtractor?.doviTrackOutput?.strippedNalCount
?: activeDoviMp4Wrapper?.doviTrackOutput?.strippedNalCount) ?: 0L),
"dvConvertedRpus" to ((activeDoviMkvExtractor?.doviTrackOutput?.convertedRpuCount
?: activeDoviMp4Wrapper?.doviTrackOutput?.convertedRpuCount) ?: 0L),
*(activeDoviMkvExtractor?.doviTrackOutput
?: activeDoviMp4Wrapper?.doviTrackOutput).let { dovi ->
arrayOf(
"dvConversionActive" to (dovi?.conversionActive == true),
"dvConversionMode" to dvMode.name,
"dvStrippedNals" to (dovi?.strippedNalCount ?: 0L),
"dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L),
)
},
)
}