feat(hls): emit blurhash and thumbhash on every NIP-71 video imeta

This commit is contained in:
davotoula
2026-05-10 09:37:37 +02:00
parent ddff04ec88
commit 7550e82283
4 changed files with 47 additions and 8 deletions
@@ -54,6 +54,11 @@ data class HlsVideoPublishInput(
// (gallery thumbnails, previews) have a still to render — the .m3u8 playlist itself is a
// text manifest that can't be decoded as an image frame.
val posterUrl: String? = null,
// Blurhash + thumbhash derived once from the same poster bitmap. Threaded into every imeta
// so receiving clients can render an instant low-res placeholder before the poster JPEG
// finishes loading. Per-rendition values would be redundant — the source frame is the same.
val blurhash: String? = null,
val thumbhash: String? = null,
)
sealed class HlsVideoEventTemplate {
@@ -96,6 +101,8 @@ object HlsVideoEventBuilder {
dimension = masterDimension,
alt = input.alt,
image = posterImage,
blurhash = input.blurhash,
thumbhash = input.thumbhash,
)
val renditionMetas =
@@ -115,6 +122,8 @@ object HlsVideoEventBuilder {
size = combinedMetadata?.size?.toInt(),
dimension = DimensionTag(summary.width, summary.height),
image = posterImage,
blurhash = input.blurhash,
thumbhash = input.thumbhash,
)
}
@@ -55,6 +55,18 @@ data class HlsPublishRequest(
val durationSeconds: Int? = null,
)
/**
* Poster JPEG upload result. Carries the public URL plus blurhash/thumbhash derived from the
* same poster pixels, so every imeta in the published NIP-71 event can render an instant
* low-res placeholder. All three fields share a single source frame; per-rendition variants
* would be redundant.
*/
data class HlsPosterUpload(
val url: String,
val blurhash: String? = null,
val thumbhash: String? = null,
)
/**
* Orchestrates the transcode → upload → build → publish pipeline for a single HLS video publish.
* Delegates transcoding and segment/media-playlist upload plumbing to the library's
@@ -81,7 +93,7 @@ class HlsPublishOrchestrator(
// uploader, returning the public URL. Returns null if poster generation isn't possible
// (unsupported source, decode failure, no readable frame). Failures here must NOT fail
// the whole publish — the orchestrator catches and continues without a poster.
private val uploadPoster: suspend (HlsBlobUploader) -> String? = { _ -> null },
private val uploadPoster: suspend (HlsBlobUploader) -> HlsPosterUpload? = { _ -> null },
) {
val state: StateFlow<HlsPublishState> = _state
@@ -208,7 +220,7 @@ class HlsPublishOrchestrator(
// previews) have a still to render. Tolerate failure: skip the poster rather than
// failing the entire publish, since the user has already paid the cost of the long
// segment uploads.
val posterUrl =
val posterResult =
try {
uploadPoster(uploader)
} catch (e: CancellationException) {
@@ -230,7 +242,9 @@ class HlsPublishOrchestrator(
description = request.description,
durationSeconds = request.durationSeconds,
contentWarning = contentWarningOrNull(request),
posterUrl = posterUrl,
posterUrl = posterResult?.url,
blurhash = posterResult?.blurhash,
thumbhash = posterResult?.thumbhash,
),
)
val eventId = signAndPublish(template)
@@ -28,6 +28,7 @@ import com.davotoula.lightcompressor.hls.HlsContentTypes
import com.davotoula.lightcompressor.hls.HlsUploadHelper
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.getThumbnail
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory
@@ -108,8 +109,9 @@ fun createProductionHlsPublishOrchestrator(
)
/**
* Extracts a still frame from the source video at [uri], encodes it as JPEG, and uploads it
* via [uploader]. Returns the public URL on success or null if any step fails (unsupported
* Extracts a still frame from the source video at [uri], encodes it as JPEG, computes blurhash +
* thumbhash from the same JPEG bytes, and uploads it via [uploader]. Returns an [HlsPosterUpload]
* carrying the public URL plus the two hashes on success, or null if any step fails (unsupported
* source, no readable frame, encode/upload error). The orchestrator treats null as "publish
* without a poster" — failure here must never abort the publish.
*/
@@ -117,11 +119,24 @@ private suspend fun generateAndUploadPoster(
context: Context,
uri: Uri,
uploader: HlsBlobUploader,
): String? {
): HlsPosterUpload? {
val posterFile = extractPosterToTempFile(context, uri) ?: return null
return try {
// Read once: hash from the same bytes we upload, so the hashes describe the exact
// JPEG receiving clients will fetch via the imeta `image` URL.
val posterBytes = posterFile.readBytes()
val hashes =
runCatching {
PreviewMetadataCalculator.computeFromBytes(posterBytes, POSTER_CONTENT_TYPE, null)
}.onFailure { Log.w(TAG) { "uploadPoster: blurhash/thumbhash compute failed: ${it.message}" } }
.getOrNull()
val result = uploader.upload(posterFile, POSTER_CONTENT_TYPE) { _, _ -> }
result.url
val url = result.url ?: return null
HlsPosterUpload(
url = url,
blurhash = hashes?.blurhash?.blurhash,
thumbhash = hashes?.thumbhash?.thumbhash,
)
} finally {
if (!posterFile.delete()) {
Log.w(TAG) { "uploadPoster: failed to delete temp file ${posterFile.absolutePath}" }
@@ -30,6 +30,7 @@ import com.davotoula.lightcompressor.hls.Rendition
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPosterUpload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishRequest
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishState
@@ -387,7 +388,7 @@ class HlsPublishOrchestratorTest {
captured += tpl
"event-id"
},
uploadPoster = { _ -> "https://cdn.test/poster.jpg" },
uploadPoster = { _ -> HlsPosterUpload("https://cdn.test/poster.jpg") },
)
runBlocking { orchestrator.publish(newRequest()) }