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
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user