fix(android): keep only the active dynamic HDR metadata in DV bitstreams

close #1296
This commit is contained in:
edde746
2026-06-10 15:39:51 +02:00
parent 0502dd7ba0
commit 804bea8e9a
5 changed files with 454 additions and 0 deletions
+2
View File
@@ -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")
}
@@ -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)
}
}
@@ -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)
@@ -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<Renderer>
) {
// 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
@@ -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
}
}