diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt new file mode 100644 index 000000000..c90473a47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt @@ -0,0 +1,124 @@ +/** + * 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.uploads + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.service.images.BlurhashWrapper +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.Log + +object BlurhashMetadataCalculator { + fun shouldAttempt(mimeType: String?): Boolean = + mimeType?.let { + it.startsWith("image/", ignoreCase = true) || it.startsWith("video/", ignoreCase = true) + } ?: false + + fun computeFromBytes( + data: ByteArray, + mimeType: String?, + dimPrecomputed: DimensionTag?, + ): Pair = + when { + mimeType?.startsWith("image/", ignoreCase = true) == true -> { + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, options) + val (blur, dim) = processBitmap(bitmap) + blur to (dim ?: dimPrecomputed) + } + mimeType?.startsWith("video/", ignoreCase = true) == true -> { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(ByteArrayMediaDataSource(data)) + processRetriever(retriever, dimPrecomputed) + } finally { + retriever.release() + } + } + else -> null to dimPrecomputed + } + + fun computeFromUri( + context: Context, + uri: Uri, + mimeType: String?, + dimPrecomputed: DimensionTag? = null, + ): Pair? { + if (!shouldAttempt(mimeType)) return null + + return try { + when { + mimeType?.startsWith("image/", ignoreCase = true) == true -> + context.contentResolver.openInputStream(uri)?.use { stream -> + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + val bitmap = BitmapFactory.decodeStream(stream, null, options) + val (blur, dim) = processBitmap(bitmap) + blur to (dim ?: dimPrecomputed) + } ?: (null to dimPrecomputed) + + mimeType?.startsWith("video/", ignoreCase = true) == true -> { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(context, uri) + processRetriever(retriever, dimPrecomputed) + } finally { + retriever.release() + } + } + else -> null + } + } catch (e: Exception) { + Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e) + null + } + } + + private fun processBitmap(bitmap: Bitmap?): Pair = + if (bitmap != null) { + try { + val blurhash = BlurhashWrapper(bitmap.toBlurhash()) + blurhash to DimensionTag(bitmap.width, bitmap.height) + } finally { + bitmap.recycle() + } + } else { + null to null + } + + private fun processRetriever( + retriever: MediaMetadataRetriever, + dimPrecomputed: DimensionTag?, + ): Pair { + val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed + val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) } + return if (dim?.hasSize() == true) { + blurhash to dim + } else { + blurhash to null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 29d86b669..cd12d86f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -20,11 +20,7 @@ */ package com.vitorpamplona.amethyst.service.uploads -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.media.MediaDataSource -import android.media.MediaMetadataRetriever -import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -75,27 +71,7 @@ class FileHeader( val hash = sha256(data).toHexKey() val size = data.size - val (blurHash, dim) = - if (mimeType?.startsWith("image/") == true) { - val opt = BitmapFactory.Options() - opt.inPreferredConfig = Bitmap.Config.ARGB_8888 - val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) - Pair(BlurhashWrapper(mBitmap.toBlurhash()), DimensionTag(mBitmap.width, mBitmap.height)) - } else if (mimeType?.startsWith("video/") == true) { - val mediaMetadataRetriever = MediaMetadataRetriever() - mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) - - val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed - val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) } - - if (newDim?.hasSize() == true) { - Pair(blurhash, newDim) - } else { - Pair(blurhash, null) - } - } else { - Pair(null, null) - } + val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed) Result.success(FileHeader(mimeType, hash, size, dim, blurHash)) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index ad65e1756..b897d5dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.uploads +import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -42,4 +43,6 @@ data class MediaUploadResult( val infohash: String? = null, // ipfs link val ipfs: String? = null, + // blurhash value for previews + val blurHash: BlurhashWrapper? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 3c087c587..ca4311edd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -245,7 +245,7 @@ class UploadOrchestrator { hash = verification.hash, size = verification.size.toInt(), dim = uploadResult.dimension, - blurHash = null, // Skip blurhash generation for verification + blurHash = uploadResult.blurHash, ) return finish( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 90033b4ca..924b63eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -28,6 +28,7 @@ import android.webkit.MimeTypeMap import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -104,25 +105,35 @@ class BlossomUploader { calculateHashAndSize(it) } + val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) + val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } - return imageInputStream.use { stream -> - upload( - stream, - streamInfo.hash, - streamInfo.size, - fileName, - myContentType, - alt, - sensitiveContent, - serverBaseUrl, - okHttpClient, - httpAuth, - context, + val serverResult = + imageInputStream.use { stream -> + upload( + stream, + streamInfo.hash, + streamInfo.size, + fileName, + myContentType, + alt, + sensitiveContent, + serverBaseUrl, + okHttpClient, + httpAuth, + context, + ) + } + + return localMetadata?.let { (blur, dim) -> + serverResult.copy( + dimension = dim ?: serverResult.dimension, + blurHash = blur ?: serverResult.blurHash, ) - } + } ?: serverResult } fun encodeAuth(event: BlossomAuthorizationEvent): String {