fix(android): render double-NUL ASS subtitles

close #1681
This commit is contained in:
edde746
2026-07-26 04:24:56 +02:00
parent 3b1e71b3fa
commit 7c6eaac2d0
2 changed files with 84 additions and 10 deletions
@@ -9,22 +9,36 @@ object AssHeaderParser {
private const val ASS_EVENTS = "[Events]\n" +
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
private val assEventsSuffix = ("\n" + ASS_EVENTS).toByteArray(Charsets.UTF_8)
/**
* Fix some ass header with error end.
* Matroska CodecPrivate data may omit the events section and end with one or more NUL
* terminators. Remove every terminator before checking or appending the section so an
* embedded NUL cannot hide the event format from libass.
*
* https://github.com/jellyfin/jellyfin-ffmpeg/issues/506
*/
private fun fixAssHeaderIfNeed(buffer: ByteArray): ByteArray = if (buffer[buffer.size - 1] != 0.toByte()) {
// validate ass header
buffer
} else {
// remote the last null character and append the events tag
(String(buffer, 0, buffer.size - 1) + "\n" + ASS_EVENTS).toByteArray()
private fun normalizeHeader(buffer: ByteArray): ByteArray {
var contentLength = buffer.size
while (contentLength > 0 && buffer[contentLength - 1] == 0.toByte()) {
contentLength--
}
val header = String(buffer, 0, contentLength, Charsets.UTF_8)
val hasEventsSection = header.lineSequence().any {
it.trim().equals("[Events]", ignoreCase = true)
}
if (hasEventsSection) {
return if (contentLength == buffer.size) buffer else buffer.copyOf(contentLength)
}
return buffer.copyOf(contentLength + assEventsSuffix.size).also {
assEventsSuffix.copyInto(it, destinationOffset = contentLength)
}
}
/**
* Parses the headers from the initialization data of the given [format]. The original
* headers are preserved (duplication checks are handled by libass).
* Parses and normalizes the ASS header from the initialization data of the given [format].
*/
fun parse(format: Format): ByteArray = fixAssHeaderIfNeed(format.initializationData[1])
fun parse(format: Format): ByteArray = normalizeHeader(format.initializationData[1])
}
@@ -0,0 +1,60 @@
package com.edde746.plezy.libass.media.parser
import androidx.annotation.OptIn
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertArrayEquals
import org.junit.Test
@OptIn(UnstableApi::class)
class AssHeaderParserTest {
@Test
fun doubleNulTerminatedHeaderAppendsEventsAfterContent() {
val input = SCRIPT_HEADER.toByteArray() + byteArrayOf(0, 0)
assertArrayEquals(expectedHeader(), parse(input))
}
@Test
fun unterminatedHeaderWithoutEventsIsRepaired() {
assertArrayEquals(expectedHeader(), parse(SCRIPT_HEADER.toByteArray()))
}
@Test
fun repairPreservesNonUtf8HeaderBytes() {
val content = SCRIPT_HEADER.toByteArray() + byteArrayOf(0xE9.toByte())
val input = content + byteArrayOf(0)
val expected = content + ("\n" + EVENTS_SECTION).toByteArray()
assertArrayEquals(expected, parse(input))
}
@Test
fun existingEventsSectionIsPreserved() {
val input = expectedHeader()
assertArrayEquals(input, parse(input))
}
@Test
fun trailingNulsAreRemovedWithoutDuplicatingExistingEvents() {
val input = expectedHeader() + byteArrayOf(0, 0)
assertArrayEquals(expectedHeader(), parse(input))
}
private fun parse(header: ByteArray): ByteArray = AssHeaderParser.parse(
Format.Builder()
.setInitializationData(listOf(byteArrayOf(), header))
.build()
)
private fun expectedHeader(): ByteArray = "$SCRIPT_HEADER\n$EVENTS_SECTION".toByteArray()
private companion object {
const val SCRIPT_HEADER = "[Script Info]\r\nScriptType: v4.00+\r\n"
const val EVENTS_SECTION = "[Events]\n" +
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
}
}