added blurhash to blossom upload

This commit is contained in:
davotoula
2025-10-29 12:08:14 +01:00
parent 7254dce3a9
commit eee1487883
5 changed files with 154 additions and 40 deletions

View File

@@ -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<BlurhashWrapper?, DimensionTag?> =
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<BlurhashWrapper?, DimensionTag?>? {
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<BlurhashWrapper?, DimensionTag?> =
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<BlurhashWrapper?, DimensionTag?> {
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
}
}
}

View File

@@ -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) {

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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 {