diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 7c5bb11752..0f6e32f856 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -5,6 +5,9 @@ * Extractors: * Fix Vorbis private codec data parsing in the Matroska extractor ([#8496](https://github.com/google/ExoPlayer/issues/8496)). +* Text: + * Add support for the SSA `primaryColour` style attribute + ([#8435](https://github.com/google/ExoPlayer/issues/8435)). ### 2.13.0 (not yet released - targeted for 2021-02-TBD) diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java index f44db4924f..b8e047dbcb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -19,6 +19,8 @@ import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_FRACTION; import static com.google.android.exoplayer2.util.Util.castNonNull; import android.text.Layout; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.text.Cue; @@ -301,7 +303,18 @@ public final class SsaDecoder extends SimpleSubtitleDecoder { SsaStyle.Overrides styleOverrides, float screenWidth, float screenHeight) { - Cue.Builder cue = new Cue.Builder().setText(text); + SpannableString spannableText = new SpannableString(text); + Cue.Builder cue = new Cue.Builder().setText(spannableText); + + if (style != null) { + if (style.primaryColor != null) { + spannableText.setSpan( + new ForegroundColorSpan(style.primaryColor), + /* start= */ 0, + /* end= */ spannableText.length(), + SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } @SsaStyle.SsaAlignment int alignment; if (styleOverrides.alignment != SsaStyle.SSA_ALIGNMENT_UNKNOWN) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java index 0cba339034..bd378cccec 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaStyle.java @@ -17,16 +17,20 @@ package com.google.android.exoplayer2.text.ssa; import static com.google.android.exoplayer2.text.ssa.SsaDecoder.STYLE_LINE_PREFIX; +import static com.google.android.exoplayer2.util.Assertions.checkArgument; import static java.lang.annotation.RetentionPolicy.SOURCE; +import android.graphics.Color; import android.graphics.PointF; import android.text.TextUtils; +import androidx.annotation.ColorInt; 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 com.google.common.primitives.Ints; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.util.regex.Matcher; @@ -85,15 +89,18 @@ import java.util.regex.Pattern; public final String name; @SsaAlignment public final int alignment; + @Nullable @ColorInt public final Integer primaryColor; - private SsaStyle(String name, @SsaAlignment int alignment) { + private SsaStyle( + String name, @SsaAlignment int alignment, @Nullable @ColorInt Integer primaryColor) { this.name = name; this.alignment = alignment; + this.primaryColor = primaryColor; } @Nullable public static SsaStyle fromStyleLine(String styleLine, Format format) { - Assertions.checkArgument(styleLine.startsWith(STYLE_LINE_PREFIX)); + checkArgument(styleLine.startsWith(STYLE_LINE_PREFIX)); String[] styleValues = TextUtils.split(styleLine.substring(STYLE_LINE_PREFIX.length()), ","); if (styleValues.length != format.length) { Log.w( @@ -105,7 +112,9 @@ import java.util.regex.Pattern; } try { return new SsaStyle( - styleValues[format.nameIndex].trim(), parseAlignment(styleValues[format.alignmentIndex])); + styleValues[format.nameIndex].trim(), + parseAlignment(styleValues[format.alignmentIndex].trim()), + parseColor(styleValues[format.primaryColorIndex].trim())); } catch (RuntimeException e) { Log.w(TAG, "Skipping malformed 'Style:' line: '" + styleLine + "'", e); return null; @@ -144,6 +153,44 @@ import java.util.regex.Pattern; } } + /** + * Parses a SSA V4+ color expression. + * + *

A SSA V4+ color can be represented in hex {@code ("&HAABBGGRR")} or in 64-bit decimal format + * (byte order AABBGGRR). In both cases the alpha channel's value needs to be inverted because in + * SSA the 0xFF alpha value means transparent and 0x00 means opaque which is the opposite from the + * Android {@link ColorInt} representation. + * + * @param ssaColorExpression A SSA V4+ color expression. + * @return The parsed color value, or null if parsing failed. + */ + @Nullable + @ColorInt + public static Integer parseColor(String ssaColorExpression) { + // We use a long because the value is an unsigned 32-bit number, so can be larger than + // Integer.MAX_VALUE. + long abgr; + try { + abgr = + ssaColorExpression.startsWith("&H") + // Parse color from hex format (&HAABBGGRR). + ? Long.parseLong(ssaColorExpression.substring(2), /* radix= */ 16) + // Parse color from decimal format (bytes order AABBGGRR). + : Long.parseLong(ssaColorExpression); + // Ensure only the bottom 4 bytes of abgr are set. + checkArgument(abgr <= 0xFFFFFFFFL); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Failed to parse color expression: '" + ssaColorExpression + "'", e); + return null; + } + // Convert ABGR to ARGB. + int a = Ints.checkedCast(((abgr >> 24) & 0xFF) ^ 0xFF); // Flip alpha. + int b = Ints.checkedCast((abgr >> 16) & 0xFF); + int g = Ints.checkedCast((abgr >> 8) & 0xFF); + int r = Ints.checkedCast(abgr & 0xFF); + return Color.argb(a, r, g, b); + } + /** * Represents a {@code Format:} line from the {@code [V4+ Styles]} section * @@ -154,11 +201,13 @@ import java.util.regex.Pattern; public final int nameIndex; public final int alignmentIndex; + public final int primaryColorIndex; public final int length; - private Format(int nameIndex, int alignmentIndex, int length) { + private Format(int nameIndex, int alignmentIndex, int primaryColorIndex, int length) { this.nameIndex = nameIndex; this.alignmentIndex = alignmentIndex; + this.primaryColorIndex = primaryColorIndex; this.length = length; } @@ -171,6 +220,7 @@ import java.util.regex.Pattern; public static Format fromFormatLine(String styleFormatLine) { int nameIndex = C.INDEX_UNSET; int alignmentIndex = C.INDEX_UNSET; + int primaryColorIndex = C.INDEX_UNSET; String[] keys = TextUtils.split(styleFormatLine.substring(SsaDecoder.FORMAT_LINE_PREFIX.length()), ","); for (int i = 0; i < keys.length; i++) { @@ -181,9 +231,14 @@ import java.util.regex.Pattern; case "alignment": alignmentIndex = i; break; + case "primarycolour": + primaryColorIndex = i; + break; } } - return nameIndex != C.INDEX_UNSET ? new Format(nameIndex, alignmentIndex, keys.length) : null; + return nameIndex != C.INDEX_UNSET + ? new Format(nameIndex, alignmentIndex, primaryColorIndex, keys.length) + : null; } } @@ -237,8 +292,7 @@ import java.util.regex.Pattern; // Ignore invalid \pos() or \move() function. } try { - @SsaAlignment - int parsedAlignment = parseAlignmentOverride(braceContents); + @SsaAlignment int parsedAlignment = parseAlignmentOverride(braceContents); if (parsedAlignment != SSA_ALIGNMENT_UNKNOWN) { alignment = parsedAlignment; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java index c7833fab04..a734019d09 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java @@ -18,10 +18,13 @@ 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.graphics.Color; import android.text.Layout; +import android.text.Spanned; 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.testutil.truth.SpannedSubject; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.Subtitle; import com.google.common.collect.Iterables; @@ -44,6 +47,7 @@ public final class SsaDecoderTest { private static final String INVALID_TIMECODES = "media/ssa/invalid_timecodes"; private static final String INVALID_POSITIONS = "media/ssa/invalid_positioning"; private static final String POSITIONS_WITHOUT_PLAYRES = "media/ssa/positioning_without_playres"; + private static final String COLORS = "media/ssa/colors"; @Test public void decodeEmpty() throws IOException { @@ -267,6 +271,54 @@ public final class SsaDecoderTest { assertTypicalCue3(subtitle, 0); } + @Test + public void decodeColors() throws IOException { + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), COLORS); + Subtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertThat(subtitle.getEventTimeCount()).isEqualTo(14); + // &H000000FF (AABBGGRR) -> #FFFF0000 (AARRGGBB) + Spanned firstCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(0))).text; + SpannedSubject.assertThat(firstCueText) + .hasForegroundColorSpanBetween(0, firstCueText.length()) + .withColor(Color.RED); + // &H0000FFFF (AABBGGRR) -> #FFFFFF00 (AARRGGBB) + Spanned secondCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(2))).text; + SpannedSubject.assertThat(secondCueText) + .hasForegroundColorSpanBetween(0, secondCueText.length()) + .withColor(Color.YELLOW); + // &HFF00 (GGRR) -> #FF00FF00 (AARRGGBB) + Spanned thirdCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(4))).text; + SpannedSubject.assertThat(thirdCueText) + .hasForegroundColorSpanBetween(0, thirdCueText.length()) + .withColor(Color.GREEN); + // &HA00000FF (AABBGGRR) -> #5FFF0000 (AARRGGBB) + Spanned fourthCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(6))).text; + SpannedSubject.assertThat(fourthCueText) + .hasForegroundColorSpanBetween(0, fourthCueText.length()) + .withColor(0x5FFF0000); + // 16711680 (AABBGGRR) -> &H00FF0000 (AABBGGRR) -> #FF0000FF (AARRGGBB) + Spanned fifthCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(8))).text; + SpannedSubject.assertThat(fifthCueText) + .hasForegroundColorSpanBetween(0, fifthCueText.length()) + .withColor(0xFF0000FF); + // 2164195328 (AABBGGRR) -> &H80FF0000 (AABBGGRR) -> #7F0000FF (AARRGGBB) + Spanned sixthCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(10))).text; + SpannedSubject.assertThat(sixthCueText) + .hasForegroundColorSpanBetween(0, sixthCueText.length()) + .withColor(0x7F0000FF); + Spanned seventhCueText = + (Spanned) Iterables.getOnlyElement(subtitle.getCues(subtitle.getEventTime(12))).text; + SpannedSubject.assertThat(seventhCueText) + .hasNoForegroundColorSpanBetween(0, seventhCueText.length()); + } + private static void assertTypicalCue1(Subtitle subtitle, int eventIndex) { assertThat(subtitle.getEventTime(eventIndex)).isEqualTo(0); assertThat(subtitle.getCues(subtitle.getEventTime(eventIndex)).get(0).text.toString()) diff --git a/testdata/src/test/assets/media/ssa/colors b/testdata/src/test/assets/media/ssa/colors new file mode 100644 index 0000000000..36ce7de761 --- /dev/null +++ b/testdata/src/test/assets/media/ssa/colors @@ -0,0 +1,26 @@ +[Script Info] +Title: Coloring +Script Type: V4.00+ +PlayResX: 1280 +PlayResY: 720 + +[V4+ Styles] +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: PrimaryColourStyleHexRed ,Roboto,50,&H000000FF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 +Style: PrimaryColourStyleHexYellow ,Roboto,50,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 +Style: PrimaryColourStyleHexGreen ,Roboto,50,&HFF00 ,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 +Style: PrimaryColourStyleHexAlpha ,Roboto,50,&HA00000FF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 +Style: PrimaryColourStyleDecimal ,Roboto,50,16711680 ,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 +Style: PrimaryColourStyleDecimalAlpha ,Roboto,50,2164195328,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 +Style: PrimaryColourStyleInvalid ,Roboto,50,blue ,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,50,50,70,1 + + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:02.00,PrimaryColourStyleHexRed ,Arnold,0,0,0,,First line in RED (&H000000FF). +Dialogue: 0,0:00:03.00,0:00:04.00,PrimaryColourStyleHexYellow ,Arnold,0,0,0,,Second line in YELLOW (&H0000FFFF). +Dialogue: 0,0:00:05.00,0:00:06.00,PrimaryColourStyleHexGreen ,Arnold,0,0,0,,Third line in GREEN (leading zeros &HFF00). +Dialogue: 0,0:00:07.00,0:00:08.00,PrimaryColourStyleHexAlpha ,Arnold,0,0,0,,Fourth line in RED with alpha (&H400000FF). +Dialogue: 0,0:00:09.00,0:00:10.00,PrimaryColourStyleDecimal ,Arnold,0,0,0,,Fifth line in BLUE (16711680). +Dialogue: 0,0:00:11.00,0:00:12.00,PrimaryColourStyleDecimalAlpha ,Arnold,0,0,0,,Sixth line in BLUE with alpha (2164195328). +Dialogue: 0,0:00:13.00,0:00:14.00,PrimaryColourInvalid ,Arnold,0,0,0,,Seventh line with invalid color .