Skip to content

Commit e8154ec

Browse files
authored
Replace video transcoder with Media3 Transformer (#5018)
1 parent aaa0407 commit e8154ec

File tree

7 files changed

+334
-258
lines changed

7 files changed

+334
-258
lines changed

gradle/libs.versions.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process",
100100
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
101101
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
102102
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
103+
androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" }
104+
androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" }
105+
androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" }
103106
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
104107

105108
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
@@ -182,7 +185,6 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
182185
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
183186
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
184187
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
185-
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
186188
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
187189
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
188190
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }

libraries/mediaupload/impl/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ dependencies {
3333
implementation(projects.services.toolbox.api)
3434
implementation(libs.inject)
3535
implementation(libs.androidx.exifinterface)
36+
implementation(libs.androidx.media3.transformer)
37+
implementation(libs.androidx.media3.effect)
38+
implementation(libs.androidx.media3.common)
3639
implementation(libs.coroutines.core)
37-
implementation(libs.otaliastudios.transcoder)
3840
implementation(libs.vanniktech.blurhash)
3941

4042
testImplementation(libs.test.junit)

libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,19 @@ class AndroidMediaPreProcessor @Inject constructor(
192192
val resultFile = runCatchingExceptions {
193193
videoCompressor.compress(uri, shouldBeCompressed)
194194
.onEach {
195-
// TODO handle progress
195+
if (it is VideoTranscodingEvent.Progress) {
196+
Timber.d("Video compression progress: ${it.value}%")
197+
} else if (it is VideoTranscodingEvent.Completed) {
198+
Timber.d("Video compression completed: ${it.file.path}")
199+
}
196200
}
197201
.filterIsInstance<VideoTranscodingEvent.Completed>()
198202
.first()
199203
.file
200204
}
205+
.onFailure {
206+
Timber.e(it, "Failed to compress video: $uri")
207+
}
201208
.getOrNull()
202209

203210
if (resultFile != null) {
@@ -283,10 +290,17 @@ class AndroidMediaPreProcessor @Inject constructor(
283290
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
284291
MediaMetadataRetriever().runAndRelease {
285292
setDataSource(context, Uri.fromFile(file))
293+
294+
val rotation = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
295+
val rawWidth = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L
296+
val rawHeight = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L
297+
298+
val (width, height) = if (rotation == 90 || rotation == 270) rawHeight to rawWidth else rawWidth to rawHeight
299+
286300
VideoInfo(
287301
duration = extractDuration(),
288-
width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L,
289-
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
302+
width = width,
303+
height = height,
290304
mimetype = mimeType,
291305
size = file.length(),
292306
thumbnailInfo = thumbnailResult?.info,

libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressor.kt

Lines changed: 110 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -8,76 +8,146 @@
88
package io.element.android.libraries.mediaupload.impl
99

1010
import android.content.Context
11+
import android.media.MediaCodecInfo
1112
import android.media.MediaMetadataRetriever
1213
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
2230
import io.element.android.libraries.androidutils.file.createTmpFile
23-
import io.element.android.libraries.androidutils.file.getMimeType
2431
import io.element.android.libraries.androidutils.file.safeDelete
2532
import io.element.android.libraries.core.extensions.runCatchingExceptions
2633
import io.element.android.libraries.di.ApplicationContext
34+
import kotlinx.coroutines.Dispatchers
2735
import kotlinx.coroutines.channels.awaitClose
36+
import kotlinx.coroutines.delay
37+
import kotlinx.coroutines.flow.Flow
2838
import kotlinx.coroutines.flow.callbackFlow
39+
import kotlinx.coroutines.isActive
40+
import kotlinx.coroutines.launch
41+
import kotlinx.coroutines.withContext
2942
import timber.log.Timber
3043
import java.io.File
3144
import javax.inject.Inject
3245

33-
private const val MP4_EXTENSION = "mp4"
34-
3546
class VideoCompressor @Inject constructor(
3647
@ApplicationContext private val context: Context,
3748
) {
38-
fun compress(uri: Uri, shouldBeCompressed: Boolean) = callbackFlow {
49+
@OptIn(UnstableApi::class)
50+
fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow<VideoTranscodingEvent> = callbackFlow {
3951
val metadata = getVideoMetadata(uri)
4052

41-
val expectedExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getMimeType(uri))
42-
43-
val videoStrategy = VideoStrategyFactory.create(
44-
expectedExtension = expectedExtension,
53+
val videoCompressorConfig = VideoCompressorConfigFactory.create(
4554
metadata = metadata,
4655
shouldBeCompressed = shouldBeCompressed
4756
)
4857

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
5962

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) {
61116
trySend(VideoTranscodingEvent.Completed(tmpFile))
62117
close()
63118
}
64119

65-
override fun onTranscodeCanceled() {
120+
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
121+
Timber.e(exportException, "Video transcoding failed")
66122
tmpFile.safeDelete()
67-
close()
123+
close(exportException)
68124
}
69125

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
74131
})
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+
}
76148

77149
awaitClose {
78-
if (!future.isDone) {
79-
future.cancel(true)
80-
}
150+
progressJob.cancel()
81151
}
82152
}
83153

@@ -89,7 +159,7 @@ class VideoCompressor @Inject constructor(
89159
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1
90160
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1
91161
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
93163
val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
94164

95165
val (actualWidth, actualHeight) = if (width == -1 || height == -1) {
@@ -104,7 +174,7 @@ class VideoCompressor @Inject constructor(
104174
width = actualWidth,
105175
height = actualHeight,
106176
bitrate = bitrate,
107-
frameRate = framerate,
177+
frameRate = frameRate,
108178
rotation = rotation,
109179
)
110180
}
@@ -126,45 +196,3 @@ sealed interface VideoTranscodingEvent {
126196
data class Progress(val value: Float) : VideoTranscodingEvent
127197
data class Completed(val file: File) : VideoTranscodingEvent
128198
}
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

Comments
 (0)