Signal-Android/src/org/thoughtcrime/securesms/video/InMemoryTranscoder.java

202 lines
8.2 KiB
Java

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.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.MemoryFileDescriptor;
import org.thoughtcrime.securesms.video.videoconverter.BadVideoException;
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 {
this.context = context;
this.dataSource = dataSource;
final MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
mediaMetadataRetriever.setDataSource(dataSource);
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, UndeliverableMessageException, BadVideoException {
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 UndeliverableMessageException("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 UndeliverableMessageException("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) {
return Long.parseLong(mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
}
private static boolean containsLocation(MediaMetadataRetriever mediaMetadataRetriever) {
String locationString = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION);
return locationString != null;
}
public interface Progress {
void onProgress(int percent);
}
}