From 90235e91f2ac15953c4643c79201eaf3759a5dc7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 09:07:11 +0200 Subject: [PATCH] refactor(hls): upgrade to lightcompressor-enhanced 2.1.1-hls-SNAPSHOT Collapses Amethyst's hand-rolled HLS orchestration onto the library's HlsUploadHelper.run. The library now ships everything the prior session had to reimplement: per-rendition width/height/codec metadata on the onRenditionComplete callback, a public PlaylistRewriter, canonical HlsContentTypes constants, and the transcode -> upload -> rewrite loop itself. - bump libs.versions.toml to 2.1.1-hls-SNAPSHOT - delete HlsUploadPipeline, HlsBundle, HlsTranscoder, HlsTranscodingSession, HlsPlaylistRewriter and their tests; HlsUploadHelper.run + the library rewriter cover everything they did - rewrite HlsVideoEventBuilder to consume HlsRenditionSummary width/height directly; drops the master-playlist streamInfRegex entirely - HlsPublishOrchestrator now wraps HlsUploadHelper.run: a SimpleHlsListener drives Transcoding progress while the uploader lambda captures each MediaUploadResult in a side-channel map keyed by the library's suggestedFilename, so per-rendition sha256/size still flow into the NIP-71 imeta tags - extract HlsBlobUploader into its own file (was inline in the deleted HlsUploadPipeline.kt) Net delta on HLS code: 3194 -> 2182 lines (-1012, ~32%). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hls/{HlsBundle.kt => HlsBlobUploader.kt} | 29 +- .../uploads/hls/HlsPlaylistRewriter.kt | 49 --- .../service/uploads/hls/HlsTranscoder.kt | 74 ----- .../uploads/hls/HlsTranscodingSession.kt | 113 ------- .../service/uploads/hls/HlsUploadPipeline.kt | 133 -------- .../uploads/hls/HlsVideoEventBuilder.kt | 67 ++-- .../video/hls/HlsPublishOrchestrator.kt | 120 +++++-- .../hls/HlsPublishOrchestratorFactory.kt | 35 +- .../uploads/hls/HlsPlaylistRewriterTest.kt | 284 ----------------- .../uploads/hls/HlsPublishOrchestratorTest.kt | 208 ++++++++---- .../uploads/hls/HlsTranscodingSessionTest.kt | 211 ------------- .../uploads/hls/HlsUploadPipelineTest.kt | 298 ------------------ .../uploads/hls/HlsVideoEventBuilderTest.kt | 137 ++++---- gradle/libs.versions.toml | 2 +- 14 files changed, 374 insertions(+), 1386 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/{HlsBundle.kt => HlsBlobUploader.kt} (59%) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt delete mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt similarity index 59% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt index cd9d2f138..a6050229e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploader.kt @@ -20,17 +20,22 @@ */ package com.vitorpamplona.amethyst.service.uploads.hls +import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import java.io.File -data class HlsBundle( - val workDir: File, - val masterPlaylist: String, - val renditions: List, -) - -data class HlsBundleRendition( - val label: String, - val combinedFile: File, - val mediaPlaylist: String, - val bitrateKbps: Int, -) +/** + * Abstraction over a blob upload transport so the HLS publish orchestrator can stay + * unit-testable. Production wiring adapts this to either + * [com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader] or + * [com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader]. The HLS orchestrator + * wraps this in a String-returning lambda for + * [com.davotoula.lightcompressor.hls.HlsUploadHelper.run] and captures each + * [MediaUploadResult] in a side-channel map keyed by the library's suggested filename so the + * per-rendition sha256/size can flow into the NIP-71 event's imeta tags. + */ +fun interface HlsBlobUploader { + suspend fun upload( + file: File, + contentType: String, + ): MediaUploadResult +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt deleted file mode 100644 index cae04678c..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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.hls - -object HlsPlaylistRewriter { - private val uriRegex = Regex("""URI="([^"]+)"""") - - fun rewrite( - playlist: String, - urlMap: Map, - ): String = - playlist.lines().joinToString("\n") { line -> - when { - line.isBlank() -> line - line.startsWith("#") -> rewriteUriInDirective(line, urlMap) - else -> urlMap[line] ?: missing(line) - } - } - - private fun rewriteUriInDirective( - line: String, - urlMap: Map, - ): String = - uriRegex.replace(line) { match -> - val original = match.groupValues[1] - val rewritten = urlMap[original] ?: missing(original) - """URI="$rewritten"""" - } - - private fun missing(reference: String): Nothing = throw IllegalArgumentException("No uploaded URL for playlist reference: $reference") -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt deleted file mode 100644 index 463317b10..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.hls - -import android.content.Context -import android.net.Uri -import com.davotoula.lightcompressor.HlsPreparer -import com.davotoula.lightcompressor.VideoCodec -import com.davotoula.lightcompressor.hls.HlsConfig -import com.davotoula.lightcompressor.hls.HlsLadder -import kotlinx.coroutines.CancellationException -import java.io.File - -/** - * Runs a full HLS preparation over the given source URI and returns the resulting [HlsBundle] once - * every rendition has been emitted, combined files moved into [workDir], and the master playlist - * received. Defaults to the library-provided [HlsConfig] (all five renditions of - * [com.davotoula.lightcompressor.hls.HlsLadder.default], single-file-per-rendition, 6s segments); - * the caller picks the video codec. - * - * Cancellation: if the caller's coroutine is cancelled while awaiting the bundle, we forward that - * cancellation to [HlsPreparer.cancel] so MediaCodec work stops. The underlying temp dir created by - * HlsPreparer is cleaned up by the library; the caller is responsible for cleaning up [workDir] - * after uploading is done. - * - * Not concurrent-safe: [HlsPreparer] is a process-wide singleton and only supports one preparation - * at a time. Overlapping calls will cancel the previous preparation. - */ -object HlsTranscoder { - suspend fun transcode( - context: Context, - uri: Uri, - workDir: File, - codec: VideoCodec, - ladder: HlsLadder = HlsLadder.default(), - onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> }, - ): HlsBundle { - workDir.mkdirs() - val session = HlsTranscodingSession(workDir, onRenditionProgress) - val config = HlsConfig(codec = codec, ladder = ladder) - - HlsPreparer.start( - context = context, - uri = uri, - config = config, - listener = session, - ) - - return try { - session.terminal.await() - } catch (e: CancellationException) { - HlsPreparer.cancel() - throw e - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt deleted file mode 100644 index 2516b8b38..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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.hls - -import com.davotoula.lightcompressor.hls.HlsError -import com.davotoula.lightcompressor.hls.HlsListener -import com.davotoula.lightcompressor.hls.HlsSegment -import com.davotoula.lightcompressor.hls.Rendition -import kotlinx.coroutines.CompletableDeferred -import java.io.File -import java.io.IOException - -/** - * Accumulates HlsListener callbacks from a single-file-per-rendition HLS preparation into an - * [HlsBundle] that the upload pipeline can consume. The combined fMP4 files emitted by - * `onSegmentReady` are moved (renameTo, with copyTo fallback) to [workDir]/{label}.mp4 so the - * library's temp dir can be cleaned up and the bundle is self-contained. - * - * Terminal states are exposed via [terminal]: completes with [HlsBundle] on success, completes - * exceptionally on failure, is cancelled on user cancel. - */ -class HlsTranscodingSession( - private val workDir: File, - private val onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> }, -) : HlsListener { - val terminal: CompletableDeferred = CompletableDeferred() - - private val combinedByLabel = mutableMapOf() - private val completed = mutableListOf() - - override fun onStart(renditionCount: Int) = Unit - - override fun onRenditionStart(rendition: Rendition) = Unit - - override fun onSegmentReady( - rendition: Rendition, - segment: HlsSegment, - ) { - if (!segment.isCombinedRendition) return - - val target = File(workDir, "${rendition.resolution.label}.mp4") - if (target.exists() && !target.delete()) { - throw IOException("Could not replace existing $target") - } - if (!segment.file.renameTo(target)) { - segment.file.copyTo(target, overwrite = true) - } - combinedByLabel[rendition.resolution.label] = target - } - - override fun onRenditionComplete( - rendition: Rendition, - playlist: String, - ) { - val combined = - combinedByLabel[rendition.resolution.label] - ?: error("onRenditionComplete without prior onSegmentReady for ${rendition.resolution.label}") - completed += - HlsBundleRendition( - label = rendition.resolution.label, - combinedFile = combined, - mediaPlaylist = playlist, - bitrateKbps = rendition.bitrateKbps, - ) - } - - override fun onComplete(masterPlaylist: String) { - terminal.complete( - HlsBundle( - workDir = workDir, - masterPlaylist = masterPlaylist, - renditions = completed.toList(), - ), - ) - } - - override fun onFailure(error: HlsError) { - terminal.completeExceptionally(HlsTranscodingException(error.message)) - } - - override fun onCancelled() { - terminal.cancel() - } - - override fun onProgress( - rendition: Rendition, - percent: Float, - ) { - onRenditionProgress(rendition.resolution.label, percent.toInt()) - } -} - -class HlsTranscodingException( - message: String, -) : RuntimeException(message) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt deleted file mode 100644 index b84e845d6..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * 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.hls - -import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult -import java.io.File - -/** - * Abstraction over a blob upload transport so [HlsUploadPipeline] can stay unit-testable. - * Production wiring adapts this to either [com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader] - * or [com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader]. - */ -fun interface HlsBlobUploader { - suspend fun upload( - file: File, - contentType: String, - ): MediaUploadResult -} - -data class HlsUploadResult( - val masterUrl: String, - val masterSha256: String?, - val renditions: List, -) - -data class HlsUploadedRendition( - val label: String, - val combinedUrl: String, - val combinedSha256: String?, - val combinedSize: Long?, - val playlistUrl: String, - val bitrateKbps: Int, -) - -/** - * Orchestrates the upload half of the HLS publish pipeline. For each rendition: - * 1. uploads the combined fMP4 file, - * 2. rewrites the media playlist so its byterange entries point at the uploaded blob URL, - * 3. uploads the rewritten playlist. - * Finally rewrites the master playlist to reference the per-rendition playlist URLs and uploads - * the master. The resulting [HlsUploadResult] is what the publisher uses to build the NIP-71 - * event. - * - * URL handling policy: the pipeline uses the URL the server returned verbatim. No extension is - * appended, no trailing dot stripped, no bare-hash rewriting. The server is responsible for - * returning a URL that the player can fetch as-is — that way we get the best cache coherence, - * correct Content-Type, clean range requests, and no double round trips. If a server returns an - * unplayable URL, the fix is server-side. - */ -class HlsUploadPipeline( - private val uploader: HlsBlobUploader, -) { - suspend fun upload( - bundle: HlsBundle, - onProgress: (done: Int, total: Int, currentLabel: String) -> Unit = { _, _, _ -> }, - ): HlsUploadResult { - val playlistDir = File(bundle.workDir, "playlists").apply { mkdirs() } - val total = bundle.renditions.size * 2 + 1 - var done = 0 - - val uploadedRenditions = - bundle.renditions.map { rendition -> - onProgress(done, total, "${rendition.label} video") - val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4) - done++ - val combinedUrl = - combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}") - - val rewrittenMedia = - HlsPlaylistRewriter.rewrite( - rendition.mediaPlaylist, - mapOf("${rendition.label}.mp4" to combinedUrl), - ) - val mediaPlaylistFile = - File(playlistDir, "${rendition.label}-media.m3u8").apply { writeText(rewrittenMedia) } - onProgress(done, total, "${rendition.label} playlist") - val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS) - done++ - val mediaPlaylistUrl = - mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}") - - HlsUploadedRendition( - label = rendition.label, - combinedUrl = combinedUrl, - combinedSha256 = combined.sha256, - combinedSize = combined.size, - playlistUrl = mediaPlaylistUrl, - bitrateKbps = rendition.bitrateKbps, - ) - } - - val masterUrlMap = - uploadedRenditions.associate { "${it.label}/media.m3u8" to it.playlistUrl } - val rewrittenMaster = HlsPlaylistRewriter.rewrite(bundle.masterPlaylist, masterUrlMap) - val masterFile = File(playlistDir, "master.m3u8").apply { writeText(rewrittenMaster) } - onProgress(done, total, "master playlist") - val master = uploader.upload(masterFile, CONTENT_TYPE_HLS) - done++ - val masterUrl = - master.url ?: error("Uploader returned null URL for master playlist") - - onProgress(done, total, "") - - return HlsUploadResult( - masterUrl = masterUrl, - masterSha256 = master.sha256, - renditions = uploadedRenditions, - ) - } - - companion object { - const val CONTENT_TYPE_VIDEO_MP4 = "video/mp4" - const val CONTENT_TYPE_HLS = "application/vnd.apple.mpegurl" - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt index 014ebd567..cb6418261 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.service.uploads.hls -import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline.Companion.CONTENT_TYPE_HLS +import com.davotoula.lightcompressor.hls.HlsContentTypes +import com.davotoula.lightcompressor.hls.HlsRenditionSummary +import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -35,8 +37,10 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid data class HlsVideoPublishInput( - val bundle: HlsBundle, - val uploadResult: HlsUploadResult, + val renditions: List, + val segmentUploads: Map, + val masterUrl: String, + val masterSha256: String?, val title: String, val description: String, val alt: String? = null, @@ -58,42 +62,52 @@ sealed class HlsVideoEventTemplate { /** * Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload - * result. Orientation is decided from the first `#EXT-X-STREAM-INF RESOLUTION` in the bundle's - * master playlist: portrait (height > width) selects kind 34236, otherwise 34235. + * result. Orientation is decided from the first rendition's width/height: portrait + * (height > width) selects kind 34236, otherwise 34235. * * The template carries one `imeta` tag for the master playlist (primary) plus one per rendition * so HLS-unaware clients can still pick a specific variant. Every imeta is marked - * `m application/vnd.apple.mpegurl`. + * `m application/vnd.apple.mpegurl`. The rendition imeta's `x`/`size` come from the combined + * fMP4 upload (single-file layout) while the `url` points at the rewritten media playlist. * * Returns the unsigned template wrapped in a sealed [HlsVideoEventTemplate]; the caller signs * via the account's signer and publishes via the relay client. */ @OptIn(ExperimentalUuidApi::class) object HlsVideoEventBuilder { - private val streamInfRegex = Regex("""#EXT-X-STREAM-INF:[^\n]*RESOLUTION=(\d+)x(\d+)""") - fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate { - val renditionDimensions = parseRenditionDimensions(input.bundle.masterPlaylist) - val isVertical = renditionDimensions.firstOrNull()?.let { it.height > it.width } ?: false + val firstRendition = input.renditions.firstOrNull() + val isVertical = firstRendition != null && firstRendition.height > firstRendition.width - val masterDimension = renditionDimensions.maxByOrNull { it.width * it.height }?.toDimensionTag() + val largest = input.renditions.maxByOrNull { it.width * it.height } + val masterDimension = largest?.let { DimensionTag(it.width, it.height) } val masterVideoMeta = VideoMeta( - url = input.uploadResult.masterUrl, - mimeType = CONTENT_TYPE_HLS, - hash = input.uploadResult.masterSha256, + url = input.masterUrl, + mimeType = HlsContentTypes.HLS_PLAYLIST, + hash = input.masterSha256, dimension = masterDimension, alt = input.alt, ) val renditionMetas = - input.uploadResult.renditions.mapIndexed { index, uploaded -> + input.renditions.map { summary -> + val combinedFilename = + summary.combinedFilename + ?: "${summary.rendition.resolution.label}.mp4" + val combinedUpload = input.segmentUploads[combinedFilename] + val playlistUpload = + input.segmentUploads[summary.playlistFilename] + ?: error("No upload recorded for media playlist ${summary.playlistFilename}") + VideoMeta( - url = uploaded.playlistUrl, - mimeType = CONTENT_TYPE_HLS, - hash = uploaded.combinedSha256, - size = uploaded.combinedSize?.toInt(), - dimension = renditionDimensions.getOrNull(index)?.toDimensionTag(), + url = + playlistUpload.url + ?: error("Uploader returned null URL for media playlist ${summary.playlistFilename}"), + mimeType = HlsContentTypes.HLS_PLAYLIST, + hash = combinedUpload?.sha256, + size = combinedUpload?.size?.toInt(), + dimension = DimensionTag(summary.width, summary.height), ) } @@ -121,17 +135,4 @@ object HlsVideoEventBuilder { ) } } - - private data class RenditionDimension( - val width: Int, - val height: Int, - ) { - fun toDimensionTag(): DimensionTag = DimensionTag(width, height) - } - - private fun parseRenditionDimensions(masterPlaylist: String): List = - streamInfRegex - .findAll(masterPlaylist) - .map { RenditionDimension(it.groupValues[1].toInt(), it.groupValues[2].toInt()) } - .toList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt index 4951847c6..f031b5c6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -21,10 +21,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.hls.HlsConfig +import com.davotoula.lightcompressor.hls.HlsContentTypes import com.davotoula.lightcompressor.hls.HlsLadder +import com.davotoula.lightcompressor.hls.HlsListener +import com.davotoula.lightcompressor.hls.HlsRenditionSummary +import com.davotoula.lightcompressor.hls.HlsSegment +import com.davotoula.lightcompressor.hls.HlsUploadResult +import com.davotoula.lightcompressor.hls.Rendition +import com.davotoula.lightcompressor.hls.SimpleHlsListener +import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader -import com.vitorpamplona.amethyst.service.uploads.hls.HlsBundle -import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput @@ -47,50 +54,115 @@ data class HlsPublishRequest( /** * 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 + * [com.davotoula.lightcompressor.hls.HlsUploadHelper] via the injected [runUpload] closure, then + * uploads the rewritten master playlist itself, builds the NIP-71 event, signs, and publishes. * All Android/account-specific concerns are injected as suspending callbacks so the whole state * machine is unit-testable. * - * State transitions: Idle → Transcoding → Uploading → Publishing → Success, or → Failure on any - * exception. The [state] flow emits each transition as it happens so the UI can reflect progress. + * State transitions: Idle → Transcoding (per rendition, driven by listener) → Uploading (per + * segment/playlist upload, driven by the uploader lambda) → Publishing → Success, or → Failure + * on any exception. */ class HlsPublishOrchestrator( private val _state: MutableStateFlow, - private val runTranscode: suspend ( - workDir: File, - codec: VideoCodec, - ladder: HlsLadder, - onProgress: (label: String, percent: Int) -> Unit, - ) -> HlsBundle, + private val runUpload: suspend ( + config: HlsConfig, + listener: HlsListener, + uploadFile: suspend (File, String) -> String, + ) -> HlsUploadResult, private val buildUploader: (ServerName) -> HlsBlobUploader, + private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, - private val workDirFactory: () -> File, ) { val state: StateFlow = _state suspend fun publish(request: HlsPublishRequest) { - val workDir = workDirFactory() try { _state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0) - val bundle = - runTranscode(workDir, request.codec, request.ladder) { label, percent -> - _state.value = HlsPublishState.Transcoding(label, percent) + + val uploader = buildUploader(request.server) + val config = + HlsConfig( + codec = request.codec, + ladder = request.ladder, + ) + + val totalSegmentUploads = request.ladder.renditions.size * 2 + val totalUploads = totalSegmentUploads + 1 + var uploadsDone = 0 + val segmentUploads = mutableMapOf() + + val listener = + object : SimpleHlsListener() { + override fun onRenditionStart(rendition: Rendition) { + if (_state.value !is HlsPublishState.Uploading) { + _state.value = HlsPublishState.Transcoding(rendition.resolution.label, 0) + } + } + + override fun onProgress( + rendition: Rendition, + percent: Float, + ) { + _state.value = HlsPublishState.Transcoding(rendition.resolution.label, percent.toInt()) + } + + override fun onSegmentReady( + rendition: Rendition, + segment: HlsSegment, + ) { + // The uploader lambda runs synchronously inside onSegmentReady; keep the + // progress overlay on "transcoding" here because the state update from + // the lambda itself will flip us into Uploading at upload time. + } + + override fun onRenditionComplete( + rendition: Rendition, + summary: HlsRenditionSummary, + ) = Unit } - val uploadTotal = bundle.renditions.size * 2 + 1 - _state.value = HlsPublishState.Uploading(done = 0, total = uploadTotal) - val uploader = buildUploader(request.server) - val pipeline = HlsUploadPipeline(uploader) val uploadResult = - pipeline.upload(bundle) { done, total, label -> - _state.value = HlsPublishState.Uploading(done, total, label) + runUpload(config, listener) { file, suggestedFilename -> + _state.value = + HlsPublishState.Uploading( + done = uploadsDone, + total = totalUploads, + currentLabel = suggestedFilename, + ) + val contentType = + if (suggestedFilename.endsWith(".m3u8")) { + HlsContentTypes.forPlaylist() + } else { + HlsContentTypes.FMP4_SEGMENT + } + val result = uploader.upload(file, contentType) + uploadsDone++ + segmentUploads[suggestedFilename] = result + result.url + ?: error("Uploader returned null URL for $suggestedFilename") } + _state.value = + HlsPublishState.Uploading( + done = uploadsDone, + total = totalUploads, + currentLabel = "master.m3u8", + ) + val masterUpload = uploadMaster(uploader, uploadResult.masterPlaylist) + uploadsDone++ + val masterUrl = + masterUpload.url ?: error("Uploader returned null URL for master playlist") + _state.value = HlsPublishState.Publishing val template = HlsVideoEventBuilder.build( HlsVideoPublishInput( - bundle = bundle, - uploadResult = uploadResult, + renditions = uploadResult.renditions, + segmentUploads = segmentUploads, + masterUrl = masterUrl, + masterSha256 = masterUpload.sha256, title = request.title, description = request.description, durationSeconds = request.durationSeconds, @@ -102,7 +174,7 @@ class HlsPublishOrchestrator( _state.value = HlsPublishState.Success( eventId = eventId, - masterUrl = uploadResult.masterUrl, + masterUrl = masterUrl, ) } catch (e: CancellationException) { _state.value = HlsPublishState.Failure(message = "Cancelled") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt index fbd0b5d7c..77722ba68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt @@ -22,19 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls import android.content.Context import android.net.Uri +import com.davotoula.lightcompressor.hls.HlsContentTypes +import com.davotoula.lightcompressor.hls.HlsUploadHelper import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory -import com.vitorpamplona.amethyst.service.uploads.hls.HlsTranscoder import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import kotlinx.coroutines.flow.MutableStateFlow import java.io.File /** - * Production wiring for [HlsPublishOrchestrator]. Binds the transcode to [HlsTranscoder], the - * uploader factory to [HlsBlobUploaderFactory], and the signAndPublish closure to the account's - * signer + outbox publish path. + * Production wiring for [HlsPublishOrchestrator]. Binds the upload closure to + * [HlsUploadHelper.run], the uploader factory to [HlsBlobUploaderFactory], and the + * signAndPublish closure to the account's signer + outbox publish path. * - * The Uri is read via [uriProvider] on each transcode invocation so the orchestrator can be built + * The Uri is read via [uriProvider] on each publish invocation so the orchestrator can be built * once (at VM load) before the user actually picks a video. */ fun createProductionHlsPublishOrchestrator( @@ -45,20 +46,29 @@ fun createProductionHlsPublishOrchestrator( ): HlsPublishOrchestrator = HlsPublishOrchestrator( _state = state, - runTranscode = { workDir, codec, ladder, onProgress -> + runUpload = { config, listener, uploadFile -> val uri = uriProvider() ?: error("No video picked") - HlsTranscoder.transcode( + HlsUploadHelper.run( context = context, uri = uri, - workDir = workDir, - codec = codec, - ladder = ladder, - onRenditionProgress = onProgress, + config = config, + listener = listener, + uploader = uploadFile, ) }, buildUploader = { server -> HlsBlobUploaderFactory.create(server, account, context) }, + uploadMaster = { uploader, masterPlaylist -> + val masterFile = + File(context.cacheDir, "hls-master-${System.currentTimeMillis()}.m3u8") + try { + masterFile.writeText(masterPlaylist) + uploader.upload(masterFile, HlsContentTypes.forPlaylist()) + } finally { + masterFile.delete() + } + }, signAndPublish = { template -> val signed = when (template) { @@ -68,7 +78,4 @@ fun createProductionHlsPublishOrchestrator( account.sendAutomatic(signed) signed.id }, - workDirFactory = { - File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } - }, ) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt deleted file mode 100644 index b837e1a77..000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt +++ /dev/null @@ -1,284 +0,0 @@ -/* - * 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.hls - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class HlsPlaylistRewriterTest { - @Test - fun rewritesSegmentReferencesInMediaPlaylist() { - val playlist = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-TARGETDURATION:4 - #EXT-X-MEDIA-SEQUENCE:0 - #EXTINF:4.000, - segment_000.m4s - #EXTINF:4.000, - segment_001.m4s - #EXT-X-ENDLIST - """.trimIndent() - - val urlMap = - mapOf( - "segment_000.m4s" to "https://cdn.example.com/abc.m4s", - "segment_001.m4s" to "https://cdn.example.com/def.m4s", - ) - - val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap) - - val expected = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-TARGETDURATION:4 - #EXT-X-MEDIA-SEQUENCE:0 - #EXTINF:4.000, - https://cdn.example.com/abc.m4s - #EXTINF:4.000, - https://cdn.example.com/def.m4s - #EXT-X-ENDLIST - """.trimIndent() - - assertEquals(expected, rewritten) - } - - @Test - fun preservesExtInfLinesExactly() { - val playlist = - """ - #EXTINF:3.9836, - segment_000.m4s - """.trimIndent() - - val rewritten = - HlsPlaylistRewriter.rewrite( - playlist, - mapOf("segment_000.m4s" to "https://cdn/x.m4s"), - ) - - assertEquals( - "#EXTINF:3.9836,\nhttps://cdn/x.m4s", - rewritten, - ) - } - - @Test - fun rewritesExtXMapUri() { - val playlist = - """ - #EXTM3U - #EXT-X-MAP:URI="init.mp4" - #EXTINF:4.000, - segment_000.m4s - """.trimIndent() - - val urlMap = - mapOf( - "init.mp4" to "https://cdn/init-abc.mp4", - "segment_000.m4s" to "https://cdn/seg-def.m4s", - ) - - val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap) - - val expected = - """ - #EXTM3U - #EXT-X-MAP:URI="https://cdn/init-abc.mp4" - #EXTINF:4.000, - https://cdn/seg-def.m4s - """.trimIndent() - - assertEquals(expected, rewritten) - } - - @Test - fun extXMapPreservesAdditionalAttributes() { - val playlist = """#EXT-X-MAP:URI="init.mp4",BYTERANGE="718@0"""" - - val rewritten = - HlsPlaylistRewriter.rewrite( - playlist, - mapOf("init.mp4" to "https://cdn/abc.mp4"), - ) - - assertEquals( - """#EXT-X-MAP:URI="https://cdn/abc.mp4",BYTERANGE="718@0"""", - rewritten, - ) - } - - @Test - fun rewritesVariantsInMasterPlaylist() { - val playlist = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" - 360p/media.m3u8 - #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" - 720p/media.m3u8 - """.trimIndent() - - val urlMap = - mapOf( - "360p/media.m3u8" to "https://cdn/360.m3u8", - "720p/media.m3u8" to "https://cdn/720.m3u8", - ) - - val rewritten = HlsPlaylistRewriter.rewrite(playlist, urlMap) - - val expected = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" - https://cdn/360.m3u8 - #EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" - https://cdn/720.m3u8 - """.trimIndent() - - assertEquals(expected, rewritten) - } - - @Test - fun preservesExtXStreamInfLinesExactly() { - val playlist = - """ - #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" - 360p/media.m3u8 - """.trimIndent() - - val rewritten = - HlsPlaylistRewriter.rewrite( - playlist, - mapOf("360p/media.m3u8" to "https://cdn/360.m3u8"), - ) - - val expected = - """ - #EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" - https://cdn/360.m3u8 - """.trimIndent() - - assertEquals(expected, rewritten) - } - - @Test - fun leavesBlankLinesAndCommentsUnchanged() { - val playlist = - """ - #EXTM3U - # this is a comment - - #EXT-X-VERSION:7 - #EXTINF:4.000, - segment_000.m4s - """.trimIndent() - - val rewritten = - HlsPlaylistRewriter.rewrite( - playlist, - mapOf("segment_000.m4s" to "https://cdn/x.m4s"), - ) - - val expected = - """ - #EXTM3U - # this is a comment - - #EXT-X-VERSION:7 - #EXTINF:4.000, - https://cdn/x.m4s - """.trimIndent() - - assertEquals(expected, rewritten) - } - - @Test - fun rewritesSingleFileByterangePlaylist() { - // Matches PlaylistGenerator.buildByteRangeMediaPlaylist output when HlsConfig - // singleFilePerRendition=true (the default). All segment references point to - // the same combined fMP4 file; #EXT-X-BYTERANGE lines must be preserved. - val playlist = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-TARGETDURATION:6 - #EXT-X-MEDIA-SEQUENCE:0 - #EXT-X-PLAYLIST-TYPE:VOD - #EXT-X-MAP:URI="360p.mp4",BYTERANGE="1234@0" - - #EXTINF:6.000, - #EXT-X-BYTERANGE:500000@1234 - 360p.mp4 - #EXTINF:6.000, - #EXT-X-BYTERANGE:480000@501234 - 360p.mp4 - #EXT-X-ENDLIST - """.trimIndent() - - val rewritten = - HlsPlaylistRewriter.rewrite( - playlist, - mapOf("360p.mp4" to "https://cdn/abc.mp4"), - ) - - val expected = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-TARGETDURATION:6 - #EXT-X-MEDIA-SEQUENCE:0 - #EXT-X-PLAYLIST-TYPE:VOD - #EXT-X-MAP:URI="https://cdn/abc.mp4",BYTERANGE="1234@0" - - #EXTINF:6.000, - #EXT-X-BYTERANGE:500000@1234 - https://cdn/abc.mp4 - #EXTINF:6.000, - #EXT-X-BYTERANGE:480000@501234 - https://cdn/abc.mp4 - #EXT-X-ENDLIST - """.trimIndent() - - assertEquals(expected, rewritten) - } - - @Test - fun throwsWhenSegmentReferenceIsMissingFromUrlMap() { - val playlist = - """ - #EXTINF:4.000, - segment_000.m4s - """.trimIndent() - - val ex = - assertThrows(IllegalArgumentException::class.java) { - HlsPlaylistRewriter.rewrite(playlist, emptyMap()) - } - - assertEquals("No uploaded URL for playlist reference: segment_000.m4s", ex.message) - } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt index 3fda699b1..ff6b65f12 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -20,7 +20,12 @@ */ package com.vitorpamplona.amethyst.service.uploads.hls +import com.davotoula.lightcompressor.Resolution import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.hls.HlsLadder +import com.davotoula.lightcompressor.hls.HlsRenditionSummary +import com.davotoula.lightcompressor.hls.HlsUploadResult +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 @@ -53,33 +58,48 @@ class HlsPublishOrchestratorTest { workDir.deleteRecursively() } - private fun fakeBundle(labels: List = listOf("360p")): HlsBundle { - val renditions = - labels.map { label -> - val file = File(workDir, "$label.mp4").apply { writeText("bytes-$label") } - HlsBundleRendition( - label = label, - combinedFile = file, - mediaPlaylist = - """ - #EXTM3U - #EXT-X-MAP:URI="$label.mp4",BYTERANGE="100@0" - #EXTINF:6.0, - $label.mp4 - """.trimIndent(), - bitrateKbps = 500, - ) + private fun landscapeSummary( + resolution: Resolution = Resolution.SD_360, + width: Int = 640, + height: Int = 360, + ): HlsRenditionSummary = + HlsRenditionSummary( + rendition = Rendition(resolution, bitrateKbps = 500), + mediaPlaylist = "", + playlistFilename = "${resolution.label}/media.m3u8", + width = width, + height = height, + codecString = "avc1.64001f", + combinedFilename = "${resolution.label}.mp4", + ) + + /** + * Simulates an HlsUploadHelper run: it calls [uploadFile] once per segment (combined .mp4) + * and once per media playlist, in the same order the real helper would, then returns a + * fake [HlsUploadResult] with the supplied rendition summaries. + */ + private fun fakeRunUpload(renditions: List = listOf(landscapeSummary())): suspend ( + config: com.davotoula.lightcompressor.hls.HlsConfig, + listener: com.davotoula.lightcompressor.hls.HlsListener, + uploadFile: suspend (File, String) -> String, + ) -> HlsUploadResult = + { _, listener, uploadFile -> + listener.onStart(renditions.size) + for (summary in renditions) { + listener.onRenditionStart(summary.rendition) + listener.onProgress(summary.rendition, 50f) + val combinedFile = File(workDir, summary.combinedFilename!!).apply { writeText("combined-${summary.rendition.resolution.label}") } + uploadFile(combinedFile, summary.combinedFilename!!) + val playlistFile = + File(workDir, "${summary.rendition.resolution.label}-media.m3u8").apply { writeText("playlist-${summary.rendition.resolution.label}") } + uploadFile(playlistFile, summary.playlistFilename) } - val master = - buildString { - appendLine("#EXTM3U") - labels.forEachIndexed { i, label -> - appendLine("#EXT-X-STREAM-INF:BANDWIDTH=${(i + 1) * 500000},RESOLUTION=${640 + i * 320}x${360 + i * 180}") - appendLine("$label/media.m3u8") - } - } - return HlsBundle(workDir, master, renditions) - } + listener.onComplete("#EXTM3U\nfake-master-playlist") + HlsUploadResult( + masterPlaylist = "#EXTM3U\nrewritten-master-playlist", + renditions = renditions, + ) + } private class CannedUploader : HlsBlobUploader { var count = 0 @@ -93,11 +113,22 @@ class HlsPublishOrchestratorTest { } } + private fun fakeUploadMaster(uploader: HlsBlobUploader): suspend (HlsBlobUploader, String) -> MediaUploadResult = + { _, masterPlaylist -> + val tmp = File(workDir, "master-${System.nanoTime()}.m3u8").apply { writeText(masterPlaylist) } + try { + uploader.upload(tmp, "application/vnd.apple.mpegurl") + } finally { + tmp.delete() + } + } + private fun newRequest( title: String = "My HD Clip", description: String = "A test clip", sensitive: Boolean = false, warningReason: String = "", + ladder: HlsLadder = HlsLadder(listOf(Rendition(Resolution.SD_360, 500))), ) = HlsPublishRequest( title = title, description = description, @@ -105,21 +136,23 @@ class HlsPublishOrchestratorTest { contentWarningReason = warningReason, codec = VideoCodec.H265, server = server, + ladder = ladder, ) @Test fun happyPathEndsInSuccessWithMasterUrlAndEventId() { val publishedTemplates = mutableListOf() + val canned = CannedUploader() val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> fakeBundle() }, - buildUploader = { CannedUploader() }, + runUpload = fakeRunUpload(), + buildUploader = { canned }, + uploadMaster = fakeUploadMaster(canned), signAndPublish = { tpl -> publishedTemplates += tpl "signed-event-id" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -144,24 +177,40 @@ class HlsPublishOrchestratorTest { val capturedDuringUpload = mutableListOf() val capturedDuringPublish = mutableListOf() + val canned = CannedUploader() + val capturingRunUpload: suspend ( + com.davotoula.lightcompressor.hls.HlsConfig, + com.davotoula.lightcompressor.hls.HlsListener, + suspend (File, String) -> String, + ) -> HlsUploadResult = { _, listener, uploadFile -> + val summary = landscapeSummary() + listener.onStart(1) + listener.onRenditionStart(summary.rendition) + capturedDuringTranscode += orchestrator.state.value + listener.onProgress(summary.rendition, 42f) + capturedDuringTranscode += orchestrator.state.value + val combinedFile = File(workDir, "360p.mp4").apply { writeText("bytes") } + uploadFile(combinedFile, "360p.mp4") + capturedDuringUpload += orchestrator.state.value + val playlistFile = File(workDir, "360p-media.m3u8").apply { writeText("bytes") } + uploadFile(playlistFile, "360p/media.m3u8") + listener.onComplete("#EXTM3U\nmaster") + HlsUploadResult( + masterPlaylist = "#EXTM3U\nrewritten", + renditions = listOf(summary), + ) + } + orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, onProgress -> - capturedDuringTranscode += orchestrator.state.value - onProgress("360p", 42) - capturedDuringTranscode += orchestrator.state.value - fakeBundle() - }, - buildUploader = { - capturedDuringUpload += orchestrator.state.value - CannedUploader() - }, + runUpload = capturingRunUpload, + buildUploader = { canned }, + uploadMaster = fakeUploadMaster(canned), signAndPublish = { capturedDuringPublish += orchestrator.state.value "event-id" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -181,10 +230,10 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> throw RuntimeException("decode failed") }, + runUpload = { _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, + uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, signAndPublish = { "never" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -199,12 +248,12 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> fakeBundle() }, + runUpload = fakeRunUpload(), buildUploader = { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } }, + uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, signAndPublish = { "never" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -215,14 +264,34 @@ class HlsPublishOrchestratorTest { } @Test - fun publishExceptionTransitionsToFailure() { + fun masterUploadExceptionTransitionsToFailure() { + val canned = CannedUploader() val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> fakeBundle() }, - buildUploader = { CannedUploader() }, + runUpload = fakeRunUpload(), + buildUploader = { canned }, + uploadMaster = { _, _ -> throw RuntimeException("master upload failed") }, + signAndPublish = { "never" }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue(final is HlsPublishState.Failure) + assertEquals("master upload failed", (final as HlsPublishState.Failure).message) + } + + @Test + fun publishExceptionTransitionsToFailure() { + val canned = CannedUploader() + val orchestrator = + HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), + runUpload = fakeRunUpload(), + buildUploader = { canned }, + uploadMaster = fakeUploadMaster(canned), signAndPublish = { throw RuntimeException("relay rejected") }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -235,16 +304,17 @@ class HlsPublishOrchestratorTest { @Test fun sensitiveContentPassesContentWarningIntoTemplate() { val captured = mutableListOf() + val canned = CannedUploader() val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> fakeBundle() }, - buildUploader = { CannedUploader() }, + runUpload = fakeRunUpload(), + buildUploader = { canned }, + uploadMaster = fakeUploadMaster(canned), signAndPublish = { tpl -> captured += tpl "event-id" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { @@ -258,31 +328,31 @@ class HlsPublishOrchestratorTest { } @Test - fun portraitBundleProducesVerticalTemplate() { - val portraitMaster = - """ - #EXTM3U - #EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=360x640 - 360p/media.m3u8 - """.trimIndent() - val rendition = - HlsBundleRendition( - label = "360p", - combinedFile = File(workDir, "360p.mp4").apply { writeText("bytes") }, - mediaPlaylist = "#EXTM3U\n#EXT-X-MAP:URI=\"360p.mp4\"\n#EXTINF:6.0,\n360p.mp4\n", - bitrateKbps = 500, + fun portraitRenditionsProduceVerticalTemplate() { + val portrait = + listOf( + HlsRenditionSummary( + rendition = Rendition(Resolution.SD_360, 500), + mediaPlaylist = "", + playlistFilename = "360p/media.m3u8", + width = 360, + height = 640, + codecString = "avc1.64001f", + combinedFilename = "360p.mp4", + ), ) val captured = mutableListOf() + val canned = CannedUploader() val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, - buildUploader = { CannedUploader() }, + runUpload = fakeRunUpload(portrait), + buildUploader = { canned }, + uploadMaster = fakeUploadMaster(canned), signAndPublish = { tpl -> captured += tpl "event-id" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } @@ -295,10 +365,10 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _, _ -> throw RuntimeException("boom") }, + runUpload = { _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, + uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, signAndPublish = { "never" }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) runBlocking { orchestrator.publish(newRequest()) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt deleted file mode 100644 index 98e04c7f2..000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -/* - * 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.hls - -import com.davotoula.lightcompressor.Resolution -import com.davotoula.lightcompressor.hls.HlsError -import com.davotoula.lightcompressor.hls.HlsSegment -import com.davotoula.lightcompressor.hls.Rendition -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import java.io.File -import java.nio.file.Files - -@OptIn(ExperimentalCoroutinesApi::class) -class HlsTranscodingSessionTest { - private lateinit var workDir: File - - @Before - fun setUp() { - workDir = Files.createTempDirectory("hls-session-test").toFile() - } - - @After - fun tearDown() { - workDir.deleteRecursively() - } - - private fun rendition360p() = Rendition(Resolution.SD_360, 500) - - private fun rendition540p() = Rendition(Resolution.SD_540, 1200) - - private fun fakeCombinedSegment(payload: String = "fake-mp4-bytes"): HlsSegment { - val temp = Files.createTempFile("hls-seg", ".mp4").toFile() - temp.writeText(payload) - return HlsSegment( - file = temp, - index = 0, - durationSeconds = 6.0, - isInitSegment = false, - isCombinedRendition = true, - ) - } - - private fun driveHappyPath( - session: HlsTranscodingSession, - rendition: Rendition, - playlist: String, - segmentPayload: String = "fake-mp4-bytes", - ) { - session.onStart(1) - session.onRenditionStart(rendition) - session.onSegmentReady(rendition, fakeCombinedSegment(segmentPayload)) - session.onRenditionComplete(rendition, playlist) - } - - @Test - fun onCompleteEmitsHlsBundleWithMasterPlaylist() { - val session = HlsTranscodingSession(workDir) - val rendition = rendition360p() - val mediaPlaylist = "#EXTM3U\n#EXT-X-MAP:URI=\"360p.mp4\"\n" - val masterPlaylist = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\n360p/media.m3u8\n" - - driveHappyPath(session, rendition, mediaPlaylist) - session.onComplete(masterPlaylist) - - val bundle = session.terminal.getCompleted() - assertEquals(masterPlaylist, bundle.masterPlaylist) - assertEquals(1, bundle.renditions.size) - assertEquals("360p", bundle.renditions[0].label) - assertEquals(mediaPlaylist, bundle.renditions[0].mediaPlaylist) - assertEquals(500, bundle.renditions[0].bitrateKbps) - } - - @Test - fun onSegmentReadyRenamesCombinedFileToWorkDir() { - val session = HlsTranscodingSession(workDir) - val rendition = rendition360p() - val segment = fakeCombinedSegment(payload = "payload-360p") - val originalPath = segment.file.absolutePath - - session.onStart(1) - session.onRenditionStart(rendition) - session.onSegmentReady(rendition, segment) - session.onRenditionComplete(rendition, "#EXTM3U\n") - session.onComplete("#EXTM3U\n") - - val bundle = session.terminal.getCompleted() - val combined = bundle.renditions[0].combinedFile - - assertEquals(File(workDir, "360p.mp4"), combined) - assertTrue(combined.exists()) - assertEquals("payload-360p", combined.readText()) - assertFalse(File(originalPath).exists()) - } - - @Test - fun happyPathWithTwoRenditionsProducesBundleWithBoth() { - val session = HlsTranscodingSession(workDir) - - session.onStart(2) - session.onRenditionStart(rendition360p()) - session.onSegmentReady(rendition360p(), fakeCombinedSegment("p360")) - session.onRenditionComplete(rendition360p(), "p360-playlist") - - session.onRenditionStart(rendition540p()) - session.onSegmentReady(rendition540p(), fakeCombinedSegment("p540")) - session.onRenditionComplete(rendition540p(), "p540-playlist") - - session.onComplete("master-playlist") - - val bundle = session.terminal.getCompleted() - assertEquals(2, bundle.renditions.size) - assertEquals(listOf("360p", "540p"), bundle.renditions.map { it.label }) - assertEquals("p360-playlist", bundle.renditions[0].mediaPlaylist) - assertEquals("p540-playlist", bundle.renditions[1].mediaPlaylist) - assertEquals("p360", bundle.renditions[0].combinedFile.readText()) - assertEquals("p540", bundle.renditions[1].combinedFile.readText()) - } - - @Test - fun onFailureCompletesTerminalExceptionally() { - val session = HlsTranscodingSession(workDir) - session.onStart(1) - session.onFailure(HlsError("boom", emptyList(), emptyList())) - - assertTrue(session.terminal.isCompleted) - try { - session.terminal.getCompleted() - fail("expected exception") - } catch (e: Throwable) { - assertNotNull(e.message) - assertTrue(e.message!!.contains("boom")) - } - } - - @Test - fun onCancelledCancelsTerminal() { - val session = HlsTranscodingSession(workDir) - session.onStart(1) - session.onCancelled() - - assertTrue(session.terminal.isCancelled) - } - - @Test - fun onProgressForwardsToCallback() { - val observed = mutableListOf>() - val session = - HlsTranscodingSession(workDir) { label, percent -> - observed += label to percent - } - - session.onStart(1) - session.onRenditionStart(rendition360p()) - session.onProgress(rendition360p(), 33.7f) - session.onProgress(rendition360p(), 75.0f) - - assertEquals(listOf("360p" to 33, "360p" to 75), observed) - } - - @Test - fun nonCombinedSegmentsAreIgnored() { - val session = HlsTranscodingSession(workDir) - val rendition = rendition360p() - val nonCombined = - HlsSegment( - file = Files.createTempFile("hls-init", ".mp4").toFile().apply { writeText("init") }, - index = 0, - durationSeconds = 0.0, - isInitSegment = true, - isCombinedRendition = false, - ) - val combined = fakeCombinedSegment("combined") - - session.onStart(1) - session.onRenditionStart(rendition) - session.onSegmentReady(rendition, nonCombined) - session.onSegmentReady(rendition, combined) - session.onRenditionComplete(rendition, "playlist") - session.onComplete("master") - - val bundle = session.terminal.getCompleted() - assertEquals(1, bundle.renditions.size) - assertEquals("combined", bundle.renditions[0].combinedFile.readText()) - } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt deleted file mode 100644 index 1b5e7b2df..000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * 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.hls - -import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.File -import java.nio.file.Files - -class HlsUploadPipelineTest { - private lateinit var workDir: File - - @Before - fun setUp() { - workDir = Files.createTempDirectory("hls-pipeline-test").toFile() - } - - @After - fun tearDown() { - workDir.deleteRecursively() - } - - private class FakeUploader : HlsBlobUploader { - data class Call( - val fileName: String, - val contentType: String, - val content: String, - ) - - val calls = mutableListOf() - - override suspend fun upload( - file: File, - contentType: String, - ): MediaUploadResult { - val content = file.readText() - calls += Call(file.name, contentType, content) - val url = "https://cdn.test/${calls.size}-${file.name}" - return MediaUploadResult(url = url, sha256 = "sha-${calls.size}", size = file.length()) - } - } - - private class BareUrlUploader : HlsBlobUploader { - val calls = mutableListOf>() - - override suspend fun upload( - file: File, - contentType: String, - ): MediaUploadResult { - val content = file.readText() - calls += Triple(file.name, contentType, content) - return MediaUploadResult(url = "https://blossom.test/bare-${calls.size}", sha256 = "sha-${calls.size}", size = file.length()) - } - } - - private fun createBundle(labels: List): HlsBundle { - val renditions = - labels.map { label -> - val combined = File(workDir, "$label.mp4").apply { writeText("bytes-$label") } - val mediaPlaylist = - """ - #EXTM3U - #EXT-X-VERSION:7 - #EXT-X-MAP:URI="$label.mp4",BYTERANGE="1000@0" - - #EXTINF:6.000, - #EXT-X-BYTERANGE:500000@1000 - $label.mp4 - #EXT-X-ENDLIST - """.trimIndent() - HlsBundleRendition( - label = label, - combinedFile = combined, - mediaPlaylist = mediaPlaylist, - bitrateKbps = 500 + labels.indexOf(label) * 1000, - ) - } - - val masterLines = - buildList { - add("#EXTM3U") - add("#EXT-X-VERSION:7") - renditions.forEach { - add("#EXT-X-STREAM-INF:BANDWIDTH=${it.bitrateKbps * 1000}") - add("${it.label}/media.m3u8") - } - } - - return HlsBundle( - workDir = workDir, - masterPlaylist = masterLines.joinToString("\n"), - renditions = renditions, - ) - } - - @Test - fun uploadsCombinedThenMediaThenMasterInOrder() { - val bundle = createBundle(listOf("360p")) - val uploader = FakeUploader() - val pipeline = HlsUploadPipeline(uploader) - - runBlocking { pipeline.upload(bundle) } - - assertEquals(3, uploader.calls.size) - assertEquals("360p.mp4", uploader.calls[0].fileName) - assertEquals("video/mp4", uploader.calls[0].contentType) - assertTrue(uploader.calls[1].fileName.endsWith(".m3u8")) - assertEquals("application/vnd.apple.mpegurl", uploader.calls[1].contentType) - assertEquals("application/vnd.apple.mpegurl", uploader.calls[2].contentType) - } - - @Test - fun mediaPlaylistIsRewrittenWithUploadedCombinedUrl() { - val bundle = createBundle(listOf("360p")) - val uploader = FakeUploader() - val pipeline = HlsUploadPipeline(uploader) - - runBlocking { pipeline.upload(bundle) } - - val combinedUrl = "https://cdn.test/1-360p.mp4" - val uploadedMediaPlaylist = uploader.calls[1].content - assertTrue(uploadedMediaPlaylist.contains(combinedUrl)) - // Original filename reference must be gone - assertTrue(!uploadedMediaPlaylist.lines().any { it.trim() == "360p.mp4" }) - // EXTINF metadata must still be present - assertTrue(uploadedMediaPlaylist.contains("#EXTINF:6.000,")) - // BYTERANGE must still be present - assertTrue(uploadedMediaPlaylist.contains("#EXT-X-BYTERANGE:500000@1000")) - } - - @Test - fun masterPlaylistIsRewrittenWithUploadedMediaPlaylistUrls() { - val bundle = createBundle(listOf("360p", "540p")) - val uploader = FakeUploader() - val pipeline = HlsUploadPipeline(uploader) - - runBlocking { pipeline.upload(bundle) } - - // 2 renditions × (combined + media) + 1 master = 5 uploads - assertEquals(5, uploader.calls.size) - val masterContent = uploader.calls[4].content - - // The uploaded media playlist URLs should appear in the rewritten master - val media360Url = uploader.calls[1].content.let { "https://cdn.test/2-" } // 2nd call is 360p media - // Extract the actual URLs the fake returned for each media playlist upload - val media360PlaylistUrl = "https://cdn.test/2-" + uploader.calls[1].fileName - val media540PlaylistUrl = "https://cdn.test/4-" + uploader.calls[3].fileName - assertTrue("master should contain $media360PlaylistUrl", masterContent.contains(media360PlaylistUrl)) - assertTrue("master should contain $media540PlaylistUrl", masterContent.contains(media540PlaylistUrl)) - - // EXT-X-STREAM-INF metadata must survive - assertTrue(masterContent.contains("#EXT-X-STREAM-INF:BANDWIDTH=500000")) - assertTrue(masterContent.contains("#EXT-X-STREAM-INF:BANDWIDTH=1500000")) - // Original rendition filenames must be gone - assertTrue(!masterContent.lines().any { it.trim() == "360p/media.m3u8" }) - assertTrue(!masterContent.lines().any { it.trim() == "540p/media.m3u8" }) - } - - @Test - fun bareServerUrlsPassThroughVerbatim() { - // Policy: the pipeline uses whatever URL the server returned, unchanged. - // Even a bare-hash URL with no extension flows straight into the rewritten - // playlists. If it does not play, the fix is server-side (return a playable URL). - val bundle = createBundle(listOf("360p")) - val uploader = BareUrlUploader() - val pipeline = HlsUploadPipeline(uploader) - - val result = runBlocking { pipeline.upload(bundle) } - - assertEquals("https://blossom.test/bare-1", result.renditions[0].combinedUrl) - assertEquals("https://blossom.test/bare-2", result.renditions[0].playlistUrl) - assertEquals("https://blossom.test/bare-3", result.masterUrl) - // The rewritten media playlist that the server received must contain the bare url. - val uploadedMediaPlaylist = uploader.calls[1].third - assertTrue( - "media playlist should reference bare url: $uploadedMediaPlaylist", - uploadedMediaPlaylist.contains("https://blossom.test/bare-1"), - ) - } - - @Test - fun serverUrlsAlreadyWithExtensionPassThroughUntouched() { - // Matches the server-side fix where the NIP-96 plugin returns clean ".m3u8" - // URLs. The pipeline must not append a second ".m3u8" on top. - val bundle = createBundle(listOf("360p")) - val uploader = - object : HlsBlobUploader { - var count = 0 - - override suspend fun upload( - file: File, - contentType: String, - ): MediaUploadResult { - count++ - val ext = - when (contentType) { - HlsUploadPipeline.CONTENT_TYPE_VIDEO_MP4 -> "mp4" - HlsUploadPipeline.CONTENT_TYPE_HLS -> "m3u8" - else -> "bin" - } - return MediaUploadResult( - url = "https://server.test/hash-$count.$ext", - sha256 = "sha-$count", - size = file.length(), - ) - } - } - val pipeline = HlsUploadPipeline(uploader) - - val result = runBlocking { pipeline.upload(bundle) } - - assertEquals("https://server.test/hash-1.mp4", result.renditions[0].combinedUrl) - assertEquals("https://server.test/hash-2.m3u8", result.renditions[0].playlistUrl) - assertEquals("https://server.test/hash-3.m3u8", result.masterUrl) - // And crucially, no double-extension anywhere: - assertTrue(!result.masterUrl.contains(".m3u8.m3u8")) - assertTrue(!result.renditions[0].combinedUrl.contains(".mp4.mp4")) - assertTrue(!result.renditions[0].playlistUrl.contains(".m3u8.m3u8")) - } - - @Test - fun reportsUploadProgressWithCurrentLabelBeforeEachStep() { - val bundle = createBundle(listOf("360p", "540p")) - val uploader = FakeUploader() - val pipeline = HlsUploadPipeline(uploader) - val observed = mutableListOf>() - - runBlocking { - pipeline.upload(bundle) { done, total, label -> - observed += Triple(done, total, label) - } - } - - // 2 renditions × 2 + 1 master = 5 uploads. Label is emitted BEFORE each upload with - // the done-count of previously-completed uploads, so the UI can show "Uploading 360p - // video (0 / 5)" while the first file is actually in flight. A trailing (5, 5, "") - // marks the final completion. - assertEquals( - listOf( - Triple(0, 5, "360p video"), - Triple(1, 5, "360p playlist"), - Triple(2, 5, "540p video"), - Triple(3, 5, "540p playlist"), - Triple(4, 5, "master playlist"), - Triple(5, 5, ""), - ), - observed, - ) - } - - @Test - fun resultExposesMasterUrlAndPerRenditionDetails() { - val bundle = createBundle(listOf("360p", "540p")) - val uploader = FakeUploader() - val pipeline = HlsUploadPipeline(uploader) - - val result = runBlocking { pipeline.upload(bundle) } - - // Master was the 5th upload - assertEquals("https://cdn.test/5-master.m3u8", result.masterUrl) - assertEquals("sha-5", result.masterSha256) - - assertEquals(2, result.renditions.size) - val r360 = result.renditions[0] - assertEquals("360p", r360.label) - assertEquals("https://cdn.test/1-360p.mp4", r360.combinedUrl) - assertEquals("sha-1", r360.combinedSha256) - assertEquals(500, r360.bitrateKbps) - - val r540 = result.renditions[1] - assertEquals("540p", r540.label) - assertEquals("https://cdn.test/3-540p.mp4", r540.combinedUrl) - assertEquals(1500, r540.bitrateKbps) - } -} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt index 450fdc30e..accecc54d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.service.uploads.hls +import com.davotoula.lightcompressor.Resolution +import com.davotoula.lightcompressor.hls.HlsRenditionSummary +import com.davotoula.lightcompressor.hls.Rendition +import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import org.junit.Assert.assertEquals @@ -27,75 +31,67 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import java.io.File class HlsVideoEventBuilderTest { - private val landscapeMasterPlaylist = - """ - #EXTM3U - #EXT-X-VERSION:7 - - #EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=640x360,CODECS="avc1.64001e,mp4a.40.2" - 360p/media.m3u8 - - #EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2" - 720p/media.m3u8 - """.trimIndent() - - private val portraitMasterPlaylist = - """ - #EXTM3U - #EXT-X-VERSION:7 - - #EXT-X-STREAM-INF:BANDWIDTH=500000,RESOLUTION=360x640,CODECS="avc1.64001e,mp4a.40.2" - 360p/media.m3u8 - """.trimIndent() - - private fun bundle(master: String): HlsBundle { - val workDir = File("/tmp/unused-builder-test") - val labels = Regex("""(\d+p)/media\.m3u8""").findAll(master).map { it.groupValues[1] }.toList() - val renditions = - labels.mapIndexed { i, label -> - HlsBundleRendition( - label = label, - combinedFile = File(workDir, "$label.mp4"), - mediaPlaylist = "", // not needed by the builder - bitrateKbps = 500 + i * 2000, - ) - } - return HlsBundle(workDir, master, renditions) - } - - private fun uploadResult(renditions: List): HlsUploadResult = - HlsUploadResult( - masterUrl = "https://cdn.test/master.m3u8", - masterSha256 = "master-sha", - renditions = - renditions.map { - HlsUploadedRendition( - label = it.label, - combinedUrl = "https://cdn.test/${it.label}.mp4", - combinedSha256 = "${it.label}-sha", - combinedSize = 1_000_000L, - playlistUrl = "https://cdn.test/${it.label}-media.m3u8", - bitrateKbps = it.bitrateKbps, - ) - }, + private val landscapeRenditions = + listOf( + summary(Resolution.SD_360, width = 640, height = 360), + summary(Resolution.HD_720, width = 1280, height = 720), ) + private val portraitRenditions = + listOf( + summary(Resolution.SD_360, width = 360, height = 640), + ) + + private fun summary( + resolution: Resolution, + width: Int, + height: Int, + ): HlsRenditionSummary = + HlsRenditionSummary( + rendition = Rendition(resolution, bitrateKbps = 500), + mediaPlaylist = "", // not needed by the builder + playlistFilename = "${resolution.label}/media.m3u8", + width = width, + height = height, + codecString = "avc1.64001f", + combinedFilename = "${resolution.label}.mp4", + ) + + private fun segmentUploadsFor(renditions: List): Map { + val uploads = mutableMapOf() + renditions.forEach { r -> + val label = r.rendition.resolution.label + uploads["$label.mp4"] = + MediaUploadResult( + url = "https://cdn.test/$label.mp4", + sha256 = "$label-sha", + size = 1_000_000L, + ) + uploads["$label/media.m3u8"] = + MediaUploadResult( + url = "https://cdn.test/$label-media.m3u8", + sha256 = "$label-playlist-sha", + ) + } + return uploads + } + private fun input( - master: String, + renditions: List, title: String = "My HD Video", description: String = "A cool video", alt: String? = null, duration: Int? = null, contentWarning: String? = null, dTag: String? = "fixed-d-tag", - ): HlsVideoPublishInput { - val b = bundle(master) - return HlsVideoPublishInput( - bundle = b, - uploadResult = uploadResult(b.renditions), + ): HlsVideoPublishInput = + HlsVideoPublishInput( + renditions = renditions, + segmentUploads = segmentUploadsFor(renditions), + masterUrl = "https://cdn.test/master.m3u8", + masterSha256 = "master-sha", title = title, description = description, alt = alt, @@ -104,15 +100,14 @@ class HlsVideoEventBuilderTest { dTag = dTag, createdAt = 1_700_000_000L, ) - } private fun Array>.findTag(name: String): Array? = firstOrNull { it.isNotEmpty() && it[0] == name } private fun Array>.findAllTags(name: String): List> = filter { it.isNotEmpty() && it[0] == name } @Test - fun landscapeMasterBuildsHorizontalTemplateKind34235() { - val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + fun landscapeRenditionsBuildHorizontalTemplateKind34235() { + val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal) val template = (result as HlsVideoEventTemplate.Horizontal).template @@ -121,8 +116,8 @@ class HlsVideoEventBuilderTest { } @Test - fun portraitMasterBuildsVerticalTemplateKind34236() { - val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) + fun portraitRenditionsBuildVerticalTemplateKind34236() { + val result = HlsVideoEventBuilder.build(input(portraitRenditions)) assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical) val template = (result as HlsVideoEventTemplate.Vertical).template @@ -131,7 +126,7 @@ class HlsVideoEventBuilderTest { @Test fun horizontalTemplateHasTitleAndDTag() { - val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val title = tags.findTag("title") @@ -145,7 +140,7 @@ class HlsVideoEventBuilderTest { @Test fun templateContainsOneImetaForMasterAndOnePerRendition() { - val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val imetas = tags.findAllTags("imeta") @@ -173,7 +168,7 @@ class HlsVideoEventBuilderTest { fun durationTagWhenDurationProvided() { val result = HlsVideoEventBuilder.build( - input(landscapeMasterPlaylist, duration = 123), + input(landscapeRenditions, duration = 123), ) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags @@ -184,7 +179,7 @@ class HlsVideoEventBuilderTest { @Test fun noDurationTagWhenNotProvided() { - val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags assertNull(tags.findTag("duration")) } @@ -193,7 +188,7 @@ class HlsVideoEventBuilderTest { fun contentWarningTagWhenProvided() { val result = HlsVideoEventBuilder.build( - input(landscapeMasterPlaylist, contentWarning = "NSFW"), + input(landscapeRenditions, contentWarning = "NSFW"), ) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags @@ -204,14 +199,14 @@ class HlsVideoEventBuilderTest { @Test fun noContentWarningTagWhenNull() { - val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags assertNull(tags.findTag("content-warning")) } @Test fun horizontalTemplateCarriesAutoGeneratedAltTag() { - val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val alt = tags.findTag("alt") assertNotNull(alt) @@ -220,7 +215,7 @@ class HlsVideoEventBuilderTest { @Test fun verticalTemplateCarriesVerticalAltTag() { - val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) + val result = HlsVideoEventBuilder.build(input(portraitRenditions)) val tags = (result as HlsVideoEventTemplate.Vertical).template.tags val alt = tags.findTag("alt") assertNotNull(alt) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c7c34c03..49928a747 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,7 @@ genaiPrompt = "1.0.0-beta2" genaiRewriting = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" -lightcompressor-enhanced = "2.1.0" +lightcompressor-enhanced = "2.1.1-hls-SNAPSHOT" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3"