diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b752d62b..787a5f85 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -188,4 +188,6 @@ dependencies { // libass-android for ASS/SSA subtitle rendering assAars.forEach { implementation(files(File(assDir, it))) } + + testImplementation("junit:junit:4.13.2") } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DvBitstreamSanitizer.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DvBitstreamSanitizer.kt new file mode 100644 index 00000000..12a60a12 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/DvBitstreamSanitizer.kt @@ -0,0 +1,140 @@ +package com.edde746.plezy.exoplayer + +import java.nio.ByteBuffer + +/** + * In-place sanitizer for HEVC Annex B buffers carrying both Dolby Vision and HDR10+ + * dynamic metadata. Buggy chipsets (Fire TV 4K Max, MediaTek-based Google TV, ...) + * crash or black-screen when a native DV codec also receives in-band HDR10+ SEI, so + * only the metadata the active decode path consumes may be kept: + * + * - Native DV codec: strip HDR10+ SEI NALs (types 39/40 with ST 2094-40 payload), + * the decoder follows the DV RPU. Port of androidx/media#3085 / Kodi xbmc#24584. + * - HEVC fallback for a DV format: strip DV RPU/EL NALs (types 62/63) instead, + * leaving HDR10+ for the display. + * + * Pure JVM (no android/media3 imports) so it stays unit-testable on the host. + */ +object DvBitstreamSanitizer { + + private const val NAL_TYPE_PREFIX_SEI = 39 + private const val NAL_TYPE_SUFFIX_SEI = 40 + private const val NAL_TYPE_UNSPEC62 = 62 // DV RPU + private const val NAL_TYPE_UNSPEC63 = 63 // DV Enhancement Layer + + private const val SEI_PAYLOAD_TYPE_ITU_T_T35 = 4 + + /** + * Scans `[position, limit)` of [data] for Annex B NAL units and removes the selected + * metadata NALs by compacting the buffer in place and reducing its limit. The position + * is left unchanged. + */ + fun sanitize(data: ByteBuffer, stripHdr10PlusSei: Boolean, stripDvRpu: Boolean) { + val startPos = data.position() + val limit = data.limit() + var writePos = startPos + var nalStartIndex = -1 + var startCodeLen = 0 + + var i = startPos + while (i <= limit) { + // Find next start code or end of buffer. + val atEnd = i == limit + var foundStartCode = false + var nextStartCodeLen = 0 + if (!atEnd && i + 2 < limit && data.get(i).toInt() == 0 && data.get(i + 1).toInt() == 0) { + if (data.get(i + 2).toInt() == 1) { + foundStartCode = true + nextStartCodeLen = 3 + } else if (data.get(i + 2).toInt() == 0 && i + 3 < limit && data.get(i + 3).toInt() == 1) { + foundStartCode = true + nextStartCodeLen = 4 + } + } + + if (foundStartCode || atEnd) { + if (nalStartIndex >= 0) { + // Complete NAL unit (including its start code) from nalStartIndex to i. + val nalDataStart = nalStartIndex + startCodeLen + val nalEnd = i + var strip = false + + if (nalEnd - nalDataStart >= 2) { + // HEVC NAL header: forbidden_zero_bit(1) + nal_unit_type(6) + nuh_layer_id MSB(1). + val nalUnitType = (data.get(nalDataStart).toInt() and 0x7E) shr 1 + strip = when (nalUnitType) { + NAL_TYPE_UNSPEC62, NAL_TYPE_UNSPEC63 -> stripDvRpu + NAL_TYPE_PREFIX_SEI, NAL_TYPE_SUFFIX_SEI -> + stripHdr10PlusSei && isHdr10PlusSeiNalUnit(data, nalDataStart + 2, nalEnd) + else -> false + } + } + + if (!strip) { + if (writePos != nalStartIndex) { + for (j in nalStartIndex until nalEnd) { + data.put(writePos++, data.get(j)) + } + } else { + writePos = nalEnd + } + } + } + nalStartIndex = i + startCodeLen = nextStartCodeLen + i += if (nextStartCodeLen > 0) nextStartCodeLen else 1 + } else { + i++ + } + } + + data.limit(writePos) + data.position(startPos) + } + + /** + * Returns whether the SEI RBSP (starting after the 2-byte HEVC NAL header) begins with an + * HDR10+ message: user_data_registered_itu_t_t35 with country code 0xB5 (United States), + * provider code 0x003C (Samsung), provider oriented code 0x0001, application identifier 4 + * (ST 2094-40), application version 0 or 1. Malformed/truncated data returns false so the + * NAL is kept. + */ + private fun isHdr10PlusSeiNalUnit(data: ByteBuffer, rbspStart: Int, nalEnd: Int): Boolean { + var pos = rbspStart + if (pos >= nalEnd) return false + + // SEI payload type: accumulated 0xFF bytes plus the final byte. + var payloadType = 0 + while (pos < nalEnd) { + val b = data.get(pos++).toInt() and 0xFF + payloadType += b + if (b != 0xFF) break + } + + // SEI payload size, same encoding. + var payloadSize = 0 + while (pos < nalEnd) { + val b = data.get(pos++).toInt() and 0xFF + payloadSize += b + if (b != 0xFF) break + } + + if (payloadType != SEI_PAYLOAD_TYPE_ITU_T_T35 || payloadSize < 7 || pos + 7 > nalEnd) { + return false + } + + // The identifier bytes (B5 00 3C 00 01 04 00/01) cannot contain the 0x000003 emulation + // prevention pattern, so they can be read without RBSP unescaping. + val countryCode = data.get(pos).toInt() and 0xFF + val providerCode = ((data.get(pos + 1).toInt() and 0xFF) shl 8) or (data.get(pos + 2).toInt() and 0xFF) + val orientedCode = ((data.get(pos + 3).toInt() and 0xFF) shl 8) or (data.get(pos + 4).toInt() and 0xFF) + val appIdentifier = data.get(pos + 5).toInt() and 0xFF + val appVersion = data.get(pos + 6).toInt() and 0xFF + + return countryCode == 0xB5 && + providerCode == 0x003C && + orientedCode == 0x0001 && + appIdentifier == 4 && + (appVersion == 0 || appVersion == 1) + } +} 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 934ac1d9..8e5eb6f3 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 @@ -466,6 +466,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Use DefaultRenderersFactory with FFmpeg fallback for unsupported or blocked audio codecs. val renderersFactory = PlezyRenderersFactory(activity).apply { audioDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) } + videoDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) } shouldBlockDirectAudioOutput = { format -> this@ExoPlayerCore.shouldBlockDirectAudioOutput(format, "sink support") } onAudioCapabilitiesChanged = { updateAudioDecoderPolicy("audio capabilities changed") } setEnableDecoderFallback(true) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index 26d09f8a..651833ef 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -3,11 +3,14 @@ package com.edde746.plezy.exoplayer import android.content.Context import android.media.AudioDeviceInfo import android.os.Build +import android.os.Handler import androidx.annotation.OptIn import androidx.media3.common.Format +import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackParameters import androidx.media3.common.util.Clock import androidx.media3.common.util.UnstableApi +import androidx.media3.decoder.DecoderInputBuffer import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.analytics.PlayerId @@ -18,6 +21,10 @@ import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider import androidx.media3.exoplayer.audio.ForwardingAudioSink +import androidx.media3.exoplayer.mediacodec.MediaCodecAdapter +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer +import androidx.media3.exoplayer.video.VideoRendererEventListener import java.nio.ByteBuffer import java.util.concurrent.atomic.AtomicLong import kotlin.math.abs @@ -36,6 +43,46 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) var audioDiagnosticsLogger: ((String, String, String) -> Unit)? = null + var videoDiagnosticsLogger: ((String, String, String) -> Unit)? = null + + override fun buildVideoRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + eventHandler: Handler, + eventListener: VideoRendererEventListener, + allowedVideoJoiningTimeMs: Long, + out: ArrayList + ) { + // Let super build the full list (it also appends extension renderers reflectively, + // e.g. the jellyfin ffmpeg artifact's video renderer), then swap the stock + // MediaCodecVideoRenderer for the DV-sanitizing variant at the same index. + super.buildVideoRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + allowedVideoJoiningTimeMs, + out + ) + val index = out.indexOfFirst { it.javaClass == MediaCodecVideoRenderer::class.java } + if (index < 0) return + out[index] = DvSanitizingVideoRenderer( + MediaCodecVideoRenderer.Builder(context) + .setCodecAdapterFactory(codecAdapterFactory) + .setMediaCodecSelector(mediaCodecSelector) + .setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs) + .setEnableDecoderFallback(enableDecoderFallback) + .setEventHandler(eventHandler) + .setEventListener(eventListener) + .setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY), + videoDiagnosticsLogger + ) + } + override fun buildAudioSink( context: Context, enableFloatOutput: Boolean, @@ -280,6 +327,69 @@ internal class SubtitleDelayRenderer( } } +/** + * MediaCodecVideoRenderer that resolves the DV / HDR10+ dual-dynamic-metadata conflict + * per decode path (#1296, generalizes androidx/media#3085): + * - native DV codec selected (media3 only selects one when decoder AND display support DV): + * strip in-band HDR10+ SEI — conflicting dynamic metadata crashes Fire TV-class chipsets + * - HEVC fallback for an HEVC-based DV format: strip DV RPU/EL NALs (profiles 7/8, where the + * base layer remains valid HDR10/HLG), keeping HDR10+ for the display + * + * Flags are reassigned on every codec init: tunneling toggles and DV retries re-init the + * codec without recreating renderers, and decoder fallback can switch the codec MIME. + */ +@OptIn(UnstableApi::class) +internal class DvSanitizingVideoRenderer( + builder: Builder, + private val log: ((String, String, String) -> Unit)? +) : MediaCodecVideoRenderer(builder) { + + private var stripHdr10PlusSei = false + private var stripDvRpu = false + + override fun onCodecInitialized( + name: String, + configuration: MediaCodecAdapter.Configuration, + initializedTimestampMs: Long, + initializationDurationMs: Long + ) { + super.onCodecInitialized(name, configuration, initializedTimestampMs, initializationDurationMs) + val codecs = configuration.format.codecs?.lowercase() ?: "" + val dvHevcFormat = configuration.format.sampleMimeType == MimeTypes.VIDEO_DOLBY_VISION && + (codecs.startsWith("dvhe.") || codecs.startsWith("dvh1.")) + val codecMimeType = configuration.codecInfo.codecMimeType + val newStripHdr10PlusSei = dvHevcFormat && codecMimeType == MimeTypes.VIDEO_DOLBY_VISION + val newStripDvRpu = dvHevcFormat && + codecMimeType == MimeTypes.VIDEO_H265 && + isBlCompatibleDvProfile(codecs) + if (newStripHdr10PlusSei != stripHdr10PlusSei || newStripDvRpu != stripDvRpu) { + log?.invoke( + "info", + "video", + "DV bitstream sanitizing: stripHdr10PlusSei=$newStripHdr10PlusSei, " + + "stripDvRpu=$newStripDvRpu (codec=$name, codecs=${configuration.format.codecs})" + ) + } + stripHdr10PlusSei = newStripHdr10PlusSei + stripDvRpu = newStripDvRpu + } + + override fun onQueueInputBuffer(buffer: DecoderInputBuffer) { + if (stripHdr10PlusSei || stripDvRpu) { + val data = buffer.data + if (data != null && data.hasRemaining() && !buffer.isEncrypted) { + DvBitstreamSanitizer.sanitize(data, stripHdr10PlusSei, stripDvRpu) + } + } + super.onQueueInputBuffer(buffer) + } + + private fun isBlCompatibleDvProfile(codecs: String): Boolean = codecs.startsWith("dvhe.07") || + codecs.startsWith("dvh1.07") || + codecs.startsWith("dvhe.08") || + codecs.startsWith("dvh1.08") +} + // --- AudioOutput wrapping: shares raw position with PositionFixAudioSink --- // Also implements AudioTrack reuse across seeks to avoid expensive teardown/recreation. // DefaultAudioSink releases the AudioOutput on every flush (seek), which destroys the diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DvBitstreamSanitizerTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DvBitstreamSanitizerTest.kt new file mode 100644 index 00000000..f1c1868d --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/DvBitstreamSanitizerTest.kt @@ -0,0 +1,201 @@ +package com.edde746.plezy.exoplayer + +import java.nio.ByteBuffer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DvBitstreamSanitizerTest { + + // --- HDR10+ SEI stripping (native DV codec path) --- + + @Test + fun stripsHdr10PlusPrefixSeiBetweenVclNals() { + val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02)) + val vcl2 = annexBNal(1, byteArrayOf(0x03, 0x04)) + val buffer = bufferOf(vcl1, hdr10PlusSei(), vcl2) + val originalLimit = buffer.limit() + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) + assertTrue(buffer.limit() < originalLimit) + assertEquals(0, buffer.position()) + } + + @Test + fun stripsSuffixSei() { + val vcl = annexBNal(1, byteArrayOf(0x01)) + val suffixSei = annexBNal(40, hdr10PlusSeiPayload()) + val buffer = bufferOf(vcl, suffixSei) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(vcl, remainingBytes(buffer)) + } + + @Test + fun handles3ByteStartCodes() { + val vcl1 = annexBNal(1, byteArrayOf(0x01, 0x02), startCodeLen = 3) + val sei = annexBNal(39, hdr10PlusSeiPayload(), startCodeLen = 3) + val vcl2 = annexBNal(1, byteArrayOf(0x03), startCodeLen = 3) + val buffer = bufferOf(vcl1, sei, vcl2) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) + } + + @Test + fun preservesNonHdr10PlusT35Sei() { + // Same T.35 layout but wrong country code (0x00 instead of 0xB5). + val sei = annexBNal( + 39, + byteArrayOf(0x04, 0x07, 0x00, 0x00, 0x3C, 0x00, 0x01, 0x04, 0x00) + ) + val buffer = bufferOf(annexBNal(1, byteArrayOf(0x01)), sei, annexBNal(1, byteArrayOf(0x02))) + val original = remainingBytes(buffer) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(original, remainingBytes(buffer)) + } + + @Test + fun noOpWithoutSeiNals() { + val buffer = bufferOf(annexBNal(1, byteArrayOf(0x01, 0x02)), annexBNal(1, byteArrayOf(0x03))) + val original = remainingBytes(buffer) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = true) + + assertArrayEquals(original, remainingBytes(buffer)) + } + + @Test + fun keepsTruncatedSei() { + // Declares payload size 7 but the identifier bytes are cut short. + val truncated = annexBNal(39, byteArrayOf(0x04, 0x07, 0xB5.toByte(), 0x00, 0x3C)) + val buffer = bufferOf(annexBNal(1, byteArrayOf(0x01)), truncated) + val original = remainingBytes(buffer) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(original, remainingBytes(buffer)) + } + + @Test + fun keepsHdr10PlusSeiWhenFlagOff() { + val buffer = bufferOf(annexBNal(1, byteArrayOf(0x01)), hdr10PlusSei()) + val original = remainingBytes(buffer) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = false, stripDvRpu = false) + + assertArrayEquals(original, remainingBytes(buffer)) + } + + // --- DV RPU/EL stripping (HEVC fallback path) --- + + @Test + fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() { + val vcl = annexBNal(1, byteArrayOf(0x01)) + val rpu = annexBNal(62, byteArrayOf(0x19, 0x08)) + val el = annexBNal(63, byteArrayOf(0x42)) + val sei = hdr10PlusSei() + val buffer = bufferOf(vcl, rpu, sei, el) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = false, stripDvRpu = true) + + assertArrayEquals(concat(vcl, sei), remainingBytes(buffer)) + } + + @Test + fun bothFlagsStripBothMetadataKinds() { + val vcl1 = annexBNal(19, byteArrayOf(0x00)) // IDR_W_RADL + val vcl2 = annexBNal(1, byteArrayOf(0x05)) + val buffer = bufferOf(vcl1, annexBNal(62, byteArrayOf(0x19)), hdr10PlusSei(), vcl2) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = true) + + assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) + } + + // --- Buffer handling --- + + @Test + fun respectsPositionAndRestoresIt() { + val prefix = byteArrayOf(0xAA.toByte(), 0xBB.toByte()) + val vcl = annexBNal(1, byteArrayOf(0x01)) + val content = concat(prefix, vcl, hdr10PlusSei()) + val buffer = ByteBuffer.wrap(content.copyOf()) + buffer.position(prefix.size) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertEquals(prefix.size, buffer.position()) + assertArrayEquals(vcl, remainingBytes(buffer)) + // Bytes before the position are untouched. + assertEquals(0xAA.toByte(), buffer.get(0)) + assertEquals(0xBB.toByte(), buffer.get(1)) + } + + @Test + fun worksOnDirectBuffers() { + val vcl = annexBNal(1, byteArrayOf(0x01, 0x02)) + val content = concat(vcl, hdr10PlusSei()) + val buffer = ByteBuffer.allocateDirect(content.size) + buffer.put(content) + buffer.flip() + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = false) + + assertArrayEquals(vcl, remainingBytes(buffer)) + } + + @Test + fun emptyBufferIsNoOp() { + val buffer = ByteBuffer.allocate(0) + + DvBitstreamSanitizer.sanitize(buffer, stripHdr10PlusSei = true, stripDvRpu = true) + + assertEquals(0, buffer.position()) + assertEquals(0, buffer.limit()) + } + + // --- Helpers --- + + /** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */ + private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray { + val startCode = if (startCodeLen == 3) byteArrayOf(0, 0, 1) else byteArrayOf(0, 0, 0, 1) + val header = byteArrayOf(((nalUnitType shl 1) and 0x7E).toByte(), 0x01) + return concat(startCode, header, payload) + } + + /** + * SEI payload: type 4 (user_data_registered_itu_t_t35), size 7, then the HDR10+ + * identifiers — country 0xB5, provider 0x003C, oriented code 0x0001, app id 4, version 0 — + * closed by the rbsp_trailing_bits stop byte real SEI NALs always end with (a trailing 0x00 + * would otherwise be ambiguous against a following 3-byte start code). + */ + private fun hdr10PlusSeiPayload(): ByteArray = byteArrayOf(0x04, 0x07, 0xB5.toByte(), 0x00, 0x3C, 0x00, 0x01, 0x04, 0x00, 0x80.toByte()) + + private fun hdr10PlusSei(): ByteArray = annexBNal(39, hdr10PlusSeiPayload()) + + private fun concat(vararg parts: ByteArray): ByteArray { + val result = ByteArray(parts.sumOf { it.size }) + var offset = 0 + for (part in parts) { + part.copyInto(result, offset) + offset += part.size + } + return result + } + + private fun bufferOf(vararg parts: ByteArray): ByteBuffer = ByteBuffer.wrap(concat(*parts)) + + private fun remainingBytes(buffer: ByteBuffer): ByteArray { + val copy = ByteArray(buffer.remaining()) + buffer.duplicate().get(copy) + return copy + } +}