mirror of
https://github.com/androidx/media.git
synced 2025-05-07 23:50:44 +08:00
Add special WebvttExtractor for HLS.
This is the main component required to enable WebVTT subtitles in HLS. It passes through each WebVTT file as a sample, and derives the correct, adjusted timestamp for each of them on the way through. Not yet wired up because we need to properly share the same PtsTimestampAdjuster everywhere, and also stop instantiating new instances of the adjuster. The adjuster will also need to correctly handle discontinuities, since we'll no longer be creating new instances of it. Issue: #151
This commit is contained in:
parent
1a9b2be551
commit
c2df814b58
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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<TsPayloadReader> 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.
|
||||
|
@ -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.
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
@ -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<WebvttCue> 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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user