Last active
August 31, 2025 18:58
-
-
Save bigman212/5c79ff928c2e74fd15a13dd642ee1bd8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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