image: refactor transcoding

- Don't transcode into memory
- Make AppFiles (now CoverFiles) handle transcoding
- Don't bother transcoding if no work needs to be done
This commit is contained in:
Alexander Capehart 2024-11-29 14:58:50 -07:00
parent 6b8b147721
commit a22e972bd3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 116 additions and 99 deletions

View file

@ -23,6 +23,7 @@ import javax.inject.Inject
import org.oxycblt.auxio.image.Cover
import org.oxycblt.auxio.image.stack.cache.CoverCache
import org.oxycblt.auxio.image.stack.extractor.CoverExtractor
import timber.log.Timber
interface CoverRetriever {
suspend fun retrieve(cover: Cover.Single): InputStream?
@ -33,5 +34,12 @@ class CoverRetrieverImpl
constructor(private val coverCache: CoverCache, private val coverExtractor: CoverExtractor) :
CoverRetriever {
override suspend fun retrieve(cover: Cover.Single) =
coverCache.read(cover) ?: coverExtractor.extract(cover)?.let { coverCache.write(cover, it) }
try {
coverCache.read(cover)
?: coverExtractor.extract(cover)?.let { coverCache.write(cover, it) }
} catch (e: Exception) {
Timber.e("Image extraction failed!")
Timber.e(e.stackTraceToString())
throw e
}
}

View file

@ -31,11 +31,13 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface StackModule {
@Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles
@Singleton @Binds fun appFiles(impl: CoverFilesImpl): CoverFiles
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache
@Binds fun coverIdentifier(identifierImpl: CoverIdentifierImpl): CoverIdentifier
@Binds fun coverFormat(coverFormatImpl: CoverFormatImpl): CoverFormat
}
@Module

View file

@ -18,11 +18,6 @@
package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Inject
import org.oxycblt.auxio.image.Cover
@ -38,79 +33,19 @@ class CoverCacheImpl
constructor(
private val coverIdentifier: CoverIdentifier,
private val storedCoversDao: StoredCoversDao,
private val appFiles: AppFiles
private val coverFiles: CoverFiles
) : CoverCache {
override suspend fun read(cover: Cover.Single): InputStream? {
val perceptualHash =
storedCoversDao.getStoredCover(cover.uid, cover.lastModified) ?: return null
return appFiles.read(fileName(perceptualHash))
val id = storedCoversDao.getStoredCoverId(cover.uid, cover.lastModified) ?: return null
return coverFiles.read(id)
}
override suspend fun write(cover: Cover.Single, data: ByteArray): InputStream? {
val id = coverIdentifier.identify(data)
val file = fileName(id)
if (!appFiles.exists(file)) {
val transcoded = transcodeImage(data, FORMAT_WEBP)
val writeSuccess = appFiles.write(fileName(id), transcoded)
if (!writeSuccess) {
return null
}
}
coverFiles.write(id, data)
storedCoversDao.setStoredCover(
StoredCover(uid = cover.uid, lastModified = cover.lastModified, coverId = id))
return appFiles.read(file)
}
private fun fileName(id: String) = "cover_$id"
private fun transcodeImage(data: ByteArray, targetFormat: Bitmap.CompressFormat): InputStream {
val options =
BitmapFactory.Options().apply {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, this)
}
options.inSampleSize = calculateInSampleSize(options, 750, 750)
val bitmap =
BitmapFactory.decodeByteArray(
data, 0, data.size, options.apply { inJustDecodeBounds = false })
return ByteArrayOutputStream().use { outputStream ->
bitmap?.compress(targetFormat, 80, outputStream)
ByteArrayInputStream(outputStream.toByteArray())
}
}
private fun calculateInSampleSize(
options: BitmapFactory.Options,
reqWidth: Int,
reqHeight: Int
): Int {
var inSampleSize = 1
val (height, width) = options.outHeight to options.outWidth
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while ((halfHeight / inSampleSize) >= reqHeight &&
(halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
private companion object {
val FORMAT_WEBP =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
return coverFiles.read(id)
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2024 Auxio Project
* AppFiles.kt is part of Auxio.
* CoverFiles.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -24,22 +24,23 @@ import java.io.File
import java.io.IOException
import java.io.InputStream
import javax.inject.Inject
import kotlin.concurrent.withLock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface AppFiles {
suspend fun read(file: String): InputStream?
interface CoverFiles {
suspend fun read(id: String): InputStream?
suspend fun write(file: String, inputStream: InputStream): Boolean
suspend fun exists(file: String): Boolean
suspend fun write(id: String, data: ByteArray)
}
class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) :
AppFiles {
class CoverFilesImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val coverFormat: CoverFormat
) : CoverFiles {
private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex()
@ -47,39 +48,38 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context:
return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } }
}
override suspend fun read(file: String): InputStream? =
override suspend fun read(id: String): InputStream? =
withContext(Dispatchers.IO) {
try {
context.openFileInput(file)
context.openFileInput(getTargetFilePath(id))
} catch (e: IOException) {
null
}
}
override suspend fun write(file: String, inputStream: InputStream): Boolean =
withContext(Dispatchers.IO) {
val fileMutex = getMutexForFile(file)
override suspend fun write(id: String, data: ByteArray) {
val fileMutex = getMutexForFile(id)
fileMutex.withLock {
val tempFile = File(context.filesDir, "$file.tmp")
val targetFile = File(context.filesDir, file)
fileMutex.withLock {
val targetFile = File(context.filesDir, getTargetFilePath(id))
if (targetFile.exists()) {
return
}
withContext(Dispatchers.IO) {
val tempFile = File(context.filesDir, getTempFilePath(id))
try {
tempFile.outputStream().use { fileOutputStream ->
inputStream.copyTo(fileOutputStream)
}
tempFile.outputStream().use { coverFormat.transcodeInto(data, it) }
tempFile.renameTo(targetFile)
true
} catch (e: IOException) {
tempFile.delete()
false
} finally {
inputStream.close()
}
}
}
}
override suspend fun exists(file: String): Boolean =
withContext(Dispatchers.IO) { File(context.filesDir, file).exists() }
private fun getTargetFilePath(name: String) = "cover_${name}.${coverFormat.extension}"
private fun getTempFilePath(name: String) = "${getTargetFilePath(name)}.tmp"
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFormat.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import java.io.OutputStream
import javax.inject.Inject
interface CoverFormat {
val extension: String
fun transcodeInto(data: ByteArray, output: OutputStream): Boolean
}
class CoverFormatImpl @Inject constructor() : CoverFormat {
override val extension = EXTENSION
override fun transcodeInto(data: ByteArray, output: OutputStream) =
BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeByteArray(data, 0, data.size, this)
inSampleSize = calculateInSampleSize(SIZE)
inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, this) ?: return@run false
bitmap.compress(FORMAT, QUALITY, output)
true
}
private fun BitmapFactory.Options.calculateInSampleSize(size: Int): Int {
var inSampleSize = 1
val (height, width) = outHeight to outWidth
if (height > size || width > size) {
val halfHeight = height / 2
val halfWidth = width / 2
while ((halfHeight / inSampleSize) >= size && (halfWidth / inSampleSize) >= size) {
inSampleSize *= 2
}
}
return inSampleSize
}
private companion object {
const val SIZE = 750
val FORMAT =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
const val QUALITY = 80
const val EXTENSION = "webp"
}
}

View file

@ -38,7 +38,7 @@ abstract class StoredCoversDatabase : RoomDatabase() {
interface StoredCoversDao {
@Query("SELECT coverId FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
@TypeConverters(Music.UID.TypeConverters::class)
suspend fun getStoredCover(uid: Music.UID, lastModified: Long): String?
suspend fun getStoredCoverId(uid: Music.UID, lastModified: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setStoredCover(storedCover: StoredCover)