diff --git a/library/core/src/androidTest/assets/ssa/empty b/library/core/src/androidTest/assets/ssa/empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/library/core/src/androidTest/assets/ssa/invalid_timecodes b/library/core/src/androidTest/assets/ssa/invalid_timecodes new file mode 100644 index 0000000000..89f3bb3f1c --- /dev/null +++ b/library/core/src/androidTest/assets/ssa/invalid_timecodes @@ -0,0 +1,12 @@ +[Script Info] +Title: SomeTitle + +[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: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,-1,0,0,0,100,100,0,0,1,1.7,0,2,0,0,28,1 + +[Events] +Format: Layer, Start, End, Style, Name, Text +Dialogue: 0,Invalid,0:00:01.23,Default,Olly,This is the first subtitle{ignored}. +Dialogue: 0,0:00:02.34,Invalid,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. \ No newline at end of file diff --git a/library/core/src/androidTest/assets/ssa/no_end_timecodes b/library/core/src/androidTest/assets/ssa/no_end_timecodes new file mode 100644 index 0000000000..c2c57ac64e --- /dev/null +++ b/library/core/src/androidTest/assets/ssa/no_end_timecodes @@ -0,0 +1,12 @@ +[Script Info] +Title: SomeTitle + +[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: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,-1,0,0,0,100,100,0,0,1,1.7,0,2,0,0,28,1 + +[Events] +Format: Layer, Start, End, Style, Name, Text +Dialogue: 0,0:00:00.00, ,Default,Olly,This is the first subtitle. +Dialogue: 0,0:00:02.34, ,Default,Olly,This is the second subtitle \nwith a newline \Nand another. +Dialogue: 0,0:00:04.56, ,Default,Olly,This is the third subtitle, with a comma. \ No newline at end of file diff --git a/library/core/src/androidTest/assets/ssa/typical b/library/core/src/androidTest/assets/ssa/typical new file mode 100644 index 0000000000..8a49099c5c --- /dev/null +++ b/library/core/src/androidTest/assets/ssa/typical @@ -0,0 +1,12 @@ +[Script Info] +Title: SomeTitle + +[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: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,-1,0,0,0,100,100,0,0,1,1.7,0,2,0,0,28,1 + +[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. \ No newline at end of file diff --git a/library/core/src/androidTest/assets/ssa/typical_dialogue b/library/core/src/androidTest/assets/ssa/typical_dialogue new file mode 100644 index 0000000000..5cdab5a84b --- /dev/null +++ b/library/core/src/androidTest/assets/ssa/typical_dialogue @@ -0,0 +1,3 @@ +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. \ No newline at end of file diff --git a/library/core/src/androidTest/assets/ssa/typical_format b/library/core/src/androidTest/assets/ssa/typical_format new file mode 100644 index 0000000000..0cc5f1690f --- /dev/null +++ b/library/core/src/androidTest/assets/ssa/typical_format @@ -0,0 +1 @@ +Format: Layer, Start, End, Style, Name, Text \ No newline at end of file diff --git a/library/core/src/androidTest/assets/ssa/typical_header b/library/core/src/androidTest/assets/ssa/typical_header new file mode 100644 index 0000000000..3e96bcf14e --- /dev/null +++ b/library/core/src/androidTest/assets/ssa/typical_header @@ -0,0 +1,6 @@ +[Script Info] +Title: SomeTitle + +[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: Default,Open Sans Semibold,36,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,-1,0,0,0,100,100,0,0,1,1.7,0,2,0,0,28,1 \ No newline at end of file diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java new file mode 100644 index 0000000000..9ed4d79307 --- /dev/null +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/text/ssa/SsaDecoderTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.text.ssa; + +import android.test.InstrumentationTestCase; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.IOException; +import java.util.ArrayList; + +/** + * Unit test for {@link SsaDecoder}. + */ +public final class SsaDecoderTest extends InstrumentationTestCase { + + private static final String EMPTY = "ssa/empty"; + private static final String TYPICAL = "ssa/typical"; + 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 INVALID_TIMECODES = "ssa/invalid_timecodes"; + private static final String NO_END_TIMECODES = "ssa/no_end_timecodes"; + + public void testDecodeEmpty() throws IOException { + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), EMPTY); + SsaSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertEquals(0, subtitle.getEventTimeCount()); + assertTrue(subtitle.getCues(0).isEmpty()); + } + + public void testDecodeTypical() throws IOException { + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL); + SsaSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertEquals(6, subtitle.getEventTimeCount()); + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + } + + public void testDecodeTypicalWithInitializationData() throws IOException { + byte[] headerBytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_HEADER_ONLY); + byte[] formatBytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_FORMAT_ONLY); + ArrayList initializationData = new ArrayList<>(); + initializationData.add(formatBytes); + initializationData.add(headerBytes); + SsaDecoder decoder = new SsaDecoder(initializationData); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_DIALOGUE_ONLY); + SsaSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertEquals(6, subtitle.getEventTimeCount()); + assertTypicalCue1(subtitle, 0); + assertTypicalCue2(subtitle, 2); + assertTypicalCue3(subtitle, 4); + } + + public void testDecodeInvalidTimecodes() throws IOException { + // Parsing should succeed, parsing the third cue only. + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), INVALID_TIMECODES); + SsaSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertEquals(2, subtitle.getEventTimeCount()); + assertTypicalCue3(subtitle, 0); + } + + public void testDecodeNoEndTimecodes() throws IOException { + SsaDecoder decoder = new SsaDecoder(); + byte[] bytes = TestUtil.getByteArray(getInstrumentation(), NO_END_TIMECODES); + SsaSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + + assertEquals(3, subtitle.getEventTimeCount()); + + assertEquals(0, subtitle.getEventTime(0)); + assertEquals("This is the first subtitle.", + subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); + + assertEquals(2340000, subtitle.getEventTime(1)); + assertEquals("This is the second subtitle \nwith a newline \nand another.", + subtitle.getCues(subtitle.getEventTime(1)).get(0).text.toString()); + + assertEquals(4560000, subtitle.getEventTime(2)); + assertEquals("This is the third subtitle, with a comma.", + subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); + } + + private static void assertTypicalCue1(SsaSubtitle subtitle, int eventIndex) { + assertEquals(0, subtitle.getEventTime(eventIndex)); + assertEquals("This is the first subtitle.", + subtitle.getCues(subtitle.getEventTime(eventIndex)).get(0).text.toString()); + assertEquals(1230000, subtitle.getEventTime(eventIndex + 1)); + } + + private static void assertTypicalCue2(SsaSubtitle subtitle, int eventIndex) { + assertEquals(2340000, subtitle.getEventTime(eventIndex)); + assertEquals("This is the second subtitle \nwith a newline \nand another.", + subtitle.getCues(subtitle.getEventTime(eventIndex)).get(0).text.toString()); + assertEquals(3450000, subtitle.getEventTime(eventIndex + 1)); + } + + private static void assertTypicalCue3(SsaSubtitle subtitle, int eventIndex) { + assertEquals(4560000, subtitle.getEventTime(eventIndex)); + assertEquals("This is the third subtitle, with a comma.", + subtitle.getCues(subtitle.getEventTime(eventIndex)).get(0).text.toString()); + assertEquals(8900000, subtitle.getEventTime(eventIndex + 1)); + } + +} diff --git a/library/core/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java b/library/core/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java index 880a214fb3..167499fcdc 100644 --- a/library/core/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java +++ b/library/core/src/androidTest/java/com/google/android/exoplayer2/text/subrip/SubripDecoderTest.java @@ -37,7 +37,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), EMPTY_FILE); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); - // Assert that the subtitle is empty. + assertEquals(0, subtitle.getEventTimeCount()); assertTrue(subtitle.getCues(0).isEmpty()); } @@ -46,6 +46,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_FILE); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertEquals(6, subtitle.getEventTimeCount()); assertTypicalCue1(subtitle, 0); assertTypicalCue2(subtitle, 2); @@ -56,6 +57,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_WITH_BYTE_ORDER_MARK); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertEquals(6, subtitle.getEventTimeCount()); assertTypicalCue1(subtitle, 0); assertTypicalCue2(subtitle, 2); @@ -66,6 +68,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_EXTRA_BLANK_LINE); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertEquals(6, subtitle.getEventTimeCount()); assertTypicalCue1(subtitle, 0); assertTypicalCue2(subtitle, 2); @@ -77,6 +80,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_MISSING_TIMECODE); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertEquals(4, subtitle.getEventTimeCount()); assertTypicalCue1(subtitle, 0); assertTypicalCue3(subtitle, 2); @@ -87,6 +91,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_MISSING_SEQUENCE); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertEquals(4, subtitle.getEventTimeCount()); assertTypicalCue1(subtitle, 0); assertTypicalCue3(subtitle, 2); @@ -97,6 +102,7 @@ public final class SubripDecoderTest extends InstrumentationTestCase { SubripDecoder decoder = new SubripDecoder(); byte[] bytes = TestUtil.getByteArray(getInstrumentation(), TYPICAL_NEGATIVE_TIMESTAMPS); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); + assertEquals(2, subtitle.getEventTimeCount()); assertTypicalCue3(subtitle, 0); } @@ -106,20 +112,16 @@ public final class SubripDecoderTest extends InstrumentationTestCase { byte[] bytes = TestUtil.getByteArray(getInstrumentation(), NO_END_TIMECODES_FILE); SubripSubtitle subtitle = decoder.decode(bytes, bytes.length, false); - // Test event count. assertEquals(3, subtitle.getEventTimeCount()); - // Test first cue. assertEquals(0, subtitle.getEventTime(0)); assertEquals("SubRip doesn't technically allow missing end timecodes.", subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); - // Test second cue. assertEquals(2345000, subtitle.getEventTime(1)); assertEquals("We interpret it to mean that a subtitle extends to the start of the next one.", subtitle.getCues(subtitle.getEventTime(1)).get(0).text.toString()); - // Test third cue. assertEquals(3456000, subtitle.getEventTime(2)); assertEquals("Or to the end of the media.", subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java index 9f438d0977..041fedd391 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -120,6 +120,7 @@ public final class MatroskaExtractor implements Extractor { private static final String CODEC_ID_ACM = "A_MS/ACM"; private static final String CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT"; private static final String CODEC_ID_SUBRIP = "S_TEXT/UTF8"; + private static final String CODEC_ID_ASS = "S_TEXT/ASS"; private static final String CODEC_ID_VOBSUB = "S_VOBSUB"; private static final String CODEC_ID_PGS = "S_HDMV/PGS"; private static final String CODEC_ID_DVBSUB = "S_DVBSUB"; @@ -226,21 +227,62 @@ public final class MatroskaExtractor implements Extractor { private static final byte[] SUBRIP_PREFIX = new byte[] {49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 10}; /** - * A special end timecode indicating that a subtitle should be displayed until the next subtitle, - * or until the end of the media in the case of the last subtitle. + * The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. + */ + private static final int SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19; + /** + * A special end timecode indicating that a subrip subtitle should be displayed until the next + * subtitle, or until the end of the media in the case of the last subtitle. *

* Equivalent to the UTF-8 string: " ". */ private static final byte[] SUBRIP_TIMECODE_EMPTY = new byte[] {32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32}; /** - * The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a subrip timecode (milliseconds). */ - private static final int SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19; + private static long SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; /** - * The length in bytes of a timecode in a subrip prefix. + * The format of a subrip timecode. */ - private static final int SUBRIP_TIMECODE_LENGTH = 12; + private static final String SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d"; + + /** + * Matroska specific format line for SSA subtitles. + */ + private static final byte[] SSA_DIALOGUE_FORMAT = Util.getUtf8Bytes("Format: Start, End, " + + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); + /** + * A template for the prefix that must be added to each SSA sample. The 10 byte end timecode + * starting at {@link #SSA_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be + * replaced with the duration of the subtitle. + *

+ * Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". + */ + private static final byte[] SSA_PREFIX = new byte[] {68, 105, 97, 108, 111, 103, 117, 101, 58, 32, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44}; + /** + * The byte offset of the end timecode in {@link #SSA_PREFIX}. + */ + private static final int SSA_PREFIX_END_TIMECODE_OFFSET = 21; + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in an SSA timecode (1/100ths of a second). + */ + private static long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10000; + /** + * A special end timecode indicating that an SSA subtitle should be displayed until the next + * subtitle, or until the end of the media in the case of the last subtitle. + *

+ * Equivalent to the UTF-8 string: " ". + */ + private static final byte[] SSA_TIMECODE_EMPTY = + new byte[] {32, 32, 32, 32, 32, 32, 32, 32, 32, 32}; + /** + * The format of an SSA timecode. + */ + private static final String SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d"; /** * The length in bytes of a WAVEFORMATEX structure. @@ -271,7 +313,7 @@ public final class MatroskaExtractor implements Extractor { private final ParsableByteArray vorbisNumPageSamples; private final ParsableByteArray seekEntryIdBytes; private final ParsableByteArray sampleStrippedBytes; - private final ParsableByteArray subripSample; + private final ParsableByteArray subtitleSample; private final ParsableByteArray encryptionInitializationVector; private final ParsableByteArray encryptionSubsampleData; private ByteBuffer encryptionSubsampleDataBuffer; @@ -349,7 +391,7 @@ public final class MatroskaExtractor implements Extractor { nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); nalLength = new ParsableByteArray(4); sampleStrippedBytes = new ParsableByteArray(); - subripSample = new ParsableByteArray(); + subtitleSample = new ParsableByteArray(); encryptionInitializationVector = new ParsableByteArray(ENCRYPTION_IV_SIZE); encryptionSubsampleData = new ParsableByteArray(); } @@ -1016,7 +1058,7 @@ public final class MatroskaExtractor implements Extractor { // For SimpleBlock, we have metadata for each sample here. while (blockLacingSampleIndex < blockLacingSampleCount) { writeSampleData(input, track, blockLacingSampleSizes[blockLacingSampleIndex]); - long sampleTimeUs = this.blockTimeUs + long sampleTimeUs = blockTimeUs + (blockLacingSampleIndex * track.defaultSampleDurationNs) / 1000; commitSampleToOutput(track, sampleTimeUs); blockLacingSampleIndex++; @@ -1036,7 +1078,11 @@ public final class MatroskaExtractor implements Extractor { private void commitSampleToOutput(Track track, long timeUs) { if (CODEC_ID_SUBRIP.equals(track.codecId)) { - writeSubripSample(track); + commitSubtitleSample(track, SUBRIP_TIMECODE_FORMAT, SUBRIP_PREFIX_END_TIMECODE_OFFSET, + SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR, SUBRIP_TIMECODE_EMPTY); + } else if (CODEC_ID_ASS.equals(track.codecId)) { + commitSubtitleSample(track, SSA_TIMECODE_FORMAT, SSA_PREFIX_END_TIMECODE_OFFSET, + SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR, SSA_TIMECODE_EMPTY); } track.output.sampleMetadata(timeUs, blockFlags, sampleBytesWritten, 0, track.cryptoData); sampleRead = true; @@ -1076,17 +1122,10 @@ public final class MatroskaExtractor implements Extractor { private void writeSampleData(ExtractorInput input, Track track, int size) throws IOException, InterruptedException { if (CODEC_ID_SUBRIP.equals(track.codecId)) { - int sizeWithPrefix = SUBRIP_PREFIX.length + size; - if (subripSample.capacity() < sizeWithPrefix) { - // Initialize subripSample to contain the required prefix and have space to hold a subtitle - // twice as long as this one. - subripSample.data = Arrays.copyOf(SUBRIP_PREFIX, sizeWithPrefix + size); - } - input.readFully(subripSample.data, SUBRIP_PREFIX.length, size); - subripSample.setPosition(0); - subripSample.setLimit(sizeWithPrefix); - // Defer writing the data to the track output. We need to modify the sample data by setting - // the correct end timecode, which we might not have yet. + writeSubtitleSampleData(input, SUBRIP_PREFIX, size); + return; + } else if (CODEC_ID_ASS.equals(track.codecId)) { + writeSubtitleSampleData(input, SSA_PREFIX, size); return; } @@ -1230,31 +1269,50 @@ public final class MatroskaExtractor implements Extractor { } } - private void writeSubripSample(Track track) { - setSubripSampleEndTimecode(subripSample.data, blockDurationUs); - // Note: If we ever want to support DRM protected subtitles then we'll need to output the - // appropriate encryption data here. - track.output.sampleData(subripSample, subripSample.limit()); - sampleBytesWritten += subripSample.limit(); + private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) + throws IOException, InterruptedException { + int sizeWithPrefix = samplePrefix.length + size; + if (subtitleSample.capacity() < sizeWithPrefix) { + // Initialize subripSample to contain the required prefix and have space to hold a subtitle + // twice as long as this one. + subtitleSample.data = Arrays.copyOf(samplePrefix, sizeWithPrefix + size); + } else { + System.arraycopy(samplePrefix, 0, subtitleSample.data, 0, samplePrefix.length); + } + input.readFully(subtitleSample.data, samplePrefix.length, size); + subtitleSample.reset(sizeWithPrefix); + // Defer writing the data to the track output. We need to modify the sample data by setting + // the correct end timecode, which we might not have yet. } - private static void setSubripSampleEndTimecode(byte[] subripSampleData, long timeUs) { + private void commitSubtitleSample(Track track, String timecodeFormat, int endTimecodeOffset, + long lastTimecodeValueScalingFactor, byte[] emptyTimecode) { + setSampleDuration(subtitleSample.data, blockDurationUs, timecodeFormat, endTimecodeOffset, + lastTimecodeValueScalingFactor, emptyTimecode); + // Note: If we ever want to support DRM protected subtitles then we'll need to output the + // appropriate encryption data here. + track.output.sampleData(subtitleSample, subtitleSample.limit()); + sampleBytesWritten += subtitleSample.limit(); + } + + private static void setSampleDuration(byte[] subripSampleData, long durationUs, + String timecodeFormat, int endTimecodeOffset, long lastTimecodeValueScalingFactor, + byte[] emptyTimecode) { byte[] timeCodeData; - if (timeUs == C.TIME_UNSET) { - timeCodeData = SUBRIP_TIMECODE_EMPTY; + if (durationUs == C.TIME_UNSET) { + timeCodeData = emptyTimecode; } else { - int hours = (int) (timeUs / 3600000000L); - timeUs -= (hours * 3600000000L); - int minutes = (int) (timeUs / 60000000); - timeUs -= (minutes * 60000000); - int seconds = (int) (timeUs / 1000000); - timeUs -= (seconds * 1000000); - int milliseconds = (int) (timeUs / 1000); - timeCodeData = Util.getUtf8Bytes(String.format(Locale.US, "%02d:%02d:%02d,%03d", hours, - minutes, seconds, milliseconds)); + int hours = (int) (durationUs / (3600 * C.MICROS_PER_SECOND)); + durationUs -= (hours * 3600 * C.MICROS_PER_SECOND); + int minutes = (int) (durationUs / (60 * C.MICROS_PER_SECOND)); + durationUs -= (minutes * 60 * C.MICROS_PER_SECOND); + int seconds = (int) (durationUs / C.MICROS_PER_SECOND); + durationUs -= (seconds * C.MICROS_PER_SECOND); + int lastValue = (int) (durationUs / lastTimecodeValueScalingFactor); + timeCodeData = Util.getUtf8Bytes(String.format(Locale.US, timecodeFormat, hours, minutes, + seconds, lastValue)); } - System.arraycopy(timeCodeData, 0, subripSampleData, SUBRIP_PREFIX_END_TIMECODE_OFFSET, - SUBRIP_TIMECODE_LENGTH); + System.arraycopy(timeCodeData, 0, subripSampleData, endTimecodeOffset, emptyTimecode.length); } /** @@ -1385,6 +1443,7 @@ public final class MatroskaExtractor implements Extractor { || CODEC_ID_ACM.equals(codecId) || CODEC_ID_PCM_INT_LIT.equals(codecId) || CODEC_ID_SUBRIP.equals(codecId) + || CODEC_ID_ASS.equals(codecId) || CODEC_ID_VOBSUB.equals(codecId) || CODEC_ID_PGS.equals(codecId) || CODEC_ID_DVBSUB.equals(codecId); @@ -1650,6 +1709,9 @@ public final class MatroskaExtractor implements Extractor { case CODEC_ID_SUBRIP: mimeType = MimeTypes.APPLICATION_SUBRIP; break; + case CODEC_ID_ASS: + mimeType = MimeTypes.TEXT_SSA; + break; case CODEC_ID_VOBSUB: mimeType = MimeTypes.APPLICATION_VOBSUB; initializationData = Collections.singletonList(codecPrivate); @@ -1702,6 +1764,14 @@ public final class MatroskaExtractor implements Extractor { type = C.TRACK_TYPE_TEXT; format = Format.createTextSampleFormat(Integer.toString(trackId), mimeType, null, Format.NO_VALUE, selectionFlags, language, drmInitData); + } else if (MimeTypes.TEXT_SSA.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + initializationData = new ArrayList<>(2); + initializationData.add(SSA_DIALOGUE_FORMAT); + initializationData.add(codecPrivate); + format = Format.createTextSampleFormat(Integer.toString(trackId), mimeType, null, + Format.NO_VALUE, selectionFlags, language, Format.NO_VALUE, drmInitData, + Format.OFFSET_SAMPLE_RELATIVE, initializationData); } else if (MimeTypes.APPLICATION_VOBSUB.equals(mimeType) || MimeTypes.APPLICATION_PGS.equals(mimeType) || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType)) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java index 795189e1a6..6a9b83a015 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java @@ -19,6 +19,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.text.cea.Cea608Decoder; import com.google.android.exoplayer2.text.cea.Cea708Decoder; import com.google.android.exoplayer2.text.dvb.DvbDecoder; +import com.google.android.exoplayer2.text.ssa.SsaDecoder; import com.google.android.exoplayer2.text.subrip.SubripDecoder; import com.google.android.exoplayer2.text.ttml.TtmlDecoder; import com.google.android.exoplayer2.text.tx3g.Tx3gDecoder; @@ -58,6 +59,7 @@ public interface SubtitleDecoderFactory { *

  • WebVTT (MP4) ({@link Mp4WebvttDecoder})
  • *
  • TTML ({@link TtmlDecoder})
  • *
  • SubRip ({@link SubripDecoder})
  • + *
  • SSA/ASS ({@link SsaDecoder})
  • *
  • TX3G ({@link Tx3gDecoder})
  • *
  • Cea608 ({@link Cea608Decoder})
  • *
  • Cea708 ({@link Cea708Decoder})
  • @@ -70,6 +72,7 @@ public interface SubtitleDecoderFactory { public boolean supportsFormat(Format format) { String mimeType = format.sampleMimeType; return MimeTypes.TEXT_VTT.equals(mimeType) + || MimeTypes.TEXT_SSA.equals(mimeType) || MimeTypes.APPLICATION_TTML.equals(mimeType) || MimeTypes.APPLICATION_MP4VTT.equals(mimeType) || MimeTypes.APPLICATION_SUBRIP.equals(mimeType) @@ -85,6 +88,8 @@ public interface SubtitleDecoderFactory { switch (format.sampleMimeType) { case MimeTypes.TEXT_VTT: return new WebvttDecoder(); + case MimeTypes.TEXT_SSA: + return new SsaDecoder(format.initializationData); case MimeTypes.APPLICATION_MP4VTT: return new Mp4WebvttDecoder(); case MimeTypes.APPLICATION_TTML: 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 new file mode 100644 index 0000000000..11489e7b35 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -0,0 +1,214 @@ +/* + * 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 com.google.android.exoplayer2.text.ssa; + +import android.text.TextUtils; +import android.util.Log; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.LongArray; +import com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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: "; + + private final boolean haveInitializationData; + + private int formatKeyCount; + private int formatStartIndex; + private int formatEndIndex; + private int formatTextIndex; + + public SsaDecoder() { + this(null); + } + + /** + * @param initializationData Optional initialization data for the decoder. If not null, 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. + */ + public SsaDecoder(List initializationData) { + super("SsaDecoder"); + if (initializationData != null) { + haveInitializationData = true; + String formatLine = new String(initializationData.get(0)); + Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX)); + parseFormatLine(formatLine); + parseHeader(new ParsableByteArray(initializationData.get(1))); + } else { + haveInitializationData = false; + } + } + + @Override + protected SsaSubtitle decode(byte[] bytes, int length, boolean reset) { + ArrayList cues = new ArrayList<>(); + LongArray cueTimesUs = new LongArray(); + + 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); + } + + /** + * Parses the header of the subtitle. + * + * @param data A {@link ParsableByteArray} from which the header should be read. + */ + 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. + return; + } + } + } + + /** + * 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. + */ + private void parseEventBody(ParsableByteArray data, List cues, LongArray cueTimesUs) { + String currentLine; + while ((currentLine = data.readLine()) != null) { + if (!haveInitializationData && currentLine.startsWith(FORMAT_LINE_PREFIX)) { + parseFormatLine(currentLine); + } else if (currentLine.startsWith(DIALOGUE_LINE_PREFIX)) { + parseDialogueLine(currentLine, 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 = values[i].trim().toLowerCase(); + switch (key) { + case "start": + formatStartIndex = i; + break; + case "end": + formatEndIndex = i; + break; + case "text": + formatTextIndex = i; + break; + default: + // Do nothing. + break; + } + } + } + + /** + * Parses a dialogue line. + * + * @param dialogueLine The line to parse. + * @param cues A list to which parsed cues will be added. + * @param cueTimesUs An array to which parsed cue timestamps will be added. + */ + private void parseDialogueLine(String dialogueLine, List cues, LongArray cueTimesUs) { + if (formatKeyCount == 0) { + Log.w(TAG, "Skipping dialogue line before format: " + dialogueLine); + return; + } + + String[] lineValues = dialogueLine.substring(DIALOGUE_LINE_PREFIX.length()) + .split(",", formatKeyCount); + long startTimeUs = SsaDecoder.parseTimecodeUs(lineValues[formatStartIndex]); + if (startTimeUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping invalid timing: " + dialogueLine); + return; + } + + long endTimeUs = C.TIME_UNSET; + String endTimeString = lineValues[formatEndIndex]; + if (!endTimeString.trim().isEmpty()) { + endTimeUs = SsaDecoder.parseTimecodeUs(endTimeString); + if (endTimeUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping invalid timing: " + dialogueLine); + return; + } + } + + String text = lineValues[formatTextIndex] + .replaceAll("\\{.*?\\}", "") + .replaceAll("\\\\N", "\n") + .replaceAll("\\\\n", "\n"); + cues.add(new Cue(text)); + cueTimesUs.add(startTimeUs); + if (endTimeUs != C.TIME_UNSET) { + cues.add(null); + cueTimesUs.add(endTimeUs); + } + } + + /** + * Parses an SSA timecode string. + * + * @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); + if (!matcher.matches()) { + return C.TIME_UNSET; + } + long timestampUs = Long.parseLong(matcher.group(1)) * 60 * 60 * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(matcher.group(2)) * 60 * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(matcher.group(3)) * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(matcher.group(4)) * 10000; // 100ths of a second. + return timestampUs; + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java new file mode 100644 index 0000000000..339119ed6b --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java @@ -0,0 +1,72 @@ +/* + * 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 com.google.android.exoplayer2.text.ssa; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.Subtitle; +import com.google.android.exoplayer2.util.Assertions; +import 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 Cue[] cues; + private final long[] cueTimesUs; + + /** + * @param cues The cues in the subtitle. Null entries may be used to represent empty cues. + * @param cueTimesUs The cue times, in microseconds. + */ + public SsaSubtitle(Cue[] cues, 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.length ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return cueTimesUs.length; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < cueTimesUs.length); + return cueTimesUs[index]; + } + + @Override + public List getCues(long timeUs) { + int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); + if (index == -1 || cues[index] == null) { + // timeUs is earlier than the start of the first cue, or we have an empty cue. + return Collections.emptyList(); + } else { + return Collections.singletonList(cues[index]); + } + } + +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index db1122dbe7..2d4a1ec96f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -65,6 +65,7 @@ public final class MimeTypes { public static final String AUDIO_UNKNOWN = BASE_TYPE_AUDIO + "/x-unknown"; public static final String TEXT_VTT = BASE_TYPE_TEXT + "/vtt"; + public static final String TEXT_SSA = BASE_TYPE_TEXT + "/x-ssa"; public static final String APPLICATION_MP4 = BASE_TYPE_APPLICATION + "/mp4"; public static final String APPLICATION_WEBM = BASE_TYPE_APPLICATION + "/webm";