feat: Add expect/actual for blurhash and base64Image in commons

- Create PlatformImage expect/actual abstracting Bitmap/BufferedImage
- Move pure blurhash algorithms (Base83, SRGB, CosineCache, BlurHashEncoder) to commonMain
- Rewrite BlurHashDecoder to return PlatformImage instead of Bitmap
- Add PlatformImage.toBlurhash() extension in commonMain
- Add platform-specific Bitmap.toBlurhash() and BufferedImage.toBlurhash()
- Add base64Image toPlatformImage() for both Android and JVM

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2025-12-27 12:48:32 +02:00
parent 131252f19d
commit e5a064d0c3
13 changed files with 362 additions and 43 deletions
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.base64Image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
import java.util.Base64
fun Base64Image.toBitmap(content: String): Bitmap {
@@ -35,3 +37,9 @@ fun Base64Image.toBitmap(content: String): Bitmap {
throw Exception("Unable to convert base64 to image $content")
}
/**
* Converts a base64 image data URI to a PlatformImage.
* Delegates to toBitmap and wraps the result.
*/
fun Base64Image.toPlatformImage(content: String): PlatformImage = toBitmap(content).toPlatformImage()
@@ -21,41 +21,9 @@
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
import kotlin.math.roundToInt
fun Bitmap.toBlurhash(): String {
val aspectRatio = this.width.toFloat() / this.height.toFloat()
if (this.width > 100 && this.height > 100) {
return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash()
}
val intArray = IntArray(width * height)
this.getPixels(intArray, 0, width, 0, 0, width, height)
val numX =
if (aspectRatio > 1) {
9
} else if (aspectRatio < 1) {
(9 * aspectRatio).roundToInt()
} else {
4
}
val numY =
if (aspectRatio > 1) {
(9 * (1 / aspectRatio)).roundToInt()
} else if (aspectRatio < 1) {
9
} else {
4
}
return BlurHashEncoder().encode(
intArray,
width,
height,
numX,
numY,
)
}
/**
* Encodes this Android Bitmap to a blurhash string.
* Delegates to PlatformImage.toBlurhash() for the actual encoding.
*/
fun Bitmap.toBlurhash(): String = this.toPlatformImage().toBlurhash()
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
actual class PlatformImage(
val bitmap: Bitmap,
) {
actual val width: Int get() = bitmap.width
actual val height: Int get() = bitmap.height
actual fun getPixels(
pixels: IntArray,
offset: Int,
stride: Int,
x: Int,
y: Int,
width: Int,
height: Int,
) {
bitmap.getPixels(pixels, offset, stride, x, y, width, height)
}
actual fun scale(
width: Int,
height: Int,
): PlatformImage = PlatformImage(Bitmap.createScaledBitmap(bitmap, width, height, false))
actual companion object {
actual fun create(
pixels: IntArray,
width: Int,
height: Int,
): PlatformImage = PlatformImage(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888))
}
}
/**
* Extension to convert Android Bitmap to PlatformImage.
*/
fun Bitmap.toPlatformImage(): PlatformImage = PlatformImage(this)
/**
* Extension to get the underlying Android Bitmap.
*/
fun PlatformImage.toAndroidBitmap(): Bitmap = this.bitmap
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear
import kotlin.math.pow
@@ -66,7 +65,7 @@ object BlurHashDecoder {
}
/**
* Decode a blur hash into a new bitmap.
* Decode a blur hash into a new PlatformImage.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations. By
@@ -76,7 +75,7 @@ object BlurHashDecoder {
blurHash: String?,
width: Int,
useCache: Boolean = true,
): Bitmap? {
): PlatformImage? {
if (blurHash == null || blurHash.length < 6) {
return null
}
@@ -87,7 +86,7 @@ object BlurHashDecoder {
val height = (width * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt()
val colors = computeColors(numCompX, numCompY, blurHash)
val imageArray = composeImageArray(width, height, numCompX, numCompY, colors, useCache)
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
return PlatformImage.create(imageArray, width, height)
}
private fun decodeDc(colorEnc: Int): FloatArray {
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
import kotlin.math.roundToInt
/**
* Encodes this PlatformImage to a blurhash string.
* The image will be scaled down if larger than 100x100 for performance.
*/
fun PlatformImage.toBlurhash(): String {
val aspectRatio = this.width.toFloat() / this.height.toFloat()
if (this.width > 100 && this.height > 100) {
return this.scale(100, (100 / aspectRatio).toInt()).toBlurhash()
}
val intArray = IntArray(width * height)
this.getPixels(intArray, 0, width, 0, 0, width, height)
val numX =
if (aspectRatio > 1) {
9
} else if (aspectRatio < 1) {
(9 * aspectRatio).roundToInt()
} else {
4
}
val numY =
if (aspectRatio > 1) {
(9 * (1 / aspectRatio)).roundToInt()
} else if (aspectRatio < 1) {
9
} else {
4
}
return BlurHashEncoder().encode(
intArray,
width,
height,
numX,
numY,
)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.blurhash
import androidx.collection.LruCache
import kotlin.math.PI
import kotlin.math.cos
object CosineCache {
@@ -54,7 +55,7 @@ object CosineCache {
DoubleArray(height * numCompY) {
val y = it / numCompY
val j = it % numCompY
cos(Math.PI * y * j / height)
cos(PI * y * j / height)
}.also {
cacheCosinesY.put(height * numCompY, it)
}
@@ -73,7 +74,7 @@ object CosineCache {
DoubleArray(width * numCompX) {
val x = it / numCompX
val i = it % numCompX
cos(Math.PI * x * i / width)
cos(PI * x * i / width)
}.also { cacheCosinesX.put(width * numCompX, it) }
}
else -> cacheCosinesX[width * numCompX]!!
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
/**
* Platform-agnostic image wrapper for blurhash encoding/decoding.
*
* On Android: wraps android.graphics.Bitmap
* On Desktop JVM: wraps java.awt.image.BufferedImage
*/
expect class PlatformImage {
val width: Int
val height: Int
/**
* Read ARGB pixels into the provided array.
* Pixels are in ARGB format (0xAARRGGBB).
*/
fun getPixels(
pixels: IntArray,
offset: Int,
stride: Int,
x: Int,
y: Int,
width: Int,
height: Int,
)
/**
* Create a scaled copy of this image.
*/
fun scale(
width: Int,
height: Int,
): PlatformImage
companion object {
/**
* Create a new image from ARGB pixel array.
*/
fun create(
pixels: IntArray,
width: Int,
height: Int,
): PlatformImage
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.base64Image
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
import java.io.ByteArrayInputStream
import java.util.Base64
import javax.imageio.ImageIO
/**
* Converts a base64 image data URI to a PlatformImage (BufferedImage wrapper).
*/
fun Base64Image.toPlatformImage(content: String): PlatformImage {
val matcher = pattern.matcher(content)
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bufferedImage =
ImageIO.read(ByteArrayInputStream(byteArray))
?: throw Exception("Unable to decode base64 image: $content")
return bufferedImage.toPlatformImage()
}
throw Exception("Unable to convert base64 to image $content")
}
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
import java.awt.image.BufferedImage
/**
* Encodes this BufferedImage to a blurhash string.
* Delegates to PlatformImage.toBlurhash() for the actual encoding.
*/
fun BufferedImage.toBlurhash(): String = this.toPlatformImage().toBlurhash()
@@ -0,0 +1,75 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
import java.awt.Image
import java.awt.image.BufferedImage
actual class PlatformImage(
val image: BufferedImage,
) {
actual val width: Int get() = image.width
actual val height: Int get() = image.height
actual fun getPixels(
pixels: IntArray,
offset: Int,
stride: Int,
x: Int,
y: Int,
width: Int,
height: Int,
) {
image.getRGB(x, y, width, height, pixels, offset, stride)
}
actual fun scale(
width: Int,
height: Int,
): PlatformImage {
val scaled = image.getScaledInstance(width, height, Image.SCALE_FAST)
val buffered = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
buffered.graphics.drawImage(scaled, 0, 0, null)
return PlatformImage(buffered)
}
actual companion object {
actual fun create(
pixels: IntArray,
width: Int,
height: Int,
): PlatformImage {
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
image.setRGB(0, 0, width, height, pixels, 0, width)
return PlatformImage(image)
}
}
}
/**
* Extension to convert BufferedImage to PlatformImage.
*/
fun BufferedImage.toPlatformImage(): PlatformImage = PlatformImage(this)
/**
* Extension to get the underlying BufferedImage.
*/
fun PlatformImage.toBufferedImage(): BufferedImage = this.image