8
8
package io.element.android.libraries.mediaupload.impl
9
9
10
10
import android.content.Context
11
+ import android.media.MediaCodecInfo
11
12
import android.media.MediaMetadataRetriever
12
13
import android.net.Uri
13
- import android.webkit.MimeTypeMap
14
- import com.otaliastudios.transcoder.Transcoder
15
- import com.otaliastudios.transcoder.TranscoderListener
16
- import com.otaliastudios.transcoder.internal.media.MediaFormatConstants
17
- import com.otaliastudios.transcoder.resize.AtMostResizer
18
- import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
19
- import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
20
- import com.otaliastudios.transcoder.strategy.TrackStrategy
21
- import com.otaliastudios.transcoder.validator.WriteAlwaysValidator
14
+ import androidx.annotation.OptIn
15
+ import androidx.media3.common.MediaItem
16
+ import androidx.media3.common.MimeTypes
17
+ import androidx.media3.common.util.Size
18
+ import androidx.media3.common.util.UnstableApi
19
+ import androidx.media3.effect.Presentation
20
+ import androidx.media3.transformer.Composition
21
+ import androidx.media3.transformer.DefaultEncoderFactory
22
+ import androidx.media3.transformer.EditedMediaItem
23
+ import androidx.media3.transformer.Effects
24
+ import androidx.media3.transformer.ExportException
25
+ import androidx.media3.transformer.ExportResult
26
+ import androidx.media3.transformer.ProgressHolder
27
+ import androidx.media3.transformer.TransformationRequest
28
+ import androidx.media3.transformer.Transformer
29
+ import androidx.media3.transformer.VideoEncoderSettings
22
30
import io.element.android.libraries.androidutils.file.createTmpFile
23
- import io.element.android.libraries.androidutils.file.getMimeType
24
31
import io.element.android.libraries.androidutils.file.safeDelete
25
32
import io.element.android.libraries.core.extensions.runCatchingExceptions
26
33
import io.element.android.libraries.di.ApplicationContext
34
+ import kotlinx.coroutines.Dispatchers
27
35
import kotlinx.coroutines.channels.awaitClose
36
+ import kotlinx.coroutines.delay
37
+ import kotlinx.coroutines.flow.Flow
28
38
import kotlinx.coroutines.flow.callbackFlow
39
+ import kotlinx.coroutines.isActive
40
+ import kotlinx.coroutines.launch
41
+ import kotlinx.coroutines.withContext
29
42
import timber.log.Timber
30
43
import java.io.File
31
44
import javax.inject.Inject
32
45
33
- private const val MP4_EXTENSION = " mp4"
34
-
35
46
class VideoCompressor @Inject constructor(
36
47
@ApplicationContext private val context : Context ,
37
48
) {
38
- fun compress (uri : Uri , shouldBeCompressed : Boolean ) = callbackFlow {
49
+ @OptIn(UnstableApi ::class )
50
+ fun compress (uri : Uri , shouldBeCompressed : Boolean ): Flow <VideoTranscodingEvent > = callbackFlow {
39
51
val metadata = getVideoMetadata(uri)
40
52
41
- val expectedExtension = MimeTypeMap .getSingleton().getExtensionFromMimeType(context.getMimeType(uri))
42
-
43
- val videoStrategy = VideoStrategyFactory .create(
44
- expectedExtension = expectedExtension,
53
+ val videoCompressorConfig = VideoCompressorConfigFactory .create(
45
54
metadata = metadata,
46
55
shouldBeCompressed = shouldBeCompressed
47
56
)
48
57
49
- val tmpFile = context.createTmpFile(extension = MP4_EXTENSION )
50
- val future = Transcoder .into(tmpFile.path)
51
- .setVideoTrackStrategy(videoStrategy)
52
- .addDataSource(context, uri)
53
- // Force the output to be written, even if no transcoding was actually needed
54
- .setValidator(WriteAlwaysValidator ())
55
- .setListener(object : TranscoderListener {
56
- override fun onTranscodeProgress (progress : Double ) {
57
- trySend(VideoTranscodingEvent .Progress (progress.toFloat()))
58
- }
58
+ val tmpFile = context.createTmpFile(extension = " mp4" )
59
+
60
+ val width = metadata?.width ? : Int .MAX_VALUE
61
+ val height = metadata?.height ? : Int .MAX_VALUE
59
62
60
- override fun onTranscodeCompleted (successCode : Int ) {
63
+ val videoResizeEffect = videoCompressorConfig.resizer?.let {
64
+ val outputSize = it.getOutputSize(Size (width, height))
65
+ if (metadata?.rotation == 90 || metadata?.rotation == 270 ) {
66
+ // If the video is rotated, we need to swap width and height
67
+ Presentation .createForWidthAndHeight(
68
+ outputSize.height,
69
+ outputSize.width,
70
+ Presentation .LAYOUT_SCALE_TO_FIT ,
71
+ )
72
+ } else {
73
+ // Otherwise, we can use the original width and height
74
+ Presentation .createForWidthAndHeight(
75
+ outputSize.width,
76
+ outputSize.height,
77
+ Presentation .LAYOUT_SCALE_TO_FIT ,
78
+ )
79
+ }
80
+ }
81
+
82
+ // If we are resizing, we also want to reduce set frame rate to the default value (30fps)
83
+ val newFrameRate = videoCompressorConfig.newFrameRate
84
+
85
+ // If we need to resize the video, we also want to recalculate the bitrate
86
+ val newBitrate = videoCompressorConfig.newBitRate
87
+
88
+ val inputMediaItem = MediaItem .fromUri(uri)
89
+ val outputMediaItem = EditedMediaItem .Builder (inputMediaItem)
90
+ .setFrameRate(newFrameRate)
91
+ .run {
92
+ if (videoResizeEffect != null ) {
93
+ setEffects(Effects (emptyList(), listOf (videoResizeEffect)))
94
+ } else {
95
+ this
96
+ }
97
+ }
98
+ .build()
99
+
100
+ val encoderFactory = DefaultEncoderFactory .Builder (context)
101
+ .setRequestedVideoEncoderSettings(
102
+ VideoEncoderSettings .Builder ()
103
+ .setBitrateMode(MediaCodecInfo .EncoderCapabilities .BITRATE_MODE_CBR )
104
+ .setBitrate(newBitrate)
105
+ .build()
106
+ )
107
+ .build()
108
+
109
+ val videoTransformer = Transformer .Builder (context)
110
+ .setVideoMimeType(MimeTypes .VIDEO_H264 )
111
+ .setAudioMimeType(MimeTypes .AUDIO_AAC )
112
+ .setPortraitEncodingEnabled(false )
113
+ .setEncoderFactory(encoderFactory)
114
+ .addListener(object : Transformer .Listener {
115
+ override fun onCompleted (composition : Composition , exportResult : ExportResult ) {
61
116
trySend(VideoTranscodingEvent .Completed (tmpFile))
62
117
close()
63
118
}
64
119
65
- override fun onTranscodeCanceled () {
120
+ override fun onError (composition : Composition , exportResult : ExportResult , exportException : ExportException ) {
121
+ Timber .e(exportException, " Video transcoding failed" )
66
122
tmpFile.safeDelete()
67
- close()
123
+ close(exportException )
68
124
}
69
125
70
- override fun onTranscodeFailed (exception : Throwable ) {
71
- tmpFile.safeDelete()
72
- close(exception)
73
- }
126
+ override fun onFallbackApplied (
127
+ composition : Composition ,
128
+ originalTransformationRequest : TransformationRequest ,
129
+ fallbackTransformationRequest : TransformationRequest
130
+ ) = Unit
74
131
})
75
- .transcode()
132
+ .build()
133
+
134
+ val progressJob = launch(Dispatchers .Main ) {
135
+ val progressHolder = ProgressHolder ()
136
+ while (isActive) {
137
+ val state = videoTransformer.getProgress(progressHolder)
138
+ if (state != Transformer .PROGRESS_STATE_NOT_STARTED ) {
139
+ channel.send(VideoTranscodingEvent .Progress (progressHolder.progress.toFloat()))
140
+ }
141
+ delay(500 )
142
+ }
143
+ }
144
+
145
+ withContext(Dispatchers .Main ) {
146
+ videoTransformer.start(outputMediaItem, tmpFile.path)
147
+ }
76
148
77
149
awaitClose {
78
- if (! future.isDone) {
79
- future.cancel(true )
80
- }
150
+ progressJob.cancel()
81
151
}
82
152
}
83
153
@@ -89,7 +159,7 @@ class VideoCompressor @Inject constructor(
89
159
val width = it.extractMetadata(MediaMetadataRetriever .METADATA_KEY_VIDEO_WIDTH )?.toIntOrNull() ? : - 1
90
160
val height = it.extractMetadata(MediaMetadataRetriever .METADATA_KEY_VIDEO_HEIGHT )?.toIntOrNull() ? : - 1
91
161
val bitrate = it.extractMetadata(MediaMetadataRetriever .METADATA_KEY_BITRATE )?.toLongOrNull() ? : - 1
92
- val framerate = it.extractMetadata(MediaMetadataRetriever .METADATA_KEY_CAPTURE_FRAMERATE )?.toIntOrNull() ? : - 1
162
+ val frameRate = it.extractMetadata(MediaMetadataRetriever .METADATA_KEY_CAPTURE_FRAMERATE )?.toIntOrNull() ? : - 1
93
163
val rotation = it.extractMetadata(MediaMetadataRetriever .METADATA_KEY_VIDEO_ROTATION )?.toIntOrNull() ? : 0
94
164
95
165
val (actualWidth, actualHeight) = if (width == - 1 || height == - 1 ) {
@@ -104,7 +174,7 @@ class VideoCompressor @Inject constructor(
104
174
width = actualWidth,
105
175
height = actualHeight,
106
176
bitrate = bitrate,
107
- frameRate = framerate ,
177
+ frameRate = frameRate ,
108
178
rotation = rotation,
109
179
)
110
180
}
@@ -126,45 +196,3 @@ sealed interface VideoTranscodingEvent {
126
196
data class Progress (val value : Float ) : VideoTranscodingEvent
127
197
data class Completed (val file : File ) : VideoTranscodingEvent
128
198
}
129
-
130
- internal object VideoStrategyFactory {
131
- // 720p
132
- private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
133
-
134
- // 1080p
135
- private const val MAX_PIXEL_SIZE = 1920
136
-
137
- fun create (
138
- expectedExtension : String? ,
139
- metadata : VideoFileMetadata ? ,
140
- shouldBeCompressed : Boolean ,
141
- ): TrackStrategy {
142
- val width = metadata?.width?.takeIf { it >= 0 } ? : Int .MAX_VALUE
143
- val height = metadata?.height?.takeIf { it >= 0 } ? : Int .MAX_VALUE
144
- val bitrate = metadata?.bitrate?.takeIf { it >= 0 }
145
- val frameRate = metadata?.frameRate?.takeIf { it >= 0 }
146
- val rotation = metadata?.rotation?.takeIf { it >= 0 }
147
-
148
- // We only create a resizer if needed
149
- val resizer = when {
150
- shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE ) -> AtMostResizer (MAX_COMPRESSED_PIXEL_SIZE )
151
- width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> AtMostResizer (MAX_PIXEL_SIZE )
152
- else -> null
153
- }
154
-
155
- return if (resizer == null && rotation == 0 && expectedExtension == MP4_EXTENSION ) {
156
- // If there's no transcoding or resizing needed for the video file, just create a new file with the same contents but no metadata
157
- // Rotation is not kept by the PassThroughTrackStrategy, so we need to ensure the video is not rotated
158
- PassThroughTrackStrategy ()
159
- } else {
160
- DefaultVideoStrategy .Builder ()
161
- .apply {
162
- resizer?.let { addResizer(it) }
163
- bitrate?.let { bitRate(it) }
164
- frameRate?.let { frameRate(it) }
165
- }
166
- .mimeType(MediaFormatConstants .MIMETYPE_VIDEO_AVC )
167
- .build()
168
- }
169
- }
170
- }
0 commit comments