SsaParser implementation - moved from SsaDecoder

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

PiperOrigin-RevId: 546336035
This commit is contained in:
jbibik 2023-07-07 19:29:07 +01:00 committed by Rohit Singh
parent e1e956cdf3
commit 377d8edf9c
7 changed files with 614 additions and 113 deletions

View file

@ -24,7 +24,7 @@ import androidx.media3.extractor.text.cea.Cea608Decoder;
import androidx.media3.extractor.text.cea.Cea708Decoder;
import androidx.media3.extractor.text.dvb.DvbDecoder;
import androidx.media3.extractor.text.pgs.PgsDecoder;
import androidx.media3.extractor.text.ssa.SsaDecoder;
import androidx.media3.extractor.text.ssa.SsaParser;
import androidx.media3.extractor.text.subrip.SubripDecoder;
import androidx.media3.extractor.text.ttml.TtmlDecoder;
import androidx.media3.extractor.text.tx3g.Tx3gDecoder;
@ -63,7 +63,7 @@ public interface SubtitleDecoderFactory {
* <li>WebVTT (MP4) ({@link Mp4WebvttDecoder})
* <li>TTML ({@link TtmlDecoder})
* <li>SubRip ({@link SubripDecoder})
* <li>SSA/ASS ({@link SsaDecoder})
* <li>SSA/ASS ({@link SsaParser})
* <li>TX3G ({@link Tx3gDecoder})
* <li>Cea608 ({@link Cea608Decoder})
* <li>Cea708 ({@link Cea708Decoder})
@ -100,7 +100,9 @@ public interface SubtitleDecoderFactory {
case MimeTypes.TEXT_VTT:
return new WebvttDecoder();
case MimeTypes.TEXT_SSA:
return new SsaDecoder(format.initializationData);
return new DelegatingSubtitleDecoder(
"DelegatingSubtitleDecoderWithSsaParser",
new SsaParser(format.initializationData));
case MimeTypes.APPLICATION_MP4VTT:
return new Mp4WebvttDecoder();
case MimeTypes.APPLICATION_TTML:

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");
* 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
* https://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,
@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.extractor.text.ssa;
package androidx.media3.exoplayer.text;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
@ -23,6 +23,7 @@ import android.text.Layout;
import android.text.Spanned;
import androidx.media3.common.text.Cue;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.ssa.SsaParser;
import androidx.media3.test.utils.TestUtil;
import androidx.media3.test.utils.truth.SpannedSubject;
import androidx.test.core.app.ApplicationProvider;
@ -34,9 +35,9 @@ import java.util.Objects;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link SsaDecoder}. */
/** Unit test for a {@link DelegatingSubtitleDecoder} backed by {@link SsaParser}. */
@RunWith(AndroidJUnit4.class)
public final class SsaDecoderTest {
public final class DelegatingSubtitleDecoderWithSsaParserTest {
private static final String EMPTY = "media/ssa/empty";
private static final String EMPTY_STYLE_LINE = "media/ssa/empty_style_line";
@ -60,7 +61,7 @@ public final class SsaDecoderTest {
@Test
public void decodeEmpty() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -69,7 +70,7 @@ public final class SsaDecoderTest {
@Test
public void decodeEmptyStyleLine() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_STYLE_LINE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, /* reset= */ false);
@ -90,7 +91,7 @@ public final class SsaDecoderTest {
@Test
public void decodeTypical() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -122,7 +123,8 @@ public final class SsaDecoderTest {
ArrayList<byte[]> initializationData = new ArrayList<>();
initializationData.add(formatBytes);
initializationData.add(headerBytes);
SsaDecoder decoder = new SsaDecoder(initializationData);
DelegatingSubtitleDecoder decoder =
new DelegatingSubtitleDecoder("SSA", new SsaParser(initializationData));
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_DIALOGUE_ONLY);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -135,7 +137,7 @@ public final class SsaDecoderTest {
@Test
public void decodeTypicalUtf16le() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -161,7 +163,7 @@ public final class SsaDecoderTest {
@Test
public void decodeTypicalUtf16be() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -187,7 +189,7 @@ public final class SsaDecoderTest {
@Test
public void decodeOverlappingTimecodes() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), OVERLAPPING_TIMECODES);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -237,7 +239,7 @@ public final class SsaDecoderTest {
@Test
public void decodePositions() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), POSITIONS);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -290,7 +292,7 @@ public final class SsaDecoderTest {
@Test
public void decodeInvalidPositions() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INVALID_POSITIONS);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -326,7 +328,7 @@ public final class SsaDecoderTest {
@Test
public void decodePositionsWithMissingPlayResY() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), POSITIONS_WITHOUT_PLAYRES);
@ -343,7 +345,7 @@ public final class SsaDecoderTest {
@Test
public void decodeInvalidTimecodes() throws IOException {
// Parsing should succeed, parsing the third cue only.
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INVALID_TIMECODES);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -354,7 +356,7 @@ public final class SsaDecoderTest {
@Test
public void decodePrimaryColor() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_PRIMARY_COLOR);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -403,7 +405,7 @@ public final class SsaDecoderTest {
@Test
public void decodeOutlineColor() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_OUTLINE_COLOR);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -423,7 +425,7 @@ public final class SsaDecoderTest {
@Test
public void decodeFontSize() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_FONT_SIZE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -439,7 +441,7 @@ public final class SsaDecoderTest {
@Test
public void decodeBoldItalic() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_BOLD_ITALIC);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -458,7 +460,7 @@ public final class SsaDecoderTest {
@Test
public void decodeUnderline() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_UNDERLINE);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
@ -474,7 +476,7 @@ public final class SsaDecoderTest {
@Test
public void decodeStrikeout() throws IOException {
SsaDecoder decoder = new SsaDecoder();
DelegatingSubtitleDecoder decoder = new DelegatingSubtitleDecoder("SSA", new SsaParser());
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_STRIKEOUT);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);

View file

@ -16,7 +16,7 @@
*/
package androidx.media3.extractor.text.ssa;
import static androidx.media3.extractor.text.ssa.SsaDecoder.FORMAT_LINE_PREFIX;
import static androidx.media3.extractor.text.ssa.SsaParser.FORMAT_LINE_PREFIX;
import android.text.TextUtils;
import androidx.annotation.Nullable;

View file

@ -34,10 +34,11 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.ParsableByteArray;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.extractor.text.SimpleSubtitleDecoder;
import androidx.media3.extractor.text.Subtitle;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.SubtitleParser;
import com.google.common.base.Ascii;
import com.google.common.base.Charsets;
import com.google.common.collect.ImmutableList;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@ -47,11 +48,11 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** A {@link SimpleSubtitleDecoder} for SSA/ASS. */
/** A {@link SubtitleParser} for SSA/ASS. */
@UnstableApi
public final class SsaDecoder extends SimpleSubtitleDecoder {
public final class SsaParser implements SubtitleParser {
private static final String TAG = "SsaDecoder";
private static final String TAG = "SsaParser";
private static final Pattern SSA_TIMECODE_PATTERN =
Pattern.compile("(?:(\\d+):)?(\\d+):(\\d+)[:.](\\d+)");
@ -81,21 +82,22 @@ public final class SsaDecoder extends SimpleSubtitleDecoder {
*/
private float screenHeight;
public SsaDecoder() {
private byte[] dataScratch = Util.EMPTY_BYTE_ARRAY;
public SsaParser() {
this(/* initializationData= */ null);
}
/**
* Constructs an SsaDecoder with optional format and header info.
* Constructs an instance with optional format and header info.
*
* @param initializationData Optional initialization data for the decoder. If not null or empty,
* @param initializationData Optional initialization data for the parser. If not null or empty,
* the initialization data must consist of two byte arrays. The first must contain an SSA
* format line. The second must contain an SSA header that will be assumed common to all
* samples. The header is everything in an SSA file before the {@code [Events]} section (i.e.
* {@code [Script Info]} and optional {@code [V4+ Styles]} section.
*/
public SsaDecoder(@Nullable List<byte[]> initializationData) {
super("SsaDecoder");
public SsaParser(@Nullable List<byte[]> initializationData) {
screenWidth = Cue.DIMEN_UNSET;
screenHeight = Cue.DIMEN_UNSET;
@ -116,18 +118,38 @@ public final class SsaDecoder extends SimpleSubtitleDecoder {
}
@Override
protected Subtitle decode(byte[] data, int length, boolean reset) {
List<List<Cue>> cues = new ArrayList<>();
List<Long> cueTimesUs = new ArrayList<>();
public void reset() {}
ParsableByteArray parsableData = new ParsableByteArray(data, length);
@Nullable
@Override
public ImmutableList<CuesWithTiming> parse(byte[] data, int offset, int length) {
List<List<Cue>> cues = new ArrayList<>();
List<Long> startTimesUs = new ArrayList<>();
if (dataScratch.length < length) {
dataScratch = new byte[length];
}
System.arraycopy(
/* src= */ data, /* scrPos= */ offset, /* dest= */ dataScratch, /* destPos= */ 0, length);
ParsableByteArray parsableData = new ParsableByteArray(dataScratch, length);
Charset charset = detectUtfCharset(parsableData);
if (!haveInitializationData) {
parseHeader(parsableData, charset);
}
parseEventBody(parsableData, cues, cueTimesUs, charset);
return new SsaSubtitle(cues, cueTimesUs);
parseEventBody(parsableData, cues, startTimesUs, charset);
ImmutableList.Builder<CuesWithTiming> cuesWithStartTimeAndDuration = ImmutableList.builder();
for (int i = 0; i < cues.size(); i++) {
List<Cue> cuesForThisStartTime = cues.get(i);
long startTimeUs = startTimesUs.get(i);
// The duration of the last CuesWithTiming is C.TIME_UNSET by design
long durationUs =
i == cues.size() - 1 ? C.TIME_UNSET : startTimesUs.get(i + 1) - startTimesUs.get(i);
cuesWithStartTimeAndDuration.add(
new CuesWithTiming(cuesForThisStartTime, startTimeUs, durationUs));
}
return cuesWithStartTimeAndDuration.build();
}
/**

View file

@ -17,7 +17,7 @@
package androidx.media3.extractor.text.ssa;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.extractor.text.ssa.SsaDecoder.STYLE_LINE_PREFIX;
import static androidx.media3.extractor.text.ssa.SsaParser.STYLE_LINE_PREFIX;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.SOURCE;
@ -371,7 +371,7 @@ import java.util.regex.Pattern;
int strikeoutIndex = C.INDEX_UNSET;
int borderStyleIndex = C.INDEX_UNSET;
String[] keys =
TextUtils.split(styleFormatLine.substring(SsaDecoder.FORMAT_LINE_PREFIX.length()), ",");
TextUtils.split(styleFormatLine.substring(SsaParser.FORMAT_LINE_PREFIX.length()), ",");
for (int i = 0; i < keys.length; i++) {
switch (Ascii.toLowerCase(keys[i].trim())) {
case "name":

View file

@ -1,69 +0,0 @@
/*
* Copyright (C) 2017 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.ssa;
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 an SSA/ASS subtitle. */
/* package */ final class SsaSubtitle implements Subtitle {
private final List<List<Cue>> cues;
private final List<Long> cueTimesUs;
/**
* @param cues The cues in the subtitle.
* @param cueTimesUs The cue times, in microseconds.
*/
public SsaSubtitle(List<List<Cue>> cues, List<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.size() ? index : C.INDEX_UNSET;
}
@Override
public int getEventTimeCount() {
return cueTimesUs.size();
}
@Override
public long getEventTime(int index) {
Assertions.checkArgument(index >= 0);
Assertions.checkArgument(index < cueTimesUs.size());
return cueTimesUs.get(index);
}
@Override
public List<Cue> getCues(long timeUs) {
int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false);
if (index == -1) {
// timeUs is earlier than the start of the first cue.
return Collections.emptyList();
} else {
return cues.get(index);
}
}
}

View file

@ -0,0 +1,544 @@
/*
* 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.ssa;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import android.graphics.Color;
import android.text.Layout;
import android.text.Spanned;
import androidx.media3.common.C;
import androidx.media3.common.text.Cue;
import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.test.utils.TestUtil;
import androidx.media3.test.utils.truth.SpannedSubject;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test {@link SsaParser}. */
@RunWith(AndroidJUnit4.class)
public final class SsaParserTest {
private static final String EMPTY = "media/ssa/empty";
private static final String EMPTY_STYLE_LINE = "media/ssa/empty_style_line";
private static final String TYPICAL = "media/ssa/typical";
private static final String TYPICAL_HEADER_ONLY = "media/ssa/typical_header";
private static final String TYPICAL_DIALOGUE_ONLY = "media/ssa/typical_dialogue";
private static final String TYPICAL_FORMAT_ONLY = "media/ssa/typical_format";
private static final String TYPICAL_UTF16LE = "media/ssa/typical_utf16le";
private static final String TYPICAL_UTF16BE = "media/ssa/typical_utf16be";
private static final String OVERLAPPING_TIMECODES = "media/ssa/overlapping_timecodes";
private static final String POSITIONS = "media/ssa/positioning";
private static final String INVALID_TIMECODES = "media/ssa/invalid_timecodes";
private static final String INVALID_POSITIONS = "media/ssa/invalid_positioning";
private static final String POSITIONS_WITHOUT_PLAYRES = "media/ssa/positioning_without_playres";
private static final String STYLE_PRIMARY_COLOR = "media/ssa/style_primary_color";
private static final String STYLE_OUTLINE_COLOR = "media/ssa/style_outline_color";
private static final String STYLE_FONT_SIZE = "media/ssa/style_font_size";
private static final String STYLE_BOLD_ITALIC = "media/ssa/style_bold_italic";
private static final String STYLE_UNDERLINE = "media/ssa/style_underline";
private static final String STYLE_STRIKEOUT = "media/ssa/style_strikeout";
@Test
public void parseEmpty() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).isEmpty();
}
@Test
public void parseEmptyStyleLine() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_STYLE_LINE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(2);
Cue cue = Iterables.getOnlyElement(allCues.get(0).cues);
SpannedSubject.assertThat((Spanned) cue.text).hasNoSpans();
assertThat(cue.textSize).isEqualTo(Cue.DIMEN_UNSET);
assertThat(cue.textSizeType).isEqualTo(Cue.TYPE_UNSET);
assertThat(cue.textAlignment).isNull();
assertThat(cue.positionAnchor).isEqualTo(Cue.TYPE_UNSET);
assertThat(cue.position).isEqualTo(Cue.DIMEN_UNSET);
assertThat(cue.size).isEqualTo(Cue.DIMEN_UNSET);
assertThat(cue.lineAnchor).isEqualTo(Cue.TYPE_UNSET);
assertThat(cue.line).isEqualTo(Cue.DIMEN_UNSET);
assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
}
@Test
public void parseTypical() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
// Check position, line, anchors & alignment are set from Alignment Style (2 - bottom-center).
Cue firstCue = allCues.get(0).cues.get(0);
assertWithMessage("Cue.textAlignment")
.that(firstCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_CENTER);
assertWithMessage("Cue.positionAnchor")
.that(firstCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertThat(firstCue.position).isEqualTo(0.5f);
assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(firstCue.line).isEqualTo(0.95f);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseTypicalWithInitializationData() throws IOException {
byte[] headerBytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_HEADER_ONLY);
byte[] formatBytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FORMAT_ONLY);
ArrayList<byte[]> initializationData = new ArrayList<>();
initializationData.add(formatBytes);
initializationData.add(headerBytes);
SsaParser parser = new SsaParser(initializationData);
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_DIALOGUE_ONLY);
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 parseTypicalWithInitializationDataAtOffsetIntoDialogueAndRestrictedLength()
throws IOException {
byte[] headerBytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_HEADER_ONLY);
byte[] formatBytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FORMAT_ONLY);
ArrayList<byte[]> initializationData = new ArrayList<>();
initializationData.add(formatBytes);
initializationData.add(headerBytes);
SsaParser parser = new SsaParser(initializationData);
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_DIALOGUE_ONLY);
ImmutableList<CuesWithTiming> allCues =
parser.parse(bytes, /* offset= */ 10, /* length= */ bytes.length - 30);
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
assertThat(allCues.get(2).startTimeUs).isEqualTo(4560000);
assertThat(allCues.get(2).durationUs).isEqualTo(8900000 - 4560000);
assertThat(allCues.get(2).cues.get(0).text.toString()).isEqualTo("This is the third subt");
}
@Test
public void parseTypicalUtf16le() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
// Check position, line, anchors & alignment are set from Alignment Style (2 - bottom-center).
Cue firstCue = allCues.get(0).cues.get(0);
assertWithMessage("Cue.textAlignment")
.that(firstCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_CENTER);
assertWithMessage("Cue.positionAnchor")
.that(firstCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertThat(firstCue.position).isEqualTo(0.5f);
assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(firstCue.line).isEqualTo(0.95f);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseTypicalUtf16be() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
// Check position, line, anchors & alignment are set from Alignment Style (2 - bottom-center).
Cue firstCue = allCues.get(0).cues.get(0);
assertWithMessage("Cue.textAlignment")
.that(firstCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_CENTER);
assertWithMessage("Cue.positionAnchor")
.that(firstCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertThat(firstCue.position).isEqualTo(0.5f);
assertThat(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(firstCue.line).isEqualTo(0.95f);
assertTypicalCue1(allCues.get(0));
assertTypicalCue2(allCues.get(2));
assertTypicalCue3(allCues.get(4));
}
@Test
public void parseOverlappingTimecodes() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), OVERLAPPING_TIMECODES);
List<CuesWithTiming> allCues = parser.parse(bytes);
String firstSubtitleText = "First subtitle - end overlaps second";
String secondSubtitleText = "Second subtitle - beginning overlaps first";
String thirdSubtitleText = "Third subtitle - out of order";
String fourthSubtitleText = "Fourth subtitle - same timings as fifth";
String fifthSubtitleText = "Fifth subtitle - same timings as fourth";
String sixthSubtitleText = "Sixth subtitle - fully encompasses seventh";
String seventhSubtitleText = "Seventh subtitle - nested fully inside sixth";
assertThat(allCues).hasSize(11);
assertThat(allCues.get(0).startTimeUs).isEqualTo(1_000_000);
assertThat(allCues.get(0).durationUs).isEqualTo(1_000_000);
assertThat(Iterables.transform(allCues.get(0).cues, cue -> cue.text.toString()))
.containsExactly(firstSubtitleText);
assertThat(allCues.get(1).startTimeUs).isEqualTo(2_000_000);
assertThat(allCues.get(1).durationUs).isEqualTo(2_230_000);
assertThat(Iterables.transform(allCues.get(1).cues, cue -> cue.text.toString()))
.containsExactly(firstSubtitleText, secondSubtitleText);
assertThat(allCues.get(2).startTimeUs).isEqualTo(4_230_000);
assertThat(allCues.get(2).durationUs).isEqualTo(1_000_000);
assertThat(Iterables.transform(allCues.get(2).cues, cue -> cue.text.toString()))
.containsExactly(secondSubtitleText);
assertThat(allCues.get(3).startTimeUs).isEqualTo(5_230_000);
assertThat(allCues.get(3).durationUs).isEqualTo(770_000);
assertThat(allCues.get(3).cues).isEmpty();
assertThat(allCues.get(4).startTimeUs).isEqualTo(6_000_000);
assertThat(allCues.get(4).durationUs).isEqualTo(2_440_000);
assertThat(Iterables.transform(allCues.get(4).cues, cue -> cue.text.toString()))
.containsExactly(thirdSubtitleText);
assertThat(allCues.get(5).startTimeUs).isEqualTo(8_440_000);
assertThat(allCues.get(5).durationUs).isEqualTo(1_000_000);
assertThat(Iterables.transform(allCues.get(5).cues, cue -> cue.text.toString()))
.containsExactly(fourthSubtitleText, fifthSubtitleText);
assertThat(allCues.get(6).startTimeUs).isEqualTo(9_440_000);
assertThat(allCues.get(6).durationUs).isEqualTo(1_280_000);
assertThat(allCues.get(6).cues).isEmpty();
assertThat(allCues.get(7).startTimeUs).isEqualTo(10_720_000);
assertThat(allCues.get(7).durationUs).isEqualTo(2_500_000);
assertThat(Iterables.transform(allCues.get(7).cues, cue -> cue.text.toString()))
.containsExactly(sixthSubtitleText);
assertThat(allCues.get(8).startTimeUs).isEqualTo(13_220_000);
assertThat(allCues.get(8).durationUs).isEqualTo(1_000_000);
assertThat(Iterables.transform(allCues.get(8).cues, cue -> cue.text.toString()))
.containsExactly(sixthSubtitleText, seventhSubtitleText);
assertThat(allCues.get(9).startTimeUs).isEqualTo(14_220_000);
assertThat(allCues.get(9).durationUs).isEqualTo(1_430_000);
assertThat(Iterables.transform(allCues.get(9).cues, cue -> cue.text.toString()))
.containsExactly(sixthSubtitleText);
assertThat(allCues.get(10).startTimeUs).isEqualTo(15_650_000);
assertThat(allCues.get(10).durationUs).isEqualTo(C.TIME_UNSET);
assertThat(allCues.get(10).cues).isEmpty();
}
@Test
public void parsePositions() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), POSITIONS);
List<CuesWithTiming> allCues = parser.parse(bytes);
// Check \pos() sets position & line
Cue firstCue = Iterables.getOnlyElement(allCues.get(0).cues);
assertThat(firstCue.position).isEqualTo(0.5f);
assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(firstCue.line).isEqualTo(0.25f);
// Check the \pos() doesn't need to be at the start of the line.
Cue secondCue = Iterables.getOnlyElement(allCues.get(2).cues);
assertThat(secondCue.position).isEqualTo(0.25f);
assertThat(secondCue.line).isEqualTo(0.25f);
// Check only the last \pos() value is used.
Cue thirdCue = Iterables.getOnlyElement(allCues.get(4).cues);
assertThat(thirdCue.position).isEqualTo(0.25f);
// Check \move() is treated as \pos()
Cue fourthCue = Iterables.getOnlyElement(allCues.get(6).cues);
assertThat(fourthCue.position).isEqualTo(0.5f);
assertThat(fourthCue.line).isEqualTo(0.25f);
// Check alignment override in a separate brace (to bottom-center) affects textAlignment and
// both line & position anchors.
Cue fifthCue = Iterables.getOnlyElement(allCues.get(8).cues);
assertThat(fifthCue.position).isEqualTo(0.5f);
assertThat(fifthCue.line).isEqualTo(0.5f);
assertWithMessage("Cue.positionAnchor")
.that(fifthCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertThat(fifthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
assertWithMessage("Cue.textAlignment")
.that(fifthCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_CENTER);
// Check alignment override in the same brace (to top-right) affects textAlignment and both line
// & position anchors.
Cue sixthCue = Iterables.getOnlyElement(allCues.get(10).cues);
assertThat(sixthCue.position).isEqualTo(0.5f);
assertThat(sixthCue.line).isEqualTo(0.5f);
assertWithMessage("Cue.positionAnchor")
.that(sixthCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_END);
assertThat(sixthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_START);
assertWithMessage("Cue.textAlignment")
.that(sixthCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_OPPOSITE);
}
@Test
public void parseInvalidPositions() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INVALID_POSITIONS);
List<CuesWithTiming> allCues = parser.parse(bytes);
// Negative parameter to \pos() - fall back to the positions implied by middle-left alignment.
Cue firstCue = Iterables.getOnlyElement(allCues.get(0).cues);
assertThat(firstCue.position).isEqualTo(0.05f);
assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(firstCue.line).isEqualTo(0.5f);
// Negative parameter to \move() - fall back to the positions implied by middle-left alignment.
Cue secondCue = Iterables.getOnlyElement(allCues.get(2).cues);
assertThat(secondCue.position).isEqualTo(0.05f);
assertThat(secondCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(secondCue.line).isEqualTo(0.5f);
// Check invalid alignment override (11) is skipped and style-provided one is used (4).
Cue thirdCue = Iterables.getOnlyElement(allCues.get(4).cues);
assertWithMessage("Cue.positionAnchor")
.that(thirdCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_START);
assertThat(thirdCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertWithMessage("Cue.textAlignment")
.that(thirdCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_NORMAL);
// No braces - fall back to the positions implied by middle-left alignment
Cue fourthCue = Iterables.getOnlyElement(allCues.get(6).cues);
assertThat(fourthCue.position).isEqualTo(0.05f);
assertThat(fourthCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(fourthCue.line).isEqualTo(0.5f);
}
@Test
public void parsePositionsWithMissingPlayResY() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), POSITIONS_WITHOUT_PLAYRES);
List<CuesWithTiming> allCues = parser.parse(bytes);
// The dialogue line has a valid \pos() override, but it's ignored because PlayResY isn't
// set (so we don't know the denominator).
Cue firstCue = Iterables.getOnlyElement(allCues.get(0).cues);
assertThat(firstCue.position).isEqualTo(Cue.DIMEN_UNSET);
assertThat(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertThat(firstCue.line).isEqualTo(Cue.DIMEN_UNSET);
}
@Test
public void parseInvalidTimecodes() throws IOException {
// Parsing should succeed, parsing the third cue only.
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INVALID_TIMECODES);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(2);
assertTypicalCue3(allCues.get(0));
}
@Test
public void parsePrimaryColor() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_PRIMARY_COLOR);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(14);
// &H000000FF (AABBGGRR) -> #FFFF0000 (AARRGGBB)
Spanned firstCueText = (Spanned) Iterables.getOnlyElement(allCues.get(0).cues).text;
SpannedSubject.assertThat(firstCueText)
.hasForegroundColorSpanBetween(0, firstCueText.length())
.withColor(Color.RED);
// &H0000FFFF (AABBGGRR) -> #FFFFFF00 (AARRGGBB)
Spanned secondCueText = (Spanned) Iterables.getOnlyElement(allCues.get(2).cues).text;
SpannedSubject.assertThat(secondCueText)
.hasForegroundColorSpanBetween(0, secondCueText.length())
.withColor(Color.YELLOW);
// &HFF00 (GGRR) -> #FF00FF00 (AARRGGBB)
Spanned thirdCueText = (Spanned) Iterables.getOnlyElement(allCues.get(4).cues).text;
SpannedSubject.assertThat(thirdCueText)
.hasForegroundColorSpanBetween(0, thirdCueText.length())
.withColor(Color.GREEN);
// &HA00000FF (AABBGGRR) -> #5FFF0000 (AARRGGBB)
Spanned fourthCueText = (Spanned) Iterables.getOnlyElement(allCues.get(6).cues).text;
SpannedSubject.assertThat(fourthCueText)
.hasForegroundColorSpanBetween(0, fourthCueText.length())
.withColor(0x5FFF0000);
// 16711680 (AABBGGRR) -> &H00FF0000 (AABBGGRR) -> #FF0000FF (AARRGGBB)
Spanned fifthCueText = (Spanned) Iterables.getOnlyElement(allCues.get(8).cues).text;
SpannedSubject.assertThat(fifthCueText)
.hasForegroundColorSpanBetween(0, fifthCueText.length())
.withColor(0xFF0000FF);
// 2164195328 (AABBGGRR) -> &H80FF0000 (AABBGGRR) -> #7F0000FF (AARRGGBB)
Spanned sixthCueText = (Spanned) Iterables.getOnlyElement(allCues.get(10).cues).text;
SpannedSubject.assertThat(sixthCueText)
.hasForegroundColorSpanBetween(0, sixthCueText.length())
.withColor(0x7F0000FF);
Spanned seventhCueText = (Spanned) Iterables.getOnlyElement(allCues.get(12).cues).text;
SpannedSubject.assertThat(seventhCueText)
.hasNoForegroundColorSpanBetween(0, seventhCueText.length());
}
@Test
public void parseOutlineColor() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_OUTLINE_COLOR);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
Spanned firstCueText = (Spanned) Iterables.getOnlyElement(allCues.get(0).cues).text;
SpannedSubject.assertThat(firstCueText)
.hasBackgroundColorSpanBetween(0, firstCueText.length())
.withColor(Color.BLUE);
// OutlineColour should be treated as background only when BorderStyle=3
Spanned secondCueText = (Spanned) Iterables.getOnlyElement(allCues.get(2).cues).text;
SpannedSubject.assertThat(secondCueText)
.hasNoBackgroundColorSpanBetween(0, secondCueText.length());
}
@Test
public void parseFontSize() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_FONT_SIZE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
Cue firstCue = Iterables.getOnlyElement(allCues.get(0).cues);
assertThat(firstCue.textSize).isWithin(1.0e-8f).of(30f / 720f);
assertThat(firstCue.textSizeType).isEqualTo(Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING);
Cue secondCue = Iterables.getOnlyElement(allCues.get(2).cues);
assertThat(secondCue.textSize).isWithin(1.0e-8f).of(72.2f / 720f);
assertThat(secondCue.textSizeType).isEqualTo(Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING);
}
@Test
public void parseBoldItalic() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_BOLD_ITALIC);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(6);
Spanned firstCueText = (Spanned) Iterables.getOnlyElement(allCues.get(0).cues).text;
SpannedSubject.assertThat(firstCueText).hasBoldSpanBetween(0, firstCueText.length());
Spanned secondCueText = (Spanned) Iterables.getOnlyElement(allCues.get(2).cues).text;
SpannedSubject.assertThat(secondCueText).hasItalicSpanBetween(0, secondCueText.length());
Spanned thirdCueText = (Spanned) Iterables.getOnlyElement(allCues.get(4).cues).text;
SpannedSubject.assertThat(thirdCueText).hasBoldItalicSpanBetween(0, thirdCueText.length());
}
@Test
public void parseUnderline() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_UNDERLINE);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
Spanned firstCueText = (Spanned) Iterables.getOnlyElement(allCues.get(0).cues).text;
SpannedSubject.assertThat(firstCueText).hasUnderlineSpanBetween(0, firstCueText.length());
Spanned secondCueText = (Spanned) Iterables.getOnlyElement(allCues.get(2).cues).text;
SpannedSubject.assertThat(secondCueText).hasNoUnderlineSpanBetween(0, secondCueText.length());
}
@Test
public void parseStrikeout() throws IOException {
SsaParser parser = new SsaParser();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), STYLE_STRIKEOUT);
List<CuesWithTiming> allCues = parser.parse(bytes);
assertThat(allCues).hasSize(4);
Spanned firstCueText = (Spanned) Iterables.getOnlyElement(allCues.get(0).cues).text;
SpannedSubject.assertThat(firstCueText).hasStrikethroughSpanBetween(0, firstCueText.length());
Spanned secondCueText = (Spanned) Iterables.getOnlyElement(allCues.get(2).cues).text;
SpannedSubject.assertThat(secondCueText)
.hasNoStrikethroughSpanBetween(0, secondCueText.length());
}
private static void assertTypicalCue1(CuesWithTiming cuesWithTiming) {
assertThat(cuesWithTiming.startTimeUs).isEqualTo(0);
assertThat(cuesWithTiming.durationUs).isEqualTo(1230000);
assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the first subtitle.");
assertThat(Objects.requireNonNull(cuesWithTiming.cues.get(0).textAlignment))
.isEqualTo(Layout.Alignment.ALIGN_CENTER);
}
private static void assertTypicalCue2(CuesWithTiming cuesWithTiming) {
assertThat(cuesWithTiming.startTimeUs).isEqualTo(2340000);
assertThat(cuesWithTiming.durationUs).isEqualTo(3450000 - 2340000);
assertThat(cuesWithTiming.cues.get(0).text.toString())
.isEqualTo("This is the second subtitle \nwith a newline \nand another.");
}
private static void assertTypicalCue3(CuesWithTiming cuesWithTiming) {
assertThat(cuesWithTiming.startTimeUs).isEqualTo(4560000);
assertThat(cuesWithTiming.durationUs).isEqualTo(8900000 - 4560000);
assertThat(cuesWithTiming.cues.get(0).text.toString())
.isEqualTo("This is the third subtitle, with a comma.");
}
}