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" + private const val ASS_EVENTS = "[Events]\n" +
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text" "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 * https://github.com/jellyfin/jellyfin-ffmpeg/issues/506
*/ */
private fun fixAssHeaderIfNeed(buffer: ByteArray): ByteArray = if (buffer[buffer.size - 1] != 0.toByte()) { private fun normalizeHeader(buffer: ByteArray): ByteArray {
// validate ass header var contentLength = buffer.size
buffer while (contentLength > 0 && buffer[contentLength - 1] == 0.toByte()) {
} else { contentLength--
// remote the last null character and append the events tag }
(String(buffer, 0, buffer.size - 1) + "\n" + ASS_EVENTS).toByteArray()
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 * Parses and normalizes the ASS header from the initialization data of the given [format].
* headers are preserved (duplication checks are handled by libass).
*/ */
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"
}
}