diff --git a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java index 1c1e0524d1..674c8abd2b 100644 --- a/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java +++ b/libraries/common/src/main/java/androidx/media3/common/MimeTypes.java @@ -40,6 +40,7 @@ public final class MimeTypes { // video/ MIME types public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; + @UnstableApi public static final String VIDEO_QUICK_TIME = BASE_TYPE_VIDEO + "/quicktime"; @UnstableApi public static final String VIDEO_MATROSKA = BASE_TYPE_VIDEO + "/x-matroska"; public static final String VIDEO_WEBM = BASE_TYPE_VIDEO + "/webm"; public static final String VIDEO_H263 = BASE_TYPE_VIDEO + "/3gpp"; diff --git a/libraries/muxer/src/main/java/androidx/media3/muxer/MotionPhotoMuxer.java b/libraries/muxer/src/main/java/androidx/media3/muxer/MotionPhotoMuxer.java new file mode 100644 index 0000000000..2e63c78566 --- /dev/null +++ b/libraries/muxer/src/main/java/androidx/media3/muxer/MotionPhotoMuxer.java @@ -0,0 +1,247 @@ +/* + * Copyright 2025 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 androidx.media3.muxer; + +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; + +import androidx.media3.common.C; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.Locale; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A muxer for creating a motion photo file. */ +@UnstableApi +public final class MotionPhotoMuxer implements AutoCloseable { + private static final int SEGMENT_MARKER_LENGTH = 2; + private static final int SEGMENT_SIZE_LENGTH = 2; + private static final short SOI_MARKER = (short) 0xFFD8; + private static final short APP1_MARKER = (short) 0xFFE1; + private static final short SOS_MARKER = (short) 0xFFDA; + private static final short EOI_MARKER = (short) 0xFFD9; + private static final String JPEG_XMP_IDENTIFIER = "http://ns.adobe.com/xap/1.0/\u0000"; + + private final SeekableMuxerOutput muxerOutput; + + private @MonotonicNonNull ByteBuffer imageData; + private int imageDataStartIndex; + private int imageDataEndIndex; + private @MonotonicNonNull String imageMimeType; + private long imagePresentationTimestampUs; + private boolean addedImageData; + private @MonotonicNonNull FileInputStream videoInputStream; + private @MonotonicNonNull String videoContainerMimeType; + private boolean addedVideoData; + + /** + * Creates a new instance. + * + * @param muxerOutputFactory A {@link MuxerOutputFactory} to provide output destinations. + */ + public MotionPhotoMuxer(MuxerOutputFactory muxerOutputFactory) { + muxerOutput = muxerOutputFactory.getSeekableMuxerOutput(); + imageDataStartIndex = C.INDEX_UNSET; + imageDataEndIndex = C.INDEX_UNSET; + imagePresentationTimestampUs = C.TIME_UNSET; + } + + /** + * Adds the image to the muxer. + * + * @param byteBuffer The image data. + * @param mimeType The mime type of the image. Must be {@link MimeTypes#IMAGE_JPEG}. + * @param presentationTimestampUs The presentation timestamp of the image in the video (in + * microseconds). + */ + public void addImageData(ByteBuffer byteBuffer, String mimeType, long presentationTimestampUs) { + checkState(!addedImageData, "Image data already added"); + checkArgument(mimeType.equals(MimeTypes.IMAGE_JPEG), "Only JPEG mime type is supported"); + imageData = byteBuffer.asReadOnlyBuffer(); + imageDataStartIndex = imageData.position(); + imageDataEndIndex = imageData.limit(); + imageMimeType = mimeType; + imagePresentationTimestampUs = presentationTimestampUs; + addedImageData = true; + } + + /** + * Adds the video to the muxer. + * + * @param inputStream A {@link FileInputStream} containing the video data. The stream will be + * automatically closed by the muxer when {@link MotionPhotoMuxer#close()} is called. + * @param containerMimeType The container mime type of the video. Must be {@link + * MimeTypes#VIDEO_MP4} or {@link MimeTypes#VIDEO_QUICK_TIME}. + */ + public void addVideoData(FileInputStream inputStream, String containerMimeType) { + checkState(!addedVideoData, "Video data already added"); + checkArgument( + containerMimeType.equals(MimeTypes.VIDEO_MP4) + || containerMimeType.equals(MimeTypes.VIDEO_QUICK_TIME), "Only MP4 and QUICKTIME container mime types are supported"); + videoInputStream = inputStream; + videoContainerMimeType = containerMimeType; + addedVideoData = true; + } + + /** + * Closes the file. + * + *
The muxer cannot be used anymore once this method returns.
+ *
+ * @throws MuxerException If the muxer fails to finish writing the output.
+ */
+ @Override
+ public void close() throws MuxerException {
+ try {
+ writeImageDataToMuxerOutput();
+ } catch (IOException e) {
+ throw new MuxerException("Error writing image data", e);
+ }
+ try {
+ writeVideoDataToMuxerOutput();
+ } catch (IOException e) {
+ throw new MuxerException("Error writing video data", e);
+ }
+ try {
+ checkNotNull(videoInputStream).close();
+ } catch (IOException e) {
+ throw new MuxerException("Failed to close video input stream", e);
+ }
+ try {
+ muxerOutput.close();
+ } catch (IOException e) {
+ throw new MuxerException("Failed to close muxer output", e);
+ }
+ }
+
+ private static ByteBuffer getApp1SegmentWithMotionPhotoXmpDate(byte[] motionPhotoXmp) {
+ short totalSegmentLength =
+ (short) (SEGMENT_SIZE_LENGTH + JPEG_XMP_IDENTIFIER.length() + motionPhotoXmp.length);
+ ByteBuffer byteBuffer = ByteBuffer.allocateDirect(SEGMENT_MARKER_LENGTH + totalSegmentLength);
+ byteBuffer.putShort(APP1_MARKER);
+ byteBuffer.putShort(totalSegmentLength);
+ byteBuffer.put(Util.getUtf8Bytes(JPEG_XMP_IDENTIFIER));
+ byteBuffer.put(motionPhotoXmp);
+ byteBuffer.flip();
+ return byteBuffer;
+ }
+
+ private static byte[] generateMotionPhotoXmp(
+ long imagePresentationTimestampUs,
+ String imageMimeType,
+ String videoContainerMimeType,
+ long videoSize) {
+ String motionPhotoXmp =
+ String.format(
+ Locale.US,
+ "