From b2f297b3bb6e05aa8b8c1dc0ab99030bfa53c237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 00:12:28 +0000 Subject: [PATCH] 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 2a5d19abc..c12df587e 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 31b4e782f..f7b25bee4 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 000000000..31c6cc137 --- /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 efb7cef5e..6d8cf5382 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 79427a04b..93c28ad5a 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 911a33da9..0853058a6 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 ef3b0aea1..5d8aed97c 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 c5ac44b9f..6dce74422 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 2950a3fc0..b17e3ba8a 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 ec1d6db0a..a40e19f2f 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 85e57f6d4..fb3de24b4 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 1557661c0..e9d03260e 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 2e5b91c9a..2cc5c9f72 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 e68aebcc5..dea8fee72 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 9b6eedabb..4ad82bbff 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 29cb0937a..b15e37e23 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 6730e72c9..3015959a2 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 03ce5be92..1c2399218 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 63cb577eb..0abc16ac9 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 77095e393..9bee04cbe 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 79790ad5e..d4c5464fe 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 b9f24db75..b16d14226 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 e6e058d70..1883e9390 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 279142002..b8ff848e4 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 716be8b86..7b4598158 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 4337bffa8..b621b520f 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 6e484ace4..ee865aea3 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 c49d58960..608fdc5cf 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 8bfb0e0af..a5bc0c568 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 bf88579e0..fc197d120 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 d7aa97489..26322c6de 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 fce93165f..66f06d75e 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 11385dd60..d4681cbdd 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 40e49b466..679295e5d 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 2efc3243a..24f8d7947 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 000000000..000a96e61 --- /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 9b1a36cbb..e439378ba 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 e832568c0..94f4119fe 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 000000000..5cde68231 --- /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 000000000..83acbd2c7 --- /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 000000000..eb58f4469 --- /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 000000000..d374f0b5d --- /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 000000000..0d9569139 --- /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 0a72468f1..2076c7309 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 000000000..bf5f7cc42 --- /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 d43b3cfd3..7981cb3a8 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 0c955558e..24f61ea9b 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 fc3d0fc90..7b11a3ec3 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 eb9f0b9fa..316cd61c9 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 08b80a230..7f78d36dd 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 c9b192372..b1742a042 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 c92afc3ae..858f8723c 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 0f45ce95e..ee2d2b844 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 8695598d5..8b8a3dc4d 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 6f8c6b48a..214f3b2ac 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 de18f8fa7..651df78b0 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 e94a0719e..06672d7b4 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 5436e8e0d..42d0e2add 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 05b07b5fb..1bf7c5a4f 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 0475663ea..66d4d334c 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 e48132cc8..cea911d9d 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 a625b4829..1fc629dcf 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 d58c700df..e000583d6 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 000000000..d8d335f68 --- /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 1c2e96d48..44710a314 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(), ) } }