|
8 | 8 | package io.element.android.libraries.mediaupload.impl
|
9 | 9 |
|
10 | 10 | import android.content.Context
|
| 11 | +import android.media.MediaMetadataRetriever |
11 | 12 | import android.net.Uri
|
| 13 | +import android.webkit.MimeTypeMap |
12 | 14 | import com.otaliastudios.transcoder.Transcoder
|
13 | 15 | import com.otaliastudios.transcoder.TranscoderListener
|
14 |
| -import com.otaliastudios.transcoder.common.TrackType |
15 | 16 | import com.otaliastudios.transcoder.internal.media.MediaFormatConstants
|
16 |
| -import com.otaliastudios.transcoder.internal.utils.mutableTrackMapOf |
17 | 17 | import com.otaliastudios.transcoder.resize.AtMostResizer
|
18 | 18 | import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
|
19 |
| -import com.otaliastudios.transcoder.time.TimeInterpolator |
20 | 19 | import io.element.android.libraries.androidutils.file.createTmpFile
|
| 20 | +import io.element.android.libraries.androidutils.file.getMimeType |
21 | 21 | import io.element.android.libraries.androidutils.file.safeDelete
|
22 | 22 | import io.element.android.libraries.di.ApplicationContext
|
23 | 23 | import kotlinx.coroutines.channels.awaitClose
|
24 | 24 | import kotlinx.coroutines.flow.callbackFlow
|
| 25 | +import timber.log.Timber |
25 | 26 | import java.io.File
|
26 | 27 | import javax.inject.Inject
|
27 | 28 |
|
28 | 29 | class VideoCompressor @Inject constructor(
|
29 | 30 | @ApplicationContext private val context: Context,
|
30 | 31 | ) {
|
| 32 | + companion object { |
| 33 | + private const val MP4_EXTENSION = "mp4" |
| 34 | + } |
| 35 | + |
31 | 36 | fun compress(uri: Uri, shouldBeCompressed: Boolean) = callbackFlow {
|
32 |
| - val tmpFile = context.createTmpFile(extension = "mp4") |
33 |
| - val future = Transcoder.into(tmpFile.path) |
34 |
| - .setTimeInterpolator(MonotonicTimelineInterpolator()) |
35 |
| - .setVideoTrackStrategy( |
36 |
| - DefaultVideoStrategy.Builder() |
37 |
| - .addResizer( |
38 |
| - AtMostResizer( |
39 |
| - if (shouldBeCompressed) { |
40 |
| - 720 |
41 |
| - } else { |
42 |
| - 1080 |
43 |
| - } |
44 |
| - ) |
45 |
| - ) |
46 |
| - .mimeType(MediaFormatConstants.MIMETYPE_VIDEO_AVC) |
47 |
| - .build() |
48 |
| - ) |
49 |
| - .addDataSource(context, uri) |
50 |
| - .setListener(object : TranscoderListener { |
51 |
| - override fun onTranscodeProgress(progress: Double) { |
52 |
| - trySend(VideoTranscodingEvent.Progress(progress.toFloat())) |
53 |
| - } |
| 37 | + val (width, height) = getVideoDimensions(uri) ?: (Int.MAX_VALUE to Int.MAX_VALUE) |
54 | 38 |
|
55 |
| - override fun onTranscodeCompleted(successCode: Int) { |
56 |
| - trySend(VideoTranscodingEvent.Completed(tmpFile)) |
57 |
| - close() |
58 |
| - } |
| 39 | + val resizer = when { |
| 40 | + shouldBeCompressed && (width > 720 || height > 720) -> AtMostResizer(720) |
| 41 | + width > 1080 || height > 1080 -> AtMostResizer(1080) |
| 42 | + else -> null |
| 43 | + } |
59 | 44 |
|
60 |
| - override fun onTranscodeCanceled() { |
61 |
| - tmpFile.safeDelete() |
62 |
| - close() |
63 |
| - } |
| 45 | + val expectedExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getMimeType(uri)) |
| 46 | + |
| 47 | + val tmpFile = context.createTmpFile(extension = MP4_EXTENSION) |
64 | 48 |
|
65 |
| - override fun onTranscodeFailed(exception: Throwable) { |
66 |
| - tmpFile.safeDelete() |
67 |
| - close(exception) |
| 49 | + // If there's no transcoding needed for the video file, just copy it to the tmp file and return it |
| 50 | + val future = if (expectedExtension == MP4_EXTENSION && resizer == null) { |
| 51 | + context.contentResolver.openInputStream(uri)?.use { input -> |
| 52 | + tmpFile.outputStream().use { output -> |
| 53 | + input.copyTo(output) |
68 | 54 | }
|
69 |
| - }) |
70 |
| - .transcode() |
| 55 | + } |
| 56 | + trySend(VideoTranscodingEvent.Completed(tmpFile)) |
| 57 | + close() |
| 58 | + null |
| 59 | + } else { |
| 60 | + Transcoder.into(tmpFile.path) |
| 61 | + .setVideoTrackStrategy( |
| 62 | + DefaultVideoStrategy.Builder() |
| 63 | + .apply { |
| 64 | + resizer?.let { addResizer(it) } |
| 65 | + } |
| 66 | + .mimeType(MediaFormatConstants.MIMETYPE_VIDEO_AVC) |
| 67 | + .build() |
| 68 | + ) |
| 69 | + .addDataSource(context, uri) |
| 70 | + .setListener(object : TranscoderListener { |
| 71 | + override fun onTranscodeProgress(progress: Double) { |
| 72 | + trySend(VideoTranscodingEvent.Progress(progress.toFloat())) |
| 73 | + } |
| 74 | + |
| 75 | + override fun onTranscodeCompleted(successCode: Int) { |
| 76 | + trySend(VideoTranscodingEvent.Completed(tmpFile)) |
| 77 | + close() |
| 78 | + } |
| 79 | + |
| 80 | + override fun onTranscodeCanceled() { |
| 81 | + tmpFile.safeDelete() |
| 82 | + close() |
| 83 | + } |
| 84 | + |
| 85 | + override fun onTranscodeFailed(exception: Throwable) { |
| 86 | + tmpFile.safeDelete() |
| 87 | + close(exception) |
| 88 | + } |
| 89 | + }) |
| 90 | + .transcode() |
| 91 | + } |
71 | 92 |
|
72 | 93 | awaitClose {
|
73 |
| - if (!future.isDone) { |
| 94 | + if (future?.isDone == false) { |
74 | 95 | future.cancel(true)
|
75 | 96 | }
|
76 | 97 | }
|
77 | 98 | }
|
78 |
| -} |
79 | 99 |
|
80 |
| -sealed interface VideoTranscodingEvent { |
81 |
| - data class Progress(val value: Float) : VideoTranscodingEvent |
82 |
| - data class Completed(val file: File) : VideoTranscodingEvent |
83 |
| -} |
| 100 | + private fun getVideoDimensions(uri: Uri): Pair<Int, Int>? { |
| 101 | + return runCatching { |
| 102 | + MediaMetadataRetriever().use { |
| 103 | + it.setDataSource(context, uri) |
84 | 104 |
|
85 |
| -/** |
86 |
| - * A TimeInterpolator that ensures timestamps are monotonically increasing. |
87 |
| - * Timestamps can go back and forth for many reasons, like miscalculations in |
88 |
| - * MediaCodec output or manually generated timestamps, or at the boundary |
89 |
| - * between one data source and another. |
90 |
| - * |
91 |
| - * Since `MediaMuxer.writeSampleData` can throw in case of invalid timestamps, |
92 |
| - * this interpolator ensures that the next timestamp is at least equal to |
93 |
| - * the previous timestamp plus 1. It does no effort to preserve the input deltas, |
94 |
| - * so the input stream must be as consistent as possible. |
95 |
| - * |
96 |
| - * For example, `20 30 40 50 10 20 30` would become `20 30 40 50 51 52 53`. |
97 |
| - * |
98 |
| - * Copied from the private [com.otaliastudios.transcoder.time.MonotonicTimelineInterpolator]. |
99 |
| - */ |
100 |
| -private class MonotonicTimelineInterpolator : TimeInterpolator { |
101 |
| - private val last = mutableTrackMapOf(Long.MIN_VALUE, Long.MIN_VALUE) |
| 105 | + val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1 |
| 106 | + val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1 |
102 | 107 |
|
103 |
| - override fun interpolate(type: TrackType, time: Long): Long { |
104 |
| - return interpolate(last[type], time).also { last[type] = it } |
| 108 | + if (width == -1 || height == -1) { |
| 109 | + // Try getting the first frame instead |
| 110 | + val bitmap = it.getFrameAtTime(0) ?: return null |
| 111 | + bitmap.width to bitmap.height |
| 112 | + } else { |
| 113 | + width to height |
| 114 | + } |
| 115 | + } |
| 116 | + }.onFailure { |
| 117 | + Timber.e(it, "Failed to get video dimensions") |
| 118 | + }.getOrNull() |
105 | 119 | }
|
| 120 | +} |
106 | 121 |
|
107 |
| - private fun interpolate(prev: Long, next: Long): Long { |
108 |
| - if (prev == Long.MIN_VALUE) return next |
109 |
| - return next.coerceAtLeast(prev + 1) |
110 |
| - } |
| 122 | +sealed interface VideoTranscodingEvent { |
| 123 | + data class Progress(val value: Float) : VideoTranscodingEvent |
| 124 | + data class Completed(val file: File) : VideoTranscodingEvent |
111 | 125 | }
|
0 commit comments