Commit 9b062053 by andrewlewis Committed by Ian Baker

Add support for JPEG motion photo extraction

PiperOrigin-RevId: 351752989
parent 789a211d
Showing with 1075 additions and 6 deletions
...@@ -164,10 +164,10 @@ ...@@ -164,10 +164,10 @@
containers. containers.
* Fix CEA-708 anchor positioning * Fix CEA-708 anchor positioning
([#1807](https://github.com/google/ExoPlayer/issues/1807)). ([#1807](https://github.com/google/ExoPlayer/issues/1807)).
* Metadata retriever:
* Parse Google Photos HEIC motion photos metadata.
* Data sources: * Data sources:
* Use the user agent of the underlying network stack by default. * Use the user agent of the underlying network stack by default.
* Metadata retriever:
* Parse Google Photos HEIC and JPEG motion photo metadata.
* IMA extension: * IMA extension:
* Add support for playback of ads in playlists * Add support for playback of ads in playlists
([#3750](https://github.com/google/ExoPlayer/issues/3750)). ([#3750](https://github.com/google/ExoPlayer/issues/3750)).
......
...@@ -33,11 +33,13 @@ public final class FileTypes { ...@@ -33,11 +33,13 @@ public final class FileTypes {
/** /**
* File types. One of {@link #UNKNOWN}, {@link #AC3}, {@link #AC4}, {@link #ADTS}, {@link #AMR}, * File types. One of {@link #UNKNOWN}, {@link #AC3}, {@link #AC4}, {@link #ADTS}, {@link #AMR},
* {@link #FLAC}, {@link #FLV}, {@link #MATROSKA}, {@link #MP3}, {@link #MP4}, {@link #OGG}, * {@link #FLAC}, {@link #FLV}, {@link #MATROSKA}, {@link #MP3}, {@link #MP4}, {@link #OGG},
* {@link #PS}, {@link #TS}, {@link #WAV} and {@link #WEBVTT}. * {@link #PS}, {@link #TS}, {@link #WAV}, {@link #WEBVTT} and {@link #JPEG}.
*/ */
@Documented @Documented
@Retention(RetentionPolicy.SOURCE) @Retention(RetentionPolicy.SOURCE)
@IntDef({UNKNOWN, AC3, AC4, ADTS, AMR, FLAC, FLV, MATROSKA, MP3, MP4, OGG, PS, TS, WAV, WEBVTT}) @IntDef({
UNKNOWN, AC3, AC4, ADTS, AMR, FLAC, FLV, MATROSKA, MP3, MP4, OGG, PS, TS, WAV, WEBVTT, JPEG
})
public @interface Type {} public @interface Type {}
/** Unknown file type. */ /** Unknown file type. */
public static final int UNKNOWN = -1; public static final int UNKNOWN = -1;
...@@ -69,6 +71,8 @@ public final class FileTypes { ...@@ -69,6 +71,8 @@ public final class FileTypes {
public static final int WAV = 12; public static final int WAV = 12;
/** File type for the WebVTT format. */ /** File type for the WebVTT format. */
public static final int WEBVTT = 13; public static final int WEBVTT = 13;
/** File type for the JPEG format. */
public static final int JPEG = 14;
@VisibleForTesting /* package */ static final String HEADER_CONTENT_TYPE = "Content-Type"; @VisibleForTesting /* package */ static final String HEADER_CONTENT_TYPE = "Content-Type";
...@@ -99,6 +103,8 @@ public final class FileTypes { ...@@ -99,6 +103,8 @@ public final class FileTypes {
private static final String EXTENSION_WAVE = ".wave"; private static final String EXTENSION_WAVE = ".wave";
private static final String EXTENSION_VTT = ".vtt"; private static final String EXTENSION_VTT = ".vtt";
private static final String EXTENSION_WEBVTT = ".webvtt"; private static final String EXTENSION_WEBVTT = ".webvtt";
private static final String EXTENSION_JPG = ".jpg";
private static final String EXTENSION_JPEG = ".jpeg";
private FileTypes() {} private FileTypes() {}
...@@ -159,6 +165,8 @@ public final class FileTypes { ...@@ -159,6 +165,8 @@ public final class FileTypes {
return FileTypes.WAV; return FileTypes.WAV;
case MimeTypes.TEXT_VTT: case MimeTypes.TEXT_VTT:
return FileTypes.WEBVTT; return FileTypes.WEBVTT;
case MimeTypes.IMAGE_JPEG:
return FileTypes.JPEG;
default: default:
return FileTypes.UNKNOWN; return FileTypes.UNKNOWN;
} }
...@@ -219,6 +227,8 @@ public final class FileTypes { ...@@ -219,6 +227,8 @@ public final class FileTypes {
return FileTypes.WAV; return FileTypes.WAV;
} else if (filename.endsWith(EXTENSION_VTT) || filename.endsWith(EXTENSION_WEBVTT)) { } else if (filename.endsWith(EXTENSION_VTT) || filename.endsWith(EXTENSION_WEBVTT)) {
return FileTypes.WEBVTT; return FileTypes.WEBVTT;
} else if (filename.endsWith(EXTENSION_JPG) || filename.endsWith(EXTENSION_JPEG)) {
return FileTypes.JPEG;
} else { } else {
return FileTypes.UNKNOWN; return FileTypes.UNKNOWN;
} }
......
...@@ -32,6 +32,7 @@ public final class MimeTypes { ...@@ -32,6 +32,7 @@ public final class MimeTypes {
public static final String BASE_TYPE_VIDEO = "video"; public static final String BASE_TYPE_VIDEO = "video";
public static final String BASE_TYPE_AUDIO = "audio"; public static final String BASE_TYPE_AUDIO = "audio";
public static final String BASE_TYPE_TEXT = "text"; public static final String BASE_TYPE_TEXT = "text";
public static final String BASE_TYPE_IMAGE = "image";
public static final String BASE_TYPE_APPLICATION = "application"; public static final String BASE_TYPE_APPLICATION = "application";
public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4";
...@@ -113,6 +114,8 @@ public final class MimeTypes { ...@@ -113,6 +114,8 @@ public final class MimeTypes {
public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy"; public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy";
public static final String APPLICATION_AIT = BASE_TYPE_APPLICATION + "/vnd.dvb.ait"; public static final String APPLICATION_AIT = BASE_TYPE_APPLICATION + "/vnd.dvb.ait";
public static final String IMAGE_JPEG = BASE_TYPE_IMAGE + "/jpeg";
private static final ArrayList<CustomMimeType> customMimeTypes = new ArrayList<>(); private static final ArrayList<CustomMimeType> customMimeTypes = new ArrayList<>();
private static final Pattern MP4A_RFC_6381_CODEC_PATTERN = private static final Pattern MP4A_RFC_6381_CODEC_PATTERN =
......
...@@ -23,6 +23,7 @@ import androidx.annotation.Nullable; ...@@ -23,6 +23,7 @@ import androidx.annotation.Nullable;
import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.amr.AmrExtractor;
import com.google.android.exoplayer2.extractor.flac.FlacExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor;
import com.google.android.exoplayer2.extractor.flv.FlvExtractor; import com.google.android.exoplayer2.extractor.flv.FlvExtractor;
import com.google.android.exoplayer2.extractor.jpeg.JpegExtractor;
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor;
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor;
import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor;
...@@ -69,12 +70,15 @@ import java.util.Map; ...@@ -69,12 +70,15 @@ import java.util.Map;
* generally include a FLAC decoder before API 27. This can be worked around by using * generally include a FLAC decoder before API 27. This can be worked around by using
* the FLAC extension or the FFmpeg extension. * the FLAC extension or the FFmpeg extension.
* </ul> * </ul>
* <li>JPEG ({@link JpegExtractor})
* </ul> * </ul>
*/ */
public final class DefaultExtractorsFactory implements ExtractorsFactory { public final class DefaultExtractorsFactory implements ExtractorsFactory {
// Extractors order is optimized according to // Extractors order is optimized according to
// https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ.
// The JPEG extractor appears after audio/video extractors because we expect audio/video input to
// be more common.
private static final int[] DEFAULT_EXTRACTOR_ORDER = private static final int[] DEFAULT_EXTRACTOR_ORDER =
new int[] { new int[] {
FileTypes.FLV, FileTypes.FLV,
...@@ -90,6 +94,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ...@@ -90,6 +94,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory {
FileTypes.AC3, FileTypes.AC3,
FileTypes.AC4, FileTypes.AC4,
FileTypes.MP3, FileTypes.MP3,
FileTypes.JPEG,
}; };
@Nullable @Nullable
...@@ -382,6 +387,11 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { ...@@ -382,6 +387,11 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory {
case FileTypes.WAV: case FileTypes.WAV:
extractors.add(new WavExtractor()); extractors.add(new WavExtractor());
break; break;
case FileTypes.JPEG:
extractors.add(new JpegExtractor());
break;
case FileTypes.WEBVTT:
case FileTypes.UNKNOWN:
default: default:
break; break;
} }
......
/*
* Copyright 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;
import java.io.IOException;
/** An overridable {@link ExtractorInput} implementation forwarding all methods to another input. */
public class ForwardingExtractorInput implements ExtractorInput {
private final ExtractorInput input;
public ForwardingExtractorInput(ExtractorInput input) {
this.input = input;
}
@Override
public int read(byte[] target, int offset, int length) throws IOException {
return input.read(target, offset, length);
}
@Override
public boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput)
throws IOException {
return input.readFully(target, offset, length, allowEndOfInput);
}
@Override
public void readFully(byte[] target, int offset, int length) throws IOException {
input.readFully(target, offset, length);
}
@Override
public int skip(int length) throws IOException {
return input.skip(length);
}
@Override
public boolean skipFully(int length, boolean allowEndOfInput) throws IOException {
return input.skipFully(length, allowEndOfInput);
}
@Override
public void skipFully(int length) throws IOException {
input.skipFully(length);
}
@Override
public int peek(byte[] target, int offset, int length) throws IOException {
return input.peek(target, offset, length);
}
@Override
public boolean peekFully(byte[] target, int offset, int length, boolean allowEndOfInput)
throws IOException {
return input.peekFully(target, offset, length, allowEndOfInput);
}
@Override
public void peekFully(byte[] target, int offset, int length) throws IOException {
input.peekFully(target, offset, length);
}
@Override
public boolean advancePeekPosition(int length, boolean allowEndOfInput) throws IOException {
return input.advancePeekPosition(length, allowEndOfInput);
}
@Override
public void advancePeekPosition(int length) throws IOException {
input.advancePeekPosition(length);
}
@Override
public void resetPeekPosition() {
input.resetPeekPosition();
}
@Override
public long getPeekPosition() {
return input.getPeekPosition();
}
@Override
public long getPosition() {
return input.getPosition();
}
@Override
public long getLength() {
return input.getLength();
}
@Override
public <E extends Throwable> void setRetryPosition(long position, E e) throws E {
input.setRetryPosition(position, e);
}
}
/*
* Copyright 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.jpeg;
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.Format;
import com.google.android.exoplayer2.extractor.Extractor;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.extractor.ExtractorOutput;
import com.google.android.exoplayer2.extractor.PositionHolder;
import com.google.android.exoplayer2.extractor.SeekMap;
import com.google.android.exoplayer2.extractor.TrackOutput;
import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata;
import com.google.android.exoplayer2.util.ParsableByteArray;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** Extracts JPEG image using the Exif format. */
public final class JpegExtractor implements Extractor {
/** Parser states. */
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef({
STATE_READING_MARKER,
STATE_READING_SEGMENT_LENGTH,
STATE_READING_SEGMENT,
STATE_SNIFFING_MOTION_PHOTO_VIDEO,
STATE_ENDED,
})
private @interface State {}
private static final int STATE_READING_MARKER = 0;
private static final int STATE_READING_SEGMENT_LENGTH = 1;
private static final int STATE_READING_SEGMENT = 2;
private static final int STATE_SNIFFING_MOTION_PHOTO_VIDEO = 4;
private static final int STATE_ENDED = 5;
private static final int JPEG_EXIF_HEADER_LENGTH = 12;
private static final long EXIF_HEADER = 0x45786966; // Exif
private static final int MARKER_SOI = 0xFFD8; // Start of image marker
private static final int MARKER_SOS = 0xFFDA; // Start of scan (image data) marker
private static final int MARKER_APP1 = 0xFFE1; // Application data 1 marker
private static final String HEADER_XMP_APP1 = "http://ns.adobe.com/xap/1.0/";
private final ParsableByteArray scratch;
private @MonotonicNonNull ExtractorOutput extractorOutput;
@State private int state;
private int marker;
private int segmentLength;
@Nullable private MotionPhotoMetadata motionPhotoMetadata;
public JpegExtractor() {
scratch = new ParsableByteArray(JPEG_EXIF_HEADER_LENGTH);
}
@Override
public boolean sniff(ExtractorInput input) throws IOException {
// See ITU-T.81 (1992) subsection B.1.1.3 and Exif version 2.2 (2002) subsection 4.5.4.
input.peekFully(scratch.getData(), /* offset= */ 0, JPEG_EXIF_HEADER_LENGTH);
if (scratch.readUnsignedShort() != MARKER_SOI || scratch.readUnsignedShort() != MARKER_APP1) {
return false;
}
scratch.skipBytes(2); // Unused segment length
return scratch.readUnsignedInt() == EXIF_HEADER && scratch.readUnsignedShort() == 0; // Exif\0\0
}
@Override
public void init(ExtractorOutput output) {
extractorOutput = output;
}
@Override
@ReadResult
public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException {
switch (state) {
case STATE_READING_MARKER:
readMarker(input);
return RESULT_CONTINUE;
case STATE_READING_SEGMENT_LENGTH:
readSegmentLength(input);
return RESULT_CONTINUE;
case STATE_READING_SEGMENT:
readSegment(input);
return RESULT_CONTINUE;
case STATE_SNIFFING_MOTION_PHOTO_VIDEO:
if (input.getPosition() != checkNotNull(motionPhotoMetadata).videoStartPosition) {
seekPosition.position = motionPhotoMetadata.videoStartPosition;
return RESULT_SEEK;
}
sniffMotionPhotoVideo(input);
return RESULT_CONTINUE;
case STATE_ENDED:
return RESULT_END_OF_INPUT;
default:
throw new IllegalStateException();
}
}
@Override
public void seek(long position, long timeUs) {
state = STATE_READING_MARKER;
}
@Override
public void release() {
// Do nothing.
}
private void readMarker(ExtractorInput input) throws IOException {
scratch.reset(2);
input.readFully(scratch.getData(), /* offset= */ 0, /* length= */ 2);
marker = scratch.readUnsignedShort();
if (marker == MARKER_SOS) { // Start of scan.
if (motionPhotoMetadata != null) {
state = STATE_SNIFFING_MOTION_PHOTO_VIDEO;
} else {
outputTracks();
state = STATE_ENDED;
}
} else if ((marker < 0xFFD0 || marker > 0xFFD9) && marker != 0xFF01) {
state = STATE_READING_SEGMENT_LENGTH;
}
}
private void readSegmentLength(ExtractorInput input) throws IOException {
scratch.reset(2);
input.readFully(scratch.getData(), /* offset= */ 0, /* length= */ 2);
segmentLength = scratch.readUnsignedShort() - 2;
state = STATE_READING_SEGMENT;
}
private void readSegment(ExtractorInput input) throws IOException {
if (marker == MARKER_APP1) {
ParsableByteArray payload = new ParsableByteArray(segmentLength);
input.readFully(payload.getData(), /* offset= */ 0, /* length= */ segmentLength);
if (motionPhotoMetadata == null
&& HEADER_XMP_APP1.equals(payload.readNullTerminatedString())) {
@Nullable String xmpString = payload.readNullTerminatedString();
if (xmpString != null) {
motionPhotoMetadata = getMotionPhotoMetadata(xmpString, input.getLength());
}
}
} else {
input.skipFully(segmentLength);
}
state = STATE_READING_MARKER;
}
private void sniffMotionPhotoVideo(ExtractorInput input) throws IOException {
// Check if the file is truncated.
boolean peekedData =
input.peekFully(
scratch.getData(), /* offset= */ 0, /* length= */ 1, /* allowEndOfInput= */ true);
if (!peekedData) {
outputTracks();
} else {
input.resetPeekPosition();
long mp4StartPosition = input.getPosition();
StartOffsetExtractorInput mp4ExtractorInput =
new StartOffsetExtractorInput(input, mp4StartPosition);
Mp4Extractor mp4Extractor = new Mp4Extractor();
if (mp4Extractor.sniff(mp4ExtractorInput)) {
outputTracks(checkNotNull(motionPhotoMetadata));
} else {
outputTracks();
}
}
state = STATE_ENDED;
}
private void outputTracks(Metadata.Entry... metadataEntries) {
TrackOutput imageTrackOutput =
checkNotNull(extractorOutput).track(/* id= */ 0, C.TRACK_TYPE_IMAGE);
imageTrackOutput.format(
new Format.Builder().setMetadata(new Metadata(metadataEntries)).build());
extractorOutput.endTracks();
extractorOutput.seekMap(new SeekMap.Unseekable(/* durationUs= */ C.TIME_UNSET));
}
/**
* Attempts to parse the specified XMP data describing the motion photo, returning the resulting
* {@link MotionPhotoMetadata} or {@code null} if it wasn't possible to derive motion photo
* metadata.
*
* @param xmpString A string of XML containing XMP motion photo metadata to attempt to parse.
* @param inputLength The length of the input stream in bytes, or {@link C#LENGTH_UNSET} if
* unknown.
* @return The {@link MotionPhotoMetadata}, or {@code null} if it wasn't possible to derive motion
* photo metadata.
* @throws IOException If an error occurs parsing the XMP string.
*/
@Nullable
private static MotionPhotoMetadata getMotionPhotoMetadata(String xmpString, long inputLength)
throws IOException {
// Metadata defines offsets from the end of the stream, so we need the stream length to
// determine start offsets.
if (inputLength == C.LENGTH_UNSET) {
return null;
}
// Motion photos have (at least) a primary image media item and a secondary video media item.
@Nullable
MotionPhotoDescription motionPhotoDescription =
XmpMotionPhotoDescriptionParser.parse(xmpString);
if (motionPhotoDescription == null) {
return null;
}
return motionPhotoDescription.getMotionPhotoMetadata(inputLength);
}
}
/*
* Copyright 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.jpeg;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata;
import com.google.android.exoplayer2.util.MimeTypes;
import java.util.List;
/** Describes the layout and metadata of a motion photo file. */
/* package */ final class MotionPhotoDescription {
/** Describes a media item in the motion photo. */
public static final class ContainerItem {
/** The MIME type of the media item. */
public final String mime;
/** The application-specific meaning of the media item. */
public final String semantic;
/**
* The positive integer length in bytes of the media item, or 0 for primary media items and
* secondary media items that share their resource with the preceding media item.
*/
public final long length;
/**
* The number of bytes of additional padding between the end of the primary media item and the
* start of the next media item. 0 for secondary media items.
*/
public final long padding;
public ContainerItem(String mime, String semantic, long length, long padding) {
this.mime = mime;
this.semantic = semantic;
this.length = length;
this.padding = padding;
}
}
/**
* The presentation timestamp of the primary media item, in microseconds, or {@link C#TIME_UNSET}
* if unknown.
*/
public final long photoPresentationTimestampUs;
/**
* The media items represented by the motion photo file, in order. The primary media item is
* listed first, followed by any secondary media items.
*/
public final List<ContainerItem> items;
public MotionPhotoDescription(long photoPresentationTimestampUs, List<ContainerItem> items) {
this.photoPresentationTimestampUs = photoPresentationTimestampUs;
this.items = items;
}
/**
* Returns the {@link MotionPhotoMetadata} for the motion photo represented by this instance, or
* {@code null} if there wasn't enough information to derive the metadata.
*
* @param motionPhotoLength The length of the motion photo file, in bytes.
* @return The motion photo metadata, or {@code null}.
*/
@Nullable
public MotionPhotoMetadata getMotionPhotoMetadata(long motionPhotoLength) {
if (items.size() < 2) {
// We need a primary item (photo) and at least one secondary item (video).
return null;
}
// Iterate backwards through the items to find the earlier video in the list. If we find a video
// item with length zero, we need to keep scanning backwards to find the preceding item with
// non-zero length, which is the item that contains the video data.
long photoStartPosition = C.POSITION_UNSET;
long photoLength = C.LENGTH_UNSET;
long mp4StartPosition = C.POSITION_UNSET;
long mp4Length = C.LENGTH_UNSET;
boolean itemContainsMp4 = false;
long itemStartPosition = motionPhotoLength;
long itemEndPosition = motionPhotoLength;
for (int i = items.size() - 1; i >= 0; i--) {
MotionPhotoDescription.ContainerItem item = items.get(i);
itemContainsMp4 |= MimeTypes.VIDEO_MP4.equals(item.mime);
itemEndPosition = itemStartPosition;
if (i == 0) {
// Padding is only applied for the primary item.
itemStartPosition = 0;
itemEndPosition -= item.padding;
} else {
itemStartPosition -= item.length;
}
if (itemContainsMp4 && itemStartPosition != itemEndPosition) {
mp4StartPosition = itemStartPosition;
mp4Length = itemEndPosition - itemStartPosition;
// Reset in case there's another video earlier in the list.
itemContainsMp4 = false;
}
if (i == 0) {
photoStartPosition = itemStartPosition;
photoLength = itemEndPosition;
}
}
if (mp4StartPosition == C.POSITION_UNSET
|| mp4Length == C.LENGTH_UNSET
|| photoStartPosition == C.POSITION_UNSET
|| photoLength == C.LENGTH_UNSET) {
return null;
}
return new MotionPhotoMetadata(
photoStartPosition, photoLength, photoPresentationTimestampUs, mp4StartPosition, mp4Length);
}
}
/*
* Copyright 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.jpeg;
import static com.google.android.exoplayer2.util.Assertions.checkState;
import com.google.android.exoplayer2.extractor.ExtractorInput;
import com.google.android.exoplayer2.extractor.ForwardingExtractorInput;
/**
* An extractor input that wraps another extractor input and exposes data starting at a given start
* byte offset.
*
* <p>This is useful for reading data from a container that's concatenated after some prefix data
* but where the container's extractor doesn't handle a non-zero start offset (for example, because
* it seeks to absolute positions read from the container data).
*/
/* package */ final class StartOffsetExtractorInput extends ForwardingExtractorInput {
private final long startOffset;
/**
* Creates a new wrapper reading from the given start byte offset.
*
* @param input The extractor input to wrap. The reading position must be at or after the start
* offset, otherwise data could be read from before the start offset.
* @param startOffset The offset from which this extractor input provides data, in bytes.
*/
public StartOffsetExtractorInput(ExtractorInput input, long startOffset) {
super(input);
checkState(input.getPosition() >= startOffset);
this.startOffset = startOffset;
}
@Override
public long getPosition() {
return super.getPosition() - startOffset;
}
@Override
public long getPeekPosition() {
return super.getPeekPosition() - startOffset;
}
@Override
public long getLength() {
return super.getLength() - startOffset;
}
@Override
public <E extends Throwable> void setRetryPosition(long position, E e) throws E {
super.setRetryPosition(position + startOffset, e);
}
}
/*
* Copyright 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.jpeg;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ParserException;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.XmlPullParserUtil;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
/**
* Parser for motion photo metadata, handling XMP following the Motion Photo V1 and Micro Video V1b
* specifications.
*/
/* package */ final class XmpMotionPhotoDescriptionParser {
/**
* Attempts to parse the specified XMP data describing the motion photo, returning the resulting
* {@link MotionPhotoDescription} or {@code null} if it wasn't possible to derive a motion photo
* description.
*
* @param xmpString A string of XML containing XMP motion photo metadata to attempt to parse.
* @return The {@link MotionPhotoDescription}, or {@code null} if it wasn't possible to derive a
* motion photo description.
* @throws IOException If an error occurs reading data from the stream.
*/
@Nullable
public static MotionPhotoDescription parse(String xmpString) throws IOException {
try {
return parseInternal(xmpString);
} catch (XmlPullParserException | ParserException | NumberFormatException e) {
Log.w(TAG, "Ignoring unexpected XMP metadata");
return null;
}
}
private static final String TAG = "MotionPhotoXmpParser";
private static final String[] MOTION_PHOTO_ATTRIBUTE_NAMES =
new String[] {
"Camera:MotionPhoto", // Motion Photo V1
"GCamera:MotionPhoto", // Motion Photo V1 (legacy element naming)
"Camera:MicroVideo", // Micro Video V1b
"GCamera:MicroVideo", // Micro Video V1b (legacy element naming)
};
private static final String[] DESCRIPTION_MOTION_PHOTO_PRESENTATION_TIMESTAMP_ATTRIBUTE_NAMES =
new String[] {
"Camera:MotionPhotoPresentationTimestampUs", // Motion Photo V1
"GCamera:MotionPhotoPresentationTimestampUs", // Motion Photo V1 (legacy element naming)
"Camera:MicroVideoPresentationTimestampUs", // Micro Video V1b
"GCamera:MicroVideoPresentationTimestampUs", // Micro Video V1b (legacy element naming)
};
private static final String[] DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES =
new String[] {
"Camera:MicroVideoOffset", // Micro Video V1b
"GCamera:MicroVideoOffset", // Micro Video V1b (legacy element naming)
};
@Nullable
private static MotionPhotoDescription parseInternal(String xmpString)
throws XmlPullParserException, IOException {
XmlPullParserFactory xmlPullParserFactory = XmlPullParserFactory.newInstance();
XmlPullParser xpp = xmlPullParserFactory.newPullParser();
xpp.setInput(new StringReader(xmpString));
xpp.next();
if (!XmlPullParserUtil.isStartTag(xpp, "x:xmpmeta")) {
throw new ParserException("Couldn't find xmp metadata");
}
long motionPhotoPresentationTimestampUs = C.TIME_UNSET;
List<MotionPhotoDescription.ContainerItem> containerItems = ImmutableList.of();
do {
xpp.next();
if (XmlPullParserUtil.isStartTag(xpp, "rdf:Description")) {
if (!parseMotionPhotoFlagFromDescription(xpp)) {
// The motion photo flag is not set, so the file should not be treated as a motion photo.
return null;
}
motionPhotoPresentationTimestampUs =
parseMotionPhotoPresentationTimestampUsFromDescription(xpp);
containerItems = parseMicroVideoOffsetFromDescription(xpp);
} else if (XmlPullParserUtil.isStartTag(xpp, "Container:Directory")) {
containerItems = parseMotionPhotoV1Directory(xpp);
}
} while (!XmlPullParserUtil.isEndTag(xpp, "x:xmpmeta"));
if (containerItems.isEmpty()) {
// No motion photo information was parsed.
return null;
}
return new MotionPhotoDescription(motionPhotoPresentationTimestampUs, containerItems);
}
private static boolean parseMotionPhotoFlagFromDescription(XmlPullParser xpp) {
for (String attributeName : MOTION_PHOTO_ATTRIBUTE_NAMES) {
@Nullable String attributeValue = XmlPullParserUtil.getAttributeValue(xpp, attributeName);
if (attributeValue != null) {
int motionPhotoFlag = Integer.parseInt(attributeValue);
return motionPhotoFlag == 1;
}
}
return false;
}
private static long parseMotionPhotoPresentationTimestampUsFromDescription(XmlPullParser xpp) {
for (String attributeName : DESCRIPTION_MOTION_PHOTO_PRESENTATION_TIMESTAMP_ATTRIBUTE_NAMES) {
@Nullable String attributeValue = XmlPullParserUtil.getAttributeValue(xpp, attributeName);
if (attributeValue != null) {
long presentationTimestampUs = Long.parseLong(attributeValue);
return presentationTimestampUs == -1 ? C.TIME_UNSET : presentationTimestampUs;
}
}
return C.TIME_UNSET;
}
private static ImmutableList<MotionPhotoDescription.ContainerItem>
parseMicroVideoOffsetFromDescription(XmlPullParser xpp) {
// We store a new Motion Photo item list based on the MicroVideo offset, so that the same
// representation is used for both specifications.
for (String attributeName : DESCRIPTION_MICRO_VIDEO_OFFSET_ATTRIBUTE_NAMES) {
@Nullable String attributeValue = XmlPullParserUtil.getAttributeValue(xpp, attributeName);
if (attributeValue != null) {
long microVideoOffset = Long.parseLong(attributeValue);
return ImmutableList.of(
new MotionPhotoDescription.ContainerItem(
MimeTypes.IMAGE_JPEG, "Primary", /* length= */ 0, /* padding= */ 0),
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4,
"MotionPhoto",
/* length= */ microVideoOffset,
/* padding= */ 0));
}
}
return ImmutableList.of();
}
private static ImmutableList<MotionPhotoDescription.ContainerItem> parseMotionPhotoV1Directory(
XmlPullParser xpp) throws XmlPullParserException, IOException {
ImmutableList.Builder<MotionPhotoDescription.ContainerItem> containerItems =
ImmutableList.builder();
do {
xpp.next();
if (XmlPullParserUtil.isStartTag(xpp, "Container:Item")) {
@Nullable String mime = XmlPullParserUtil.getAttributeValue(xpp, "Item:Mime");
@Nullable String semantic = XmlPullParserUtil.getAttributeValue(xpp, "Item:Semantic");
@Nullable String length = XmlPullParserUtil.getAttributeValue(xpp, "Item:Length");
@Nullable String padding = XmlPullParserUtil.getAttributeValue(xpp, "Item:Padding");
if (mime == null || semantic == null) {
// Required values are missing.
return ImmutableList.of();
}
containerItems.add(
new MotionPhotoDescription.ContainerItem(
mime,
semantic,
length != null ? Long.parseLong(length) : 0,
padding != null ? Long.parseLong(padding) : 0));
}
} while (!XmlPullParserUtil.isEndTag(xpp, "Container:Directory"));
return containerItems.build();
}
private XmpMotionPhotoDescriptionParser() {}
}
/*
* Copyright 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.
*/
@NonNullApi
package com.google.android.exoplayer2.extractor.jpeg;
import com.google.android.exoplayer2.util.NonNullApi;
...@@ -22,6 +22,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; ...@@ -22,6 +22,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.amr.AmrExtractor;
import com.google.android.exoplayer2.extractor.flac.FlacExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor;
import com.google.android.exoplayer2.extractor.flv.FlvExtractor; import com.google.android.exoplayer2.extractor.flv.FlvExtractor;
import com.google.android.exoplayer2.extractor.jpeg.JpegExtractor;
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor;
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor;
import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor;
...@@ -68,7 +69,8 @@ public final class DefaultExtractorsFactoryTest { ...@@ -68,7 +69,8 @@ public final class DefaultExtractorsFactoryTest {
AdtsExtractor.class, AdtsExtractor.class,
Ac3Extractor.class, Ac3Extractor.class,
Ac4Extractor.class, Ac4Extractor.class,
Mp3Extractor.class) Mp3Extractor.class,
JpegExtractor.class)
.inOrder(); .inOrder();
} }
...@@ -109,7 +111,8 @@ public final class DefaultExtractorsFactoryTest { ...@@ -109,7 +111,8 @@ public final class DefaultExtractorsFactoryTest {
MatroskaExtractor.class, MatroskaExtractor.class,
AdtsExtractor.class, AdtsExtractor.class,
Ac3Extractor.class, Ac3Extractor.class,
Ac4Extractor.class) Ac4Extractor.class,
JpegExtractor.class)
.inOrder(); .inOrder();
} }
......
/*
* Copyright 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.jpeg;
import com.google.android.exoplayer2.testutil.ExtractorAsserts;
import com.google.common.collect.ImmutableList;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.ParameterizedRobolectricTestRunner;
/** Unit tests for {@link JpegExtractor}. */
@RunWith(ParameterizedRobolectricTestRunner.class)
public final class JpegExtractorTest {
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
public static ImmutableList<ExtractorAsserts.SimulationConfig> params() {
return ExtractorAsserts.configs();
}
@ParameterizedRobolectricTestRunner.Parameter
public ExtractorAsserts.SimulationConfig simulationConfig;
@Test
public void sampleNonMotionPhotoShortened() throws Exception {
ExtractorAsserts.assertBehavior(
JpegExtractor::new, "media/jpeg/non-motion-photo-shortened.jpg", simulationConfig);
}
@Test
public void samplePixelMotionPhotoShortened() throws Exception {
ExtractorAsserts.assertBehavior(
JpegExtractor::new, "media/jpeg/pixel-motion-photo-shortened.jpg", simulationConfig);
}
@Test
public void samplePixelMotionPhotoVideoRemovedShortened() throws Exception {
ExtractorAsserts.assertBehavior(
JpegExtractor::new,
"media/jpeg/pixel-motion-photo-video-removed-shortened.jpg",
simulationConfig);
}
@Test
public void sampleSsMotionPhotoShortened() throws Exception {
ExtractorAsserts.assertBehavior(
JpegExtractor::new, "media/jpeg/ss-motion-photo-shortened.jpg", simulationConfig);
}
}
/*
* Copyright 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.jpeg;
import static com.google.common.truth.Truth.assertThat;
import androidx.annotation.Nullable;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.metadata.mp4.MotionPhotoMetadata;
import com.google.android.exoplayer2.util.MimeTypes;
import com.google.common.collect.ImmutableList;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link MotionPhotoDescription}. */
@RunWith(AndroidJUnit4.class)
public final class MotionPhotoDescriptionTest {
private static final long TEST_PRESENTATION_TIMESTAMP_US = 5L;
private static final long TEST_MOTION_PHOTO_LENGTH_BYTES = 20;
private static final long TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES = 7;
private static final long TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES = 1;
@Test
public void getMotionPhotoMetadata_withPrimaryAndSecondaryMediaItems() {
MotionPhotoDescription motionPhotoDescription =
new MotionPhotoDescription(
TEST_PRESENTATION_TIMESTAMP_US,
ImmutableList.of(
new MotionPhotoDescription.ContainerItem(
MimeTypes.IMAGE_JPEG,
"Primary",
/* length= */ 0,
TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES),
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4,
"MotionPhoto",
TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES,
/* padding= */ 0)));
@Nullable
MotionPhotoMetadata metadata =
motionPhotoDescription.getMotionPhotoMetadata(TEST_MOTION_PHOTO_LENGTH_BYTES);
assertThat(metadata.photoStartPosition).isEqualTo(0);
assertThat(metadata.photoSize)
.isEqualTo(
TEST_MOTION_PHOTO_LENGTH_BYTES
- TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES
- TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES);
assertThat(metadata.photoPresentationTimestampUs).isEqualTo(TEST_PRESENTATION_TIMESTAMP_US);
assertThat(metadata.videoStartPosition)
.isEqualTo(TEST_MOTION_PHOTO_LENGTH_BYTES - TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES);
assertThat(metadata.videoSize).isEqualTo(TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES);
}
@Test
public void
getMotionPhotoMetadata_withPrimaryAndMultipleSecondaryMediaItems_returnsSecondMediaItemAsVideo() {
MotionPhotoDescription motionPhotoDescription =
new MotionPhotoDescription(
TEST_PRESENTATION_TIMESTAMP_US,
ImmutableList.of(
new MotionPhotoDescription.ContainerItem(
MimeTypes.IMAGE_JPEG,
"Primary",
/* length= */ 0,
TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES),
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4,
"MotionPhoto",
TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES,
/* padding= */ 0),
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4,
"MotionPhoto",
TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES,
/* padding= */ 0)));
@Nullable
MotionPhotoMetadata metadata =
motionPhotoDescription.getMotionPhotoMetadata(TEST_MOTION_PHOTO_LENGTH_BYTES);
assertThat(metadata.photoStartPosition).isEqualTo(0);
assertThat(metadata.photoSize)
.isEqualTo(
TEST_MOTION_PHOTO_LENGTH_BYTES
- TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES * 2
- TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES);
assertThat(metadata.photoPresentationTimestampUs).isEqualTo(TEST_PRESENTATION_TIMESTAMP_US);
assertThat(metadata.videoStartPosition)
.isEqualTo(TEST_MOTION_PHOTO_LENGTH_BYTES - TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES * 2);
assertThat(metadata.videoSize).isEqualTo(TEST_MOTION_PHOTO_VIDEO_LENGTH_BYTES);
}
@Test
public void
getMotionPhotoMetadata_withPrimaryAndSecondaryItemSharingData_returnsPrimaryItemAsPhotoAndVideo() {
// Theoretical example of an HEIF file that has both an image and a video represented in the
// same file, which looks like an MP4.
MotionPhotoDescription motionPhotoDescription =
new MotionPhotoDescription(
TEST_PRESENTATION_TIMESTAMP_US,
ImmutableList.of(
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4,
"Primary",
/* length= */ 0,
TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES),
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4, "MotionPhoto", /* length= */ 0, /* padding= */ 0)));
@Nullable
MotionPhotoMetadata metadata =
motionPhotoDescription.getMotionPhotoMetadata(TEST_MOTION_PHOTO_LENGTH_BYTES);
assertThat(metadata.photoStartPosition).isEqualTo(0);
assertThat(metadata.photoSize)
.isEqualTo(TEST_MOTION_PHOTO_LENGTH_BYTES - TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES);
assertThat(metadata.photoPresentationTimestampUs).isEqualTo(TEST_PRESENTATION_TIMESTAMP_US);
assertThat(metadata.videoStartPosition).isEqualTo(0);
assertThat(metadata.videoSize)
.isEqualTo(TEST_MOTION_PHOTO_LENGTH_BYTES - TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES);
}
@Test
public void getMotionPhotoMetadata_withOnlyPrimaryItem_returnsNull() {
MotionPhotoDescription motionPhotoDescription =
new MotionPhotoDescription(
TEST_PRESENTATION_TIMESTAMP_US,
ImmutableList.of(
new MotionPhotoDescription.ContainerItem(
MimeTypes.VIDEO_MP4,
"Primary",
/* length= */ 0,
TEST_MOTION_PHOTO_PHOTO_PADDING_BYTES)));
@Nullable
MotionPhotoMetadata metadata =
motionPhotoDescription.getMotionPhotoMetadata(TEST_MOTION_PHOTO_LENGTH_BYTES);
assertThat(metadata).isNull();
}
}
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[Motion photo metadata: photoStartPosition=0, photoSize=131582, photoPresentationTimestampUs=0, videoStartPosition=131582, videoSize=8730]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[Motion photo metadata: photoStartPosition=0, photoSize=20345, photoPresentationTimestampUs=-9223372036854775807, videoStartPosition=20345, videoSize=2582]
tracksEnded = true
seekMap:
isSeekable = false
duration = UNSET TIME
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 1
track 0:
total output bytes = 0
sample count = 0
format 0:
metadata = entries=[]
tracksEnded = true
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment