From 55ee2c270dce49946a73ff81016df58f07d1438e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 18 May 2026 22:06:39 +0200 Subject: [PATCH] fix(android): strip DV init data --- .../exoplayer/DoviConvertingTrackOutput.kt | 187 ++++++++++++++---- .../plezy/exoplayer/DoviExtractorWrapper.kt | 8 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 9 +- 3 files changed, 162 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt index f16229ad..765e4ff4 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviConvertingTrackOutput.kt @@ -28,7 +28,8 @@ import androidx.media3.extractor.TrackOutput */ class DoviConvertingTrackOutput( 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 { companion object { @@ -45,6 +46,8 @@ class DoviConvertingTrackOutput( private set var strippedNalCount = 0L private set + var strippedInitNalCount = 0L + private set var convertedRpuCount = 0L private set var rpuConversionFailureCount = 0L @@ -75,13 +78,13 @@ class DoviConvertingTrackOutput( val codecs = format.codecs if (codecs != null && codecs.startsWith("dvhe.07")) { conversionActive = true - Log.i(TAG, "DV Profile 7 detected ($codecs), mode=$dvMode") - Log.i( - TAG, + logInfo("DV Profile 7 detected ($codecs), mode=$dvMode") + logInfo( "Original format: mime=${format.sampleMimeType}, codecs=$codecs, " + "initData=${format.initializationData.size} entries " + "(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})" ) + val strippedInitializationData = stripInitializationData(format.initializationData) val newFormat = when (dvMode) { DvConversionMode.DV81 -> { @@ -89,39 +92,26 @@ class DoviConvertingTrackOutput( val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6 val newCodecs = "dvhe.08.%02d".format(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() .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) .setCodecs(newCodecs) - .setInitializationData( - if (format.initializationData.isNotEmpty()) { - listOf(format.initializationData[0], dvConfigRecord) - } else { - listOf(ByteArray(0), dvConfigRecord) - } - ) + .setInitializationData(strippedInitializationData + dvConfigRecord) .build() } else -> { // HEVC_STRIP: present as plain HEVC - Log.i(TAG, "HEVC_STRIP: rewriting to video/hevc") + logInfo("HEVC_STRIP: rewriting to video/hevc") format.buildUpon() .setSampleMimeType(MimeTypes.VIDEO_H265) .setCodecs(null) - .setInitializationData( - if (format.initializationData.isNotEmpty()) { - listOf(format.initializationData[0]) - } else { - emptyList() - } - ) + .setInitializationData(strippedInitializationData) .build() } } - Log.i( - TAG, + logInfo( "Rewritten format: mime=${newFormat.sampleMimeType}, " + "codecs=${newFormat.codecs}, initData=${newFormat.initializationData.size} entries" ) @@ -186,7 +176,7 @@ class DoviConvertingTrackOutput( processNalUnits(srcLen) true } catch (e: Exception) { - Log.e(TAG, "NAL processing failed, passing raw sample", e) + logError("NAL processing failed, passing raw sample", e) false } if (success) { @@ -240,8 +230,7 @@ class DoviConvertingTrackOutput( ) if (sampleCount == 0L) { - Log.d( - TAG, + logDebug( "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " + "first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}" ) @@ -340,8 +329,7 @@ class DoviConvertingTrackOutput( sampleCount++ if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { - Log.d( - TAG, + logDebug( "Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " + "kept=$kept stripped=$stripped NALs" ) @@ -363,7 +351,7 @@ class DoviConvertingTrackOutput( if (nalLen <= 0 || pos + 4 + nalLen > dataLen) { 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 } @@ -401,8 +389,7 @@ class DoviConvertingTrackOutput( sampleCount++ if (sampleCount <= 3 || (sampleCount % 500 == 0L)) { - Log.d( - TAG, + logDebug( "Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " + "kept=$kept stripped=$stripped NALs" ) @@ -449,8 +436,7 @@ class DoviConvertingTrackOutput( private fun recordSampleProcessing(elapsedUs: Long) { totalSampleProcessingTimeUs += elapsedUs if (sampleCount <= 3 || (sampleCount > 0 && sampleCount % 500 == 0L)) { - Log.d( - TAG, + logDebug( "Perf: avgSample=${averageSampleProcessingTimeUs}us, " + "avgRpu=${averageRpuConversionTimeUs}us, converted=$convertedRpuCount, " + "rpuFailures=$rpuConversionFailureCount, rpuTooSmall=$rpuOutputTooSmallCount" @@ -458,14 +444,121 @@ class DoviConvertingTrackOutput( } } + private fun stripInitializationData(initializationData: List): List { + if (initializationData.isEmpty()) return emptyList() + + val stripped = ArrayList(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. */ - 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 - val nalType = (sampleBuf[offset].toInt() ushr 1) and 0x3F - val nuhLayerId = ((sampleBuf[offset].toInt() and 1) shl 5) or - ((sampleBuf[offset + 1].toInt() ushr 3) and 0x1F) + val nalType = (data[offset].toInt() ushr 1) and 0x3F + val nuhLayerId = ((data[offset].toInt() and 1) shl 5) or + ((data[offset + 1].toInt() ushr 3) and 0x1F) 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 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. * diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt index 31bf9c68..cd8c9858 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DoviExtractorWrapper.kt @@ -16,12 +16,13 @@ import androidx.media3.extractor.TrackOutput class DoviExtractorOutputWrapper( private val delegate: ExtractorOutput, private val dvMode: DvConversionMode, + private val emitLog: ((String, String, String) -> Unit)?, 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) + val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog) onVideoTrackWrapped(wrapper) return wrapper } @@ -39,7 +40,8 @@ class DoviExtractorOutputWrapper( */ class DoviExtractorWrapper( 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 { @Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null @@ -48,7 +50,7 @@ class DoviExtractorWrapper( override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input) 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) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 5743e6ff..1a02eba4 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -438,7 +438,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { extractor is MatroskaExtractor -> { val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler) val inner = if (doviEnabled) { - DoviExtractorWrapper(assExtractor, currentDvMode).also { + DoviExtractorWrapper(assExtractor, currentDvMode) { level, prefix, message -> + emitLog(level, prefix, message) + }.also { activeDoviMkvWrapper = it } } else { @@ -448,7 +450,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { CuelessSeekExtractorWrapper(inner) } doviEnabled && (extractor is Mp4Extractor || extractor is FragmentedMp4Extractor) -> { - DoviExtractorWrapper(extractor, currentDvMode).also { + DoviExtractorWrapper(extractor, currentDvMode) { level, prefix, message -> + emitLog(level, prefix, message) + }.also { activeDoviMp4Wrapper = it } } @@ -1909,6 +1913,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "dvConversionActive" to (dovi?.conversionActive == true), "dvConversionMode" to dvMode.name, "dvConversionDebugMode" to (debugDvModeOverride?.name ?: "AUTO"), + "dvStrippedInitNals" to (dovi?.strippedInitNalCount ?: 0L), "dvStrippedNals" to (dovi?.strippedNalCount ?: 0L), "dvConvertedRpus" to (dovi?.convertedRpuCount ?: 0L), "dvRpuConversionFailures" to (dovi?.rpuConversionFailureCount ?: 0L),