Replace video transcoder with Media3 Transformer (#5018)
This commit is contained in:
parent
23e5392471
commit
2c325df32f
7 changed files with 334 additions and 258 deletions
|
|
@ -100,6 +100,9 @@ androidx_lifecycle_process = { module = "androidx.lifecycle:lifecycle-process",
|
||||||
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
androidx_splash = "androidx.core:core-splashscreen:1.0.1"
|
||||||
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
androidx_media3_exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||||
|
androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" }
|
||||||
|
androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" }
|
||||||
|
androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" }
|
||||||
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||||
|
|
||||||
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
|
||||||
|
|
@ -182,7 +185,6 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions",
|
||||||
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
|
sqlcipher = "net.zetetic:sqlcipher-android:4.9.0"
|
||||||
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
|
sqlite = "androidx.sqlite:sqlite-ktx:2.5.2"
|
||||||
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
|
unifiedpush = "org.unifiedpush.android:connector:3.0.10"
|
||||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.11.2"
|
|
||||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
|
||||||
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
|
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,10 @@ dependencies {
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
implementation(libs.inject)
|
implementation(libs.inject)
|
||||||
implementation(libs.androidx.exifinterface)
|
implementation(libs.androidx.exifinterface)
|
||||||
|
implementation(libs.androidx.media3.transformer)
|
||||||
|
implementation(libs.androidx.media3.effect)
|
||||||
|
implementation(libs.androidx.media3.common)
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(libs.otaliastudios.transcoder)
|
|
||||||
implementation(libs.vanniktech.blurhash)
|
implementation(libs.vanniktech.blurhash)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
|
|
|
||||||
|
|
@ -192,12 +192,19 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
val resultFile = runCatchingExceptions {
|
val resultFile = runCatchingExceptions {
|
||||||
videoCompressor.compress(uri, shouldBeCompressed)
|
videoCompressor.compress(uri, shouldBeCompressed)
|
||||||
.onEach {
|
.onEach {
|
||||||
// TODO handle progress
|
if (it is VideoTranscodingEvent.Progress) {
|
||||||
|
Timber.d("Video compression progress: ${it.value}%")
|
||||||
|
} else if (it is VideoTranscodingEvent.Completed) {
|
||||||
|
Timber.d("Video compression completed: ${it.file.path}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.filterIsInstance<VideoTranscodingEvent.Completed>()
|
.filterIsInstance<VideoTranscodingEvent.Completed>()
|
||||||
.first()
|
.first()
|
||||||
.file
|
.file
|
||||||
}
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.e(it, "Failed to compress video: $uri")
|
||||||
|
}
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
||||||
if (resultFile != null) {
|
if (resultFile != null) {
|
||||||
|
|
@ -283,10 +290,17 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
|
private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailResult: ThumbnailResult?): VideoInfo =
|
||||||
MediaMetadataRetriever().runAndRelease {
|
MediaMetadataRetriever().runAndRelease {
|
||||||
setDataSource(context, Uri.fromFile(file))
|
setDataSource(context, Uri.fromFile(file))
|
||||||
|
|
||||||
|
val rotation = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
|
||||||
|
val rawWidth = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L
|
||||||
|
val rawHeight = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L
|
||||||
|
|
||||||
|
val (width, height) = if (rotation == 90 || rotation == 270) rawHeight to rawWidth else rawWidth to rawHeight
|
||||||
|
|
||||||
VideoInfo(
|
VideoInfo(
|
||||||
duration = extractDuration(),
|
duration = extractDuration(),
|
||||||
width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L,
|
width = width,
|
||||||
height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L,
|
height = height,
|
||||||
mimetype = mimeType,
|
mimetype = mimeType,
|
||||||
size = file.length(),
|
size = file.length(),
|
||||||
thumbnailInfo = thumbnailResult?.info,
|
thumbnailInfo = thumbnailResult?.info,
|
||||||
|
|
|
||||||
|
|
@ -8,76 +8,146 @@
|
||||||
package io.element.android.libraries.mediaupload.impl
|
package io.element.android.libraries.mediaupload.impl
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.MimeTypeMap
|
import androidx.annotation.OptIn
|
||||||
import com.otaliastudios.transcoder.Transcoder
|
import androidx.media3.common.MediaItem
|
||||||
import com.otaliastudios.transcoder.TranscoderListener
|
import androidx.media3.common.MimeTypes
|
||||||
import com.otaliastudios.transcoder.internal.media.MediaFormatConstants
|
import androidx.media3.common.util.Size
|
||||||
import com.otaliastudios.transcoder.resize.AtMostResizer
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
|
import androidx.media3.effect.Presentation
|
||||||
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
|
import androidx.media3.transformer.Composition
|
||||||
import com.otaliastudios.transcoder.strategy.TrackStrategy
|
import androidx.media3.transformer.DefaultEncoderFactory
|
||||||
import com.otaliastudios.transcoder.validator.WriteAlwaysValidator
|
import androidx.media3.transformer.EditedMediaItem
|
||||||
|
import androidx.media3.transformer.Effects
|
||||||
|
import androidx.media3.transformer.ExportException
|
||||||
|
import androidx.media3.transformer.ExportResult
|
||||||
|
import androidx.media3.transformer.ProgressHolder
|
||||||
|
import androidx.media3.transformer.TransformationRequest
|
||||||
|
import androidx.media3.transformer.Transformer
|
||||||
|
import androidx.media3.transformer.VideoEncoderSettings
|
||||||
import io.element.android.libraries.androidutils.file.createTmpFile
|
import io.element.android.libraries.androidutils.file.createTmpFile
|
||||||
import io.element.android.libraries.androidutils.file.getMimeType
|
|
||||||
import io.element.android.libraries.androidutils.file.safeDelete
|
import io.element.android.libraries.androidutils.file.safeDelete
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val MP4_EXTENSION = "mp4"
|
|
||||||
|
|
||||||
class VideoCompressor @Inject constructor(
|
class VideoCompressor @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
fun compress(uri: Uri, shouldBeCompressed: Boolean) = callbackFlow {
|
@OptIn(UnstableApi::class)
|
||||||
|
fun compress(uri: Uri, shouldBeCompressed: Boolean): Flow<VideoTranscodingEvent> = callbackFlow {
|
||||||
val metadata = getVideoMetadata(uri)
|
val metadata = getVideoMetadata(uri)
|
||||||
|
|
||||||
val expectedExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getMimeType(uri))
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
metadata = metadata,
|
||||||
shouldBeCompressed = shouldBeCompressed
|
shouldBeCompressed = shouldBeCompressed
|
||||||
)
|
)
|
||||||
|
|
||||||
val tmpFile = context.createTmpFile(extension = MP4_EXTENSION)
|
val tmpFile = context.createTmpFile(extension = "mp4")
|
||||||
val future = Transcoder.into(tmpFile.path)
|
|
||||||
.setVideoTrackStrategy(videoStrategy)
|
|
||||||
.addDataSource(context, uri)
|
|
||||||
// Force the output to be written, even if no transcoding was actually needed
|
|
||||||
.setValidator(WriteAlwaysValidator())
|
|
||||||
.setListener(object : TranscoderListener {
|
|
||||||
override fun onTranscodeProgress(progress: Double) {
|
|
||||||
trySend(VideoTranscodingEvent.Progress(progress.toFloat()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTranscodeCompleted(successCode: Int) {
|
val width = metadata?.width ?: Int.MAX_VALUE
|
||||||
|
val height = metadata?.height ?: Int.MAX_VALUE
|
||||||
|
|
||||||
|
val videoResizeEffect = videoCompressorConfig.resizer?.let {
|
||||||
|
val outputSize = it.getOutputSize(Size(width, height))
|
||||||
|
if (metadata?.rotation == 90 || metadata?.rotation == 270) {
|
||||||
|
// If the video is rotated, we need to swap width and height
|
||||||
|
Presentation.createForWidthAndHeight(
|
||||||
|
outputSize.height,
|
||||||
|
outputSize.width,
|
||||||
|
Presentation.LAYOUT_SCALE_TO_FIT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Otherwise, we can use the original width and height
|
||||||
|
Presentation.createForWidthAndHeight(
|
||||||
|
outputSize.width,
|
||||||
|
outputSize.height,
|
||||||
|
Presentation.LAYOUT_SCALE_TO_FIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are resizing, we also want to reduce set frame rate to the default value (30fps)
|
||||||
|
val newFrameRate = videoCompressorConfig.newFrameRate
|
||||||
|
|
||||||
|
// If we need to resize the video, we also want to recalculate the bitrate
|
||||||
|
val newBitrate = videoCompressorConfig.newBitRate
|
||||||
|
|
||||||
|
val inputMediaItem = MediaItem.fromUri(uri)
|
||||||
|
val outputMediaItem = EditedMediaItem.Builder(inputMediaItem)
|
||||||
|
.setFrameRate(newFrameRate)
|
||||||
|
.run {
|
||||||
|
if (videoResizeEffect != null) {
|
||||||
|
setEffects(Effects(emptyList(), listOf(videoResizeEffect)))
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val encoderFactory = DefaultEncoderFactory.Builder(context)
|
||||||
|
.setRequestedVideoEncoderSettings(
|
||||||
|
VideoEncoderSettings.Builder()
|
||||||
|
.setBitrateMode(MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR)
|
||||||
|
.setBitrate(newBitrate)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val videoTransformer = Transformer.Builder(context)
|
||||||
|
.setVideoMimeType(MimeTypes.VIDEO_H264)
|
||||||
|
.setAudioMimeType(MimeTypes.AUDIO_AAC)
|
||||||
|
.setPortraitEncodingEnabled(false)
|
||||||
|
.setEncoderFactory(encoderFactory)
|
||||||
|
.addListener(object : Transformer.Listener {
|
||||||
|
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
||||||
trySend(VideoTranscodingEvent.Completed(tmpFile))
|
trySend(VideoTranscodingEvent.Completed(tmpFile))
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTranscodeCanceled() {
|
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
|
||||||
|
Timber.e(exportException, "Video transcoding failed")
|
||||||
tmpFile.safeDelete()
|
tmpFile.safeDelete()
|
||||||
close()
|
close(exportException)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTranscodeFailed(exception: Throwable) {
|
override fun onFallbackApplied(
|
||||||
tmpFile.safeDelete()
|
composition: Composition,
|
||||||
close(exception)
|
originalTransformationRequest: TransformationRequest,
|
||||||
}
|
fallbackTransformationRequest: TransformationRequest
|
||||||
|
) = Unit
|
||||||
})
|
})
|
||||||
.transcode()
|
.build()
|
||||||
|
|
||||||
|
val progressJob = launch(Dispatchers.Main) {
|
||||||
|
val progressHolder = ProgressHolder()
|
||||||
|
while (isActive) {
|
||||||
|
val state = videoTransformer.getProgress(progressHolder)
|
||||||
|
if (state != Transformer.PROGRESS_STATE_NOT_STARTED) {
|
||||||
|
channel.send(VideoTranscodingEvent.Progress(progressHolder.progress.toFloat()))
|
||||||
|
}
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
videoTransformer.start(outputMediaItem, tmpFile.path)
|
||||||
|
}
|
||||||
|
|
||||||
awaitClose {
|
awaitClose {
|
||||||
if (!future.isDone) {
|
progressJob.cancel()
|
||||||
future.cancel(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +159,7 @@ class VideoCompressor @Inject constructor(
|
||||||
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1
|
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: -1
|
||||||
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1
|
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: -1
|
||||||
val bitrate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: -1
|
val bitrate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)?.toLongOrNull() ?: -1
|
||||||
val framerate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1
|
val frameRate = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)?.toIntOrNull() ?: -1
|
||||||
val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
||||||
|
|
||||||
val (actualWidth, actualHeight) = if (width == -1 || height == -1) {
|
val (actualWidth, actualHeight) = if (width == -1 || height == -1) {
|
||||||
|
|
@ -104,7 +174,7 @@ class VideoCompressor @Inject constructor(
|
||||||
width = actualWidth,
|
width = actualWidth,
|
||||||
height = actualHeight,
|
height = actualHeight,
|
||||||
bitrate = bitrate,
|
bitrate = bitrate,
|
||||||
frameRate = framerate,
|
frameRate = frameRate,
|
||||||
rotation = rotation,
|
rotation = rotation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -126,45 +196,3 @@ sealed interface VideoTranscodingEvent {
|
||||||
data class Progress(val value: Float) : VideoTranscodingEvent
|
data class Progress(val value: Float) : VideoTranscodingEvent
|
||||||
data class Completed(val file: File) : VideoTranscodingEvent
|
data class Completed(val file: File) : VideoTranscodingEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object VideoStrategyFactory {
|
|
||||||
// 720p
|
|
||||||
private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
|
|
||||||
|
|
||||||
// 1080p
|
|
||||||
private const val MAX_PIXEL_SIZE = 1920
|
|
||||||
|
|
||||||
fun create(
|
|
||||||
expectedExtension: String?,
|
|
||||||
metadata: VideoFileMetadata?,
|
|
||||||
shouldBeCompressed: Boolean,
|
|
||||||
): TrackStrategy {
|
|
||||||
val width = metadata?.width?.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
|
||||||
val height = metadata?.height?.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
|
||||||
val bitrate = metadata?.bitrate?.takeIf { it >= 0 }
|
|
||||||
val frameRate = metadata?.frameRate?.takeIf { it >= 0 }
|
|
||||||
val rotation = metadata?.rotation?.takeIf { it >= 0 }
|
|
||||||
|
|
||||||
// We only create a resizer if needed
|
|
||||||
val resizer = when {
|
|
||||||
shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> AtMostResizer(MAX_COMPRESSED_PIXEL_SIZE)
|
|
||||||
width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> AtMostResizer(MAX_PIXEL_SIZE)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (resizer == null && rotation == 0 && expectedExtension == MP4_EXTENSION) {
|
|
||||||
// If there's no transcoding or resizing needed for the video file, just create a new file with the same contents but no metadata
|
|
||||||
// Rotation is not kept by the PassThroughTrackStrategy, so we need to ensure the video is not rotated
|
|
||||||
PassThroughTrackStrategy()
|
|
||||||
} else {
|
|
||||||
DefaultVideoStrategy.Builder()
|
|
||||||
.apply {
|
|
||||||
resizer?.let { addResizer(it) }
|
|
||||||
bitrate?.let { bitRate(it) }
|
|
||||||
frameRate?.let { frameRate(it) }
|
|
||||||
}
|
|
||||||
.mimeType(MediaFormatConstants.MIMETYPE_VIDEO_AVC)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaupload.impl
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.Size
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.transformer.VideoEncoderSettings
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal object VideoCompressorConfigFactory {
|
||||||
|
// Major dimension of 720p
|
||||||
|
private const val MAX_COMPRESSED_PIXEL_SIZE = 1280
|
||||||
|
|
||||||
|
// Major dimension of 1080p
|
||||||
|
private const val MAX_PIXEL_SIZE = 1920
|
||||||
|
|
||||||
|
private const val DEFAULT_FRAME_RATE = 30
|
||||||
|
|
||||||
|
fun create(
|
||||||
|
metadata: VideoFileMetadata?,
|
||||||
|
shouldBeCompressed: Boolean,
|
||||||
|
): VideoCompressorConfig {
|
||||||
|
val width = metadata?.width?.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||||
|
val height = metadata?.height?.takeIf { it >= 0 } ?: Int.MAX_VALUE
|
||||||
|
val originalBitrate = metadata?.bitrate?.takeIf { it >= 0 }
|
||||||
|
val originalFrameRate = metadata?.frameRate?.takeIf { it >= 0 } ?: DEFAULT_FRAME_RATE
|
||||||
|
|
||||||
|
// We only create a resizer if needed
|
||||||
|
val resizer = when {
|
||||||
|
shouldBeCompressed && (width > MAX_COMPRESSED_PIXEL_SIZE || height > MAX_COMPRESSED_PIXEL_SIZE) -> VideoResizer(MAX_COMPRESSED_PIXEL_SIZE)
|
||||||
|
width > MAX_PIXEL_SIZE || height > MAX_PIXEL_SIZE -> VideoResizer(MAX_PIXEL_SIZE)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are resizing, we also want to reduce the frame rate to the default value (30fps)
|
||||||
|
val newFrameRate = if (resizer is VideoResizer) {
|
||||||
|
min(originalFrameRate, DEFAULT_FRAME_RATE)
|
||||||
|
} else {
|
||||||
|
originalFrameRate
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we need to resize the video, we also want to recalculate the bitrate
|
||||||
|
val newBitrate = if (resizer is VideoResizer) {
|
||||||
|
val maxSize = resizer.getOutputSize(Size(width, height))
|
||||||
|
val pixelsPerFrame = maxSize.width * maxSize.height
|
||||||
|
val frameRate = newFrameRate
|
||||||
|
// Apparently, 0.1 bits per pixel is a sweet spot for video compression
|
||||||
|
val bitsPerPixel = 0.1f
|
||||||
|
|
||||||
|
(pixelsPerFrame * bitsPerPixel * frameRate).toLong()
|
||||||
|
} else {
|
||||||
|
originalBitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
return VideoCompressorConfig(
|
||||||
|
resizer = resizer,
|
||||||
|
newBitRate = newBitrate?.toInt() ?: VideoEncoderSettings.NO_VALUE,
|
||||||
|
newFrameRate = newFrameRate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal data class VideoCompressorConfig(
|
||||||
|
val resizer: VideoResizer?,
|
||||||
|
val newBitRate: Int,
|
||||||
|
val newFrameRate: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class VideoResizer(
|
||||||
|
val maxSize: Int,
|
||||||
|
) {
|
||||||
|
fun getOutputSize(inputSize: Size): Size {
|
||||||
|
val resultMajor = min(inputSize.major(), maxSize)
|
||||||
|
val aspectRatio = inputSize.major().toFloat() / inputSize.minor().toFloat()
|
||||||
|
return Size(resultMajor, (resultMajor / aspectRatio).roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal fun Size.major(): Int = if (width > height) width else height
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal fun Size.minor(): Int = if (width < height) width else height
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaupload.impl
|
||||||
|
|
||||||
|
import androidx.media3.transformer.VideoEncoderSettings
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class VideoCompressorConfigFactoryTest {
|
||||||
|
@Test
|
||||||
|
fun `if we don't have metadata the video will be resized`() {
|
||||||
|
// Given
|
||||||
|
val metadata = null
|
||||||
|
val shouldBeCompressed = false
|
||||||
|
|
||||||
|
// When
|
||||||
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
|
metadata = metadata,
|
||||||
|
shouldBeCompressed = shouldBeCompressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(videoCompressorConfig.resizer).isNotNull()
|
||||||
|
assertThat(videoCompressorConfig.newFrameRate).isEqualTo(30)
|
||||||
|
assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `if the video should be compressed and is larger than 720p it will be resized`() {
|
||||||
|
// Given
|
||||||
|
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
||||||
|
val shouldBeCompressed = true
|
||||||
|
|
||||||
|
// When
|
||||||
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
|
metadata = metadata,
|
||||||
|
shouldBeCompressed = shouldBeCompressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertIsResized(videoCompressorConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `if the video should be compressed and is smaller or equal to 720p it will not be resized`() {
|
||||||
|
// Given
|
||||||
|
val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
||||||
|
val shouldBeCompressed = true
|
||||||
|
|
||||||
|
// When
|
||||||
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
|
metadata = metadata,
|
||||||
|
shouldBeCompressed = shouldBeCompressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertIsNotResized(videoCompressorConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `if the video should not be compressed and is larger than 1080p it will be resized`() {
|
||||||
|
// Given
|
||||||
|
val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
||||||
|
val shouldBeCompressed = false
|
||||||
|
|
||||||
|
// When
|
||||||
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
|
metadata = metadata,
|
||||||
|
shouldBeCompressed = shouldBeCompressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertIsResized(videoCompressorConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `if the video should not be compressed and is smaller or equal than 1080p it will not be resized`() {
|
||||||
|
// Given
|
||||||
|
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
||||||
|
val shouldBeCompressed = false
|
||||||
|
|
||||||
|
// When
|
||||||
|
val videoCompressorConfig = VideoCompressorConfigFactory.create(
|
||||||
|
metadata = metadata,
|
||||||
|
shouldBeCompressed = shouldBeCompressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertIsNotResized(videoCompressorConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun assertIsResized(videoCompressorConfig: VideoCompressorConfig) {
|
||||||
|
assertThat(videoCompressorConfig.resizer).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun assertIsNotResized(videoCompressorConfig: VideoCompressorConfig) {
|
||||||
|
assertThat(videoCompressorConfig.resizer).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.mediaupload.impl
|
|
||||||
|
|
||||||
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
|
|
||||||
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
|
|
||||||
import com.otaliastudios.transcoder.strategy.TrackStrategy
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
class VideoStrategyFactoryTest {
|
|
||||||
@Test
|
|
||||||
fun `if we don't have metadata the video will be transcoded just in case`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mp4"
|
|
||||||
val metadata = null
|
|
||||||
val shouldBeCompressed = true
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should be compressed and is larger than 720p it will be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mp4"
|
|
||||||
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
|
||||||
val shouldBeCompressed = true
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should be compressed, has the right format and is smaller or equal to 720p it will not be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mp4"
|
|
||||||
val metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
|
||||||
val shouldBeCompressed = true
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsNotTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should not be compressed and is larger than 1080p it will be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mp4"
|
|
||||||
val metadata = VideoFileMetadata(width = 2560, height = 1440, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
|
||||||
val shouldBeCompressed = false
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should not be compressed, has the right format and is smaller or equal than 1080p it will not be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mp4"
|
|
||||||
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
|
||||||
val shouldBeCompressed = false
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsNotTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should not be compressed but has a wrong format it will be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mkv"
|
|
||||||
val metadata = VideoFileMetadata(width = 320, height = 240, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
|
||||||
val shouldBeCompressed = false
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should be compressed and has a wrong format it will be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mkv"
|
|
||||||
val metadata = VideoFileMetadata(width = 320, height = 240, bitrate = 1_000_000, frameRate = 50, rotation = 0)
|
|
||||||
val shouldBeCompressed = true
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `if the video should not be compressed but has a rotation not zero it will be transcoded`() {
|
|
||||||
// Given
|
|
||||||
val expectedExtension = "mp4"
|
|
||||||
val metadata = VideoFileMetadata(width = 320, height = 240, bitrate = 1_000_000, frameRate = 50, rotation = 90)
|
|
||||||
val shouldBeCompressed = false
|
|
||||||
|
|
||||||
// When
|
|
||||||
val videoStrategy = VideoStrategyFactory.create(
|
|
||||||
expectedExtension = expectedExtension,
|
|
||||||
metadata = metadata,
|
|
||||||
shouldBeCompressed = shouldBeCompressed
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertIsTranscoded(videoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun assertIsTranscoded(videoStrategy: TrackStrategy) {
|
|
||||||
assert(videoStrategy is DefaultVideoStrategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun assertIsNotTranscoded(videoStrategy: TrackStrategy) {
|
|
||||||
assert(videoStrategy is PassThroughTrackStrategy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue