fix: isolate libdovi from JVM heap, reuse DV buffers

This commit is contained in:
edde746
2026-03-28 17:38:11 +01:00
parent 059509354d
commit 3306a28892
2 changed files with 240 additions and 144 deletions
+30 -9
View File
@@ -1,6 +1,7 @@
#include <jni.h>
#include <android/log.h>
#include <cstring>
#include <new>
#define TAG "DoviBridge"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
@@ -23,15 +24,29 @@ Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
jsize len = env->GetArrayLength(payload);
if (len <= 0) return nullptr;
jbyte *buf = env->GetByteArrayElements(payload, nullptr);
// Valid RPU NALs are typically <2 KiB; reject unreasonable sizes
if (len > 8192) {
LOGW("RPU payload too large (%d bytes), skipping", len);
return nullptr;
}
// Copy to native heap so libdovi never touches JVM heap memory.
// GetByteArrayElements on ART may return a direct heap pointer; any
// out-of-bounds access by libdovi would corrupt adjacent JVM objects.
auto *buf = new (std::nothrow) uint8_t[static_cast<size_t>(len)];
if (buf == nullptr) return nullptr;
env->GetByteArrayRegion(payload, 0, len, reinterpret_cast<jbyte *>(buf));
if (env->ExceptionCheck()) {
delete[] buf;
return nullptr;
}
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(buf, static_cast<size_t>(len));
if (rpu == nullptr) {
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
delete[] buf;
return nullptr;
}
@@ -39,22 +54,21 @@ Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
if (err != nullptr) {
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
dovi_rpu_free(rpu);
rpu = dovi_parse_rpu(
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
rpu = dovi_parse_rpu(buf, static_cast<size_t>(len));
if (rpu == nullptr) {
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
delete[] buf;
return nullptr;
}
err = dovi_rpu_get_error(rpu);
if (err != nullptr) {
LOGW("RPU parse failed: %s", err);
dovi_rpu_free(rpu);
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
delete[] buf;
return nullptr;
}
}
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
delete[] buf;
// Convert to target profile (mode 2 = P8.1 with no-op curves)
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
@@ -75,6 +89,13 @@ Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
return nullptr;
}
if (out->len > 16384) {
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
dovi_data_free(out);
dovi_rpu_free(rpu);
return nullptr;
}
jbyteArray result = env->NewByteArray(static_cast<jsize>(out->len));
if (result != nullptr) {
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len),
@@ -6,7 +6,6 @@ import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
/**
* TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode:
@@ -24,6 +23,8 @@ import java.io.ByteArrayOutputStream
* - Type 63 (UNSPEC63): DV Enhancement Layer → strip
* - nuh_layer_id > 0: Enhancement layer NAL → strip
* - All retained NALs: normalize nuh_layer_id to 0
*
* All buffers are reused across samples to minimize GC pressure on the hot path.
*/
class DoviConvertingTrackOutput(
private val delegate: TrackOutput,
@@ -35,6 +36,8 @@ class DoviConvertingTrackOutput(
private const val NAL_TYPE_UNSPEC62 = 62
private const val NAL_TYPE_UNSPEC63 = 63
private const val LIBDOVI_MODE_TO_81 = 2
private const val INITIAL_BUFFER_SIZE = 256 * 1024
private const val READ_CHUNK = 64 * 1024
private val ANNEX_B_START_CODE = byteArrayOf(0, 0, 0, 1)
}
@@ -45,10 +48,18 @@ class DoviConvertingTrackOutput(
var convertedRpuCount = 0L
private set
// Sample buffering between sampleData() and sampleMetadata()
private val sampleBuffer = ByteArrayOutputStream(256 * 1024)
// Reusable buffers — grown as needed, never shrunk
private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var sampleLen = 0
private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE)
private var outputLen = 0
private var readBuf = ByteArray(READ_CHUNK)
private val outputParsable = ParsableByteArray()
private var buffering = false
// Sample counter for periodic logging
private var sampleCount = 0L
override fun format(format: Format) {
if (!conversionActive) {
val codecs = format.codecs
@@ -110,12 +121,13 @@ class DoviConvertingTrackOutput(
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
}
// Buffer sample data for processing at sampleMetadata() time
buffering = true
val buf = ByteArray(length)
val bytesRead = input.read(buf, 0, length)
if (readBuf.size < length) readBuf = ByteArray(length)
val bytesRead = input.read(readBuf, 0, length)
if (bytesRead > 0) {
sampleBuffer.write(buf, 0, bytesRead)
ensureSampleCapacity(sampleLen + bytesRead)
System.arraycopy(readBuf, 0, sampleBuf, sampleLen, bytesRead)
sampleLen += bytesRead
}
return bytesRead
}
@@ -126,11 +138,10 @@ class DoviConvertingTrackOutput(
return
}
// Buffer sample data for processing at sampleMetadata() time
buffering = true
val bytes = ByteArray(length)
data.readBytes(bytes, 0, length)
sampleBuffer.write(bytes, 0, length)
ensureSampleCapacity(sampleLen + length)
data.readBytes(sampleBuf, sampleLen, length)
sampleLen += length
}
override fun sampleMetadata(
@@ -142,172 +153,212 @@ class DoviConvertingTrackOutput(
}
buffering = false
val rawSample = sampleBuffer.toByteArray()
sampleBuffer.reset()
val srcLen = sampleLen
sampleLen = 0
val processed = try {
processNalUnits(rawSample)
val outLen: Int
val outBuf: ByteArray
val success = try {
processNalUnits(srcLen)
true
} catch (e: Exception) {
Log.e(TAG, "NAL processing failed, passing raw sample", e)
rawSample
false
}
if (success) {
outLen = outputLen
outBuf = outputBuf
} else {
outLen = srcLen
outBuf = sampleBuf
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
if (processed.isEmpty()) return
if (outLen == 0) return
// Write processed data to delegate. Offset must be 0 since we write exactly
// the processed amount (no trailing data from next sample in the buffer).
val parsable = ParsableByteArray(processed, processed.size)
delegate.sampleData(parsable, processed.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, processed.size, 0, cryptoData)
}
// Sample counter for periodic logging
private var sampleCount = 0L
/**
* Process a single NAL: convert RPU (DV81), strip DV layers, or keep.
* Returns processed NAL data to write, or null if stripped.
*/
private fun processNal(nalData: ByteArray): ByteArray? {
if (nalData.size < 2) return nalData
val nalType = (nalData[0].toInt() ushr 1) and 0x3F
val nuhLayerId = ((nalData[0].toInt() and 1) shl 5) or
((nalData[1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> {
val converted = DoviBridge.convertRpuNalu(nalData, LIBDOVI_MODE_TO_81)
if (converted != null) {
normalizeLayerId(converted)
convertedRpuCount++
converted
} else {
strippedNalCount++
null
}
}
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> {
strippedNalCount++
null
}
else -> {
normalizeLayerId(nalData)
nalData
}
}
outputParsable.reset(outBuf, outLen)
delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData)
}
/**
* Process NAL units in the sample data. Auto-detects format:
* Process NAL units in sampleBuf[0..dataLen). Auto-detects format:
* - Annex B (00 00 00 01 / 00 00 01 start codes) — used by MatroskaExtractor
* - Length-prefixed (4-byte big-endian length) — used by Mp4Extractor
*
* Result is written to outputBuf[0..outputLen).
*/
private fun processNalUnits(data: ByteArray): ByteArray {
if (data.size < 4) return data
private fun processNalUnits(dataLen: Int) {
outputLen = 0
if (dataLen < 4) {
ensureOutputCapacity(dataLen)
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
outputLen = dataLen
return
}
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
val isAnnexB = (data.size >= 4 && data[0] == 0.toByte() && data[1] == 0.toByte() &&
data[2] == 0.toByte() && data[3] == 1.toByte()) ||
(data.size >= 3 && data[0] == 0.toByte() && data[1] == 0.toByte() &&
data[2] == 1.toByte())
val isAnnexB = (dataLen >= 4 && sampleBuf[0] == 0.toByte() && sampleBuf[1] == 0.toByte() &&
sampleBuf[2] == 0.toByte() && sampleBuf[3] == 1.toByte()) ||
(dataLen >= 3 && sampleBuf[0] == 0.toByte() && sampleBuf[1] == 0.toByte() &&
sampleBuf[2] == 1.toByte())
if (sampleCount == 0L) {
Log.d(TAG, "NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
"first bytes: ${data.take(8).joinToString(" ") { "%02X".format(it) }}")
"first bytes: ${sampleBuf.take(8).joinToString(" ") { "%02X".format(it) }}")
}
return if (isAnnexB) processAnnexBNals(data) else processLengthPrefixedNals(data)
if (isAnnexB) processAnnexBNals(dataLen) else processLengthPrefixedNals(dataLen)
}
/**
* Find all Annex B start code positions (00 00 01 or 00 00 00 01) in the data.
* Returns list of pairs: (startCodeEnd, startCodeLen) where startCodeEnd is the
* byte index right after the start code, and startCodeLen is 3 or 4.
*/
private fun findAnnexBStartCodes(data: ByteArray): List<Pair<Int, Int>> {
val positions = mutableListOf<Pair<Int, Int>>()
/** Process Annex B formatted NAL units (MKV path). Scans inline, no list allocation. */
private fun processAnnexBNals(dataLen: Int) {
ensureOutputCapacity(dataLen)
var kept = 0
var stripped = 0
// Find first start code
var scEnd = -1
var i = 0
while (i < data.size - 2) {
if (data[i] == 0.toByte() && data[i + 1] == 0.toByte()) {
if (i + 3 < data.size && data[i + 2] == 0.toByte() && data[i + 3] == 1.toByte()) {
positions.add(Pair(i + 4, 4))
i += 4
continue
} else if (data[i + 2] == 1.toByte()) {
positions.add(Pair(i + 3, 3))
i += 3
continue
while (i < dataLen - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
scEnd = i + 4
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
scEnd = i + 3
break
}
}
i++
}
return positions
}
/** Process Annex B formatted NAL units (MKV path). */
private fun processAnnexBNals(data: ByteArray): ByteArray {
val output = ByteArrayOutputStream(data.size)
var kept = 0
var stripped = 0
val startCodes = findAnnexBStartCodes(data)
if (startCodes.isEmpty()) {
if (scEnd < 0) {
// No start codes found — pass through
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
outputLen = dataLen
sampleCount++
return data
return
}
for (idx in startCodes.indices) {
val nalStart = startCodes[idx].first
val nalEnd = if (idx + 1 < startCodes.size) {
startCodes[idx + 1].first - startCodes[idx + 1].second
} else {
data.size
}
if (nalEnd <= nalStart) continue
var nalStart = scEnd
val result = processNal(data.copyOfRange(nalStart, nalEnd))
if (result != null) {
output.write(ANNEX_B_START_CODE)
output.write(result)
kept++
while (nalStart < dataLen) {
// Find next start code to determine end of current NAL
var nalEnd = dataLen
i = nalStart
while (i < dataLen - 2) {
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
if (i + 3 < dataLen && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
nalEnd = i
break
} else if (sampleBuf[i + 2] == 1.toByte()) {
nalEnd = i
break
}
}
i++
}
val nalLen = nalEnd - nalStart
if (nalLen > 0) {
val action = processNalInline(nalStart, nalLen)
if (action == NalAction.KEEP) {
ensureOutputCapacity(outputLen + 4 + nalLen)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
val converted = DoviBridge.convertRpuNalu(
sampleBuf.copyOfRange(nalStart, nalStart + nalLen), LIBDOVI_MODE_TO_81
)
if (converted != null) {
normalizeLayerId(converted, 0)
ensureOutputCapacity(outputLen + 4 + converted.size)
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
outputLen += 4
System.arraycopy(converted, 0, outputBuf, outputLen, converted.size)
outputLen += converted.size
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
} else {
strippedNalCount++
stripped++
}
}
// Advance past the next start code
if (nalEnd >= dataLen) break
nalStart = if (nalEnd + 3 < dataLen && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) {
nalEnd + 4
} else {
stripped++
nalEnd + 3
}
}
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (AnnexB): ${data.size}B -> ${output.size()}B, " +
Log.d(TAG, "Sample #$sampleCount (AnnexB): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
}
/** Process length-prefixed NAL units (MP4 path). */
private fun processLengthPrefixedNals(data: ByteArray): ByteArray {
val output = ByteArrayOutputStream(data.size)
private fun processLengthPrefixedNals(dataLen: Int) {
ensureOutputCapacity(dataLen)
var pos = 0
var kept = 0
var stripped = 0
while (pos + 4 <= data.size) {
val nalLen = ((data[pos].toInt() and 0xFF) shl 24) or
((data[pos + 1].toInt() and 0xFF) shl 16) or
((data[pos + 2].toInt() and 0xFF) shl 8) or
(data[pos + 3].toInt() and 0xFF)
while (pos + 4 <= dataLen) {
val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or
((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or
((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or
(sampleBuf[pos + 3].toInt() and 0xFF)
if (nalLen <= 0 || pos + 4 + nalLen > data.size) {
if (nalLen <= 0 || pos + 4 + nalLen > dataLen) {
if (sampleCount < 5) {
Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=${data.size})")
Log.w(TAG, "Bad NAL length $nalLen at pos $pos (data.size=$dataLen)")
}
break
}
val result = processNal(data.copyOfRange(pos + 4, pos + 4 + nalLen))
if (result != null) {
writeLengthPrefixedNal(output, result)
val nalStart = pos + 4
val action = processNalInline(nalStart, nalLen)
if (action == NalAction.KEEP) {
ensureOutputCapacity(outputLen + 4 + nalLen)
writeInt32BE(outputBuf, outputLen, nalLen)
outputLen += 4
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
normalizeLayerId(outputBuf, outputLen)
outputLen += nalLen
kept++
} else if (action == NalAction.CONVERT) {
val converted = DoviBridge.convertRpuNalu(
sampleBuf.copyOfRange(nalStart, nalStart + nalLen), LIBDOVI_MODE_TO_81
)
if (converted != null) {
normalizeLayerId(converted, 0)
ensureOutputCapacity(outputLen + 4 + converted.size)
writeInt32BE(outputBuf, outputLen, converted.size)
outputLen += 4
System.arraycopy(converted, 0, outputBuf, outputLen, converted.size)
outputLen += converted.size
convertedRpuCount++
kept++
} else {
strippedNalCount++
stripped++
}
} else {
strippedNalCount++
stripped++
}
@@ -316,26 +367,50 @@ class DoviConvertingTrackOutput(
sampleCount++
if (sampleCount <= 3 || (sampleCount % 500 == 0L)) {
Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${data.size}B -> ${output.size()}B, " +
Log.d(TAG, "Sample #$sampleCount (LenPrefix): ${dataLen}B -> ${outputLen}B, " +
"kept=$kept stripped=$stripped NALs")
}
return output.toByteArray()
}
private fun normalizeLayerId(nalData: ByteArray) {
if (nalData.size >= 2) {
nalData[0] = (nalData[0].toInt() and 0xFE).toByte() // Clear bit 0 of byte 0
nalData[1] = (nalData[1].toInt() and 0x07).toByte() // Clear bits 7-3 of byte 1
private enum class NalAction { KEEP, STRIP, CONVERT }
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
private fun processNalInline(offset: Int, len: Int): NalAction {
if (len < 2) return NalAction.KEEP
val nalType = (sampleBuf[offset].toInt() ushr 1) and 0x3F
val nuhLayerId = ((sampleBuf[offset].toInt() and 1) shl 5) or
((sampleBuf[offset + 1].toInt() ushr 3) and 0x1F)
return when {
nalType == NAL_TYPE_UNSPEC62 && dvMode == DvConversionMode.DV81 -> NalAction.CONVERT
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP
else -> NalAction.KEEP
}
}
private fun writeLengthPrefixedNal(output: ByteArrayOutputStream, nalData: ByteArray) {
val len = nalData.size
output.write((len ushr 24) and 0xFF)
output.write((len ushr 16) and 0xFF)
output.write((len ushr 8) and 0xFF)
output.write(len and 0xFF)
output.write(nalData)
private fun normalizeLayerId(data: ByteArray, offset: Int) {
if (data.size - offset >= 2) {
data[offset] = (data[offset].toInt() and 0xFE).toByte()
data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte()
}
}
private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) {
buf[offset] = ((value ushr 24) and 0xFF).toByte()
buf[offset + 1] = ((value ushr 16) and 0xFF).toByte()
buf[offset + 2] = ((value ushr 8) and 0xFF).toByte()
buf[offset + 3] = (value and 0xFF).toByte()
}
private fun ensureSampleCapacity(needed: Int) {
if (sampleBuf.size < needed) {
sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2))
}
}
private fun ensureOutputCapacity(needed: Int) {
if (outputBuf.size < needed) {
outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2))
}
}
/**