feat: add ThumbHash support alongside BlurHash across events, uploads, and UI
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used. Remote events now carry both hashes; renderers prefer thumbhash when available and fall back to blurhash. The MIP-04 `thumbhash` imeta field that was previously reserved-but-unused is now wired end-to-end. - New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep) - New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68, 71, 94, 95, 99, the experimental profile gallery, and MIP-04 - RichTextParser + MediaContentModels carry a thumbhash field alongside blurhash so every downstream composable can pick its preferred placeholder - New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a small placeholderModel(thumbhash, blurhash) helper centralising the "prefer thumbhash, fall back to blurhash" rule - Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the calculator decodes the bitmap / video thumbnail once and runs both encoders on the same pixels to keep upload cost flat - DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM, Classifieds, picture, video, and long-form upload paths - Round-trip unit test for the ThumbHash port
This commit is contained in:
+19
-9
@@ -29,6 +29,7 @@ abstract class BaseMediaContent(
|
||||
val description: String? = null,
|
||||
val dim: DimensionTag? = null,
|
||||
val blurhash: String? = null,
|
||||
val thumbhash: String? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@@ -40,7 +41,8 @@ abstract class MediaUrlContent(
|
||||
blurhash: String? = null,
|
||||
val uri: String? = null,
|
||||
val mimeType: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash)
|
||||
thumbhash: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash, thumbhash)
|
||||
|
||||
@Immutable
|
||||
open class MediaUrlImage(
|
||||
@@ -52,7 +54,8 @@ open class MediaUrlImage(
|
||||
uri: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
thumbhash: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
|
||||
|
||||
class EncryptedMediaUrlImage(
|
||||
url: String,
|
||||
@@ -66,7 +69,8 @@ class EncryptedMediaUrlImage(
|
||||
val encryptionAlgo: String,
|
||||
val encryptionKey: ByteArray,
|
||||
val encryptionNonce: ByteArray,
|
||||
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType)
|
||||
thumbhash: String? = null,
|
||||
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash)
|
||||
|
||||
@Immutable
|
||||
open class MediaUrlPdf(
|
||||
@@ -77,7 +81,8 @@ open class MediaUrlPdf(
|
||||
dim: DimensionTag? = null,
|
||||
uri: String? = null,
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
thumbhash: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
|
||||
|
||||
@Immutable
|
||||
open class MediaUrlVideo(
|
||||
@@ -91,7 +96,8 @@ open class MediaUrlVideo(
|
||||
blurhash: String? = null,
|
||||
val contentWarning: String? = null,
|
||||
mimeType: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType)
|
||||
thumbhash: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
|
||||
|
||||
@Immutable
|
||||
class EncryptedMediaUrlVideo(
|
||||
@@ -108,7 +114,8 @@ class EncryptedMediaUrlVideo(
|
||||
val encryptionAlgo: String,
|
||||
val encryptionKey: ByteArray,
|
||||
val encryptionNonce: ByteArray,
|
||||
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType)
|
||||
thumbhash: String? = null,
|
||||
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash)
|
||||
|
||||
@Immutable
|
||||
abstract class MediaPreloadedContent(
|
||||
@@ -120,7 +127,8 @@ abstract class MediaPreloadedContent(
|
||||
blurhash: String? = null,
|
||||
val uri: String,
|
||||
val id: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash) {
|
||||
thumbhash: String? = null,
|
||||
) : BaseMediaContent(description, dim, blurhash, thumbhash) {
|
||||
fun localFileExists() = localFile != null && localFile.exists()
|
||||
}
|
||||
|
||||
@@ -133,7 +141,8 @@ class MediaLocalImage(
|
||||
blurhash: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String,
|
||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
|
||||
thumbhash: String? = null,
|
||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
|
||||
|
||||
@Immutable
|
||||
class MediaLocalVideo(
|
||||
@@ -146,4 +155,5 @@ class MediaLocalVideo(
|
||||
uri: String,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null,
|
||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri)
|
||||
thumbhash: String? = null,
|
||||
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
|
||||
|
||||
+4
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -85,6 +86,7 @@ class RichTextParser {
|
||||
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
)
|
||||
} else if (isVideo) {
|
||||
MediaUrlVideo(
|
||||
@@ -96,6 +98,7 @@ class RichTextParser {
|
||||
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
)
|
||||
} else if (isPdf) {
|
||||
MediaUrlPdf(
|
||||
@@ -106,6 +109,7 @@ class RichTextParser {
|
||||
dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* 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.thumbhash
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.round
|
||||
|
||||
/**
|
||||
* ThumbHash decoder.
|
||||
*
|
||||
* Port of the reference implementation by Evan Wallace
|
||||
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
|
||||
*/
|
||||
object ThumbHashDecoder {
|
||||
/**
|
||||
* Returns width/height. Returns null if the hash is malformed.
|
||||
*/
|
||||
fun aspectRatio(hash: ByteArray): Float? {
|
||||
if (hash.size < 5) return null
|
||||
val header = hash[3].toInt() and 0xff
|
||||
val hasAlpha = (hash[2].toInt() and 0x80) != 0
|
||||
val isLandscape = (hash[4].toInt() and 0x80) != 0
|
||||
val lx = if (isLandscape) (if (hasAlpha) 5 else 7) else (header and 7)
|
||||
val ly = if (isLandscape) (header and 7) else (if (hasAlpha) 5 else 7)
|
||||
if (lx == 0 || ly == 0) return null
|
||||
return lx.toFloat() / ly.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns width/height. Returns null if the string is malformed.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun aspectRatio(base64Hash: String?): Float? {
|
||||
if (base64Hash.isNullOrBlank()) return null
|
||||
return try {
|
||||
aspectRatio(Base64.decode(padBase64(base64Hash)))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class RGBAImage(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val pixels: IntArray,
|
||||
)
|
||||
|
||||
/**
|
||||
* Decode a ThumbHash byte array into ARGB pixels.
|
||||
* Returns null if the hash is malformed.
|
||||
*/
|
||||
fun decode(hash: ByteArray): RGBAImage? {
|
||||
if (hash.size < 5) return null
|
||||
|
||||
val b0 = hash[0].toInt() and 0xff
|
||||
val b1 = hash[1].toInt() and 0xff
|
||||
val b2 = hash[2].toInt() and 0xff
|
||||
val b3 = hash[3].toInt() and 0xff
|
||||
val b4 = hash[4].toInt() and 0xff
|
||||
val header24 = b0 or (b1 shl 8) or (b2 shl 16)
|
||||
val header16 = b3 or (b4 shl 8)
|
||||
|
||||
val lDc = (header24 and 63) / 63.0
|
||||
val pDc = ((header24 shr 6) and 63) / 31.5 - 1.0
|
||||
val qDc = ((header24 shr 12) and 63) / 31.5 - 1.0
|
||||
val lScale = ((header24 shr 18) and 31) / 31.0
|
||||
val hasAlpha = (header24 shr 23) and 1 == 1
|
||||
val pScale = ((header16 shr 3) and 63) / 63.0
|
||||
val qScale = ((header16 shr 9) and 63) / 63.0
|
||||
val isLandscape = (header16 shr 15) and 1 == 1
|
||||
val lx = max(3, if (isLandscape) (if (hasAlpha) 5 else 7) else (header16 and 7))
|
||||
val ly = max(3, if (isLandscape) (header16 and 7) else (if (hasAlpha) 5 else 7))
|
||||
|
||||
val aDc: Double
|
||||
val aScale: Double
|
||||
if (hasAlpha) {
|
||||
if (hash.size < 6) return null
|
||||
aDc = (hash[5].toInt() and 15) / 15.0
|
||||
aScale = ((hash[5].toInt() shr 4) and 15) / 15.0
|
||||
} else {
|
||||
aDc = 1.0
|
||||
aScale = 0.0
|
||||
}
|
||||
|
||||
val acStart = if (hasAlpha) 6 else 5
|
||||
var acIndex = 0
|
||||
|
||||
fun readAc(
|
||||
nx: Int,
|
||||
ny: Int,
|
||||
scale: Double,
|
||||
): DoubleArray {
|
||||
val ac = ArrayList<Double>(nx * ny)
|
||||
var cy = 0
|
||||
while (cy < ny) {
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
while (cx * ny < nx * (ny - cy)) {
|
||||
val byteIdx = acStart + (acIndex shr 1)
|
||||
if (byteIdx >= hash.size) return DoubleArray(0)
|
||||
val shift = (acIndex and 1) shl 2
|
||||
val q4 = ((hash[byteIdx].toInt() ushr shift) and 15)
|
||||
ac.add((q4 / 7.5 - 1.0) * scale)
|
||||
acIndex++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
return DoubleArray(ac.size) { ac[it] }
|
||||
}
|
||||
|
||||
val lAc = readAc(lx, ly, lScale)
|
||||
val pAc = readAc(3, 3, pScale * 1.25)
|
||||
val qAc = readAc(3, 3, qScale * 1.25)
|
||||
val aAc = if (hasAlpha) readAc(5, 5, aScale) else DoubleArray(0)
|
||||
|
||||
val ratio = lx.toDouble() / ly.toDouble()
|
||||
val w = round(if (ratio > 1) 32.0 else 32.0 * ratio).toInt()
|
||||
val h = round(if (ratio > 1) 32.0 / ratio else 32.0).toInt()
|
||||
val pixels = IntArray(w * h)
|
||||
|
||||
val fxMax = max(lx, if (hasAlpha) 5 else 3)
|
||||
val fyMax = max(ly, if (hasAlpha) 5 else 3)
|
||||
val fx = DoubleArray(fxMax)
|
||||
val fy = DoubleArray(fyMax)
|
||||
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
var lVal = lDc
|
||||
var pVal = pDc
|
||||
var qVal = qDc
|
||||
var aVal = aDc
|
||||
|
||||
for (cx in 0 until fxMax) fx[cx] = cos(PI / w * (x + 0.5) * cx)
|
||||
for (cy in 0 until fyMax) fy[cy] = cos(PI / h * (y + 0.5) * cy)
|
||||
|
||||
// L
|
||||
run {
|
||||
var cy = 0
|
||||
var j = 0
|
||||
while (cy < ly) {
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
val fy2 = fy[cy] * 2.0
|
||||
while (cx * ly < lx * (ly - cy)) {
|
||||
lVal += lAc[j] * fx[cx] * fy2
|
||||
j++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
}
|
||||
|
||||
// P & Q
|
||||
run {
|
||||
var cy = 0
|
||||
var j = 0
|
||||
while (cy < 3) {
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
val fy2 = fy[cy] * 2.0
|
||||
while (cx < 3 - cy) {
|
||||
val f = fx[cx] * fy2
|
||||
pVal += pAc[j] * f
|
||||
qVal += qAc[j] * f
|
||||
j++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
}
|
||||
|
||||
// A
|
||||
if (hasAlpha) {
|
||||
var cy = 0
|
||||
var j = 0
|
||||
while (cy < 5) {
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
val fy2 = fy[cy] * 2.0
|
||||
while (cx < 5 - cy) {
|
||||
aVal += aAc[j] * fx[cx] * fy2
|
||||
j++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
}
|
||||
|
||||
// LPQA → RGB
|
||||
val bCh = lVal - 2.0 / 3.0 * pVal
|
||||
val rCh = (3.0 * lVal - bCh + qVal) / 2.0
|
||||
val gCh = rCh - qVal
|
||||
val rOut = (255.0 * min(1.0, max(0.0, rCh))).let { round(it).toInt() }.coerceIn(0, 255)
|
||||
val gOut = (255.0 * min(1.0, max(0.0, gCh))).let { round(it).toInt() }.coerceIn(0, 255)
|
||||
val bOut = (255.0 * min(1.0, max(0.0, bCh))).let { round(it).toInt() }.coerceIn(0, 255)
|
||||
val aOut = if (hasAlpha) (255.0 * min(1.0, max(0.0, aVal))).let { round(it).toInt() }.coerceIn(0, 255) else 255
|
||||
pixels[x + y * w] = (aOut shl 24) or (rOut shl 16) or (gOut shl 8) or bOut
|
||||
}
|
||||
}
|
||||
|
||||
return RGBAImage(w, h, pixels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64-encoded ThumbHash string to ARGB pixels.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun decode(base64Hash: String?): RGBAImage? {
|
||||
if (base64Hash.isNullOrBlank()) return null
|
||||
return try {
|
||||
decode(Base64.decode(padBase64(base64Hash)))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a ThumbHash string into a [PlatformImage] roughly [targetWidth] wide,
|
||||
* preserving the aspect ratio of the original image.
|
||||
*
|
||||
* Mirrors [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio]
|
||||
* so existing placeholder pipelines can swap in thumbhash transparently.
|
||||
*/
|
||||
fun decodeKeepAspectRatio(
|
||||
hash: String?,
|
||||
targetWidth: Int,
|
||||
): PlatformImage? {
|
||||
val rgba = decode(hash) ?: return null
|
||||
return PlatformImage.create(rgba.pixels, rgba.width, rgba.height)
|
||||
}
|
||||
|
||||
private fun padBase64(s: String): String {
|
||||
val remainder = s.length % 4
|
||||
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.thumbhash
|
||||
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.round
|
||||
|
||||
/**
|
||||
* ThumbHash encoder.
|
||||
*
|
||||
* Port of the reference implementation by Evan Wallace
|
||||
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
|
||||
*
|
||||
* Input pixels are ARGB (0xAARRGGBB) to match [com.vitorpamplona.amethyst.commons.blurhash.PlatformImage.getPixels].
|
||||
*/
|
||||
object ThumbHashEncoder {
|
||||
/**
|
||||
* Encodes an ARGB image to a ThumbHash byte array.
|
||||
*
|
||||
* @param pixels ARGB pixels (0xAARRGGBB), row-by-row; must contain [width] * [height] entries.
|
||||
* @param width Image width in pixels; must be ≤ 100.
|
||||
* @param height Image height in pixels; must be ≤ 100.
|
||||
*/
|
||||
fun encode(
|
||||
pixels: IntArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
): ByteArray {
|
||||
require(width in 1..100 && height in 1..100) { "ThumbHash input must be ≤100x100 (got ${width}x$height)" }
|
||||
require(pixels.size == width * height) { "pixels.size must equal width * height" }
|
||||
|
||||
// Average color (premultiplied by alpha)
|
||||
var avgR = 0.0
|
||||
var avgG = 0.0
|
||||
var avgB = 0.0
|
||||
var avgA = 0.0
|
||||
for (i in 0 until width * height) {
|
||||
val argb = pixels[i]
|
||||
val alpha = ((argb ushr 24) and 0xff) / 255.0
|
||||
val r = (argb ushr 16) and 0xff
|
||||
val g = (argb ushr 8) and 0xff
|
||||
val b = argb and 0xff
|
||||
avgR += alpha / 255.0 * r
|
||||
avgG += alpha / 255.0 * g
|
||||
avgB += alpha / 255.0 * b
|
||||
avgA += alpha
|
||||
}
|
||||
if (avgA > 0.0) {
|
||||
avgR /= avgA
|
||||
avgG /= avgA
|
||||
avgB /= avgA
|
||||
}
|
||||
|
||||
val hasAlpha = avgA < width * height
|
||||
val lLimit = if (hasAlpha) 5 else 7
|
||||
val lx = max(1, round(lLimit * width.toDouble() / max(width, height)).toInt())
|
||||
val ly = max(1, round(lLimit * height.toDouble() / max(width, height)).toInt())
|
||||
|
||||
val size = width * height
|
||||
val l = DoubleArray(size) // luminance
|
||||
val p = DoubleArray(size) // yellow - blue
|
||||
val q = DoubleArray(size) // red - green
|
||||
val a = DoubleArray(size) // alpha
|
||||
|
||||
// Convert ARGB to LPQA, composited over the average color
|
||||
for (i in 0 until size) {
|
||||
val argb = pixels[i]
|
||||
val alpha = ((argb ushr 24) and 0xff) / 255.0
|
||||
val rPx = (argb ushr 16) and 0xff
|
||||
val gPx = (argb ushr 8) and 0xff
|
||||
val bPx = argb and 0xff
|
||||
val r = avgR * (1 - alpha) + alpha / 255.0 * rPx
|
||||
val g = avgG * (1 - alpha) + alpha / 255.0 * gPx
|
||||
val b = avgB * (1 - alpha) + alpha / 255.0 * bPx
|
||||
l[i] = (r + g + b) / 3.0
|
||||
p[i] = (r + g) / 2.0 - b
|
||||
q[i] = r - g
|
||||
a[i] = alpha
|
||||
}
|
||||
|
||||
val lEnc = encodeChannel(l, width, height, max(3, lx), max(3, ly))
|
||||
val pEnc = encodeChannel(p, width, height, 3, 3)
|
||||
val qEnc = encodeChannel(q, width, height, 3, 3)
|
||||
val aEnc = if (hasAlpha) encodeChannel(a, width, height, 5, 5) else null
|
||||
|
||||
val isLandscape = width > height
|
||||
val lCount = if (isLandscape) ly else lx
|
||||
val hasAlphaBit = if (hasAlpha) 1 else 0
|
||||
val isLandscapeBit = if (isLandscape) 1 else 0
|
||||
|
||||
val header24 =
|
||||
round(63.0 * lEnc.dc).toInt() or
|
||||
(round(31.5 + 31.5 * pEnc.dc).toInt() shl 6) or
|
||||
(round(31.5 + 31.5 * qEnc.dc).toInt() shl 12) or
|
||||
(round(31.0 * lEnc.scale).toInt() shl 18) or
|
||||
(hasAlphaBit shl 23)
|
||||
val header16 =
|
||||
lCount or
|
||||
(round(63.0 * pEnc.scale).toInt() shl 3) or
|
||||
(round(63.0 * qEnc.scale).toInt() shl 9) or
|
||||
(isLandscapeBit shl 15)
|
||||
|
||||
val acChannels = if (hasAlpha) listOf(lEnc.ac, pEnc.ac, qEnc.ac, aEnc!!.ac) else listOf(lEnc.ac, pEnc.ac, qEnc.ac)
|
||||
val totalAc = acChannels.sumOf { it.size }
|
||||
val acStart = if (hasAlpha) 6 else 5
|
||||
val hashSize = acStart + ((totalAc + 1) / 2)
|
||||
val hash = ByteArray(hashSize)
|
||||
hash[0] = (header24 and 0xff).toByte()
|
||||
hash[1] = ((header24 ushr 8) and 0xff).toByte()
|
||||
hash[2] = ((header24 ushr 16) and 0xff).toByte()
|
||||
hash[3] = (header16 and 0xff).toByte()
|
||||
hash[4] = ((header16 ushr 8) and 0xff).toByte()
|
||||
|
||||
if (hasAlpha) {
|
||||
val aDcQ = round(15.0 * aEnc!!.dc).toInt() and 0xf
|
||||
val aScaleQ = round(15.0 * aEnc.scale).toInt() and 0xf
|
||||
hash[5] = (aDcQ or (aScaleQ shl 4)).toByte()
|
||||
}
|
||||
|
||||
var acIndex = 0
|
||||
for (ac in acChannels) {
|
||||
for (f in ac) {
|
||||
val q4 = round(15.0 * f).toInt() and 0xf
|
||||
val byteIdx = acStart + (acIndex shr 1)
|
||||
val shift = (acIndex and 1) shl 2
|
||||
hash[byteIdx] = (hash[byteIdx].toInt() or (q4 shl shift)).toByte()
|
||||
acIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an ARGB image to a base64 ThumbHash string (no padding).
|
||||
*/
|
||||
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||
fun encodeToBase64(
|
||||
pixels: IntArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
): String = Base64.encode(encode(pixels, width, height)).trimEnd('=')
|
||||
|
||||
private data class ChannelEncoded(
|
||||
val dc: Double,
|
||||
val ac: DoubleArray,
|
||||
val scale: Double,
|
||||
)
|
||||
|
||||
private fun encodeChannel(
|
||||
channel: DoubleArray,
|
||||
w: Int,
|
||||
h: Int,
|
||||
nx: Int,
|
||||
ny: Int,
|
||||
): ChannelEncoded {
|
||||
var dc = 0.0
|
||||
var scale = 0.0
|
||||
val acList = ArrayList<Double>((nx * ny))
|
||||
val fx = DoubleArray(w)
|
||||
|
||||
var cy = 0
|
||||
while (cy < ny) {
|
||||
var cx = 0
|
||||
while (cx * ny < nx * (ny - cy)) {
|
||||
var f = 0.0
|
||||
for (x in 0 until w) {
|
||||
fx[x] = cos(PI / w * cx * (x + 0.5))
|
||||
}
|
||||
for (y in 0 until h) {
|
||||
val fy = cos(PI / h * cy * (y + 0.5))
|
||||
for (x in 0 until w) {
|
||||
f += channel[x + y * w] * fx[x] * fy
|
||||
}
|
||||
}
|
||||
f /= (w * h).toDouble()
|
||||
if (cx != 0 || cy != 0) {
|
||||
acList.add(f)
|
||||
if (abs(f) > scale) scale = abs(f)
|
||||
} else {
|
||||
dc = f
|
||||
}
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
|
||||
val ac = DoubleArray(acList.size) { acList[it] }
|
||||
if (scale != 0.0) {
|
||||
for (i in ac.indices) {
|
||||
ac[i] = 0.5 + 0.5 / scale * ac[i]
|
||||
}
|
||||
}
|
||||
return ChannelEncoded(dc, ac, scale)
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.thumbhash
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
|
||||
|
||||
/**
|
||||
* Encodes this [PlatformImage] to a base64 ThumbHash string (no padding).
|
||||
*
|
||||
* ThumbHash is specified at ≤100x100. Larger images are downscaled first.
|
||||
*/
|
||||
fun PlatformImage.toThumbhash(): String {
|
||||
val source =
|
||||
if (width > 100 || height > 100) {
|
||||
val aspect = width.toDouble() / height.toDouble()
|
||||
val scaled =
|
||||
if (width >= height) {
|
||||
val w = 100
|
||||
val h = (100.0 / aspect).toInt().coerceAtLeast(1)
|
||||
this.scale(w, h)
|
||||
} else {
|
||||
val h = 100
|
||||
val w = (100.0 * aspect).toInt().coerceAtLeast(1)
|
||||
this.scale(w, h)
|
||||
}
|
||||
scaled
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
val pixels = IntArray(source.width * source.height)
|
||||
source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height)
|
||||
return ThumbHashEncoder.encodeToBase64(pixels, source.width, source.height)
|
||||
}
|
||||
Reference in New Issue
Block a user