move video compression into helper

This commit is contained in:
davotoula
2025-09-22 20:51:24 +02:00
parent 49dc63c876
commit 524ead2eac
2 changed files with 294 additions and 123 deletions

View File

@@ -22,18 +22,10 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.text.format.Formatter.formatFileSize
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.abedelazizshe.lightcompressorlibrary.config.VideoResizer
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -54,125 +46,14 @@ class MediaCompressorResult(
val size: Long?, val size: Long?,
) )
data class CompressionRule(
val width: Int,
val height: Int,
val bitrateMbps: Float,
val description: String,
) {
fun getBitrateMbpsInt(): Int {
// Library doesn't support float so we have to convert it to int and use 1 as minimum
return if (bitrateMbps < 1) {
1
} else {
bitrateMbps.roundToInt()
}
}
}
private data class VideoInfo(
val resolution: VideoResolution,
val framerate: Float,
)
data class VideoResolution(
val width: Int,
val height: Int,
) {
val pixels: Int get() = width * height
val isPortrait: Boolean get() = height > width
fun getStandardName(): String =
when {
pixels >= 3840 * 2160 -> "4K"
pixels >= 2560 * 1440 -> "1440p"
pixels >= 1920 * 1080 -> "1080p"
pixels >= 1280 * 720 -> "720p"
pixels >= 854 * 480 -> "480p"
pixels >= 640 * 360 -> "360p"
pixels >= 426 * 240 -> "240p"
else -> "${width}x$height"
}
}
private val compressionRules =
mapOf(
CompressorQuality.LOW to
mapOf(
"4K" to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"),
"1440p" to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"),
"1080p" to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"),
"720p" to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"),
"480p" to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"),
"360p" to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"),
"240p" to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"),
"default" to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"),
),
CompressorQuality.MEDIUM to
mapOf(
"4K" to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"),
"1440p" to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"),
"1080p" to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"),
"720p" to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"),
"480p" to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"),
"360p" to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"),
"240p" to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"),
"default" to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"),
),
CompressorQuality.HIGH to
mapOf(
"4K" to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"),
"1440p" to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"),
"1080p" to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"),
"720p" to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"),
"480p" to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"),
"360p" to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"),
"240p" to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"),
"default" to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"),
),
)
private fun getVideoInfo(
uri: Uri,
context: Context,
): VideoInfo? =
try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val width = retriever.prepareVideoWidth()
val height = retriever.prepareVideoHeight()
val rotation = retriever.prepareRotation() ?: 0
// Get framerate
val framerateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
val framerate = framerateString?.toFloatOrNull() ?: 30.0f
retriever.release()
if (width != null && height != null && width > 0 && height > 0) {
// Account for rotation
val resolution =
if (rotation == 90 || rotation == 270) {
VideoResolution(height, width)
} else {
VideoResolution(width, height)
}
VideoInfo(resolution, framerate)
} else {
null
}
} catch (e: Exception) {
Log.w("MediaCompressor", "Failed to get video resolution: ${e.message}")
null
}
/** The plan /** The plan
* xxx 1. Check input resolution and input fps * xxx 1. Check input resolution and input fps
* xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution
* xxx 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false * xxx 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false
* 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) * xxx 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null))
* xxx 5. Add toast message about file size saving * xxx 5. Add toast message about file size saving
* 7. refactor (helper class for video compression) * xxx 6. refactor (helper class for video compression)
* 7. Fix toast for case when compressed file is larger than original
* *
* *
* Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality
@@ -200,7 +81,10 @@ class MediaCompressor {
// branch into compression based on content type // branch into compression based on content type
return when { return when {
contentType?.startsWith("video", ignoreCase = true) == true -> compressVideo(uri, contentType, applicationContext, mediaQuality) contentType?.startsWith("video", ignoreCase = true) == true -> {
val helper = VideoCompressionHelper()
helper.compressVideo(uri, contentType, applicationContext, mediaQuality)
}
contentType?.startsWith("image", ignoreCase = true) == true && contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") && !contentType.contains("gif") &&
!contentType.contains("svg") -> !contentType.contains("svg") ->

View File

@@ -0,0 +1,287 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.text.format.Formatter.formatFileSize
import android.util.Log
import android.widget.Toast
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.abedelazizshe.lightcompressorlibrary.config.VideoResizer
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.math.roundToInt
data class VideoInfo(
val resolution: VideoResolution,
val framerate: Float,
)
data class VideoResolution(
val width: Int,
val height: Int,
) {
val pixels: Int get() = width * height
fun getStandardName(): String =
when {
pixels >= 3840 * 2160 -> "4K"
pixels >= 2560 * 1440 -> "1440p"
pixels >= 1920 * 1080 -> "1080p"
pixels >= 1280 * 720 -> "720p"
pixels >= 854 * 480 -> "480p"
pixels >= 640 * 360 -> "360p"
pixels >= 426 * 240 -> "240p"
else -> "${width}x$height"
}
}
data class CompressionRule(
val width: Int,
val height: Int,
val bitrateMbps: Float,
val description: String,
) {
fun getBitrateMbpsInt(): Int {
// Library doesn't support float so we have to convert it to int and use 1 as minimum
return if (bitrateMbps < 1) {
1
} else {
bitrateMbps.roundToInt()
}
}
}
class VideoCompressionHelper {
companion object {
private val compressionRules =
mapOf(
CompressorQuality.LOW to
mapOf(
"4K" to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"),
"1440p" to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"),
"1080p" to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"),
"720p" to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"),
"480p" to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"),
"360p" to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"),
"240p" to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"),
"default" to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"),
),
CompressorQuality.MEDIUM to
mapOf(
"4K" to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"),
"1440p" to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"),
"1080p" to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"),
"720p" to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"),
"480p" to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"),
"360p" to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"),
"240p" to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"),
"default" to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"),
),
CompressorQuality.HIGH to
mapOf(
"4K" to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"),
"1440p" to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"),
"1080p" to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"),
"720p" to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"),
"480p" to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"),
"360p" to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"),
"240p" to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"),
"default" to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"),
),
)
}
suspend fun compressVideo(
uri: Uri,
contentType: String?,
applicationContext: Context,
mediaQuality: CompressorQuality,
): MediaCompressorResult {
val videoInfo = getVideoInfo(uri, applicationContext)
val videoBitrateInMbps =
if (videoInfo != null) {
val bitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt()
Log.d("VideoCompressionHelper", "Video bitrate calculated: ${bitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality")
bitrate
} else {
// Default/fallback logic when videoInfo is null
Log.d("VideoCompressionHelper", "Video bitrate fallback: 2Mbps (videoInfo unavailable)")
2
}
val resizer =
if (videoInfo != null) {
val rules = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName())
Log.d("VideoCompressionHelper", "Video resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> ${rules.width}x${rules.height} (${rules.description})")
VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble())
} else {
// null VideoResizer should result in unchanged resolution
Log.d("VideoCompressionHelper", "Video resizer: null (original resolution preserved)")
null
}
// Get original file size for compression reporting
val originalSize =
try {
applicationContext.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.available().toLong()
} ?: 0L
} catch (e: Exception) {
Log.w("VideoCompressionHelper", "Failed to get original file size: ${e.message}")
0L
}
val result =
withTimeoutOrNull(30000) {
suspendCancellableCoroutine { continuation ->
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = true,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
storageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
videoBitrateInMbps = videoBitrateInMbps,
resizer = resizer,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
isMinBitrateCheckEnabled = false,
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {}
override fun onStart(index: Int) {}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
// Sanity check: if compressed file is larger than original, return original
if (originalSize > 0 && size >= originalSize) {
Log.d("VideoCompressionHelper", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.")
continuation.resume(MediaCompressorResult(uri, contentType, null))
return
}
val reductionPercent =
if (originalSize > 0) {
((originalSize - size) * 100.0 / originalSize).toInt()
} else {
0
}
// Show compression result toast
if (originalSize > 0 && size > 0) {
val message =
"Video compressed: ${formatFileSize(applicationContext, size)} " +
"(${if (reductionPercent > 0) "-$reductionPercent%" else "+${-reductionPercent}%"})"
// Post on main thread for Toast
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
Log.d("VideoCompressionHelper", "Video compression success. Original size [$originalSize] -> Compressed size [$size] ($reductionPercent% reduction)")
continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size))
} else {
Log.d("VideoCompressionHelper", "Video compression successful, but returned null path")
continuation.resume(null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
Log.d("VideoCompressionHelper", "Video compression failed: $failureMessage")
// keeps going with original video
continuation.resume(null)
}
override fun onCancelled(index: Int) {
continuation.resume(null)
}
},
)
}
}
return result ?: MediaCompressorResult(uri, contentType, null)
}
private fun getVideoInfo(
uri: Uri,
context: Context,
): VideoInfo? =
try {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val width = retriever.prepareVideoWidth()
val height = retriever.prepareVideoHeight()
val rotation = retriever.prepareRotation() ?: 0
// Get framerate
val framerateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
val framerate = framerateString?.toFloatOrNull() ?: 30.0f
retriever.release()
if (width != null && height != null && width > 0 && height > 0) {
// Account for rotation
val resolution =
if (rotation == 90 || rotation == 270) {
VideoResolution(height, width)
} else {
VideoResolution(width, height)
}
VideoInfo(resolution, framerate)
} else {
null
}
} catch (e: Exception) {
Log.w("VideoCompressionHelper", "Failed to get video resolution: ${e.message}")
null
}
}