#1441 fallback decoding of images packed in RGBA_1010102 config

This commit is contained in:
Thibault Deckers 2025-02-24 23:37:41 +01:00
parent a3f6cd7a32
commit f02363592f
8 changed files with 106 additions and 11 deletions

View file

@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file.
- opening motion photo embedded video when video track is not the first one - opening motion photo embedded video when video track is not the first one
- some SVG rendering issues - some SVG rendering issues
- decoding of SVG containing references to namespaces in !ATTLIST - decoding of SVG containing references to namespaces in !ATTLIST
- fallback decoding of images packed in RGBA_1010102 config
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06 ## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06

View file

@ -11,6 +11,7 @@ import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -111,7 +112,14 @@ class RegionFetcher internal constructor(
} }
val bitmap = decoder.decodeRegion(effectiveRect, options) val bitmap = decoder.decodeRegion(effectiveRect, options)
if (bitmap != null) { if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = true)) val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
val recycle = false
var bytes = bitmap.getBytes(canHaveAlpha, recycle = recycle)
if (bytes != null && bytes.isEmpty()) {
bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getBytes(canHaveAlpha, recycle = recycle)
}
bitmap.recycle()
result.success(bytes)
} else { } else {
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null) result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
} }

View file

@ -7,6 +7,7 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -14,6 +15,7 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -24,7 +26,6 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import androidx.core.net.toUri
class ThumbnailFetcher internal constructor( class ThumbnailFetcher internal constructor(
private val context: Context, private val context: Context,
@ -78,7 +79,13 @@ class ThumbnailFetcher internal constructor(
} }
if (bitmap != null) { if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false, quality = quality)) val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
val recycle = false
var bytes = bitmap.getBytes(canHaveAlpha, quality, recycle)
if (bytes != null && bytes.isEmpty()) {
bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getBytes(canHaveAlpha, quality, recycle)
}
result.success(bytes)
} else { } else {
var errorDetails: String? = exception?.message var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) { if (errorDetails?.isNotEmpty() == true) {

View file

@ -8,6 +8,7 @@ import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -137,7 +138,12 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
if (bitmap != null) { if (bitmap != null) {
val bytes = bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false) val recycle = false
val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType)
var bytes = bitmap.getBytes(canHaveAlpha, recycle = recycle)
if (bytes != null && bytes.isEmpty()) {
bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getBytes(canHaveAlpha, recycle = recycle)
}
if (MemoryUtils.canAllocate(sizeBytes)) { if (MemoryUtils.canAllocate(sizeBytes)) {
success(bytes) success(bytes)
} else { } else {

View file

@ -2,13 +2,18 @@ package deckers.thibault.aves.utils
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.ColorSpace
import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.graphics.createBitmap
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.TransformationUtils import com.bumptech.glide.load.resource.bitmap.TransformationUtils
import deckers.thibault.aves.metadata.Metadata.getExifCode import deckers.thibault.aves.metadata.Metadata.getExifCode
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
object BitmapUtils { object BitmapUtils {
private val LOG_TAG = LogUtils.createTag<BitmapUtils>() private val LOG_TAG = LogUtils.createTag<BitmapUtils>()
@ -21,6 +26,7 @@ object BitmapUtils {
private val mutex = Mutex() private val mutex = Mutex()
const val ARGB_8888_BYTE_SIZE = 4 const val ARGB_8888_BYTE_SIZE = 4
private const val RGBA_1010102_BYTE_SIZE = 4
suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? { suspend fun Bitmap.getBytes(canHaveAlpha: Boolean = false, quality: Int = 100, recycle: Boolean): ByteArray? {
val stream: ByteArrayOutputStream val stream: ByteArrayOutputStream
@ -62,6 +68,76 @@ object BitmapUtils {
return null return null
} }
// On some devices, RGBA_1010102 config can be displayed directly from the hardware buffer,
// but the native image decoder cannot convert RGBA_1010102 to another config like ARGB_8888,
// so we manually check the config and convert the pixels as a fallback mechanism.
fun tryPixelFormatConversion(bitmap: Bitmap): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && bitmap.config == Bitmap.Config.RGBA_1010102) {
val byteCount = bitmap.byteCount
if (MemoryUtils.canAllocate(byteCount)) {
val bytes = ByteBuffer.allocate(byteCount).apply {
bitmap.copyPixelsToBuffer(this)
rewind()
}.array()
val srcColorSpace = bitmap.colorSpace
if (srcColorSpace != null) {
val dstColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
val connector = ColorSpace.connect(srcColorSpace, dstColorSpace)
rgba1010102toArgb8888(bytes, connector)
val hasAlpha = false
return createBitmap(
bitmap.width,
bitmap.height,
Bitmap.Config.ARGB_8888,
hasAlpha = hasAlpha,
colorSpace = dstColorSpace,
).apply {
copyPixelsFromBuffer(ByteBuffer.wrap(bytes))
}
}
}
}
return null
}
// convert bytes, without reallocation:
// - from config RGBA_1010102 to ARGB_8888,
// - from original color space to sRGB.
@RequiresApi(Build.VERSION_CODES.O)
private fun rgba1010102toArgb8888(bytes: ByteArray, connector: ColorSpace.Connector) {
val max10Bits = 0x3ff.toFloat()
val dstAlpha = 0xff.toByte()
val byteCount = bytes.size
for (i in 0..<byteCount step RGBA_1010102_BYTE_SIZE) {
val i3 = bytes[i + 3].toInt()
val i2 = bytes[i + 2].toInt()
val i1 = bytes[i + 1].toInt()
val i0 = bytes[i].toInt()
// unpacking from RGBA_1010102
// stored as [3,2,1,0] -> [AABBBBBB BBBBGGGG GGGGGGRR RRRRRRRR]
// val iA = ((i3 and 0xc0) shr 6)
val iB = ((i3 and 0x3f) shl 4) or ((i2 and 0xf0) shr 4)
val iG = ((i2 and 0x0f) shl 6) or ((i1 and 0xfc) shr 2)
val iR = ((i1 and 0x03) shl 8) or ((i0 and 0xff) shr 0)
// components as floats in sRGB
val srgbFloats = connector.transform(iR / max10Bits, iG / max10Bits, iB / max10Bits)
val srgbR = (srgbFloats[0] * 255.0f + 0.5f).toInt()
val srgbG = (srgbFloats[1] * 255.0f + 0.5f).toInt()
val srgbB = (srgbFloats[2] * 255.0f + 0.5f).toInt()
// packing to ARGB_8888
// stored as [3,2,1,0] -> [AAAAAAAA BBBBBBBB GGGGGGGG RRRRRRRR]
bytes[i + 3] = dstAlpha
bytes[i + 2] = srgbB.toByte()
bytes[i + 1] = srgbG.toByte()
bytes[i] = srgbR.toByte()
}
}
fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? { fun applyExifOrientation(context: Context, bitmap: Bitmap?, rotationDegrees: Int?, isFlipped: Boolean?): Bitmap? {
if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap if (bitmap == null || rotationDegrees == null || isFlipped == null) return bitmap
if (rotationDegrees == 0 && !isFlipped) return bitmap if (rotationDegrees == 0 && !isFlipped) return bitmap

View file

@ -90,12 +90,7 @@ object BmpWriter {
var column = 0 var column = 0
while (column < biWidth) { while (column < biWidth) {
/* // non-premultiplied ARGB values in the sRGB color space
alpha: (value shr 24 and 0xFF).toByte()
red: (value shr 16 and 0xFF).toByte()
green: (value shr 8 and 0xFF).toByte()
blue: (value and 0xFF).toByte()
*/
value = pixels[column] value = pixels[column]
// blue: [0], green: [1], red: [2] // blue: [0], green: [1], red: [2]
rgb[0] = (value and 0xFF).toByte() rgb[0] = (value and 0xFF).toByte()

View file

@ -8,6 +8,8 @@ fun ByteBuffer.toByteArray(): ByteArray {
return bytes return bytes
} }
fun Int.toHex(): String = "0x${byteArrayOf(shr(8).toByte(), toByte()).toHex()}"
fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() } fun ByteArray.toHex(): String = joinToString(separator = "") { it.toHex() }
fun Byte.toHex(): String = "%02x".format(this) fun Byte.toHex(): String = "%02x".format(this)

View file

@ -34,7 +34,7 @@ extension ExtraAvesEntryProps on AvesEntry {
// size // size
bool get useTiles => (width > 4096 || height > 4096) && !isAnimated; bool get useTiles => !isAnimated;
bool get isSized => width > 0 && height > 0; bool get isSized => width > 0 && height > 0;