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 // libass-android for ASS/SSA subtitle rendering
assAars.forEach { implementation(files(File(assDir, it))) } 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. // Use DefaultRenderersFactory with FFmpeg fallback for unsupported or blocked audio codecs.
val renderersFactory = PlezyRenderersFactory(activity).apply { val renderersFactory = PlezyRenderersFactory(activity).apply {
audioDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) } audioDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) }
videoDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) }
shouldBlockDirectAudioOutput = { format -> this@ExoPlayerCore.shouldBlockDirectAudioOutput(format, "sink support") } shouldBlockDirectAudioOutput = { format -> this@ExoPlayerCore.shouldBlockDirectAudioOutput(format, "sink support") }
onAudioCapabilitiesChanged = { updateAudioDecoderPolicy("audio capabilities changed") } onAudioCapabilitiesChanged = { updateAudioDecoderPolicy("audio capabilities changed") }
setEnableDecoderFallback(true) setEnableDecoderFallback(true)
@@ -3,11 +3,14 @@ package com.edde746.plezy.exoplayer
import android.content.Context import android.content.Context
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.os.Build import android.os.Build
import android.os.Handler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.Format import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.Clock import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.decoder.DecoderInputBuffer
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.analytics.PlayerId 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.DefaultAudioSink
import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider
import androidx.media3.exoplayer.audio.ForwardingAudioSink 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.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.math.abs import kotlin.math.abs
@@ -36,6 +43,46 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
var audioDiagnosticsLogger: ((String, String, String) -> Unit)? = null 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( override fun buildAudioSink(
context: Context, context: Context,
enableFloatOutput: Boolean, 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 --- // --- AudioOutput wrapping: shares raw position with PositionFixAudioSink ---
// Also implements AudioTrack reuse across seeks to avoid expensive teardown/recreation. // Also implements AudioTrack reuse across seeks to avoid expensive teardown/recreation.
// DefaultAudioSink releases the AudioOutput on every flush (seek), which destroys the // 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
}
}