mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
feat(hls): emit blurhash and thumbhash on every NIP-71 video imeta
This commit is contained in:
+9
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -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)
|
||||
|
||||
+19
-4
@@ -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}" }
|
||||
|
||||
+2
-1
@@ -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()) }
|
||||
|
||||
Reference in New Issue
Block a user