Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 android.graphics.Bitmap
|
||||
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
|
||||
|
||||
fun Bitmap.toThumbhash(): String = this.toPlatformImage().toThumbhash()
|
||||
+29
-8
@@ -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,20 @@ 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(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
blurhash: String? = null,
|
||||
dim: DimensionTag? = null,
|
||||
uri: String? = null,
|
||||
mimeType: String? = null,
|
||||
thumbhash: String? = null,
|
||||
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
|
||||
|
||||
@Immutable
|
||||
open class MediaUrlVideo(
|
||||
@@ -80,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(
|
||||
@@ -97,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(
|
||||
@@ -109,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()
|
||||
}
|
||||
|
||||
@@ -122,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(
|
||||
@@ -135,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)
|
||||
|
||||
+40
-2
@@ -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
|
||||
@@ -58,17 +59,21 @@ class RichTextParser {
|
||||
|
||||
val isImage: Boolean
|
||||
val isVideo: Boolean
|
||||
val isPdf: Boolean
|
||||
|
||||
if (contentType != null) {
|
||||
isImage = contentType.startsWith("image/")
|
||||
isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/")
|
||||
isPdf = contentType.startsWith("application/pdf")
|
||||
} else if (fullUrl.startsWith("data:")) {
|
||||
isImage = fullUrl.startsWith("data:image/")
|
||||
isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/")
|
||||
isPdf = fullUrl.startsWith("data:application/pdf")
|
||||
} else {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
|
||||
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
isPdf = pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
return if (isImage) {
|
||||
@@ -81,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(
|
||||
@@ -92,6 +98,18 @@ 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(
|
||||
url = fullUrl,
|
||||
description = description ?: frags[AltTag.TAG_NAME] ?: tags[AltTag.TAG_NAME]?.firstOrNull(),
|
||||
hash = frags[HashSha256Tag.TAG_NAME] ?: tags[HashSha256Tag.TAG_NAME]?.firstOrNull(),
|
||||
blurhash = frags[BlurhashTag.TAG_NAME] ?: tags[BlurhashTag.TAG_NAME]?.firstOrNull(),
|
||||
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
|
||||
@@ -158,6 +176,7 @@ class RichTextParser {
|
||||
|
||||
val imageUrls = mediaForPager.filterValues { it is MediaUrlImage }.keys
|
||||
val videoUrls = mediaForPager.filterValues { it is MediaUrlVideo }.keys
|
||||
val pdfUrls = mediaForPager.filterValues { it is MediaUrlPdf }.keys
|
||||
|
||||
val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
|
||||
|
||||
@@ -165,7 +184,7 @@ class RichTextParser {
|
||||
|
||||
val newContent = fixMissingSpaces(content, allUrls)
|
||||
|
||||
val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
|
||||
val segments = findTextSegments(newContent, imageUrls, videoUrls, pdfUrls, urlSet, emojiMap, tags)
|
||||
|
||||
val mediaForPagerWithBase64 =
|
||||
mediaForPager +
|
||||
@@ -197,6 +216,7 @@ class RichTextParser {
|
||||
content: String,
|
||||
images: Set<String>,
|
||||
videos: Set<String>,
|
||||
pdfs: Set<String>,
|
||||
urls: Urls,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
@@ -211,7 +231,7 @@ class RichTextParser {
|
||||
|
||||
val segments = ArrayList<Segment>(wordList.size)
|
||||
wordList.forEach { word ->
|
||||
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
|
||||
segments.add(wordIdentifier(word, images, videos, pdfs, urls, emojis, tags))
|
||||
}
|
||||
|
||||
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
|
||||
@@ -262,6 +282,7 @@ class RichTextParser {
|
||||
word: String,
|
||||
images: Set<String>,
|
||||
videos: Set<String>,
|
||||
pdfs: Set<String>,
|
||||
urls: Urls,
|
||||
emojis: Map<String, String>,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
@@ -288,6 +309,14 @@ class RichTextParser {
|
||||
}
|
||||
}
|
||||
|
||||
if (pdfs.contains(word)) {
|
||||
return if (urls.withoutScheme.contains(word)) {
|
||||
PdfSegment("https://$word")
|
||||
} else {
|
||||
PdfSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
if (urls.withoutScheme.contains(word)) return SchemelessUrlSegment(word)
|
||||
|
||||
if (urls.withScheme.contains(word)) return LinkSegment(word)
|
||||
@@ -377,9 +406,11 @@ class RichTextParser {
|
||||
|
||||
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
|
||||
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
||||
val pdfExt = listOf("pdf")
|
||||
|
||||
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
|
||||
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
|
||||
val pdfExtensions = pdfExt + pdfExt.map { it.uppercase() }
|
||||
|
||||
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
|
||||
val hashTagsPattern: Regex =
|
||||
@@ -421,6 +452,11 @@ class RichTextParser {
|
||||
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isPdfUrl(url: String): Boolean {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
return pdfExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
}
|
||||
|
||||
fun isValidURL(url: String?): Boolean =
|
||||
try {
|
||||
if (url != null) {
|
||||
@@ -496,4 +532,6 @@ val mimeTypeMap: Map<String, String> =
|
||||
"m4a" to "audio/mp4",
|
||||
"aac" to "audio/aac",
|
||||
"flac" to "audio/flac",
|
||||
// Documents
|
||||
"pdf" to "application/pdf",
|
||||
)
|
||||
|
||||
+5
@@ -62,6 +62,11 @@ class VideoSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PdfSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(
|
||||
segment: String,
|
||||
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
/*
|
||||
* 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.round
|
||||
|
||||
/**
|
||||
* ThumbHash decoder.
|
||||
*
|
||||
* Port of the reference implementation by Evan Wallace
|
||||
* (https://github.com/evanw/thumbhash, public domain), with performance
|
||||
* optimisations for the decode hot path:
|
||||
*
|
||||
* - cosine tables for the inverse DCT are precomputed once per decode and
|
||||
* cached across decodes keyed by `(size, componentCount)`; the reference
|
||||
* JS impl recomputes them for every single output pixel.
|
||||
* - AC coefficients are unpacked into fixed-size `DoubleArray`s, avoiding
|
||||
* `ArrayList<Double>` boxing and the final array copy.
|
||||
* - The LPQA → sRGB conversion uses an inline branch clamp instead of
|
||||
* `min/max/round/coerceIn` chains.
|
||||
*/
|
||||
object ThumbHashDecoder {
|
||||
// Cosine tables are small and decoded sizes repeat heavily in practice
|
||||
// (every Coil request at a given target width shares the same table).
|
||||
// Keep an unbounded map — there are at most a few dozen distinct
|
||||
// (size, components) pairs across the entire app lifetime, each table is
|
||||
// a few KB, so the memory ceiling is tiny.
|
||||
private val cosineCache = HashMap<Long, DoubleArray>()
|
||||
private val cosineCacheLock = Any()
|
||||
|
||||
/**
|
||||
* Clear the cosine table cache. Tables are tiny but callers under memory
|
||||
* pressure can release them; they will be recomputed on demand.
|
||||
*/
|
||||
fun clearCache() {
|
||||
synchronized(cosineCacheLock) { cosineCache.clear() }
|
||||
}
|
||||
|
||||
private fun cosTable(
|
||||
size: Int,
|
||||
components: Int,
|
||||
): DoubleArray {
|
||||
val key = (size.toLong() shl 32) or components.toLong()
|
||||
synchronized(cosineCacheLock) {
|
||||
cosineCache[key]?.let { return it }
|
||||
}
|
||||
val table = DoubleArray(size * components)
|
||||
val piOverSize = PI / size
|
||||
for (i in 0 until size) {
|
||||
val phase = piOverSize * (i + 0.5)
|
||||
val rowOffset = i * components
|
||||
for (c in 0 until components) {
|
||||
table[rowOffset + c] = cos(phase * c)
|
||||
}
|
||||
}
|
||||
synchronized(cosineCacheLock) {
|
||||
cosineCache.getOrPut(key) { table }
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is RGBAImage) return false
|
||||
return width == other.width && height == other.height && pixels.contentEquals(other.pixels)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = width
|
||||
result = 31 * result + height
|
||||
result = 31 * result + pixels.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// Pre-size and unpack AC coefficients
|
||||
val lAcCount = countAc(lx, ly)
|
||||
val pqAcCount = countAc(3, 3)
|
||||
val aAcCount = if (hasAlpha) countAc(5, 5) else 0
|
||||
val totalAc = lAcCount + pqAcCount * 2 + aAcCount
|
||||
val acStart = if (hasAlpha) 6 else 5
|
||||
val acBytesAvailable = hash.size - acStart
|
||||
// 2 coefficients per byte
|
||||
if (acBytesAvailable * 2 < totalAc) return null
|
||||
|
||||
val lAc = DoubleArray(lAcCount)
|
||||
val pAc = DoubleArray(pqAcCount)
|
||||
val qAc = DoubleArray(pqAcCount)
|
||||
val aAc = if (hasAlpha) DoubleArray(aAcCount) else EMPTY_DOUBLE
|
||||
|
||||
var acIndex = 0
|
||||
acIndex = readAcInto(hash, acStart, acIndex, lx, ly, lScale, lAc)
|
||||
acIndex = readAcInto(hash, acStart, acIndex, 3, 3, pScale * 1.25, pAc)
|
||||
acIndex = readAcInto(hash, acStart, acIndex, 3, 3, qScale * 1.25, qAc)
|
||||
if (hasAlpha) readAcInto(hash, acStart, acIndex, 5, 5, aScale, aAc)
|
||||
|
||||
// Output size
|
||||
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)
|
||||
|
||||
// Precomputed cosine tables (shared across decodes with matching size/components)
|
||||
val cosXL = cosTable(w, lx)
|
||||
val cosYL = cosTable(h, ly)
|
||||
val cosXPQ = cosTable(w, 3)
|
||||
val cosYPQ = cosTable(h, 3)
|
||||
val cosXA: DoubleArray
|
||||
val cosYA: DoubleArray
|
||||
if (hasAlpha) {
|
||||
cosXA = cosTable(w, 5)
|
||||
cosYA = cosTable(h, 5)
|
||||
} else {
|
||||
cosXA = EMPTY_DOUBLE
|
||||
cosYA = EMPTY_DOUBLE
|
||||
}
|
||||
|
||||
// Decode pixels using the inverse DCT
|
||||
var pixelIdx = 0
|
||||
for (y in 0 until h) {
|
||||
val cosYLBase = y * ly
|
||||
val cosYPQBase = y * 3
|
||||
val cosYABase = y * 5
|
||||
for (x in 0 until w) {
|
||||
val cosXLBase = x * lx
|
||||
val cosXPQBase = x * 3
|
||||
val cosXABase = x * 5
|
||||
|
||||
var l = lDc
|
||||
var p = pDc
|
||||
var q = qDc
|
||||
var a = aDc
|
||||
|
||||
// L channel — triangular iteration over (cx, cy)
|
||||
var j = 0
|
||||
var cy = 0
|
||||
while (cy < ly) {
|
||||
val fyL2 = cosYL[cosYLBase + cy] * 2.0
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
val cxLimit = cxLimitForL(lx, ly, cy)
|
||||
while (cx < cxLimit) {
|
||||
l += lAc[j] * cosXL[cosXLBase + cx] * fyL2
|
||||
j++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
|
||||
// P and Q share the same 3x3 triangular iteration
|
||||
j = 0
|
||||
cy = 0
|
||||
while (cy < 3) {
|
||||
val fyPQ2 = cosYPQ[cosYPQBase + cy] * 2.0
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
val cxLimit = 3 - cy
|
||||
while (cx < cxLimit) {
|
||||
val f = cosXPQ[cosXPQBase + cx] * fyPQ2
|
||||
p += pAc[j] * f
|
||||
q += qAc[j] * f
|
||||
j++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
|
||||
// Alpha channel
|
||||
if (hasAlpha) {
|
||||
j = 0
|
||||
cy = 0
|
||||
while (cy < 5) {
|
||||
val fyA2 = cosYA[cosYABase + cy] * 2.0
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
val cxLimit = 5 - cy
|
||||
while (cx < cxLimit) {
|
||||
a += aAc[j] * cosXA[cosXABase + cx] * fyA2
|
||||
j++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
}
|
||||
|
||||
// LPQA → sRGB with inline clamp
|
||||
val bCh = l - 2.0 / 3.0 * p
|
||||
val rCh = (3.0 * l - bCh + q) * 0.5
|
||||
val gCh = rCh - q
|
||||
val rOut = clamp255(rCh)
|
||||
val gOut = clamp255(gCh)
|
||||
val bOut = clamp255(bCh)
|
||||
val aOut = if (hasAlpha) clamp255(a) else 255
|
||||
pixels[pixelIdx++] = (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] whose aspect ratio
|
||||
* matches the original image. [targetWidth] is accepted for API symmetry
|
||||
* with [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio]
|
||||
* but the intrinsic decode output size is used because ThumbHash's own
|
||||
* reconstruction is already aspect-correct at ~32px.
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun decodeKeepAspectRatio(
|
||||
hash: String?,
|
||||
targetWidth: Int,
|
||||
): PlatformImage? {
|
||||
val rgba = decode(hash) ?: return null
|
||||
return PlatformImage.create(rgba.pixels, rgba.width, rgba.height)
|
||||
}
|
||||
|
||||
// --- internal helpers --- //
|
||||
|
||||
private val EMPTY_DOUBLE = DoubleArray(0)
|
||||
|
||||
/**
|
||||
* Count the number of AC coefficients carried by a channel of size nx × ny,
|
||||
* following the reference implementation's triangular traversal.
|
||||
*/
|
||||
private fun countAc(
|
||||
nx: Int,
|
||||
ny: Int,
|
||||
): Int {
|
||||
var count = 0
|
||||
var cy = 0
|
||||
while (cy < ny) {
|
||||
var cx = if (cy != 0) 0 else 1
|
||||
while (cx * ny < nx * (ny - cy)) {
|
||||
count++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Row limit for the L channel's triangular traversal. For nx == ny this
|
||||
* collapses to `nx - cy`; keeping the explicit form avoids a mispredicted
|
||||
* branch in the inner loop for non-square L blocks.
|
||||
*/
|
||||
private fun cxLimitForL(
|
||||
lx: Int,
|
||||
ly: Int,
|
||||
cy: Int,
|
||||
): Int {
|
||||
// cx * ly < lx * (ly - cy) ⇔ cx < (lx * (ly - cy)) / ly
|
||||
// Use integer ceil emulation: smallest cx that fails the condition.
|
||||
val numerator = lx * (ly - cy)
|
||||
// Largest cx satisfying cx * ly < numerator:
|
||||
// cx <= ceil(numerator / ly) - 1 when numerator is exact,
|
||||
// otherwise cx <= floor(numerator / ly).
|
||||
// So the limit (exclusive) is ceil(numerator / ly) when numerator % ly != 0,
|
||||
// else numerator / ly.
|
||||
return if (numerator % ly == 0) numerator / ly else numerator / ly + 1
|
||||
}
|
||||
|
||||
private fun readAcInto(
|
||||
hash: ByteArray,
|
||||
acStart: Int,
|
||||
startIndex: Int,
|
||||
nx: Int,
|
||||
ny: Int,
|
||||
scale: Double,
|
||||
out: DoubleArray,
|
||||
): Int {
|
||||
var acIndex = startIndex
|
||||
var outIdx = 0
|
||||
val hashLen = hash.size
|
||||
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 >= hashLen) return acIndex
|
||||
val shift = (acIndex and 1) shl 2
|
||||
val q4 = (hash[byteIdx].toInt() ushr shift) and 15
|
||||
out[outIdx++] = (q4 / 7.5 - 1.0) * scale
|
||||
acIndex++
|
||||
cx++
|
||||
}
|
||||
cy++
|
||||
}
|
||||
return acIndex
|
||||
}
|
||||
|
||||
/** Clamp v into 0..1 and scale to 0..255 with rounding, branchlessly on the hot path. */
|
||||
private fun clamp255(v: Double): Int {
|
||||
if (v <= 0.0) return 0
|
||||
if (v >= 1.0) return 255
|
||||
return (v * 255.0 + 0.5).toInt()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder
|
||||
import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
class ThumbHashTest {
|
||||
@Test
|
||||
fun `encode and decode a solid color produces a hash with matching aspect`() {
|
||||
val w = 32
|
||||
val h = 24
|
||||
val pixels = IntArray(w * h) { 0xFFFF8040.toInt() } // opaque warm orange
|
||||
|
||||
val hashBytes = ThumbHashEncoder.encode(pixels, w, h)
|
||||
assertTrue("hash should have at least header bytes", hashBytes.size >= 5)
|
||||
|
||||
val decoded = ThumbHashDecoder.decode(hashBytes)
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
assertTrue("decoded width should be positive", decoded.width > 0)
|
||||
assertTrue("decoded height should be positive", decoded.height > 0)
|
||||
|
||||
val originalRatio = w.toFloat() / h.toFloat()
|
||||
val decodedRatio = decoded.width.toFloat() / decoded.height.toFloat()
|
||||
// ThumbHash loses some precision, but landscape vs portrait should be preserved.
|
||||
assertTrue(
|
||||
"decoded ratio ($decodedRatio) should be on the same side of 1 as original ($originalRatio)",
|
||||
(originalRatio > 1f) == (decodedRatio > 1f) || originalRatio == decodedRatio,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base64 round-trip preserves decoded dimensions`() {
|
||||
val w = 40
|
||||
val h = 40
|
||||
// A simple gradient so the image isn't entirely flat.
|
||||
val pixels =
|
||||
IntArray(w * h) { i ->
|
||||
val x = i % w
|
||||
val y = i / w
|
||||
val r = (x * 255 / (w - 1))
|
||||
val g = (y * 255 / (h - 1))
|
||||
(0xFF shl 24) or (r shl 16) or (g shl 8) or 0x40
|
||||
}
|
||||
|
||||
val encoded = ThumbHashEncoder.encodeToBase64(pixels, w, h)
|
||||
assertTrue("base64 string should be non-empty", encoded.isNotEmpty())
|
||||
assertTrue("base64 string should not contain padding", !encoded.contains('='))
|
||||
|
||||
val viaBase64 = ThumbHashDecoder.decode(encoded)
|
||||
assertNotNull(viaBase64)
|
||||
viaBase64!!
|
||||
|
||||
val viaBytes = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
|
||||
assertNotNull(viaBytes)
|
||||
viaBytes!!
|
||||
|
||||
assertEquals("base64 path and raw path should agree on width", viaBytes.width, viaBase64.width)
|
||||
assertEquals("base64 path and raw path should agree on height", viaBytes.height, viaBase64.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoding a malformed hash returns null`() {
|
||||
assertNull(ThumbHashDecoder.decode(ByteArray(3)))
|
||||
assertNull(ThumbHashDecoder.decode(null as String?))
|
||||
assertNull(ThumbHashDecoder.decode(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoded opaque image has fully opaque alpha`() {
|
||||
val w = 32
|
||||
val h = 32
|
||||
val pixels = IntArray(w * h) { 0xFF8080FF.toInt() } // opaque cornflower-ish
|
||||
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
for (p in decoded.pixels) {
|
||||
val a = (p ushr 24) and 0xff
|
||||
assertEquals("alpha should be 255 for opaque encode", 255, a)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoded transparent image preserves alpha channel`() {
|
||||
val w = 32
|
||||
val h = 32
|
||||
// Fully transparent pixels everywhere.
|
||||
val pixels = IntArray(w * h) { 0x00000000 }
|
||||
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
// The average alpha is 0, so every decoded alpha should be at or near 0.
|
||||
var maxAlpha = 0
|
||||
for (p in decoded.pixels) {
|
||||
val a = (p ushr 24) and 0xff
|
||||
if (a > maxAlpha) maxAlpha = a
|
||||
}
|
||||
assertTrue("max alpha of all-transparent decode should be low; got $maxAlpha", maxAlpha <= 16)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoded average color is close to input average`() {
|
||||
val w = 48
|
||||
val h = 32
|
||||
val target = intArrayOf(200, 120, 60) // warm orange
|
||||
val pixels =
|
||||
IntArray(w * h) {
|
||||
(0xFF shl 24) or (target[0] shl 16) or (target[1] shl 8) or target[2]
|
||||
}
|
||||
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
|
||||
var sumR = 0
|
||||
var sumG = 0
|
||||
var sumB = 0
|
||||
for (p in decoded.pixels) {
|
||||
sumR += (p shr 16) and 0xff
|
||||
sumG += (p shr 8) and 0xff
|
||||
sumB += p and 0xff
|
||||
}
|
||||
val count = decoded.pixels.size
|
||||
val avgR = sumR / count
|
||||
val avgG = sumG / count
|
||||
val avgB = sumB / count
|
||||
|
||||
// ThumbHash quantisation allows a handful of codepoints of drift.
|
||||
assertTrue("avg R drift: expected ${target[0]}, got $avgR", abs(avgR - target[0]) < 8)
|
||||
assertTrue("avg G drift: expected ${target[1]}, got $avgG", abs(avgG - target[1]) < 8)
|
||||
assertTrue("avg B drift: expected ${target[2]}, got $avgB", abs(avgB - target[2]) < 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aspect ratio matches landscape input`() {
|
||||
val w = 60
|
||||
val h = 30
|
||||
val pixels = IntArray(w * h) { 0xFF446688.toInt() }
|
||||
val hash = ThumbHashEncoder.encode(pixels, w, h)
|
||||
val ratio = ThumbHashDecoder.aspectRatio(hash)
|
||||
assertNotNull(ratio)
|
||||
assertTrue("landscape ratio should be > 1, got $ratio", ratio!! > 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aspect ratio matches portrait input`() {
|
||||
val w = 30
|
||||
val h = 60
|
||||
val pixels = IntArray(w * h) { 0xFF446688.toInt() }
|
||||
val hash = ThumbHashEncoder.encode(pixels, w, h)
|
||||
val ratio = ThumbHashDecoder.aspectRatio(hash)
|
||||
assertNotNull(ratio)
|
||||
assertTrue("portrait ratio should be < 1, got $ratio", ratio!! < 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated decodes produce identical output (cosine cache determinism)`() {
|
||||
val w = 40
|
||||
val h = 30
|
||||
val pixels =
|
||||
IntArray(w * h) { i ->
|
||||
val x = i % w
|
||||
(0xFF shl 24) or (x * 6 shl 16) or ((i % 255) shl 8) or ((i * 3) and 0xff)
|
||||
}
|
||||
val hash = ThumbHashEncoder.encode(pixels, w, h)
|
||||
|
||||
val first = ThumbHashDecoder.decode(hash)
|
||||
val second = ThumbHashDecoder.decode(hash)
|
||||
val third = ThumbHashDecoder.decode(hash)
|
||||
assertNotNull(first)
|
||||
assertNotNull(second)
|
||||
assertNotNull(third)
|
||||
|
||||
// Bit-exact: the cached cosine tables must produce identical output.
|
||||
assertEquals(first, second)
|
||||
assertEquals(first, third)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearCache does not affect correctness of subsequent decodes`() {
|
||||
val w = 32
|
||||
val h = 32
|
||||
val pixels = IntArray(w * h) { 0xFFAABBCC.toInt() }
|
||||
val hash = ThumbHashEncoder.encode(pixels, w, h)
|
||||
|
||||
val before = ThumbHashDecoder.decode(hash)
|
||||
ThumbHashDecoder.clearCache()
|
||||
val after = ThumbHashDecoder.decode(hash)
|
||||
assertEquals(before, after)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated AC payload returns null`() {
|
||||
val w = 32
|
||||
val h = 32
|
||||
val pixels = IntArray(w * h) { 0xFF336699.toInt() }
|
||||
val fullHash = ThumbHashEncoder.encode(pixels, w, h)
|
||||
// Chop off half the AC payload.
|
||||
val truncated = fullHash.copyOfRange(0, 5 + (fullHash.size - 5) / 4)
|
||||
assertNull(
|
||||
"hash with insufficient AC bytes should be rejected",
|
||||
ThumbHashDecoder.decode(truncated),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoded output size stays within 32 x 32 bounds`() {
|
||||
val w = 50
|
||||
val h = 40
|
||||
val pixels =
|
||||
IntArray(w * h) { i ->
|
||||
(0xFF shl 24) or ((i and 0xff) shl 16) or (((i * 2) and 0xff) shl 8) or ((i * 3) and 0xff)
|
||||
}
|
||||
val decoded = ThumbHashDecoder.decode(ThumbHashEncoder.encode(pixels, w, h))
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
assertTrue(
|
||||
"expected output to fit in 32x32, got ${decoded.width}x${decoded.height}",
|
||||
decoded.width in 1..32 && decoded.height in 1..32,
|
||||
)
|
||||
assertEquals(
|
||||
"pixel buffer size must match dimensions",
|
||||
decoded.width * decoded.height,
|
||||
decoded.pixels.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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.richtext
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PdfParserTest {
|
||||
@Test
|
||||
fun detectsPdfByExtension() {
|
||||
val url = "https://example.com/docs/paper.pdf"
|
||||
val state = RichTextParser().parseText(url, EmptyTagList, null)
|
||||
|
||||
val pdfMedia = state.mediaForPager[url]
|
||||
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf for .pdf URL")
|
||||
|
||||
val segment = state.paragraphs[0].words[0]
|
||||
assertTrue(segment is PdfSegment, "Expected PdfSegment for .pdf URL, got ${segment::class.simpleName}")
|
||||
assertEquals(url, segment.segmentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsPdfFromImetaMimeTypeWithoutExtension() {
|
||||
val url = "https://files.example.com/abcd1234"
|
||||
val tags =
|
||||
ImmutableListOfLists(
|
||||
arrayOf(
|
||||
arrayOf("imeta", "url $url", "m application/pdf"),
|
||||
),
|
||||
)
|
||||
|
||||
val state = RichTextParser().parseText(url, tags, null)
|
||||
|
||||
val pdfMedia = state.mediaForPager[url]
|
||||
assertTrue(pdfMedia is MediaUrlPdf, "Expected MediaUrlPdf from imeta MIME tag")
|
||||
assertEquals("application/pdf", (pdfMedia as MediaUrlPdf).mimeType)
|
||||
|
||||
val segment = state.paragraphs[0].words[0]
|
||||
assertTrue(segment is PdfSegment, "Expected PdfSegment from imeta MIME tag, got ${segment::class.simpleName}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isPdfUrlHelperMatchesPdfExtension() {
|
||||
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf"))
|
||||
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.PDF"))
|
||||
assertTrue(RichTextParser.isPdfUrl("https://example.com/doc.pdf?sig=abc"))
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.toPlatformImage
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
fun BufferedImage.toThumbhash(): String = this.toPlatformImage().toThumbhash()
|
||||
Reference in New Issue
Block a user