diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 0a24b01f1..04efb10ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -256,7 +256,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med } else if (!Util.isEmpty(media)) { viewModel.onSelectedMediaChanged(this, media); - Fragment fragment = MediaSendFragment.newInstance(Locale.getDefault()); + Fragment fragment = MediaSendFragment.newInstance(); getSupportFragmentManager().beginTransaction() .replace(R.id.mediasend_fragment_container, fragment, TAG_SEND) .commit(); @@ -306,7 +306,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med sendButton.setTransport(transport); sendButton.disableTransport(transport.getType() == TransportOption.Type.SMS ? TransportOption.Type.TEXTSECURE : TransportOption.Type.SMS); - countButton.setOnClickListener(v -> navigateToMediaSend(Locale.getDefault())); + countButton.setOnClickListener(v -> navigateToMediaSend()); composeText.append(viewModel.getBody()); @@ -368,7 +368,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med @Override public void onMediaSelected(@NonNull Media media) { viewModel.onSingleMediaSelected(this, media); - navigateToMediaSend(Locale.getDefault()); + navigateToMediaSend(); } @Override @@ -458,7 +458,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med Log.i(TAG, "Camera capture stored: " + media.getUri().toString()); viewModel.onMediaCaptured(media); - navigateToMediaSend(Locale.getDefault()); + navigateToMediaSend(); }); } @@ -469,7 +469,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med @Override public void onCameraCountButtonClicked() { - navigateToMediaSend(Locale.getDefault()); + navigateToMediaSend(); } @Override @@ -841,8 +841,8 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med } - private void navigateToMediaSend(@NonNull Locale locale) { - MediaSendFragment fragment = MediaSendFragment.newInstance(locale); + private void navigateToMediaSend() { + MediaSendFragment fragment = MediaSendFragment.newInstance(); String backstackTag = null; if (getSupportFragmentManager().findFragmentByTag(TAG_SEND) != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index 738b3c8b8..0cfcac53c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -14,10 +14,10 @@ import androidx.viewpager.widget.ViewPager; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.ControllableViewPager; +import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.util.Util; import java.util.List; -import java.util.Locale; import java.util.Map; /** @@ -25,20 +25,14 @@ import java.util.Map; */ public class MediaSendFragment extends Fragment { - private static final String TAG = MediaSendFragment.class.getSimpleName(); - - private static final String KEY_LOCALE = "locale"; - private ViewGroup playbackControlsContainer; private ControllableViewPager fragmentPager; private MediaSendFragmentPagerAdapter fragmentPagerAdapter; private MediaSendViewModel viewModel; - - public static MediaSendFragment newInstance(@NonNull Locale locale) { + public static MediaSendFragment newInstance() { Bundle args = new Bundle(); - args.putSerializable(KEY_LOCALE, locale); MediaSendFragment fragment = new MediaSendFragment(); fragment.setArguments(args); @@ -61,8 +55,7 @@ public class MediaSendFragment extends Fragment { fragmentPager = view.findViewById(R.id.mediasend_pager); playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container); - - fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager()); + fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(getChildFragmentManager(), viewModel.isSms() ? MediaConstraints.getMmsMediaConstraints(-1) : MediaConstraints.getPushMediaConstraints()); fragmentPager.setAdapter(fragmentPagerAdapter); FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java index d5bc186ff..531261f9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.mediasend; import android.net.Uri; +import android.net.http.LoggingEventHandler; import android.view.View; import android.view.ViewGroup; @@ -12,6 +13,10 @@ import androidx.fragment.app.FragmentStatePagerAdapter; import com.annimon.stream.Stream; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.MediaConstraints; +import org.thoughtcrime.securesms.mms.PushMediaConstraints; import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.util.MediaUtil; @@ -25,12 +30,14 @@ class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { private final List media; private final Map fragments; private final Map savedState; + private final MediaConstraints mediaConstraints; - MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm) { + MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm, @NonNull MediaConstraints mediaConstraints) { super(fm); - this.media = new ArrayList<>(); - this.fragments = new HashMap<>(); - this.savedState = new HashMap<>(); + this.mediaConstraints = mediaConstraints; + this.media = new ArrayList<>(); + this.fragments = new HashMap<>(); + this.savedState = new HashMap<>(); } @Override @@ -42,7 +49,9 @@ class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { } else if (MediaUtil.isImageType(mediaItem.getMimeType())) { return ImageEditorFragment.newInstance(mediaItem.getUri()); } else if (MediaUtil.isVideoType(mediaItem.getMimeType())) { - return MediaSendVideoFragment.newInstance(mediaItem.getUri()); + return MediaSendVideoFragment.newInstance(mediaItem.getUri(), + mediaConstraints.getCompressedVideoMaxSize(ApplicationDependencies.getApplication()), + mediaConstraints.getVideoMaxSize(ApplicationDependencies.getApplication())); } else { throw new UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.getMimeType() + "'"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java index 0d0faab65..e17123541 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.scribbles.VideoEditorHud; import org.thoughtcrime.securesms.util.Throttler; +import org.thoughtcrime.securesms.video.VideoBitRateCalculator; import org.thoughtcrime.securesms.video.VideoPlayer; import java.io.IOException; @@ -28,7 +29,9 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E private static final String TAG = Log.tag(MediaSendVideoFragment.class); - private static final String KEY_URI = "uri"; + private static final String KEY_URI = "uri"; + private static final String KEY_MAX_OUTPUT = "max_output_size"; + private static final String KEY_MAX_SEND = "max_send_size"; private final Throttler videoScanThrottle = new Throttler(150); private final Handler handler = new Handler(); @@ -40,9 +43,11 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E @Nullable private VideoEditorHud hud; private Runnable updatePosition; - public static MediaSendVideoFragment newInstance(@NonNull Uri uri) { + public static MediaSendVideoFragment newInstance(@NonNull Uri uri, long maxCompressedVideoSize, long maxAttachmentSize) { Bundle args = new Bundle(); args.putParcelable(KEY_URI, uri); + args.putLong(KEY_MAX_OUTPUT, maxCompressedVideoSize); + args.putLong(KEY_MAX_SEND, maxAttachmentSize); MediaSendVideoFragment fragment = new MediaSendVideoFragment(); fragment.setArguments(args); @@ -71,7 +76,9 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E player = view.findViewById(R.id.video_player); uri = requireArguments().getParcelable(KEY_URI); - VideoSlide slide = new VideoSlide(requireContext(), uri, 0); + long maxOutput = requireArguments().getLong(KEY_MAX_OUTPUT); + long maxSend = requireArguments().getLong(KEY_MAX_SEND); + VideoSlide slide = new VideoSlide(requireContext(), uri, 0); player.setWindow(requireActivity().getWindow()); player.setVideoSource(slide, true); @@ -84,7 +91,7 @@ public class MediaSendVideoFragment extends Fragment implements VideoEditorHud.E player.clip(data.startTimeUs, data.endTimeUs, true); } try { - hud.setVideoSource(slide); + hud.setVideoSource(slide, new VideoBitRateCalculator(maxOutput), maxSend); hud.setVisibility(View.VISIBLE); startPositionUpdates(); } catch (IOException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index adad53961..e746845b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -672,6 +672,10 @@ class MediaSendViewModel extends ViewModel { } } + boolean isSms() { + return transport.isSms(); + } + enum Error { ITEM_TOO_LARGE, TOO_MANY_ITEMS, NO_ITEMS, ONLY_ITEM_TOO_LARGE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java index 4422a8250..b559a45a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/VideoEditorHud.java @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.scribbles; import android.animation.Animator; import android.content.Context; +import android.database.Cursor; import android.net.Uri; +import android.provider.OpenableColumns; import android.util.AttributeSet; import android.view.View; import android.view.animation.OvershootInterpolator; @@ -16,6 +18,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.media.DecryptableUriMediaInput; import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.video.VideoBitRateCalculator; import org.thoughtcrime.securesms.video.VideoUtil; import org.thoughtcrime.securesms.video.videoconverter.VideoThumbnailsRangeSelectorView; @@ -64,17 +68,23 @@ public final class VideoEditorHud extends LinearLayout { } @RequiresApi(api = 23) - public void setVideoSource(VideoSlide slide) throws IOException { + public void setVideoSource(@NonNull VideoSlide slide, @NonNull VideoBitRateCalculator videoBitRateCalculator, long maxSendSize) + throws IOException + { Uri uri = slide.getUri(); if (uri == null || !slide.hasVideo()) { return; } - videoTimeLine.setTimeLimit(VideoUtil.getMaxVideoUploadDurationInSeconds(), TimeUnit.SECONDS); - videoTimeLine.setInput(DecryptableUriMediaInput.createForUri(getContext(), uri)); + long size = tryGetUriSize(getContext(), uri, Long.MAX_VALUE); + + if (size > maxSendSize) { + videoTimeLine.setTimeLimit(VideoUtil.getMaxVideoUploadDurationInSeconds(), TimeUnit.SECONDS); + } + videoTimeLine.setOnRangeChangeListener(new VideoThumbnailsRangeSelectorView.OnRangeChangeListener() { @Override @@ -92,18 +102,26 @@ public final class VideoEditorHud extends LinearLayout { } @Override - public void onRangeDrag(long minValue, long maxValue, long duration, VideoThumbnailsRangeSelectorView.Thumb thumb) { + public void onRangeDrag(long minValueUs, long maxValueUs, long durationUs, VideoThumbnailsRangeSelectorView.Thumb thumb) { if (eventListener != null) { - eventListener.onEditVideoDuration(duration, minValue, maxValue, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, false); + eventListener.onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, false); } } @Override - public void onRangeDragEnd(long minValue, long maxValue, long duration, VideoThumbnailsRangeSelectorView.Thumb thumb) { + public void onRangeDragEnd(long minValueUs, long maxValueUs, long durationUs, VideoThumbnailsRangeSelectorView.Thumb thumb) { if (eventListener != null) { - eventListener.onEditVideoDuration(duration, minValue, maxValue, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, true); + eventListener.onEditVideoDuration(durationUs, minValueUs, maxValueUs, thumb == VideoThumbnailsRangeSelectorView.Thumb.MIN, true); } } + + @Override + public VideoThumbnailsRangeSelectorView.Quality getQuality(long clipDurationUs, long totalDurationUs) { + int inputBitRate = VideoBitRateCalculator.bitRate(size, TimeUnit.MICROSECONDS.toMillis(totalDurationUs)); + + VideoBitRateCalculator.Quality targetQuality = videoBitRateCalculator.getTargetQuality(TimeUnit.MICROSECONDS.toMillis(clipDurationUs), inputBitRate); + return new VideoThumbnailsRangeSelectorView.Quality(targetQuality.getFileSizeEstimate(), (int) (100 * targetQuality.getQuality())); + } }); } @@ -164,4 +182,29 @@ public final class VideoEditorHud extends LinearLayout { void onSeek(long position, boolean dragComplete); } + + private long tryGetUriSize(@NonNull Context context, @NonNull Uri uri, long defaultValue) { + try { + return getSize(context, uri); + } catch (IOException e) { + Log.w(TAG, e); + return defaultValue; + } + } + + private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException { + long size = 0; + + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, uri); + } + + return size; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryUnitFormat.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryUnitFormat.java new file mode 100644 index 000000000..b77fb4765 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryUnitFormat.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import java.text.DecimalFormat; + +/** + * Used for the pretty formatting of bytes for user display. + */ +public enum MemoryUnitFormat { + BYTES(" B"), + KILO_BYTES(" kB"), + MEGA_BYTES(" MB"), + GIGA_BYTES(" GB"), + TERA_BYTES(" TB"); + + private static final DecimalFormat ONE_DP = new DecimalFormat("#,##0.0"); + private static final DecimalFormat OPTIONAL_ONE_DP = new DecimalFormat("#,##0.#"); + + private final String unitString; + + MemoryUnitFormat(String unitString) { + this.unitString = unitString; + } + + public double fromBytes(long bytes) { + return bytes / Math.pow(1000, ordinal()); + } + + /** + * Creates a string suitable to present to the user from the specified {@param bytes}. + * It will pick a suitable unit of measure to display depending on the size of the bytes. + * It will not select a unit of measure lower than the specified {@param minimumUnit}. + * + * @param forceOneDp If true, will include 1 decimal place, even if 0. If false, will only show 1 dp when it's non-zero. + */ + public static String formatBytes(long bytes, @NonNull MemoryUnitFormat minimumUnit, boolean forceOneDp) { + if (bytes <= 0) bytes = 0; + + int ordinal = bytes != 0 ? (int) (Math.log10(bytes) / 3) : 0; + + if (ordinal >= MemoryUnitFormat.values().length) { + ordinal = MemoryUnitFormat.values().length - 1; + } + + MemoryUnitFormat unit = MemoryUnitFormat.values()[ordinal]; + + if (unit.ordinal() < minimumUnit.ordinal()) { + unit = minimumUnit; + } + + return (forceOneDp ? ONE_DP : OPTIONAL_ONE_DP).format(unit.fromBytes(bytes)) + unit.unitString; + } + + public static String formatBytes(long bytes) { + return formatBytes(bytes, BYTES, false); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 45895e1d4..10d638cdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -62,7 +62,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.security.SecureRandom; -import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -625,12 +624,7 @@ public class Util { } public static String getPrettyFileSize(long sizeBytes) { - if (sizeBytes <= 0) return "0"; - - String[] units = new String[]{"B", "kB", "MB", "GB", "TB"}; - int digitGroups = (int) (Math.log10(sizeBytes) / 3); - - return new DecimalFormat("#,##0.#").format(sizeBytes/Math.pow(1000, digitGroups)) + " " + units[digitGroups]; + return MemoryUnitFormat.formatBytes(sizeBytes); } public static void sleep(long millis) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java index 2aeacac32..5d5a9a87b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java new file mode 100644 index 000000000..d85f9223e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoBitRateCalculator.java @@ -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] + *

+ * 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() + + '}'; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java index 04a70afee..18a4e4ae0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java index 6bdab08ee..832482823 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/videoconverter/VideoThumbnailsRangeSelectorView.java @@ -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; + } } } diff --git a/app/src/main/res/layout/video_editor_hud.xml b/app/src/main/res/layout/video_editor_hud.xml index 4d4acf8b3..2c1bb7ba5 100644 --- a/app/src/main/res/layout/video_editor_hud.xml +++ b/app/src/main/res/layout/video_editor_hud.xml @@ -16,8 +16,10 @@