SubripParser implementation - moved from SubripDecoder

`SubripDecoder` which used to be `SimpleSubtitleDecoder` will now be called `SubripParser` and implement `SubtitleParser` interface. For backwards compatibility, we will have the same functionality provided by `DelegatingSubtitleDecoder` backed-up by a new `SubripParser` instance.

PiperOrigin-RevId: 546538113
This commit is contained in:
jbibik 2023-07-08 16:26:33 +01:00 committed by Rohit Singh
parent 377d8edf9c
commit ca483a3c2c
5 changed files with 364 additions and 110 deletions

View File

@ -25,7 +25,7 @@ import androidx.media3.extractor.text.cea.Cea708Decoder;
import androidx.media3.extractor.text.dvb.DvbDecoder; import androidx.media3.extractor.text.dvb.DvbDecoder;
import androidx.media3.extractor.text.pgs.PgsDecoder; import androidx.media3.extractor.text.pgs.PgsDecoder;
import androidx.media3.extractor.text.ssa.SsaParser; import androidx.media3.extractor.text.ssa.SsaParser;
import androidx.media3.extractor.text.subrip.SubripDecoder; import androidx.media3.extractor.text.subrip.SubripParser;
import androidx.media3.extractor.text.ttml.TtmlDecoder; import androidx.media3.extractor.text.ttml.TtmlDecoder;
import androidx.media3.extractor.text.tx3g.Tx3gDecoder; import androidx.media3.extractor.text.tx3g.Tx3gDecoder;
import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder; import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder;
@ -62,7 +62,7 @@ public interface SubtitleDecoderFactory {
* <li>WebVTT ({@link WebvttDecoder}) * <li>WebVTT ({@link WebvttDecoder})
* <li>WebVTT (MP4) ({@link Mp4WebvttDecoder}) * <li>WebVTT (MP4) ({@link Mp4WebvttDecoder})
* <li>TTML ({@link TtmlDecoder}) * <li>TTML ({@link TtmlDecoder})
* <li>SubRip ({@link SubripDecoder}) * <li>SubRip ({@link SubripParser})
* <li>SSA/ASS ({@link SsaParser}) * <li>SSA/ASS ({@link SsaParser})
* <li>TX3G ({@link Tx3gDecoder}) * <li>TX3G ({@link Tx3gDecoder})
* <li>Cea608 ({@link Cea608Decoder}) * <li>Cea608 ({@link Cea608Decoder})
@ -108,7 +108,8 @@ public interface SubtitleDecoderFactory {
case MimeTypes.APPLICATION_TTML: case MimeTypes.APPLICATION_TTML:
return new TtmlDecoder(); return new TtmlDecoder();
case MimeTypes.APPLICATION_SUBRIP: case MimeTypes.APPLICATION_SUBRIP:
return new SubripDecoder(); return new DelegatingSubtitleDecoder(
"DelegatingSubtitleDecoderWithSubripParser", new SubripParser());
case MimeTypes.APPLICATION_TX3G: case MimeTypes.APPLICATION_TX3G:
return new Tx3gDecoder(format.initializationData); return new Tx3gDecoder(format.initializationData);
case MimeTypes.APPLICATION_CEA608: case MimeTypes.APPLICATION_CEA608:

View File

@ -1,11 +1,11 @@
/* /*
* Copyright (C) 2016 The Android Open Source Project * Copyright 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * https://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package androidx.media3.extractor.text.subrip; package androidx.media3.exoplayer.text;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import androidx.media3.common.text.Cue; import androidx.media3.common.text.Cue;
import androidx.media3.extractor.text.Subtitle; import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.subrip.SubripParser;
import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
@ -26,9 +27,9 @@ import java.io.IOException;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
/** Unit test for {@link SubripDecoder}. */ /** Unit test for a DelegatingSubtitleDecoder backed by {@link SubripParser}. */
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public final class SubripDecoderTest { public class DelegatingSubtitleDecoderWithSubripParserTest {
private static final String EMPTY_FILE = "media/subrip/empty"; private static final String EMPTY_FILE = "media/subrip/empty";
private static final String TYPICAL_FILE = "media/subrip/typical"; private static final String TYPICAL_FILE = "media/subrip/typical";
@ -48,7 +49,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeEmpty() throws IOException { public void decodeEmpty() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_FILE); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_FILE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false); Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -59,7 +61,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypical() throws IOException { public void decodeTypical() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false); Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -72,7 +75,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalWithByteOrderMark() throws IOException { public void decodeTypicalWithByteOrderMark() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray( TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK); ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK);
@ -87,7 +91,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalExtraBlankLine() throws IOException { public void decodeTypicalExtraBlankLine() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray( TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_EXTRA_BLANK_LINE); ApplicationProvider.getApplicationContext(), TYPICAL_EXTRA_BLANK_LINE);
@ -103,7 +108,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalMissingTimecode() throws IOException { public void decodeTypicalMissingTimecode() throws IOException {
// Parsing should succeed, parsing the first and third cues only. // Parsing should succeed, parsing the first and third cues only.
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray( TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_TIMECODE); ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_TIMECODE);
@ -118,7 +124,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalMissingSequence() throws IOException { public void decodeTypicalMissingSequence() throws IOException {
// Parsing should succeed, parsing the first and third cues only. // Parsing should succeed, parsing the first and third cues only.
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray( TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_SEQUENCE); ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_SEQUENCE);
@ -133,7 +140,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalNegativeTimestamps() throws IOException { public void decodeTypicalNegativeTimestamps() throws IOException {
// Parsing should succeed, parsing the third cue only. // Parsing should succeed, parsing the third cue only.
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray( TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_NEGATIVE_TIMESTAMPS); ApplicationProvider.getApplicationContext(), TYPICAL_NEGATIVE_TIMESTAMPS);
@ -147,7 +155,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalUnexpectedEnd() throws IOException { public void decodeTypicalUnexpectedEnd() throws IOException {
// Parsing should succeed, parsing the first and second cues only. // Parsing should succeed, parsing the first and second cues only.
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UNEXPECTED_END); TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UNEXPECTED_END);
@ -160,7 +169,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalUtf16LittleEndian() throws IOException { public void decodeTypicalUtf16LittleEndian() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE); TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE);
@ -174,7 +184,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalUtf16BigEndian() throws IOException { public void decodeTypicalUtf16BigEndian() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE); TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE);
@ -188,7 +199,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeCueWithTag() throws IOException { public void decodeCueWithTag() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_WITH_TAGS); TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_WITH_TAGS);
@ -217,7 +229,8 @@ public final class SubripDecoderTest {
@Test @Test
public void decodeTypicalNoHoursAndMillis() throws IOException { public void decodeTypicalNoHoursAndMillis() throws IOException {
SubripDecoder decoder = new SubripDecoder(); DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser());
byte[] bytes = byte[] bytes =
TestUtil.getByteArray( TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_NO_HOURS_AND_MILLIS); ApplicationProvider.getApplicationContext(), TYPICAL_NO_HOURS_AND_MILLIS);
@ -263,9 +276,9 @@ public final class SubripDecoderTest {
Cue cue = subtitle.getCues(eventTimeUs).get(0); Cue cue = subtitle.getCues(eventTimeUs).get(0);
assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(cue.lineAnchor).isEqualTo(lineAnchor); assertThat(cue.lineAnchor).isEqualTo(lineAnchor);
assertThat(cue.line).isEqualTo(SubripDecoder.getFractionalPositionForAnchorType(lineAnchor));
assertThat(cue.positionAnchor).isEqualTo(positionAnchor); assertThat(cue.positionAnchor).isEqualTo(positionAnchor);
assertThat(cue.line).isEqualTo(SubripParser.getFractionalPositionForAnchorType(lineAnchor));
assertThat(cue.position) assertThat(cue.position)
.isEqualTo(SubripDecoder.getFractionalPositionForAnchorType(positionAnchor)); .isEqualTo(SubripParser.getFractionalPositionForAnchorType(positionAnchor));
} }
} }

View File

@ -15,34 +15,40 @@
*/ */
package androidx.media3.extractor.text.subrip; package androidx.media3.extractor.text.subrip;
import static androidx.annotation.VisibleForTesting.PRIVATE;
import android.text.Html; import android.text.Html;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.C;
import androidx.media3.common.text.Cue; import androidx.media3.common.text.Cue;
import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.LongArray; import androidx.media3.common.util.LongArray;
import androidx.media3.common.util.ParsableByteArray; import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.extractor.text.SimpleSubtitleDecoder; import androidx.media3.common.util.Util;
import androidx.media3.extractor.text.Subtitle; import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.common.base.Charsets; import com.google.common.base.Charsets;
import com.google.common.collect.ImmutableList;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** A {@link SimpleSubtitleDecoder} for SubRip. */ /** A {@link SubtitleParser} for SubRip. */
@UnstableApi @UnstableApi
public final class SubripDecoder extends SimpleSubtitleDecoder { public final class SubripParser implements SubtitleParser {
// Fractional positions for use when alignment tags are present. // Fractional positions for use when alignment tags are present.
private static final float START_FRACTION = 0.08f; private static final float START_FRACTION = 0.08f;
private static final float END_FRACTION = 1 - START_FRACTION; private static final float END_FRACTION = 1 - START_FRACTION;
private static final float MID_FRACTION = 0.5f; private static final float MID_FRACTION = 0.5f;
private static final String TAG = "SubripDecoder"; private static final String TAG = "SubripParser";
// Some SRT files don't include hours or milliseconds in the timecode, so we use optional groups. // Some SRT files don't include hours or milliseconds in the timecode, so we use optional groups.
private static final String SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:,(\\d+))?"; private static final String SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:,(\\d+))?";
@ -66,18 +72,25 @@ public final class SubripDecoder extends SimpleSubtitleDecoder {
private final StringBuilder textBuilder; private final StringBuilder textBuilder;
private final ArrayList<String> tags; private final ArrayList<String> tags;
private byte[] dataScratch = Util.EMPTY_BYTE_ARRAY;
public SubripDecoder() { public SubripParser() {
super("SubripDecoder");
textBuilder = new StringBuilder(); textBuilder = new StringBuilder();
tags = new ArrayList<>(); tags = new ArrayList<>();
} }
@Nullable
@Override @Override
protected Subtitle decode(byte[] data, int length, boolean reset) { public ImmutableList<CuesWithTiming> parse(byte[] data, int offset, int length) {
ArrayList<Cue> cues = new ArrayList<>(); ArrayList<Cue> cues = new ArrayList<>();
LongArray cueTimesUs = new LongArray(); LongArray startTimesUs = new LongArray();
ParsableByteArray subripData = new ParsableByteArray(data, length);
if (dataScratch.length < length) {
dataScratch = new byte[length];
}
System.arraycopy(
/* src= */ data, /* scrPos= */ offset, /* dest= */ dataScratch, /* destPos= */ 0, length);
ParsableByteArray subripData = new ParsableByteArray(dataScratch, length);
Charset charset = detectUtfCharset(subripData); Charset charset = detectUtfCharset(subripData);
@Nullable String currentLine; @Nullable String currentLine;
@ -104,8 +117,8 @@ public final class SubripDecoder extends SimpleSubtitleDecoder {
Matcher matcher = SUBRIP_TIMING_LINE.matcher(currentLine); Matcher matcher = SUBRIP_TIMING_LINE.matcher(currentLine);
if (matcher.matches()) { if (matcher.matches()) {
cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 1)); startTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 1));
cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 6)); startTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 6));
} else { } else {
Log.w(TAG, "Skipping invalid timing: " + currentLine); Log.w(TAG, "Skipping invalid timing: " + currentLine);
continue; continue;
@ -138,11 +151,22 @@ public final class SubripDecoder extends SimpleSubtitleDecoder {
cues.add(Cue.EMPTY); cues.add(Cue.EMPTY);
} }
Cue[] cuesArray = cues.toArray(new Cue[0]); ImmutableList.Builder<CuesWithTiming> cuesWithStartTimeAndDuration = ImmutableList.builder();
long[] cueTimesUsArray = cueTimesUs.toArray(); for (int i = 0; i < cues.size(); i++) {
return new SubripSubtitle(cuesArray, cueTimesUsArray); cuesWithStartTimeAndDuration.add(
new CuesWithTiming(
/* cues= */ ImmutableList.of(cues.get(i)),
/* startTimeUs= */ startTimesUs.get(i),
/* durationUs= */ i == cues.size() - 1
? C.TIME_UNSET
: startTimesUs.get(i + 1) - startTimesUs.get(i)));
}
return cuesWithStartTimeAndDuration.build();
} }
@Override
public void reset() {}
/** /**
* Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if
* no BOM is found. * no BOM is found.
@ -248,14 +272,17 @@ public final class SubripDecoder extends SimpleSubtitleDecoder {
return timestampMs * 1000; return timestampMs * 1000;
} }
/* package */ static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) { // TODO(b/289983417): Make package-private again, once it is no longer needed in
// DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed)
@VisibleForTesting(otherwise = PRIVATE)
public static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) {
switch (anchorType) { switch (anchorType) {
case Cue.ANCHOR_TYPE_START: case Cue.ANCHOR_TYPE_START:
return SubripDecoder.START_FRACTION; return START_FRACTION;
case Cue.ANCHOR_TYPE_MIDDLE: case Cue.ANCHOR_TYPE_MIDDLE:
return SubripDecoder.MID_FRACTION; return MID_FRACTION;
case Cue.ANCHOR_TYPE_END: case Cue.ANCHOR_TYPE_END:
return SubripDecoder.END_FRACTION; return END_FRACTION;
case Cue.TYPE_UNSET: case Cue.TYPE_UNSET:
default: default:
// Should never happen. // Should never happen.

View File

@ -1,69 +0,0 @@
/*
* Copyright (C) 2016 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.subrip;
import androidx.media3.common.C;
import androidx.media3.common.text.Cue;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.text.Subtitle;
import java.util.Collections;
import java.util.List;
/** A representation of a SubRip subtitle. */
/* package */ final class SubripSubtitle implements Subtitle {
private final Cue[] cues;
private final long[] cueTimesUs;
/**
* @param cues The cues in the subtitle.
* @param cueTimesUs The cue times, in microseconds.
*/
public SubripSubtitle(Cue[] cues, long[] cueTimesUs) {
this.cues = cues;
this.cueTimesUs = cueTimesUs;
}
@Override
public int getNextEventTimeIndex(long timeUs) {
int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false);
return index < cueTimesUs.length ? index : C.INDEX_UNSET;
}
@Override
public int getEventTimeCount() {
return cueTimesUs.length;
}
@Override
public long getEventTime(int index) {
Assertions.checkArgument(index >= 0);
Assertions.checkArgument(index < cueTimesUs.length);
return cueTimesUs[index];
}
@Override
public List<Cue> getCues(long timeUs) {
int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false);
if (index == -1 || cues[index] == Cue.EMPTY) {
// timeUs is earlier than the start of the first cue, or we have an empty cue.
return Collections.emptyList();
} else {
return Collections.singletonList(cues[index]);
}
}
}

View File

@ -0,0 +1,282 @@
/*
* Copyright (C) 2016 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.subrip;
import static com.google.common.truth.Truth.assertThat;
import androidx.media3.common.text.Cue;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.test.utils.TestUtil;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link SubripParser}. */
@RunWith(AndroidJUnit4.class)
public final class SubripParserTest {
private static final String EMPTY_FILE = "media/subrip/empty";
private static final String TYPICAL_FILE = "media/subrip/typical";
private static final String TYPICAL_WITH_BYTE_ORDER_MARK =
"media/subrip/typical_with_byte_order_mark";
private static final String TYPICAL_EXTRA_BLANK_LINE = "media/subrip/typical_extra_blank_line";
private static final String TYPICAL_MISSING_TIMECODE = "media/subrip/typical_missing_timecode";
private static final String TYPICAL_MISSING_SEQUENCE = "media/subrip/typical_missing_sequence";
private static final String TYPICAL_NEGATIVE_TIMESTAMPS =
"media/subrip/typical_negative_timestamps";
private static final String TYPICAL_UNEXPECTED_END = "media/subrip/typical_unexpected_end";
private static final String TYPICAL_UTF16BE = "media/subrip/typical_utf16be";
private static final String TYPICAL_UTF16LE = "media/subrip/typical_utf16le";
private static final String TYPICAL_WITH_TAGS = "media/subrip/typical_with_tags";
private static final String TYPICAL_NO_HOURS_AND_MILLIS =
"media/subrip/typical_no_hours_and_millis";
@Test
public void parseEmpty() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_FILE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).isEmpty();
}
@Test
public void parseTypical() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseTypicalAtOffsetAndRestrictedLength() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE);
ImmutableList<CuesWithTiming> allCues = parser.parse(bytes, 10, bytes.length - 15);
assertThat(allCues).hasSize(4);
// Because of the offset, we skip the first line of dialogue
assertTypicalCue2(allCues.get(0));
// Because of the length restriction, we only partially parse the third line of dialogue
Cue thirdCue = allCues.get(2).cues.get(0);
assertThat(thirdCue.text.toString()).isEqualTo("This is the third subti");
}
@Test
public void parseTypicalWithByteOrderMark() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseTypicalExtraBlankLine() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_EXTRA_BLANK_LINE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseTypicalMissingTimecode() throws IOException {
// Parsing should succeed, parsing the first and third cues only.
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_TIMECODE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
assertTypicalCue1(allCues.get(0));
assertTypicalCue3(allCues.get(2));
}
@Test
public void parseTypicalMissingSequence() throws IOException {
// Parsing should succeed, parsing the first and third cues only.
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_SEQUENCE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
assertTypicalCue1(allCues.get(0));
assertTypicalCue3(allCues.get(2));
}
@Test
public void parseTypicalNegativeTimestamps() throws IOException {
// Parsing should succeed, parsing the third cue only.
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_NEGATIVE_TIMESTAMPS);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(2);
assertTypicalCue3(allCues.get(0));
}
@Test
public void parseTypicalUnexpectedEnd() throws IOException {
// Parsing should succeed, parsing the first and second cues only.
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UNEXPECTED_END);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
}
@Test
public void parseTypicalUtf16LittleEndian() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseTypicalUtf16BigEndian() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseCueWithTag() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_WITH_TAGS);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).isNotNull();
assertThat(allCues.get(0).cues.get(0).text.toString()).isEqualTo("This is the first subtitle.");
assertThat(allCues.get(2).cues.get(0).text.toString())
.isEqualTo("This is the second subtitle.\nSecond subtitle with second line.");
assertThat(allCues.get(4).cues.get(0).text.toString()).isEqualTo("This is the third subtitle.");
assertThat(allCues.get(6).cues.get(0).text.toString())
.isEqualTo("This { \\an2} is not a valid tag due to the space after the opening bracket.");
assertThat(allCues.get(8).cues.get(0).text.toString())
.isEqualTo("This is the fifth subtitle with multiple valid tags.");
assertAlignmentCue(allCues.get(10), Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_START); // {/an1}
assertAlignmentCue(allCues.get(12), Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_MIDDLE); // {/an2}
assertAlignmentCue(allCues.get(14), Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_END); // {/an3}
assertAlignmentCue(allCues.get(16), Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_START); // {/an4}
assertAlignmentCue(allCues.get(18), Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_MIDDLE); // {/an5}
assertAlignmentCue(allCues.get(20), Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_END); // {/an6}
assertAlignmentCue(allCues.get(22), Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_START); // {/an7}
assertAlignmentCue(allCues.get(24), Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_MIDDLE); // {/an8}
assertAlignmentCue(allCues.get(26), Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_END); // {/an9}
}
@Test
public void parseTypicalNoHoursAndMillis() throws IOException {
SubripParser parser = new SubripParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), TYPICAL_NO_HOURS_AND_MILLIS);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
assertTypicalCue1(allCues.get(0));
assertThat(allCues.get(2).startTimeUs).isEqualTo(2_000_000);
assertThat(allCues.get(3).startTimeUs).isEqualTo(3_000_000);
assertTypicalCue3(allCues.get(4));
}
private static void assertTypicalCue1(CuesWithTiming cuesWithTiming) {
assertThat(cuesWithTiming.startTimeUs).isEqualTo(0);
assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the first subtitle.");
assertThat(cuesWithTiming.durationUs).isEqualTo(1234000);
}
private static void assertTypicalCue2(CuesWithTiming cuesWithTiming) {
assertThat(cuesWithTiming.startTimeUs).isEqualTo(2345000);
assertThat(cuesWithTiming.cues.get(0).text.toString())
.isEqualTo("This is the second subtitle.\nSecond subtitle with second line.");
assertThat(cuesWithTiming.durationUs).isEqualTo(3456000 - 2345000);
}
private static void assertTypicalCue3(CuesWithTiming cuesWithTiming) {
long expectedStartTimeUs = (((2L * 60L * 60L) + 4L) * 1000L + 567L) * 1000L;
assertThat(cuesWithTiming.startTimeUs).isEqualTo(expectedStartTimeUs);
assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the third subtitle.");
long expectedEndTimeUs = (((2L * 60L * 60L) + 8L) * 1000L + 901L) * 1000L;
assertThat(cuesWithTiming.durationUs).isEqualTo(expectedEndTimeUs - expectedStartTimeUs);
}
private static void assertAlignmentCue(
CuesWithTiming cuesWithTiming,
@Cue.AnchorType int lineAnchor,
@Cue.AnchorType int positionAnchor) {
Cue cue = cuesWithTiming.cues.get(0);
assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(cue.lineAnchor).isEqualTo(lineAnchor);
assertThat(cue.line).isEqualTo(SubripParser.getFractionalPositionForAnchorType(lineAnchor));
assertThat(cue.positionAnchor).isEqualTo(positionAnchor);
assertThat(cue.position)
.isEqualTo(SubripParser.getFractionalPositionForAnchorType(positionAnchor));
}
}