diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ts/PtsTimestampAdjuster.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/PtsTimestampAdjuster.java index 59d96eb003..25a92e2bce 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ts/PtsTimestampAdjuster.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/PtsTimestampAdjuster.java @@ -49,6 +49,13 @@ public final class PtsTimestampAdjuster { lastPts = Long.MIN_VALUE; } + /** + * Whether this adjuster has been initialized with a first MPEG-2 TS presentation timestamp. + */ + public boolean isInitialized() { + return lastPts != Long.MIN_VALUE; + } + /** * Scales and adjusts an MPEG-2 TS presentation timestamp. * @@ -66,7 +73,7 @@ public final class PtsTimestampAdjuster { ? ptsWrapBelow : ptsWrapAbove; } // Calculate the corresponding timestamp. - long timeUs = (pts * C.MICROS_PER_SECOND) / 90000; + long timeUs = ptsToUs(pts); // If we haven't done the initial timestamp adjustment, do it now. if (lastPts == Long.MIN_VALUE) { timestampOffsetUs = firstSampleTimestampUs - timeUs; @@ -76,4 +83,24 @@ public final class PtsTimestampAdjuster { return timeUs + timestampOffsetUs; } + /** + * Converts a value in MPEG-2 timestamp units to the corresponding value in microseconds. + * + * @param pts A value in MPEG-2 timestamp units. + * @return The corresponding value in microseconds. + */ + public static long ptsToUs(long pts) { + return (pts * C.MICROS_PER_SECOND) / 90000; + } + + /** + * Converts a value in microseconds to the corresponding values in MPEG-2 timestamp units. + * + * @param us A value in microseconds. + * @return The corresponding value in MPEG-2 timestamp units. + */ + public static long usToPts(long us) { + return (us * 90000) / C.MICROS_PER_SECOND; + } + } diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java index 3ca27db441..f70de84f1d 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/ts/TsExtractor.java @@ -57,11 +57,11 @@ public final class TsExtractor implements Extractor { private static final long HEVC_FORMAT_IDENTIFIER = Util.getIntegerCodeForString("HEVC"); private final PtsTimestampAdjuster ptsTimestampAdjuster; + private final boolean idrKeyframesOnly; private final ParsableByteArray tsPacketBuffer; private final ParsableBitArray tsScratch; - private final boolean idrKeyframesOnly; - /* package */ final SparseBooleanArray streamTypes; /* package */ final SparseArray tsPayloadReaders; // Indexed by pid + /* package */ final SparseBooleanArray streamTypes; // Accessed only by the loading thread. private ExtractorOutput output; @@ -76,13 +76,13 @@ public final class TsExtractor implements Extractor { } public TsExtractor(PtsTimestampAdjuster ptsTimestampAdjuster, boolean idrKeyframesOnly) { + this.ptsTimestampAdjuster = ptsTimestampAdjuster; this.idrKeyframesOnly = idrKeyframesOnly; - tsScratch = new ParsableBitArray(new byte[3]); tsPacketBuffer = new ParsableByteArray(TS_PACKET_SIZE); - streamTypes = new SparseBooleanArray(); + tsScratch = new ParsableBitArray(new byte[3]); tsPayloadReaders = new SparseArray<>(); tsPayloadReaders.put(TS_PAT_PID, new PatReader()); - this.ptsTimestampAdjuster = ptsTimestampAdjuster; + streamTypes = new SparseBooleanArray(); } // Extractor implementation. diff --git a/library/src/main/java/com/google/android/exoplayer/hls/WebvttExtractor.java b/library/src/main/java/com/google/android/exoplayer/hls/WebvttExtractor.java new file mode 100644 index 0000000000..0e648a0388 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/hls/WebvttExtractor.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2014 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.exoplayer.hls; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.MediaFormat; +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.extractor.Extractor; +import com.google.android.exoplayer.extractor.ExtractorInput; +import com.google.android.exoplayer.extractor.ExtractorOutput; +import com.google.android.exoplayer.extractor.PositionHolder; +import com.google.android.exoplayer.extractor.SeekMap; +import com.google.android.exoplayer.extractor.TrackOutput; +import com.google.android.exoplayer.extractor.ts.PtsTimestampAdjuster; +import com.google.android.exoplayer.text.webvtt.WebvttParserUtil; +import com.google.android.exoplayer.util.MimeTypes; +import com.google.android.exoplayer.util.ParsableByteArray; + +import android.text.TextUtils; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A special purpose extractor for WebVTT content in HLS. + *

+ * This extractor passes through non-empty WebVTT files untouched, however derives the correct + * sample timestamp for each by sniffing the X-TIMESTAMP-MAP header along with the start timestamp + * of the first cue header. Empty WebVTT files are not passed through, since it's not possible to + * derive a sample timestamp in this case. + */ +/* package */ final class WebvttExtractor implements Extractor { + + private static final Pattern LOCAL_TIMESTAMP = Pattern.compile("LOCAL:([^,]+)"); + private static final Pattern MEDIA_TIMESTAMP = Pattern.compile("MPEGTS:(\\d+)"); + + private final PtsTimestampAdjuster ptsTimestampAdjuster; + private final ParsableByteArray sampleDataWrapper; + + private ExtractorOutput output; + + private byte[] sampleData; + private int sampleSize; + + public WebvttExtractor(PtsTimestampAdjuster ptsTimestampAdjuster) { + this.ptsTimestampAdjuster = ptsTimestampAdjuster; + this.sampleDataWrapper = new ParsableByteArray(); + sampleData = new byte[1024]; + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // This extractor is only used for the HLS use case, which should not call this method. + throw new IllegalStateException(); + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + output.seekMap(SeekMap.UNSEEKABLE); + } + + @Override + public void seek() { + // This extractor is only used for the HLS use case, which should not call this method. + throw new IllegalStateException(); + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + int currentFileSize = (int) input.getLength(); + + // Increase the size of sampleData if necessary. + if (sampleSize == sampleData.length) { + sampleData = Arrays.copyOf(sampleData, + (currentFileSize != C.LENGTH_UNBOUNDED ? currentFileSize : sampleData.length) * 3 / 2); + } + + // Consume to the input. + int bytesRead = input.read(sampleData, sampleSize, sampleData.length - sampleSize); + if (bytesRead != C.RESULT_END_OF_INPUT) { + sampleSize += bytesRead; + if (currentFileSize == C.LENGTH_UNBOUNDED || sampleSize != currentFileSize) { + return Extractor.RESULT_CONTINUE; + } + } + + // We've reached the end of the input, which corresponds to the end of the current file. + processSample(); + return Extractor.RESULT_END_OF_INPUT; + } + + private void processSample() throws IOException { + BufferedReader reader = new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(sampleData), C.UTF8_NAME)); + + // Validate the first line of the header. + WebvttParserUtil.validateWebvttHeaderLine(reader); + + // Defaults to use if the header doesn't contain an X-TIMESTAMP-MAP header. + long vttTimestampUs = 0; + long tsTimestampUs = 0; + + // Parse the remainder of the header looking for X-TIMESTAMP-MAP. + String line; + while (!TextUtils.isEmpty(line = reader.readLine())) { + if (line.startsWith("X-TIMESTAMP-MAP")) { + Matcher localTimestampMatcher = LOCAL_TIMESTAMP.matcher(line); + if (!localTimestampMatcher.find()) { + throw new ParserException("X-TIMESTAMP-MAP doesn't contain local timestamp: " + line); + } + Matcher mediaTimestampMatcher = MEDIA_TIMESTAMP.matcher(line); + if (!mediaTimestampMatcher.find()) { + throw new ParserException("X-TIMESTAMP-MAP doesn't contain media timestamp: " + line); + } + vttTimestampUs = WebvttParserUtil.parseTimestampUs(localTimestampMatcher.group(1)); + tsTimestampUs = PtsTimestampAdjuster.ptsToUs( + Long.parseLong(mediaTimestampMatcher.group(1))); + } + } + + // Find the first cue header and parse the start time. + Matcher cueHeaderMatcher = WebvttParserUtil.findNextCueHeader(reader); + if (cueHeaderMatcher == null) { + // No cues found. Don't output a sample, but still output a corresponding track. + buildTrackOutput(0); + return; + } + + long firstCueTimeUs = WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1)); + long sampleTimeUs = ptsTimestampAdjuster.adjustTimestamp( + PtsTimestampAdjuster.usToPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); + long subsampleOffsetUs = sampleTimeUs - firstCueTimeUs; + // Output the track. + TrackOutput trackOutput = buildTrackOutput(subsampleOffsetUs); + // Output the sample. + sampleDataWrapper.reset(sampleData, sampleSize); + trackOutput.sampleData(sampleDataWrapper, sampleSize); + trackOutput.sampleMetadata(sampleTimeUs, C.SAMPLE_FLAG_SYNC, sampleSize, 0, null); + } + + private TrackOutput buildTrackOutput(long subsampleOffsetUs) { + TrackOutput trackOutput = output.track(0); + trackOutput.format(MediaFormat.createTextFormat("id", MimeTypes.TEXT_VTT, MediaFormat.NO_VALUE, + C.UNKNOWN_TIME_US, "en", subsampleOffsetUs)); + output.endTracks(); + return trackOutput; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java index d57cd9a51c..aca43b451f 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java @@ -16,7 +16,6 @@ package com.google.android.exoplayer.text.webvtt; import com.google.android.exoplayer.C; -import com.google.android.exoplayer.ParserException; import com.google.android.exoplayer.text.Cue; import com.google.android.exoplayer.text.SubtitleParser; import com.google.android.exoplayer.util.MimeTypes; @@ -42,9 +41,6 @@ public final class WebvttParser implements SubtitleParser { private static final String TAG = "WebvttParser"; - private static final Pattern HEADER = Pattern.compile("^\uFEFF?WEBVTT((\u0020|\u0009).*)?$"); - private static final Pattern COMMENT = Pattern.compile("^NOTE((\u0020|\u0009).*)?$"); - private static final Pattern CUE_HEADER = Pattern.compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$"); private static final Pattern CUE_SETTING = Pattern.compile("(\\S+?):(\\S+)"); private final WebvttCueParser cueParser; @@ -67,18 +63,19 @@ public final class WebvttParser implements SubtitleParser { BufferedReader webvttData = new BufferedReader(new InputStreamReader(inputStream, C.UTF8_NAME)); // Validate the first line of the header, and skip the remainder. - validateWebvttHeaderLine(webvttData); + WebvttParserUtil.validateWebvttHeaderLine(webvttData); while (!TextUtils.isEmpty(webvttData.readLine())) {} + // Process the cues and text. ArrayList subtitles = new ArrayList<>(); Matcher cueHeaderMatcher; - while ((cueHeaderMatcher = findNextCueHeader(webvttData)) != null) { + while ((cueHeaderMatcher = WebvttParserUtil.findNextCueHeader(webvttData)) != null) { long cueStartTime; long cueEndTime; try { // Parse the cue start and end times. - cueStartTime = parseTimestampUs(cueHeaderMatcher.group(1)); - cueEndTime = parseTimestampUs(cueHeaderMatcher.group(2)); + cueStartTime = WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1)); + cueEndTime = WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(2)); } catch (NumberFormatException e) { Log.w(TAG, "Skipping cue with bad header: " + cueHeaderMatcher.group()); continue; @@ -146,55 +143,6 @@ public final class WebvttParser implements SubtitleParser { return new WebvttSubtitle(subtitles); } - /** - * Reads and validates the first line of a WebVTT file. - * - * @param input The input from which the line should be read. - * @throws ParserException If the line isn't the start of a valid WebVTT file. - * @throws IOException If an error occurs reading from the input. - */ - private static void validateWebvttHeaderLine(BufferedReader input) throws IOException { - String line = input.readLine(); - if (line == null || !HEADER.matcher(line).matches()) { - throw new ParserException("Expected WEBVTT. Got " + line); - } - } - - /** - * Reads lines up to and including the next WebVTT cue header. - * - * @param input The input from which lines should be read. - * @throws IOException If an error occurs reading from the input. - * @return A {@link Matcher} for the WebVTT cue header, or null if the end of the input was - * reached without a cue header being found. In the case that a cue header is found, groups 1, - * 2 and 3 of the returned matcher contain the start time, end time and settings list. - */ - private static Matcher findNextCueHeader(BufferedReader input) throws IOException { - String line; - while ((line = input.readLine()) != null) { - if (COMMENT.matcher(line).matches()) { - // Skip until the end of the comment block. - while ((line = input.readLine()) != null && !line.isEmpty()) {} - } else { - Matcher cueHeaderMatcher = CUE_HEADER.matcher(line); - if (cueHeaderMatcher.matches()) { - return cueHeaderMatcher; - } - } - } - return null; - } - - private static long parseTimestampUs(String s) throws NumberFormatException { - long value = 0; - String[] parts = s.split("\\.", 2); - String[] subparts = parts[0].split(":"); - for (int i = 0; i < subparts.length; i++) { - value = value * 60 + Long.parseLong(subparts[i]); - } - return (value * 1000 + Long.parseLong(parts[1])) * 1000; - } - private static void parseLineAttribute(String s, PositionHolder out) throws NumberFormatException { int lineAnchor; diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParserUtil.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParserUtil.java new file mode 100644 index 0000000000..1ecc299196 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParserUtil.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2014 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.exoplayer.text.webvtt; + +import com.google.android.exoplayer.ParserException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility methods for parsing WebVTT data. + */ +public final class WebvttParserUtil { + + private static final Pattern HEADER = Pattern.compile("^\uFEFF?WEBVTT((\u0020|\u0009).*)?$"); + private static final Pattern COMMENT = Pattern.compile("^NOTE((\u0020|\u0009).*)?$"); + private static final Pattern CUE_HEADER = Pattern.compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$"); + + private WebvttParserUtil() {} + + /** + * Reads and validates the first line of a WebVTT file. + * + * @param input The input from which the line should be read. + * @throws ParserException If the line isn't the start of a valid WebVTT file. + * @throws IOException If an error occurs reading from the input. + */ + public static void validateWebvttHeaderLine(BufferedReader input) throws IOException { + String line = input.readLine(); + if (line == null || !HEADER.matcher(line).matches()) { + throw new ParserException("Expected WEBVTT. Got " + line); + } + } + + /** + * Reads lines up to and including the next WebVTT cue header. + * + * @param input The input from which lines should be read. + * @throws IOException If an error occurs reading from the input. + * @return A {@link Matcher} for the WebVTT cue header, or null if the end of the input was + * reached without a cue header being found. In the case that a cue header is found, groups 1, + * 2 and 3 of the returned matcher contain the start time, end time and settings list. + */ + public static Matcher findNextCueHeader(BufferedReader input) throws IOException { + String line; + while ((line = input.readLine()) != null) { + if (COMMENT.matcher(line).matches()) { + // Skip until the end of the comment block. + while ((line = input.readLine()) != null && !line.isEmpty()) {} + } else { + Matcher cueHeaderMatcher = CUE_HEADER.matcher(line); + if (cueHeaderMatcher.matches()) { + return cueHeaderMatcher; + } + } + } + return null; + } + + /** + * Parses a WebVTT timestamp. + * + * @param timestamp The timestamp string. + * @return The parsed timestamp in microseconds. + * @throws NumberFormatException If the timestamp could not be parsed. + */ + public static long parseTimestampUs(String timestamp) throws NumberFormatException { + long value = 0; + String[] parts = timestamp.split("\\.", 2); + String[] subparts = parts[0].split(":"); + for (int i = 0; i < subparts.length; i++) { + value = value * 60 + Long.parseLong(subparts[i]); + } + return (value * 1000 + Long.parseLong(parts[1])) * 1000; + } + +}