improved memory usage and check for video thumbnail decoding

This commit is contained in:
Thibault Deckers 2024-04-25 00:37:36 +02:00
parent 99cd9faec7
commit a3dcf41786
3 changed files with 82 additions and 7 deletions

View file

@ -61,7 +61,7 @@ internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int)
val bitmapWidth: Int val bitmapWidth: Int
val bitmapHeight: Int val bitmapHeight: Int
if (width / height > svgWidth / svgHeight) { if (width / height.toFloat() > svgWidth / svgHeight) {
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt() bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
bitmapHeight = height bitmapHeight = height
} else { } else {

View file

@ -63,7 +63,7 @@ internal class TiffFetcher(val model: TiffImage, val width: Int, val height: Int
TiffBitmapFactory.decodeFileDescriptor(fd, options) TiffBitmapFactory.decodeFileDescriptor(fd, options)
val imageWidth = options.outWidth val imageWidth = options.outWidth
val imageHeight = options.outHeight val imageHeight = options.outHeight
if (imageHeight > height || imageWidth > width) { if (imageWidth > width || imageHeight > height) {
while (imageHeight / (sampleSize * 2) >= height && imageWidth / (sampleSize * 2) >= width) { while (imageHeight / (sampleSize * 2) >= height && imageWidth / (sampleSize * 2) >= width) {
sampleSize *= 2 sampleSize *= 2
} }

View file

@ -1,8 +1,11 @@
package deckers.thibault.aves.decoder package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
@ -16,7 +19,9 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.module.LibraryGlideModule import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -24,6 +29,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import kotlin.math.ceil
import kotlin.math.roundToInt
@GlideModule @GlideModule
class VideoThumbnailGlideModule : LibraryGlideModule() { class VideoThumbnailGlideModule : LibraryGlideModule() {
@ -36,7 +43,7 @@ class VideoThumbnail(val context: Context, val uri: Uri)
internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> { internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> { override fun buildLoadData(model: VideoThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream> {
return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model)) return ModelLoader.LoadData(ObjectKey(model.uri), VideoThumbnailFetcher(model, width, height))
} }
override fun handles(model: VideoThumbnail): Boolean = true override fun handles(model: VideoThumbnail): Boolean = true
@ -48,7 +55,7 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
} }
} }
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> { internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val width: Int, val height: Int) : DataFetcher<InputStream> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) { override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
@ -68,10 +75,62 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
if (durationMillis != null) { if (durationMillis != null) {
timeMillis = if (durationMillis < 15000) 0 else 15000 timeMillis = if (durationMillis < 15000) 0 else 15000
} }
val frame = if (timeMillis != null) { val timeMicros = if (timeMillis != null) timeMillis * 1000 else -1
retriever.getFrameAtTime(timeMillis * 1000) val option = MediaMetadataRetriever.OPTION_CLOSEST_SYNC
var videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
var videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
if (videoWidth == null || videoHeight == null) {
throw Exception("failed to get video dimensions")
}
var dstWidth = 0
var dstHeight = 0
if (width > 0 && height > 0) {
val rotationDegrees = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull()
if (rotationDegrees != null) {
val isRotated = rotationDegrees % 180 == 90
if (isRotated) {
val temp = videoWidth
videoWidth = videoHeight
videoHeight = temp
}
// cover fit
val videoAspectRatio = videoWidth / videoHeight
if (videoWidth > width || videoHeight > height) {
if (width / height.toFloat() > videoAspectRatio) {
dstHeight = ceil(videoHeight * width / videoWidth).toInt()
dstWidth = (dstHeight * videoAspectRatio).roundToInt()
} else {
dstWidth = ceil(videoWidth * height / videoHeight).toInt()
dstHeight = (dstWidth / videoAspectRatio).roundToInt()
}
}
}
}
// the returned frame is already rotated according to the video metadata
val frame = if (dstWidth > 0 && dstHeight > 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
val targetBitmapSizeBytes: Long = FORMAT_BYTE_SIZE.toLong() * dstWidth * dstHeight
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the scaled frame at $dstWidth x $dstHeight")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight, getBitmapParams())
} else {
retriever.getScaledFrameAtTime(timeMicros, option, dstWidth, dstHeight)
}
} else { } else {
retriever.frameAtTime val targetBitmapSizeBytes: Long = (FORMAT_BYTE_SIZE.toLong() * videoWidth * videoHeight).toLong()
if (!MemoryUtils.canAllocate(targetBitmapSizeBytes)) {
throw Exception("not enough memory to allocate $targetBitmapSizeBytes bytes for the full frame at $videoWidth x $videoHeight")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
retriever.getFrameAtTime(timeMicros, option, getBitmapParams())
} else {
retriever.getFrameAtTime(timeMicros, option)
}
} }
bytes = frame?.getBytes(canHaveAlpha = false, recycle = false) bytes = frame?.getBytes(canHaveAlpha = false, recycle = false)
} }
@ -91,6 +150,17 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
} }
} }
@RequiresApi(Build.VERSION_CODES.P)
private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel)
// for wide-gamut and HDR content which does not require alpha blending
setPreferredConfig(Bitmap.Config.RGBA_1010102)
} else {
setPreferredConfig(Bitmap.Config.ARGB_8888)
}
}
// already cleaned up in loadData and ByteArrayInputStream will be GC'd // already cleaned up in loadData and ByteArrayInputStream will be GC'd
override fun cleanup() {} override fun cleanup() {}
@ -100,4 +170,9 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFe
override fun getDataClass(): Class<InputStream> = InputStream::class.java override fun getDataClass(): Class<InputStream> = InputStream::class.java
override fun getDataSource(): DataSource = DataSource.LOCAL override fun getDataSource(): DataSource = DataSource.LOCAL
companion object {
// same for either `ARGB_8888` or `RGBA_1010102`
private const val FORMAT_BYTE_SIZE = BitmapUtils.ARGB_8888_BYTE_SIZE
}
} }