From b2f297b3bb6e05aa8b8c1dc0ab99030bfa53c237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:12:28 +0000 Subject: [PATCH 1/2] 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 --- .../vitorpamplona/amethyst/model/Account.kt | 44 +-- .../service/images/ImageLoaderSetup.kt | 2 + .../service/images/ThumbHashFetcher.kt | 85 ++++++ .../service/playback/composable/VideoView.kt | 9 +- .../amethyst/service/uploads/FileHeader.kt | 6 +- .../service/uploads/MediaUploadResult.kt | 12 +- ...ulator.kt => PreviewMetadataCalculator.kt} | 70 +++-- .../uploads/blossom/BlossomUploader.kt | 4 +- .../service/uploads/nip96/Nip96Uploader.kt | 4 +- .../amethyst/ui/actions/EditPostViewModel.kt | 3 + .../ui/components/SensitivityWarning.kt | 7 +- .../ui/components/ZoomableContentView.kt | 27 +- .../nip22Comments/CommentPostViewModel.kt | 3 + .../amethyst/ui/note/types/Classifieds.kt | 1 + .../amethyst/ui/note/types/FileHeader.kt | 3 + .../amethyst/ui/note/types/FileStorage.kt | 4 + .../amethyst/ui/note/types/PictureDisplay.kt | 3 +- .../amethyst/ui/note/types/Video.kt | 2 + .../amethyst/ui/note/types/VideoDisplay.kt | 4 +- .../chats/feed/types/RenderEncryptedFile.kt | 2 + .../feed/types/RenderMarmotEncryptedMedia.kt | 2 + .../marmotGroup/send/MarmotFileSender.kt | 1 + .../marmotGroup/send/MarmotFileUploader.kt | 2 + .../chats/privateDM/send/IMetaAttachments.kt | 3 + .../chats/privateDM/send/NewGroupDMScreen.kt | 12 + .../privateDM/send/upload/ChatFileSender.kt | 1 + .../nip23LongForm/LongFormPostViewModel.kt | 3 + .../nip99Classifieds/NewProductViewModel.kt | 20 +- .../loggedIn/home/ShortNotePostViewModel.kt | 3 + .../NewPublicMessageViewModel.kt | 3 + .../loggedIn/pictures/PictureCardCompose.kt | 3 +- .../loggedIn/profile/gallery/GalleryThumb.kt | 10 +- .../loggedIn/shorts/VideoCardCompose.kt | 4 +- .../loggedIn/threadview/ThreadFeedView.kt | 1 + .../loggedIn/video/FileHeaderCardCompose.kt | 5 +- .../amethyst/commons/thumbhash/BitmapUtils.kt | 26 ++ .../commons/richtext/MediaContentModels.kt | 28 +- .../commons/richtext/RichTextParser.kt | 4 + .../commons/thumbhash/ThumbHashDecoder.kt | 257 ++++++++++++++++++ .../commons/thumbhash/ThumbHashEncoder.kt | 218 +++++++++++++++ .../commons/thumbhash/ThumbHashEncoderExt.kt | 52 ++++ .../amethyst/commons/ThumbHashTest.kt | 91 +++++++ .../commons/thumbhash/BitmapUtils.jvm.kt | 26 ++ .../service/images/DesktopImageLoaderSetup.kt | 2 + .../service/images/DesktopThumbHashFetcher.kt | 86 ++++++ .../service/upload/DesktopMediaMetadata.kt | 8 +- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 2 + .../amethyst/desktop/ui/chats/ChatPane.kt | 1 + .../nip95/header/FileStorageHeaderEvent.kt | 3 + .../nip95/header/TagArrayBuilderExt.kt | 3 + .../ProfileGalleryEntryEvent.kt | 3 + .../profileGallery/TagArrayBuilderExt.kt | 3 + .../mip04EncryptedMedia/Mip04IMetaTag.kt | 2 + .../ChatMessageEncryptedFileHeaderEvent.kt | 5 + .../nip17Dm/files/TagArrayBuilderExt.kt | 3 + .../quartz/nip68Picture/IMetaTagBuilderExt.kt | 3 + .../quartz/nip68Picture/IMetaTagExt.kt | 3 + .../quartz/nip68Picture/PictureMeta.kt | 23 +- .../quartz/nip68Picture/TagArrayBuilderExt.kt | 3 +- .../quartz/nip71Video/IMetaTagBuilderExt.kt | 3 + .../quartz/nip71Video/IMetaTagExt.kt | 3 + .../quartz/nip71Video/TagArrayBuilderExt.kt | 3 +- .../quartz/nip71Video/VideoMeta.kt | 23 +- .../nip94FileMetadata/FileHeaderEvent.kt | 5 + .../nip94FileMetadata/IMetaTagBuilderExt.kt | 3 + .../nip94FileMetadata/TagArrayBuilderExt.kt | 3 + .../nip94FileMetadata/tags/ThumbhashTag.kt | 39 +++ .../nip99Classifieds/ProductImageMeta.kt | 18 +- 68 files changed, 1203 insertions(+), 120 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/{BlurhashMetadataCalculator.kt => PreviewMetadataCalculator.kt} (71%) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2a5d19abcc..c12df587ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -228,6 +228,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -249,6 +250,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException +import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash +import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash @OptIn(DelicateCoroutinesApi::class) @Stable @@ -1135,6 +1138,7 @@ class Account( headerInfo.mimeType?.let { mimeType(it) } headerInfo.dim?.let { dimension(it) } headerInfo.blurHash?.let { blurhash(it.blurhash) } + headerInfo.thumbHash?.let { nip95thumbhash(it.thumbhash) } contentWarningReason?.let { contentWarning(contentWarningReason) } } @@ -1219,16 +1223,17 @@ class Account( val iMetas = urlHeaderInfo.map { PictureMeta( - it.key, - it.value.mimeType, - it.value.blurHash?.blurhash, - it.value.dim, - caption, - it.value.hash, - it.value.size, - null, - emptyList(), - emptyList(), + url = it.key, + mimeType = it.value.mimeType, + blurhash = it.value.blurHash?.blurhash, + dimension = it.value.dim, + alt = caption, + hash = it.value.hash, + size = it.value.size, + service = null, + fallback = emptyList(), + annotations = emptyList(), + thumbhash = it.value.thumbHash?.thumbhash, ) } @@ -1271,13 +1276,14 @@ class Account( quotes(findNostrUris(it)) } pictureIMeta( - url, - headerInfo.mimeType, - headerInfo.blurHash?.blurhash, - headerInfo.dim, - headerInfo.hash, - headerInfo.size, - alt, + url = url, + mimeType = headerInfo.mimeType, + blurhash = headerInfo.blurHash?.blurhash, + dimension = headerInfo.dim, + hash = headerInfo.hash, + size = headerInfo.size, + alt = alt, + thumbhash = headerInfo.thumbHash?.thumbhash, ) // add zap splits // add zap raiser @@ -1295,6 +1301,7 @@ class Account( dimension = headerInfo.dim, blurhash = headerInfo.blurHash?.blurhash, alt = alt, + thumbhash = headerInfo.thumbHash?.thumbhash, ) if (headerInfo.dim.height > headerInfo.dim.width) { @@ -1314,6 +1321,7 @@ class Account( headerInfo.mimeType?.let { mimeType(it) } headerInfo.dim?.let { dimension(it) } headerInfo.blurHash?.let { blurhash(it.blurhash) } + headerInfo.thumbHash?.let { thumbhash(it.thumbhash) } originalHash?.let { originalHash(it) } magnetUri?.let { magnet(it) } @@ -2164,6 +2172,7 @@ class Account( dim: DimensionTag?, hash: String?, mimeType: String?, + thumbhash: String? = null, ) { val template = ProfileGalleryEntryEvent.build(url) { @@ -2172,6 +2181,7 @@ class Account( mimeType?.let { mimeType(it) } dim?.let { dimension(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { galleryThumbhash(it) } } val event = signer.sign(template) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index 31b4e782f8..f7b25bee4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -83,10 +83,12 @@ class ImageLoaderSetup { add(VideoFrameDecoder.Factory()) add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) + add(ThumbHashFetcher.Factory) add(BlossomFetcher.Factory(blossomServerResolver, callFactory)) add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope)) add(Base64Fetcher.BKeyer) add(BlurHashFetcher.BKeyer) + add(ThumbHashFetcher.TKeyer) add(ProfilePictureFetcher.BKeyer) add(OkHttpFactory(callFactory)) }.build(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt new file mode 100644 index 0000000000..31c6cc1379 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbHashFetcher.kt @@ -0,0 +1,85 @@ +/* + * 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.service.images + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder + +data class ThumbhashWrapper( + val thumbhash: String, +) + +@Stable +class ThumbHashFetcher( + private val options: Options, + private val data: ThumbhashWrapper, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val hash = data.thumbhash + val platformImage = ThumbHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + return ImageFetchResult( + image = platformImage.toAndroidBitmap().asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: ThumbhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = ThumbHashFetcher(options, data) + } + + object TKeyer : Keyer { + override fun key( + data: ThumbhashWrapper, + options: Options, + ): String = data.thumbhash + } +} + +/** + * Pick the best Coil model for a media placeholder. + * + * Prefers [ThumbhashWrapper] when a thumbhash is available (better quality, preserves aspect ratio + * and alpha) and falls back to [BlurhashWrapper] when only a blurhash is present. Returns null + * when neither is available, so callers can skip the placeholder request entirely. + */ +fun placeholderModel( + thumbhash: String?, + blurhash: String?, +): Any? = + when { + !thumbhash.isNullOrEmpty() -> ThumbhashWrapper(thumbhash) + !blurhash.isNullOrEmpty() -> BlurhashWrapper(blurhash) + else -> null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index efb7cef5e3..6d8cf5382c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -68,6 +68,7 @@ fun VideoView( onDialog: (() -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false, + thumbhash: String? = null, ) { val borderModifier = if (roundedCorner) { @@ -78,7 +79,7 @@ fun VideoView( Modifier } - VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel) + VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash) } @Composable @@ -99,6 +100,7 @@ fun VideoView( alwaysShowVideo: Boolean = false, showControls: Boolean = true, accountViewModel: AccountViewModel, + thumbhash: String? = null, ) { val automaticallyStartPlayback = remember { @@ -107,7 +109,7 @@ fun VideoView( ) } - if (blurhash == null) { + if (blurhash == null && thumbhash == null) { val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) val modifier = @@ -154,12 +156,13 @@ fun VideoView( } Box(modifier, contentAlignment = Alignment.Center) { - // Always displays Blurharh to avoid size flickering + // Always displays a placeholder (thumbhash preferred, blurhash fallback) to avoid size flickering DisplayBlurHash( blurhash, null, contentScale, if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier, + thumbhash = thumbhash, ) if (!automaticallyStartPlayback.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 79427a04b2..93c28ad5a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads import android.media.MediaDataSource import com.vitorpamplona.amethyst.service.images.BlurhashWrapper +import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.Log @@ -36,6 +37,7 @@ class FileHeader( val size: Int, val dim: DimensionTag?, val blurHash: BlurhashWrapper?, + val thumbHash: ThumbhashWrapper? = null, ) { class UnableToDownload( val fileUrl: String, @@ -71,9 +73,9 @@ class FileHeader( val hash = sha256(data).toHexKey() val size = data.size - val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed) + val preview = PreviewMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed) - Result.success(FileHeader(mimeType, hash, size, dim, blurHash)) + Result.success(FileHeader(mimeType, hash, size, preview.dim, preview.blurhash, preview.thumbhash)) } catch (e: Exception) { if (e is CancellationException) throw e Log.e("ImageDownload") { "Couldn't convert image in to File Header: ${e.message}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index 911a33da99..0853058a60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.uploads import com.vitorpamplona.amethyst.service.images.BlurhashWrapper +import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -45,12 +46,15 @@ data class MediaUploadResult( val ipfs: String? = null, // blurhash value for previews val blurHash: BlurhashWrapper? = null, + // thumbhash value for previews (preferred over blurhash in the UI) + val thumbHash: ThumbhashWrapper? = null, ) { - fun mergeLocalMetadata(localMetadata: Pair?): MediaUploadResult = - localMetadata?.let { (blur, dim) -> + fun mergeLocalMetadata(localMetadata: PreviewHashes?): MediaUploadResult = + localMetadata?.let { hashes -> copy( - dimension = dim ?: dimension, - blurHash = blur ?: blurHash, + dimension = hashes.dim ?: dimension, + blurHash = hashes.blurhash ?: blurHash, + thumbHash = hashes.thumbhash ?: thumbHash, ) } ?: this } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt similarity index 71% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt index ef3b0aea17..5d8aed97c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/PreviewMetadataCalculator.kt @@ -26,11 +26,28 @@ import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash import com.vitorpamplona.amethyst.service.images.BlurhashWrapper +import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.Log -object BlurhashMetadataCalculator { +/** + * Result of precomputing placeholder metadata during an upload. The bitmap or video thumbnail is + * decoded exactly once and both hashes are computed from the same pixels to keep the hot upload + * path cheap. + */ +data class PreviewHashes( + val blurhash: BlurhashWrapper? = null, + val thumbhash: ThumbhashWrapper? = null, + val dim: DimensionTag? = null, +) { + companion object { + val EMPTY = PreviewHashes() + } +} + +object PreviewMetadataCalculator { private fun isImage(mimeType: String?) = mimeType?.startsWith("image/", ignoreCase = true) == true private fun isVideo(mimeType: String?) = mimeType?.startsWith("video/", ignoreCase = true) == true @@ -42,19 +59,11 @@ object BlurhashMetadataCalculator { inPreferredConfig = Bitmap.Config.ARGB_8888 } - private fun processImage( - bitmap: Bitmap?, - dimPrecomputed: DimensionTag?, - ): Pair { - val (blur, dim) = processBitmap(bitmap) - return blur to (dim ?: dimPrecomputed) - } - fun computeFromBytes( data: ByteArray, mimeType: String?, dimPrecomputed: DimensionTag?, - ): Pair = + ): PreviewHashes = when { isImage(mimeType) -> { val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions()) @@ -72,7 +81,7 @@ object BlurhashMetadataCalculator { } else -> { - null to dimPrecomputed + PreviewHashes(dim = dimPrecomputed) } } @@ -81,7 +90,7 @@ object BlurhashMetadataCalculator { uri: Uri, mimeType: String?, dimPrecomputed: DimensionTag? = null, - ): Pair? { + ): PreviewHashes? { if (!shouldAttempt(mimeType)) return null return try { @@ -90,7 +99,7 @@ object BlurhashMetadataCalculator { context.contentResolver.openInputStream(uri)?.use { stream -> val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions()) processImage(bitmap, dimPrecomputed) - } ?: (null to dimPrecomputed) + } ?: PreviewHashes(dim = dimPrecomputed) } isVideo(mimeType) -> { @@ -108,33 +117,44 @@ object BlurhashMetadataCalculator { } } } catch (e: Exception) { - Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e) + Log.w("PreviewMetadataCalc", "Failed to compute metadata from uri", e) null } } - private fun processBitmap(bitmap: Bitmap?): Pair = + private fun processImage( + bitmap: Bitmap?, + dimPrecomputed: DimensionTag?, + ): PreviewHashes { + val hashes = processBitmap(bitmap) + return hashes.copy(dim = hashes.dim ?: dimPrecomputed) + } + + private fun processBitmap(bitmap: Bitmap?): PreviewHashes = if (bitmap != null) { try { - val blurhash = BlurhashWrapper(bitmap.toBlurhash()) - blurhash to DimensionTag(bitmap.width, bitmap.height) + val blurhash = runCatching { BlurhashWrapper(bitmap.toBlurhash()) }.getOrNull() + val thumbhash = runCatching { ThumbhashWrapper(bitmap.toThumbhash()) }.getOrNull() + PreviewHashes( + blurhash = blurhash, + thumbhash = thumbhash, + dim = DimensionTag(bitmap.width, bitmap.height), + ) } finally { bitmap.recycle() } } else { - null to null + PreviewHashes.EMPTY } private fun processRetriever( retriever: MediaMetadataRetriever, dimPrecomputed: DimensionTag?, - ): Pair { + ): PreviewHashes { val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed - val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) } - return if (dim?.hasSize() == true) { - blurhash to dim - } else { - blurhash to null - } + val thumb = retriever.getThumbnail() + val hashes = processBitmap(thumb) + val finalDim = if (dim?.hasSize() == true) dim else hashes.dim + return hashes.copy(dim = finalDim) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index c5ac44b9f2..6dce744220 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -28,8 +28,8 @@ import android.webkit.MimeTypeMap import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult +import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper @@ -95,7 +95,7 @@ class BlossomUploader { hashBytes.toHexKey() to totalBytes } - val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) + val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType) val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 2950a3fc04..b17e3ba8a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -30,8 +30,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult +import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag @@ -107,7 +107,7 @@ class Nip96Uploader { val myContentType = contentType ?: contentResolver.getType(uri) val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0 - val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) + val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType) val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index ec1d6db0ab..a40e19f2f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -233,6 +234,8 @@ open class EditPostViewModel : ViewModel() { ?.let { dims(it) } state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } + state.result.fileHeader.thumbHash + ?.let { thumbhash(it.thumbhash) } state.result.magnet?.let { magnet(it) } state.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index 85e57f6d42..fb3de24b47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -207,14 +207,16 @@ fun ContentWarningNoteWithBigReasonPreview() { @Composable fun BlurhashBackdrop( - blurhash: String, + blurhash: String?, description: String?, + thumbhash: String? = null, ) { DisplayBlurHash( blurhash = blurhash, description = description, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + thumbhash = thumbhash, ) } @@ -222,12 +224,13 @@ fun BlurhashBackdrop( fun BlurhashGridBackdrop(media: List) { AutoNonlazyGrid(media.size) { idx -> val item = media[idx] - if (item.blurhash != null) { + if (item.blurhash != null || item.thumbhash != null) { DisplayBlurHash( blurhash = item.blurhash, description = item.description, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + thumbhash = item.thumbhash, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 1557661c0d..e9d03260ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.MediaAspectRatioCache -import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -162,7 +161,7 @@ fun ZoomableContentView( preloadUrls = listOf(content.url), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, contentScale), - backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, ) { TwoSecondController(content) { controllerVisible -> val mainImageModifier = @@ -184,7 +183,7 @@ fun ZoomableContentView( preloadUrls = emptyList(), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, contentScale), - backdrop = content.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, ) { Box( modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), @@ -203,6 +202,7 @@ fun ZoomableContentView( nostrUriCallback = content.uri, onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, + thumbhash = content.thumbhash, ) } } @@ -330,13 +330,14 @@ fun LocalImageView( when (state) { is AsyncImagePainter.State.Loading, -> { - if (content.blurhash != null) { + if (content.blurhash != null || content.thumbhash != null) { if (ratio != null) { DisplayBlurHash( content.blurhash, content.description, contentScale, loadedImageModifier.aspectRatio(ratio), + thumbhash = content.thumbhash, ) } else { DisplayBlurHash( @@ -344,6 +345,7 @@ fun LocalImageView( content.description, contentScale, loadedImageModifier, + thumbhash = content.thumbhash, ) } } else { @@ -389,7 +391,7 @@ fun LocalImageView( } } } else { - if (content.blurhash != null && ratio != null) { + if ((content.blurhash != null || content.thumbhash != null) && ratio != null) { DisplayBlurHash( content.blurhash, content.description, @@ -397,6 +399,7 @@ fun LocalImageView( loadedImageModifier .aspectRatio(ratio) .clickable { showImage.value = true }, + thumbhash = content.thumbhash, ) IconButton( modifier = Modifier.size(Size75dp), @@ -460,7 +463,7 @@ fun UrlImageView( when (state) { is AsyncImagePainter.State.Loading, -> { - if (content.blurhash != null) { + if (content.blurhash != null || content.thumbhash != null) { if (ratio != null) { val modifier = if (contentScale == ContentScale.Crop) { @@ -474,6 +477,7 @@ fun UrlImageView( content.description, ContentScale.Crop, modifier, + thumbhash = content.thumbhash, ) } else { DisplayBlurHash( @@ -481,6 +485,7 @@ fun UrlImageView( content.description, ContentScale.Crop, loadedImageModifier, + thumbhash = content.thumbhash, ) } } else { @@ -515,7 +520,7 @@ fun UrlImageView( } } } else { - if (content.blurhash != null && ratio != null) { + if ((content.blurhash != null || content.thumbhash != null) && ratio != null) { val modifier = if (contentScale == ContentScale.Crop) { loadedImageModifier.clickable { showImage.value = true } @@ -528,6 +533,7 @@ fun UrlImageView( content.description, contentScale, modifier, + thumbhash = content.thumbhash, ) IconButton( modifier = Modifier.size(Size75dp), @@ -786,11 +792,14 @@ fun DisplayBlurHash( description: String?, contentScale: ContentScale, modifier: Modifier, + thumbhash: String? = null, ) { - if (blurhash == null) return + val model = + com.vitorpamplona.amethyst.service.images + .placeholderModel(thumbhash, blurhash) ?: return AsyncImage( - model = BlurhashWrapper(blurhash), + model = model, contentDescription = description, contentScale = contentScale, modifier = modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 2e5b91c9a9..2cc5c9f721 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers @@ -543,6 +544,8 @@ open class CommentPostViewModel : ?.let { dims(it) } state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } + state.result.fileHeader.thumbHash + ?.let { thumbhash(it.thumbhash) } state.result.magnet?.let { magnet(it) } state.result.uploadedHash?.let { originalHash(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt index e68aebcc58..dea8fee724 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Classifieds.kt @@ -70,6 +70,7 @@ fun RenderClassifieds( dim = it.dimension, uri = note.toNostrUri(), mimeType = it.mimeType, + thumbhash = it.thumbhash, ) } val title = noteEvent.title() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 9b6eedabb0..4ad82bbff9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -49,6 +49,7 @@ fun FileHeaderDisplay( val content by remember(note) { val blurHash = event.blurhash() + val thumbHash = event.thumbhash() val hash = event.hash() val dimensions = event.dimensions() val description = event.content.ifEmpty { null } ?: event.alt() @@ -66,6 +67,7 @@ fun FileHeaderDisplay( dim = dimensions, uri = uri, mimeType = mimeType, + thumbhash = thumbHash, ) } else { MediaUrlVideo( @@ -77,6 +79,7 @@ fun FileHeaderDisplay( uri = uri, authorName = note.author?.toBestDisplayName(), mimeType = mimeType, + thumbhash = thumbHash, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 29cb0937af..b15e37e233 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -76,6 +76,7 @@ private fun ObserverAndRenderNIP95( val uri = header.toNostrUri() val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) } val blurHash = eventHeader.blurhash() + val thumbHash = eventHeader.thumbhash() val dimensions = eventHeader.dimensions() val description = eventHeader.alt() ?: eventHeader.content val mimeType = eventHeader.mimeType() @@ -90,6 +91,7 @@ private fun ObserverAndRenderNIP95( blurhash = blurHash, isVerified = true, uri = uri, + thumbhash = thumbHash, ) } else { MediaLocalVideo( @@ -100,6 +102,8 @@ private fun ObserverAndRenderNIP95( isVerified = true, uri = uri, authorName = header.author?.toBestDisplayName(), + blurhash = blurHash, + thumbhash = thumbHash, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt index 6730e72c9e..3015959a23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PictureDisplay.kt @@ -85,6 +85,7 @@ fun PictureDisplay( dim = it.dimension, uri = uri, mimeType = it.mimeType, + thumbhash = it.thumbhash, ) }.toImmutableList(), ) @@ -116,7 +117,7 @@ fun PictureDisplay( preloadUrls = listOf(first.url), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), - backdrop = first.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, first.description) } }, + backdrop = (first.thumbhash ?: first.blurhash)?.let { { BlurhashBackdrop(first.blurhash, first.description, first.thumbhash) } }, ) { ZoomableContentView( content = first, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 03ce5be924..1c23992180 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -103,6 +103,7 @@ fun VideoDisplay( dim = imeta.dimension, uri = uri, mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) } else { MediaUrlVideo( @@ -115,6 +116,7 @@ fun VideoDisplay( artworkUri = imeta.image.firstOrNull(), mimeType = imeta.mimeType, blurhash = imeta.blurhash, + thumbhash = imeta.thumbhash, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 63cb577ebe..0abc16ac93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -71,6 +71,7 @@ fun JustVideoDisplay( dim = imeta.dimension, uri = note.toNostrUri(), mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) } else { MediaUrlVideo( @@ -82,6 +83,7 @@ fun JustVideoDisplay( uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) }, ) @@ -95,7 +97,7 @@ fun JustVideoDisplay( preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, contentScale), - backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + backdrop = (imeta.thumbhash ?: imeta.blurhash)?.let { { BlurhashBackdrop(imeta.blurhash, content.description, imeta.thumbhash) } }, ) { ZoomableContentView( content = content, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt index 77095e3933..9bee04cbe7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderEncryptedFile.kt @@ -79,6 +79,7 @@ fun RenderEncryptedFile( encryptionAlgo = algo, encryptionKey = key, encryptionNonce = nonce, + thumbhash = noteEvent.thumbhash(), ) } else { EncryptedMediaUrlVideo( @@ -93,6 +94,7 @@ fun RenderEncryptedFile( encryptionAlgo = algo, encryptionKey = key, encryptionNonce = nonce, + thumbhash = noteEvent.thumbhash(), ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt index 79790ad5e4..d4c5464fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt @@ -139,6 +139,7 @@ private fun RenderMip04Content( encryptionAlgo = meta.version, encryptionKey = exporterSecret, encryptionNonce = meta.nonceBytes, + thumbhash = meta.thumbhash, ) } else { EncryptedMediaUrlVideo( @@ -153,6 +154,7 @@ private fun RenderMip04Content( encryptionAlgo = meta.version, encryptionKey = exporterSecret, encryptionNonce = meta.nonceBytes, + thumbhash = meta.thumbhash, ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt index b9f24db75c..b16d142265 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt @@ -43,6 +43,7 @@ class MarmotFileSender( nonce = upload.nonce, dimensions = upload.dimensions, blurhash = upload.blurhash, + thumbhash = upload.thumbhash, ) accountViewModel.sendMarmotGroupMediaMessage( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt index e6e058d70d..1883e9390a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -43,6 +43,7 @@ class Mip04UploadResult( val dimensions: String?, val blurhash: String?, val caption: String?, + val thumbhash: String? = null, ) /** @@ -102,6 +103,7 @@ class MarmotFileUploader( dimensions = serverResult.fileHeader.dim?.toString(), blurhash = serverResult.fileHeader.blurHash?.blurhash, caption = viewState.caption.ifEmpty { null }, + thumbhash = serverResult.fileHeader.thumbHash?.thumbhash, ), ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt index 279142002e..b8ff848e4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/IMetaAttachments.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import okhttp3.OkHttpClient import java.util.Locale @@ -60,6 +61,7 @@ class IMetaAttachments { it.mimeType?.let { mimeType(it) } it.dim?.let { dims(it) } it.blurHash?.let { blurhash(it.blurhash) } + it.thumbHash?.let { thumbhash(it.thumbhash) } }.build() } @@ -101,6 +103,7 @@ class IMetaAttachments { result.fileHeader.mimeType?.let { mimeType(it) } result.fileHeader.dim?.let { dims(it) } result.fileHeader.blurHash?.let { blurhash(it.blurhash) } + result.fileHeader.thumbHash?.let { thumbhash(it.thumbhash) } result.magnet?.let { magnet(it) } result.uploadedHash?.let { originalHash(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 716be8b864..7b45981583 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -626,6 +626,9 @@ fun ShowImageUploadGallery( encryptionAlgo = data.cipher.name(), encryptionKey = data.cipher.keyBytes, encryptionNonce = data.cipher.nonce, + thumbhash = + data.result.fileHeader.thumbHash + ?.thumbhash, ) } else { EncryptedMediaUrlVideo( @@ -641,6 +644,9 @@ fun ShowImageUploadGallery( encryptionAlgo = data.cipher.name(), encryptionKey = data.cipher.keyBytes, encryptionNonce = data.cipher.nonce, + thumbhash = + data.result.fileHeader.thumbHash + ?.thumbhash, ) } } else { @@ -655,6 +661,9 @@ fun ShowImageUploadGallery( dim = data.result.fileHeader.dim, uri = null, mimeType = data.result.mimeTypeBeforeEncryption, + thumbhash = + data.result.fileHeader.thumbHash + ?.thumbhash, ) } else { MediaUrlVideo( @@ -667,6 +676,9 @@ fun ShowImageUploadGallery( dim = data.result.fileHeader.dim, uri = null, mimeType = data.result.mimeTypeBeforeEncryption, + thumbhash = + data.result.fileHeader.thumbHash + ?.thumbhash, ) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 4337bffa82..b621b520fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -64,6 +64,7 @@ class ChatFileSender( size = result.fileHeader.size, dimension = result.fileHeader.dim, blurhash = result.fileHeader.blurHash?.blurhash, + thumbhash = result.fileHeader.thumbHash?.thumbhash, ) { if (!caption.isNullOrEmpty()) { alt(caption) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 6e484ace41..ee865aea33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils @@ -544,6 +545,8 @@ class LongFormPostViewModel : ?.let { dims(it) } state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } + state.result.fileHeader.thumbHash + ?.let { thumbhash(it.thumbhash) } state.result.magnet?.let { magnet(it) } state.result.uploadedHash?.let { originalHash(it) } alt?.let { alt(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index c49d589602..608fdc5cf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -429,14 +429,18 @@ open class NewProductViewModel : ) { productImages = productImages + ProductImageMeta( - it.result.url, - it.result.fileHeader.mimeType, - it.result.fileHeader.blurHash - ?.blurhash, - it.result.fileHeader.dim, - alt, - it.result.fileHeader.hash, - it.result.fileHeader.size, + url = it.result.url, + mimeType = it.result.fileHeader.mimeType, + blurhash = + it.result.fileHeader.blurHash + ?.blurhash, + dimension = it.result.fileHeader.dim, + alt = alt, + hash = it.result.fileHeader.hash, + size = it.result.fileHeader.size, + thumbhash = + it.result.fileHeader.thumbHash + ?.thumbhash, ) } else { iMetaDescription.add(it.result, alt, contentWarningReason) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 8bfb0e0af0..a5bc0c5684 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -1124,6 +1125,8 @@ open class ShortNotePostViewModel : ?.let { dims(it) } state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } + state.result.fileHeader.thumbHash + ?.let { thumbhash(it.thumbhash) } state.result.magnet?.let { magnet(it) } state.result.uploadedHash?.let { originalHash(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index bf88579e00..fc197d1201 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -107,6 +107,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size +import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.utils.Hex @@ -486,6 +487,8 @@ class NewPublicMessageViewModel : ?.let { dims(it) } state.result.fileHeader.blurHash ?.let { blurhash(it.blurhash) } + state.result.fileHeader.thumbHash + ?.let { thumbhash(it.thumbhash) } state.result.magnet?.let { magnet(it) } state.result.uploadedHash?.let { originalHash(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index d7aa97489a..26322c6de0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -115,6 +115,7 @@ private fun PictureCardImage( dim = it.dimension, uri = uri, mimeType = it.mimeType, + thumbhash = it.thumbhash, ) }.toImmutableList(), ) @@ -131,7 +132,7 @@ private fun PictureCardImage( preloadUrls = listOf(single.url), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), - backdrop = single.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, single.description) } }, + backdrop = (single.thumbhash ?: single.blurhash)?.let { { BlurhashBackdrop(single.blurhash, single.description, single.thumbhash) } }, ) { ZoomableContentView( content = single, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index fce93165f7..66f06d75e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -86,6 +86,7 @@ fun GalleryThumbnail( dim = noteEvent.dimensions(), uri = null, mimeType = noteEvent.mimeType(), + thumbhash = noteEvent.thumbhash(), ) } else { MediaUrlImage( @@ -97,6 +98,7 @@ fun GalleryThumbnail( dim = noteEvent.dimensions(), uri = null, mimeType = noteEvent.mimeType(), + thumbhash = noteEvent.thumbhash(), ) } } @@ -111,6 +113,7 @@ fun GalleryThumbnail( dim = imeta.dimension, uri = null, mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) } } else if (noteEvent is VideoEvent) { @@ -124,6 +127,7 @@ fun GalleryThumbnail( dim = imeta.dimension, uri = null, mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) } } else { @@ -210,12 +214,13 @@ fun UrlImageView( when (state) { is AsyncImagePainter.State.Loading, -> { - if (content.blurhash != null) { + if (content.blurhash != null || content.thumbhash != null) { DisplayBlurHash( content.blurhash, content.description, ContentScale.Crop, defaultModifier, + thumbhash = content.thumbhash, ) } else { Box(defaultModifier, contentAlignment = Alignment.Center) { @@ -243,12 +248,13 @@ fun UrlImageView( } } } else { - if (content.blurhash != null) { + if (content.blurhash != null || content.thumbhash != null) { DisplayBlurHash( content.blurhash, content.description, ContentScale.Crop, defaultModifier.clickable { showImage.value = true }, + thumbhash = content.thumbhash, ) Icon( imageVector = Icons.Default.PlayCircleOutline, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt index 11385dd60b..d4681cbdd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt @@ -121,6 +121,7 @@ private fun VideoCardImage( dim = imeta.dimension, uri = note.toNostrUri(), mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) } else { MediaUrlVideo( @@ -132,6 +133,7 @@ private fun VideoCardImage( uri = note.toNostrUri(), authorName = note.author?.toBestDisplayName(), mimeType = imeta.mimeType, + thumbhash = imeta.thumbhash, ) }, ) @@ -145,7 +147,7 @@ private fun VideoCardImage( preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), - backdrop = imeta.blurhash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + backdrop = (imeta.thumbhash ?: imeta.blurhash)?.let { { BlurhashBackdrop(imeta.blurhash, content.description, imeta.thumbhash) } }, ) { ZoomableContentView( content = content, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 40e49b4661..679295e5db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -922,6 +922,7 @@ private fun RenderClassifiedsReaderForThread( dim = it.dimension, uri = note.toNostrUri(), mimeType = it.mimeType, + thumbhash = it.thumbhash, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt index 2efc3243a6..24f8d7947c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/FileHeaderCardCompose.kt @@ -104,6 +104,7 @@ private fun FileHeaderCardImage( val reasons = remember(note) { collectContentWarningReasons(event) } val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) } val blurHash = remember(note) { event.blurhash() } + val thumbHash = remember(note) { event.thumbhash() } val dimensions = remember(note) { event.dimensions() } val content by remember(note) { @@ -122,6 +123,7 @@ private fun FileHeaderCardImage( dim = dimensions, uri = uri, mimeType = mimeType, + thumbhash = thumbHash, ) } else { MediaUrlVideo( @@ -133,6 +135,7 @@ private fun FileHeaderCardImage( uri = uri, authorName = note.author?.toBestDisplayName(), mimeType = mimeType, + thumbhash = thumbHash, ) }, ) @@ -146,7 +149,7 @@ private fun FileHeaderCardImage( preloadUrls = if (isImage) listOf(fullUrl) else emptyList(), accountViewModel = accountViewModel, modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), - backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, + backdrop = (thumbHash ?: blurHash)?.let { { BlurhashBackdrop(blurHash, content.description, thumbHash) } }, ) { ZoomableContentView( content = content, diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt new file mode 100644 index 0000000000..000a96e617 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.kt @@ -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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 9b1a36cbb6..e439378bab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index e832568c0e..94f4119fe6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt new file mode 100644 index 0000000000..5cde68231f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -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(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) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt new file mode 100644 index 0000000000..83acbd2c7f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoder.kt @@ -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((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) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt new file mode 100644 index 0000000000..eb58f44693 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashEncoderExt.kt @@ -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) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt new file mode 100644 index 0000000000..d374f0b5d6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt @@ -0,0 +1,91 @@ +/* + * 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.assertTrue +import org.junit.Test + +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`() { + assertEquals(null, ThumbHashDecoder.decode(ByteArray(3))) + assertEquals(null, ThumbHashDecoder.decode(null as String?)) + assertEquals(null, ThumbHashDecoder.decode("")) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt new file mode 100644 index 0000000000..0d95691397 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/BitmapUtils.jvm.kt @@ -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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt index 0a72468f17..2076c73096 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopImageLoaderSetup.kt @@ -60,8 +60,10 @@ object DesktopImageLoaderSetup { add(SkiaGifDecoder.Factory()) add(DesktopBase64Fetcher.Factory) add(DesktopBlurHashFetcher.Factory) + add(DesktopThumbHashFetcher.Factory) add(DesktopBase64Fetcher.BKeyer) add(DesktopBlurHashFetcher.BKeyer) + add(DesktopThumbHashFetcher.TKeyer) }.build() private fun newMemoryCache(): MemoryCache { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt new file mode 100644 index 0000000000..bf5f7cc42f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/images/DesktopThumbHashFetcher.kt @@ -0,0 +1,86 @@ +/* + * 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.desktop.service.images + +import androidx.compose.runtime.Stable +import coil3.ImageLoader +import coil3.asImage +import coil3.decode.DataSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.key.Keyer +import coil3.request.Options +import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder + +data class ThumbhashWrapper( + val thumbhash: String, +) + +@Stable +class DesktopThumbHashFetcher( + private val data: ThumbhashWrapper, +) : Fetcher { + override suspend fun fetch(): FetchResult? { + val hash = data.thumbhash + val platformImage = ThumbHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + val bufferedImage = platformImage.toBufferedImage() + val bitmap = bufferedImageToSkiaBitmap(bufferedImage) + + return ImageFetchResult( + image = bitmap.asImage(true), + isSampled = false, + dataSource = DataSource.MEMORY, + ) + } + + object Factory : Fetcher.Factory { + override fun create( + data: ThumbhashWrapper, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = DesktopThumbHashFetcher(data) + } + + object TKeyer : Keyer { + override fun key( + data: ThumbhashWrapper, + options: Options, + ): String = data.thumbhash + } +} + +/** + * Pick the best Coil model for a media placeholder on Desktop. + * + * Prefers [ThumbhashWrapper] when a thumbhash is available (better quality, preserves aspect ratio + * and alpha) and falls back to [BlurhashWrapper] when only a blurhash is present. + */ +fun placeholderModel( + thumbhash: String?, + blurhash: String?, +): Any? = + when { + !thumbhash.isNullOrEmpty() -> ThumbhashWrapper(thumbhash) + !blurhash.isNullOrEmpty() -> BlurhashWrapper(blurhash) + else -> null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt index d43b3cfd36..7981cb3a84 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/DesktopMediaMetadata.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.upload import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File @@ -34,6 +35,7 @@ data class MediaMetadata( val width: Int? = null, val height: Int? = null, val blurhash: String? = null, + val thumbhash: String? = null, ) object DesktopMediaMetadata { @@ -44,6 +46,7 @@ object DesktopMediaMetadata { var width: Int? = null var height: Int? = null var blurhash: String? = null + var thumbhash: String? = null if (mimeType.startsWith("image/")) { try { @@ -51,7 +54,9 @@ object DesktopMediaMetadata { if (image != null) { width = image.width height = image.height - blurhash = image.toPlatformImage().toBlurhash() + val platformImage = image.toPlatformImage() + blurhash = runCatching { platformImage.toBlurhash() }.getOrNull() + thumbhash = runCatching { platformImage.toThumbhash() }.getOrNull() } } catch (_: Exception) { } @@ -64,6 +69,7 @@ object DesktopMediaMetadata { width = width, height = height, blurhash = blurhash, + thumbhash = thumbhash, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 0c955558ee..24f61ea9bd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -358,6 +358,7 @@ private fun buildIMetaTags(results: List): List = props["dim"] = listOf("${meta.width}x${meta.height}") } meta.blurhash?.let { props["blurhash"] = listOf(it) } + meta.thumbhash?.let { props["thumbhash"] = listOf(it) } IMetaTag(url = url, properties = props) } @@ -460,6 +461,7 @@ private fun buildPictureMetas(results: List): List.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) @@ -45,6 +46,8 @@ fun TagArrayBuilder.dimension(dim: DimensionTag) = add(D fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) fun TagArrayBuilder.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt index fc3d0fc90b..7b11a3ec3a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/ProfileGalleryEntryEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -70,6 +71,8 @@ class ProfileGalleryEntryEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt index eb9f0b9fa5..316cd61c9b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/TagArrayBuilderExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @@ -51,6 +52,8 @@ fun TagArrayBuilder.dimension(dim: DimensionTag) = add fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt index 08b80a2303..7f78d36dd1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt @@ -166,6 +166,7 @@ fun buildMip04IMetaTag( nonce: ByteArray, dimensions: String? = null, blurhash: String? = null, + thumbhash: String? = null, ): IMetaTag = IMetaTagBuilder(url) .apply { @@ -176,4 +177,5 @@ fun buildMip04IMetaTag( add(Mip04Fields.VERSION, Mip04MediaEncryption.VERSION) dimensions?.let { add(Mip04Fields.DIMENSIONS, it) } blurhash?.let { add(Mip04Fields.BLURHASH, it) } + thumbhash?.let { add(Mip04Fields.THUMBHASH, it) } }.build() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt index c9b1923727..b1742a042d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.ciphers.AESGCM @@ -65,6 +66,8 @@ class ChatMessageEncryptedFileHeaderEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) + fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse) fun algo() = tags.firstNotNullOfOrNull(EncryptionAlgo::parse) @@ -87,6 +90,7 @@ class ChatMessageEncryptedFileHeaderEvent( size: Int? = null, dimension: DimensionTag? = null, blurhash: String? = null, + thumbhash: String? = null, originalHash: String? = null, magnetUri: String? = null, torrentInfoHash: String? = null, @@ -108,6 +112,7 @@ class ChatMessageEncryptedFileHeaderEvent( mimeType?.let { mimeType(it) } dimension?.let { dimension(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } originalHash?.let { originalHash(it) } magnetUri?.let { magnet(it) } torrentInfoHash?.let { torrentInfohash(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt index c92afc3ae5..858f8723c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/files/TagArrayBuilderExt.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash fun TagArrayBuilder.reply(msg: MarkedETag) = add(msg.toTagArray()) @@ -70,6 +71,8 @@ fun TagArrayBuilder.dimension(dim: Dimensio fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt index 0f45ce95e5..ee2d2b8443 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagBuilderExt.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -56,6 +57,8 @@ fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.t fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) +fun IMetaTagBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.TAG_NAME, thumbhash) + fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt index 8695598d56..8b8a3dc4d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/IMetaTagExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -55,6 +56,8 @@ fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME) fun IMetaTag.blurhash() = properties.get(BlurhashTag.TAG_NAME) +fun IMetaTag.thumbhash() = properties.get(ThumbhashTag.TAG_NAME) + fun IMetaTag.originalHash() = properties.get(OriginalHashTag.TAG_NAME) fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt index 6f8c6b48af..214f3b2ac7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/PictureMeta.kt @@ -36,6 +36,7 @@ data class PictureMeta( val service: String? = null, val fallback: List = emptyList(), val annotations: List = emptyList(), + val thumbhash: String? = null, ) { fun toIMetaArray(): Array = IMetaTagBuilder(url) @@ -46,6 +47,7 @@ data class PictureMeta( size?.let { size(it) } dimension?.let { dims(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } service?.let { service(it) } fallback.forEach { fallback(it) } annotations.forEach { userAnnotations(it) } @@ -55,16 +57,17 @@ data class PictureMeta( companion object { fun parse(iMeta: IMetaTag): PictureMeta = PictureMeta( - iMeta.url, - iMeta.mimeType()?.firstOrNull(), - iMeta.blurhash()?.firstOrNull(), - iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, - iMeta.alt()?.firstOrNull(), - iMeta.hash()?.firstOrNull(), - iMeta.size()?.firstOrNull()?.toIntOrNull(), - iMeta.service()?.firstOrNull(), - iMeta.fallback() ?: emptyList(), - iMeta.userAnnotations() ?: emptyList(), + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + blurhash = iMeta.blurhash()?.firstOrNull(), + dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + alt = iMeta.alt()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + size = iMeta.size()?.firstOrNull()?.toIntOrNull(), + service = iMeta.service()?.firstOrNull(), + fallback = iMeta.fallback() ?: emptyList(), + annotations = iMeta.userAnnotations() ?: emptyList(), + thumbhash = iMeta.thumbhash()?.firstOrNull(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt index de18f8fa74..651df78b08 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip68Picture/TagArrayBuilderExt.kt @@ -39,7 +39,8 @@ fun TagArrayBuilder.pictureIMeta( hash: String? = null, size: Int? = null, alt: String? = null, -) = pictureIMeta(PictureMeta(url, mimeType, blurhash, dimension, alt, hash, size)) + thumbhash: String? = null, +) = pictureIMeta(PictureMeta(url, mimeType, blurhash, dimension, alt, hash, size, thumbhash = thumbhash)) fun TagArrayBuilder.pictureIMeta(imeta: PictureMeta): TagArrayBuilder { add(imeta.toIMetaArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt index e94a0719e2..06672d7b49 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -55,6 +56,8 @@ fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.t fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) +fun IMetaTagBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.TAG_NAME, thumbhash) + fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt index 5436e8e0db..42d0e2addd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -54,6 +55,8 @@ fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME) fun IMetaTag.blurhash() = properties.get(BlurhashTag.TAG_NAME) +fun IMetaTag.thumbhash() = properties.get(ThumbhashTag.TAG_NAME) + fun IMetaTag.originalHash() = properties.get(OriginalHashTag.TAG_NAME) fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt index 05b07b5fb7..1bf7c5a4f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/TagArrayBuilderExt.kt @@ -43,7 +43,8 @@ fun TagArrayBuilder.videoIMeta( hash: String? = null, size: Int? = null, alt: String? = null, -) = videoIMeta(VideoMeta(url, mimeType, blurhash, dimension, alt, hash, size)) + thumbhash: String? = null, +) = videoIMeta(VideoMeta(url, mimeType, blurhash, dimension, alt, hash, size, thumbhash = thumbhash)) fun TagArrayBuilder.videoIMeta(imeta: VideoMeta) = add(imeta.toIMetaArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt index 0475663ea9..66d4d334c0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -35,6 +35,7 @@ data class VideoMeta( val service: String? = null, val fallback: List = emptyList(), val image: List = emptyList(), + val thumbhash: String? = null, ) { fun toIMetaArray(): Array = IMetaTagBuilder(url) @@ -45,6 +46,7 @@ data class VideoMeta( size?.let { size(it) } dimension?.let { dims(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } service?.let { service(it) } fallback.forEach { fallback(it) } image.forEach { image(it) } @@ -54,16 +56,17 @@ data class VideoMeta( companion object { fun parse(iMeta: IMetaTag): VideoMeta = VideoMeta( - iMeta.url, - iMeta.mimeType()?.firstOrNull(), - iMeta.blurhash()?.firstOrNull(), - iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, - iMeta.alt()?.firstOrNull(), - iMeta.hash()?.firstOrNull(), - iMeta.size()?.firstOrNull()?.toIntOrNull(), - iMeta.service()?.firstOrNull(), - iMeta.fallback() ?: emptyList(), - iMeta.image() ?: emptyList(), + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + blurhash = iMeta.blurhash()?.firstOrNull(), + dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + alt = iMeta.alt()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + size = iMeta.size()?.firstOrNull()?.toIntOrNull(), + service = iMeta.service()?.firstOrNull(), + fallback = iMeta.fallback() ?: emptyList(), + image = iMeta.image() ?: emptyList(), + thumbhash = iMeta.thumbhash()?.firstOrNull(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt index e48132cc88..cea911d9d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/FileHeaderEvent.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -69,6 +70,8 @@ class FileHeaderEvent( fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) + fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse) + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) @@ -106,6 +109,7 @@ class FileHeaderEvent( size: Int? = null, dimension: DimensionTag? = null, blurhash: String? = null, + thumbhash: String? = null, originalHash: String? = null, magnetUri: String? = null, torrentInfoHash: String? = null, @@ -120,6 +124,7 @@ class FileHeaderEvent( mimeType?.let { mimeType(it) } dimension?.let { dimension(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } originalHash?.let { originalHash(it) } magnetUri?.let { magnet(it) } torrentInfoHash?.let { torrentInfohash(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt index a625b48290..1fc629dcf2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/IMetaTagBuilderExt.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash /** @@ -55,6 +56,8 @@ fun IMetaTagBuilder.dims(dims: DimensionTag) = add(DimensionTag.TAG_NAME, dims.t fun IMetaTagBuilder.blurhash(blurhash: String) = add(BlurhashTag.TAG_NAME, blurhash) +fun IMetaTagBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.TAG_NAME, thumbhash) + fun IMetaTagBuilder.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt index d58c700df7..e000583d6e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/TagArrayBuilderExt.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag @@ -49,6 +50,8 @@ fun TagArrayBuilder.dimension(dim: DimensionTag) = add(Dimensio fun TagArrayBuilder.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) +fun TagArrayBuilder.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash)) + fun TagArrayBuilder.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt new file mode 100644 index 0000000000..d8d335f686 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/ThumbhashTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.nip94FileMetadata.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ThumbhashTag { + companion object { + const val TAG_NAME = "thumbhash" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(hash: String) = arrayOf(TAG_NAME, hash) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt index 1c2e96d48c..44710a314c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip99Classifieds/ProductImageMeta.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip68Picture.dims import com.vitorpamplona.quartz.nip68Picture.hash import com.vitorpamplona.quartz.nip68Picture.mimeType import com.vitorpamplona.quartz.nip68Picture.size +import com.vitorpamplona.quartz.nip68Picture.thumbhash import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -38,6 +39,7 @@ data class ProductImageMeta( val alt: String? = null, val hash: String? = null, val size: Int? = null, + val thumbhash: String? = null, ) { fun toIMeta(): IMetaTag = IMetaTagBuilder(url) @@ -48,6 +50,7 @@ data class ProductImageMeta( size?.let { size(it) } dimension?.let { dims(it) } blurhash?.let { blurhash(it) } + thumbhash?.let { thumbhash(it) } }.build() fun toIMetaArray(): Array = toIMeta().toTagArray() @@ -55,13 +58,14 @@ data class ProductImageMeta( companion object { fun parse(iMeta: IMetaTag): ProductImageMeta = ProductImageMeta( - iMeta.url, - iMeta.mimeType()?.firstOrNull(), - iMeta.blurhash()?.firstOrNull(), - iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, - iMeta.alt()?.firstOrNull(), - iMeta.hash()?.firstOrNull(), - iMeta.size()?.firstOrNull()?.toIntOrNull(), + url = iMeta.url, + mimeType = iMeta.mimeType()?.firstOrNull(), + blurhash = iMeta.blurhash()?.firstOrNull(), + dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, + alt = iMeta.alt()?.firstOrNull(), + hash = iMeta.hash()?.firstOrNull(), + size = iMeta.size()?.firstOrNull()?.toIntOrNull(), + thumbhash = iMeta.thumbhash()?.firstOrNull(), ) } } From 63da850e3be2d6f78049fc026f5778982f32ec0c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:33:26 +0000 Subject: [PATCH 2/2] perf(thumbhash): cache cosine tables and flatten hot loop in decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference port recomputed the inverse DCT cosine tables once per output pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change lifts the tables out of the inner loop and caches them across decodes keyed by (size, componentCount) so subsequent placeholders at the same target size skip cosine evaluation entirely. Additional wins: - AC coefficients unpack into pre-sized DoubleArrays (no ArrayList boxing, no post-hoc copy) - truncated hashes are rejected up-front via a single length check instead of mid-stream - LPQA -> sRGB uses an inline branch clamp instead of a min/max/round/coerceIn chain Tests: 12 unit tests cover determinism across repeated decodes, cache clearing, alpha preservation, aspect-ratio preservation both directions, average-color drift bounds, truncated input rejection, and round-tripping base64 vs. raw bytes. All green. Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode, warm- and cold-cache decode, aspect-ratio probe, and the full decodeKeepAspectRatio pipeline so the perf delta is visible on device. --- .../amethyst/benchmark/ThumbHashBenchmark.kt | 156 +++++++++ .../commons/thumbhash/ThumbHashDecoder.kt | 327 +++++++++++++----- .../amethyst/commons/ThumbHashTest.kt | 166 ++++++++- 3 files changed, 557 insertions(+), 92 deletions(-) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt new file mode 100644 index 0000000000..e7aaa4d56e --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/ThumbHashBenchmark.kt @@ -0,0 +1,156 @@ +/* + * 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashDecoder +import com.vitorpamplona.amethyst.commons.thumbhash.ThumbHashEncoder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +@RunWith(AndroidJUnit4::class) +class ThumbHashBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + // Representative opaque landscape hash. Produced by encoding a smooth + // 32x24 warm gradient so the AC coefficients exercise the full L block. + private val warmLandscape = + run { + val w = 32 + val h = 24 + val pixels = + IntArray(w * h) { i -> + val x = i % w + val y = i / w + val r = (160 + (x * 3)) and 0xff + val g = (90 + (y * 4)) and 0xff + val b = (40 + ((x + y) * 2)) and 0xff + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + ThumbHashEncoder.encodeToBase64(pixels, w, h) + } + + // Representative alpha hash. Radial alpha vignette forces the alpha DCT + // block to carry real energy so decode cost matches production inputs. + private val alphaPortrait = + run { + val w = 24 + val h = 32 + val pixels = + IntArray(w * h) { i -> + val x = i % w + val y = i / w + val dx = x - w / 2 + val dy = y - h / 2 + val dist = kotlin.math.sqrt((dx * dx + dy * dy).toDouble()) + val maxDist = kotlin.math.sqrt((w * w / 4 + h * h / 4).toDouble()) + val alpha = (255 * (1.0 - (dist / maxDist).coerceIn(0.0, 1.0))).toInt() + (alpha shl 24) or (0x40 shl 16) or (0x80 shl 8) or 0xC0 + } + ThumbHashEncoder.encodeToBase64(pixels, w, h) + } + + private val warmBytes = Base64.decode(padded(warmLandscape)) + private val alphaBytes = Base64.decode(padded(alphaPortrait)) + + private fun padded(s: String): String { + val r = s.length % 4 + return if (r == 0) s else s + "=".repeat(4 - r) + } + + @Test + fun testAspectRatioFromBase64() { + // Warm up + ThumbHashDecoder.aspectRatio(warmLandscape) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.aspectRatio(warmLandscape) + } + } + + @Test + fun testAspectRatioFromBytes() { + ThumbHashDecoder.aspectRatio(warmBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.aspectRatio(warmBytes) + } + } + + @Test + fun testDecodeOpaqueBytes() { + // Warm up the cosine cache for this size. + ThumbHashDecoder.decode(warmBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decode(warmBytes) + } + } + + @Test + fun testDecodeWithAlphaBytes() { + ThumbHashDecoder.decode(alphaBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decode(alphaBytes) + } + } + + @Test + fun testDecodeOpaqueBase64() { + ThumbHashDecoder.decode(warmLandscape) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decode(warmLandscape) + } + } + + /** + * Measures decode cost when the cosine cache is cold on every call. + * This is the realistic "first time we see a new output size" cost; the + * cached case above represents steady-state feed scrolling. + */ + @Test + fun testDecodeOpaqueColdCache() { + ThumbHashDecoder.decode(warmBytes) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.clearCache() + ThumbHashDecoder.decode(warmBytes) + } + } + + @Test + fun testDecodeKeepAspectRatio() { + ThumbHashDecoder.decodeKeepAspectRatio(warmLandscape, 32) + + benchmarkRule.measureRepeated { + ThumbHashDecoder.decodeKeepAspectRatio(warmLandscape, 32) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt index 5cde68231f..30bc6fd3b0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/thumbhash/ThumbHashDecoder.kt @@ -26,16 +26,63 @@ 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. + * (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` 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() + 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. */ @@ -67,7 +114,20 @@ object ThumbHashDecoder { 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. @@ -106,100 +166,106 @@ object ThumbHashDecoder { 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) - fun readAc( - nx: Int, - ny: Int, - scale: Double, - ): DoubleArray { - val ac = ArrayList(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) - + // 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) - 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) + // 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) { - var lVal = lDc - var pVal = pDc - var qVal = qDc - var aVal = aDc + val cosXLBase = x * lx + val cosXPQBase = x * 3 + val cosXABase = x * 5 - 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) + var l = lDc + var p = pDc + var q = qDc + var a = aDc - // 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++ + // 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 & 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++ + // 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++ } - // A + // Alpha channel if (hasAlpha) { - var cy = 0 - var j = 0 + j = 0 + cy = 0 while (cy < 5) { + val fyA2 = cosYA[cosYABase + cy] * 2.0 var cx = if (cy != 0) 0 else 1 - val fy2 = fy[cy] * 2.0 - while (cx < 5 - cy) { - aVal += aAc[j] * fx[cx] * fy2 + val cxLimit = 5 - cy + while (cx < cxLimit) { + a += aAc[j] * cosXA[cosXABase + cx] * fyA2 j++ cx++ } @@ -207,15 +273,15 @@ object ThumbHashDecoder { } } - // 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 + // 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 } } @@ -236,12 +302,13 @@ object ThumbHashDecoder { } /** - * 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. + * 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, @@ -250,6 +317,88 @@ object ThumbHashDecoder { 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) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt index d374f0b5d6..a152963ec9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/ThumbHashTest.kt @@ -24,8 +24,10 @@ 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 @@ -84,8 +86,166 @@ class ThumbHashTest { @Test fun `decoding a malformed hash returns null`() { - assertEquals(null, ThumbHashDecoder.decode(ByteArray(3))) - assertEquals(null, ThumbHashDecoder.decode(null as String?)) - assertEquals(null, ThumbHashDecoder.decode("")) + 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, + ) } }