diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa')
5 files changed, 920 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java new file mode 100644 index 0000000000..8f878a998e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -0,0 +1,446 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.text.Layout; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.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. */ +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+)"); + + /* 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 @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 and 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. 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)); + 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) { + 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); + return new SsaSubtitle(cues, cueTimesUs); + } + + /** + * Parses the header of the subtitle. + * + * @param data A {@link ParsableByteArray} from which the header should be read. + */ + private void parseHeader(ParsableByteArray data) { + @Nullable String currentLine; + while ((currentLine = data.readLine()) != null) { + 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) { + @Nullable 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) { + Map<String, SsaStyle> styles = new LinkedHashMap<>(); + @Nullable SsaStyle.Format formatInfo = null; + @Nullable 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; + } + @Nullable 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 A sorted list to which parsed cue timestamps will be added. + */ + private void parseEventBody(ParsableByteArray data, List<List<Cue>> cues, List<Long> cueTimesUs) { + @Nullable + SsaDialogueFormat format = haveInitializationData ? dialogueFormatFromInitializationData : null; + @Nullable String currentLine; + while ((currentLine = data.readLine()) != null) { + if (currentLine.startsWith(FORMAT_LINE_PREFIX)) { + format = SsaDialogueFormat.fromFormatLine(currentLine); + } else if (currentLine.startsWith(DIALOGUE_LINE_PREFIX)) { + if (format == null) { + Log.w(TAG, "Skipping dialogue line before complete format: " + currentLine); + continue; + } + parseDialogueLine(currentLine, format, cues, cueTimesUs); + } + } + } + + /** + * Parses a dialogue line. + * + * @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 A sorted list to which parsed cue timestamps will be added. + */ + 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[format.startTimeIndex]); + if (startTimeUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping invalid timing: " + dialogueLine); + return; + } + + long endTimeUs = parseTimecodeUs(lineValues[format.endTimeIndex]); + if (endTimeUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping invalid timing: " + dialogueLine); + return; + } + + @Nullable + 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 = + SsaStyle.Overrides.stripStyleOverrides(rawText) + .replaceAll("\\\\N", "\n") + .replaceAll("\\\\n", "\n"); + 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); + } + } + + /** + * Parses an SSA timecode string. + * + * @param timeString The string to parse. + * @return The parsed timestamp in microseconds. + */ + private static long parseTimecodeUs(String timeString) { + Matcher matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim()); + if (!matcher.matches()) { + return C.TIME_UNSET; + } + long timestampUs = + Long.parseLong(castNonNull(matcher.group(1))) * 60 * 60 * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(castNonNull(matcher.group(2))) * 60 * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(castNonNull(matcher.group(3))) * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(castNonNull(matcher.group(4))) * 10000; // 100ths of a second. + 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.SSA_ALIGNMENT_UNKNOWN) { + alignment = styleOverrides.alignment; + } else if (style != null) { + alignment = style.alignment; + } else { + alignment = SsaStyle.SSA_ALIGNMENT_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.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + return Layout.Alignment.ALIGN_NORMAL; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + return Layout.Alignment.ALIGN_CENTER; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: + return Layout.Alignment.ALIGN_OPPOSITE; + case SsaStyle.SSA_ALIGNMENT_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.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + return Cue.ANCHOR_TYPE_END; + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + return Cue.ANCHOR_TYPE_MIDDLE; + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: + return Cue.ANCHOR_TYPE_START; + case SsaStyle.SSA_ALIGNMENT_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.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + return Cue.ANCHOR_TYPE_START; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + return Cue.ANCHOR_TYPE_MIDDLE; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: + return Cue.ANCHOR_TYPE_END; + case SsaStyle.SSA_ALIGNMENT_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; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java new file mode 100644 index 0000000000..312c779e23 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java @@ -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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder.FORMAT_LINE_PREFIX; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.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; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java new file mode 100644 index 0000000000..3c3639a3fb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java @@ -0,0 +1,301 @@ +/* + * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import static org.mozilla.thirdparty.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 org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.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"; + + /** + * The SSA/ASS alignments. + * + * <p>Allowed values: + * + * <ul> + * <li>{@link #SSA_ALIGNMENT_UNKNOWN} + * <li>{@link #SSA_ALIGNMENT_BOTTOM_LEFT} + * <li>{@link #SSA_ALIGNMENT_BOTTOM_CENTER} + * <li>{@link #SSA_ALIGNMENT_BOTTOM_RIGHT} + * <li>{@link #SSA_ALIGNMENT_MIDDLE_LEFT} + * <li>{@link #SSA_ALIGNMENT_MIDDLE_CENTER} + * <li>{@link #SSA_ALIGNMENT_MIDDLE_RIGHT} + * <li>{@link #SSA_ALIGNMENT_TOP_LEFT} + * <li>{@link #SSA_ALIGNMENT_TOP_CENTER} + * <li>{@link #SSA_ALIGNMENT_TOP_RIGHT} + * </ul> + */ + @IntDef({ + SSA_ALIGNMENT_UNKNOWN, + SSA_ALIGNMENT_BOTTOM_LEFT, + SSA_ALIGNMENT_BOTTOM_CENTER, + SSA_ALIGNMENT_BOTTOM_RIGHT, + SSA_ALIGNMENT_MIDDLE_LEFT, + SSA_ALIGNMENT_MIDDLE_CENTER, + SSA_ALIGNMENT_MIDDLE_RIGHT, + SSA_ALIGNMENT_TOP_LEFT, + SSA_ALIGNMENT_TOP_CENTER, + SSA_ALIGNMENT_TOP_RIGHT, + }) + @Documented + @Retention(SOURCE) + public @interface SsaAlignment {} + + // The numbering follows the ASS (v4+) spec (i.e. the points on the number pad). + public static final int SSA_ALIGNMENT_UNKNOWN = -1; + public static final int SSA_ALIGNMENT_BOTTOM_LEFT = 1; + public static final int SSA_ALIGNMENT_BOTTOM_CENTER = 2; + public static final int SSA_ALIGNMENT_BOTTOM_RIGHT = 3; + public static final int SSA_ALIGNMENT_MIDDLE_LEFT = 4; + public static final int SSA_ALIGNMENT_MIDDLE_CENTER = 5; + public static final int SSA_ALIGNMENT_MIDDLE_RIGHT = 6; + public static final int SSA_ALIGNMENT_TOP_LEFT = 7; + public static final int SSA_ALIGNMENT_TOP_CENTER = 8; + public static final int SSA_ALIGNMENT_TOP_RIGHT = 9; + + 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 SSA_ALIGNMENT_UNKNOWN; + } + + private static boolean isValidAlignment(@SsaAlignment int alignment) { + switch (alignment) { + case SSA_ALIGNMENT_BOTTOM_CENTER: + case SSA_ALIGNMENT_BOTTOM_LEFT: + case SSA_ALIGNMENT_BOTTOM_RIGHT: + case SSA_ALIGNMENT_MIDDLE_CENTER: + case SSA_ALIGNMENT_MIDDLE_LEFT: + case SSA_ALIGNMENT_MIDDLE_RIGHT: + case SSA_ALIGNMENT_TOP_CENTER: + case SSA_ALIGNMENT_TOP_LEFT: + case SSA_ALIGNMENT_TOP_RIGHT: + return true; + case SSA_ALIGNMENT_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 = SSA_ALIGNMENT_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 != SSA_ALIGNMENT_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)) : SSA_ALIGNMENT_UNKNOWN; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java new file mode 100644 index 0000000000..fb0544156d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Collections; +import java.util.List; + +/** + * A representation of an SSA/ASS subtitle. + */ +/* package */ final class SsaSubtitle implements Subtitle { + + private final List<List<Cue>> cues; + private final List<Long> cueTimesUs; + + /** + * @param cues The cues in the subtitle. + * @param cueTimesUs The cue times, in microseconds. + */ + public SsaSubtitle(List<List<Cue>> cues, List<Long> cueTimesUs) { + this.cues = cues; + this.cueTimesUs = cueTimesUs; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false); + return index < cueTimesUs.size() ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return cueTimesUs.size(); + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < cueTimesUs.size()); + return cueTimesUs.get(index); + } + + @Override + public List<Cue> getCues(long timeUs) { + int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); + if (index == -1) { + // timeUs is earlier than the start of the first cue. + return Collections.emptyList(); + } else { + return cues.get(index); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java new file mode 100644 index 0000000000..bc4b625d77 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; |