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:
parent
6b8b147721
commit
a22e972bd3
6 changed files with 116 additions and 99 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
72
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverFormat.kt
vendored
Normal file
72
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverFormat.kt
vendored
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue