feat: add ThumbHash support alongside BlurHash across events, uploads, and UI

Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.

- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
  71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
  blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
  small placeholderModel(thumbhash, blurhash) helper centralising the
  "prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
  calculator decodes the bitmap / video thumbnail once and runs both
  encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
  both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
  Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
This commit is contained in:
Claude
2026-04-20 00:12:28 +00:00
parent 2d5097e8b0
commit b2f297b3bb
68 changed files with 1203 additions and 120 deletions
@@ -228,6 +228,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.magnet
import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
@@ -249,6 +250,8 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException 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) @OptIn(DelicateCoroutinesApi::class)
@Stable @Stable
@@ -1135,6 +1138,7 @@ class Account(
headerInfo.mimeType?.let { mimeType(it) } headerInfo.mimeType?.let { mimeType(it) }
headerInfo.dim?.let { dimension(it) } headerInfo.dim?.let { dimension(it) }
headerInfo.blurHash?.let { blurhash(it.blurhash) } headerInfo.blurHash?.let { blurhash(it.blurhash) }
headerInfo.thumbHash?.let { nip95thumbhash(it.thumbhash) }
contentWarningReason?.let { contentWarning(contentWarningReason) } contentWarningReason?.let { contentWarning(contentWarningReason) }
} }
@@ -1219,16 +1223,17 @@ class Account(
val iMetas = val iMetas =
urlHeaderInfo.map { urlHeaderInfo.map {
PictureMeta( PictureMeta(
it.key, url = it.key,
it.value.mimeType, mimeType = it.value.mimeType,
it.value.blurHash?.blurhash, blurhash = it.value.blurHash?.blurhash,
it.value.dim, dimension = it.value.dim,
caption, alt = caption,
it.value.hash, hash = it.value.hash,
it.value.size, size = it.value.size,
null, service = null,
emptyList(), fallback = emptyList(),
emptyList(), annotations = emptyList(),
thumbhash = it.value.thumbHash?.thumbhash,
) )
} }
@@ -1271,13 +1276,14 @@ class Account(
quotes(findNostrUris(it)) quotes(findNostrUris(it))
} }
pictureIMeta( pictureIMeta(
url, url = url,
headerInfo.mimeType, mimeType = headerInfo.mimeType,
headerInfo.blurHash?.blurhash, blurhash = headerInfo.blurHash?.blurhash,
headerInfo.dim, dimension = headerInfo.dim,
headerInfo.hash, hash = headerInfo.hash,
headerInfo.size, size = headerInfo.size,
alt, alt = alt,
thumbhash = headerInfo.thumbHash?.thumbhash,
) )
// add zap splits // add zap splits
// add zap raiser // add zap raiser
@@ -1295,6 +1301,7 @@ class Account(
dimension = headerInfo.dim, dimension = headerInfo.dim,
blurhash = headerInfo.blurHash?.blurhash, blurhash = headerInfo.blurHash?.blurhash,
alt = alt, alt = alt,
thumbhash = headerInfo.thumbHash?.thumbhash,
) )
if (headerInfo.dim.height > headerInfo.dim.width) { if (headerInfo.dim.height > headerInfo.dim.width) {
@@ -1314,6 +1321,7 @@ class Account(
headerInfo.mimeType?.let { mimeType(it) } headerInfo.mimeType?.let { mimeType(it) }
headerInfo.dim?.let { dimension(it) } headerInfo.dim?.let { dimension(it) }
headerInfo.blurHash?.let { blurhash(it.blurhash) } headerInfo.blurHash?.let { blurhash(it.blurhash) }
headerInfo.thumbHash?.let { thumbhash(it.thumbhash) }
originalHash?.let { originalHash(it) } originalHash?.let { originalHash(it) }
magnetUri?.let { magnet(it) } magnetUri?.let { magnet(it) }
@@ -2164,6 +2172,7 @@ class Account(
dim: DimensionTag?, dim: DimensionTag?,
hash: String?, hash: String?,
mimeType: String?, mimeType: String?,
thumbhash: String? = null,
) { ) {
val template = val template =
ProfileGalleryEntryEvent.build(url) { ProfileGalleryEntryEvent.build(url) {
@@ -2172,6 +2181,7 @@ class Account(
mimeType?.let { mimeType(it) } mimeType?.let { mimeType(it) }
dim?.let { dimension(it) } dim?.let { dimension(it) }
blurhash?.let { blurhash(it) } blurhash?.let { blurhash(it) }
thumbhash?.let { galleryThumbhash(it) }
} }
val event = signer.sign(template) val event = signer.sign(template)
@@ -83,10 +83,12 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory()) add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory) add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory) add(BlurHashFetcher.Factory)
add(ThumbHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory)) add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope)) add(ProfilePictureFetcher.Factory(thumbnailCache, callFactory, backgroundScope))
add(Base64Fetcher.BKeyer) add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer) add(BlurHashFetcher.BKeyer)
add(ThumbHashFetcher.TKeyer)
add(ProfilePictureFetcher.BKeyer) add(ProfilePictureFetcher.BKeyer)
add(OkHttpFactory(callFactory)) add(OkHttpFactory(callFactory))
}.build(), }.build(),
@@ -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<ThumbhashWrapper> {
override fun create(
data: ThumbhashWrapper,
options: Options,
imageLoader: ImageLoader,
): Fetcher = ThumbHashFetcher(options, data)
}
object TKeyer : Keyer<ThumbhashWrapper> {
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
}
@@ -68,6 +68,7 @@ fun VideoView(
onDialog: (() -> Unit)? = null, onDialog: (() -> Unit)? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false, alwaysShowVideo: Boolean = false,
thumbhash: String? = null,
) { ) {
val borderModifier = val borderModifier =
if (roundedCorner) { if (roundedCorner) {
@@ -78,7 +79,7 @@ fun VideoView(
Modifier 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 @Composable
@@ -99,6 +100,7 @@ fun VideoView(
alwaysShowVideo: Boolean = false, alwaysShowVideo: Boolean = false,
showControls: Boolean = true, showControls: Boolean = true,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
thumbhash: String? = null,
) { ) {
val automaticallyStartPlayback = val automaticallyStartPlayback =
remember { remember {
@@ -107,7 +109,7 @@ fun VideoView(
) )
} }
if (blurhash == null) { if (blurhash == null && thumbhash == null) {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier = val modifier =
@@ -154,12 +156,13 @@ fun VideoView(
} }
Box(modifier, contentAlignment = Alignment.Center) { 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( DisplayBlurHash(
blurhash, blurhash,
null, null,
contentScale, contentScale,
if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier, if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
thumbhash = thumbhash,
) )
if (!automaticallyStartPlayback.value) { if (!automaticallyStartPlayback.value) {
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads
import android.media.MediaDataSource import android.media.MediaDataSource
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper 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.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -36,6 +37,7 @@ class FileHeader(
val size: Int, val size: Int,
val dim: DimensionTag?, val dim: DimensionTag?,
val blurHash: BlurhashWrapper?, val blurHash: BlurhashWrapper?,
val thumbHash: ThumbhashWrapper? = null,
) { ) {
class UnableToDownload( class UnableToDownload(
val fileUrl: String, val fileUrl: String,
@@ -71,9 +73,9 @@ class FileHeader(
val hash = sha256(data).toHexKey() val hash = sha256(data).toHexKey()
val size = data.size 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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
Log.e("ImageDownload") { "Couldn't convert image in to File Header: ${e.message}" } Log.e("ImageDownload") { "Couldn't convert image in to File Header: ${e.message}" }
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.uploads package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper 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.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -45,12 +46,15 @@ data class MediaUploadResult(
val ipfs: String? = null, val ipfs: String? = null,
// blurhash value for previews // blurhash value for previews
val blurHash: BlurhashWrapper? = null, val blurHash: BlurhashWrapper? = null,
// thumbhash value for previews (preferred over blurhash in the UI)
val thumbHash: ThumbhashWrapper? = null,
) { ) {
fun mergeLocalMetadata(localMetadata: Pair<BlurhashWrapper?, DimensionTag?>?): MediaUploadResult = fun mergeLocalMetadata(localMetadata: PreviewHashes?): MediaUploadResult =
localMetadata?.let { (blur, dim) -> localMetadata?.let { hashes ->
copy( copy(
dimension = dim ?: dimension, dimension = hashes.dim ?: dimension,
blurHash = blur ?: blurHash, blurHash = hashes.blurhash ?: blurHash,
thumbHash = hashes.thumbhash ?: thumbHash,
) )
} ?: this } ?: this
} }
@@ -26,11 +26,28 @@ import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash 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.BlurhashWrapper
import com.vitorpamplona.amethyst.service.images.ThumbhashWrapper
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.Log 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 isImage(mimeType: String?) = mimeType?.startsWith("image/", ignoreCase = true) == true
private fun isVideo(mimeType: String?) = mimeType?.startsWith("video/", 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 inPreferredConfig = Bitmap.Config.ARGB_8888
} }
private fun processImage(
bitmap: Bitmap?,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> {
val (blur, dim) = processBitmap(bitmap)
return blur to (dim ?: dimPrecomputed)
}
fun computeFromBytes( fun computeFromBytes(
data: ByteArray, data: ByteArray,
mimeType: String?, mimeType: String?,
dimPrecomputed: DimensionTag?, dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> = ): PreviewHashes =
when { when {
isImage(mimeType) -> { isImage(mimeType) -> {
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions()) val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions())
@@ -72,7 +81,7 @@ object BlurhashMetadataCalculator {
} }
else -> { else -> {
null to dimPrecomputed PreviewHashes(dim = dimPrecomputed)
} }
} }
@@ -81,7 +90,7 @@ object BlurhashMetadataCalculator {
uri: Uri, uri: Uri,
mimeType: String?, mimeType: String?,
dimPrecomputed: DimensionTag? = null, dimPrecomputed: DimensionTag? = null,
): Pair<BlurhashWrapper?, DimensionTag?>? { ): PreviewHashes? {
if (!shouldAttempt(mimeType)) return null if (!shouldAttempt(mimeType)) return null
return try { return try {
@@ -90,7 +99,7 @@ object BlurhashMetadataCalculator {
context.contentResolver.openInputStream(uri)?.use { stream -> context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions()) val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions())
processImage(bitmap, dimPrecomputed) processImage(bitmap, dimPrecomputed)
} ?: (null to dimPrecomputed) } ?: PreviewHashes(dim = dimPrecomputed)
} }
isVideo(mimeType) -> { isVideo(mimeType) -> {
@@ -108,33 +117,44 @@ object BlurhashMetadataCalculator {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e) Log.w("PreviewMetadataCalc", "Failed to compute metadata from uri", e)
null null
} }
} }
private fun processBitmap(bitmap: Bitmap?): Pair<BlurhashWrapper?, DimensionTag?> = 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) { if (bitmap != null) {
try { try {
val blurhash = BlurhashWrapper(bitmap.toBlurhash()) val blurhash = runCatching { BlurhashWrapper(bitmap.toBlurhash()) }.getOrNull()
blurhash to DimensionTag(bitmap.width, bitmap.height) val thumbhash = runCatching { ThumbhashWrapper(bitmap.toThumbhash()) }.getOrNull()
PreviewHashes(
blurhash = blurhash,
thumbhash = thumbhash,
dim = DimensionTag(bitmap.width, bitmap.height),
)
} finally { } finally {
bitmap.recycle() bitmap.recycle()
} }
} else { } else {
null to null PreviewHashes.EMPTY
} }
private fun processRetriever( private fun processRetriever(
retriever: MediaMetadataRetriever, retriever: MediaMetadataRetriever,
dimPrecomputed: DimensionTag?, dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> { ): PreviewHashes {
val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) } val thumb = retriever.getThumbnail()
return if (dim?.hasSize() == true) { val hashes = processBitmap(thumb)
blurhash to dim val finalDim = if (dim?.hasSize() == true) dim else hashes.dim
} else { return hashes.copy(dim = finalDim)
blurhash to null
}
} }
} }
@@ -28,8 +28,8 @@ import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread 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.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -95,7 +95,7 @@ class BlossomUploader {
hashBytes.toHexKey() to totalBytes hashBytes.toHexKey() to totalBytes
} }
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) val localMetadata = PreviewMetadataCalculator.computeFromUri(context, uri, myContentType)
val imageInputStream = contentResolver.openInputStream(uri) val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" } checkNotNull(imageInputStream) { "Can't open the image input stream" }
@@ -30,8 +30,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread 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.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
@@ -107,7 +107,7 @@ class Nip96Uploader {
val myContentType = contentType ?: contentResolver.getType(uri) val myContentType = contentType ?: contentResolver.getType(uri)
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0 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) val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" } checkNotNull(imageInputStream) { "Can't open the image input stream" }
@@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -233,6 +234,8 @@ open class EditPostViewModel : ViewModel() {
?.let { dims(it) } ?.let { dims(it) }
state.result.fileHeader.blurHash state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) } state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) } state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) } alt?.let { alt(it) }
@@ -207,14 +207,16 @@ fun ContentWarningNoteWithBigReasonPreview() {
@Composable @Composable
fun BlurhashBackdrop( fun BlurhashBackdrop(
blurhash: String, blurhash: String?,
description: String?, description: String?,
thumbhash: String? = null,
) { ) {
DisplayBlurHash( DisplayBlurHash(
blurhash = blurhash, blurhash = blurhash,
description = description, description = description,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
thumbhash = thumbhash,
) )
} }
@@ -222,12 +224,13 @@ fun BlurhashBackdrop(
fun BlurhashGridBackdrop(media: List<MediaUrlImage>) { fun BlurhashGridBackdrop(media: List<MediaUrlImage>) {
AutoNonlazyGrid(media.size) { idx -> AutoNonlazyGrid(media.size) { idx ->
val item = media[idx] val item = media[idx]
if (item.blurhash != null) { if (item.blurhash != null || item.thumbhash != null) {
DisplayBlurHash( DisplayBlurHash(
blurhash = item.blurhash, blurhash = item.blurhash,
description = item.description, description = item.description,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
thumbhash = item.thumbhash,
) )
} }
} }
@@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache 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.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -162,7 +161,7 @@ fun ZoomableContentView(
preloadUrls = listOf(content.url), preloadUrls = listOf(content.url),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale), 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 -> TwoSecondController(content) { controllerVisible ->
val mainImageModifier = val mainImageModifier =
@@ -184,7 +183,7 @@ fun ZoomableContentView(
preloadUrls = emptyList(), preloadUrls = emptyList(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale), 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( Box(
modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier),
@@ -203,6 +202,7 @@ fun ZoomableContentView(
nostrUriCallback = content.uri, nostrUriCallback = content.uri,
onDialog = { dialogOpen = true }, onDialog = { dialogOpen = true },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
thumbhash = content.thumbhash,
) )
} }
} }
@@ -330,13 +330,14 @@ fun LocalImageView(
when (state) { when (state) {
is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Loading,
-> { -> {
if (content.blurhash != null) { if (content.blurhash != null || content.thumbhash != null) {
if (ratio != null) { if (ratio != null) {
DisplayBlurHash( DisplayBlurHash(
content.blurhash, content.blurhash,
content.description, content.description,
contentScale, contentScale,
loadedImageModifier.aspectRatio(ratio), loadedImageModifier.aspectRatio(ratio),
thumbhash = content.thumbhash,
) )
} else { } else {
DisplayBlurHash( DisplayBlurHash(
@@ -344,6 +345,7 @@ fun LocalImageView(
content.description, content.description,
contentScale, contentScale,
loadedImageModifier, loadedImageModifier,
thumbhash = content.thumbhash,
) )
} }
} else { } else {
@@ -389,7 +391,7 @@ fun LocalImageView(
} }
} }
} else { } else {
if (content.blurhash != null && ratio != null) { if ((content.blurhash != null || content.thumbhash != null) && ratio != null) {
DisplayBlurHash( DisplayBlurHash(
content.blurhash, content.blurhash,
content.description, content.description,
@@ -397,6 +399,7 @@ fun LocalImageView(
loadedImageModifier loadedImageModifier
.aspectRatio(ratio) .aspectRatio(ratio)
.clickable { showImage.value = true }, .clickable { showImage.value = true },
thumbhash = content.thumbhash,
) )
IconButton( IconButton(
modifier = Modifier.size(Size75dp), modifier = Modifier.size(Size75dp),
@@ -460,7 +463,7 @@ fun UrlImageView(
when (state) { when (state) {
is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Loading,
-> { -> {
if (content.blurhash != null) { if (content.blurhash != null || content.thumbhash != null) {
if (ratio != null) { if (ratio != null) {
val modifier = val modifier =
if (contentScale == ContentScale.Crop) { if (contentScale == ContentScale.Crop) {
@@ -474,6 +477,7 @@ fun UrlImageView(
content.description, content.description,
ContentScale.Crop, ContentScale.Crop,
modifier, modifier,
thumbhash = content.thumbhash,
) )
} else { } else {
DisplayBlurHash( DisplayBlurHash(
@@ -481,6 +485,7 @@ fun UrlImageView(
content.description, content.description,
ContentScale.Crop, ContentScale.Crop,
loadedImageModifier, loadedImageModifier,
thumbhash = content.thumbhash,
) )
} }
} else { } else {
@@ -515,7 +520,7 @@ fun UrlImageView(
} }
} }
} else { } else {
if (content.blurhash != null && ratio != null) { if ((content.blurhash != null || content.thumbhash != null) && ratio != null) {
val modifier = val modifier =
if (contentScale == ContentScale.Crop) { if (contentScale == ContentScale.Crop) {
loadedImageModifier.clickable { showImage.value = true } loadedImageModifier.clickable { showImage.value = true }
@@ -528,6 +533,7 @@ fun UrlImageView(
content.description, content.description,
contentScale, contentScale,
modifier, modifier,
thumbhash = content.thumbhash,
) )
IconButton( IconButton(
modifier = Modifier.size(Size75dp), modifier = Modifier.size(Size75dp),
@@ -786,11 +792,14 @@ fun DisplayBlurHash(
description: String?, description: String?,
contentScale: ContentScale, contentScale: ContentScale,
modifier: Modifier, modifier: Modifier,
thumbhash: String? = null,
) { ) {
if (blurhash == null) return val model =
com.vitorpamplona.amethyst.service.images
.placeholderModel(thumbhash, blurhash) ?: return
AsyncImage( AsyncImage(
model = BlurhashWrapper(blurhash), model = model,
contentDescription = description, contentDescription = description,
contentScale = contentScale, contentScale = contentScale,
modifier = modifier, modifier = modifier,
@@ -109,6 +109,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -543,6 +544,8 @@ open class CommentPostViewModel :
?.let { dims(it) } ?.let { dims(it) }
state.result.fileHeader.blurHash state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) } state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) } state.result.uploadedHash?.let { originalHash(it) }
@@ -70,6 +70,7 @@ fun RenderClassifieds(
dim = it.dimension, dim = it.dimension,
uri = note.toNostrUri(), uri = note.toNostrUri(),
mimeType = it.mimeType, mimeType = it.mimeType,
thumbhash = it.thumbhash,
) )
} }
val title = noteEvent.title() val title = noteEvent.title()
@@ -49,6 +49,7 @@ fun FileHeaderDisplay(
val content by val content by
remember(note) { remember(note) {
val blurHash = event.blurhash() val blurHash = event.blurhash()
val thumbHash = event.thumbhash()
val hash = event.hash() val hash = event.hash()
val dimensions = event.dimensions() val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt() val description = event.content.ifEmpty { null } ?: event.alt()
@@ -66,6 +67,7 @@ fun FileHeaderDisplay(
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
thumbhash = thumbHash,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -77,6 +79,7 @@ fun FileHeaderDisplay(
uri = uri, uri = uri,
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
mimeType = mimeType, mimeType = mimeType,
thumbhash = thumbHash,
) )
}, },
) )
@@ -76,6 +76,7 @@ private fun ObserverAndRenderNIP95(
val uri = header.toNostrUri() val uri = header.toNostrUri()
val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) } val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) }
val blurHash = eventHeader.blurhash() val blurHash = eventHeader.blurhash()
val thumbHash = eventHeader.thumbhash()
val dimensions = eventHeader.dimensions() val dimensions = eventHeader.dimensions()
val description = eventHeader.alt() ?: eventHeader.content val description = eventHeader.alt() ?: eventHeader.content
val mimeType = eventHeader.mimeType() val mimeType = eventHeader.mimeType()
@@ -90,6 +91,7 @@ private fun ObserverAndRenderNIP95(
blurhash = blurHash, blurhash = blurHash,
isVerified = true, isVerified = true,
uri = uri, uri = uri,
thumbhash = thumbHash,
) )
} else { } else {
MediaLocalVideo( MediaLocalVideo(
@@ -100,6 +102,8 @@ private fun ObserverAndRenderNIP95(
isVerified = true, isVerified = true,
uri = uri, uri = uri,
authorName = header.author?.toBestDisplayName(), authorName = header.author?.toBestDisplayName(),
blurhash = blurHash,
thumbhash = thumbHash,
) )
} }
@@ -85,6 +85,7 @@ fun PictureDisplay(
dim = it.dimension, dim = it.dimension,
uri = uri, uri = uri,
mimeType = it.mimeType, mimeType = it.mimeType,
thumbhash = it.thumbhash,
) )
}.toImmutableList(), }.toImmutableList(),
) )
@@ -116,7 +117,7 @@ fun PictureDisplay(
preloadUrls = listOf(first.url), preloadUrls = listOf(first.url),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), 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( ZoomableContentView(
content = first, content = first,
@@ -103,6 +103,7 @@ fun VideoDisplay(
dim = imeta.dimension, dim = imeta.dimension,
uri = uri, uri = uri,
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -115,6 +116,7 @@ fun VideoDisplay(
artworkUri = imeta.image.firstOrNull(), artworkUri = imeta.image.firstOrNull(),
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
blurhash = imeta.blurhash, blurhash = imeta.blurhash,
thumbhash = imeta.thumbhash,
) )
}, },
) )
@@ -71,6 +71,7 @@ fun JustVideoDisplay(
dim = imeta.dimension, dim = imeta.dimension,
uri = note.toNostrUri(), uri = note.toNostrUri(),
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -82,6 +83,7 @@ fun JustVideoDisplay(
uri = note.toNostrUri(), uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
}, },
) )
@@ -95,7 +97,7 @@ fun JustVideoDisplay(
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale), 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( ZoomableContentView(
content = content, content = content,
@@ -79,6 +79,7 @@ fun RenderEncryptedFile(
encryptionAlgo = algo, encryptionAlgo = algo,
encryptionKey = key, encryptionKey = key,
encryptionNonce = nonce, encryptionNonce = nonce,
thumbhash = noteEvent.thumbhash(),
) )
} else { } else {
EncryptedMediaUrlVideo( EncryptedMediaUrlVideo(
@@ -93,6 +94,7 @@ fun RenderEncryptedFile(
encryptionAlgo = algo, encryptionAlgo = algo,
encryptionKey = key, encryptionKey = key,
encryptionNonce = nonce, encryptionNonce = nonce,
thumbhash = noteEvent.thumbhash(),
) )
}, },
) )
@@ -139,6 +139,7 @@ private fun RenderMip04Content(
encryptionAlgo = meta.version, encryptionAlgo = meta.version,
encryptionKey = exporterSecret, encryptionKey = exporterSecret,
encryptionNonce = meta.nonceBytes, encryptionNonce = meta.nonceBytes,
thumbhash = meta.thumbhash,
) )
} else { } else {
EncryptedMediaUrlVideo( EncryptedMediaUrlVideo(
@@ -153,6 +154,7 @@ private fun RenderMip04Content(
encryptionAlgo = meta.version, encryptionAlgo = meta.version,
encryptionKey = exporterSecret, encryptionKey = exporterSecret,
encryptionNonce = meta.nonceBytes, encryptionNonce = meta.nonceBytes,
thumbhash = meta.thumbhash,
) )
}, },
) )
@@ -43,6 +43,7 @@ class MarmotFileSender(
nonce = upload.nonce, nonce = upload.nonce,
dimensions = upload.dimensions, dimensions = upload.dimensions,
blurhash = upload.blurhash, blurhash = upload.blurhash,
thumbhash = upload.thumbhash,
) )
accountViewModel.sendMarmotGroupMediaMessage( accountViewModel.sendMarmotGroupMediaMessage(
@@ -43,6 +43,7 @@ class Mip04UploadResult(
val dimensions: String?, val dimensions: String?,
val blurhash: String?, val blurhash: String?,
val caption: String?, val caption: String?,
val thumbhash: String? = null,
) )
/** /**
@@ -102,6 +103,7 @@ class MarmotFileUploader(
dimensions = serverResult.fileHeader.dim?.toString(), dimensions = serverResult.fileHeader.dim?.toString(),
blurhash = serverResult.fileHeader.blurHash?.blurhash, blurhash = serverResult.fileHeader.blurHash?.blurhash,
caption = viewState.caption.ifEmpty { null }, caption = viewState.caption.ifEmpty { null },
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
), ),
) )
} else { } else {
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.Locale import java.util.Locale
@@ -60,6 +61,7 @@ class IMetaAttachments {
it.mimeType?.let { mimeType(it) } it.mimeType?.let { mimeType(it) }
it.dim?.let { dims(it) } it.dim?.let { dims(it) }
it.blurHash?.let { blurhash(it.blurhash) } it.blurHash?.let { blurhash(it.blurhash) }
it.thumbHash?.let { thumbhash(it.thumbhash) }
}.build() }.build()
} }
@@ -101,6 +103,7 @@ class IMetaAttachments {
result.fileHeader.mimeType?.let { mimeType(it) } result.fileHeader.mimeType?.let { mimeType(it) }
result.fileHeader.dim?.let { dims(it) } result.fileHeader.dim?.let { dims(it) }
result.fileHeader.blurHash?.let { blurhash(it.blurhash) } result.fileHeader.blurHash?.let { blurhash(it.blurhash) }
result.fileHeader.thumbHash?.let { thumbhash(it.thumbhash) }
result.magnet?.let { magnet(it) } result.magnet?.let { magnet(it) }
result.uploadedHash?.let { originalHash(it) } result.uploadedHash?.let { originalHash(it) }
@@ -626,6 +626,9 @@ fun ShowImageUploadGallery(
encryptionAlgo = data.cipher.name(), encryptionAlgo = data.cipher.name(),
encryptionKey = data.cipher.keyBytes, encryptionKey = data.cipher.keyBytes,
encryptionNonce = data.cipher.nonce, encryptionNonce = data.cipher.nonce,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
) )
} else { } else {
EncryptedMediaUrlVideo( EncryptedMediaUrlVideo(
@@ -641,6 +644,9 @@ fun ShowImageUploadGallery(
encryptionAlgo = data.cipher.name(), encryptionAlgo = data.cipher.name(),
encryptionKey = data.cipher.keyBytes, encryptionKey = data.cipher.keyBytes,
encryptionNonce = data.cipher.nonce, encryptionNonce = data.cipher.nonce,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
) )
} }
} else { } else {
@@ -655,6 +661,9 @@ fun ShowImageUploadGallery(
dim = data.result.fileHeader.dim, dim = data.result.fileHeader.dim,
uri = null, uri = null,
mimeType = data.result.mimeTypeBeforeEncryption, mimeType = data.result.mimeTypeBeforeEncryption,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -667,6 +676,9 @@ fun ShowImageUploadGallery(
dim = data.result.fileHeader.dim, dim = data.result.fileHeader.dim,
uri = null, uri = null,
mimeType = data.result.mimeTypeBeforeEncryption, mimeType = data.result.mimeTypeBeforeEncryption,
thumbhash =
data.result.fileHeader.thumbHash
?.thumbhash,
) )
} }
}, },
@@ -64,6 +64,7 @@ class ChatFileSender(
size = result.fileHeader.size, size = result.fileHeader.size,
dimension = result.fileHeader.dim, dimension = result.fileHeader.dim,
blurhash = result.fileHeader.blurHash?.blurhash, blurhash = result.fileHeader.blurHash?.blurhash,
thumbhash = result.fileHeader.thumbHash?.thumbhash,
) { ) {
if (!caption.isNullOrEmpty()) { if (!caption.isNullOrEmpty()) {
alt(caption) alt(caption)
@@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -544,6 +545,8 @@ class LongFormPostViewModel :
?.let { dims(it) } ?.let { dims(it) }
state.result.fileHeader.blurHash state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) } state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) } state.result.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) } alt?.let { alt(it) }
@@ -429,14 +429,18 @@ open class NewProductViewModel :
) { ) {
productImages = productImages + productImages = productImages +
ProductImageMeta( ProductImageMeta(
it.result.url, url = it.result.url,
it.result.fileHeader.mimeType, mimeType = it.result.fileHeader.mimeType,
it.result.fileHeader.blurHash blurhash =
?.blurhash, it.result.fileHeader.blurHash
it.result.fileHeader.dim, ?.blurhash,
alt, dimension = it.result.fileHeader.dim,
it.result.fileHeader.hash, alt = alt,
it.result.fileHeader.size, hash = it.result.fileHeader.hash,
size = it.result.fileHeader.size,
thumbhash =
it.result.fileHeader.thumbHash
?.thumbhash,
) )
} else { } else {
iMetaDescription.add(it.result, alt, contentWarningReason) iMetaDescription.add(it.result, alt, contentWarningReason)
@@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
@@ -1124,6 +1125,8 @@ open class ShortNotePostViewModel :
?.let { dims(it) } ?.let { dims(it) }
state.result.fileHeader.blurHash state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) } state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) } state.result.uploadedHash?.let { originalHash(it) }
@@ -107,6 +107,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag import com.vitorpamplona.quartz.nipA4PublicMessages.tags.ReceiverTag
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
@@ -486,6 +487,8 @@ class NewPublicMessageViewModel :
?.let { dims(it) } ?.let { dims(it) }
state.result.fileHeader.blurHash state.result.fileHeader.blurHash
?.let { blurhash(it.blurhash) } ?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) } state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) } state.result.uploadedHash?.let { originalHash(it) }
@@ -115,6 +115,7 @@ private fun PictureCardImage(
dim = it.dimension, dim = it.dimension,
uri = uri, uri = uri,
mimeType = it.mimeType, mimeType = it.mimeType,
thumbhash = it.thumbhash,
) )
}.toImmutableList(), }.toImmutableList(),
) )
@@ -131,7 +132,7 @@ private fun PictureCardImage(
preloadUrls = listOf(single.url), preloadUrls = listOf(single.url),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), 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( ZoomableContentView(
content = single, content = single,
@@ -86,6 +86,7 @@ fun GalleryThumbnail(
dim = noteEvent.dimensions(), dim = noteEvent.dimensions(),
uri = null, uri = null,
mimeType = noteEvent.mimeType(), mimeType = noteEvent.mimeType(),
thumbhash = noteEvent.thumbhash(),
) )
} else { } else {
MediaUrlImage( MediaUrlImage(
@@ -97,6 +98,7 @@ fun GalleryThumbnail(
dim = noteEvent.dimensions(), dim = noteEvent.dimensions(),
uri = null, uri = null,
mimeType = noteEvent.mimeType(), mimeType = noteEvent.mimeType(),
thumbhash = noteEvent.thumbhash(),
) )
} }
} }
@@ -111,6 +113,7 @@ fun GalleryThumbnail(
dim = imeta.dimension, dim = imeta.dimension,
uri = null, uri = null,
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
} }
} else if (noteEvent is VideoEvent) { } else if (noteEvent is VideoEvent) {
@@ -124,6 +127,7 @@ fun GalleryThumbnail(
dim = imeta.dimension, dim = imeta.dimension,
uri = null, uri = null,
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
} }
} else { } else {
@@ -210,12 +214,13 @@ fun UrlImageView(
when (state) { when (state) {
is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Loading,
-> { -> {
if (content.blurhash != null) { if (content.blurhash != null || content.thumbhash != null) {
DisplayBlurHash( DisplayBlurHash(
content.blurhash, content.blurhash,
content.description, content.description,
ContentScale.Crop, ContentScale.Crop,
defaultModifier, defaultModifier,
thumbhash = content.thumbhash,
) )
} else { } else {
Box(defaultModifier, contentAlignment = Alignment.Center) { Box(defaultModifier, contentAlignment = Alignment.Center) {
@@ -243,12 +248,13 @@ fun UrlImageView(
} }
} }
} else { } else {
if (content.blurhash != null) { if (content.blurhash != null || content.thumbhash != null) {
DisplayBlurHash( DisplayBlurHash(
content.blurhash, content.blurhash,
content.description, content.description,
ContentScale.Crop, ContentScale.Crop,
defaultModifier.clickable { showImage.value = true }, defaultModifier.clickable { showImage.value = true },
thumbhash = content.thumbhash,
) )
Icon( Icon(
imageVector = Icons.Default.PlayCircleOutline, imageVector = Icons.Default.PlayCircleOutline,
@@ -121,6 +121,7 @@ private fun VideoCardImage(
dim = imeta.dimension, dim = imeta.dimension,
uri = note.toNostrUri(), uri = note.toNostrUri(),
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -132,6 +133,7 @@ private fun VideoCardImage(
uri = note.toNostrUri(), uri = note.toNostrUri(),
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
mimeType = imeta.mimeType, mimeType = imeta.mimeType,
thumbhash = imeta.thumbhash,
) )
}, },
) )
@@ -145,7 +147,7 @@ private fun VideoCardImage(
preloadUrls = if (isImage) listOf(imeta.url) else emptyList(), preloadUrls = if (isImage) listOf(imeta.url) else emptyList(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), 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( ZoomableContentView(
content = content, content = content,
@@ -922,6 +922,7 @@ private fun RenderClassifiedsReaderForThread(
dim = it.dimension, dim = it.dimension,
uri = note.toNostrUri(), uri = note.toNostrUri(),
mimeType = it.mimeType, mimeType = it.mimeType,
thumbhash = it.thumbhash,
) )
} }
@@ -104,6 +104,7 @@ private fun FileHeaderCardImage(
val reasons = remember(note) { collectContentWarningReasons(event) } val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) } val isImage = remember(note) { event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) }
val blurHash = remember(note) { event.blurhash() } val blurHash = remember(note) { event.blurhash() }
val thumbHash = remember(note) { event.thumbhash() }
val dimensions = remember(note) { event.dimensions() } val dimensions = remember(note) { event.dimensions() }
val content by remember(note) { val content by remember(note) {
@@ -122,6 +123,7 @@ private fun FileHeaderCardImage(
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
mimeType = mimeType, mimeType = mimeType,
thumbhash = thumbHash,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -133,6 +135,7 @@ private fun FileHeaderCardImage(
uri = uri, uri = uri,
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
mimeType = mimeType, mimeType = mimeType,
thumbhash = thumbHash,
) )
}, },
) )
@@ -146,7 +149,7 @@ private fun FileHeaderCardImage(
preloadUrls = if (isImage) listOf(fullUrl) else emptyList(), preloadUrls = if (isImage) listOf(fullUrl) else emptyList(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, ContentScale.FillWidth), modifier = mediaSizingModifier(ratio, ContentScale.FillWidth),
backdrop = blurHash?.let { blurhash -> { BlurhashBackdrop(blurhash, content.description) } }, backdrop = (thumbHash ?: blurHash)?.let { { BlurhashBackdrop(blurHash, content.description, thumbHash) } },
) { ) {
ZoomableContentView( ZoomableContentView(
content = content, content = content,
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.thumbhash
import android.graphics.Bitmap
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
fun Bitmap.toThumbhash(): String = this.toPlatformImage().toThumbhash()
@@ -29,6 +29,7 @@ abstract class BaseMediaContent(
val description: String? = null, val description: String? = null,
val dim: DimensionTag? = null, val dim: DimensionTag? = null,
val blurhash: String? = null, val blurhash: String? = null,
val thumbhash: String? = null,
) )
@Immutable @Immutable
@@ -40,7 +41,8 @@ abstract class MediaUrlContent(
blurhash: String? = null, blurhash: String? = null,
val uri: String? = null, val uri: String? = null,
val mimeType: String? = null, val mimeType: String? = null,
) : BaseMediaContent(description, dim, blurhash) thumbhash: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash)
@Immutable @Immutable
open class MediaUrlImage( open class MediaUrlImage(
@@ -52,7 +54,8 @@ open class MediaUrlImage(
uri: String? = null, uri: String? = null,
val contentWarning: String? = null, val contentWarning: String? = null,
mimeType: 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( class EncryptedMediaUrlImage(
url: String, url: String,
@@ -66,7 +69,8 @@ class EncryptedMediaUrlImage(
val encryptionAlgo: String, val encryptionAlgo: String,
val encryptionKey: ByteArray, val encryptionKey: ByteArray,
val encryptionNonce: 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 @Immutable
open class MediaUrlPdf( open class MediaUrlPdf(
@@ -77,7 +81,8 @@ open class MediaUrlPdf(
dim: DimensionTag? = null, dim: DimensionTag? = null,
uri: String? = null, uri: String? = null,
mimeType: 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 @Immutable
open class MediaUrlVideo( open class MediaUrlVideo(
@@ -91,7 +96,8 @@ open class MediaUrlVideo(
blurhash: String? = null, blurhash: String? = null,
val contentWarning: String? = null, val contentWarning: String? = null,
mimeType: 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 @Immutable
class EncryptedMediaUrlVideo( class EncryptedMediaUrlVideo(
@@ -108,7 +114,8 @@ class EncryptedMediaUrlVideo(
val encryptionAlgo: String, val encryptionAlgo: String,
val encryptionKey: ByteArray, val encryptionKey: ByteArray,
val encryptionNonce: 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 @Immutable
abstract class MediaPreloadedContent( abstract class MediaPreloadedContent(
@@ -120,7 +127,8 @@ abstract class MediaPreloadedContent(
blurhash: String? = null, blurhash: String? = null,
val uri: String, val uri: String,
val id: String? = null, val id: String? = null,
) : BaseMediaContent(description, dim, blurhash) { thumbhash: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash) {
fun localFileExists() = localFile != null && localFile.exists() fun localFileExists() = localFile != null && localFile.exists()
} }
@@ -133,7 +141,8 @@ class MediaLocalImage(
blurhash: String? = null, blurhash: String? = null,
isVerified: Boolean? = null, isVerified: Boolean? = null,
uri: String, uri: String,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri) thumbhash: String? = null,
) : MediaPreloadedContent(localFile, description, mimeType, isVerified, dim, blurhash, uri, thumbhash = thumbhash)
@Immutable @Immutable
class MediaLocalVideo( class MediaLocalVideo(
@@ -146,4 +155,5 @@ class MediaLocalVideo(
uri: String, uri: String,
val artworkUri: String? = null, val artworkUri: String? = null,
val authorName: 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)
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.MimeTypeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -85,6 +86,7 @@ class RichTextParser {
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
uri = callbackUri, uri = callbackUri,
mimeType = contentType, mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
) )
} else if (isVideo) { } else if (isVideo) {
MediaUrlVideo( MediaUrlVideo(
@@ -96,6 +98,7 @@ class RichTextParser {
contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(), contentWarning = frags[ContentWarningTag.TAG_NAME] ?: tags[ContentWarningTag.TAG_NAME]?.firstOrNull(),
uri = callbackUri, uri = callbackUri,
mimeType = contentType, mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
) )
} else if (isPdf) { } else if (isPdf) {
MediaUrlPdf( 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) }, dim = frags[DimensionTag.TAG_NAME]?.let { DimensionTag.parse(it) } ?: tags[DimensionTag.TAG_NAME]?.firstOrNull()?.let { DimensionTag.parse(it) },
uri = callbackUri, uri = callbackUri,
mimeType = contentType, mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
) )
} else { } else {
null null
@@ -0,0 +1,257 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.round
/**
* ThumbHash decoder.
*
* Port of the reference implementation by Evan Wallace
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
*/
object ThumbHashDecoder {
/**
* Returns width/height. Returns null if the hash is malformed.
*/
fun aspectRatio(hash: ByteArray): Float? {
if (hash.size < 5) return null
val header = hash[3].toInt() and 0xff
val hasAlpha = (hash[2].toInt() and 0x80) != 0
val isLandscape = (hash[4].toInt() and 0x80) != 0
val lx = if (isLandscape) (if (hasAlpha) 5 else 7) else (header and 7)
val ly = if (isLandscape) (header and 7) else (if (hasAlpha) 5 else 7)
if (lx == 0 || ly == 0) return null
return lx.toFloat() / ly.toFloat()
}
/**
* Returns width/height. Returns null if the string is malformed.
*/
@OptIn(ExperimentalEncodingApi::class)
fun aspectRatio(base64Hash: String?): Float? {
if (base64Hash.isNullOrBlank()) return null
return try {
aspectRatio(Base64.decode(padBase64(base64Hash)))
} catch (_: IllegalArgumentException) {
null
}
}
data class RGBAImage(
val width: Int,
val height: Int,
val pixels: IntArray,
)
/**
* Decode a ThumbHash byte array into ARGB pixels.
* Returns null if the hash is malformed.
*/
fun decode(hash: ByteArray): RGBAImage? {
if (hash.size < 5) return null
val b0 = hash[0].toInt() and 0xff
val b1 = hash[1].toInt() and 0xff
val b2 = hash[2].toInt() and 0xff
val b3 = hash[3].toInt() and 0xff
val b4 = hash[4].toInt() and 0xff
val header24 = b0 or (b1 shl 8) or (b2 shl 16)
val header16 = b3 or (b4 shl 8)
val lDc = (header24 and 63) / 63.0
val pDc = ((header24 shr 6) and 63) / 31.5 - 1.0
val qDc = ((header24 shr 12) and 63) / 31.5 - 1.0
val lScale = ((header24 shr 18) and 31) / 31.0
val hasAlpha = (header24 shr 23) and 1 == 1
val pScale = ((header16 shr 3) and 63) / 63.0
val qScale = ((header16 shr 9) and 63) / 63.0
val isLandscape = (header16 shr 15) and 1 == 1
val lx = max(3, if (isLandscape) (if (hasAlpha) 5 else 7) else (header16 and 7))
val ly = max(3, if (isLandscape) (header16 and 7) else (if (hasAlpha) 5 else 7))
val aDc: Double
val aScale: Double
if (hasAlpha) {
if (hash.size < 6) return null
aDc = (hash[5].toInt() and 15) / 15.0
aScale = ((hash[5].toInt() shr 4) and 15) / 15.0
} else {
aDc = 1.0
aScale = 0.0
}
val acStart = if (hasAlpha) 6 else 5
var acIndex = 0
fun readAc(
nx: Int,
ny: Int,
scale: Double,
): DoubleArray {
val ac = ArrayList<Double>(nx * ny)
var cy = 0
while (cy < ny) {
var cx = if (cy != 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
val byteIdx = acStart + (acIndex shr 1)
if (byteIdx >= hash.size) return DoubleArray(0)
val shift = (acIndex and 1) shl 2
val q4 = ((hash[byteIdx].toInt() ushr shift) and 15)
ac.add((q4 / 7.5 - 1.0) * scale)
acIndex++
cx++
}
cy++
}
return DoubleArray(ac.size) { ac[it] }
}
val lAc = readAc(lx, ly, lScale)
val pAc = readAc(3, 3, pScale * 1.25)
val qAc = readAc(3, 3, qScale * 1.25)
val aAc = if (hasAlpha) readAc(5, 5, aScale) else DoubleArray(0)
val ratio = lx.toDouble() / ly.toDouble()
val w = round(if (ratio > 1) 32.0 else 32.0 * ratio).toInt()
val h = round(if (ratio > 1) 32.0 / ratio else 32.0).toInt()
val pixels = IntArray(w * h)
val fxMax = max(lx, if (hasAlpha) 5 else 3)
val fyMax = max(ly, if (hasAlpha) 5 else 3)
val fx = DoubleArray(fxMax)
val fy = DoubleArray(fyMax)
for (y in 0 until h) {
for (x in 0 until w) {
var lVal = lDc
var pVal = pDc
var qVal = qDc
var aVal = aDc
for (cx in 0 until fxMax) fx[cx] = cos(PI / w * (x + 0.5) * cx)
for (cy in 0 until fyMax) fy[cy] = cos(PI / h * (y + 0.5) * cy)
// L
run {
var cy = 0
var j = 0
while (cy < ly) {
var cx = if (cy != 0) 0 else 1
val fy2 = fy[cy] * 2.0
while (cx * ly < lx * (ly - cy)) {
lVal += lAc[j] * fx[cx] * fy2
j++
cx++
}
cy++
}
}
// P & Q
run {
var cy = 0
var j = 0
while (cy < 3) {
var cx = if (cy != 0) 0 else 1
val fy2 = fy[cy] * 2.0
while (cx < 3 - cy) {
val f = fx[cx] * fy2
pVal += pAc[j] * f
qVal += qAc[j] * f
j++
cx++
}
cy++
}
}
// A
if (hasAlpha) {
var cy = 0
var j = 0
while (cy < 5) {
var cx = if (cy != 0) 0 else 1
val fy2 = fy[cy] * 2.0
while (cx < 5 - cy) {
aVal += aAc[j] * fx[cx] * fy2
j++
cx++
}
cy++
}
}
// LPQA → RGB
val bCh = lVal - 2.0 / 3.0 * pVal
val rCh = (3.0 * lVal - bCh + qVal) / 2.0
val gCh = rCh - qVal
val rOut = (255.0 * min(1.0, max(0.0, rCh))).let { round(it).toInt() }.coerceIn(0, 255)
val gOut = (255.0 * min(1.0, max(0.0, gCh))).let { round(it).toInt() }.coerceIn(0, 255)
val bOut = (255.0 * min(1.0, max(0.0, bCh))).let { round(it).toInt() }.coerceIn(0, 255)
val aOut = if (hasAlpha) (255.0 * min(1.0, max(0.0, aVal))).let { round(it).toInt() }.coerceIn(0, 255) else 255
pixels[x + y * w] = (aOut shl 24) or (rOut shl 16) or (gOut shl 8) or bOut
}
}
return RGBAImage(w, h, pixels)
}
/**
* Decode a base64-encoded ThumbHash string to ARGB pixels.
*/
@OptIn(ExperimentalEncodingApi::class)
fun decode(base64Hash: String?): RGBAImage? {
if (base64Hash.isNullOrBlank()) return null
return try {
decode(Base64.decode(padBase64(base64Hash)))
} catch (_: IllegalArgumentException) {
null
}
}
/**
* Decode a ThumbHash string into a [PlatformImage] roughly [targetWidth] wide,
* preserving the aspect ratio of the original image.
*
* Mirrors [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio]
* so existing placeholder pipelines can swap in thumbhash transparently.
*/
fun decodeKeepAspectRatio(
hash: String?,
targetWidth: Int,
): PlatformImage? {
val rgba = decode(hash) ?: return null
return PlatformImage.create(rgba.pixels, rgba.width, rgba.height)
}
private fun padBase64(s: String): String {
val remainder = s.length % 4
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
}
}
@@ -0,0 +1,218 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.thumbhash
import kotlin.io.encoding.Base64
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.round
/**
* ThumbHash encoder.
*
* Port of the reference implementation by Evan Wallace
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
*
* Input pixels are ARGB (0xAARRGGBB) to match [com.vitorpamplona.amethyst.commons.blurhash.PlatformImage.getPixels].
*/
object ThumbHashEncoder {
/**
* Encodes an ARGB image to a ThumbHash byte array.
*
* @param pixels ARGB pixels (0xAARRGGBB), row-by-row; must contain [width] * [height] entries.
* @param width Image width in pixels; must be 100.
* @param height Image height in pixels; must be 100.
*/
fun encode(
pixels: IntArray,
width: Int,
height: Int,
): ByteArray {
require(width in 1..100 && height in 1..100) { "ThumbHash input must be ≤100x100 (got ${width}x$height)" }
require(pixels.size == width * height) { "pixels.size must equal width * height" }
// Average color (premultiplied by alpha)
var avgR = 0.0
var avgG = 0.0
var avgB = 0.0
var avgA = 0.0
for (i in 0 until width * height) {
val argb = pixels[i]
val alpha = ((argb ushr 24) and 0xff) / 255.0
val r = (argb ushr 16) and 0xff
val g = (argb ushr 8) and 0xff
val b = argb and 0xff
avgR += alpha / 255.0 * r
avgG += alpha / 255.0 * g
avgB += alpha / 255.0 * b
avgA += alpha
}
if (avgA > 0.0) {
avgR /= avgA
avgG /= avgA
avgB /= avgA
}
val hasAlpha = avgA < width * height
val lLimit = if (hasAlpha) 5 else 7
val lx = max(1, round(lLimit * width.toDouble() / max(width, height)).toInt())
val ly = max(1, round(lLimit * height.toDouble() / max(width, height)).toInt())
val size = width * height
val l = DoubleArray(size) // luminance
val p = DoubleArray(size) // yellow - blue
val q = DoubleArray(size) // red - green
val a = DoubleArray(size) // alpha
// Convert ARGB to LPQA, composited over the average color
for (i in 0 until size) {
val argb = pixels[i]
val alpha = ((argb ushr 24) and 0xff) / 255.0
val rPx = (argb ushr 16) and 0xff
val gPx = (argb ushr 8) and 0xff
val bPx = argb and 0xff
val r = avgR * (1 - alpha) + alpha / 255.0 * rPx
val g = avgG * (1 - alpha) + alpha / 255.0 * gPx
val b = avgB * (1 - alpha) + alpha / 255.0 * bPx
l[i] = (r + g + b) / 3.0
p[i] = (r + g) / 2.0 - b
q[i] = r - g
a[i] = alpha
}
val lEnc = encodeChannel(l, width, height, max(3, lx), max(3, ly))
val pEnc = encodeChannel(p, width, height, 3, 3)
val qEnc = encodeChannel(q, width, height, 3, 3)
val aEnc = if (hasAlpha) encodeChannel(a, width, height, 5, 5) else null
val isLandscape = width > height
val lCount = if (isLandscape) ly else lx
val hasAlphaBit = if (hasAlpha) 1 else 0
val isLandscapeBit = if (isLandscape) 1 else 0
val header24 =
round(63.0 * lEnc.dc).toInt() or
(round(31.5 + 31.5 * pEnc.dc).toInt() shl 6) or
(round(31.5 + 31.5 * qEnc.dc).toInt() shl 12) or
(round(31.0 * lEnc.scale).toInt() shl 18) or
(hasAlphaBit shl 23)
val header16 =
lCount or
(round(63.0 * pEnc.scale).toInt() shl 3) or
(round(63.0 * qEnc.scale).toInt() shl 9) or
(isLandscapeBit shl 15)
val acChannels = if (hasAlpha) listOf(lEnc.ac, pEnc.ac, qEnc.ac, aEnc!!.ac) else listOf(lEnc.ac, pEnc.ac, qEnc.ac)
val totalAc = acChannels.sumOf { it.size }
val acStart = if (hasAlpha) 6 else 5
val hashSize = acStart + ((totalAc + 1) / 2)
val hash = ByteArray(hashSize)
hash[0] = (header24 and 0xff).toByte()
hash[1] = ((header24 ushr 8) and 0xff).toByte()
hash[2] = ((header24 ushr 16) and 0xff).toByte()
hash[3] = (header16 and 0xff).toByte()
hash[4] = ((header16 ushr 8) and 0xff).toByte()
if (hasAlpha) {
val aDcQ = round(15.0 * aEnc!!.dc).toInt() and 0xf
val aScaleQ = round(15.0 * aEnc.scale).toInt() and 0xf
hash[5] = (aDcQ or (aScaleQ shl 4)).toByte()
}
var acIndex = 0
for (ac in acChannels) {
for (f in ac) {
val q4 = round(15.0 * f).toInt() and 0xf
val byteIdx = acStart + (acIndex shr 1)
val shift = (acIndex and 1) shl 2
hash[byteIdx] = (hash[byteIdx].toInt() or (q4 shl shift)).toByte()
acIndex++
}
}
return hash
}
/**
* Encodes an ARGB image to a base64 ThumbHash string (no padding).
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
fun encodeToBase64(
pixels: IntArray,
width: Int,
height: Int,
): String = Base64.encode(encode(pixels, width, height)).trimEnd('=')
private data class ChannelEncoded(
val dc: Double,
val ac: DoubleArray,
val scale: Double,
)
private fun encodeChannel(
channel: DoubleArray,
w: Int,
h: Int,
nx: Int,
ny: Int,
): ChannelEncoded {
var dc = 0.0
var scale = 0.0
val acList = ArrayList<Double>((nx * ny))
val fx = DoubleArray(w)
var cy = 0
while (cy < ny) {
var cx = 0
while (cx * ny < nx * (ny - cy)) {
var f = 0.0
for (x in 0 until w) {
fx[x] = cos(PI / w * cx * (x + 0.5))
}
for (y in 0 until h) {
val fy = cos(PI / h * cy * (y + 0.5))
for (x in 0 until w) {
f += channel[x + y * w] * fx[x] * fy
}
}
f /= (w * h).toDouble()
if (cx != 0 || cy != 0) {
acList.add(f)
if (abs(f) > scale) scale = abs(f)
} else {
dc = f
}
cx++
}
cy++
}
val ac = DoubleArray(acList.size) { acList[it] }
if (scale != 0.0) {
for (i in ac.indices) {
ac[i] = 0.5 + 0.5 / scale * ac[i]
}
}
return ChannelEncoded(dc, ac, scale)
}
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.thumbhash
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
/**
* Encodes this [PlatformImage] to a base64 ThumbHash string (no padding).
*
* ThumbHash is specified at 100x100. Larger images are downscaled first.
*/
fun PlatformImage.toThumbhash(): String {
val source =
if (width > 100 || height > 100) {
val aspect = width.toDouble() / height.toDouble()
val scaled =
if (width >= height) {
val w = 100
val h = (100.0 / aspect).toInt().coerceAtLeast(1)
this.scale(w, h)
} else {
val h = 100
val w = (100.0 * aspect).toInt().coerceAtLeast(1)
this.scale(w, h)
}
scaled
} else {
this
}
val pixels = IntArray(source.width * source.height)
source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height)
return ThumbHashEncoder.encodeToBase64(pixels, source.width, source.height)
}
@@ -0,0 +1,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(""))
}
}
@@ -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()
@@ -60,8 +60,10 @@ object DesktopImageLoaderSetup {
add(SkiaGifDecoder.Factory()) add(SkiaGifDecoder.Factory())
add(DesktopBase64Fetcher.Factory) add(DesktopBase64Fetcher.Factory)
add(DesktopBlurHashFetcher.Factory) add(DesktopBlurHashFetcher.Factory)
add(DesktopThumbHashFetcher.Factory)
add(DesktopBase64Fetcher.BKeyer) add(DesktopBase64Fetcher.BKeyer)
add(DesktopBlurHashFetcher.BKeyer) add(DesktopBlurHashFetcher.BKeyer)
add(DesktopThumbHashFetcher.TKeyer)
}.build() }.build()
private fun newMemoryCache(): MemoryCache { private fun newMemoryCache(): MemoryCache {
@@ -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<ThumbhashWrapper> {
override fun create(
data: ThumbhashWrapper,
options: Options,
imageLoader: ImageLoader,
): Fetcher = DesktopThumbHashFetcher(data)
}
object TKeyer : Keyer<ThumbhashWrapper> {
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
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage 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.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256 import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File import java.io.File
@@ -34,6 +35,7 @@ data class MediaMetadata(
val width: Int? = null, val width: Int? = null,
val height: Int? = null, val height: Int? = null,
val blurhash: String? = null, val blurhash: String? = null,
val thumbhash: String? = null,
) )
object DesktopMediaMetadata { object DesktopMediaMetadata {
@@ -44,6 +46,7 @@ object DesktopMediaMetadata {
var width: Int? = null var width: Int? = null
var height: Int? = null var height: Int? = null
var blurhash: String? = null var blurhash: String? = null
var thumbhash: String? = null
if (mimeType.startsWith("image/")) { if (mimeType.startsWith("image/")) {
try { try {
@@ -51,7 +54,9 @@ object DesktopMediaMetadata {
if (image != null) { if (image != null) {
width = image.width width = image.width
height = image.height height = image.height
blurhash = image.toPlatformImage().toBlurhash() val platformImage = image.toPlatformImage()
blurhash = runCatching { platformImage.toBlurhash() }.getOrNull()
thumbhash = runCatching { platformImage.toThumbhash() }.getOrNull()
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -64,6 +69,7 @@ object DesktopMediaMetadata {
width = width, width = width,
height = height, height = height,
blurhash = blurhash, blurhash = blurhash,
thumbhash = thumbhash,
) )
} }
@@ -358,6 +358,7 @@ private fun buildIMetaTags(results: List<UploadResult>): List<IMetaTag> =
props["dim"] = listOf("${meta.width}x${meta.height}") props["dim"] = listOf("${meta.width}x${meta.height}")
} }
meta.blurhash?.let { props["blurhash"] = listOf(it) } meta.blurhash?.let { props["blurhash"] = listOf(it) }
meta.thumbhash?.let { props["thumbhash"] = listOf(it) }
IMetaTag(url = url, properties = props) IMetaTag(url = url, properties = props)
} }
@@ -460,6 +461,7 @@ private fun buildPictureMetas(results: List<UploadResult>): List<com.vitorpamplo
}, },
hash = meta.sha256, hash = meta.sha256,
size = meta.size.toInt(), size = meta.size.toInt(),
thumbhash = meta.thumbhash,
) )
} }
@@ -825,6 +825,7 @@ private suspend fun sendEncryptedFiles(
null null
}, },
blurhash = result.metadata.blurhash, blurhash = result.metadata.blurhash,
thumbhash = result.metadata.thumbhash,
originalHash = result.metadata.sha256, originalHash = result.metadata.sha256,
) )
account.sendNip17EncryptedFile(template) account.sendNip17EncryptedFile(template)
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -75,6 +76,8 @@ class FileStorageHeaderEvent(
fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse)
fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse)
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
fun TagArrayBuilder<FileStorageHeaderEvent>.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType)) fun TagArrayBuilder<FileStorageHeaderEvent>.mimeType(mimeType: String) = add(MimeTypeTag.assemble(mimeType))
@@ -45,6 +46,8 @@ fun TagArrayBuilder<FileStorageHeaderEvent>.dimension(dim: DimensionTag) = add(D
fun TagArrayBuilder<FileStorageHeaderEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) fun TagArrayBuilder<FileStorageHeaderEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash))
fun TagArrayBuilder<FileStorageHeaderEvent>.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash))
fun TagArrayBuilder<FileStorageHeaderEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) fun TagArrayBuilder<FileStorageHeaderEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash))
fun TagArrayBuilder<FileStorageHeaderEvent>.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri)) fun TagArrayBuilder<FileStorageHeaderEvent>.magnet(magnetUri: String) = add(MagnetTag.assemble(magnetUri))
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -70,6 +71,8 @@ class ProfileGalleryEntryEvent(
fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse)
fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse)
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
@@ -51,6 +52,8 @@ fun TagArrayBuilder<ProfileGalleryEntryEvent>.dimension(dim: DimensionTag) = add
fun TagArrayBuilder<ProfileGalleryEntryEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) fun TagArrayBuilder<ProfileGalleryEntryEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash))
fun TagArrayBuilder<ProfileGalleryEntryEvent>.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash))
fun TagArrayBuilder<ProfileGalleryEntryEvent>.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder<ProfileGalleryEntryEvent>.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash))
fun TagArrayBuilder<ProfileGalleryEntryEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) fun TagArrayBuilder<ProfileGalleryEntryEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash))
@@ -166,6 +166,7 @@ fun buildMip04IMetaTag(
nonce: ByteArray, nonce: ByteArray,
dimensions: String? = null, dimensions: String? = null,
blurhash: String? = null, blurhash: String? = null,
thumbhash: String? = null,
): IMetaTag = ): IMetaTag =
IMetaTagBuilder(url) IMetaTagBuilder(url)
.apply { .apply {
@@ -176,4 +177,5 @@ fun buildMip04IMetaTag(
add(Mip04Fields.VERSION, Mip04MediaEncryption.VERSION) add(Mip04Fields.VERSION, Mip04MediaEncryption.VERSION)
dimensions?.let { add(Mip04Fields.DIMENSIONS, it) } dimensions?.let { add(Mip04Fields.DIMENSIONS, it) }
blurhash?.let { add(Mip04Fields.BLURHASH, it) } blurhash?.let { add(Mip04Fields.BLURHASH, it) }
thumbhash?.let { add(Mip04Fields.THUMBHASH, it) }
}.build() }.build()
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag 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.TimeUtils
import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@@ -65,6 +66,8 @@ class ChatMessageEncryptedFileHeaderEvent(
fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse)
fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse)
fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse) fun originalHash() = tags.firstNotNullOfOrNull(OriginalHashTag::parse)
fun algo() = tags.firstNotNullOfOrNull(EncryptionAlgo::parse) fun algo() = tags.firstNotNullOfOrNull(EncryptionAlgo::parse)
@@ -87,6 +90,7 @@ class ChatMessageEncryptedFileHeaderEvent(
size: Int? = null, size: Int? = null,
dimension: DimensionTag? = null, dimension: DimensionTag? = null,
blurhash: String? = null, blurhash: String? = null,
thumbhash: String? = null,
originalHash: String? = null, originalHash: String? = null,
magnetUri: String? = null, magnetUri: String? = null,
torrentInfoHash: String? = null, torrentInfoHash: String? = null,
@@ -108,6 +112,7 @@ class ChatMessageEncryptedFileHeaderEvent(
mimeType?.let { mimeType(it) } mimeType?.let { mimeType(it) }
dimension?.let { dimension(it) } dimension?.let { dimension(it) }
blurhash?.let { blurhash(it) } blurhash?.let { blurhash(it) }
thumbhash?.let { thumbhash(it) }
originalHash?.let { originalHash(it) } originalHash?.let { originalHash(it) }
magnetUri?.let { magnet(it) } magnetUri?.let { magnet(it) }
torrentInfoHash?.let { torrentInfohash(it) } torrentInfoHash?.let { torrentInfohash(it) }
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.reply(msg: MarkedETag) = add(msg.toTagArray()) fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.reply(msg: MarkedETag) = add(msg.toTagArray())
@@ -70,6 +71,8 @@ fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.dimension(dim: Dimensio
fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash))
fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash))
fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash))
fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) fun TagArrayBuilder<ChatMessageEncryptedFileHeaderEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash))
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.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.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.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash)
fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri)
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
/** /**
@@ -55,6 +56,8 @@ fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME)
fun IMetaTag.blurhash() = properties.get(BlurhashTag.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.originalHash() = properties.get(OriginalHashTag.TAG_NAME)
fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME)
@@ -36,6 +36,7 @@ data class PictureMeta(
val service: String? = null, val service: String? = null,
val fallback: List<String> = emptyList(), val fallback: List<String> = emptyList(),
val annotations: List<UserAnnotationTag> = emptyList(), val annotations: List<UserAnnotationTag> = emptyList(),
val thumbhash: String? = null,
) { ) {
fun toIMetaArray(): Array<String> = fun toIMetaArray(): Array<String> =
IMetaTagBuilder(url) IMetaTagBuilder(url)
@@ -46,6 +47,7 @@ data class PictureMeta(
size?.let { size(it) } size?.let { size(it) }
dimension?.let { dims(it) } dimension?.let { dims(it) }
blurhash?.let { blurhash(it) } blurhash?.let { blurhash(it) }
thumbhash?.let { thumbhash(it) }
service?.let { service(it) } service?.let { service(it) }
fallback.forEach { fallback(it) } fallback.forEach { fallback(it) }
annotations.forEach { userAnnotations(it) } annotations.forEach { userAnnotations(it) }
@@ -55,16 +57,17 @@ data class PictureMeta(
companion object { companion object {
fun parse(iMeta: IMetaTag): PictureMeta = fun parse(iMeta: IMetaTag): PictureMeta =
PictureMeta( PictureMeta(
iMeta.url, url = iMeta.url,
iMeta.mimeType()?.firstOrNull(), mimeType = iMeta.mimeType()?.firstOrNull(),
iMeta.blurhash()?.firstOrNull(), blurhash = iMeta.blurhash()?.firstOrNull(),
iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) },
iMeta.alt()?.firstOrNull(), alt = iMeta.alt()?.firstOrNull(),
iMeta.hash()?.firstOrNull(), hash = iMeta.hash()?.firstOrNull(),
iMeta.size()?.firstOrNull()?.toIntOrNull(), size = iMeta.size()?.firstOrNull()?.toIntOrNull(),
iMeta.service()?.firstOrNull(), service = iMeta.service()?.firstOrNull(),
iMeta.fallback() ?: emptyList(), fallback = iMeta.fallback() ?: emptyList(),
iMeta.userAnnotations() ?: emptyList(), annotations = iMeta.userAnnotations() ?: emptyList(),
thumbhash = iMeta.thumbhash()?.firstOrNull(),
) )
} }
} }
@@ -39,7 +39,8 @@ fun TagArrayBuilder<PictureEvent>.pictureIMeta(
hash: String? = null, hash: String? = null,
size: Int? = null, size: Int? = null,
alt: String? = 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<PictureEvent>.pictureIMeta(imeta: PictureMeta): TagArrayBuilder<PictureEvent> { fun TagArrayBuilder<PictureEvent>.pictureIMeta(imeta: PictureMeta): TagArrayBuilder<PictureEvent> {
add(imeta.toIMetaArray()) add(imeta.toIMetaArray())
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.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.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.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash)
fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri)
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
/** /**
@@ -54,6 +55,8 @@ fun IMetaTag.dims() = properties.get(DimensionTag.TAG_NAME)
fun IMetaTag.blurhash() = properties.get(BlurhashTag.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.originalHash() = properties.get(OriginalHashTag.TAG_NAME)
fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME) fun IMetaTag.torrent() = properties.get(TorrentInfoHash.TAG_NAME)
@@ -43,7 +43,8 @@ fun <T : VideoEvent> TagArrayBuilder<T>.videoIMeta(
hash: String? = null, hash: String? = null,
size: Int? = null, size: Int? = null,
alt: String? = 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 <T : VideoEvent> TagArrayBuilder<T>.videoIMeta(imeta: VideoMeta) = add(imeta.toIMetaArray()) fun <T : VideoEvent> TagArrayBuilder<T>.videoIMeta(imeta: VideoMeta) = add(imeta.toIMetaArray())
@@ -35,6 +35,7 @@ data class VideoMeta(
val service: String? = null, val service: String? = null,
val fallback: List<String> = emptyList(), val fallback: List<String> = emptyList(),
val image: List<String> = emptyList(), val image: List<String> = emptyList(),
val thumbhash: String? = null,
) { ) {
fun toIMetaArray(): Array<String> = fun toIMetaArray(): Array<String> =
IMetaTagBuilder(url) IMetaTagBuilder(url)
@@ -45,6 +46,7 @@ data class VideoMeta(
size?.let { size(it) } size?.let { size(it) }
dimension?.let { dims(it) } dimension?.let { dims(it) }
blurhash?.let { blurhash(it) } blurhash?.let { blurhash(it) }
thumbhash?.let { thumbhash(it) }
service?.let { service(it) } service?.let { service(it) }
fallback.forEach { fallback(it) } fallback.forEach { fallback(it) }
image.forEach { image(it) } image.forEach { image(it) }
@@ -54,16 +56,17 @@ data class VideoMeta(
companion object { companion object {
fun parse(iMeta: IMetaTag): VideoMeta = fun parse(iMeta: IMetaTag): VideoMeta =
VideoMeta( VideoMeta(
iMeta.url, url = iMeta.url,
iMeta.mimeType()?.firstOrNull(), mimeType = iMeta.mimeType()?.firstOrNull(),
iMeta.blurhash()?.firstOrNull(), blurhash = iMeta.blurhash()?.firstOrNull(),
iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) },
iMeta.alt()?.firstOrNull(), alt = iMeta.alt()?.firstOrNull(),
iMeta.hash()?.firstOrNull(), hash = iMeta.hash()?.firstOrNull(),
iMeta.size()?.firstOrNull()?.toIntOrNull(), size = iMeta.size()?.firstOrNull()?.toIntOrNull(),
iMeta.service()?.firstOrNull(), service = iMeta.service()?.firstOrNull(),
iMeta.fallback() ?: emptyList(), fallback = iMeta.fallback() ?: emptyList(),
iMeta.image() ?: emptyList(), image = iMeta.image() ?: emptyList(),
thumbhash = iMeta.thumbhash()?.firstOrNull(),
) )
} }
} }
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -69,6 +70,8 @@ class FileHeaderEvent(
fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse) fun blurhash() = tags.firstNotNullOfOrNull(BlurhashTag::parse)
fun thumbhash() = tags.firstNotNullOfOrNull(ThumbhashTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse) fun thumb() = tags.firstNotNullOfOrNull(ThumbTag::parse)
@@ -106,6 +109,7 @@ class FileHeaderEvent(
size: Int? = null, size: Int? = null,
dimension: DimensionTag? = null, dimension: DimensionTag? = null,
blurhash: String? = null, blurhash: String? = null,
thumbhash: String? = null,
originalHash: String? = null, originalHash: String? = null,
magnetUri: String? = null, magnetUri: String? = null,
torrentInfoHash: String? = null, torrentInfoHash: String? = null,
@@ -120,6 +124,7 @@ class FileHeaderEvent(
mimeType?.let { mimeType(it) } mimeType?.let { mimeType(it) }
dimension?.let { dimension(it) } dimension?.let { dimension(it) }
blurhash?.let { blurhash(it) } blurhash?.let { blurhash(it) }
thumbhash?.let { thumbhash(it) }
originalHash?.let { originalHash(it) } originalHash?.let { originalHash(it) }
magnetUri?.let { magnet(it) } magnetUri?.let { magnet(it) }
torrentInfoHash?.let { torrentInfohash(it) } torrentInfoHash?.let { torrentInfohash(it) }
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.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.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.originalHash(originalHash: String) = add(OriginalHashTag.TAG_NAME, originalHash)
fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri) fun IMetaTagBuilder.torrent(uri: String) = add(TorrentInfoHash.TAG_NAME, uri)
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.ServiceTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SizeTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag 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.TorrentInfoHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.UrlTag
@@ -49,6 +50,8 @@ fun TagArrayBuilder<FileHeaderEvent>.dimension(dim: DimensionTag) = add(Dimensio
fun TagArrayBuilder<FileHeaderEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash)) fun TagArrayBuilder<FileHeaderEvent>.blurhash(blurhash: String) = add(BlurhashTag.assemble(blurhash))
fun TagArrayBuilder<FileHeaderEvent>.thumbhash(thumbhash: String) = add(ThumbhashTag.assemble(thumbhash))
fun TagArrayBuilder<FileHeaderEvent>.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash)) fun TagArrayBuilder<FileHeaderEvent>.originalHash(hash: HexKey) = add(OriginalHashTag.assemble(hash))
fun TagArrayBuilder<FileHeaderEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash)) fun TagArrayBuilder<FileHeaderEvent>.torrentInfohash(hash: String) = add(TorrentInfoHash.assemble(hash))
@@ -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>): 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)
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip68Picture.dims
import com.vitorpamplona.quartz.nip68Picture.hash import com.vitorpamplona.quartz.nip68Picture.hash
import com.vitorpamplona.quartz.nip68Picture.mimeType import com.vitorpamplona.quartz.nip68Picture.mimeType
import com.vitorpamplona.quartz.nip68Picture.size import com.vitorpamplona.quartz.nip68Picture.size
import com.vitorpamplona.quartz.nip68Picture.thumbhash
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -38,6 +39,7 @@ data class ProductImageMeta(
val alt: String? = null, val alt: String? = null,
val hash: String? = null, val hash: String? = null,
val size: Int? = null, val size: Int? = null,
val thumbhash: String? = null,
) { ) {
fun toIMeta(): IMetaTag = fun toIMeta(): IMetaTag =
IMetaTagBuilder(url) IMetaTagBuilder(url)
@@ -48,6 +50,7 @@ data class ProductImageMeta(
size?.let { size(it) } size?.let { size(it) }
dimension?.let { dims(it) } dimension?.let { dims(it) }
blurhash?.let { blurhash(it) } blurhash?.let { blurhash(it) }
thumbhash?.let { thumbhash(it) }
}.build() }.build()
fun toIMetaArray(): Array<String> = toIMeta().toTagArray() fun toIMetaArray(): Array<String> = toIMeta().toTagArray()
@@ -55,13 +58,14 @@ data class ProductImageMeta(
companion object { companion object {
fun parse(iMeta: IMetaTag): ProductImageMeta = fun parse(iMeta: IMetaTag): ProductImageMeta =
ProductImageMeta( ProductImageMeta(
iMeta.url, url = iMeta.url,
iMeta.mimeType()?.firstOrNull(), mimeType = iMeta.mimeType()?.firstOrNull(),
iMeta.blurhash()?.firstOrNull(), blurhash = iMeta.blurhash()?.firstOrNull(),
iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) }, dimension = iMeta.dims()?.firstOrNull()?.let { DimensionTag.parse(it) },
iMeta.alt()?.firstOrNull(), alt = iMeta.alt()?.firstOrNull(),
iMeta.hash()?.firstOrNull(), hash = iMeta.hash()?.firstOrNull(),
iMeta.size()?.firstOrNull()?.toIntOrNull(), size = iMeta.size()?.firstOrNull()?.toIntOrNull(),
thumbhash = iMeta.thumbhash()?.firstOrNull(),
) )
} }
} }