Display video file output size and duration during clipping.

Prevent video upscale, i.e. use input bit rate if lower than our normal target rates.
Do not time limit videos that are under the send file size.
Increase time limit to 10 minutes to match our lowest acceptable bitrate.
This commit is contained in:
Alan Evans
2020-11-20 13:27:58 -04:00
committed by GitHub
parent abb1ca2afe
commit 89f2c25d73
13 changed files with 388 additions and 95 deletions

View File

@@ -11,11 +11,11 @@ import androidx.annotation.RequiresApi;
import com.google.android.exoplayer2.util.MimeTypes;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.media.MediaInput;
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 org.thoughtcrime.securesms.media.MediaInput;
import java.io.Closeable;
import java.io.FileDescriptor;
@@ -29,25 +29,17 @@ public final class InMemoryTranscoder implements Closeable {
private static final String TAG = Log.tag(InMemoryTranscoder.class);
private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoUtil.VIDEO_BIT_RATE;
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 = VideoUtil.AUDIO_BIT_RATE;
private static final int OUTPUT_FORMAT = VideoUtil.VIDEO_SHORT_WIDTH;
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 final @Nullable Options options;
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 VideoBitRateCalculator.Quality targetQuality;
private final long memoryFileEstimate;
private final boolean transcodeRequired;
private final long fileSizeEstimate;
private final @Nullable Options options;
private @Nullable MemoryFileDescriptor memoryFile;
@@ -67,24 +59,19 @@ public final class InMemoryTranscoder implements Closeable {
throw new VideoSourceException("Unable to read datasource", e);
}
long upperSizeLimitWithMargin = (long) (upperSizeLimit / 1.1);
this.inSize = dataSource.getSize();
this.duration = getDuration(mediaMetadataRetriever);
this.inputBitRate = VideoBitRateCalculator.bitRate(inSize, duration);
this.targetQuality = new VideoBitRateCalculator(upperSizeLimit).getTargetQuality(duration, inputBitRate);
this.upperSizeLimit = upperSizeLimit;
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) || options != null;
this.transcodeRequired = inputBitRate >= targetQuality.getTargetTotalBitRate() * 1.2 || inSize > upperSizeLimit || containsLocation(mediaMetadataRetriever) || options != null;
if (!transcodeRequired) {
Log.i(TAG, "Video is within 20% of target bitrate, below the size limit, contained no location metadata or custom options.");
}
this.fileSizeEstimate = (targetVideoBitRate + AUDIO_BITRATE) * duration / 8000;
this.fileSizeEstimate = targetQuality.getFileSizeEstimate();
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,
@@ -106,10 +93,10 @@ public final class InMemoryTranscoder implements Closeable {
"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,
numberFormat.format(targetQuality.getTargetVideoBitRate()),
numberFormat.format(targetQuality.getTargetAudioBitRate()),
numberFormat.format(targetQuality.getTargetTotalBitRate()),
targetQuality.getOutputResolution(),
durationSec,
numberFormat.format(upperSizeLimit / 1024),
numberFormat.format(fileSizeEstimate / 1024),
@@ -131,9 +118,9 @@ public final class InMemoryTranscoder implements Closeable {
converter.setInput(new MediaInput.MediaDataSourceMediaInput(dataSource));
converter.setOutput(memoryFileFileDescriptor);
converter.setVideoResolution(outputFormat);
converter.setVideoBitrate(targetVideoBitRate);
converter.setAudioBitrate(AUDIO_BITRATE);
converter.setVideoResolution(targetQuality.getOutputResolution());
converter.setVideoBitrate(targetQuality.getTargetVideoBitRate());
converter.setAudioBitrate(targetQuality.getTargetAudioBitRate());
if (options != null) {
if (options.endTimeUs > 0) {
@@ -169,7 +156,7 @@ public final class InMemoryTranscoder implements Closeable {
(outSize * 100d) / inSize,
(outSize * 100d) / fileSizeEstimate,
(outSize * 100d) / memoryFileEstimate,
numberFormat.format(bitRate(outSize, duration))));
numberFormat.format(VideoBitRateCalculator.bitRate(outSize, duration))));
if (outSize > upperSizeLimit) {
throw new VideoSizeException("Size constraints could not be met!");
@@ -191,19 +178,6 @@ public final class InMemoryTranscoder implements Closeable {
}
}
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) {

View File

@@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.video;
import org.thoughtcrime.securesms.logging.Log;
/**
* Calculates a target quality output for a video to fit within a specified size.
*/
public final class VideoBitRateCalculator {
private static final int MAXIMUM_TARGET_VIDEO_BITRATE = VideoUtil.VIDEO_BIT_RATE;
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 = VideoUtil.AUDIO_BIT_RATE;
private static final int OUTPUT_FORMAT = VideoUtil.VIDEO_SHORT_WIDTH;
private static final int LOW_RES_OUTPUT_FORMAT = 480;
private final long upperFileSizeLimitWithMargin;
public VideoBitRateCalculator(long upperFileSizeLimit) {
upperFileSizeLimitWithMargin = (long) (upperFileSizeLimit / 1.1);
}
/**
* Gets the output quality of a video of the given {@param duration}.
*/
public Quality getTargetQuality(long duration, int inputTotalBitRate) {
int maxVideoBitRate = Math.min(MAXIMUM_TARGET_VIDEO_BITRATE, inputTotalBitRate - AUDIO_BITRATE);
int minVideoBitRate = Math.min(MINIMUM_TARGET_VIDEO_BITRATE, maxVideoBitRate);
int targetVideoBitRate = Math.max(minVideoBitRate, Math.min(getTargetVideoBitRate(upperFileSizeLimitWithMargin, duration), maxVideoBitRate));
int bitRateRange = maxVideoBitRate - minVideoBitRate;
double quality = bitRateRange == 0 ? 1 : (targetVideoBitRate - minVideoBitRate) / (double) bitRateRange;
return new Quality(targetVideoBitRate, AUDIO_BITRATE, quality, duration);
}
private int getTargetVideoBitRate(long sizeGuideBytes, long duration) {
double durationSeconds = duration / 1000d;
sizeGuideBytes -= durationSeconds * AUDIO_BITRATE / 8;
double targetAttachmentSizeBits = sizeGuideBytes * 8L;
return (int) (targetAttachmentSizeBits / durationSeconds);
}
public static int bitRate(long bytes, long durationMs) {
return (int) (bytes * 8 / (durationMs / 1000f));
}
public static class Quality {
private final int targetVideoBitRate;
private final int targetAudioBitRate;
private final double quality;
private final long duration;
private Quality(int targetVideoBitRate, int targetAudioBitRate, double quality, long duration) {
this.targetVideoBitRate = targetVideoBitRate;
this.targetAudioBitRate = targetAudioBitRate;
this.quality = Math.max(0, Math.min(quality, 1));
this.duration = duration;
}
/**
* [0..1]
* <p>
* 0 = {@link #MINIMUM_TARGET_VIDEO_BITRATE}
* 1 = {@link #MAXIMUM_TARGET_VIDEO_BITRATE}
*/
public double getQuality() {
return quality;
}
public int getTargetVideoBitRate() {
return targetVideoBitRate;
}
public int getTargetAudioBitRate() {
return targetAudioBitRate;
}
public int getTargetTotalBitRate() {
return targetVideoBitRate + targetAudioBitRate;
}
public boolean useLowRes() {
return targetVideoBitRate < LOW_RES_TARGET_VIDEO_BITRATE;
}
public int getOutputResolution() {
return useLowRes() ? LOW_RES_OUTPUT_FORMAT
: OUTPUT_FORMAT;
}
public long getFileSizeEstimate() {
return getTargetTotalBitRate() * duration / 8000;
}
@Override
public String toString() {
return "Quality{" +
"targetVideoBitRate=" + targetVideoBitRate +
", targetAudioBitRate=" + targetAudioBitRate +
", quality=" + quality +
", duration=" + duration +
", filesize=" + getFileSizeEstimate() +
'}';
}
}
}

View File

@@ -12,6 +12,8 @@ import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.concurrent.TimeUnit;
public final class VideoUtil {
public static final int AUDIO_BIT_RATE = 192_000;
@@ -22,7 +24,7 @@ public final class VideoUtil {
private static final int VIDEO_LONG_WIDTH = 1280;
private static final int VIDEO_MAX_RECORD_LENGTH_S = 60;
private static final int VIDEO_MAX_UPLOAD_LENGTH_S = 120;
private static final int VIDEO_MAX_UPLOAD_LENGTH_S = (int) TimeUnit.MINUTES.toSeconds(10);
private static final int TOTAL_BYTES_PER_SECOND = (VIDEO_BIT_RATE / 8) + (AUDIO_BIT_RATE / 8);

View File

@@ -19,8 +19,10 @@ import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.MemoryUnitFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@RequiresApi(api = 23)
@@ -68,6 +70,8 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
private long dragStartTimeMs;
private long dragEndTimeMs;
private long maximumSelectableRangeMicros;
private Quality outputQuality;
private long qualityAvailableTimeMs;
public VideoThumbnailsRangeSelectorView(final Context context) {
super(context);
@@ -149,6 +153,11 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
}
}
if (onRangeChangeListener != null) {
onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), Thumb.MIN);
setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration()));
}
invalidate();
}
@@ -172,6 +181,11 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
@Override
protected void onDraw(final Canvas canvas) {
if (thumbHintTextSize > 0) {
thumbTimeTextPaint.getTextBounds("0", 0, "0".length(), tempDrawRect);
canvas.translate(0, tempDrawRect.height());
}
super.onDraw(canvas);
canvas.translate(getPaddingLeft(), getPaddingTop());
@@ -237,6 +251,8 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
if (dragEndTimeMs > 0 && (lastDragThumb == Thumb.MIN || lastDragThumb == Thumb.MAX)) {
drawTimeHint(canvas, drawableWidth, drawableHeight, lastDragThumb, true);
}
drawDurationAndSizeHint(canvas, drawableWidth);
}
// draw current position marker
@@ -292,6 +308,42 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
}
}
private void drawDurationAndSizeHint(Canvas canvas, int drawableWidth) {
if (outputQuality == null) return;
canvas.save();
long microsecondValue = getMaxValue() - getMinValue();
long seconds = TimeUnit.MICROSECONDS.toSeconds(microsecondValue);
String durationAndSize = String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, MemoryUnitFormat.formatBytes(outputQuality.fileSize, MemoryUnitFormat.MEGA_BYTES, true));
float topBottomPadding = thumbHintTextSize * 0.5f;
float leftRightPadding = thumbHintTextSize * 0.75f;
thumbTimeTextPaint.getTextBounds(durationAndSize, 0, durationAndSize.length(), tempDrawRect);
timePillRect.set(tempDrawRect.left - leftRightPadding, tempDrawRect.top - topBottomPadding, tempDrawRect.right + leftRightPadding, tempDrawRect.bottom + topBottomPadding);
float halfPillWidth = timePillRect.width() / 2f;
float halfPillHeight = timePillRect.height() / 2f;
long animationTime = Math.min(ANIMATION_DURATION_MS, System.currentTimeMillis() - qualityAvailableTimeMs);
float animationPosition = animationTime / (float) ANIMATION_DURATION_MS;
float scaleIn = 0.2f * animationPosition + 0.8f;
int alpha = (int) (255 * animationPosition);
canvas.translate(Math.max(halfPillWidth, Math.min((right + left) / 2f, drawableWidth - halfPillWidth)), - 2 * halfPillHeight);
canvas.scale(scaleIn, scaleIn);
thumbTimeBackgroundPaint.setAlpha(alpha);
thumbTimeTextPaint.setAlpha(alpha);
canvas.translate(leftRightPadding - halfPillWidth, halfPillHeight);
canvas.drawRoundRect(timePillRect, halfPillHeight, halfPillHeight, thumbTimeBackgroundPaint);
canvas.drawText(durationAndSize, 0, 0, thumbTimeTextPaint);
canvas.restore();
if (animationTime < ANIMATION_DURATION_MS) {
invalidate();
}
}
public long getMinValue() {
return minValue == null ? 0 : minValue;
}
@@ -300,6 +352,10 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
return maxValue == null ? getDuration() : maxValue;
}
public long getClipDuration() {
return getMaxValue() - getMinValue();
}
private boolean setMinValue(long minValue) {
if (this.minValue == null || this.minValue != minValue) {
return setMinMax(minValue, getMaxValue(), Thumb.MIN);
@@ -383,6 +439,7 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
onRangeChangeListener.onPositionDrag(dragPosition);
} else {
onRangeChangeListener.onRangeDrag(getMinValue(), getMaxValue(), getDuration(), dragThumb);
setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration()));
}
}
return true;
@@ -394,6 +451,7 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
onRangeChangeListener.onEndPositionDrag(dragPosition);
} else {
onRangeChangeListener.onRangeDragEnd(getMinValue(), getMaxValue(), getDuration(), dragThumb);
setOutputQuality(onRangeChangeListener.getQuality(getClipDuration(), getDuration()));
}
lastDragThumb = dragThumb;
dragEndTimeMs = System.currentTimeMillis();
@@ -410,6 +468,16 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
return true;
}
private void setOutputQuality(@Nullable Quality outputQuality) {
if (!Objects.equals(this.outputQuality, outputQuality)) {
if (this.outputQuality == null) {
qualityAvailableTimeMs = System.currentTimeMillis();
}
this.outputQuality = outputQuality;
invalidate();
}
}
private @Nullable Thumb closestThumb(@Px float x) {
float midPoint = (right + left) / 2f;
Thumb possibleThumb = x < midPoint ? Thumb.MIN : Thumb.MAX;
@@ -463,5 +531,34 @@ public final class VideoThumbnailsRangeSelectorView extends VideoThumbnailsView
void onRangeDrag(long minValue, long maxValue, long duration, Thumb thumb);
void onRangeDragEnd(long minValue, long maxValue, long duration, Thumb thumb);
@Nullable Quality getQuality(long clipDurationUs, long totalDurationUs);
}
public static final class Quality {
private final long fileSize;
private final int qualityRange;
public Quality(long fileSize, int qualityRange) {
this.fileSize = fileSize;
this.qualityRange = qualityRange;
}
@Override public boolean equals(Object o) {
if (!(o instanceof Quality)) {
return false;
}
final Quality quality = (Quality) o;
return fileSize == quality.fileSize &&
qualityRange == quality.qualityRange;
}
@Override public int hashCode() {
int result = (int) (fileSize ^ (fileSize >>> 32));
result = 31 * result + qualityRange;
return result;
}
}
}