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:
@@ -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
|
||||||
|
}
|
||||||
+6
-3
@@ -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}" }
|
||||||
|
|||||||
+8
-4
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-25
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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" }
|
||||||
|
|||||||
+2
-2
@@ -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) }
|
||||||
|
|||||||
+5
-2
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-9
@@ -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,
|
||||||
|
|||||||
+3
@@ -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,
|
||||||
|
|||||||
+2
@@ -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(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -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(
|
||||||
|
|||||||
+2
@@ -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 {
|
||||||
|
|||||||
+3
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
+12
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
@@ -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)
|
||||||
|
|||||||
+3
@@ -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) }
|
||||||
|
|||||||
+12
-8
@@ -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)
|
||||||
|
|||||||
+3
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
+3
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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,
|
||||||
|
|||||||
+8
-2
@@ -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,
|
||||||
|
|||||||
+3
-1
@@ -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,
|
||||||
|
|||||||
+1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -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,
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.thumbhash
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
|
||||||
|
|
||||||
|
fun Bitmap.toThumbhash(): String = this.toPlatformImage().toThumbhash()
|
||||||
+19
-9
@@ -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)
|
||||||
|
|||||||
+4
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag
|
|||||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
import com.vitorpamplona.quartz.nip94FileMetadata.tags.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
|
||||||
|
|||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.thumbhash
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThumbHash decoder.
|
||||||
|
*
|
||||||
|
* Port of the reference implementation by Evan Wallace
|
||||||
|
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
|
||||||
|
*/
|
||||||
|
object ThumbHashDecoder {
|
||||||
|
/**
|
||||||
|
* Returns width/height. Returns null if the hash is malformed.
|
||||||
|
*/
|
||||||
|
fun aspectRatio(hash: ByteArray): Float? {
|
||||||
|
if (hash.size < 5) return null
|
||||||
|
val header = hash[3].toInt() and 0xff
|
||||||
|
val hasAlpha = (hash[2].toInt() and 0x80) != 0
|
||||||
|
val isLandscape = (hash[4].toInt() and 0x80) != 0
|
||||||
|
val lx = if (isLandscape) (if (hasAlpha) 5 else 7) else (header and 7)
|
||||||
|
val ly = if (isLandscape) (header and 7) else (if (hasAlpha) 5 else 7)
|
||||||
|
if (lx == 0 || ly == 0) return null
|
||||||
|
return lx.toFloat() / ly.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns width/height. Returns null if the string is malformed.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun aspectRatio(base64Hash: String?): Float? {
|
||||||
|
if (base64Hash.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
aspectRatio(Base64.decode(padBase64(base64Hash)))
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RGBAImage(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val pixels: IntArray,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a ThumbHash byte array into ARGB pixels.
|
||||||
|
* Returns null if the hash is malformed.
|
||||||
|
*/
|
||||||
|
fun decode(hash: ByteArray): RGBAImage? {
|
||||||
|
if (hash.size < 5) return null
|
||||||
|
|
||||||
|
val b0 = hash[0].toInt() and 0xff
|
||||||
|
val b1 = hash[1].toInt() and 0xff
|
||||||
|
val b2 = hash[2].toInt() and 0xff
|
||||||
|
val b3 = hash[3].toInt() and 0xff
|
||||||
|
val b4 = hash[4].toInt() and 0xff
|
||||||
|
val header24 = b0 or (b1 shl 8) or (b2 shl 16)
|
||||||
|
val header16 = b3 or (b4 shl 8)
|
||||||
|
|
||||||
|
val lDc = (header24 and 63) / 63.0
|
||||||
|
val pDc = ((header24 shr 6) and 63) / 31.5 - 1.0
|
||||||
|
val qDc = ((header24 shr 12) and 63) / 31.5 - 1.0
|
||||||
|
val lScale = ((header24 shr 18) and 31) / 31.0
|
||||||
|
val hasAlpha = (header24 shr 23) and 1 == 1
|
||||||
|
val pScale = ((header16 shr 3) and 63) / 63.0
|
||||||
|
val qScale = ((header16 shr 9) and 63) / 63.0
|
||||||
|
val isLandscape = (header16 shr 15) and 1 == 1
|
||||||
|
val lx = max(3, if (isLandscape) (if (hasAlpha) 5 else 7) else (header16 and 7))
|
||||||
|
val ly = max(3, if (isLandscape) (header16 and 7) else (if (hasAlpha) 5 else 7))
|
||||||
|
|
||||||
|
val aDc: Double
|
||||||
|
val aScale: Double
|
||||||
|
if (hasAlpha) {
|
||||||
|
if (hash.size < 6) return null
|
||||||
|
aDc = (hash[5].toInt() and 15) / 15.0
|
||||||
|
aScale = ((hash[5].toInt() shr 4) and 15) / 15.0
|
||||||
|
} else {
|
||||||
|
aDc = 1.0
|
||||||
|
aScale = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
val acStart = if (hasAlpha) 6 else 5
|
||||||
|
var acIndex = 0
|
||||||
|
|
||||||
|
fun readAc(
|
||||||
|
nx: Int,
|
||||||
|
ny: Int,
|
||||||
|
scale: Double,
|
||||||
|
): DoubleArray {
|
||||||
|
val ac = ArrayList<Double>(nx * ny)
|
||||||
|
var cy = 0
|
||||||
|
while (cy < ny) {
|
||||||
|
var cx = if (cy != 0) 0 else 1
|
||||||
|
while (cx * ny < nx * (ny - cy)) {
|
||||||
|
val byteIdx = acStart + (acIndex shr 1)
|
||||||
|
if (byteIdx >= hash.size) return DoubleArray(0)
|
||||||
|
val shift = (acIndex and 1) shl 2
|
||||||
|
val q4 = ((hash[byteIdx].toInt() ushr shift) and 15)
|
||||||
|
ac.add((q4 / 7.5 - 1.0) * scale)
|
||||||
|
acIndex++
|
||||||
|
cx++
|
||||||
|
}
|
||||||
|
cy++
|
||||||
|
}
|
||||||
|
return DoubleArray(ac.size) { ac[it] }
|
||||||
|
}
|
||||||
|
|
||||||
|
val lAc = readAc(lx, ly, lScale)
|
||||||
|
val pAc = readAc(3, 3, pScale * 1.25)
|
||||||
|
val qAc = readAc(3, 3, qScale * 1.25)
|
||||||
|
val aAc = if (hasAlpha) readAc(5, 5, aScale) else DoubleArray(0)
|
||||||
|
|
||||||
|
val ratio = lx.toDouble() / ly.toDouble()
|
||||||
|
val w = round(if (ratio > 1) 32.0 else 32.0 * ratio).toInt()
|
||||||
|
val h = round(if (ratio > 1) 32.0 / ratio else 32.0).toInt()
|
||||||
|
val pixels = IntArray(w * h)
|
||||||
|
|
||||||
|
val fxMax = max(lx, if (hasAlpha) 5 else 3)
|
||||||
|
val fyMax = max(ly, if (hasAlpha) 5 else 3)
|
||||||
|
val fx = DoubleArray(fxMax)
|
||||||
|
val fy = DoubleArray(fyMax)
|
||||||
|
|
||||||
|
for (y in 0 until h) {
|
||||||
|
for (x in 0 until w) {
|
||||||
|
var lVal = lDc
|
||||||
|
var pVal = pDc
|
||||||
|
var qVal = qDc
|
||||||
|
var aVal = aDc
|
||||||
|
|
||||||
|
for (cx in 0 until fxMax) fx[cx] = cos(PI / w * (x + 0.5) * cx)
|
||||||
|
for (cy in 0 until fyMax) fy[cy] = cos(PI / h * (y + 0.5) * cy)
|
||||||
|
|
||||||
|
// L
|
||||||
|
run {
|
||||||
|
var cy = 0
|
||||||
|
var j = 0
|
||||||
|
while (cy < ly) {
|
||||||
|
var cx = if (cy != 0) 0 else 1
|
||||||
|
val fy2 = fy[cy] * 2.0
|
||||||
|
while (cx * ly < lx * (ly - cy)) {
|
||||||
|
lVal += lAc[j] * fx[cx] * fy2
|
||||||
|
j++
|
||||||
|
cx++
|
||||||
|
}
|
||||||
|
cy++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P & Q
|
||||||
|
run {
|
||||||
|
var cy = 0
|
||||||
|
var j = 0
|
||||||
|
while (cy < 3) {
|
||||||
|
var cx = if (cy != 0) 0 else 1
|
||||||
|
val fy2 = fy[cy] * 2.0
|
||||||
|
while (cx < 3 - cy) {
|
||||||
|
val f = fx[cx] * fy2
|
||||||
|
pVal += pAc[j] * f
|
||||||
|
qVal += qAc[j] * f
|
||||||
|
j++
|
||||||
|
cx++
|
||||||
|
}
|
||||||
|
cy++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A
|
||||||
|
if (hasAlpha) {
|
||||||
|
var cy = 0
|
||||||
|
var j = 0
|
||||||
|
while (cy < 5) {
|
||||||
|
var cx = if (cy != 0) 0 else 1
|
||||||
|
val fy2 = fy[cy] * 2.0
|
||||||
|
while (cx < 5 - cy) {
|
||||||
|
aVal += aAc[j] * fx[cx] * fy2
|
||||||
|
j++
|
||||||
|
cx++
|
||||||
|
}
|
||||||
|
cy++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LPQA → RGB
|
||||||
|
val bCh = lVal - 2.0 / 3.0 * pVal
|
||||||
|
val rCh = (3.0 * lVal - bCh + qVal) / 2.0
|
||||||
|
val gCh = rCh - qVal
|
||||||
|
val rOut = (255.0 * min(1.0, max(0.0, rCh))).let { round(it).toInt() }.coerceIn(0, 255)
|
||||||
|
val gOut = (255.0 * min(1.0, max(0.0, gCh))).let { round(it).toInt() }.coerceIn(0, 255)
|
||||||
|
val bOut = (255.0 * min(1.0, max(0.0, bCh))).let { round(it).toInt() }.coerceIn(0, 255)
|
||||||
|
val aOut = if (hasAlpha) (255.0 * min(1.0, max(0.0, aVal))).let { round(it).toInt() }.coerceIn(0, 255) else 255
|
||||||
|
pixels[x + y * w] = (aOut shl 24) or (rOut shl 16) or (gOut shl 8) or bOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RGBAImage(w, h, pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a base64-encoded ThumbHash string to ARGB pixels.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
fun decode(base64Hash: String?): RGBAImage? {
|
||||||
|
if (base64Hash.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
decode(Base64.decode(padBase64(base64Hash)))
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a ThumbHash string into a [PlatformImage] roughly [targetWidth] wide,
|
||||||
|
* preserving the aspect ratio of the original image.
|
||||||
|
*
|
||||||
|
* Mirrors [com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder.decodeKeepAspectRatio]
|
||||||
|
* so existing placeholder pipelines can swap in thumbhash transparently.
|
||||||
|
*/
|
||||||
|
fun decodeKeepAspectRatio(
|
||||||
|
hash: String?,
|
||||||
|
targetWidth: Int,
|
||||||
|
): PlatformImage? {
|
||||||
|
val rgba = decode(hash) ?: return null
|
||||||
|
return PlatformImage.create(rgba.pixels, rgba.width, rgba.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun padBase64(s: String): String {
|
||||||
|
val remainder = s.length % 4
|
||||||
|
return if (remainder == 0) s else s + "=".repeat(4 - remainder)
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.thumbhash
|
||||||
|
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThumbHash encoder.
|
||||||
|
*
|
||||||
|
* Port of the reference implementation by Evan Wallace
|
||||||
|
* (https://github.com/evanw/thumbhash, public domain), adapted to Kotlin.
|
||||||
|
*
|
||||||
|
* Input pixels are ARGB (0xAARRGGBB) to match [com.vitorpamplona.amethyst.commons.blurhash.PlatformImage.getPixels].
|
||||||
|
*/
|
||||||
|
object ThumbHashEncoder {
|
||||||
|
/**
|
||||||
|
* Encodes an ARGB image to a ThumbHash byte array.
|
||||||
|
*
|
||||||
|
* @param pixels ARGB pixels (0xAARRGGBB), row-by-row; must contain [width] * [height] entries.
|
||||||
|
* @param width Image width in pixels; must be ≤ 100.
|
||||||
|
* @param height Image height in pixels; must be ≤ 100.
|
||||||
|
*/
|
||||||
|
fun encode(
|
||||||
|
pixels: IntArray,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): ByteArray {
|
||||||
|
require(width in 1..100 && height in 1..100) { "ThumbHash input must be ≤100x100 (got ${width}x$height)" }
|
||||||
|
require(pixels.size == width * height) { "pixels.size must equal width * height" }
|
||||||
|
|
||||||
|
// Average color (premultiplied by alpha)
|
||||||
|
var avgR = 0.0
|
||||||
|
var avgG = 0.0
|
||||||
|
var avgB = 0.0
|
||||||
|
var avgA = 0.0
|
||||||
|
for (i in 0 until width * height) {
|
||||||
|
val argb = pixels[i]
|
||||||
|
val alpha = ((argb ushr 24) and 0xff) / 255.0
|
||||||
|
val r = (argb ushr 16) and 0xff
|
||||||
|
val g = (argb ushr 8) and 0xff
|
||||||
|
val b = argb and 0xff
|
||||||
|
avgR += alpha / 255.0 * r
|
||||||
|
avgG += alpha / 255.0 * g
|
||||||
|
avgB += alpha / 255.0 * b
|
||||||
|
avgA += alpha
|
||||||
|
}
|
||||||
|
if (avgA > 0.0) {
|
||||||
|
avgR /= avgA
|
||||||
|
avgG /= avgA
|
||||||
|
avgB /= avgA
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasAlpha = avgA < width * height
|
||||||
|
val lLimit = if (hasAlpha) 5 else 7
|
||||||
|
val lx = max(1, round(lLimit * width.toDouble() / max(width, height)).toInt())
|
||||||
|
val ly = max(1, round(lLimit * height.toDouble() / max(width, height)).toInt())
|
||||||
|
|
||||||
|
val size = width * height
|
||||||
|
val l = DoubleArray(size) // luminance
|
||||||
|
val p = DoubleArray(size) // yellow - blue
|
||||||
|
val q = DoubleArray(size) // red - green
|
||||||
|
val a = DoubleArray(size) // alpha
|
||||||
|
|
||||||
|
// Convert ARGB to LPQA, composited over the average color
|
||||||
|
for (i in 0 until size) {
|
||||||
|
val argb = pixels[i]
|
||||||
|
val alpha = ((argb ushr 24) and 0xff) / 255.0
|
||||||
|
val rPx = (argb ushr 16) and 0xff
|
||||||
|
val gPx = (argb ushr 8) and 0xff
|
||||||
|
val bPx = argb and 0xff
|
||||||
|
val r = avgR * (1 - alpha) + alpha / 255.0 * rPx
|
||||||
|
val g = avgG * (1 - alpha) + alpha / 255.0 * gPx
|
||||||
|
val b = avgB * (1 - alpha) + alpha / 255.0 * bPx
|
||||||
|
l[i] = (r + g + b) / 3.0
|
||||||
|
p[i] = (r + g) / 2.0 - b
|
||||||
|
q[i] = r - g
|
||||||
|
a[i] = alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
val lEnc = encodeChannel(l, width, height, max(3, lx), max(3, ly))
|
||||||
|
val pEnc = encodeChannel(p, width, height, 3, 3)
|
||||||
|
val qEnc = encodeChannel(q, width, height, 3, 3)
|
||||||
|
val aEnc = if (hasAlpha) encodeChannel(a, width, height, 5, 5) else null
|
||||||
|
|
||||||
|
val isLandscape = width > height
|
||||||
|
val lCount = if (isLandscape) ly else lx
|
||||||
|
val hasAlphaBit = if (hasAlpha) 1 else 0
|
||||||
|
val isLandscapeBit = if (isLandscape) 1 else 0
|
||||||
|
|
||||||
|
val header24 =
|
||||||
|
round(63.0 * lEnc.dc).toInt() or
|
||||||
|
(round(31.5 + 31.5 * pEnc.dc).toInt() shl 6) or
|
||||||
|
(round(31.5 + 31.5 * qEnc.dc).toInt() shl 12) or
|
||||||
|
(round(31.0 * lEnc.scale).toInt() shl 18) or
|
||||||
|
(hasAlphaBit shl 23)
|
||||||
|
val header16 =
|
||||||
|
lCount or
|
||||||
|
(round(63.0 * pEnc.scale).toInt() shl 3) or
|
||||||
|
(round(63.0 * qEnc.scale).toInt() shl 9) or
|
||||||
|
(isLandscapeBit shl 15)
|
||||||
|
|
||||||
|
val acChannels = if (hasAlpha) listOf(lEnc.ac, pEnc.ac, qEnc.ac, aEnc!!.ac) else listOf(lEnc.ac, pEnc.ac, qEnc.ac)
|
||||||
|
val totalAc = acChannels.sumOf { it.size }
|
||||||
|
val acStart = if (hasAlpha) 6 else 5
|
||||||
|
val hashSize = acStart + ((totalAc + 1) / 2)
|
||||||
|
val hash = ByteArray(hashSize)
|
||||||
|
hash[0] = (header24 and 0xff).toByte()
|
||||||
|
hash[1] = ((header24 ushr 8) and 0xff).toByte()
|
||||||
|
hash[2] = ((header24 ushr 16) and 0xff).toByte()
|
||||||
|
hash[3] = (header16 and 0xff).toByte()
|
||||||
|
hash[4] = ((header16 ushr 8) and 0xff).toByte()
|
||||||
|
|
||||||
|
if (hasAlpha) {
|
||||||
|
val aDcQ = round(15.0 * aEnc!!.dc).toInt() and 0xf
|
||||||
|
val aScaleQ = round(15.0 * aEnc.scale).toInt() and 0xf
|
||||||
|
hash[5] = (aDcQ or (aScaleQ shl 4)).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
var acIndex = 0
|
||||||
|
for (ac in acChannels) {
|
||||||
|
for (f in ac) {
|
||||||
|
val q4 = round(15.0 * f).toInt() and 0xf
|
||||||
|
val byteIdx = acStart + (acIndex shr 1)
|
||||||
|
val shift = (acIndex and 1) shl 2
|
||||||
|
hash[byteIdx] = (hash[byteIdx].toInt() or (q4 shl shift)).toByte()
|
||||||
|
acIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes an ARGB image to a base64 ThumbHash string (no padding).
|
||||||
|
*/
|
||||||
|
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||||
|
fun encodeToBase64(
|
||||||
|
pixels: IntArray,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
): String = Base64.encode(encode(pixels, width, height)).trimEnd('=')
|
||||||
|
|
||||||
|
private data class ChannelEncoded(
|
||||||
|
val dc: Double,
|
||||||
|
val ac: DoubleArray,
|
||||||
|
val scale: Double,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun encodeChannel(
|
||||||
|
channel: DoubleArray,
|
||||||
|
w: Int,
|
||||||
|
h: Int,
|
||||||
|
nx: Int,
|
||||||
|
ny: Int,
|
||||||
|
): ChannelEncoded {
|
||||||
|
var dc = 0.0
|
||||||
|
var scale = 0.0
|
||||||
|
val acList = ArrayList<Double>((nx * ny))
|
||||||
|
val fx = DoubleArray(w)
|
||||||
|
|
||||||
|
var cy = 0
|
||||||
|
while (cy < ny) {
|
||||||
|
var cx = 0
|
||||||
|
while (cx * ny < nx * (ny - cy)) {
|
||||||
|
var f = 0.0
|
||||||
|
for (x in 0 until w) {
|
||||||
|
fx[x] = cos(PI / w * cx * (x + 0.5))
|
||||||
|
}
|
||||||
|
for (y in 0 until h) {
|
||||||
|
val fy = cos(PI / h * cy * (y + 0.5))
|
||||||
|
for (x in 0 until w) {
|
||||||
|
f += channel[x + y * w] * fx[x] * fy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f /= (w * h).toDouble()
|
||||||
|
if (cx != 0 || cy != 0) {
|
||||||
|
acList.add(f)
|
||||||
|
if (abs(f) > scale) scale = abs(f)
|
||||||
|
} else {
|
||||||
|
dc = f
|
||||||
|
}
|
||||||
|
cx++
|
||||||
|
}
|
||||||
|
cy++
|
||||||
|
}
|
||||||
|
|
||||||
|
val ac = DoubleArray(acList.size) { acList[it] }
|
||||||
|
if (scale != 0.0) {
|
||||||
|
for (i in ac.indices) {
|
||||||
|
ac[i] = 0.5 + 0.5 / scale * ac[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ChannelEncoded(dc, ac, scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.thumbhash
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes this [PlatformImage] to a base64 ThumbHash string (no padding).
|
||||||
|
*
|
||||||
|
* ThumbHash is specified at ≤100x100. Larger images are downscaled first.
|
||||||
|
*/
|
||||||
|
fun PlatformImage.toThumbhash(): String {
|
||||||
|
val source =
|
||||||
|
if (width > 100 || height > 100) {
|
||||||
|
val aspect = width.toDouble() / height.toDouble()
|
||||||
|
val scaled =
|
||||||
|
if (width >= height) {
|
||||||
|
val w = 100
|
||||||
|
val h = (100.0 / aspect).toInt().coerceAtLeast(1)
|
||||||
|
this.scale(w, h)
|
||||||
|
} else {
|
||||||
|
val h = 100
|
||||||
|
val w = (100.0 * aspect).toInt().coerceAtLeast(1)
|
||||||
|
this.scale(w, h)
|
||||||
|
}
|
||||||
|
scaled
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
val pixels = IntArray(source.width * source.height)
|
||||||
|
source.getPixels(pixels, 0, source.width, 0, 0, source.width, source.height)
|
||||||
|
return ThumbHashEncoder.encodeToBase64(pixels, source.width, source.height)
|
||||||
|
}
|
||||||
@@ -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(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.thumbhash
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
|
||||||
|
fun BufferedImage.toThumbhash(): String = this.toPlatformImage().toThumbhash()
|
||||||
+2
@@ -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 {
|
||||||
|
|||||||
+86
@@ -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
|
||||||
|
}
|
||||||
+7
-1
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -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)
|
||||||
|
|||||||
+3
@@ -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)
|
||||||
|
|||||||
+3
@@ -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))
|
||||||
|
|||||||
+3
@@ -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)
|
||||||
|
|||||||
+3
@@ -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))
|
||||||
|
|||||||
+2
@@ -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()
|
||||||
|
|||||||
+5
@@ -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) }
|
||||||
|
|||||||
+3
@@ -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))
|
||||||
|
|||||||
+3
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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)
|
||||||
|
|||||||
+2
-1
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -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) }
|
||||||
|
|||||||
+3
@@ -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)
|
||||||
|
|||||||
+3
@@ -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))
|
||||||
|
|||||||
+39
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-7
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user