Merge pull request #6595 from szaboa:dev-v2-ssa-position
PiperOrigin-RevId: 283722376
This commit is contained in:
commit
8494c3aeea
@ -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) ###
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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')
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
16
library/core/src/test/assets/ssa/invalid_positioning
Normal file
16
library/core/src/test/assets/ssa/invalid_positioning
Normal 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).
|
12
library/core/src/test/assets/ssa/overlapping_timecodes
Normal file
12
library/core/src/test/assets/ssa/overlapping_timecodes
Normal 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
|
18
library/core/src/test/assets/ssa/positioning
Normal file
18
library/core/src/test/assets/ssa/positioning
Normal 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).
|
@ -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.
|
@ -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.
|
||||
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user