From 79db618ba63195c08bfda64c5a3b50906e36f5ef Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Wed, 15 Jul 2015 18:53:32 +0100 Subject: [PATCH] Add support for header stripping in Matroska streams. Issue: #589 --- .../extractor/webm/WebmExtractor.java | 151 ++++++++++++------ .../extractor/webm/StreamBuilder.java | 43 +++-- .../extractor/webm/WebmExtractorTest.java | 59 +++++-- 3 files changed, 181 insertions(+), 72 deletions(-) diff --git a/library/src/main/java/com/google/android/exoplayer/extractor/webm/WebmExtractor.java b/library/src/main/java/com/google/android/exoplayer/extractor/webm/WebmExtractor.java index ccc94ec3c3..6056340e32 100644 --- a/library/src/main/java/com/google/android/exoplayer/extractor/webm/WebmExtractor.java +++ b/library/src/main/java/com/google/android/exoplayer/extractor/webm/WebmExtractor.java @@ -113,7 +113,9 @@ public final class WebmExtractor implements Extractor { private static final int ID_CONTENT_ENCODING = 0x6240; private static final int ID_CONTENT_ENCODING_ORDER = 0x5031; private static final int ID_CONTENT_ENCODING_SCOPE = 0x5032; - private static final int ID_CONTENT_ENCODING_TYPE = 0x5033; + private static final int ID_CONTENT_COMPRESSION = 0x5034; + private static final int ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254; + private static final int ID_CONTENT_COMPRESSION_SETTINGS = 0x4255; private static final int ID_CONTENT_ENCRYPTION = 0x5035; private static final int ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1; private static final int ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2; @@ -139,6 +141,7 @@ public final class WebmExtractor implements Extractor { private final ParsableByteArray scratch; private final ParsableByteArray vorbisNumPageSamples; private final ParsableByteArray seekEntryIdBytes; + private final ParsableByteArray sampleStrippedBytes; private long segmentContentPosition = UNKNOWN; private long segmentContentSize = UNKNOWN; @@ -178,7 +181,7 @@ public final class WebmExtractor implements Extractor { // Sample reading state. private int sampleBytesRead; - private boolean sampleEncryptionDataRead; + private boolean sampleEncodingHandled; private int sampleCurrentNalBytesRemaining; private int sampleBytesWritten; private boolean sampleRead; @@ -200,6 +203,7 @@ public final class WebmExtractor implements Extractor { seekEntryIdBytes = new ParsableByteArray(4); nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); nalLength = new ParsableByteArray(4); + sampleStrippedBytes = new ParsableByteArray(); } @Override @@ -213,10 +217,7 @@ public final class WebmExtractor implements Extractor { blockState = BLOCK_STATE_START; reader.reset(); varintReader.reset(); - sampleCurrentNalBytesRemaining = 0; - sampleBytesRead = 0; - sampleBytesWritten = 0; - sampleEncryptionDataRead = false; + resetSample(); } @Override @@ -247,6 +248,7 @@ public final class WebmExtractor implements Extractor { case ID_VIDEO: case ID_CONTENT_ENCODINGS: case ID_CONTENT_ENCODING: + case ID_CONTENT_COMPRESSION: case ID_CONTENT_ENCRYPTION: case ID_CONTENT_ENCRYPTION_AES_SETTINGS: case ID_CUES: @@ -269,7 +271,7 @@ public final class WebmExtractor implements Extractor { case ID_CHANNELS: case ID_CONTENT_ENCODING_ORDER: case ID_CONTENT_ENCODING_SCOPE: - case ID_CONTENT_ENCODING_TYPE: + case ID_CONTENT_COMPRESSION_ALGORITHM: case ID_CONTENT_ENCRYPTION_ALGORITHM: case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: case ID_CUE_TIME: @@ -280,6 +282,7 @@ public final class WebmExtractor implements Extractor { case ID_CODEC_ID: return EbmlReader.TYPE_STRING; case ID_SEEK_ID: + case ID_CONTENT_COMPRESSION_SETTINGS: case ID_CONTENT_ENCRYPTION_KEY_ID: case ID_SIMPLE_BLOCK: case ID_BLOCK: @@ -371,17 +374,20 @@ public final class WebmExtractor implements Extractor { blockState = BLOCK_STATE_START; return; case ID_CONTENT_ENCODING: - if (!trackFormat.hasContentEncryption) { - // We found a ContentEncoding other than Encryption. - throw new ParserException("Found an unsupported ContentEncoding"); + if (trackFormat.hasContentEncryption) { + if (trackFormat.encryptionKeyId == null) { + throw new ParserException("Encrypted Track found but ContentEncKeyID was not found"); + } + if (!sentDrmInitData) { + extractorOutput.drmInitData( + new DrmInitData.Universal(MimeTypes.VIDEO_WEBM, trackFormat.encryptionKeyId)); + sentDrmInitData = true; + } } - if (trackFormat.encryptionKeyId == null) { - throw new ParserException("Encrypted Track found but ContentEncKeyID was not found"); - } - if (!sentDrmInitData) { - extractorOutput.drmInitData( - new DrmInitData.Universal(MimeTypes.VIDEO_WEBM, trackFormat.encryptionKeyId)); - sentDrmInitData = true; + return; + case ID_CONTENT_ENCODINGS: + if (trackFormat.hasContentEncryption && trackFormat.sampleStrippedBytes != null) { + throw new ParserException("Combining encryption and compression is not supported"); } return; case ID_TRACK_ENTRY: @@ -474,16 +480,15 @@ public final class WebmExtractor implements Extractor { } return; case ID_CONTENT_ENCODING_SCOPE: - // This extractor only supports the scope of all frames (since that's the only scope used - // for Encryption). + // This extractor only supports the scope of all frames. if (value != 1) { throw new ParserException("ContentEncodingScope " + value + " not supported"); } return; - case ID_CONTENT_ENCODING_TYPE: - // This extractor only supports Encrypted ContentEncodingType. - if (value != 1) { - throw new ParserException("ContentEncodingType " + value + " not supported"); + case ID_CONTENT_COMPRESSION_ALGORITHM: + // This extractor only supports header stripping. + if (value != 3) { + throw new ParserException("ContentCompAlgo " + value + " not supported"); } return; case ID_CONTENT_ENCRYPTION_ALGORITHM: @@ -560,6 +565,11 @@ public final class WebmExtractor implements Extractor { trackFormat.codecPrivate = new byte[contentSize]; input.readFully(trackFormat.codecPrivate, 0, contentSize); return; + case ID_CONTENT_COMPRESSION_SETTINGS: + // This extractor only supports header stripping, so the payload is the stripped bytes. + trackFormat.sampleStrippedBytes = new byte[contentSize]; + input.readFully(trackFormat.sampleStrippedBytes, 0, contentSize); + return; case ID_CONTENT_ENCRYPTION_KEY_ID: trackFormat.encryptionKeyId = new byte[contentSize]; input.readFully(trackFormat.encryptionKeyId, 0, contentSize); @@ -714,9 +724,15 @@ public final class WebmExtractor implements Extractor { private void outputSampleMetadata(TrackOutput trackOutput, long timeUs) { trackOutput.sampleMetadata(timeUs, blockFlags, sampleBytesWritten, 0, blockEncryptionKeyId); sampleRead = true; + resetSample(); + } + + private void resetSample() { sampleBytesRead = 0; sampleBytesWritten = 0; - sampleEncryptionDataRead = false; + sampleCurrentNalBytesRemaining = 0; + sampleEncodingHandled = false; + sampleStrippedBytes.reset(); } /** @@ -738,26 +754,30 @@ public final class WebmExtractor implements Extractor { private void writeSampleData(ExtractorInput input, TrackOutput output, TrackFormat format, int size) throws IOException, InterruptedException { - // Read the sample's encryption signal byte and set the IV size if necessary. - if (format.hasContentEncryption && !sampleEncryptionDataRead) { - // Clear the encrypted flag. - blockFlags &= ~C.SAMPLE_FLAG_ENCRYPTED; - input.readFully(scratch.data, 0, 1); - sampleBytesRead++; - if ((scratch.data[0] & 0x80) == 0x80) { - throw new ParserException("Extension bit is set in signal byte"); - } - sampleEncryptionDataRead = true; - - // If the sample is encrypted, write the IV size instead of the signal byte, and set the flag. - if ((scratch.data[0] & 0x01) == 0x01) { - scratch.data[0] = (byte) ENCRYPTION_IV_SIZE; - scratch.setPosition(0); - output.sampleData(scratch, 1); - sampleBytesWritten++; - blockFlags |= C.SAMPLE_FLAG_ENCRYPTED; + if (!sampleEncodingHandled) { + if (format.hasContentEncryption) { + // If the sample is encrypted, read its encryption signal byte and set the IV size. + // Clear the encrypted flag. + blockFlags &= ~C.SAMPLE_FLAG_ENCRYPTED; + input.readFully(scratch.data, 0, 1); + sampleBytesRead++; + if ((scratch.data[0] & 0x80) == 0x80) { + throw new ParserException("Extension bit is set in signal byte"); + } + if ((scratch.data[0] & 0x01) == 0x01) { + scratch.data[0] = (byte) ENCRYPTION_IV_SIZE; + scratch.setPosition(0); + output.sampleData(scratch, 1); + sampleBytesWritten++; + blockFlags |= C.SAMPLE_FLAG_ENCRYPTED; + } + } else if (format.sampleStrippedBytes != null) { + // If the sample has header stripping, prepare to read/output the stripped bytes first. + sampleStrippedBytes.reset(format.sampleStrippedBytes, format.sampleStrippedBytes.length); } + sampleEncodingHandled = true; } + size += sampleStrippedBytes.limit(); if (CODEC_ID_H264.equals(format.codecId)) { // TODO: Deduplicate with Mp4Extractor. @@ -776,28 +796,23 @@ public final class WebmExtractor implements Extractor { while (sampleBytesRead < size) { if (sampleCurrentNalBytesRemaining == 0) { // Read the NAL length so that we know where we find the next one. - input.readFully(nalLengthData, nalUnitLengthFieldLengthDiff, + readToTarget(input, nalLengthData, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); nalLength.setPosition(0); sampleCurrentNalBytesRemaining = nalLength.readUnsignedIntToInt(); // Write a start code for the current NAL unit. nalStartCode.setPosition(0); output.sampleData(nalStartCode, 4); - sampleBytesRead += nalUnitLengthFieldLength; sampleBytesWritten += 4; } else { // Write the payload of the NAL unit. - int writtenBytes = output.sampleData(input, sampleCurrentNalBytesRemaining); - sampleCurrentNalBytesRemaining -= writtenBytes; - sampleBytesRead += writtenBytes; - sampleBytesWritten += writtenBytes; + sampleCurrentNalBytesRemaining -= + readToOutput(input, output, sampleCurrentNalBytesRemaining); } } } else { while (sampleBytesRead < size) { - int writtenBytes = output.sampleData(input, size - sampleBytesRead); - sampleBytesRead += writtenBytes; - sampleBytesWritten += writtenBytes; + readToOutput(input, output, size - sampleBytesRead); } } @@ -814,6 +829,39 @@ public final class WebmExtractor implements Extractor { } } + /** + * Writes {@code length} bytes of sample data into {@code target} at {@code offset}, consisting of + * pending {@link #sampleStrippedBytes} and any remaining data read from {@code input}. + */ + private void readToTarget(ExtractorInput input, byte[] target, int offset, int length) + throws IOException, InterruptedException { + int pendingStrippedBytes = Math.min(length, sampleStrippedBytes.bytesLeft()); + input.readFully(target, offset + pendingStrippedBytes, length - pendingStrippedBytes); + if (pendingStrippedBytes > 0) { + sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes); + } + sampleBytesRead += length; + } + + /** + * Outputs up to {@code length} bytes of sample data to {@code output}, consisting of either + * {@link #sampleStrippedBytes} or data read from {@code input}. + */ + private int readToOutput(ExtractorInput input, TrackOutput output, int length) + throws IOException, InterruptedException { + int bytesRead; + int strippedBytesLeft = sampleStrippedBytes.bytesLeft(); + if (strippedBytesLeft > 0) { + bytesRead = Math.min(length, strippedBytesLeft); + output.sampleData(sampleStrippedBytes, bytesRead); + } else { + bytesRead = output.sampleData(input, length); + } + sampleBytesRead += bytesRead; + sampleBytesWritten += bytesRead; + return bytesRead; + } + /** * Builds a {@link ChunkIndex} containing recently gathered Cues information. * @@ -958,6 +1006,7 @@ public final class WebmExtractor implements Extractor { public int type = UNKNOWN; public int defaultSampleDurationNs = UNKNOWN; public boolean hasContentEncryption; + public byte[] sampleStrippedBytes; public byte[] encryptionKeyId; public byte[] codecPrivate; diff --git a/library/src/test/java/com/google/android/exoplayer/extractor/webm/StreamBuilder.java b/library/src/test/java/com/google/android/exoplayer/extractor/webm/StreamBuilder.java index be4b9ed9da..100904ed35 100644 --- a/library/src/test/java/com/google/android/exoplayer/extractor/webm/StreamBuilder.java +++ b/library/src/test/java/com/google/android/exoplayer/extractor/webm/StreamBuilder.java @@ -28,7 +28,7 @@ import java.util.List; */ /* package */ final class StreamBuilder { - /** Used by {@link #addVp9Track} to create a Track header with Encryption. */ + /** Used by {@link #addVp9Track} to create a track header with encryption/compression. */ public static final class ContentEncodingSettings { private final int order; @@ -36,14 +36,24 @@ import java.util.List; private final int type; private final int algorithm; private final int aesCipherMode; + private final byte[] strippedBytes; - public ContentEncodingSettings(int order, int scope, int type, int algorithm, - int aesCipherMode) { + public ContentEncodingSettings(int order, int scope, int algorithm, int aesCipherMode) { this.order = order; this.scope = scope; - this.type = type; + this.type = 1; // Encryption this.algorithm = algorithm; this.aesCipherMode = aesCipherMode; + this.strippedBytes = null; + } + + public ContentEncodingSettings(int order, int scope, int algorithm, byte[] strippedBytes) { + this.order = order; + this.scope = scope; + this.type = 0; // Compression + this.algorithm = algorithm; + this.aesCipherMode = 0; + this.strippedBytes = strippedBytes; } } @@ -225,6 +235,23 @@ import java.util.List; byte[] heightBytes = getIntegerBytes(pixelHeight); EbmlElement contentEncodingSettingsElement; if (contentEncodingSettings != null) { + EbmlElement encryptionOrCompressionElement; + if (contentEncodingSettings.type == 0) { + encryptionOrCompressionElement = element(0x5034, // ContentCompression + element(0x4254, (byte) (contentEncodingSettings.algorithm & 0xFF)), // ContentCompAlgo + element(0x4255, contentEncodingSettings.strippedBytes)); // ContentCompSettings + } else if (contentEncodingSettings.type == 1) { + encryptionOrCompressionElement = element(0x5035, // ContentEncryption + // ContentEncAlgo + element(0x47E1, (byte) (contentEncodingSettings.algorithm & 0xFF)), + element(0x47E2, TEST_ENCRYPTION_KEY_ID), // ContentEncKeyID + element(0x47E7, // ContentEncAESSettings + // AESSettingsCipherMode + element(0x47E8, (byte) (contentEncodingSettings.aesCipherMode & 0xFF)))); + } else { + throw new IllegalArgumentException("Unexpected encoding type."); + } + contentEncodingSettingsElement = element(0x6D80, // ContentEncodings element(0x6240, // ContentEncoding @@ -234,13 +261,7 @@ import java.util.List; element(0x5032, (byte) (contentEncodingSettings.scope & 0xFF)), // ContentEncodingType element(0x5033, (byte) (contentEncodingSettings.type & 0xFF)), - element(0x5035, // ContentEncryption - // ContentEncAlgo - element(0x47E1, (byte) (contentEncodingSettings.algorithm & 0xFF)), - element(0x47E2, TEST_ENCRYPTION_KEY_ID), // ContentEncKeyID - element(0x47E7, // ContentEncAESSettings - // AESSettingsCipherMode - element(0x47E8, (byte) (contentEncodingSettings.aesCipherMode & 0xFF)))))); + encryptionOrCompressionElement)); } else { contentEncodingSettingsElement = empty(); } diff --git a/library/src/test/java/com/google/android/exoplayer/extractor/webm/WebmExtractorTest.java b/library/src/test/java/com/google/android/exoplayer/extractor/webm/WebmExtractorTest.java index 6b9492cc96..aff52f091a 100644 --- a/library/src/test/java/com/google/android/exoplayer/extractor/webm/WebmExtractorTest.java +++ b/library/src/test/java/com/google/android/exoplayer/extractor/webm/WebmExtractorTest.java @@ -205,7 +205,7 @@ public final class WebmExtractorTest extends InstrumentationTestCase { } public void testPrepareContentEncodingEncryption() throws IOException, InterruptedException { - ContentEncodingSettings settings = new StreamBuilder.ContentEncodingSettings(0, 1, 1, 5, 1); + ContentEncodingSettings settings = new StreamBuilder.ContentEncodingSettings(0, 1, 5, 1); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -305,7 +305,7 @@ public final class WebmExtractorTest extends InstrumentationTestCase { } public void testPrepareInvalidContentEncodingOrder() throws IOException, InterruptedException { - ContentEncodingSettings settings = new ContentEncodingSettings(1, 1, 1, 5, 1); + ContentEncodingSettings settings = new ContentEncodingSettings(1, 1, 5, 1); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -320,7 +320,7 @@ public final class WebmExtractorTest extends InstrumentationTestCase { } public void testPrepareInvalidContentEncodingScope() throws IOException, InterruptedException { - ContentEncodingSettings settings = new ContentEncodingSettings(0, 0, 1, 5, 1); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 0, 5, 1); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -334,8 +334,9 @@ public final class WebmExtractorTest extends InstrumentationTestCase { } } - public void testPrepareInvalidContentEncodingType() throws IOException, InterruptedException { - ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 0, 5, 1); + public void testPrepareInvalidContentCompAlgo() + throws IOException, InterruptedException { + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 0, new byte[0]); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -345,12 +346,12 @@ public final class WebmExtractorTest extends InstrumentationTestCase { TestUtil.consumeTestData(extractor, data); fail(); } catch (ParserException exception) { - assertEquals("ContentEncodingType 0 not supported", exception.getMessage()); + assertEquals("ContentCompAlgo 0 not supported", exception.getMessage()); } } public void testPrepareInvalidContentEncAlgo() throws IOException, InterruptedException { - ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 4, 1); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 4, 1); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -365,7 +366,7 @@ public final class WebmExtractorTest extends InstrumentationTestCase { } public void testPrepareInvalidAESSettingsCipherMode() throws IOException, InterruptedException { - ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 0); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 5, 0); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -395,6 +396,44 @@ public final class WebmExtractorTest extends InstrumentationTestCase { assertSample(0, media, 0, true, false, null, getVideoOutput()); } + public void testReadSampleKeyframeStripped() throws IOException, InterruptedException { + byte[] strippedBytes = new byte[] {-1, -1}; + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 3, strippedBytes); + byte[] sampleBytes = createFrameData(100); + byte[] unstrippedSampleBytes = TestUtil.joinByteArrays(strippedBytes, sampleBytes); + byte[] data = new StreamBuilder() + .setHeader(WEBM_DOC_TYPE) + .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) + .addVp9Track(TEST_WIDTH, TEST_HEIGHT, settings) + .addSimpleBlockMedia(1 /* trackNumber */, 0 /* clusterTimecode */, 0 /* blockTimecode */, + true /* keyframe */, false /* invisible */, sampleBytes) + .build(1); + + TestUtil.consumeTestData(extractor, data); + + assertVp9VideoFormat(); + assertSample(0, unstrippedSampleBytes, 0, true, false, null, getVideoOutput()); + } + + public void testReadSampleKeyframeManyBytesStripped() throws IOException, InterruptedException { + byte[] strippedBytes = createFrameData(100); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 3, strippedBytes); + byte[] sampleBytes = createFrameData(5); + byte[] unstrippedSampleBytes = TestUtil.joinByteArrays(strippedBytes, sampleBytes); + byte[] data = new StreamBuilder() + .setHeader(WEBM_DOC_TYPE) + .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) + .addVp9Track(TEST_WIDTH, TEST_HEIGHT, settings) + .addSimpleBlockMedia(1 /* trackNumber */, 0 /* clusterTimecode */, 0 /* blockTimecode */, + true /* keyframe */, false /* invisible */, sampleBytes) + .build(1); + + TestUtil.consumeTestData(extractor, data); + + assertVp9VideoFormat(); + assertSample(0, unstrippedSampleBytes, 0, true, false, null, getVideoOutput()); + } + public void testReadTwoTrackSamples() throws IOException, InterruptedException { byte[] media = createFrameData(100); byte[] data = new StreamBuilder() @@ -479,7 +518,7 @@ public final class WebmExtractorTest extends InstrumentationTestCase { public void testReadEncryptedFrame() throws IOException, InterruptedException { byte[] media = createFrameData(100); - ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 1); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 5, 1); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US) @@ -498,7 +537,7 @@ public final class WebmExtractorTest extends InstrumentationTestCase { public void testReadEncryptedFrameWithInvalidSignalByte() throws IOException, InterruptedException { byte[] media = createFrameData(100); - ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 1, 5, 1); + ContentEncodingSettings settings = new ContentEncodingSettings(0, 1, 5, 1); byte[] data = new StreamBuilder() .setHeader(WEBM_DOC_TYPE) .setInfo(DEFAULT_TIMECODE_SCALE, TEST_DURATION_US)