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.Cover
import org.oxycblt.auxio.image.stack.cache.CoverCache import org.oxycblt.auxio.image.stack.cache.CoverCache
import org.oxycblt.auxio.image.stack.extractor.CoverExtractor import org.oxycblt.auxio.image.stack.extractor.CoverExtractor
import timber.log.Timber
interface CoverRetriever { interface CoverRetriever {
suspend fun retrieve(cover: Cover.Single): InputStream? suspend fun retrieve(cover: Cover.Single): InputStream?
@ -33,5 +34,12 @@ class CoverRetrieverImpl
constructor(private val coverCache: CoverCache, private val coverExtractor: CoverExtractor) : constructor(private val coverCache: CoverCache, private val coverExtractor: CoverExtractor) :
CoverRetriever { CoverRetriever {
override suspend fun retrieve(cover: Cover.Single) = 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 @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface StackModule { interface StackModule {
@Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles @Singleton @Binds fun appFiles(impl: CoverFilesImpl): CoverFiles
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache @Binds fun coverCache(cache: CoverCacheImpl): CoverCache
@Binds fun coverIdentifier(identifierImpl: CoverIdentifierImpl): CoverIdentifier @Binds fun coverIdentifier(identifierImpl: CoverIdentifierImpl): CoverIdentifier
@Binds fun coverFormat(coverFormatImpl: CoverFormatImpl): CoverFormat
} }
@Module @Module

View file

@ -18,11 +18,6 @@
package org.oxycblt.auxio.image.stack.cache 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 java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.image.Cover import org.oxycblt.auxio.image.Cover
@ -38,79 +33,19 @@ class CoverCacheImpl
constructor( constructor(
private val coverIdentifier: CoverIdentifier, private val coverIdentifier: CoverIdentifier,
private val storedCoversDao: StoredCoversDao, private val storedCoversDao: StoredCoversDao,
private val appFiles: AppFiles private val coverFiles: CoverFiles
) : CoverCache { ) : CoverCache {
override suspend fun read(cover: Cover.Single): InputStream? { override suspend fun read(cover: Cover.Single): InputStream? {
val perceptualHash = val id = storedCoversDao.getStoredCoverId(cover.uid, cover.lastModified) ?: return null
storedCoversDao.getStoredCover(cover.uid, cover.lastModified) ?: return null return coverFiles.read(id)
return appFiles.read(fileName(perceptualHash))
} }
override suspend fun write(cover: Cover.Single, data: ByteArray): InputStream? { override suspend fun write(cover: Cover.Single, data: ByteArray): InputStream? {
val id = coverIdentifier.identify(data) val id = coverIdentifier.identify(data)
val file = fileName(id) coverFiles.write(id, data)
if (!appFiles.exists(file)) {
val transcoded = transcodeImage(data, FORMAT_WEBP)
val writeSuccess = appFiles.write(fileName(id), transcoded)
if (!writeSuccess) {
return null
}
}
storedCoversDao.setStoredCover( storedCoversDao.setStoredCover(
StoredCover(uid = cover.uid, lastModified = cover.lastModified, coverId = id)) StoredCover(uid = cover.uid, lastModified = cover.lastModified, coverId = id))
return coverFiles.read(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
}
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2024 Auxio Project * 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 * 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 * 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.IOException
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlin.concurrent.withLock
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
interface AppFiles { interface CoverFiles {
suspend fun read(file: String): InputStream? suspend fun read(id: String): InputStream?
suspend fun write(file: String, inputStream: InputStream): Boolean suspend fun write(id: String, data: ByteArray)
suspend fun exists(file: String): Boolean
} }
class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) : class CoverFilesImpl
AppFiles { @Inject
constructor(
@ApplicationContext private val context: Context,
private val coverFormat: CoverFormat
) : CoverFiles {
private val fileMutexes = mutableMapOf<String, Mutex>() private val fileMutexes = mutableMapOf<String, Mutex>()
private val mapMutex = Mutex() private val mapMutex = Mutex()
@ -47,39 +48,38 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context:
return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } } return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } }
} }
override suspend fun read(file: String): InputStream? = override suspend fun read(id: String): InputStream? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
context.openFileInput(file) context.openFileInput(getTargetFilePath(id))
} catch (e: IOException) { } catch (e: IOException) {
null null
} }
} }
override suspend fun write(file: String, inputStream: InputStream): Boolean = override suspend fun write(id: String, data: ByteArray) {
withContext(Dispatchers.IO) { val fileMutex = getMutexForFile(id)
val fileMutex = getMutexForFile(file)
fileMutex.withLock { fileMutex.withLock {
val tempFile = File(context.filesDir, "$file.tmp") val targetFile = File(context.filesDir, getTargetFilePath(id))
val targetFile = File(context.filesDir, file) if (targetFile.exists()) {
return
}
withContext(Dispatchers.IO) {
val tempFile = File(context.filesDir, getTempFilePath(id))
try { try {
tempFile.outputStream().use { fileOutputStream -> tempFile.outputStream().use { coverFormat.transcodeInto(data, it) }
inputStream.copyTo(fileOutputStream)
}
tempFile.renameTo(targetFile) tempFile.renameTo(targetFile)
true
} catch (e: IOException) { } catch (e: IOException) {
tempFile.delete() tempFile.delete()
false
} finally {
inputStream.close()
} }
} }
} }
}
override suspend fun exists(file: String): Boolean = private fun getTargetFilePath(name: String) = "cover_${name}.${coverFormat.extension}"
withContext(Dispatchers.IO) { File(context.filesDir, file).exists() }
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 { interface StoredCoversDao {
@Query("SELECT coverId FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified") @Query("SELECT coverId FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
@TypeConverters(Music.UID.TypeConverters::class) @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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setStoredCover(storedCover: StoredCover) suspend fun setStoredCover(storedCover: StoredCover)