From c0a0708fc382172e85cd377d4de771e8bd99fc2e Mon Sep 17 00:00:00 2001 From: samrobinson Date: Tue, 27 Oct 2020 19:44:12 +0000 Subject: [PATCH] Extract SEF slow motion cues as Metadata PiperOrigin-RevId: 339307746 --- .../android/exoplayer2/MetadataRetriever.java | 5 +- .../extractor/mp4/Mp4Extractor.java | 63 ++++- .../exoplayer2/extractor/mp4/SefReader.java | 222 ++++++++++++++++++ 3 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java index 5a00cd66f8..6deb792c1b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MetadataRetriever.java @@ -48,7 +48,7 @@ public final class MetadataRetriever { * *

This is equivalent to using {@link #retrieveMetadata(MediaSourceFactory, MediaItem)} with a * {@link DefaultMediaSourceFactory} and a {@link DefaultExtractorsFactory} with {@link - * Mp4Extractor#FLAG_READ_MOTION_PHOTO_METADATA} set. + * Mp4Extractor#FLAG_READ_MOTION_PHOTO_METADATA} and {@link Mp4Extractor#FLAG_READ_SEF_DATA} set. * * @param context The {@link Context}. * @param mediaItem The {@link MediaItem} whose metadata should be retrieved. @@ -58,7 +58,8 @@ public final class MetadataRetriever { Context context, MediaItem mediaItem) { ExtractorsFactory extractorsFactory = new DefaultExtractorsFactory() - .setMp4ExtractorFlags(Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA); + .setMp4ExtractorFlags( + Mp4Extractor.FLAG_READ_MOTION_PHOTO_METADATA | Mp4Extractor.FLAG_READ_SEF_DATA); MediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(context, extractorsFactory); return retrieveMetadata(mediaSourceFactory, mediaItem); diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java index ed386a1541..5e5848a017 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -41,6 +41,7 @@ import com.google.android.exoplayer2.extractor.TrackOutput; import com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata; +import com.google.android.exoplayer2.metadata.mp4.SefSlowMotion; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.NalUnitUtil; @@ -65,17 +66,20 @@ public final class Mp4Extractor implements Extractor, SeekMap { /** * Flags controlling the behavior of the extractor. Possible flag values are {@link - * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS} and {@link #FLAG_READ_MOTION_PHOTO_METADATA}. + * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS}, {@link #FLAG_READ_MOTION_PHOTO_METADATA} and {@link + * #FLAG_READ_SEF_DATA}. */ @Documented @Retention(RetentionPolicy.SOURCE) @IntDef( flag = true, - value = {FLAG_WORKAROUND_IGNORE_EDIT_LISTS, FLAG_READ_MOTION_PHOTO_METADATA}) + value = { + FLAG_WORKAROUND_IGNORE_EDIT_LISTS, + FLAG_READ_MOTION_PHOTO_METADATA, + FLAG_READ_SEF_DATA + }) public @interface Flags {} - /** - * Flag to ignore any edit lists in the stream. - */ + /** Flag to ignore any edit lists in the stream. */ public static final int FLAG_WORKAROUND_IGNORE_EDIT_LISTS = 1; /** * Flag to extract {@link MotionPhotoMetadata} from HEIC motion photos following the Google Photos @@ -85,16 +89,27 @@ public final class Mp4Extractor implements Extractor, SeekMap { * retrieval use cases. */ public static final int FLAG_READ_MOTION_PHOTO_METADATA = 1 << 1; + /** + * Flag to extract {@link SefSlowMotion} metadata from Samsung Extension Format (SEF) slow motion + * videos. + */ + public static final int FLAG_READ_SEF_DATA = 1 << 2; /** Parser states. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({STATE_READING_ATOM_HEADER, STATE_READING_ATOM_PAYLOAD, STATE_READING_SAMPLE}) + @IntDef({ + STATE_READING_ATOM_HEADER, + STATE_READING_ATOM_PAYLOAD, + STATE_READING_SAMPLE, + STATE_READING_SEF, + }) private @interface State {} private static final int STATE_READING_ATOM_HEADER = 0; private static final int STATE_READING_ATOM_PAYLOAD = 1; private static final int STATE_READING_SAMPLE = 2; + private static final int STATE_READING_SEF = 3; /** Supported file types. */ @Documented @@ -127,6 +142,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { private final ParsableByteArray atomHeader; private final ArrayDeque containerAtoms; + private final SefReader sefReader; + private final List slowMotionMetadataEntries; @State private int parserState; private int atomType; @@ -153,7 +170,7 @@ public final class Mp4Extractor implements Extractor, SeekMap { * Creates a new extractor for unfragmented MP4 streams. */ public Mp4Extractor() { - this(0); + this(/* flags= */ 0); } /** @@ -164,6 +181,10 @@ public final class Mp4Extractor implements Extractor, SeekMap { */ public Mp4Extractor(@Flags int flags) { this.flags = flags; + parserState = + ((flags & FLAG_READ_SEF_DATA) != 0) ? STATE_READING_SEF : STATE_READING_ATOM_HEADER; + sefReader = new SefReader(); + slowMotionMetadataEntries = new ArrayList<>(); atomHeader = new ParsableByteArray(Atom.LONG_HEADER_SIZE); containerAtoms = new ArrayDeque<>(); nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); @@ -192,7 +213,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { sampleBytesWritten = 0; sampleCurrentNalBytesRemaining = 0; if (position == 0) { - enterReadingAtomHeaderState(); + // Reading the SEF data occurs before normal MP4 parsing. Therefore we can not transition to + // reading the atom header until that has completed. + if (parserState != STATE_READING_SEF) { + enterReadingAtomHeaderState(); + } else { + sefReader.reset(); + slowMotionMetadataEntries.clear(); + } } else if (tracks != null) { updateSampleIndices(timeUs); } @@ -219,6 +247,8 @@ public final class Mp4Extractor implements Extractor, SeekMap { break; case STATE_READING_SAMPLE: return readSample(input, seekPosition); + case STATE_READING_SEF: + return readSefData(input, seekPosition); default: throw new IllegalStateException(); } @@ -396,6 +426,15 @@ public final class Mp4Extractor implements Extractor, SeekMap { return seekRequired && parserState != STATE_READING_SAMPLE; } + @ReadResult + private int readSefData(ExtractorInput input, PositionHolder seekPosition) throws IOException { + @ReadResult int result = sefReader.read(input, seekPosition, slowMotionMetadataEntries); + if (result == RESULT_SEEK && seekPosition.position == 0) { + enterReadingAtomHeaderState(); + } + return result; + } + private void processAtomEnded(long atomEndPosition) throws ParserException { while (!containerAtoms.isEmpty() && containerAtoms.peek().endPosition == atomEndPosition) { Atom.ContainerAtom containerAtom = containerAtoms.pop(); @@ -474,8 +513,14 @@ public final class Mp4Extractor implements Extractor, SeekMap { float frameRate = trackSampleTable.sampleCount / (trackDurationUs / 1000000f); formatBuilder.setFrameRate(frameRate); } + MetadataUtil.setFormatMetadata( - track.type, udtaMetadata, mdtaMetadata, gaplessInfoHolder, formatBuilder); + track.type, + udtaMetadata, + mdtaMetadata, + gaplessInfoHolder, + formatBuilder, + /* additionalEntries...= */ slowMotionMetadataEntries.toArray(new Metadata.Entry[0])); mp4Track.trackOutput.format(formatBuilder.build()); if (track.type == C.TRACK_TYPE_VIDEO && firstVideoTrackIndex == C.INDEX_UNSET) { diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java new file mode 100644 index 0000000000..2978e0f714 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/mp4/SefReader.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2020 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.extractor.mp4; + +import static com.google.android.exoplayer2.extractor.Extractor.RESULT_SEEK; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ParserException; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.mp4.SefSlowMotion; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.common.base.Splitter; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Reads Samsung Extension Format (SEF) metadata. + * + *

To be used in conjunction with {@link Mp4Extractor}. + */ +/* package */ final class SefReader { + + /** Reader states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_SHOULD_CHECK_FOR_SEF, + STATE_CHECKING_FOR_SEF, + STATE_READING_SDRS, + STATE_READING_SEF_DATA + }) + private @interface State {} + + private static final int STATE_SHOULD_CHECK_FOR_SEF = 0; + private static final int STATE_CHECKING_FOR_SEF = 1; + private static final int STATE_READING_SDRS = 2; + private static final int STATE_READING_SEF_DATA = 3; + + /** Supported data types. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_SLOW_MOTION_DATA}) + private @interface DataType {} + + private static final int TYPE_SLOW_MOTION_DATA = 0x0890; + + private static final String TAG = "SefReader"; + + // Hex representation of `SEFT` (in ASCII). This is the last byte of a file that has Samsung + // Extension Format (SEF) data. + private static final int SAMSUNG_TAIL_SIGNATURE = 0x53454654; + + // Start signature (4 bytes), SEF version (4 bytes), SDR count (4 bytes). + private static final int TAIL_HEADER_LENGTH = 12; + // Tail offset (4 bytes), tail signature (4 bytes). + private static final int TAIL_FOOTER_LENGTH = 8; + private static final int LENGTH_OF_ONE_SDR = 12; + + private final List dataReferences; + @State private int readerState; + private int tailLength; + + public SefReader() { + dataReferences = new ArrayList<>(); + readerState = STATE_SHOULD_CHECK_FOR_SEF; + } + + public void reset() { + dataReferences.clear(); + readerState = STATE_SHOULD_CHECK_FOR_SEF; + } + + @Extractor.ReadResult + public int read( + ExtractorInput input, + PositionHolder seekPosition, + List slowMotionMetadataEntries) + throws IOException { + switch (readerState) { + case STATE_SHOULD_CHECK_FOR_SEF: + long inputLength = input.getLength(); + seekPosition.position = + inputLength == C.LENGTH_UNSET || inputLength < TAIL_FOOTER_LENGTH + ? 0 + : inputLength - TAIL_FOOTER_LENGTH; + readerState = STATE_CHECKING_FOR_SEF; + break; + case STATE_CHECKING_FOR_SEF: + checkForSefData(input, seekPosition); + break; + case STATE_READING_SDRS: + readSdrs(input, seekPosition); + break; + case STATE_READING_SEF_DATA: + readSefData(input, slowMotionMetadataEntries); + seekPosition.position = 0; + break; + default: + throw new IllegalStateException(); + } + return RESULT_SEEK; + } + + private void checkForSefData(ExtractorInput input, PositionHolder seekPosition) + throws IOException { + ParsableByteArray scratch = new ParsableByteArray(/* limit= */ TAIL_FOOTER_LENGTH); + input.readFully(scratch.getData(), /* offset= */ 0, /* length= */ TAIL_FOOTER_LENGTH); + tailLength = scratch.readLittleEndianInt() + TAIL_FOOTER_LENGTH; + if (scratch.readInt() != SAMSUNG_TAIL_SIGNATURE) { + seekPosition.position = 0; + return; + } + + // input.getPosition is at the very end of the tail, so jump forward by sefTailLength, but + // account for the tail header, which needs to be ignored. + seekPosition.position = input.getPosition() - (tailLength - TAIL_HEADER_LENGTH); + readerState = STATE_READING_SDRS; + } + + private void readSdrs(ExtractorInput input, PositionHolder seekPosition) throws IOException { + long streamLength = input.getLength(); + int sdrsLength = tailLength - TAIL_HEADER_LENGTH - TAIL_FOOTER_LENGTH; + ParsableByteArray scratch = new ParsableByteArray(/* limit= */ sdrsLength); + input.readFully(scratch.getData(), /* offset= */ 0, /* length= */ sdrsLength); + + for (int i = 0; i < sdrsLength / LENGTH_OF_ONE_SDR; i++) { + scratch.skipBytes(2); // SDR data sub info flag and reserved bits (2). + @DataType int dataType = scratch.readLittleEndianShort(); + if (dataType == TYPE_SLOW_MOTION_DATA) { + // The read int is the distance from the tail info to the start of the metadata. + // Calculated as an offset from the start by working backwards. + long startOffset = streamLength - tailLength - scratch.readLittleEndianInt(); + int size = scratch.readLittleEndianInt(); + dataReferences.add(new DataReference(dataType, startOffset, size)); + } else { + scratch.skipBytes(8); // startPosition (4), size (4). + } + } + + if (dataReferences.isEmpty()) { + seekPosition.position = 0; + return; + } + + Collections.sort(dataReferences, (o1, o2) -> Long.compare(o1.startOffset, o2.startOffset)); + readerState = STATE_READING_SEF_DATA; + seekPosition.position = dataReferences.get(0).startOffset; + } + + private void readSefData(ExtractorInput input, List slowMotionMetadataEntries) + throws IOException { + checkNotNull(dataReferences); + Splitter splitter = Splitter.on(':'); + int totalDataLength = (int) (input.getLength() - input.getPosition() - tailLength); + ParsableByteArray scratch = new ParsableByteArray(/* limit= */ totalDataLength); + input.readFully(scratch.getData(), 0, totalDataLength); + + int totalDataReferenceBytesConsumed = 0; + for (int i = 0; i < dataReferences.size(); i++) { + DataReference dataReference = dataReferences.get(i); + if (dataReference.dataType == TYPE_SLOW_MOTION_DATA) { + scratch.skipBytes(23); // data type (2), data sub info (2), name len (4), name (15). + List segments = new ArrayList<>(); + int dataReferenceEndPosition = totalDataReferenceBytesConsumed + dataReference.size; + while (scratch.getPosition() < dataReferenceEndPosition) { + @Nullable String data = scratch.readDelimiterTerminatedString('*'); + List values = splitter.splitToList(checkNotNull(data)); + if (values.size() != 3) { + throw new ParserException(); + } + try { + int startTimeMs = Integer.parseInt(values.get(0)); + int endTimeMs = Integer.parseInt(values.get(1)); + int speedMode = Integer.parseInt(values.get(2)); + int speedDivisor = 1 << (speedMode - 1); + segments.add(new SefSlowMotion.Segment(startTimeMs, endTimeMs, speedDivisor)); + } catch (NumberFormatException e) { + throw new ParserException(e); + } + } + totalDataReferenceBytesConsumed += dataReference.size; + slowMotionMetadataEntries.add(new SefSlowMotion(segments)); + } + } + } + + private static final class DataReference { + @DataType public final int dataType; + public final long startOffset; + public final int size; + + public DataReference(@DataType int dataType, long startOffset, int size) { + this.dataType = dataType; + this.startOffset = startOffset; + this.size = size; + } + } +}