package org.thoughtcrime.securesms.video; import android.content.Context; import android.media.MediaDataSource; import android.media.MediaMetadataRetriever; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.google.android.exoplayer2.util.MimeTypes; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MediaStream; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; import org.thoughtcrime.securesms.video.videoconverter.EncodingException; import org.thoughtcrime.securesms.video.videoconverter.MediaConverter; import java.io.Closeable; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.IOException; import java.text.NumberFormat; import java.util.Locale; @RequiresApi(26) public final class InMemoryTranscoder implements Closeable { private static final String TAG = Log.tag(InMemoryTranscoder.class); private static final int MAXIMUM_TARGET_VIDEO_BITRATE = 2_000_000; private static final int LOW_RES_TARGET_VIDEO_BITRATE = 1_750_000; private static final int MINIMUM_TARGET_VIDEO_BITRATE = 500_000; private static final int AUDIO_BITRATE = 192_000; private static final int OUTPUT_FORMAT = 720; private static final int LOW_RES_OUTPUT_FORMAT = 480; private final Context context; private final MediaDataSource dataSource; private final long upperSizeLimit; private final long inSize; private final long duration; private final int inputBitRate; private final int targetVideoBitRate; private final long memoryFileEstimate; private final boolean transcodeRequired; private final long fileSizeEstimate; private final int outputFormat; private @Nullable MemoryFileDescriptor memoryFile; /** * @param upperSizeLimit A upper size to transcode to. The actual output size can be up to 10% smaller. */ public InMemoryTranscoder(@NonNull Context context, @NonNull MediaDataSource dataSource, long upperSizeLimit) throws IOException, VideoSourceException { this.context = context; this.dataSource = dataSource; final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); try { mediaMetadataRetriever.setDataSource(dataSource); } catch (RuntimeException e) { Log.w(TAG, "Unable to read datasource", e); throw new VideoSourceException("Unable to read datasource", e); } long upperSizeLimitWithMargin = (long) (upperSizeLimit / 1.1); this.inSize = dataSource.getSize(); this.duration = getDuration(mediaMetadataRetriever); this.inputBitRate = bitRate(inSize, duration); this.targetVideoBitRate = getTargetVideoBitRate(upperSizeLimitWithMargin, duration); this.upperSizeLimit = upperSizeLimit; this.transcodeRequired = inputBitRate >= targetVideoBitRate * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever); if (!transcodeRequired) { Log.i(TAG, "Video is within 20% of target bitrate, below the size limit and contained no location metadata."); } this.fileSizeEstimate = (targetVideoBitRate + AUDIO_BITRATE) * duration / 8000; this.memoryFileEstimate = (long) (fileSizeEstimate * 1.1); this.outputFormat = targetVideoBitRate < LOW_RES_TARGET_VIDEO_BITRATE ? LOW_RES_OUTPUT_FORMAT : OUTPUT_FORMAT; } public @NonNull MediaStream transcode(@NonNull Progress progress) throws IOException, EncodingException, VideoSizeException { if (memoryFile != null) throw new AssertionError("Not expecting to reuse transcoder"); float durationSec = duration / 1000f; NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); Log.i(TAG, String.format(Locale.US, "Transcoding:\n" + "Target bitrate : %s + %s = %s\n" + "Target format : %dp\n" + "Video duration : %.1fs\n" + "Size limit : %s kB\n" + "Estimate : %s kB\n" + "Input size : %s kB\n" + "Input bitrate : %s bps", numberFormat.format(targetVideoBitRate), numberFormat.format(AUDIO_BITRATE), numberFormat.format(targetVideoBitRate + AUDIO_BITRATE), outputFormat, durationSec, numberFormat.format(upperSizeLimit / 1024), numberFormat.format(fileSizeEstimate / 1024), numberFormat.format(inSize / 1024), numberFormat.format(inputBitRate))); if (fileSizeEstimate > upperSizeLimit) { throw new VideoSizeException("Size constraints could not be met!"); } memoryFile = MemoryFileDescriptor.newMemoryFileDescriptor(context, "TRANSCODE", memoryFileEstimate); final long startTime = System.currentTimeMillis(); final FileDescriptor memoryFileFileDescriptor = memoryFile.getFileDescriptor(); final MediaConverter converter = new MediaConverter(); converter.setInput(dataSource); converter.setOutput(memoryFileFileDescriptor); converter.setVideoResolution(outputFormat); converter.setVideoBitrate(targetVideoBitRate); converter.setAudioBitrate(AUDIO_BITRATE); converter.setListener(percent -> { progress.onProgress(percent); return false; }); converter.convert(); // output details of the transcoding long outSize = memoryFile.size(); float encodeDurationSec = (System.currentTimeMillis() - startTime) / 1000f; Log.i(TAG, String.format(Locale.US, "Transcoding complete:\n" + "Transcode time : %.1fs (%.1fx)\n" + "Output size : %s kB\n" + " of Original : %.1f%%\n" + " of Estimate : %.1f%%\n" + " of Memory : %.1f%%\n" + "Output bitrate : %s bps", encodeDurationSec, durationSec / encodeDurationSec, numberFormat.format(outSize / 1024), (outSize * 100d) / inSize, (outSize * 100d) / fileSizeEstimate, (outSize * 100d) / memoryFileEstimate, numberFormat.format(bitRate(outSize, duration)))); if (outSize > upperSizeLimit) { throw new VideoSizeException("Size constraints could not be met!"); } memoryFile.seek(0); return new MediaStream(new FileInputStream(memoryFileFileDescriptor), MimeTypes.VIDEO_MP4, 0, 0); } public boolean isTranscodeRequired() { return transcodeRequired; } @Override public void close() throws IOException { if (memoryFile != null) { memoryFile.close(); } } private static int bitRate(long bytes, long duration) { return (int) (bytes * 8 / (duration / 1000f)); } private static int getTargetVideoBitRate(long sizeGuideBytes, long duration) { sizeGuideBytes -= (duration / 1000d) * AUDIO_BITRATE / 8; double targetAttachmentSizeBits = sizeGuideBytes * 8L; double bitRateToFixTarget = targetAttachmentSizeBits / (duration / 1000d); return Math.max(MINIMUM_TARGET_VIDEO_BITRATE, Math.min(MAXIMUM_TARGET_VIDEO_BITRATE, (int) bitRateToFixTarget)); } private static long getDuration(MediaMetadataRetriever mediaMetadataRetriever) throws VideoSourceException { String durationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); if (durationString == null) { throw new VideoSourceException("Cannot determine duration of video, null meta data"); } try { long duration = Long.parseLong(durationString); if (duration <= 0) { throw new VideoSourceException("Cannot determine duration of video, meta data: " + durationString); } return duration; } catch (NumberFormatException e) { throw new VideoSourceException("Cannot determine duration of video, meta data: " + durationString, e); } } private static boolean containsLocation(MediaMetadataRetriever mediaMetadataRetriever) { String locationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION); return locationString != null; } public interface Progress { void onProgress(int percent); } }