Merge pull request #6595 from szaboa:dev-v2-ssa-position

PiperOrigin-RevId: 283722376
This commit is contained in:
Ian Baker 2019-12-05 10:19:27 +00:00
commit 8494c3aeea
13 changed files with 916 additions and 99 deletions

View File

@ -22,6 +22,8 @@
* Allow `AdtsExtractor` to encounter EoF when calculating average frame size
([#6700](https://github.com/google/ExoPlayer/issues/6700)).
* Make media session connector dispatch ACTION_SET_CAPTIONING_ENABLED.
* Add support for position and overlapping start/end times in SSA/ASS subtitles
([#6320](https://github.com/google/ExoPlayer/issues/6320)).
### 2.11.0 (not yet released) ###

View File

@ -20,6 +20,7 @@ project.ext {
targetSdkVersion = 28 // TODO: Bump once b/143232359 is resolved
compileSdkVersion = 29
dexmakerVersion = '2.21.0'
guavaVersion = '23.5-android'
mockitoVersion = '2.25.0'
robolectricVersion = '4.3'
autoValueVersion = '1.6'

View File

@ -53,6 +53,7 @@ dependencies {
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
androidTestImplementation 'com.google.truth:truth:' + truthVersion
androidTestImplementation 'com.google.guava:guava:' + guavaVersion
androidTestImplementation 'com.linkedin.dexmaker:dexmaker:' + dexmakerVersion
androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:' + dexmakerVersion
androidTestImplementation 'org.mockito:mockito-core:' + mockitoVersion
@ -60,6 +61,7 @@ dependencies {
testImplementation 'androidx.test:core:' + androidxTestCoreVersion
testImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
testImplementation 'com.google.truth:truth:' + truthVersion
testImplementation 'com.google.guava:guava:' + guavaVersion
testImplementation 'org.mockito:mockito-core:' + mockitoVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
testImplementation project(modulePrefix + 'testutils')

View File

@ -15,7 +15,7 @@
*/
package com.google.android.exoplayer2.text.ssa;
import android.text.TextUtils;
import android.text.Layout;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.text.Cue;
@ -23,71 +23,90 @@ import com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
import com.google.android.exoplayer2.text.Subtitle;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.LongArray;
import com.google.android.exoplayer2.util.ParsableByteArray;
import com.google.android.exoplayer2.util.Util;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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 SimpleSubtitleDecoder} for SSA/ASS. */
public final class SsaDecoder extends SimpleSubtitleDecoder {
private static final String TAG = "SsaDecoder";
private static final Pattern SSA_TIMECODE_PATTERN =
Pattern.compile("(?:(\\d+):)?(\\d+):(\\d+)[:.](\\d+)");
private static final String FORMAT_LINE_PREFIX = "Format: ";
private static final String DIALOGUE_LINE_PREFIX = "Dialogue: ";
/* package */ static final String FORMAT_LINE_PREFIX = "Format:";
/* package */ static final String STYLE_LINE_PREFIX = "Style:";
private static final String DIALOGUE_LINE_PREFIX = "Dialogue:";
private static final float DEFAULT_MARGIN = 0.05f;
private final boolean haveInitializationData;
@Nullable private final SsaDialogueFormat dialogueFormatFromInitializationData;
private int formatKeyCount;
private int formatStartIndex;
private int formatEndIndex;
private int formatTextIndex;
private @MonotonicNonNull Map<String, SsaStyle> styles;
/**
* The horizontal resolution used by the subtitle author - all cue positions are relative to this.
*
* <p>Parsed from the {@code PlayResX} value in the {@code [Script Info]} section.
*/
private float screenWidth;
/**
* The vertical resolution used by the subtitle author - all cue positions are relative to this.
*
* <p>Parsed from the {@code PlayResY} value in the {@code [Script Info]} section.
*/
private float screenHeight;
public SsaDecoder() {
this(/* initializationData= */ null);
}
/**
* Constructs an SsaDecoder with optional format & header info.
*
* @param initializationData Optional initialization data for the decoder. 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.
* 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");
screenWidth = Cue.DIMEN_UNSET;
screenHeight = Cue.DIMEN_UNSET;
if (initializationData != null && !initializationData.isEmpty()) {
haveInitializationData = true;
String formatLine = Util.fromUtf8Bytes(initializationData.get(0));
Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX));
parseFormatLine(formatLine);
dialogueFormatFromInitializationData =
Assertions.checkNotNull(SsaDialogueFormat.fromFormatLine(formatLine));
parseHeader(new ParsableByteArray(initializationData.get(1)));
} else {
haveInitializationData = false;
dialogueFormatFromInitializationData = null;
}
}
@Override
protected Subtitle decode(byte[] bytes, int length, boolean reset) {
ArrayList<Cue> cues = new ArrayList<>();
LongArray cueTimesUs = new LongArray();
List<List<Cue>> cues = new ArrayList<>();
List<Long> cueTimesUs = new ArrayList<>();
ParsableByteArray data = new ParsableByteArray(bytes, length);
if (!haveInitializationData) {
parseHeader(data);
}
parseEventBody(data, cues, cueTimesUs);
Cue[] cuesArray = new Cue[cues.size()];
cues.toArray(cuesArray);
long[] cueTimesUsArray = cueTimesUs.toArray();
return new SsaSubtitle(cuesArray, cueTimesUsArray);
return new SsaSubtitle(cues, cueTimesUs);
}
/**
@ -98,109 +117,157 @@ public final class SsaDecoder extends SimpleSubtitleDecoder {
private void parseHeader(ParsableByteArray data) {
String currentLine;
while ((currentLine = data.readLine()) != null) {
// TODO: Parse useful data from the header.
if (currentLine.startsWith("[Events]")) {
// We've reached the event body.
if ("[Script Info]".equalsIgnoreCase(currentLine)) {
parseScriptInfo(data);
} else if ("[V4+ Styles]".equalsIgnoreCase(currentLine)) {
styles = parseStyles(data);
} else if ("[V4 Styles]".equalsIgnoreCase(currentLine)) {
Log.i(TAG, "[V4 Styles] are not supported");
} else if ("[Events]".equalsIgnoreCase(currentLine)) {
// We've reached the [Events] section, so the header is over.
return;
}
}
}
/**
* Parse the {@code [Script Info]} section.
*
* <p>When this returns, {@code data.position} will be set to the beginning of the first line that
* starts with {@code [} (i.e. the title of the next section).
*
* @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition() position}
* set to the beginning of of the first line after {@code [Script Info]}.
*/
private void parseScriptInfo(ParsableByteArray data) {
String currentLine;
while ((currentLine = data.readLine()) != null
&& (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) {
String[] infoNameAndValue = currentLine.split(":");
if (infoNameAndValue.length != 2) {
continue;
}
switch (Util.toLowerInvariant(infoNameAndValue[0].trim())) {
case "playresx":
try {
screenWidth = Float.parseFloat(infoNameAndValue[1].trim());
} catch (NumberFormatException e) {
// Ignore invalid PlayResX value.
}
break;
case "playresy":
try {
screenHeight = Float.parseFloat(infoNameAndValue[1].trim());
} catch (NumberFormatException e) {
// Ignore invalid PlayResY value.
}
break;
}
}
}
/**
* Parse the {@code [V4+ Styles]} section.
*
* <p>When this returns, {@code data.position} will be set to the beginning of the first line that
* starts with {@code [} (i.e. the title of the next section).
*
* @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition()} pointing
* at the beginning of of the first line after {@code [V4+ Styles]}.
*/
private static Map<String, SsaStyle> parseStyles(ParsableByteArray data) {
SsaStyle.Format formatInfo = null;
Map<String, SsaStyle> styles = new LinkedHashMap<>();
String currentLine;
while ((currentLine = data.readLine()) != null
&& (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) {
if (currentLine.startsWith(FORMAT_LINE_PREFIX)) {
formatInfo = SsaStyle.Format.fromFormatLine(currentLine);
} else if (currentLine.startsWith(STYLE_LINE_PREFIX)) {
if (formatInfo == null) {
Log.w(TAG, "Skipping 'Style:' line before 'Format:' line: " + currentLine);
continue;
}
SsaStyle style = SsaStyle.fromStyleLine(currentLine, formatInfo);
if (style != null) {
styles.put(style.name, style);
}
}
}
return styles;
}
/**
* Parses the event body of the subtitle.
*
* @param data A {@link ParsableByteArray} from which the body should be read.
* @param cues A list to which parsed cues will be added.
* @param cueTimesUs An array to which parsed cue timestamps will be added.
* @param cueTimesUs A sorted list to which parsed cue timestamps will be added.
*/
private void parseEventBody(ParsableByteArray data, List<Cue> cues, LongArray cueTimesUs) {
private void parseEventBody(ParsableByteArray data, List<List<Cue>> cues, List<Long> cueTimesUs) {
SsaDialogueFormat format = haveInitializationData ? dialogueFormatFromInitializationData : null;
String currentLine;
while ((currentLine = data.readLine()) != null) {
if (!haveInitializationData && currentLine.startsWith(FORMAT_LINE_PREFIX)) {
parseFormatLine(currentLine);
if (currentLine.startsWith(FORMAT_LINE_PREFIX)) {
format = SsaDialogueFormat.fromFormatLine(currentLine);
} else if (currentLine.startsWith(DIALOGUE_LINE_PREFIX)) {
parseDialogueLine(currentLine, cues, cueTimesUs);
if (format == null) {
Log.w(TAG, "Skipping dialogue line before complete format: " + currentLine);
continue;
}
parseDialogueLine(currentLine, format, cues, cueTimesUs);
}
}
}
/**
* Parses a format line.
*
* @param formatLine The line to parse.
*/
private void parseFormatLine(String formatLine) {
String[] values = TextUtils.split(formatLine.substring(FORMAT_LINE_PREFIX.length()), ",");
formatKeyCount = values.length;
formatStartIndex = C.INDEX_UNSET;
formatEndIndex = C.INDEX_UNSET;
formatTextIndex = C.INDEX_UNSET;
for (int i = 0; i < formatKeyCount; i++) {
String key = Util.toLowerInvariant(values[i].trim());
switch (key) {
case "start":
formatStartIndex = i;
break;
case "end":
formatEndIndex = i;
break;
case "text":
formatTextIndex = i;
break;
default:
// Do nothing.
break;
}
}
if (formatStartIndex == C.INDEX_UNSET
|| formatEndIndex == C.INDEX_UNSET
|| formatTextIndex == C.INDEX_UNSET) {
// Set to 0 so that parseDialogueLine skips lines until a complete format line is found.
formatKeyCount = 0;
}
}
/**
* Parses a dialogue line.
*
* @param dialogueLine The line to parse.
* @param dialogueLine The dialogue values (i.e. everything after {@code Dialogue:}).
* @param format The dialogue format to use when parsing {@code dialogueLine}.
* @param cues A list to which parsed cues will be added.
* @param cueTimesUs An array to which parsed cue timestamps will be added.
* @param cueTimesUs A sorted list to which parsed cue timestamps will be added.
*/
private void parseDialogueLine(String dialogueLine, List<Cue> cues, LongArray cueTimesUs) {
if (formatKeyCount == 0) {
Log.w(TAG, "Skipping dialogue line before complete format: " + dialogueLine);
return;
}
String[] lineValues = dialogueLine.substring(DIALOGUE_LINE_PREFIX.length())
.split(",", formatKeyCount);
if (lineValues.length != formatKeyCount) {
private void parseDialogueLine(
String dialogueLine, SsaDialogueFormat format, List<List<Cue>> cues, List<Long> cueTimesUs) {
Assertions.checkArgument(dialogueLine.startsWith(DIALOGUE_LINE_PREFIX));
String[] lineValues =
dialogueLine.substring(DIALOGUE_LINE_PREFIX.length()).split(",", format.length);
if (lineValues.length != format.length) {
Log.w(TAG, "Skipping dialogue line with fewer columns than format: " + dialogueLine);
return;
}
long startTimeUs = parseTimecodeUs(lineValues[formatStartIndex]);
long startTimeUs = parseTimecodeUs(lineValues[format.startTimeIndex]);
if (startTimeUs == C.TIME_UNSET) {
Log.w(TAG, "Skipping invalid timing: " + dialogueLine);
return;
}
long endTimeUs = parseTimecodeUs(lineValues[formatEndIndex]);
long endTimeUs = parseTimecodeUs(lineValues[format.endTimeIndex]);
if (endTimeUs == C.TIME_UNSET) {
Log.w(TAG, "Skipping invalid timing: " + dialogueLine);
return;
}
SsaStyle style =
styles != null && format.styleIndex != C.INDEX_UNSET
? styles.get(lineValues[format.styleIndex].trim())
: null;
String rawText = lineValues[format.textIndex];
SsaStyle.Overrides styleOverrides = SsaStyle.Overrides.parseFromDialogue(rawText);
String text =
lineValues[formatTextIndex]
.replaceAll("\\{.*?\\}", "") // Warning that \\} can be replaced with } is bogus.
SsaStyle.Overrides.stripStyleOverrides(rawText)
.replaceAll("\\\\N", "\n")
.replaceAll("\\\\n", "\n");
cues.add(new Cue(text));
cueTimesUs.add(startTimeUs);
cues.add(Cue.EMPTY);
cueTimesUs.add(endTimeUs);
Cue cue = createCue(text, style, styleOverrides, screenWidth, screenHeight);
int startTimeIndex = addCuePlacerholderByTime(startTimeUs, cueTimesUs, cues);
int endTimeIndex = addCuePlacerholderByTime(endTimeUs, cueTimesUs, cues);
// Iterate on cues from startTimeIndex until endTimeIndex, adding the current cue.
for (int i = startTimeIndex; i < endTimeIndex; i++) {
cues.get(i).add(cue);
}
}
/**
@ -209,8 +276,8 @@ public final class SsaDecoder extends SimpleSubtitleDecoder {
* @param timeString The string to parse.
* @return The parsed timestamp in microseconds.
*/
public static long parseTimecodeUs(String timeString) {
Matcher matcher = SSA_TIMECODE_PATTERN.matcher(timeString);
private static long parseTimecodeUs(String timeString) {
Matcher matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim());
if (!matcher.matches()) {
return C.TIME_UNSET;
}
@ -221,4 +288,154 @@ public final class SsaDecoder extends SimpleSubtitleDecoder {
return timestampUs;
}
private static Cue createCue(
String text,
@Nullable SsaStyle style,
SsaStyle.Overrides styleOverrides,
float screenWidth,
float screenHeight) {
@SsaStyle.SsaAlignment int alignment;
if (styleOverrides.alignment != SsaStyle.SsaAlignment.UNKNOWN) {
alignment = styleOverrides.alignment;
} else if (style != null) {
alignment = style.alignment;
} else {
alignment = SsaStyle.SsaAlignment.UNKNOWN;
}
@Cue.AnchorType int positionAnchor = toPositionAnchor(alignment);
@Cue.AnchorType int lineAnchor = toLineAnchor(alignment);
float position;
float line;
if (styleOverrides.position != null
&& screenHeight != Cue.DIMEN_UNSET
&& screenWidth != Cue.DIMEN_UNSET) {
position = styleOverrides.position.x / screenWidth;
line = styleOverrides.position.y / screenHeight;
} else {
// TODO: Read the MarginL, MarginR and MarginV values from the Style & Dialogue lines.
position = computeDefaultLineOrPosition(positionAnchor);
line = computeDefaultLineOrPosition(lineAnchor);
}
return new Cue(
text,
toTextAlignment(alignment),
line,
Cue.LINE_TYPE_FRACTION,
lineAnchor,
position,
positionAnchor,
/* size= */ Cue.DIMEN_UNSET);
}
@Nullable
private static Layout.Alignment toTextAlignment(@SsaStyle.SsaAlignment int alignment) {
switch (alignment) {
case SsaStyle.SsaAlignment.BOTTOM_LEFT:
case SsaStyle.SsaAlignment.MIDDLE_LEFT:
case SsaStyle.SsaAlignment.TOP_LEFT:
return Layout.Alignment.ALIGN_NORMAL;
case SsaStyle.SsaAlignment.BOTTOM_CENTER:
case SsaStyle.SsaAlignment.MIDDLE_CENTER:
case SsaStyle.SsaAlignment.TOP_CENTER:
return Layout.Alignment.ALIGN_CENTER;
case SsaStyle.SsaAlignment.BOTTOM_RIGHT:
case SsaStyle.SsaAlignment.MIDDLE_RIGHT:
case SsaStyle.SsaAlignment.TOP_RIGHT:
return Layout.Alignment.ALIGN_OPPOSITE;
case SsaStyle.SsaAlignment.UNKNOWN:
return null;
default:
Log.w(TAG, "Unknown alignment: " + alignment);
return null;
}
}
@Cue.AnchorType
private static int toLineAnchor(@SsaStyle.SsaAlignment int alignment) {
switch (alignment) {
case SsaStyle.SsaAlignment.BOTTOM_LEFT:
case SsaStyle.SsaAlignment.BOTTOM_CENTER:
case SsaStyle.SsaAlignment.BOTTOM_RIGHT:
return Cue.ANCHOR_TYPE_END;
case SsaStyle.SsaAlignment.MIDDLE_LEFT:
case SsaStyle.SsaAlignment.MIDDLE_CENTER:
case SsaStyle.SsaAlignment.MIDDLE_RIGHT:
return Cue.ANCHOR_TYPE_MIDDLE;
case SsaStyle.SsaAlignment.TOP_LEFT:
case SsaStyle.SsaAlignment.TOP_CENTER:
case SsaStyle.SsaAlignment.TOP_RIGHT:
return Cue.ANCHOR_TYPE_START;
case SsaStyle.SsaAlignment.UNKNOWN:
return Cue.TYPE_UNSET;
default:
Log.w(TAG, "Unknown alignment: " + alignment);
return Cue.TYPE_UNSET;
}
}
@Cue.AnchorType
private static int toPositionAnchor(@SsaStyle.SsaAlignment int alignment) {
switch (alignment) {
case SsaStyle.SsaAlignment.BOTTOM_LEFT:
case SsaStyle.SsaAlignment.MIDDLE_LEFT:
case SsaStyle.SsaAlignment.TOP_LEFT:
return Cue.ANCHOR_TYPE_START;
case SsaStyle.SsaAlignment.BOTTOM_CENTER:
case SsaStyle.SsaAlignment.MIDDLE_CENTER:
case SsaStyle.SsaAlignment.TOP_CENTER:
return Cue.ANCHOR_TYPE_MIDDLE;
case SsaStyle.SsaAlignment.BOTTOM_RIGHT:
case SsaStyle.SsaAlignment.MIDDLE_RIGHT:
case SsaStyle.SsaAlignment.TOP_RIGHT:
return Cue.ANCHOR_TYPE_END;
case SsaStyle.SsaAlignment.UNKNOWN:
return Cue.TYPE_UNSET;
default:
Log.w(TAG, "Unknown alignment: " + alignment);
return Cue.TYPE_UNSET;
}
}
private static float computeDefaultLineOrPosition(@Cue.AnchorType int anchor) {
switch (anchor) {
case Cue.ANCHOR_TYPE_START:
return DEFAULT_MARGIN;
case Cue.ANCHOR_TYPE_MIDDLE:
return 0.5f;
case Cue.ANCHOR_TYPE_END:
return 1.0f - DEFAULT_MARGIN;
case Cue.TYPE_UNSET:
default:
return Cue.DIMEN_UNSET;
}
}
/**
* Searches for {@code timeUs} in {@code sortedCueTimesUs}, inserting it if it's not found, and
* returns the index.
*
* <p>If it's inserted, we also insert a matching entry to {@code cues}.
*/
private static int addCuePlacerholderByTime(
long timeUs, List<Long> sortedCueTimesUs, List<List<Cue>> cues) {
int insertionIndex = 0;
for (int i = sortedCueTimesUs.size() - 1; i >= 0; i--) {
if (sortedCueTimesUs.get(i) == timeUs) {
return i;
}
if (sortedCueTimesUs.get(i) < timeUs) {
insertionIndex = i + 1;
break;
}
}
sortedCueTimesUs.add(insertionIndex, timeUs);
// Copy over cues from left, or use an empty list if we're inserting at the beginning.
cues.add(
insertionIndex,
insertionIndex == 0 ? new ArrayList<>() : new ArrayList<>(cues.get(insertionIndex - 1)));
return insertionIndex;
}
}

View File

@ -0,0 +1,83 @@
/*
* Copyright (C) 2019 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 com.google.android.exoplayer2.text.ssa;
import static com.google.android.exoplayer2.text.ssa.SsaDecoder.FORMAT_LINE_PREFIX;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Util;
/**
* Represents a {@code Format:} line from the {@code [Events]} section
*
* <p>The indices are used to determine the location of particular properties in each {@code
* Dialogue:} line.
*/
/* package */ final class SsaDialogueFormat {
public final int startTimeIndex;
public final int endTimeIndex;
public final int styleIndex;
public final int textIndex;
public final int length;
private SsaDialogueFormat(
int startTimeIndex, int endTimeIndex, int styleIndex, int textIndex, int length) {
this.startTimeIndex = startTimeIndex;
this.endTimeIndex = endTimeIndex;
this.styleIndex = styleIndex;
this.textIndex = textIndex;
this.length = length;
}
/**
* Parses the format info from a 'Format:' line in the [Events] section.
*
* @return the parsed info, or null if {@code formatLine} doesn't contain both 'start' and 'end'.
*/
@Nullable
public static SsaDialogueFormat fromFormatLine(String formatLine) {
int startTimeIndex = C.INDEX_UNSET;
int endTimeIndex = C.INDEX_UNSET;
int styleIndex = C.INDEX_UNSET;
int textIndex = C.INDEX_UNSET;
Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX));
String[] keys = TextUtils.split(formatLine.substring(FORMAT_LINE_PREFIX.length()), ",");
for (int i = 0; i < keys.length; i++) {
switch (Util.toLowerInvariant(keys[i].trim())) {
case "start":
startTimeIndex = i;
break;
case "end":
endTimeIndex = i;
break;
case "style":
styleIndex = i;
break;
case "text":
textIndex = i;
break;
}
}
return (startTimeIndex != C.INDEX_UNSET && endTimeIndex != C.INDEX_UNSET)
? new SsaDialogueFormat(startTimeIndex, endTimeIndex, styleIndex, textIndex, keys.length)
: null;
}
}

View File

@ -0,0 +1,284 @@
/*
* Copyright (C) 2019 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 com.google.android.exoplayer2.text.ssa;
import static com.google.android.exoplayer2.text.ssa.SsaDecoder.STYLE_LINE_PREFIX;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.graphics.PointF;
import android.text.TextUtils;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Represents a line from an SSA/ASS {@code [V4+ Styles]} section. */
/* package */ final class SsaStyle {
private static final String TAG = "SsaStyle";
public final String name;
@SsaAlignment public final int alignment;
private SsaStyle(String name, @SsaAlignment int alignment) {
this.name = name;
this.alignment = alignment;
}
@Nullable
public static SsaStyle fromStyleLine(String styleLine, Format format) {
Assertions.checkArgument(styleLine.startsWith(STYLE_LINE_PREFIX));
String[] styleValues = TextUtils.split(styleLine.substring(STYLE_LINE_PREFIX.length()), ",");
if (styleValues.length != format.length) {
Log.w(
TAG,
Util.formatInvariant(
"Skipping malformed 'Style:' line (expected %s values, found %s): '%s'",
format.length, styleValues.length, styleLine));
return null;
}
try {
return new SsaStyle(
styleValues[format.nameIndex].trim(), parseAlignment(styleValues[format.alignmentIndex]));
} catch (RuntimeException e) {
Log.w(TAG, "Skipping malformed 'Style:' line: '" + styleLine + "'", e);
return null;
}
}
@SsaAlignment
private static int parseAlignment(String alignmentStr) {
try {
@SsaAlignment int alignment = Integer.parseInt(alignmentStr.trim());
if (isValidAlignment(alignment)) {
return alignment;
}
} catch (NumberFormatException e) {
// Swallow the exception and return UNKNOWN below.
}
Log.w(TAG, "Ignoring unknown alignment: " + alignmentStr);
return SsaAlignment.UNKNOWN;
}
private static boolean isValidAlignment(@SsaAlignment int alignment) {
switch (alignment) {
case SsaAlignment.BOTTOM_CENTER:
case SsaAlignment.BOTTOM_LEFT:
case SsaAlignment.BOTTOM_RIGHT:
case SsaAlignment.MIDDLE_CENTER:
case SsaAlignment.MIDDLE_LEFT:
case SsaAlignment.MIDDLE_RIGHT:
case SsaAlignment.TOP_CENTER:
case SsaAlignment.TOP_LEFT:
case SsaAlignment.TOP_RIGHT:
return true;
case SsaAlignment.UNKNOWN:
default:
return false;
}
}
/**
* Represents a {@code Format:} line from the {@code [V4+ Styles]} section
*
* <p>The indices are used to determine the location of particular properties in each {@code
* Style:} line.
*/
/* package */ static final class Format {
public final int nameIndex;
public final int alignmentIndex;
public final int length;
private Format(int nameIndex, int alignmentIndex, int length) {
this.nameIndex = nameIndex;
this.alignmentIndex = alignmentIndex;
this.length = length;
}
/**
* Parses the format info from a 'Format:' line in the [V4+ Styles] section.
*
* @return the parsed info, or null if {@code styleFormatLine} doesn't contain 'name'.
*/
@Nullable
public static Format fromFormatLine(String styleFormatLine) {
int nameIndex = C.INDEX_UNSET;
int alignmentIndex = C.INDEX_UNSET;
String[] keys =
TextUtils.split(styleFormatLine.substring(SsaDecoder.FORMAT_LINE_PREFIX.length()), ",");
for (int i = 0; i < keys.length; i++) {
switch (Util.toLowerInvariant(keys[i].trim())) {
case "name":
nameIndex = i;
break;
case "alignment":
alignmentIndex = i;
break;
}
}
return nameIndex != C.INDEX_UNSET ? new Format(nameIndex, alignmentIndex, keys.length) : null;
}
}
/**
* Represents the style override information parsed from an SSA/ASS dialogue line.
*
* <p>Overrides are contained in braces embedded in the dialogue text of the cue.
*/
/* package */ static final class Overrides {
private static final String TAG = "SsaStyle.Overrides";
/** Matches "{foo}" and returns "foo" in group 1 */
// Warning that \\} can be replaced with } is bogus [internal: b/144480183].
private static final Pattern BRACES_PATTERN = Pattern.compile("\\{([^}]*)\\}");
private static final String PADDED_DECIMAL_PATTERN = "\\s*\\d+(?:\\.\\d+)?\\s*";
/** Matches "\pos(x,y)" and returns "x" in group 1 and "y" in group 2 */
private static final Pattern POSITION_PATTERN =
Pattern.compile(Util.formatInvariant("\\\\pos\\((%1$s),(%1$s)\\)", PADDED_DECIMAL_PATTERN));
/** Matches "\move(x1,y1,x2,y2[,t1,t2])" and returns "x2" in group 1 and "y2" in group 2 */
private static final Pattern MOVE_PATTERN =
Pattern.compile(
Util.formatInvariant(
"\\\\move\\(%1$s,%1$s,(%1$s),(%1$s)(?:,%1$s,%1$s)?\\)", PADDED_DECIMAL_PATTERN));
/** Matches "\anx" and returns x in group 1 */
private static final Pattern ALIGNMENT_OVERRIDE_PATTERN = Pattern.compile("\\\\an(\\d+)");
@SsaAlignment public final int alignment;
@Nullable public final PointF position;
private Overrides(@SsaAlignment int alignment, @Nullable PointF position) {
this.alignment = alignment;
this.position = position;
}
public static Overrides parseFromDialogue(String text) {
@SsaAlignment int alignment = SsaAlignment.UNKNOWN;
PointF position = null;
Matcher matcher = BRACES_PATTERN.matcher(text);
while (matcher.find()) {
String braceContents = matcher.group(1);
try {
PointF parsedPosition = parsePosition(braceContents);
if (parsedPosition != null) {
position = parsedPosition;
}
} catch (RuntimeException e) {
// Ignore invalid \pos() or \move() function.
}
try {
@SsaAlignment int parsedAlignment = parseAlignmentOverride(braceContents);
if (parsedAlignment != SsaAlignment.UNKNOWN) {
alignment = parsedAlignment;
}
} catch (RuntimeException e) {
// Ignore invalid \an alignment override.
}
}
return new Overrides(alignment, position);
}
public static String stripStyleOverrides(String dialogueLine) {
return BRACES_PATTERN.matcher(dialogueLine).replaceAll("");
}
/**
* Parses the position from a style override, returns null if no position is found.
*
* <p>The attribute is expected to be in the form {@code \pos(x,y)} or {@code
* \move(x1,y1,x2,y2,startTime,endTime)} (startTime and endTime are optional). In the case of
* {@code \move()}, this returns {@code (x2, y2)} (i.e. the end position of the move).
*
* @param styleOverride The string to parse.
* @return The parsed position, or null if no position is found.
*/
@Nullable
private static PointF parsePosition(String styleOverride) {
Matcher positionMatcher = POSITION_PATTERN.matcher(styleOverride);
Matcher moveMatcher = MOVE_PATTERN.matcher(styleOverride);
boolean hasPosition = positionMatcher.find();
boolean hasMove = moveMatcher.find();
String x;
String y;
if (hasPosition) {
if (hasMove) {
Log.i(
TAG,
"Override has both \\pos(x,y) and \\move(x1,y1,x2,y2); using \\pos values. override='"
+ styleOverride
+ "'");
}
x = positionMatcher.group(1);
y = positionMatcher.group(2);
} else if (hasMove) {
x = moveMatcher.group(1);
y = moveMatcher.group(2);
} else {
return null;
}
return new PointF(
Float.parseFloat(Assertions.checkNotNull(x).trim()),
Float.parseFloat(Assertions.checkNotNull(y).trim()));
}
@SsaAlignment
private static int parseAlignmentOverride(String braceContents) {
Matcher matcher = ALIGNMENT_OVERRIDE_PATTERN.matcher(braceContents);
return matcher.find() ? parseAlignment(matcher.group(1)) : SsaAlignment.UNKNOWN;
}
}
/** The SSA/ASS alignments. */
@IntDef({
SsaAlignment.UNKNOWN,
SsaAlignment.BOTTOM_LEFT,
SsaAlignment.BOTTOM_CENTER,
SsaAlignment.BOTTOM_RIGHT,
SsaAlignment.MIDDLE_LEFT,
SsaAlignment.MIDDLE_CENTER,
SsaAlignment.MIDDLE_RIGHT,
SsaAlignment.TOP_LEFT,
SsaAlignment.TOP_CENTER,
SsaAlignment.TOP_RIGHT,
})
@Documented
@Retention(SOURCE)
/* package */ @interface SsaAlignment {
// The numbering follows the ASS (v4+) spec (i.e. the points on the number pad).
int UNKNOWN = -1;
int BOTTOM_LEFT = 1;
int BOTTOM_CENTER = 2;
int BOTTOM_RIGHT = 3;
int MIDDLE_LEFT = 4;
int MIDDLE_CENTER = 5;
int MIDDLE_RIGHT = 6;
int TOP_LEFT = 7;
int TOP_CENTER = 8;
int TOP_RIGHT = 9;
}
}

View File

@ -28,14 +28,14 @@ import java.util.List;
*/
/* package */ final class SsaSubtitle implements Subtitle {
private final Cue[] cues;
private final long[] cueTimesUs;
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(Cue[] cues, long[] cueTimesUs) {
public SsaSubtitle(List<List<Cue>> cues, List<Long> cueTimesUs) {
this.cues = cues;
this.cueTimesUs = cueTimesUs;
}
@ -43,30 +43,29 @@ import java.util.List;
@Override
public int getNextEventTimeIndex(long timeUs) {
int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false);
return index < cueTimesUs.length ? index : C.INDEX_UNSET;
return index < cueTimesUs.size() ? index : C.INDEX_UNSET;
}
@Override
public int getEventTimeCount() {
return cueTimesUs.length;
return cueTimesUs.size();
}
@Override
public long getEventTime(int index) {
Assertions.checkArgument(index >= 0);
Assertions.checkArgument(index < cueTimesUs.length);
return cueTimesUs[index];
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 || cues[index] == Cue.EMPTY) {
// timeUs is earlier than the start of the first cue, or we have an empty cue.
if (index == -1) {
// timeUs is earlier than the start of the first cue.
return Collections.emptyList();
} else {
return Collections.singletonList(cues[index]);
return cues.get(index);
}
}
}

View File

@ -0,0 +1,16 @@
[Script Info]
Title: SomeTitle
PlayResX: 300
PlayResY: 200
[V4+ Styles]
! Alignment is set to 4 - i.e. middle-left
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,-1,0,0,0,100,100,0,0,1,1.7,0,4,0,0,28,1
[Events]
Format: Layer, Start, End, Style, Name, Text
Dialogue: 0,0:00:00.00,0:00:01.23,Default,Olly,{\pos(-5,50)}First subtitle (negative \pos()).
Dialogue: 0,0:00:02.34,0:00:03.45,Default,Olly,{\move(-5,50,-5,50)}Second subtitle (negative \move()).
Dialogue: 0,0:00:04:56,0:00:08:90,Default,Olly,{\an11}Third subtitle (invalid alignment).
Dialogue: 0,0:00:09:56,0:00:12:90,Default,Olly,\pos(150,100) Fourth subtitle (no braces).

View File

@ -0,0 +1,12 @@
[Script Info]
Title: SomeTitle
[Events]
Format: Start, End, Text
Dialogue: 0:00:01.00,0:00:04.23,First subtitle - end overlaps second
Dialogue: 0:00:02.00,0:00:05.23,Second subtitle - beginning overlaps first
Dialogue: 0:00:08.44,0:00:09.44,Fourth subtitle - same timings as fifth
Dialogue: 0:00:06.00,0:00:08.44,Third subtitle - out of order
Dialogue: 0:00:08.44,0:00:09.44,Fifth subtitle - same timings as fourth
Dialogue: 0:00:10.72,0:00:15.65,Sixth subtitle - fully encompasses seventh
Dialogue: 0:00:13.22,0:00:14.22,Seventh subtitle - nested fully inside sixth

View File

@ -0,0 +1,18 @@
[Script Info]
Title: SomeTitle
PlayResX: 300
PlayResY: 202
[V4+ Styles]
! Alignment is set to 4 - i.e. middle-left
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,-1,0,0,0,100,100,0,0,1,1.7,0,4,0,0,28,1
[Events]
Format: Layer, Start, End, Style, Name, Text
Dialogue: 0,0:00:00.00,0:00:01.23,Default,Olly,{\pos(150,50.5)}First subtitle.
Dialogue: 0,0:00:02.34,0:00:03.45,Default,Olly,Second subtitle{\pos(75,50.5)}.
Dialogue: 0,0:00:04:56,0:00:08:90,Default,Olly,{\pos(150,100)}Third subtitle{\pos(75,101)}, (only last counts).
Dialogue: 0,0:00:09:56,0:00:12:90,Default,Olly,{\move(150,100,150,50.5)}Fourth subtitle.
Dialogue: 0,0:00:13:56,0:00:15:90,Default,Olly,{ \pos( 150, 101 ) }Fifth subtitle {\an2}(alignment override, spaces around pos arguments).
Dialogue: 0,0:00:16:56,0:00:19:90,Default,Olly,{\pos(150,101)\an9}Sixth subtitle (multiple overrides in same braces).

View File

@ -0,0 +1,7 @@
[Script Info]
Title: SomeTitle
PlayResX: 300
[Events]
Format: Layer, Start, End, Style, Name, Text
Dialogue: 0,0:00:00.00,0:00:01.23,Default,Olly,{\pos(150,50)}First subtitle.

View File

@ -7,6 +7,6 @@ Style: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000
[Events]
Format: Layer, Start, End, Style, Name, Text
Dialogue: 0,0:00:00.00,0:00:01.23,Default,Olly,This is the first subtitle{ignored}.
Dialogue: 0,0:00:02.34,0:00:03.45,Default,Olly,This is the second subtitle \nwith a newline \Nand another.
Dialogue: 0,0:00:04:56,0:00:08:90,Default,Olly,This is the third subtitle, with a comma.
Dialogue: 0,0:00:00.00,0:00:01.23,Default ,Olly,This is the first subtitle{ignored}.
Dialogue: 0,0:00:02.34,0:00:03.45,Default ,Olly,This is the second subtitle \nwith a newline \Nand another.
Dialogue: 0,0:00:04:56,0:00:08:90,Default ,Olly,This is the third subtitle, with a comma.

View File

@ -16,11 +16,15 @@
package com.google.android.exoplayer2.text.ssa;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import android.text.Layout;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.testutil.TestUtil;
import com.google.android.exoplayer2.text.Cue;
import com.google.android.exoplayer2.text.Subtitle;
import com.google.common.collect.Iterables;
import java.io.IOException;
import java.util.ArrayList;
import org.junit.Test;
@ -35,7 +39,11 @@ public final class SsaDecoderTest {
private static final String TYPICAL_HEADER_ONLY = "ssa/typical_header";
private static final String TYPICAL_DIALOGUE_ONLY = "ssa/typical_dialogue";
private static final String TYPICAL_FORMAT_ONLY = "ssa/typical_format";
private static final String OVERLAPPING_TIMECODES = "ssa/overlapping_timecodes";
private static final String POSITIONS = "ssa/positioning";
private static final String INVALID_TIMECODES = "ssa/invalid_timecodes";
private static final String INVALID_POSITIONS = "ssa/invalid_positioning";
private static final String POSITIONS_WITHOUT_PLAYRES = "ssa/positioning_without_playres";
@Test
public void testDecodeEmpty() throws IOException {
@ -54,6 +62,19 @@ public final class SsaDecoderTest {
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
assertThat(subtitle.getEventTimeCount()).isEqualTo(6);
// Check position, line, anchors & alignment are set from Alignment Style (2 - bottom-center).
Cue firstCue = subtitle.getCues(subtitle.getEventTime(0)).get(0);
assertWithMessage("Cue.textAlignment")
.that(firstCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_CENTER);
assertWithMessage("Cue.positionAnchor")
.that(firstCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertWithMessage("Cue.position").that(firstCue.position).isEqualTo(0.5f);
assertWithMessage("Cue.lineAnchor").that(firstCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_END);
assertWithMessage("Cue.lineType").that(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertWithMessage("Cue.line").that(firstCue.line).isEqualTo(0.95f);
assertTypicalCue1(subtitle, 0);
assertTypicalCue2(subtitle, 2);
assertTypicalCue3(subtitle, 4);
@ -79,6 +100,161 @@ public final class SsaDecoderTest {
assertTypicalCue3(subtitle, 4);
}
@Test
public void testDecodeOverlappingTimecodes() throws IOException {
SsaDecoder decoder = new SsaDecoder();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), OVERLAPPING_TIMECODES);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
assertThat(subtitle.getEventTime(0)).isEqualTo(1_000_000);
assertThat(subtitle.getEventTime(1)).isEqualTo(2_000_000);
assertThat(subtitle.getEventTime(2)).isEqualTo(4_230_000);
assertThat(subtitle.getEventTime(3)).isEqualTo(5_230_000);
assertThat(subtitle.getEventTime(4)).isEqualTo(6_000_000);
assertThat(subtitle.getEventTime(5)).isEqualTo(8_440_000);
assertThat(subtitle.getEventTime(6)).isEqualTo(9_440_000);
assertThat(subtitle.getEventTime(7)).isEqualTo(10_720_000);
assertThat(subtitle.getEventTime(8)).isEqualTo(13_220_000);
assertThat(subtitle.getEventTime(9)).isEqualTo(14_220_000);
assertThat(subtitle.getEventTime(10)).isEqualTo(15_650_000);
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(Iterables.transform(subtitle.getCues(1_000_010), cue -> cue.text.toString()))
.containsExactly(firstSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(2_000_010), cue -> cue.text.toString()))
.containsExactly(firstSubtitleText, secondSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(4_230_010), cue -> cue.text.toString()))
.containsExactly(secondSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(5_230_010), cue -> cue.text.toString()))
.isEmpty();
assertThat(Iterables.transform(subtitle.getCues(6_000_010), cue -> cue.text.toString()))
.containsExactly(thirdSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(8_440_010), cue -> cue.text.toString()))
.containsExactly(fourthSubtitleText, fifthSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(9_440_010), cue -> cue.text.toString()))
.isEmpty();
assertThat(Iterables.transform(subtitle.getCues(10_720_010), cue -> cue.text.toString()))
.containsExactly(sixthSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(13_220_010), cue -> cue.text.toString()))
.containsExactly(sixthSubtitleText, seventhSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(14_220_010), cue -> cue.text.toString()))
.containsExactly(sixthSubtitleText);
assertThat(Iterables.transform(subtitle.getCues(15_650_010), cue -> cue.text.toString()))
.isEmpty();
}
@Test
public void testDecodePositions() throws IOException {
SsaDecoder decoder = new SsaDecoder();
byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), POSITIONS);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
// Check \pos() sets position & line
Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0)));
assertWithMessage("Cue.position").that(firstCue.position).isEqualTo(0.5f);
assertWithMessage("Cue.lineType").that(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertWithMessage("Cue.line").that(firstCue.line).isEqualTo(0.25f);
// Check the \pos() doesn't need to be at the start of the line.
Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2)));
assertWithMessage("Cue.position").that(secondCue.position).isEqualTo(0.25f);
assertWithMessage("Cue.line").that(secondCue.line).isEqualTo(0.25f);
// Check only the last \pos() value is used.
Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4)));
assertWithMessage("Cue.position").that(thirdCue.position).isEqualTo(0.25f);
// Check \move() is treated as \pos()
Cue fourthCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6)));
assertWithMessage("Cue.position").that(fourthCue.position).isEqualTo(0.5f);
assertWithMessage("Cue.line").that(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(subtitle.getCues(subtitle.getEventTime(8)));
assertWithMessage("Cue.position").that(fifthCue.position).isEqualTo(0.5f);
assertWithMessage("Cue.line").that(fifthCue.line).isEqualTo(0.5f);
assertWithMessage("Cue.positionAnchor")
.that(fifthCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_MIDDLE);
assertWithMessage("Cue.lineAnchor").that(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(subtitle.getCues(subtitle.getEventTime(10)));
assertWithMessage("Cue.position").that(sixthCue.position).isEqualTo(0.5f);
assertWithMessage("Cue.line").that(sixthCue.line).isEqualTo(0.5f);
assertWithMessage("Cue.positionAnchor")
.that(sixthCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_END);
assertWithMessage("Cue.lineAnchor").that(sixthCue.lineAnchor).isEqualTo(Cue.ANCHOR_TYPE_START);
assertWithMessage("Cue.textAlignment")
.that(sixthCue.textAlignment)
.isEqualTo(Layout.Alignment.ALIGN_OPPOSITE);
}
@Test
public void testDecodeInvalidPositions() throws IOException {
SsaDecoder decoder = new SsaDecoder();
byte[] bytes =
TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), INVALID_POSITIONS);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
// Negative parameter to \pos() - fall back to the positions implied by middle-left alignment.
Cue firstCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0)));
assertWithMessage("Cue.position").that(firstCue.position).isEqualTo(0.05f);
assertWithMessage("Cue.lineType").that(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertWithMessage("Cue.line").that(firstCue.line).isEqualTo(0.5f);
// Negative parameter to \move() - fall back to the positions implied by middle-left alignment.
Cue secondCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2)));
assertWithMessage("Cue.position").that(secondCue.position).isEqualTo(0.05f);
assertWithMessage("Cue.lineType").that(secondCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertWithMessage("Cue.line").that(secondCue.line).isEqualTo(0.5f);
// Check invalid alignment override (11) is skipped and style-provided one is used (4).
Cue thirdCue = Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4)));
assertWithMessage("Cue.positionAnchor")
.that(thirdCue.positionAnchor)
.isEqualTo(Cue.ANCHOR_TYPE_START);
assertWithMessage("Cue.lineAnchor").that(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(subtitle.getCues(subtitle.getEventTime(6)));
assertWithMessage("Cue.position").that(fourthCue.position).isEqualTo(0.05f);
assertWithMessage("Cue.lineType").that(fourthCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertWithMessage("Cue.line").that(fourthCue.line).isEqualTo(0.5f);
}
@Test
public void testDecodePositionsWithMissingPlayResY() throws IOException {
SsaDecoder decoder = new SsaDecoder();
byte[] bytes =
TestUtil.getByteArray(
ApplicationProvider.getApplicationContext(), POSITIONS_WITHOUT_PLAYRES);
Subtitle subtitle = decoder.decode(bytes, bytes.length, false);
// 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(subtitle.getCues(subtitle.getEventTime(0)));
assertWithMessage("Cue.position").that(firstCue.position).isEqualTo(Cue.DIMEN_UNSET);
assertWithMessage("Cue.lineType").that(firstCue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION);
assertWithMessage("Cue.line").that(firstCue.line).isEqualTo(Cue.DIMEN_UNSET);
}
@Test
public void testDecodeInvalidTimecodes() throws IOException {
// Parsing should succeed, parsing the third cue only.