diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/parser/AssHeaderParser.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/parser/AssHeaderParser.kt index c60fb494..553de9f4 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/parser/AssHeaderParser.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/parser/AssHeaderParser.kt @@ -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]) } diff --git a/android/libass/src/test/java/com/edde746/plezy/libass/media/parser/AssHeaderParserTest.kt b/android/libass/src/test/java/com/edde746/plezy/libass/media/parser/AssHeaderParserTest.kt new file mode 100644 index 00000000..57c69859 --- /dev/null +++ b/android/libass/src/test/java/com/edde746/plezy/libass/media/parser/AssHeaderParserTest.kt @@ -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" + } +}