diff --git a/RELEASENOTES.md b/RELEASENOTES.md index e47dbef808..50b7bb0f99 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -164,10 +164,10 @@ containers. * Fix CEA-708 anchor positioning ([#1807](https://github.com/google/ExoPlayer/issues/1807)). -* Metadata retriever: - * Parse Google Photos HEIC motion photos metadata. * Data sources: * Use the user agent of the underlying network stack by default. +* Metadata retriever: + * Parse Google Photos HEIC and JPEG motion photo metadata. * IMA extension: * Add support for playback of ads in playlists ([#3750](https://github.com/google/ExoPlayer/issues/3750)). diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java index d4b87abfdd..53396e135b 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/FileTypes.java @@ -33,11 +33,13 @@ public final class FileTypes { /** * 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 #PS}, {@link #TS}, {@link #WAV} and {@link #WEBVTT}. + * {@link #PS}, {@link #TS}, {@link #WAV}, {@link #WEBVTT} and {@link #JPEG}. */ @Documented @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 {} /** Unknown file type. */ public static final int UNKNOWN = -1; @@ -69,6 +71,8 @@ public final class FileTypes { public static final int WAV = 12; /** File type for the WebVTT format. */ 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"; @@ -99,6 +103,8 @@ public final class FileTypes { private static final String EXTENSION_WAVE = ".wave"; private static final String EXTENSION_VTT = ".vtt"; private static final String EXTENSION_WEBVTT = ".webvtt"; + private static final String EXTENSION_JPG = ".jpg"; + private static final String EXTENSION_JPEG = ".jpeg"; private FileTypes() {} @@ -159,6 +165,8 @@ public final class FileTypes { return FileTypes.WAV; case MimeTypes.TEXT_VTT: return FileTypes.WEBVTT; + case MimeTypes.IMAGE_JPEG: + return FileTypes.JPEG; default: return FileTypes.UNKNOWN; } @@ -219,6 +227,8 @@ public final class FileTypes { return FileTypes.WAV; } else if (filename.endsWith(EXTENSION_VTT) || filename.endsWith(EXTENSION_WEBVTT)) { return FileTypes.WEBVTT; + } else if (filename.endsWith(EXTENSION_JPG) || filename.endsWith(EXTENSION_JPEG)) { + return FileTypes.JPEG; } else { return FileTypes.UNKNOWN; } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java index d6dd67ee7d..13cf6b18c3 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/util/MimeTypes.java @@ -32,6 +32,7 @@ public final class MimeTypes { public static final String BASE_TYPE_VIDEO = "video"; public static final String BASE_TYPE_AUDIO = "audio"; 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 VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; @@ -113,6 +114,8 @@ public final class MimeTypes { 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 IMAGE_JPEG = BASE_TYPE_IMAGE + "/jpeg"; + private static final ArrayList customMimeTypes = new ArrayList<>(); private static final Pattern MP4A_RFC_6381_CODEC_PATTERN = diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java index 2068853d9e..ff887000a3 100644 --- a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -23,6 +23,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.extractor.amr.AmrExtractor; import com.google.android.exoplayer2.extractor.flac.FlacExtractor; 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.mp3.Mp3Extractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; @@ -69,12 +70,15 @@ import java.util.Map; * generally include a FLAC decoder before API 27. This can be worked around by using * the FLAC extension or the FFmpeg extension. * + *
  • JPEG ({@link JpegExtractor}) * */ public final class DefaultExtractorsFactory implements ExtractorsFactory { // Extractors order is optimized according to // 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 = new int[] { FileTypes.FLV, @@ -90,6 +94,7 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { FileTypes.AC3, FileTypes.AC4, FileTypes.MP3, + FileTypes.JPEG, }; @Nullable @@ -382,6 +387,11 @@ public final class DefaultExtractorsFactory implements ExtractorsFactory { case FileTypes.WAV: extractors.add(new WavExtractor()); break; + case FileTypes.JPEG: + extractors.add(new JpegExtractor()); + break; + case FileTypes.WEBVTT: + case FileTypes.UNKNOWN: default: break; } diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ForwardingExtractorInput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ForwardingExtractorInput.java new file mode 100644 index 0000000000..2a9726fac2 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/ForwardingExtractorInput.java @@ -0,0 +1,110 @@ +/* + * 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 void setRetryPosition(long position, E e) throws E { + input.setRetryPosition(position, e); + } +} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/JpegExtractor.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/JpegExtractor.java new file mode 100644 index 0000000000..da38bb19ce --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/JpegExtractor.java @@ -0,0 +1,236 @@ +/* + * 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); + } +} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/MotionPhotoDescription.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/MotionPhotoDescription.java new file mode 100644 index 0000000000..3117dfa5f4 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/MotionPhotoDescription.java @@ -0,0 +1,122 @@ +/* + * 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 items; + + public MotionPhotoDescription(long photoPresentationTimestampUs, List 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); + } +} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/StartOffsetExtractorInput.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/StartOffsetExtractorInput.java new file mode 100644 index 0000000000..225a408387 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/StartOffsetExtractorInput.java @@ -0,0 +1,67 @@ +/* + * 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. + * + *

    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 void setRetryPosition(long position, E e) throws E { + super.setRetryPosition(position + startOffset, e); + } +} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/XmpMotionPhotoDescriptionParser.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/XmpMotionPhotoDescriptionParser.java new file mode 100644 index 0000000000..3273edc2d7 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/XmpMotionPhotoDescriptionParser.java @@ -0,0 +1,183 @@ +/* + * 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 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 + 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 parseMotionPhotoV1Directory( + XmlPullParser xpp) throws XmlPullParserException, IOException { + ImmutableList.Builder 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() {} +} diff --git a/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/package-info.java b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/package-info.java new file mode 100644 index 0000000000..7e0522b275 --- /dev/null +++ b/library/extractor/src/main/java/com/google/android/exoplayer2/extractor/jpeg/package-info.java @@ -0,0 +1,19 @@ +/* + * 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; diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java index ba10f56a51..1c6ce7b70c 100644 --- a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/DefaultExtractorsFactoryTest.java @@ -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.flac.FlacExtractor; 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.mp3.Mp3Extractor; import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; @@ -68,7 +69,8 @@ public final class DefaultExtractorsFactoryTest { AdtsExtractor.class, Ac3Extractor.class, Ac4Extractor.class, - Mp3Extractor.class) + Mp3Extractor.class, + JpegExtractor.class) .inOrder(); } @@ -109,7 +111,8 @@ public final class DefaultExtractorsFactoryTest { MatroskaExtractor.class, AdtsExtractor.class, Ac3Extractor.class, - Ac4Extractor.class) + Ac4Extractor.class, + JpegExtractor.class) .inOrder(); } diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/jpeg/JpegExtractorTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/jpeg/JpegExtractorTest.java new file mode 100644 index 0000000000..9166f335a7 --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/jpeg/JpegExtractorTest.java @@ -0,0 +1,61 @@ +/* + * 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 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); + } +} diff --git a/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/jpeg/MotionPhotoDescriptionTest.java b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/jpeg/MotionPhotoDescriptionTest.java new file mode 100644 index 0000000000..6c068d2587 --- /dev/null +++ b/library/extractor/src/test/java/com/google/android/exoplayer2/extractor/jpeg/MotionPhotoDescriptionTest.java @@ -0,0 +1,157 @@ +/* + * 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(); + } +} diff --git a/testdata/src/test/assets/extractordumps/jpeg/non-motion-photo-shortened.jpg.0.dump b/testdata/src/test/assets/extractordumps/jpeg/non-motion-photo-shortened.jpg.0.dump new file mode 100644 index 0000000000..db94ad32bc --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/non-motion-photo-shortened.jpg.0.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/non-motion-photo-shortened.jpg.unknown_length.dump b/testdata/src/test/assets/extractordumps/jpeg/non-motion-photo-shortened.jpg.unknown_length.dump new file mode 100644 index 0000000000..db94ad32bc --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/non-motion-photo-shortened.jpg.unknown_length.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-shortened.jpg.0.dump b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-shortened.jpg.0.dump new file mode 100644 index 0000000000..cb4bccefa5 --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-shortened.jpg.0.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-shortened.jpg.unknown_length.dump b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-shortened.jpg.unknown_length.dump new file mode 100644 index 0000000000..db94ad32bc --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-shortened.jpg.unknown_length.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-video-removed-shortened.jpg.0.dump b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-video-removed-shortened.jpg.0.dump new file mode 100644 index 0000000000..db94ad32bc --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-video-removed-shortened.jpg.0.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-video-removed-shortened.jpg.unknown_length.dump b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-video-removed-shortened.jpg.unknown_length.dump new file mode 100644 index 0000000000..db94ad32bc --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/pixel-motion-photo-video-removed-shortened.jpg.unknown_length.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/ss-motion-photo-shortened.jpg.0.dump b/testdata/src/test/assets/extractordumps/jpeg/ss-motion-photo-shortened.jpg.0.dump new file mode 100644 index 0000000000..b9b6c3b614 --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/ss-motion-photo-shortened.jpg.0.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/extractordumps/jpeg/ss-motion-photo-shortened.jpg.unknown_length.dump b/testdata/src/test/assets/extractordumps/jpeg/ss-motion-photo-shortened.jpg.unknown_length.dump new file mode 100644 index 0000000000..db94ad32bc --- /dev/null +++ b/testdata/src/test/assets/extractordumps/jpeg/ss-motion-photo-shortened.jpg.unknown_length.dump @@ -0,0 +1,11 @@ +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 diff --git a/testdata/src/test/assets/media/jpeg/non-motion-photo-shortened.jpg b/testdata/src/test/assets/media/jpeg/non-motion-photo-shortened.jpg new file mode 100644 index 0000000000..f5eb777e83 Binary files /dev/null and b/testdata/src/test/assets/media/jpeg/non-motion-photo-shortened.jpg differ diff --git a/testdata/src/test/assets/media/jpeg/pixel-motion-photo-shortened.jpg b/testdata/src/test/assets/media/jpeg/pixel-motion-photo-shortened.jpg new file mode 100644 index 0000000000..59d178d78c Binary files /dev/null and b/testdata/src/test/assets/media/jpeg/pixel-motion-photo-shortened.jpg differ diff --git a/testdata/src/test/assets/media/jpeg/pixel-motion-photo-video-removed-shortened.jpg b/testdata/src/test/assets/media/jpeg/pixel-motion-photo-video-removed-shortened.jpg new file mode 100644 index 0000000000..a1568781c4 Binary files /dev/null and b/testdata/src/test/assets/media/jpeg/pixel-motion-photo-video-removed-shortened.jpg differ diff --git a/testdata/src/test/assets/media/jpeg/ss-motion-photo-shortened.jpg b/testdata/src/test/assets/media/jpeg/ss-motion-photo-shortened.jpg new file mode 100644 index 0000000000..900fb481a2 Binary files /dev/null and b/testdata/src/test/assets/media/jpeg/ss-motion-photo-shortened.jpg differ