fix(android): keep only the active dynamic HDR metadata in DV bitstreams
close #1296
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user