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.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
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 {
|
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)
|
||||||
|
|
Loading…
Reference in a new issue