fix(android): strip DV init data

This commit is contained in:
edde746
2026-05-18 22:06:39 +02:00
parent 81b05f00df
commit 55ee2c270d
3 changed files with 162 additions and 42 deletions
@@ -28,7 +28,8 @@ import androidx.media3.extractor.TrackOutput
*/ */
class DoviConvertingTrackOutput( class DoviConvertingTrackOutput(
private val delegate: TrackOutput, private val delegate: TrackOutput,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : TrackOutput { ) : TrackOutput {
companion object { companion object {
@@ -45,6 +46,8 @@ class DoviConvertingTrackOutput(
private set private set
var strippedNalCount = 0L var strippedNalCount = 0L
private set private set
var strippedInitNalCount = 0L
private set
var convertedRpuCount = 0L var convertedRpuCount = 0L
private set private set
var rpuConversionFailureCount = 0L var rpuConversionFailureCount = 0L
@@ -75,13 +78,13 @@ class DoviConvertingTrackOutput(
val codecs = format.codecs val codecs = format.codecs
if (codecs != null && codecs.startsWith("dvhe.07")) { if (codecs != null && codecs.startsWith("dvhe.07")) {
conversionActive = true conversionActive = true
Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode") logInfo("DV Profile 7 detected ($codecs), mode=$dvMode")
Log.i( logInfo(
TAG,
"Original format: mime=${format.sampleMimeType}, codecs=$codecs, " + "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
"initData=${format.initializationData.size} entries " + "initData=${format.initializationData.size} entries " +
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})" "(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})"
) )
val strippedInitializationData = stripInitializationData(format.initializationData)
val newFormat = when (dvMode) { val newFormat = when (dvMode) {
DvConversionMode.DV81 -> { DvConversionMode.DV81 -> {
@@ -89,39 +92,26 @@ class DoviConvertingTrackOutput(
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6 val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
val newCodecs = "dvhe.08.%02d".format(level) val newCodecs = "dvhe.08.%02d".format(level)
val dvConfigRecord = buildDv81ConfigRecord(level) val dvConfigRecord = buildDv81ConfigRecord(level)
Log.i(TAG, "DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B") logInfo("DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
format.buildUpon() format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(newCodecs) .setCodecs(newCodecs)
.setInitializationData( .setInitializationData(strippedInitializationData + dvConfigRecord)
if (format.initializationData.isNotEmpty()) {
listOf(format.initializationData[0], dvConfigRecord)
} else {
listOf(ByteArray(0), dvConfigRecord)
}
)
.build() .build()
} }
else -> { else -> {
// HEVC_STRIP: present as plain HEVC // HEVC_STRIP: present as plain HEVC
Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc") logInfo("HEVC_STRIP: rewriting to video/hevc")
format.buildUpon() format.buildUpon()
.setSampleMimeType(MimeTypes.VIDEO_H265) .setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs(null) .setCodecs(null)
.setInitializationData( .setInitializationData(strippedInitializationData)
if (format.initializationData.isNotEmpty()) {
listOf(format.initializationData[0])
} else {
emptyList()
}
)
.build() .build()
} }
} }
Log.i( logInfo(
TAG,
"Rewritten format: mime=${newFormat.sampleMimeType}, " + "Rewritten format: mime=${newFormat.sampleMimeType}, " +
"codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries" "codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries"
) )
@@ -186,7 +176,7 @@ class DoviConvertingTrackOutput(
processNalUnits(srcLen) processNalUnits(srcLen)
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "NAL processing failed, passing raw sample", e) logError("NAL processing failed, passing raw sample", e)
false false
} }
if (success) { if (success) {
@@ -240,8 +230,7 @@ class DoviConvertingTrackOutput(
) )
if (sampleCount == 0L) { if (sampleCount == 0L) {
Log.d( logDebug(
TAG,
"NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " + "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}" "first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}"
) )
@@ -340,8 +329,7 @@ class DoviConvertingTrackOutput(
sampleCount++ sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d( logDebug(
TAG,
"Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " + "Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs" "kept=$kept stripped=$stripped NALs"
) )
@@ -363,7 +351,7 @@ class DoviConvertingTrackOutput(
if (nalLen <= 0 || pos + 4 + nalLen > dataLen) { if (nalLen <= 0 || pos + 4 + nalLen > dataLen) {
if (sampleCount < 5) { if (sampleCount < 5) {
Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=$dataLen)") logWarn("Bad NAL length $nalLen at pos $pos (data.size=$dataLen)")
} }
break break
} }
@@ -401,8 +389,7 @@ class DoviConvertingTrackOutput(
sampleCount++ sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d( logDebug(
TAG,
"Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " + "Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs" "kept=$kept stripped=$stripped NALs"
) )
@@ -449,8 +436,7 @@ class DoviConvertingTrackOutput(
private fun recordSampleProcessing(elapsedUs: Long) { private fun recordSampleProcessing(elapsedUs: Long) {
totalSampleProcessingTimeUs += elapsedUs totalSampleProcessingTimeUs += elapsedUs
if (sampleCount <= 3 || (sampleCount > 0 && sampleCount % 500 == 0L)) { if (sampleCount <= 3 || (sampleCount > 0 && sampleCount % 500 == 0L)) {
Log.d( logDebug(
TAG,
"Perf: avgSample=${averageSampleProcessingTimeUs}us, " + "Perf: avgSample=${averageSampleProcessingTimeUs}us, " +
"avgRpu=${averageRpuConversionTimeUs}us, converted=$convertedRpuCount, " + "avgRpu=${averageRpuConversionTimeUs}us, converted=$convertedRpuCount, " +
"rpuFailures=$rpuConversionFailureCount, rpuTooSmall=$rpuOutputTooSmallCount" "rpuFailures=$rpuConversionFailureCount, rpuTooSmall=$rpuOutputTooSmallCount"
@@ -458,14 +444,121 @@ class DoviConvertingTrackOutput(
} }
} }
private fun stripInitializationData(initializationData: List<ByteArray>): List<ByteArray> {
if (initializationData.isEmpty()) return emptyList()
val stripped = ArrayList<ByteArray>(initializationData.size)
var beforeBytes = 0
var afterBytes = 0
var droppedBuffers = 0
for (data in initializationData) {
beforeBytes += data.size
val processed = stripInitializationAnnexB(data)
if (processed == null) {
// Media3's HEVC init data is normally Annex B. Non-AnnexB entries in
// Dolby Vision formats are typically dvcC/dvvC records, which must not
// be forwarded when advertising plain HEVC.
droppedBuffers++
continue
}
if (processed.isEmpty()) {
droppedBuffers++
continue
}
afterBytes += processed.size
stripped.add(processed)
}
logInfo(
"Init data stripped: ${initializationData.size} entries/${beforeBytes}B -> " +
"${stripped.size} entries/${afterBytes}B, strippedNals=$strippedInitNalCount, " +
"droppedBuffers=$droppedBuffers"
)
return stripped
}
private fun stripInitializationAnnexB(data: ByteArray): ByteArray? {
if (data.isEmpty()) return data
val firstStartCodeEnd = findStartCodeEnd(data, 0) ?: return null
ensureOutputCapacity(data.size)
outputLen = 0
var kept = 0
var stripped = 0
var nalStart = firstStartCodeEnd
while (nalStart < data.size) {
val nalEnd = findNextStartCodeOffset(data, nalStart) ?: data.size
val nalLen = nalEnd - nalStart
if (nalLen > 0) {
when (classifyNal(data, nalStart, nalLen, convertRpu = false)) {
NalAction.KEEP -> {
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(data, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
}
else -> {
strippedInitNalCount++
stripped++
}
}
}
if (nalEnd >= data.size) break
nalStart = findStartCodeEnd(data, nalEnd) ?: break
}
logDebug("Init data sample: ${data.size}B -> ${outputLen}B, kept=$kept stripped=$stripped NALs")
return outputBuf.copyOf(outputLen)
}
private fun findStartCodeEnd(data: ByteArray, from: Int): Int? {
var i = from
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()) {
return i + 4
} else if (data[i + 2] == 1.toByte()) {
return i + 3
}
}
i++
}
return null
}
private fun findNextStartCodeOffset(data: ByteArray, from: Int): Int? {
var i = from
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()) {
return i
} else if (data[i + 2] == 1.toByte()) {
return i
}
}
i++
}
return null
}
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */ /** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
private fun processNalInline(offset: Int, len: Int): NalAction { private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(sampleBuf, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
private fun classifyNal(data: ByteArray, offset: Int, len: Int, convertRpu: Boolean): NalAction {
if (len < 2) return NalAction.KEEP if (len < 2) return NalAction.KEEP
val nalType = (sampleBuf[offset].toInt() ushr 1) and 0x3F val nalType = (data[offset].toInt() ushr 1) and 0x3F
val nuhLayerId = ((sampleBuf[offset].toInt() and 1) shl 5) or val nuhLayerId = ((data[offset].toInt() and 1) shl 5) or
((sampleBuf[offset + 1].toInt() ushr 3) and 0x1F) ((data[offset + 1].toInt() ushr 3) and 0x1F)
return when { return when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> NalAction.CONVERT nalType == NAL_TYPE_UNSPEC62 && convertRpu -> NalAction.CONVERT
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP
else -> NalAction.KEEP else -> NalAction.KEEP
} }
@@ -497,6 +590,26 @@ class DoviConvertingTrackOutput(
} }
} }
private fun logDebug(message: String) {
Log.d(TAG, message)
emitLog?.invoke("debug", "dv-convert", message)
}
private fun logInfo(message: String) {
Log.i(TAG, message)
emitLog?.invoke("info", "dv-convert", message)
}
private fun logWarn(message: String) {
Log.w(TAG, message)
emitLog?.invoke("warn", "dv-convert", message)
}
private fun logError(message: String, throwable: Throwable) {
Log.e(TAG, message, throwable)
emitLog?.invoke("error", "dv-convert", "$message: ${throwable.message}")
}
/** /**
* Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1. * Build a 24-byte DOVIDecoderConfigurationRecord for DV Profile 8.1.
* *
@@ -16,12 +16,13 @@ import androidx.media3.extractor.TrackOutput
class DoviExtractorOutputWrapper( class DoviExtractorOutputWrapper(
private val delegate: ExtractorOutput, private val delegate: ExtractorOutput,
private val dvMode: DvConversionMode, private val dvMode: DvConversionMode,
private val emitLog: ((String, String, String) -> Unit)?,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
) : ExtractorOutput { ) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput { override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type) val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) { if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode) val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog)
onVideoTrackWrapped(wrapper) onVideoTrackWrapped(wrapper)
return wrapper return wrapper
} }
@@ -39,7 +40,8 @@ class DoviExtractorOutputWrapper(
*/ */
class DoviExtractorWrapper( class DoviExtractorWrapper(
private val delegate: Extractor, private val delegate: Extractor,
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
private val emitLog: ((String, String, String) -> Unit)? = null
) : Extractor { ) : Extractor {
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null @Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
@@ -48,7 +50,7 @@ class DoviExtractorWrapper(
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) { override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it }) delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
} }
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition) override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
@@ -438,7 +438,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
extractor is MatroskaExtractor -> { extractor is MatroskaExtractor -> {
val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler) val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler)
val inner = if (doviEnabled) { val inner = if (doviEnabled) {
DoviExtractorWrapper(assExtractor, currentDvMode).also { DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message ->
emitLog(level, prefix, message)
}.also {
activeDoviMkvWrapper = it activeDoviMkvWrapper = it
} }
} else { } else {
@@ -448,7 +450,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
CuelessSeekExtractorWrapper(inner) CuelessSeekExtractorWrapper(inner)
} }
doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> {
DoviExtractorWrapper(extractor, currentDvMode).also { DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message ->
emitLog(level, prefix, message)
}.also {
activeDoviMp4Wrapper = it activeDoviMp4Wrapper = it
} }
} }
@@ -1909,6 +1913,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"dvConversionActive" to (dovi?.conversionActive == true), "dvConversionActive" to (dovi?.conversionActive == true),
"dvConversionMode" to dvMode.name, "dvConversionMode" to dvMode.name,
"dvConversionDebugMode" to (debugDvModeOverride?.name ?: "AUTO"), "dvConversionDebugMode" to (debugDvModeOverride?.name ?: "AUTO"),
"dvStrippedInitNals" to (dovi?.strippedInitNalCount ?: 0L),
"dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L),
"dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L), "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L),
"dvRpuConversionFailures" to (dovi?.rpuConversionFailureCount ?: 0L), "dvRpuConversionFailures" to (dovi?.rpuConversionFailureCount ?: 0L),