Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
* Fix reporting of late video frames with identical release timestamps so
that they are reported as dropped instead of skipped.
* Text:
* SubRip: Add support for injecting a `CharsetDetector` into
`DefaultSubtitleParserFactory` to detect the character encoding of
standalone SubRip subtitles without a byte order mark
([#2247](https://github.com/androidx/media/issues/2247)).
* Metadata:
* Image:
* DataSource:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.extractor.text;

import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;
import java.nio.charset.Charset;

/** Detects the character encoding of subtitle byte data. */
@UnstableApi
public interface CharsetDetector {

/**
* Detects the character encoding of the requested range of {@code data}.
*
* <p>The requested range is not guaranteed to contain a complete subtitle file.
*
* @param data The subtitle byte data.
* @param offset The start offset in {@code data}.
* @param length The number of bytes to inspect.
* @return The detected character encoding, or {@code null} if it could not be determined.
*/
@Nullable
Charset detect(byte[] data, int offset, int length);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,33 @@
* <li>DVB ({@link DvbParser})
* <li>TTML ({@link TtmlParser})
* </ul>
*
* <p>A {@link CharsetDetector} can be provided to detect the character encoding of standalone
* SubRip subtitles without a byte order mark.
*/
@UnstableApi
public final class DefaultSubtitleParserFactory implements SubtitleParser.Factory {

@Nullable private final CharsetDetector charsetDetector;

/** Creates an instance that defaults to UTF-8 for SubRip subtitles without a byte order mark. */
public DefaultSubtitleParserFactory() {
this(/* charsetDetector= */ null);
}

/**
* Creates an instance that uses {@code charsetDetector} for standalone SubRip subtitles without a
* byte order mark.
*
* <p>The detector is not used for SubRip subtitles embedded in a media container because these
* samples may contain only a small part of the subtitle file.
*
* @param charsetDetector The detector to use, or {@code null} to default to UTF-8.
*/
public DefaultSubtitleParserFactory(@Nullable CharsetDetector charsetDetector) {
this.charsetDetector = charsetDetector;
}

@Override
public boolean supportsFormat(Format format) {
@Nullable String mimeType = format.sampleMimeType;
Expand Down Expand Up @@ -106,7 +129,7 @@ public SubtitleParser create(Format format) {
case MimeTypes.APPLICATION_MP4VTT:
return new Mp4WebvttParser();
case MimeTypes.APPLICATION_SUBRIP:
return new SubripParser();
return new SubripParser(isStandaloneSubrip(format) ? charsetDetector : null);
case MimeTypes.APPLICATION_TX3G:
return new Tx3gParser(format.initializationData);
case MimeTypes.APPLICATION_PGS:
Expand All @@ -123,4 +146,8 @@ public SubtitleParser create(Format format) {
}
throw new IllegalArgumentException("Unsupported MIME type: " + mimeType);
}

private static boolean isStandaloneSubrip(Format format) {
return format.containerMimeType == null || MimeTypes.isText(format.containerMimeType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import androidx.media3.common.util.Log;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.text.CharsetDetector;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.common.collect.ImmutableList;
Expand Down Expand Up @@ -82,11 +83,24 @@ public final class SubripParser implements SubtitleParser {
private final StringBuilder textBuilder;
private final ArrayList<String> tags;
private final ParsableByteArray parsableByteArray;
@Nullable private final CharsetDetector charsetDetector;

public SubripParser() {
this(/* charsetDetector= */ null);
}

/**
* Creates an instance that uses {@code charsetDetector} when the input doesn't contain a byte
* order mark.
*
* @param charsetDetector The detector to use, or {@code null} to default to UTF-8 when the input
* doesn't contain a byte order mark.
*/
public SubripParser(@Nullable CharsetDetector charsetDetector) {
textBuilder = new StringBuilder();
tags = new ArrayList<>();
parsableByteArray = new ParsableByteArray();
this.charsetDetector = charsetDetector;
}

@Override
Expand All @@ -103,7 +117,7 @@ public void parse(
Consumer<CuesWithTiming> output) {
parsableByteArray.reset(data, /* limit= */ offset + length);
parsableByteArray.setPosition(offset);
Charset charset = detectUtfCharset(parsableByteArray);
Charset charset = detectCharset(data, offset, length);

@Nullable
List<CuesWithTiming> cuesWithTimingBeforeRequestedStartTimeUs =
Expand Down Expand Up @@ -188,12 +202,27 @@ public void parse(
}

/**
* Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if
* no BOM is found.
* Returns the charset to use for line parsing.
*
* <p>A byte order mark takes precedence. Otherwise, input detected as anything other than UTF-8
* or US-ASCII is transcoded to UTF-8 in {@link #parsableByteArray} first.
*/
private Charset detectUtfCharset(ParsableByteArray data) {
@Nullable Charset charset = data.readUtfCharsetFromBom();
return charset != null ? charset : StandardCharsets.UTF_8;
private Charset detectCharset(byte[] data, int offset, int length) {
@Nullable Charset utfCharset = parsableByteArray.readUtfCharsetFromBom();
if (utfCharset != null) {
return utfCharset;
}
@Nullable
Charset detectedCharset =
charsetDetector != null ? charsetDetector.detect(data, offset, length) : null;
Charset charset = detectedCharset != null ? detectedCharset : StandardCharsets.UTF_8;
if (charset.equals(StandardCharsets.UTF_8) || charset.equals(StandardCharsets.US_ASCII)) {
return charset;
}
// Normalize detected input to UTF-8 so line parsing uses a single code path.
parsableByteArray.reset(
new String(data, offset, length, charset).getBytes(StandardCharsets.UTF_8));
return StandardCharsets.UTF_8;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,81 @@

import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.extractor.text.SubtitleParser.OutputOptions;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableList;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;

/** Tests for {@link DefaultSubtitleParserFactory}. */
@RunWith(AndroidJUnit4.class)
public class DefaultSubtitleParserFactoryTest {

@Test
public void createStandaloneSubripParser_usesCharsetDetector() {
Charset charset = Charset.forName("GB18030");
DefaultSubtitleParserFactory factory =
new DefaultSubtitleParserFactory((data, offset, length) -> charset);
Format format = new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_SUBRIP).build();
String expectedText = "起来 快起来";
byte[] bytes = createSubripBytes(expectedText, charset);

List<CuesWithTiming> cues = new ArrayList<>();
factory.create(format).parse(bytes, OutputOptions.allCues(), cues::add);

assertThat(cues).hasSize(1);
assertThat(cues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText);
}

@Test
public void createStandaloneSubripParserWithTextContainerMimeType_usesCharsetDetector() {
Charset charset = Charset.forName("GB18030");
DefaultSubtitleParserFactory factory =
new DefaultSubtitleParserFactory((data, offset, length) -> charset);
Format format =
new Format.Builder()
.setSampleMimeType(MimeTypes.APPLICATION_SUBRIP)
.setContainerMimeType(MimeTypes.APPLICATION_SUBRIP)
.build();
String expectedText = "起来 快起来";
byte[] bytes = createSubripBytes(expectedText, charset);

List<CuesWithTiming> cues = new ArrayList<>();
factory.create(format).parse(bytes, OutputOptions.allCues(), cues::add);

assertThat(cues).hasSize(1);
assertThat(cues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText);
}

@Test
public void createEmbeddedSubripParser_doesNotUseCharsetDetector() {
DefaultSubtitleParserFactory factory =
new DefaultSubtitleParserFactory(
(data, offset, length) -> {
throw new AssertionError("Charset detector should not be called");
});
Format format =
new Format.Builder()
.setSampleMimeType(MimeTypes.APPLICATION_SUBRIP)
.setContainerMimeType(MimeTypes.VIDEO_MATROSKA)
.build();
String expectedText = "This is an embedded subtitle.";
byte[] bytes = createSubripBytes(expectedText, StandardCharsets.UTF_8);

List<CuesWithTiming> cues = new ArrayList<>();
factory.create(format).parse(bytes, OutputOptions.allCues(), cues::add);

assertThat(cues).hasSize(1);
assertThat(cues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText);
}

/**
* This test loops through all the public fields of {@link MimeTypes} and assumes all the static,
* string fields with a single "/" in them are MIME types - then it uses these to 'fuzz' the
Expand Down Expand Up @@ -74,4 +137,8 @@ public void formatSupportIsConsistent() throws Exception {
}
}
}

private static byte[] createSubripBytes(String text, Charset charset) {
return ("1\r\n" + "00:00:00,000 --> 00:00:05,000\r\n" + text + "\r\n").getBytes(charset);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
Expand Down Expand Up @@ -83,6 +85,89 @@ public void parseTypical() throws IOException {
assertTypicalCue3(allCues.get(2));
}

@Test
public void parseGb18030WithCharsetDetector_outputsDecodedText() {
assertTextParsedWithCharsetDetector("起来 快起来", Charset.forName("GB18030"));
}

@Test
public void parseEucKrWithCharsetDetector_outputsDecodedText() {
assertTextParsedWithCharsetDetector("이것은 한국어 자막 테스트입니다.", Charset.forName("EUC-KR"));
}

@Test
public void parseShiftJisWithCharsetDetector_outputsDecodedText() {
assertTextParsedWithCharsetDetector("これは日本語の字幕テストです。", Charset.forName("Shift_JIS"));
}

@Test
public void parseUtf8WithCharsetDetector_outputsDecodedText() {
assertTextParsedWithCharsetDetector("This is a UTF-8 subtitle.", StandardCharsets.UTF_8);
}

@Test
public void parseUsAsciiWithCharsetDetector_outputsDecodedText() {
assertTextParsedWithCharsetDetector("This is an ASCII subtitle.", StandardCharsets.US_ASCII);
}

@Test
public void parseWithByteOrderMark_doesNotCallCharsetDetector() throws IOException {
SubripParser parser =
new SubripParser(
(data, offset, length) -> {
throw new AssertionError("Charset detector should not be called");
});
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK);

ImmutableList<CuesWithTiming> allCues = parseAllCues(parser, bytes);

assertThat(allCues).hasSize(3);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(1));
assertTypicalCue3(allCues.get(2));
}

@Test
public void parseWithCharsetDetectorReturningNull_defaultsToUtf8() throws IOException {
SubripParser parser = new SubripParser((data, offset, length) -> null);
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE);

ImmutableList<CuesWithTiming> allCues = parseAllCues(parser, bytes);

assertThat(allCues).hasSize(3);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(1));
assertTypicalCue3(allCues.get(2));
}

@Test
public void parseAtOffsetWithCharsetDetector_passesRequestedRangeToDetector() {
Charset charset = Charset.forName("GB18030");
String expectedText = "这是一个字幕测试。";
byte[] subtitleBytes =
("1\r\n" + "00:00:00,000 --> 00:00:05,000\r\n" + expectedText + "\r\n").getBytes(charset);
int offset = 5;
byte[] bytes = new byte[offset + subtitleBytes.length + 7];
System.arraycopy(subtitleBytes, 0, bytes, offset, subtitleBytes.length);
SubripParser parser =
new SubripParser(
(data, detectorOffset, detectorLength) -> {
assertThat(data).isSameInstanceAs(bytes);
assertThat(detectorOffset).isEqualTo(offset);
assertThat(detectorLength).isEqualTo(subtitleBytes.length);
return charset;
});
ImmutableList.Builder<CuesWithTiming> cues = ImmutableList.builder();

parser.parse(bytes, offset, subtitleBytes.length, OutputOptions.allCues(), cues::add);

ImmutableList<CuesWithTiming> allCues = cues.build();
assertThat(allCues).hasSize(1);
assertThat(allCues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText);
}

@Test
public void parseTypicalAtOffsetAndRestrictedLength() throws IOException {
SubripParser parser = new SubripParser();
Expand Down Expand Up @@ -309,6 +394,17 @@ private static ImmutableList<CuesWithTiming> parseAllCues(SubtitleParser parser,
return cues.build();
}

private static void assertTextParsedWithCharsetDetector(String expectedText, Charset charset) {
byte[] bytes =
("1\r\n" + "00:00:00,000 --> 00:00:05,000\r\n" + expectedText + "\r\n").getBytes(charset);
SubripParser parser = new SubripParser((data, offset, length) -> charset);

ImmutableList<CuesWithTiming> allCues = parseAllCues(parser, bytes);

assertThat(allCues).hasSize(1);
assertThat(allCues.get(0).cues.get(0).text.toString()).isEqualTo(expectedText);
}

private static void assertTypicalCue1(CuesWithTiming cuesWithTiming) {
assertThat(cuesWithTiming.startTimeUs).isEqualTo(0);
assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the first subtitle.");
Expand Down