Skip to content

Instantly share code, notes, and snippets.

@bigman212
Last active August 31, 2025 18:58
Show Gist options
  • Select an option

  • Save bigman212/5c79ff928c2e74fd15a13dd642ee1bd8 to your computer and use it in GitHub Desktop.

Select an option

Save bigman212/5c79ff928c2e74fd15a13dd642ee1bd8 to your computer and use it in GitHub Desktop.
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import androidx.annotation.IntRange
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.effect.Presentation
import androidx.media3.transformer.Composition
import androidx.media3.transformer.DefaultEncoderFactory
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.Transformer
import androidx.media3.transformer.VideoEncoderSettings
import fitness.online.app.core.extensions.orThrow
import fitness.online.app.util.coroutines.AppIoScope
import fitness.online.app.util.coroutines.DispatcherProvider
import fitness.online.app.util.file.FileHelper
import fitness.online.app.util.media.WidthHeight
import fitness.online.app.util.orZero
import fitness.online.app.util.toMultipleValue
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import kotlin.coroutines.resume
class VideoCompressor @Inject constructor(
private val appIoScope: AppIoScope,
private val context: Context,
private val dispatcherProvider: DispatcherProvider,
) {
companion object {
/**
* Max size for width/height of result video on crop.
*/
private const val CROP_VIDEO_SIZE = 960
/**
* Max size for smaller size of width/height.
*/
private const val MAX_VIDEO_SIZE = 720
/**
* Result video height should be multiple of 2. It's codec requirement.
*/
private const val HEIGHT_MULTIPLE_VALUE = 2
/**
* Delta to get progress
*/
private const val GET_PROGRESS_DELAY_MILLS = 500L
/**
* 1 mb buffer size to make a copy video file.
*/
private const val BUFFER_SIZE = 1_048_576
}
/**
* @param cropVideo
* TRUE - Compress video and reduce size to 960px for greater of width/Height.
* FALSE - Compress video and reduce size to 720px for smaller width/height.
*
* High quality video is enable for compress.
*/
suspend fun compressVideo(
videoUri: Uri,
cropVideo: Boolean,
progressListener: CompressProgressListener
): CompressResult {
val currentVideoSize = getVideoWidthHeight(videoUri)
val targetVideoSize = if (cropVideo) {
getCropScaleSize(currentVideoSize)
} else {
getScaleSize(currentVideoSize)
}
return internalCompressVideo(videoUri, currentVideoSize, targetVideoSize, progressListener)
}
@OptIn(UnstableApi::class)
private suspend fun internalCompressVideo(
videoUri: Uri,
currentVideoSize: WidthHeight,
targetVideoSize: WidthHeight,
listener: CompressProgressListener
): CompressResult = suspendCancellableCoroutine { continuation ->
val isSizeChanged = currentVideoSize.width != targetVideoSize.width
&& currentVideoSize.height != targetVideoSize.height
// No video size changed. No need compression. Return original video.
if (!isSizeChanged) {
val videoFile = copyToNewFileFromUri(videoUri = videoUri)
Timber.i("No need to compress: $videoUri, target size is not changed, size: ${videoFile.length()}")
continuation.resume(
CompressResult.OnSuccess(
resultFile = videoFile,
videoWidth = currentVideoSize.width,
videoHeight = currentVideoSize.height
)
)
return@suspendCancellableCoroutine
}
val targetVideoHeight = targetVideoSize.height.toMultipleValue(value = HEIGHT_MULTIPLE_VALUE)
val resultFileName = FileHelper.getNewVideoFileName()
val resultFile = File(context.cacheDir, resultFileName)
var progressJob: Job? = null
Timber.i(
"Compressing video: $videoUri to ${resultFile.toUri()}. Size $currentVideoSize -> $targetVideoSize"
)
// Transformation with desired height and saved scale.
val editedMediaItem =
EditedMediaItem.Builder(MediaItem.fromUri(videoUri))
.setEffects(
Effects(
/* audioProcessors= */ listOf(),
/* videoEffects= */ listOf(
Presentation.createForHeight(targetVideoHeight)
)
)
)
.build()
// Compression listener
val transformerListener = object : Transformer.Listener {
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
super.onCompleted(composition, exportResult)
Timber.i(
"Compress completed to ${resultFile.toUri()}, " +
"width=${exportResult.width}, height=${exportResult.height}"
)
Timber.i("Size after compression: ${resultFile.length()}")
// Get the actual dimensions after compression, considering rotation
val actualWidth = if (currentVideoSize.width > currentVideoSize.height) {
exportResult.width
} else {
exportResult.height
}
val actualHeight = if (currentVideoSize.width > currentVideoSize.height) {
exportResult.height
} else {
exportResult.width
}
progressJob?.cancel()
continuation.resume(
CompressResult.OnSuccess(
resultFile = resultFile,
videoWidth = actualWidth,
videoHeight = actualHeight
)
)
}
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
super.onError(composition, exportResult, exportException)
Timber.e(exportException, "Compress error for $videoUri")
progressJob?.cancel()
continuation.resume(CompressResult.OnError(throwable = VideoCompressFailed("", exportException)))
}
}
val transformer = Transformer.Builder(context)
.setEncoderFactory(
DefaultEncoderFactory.Builder(context)
.setRequestedVideoEncoderSettings(
VideoEncoderSettings.Builder()
.experimentalSetEnableHighQualityTargeting(true)
.build()
)
.build()
)
.setVideoMimeType(MimeTypes.VIDEO_H264)
.addListener(transformerListener)
.build()
progressJob = getProgressJob(transformer, listener)
// Launch compression from MAIN thread. It's Media 3 requirement.
val startCompression = appIoScope.launch(dispatcherProvider.main) {
transformer.start(editedMediaItem, resultFile.absolutePath)
}
continuation.invokeOnCancellation {
startCompression.cancel()
progressJob.cancel()
transformer.cancel()
}
}
@OptIn(UnstableApi::class)
private fun getProgressJob(transformer: Transformer, listener: CompressProgressListener): Job =
appIoScope.launch {
val progressHolder = ProgressHolder()
while (isActive) {
withContext(dispatcherProvider.main) {
transformer.getProgress(progressHolder)
listener.onProgress(progressHolder.progress)
Timber.d("Progress result: ${progressHolder.progress}")
}
delay(GET_PROGRESS_DELAY_MILLS)
}
}
private fun getScaleSize(size: WidthHeight): WidthHeight {
val scale = MAX_VIDEO_SIZE / size.getMinDimension().toFloat()
return if (scale > 1f) {
size
} else {
WidthHeight(
width = (size.width * scale).toInt(),
height = (size.height * scale).toInt()
)
}
}
private fun getCropScaleSize(size: WidthHeight): WidthHeight {
val scale = CROP_VIDEO_SIZE / size.getMaxDimension().toFloat()
return if (scale > 1f) {
size
} else {
WidthHeight(
width = (size.width * scale).toInt(),
height = (size.height * scale).toInt()
)
}
}
private fun getVideoWidthHeight(videoUri: Uri): WidthHeight = runCatching {
MediaMetadataRetriever().apply {
setDataSource(context, videoUri)
}
.use {
val width = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
.orThrow { "Expected to extract width metadata for $videoUri" }
.toInt()
val height = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
.orThrow { "Expected to extract height metadata for $videoUri" }
.toInt()
val rotation = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull().orZero()
if (rotation == 90 || rotation == 270) {
WidthHeight(width = height, height = width)
} else {
WidthHeight(width = width, height = height)
}
}
}
.onFailure { Timber.w(it, "Failed to get width and height for video") }
.getOrThrow()
private fun copyToNewFileFromUri(videoUri: Uri): File {
val resultFile: File = FileHelper.getNewVideoFile()
val input = context.contentResolver.openInputStream(videoUri).orThrow {
"Expected $videoUri to be opened as InputStream from content resolver, but was null"
}
Timber.i("copyToNewFileFromUri: (${input.available()}) $videoUri")
input.use { `in` ->
FileOutputStream(resultFile).use { out -> `in`.copyTo(out, bufferSize = BUFFER_SIZE) }
}
return resultFile
}
sealed interface CompressResult {
data class OnSuccess(
val resultFile: File,
val videoWidth: Int,
val videoHeight: Int
) : CompressResult
data class OnError(
val throwable: Throwable
) : CompressResult
}
fun interface CompressProgressListener {
/**
* Emit progress in main thread.
*/
fun onProgress(@IntRange(from = 0, to = 100) progress: Int)
}
class VideoCompressFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment