From 80b7b38b27bc5fbaef9c77887ca2f15f58cd67f4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 15:18:55 +0200 Subject: [PATCH 01/26] feat(hls): add HlsPlaylistRewriter for post-upload URL substitution Pure rewriter that walks HLS master or media playlists line-by-line and substitutes each resource reference (segment file, EXT-X-MAP init, variant media.m3u8) with its uploaded absolute URL. Preserves every #EXT-X-STREAM-INF, #EXTINF and other directive line verbatim so the BANDWIDTH/RESOLUTION/CODECS attributes that ExoPlayer's AdaptiveTrackSelection reads stay intact. Loud failure on missing entries in the URL map to avoid silent data loss. Bumps lightcompressor-enhanced to 2.1.0 for the HlsPreparer API that the following milestones will wrap. Milestone 1 of the HLS video sharing plan (2026-04-13). Co-Authored-By: Claude Opus 4.5 --- .../uploads/hls/HlsPlaylistRewriter.kt | 49 ++++ .../uploads/hls/HlsPlaylistRewriterTest.kt | 234 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt 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 new file mode 100644 index 0000000000..cae04678c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriter.kt @@ -0,0 +1,49 @@ +/* + * 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/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt new file mode 100644 index 0000000000..7c1b725764 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPlaylistRewriterTest.kt @@ -0,0 +1,234 @@ +/* + * 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 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6bb0a4be3f..1c7c34c03d 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.0.0" +lightcompressor-enhanced = "2.1.0" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3" From 07ae5d3ac755d632f68c4b3286ebe68da37f430a Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 15:30:31 +0200 Subject: [PATCH 02/26] test(hls): add byterange playlist rewrite case Verifies HlsPlaylistRewriter handles the default single-file-per-rendition output from HlsPreparer where every segment reference points to the same combined fMP4 file and EXT-X-BYTERANGE lines must be preserved unchanged. Co-Authored-By: Claude Opus 4.5 --- .../uploads/hls/HlsPlaylistRewriterTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 index 7c1b725764..b837e1a77e 100644 --- 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 @@ -216,6 +216,56 @@ class HlsPlaylistRewriterTest { 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 = From c008daee7ea632d11e5d8265d6137dcb8f141b5b Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 15:35:01 +0200 Subject: [PATCH 03/26] feat(hls): publish orchestrator + ViewModel state machine feat(hls): build NIP-71 video event templates from upload result feat(hls): orchestrate per-rendition and master uploads feat(hls): wrap HlsPreparer into HlsTranscoder + HlsBundle --- .../amethyst/service/uploads/hls/HlsBundle.kt | 36 +++ .../service/uploads/hls/HlsTranscoder.kt | 72 +++++ .../uploads/hls/HlsTranscodingSession.kt | 113 +++++++ .../service/uploads/hls/HlsUploadPipeline.kt | 147 +++++++++ .../uploads/hls/HlsVideoEventBuilder.kt | 137 ++++++++ .../video/hls/HlsPublishOrchestrator.kt | 113 +++++++ .../loggedIn/video/hls/HlsPublishState.kt | 46 +++ .../video/hls/NewHlsVideoViewModel.kt | 146 +++++++++ .../uploads/hls/HlsPublishOrchestratorTest.kt | 301 ++++++++++++++++++ .../uploads/hls/HlsTranscodingSessionTest.kt | 211 ++++++++++++ .../uploads/hls/HlsUploadPipelineTest.kt | 273 ++++++++++++++++ .../uploads/hls/HlsVideoEventBuilderTest.kt | 229 +++++++++++++ 12 files changed, 1824 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.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/HlsBundle.kt new file mode 100644 index 0000000000..cd9d2f1389 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBundle.kt @@ -0,0 +1,36 @@ +/* + * 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 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, +) 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 new file mode 100644 index 0000000000..111ed89140 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscoder.kt @@ -0,0 +1,72 @@ +/* + * 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 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, + onRenditionProgress: (label: String, percent: Int) -> Unit = { _, _ -> }, + ): HlsBundle { + workDir.mkdirs() + val session = HlsTranscodingSession(workDir, onRenditionProgress) + val config = HlsConfig(codec = codec) + + 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 new file mode 100644 index 0000000000..2516b8b38c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSession.kt @@ -0,0 +1,113 @@ +/* + * 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 new file mode 100644 index 0000000000..4c0221dbde --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipeline.kt @@ -0,0 +1,147 @@ +/* + * 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. + */ +class HlsUploadPipeline( + private val uploader: HlsBlobUploader, +) { + suspend fun upload( + bundle: HlsBundle, + onProgress: (done: Int, total: Int) -> 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 -> + val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4) + onProgress(++done, total) + val combinedUrl = + withExtensionHint( + combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}"), + CONTENT_TYPE_VIDEO_MP4, + ) + + val rewrittenMedia = + HlsPlaylistRewriter.rewrite( + rendition.mediaPlaylist, + mapOf("${rendition.label}.mp4" to combinedUrl), + ) + val mediaPlaylistFile = + File(playlistDir, "${rendition.label}-media.m3u8").apply { writeText(rewrittenMedia) } + val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS) + onProgress(++done, total) + val mediaPlaylistUrl = + withExtensionHint( + mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}"), + CONTENT_TYPE_HLS, + ) + + 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) } + val master = uploader.upload(masterFile, CONTENT_TYPE_HLS) + onProgress(++done, total) + val masterUrl = + withExtensionHint( + master.url ?: error("Uploader returned null URL for master playlist"), + CONTENT_TYPE_HLS, + ) + + return HlsUploadResult( + masterUrl = masterUrl, + masterSha256 = master.sha256, + renditions = uploadedRenditions, + ) + } + + // Blossom servers typically return bare-hash URLs (https://server/), but HLS parsers + // and ExoPlayer's Util.inferContentType sniff the URL extension to pick the right source + // factory. Append a hint unless the upload server already baked one in. + private fun withExtensionHint( + url: String, + contentType: String, + ): String { + val ext = + when (contentType) { + CONTENT_TYPE_VIDEO_MP4 -> ".mp4" + CONTENT_TYPE_HLS -> ".m3u8" + else -> return url + } + return if (url.endsWith(ext, ignoreCase = true)) url else url + ext + } + + 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 new file mode 100644 index 0000000000..014ebd567e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilder.kt @@ -0,0 +1,137 @@ +/* + * 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.hls.HlsUploadPipeline.Companion.CONTENT_TYPE_HLS +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoMeta +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip71Video.duration +import com.vitorpamplona.quartz.nip71Video.title +import com.vitorpamplona.quartz.nip71Video.videoIMetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +data class HlsVideoPublishInput( + val bundle: HlsBundle, + val uploadResult: HlsUploadResult, + val title: String, + val description: String, + val alt: String? = null, + val durationSeconds: Int? = null, + val contentWarning: String? = null, + val dTag: String? = null, + val createdAt: Long? = null, +) + +sealed class HlsVideoEventTemplate { + data class Horizontal( + val template: EventTemplate, + ) : HlsVideoEventTemplate() + + data class Vertical( + val template: EventTemplate, + ) : 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. + * + * 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`. + * + * 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 masterDimension = renditionDimensions.maxByOrNull { it.width * it.height }?.toDimensionTag() + val masterVideoMeta = + VideoMeta( + url = input.uploadResult.masterUrl, + mimeType = CONTENT_TYPE_HLS, + hash = input.uploadResult.masterSha256, + dimension = masterDimension, + alt = input.alt, + ) + + val renditionMetas = + input.uploadResult.renditions.mapIndexed { index, uploaded -> + VideoMeta( + url = uploaded.playlistUrl, + mimeType = CONTENT_TYPE_HLS, + hash = uploaded.combinedSha256, + size = uploaded.combinedSize?.toInt(), + dimension = renditionDimensions.getOrNull(index)?.toDimensionTag(), + ) + } + + val videoMetas = listOf(masterVideoMeta) + renditionMetas + val dTag = input.dTag ?: Uuid.random().toString() + val createdAt = input.createdAt ?: TimeUtils.now() + + return if (isVertical) { + HlsVideoEventTemplate.Vertical( + VideoVerticalEvent.build(input.description, dTag, createdAt) { + videoIMetas(videoMetas) + title(input.title) + input.durationSeconds?.let { duration(it) } + input.contentWarning?.let { contentWarning(it) } + }, + ) + } else { + HlsVideoEventTemplate.Horizontal( + VideoHorizontalEvent.build(input.description, dTag, createdAt) { + videoIMetas(videoMetas) + title(input.title) + input.durationSeconds?.let { duration(it) } + input.contentWarning?.let { contentWarning(it) } + }, + ) + } + } + + 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 new file mode 100644 index 0000000000..c673c92b67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestrator.kt @@ -0,0 +1,113 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +import com.davotoula.lightcompressor.VideoCodec +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 +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +data class HlsPublishRequest( + val title: String, + val description: String, + val sensitiveContent: Boolean, + val contentWarningReason: String, + val codec: VideoCodec, + val server: ServerName, + val durationSeconds: Int? = null, +) + +/** + * Orchestrates the transcode → upload → build → publish pipeline for a single HLS video publish. + * 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. + */ +class HlsPublishOrchestrator( + private val runTranscode: suspend ( + workDir: File, + codec: VideoCodec, + onProgress: (label: String, percent: Int) -> Unit, + ) -> HlsBundle, + private val buildUploader: (ServerName) -> HlsBlobUploader, + private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, + private val workDirFactory: () -> File, +) { + private val _state = MutableStateFlow(HlsPublishState.Idle) + 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) { label, percent -> + _state.value = HlsPublishState.Transcoding(label, percent) + } + + 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 -> + _state.value = HlsPublishState.Uploading(done, total) + } + + _state.value = HlsPublishState.Publishing + val template = + HlsVideoEventBuilder.build( + HlsVideoPublishInput( + bundle = bundle, + uploadResult = uploadResult, + title = request.title, + description = request.description, + durationSeconds = request.durationSeconds, + contentWarning = contentWarningOrNull(request), + ), + ) + val eventId = signAndPublish(template) + + _state.value = HlsPublishState.Success(eventId = eventId, masterUrl = uploadResult.masterUrl) + } catch (e: CancellationException) { + _state.value = HlsPublishState.Failure(message = "Cancelled") + throw e + } catch (e: Throwable) { + _state.value = HlsPublishState.Failure(message = e.message ?: e::class.simpleName.orEmpty()) + } + } + + fun reset() { + _state.value = HlsPublishState.Idle + } + + private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt new file mode 100644 index 0000000000..da48161d19 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -0,0 +1,46 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +sealed class HlsPublishState { + data object Idle : HlsPublishState() + + data class Transcoding( + val currentLabel: String, + val percent: Int, + ) : HlsPublishState() + + data class Uploading( + val done: Int, + val total: Int, + ) : HlsPublishState() + + data object Publishing : HlsPublishState() + + data class Success( + val eventId: String, + val masterUrl: String, + ) : HlsPublishState() + + data class Failure( + val message: String, + ) : HlsPublishState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt new file mode 100644 index 0000000000..8a5fe3da3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -0,0 +1,146 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.utils.CompressorUtils +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Compose-facing ViewModel for the "Share HD Video" screen. Holds the form state and a single + * [HlsPublishOrchestrator] that runs the transcode → upload → publish pipeline. The orchestrator + * receives closures that capture the account/context so the VM only needs a [load] call from the + * screen to wire everything together. + * + * This class is intentionally thin — all orchestration logic and state-machine tests live in + * [HlsPublishOrchestrator]. + */ +@Stable +open class NewHlsVideoViewModel : ViewModel() { + var account: Account? = null + private set + var pickedUri by mutableStateOf(null) + private set + var sourceMetadata by mutableStateOf(null) + private set + + var title by mutableStateOf("") + var description by mutableStateOf("") + var sensitiveContent by mutableStateOf(false) + var contentWarningReason by mutableStateOf("") + var useH265 by mutableStateOf(true) + var selectedServer by mutableStateOf(null) + + private var orchestrator: HlsPublishOrchestrator? = null + private var currentJob: Job? = null + + val state: StateFlow + get() = orchestrator?.state ?: throw IllegalStateException("load() must be called first") + + fun load( + account: Account, + orchestrator: HlsPublishOrchestrator, + ) { + this.account = account + this.orchestrator = orchestrator + this.selectedServer = this.selectedServer ?: DEFAULT_MEDIA_SERVERS.first() + } + + fun onVideoPicked( + uri: Uri, + metadata: HlsSourceMetadata?, + ) { + pickedUri = uri + sourceMetadata = metadata + } + + fun clearPickedVideo() { + pickedUri = null + sourceMetadata = null + } + + fun publish(context: Context) { + val orch = orchestrator ?: return + val server = selectedServer ?: return + if (pickedUri == null) return + if (title.isBlank()) return + + val codec = effectiveCodec(useH265) + val request = + HlsPublishRequest( + title = title, + description = description, + sensitiveContent = sensitiveContent, + contentWarningReason = contentWarningReason, + codec = codec, + server = server, + durationSeconds = sourceMetadata?.durationSeconds, + ) + + currentJob = + viewModelScope.launch(Dispatchers.IO) { + orch.publish(request) + } + } + + fun cancel() { + currentJob?.cancel() + currentJob = null + orchestrator?.reset() + } + + fun reset() { + orchestrator?.reset() + } + + override fun onCleared() { + super.onCleared() + currentJob?.cancel() + } + + private fun effectiveCodec(wantH265: Boolean): VideoCodec = + if (wantH265 && CompressorUtils.isHevcEncodingSupported()) { + VideoCodec.H265 + } else { + VideoCodec.H264 + } +} + +data class HlsSourceMetadata( + val width: Int, + val height: Int, + val durationSeconds: Int, + val sizeBytes: Long, +) 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 new file mode 100644 index 0000000000..e662d528ce --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsPublishOrchestratorTest.kt @@ -0,0 +1,301 @@ +/* + * 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.VideoCodec +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.HlsPublishOrchestrator +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishRequest +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.HlsPublishState +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class HlsPublishOrchestratorTest { + private lateinit var workDir: File + + private val server = ServerName("Test Blossom", "https://test.example/", ServerType.Blossom) + + @Before + fun setUp() { + workDir = Files.createTempDirectory("hls-orchestrator-test").toFile() + } + + @After + fun tearDown() { + 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, + ) + } + 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) + } + + private class CannedUploader : HlsBlobUploader { + var count = 0 + + override suspend fun upload( + file: File, + contentType: String, + ): MediaUploadResult { + count++ + return MediaUploadResult(url = "https://cdn.test/$count", sha256 = "sha-$count", size = file.length()) + } + } + + private fun newRequest( + title: String = "My HD Clip", + description: String = "A test clip", + sensitive: Boolean = false, + warningReason: String = "", + ) = HlsPublishRequest( + title = title, + description = description, + sensitiveContent = sensitive, + contentWarningReason = warningReason, + codec = VideoCodec.H265, + server = server, + ) + + @Test + fun happyPathEndsInSuccessWithMasterUrlAndEventId() { + val publishedTemplates = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { tpl -> + publishedTemplates += tpl + "signed-event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue("expected Success, was $final", final is HlsPublishState.Success) + final as HlsPublishState.Success + assertEquals("signed-event-id", final.eventId) + assertTrue("masterUrl should contain https://cdn.test/", final.masterUrl.startsWith("https://cdn.test/")) + + assertEquals(1, publishedTemplates.size) + assertTrue(publishedTemplates[0] is HlsVideoEventTemplate.Horizontal) + } + + @Test + fun statePhasesVisibleToFakesDuringPublish() { + // Capture state.value at the point each phase's fake runs — this verifies that the + // orchestrator has already transitioned into the right state before dispatching the + // corresponding dep call. + lateinit var orchestrator: HlsPublishOrchestrator + val capturedDuringTranscode = mutableListOf() + val capturedDuringUpload = mutableListOf() + val capturedDuringPublish = mutableListOf() + + orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, onProgress -> + capturedDuringTranscode += orchestrator.state.value + onProgress("360p", 42) + capturedDuringTranscode += orchestrator.state.value + fakeBundle() + }, + buildUploader = { + capturedDuringUpload += orchestrator.state.value + CannedUploader() + }, + signAndPublish = { + capturedDuringPublish += orchestrator.state.value + "event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + assertTrue(capturedDuringTranscode.all { it is HlsPublishState.Transcoding }) + assertEquals("360p", (capturedDuringTranscode.last() as HlsPublishState.Transcoding).currentLabel) + assertEquals(42, (capturedDuringTranscode.last() as HlsPublishState.Transcoding).percent) + + assertTrue(capturedDuringUpload.single() is HlsPublishState.Uploading) + assertTrue(capturedDuringPublish.single() is HlsPublishState.Publishing) + + assertTrue(orchestrator.state.value is HlsPublishState.Success) + } + + @Test + fun transcodeExceptionTransitionsToFailure() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, + buildUploader = { CannedUploader() }, + signAndPublish = { "never" }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue("expected Failure, was $final", final is HlsPublishState.Failure) + assertEquals("decode failed", (final as HlsPublishState.Failure).message) + } + + @Test + fun uploadExceptionTransitionsToFailure() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { + HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } + }, + signAndPublish = { "never" }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue(final is HlsPublishState.Failure) + assertEquals("server 500", (final as HlsPublishState.Failure).message) + } + + @Test + fun publishExceptionTransitionsToFailure() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { throw RuntimeException("relay rejected") }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + val final = orchestrator.state.value + assertTrue(final is HlsPublishState.Failure) + assertEquals("relay rejected", (final as HlsPublishState.Failure).message) + } + + @Test + fun sensitiveContentPassesContentWarningIntoTemplate() { + val captured = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { tpl -> + captured += tpl + "event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { + orchestrator.publish(newRequest(sensitive = true, warningReason = "NSFW")) + } + + val template = (captured.single() as HlsVideoEventTemplate.Horizontal).template + val cw = template.tags.firstOrNull { it.isNotEmpty() && it[0] == "content-warning" } + assertNotNull(cw) + assertEquals("NSFW", cw!![1]) + } + + @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, + ) + val captured = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, + buildUploader = { CannedUploader() }, + signAndPublish = { tpl -> + captured += tpl + "event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + + assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) + } + + @Test + fun resetRestoresIdleState() { + val orchestrator = + HlsPublishOrchestrator( + runTranscode = { _, _, _ -> throw RuntimeException("boom") }, + buildUploader = { CannedUploader() }, + signAndPublish = { "never" }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { orchestrator.publish(newRequest()) } + assertTrue(orchestrator.state.value is HlsPublishState.Failure) + + orchestrator.reset() + assertEquals(HlsPublishState.Idle, orchestrator.state.value) + } +} 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 new file mode 100644 index 0000000000..98e04c7f20 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsTranscodingSessionTest.kt @@ -0,0 +1,211 @@ +/* + * 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 new file mode 100644 index 0000000000..06a6a9951a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsUploadPipelineTest.kt @@ -0,0 +1,273 @@ +/* + * 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 appendsMp4HintToBareCombinedUrlInMediaPlaylist() { + val bundle = createBundle(listOf("360p")) + val uploader = BareUrlUploader() + val pipeline = HlsUploadPipeline(uploader) + + runBlocking { pipeline.upload(bundle) } + + val uploadedMediaPlaylist = uploader.calls[1].third + assertTrue( + "media playlist should reference url with .mp4 hint", + uploadedMediaPlaylist.contains("https://blossom.test/bare-1.mp4"), + ) + assertTrue(!uploadedMediaPlaylist.contains("https://blossom.test/bare-1\"")) + } + + @Test + fun appendsM3u8HintToBarePlaylistUrlInMasterPlaylist() { + val bundle = createBundle(listOf("360p")) + val uploader = BareUrlUploader() + val pipeline = HlsUploadPipeline(uploader) + + val result = runBlocking { pipeline.upload(bundle) } + + val uploadedMaster = uploader.calls[2].third + assertTrue( + "master playlist should reference playlist url with .m3u8 hint", + uploadedMaster.contains("https://blossom.test/bare-2.m3u8"), + ) + assertEquals("https://blossom.test/bare-3.m3u8", result.masterUrl) + assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) + } + + @Test + fun doesNotDoubleAppendExtensionWhenAlreadyPresent() { + val bundle = createBundle(listOf("360p")) + val uploader = FakeUploader() // returns urls ending in .mp4 / .m3u8 + val pipeline = HlsUploadPipeline(uploader) + + runBlocking { pipeline.upload(bundle) } + + val uploadedMediaPlaylist = uploader.calls[1].content + assertTrue(!uploadedMediaPlaylist.contains(".mp4.mp4")) + val uploadedMaster = uploader.calls[2].content + assertTrue(!uploadedMaster.contains(".m3u8.m3u8")) + } + + @Test + fun reportsUploadProgressPerStep() { + val bundle = createBundle(listOf("360p", "540p")) + val uploader = FakeUploader() + val pipeline = HlsUploadPipeline(uploader) + val observed = mutableListOf>() + + runBlocking { + pipeline.upload(bundle) { done, total -> + observed += done to total + } + } + + // 2 renditions × 2 + 1 master = 5 uploads + assertEquals( + listOf(1 to 5, 2 to 5, 3 to 5, 4 to 5, 5 to 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 new file mode 100644 index 0000000000..450fdc30e2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsVideoEventBuilderTest.kt @@ -0,0 +1,229 @@ +/* + * 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.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import org.junit.Assert.assertEquals +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 fun input( + master: String, + 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), + title = title, + description = description, + alt = alt, + durationSeconds = duration, + contentWarning = contentWarning, + 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)) + + assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal) + val template = (result as HlsVideoEventTemplate.Horizontal).template + assertEquals(VideoHorizontalEvent.KIND, template.kind) + assertEquals("A cool video", template.content) + } + + @Test + fun portraitMasterBuildsVerticalTemplateKind34236() { + val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) + + assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical) + val template = (result as HlsVideoEventTemplate.Vertical).template + assertEquals(VideoVerticalEvent.KIND, template.kind) + } + + @Test + fun horizontalTemplateHasTitleAndDTag() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val title = tags.findTag("title") + assertNotNull(title) + assertEquals("My HD Video", title!![1]) + + val d = tags.findTag("d") + assertNotNull(d) + assertEquals("fixed-d-tag", d!![1]) + } + + @Test + fun templateContainsOneImetaForMasterAndOnePerRendition() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val imetas = tags.findAllTags("imeta") + // 1 master + 2 renditions + assertEquals(3, imetas.size) + + // First imeta is the master + val masterImeta = imetas[0].joinToString("|") + assertTrue(masterImeta.contains("url https://cdn.test/master.m3u8")) + assertTrue(masterImeta.contains("m application/vnd.apple.mpegurl")) + + // Subsequent imetas are per-rendition playlist URLs + val r360Imeta = imetas[1].joinToString("|") + assertTrue("360p imeta: $r360Imeta", r360Imeta.contains("url https://cdn.test/360p-media.m3u8")) + assertTrue(r360Imeta.contains("m application/vnd.apple.mpegurl")) + assertTrue("360p dim: $r360Imeta", r360Imeta.contains("dim 640x360")) + assertTrue(r360Imeta.contains("x 360p-sha")) + + val r720Imeta = imetas[2].joinToString("|") + assertTrue("720p imeta: $r720Imeta", r720Imeta.contains("url https://cdn.test/720p-media.m3u8")) + assertTrue(r720Imeta.contains("dim 1280x720")) + } + + @Test + fun durationTagWhenDurationProvided() { + val result = + HlsVideoEventBuilder.build( + input(landscapeMasterPlaylist, duration = 123), + ) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val duration = tags.findTag("duration") + assertNotNull(duration) + assertEquals("123", duration!![1]) + } + + @Test + fun noDurationTagWhenNotProvided() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + assertNull(tags.findTag("duration")) + } + + @Test + fun contentWarningTagWhenProvided() { + val result = + HlsVideoEventBuilder.build( + input(landscapeMasterPlaylist, contentWarning = "NSFW"), + ) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + + val warning = tags.findTag("content-warning") + assertNotNull(warning) + assertEquals("NSFW", warning!![1]) + } + + @Test + fun noContentWarningTagWhenNull() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + assertNull(tags.findTag("content-warning")) + } + + @Test + fun horizontalTemplateCarriesAutoGeneratedAltTag() { + val result = HlsVideoEventBuilder.build(input(landscapeMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags + val alt = tags.findTag("alt") + assertNotNull(alt) + assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1]) + } + + @Test + fun verticalTemplateCarriesVerticalAltTag() { + val result = HlsVideoEventBuilder.build(input(portraitMasterPlaylist)) + val tags = (result as HlsVideoEventTemplate.Vertical).template.tags + val alt = tags.findTag("alt") + assertNotNull(alt) + assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1]) + } +} From 6942314cf8a63567514a2f894f8581101af2b577 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 17:12:19 +0200 Subject: [PATCH 04/26] feat(hls): adapter for Nip96/Blossom + production orchestrator wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HlsBlobUploaderFactory turns a user-chosen ServerName into the simple file+contentType HlsBlobUploader the pipeline expects by adapting BlossomUploader or Nip96Uploader. Each adapter reuses the existing roleBasedHttpClientBuilder.okHttpClientForUploads and the account's signer helpers (createBlossomUploadAuth / createHTTPAuthorization), matching how UploadOrchestrator wires them today. NIP-95 is rejected explicitly — storing each rendition as an event would blow past relay size limits. createProductionHlsPublishOrchestrator binds the transcode to HlsTranscoder, uploads to HlsBlobUploaderFactory, and signAndPublish to account.signer.sign(...) + account.sendAutomatic(...). The Uri is captured via a lazy provider so the orchestrator can be constructed at VM load time before the user picks a video. NewHlsVideoViewModel.load(account, context) is the new one-call setup the screen will use; the existing load(account, orchestrator) overload stays for unit tests. Milestone 5b of the HLS video sharing plan (2026-04-13). Co-Authored-By: Claude Opus 4.5 --- .../uploads/hls/HlsBlobUploaderFactory.kt | 98 +++++++++++++++++++ .../hls/HlsPublishOrchestratorFactory.kt | 70 +++++++++++++ .../video/hls/NewHlsVideoViewModel.kt | 12 +++ 3 files changed, 180 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt new file mode 100644 index 0000000000..17bd742d3d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt @@ -0,0 +1,98 @@ +/* + * 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 androidx.core.net.toUri +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType + +/** + * Turns a user-chosen [ServerName] into an [HlsBlobUploader] by adapting the concrete + * [Nip96Uploader] / [BlossomUploader] to the simpler file+contentType interface the HLS upload + * pipeline uses. Keeps the pipeline free of direct Amethyst/account wiring so it stays + * unit-testable. + */ +object HlsBlobUploaderFactory { + fun create( + server: ServerName, + account: Account, + context: Context, + ): HlsBlobUploader = + when (server.type) { + ServerType.Blossom -> { + blossomAdapter(server.baseUrl, account, context) + } + + ServerType.NIP96 -> { + nip96Adapter(server.baseUrl, account, context) + } + + ServerType.NIP95 -> { + throw IllegalArgumentException( + "NIP-95 storage stores each blob as an event and is not suitable for HLS renditions", + ) + } + } + + private fun blossomAdapter( + serverBaseUrl: String, + account: Account, + context: Context, + ): HlsBlobUploader = + HlsBlobUploader { file, contentType -> + BlossomUploader().upload( + uri = file.toUri(), + contentType = contentType, + size = file.length(), + alt = null, + sensitiveContent = null, + serverBaseUrl = serverBaseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } + + private fun nip96Adapter( + serverBaseUrl: String, + account: Account, + context: Context, + ): HlsBlobUploader = + HlsBlobUploader { file, contentType -> + Nip96Uploader().upload( + uri = file.toUri(), + contentType = contentType, + size = file.length(), + alt = null, + sensitiveContent = null, + serverBaseUrl = serverBaseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + onProgress = { /* pipeline reports progress per-upload; NIP-96 per-request progress is not forwarded */ }, + httpAuth = account::createHTTPAuthorization, + context = context, + ) + } +} 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 new file mode 100644 index 0000000000..22a6875fea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishOrchestratorFactory.kt @@ -0,0 +1,70 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +import android.content.Context +import android.net.Uri +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 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. + * + * The Uri is read via [uriProvider] on each transcode invocation so the orchestrator can be built + * once (at VM load) before the user actually picks a video. + */ +fun createProductionHlsPublishOrchestrator( + account: Account, + context: Context, + uriProvider: () -> Uri?, +): HlsPublishOrchestrator = + HlsPublishOrchestrator( + runTranscode = { workDir, codec, onProgress -> + val uri = uriProvider() ?: error("No video picked") + HlsTranscoder.transcode( + context = context, + uri = uri, + workDir = workDir, + codec = codec, + onRenditionProgress = onProgress, + ) + }, + buildUploader = { server -> + HlsBlobUploaderFactory.create(server, account, context) + }, + signAndPublish = { template -> + val signed = + when (template) { + is HlsVideoEventTemplate.Horizontal -> account.signer.sign(template.template) + is HlsVideoEventTemplate.Vertical -> account.signer.sign(template.template) + } + account.sendAutomatic(signed) + signed.id + }, + workDirFactory = { + File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } + }, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index 8a5fe3da3f..4c897b14c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -78,6 +78,18 @@ open class NewHlsVideoViewModel : ViewModel() { this.selectedServer = this.selectedServer ?: DEFAULT_MEDIA_SERVERS.first() } + fun load( + account: Account, + context: Context, + ) = load( + account, + createProductionHlsPublishOrchestrator( + account = account, + context = context, + uriProvider = { pickedUri }, + ), + ) + fun onVideoPicked( uri: Uri, metadata: HlsSourceMetadata?, From b1f100b2137035b0496f6c6f22927468e96727ba Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 17:23:58 +0200 Subject: [PATCH 05/26] feat(hls): NewHlsVideoScreen Compose UI Four-state screen driven by HlsPublishState: - Idle (no video): big "Pick a video" card using ActivityResultContracts.PickVisualMedia(VideoOnly). - Idle (video picked): selected-file card with duration/resolution/size probed off-thread via MediaMetadataRetriever, then form fields (title, multi-line description, content warning switch + reason, server picker, H.265/H.264 FilterChip toggle that disables H.265 when CompressorUtils.isHevcEncodingSupported() is false, a read-only renditions preview computed from HlsLadder.default().forSource(), and the primary "Publish HD video" button). - In-flight (Transcoding / Uploading / Publishing): three-row phase view with a check icon for completed phases, a ring for the active one, and a LinearProgressIndicator underneath the active row; cancel button at the bottom. - Success: green check + master playlist URL + Done button that resets the VM and pops back. - Failure: error icon + message + Try Again button that resets the VM. Server picker reuses the existing TextSpinner / TitleExplainer pattern (same widgets FileServerSelectionRow uses), filtered to exclude NIP-95 since blob-per-event storage blows past relay size limits. Strings live in values/strings.xml under the "HLS multi-resolution video sharing" block and reuse the pre-existing content_warning, file_server, cancel and dismiss entries where appropriate. Drawer row + route wiring come in milestone 5d. Milestone 5c of the HLS video sharing plan (2026-04-13). Co-Authored-By: Claude Opus 4.5 --- .../loggedIn/video/hls/NewHlsVideoScreen.kt | 675 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 32 + 2 files changed, 707 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt new file mode 100644 index 0000000000..ed10dc4f17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -0,0 +1,675 @@ +/* + * 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.ui.screen.loggedIn.video.hls + +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.davotoula.lightcompressor.hls.HlsLadder +import com.davotoula.lightcompressor.utils.CompressorUtils +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewHlsVideoScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewHlsVideoViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { + vm.load(accountViewModel.account, context) + } + + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringResource(R.string.share_hls_video), + popBack = nav::popBack, + ) + }, + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + NewHlsVideoBody(vm, nav) + } + } +} + +@Composable +private fun NewHlsVideoBody( + vm: NewHlsVideoViewModel, + nav: INav, +) { + val publishState by vm.state.collectAsState() + + when (val state = publishState) { + is HlsPublishState.Idle -> IdleBody(vm) + + is HlsPublishState.Transcoding, + is HlsPublishState.Uploading, + is HlsPublishState.Publishing, + -> ProgressBody(vm, state) + + is HlsPublishState.Success -> SuccessBody(vm, state, nav) + + is HlsPublishState.Failure -> FailureBody(vm, state) + } +} + +@Composable +private fun IdleBody(vm: NewHlsVideoViewModel) { + val context = LocalContext.current + + val pickLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + vm.onVideoPicked(uri, metadata = null) + } + + // Probe source metadata in the background whenever pickedUri flips to a new Uri. + LaunchedEffect(vm.pickedUri) { + val uri = vm.pickedUri ?: return@LaunchedEffect + if (vm.sourceMetadata != null) return@LaunchedEffect + val probed = probeSourceMetadata(context, uri) + if (probed != null) vm.onVideoPicked(uri, probed) + } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp), + ) { + val pickedUri = vm.pickedUri + if (pickedUri == null) { + EmptyPickVideoCard( + onClick = { + pickLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly), + ) + }, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.hls_pick_video_helper), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + PickedVideoCard( + vm = vm, + onChange = { + vm.clearPickedVideo() + pickLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly), + ) + }, + ) + Spacer(Modifier.height(16.dp)) + FormFields(vm) + Spacer(Modifier.height(24.dp)) + Button( + onClick = { vm.publish(context) }, + enabled = vm.title.isNotBlank() && vm.selectedServer != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_publish_button)) + } + } + } +} + +@Composable +private fun EmptyPickVideoCard(onClick: () -> Unit) { + Card( + modifier = + Modifier + .fillMaxWidth() + .height(200.dp) + .clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.VideoLibrary, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.hls_pick_video_primary), + style = MaterialTheme.typography.titleMedium, + ) + } + } +} + +@Composable +private fun PickedVideoCard( + vm: NewHlsVideoViewModel, + onChange: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.VideoLibrary, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp), + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = vm.pickedUri?.lastPathSegment ?: "Video", + style = MaterialTheme.typography.bodyLarge, + ) + val meta = vm.sourceMetadata + if (meta != null) { + val duration = "${meta.durationSeconds / 60}:${(meta.durationSeconds % 60).toString().padStart(2, '0')}" + Text( + text = "$duration · ${meta.width}×${meta.height} · ${formatSize(meta.sizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + TextButton(onClick = onChange) { + Text(stringResource(R.string.hls_change_video)) + } + } + } +} + +@Composable +private fun FormFields(vm: NewHlsVideoViewModel) { + OutlinedTextField( + value = vm.title, + onValueChange = { vm.title = it }, + label = { Text(stringResource(R.string.hls_title_label)) }, + placeholder = { Text(stringResource(R.string.hls_title_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = vm.description, + onValueChange = { vm.description = it }, + label = { Text(stringResource(R.string.hls_description_label)) }, + placeholder = { Text(stringResource(R.string.hls_description_placeholder)) }, + modifier = Modifier.fillMaxWidth().height(120.dp), + ) + + Spacer(Modifier.height(16.dp)) + + // Content warning toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.content_warning), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = vm.sensitiveContent, + onCheckedChange = { vm.sensitiveContent = it }, + ) + } + + if (vm.sensitiveContent) { + OutlinedTextField( + value = vm.contentWarningReason, + onValueChange = { vm.contentWarningReason = it }, + placeholder = { Text(stringResource(R.string.hls_content_warning_reason_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Server picker + Text( + text = stringResource(R.string.file_server), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + val servers = remember { DEFAULT_MEDIA_SERVERS.filter { it.type != ServerType.NIP95 } } + val serverOptions = + remember(servers) { + servers.map { TitleExplainer(it.name, it.baseUrl) }.toImmutableList() + } + TextSpinner( + label = "", + placeholder = vm.selectedServer?.name ?: servers.firstOrNull()?.name ?: "", + options = serverOptions, + onSelect = { index -> servers.getOrNull(index)?.let { vm.selectedServer = it } }, + ) + + Spacer(Modifier.height(16.dp)) + + // Codec toggle + CodecToggle( + useH265 = vm.useH265, + onChange = { vm.useH265 = it }, + ) + + Spacer(Modifier.height(16.dp)) + + // Renditions preview (read-only) + RenditionsPreview(vm.sourceMetadata) +} + +@Composable +private fun CodecToggle( + useH265: Boolean, + onChange: (Boolean) -> Unit, +) { + val hevcSupported = remember { CompressorUtils.isHevcEncodingSupported() } + + Text( + text = stringResource(R.string.hls_codec_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(6.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = useH265 && hevcSupported, + enabled = hevcSupported, + onClick = { onChange(true) }, + label = { Text(stringResource(R.string.hls_codec_h265)) }, + colors = FilterChipDefaults.filterChipColors(), + ) + FilterChip( + selected = !useH265 || !hevcSupported, + onClick = { onChange(false) }, + label = { Text(stringResource(R.string.hls_codec_h264)) }, + ) + } + if (!hevcSupported) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.hls_codec_fallback_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun RenditionsPreview(metadata: HlsSourceMetadata?) { + Text( + text = stringResource(R.string.hls_renditions_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + if (metadata == null) { + Text( + text = "360p · 540p · 720p · 1080p · 4K", + style = MaterialTheme.typography.bodyMedium, + ) + return + } + val shortSide = minOf(metadata.width, metadata.height) + val ladder = + HlsLadder + .default() + .forSource(shortSide) + .renditions + .map { it.resolution.label } + val skipped = + HlsLadder + .default() + .renditions + .map { it.resolution.label } + .filter { it !in ladder } + + Text( + text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.hls_renditions_produce_format, ladder.joinToString(" · ")), + style = MaterialTheme.typography.bodyMedium, + ) + if (skipped.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.hls_renditions_skipped_format, skipped.joinToString(", ")), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ProgressBody( + vm: NewHlsVideoViewModel, + state: HlsPublishState, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 24.dp), + ) { + Text( + text = stringResource(R.string.hls_publishing_header_format, vm.title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(24.dp)) + + PhaseRow( + label = stringResource(R.string.hls_state_transcoding_format, (state as? HlsPublishState.Transcoding)?.currentLabel?.ifBlank { "…" } ?: "…"), + active = state is HlsPublishState.Transcoding, + done = state is HlsPublishState.Uploading || state is HlsPublishState.Publishing, + progressFraction = (state as? HlsPublishState.Transcoding)?.percent?.let { it / 100f }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + val uploadingFraction = + (state as? HlsPublishState.Uploading)?.let { up -> + if (up.total == 0) 0f else up.done.toFloat() / up.total + } + val uploadingLabel = + when (state) { + is HlsPublishState.Uploading -> stringResource(R.string.hls_state_uploading_format, state.done, state.total) + else -> stringResource(R.string.hls_state_uploading_format, 0, 0) + } + PhaseRow( + label = uploadingLabel, + active = state is HlsPublishState.Uploading, + done = state is HlsPublishState.Publishing, + progressFraction = uploadingFraction, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + PhaseRow( + label = stringResource(R.string.hls_state_publishing), + active = state is HlsPublishState.Publishing, + done = false, + progressFraction = null, + ) + + Spacer(Modifier.height(32.dp)) + + OutlinedButton( + onClick = { vm.cancel() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.cancel)) + } + } +} + +@Composable +private fun PhaseRow( + label: String, + active: Boolean, + done: Boolean, + progressFraction: Float?, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + when { + done -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF22C55E), + modifier = Modifier.size(20.dp), + ) + } + + active -> { + Spacer( + Modifier + .size(20.dp) + .border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(10.dp)), + ) + } + + else -> { + Spacer(Modifier.size(20.dp)) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (active && progressFraction != null) { + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { progressFraction.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun SuccessBody( + vm: NewHlsVideoViewModel, + state: HlsPublishState.Success, + nav: INav, +) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = Color(0xFF22C55E), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.hls_state_success_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.hls_state_success_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = state.masterUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + vm.reset() + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_done)) + } + } +} + +@Composable +private fun FailureBody( + vm: NewHlsVideoViewModel, + state: HlsPublishState.Failure, +) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.hls_state_failure_title), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = { vm.reset() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_try_again)) + } + } +} + +private suspend fun probeSourceMetadata( + context: android.content.Context, + uri: Uri, +): HlsSourceMetadata? = + withContext(Dispatchers.IO) { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(context, uri) + val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: return@withContext null + val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: return@withContext null + val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 + val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + val (w, h) = if (rotation == 90 || rotation == 270) height to width else width to height + val size = + context.contentResolver + .openFileDescriptor(uri, "r") + ?.use { it.statSize } ?: 0L + HlsSourceMetadata( + width = w, + height = h, + durationSeconds = (durationMs / 1000).toInt(), + sizeBytes = size, + ) + } catch (_: Exception) { + null + } finally { + runCatching { retriever.release() } + } + } + +private fun formatSize(bytes: Long): String { + if (bytes <= 0) return "—" + val mb = bytes.toDouble() / (1024 * 1024) + return if (mb >= 1) String.format("%.1f MB", mb) else String.format("%.0f KB", bytes / 1024.0) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0d4c4c2e40..158d6bfc42 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2081,6 +2081,38 @@ Media Actions Playback Auto + + + Share HD Video + Publish multi-resolution HLS to your media server + Pick a video + Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. + Change + Title + Give your video a title + Description + What is this video about? + Reason (optional) + Codec + H.265 (better compression) + H.264 + H.265 not available on this device — falling back to H.264. + Renditions + Source resolution: %1$d×%2$d + Will produce: %1$s + (%1$s skipped — above source) + Publish HD video + Publishing “%1$s”… + Transcoding %1$s + Uploading %1$d / %2$d + Publishing event… + Video published + Your HD video is live on Nostr. + Something went wrong + View note + Done + Try again + Pack Actions List Actions Bookmark Actions From 9467500d4aacb569babf82b2e0738b111dc736b4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 17:58:37 +0200 Subject: [PATCH 06/26] feat(hls): wire NewHlsVideoScreen into drawer + navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renames the share_hls_video label to "HLS Upload" so the drawer row matches the on-device name the user sees everywhere. - Adds Route.NewHlsVideo as a data object next to the other create routes in Routes.kt. - Registers it with composableFromEnd in AppNavigation.kt, sliding in from the end the same way the Longs, Shorts and Pictures creation surfaces do. - Adds a drawer NavigationRow between Longs and Wallet in ListContent() with Icons.Outlined.SettingsInputAntenna — reads as "broadcasting / antenna" and matches the HLS-as-streaming metaphor. Milestone 5d (final 5.x piece) of the HLS video sharing plan (2026-04-13). The feature is now reachable end-to-end from the UI. Co-Authored-By: Claude Opus 4.5 --- .../amethyst/ui/navigation/AppNavigation.kt | 2 ++ .../amethyst/ui/navigation/drawer/DrawerContent.kt | 9 +++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 2 ++ amethyst/src/main/res/values/strings.xml | 2 +- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 5d316e403c..a4f50cd67f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -146,6 +146,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen @@ -221,6 +222,7 @@ fun BuildNavigation( composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } + composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { WalletScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index ff9c8cf4d4..934dc23c74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -61,6 +61,7 @@ import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SettingsInputAntenna import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -607,6 +608,14 @@ fun ListContent( route = Route.Longs, ) + NavigationRow( + title = R.string.share_hls_video, + icon = Icons.Outlined.SettingsInputAntenna, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.NewHlsVideo, + ) + NavigationRow( title = R.string.wallet, icon = Icons.Outlined.AccountBalanceWallet, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 8c6f7c3596..07db7d4247 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -410,6 +410,8 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable data object NewHlsVideo : Route() + @Serializable data class VoiceReply( val replyToNoteId: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 158d6bfc42..f562734dab 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2083,7 +2083,7 @@ Auto - Share HD Video + HLS Upload Publish multi-resolution HLS to your media server Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. From 9e49353acb4525f094723f0b110709beab619132 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 18:09:18 +0200 Subject: [PATCH 07/26] fix(hls): move publish state ownership to the ViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NewHlsVideoScreen crashed on open with IllegalStateException("load() must be called first") because NewHlsVideoBody reads vm.state during the first composition, but LaunchedEffect runs vm.load(account, context) only AFTER that first composition completes — classic pre-load race. Moves the MutableStateFlow out of the orchestrator and into the ViewModel, so vm.state is safe to read from composition start. The orchestrator now receives the flow as a constructor param and writes into it as before — same semantics, no new race windows. Tests and the production factory are updated to pass the shared flow through. Co-Authored-By: Claude Opus 4.5 --- .../screen/loggedIn/video/hls/HlsPublishOrchestrator.kt | 2 +- .../loggedIn/video/hls/HlsPublishOrchestratorFactory.kt | 3 +++ .../ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt | 9 ++++++--- .../service/uploads/hls/HlsPublishOrchestratorTest.kt | 9 +++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) 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 c673c92b67..58682fd173 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 @@ -52,6 +52,7 @@ data class HlsPublishRequest( * exception. The [state] flow emits each transition as it happens so the UI can reflect progress. */ class HlsPublishOrchestrator( + private val _state: MutableStateFlow, private val runTranscode: suspend ( workDir: File, codec: VideoCodec, @@ -61,7 +62,6 @@ class HlsPublishOrchestrator( private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, private val workDirFactory: () -> File, ) { - private val _state = MutableStateFlow(HlsPublishState.Idle) val state: StateFlow = _state suspend fun publish(request: HlsPublishRequest) { 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 22a6875fea..659a124c0c 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 @@ -26,6 +26,7 @@ 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 /** @@ -37,11 +38,13 @@ import java.io.File * once (at VM load) before the user actually picks a video. */ fun createProductionHlsPublishOrchestrator( + state: MutableStateFlow, account: Account, context: Context, uriProvider: () -> Uri?, ): HlsPublishOrchestrator = HlsPublishOrchestrator( + _state = state, runTranscode = { workDir, codec, onProgress -> val uri = uriProvider() ?: error("No video picked") HlsTranscoder.transcode( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index 4c897b14c8..d19795ac14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -35,7 +35,9 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** @@ -63,12 +65,12 @@ open class NewHlsVideoViewModel : ViewModel() { var useH265 by mutableStateOf(true) var selectedServer by mutableStateOf(null) + private val _state = MutableStateFlow(HlsPublishState.Idle) + val state: StateFlow = _state.asStateFlow() + private var orchestrator: HlsPublishOrchestrator? = null private var currentJob: Job? = null - val state: StateFlow - get() = orchestrator?.state ?: throw IllegalStateException("load() must be called first") - fun load( account: Account, orchestrator: HlsPublishOrchestrator, @@ -84,6 +86,7 @@ open class NewHlsVideoViewModel : ViewModel() { ) = load( account, createProductionHlsPublishOrchestrator( + state = _state, account = account, context = context, uriProvider = { pickedUri }, 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 e662d528ce..fbce8d2c3e 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType 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 +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -111,6 +112,7 @@ class HlsPublishOrchestratorTest { val publishedTemplates = mutableListOf() val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> @@ -144,6 +146,7 @@ class HlsPublishOrchestratorTest { orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, onProgress -> capturedDuringTranscode += orchestrator.state.value onProgress("360p", 42) @@ -177,6 +180,7 @@ class HlsPublishOrchestratorTest { fun transcodeExceptionTransitionsToFailure() { val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, @@ -194,6 +198,7 @@ class HlsPublishOrchestratorTest { fun uploadExceptionTransitionsToFailure() { val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } @@ -213,6 +218,7 @@ class HlsPublishOrchestratorTest { fun publishExceptionTransitionsToFailure() { val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { throw RuntimeException("relay rejected") }, @@ -231,6 +237,7 @@ class HlsPublishOrchestratorTest { val captured = mutableListOf() val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> @@ -268,6 +275,7 @@ class HlsPublishOrchestratorTest { val captured = mutableListOf() val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> @@ -286,6 +294,7 @@ class HlsPublishOrchestratorTest { fun resetRestoresIdleState() { val orchestrator = HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), runTranscode = { _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, From ddd1c19c47e7cd55acd40f4d42867d7f836c4ac4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 19:39:46 +0200 Subject: [PATCH 08/26] fix(hls): read user's configured Blossom servers from account The server dropdown on the HLS Upload screen was hardcoded to DEFAULT_MEDIA_SERVERS, so any Blossom server the user configured in Settings -> Media Servers did not appear. Wire it to account.blossomServers.hostNameFlow (same StateFlow the existing AllMediaBody settings screen consumes), which yields the signed BlossomServersEvent normalized into List and falls back to DEFAULT_MEDIA_SERVERS when the user has none configured. Initial selectedServer prefers account.settings.defaultFileServer when it is still in the list, otherwise the first available server. A collect job re-syncs the list if the user adds/removes servers while the screen is open. Co-Authored-By: Claude Opus 4.5 --- .../loggedIn/video/hls/NewHlsVideoScreen.kt | 6 ++--- .../video/hls/NewHlsVideoViewModel.kt | 26 ++++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index ed10dc4f17..075a4fa782 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -76,8 +76,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.davotoula.lightcompressor.hls.HlsLadder import com.davotoula.lightcompressor.utils.CompressorUtils import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -322,14 +320,14 @@ private fun FormFields(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(16.dp)) - // Server picker + // Server picker — reads the user's configured Blossom servers from the account Text( text = stringResource(R.string.file_server), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) - val servers = remember { DEFAULT_MEDIA_SERVERS.filter { it.type != ServerType.NIP95 } } + val servers by vm.availableServers.collectAsState() val serverOptions = remember(servers) { servers.map { TitleExplainer(it.name, it.baseUrl) }.toImmutableList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index d19795ac14..79f22db89a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -68,8 +68,12 @@ open class NewHlsVideoViewModel : ViewModel() { private val _state = MutableStateFlow(HlsPublishState.Idle) val state: StateFlow = _state.asStateFlow() + private val _availableServers = MutableStateFlow>(DEFAULT_MEDIA_SERVERS) + val availableServers: StateFlow> = _availableServers.asStateFlow() + private var orchestrator: HlsPublishOrchestrator? = null private var currentJob: Job? = null + private var serversJob: Job? = null fun load( account: Account, @@ -77,7 +81,27 @@ open class NewHlsVideoViewModel : ViewModel() { ) { this.account = account this.orchestrator = orchestrator - this.selectedServer = this.selectedServer ?: DEFAULT_MEDIA_SERVERS.first() + + val initialServers = account.blossomServers.hostNameFlow.value + _availableServers.value = initialServers + + if (selectedServer == null || initialServers.none { it == selectedServer }) { + selectedServer = account.settings.defaultFileServer + .takeIf { s -> initialServers.any { it == s } } + ?: initialServers.firstOrNull() + ?: DEFAULT_MEDIA_SERVERS.first() + } + + serversJob?.cancel() + serversJob = + viewModelScope.launch { + account.blossomServers.hostNameFlow.collect { servers -> + _availableServers.value = servers + if (selectedServer == null || servers.none { it == selectedServer }) { + selectedServer = servers.firstOrNull() ?: DEFAULT_MEDIA_SERVERS.first() + } + } + } } fun load( From 1255ae3e6176d75219342686efce32430705d98b Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 19:56:37 +0200 Subject: [PATCH 09/26] fix(hls): disable per-write/read timeouts for large rendition uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared okHttpClientForUploads ships with a 30 s read and write timeout on Wi-Fi. A 9 MB+ rendition where the server does synchronous hashing / virus scanning easily takes longer than 30 s to return 200, and OkHttp fires readTimeout while the request is still in flight. The pipeline throws, the orchestrator moves to Failure, and the master playlist upload never happens — matching the on-wire observation that all 10 rendition files reach the server but the master does not. HLS gets its own dedicated OkHttpClient derived from the shared upload client: writeTimeout and readTimeout are set to 0 (disabled) so a slow rendition can trickle through and a slow server can take as long as it needs to respond. A generous 15 minute per-call timeout remains as a hard cap so a silently dead connection eventually errors out. Fixes the "server processed the upload 74 seconds after the client marked it failed" race the upload report flagged as a classic wire-won case. Co-Authored-By: Claude Opus 4.5 --- .../uploads/hls/HlsBlobUploaderFactory.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt index 17bd742d3d..9cae736004 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/hls/HlsBlobUploaderFactory.kt @@ -28,14 +28,33 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit /** * Turns a user-chosen [ServerName] into an [HlsBlobUploader] by adapting the concrete * [Nip96Uploader] / [BlossomUploader] to the simpler file+contentType interface the HLS upload * pipeline uses. Keeps the pipeline free of direct Amethyst/account wiring so it stays * unit-testable. + * + * HLS uploads get a dedicated OkHttp client derived from the shared upload client: write and + * read timeouts are disabled so a slow rendition trickling through at a few hundred KB/s is + * not killed mid-stream, and server-side hashing/scanning that blocks the response for minutes + * does not fire the read timeout while the request is still in flight. A generous per-call + * timeout remains in place as a hard cap so a silently dead connection eventually errors out. */ object HlsBlobUploaderFactory { + private const val CALL_TIMEOUT_MINUTES = 15L + + private fun okHttpClientForHlsUploads(serverBaseUrl: String): OkHttpClient = + Amethyst.instance.roleBasedHttpClientBuilder + .okHttpClientForUploads(serverBaseUrl) + .newBuilder() + .writeTimeout(0, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(CALL_TIMEOUT_MINUTES, TimeUnit.MINUTES) + .build() + fun create( server: ServerName, account: Account, @@ -70,7 +89,7 @@ object HlsBlobUploaderFactory { alt = null, sensitiveContent = null, serverBaseUrl = serverBaseUrl, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + okHttpClient = ::okHttpClientForHlsUploads, httpAuth = account::createBlossomUploadAuth, context = context, ) @@ -89,7 +108,7 @@ object HlsBlobUploaderFactory { alt = null, sensitiveContent = null, serverBaseUrl = serverBaseUrl, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + okHttpClient = ::okHttpClientForHlsUploads, onProgress = { /* pipeline reports progress per-upload; NIP-96 per-request progress is not forwarded */ }, httpAuth = account::createHTTPAuthorization, context = context, From 3bb9aba8fb0069865f511c709b478193284a8087 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 20:33:08 +0200 Subject: [PATCH 10/26] fix(hls): master playlist URL had ..m3u8 double extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Android's MimeTypeMap does not know application/vnd.apple.mpegurl, so Nip96Uploader.upload() resolved the extension to "" and sent the multipart filename as "abc." (trailing dot). The NIP-96 server then echoed the upload name into its returned URL, which arrived at the pipeline ending in ".". The pipeline's withExtensionHint helper appended ".m3u8" on top of that, producing "..m3u8" — which the subsequent playback GET 404s against because the server stored the file at the single-dot path. Two fixes, both needed: 1. Nip96Uploader.upload(InputStream, ...) now falls back to a small static MIME->extension table when MimeTypeMap returns null, covering the HLS playlist types and video/mp4 / fMP4 segment types. The file is uploaded with a real ".m3u8" / ".mp4" extension, so the server never has to invent one. 2. HlsUploadPipeline.withExtensionHint now trims trailing dots and collapses any existing "..ext" sequences before checking whether to append, so a server that still echoes a single trailing dot cannot produce unreachable URLs. Regression test locks in that a fake uploader returning a "https://server/bare-1." URL yields a single-dot ".m3u8" in the final upload result. Co-Authored-By: Claude Opus 4.5 --- .../service/uploads/hls/HlsUploadPipeline.kt | 10 +++++-- .../service/uploads/nip96/Nip96Uploader.kt | 17 +++++++++++- .../uploads/hls/HlsUploadPipelineTest.kt | 26 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) 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 index 4c0221dbde..6946dca488 100644 --- 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 @@ -126,7 +126,9 @@ class HlsUploadPipeline( // Blossom servers typically return bare-hash URLs (https://server/), but HLS parsers // and ExoPlayer's Util.inferContentType sniff the URL extension to pick the right source - // factory. Append a hint unless the upload server already baked one in. + // factory. Append a hint unless the upload server already baked one in. Trailing dots are + // trimmed and any pre-existing doubled extension (e.g. "..m3u8") collapsed, so a server that + // echoes our empty-extension upload filename does not produce unreachable URLs. private fun withExtensionHint( url: String, contentType: String, @@ -137,7 +139,11 @@ class HlsUploadPipeline( CONTENT_TYPE_HLS -> ".m3u8" else -> return url } - return if (url.endsWith(ext, ignoreCase = true)) url else url + ext + val sanitised = + url + .replace(Regex("""\.{2,}""" + Regex.escape(ext.trimStart('.')) + "$", RegexOption.IGNORE_CASE), ext) + .trimEnd('.') + return if (sanitised.endsWith(ext, ignoreCase = true)) sanitised else sanitised + ext } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 5b4317988c..2950a3fc04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -144,7 +144,10 @@ class Nip96Uploader { checkNotInMainThread() val fileName = RandomInstance.randomChars(16) - val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: "" + val extension = + contentType?.let { + MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: fallbackExtensionForMimeType(it) + } ?: "" val client = okHttpClient(server.apiUrl) val requestBuilder = Request.Builder() @@ -231,6 +234,18 @@ class Nip96Uploader { fun String.displayUrl() = this.removeSuffix("/").removePrefix("https://") + // Android's MimeTypeMap does not know every MIME we upload (notably HLS playlist types). + // When it returns null we fall back to a small static table so the multipart filename still + // carries a real extension — otherwise the server gets "name." and echoes it back, which + // breaks HLS URL rewriting. + private fun fallbackExtensionForMimeType(mimeType: String): String? = + when (mimeType.lowercase()) { + "application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/x-mpegurl", "audio/mpegurl" -> "m3u8" + "video/mp2t" -> "ts" + "video/iso.segment", "video/mp4" -> "mp4" + else -> null + } + fun convertToMediaResult(nip96: PartialEvent): MediaUploadResult { // Images don't seem to be ready immediately after upload val imageUrl = nip96.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) 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 index 06a6a9951a..346abb454d 100644 --- 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 @@ -212,6 +212,32 @@ class HlsUploadPipelineTest { assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) } + @Test + fun trailingDotUrlsAreSanitisedIntoCleanExtension() { + val bundle = createBundle(listOf("360p")) + val uploader = + object : HlsBlobUploader { + var count = 0 + + override suspend fun upload( + file: File, + contentType: String, + ): MediaUploadResult { + count++ + return MediaUploadResult(url = "https://blossom.test/bare-$count.", sha256 = "sha-$count", size = file.length()) + } + } + val pipeline = HlsUploadPipeline(uploader) + + val result = runBlocking { pipeline.upload(bundle) } + + assertTrue("masterUrl must not contain '..': ${result.masterUrl}", !result.masterUrl.contains("..")) + assertTrue("masterUrl must not end with '.': ${result.masterUrl}", !result.masterUrl.endsWith(".")) + assertTrue(result.masterUrl.endsWith(".m3u8")) + assertTrue(!result.renditions[0].combinedUrl.contains("..")) + assertTrue(result.renditions[0].combinedUrl.endsWith(".mp4")) + } + @Test fun doesNotDoubleAppendExtensionWhenAlreadyPresent() { val bundle = createBundle(listOf("360p")) From 50e76716bd07296cc4e02418d128b0005796edfc Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 20:44:57 +0200 Subject: [PATCH 11/26] feat(hls): cross-post HLS upload as a kind-1 note Adds an optional kind-1 TextNoteEvent companion to every HLS publish so the upload shows up in the home feed (where most people scroll) alongside the NIP-71 VideoHorizontalEvent/VideoVerticalEvent in the dedicated video tab. The companion note's content is the title, the description and the master playlist URL joined with blank lines so Amethyst's rich-text parser renders the inline video player and non-NIP-71 clients still see a clickable master.m3u8 link. Orchestrator picks up a new suspend signAndPublishNote callback that takes the note content and returns the signed event id. The note is only signed after the NIP-71 publish succeeds, so a failed video publish never produces an orphaned note. HlsPublishState.Success now carries the nullable noteEventId, and the success screen offers a "View note" primary button that navigates to Route.Note(noteEventId) when set; otherwise it falls back to the existing "Done" button. The form switch defaults to on and sits just below the content-warning row. Turning it off leaves the noteEventId null in the final Success state. Production wiring uses TextNoteEvent.build(content) + account.signer.sign + account.sendAutomatic, matching the existing short-note publish path. Two new orchestrator tests lock in the cross-post behaviour: one that captures the note content and asserts it contains the title, description and master URL, and one that verifies crossPostAsNote=false never invokes the note callback and leaves noteEventId null. Co-Authored-By: Claude Opus 4.5 --- .../video/hls/HlsPublishOrchestrator.kt | 26 ++++++- .../hls/HlsPublishOrchestratorFactory.kt | 6 ++ .../loggedIn/video/hls/HlsPublishState.kt | 1 + .../loggedIn/video/hls/NewHlsVideoScreen.kt | 65 ++++++++++++++--- .../video/hls/NewHlsVideoViewModel.kt | 2 + amethyst/src/main/res/values/strings.xml | 2 + .../uploads/hls/HlsPublishOrchestratorTest.kt | 72 +++++++++++++++++++ 7 files changed, 165 insertions(+), 9 deletions(-) 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 58682fd173..9c4798e1d9 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 @@ -41,6 +41,7 @@ data class HlsPublishRequest( val codec: VideoCodec, val server: ServerName, val durationSeconds: Int? = null, + val crossPostAsNote: Boolean = true, ) /** @@ -60,6 +61,7 @@ class HlsPublishOrchestrator( ) -> HlsBundle, private val buildUploader: (ServerName) -> HlsBlobUploader, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, + private val signAndPublishNote: suspend (content: String) -> String, private val workDirFactory: () -> File, ) { val state: StateFlow = _state @@ -96,7 +98,20 @@ class HlsPublishOrchestrator( ) val eventId = signAndPublish(template) - _state.value = HlsPublishState.Success(eventId = eventId, masterUrl = uploadResult.masterUrl) + val noteEventId = + if (request.crossPostAsNote) { + val content = buildCompanionNoteContent(request, uploadResult.masterUrl) + signAndPublishNote(content) + } else { + null + } + + _state.value = + HlsPublishState.Success( + eventId = eventId, + masterUrl = uploadResult.masterUrl, + noteEventId = noteEventId, + ) } catch (e: CancellationException) { _state.value = HlsPublishState.Failure(message = "Cancelled") throw e @@ -110,4 +125,13 @@ class HlsPublishOrchestrator( } private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null + + private fun buildCompanionNoteContent( + request: HlsPublishRequest, + masterUrl: String, + ): String = + listOf(request.title, request.description, masterUrl) + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n\n") } 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 659a124c0c..2810c7aab5 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 @@ -26,6 +26,7 @@ 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 com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.MutableStateFlow import java.io.File @@ -67,6 +68,11 @@ fun createProductionHlsPublishOrchestrator( account.sendAutomatic(signed) signed.id }, + signAndPublishNote = { content -> + val signed = account.signer.sign(TextNoteEvent.build(content)) + account.sendAutomatic(signed) + signed.id + }, workDirFactory = { File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt index da48161d19..8b782938e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -38,6 +38,7 @@ sealed class HlsPublishState { data class Success( val eventId: String, val masterUrl: String, + val noteEventId: String? = null, ) : HlsPublishState() data class Failure( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 075a4fa782..c9a2a578b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.collections.immutable.toImmutableList @@ -318,6 +319,30 @@ private fun FormFields(vm: NewHlsVideoViewModel) { ) } + Spacer(Modifier.height(8.dp)) + + // Cross-post as kind-1 note toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.hls_cross_post_as_note), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.hls_cross_post_as_note_explainer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = vm.crossPostAsNote, + onCheckedChange = { vm.crossPostAsNote = it }, + ) + } + Spacer(Modifier.height(16.dp)) // Server picker — reads the user's configured Blossom servers from the account @@ -587,14 +612,38 @@ private fun SuccessBody( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(16.dp)) - Button( - onClick = { - vm.reset() - nav.popBack() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.hls_done)) + + val noteId = state.noteEventId + if (noteId != null) { + Button( + onClick = { + vm.reset() + nav.nav(Route.Note(noteId)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_view_note)) + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { + vm.reset() + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_done)) + } + } else { + Button( + onClick = { + vm.reset() + nav.popBack() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.hls_done)) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index 79f22db89a..72db7b4422 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -63,6 +63,7 @@ open class NewHlsVideoViewModel : ViewModel() { var sensitiveContent by mutableStateOf(false) var contentWarningReason by mutableStateOf("") var useH265 by mutableStateOf(true) + var crossPostAsNote by mutableStateOf(true) var selectedServer by mutableStateOf(null) private val _state = MutableStateFlow(HlsPublishState.Idle) @@ -146,6 +147,7 @@ open class NewHlsVideoViewModel : ViewModel() { codec = codec, server = server, durationSeconds = sourceMetadata?.durationSeconds, + crossPostAsNote = crossPostAsNote, ) currentJob = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f562734dab..837b8b0d14 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2112,6 +2112,8 @@ View note Done Try again + Cross-post as note + Also publish a regular Nostr note linking to the video so it shows in the home feed. Pack Actions List Actions 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 fbce8d2c3e..74c93d5fc5 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 @@ -98,6 +98,7 @@ class HlsPublishOrchestratorTest { description: String = "A test clip", sensitive: Boolean = false, warningReason: String = "", + crossPostAsNote: Boolean = false, ) = HlsPublishRequest( title = title, description = description, @@ -105,6 +106,7 @@ class HlsPublishOrchestratorTest { contentWarningReason = warningReason, codec = VideoCodec.H265, server = server, + crossPostAsNote = crossPostAsNote, ) @Test @@ -119,6 +121,7 @@ class HlsPublishOrchestratorTest { publishedTemplates += tpl "signed-event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -161,6 +164,7 @@ class HlsPublishOrchestratorTest { capturedDuringPublish += orchestrator.state.value "event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -184,6 +188,7 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -204,6 +209,7 @@ class HlsPublishOrchestratorTest { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } }, signAndPublish = { "never" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -222,6 +228,7 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { throw RuntimeException("relay rejected") }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -244,6 +251,7 @@ class HlsPublishOrchestratorTest { captured += tpl "event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -282,6 +290,7 @@ class HlsPublishOrchestratorTest { captured += tpl "event-id" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -290,6 +299,68 @@ class HlsPublishOrchestratorTest { assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) } + @Test + fun crossPostAsNoteInvokesSignAndPublishNoteWithTitleDescriptionAndMasterUrl() { + val capturedNoteContent = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { "video-event-id" }, + signAndPublishNote = { content -> + capturedNoteContent += content + "note-event-id" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { + orchestrator.publish( + newRequest( + title = "Sunset", + description = "Golden hour", + crossPostAsNote = true, + ), + ) + } + + assertEquals(1, capturedNoteContent.size) + val note = capturedNoteContent[0] + assertTrue("note missing title: $note", note.contains("Sunset")) + assertTrue("note missing description: $note", note.contains("Golden hour")) + assertTrue("note missing master url: $note", note.contains("https://cdn.test/")) + + val final = orchestrator.state.value as HlsPublishState.Success + assertEquals("note-event-id", final.noteEventId) + assertEquals("video-event-id", final.eventId) + } + + @Test + fun crossPostDisabledLeavesNoteEventIdNull() { + val noteCallbackInvocations = mutableListOf() + val orchestrator = + HlsPublishOrchestrator( + _state = MutableStateFlow(HlsPublishState.Idle), + runTranscode = { _, _, _ -> fakeBundle() }, + buildUploader = { CannedUploader() }, + signAndPublish = { "video-event-id" }, + signAndPublishNote = { + noteCallbackInvocations += it + "should-not-be-used" + }, + workDirFactory = { File(workDir, "work").apply { mkdirs() } }, + ) + + runBlocking { + orchestrator.publish(newRequest(crossPostAsNote = false)) + } + + assertEquals(0, noteCallbackInvocations.size) + val final = orchestrator.state.value as HlsPublishState.Success + assertEquals(null, final.noteEventId) + } + @Test fun resetRestoresIdleState() { val orchestrator = @@ -298,6 +369,7 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, + signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) From 295e208f36a8403eefdce861a872a14a13ea1234 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 21:04:10 +0200 Subject: [PATCH 12/26] test(hls): lock in server-returns-clean-url pass-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side fix is in: the NIP-96 WordPress plugin now returns clean ".m3u8" / ".mp4" URLs instead of echoing our upload filename with a trailing bare dot. Pin down that the pipeline passes those URLs through untouched — no second ".m3u8" appended on top, no regex hiccup, nothing. The existing withExtensionHint logic already handles this correctly (endsWith check with ignoreCase) but the test makes the intent explicit so a future refactor cannot silently re-introduce the double-extension bug. Co-Authored-By: Claude Opus 4.5 --- .../uploads/hls/HlsUploadPipelineTest.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 index 346abb454d..edac38b65b 100644 --- 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 @@ -212,6 +212,46 @@ class HlsUploadPipelineTest { assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) } + @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 trailingDotUrlsAreSanitisedIntoCleanExtension() { val bundle = createBundle(listOf("360p")) From 2dab90fc2e4ca1f74d196b9f41a07699492fd340 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 21:21:58 +0200 Subject: [PATCH 13/26] fix(video-quality): label by short side so portrait videos show 360p/540p etc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VideoQualityButton and VideoQualityChoices were labelling each rendition by format.height. That matches the streaming convention "360p = 360 pixel short side" only for landscape content. For a portrait upload (9:16) the renditions encode as 360x640, 540x960, 720x1280, 1080x1920, 2160x3840 — so format.height is the long side, and the picker rendered "640p / 960p / 1280p / 1920p / 3840p" instead of the expected "360p / 540p / 720p / 1080p / 4K". Switch to minOf(format.width, format.height) (the short side) for both the ladder rung labels and the currently-playing indicator. Rename QualityChoice.height -> QualityChoice.shortSide and getCurrentPlayingHeight() -> getCurrentPlayingShortSide() so the fields match the thing they now represent, and add a one-line comment explaining the convention. Co-Authored-By: Claude Opus 4.5 --- .../controls/VideoQualityAvailability.kt | 11 ++++++++-- .../composable/controls/VideoQualityButton.kt | 22 +++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt index ff9be88bd1..25581490cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt @@ -25,10 +25,17 @@ import androidx.media3.common.Tracks fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } -fun getCurrentPlayingHeight(tracks: Tracks): Int? { +// Returns the "Xp" value for the currently selected video track. Uses min(width, height) so +// that a portrait video's renditions get the same "360p / 540p / 720p" labels as a landscape +// source — the streaming convention is to label by the short side, not format.height which is +// the long side for portrait content. +fun getCurrentPlayingShortSide(tracks: Tracks): Int? { val group = getVideoTrackGroup(tracks) ?: return null for (i in 0 until group.length) { - if (group.isTrackSelected(i)) return group.getTrackFormat(i).height + if (group.isTrackSelected(i)) { + val format = group.getTrackFormat(i) + return minOf(format.width, format.height).takeIf { it > 0 } + } } return null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index 3d59c00705..99637bfbcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -124,7 +124,7 @@ fun VideoQualityButton( ) { VideoQualityChoices( videoGroup = videoGroup, - currentHeight = getCurrentPlayingHeight(tracks), + currentShortSide = getCurrentPlayingShortSide(tracks), isAuto = !hasVideoOverride(player), onSelectAuto = { clearVideoOverride(player) @@ -142,7 +142,7 @@ fun VideoQualityButton( @Composable private fun VideoQualityChoices( videoGroup: Tracks.Group, - currentHeight: Int?, + currentShortSide: Int?, isAuto: Boolean, onSelectAuto: () -> Unit, onSelectTrack: (Int) -> Unit, @@ -162,7 +162,7 @@ private fun VideoQualityChoices( horizontalAlignment = Alignment.CenterHorizontally, ) { TextButton(colors = colors, onClick = onSelectAuto) { - val suffix = currentHeight?.let { " (${it}p)" } ?: "" + val suffix = currentShortSide?.let { " (${it}p)" } ?: "" Text( stringRes(R.string.video_quality_auto) + suffix, fontWeight = if (isAuto) FontWeight(1000) else FontWeight(400), @@ -172,17 +172,20 @@ private fun VideoQualityChoices( choices.forEach { choice -> TextButton(colors = colors, onClick = { onSelectTrack(choice.trackIndex) }) { Text( - "${choice.height}p ${formatBitrate(choice.bitrate)}", - fontWeight = if (!isAuto && currentHeight == choice.height) FontWeight(1000) else FontWeight(400), + "${choice.shortSide}p ${formatBitrate(choice.bitrate)}", + fontWeight = if (!isAuto && currentShortSide == choice.shortSide) FontWeight(1000) else FontWeight(400), ) } } } } +// shortSide = min(width, height). Matches the streaming convention that "360p" means +// 360 pixels on the short side regardless of orientation, so portrait videos get sensible +// labels instead of "640p / 960p / 1280p" for the same ladder rungs. private data class QualityChoice( val trackIndex: Int, - val height: Int, + val shortSide: Int, val bitrate: Int, ) @@ -190,11 +193,12 @@ private fun buildQualityChoices(group: Tracks.Group): ImmutableList() for (i in 0 until group.length) { val format = group.getTrackFormat(i) - if (format.height > 0) { - choices.add(QualityChoice(i, format.height, format.bitrate)) + val shortSide = minOf(format.width, format.height) + if (shortSide > 0) { + choices.add(QualityChoice(i, shortSide, format.bitrate)) } } - return choices.sortedByDescending { it.height }.toImmutableList() + return choices.sortedByDescending { it.shortSide }.toImmutableList() } private fun formatBitrate(bitrate: Int): String = From 453bdab2022d2ec2449649dfde82fc4c4a19b920 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 21:57:55 +0200 Subject: [PATCH 14/26] refactor(hls): replace auto kind-1 cross-post with editable draft handoff The auto-publish behaviour from the previous commit silently sent a kind-1 note the user could not edit before it hit the relays. Replace it with a handoff into Amethyst's existing NewShortNote composer pre-filled with the title, description and master playlist URL so the author can tweak the text, add hashtags/mentions, and send when ready. Changes: - HlsPublishOrchestrator drops the signAndPublishNote callback and stops publishing any kind-1 itself. HlsPublishState.Success drops the noteEventId field. - HlsPublishRequest drops crossPostAsNote (the orchestrator no longer cares about the toggle). - NewHlsVideoViewModel renames crossPostAsNote -> draftNoteAfterUpload and the screen renames the switch to "Draft note after upload". - SuccessBody now reads vm.draftNoteAfterUpload: when on, it offers a "Draft note" primary button that navigates to Route.NewShortNote with the message parameter filled via a small buildDraftNoteText helper (title + description + masterUrl, blanks collapsed). The existing short-note composer accepts message as its initial text. When off, the button reverts to "Done". - Production factory wiring drops the TextNoteEvent import + closure. - Orchestrator tests drop the two cross-post cases; a single signAndPublish callback is now enough. Co-Authored-By: Claude Opus 4.5 --- .../video/hls/HlsPublishOrchestrator.kt | 20 ------ .../hls/HlsPublishOrchestratorFactory.kt | 6 -- .../loggedIn/video/hls/HlsPublishState.kt | 1 - .../loggedIn/video/hls/NewHlsVideoScreen.kt | 29 +++++--- .../video/hls/NewHlsVideoViewModel.kt | 3 +- amethyst/src/main/res/values/strings.xml | 5 +- .../uploads/hls/HlsPublishOrchestratorTest.kt | 72 ------------------- 7 files changed, 24 insertions(+), 112 deletions(-) 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 9c4798e1d9..d315a11fea 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 @@ -41,7 +41,6 @@ data class HlsPublishRequest( val codec: VideoCodec, val server: ServerName, val durationSeconds: Int? = null, - val crossPostAsNote: Boolean = true, ) /** @@ -61,7 +60,6 @@ class HlsPublishOrchestrator( ) -> HlsBundle, private val buildUploader: (ServerName) -> HlsBlobUploader, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, - private val signAndPublishNote: suspend (content: String) -> String, private val workDirFactory: () -> File, ) { val state: StateFlow = _state @@ -98,19 +96,10 @@ class HlsPublishOrchestrator( ) val eventId = signAndPublish(template) - val noteEventId = - if (request.crossPostAsNote) { - val content = buildCompanionNoteContent(request, uploadResult.masterUrl) - signAndPublishNote(content) - } else { - null - } - _state.value = HlsPublishState.Success( eventId = eventId, masterUrl = uploadResult.masterUrl, - noteEventId = noteEventId, ) } catch (e: CancellationException) { _state.value = HlsPublishState.Failure(message = "Cancelled") @@ -125,13 +114,4 @@ class HlsPublishOrchestrator( } private fun contentWarningOrNull(request: HlsPublishRequest): String? = if (request.sensitiveContent) request.contentWarningReason else null - - private fun buildCompanionNoteContent( - request: HlsPublishRequest, - masterUrl: String, - ): String = - listOf(request.title, request.description, masterUrl) - .map { it.trim() } - .filter { it.isNotEmpty() } - .joinToString("\n\n") } 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 2810c7aab5..659a124c0c 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 @@ -26,7 +26,6 @@ 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 com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.flow.MutableStateFlow import java.io.File @@ -68,11 +67,6 @@ fun createProductionHlsPublishOrchestrator( account.sendAutomatic(signed) signed.id }, - signAndPublishNote = { content -> - val signed = account.signer.sign(TextNoteEvent.build(content)) - account.sendAutomatic(signed) - signed.id - }, workDirFactory = { File(context.cacheDir, "hls-${System.currentTimeMillis()}").apply { mkdirs() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt index 8b782938e6..da48161d19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -38,7 +38,6 @@ sealed class HlsPublishState { data class Success( val eventId: String, val masterUrl: String, - val noteEventId: String? = null, ) : HlsPublishState() data class Failure( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index c9a2a578b1..cb51acc81b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -321,25 +321,26 @@ private fun FormFields(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(8.dp)) - // Cross-post as kind-1 note toggle + // Draft-a-note-after-upload toggle — opens the existing short-note composer prefilled + // with the title, description and master playlist URL; the user edits and posts. Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.hls_cross_post_as_note), + text = stringResource(R.string.hls_draft_note_after_upload), style = MaterialTheme.typography.bodyLarge, ) Text( - text = stringResource(R.string.hls_cross_post_as_note_explainer), + text = stringResource(R.string.hls_draft_note_after_upload_explainer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Switch( - checked = vm.crossPostAsNote, - onCheckedChange = { vm.crossPostAsNote = it }, + checked = vm.draftNoteAfterUpload, + onCheckedChange = { vm.draftNoteAfterUpload = it }, ) } @@ -613,16 +614,16 @@ private fun SuccessBody( ) Spacer(Modifier.height(16.dp)) - val noteId = state.noteEventId - if (noteId != null) { + if (vm.draftNoteAfterUpload) { Button( onClick = { + val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl) vm.reset() - nav.nav(Route.Note(noteId)) + nav.nav(Route.NewShortNote(message = draft)) }, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(R.string.hls_view_note)) + Text(stringResource(R.string.hls_draft_note_button)) } Spacer(Modifier.height(8.dp)) OutlinedButton( @@ -648,6 +649,16 @@ private fun SuccessBody( } } +private fun buildDraftNoteText( + title: String, + description: String, + masterUrl: String, +): String = + listOf(title, description, masterUrl) + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n\n") + @Composable private fun FailureBody( vm: NewHlsVideoViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index 72db7b4422..c7816c86f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -63,7 +63,7 @@ open class NewHlsVideoViewModel : ViewModel() { var sensitiveContent by mutableStateOf(false) var contentWarningReason by mutableStateOf("") var useH265 by mutableStateOf(true) - var crossPostAsNote by mutableStateOf(true) + var draftNoteAfterUpload by mutableStateOf(true) var selectedServer by mutableStateOf(null) private val _state = MutableStateFlow(HlsPublishState.Idle) @@ -147,7 +147,6 @@ open class NewHlsVideoViewModel : ViewModel() { codec = codec, server = server, durationSeconds = sourceMetadata?.durationSeconds, - crossPostAsNote = crossPostAsNote, ) currentJob = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 837b8b0d14..99c580a522 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2112,8 +2112,9 @@ View note Done Try again - Cross-post as note - Also publish a regular Nostr note linking to the video so it shows in the home feed. + Draft note after upload + Open the note composer pre-filled with the title, description and video link so you can tweak it before posting. + Draft note Pack Actions List Actions 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 74c93d5fc5..fbce8d2c3e 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 @@ -98,7 +98,6 @@ class HlsPublishOrchestratorTest { description: String = "A test clip", sensitive: Boolean = false, warningReason: String = "", - crossPostAsNote: Boolean = false, ) = HlsPublishRequest( title = title, description = description, @@ -106,7 +105,6 @@ class HlsPublishOrchestratorTest { contentWarningReason = warningReason, codec = VideoCodec.H265, server = server, - crossPostAsNote = crossPostAsNote, ) @Test @@ -121,7 +119,6 @@ class HlsPublishOrchestratorTest { publishedTemplates += tpl "signed-event-id" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -164,7 +161,6 @@ class HlsPublishOrchestratorTest { capturedDuringPublish += orchestrator.state.value "event-id" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -188,7 +184,6 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -209,7 +204,6 @@ class HlsPublishOrchestratorTest { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } }, signAndPublish = { "never" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -228,7 +222,6 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { throw RuntimeException("relay rejected") }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -251,7 +244,6 @@ class HlsPublishOrchestratorTest { captured += tpl "event-id" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -290,7 +282,6 @@ class HlsPublishOrchestratorTest { captured += tpl "event-id" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) @@ -299,68 +290,6 @@ class HlsPublishOrchestratorTest { assertTrue(captured.single() is HlsVideoEventTemplate.Vertical) } - @Test - fun crossPostAsNoteInvokesSignAndPublishNoteWithTitleDescriptionAndMasterUrl() { - val capturedNoteContent = mutableListOf() - val orchestrator = - HlsPublishOrchestrator( - _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, - buildUploader = { CannedUploader() }, - signAndPublish = { "video-event-id" }, - signAndPublishNote = { content -> - capturedNoteContent += content - "note-event-id" - }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, - ) - - runBlocking { - orchestrator.publish( - newRequest( - title = "Sunset", - description = "Golden hour", - crossPostAsNote = true, - ), - ) - } - - assertEquals(1, capturedNoteContent.size) - val note = capturedNoteContent[0] - assertTrue("note missing title: $note", note.contains("Sunset")) - assertTrue("note missing description: $note", note.contains("Golden hour")) - assertTrue("note missing master url: $note", note.contains("https://cdn.test/")) - - val final = orchestrator.state.value as HlsPublishState.Success - assertEquals("note-event-id", final.noteEventId) - assertEquals("video-event-id", final.eventId) - } - - @Test - fun crossPostDisabledLeavesNoteEventIdNull() { - val noteCallbackInvocations = mutableListOf() - val orchestrator = - HlsPublishOrchestrator( - _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, - buildUploader = { CannedUploader() }, - signAndPublish = { "video-event-id" }, - signAndPublishNote = { - noteCallbackInvocations += it - "should-not-be-used" - }, - workDirFactory = { File(workDir, "work").apply { mkdirs() } }, - ) - - runBlocking { - orchestrator.publish(newRequest(crossPostAsNote = false)) - } - - assertEquals(0, noteCallbackInvocations.size) - val final = orchestrator.state.value as HlsPublishState.Success - assertEquals(null, final.noteEventId) - } - @Test fun resetRestoresIdleState() { val orchestrator = @@ -369,7 +298,6 @@ class HlsPublishOrchestratorTest { runTranscode = { _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, - signAndPublishNote = { "note-id" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, ) From a4787c5fdca3ea91bd2d2bb3e0a92731cb21dd0a Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 22:15:11 +0200 Subject: [PATCH 15/26] feat(hls): checkboxes to pick which renditions to upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users hit a server upload failure and asked for the ability to skip the biggest renditions. The LightCompressor HlsLadder API already supports filtering — this commit wires that through the full stack: - HlsTranscoder.transcode() now accepts an HlsLadder parameter defaulting to HlsLadder.default(), and passes it into HlsConfig. - HlsPublishRequest gains a ladder field. - HlsPublishOrchestrator.runTranscode callback now takes the ladder as a fourth parameter; the orchestrator forwards request.ladder into it. - The production factory captures the ladder on each publish. - NewHlsVideoViewModel tracks selectedRenditionLabels as a mutable Set defaulting to all five default rungs. publish() builds an HlsLadder from that set by filtering HlsLadder.default().renditions and refuses to publish when the set is empty. - NewHlsVideoScreen replaces the read-only renditions preview with a RenditionsCheckboxes composable. Each default rung renders as a Checkbox + label + bitrate; rungs above the detected source short side are disabled with an "above source — will be skipped" subline so the user cannot accidentally select a rendition the library would drop anyway. - Publish button gates on selectedRenditionLabels.isNotEmpty() so an empty selection never fires the pipeline. Tests updated for the new runTranscode signature. Co-Authored-By: Claude Opus 4.5 --- .../service/uploads/hls/HlsTranscoder.kt | 4 +- .../video/hls/HlsPublishOrchestrator.kt | 5 +- .../hls/HlsPublishOrchestratorFactory.kt | 3 +- .../loggedIn/video/hls/NewHlsVideoScreen.kt | 97 ++++++++++++------- .../video/hls/NewHlsVideoViewModel.kt | 15 +++ amethyst/src/main/res/values/strings.xml | 2 + .../uploads/hls/HlsPublishOrchestratorTest.kt | 16 +-- 7 files changed, 94 insertions(+), 48 deletions(-) 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 index 111ed89140..463317b10f 100644 --- 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 @@ -25,6 +25,7 @@ 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 @@ -49,11 +50,12 @@ object HlsTranscoder { 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) + val config = HlsConfig(codec = codec, ladder = ladder) HlsPreparer.start( context = context, 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 d315a11fea..efebabf3b4 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,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.hls.HlsLadder import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader import com.vitorpamplona.amethyst.service.uploads.hls.HlsBundle import com.vitorpamplona.amethyst.service.uploads.hls.HlsUploadPipeline @@ -40,6 +41,7 @@ data class HlsPublishRequest( val contentWarningReason: String, val codec: VideoCodec, val server: ServerName, + val ladder: HlsLadder = HlsLadder.default(), val durationSeconds: Int? = null, ) @@ -56,6 +58,7 @@ class HlsPublishOrchestrator( private val runTranscode: suspend ( workDir: File, codec: VideoCodec, + ladder: HlsLadder, onProgress: (label: String, percent: Int) -> Unit, ) -> HlsBundle, private val buildUploader: (ServerName) -> HlsBlobUploader, @@ -69,7 +72,7 @@ class HlsPublishOrchestrator( try { _state.value = HlsPublishState.Transcoding(currentLabel = "", percent = 0) val bundle = - runTranscode(workDir, request.codec) { label, percent -> + runTranscode(workDir, request.codec, request.ladder) { label, percent -> _state.value = HlsPublishState.Transcoding(label, percent) } 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 659a124c0c..fbd0b5d7c5 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 @@ -45,13 +45,14 @@ fun createProductionHlsPublishOrchestrator( ): HlsPublishOrchestrator = HlsPublishOrchestrator( _state = state, - runTranscode = { workDir, codec, onProgress -> + runTranscode = { workDir, codec, ladder, onProgress -> val uri = uriProvider() ?: error("No video picked") HlsTranscoder.transcode( context = context, uri = uri, workDir = workDir, codec = codec, + ladder = ladder, onRenditionProgress = onProgress, ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index cb51acc81b..8f0414ad61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -189,7 +190,7 @@ private fun IdleBody(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(24.dp)) Button( onClick = { vm.publish(context) }, - enabled = vm.title.isNotBlank() && vm.selectedServer != null, + enabled = vm.title.isNotBlank() && vm.selectedServer != null && vm.selectedRenditionLabels.isNotEmpty(), modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.hls_publish_button)) @@ -375,8 +376,8 @@ private fun FormFields(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(16.dp)) - // Renditions preview (read-only) - RenditionsPreview(vm.sourceMetadata) + // Renditions — user can toggle which rungs to upload + RenditionsCheckboxes(vm) } @Composable @@ -419,51 +420,73 @@ private fun CodecToggle( } @Composable -private fun RenditionsPreview(metadata: HlsSourceMetadata?) { +private fun RenditionsCheckboxes(vm: NewHlsVideoViewModel) { Text( text = stringResource(R.string.hls_renditions_label), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) - if (metadata == null) { - Text( - text = "360p · 540p · 720p · 1080p · 4K", - style = MaterialTheme.typography.bodyMedium, - ) - return - } - val shortSide = minOf(metadata.width, metadata.height) - val ladder = - HlsLadder - .default() - .forSource(shortSide) - .renditions - .map { it.resolution.label } - val skipped = - HlsLadder - .default() - .renditions - .map { it.resolution.label } - .filter { it !in ladder } - Text( - text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.hls_renditions_produce_format, ladder.joinToString(" · ")), - style = MaterialTheme.typography.bodyMedium, - ) - if (skipped.isNotEmpty()) { - Spacer(Modifier.height(2.dp)) + val metadata = vm.sourceMetadata + val sourceShortSide = metadata?.let { minOf(it.width, it.height) } + if (metadata != null) { Text( - text = stringResource(R.string.hls_renditions_skipped_format, skipped.joinToString(", ")), + text = stringResource(R.string.hls_renditions_source_format, metadata.width, metadata.height), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(Modifier.height(8.dp)) + } + + HlsLadder.default().renditions.forEach { rendition -> + val label = rendition.resolution.label + val aboveSource = sourceShortSide != null && rendition.resolution.shortSide > sourceShortSide + val enabled = !aboveSource + val checked = label in vm.selectedRenditionLabels && !aboveSource + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { + vm.selectedRenditionLabels = + if (checked) { + vm.selectedRenditionLabels - label + } else { + vm.selectedRenditionLabels + label + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + enabled = enabled, + onCheckedChange = { + vm.selectedRenditionLabels = + if (it) vm.selectedRenditionLabels + label else vm.selectedRenditionLabels - label + }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = + if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + val subline = + if (aboveSource) { + stringResource(R.string.hls_rendition_above_source) + } else { + stringResource(R.string.hls_rendition_bitrate_kbps_format, rendition.bitrateKbps) + } + Text( + text = subline, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt index c7816c86f4..eaccaa3a4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoViewModel.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.davotoula.lightcompressor.VideoCodec +import com.davotoula.lightcompressor.hls.HlsLadder import com.davotoula.lightcompressor.utils.CompressorUtils import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS @@ -66,6 +67,14 @@ open class NewHlsVideoViewModel : ViewModel() { var draftNoteAfterUpload by mutableStateOf(true) var selectedServer by mutableStateOf(null) + var selectedRenditionLabels by mutableStateOf( + HlsLadder + .default() + .renditions + .map { it.resolution.label } + .toSet(), + ) + private val _state = MutableStateFlow(HlsPublishState.Idle) val state: StateFlow = _state.asStateFlow() @@ -136,8 +145,13 @@ open class NewHlsVideoViewModel : ViewModel() { val server = selectedServer ?: return if (pickedUri == null) return if (title.isBlank()) return + if (selectedRenditionLabels.isEmpty()) return val codec = effectiveCodec(useH265) + val ladder = + HlsLadder( + HlsLadder.default().renditions.filter { it.resolution.label in selectedRenditionLabels }, + ) val request = HlsPublishRequest( title = title, @@ -146,6 +160,7 @@ open class NewHlsVideoViewModel : ViewModel() { contentWarningReason = contentWarningReason, codec = codec, server = server, + ladder = ladder, durationSeconds = sourceMetadata?.durationSeconds, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 99c580a522..bc1cf356ba 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2101,6 +2101,8 @@ Source resolution: %1$d×%2$d Will produce: %1$s (%1$s skipped — above source) + %1$d kbps + above source — will be skipped Publish HD video Publishing “%1$s”… Transcoding %1$s 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 fbce8d2c3e..3fda699b10 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 @@ -113,7 +113,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> publishedTemplates += tpl @@ -147,7 +147,7 @@ class HlsPublishOrchestratorTest { orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, onProgress -> + runTranscode = { _, _, _, onProgress -> capturedDuringTranscode += orchestrator.state.value onProgress("360p", 42) capturedDuringTranscode += orchestrator.state.value @@ -181,7 +181,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> throw RuntimeException("decode failed") }, + runTranscode = { _, _, _, _ -> throw RuntimeException("decode failed") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, @@ -199,7 +199,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { HlsBlobUploader { _, _ -> throw RuntimeException("server 500") } }, @@ -219,7 +219,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { throw RuntimeException("relay rejected") }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, @@ -238,7 +238,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> fakeBundle() }, + runTranscode = { _, _, _, _ -> fakeBundle() }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> captured += tpl @@ -276,7 +276,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, + runTranscode = { _, _, _, _ -> HlsBundle(workDir, portraitMaster, listOf(rendition)) }, buildUploader = { CannedUploader() }, signAndPublish = { tpl -> captured += tpl @@ -295,7 +295,7 @@ class HlsPublishOrchestratorTest { val orchestrator = HlsPublishOrchestrator( _state = MutableStateFlow(HlsPublishState.Idle), - runTranscode = { _, _, _ -> throw RuntimeException("boom") }, + runTranscode = { _, _, _, _ -> throw RuntimeException("boom") }, buildUploader = { CannedUploader() }, signAndPublish = { "never" }, workDirFactory = { File(workDir, "work").apply { mkdirs() } }, From 610528cfcd759470eff63b5846892f240eb720e3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 23:13:42 +0200 Subject: [PATCH 16/26] refactor(hls): trust the upload server URL verbatim, drop withExtensionHint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policy shift agreed with the server-side collaborator: the URL the NIP-96 / Blossom server returns is already the optimal form — clean Content-Type, correct cache keys, range-friendly — and the client should not second-guess it. Stripping extensions, appending hints or rewriting to bare sha256 all add round trips and cache misses for no real win now that the WordPress plugin returns clean .m3u8 URLs. Concretely: - HlsUploadPipeline drops the withExtensionHint helper entirely. Every combinedUrl / mediaPlaylistUrl / masterUrl flows straight from the uploader's MediaUploadResult.url into the playlist rewrite and the HlsUploadResult. - A small kdoc note at the top of the pipeline documents the policy ("if a server returns an unplayable URL, the fix is server-side"). Pipeline tests pruned: - appendsMp4HintToBareCombinedUrlInMediaPlaylist — removed, was testing the appender that no longer exists. - appendsM3u8HintToBarePlaylistUrlInMasterPlaylist — removed, same. - trailingDotUrlsAreSanitisedIntoCleanExtension — removed, the Nip96 fallback-extension map keeps the server from returning trailing-dot URLs in the first place. - doesNotDoubleAppendExtensionWhenAlreadyPresent — removed. Kept and reframed: - serverUrlsAlreadyWithExtensionPassThroughUntouched — the canonical contract: server returns clean .m3u8 / .mp4, pipeline forwards verbatim. - bareServerUrlsPassThroughVerbatim (new) — explicitly documents that even a bare-hash URL flows through unchanged; the pipeline does not try to make it playable. Co-Authored-By: Claude Opus 4.5 --- .../service/uploads/hls/HlsUploadPipeline.kt | 43 +++-------- .../uploads/hls/HlsUploadPipelineTest.kt | 73 +++---------------- 2 files changed, 20 insertions(+), 96 deletions(-) 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 index 6946dca488..86783a93a0 100644 --- 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 @@ -58,6 +58,12 @@ data class HlsUploadedRendition( * 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, @@ -75,10 +81,7 @@ class HlsUploadPipeline( val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4) onProgress(++done, total) val combinedUrl = - withExtensionHint( - combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}"), - CONTENT_TYPE_VIDEO_MP4, - ) + combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}") val rewrittenMedia = HlsPlaylistRewriter.rewrite( @@ -90,10 +93,7 @@ class HlsUploadPipeline( val mediaPlaylist = uploader.upload(mediaPlaylistFile, CONTENT_TYPE_HLS) onProgress(++done, total) val mediaPlaylistUrl = - withExtensionHint( - mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}"), - CONTENT_TYPE_HLS, - ) + mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}") HlsUploadedRendition( label = rendition.label, @@ -112,10 +112,7 @@ class HlsUploadPipeline( val master = uploader.upload(masterFile, CONTENT_TYPE_HLS) onProgress(++done, total) val masterUrl = - withExtensionHint( - master.url ?: error("Uploader returned null URL for master playlist"), - CONTENT_TYPE_HLS, - ) + master.url ?: error("Uploader returned null URL for master playlist") return HlsUploadResult( masterUrl = masterUrl, @@ -124,28 +121,6 @@ class HlsUploadPipeline( ) } - // Blossom servers typically return bare-hash URLs (https://server/), but HLS parsers - // and ExoPlayer's Util.inferContentType sniff the URL extension to pick the right source - // factory. Append a hint unless the upload server already baked one in. Trailing dots are - // trimmed and any pre-existing doubled extension (e.g. "..m3u8") collapsed, so a server that - // echoes our empty-extension upload filename does not produce unreachable URLs. - private fun withExtensionHint( - url: String, - contentType: String, - ): String { - val ext = - when (contentType) { - CONTENT_TYPE_VIDEO_MP4 -> ".mp4" - CONTENT_TYPE_HLS -> ".m3u8" - else -> return url - } - val sanitised = - url - .replace(Regex("""\.{2,}""" + Regex.escape(ext.trimStart('.')) + "$", RegexOption.IGNORE_CASE), ext) - .trimEnd('.') - return if (sanitised.endsWith(ext, ignoreCase = true)) sanitised else sanitised + ext - } - companion object { const val CONTENT_TYPE_VIDEO_MP4 = "video/mp4" const val CONTENT_TYPE_HLS = "application/vnd.apple.mpegurl" 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 index edac38b65b..75b8048551 100644 --- 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 @@ -180,36 +180,25 @@ class HlsUploadPipelineTest { } @Test - fun appendsMp4HintToBareCombinedUrlInMediaPlaylist() { - val bundle = createBundle(listOf("360p")) - val uploader = BareUrlUploader() - val pipeline = HlsUploadPipeline(uploader) - - runBlocking { pipeline.upload(bundle) } - - val uploadedMediaPlaylist = uploader.calls[1].third - assertTrue( - "media playlist should reference url with .mp4 hint", - uploadedMediaPlaylist.contains("https://blossom.test/bare-1.mp4"), - ) - assertTrue(!uploadedMediaPlaylist.contains("https://blossom.test/bare-1\"")) - } - - @Test - fun appendsM3u8HintToBarePlaylistUrlInMasterPlaylist() { + 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) } - val uploadedMaster = uploader.calls[2].third + 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( - "master playlist should reference playlist url with .m3u8 hint", - uploadedMaster.contains("https://blossom.test/bare-2.m3u8"), + "media playlist should reference bare url: $uploadedMediaPlaylist", + uploadedMediaPlaylist.contains("https://blossom.test/bare-1"), ) - assertEquals("https://blossom.test/bare-3.m3u8", result.masterUrl) - assertEquals("https://blossom.test/bare-1.mp4", result.renditions[0].combinedUrl) } @Test @@ -252,46 +241,6 @@ class HlsUploadPipelineTest { assertTrue(!result.renditions[0].playlistUrl.contains(".m3u8.m3u8")) } - @Test - fun trailingDotUrlsAreSanitisedIntoCleanExtension() { - val bundle = createBundle(listOf("360p")) - val uploader = - object : HlsBlobUploader { - var count = 0 - - override suspend fun upload( - file: File, - contentType: String, - ): MediaUploadResult { - count++ - return MediaUploadResult(url = "https://blossom.test/bare-$count.", sha256 = "sha-$count", size = file.length()) - } - } - val pipeline = HlsUploadPipeline(uploader) - - val result = runBlocking { pipeline.upload(bundle) } - - assertTrue("masterUrl must not contain '..': ${result.masterUrl}", !result.masterUrl.contains("..")) - assertTrue("masterUrl must not end with '.': ${result.masterUrl}", !result.masterUrl.endsWith(".")) - assertTrue(result.masterUrl.endsWith(".m3u8")) - assertTrue(!result.renditions[0].combinedUrl.contains("..")) - assertTrue(result.renditions[0].combinedUrl.endsWith(".mp4")) - } - - @Test - fun doesNotDoubleAppendExtensionWhenAlreadyPresent() { - val bundle = createBundle(listOf("360p")) - val uploader = FakeUploader() // returns urls ending in .mp4 / .m3u8 - val pipeline = HlsUploadPipeline(uploader) - - runBlocking { pipeline.upload(bundle) } - - val uploadedMediaPlaylist = uploader.calls[1].content - assertTrue(!uploadedMediaPlaylist.contains(".mp4.mp4")) - val uploadedMaster = uploader.calls[2].content - assertTrue(!uploadedMaster.contains(".m3u8.m3u8")) - } - @Test fun reportsUploadProgressPerStep() { val bundle = createBundle(listOf("360p", "540p")) From 11c6a9fea144866604a69b9eb4f9adadff2c4af8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 23:22:41 +0200 Subject: [PATCH 17/26] feat(hls): show which file is currently uploading during the upload phase Users saw "Uploading 0/3" sit motionless for many seconds while the first large rendition crossed the wire, with no indication that anything was in flight. The counter only increments AFTER each upload completes, so the 0/N state is technically correct but reads like a stall. Pipeline progress callback signature is now (done, total, currentLabel), emitted BEFORE each upload starts: - "360p video" (done=0) -> upload - "360p playlist" (done=1) -> upload - "540p video" (done=2) -> upload - ... - "master playlist" (done=4) -> upload - "" (done=5) -> trailing completion tick HlsPublishState.Uploading gains a currentLabel field. The progress row in NewHlsVideoScreen picks up a new string "Uploading %s (%d / %d)" when the label is non-blank, falling back to the plain counter form for the initial Transcoding->Uploading transition. Test updated to assert the full six-emit sequence for a two-rendition bundle including the blank-label terminal emit. Co-Authored-By: Claude Opus 4.5 --- .../service/uploads/hls/HlsUploadPipeline.kt | 13 +++++++---- .../video/hls/HlsPublishOrchestrator.kt | 4 ++-- .../loggedIn/video/hls/HlsPublishState.kt | 1 + .../loggedIn/video/hls/NewHlsVideoScreen.kt | 18 +++++++++++++-- amethyst/src/main/res/values/strings.xml | 1 + .../uploads/hls/HlsUploadPipelineTest.kt | 22 ++++++++++++++----- 6 files changed, 45 insertions(+), 14 deletions(-) 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 index 86783a93a0..b84e845d69 100644 --- 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 @@ -70,7 +70,7 @@ class HlsUploadPipeline( ) { suspend fun upload( bundle: HlsBundle, - onProgress: (done: Int, total: Int) -> Unit = { _, _ -> }, + onProgress: (done: Int, total: Int, currentLabel: String) -> Unit = { _, _, _ -> }, ): HlsUploadResult { val playlistDir = File(bundle.workDir, "playlists").apply { mkdirs() } val total = bundle.renditions.size * 2 + 1 @@ -78,8 +78,9 @@ class HlsUploadPipeline( val uploadedRenditions = bundle.renditions.map { rendition -> + onProgress(done, total, "${rendition.label} video") val combined = uploader.upload(rendition.combinedFile, CONTENT_TYPE_VIDEO_MP4) - onProgress(++done, total) + done++ val combinedUrl = combined.url ?: error("Uploader returned null URL for ${rendition.combinedFile.name}") @@ -90,8 +91,9 @@ class HlsUploadPipeline( ) 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) - onProgress(++done, total) + done++ val mediaPlaylistUrl = mediaPlaylist.url ?: error("Uploader returned null URL for media playlist ${rendition.label}") @@ -109,11 +111,14 @@ class HlsUploadPipeline( 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) - onProgress(++done, total) + done++ val masterUrl = master.url ?: error("Uploader returned null URL for master playlist") + onProgress(done, total, "") + return HlsUploadResult( masterUrl = masterUrl, masterSha256 = master.sha256, 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 efebabf3b4..4951847c6f 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 @@ -81,8 +81,8 @@ class HlsPublishOrchestrator( val uploader = buildUploader(request.server) val pipeline = HlsUploadPipeline(uploader) val uploadResult = - pipeline.upload(bundle) { done, total -> - _state.value = HlsPublishState.Uploading(done, total) + pipeline.upload(bundle) { done, total, label -> + _state.value = HlsPublishState.Uploading(done, total, label) } _state.value = HlsPublishState.Publishing diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt index da48161d19..9fafc653ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/HlsPublishState.kt @@ -31,6 +31,7 @@ sealed class HlsPublishState { data class Uploading( val done: Int, val total: Int, + val currentLabel: String = "", ) : HlsPublishState() data object Publishing : HlsPublishState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 8f0414ad61..6627e91e1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -523,8 +523,22 @@ private fun ProgressBody( } val uploadingLabel = when (state) { - is HlsPublishState.Uploading -> stringResource(R.string.hls_state_uploading_format, state.done, state.total) - else -> stringResource(R.string.hls_state_uploading_format, 0, 0) + is HlsPublishState.Uploading -> { + if (state.currentLabel.isNotBlank()) { + stringResource( + R.string.hls_state_uploading_with_label_format, + state.currentLabel, + state.done, + state.total, + ) + } else { + stringResource(R.string.hls_state_uploading_format, state.done, state.total) + } + } + + else -> { + stringResource(R.string.hls_state_uploading_format, 0, 0) + } } PhaseRow( label = uploadingLabel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bc1cf356ba..4a05514350 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2107,6 +2107,7 @@ Publishing “%1$s”… Transcoding %1$s Uploading %1$d / %2$d + Uploading %1$s (%2$d / %3$d) Publishing event… Video published Your HD video is live on Nostr. 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 index 75b8048551..1b5e7b2df2 100644 --- 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 @@ -242,21 +242,31 @@ class HlsUploadPipelineTest { } @Test - fun reportsUploadProgressPerStep() { + fun reportsUploadProgressWithCurrentLabelBeforeEachStep() { val bundle = createBundle(listOf("360p", "540p")) val uploader = FakeUploader() val pipeline = HlsUploadPipeline(uploader) - val observed = mutableListOf>() + val observed = mutableListOf>() runBlocking { - pipeline.upload(bundle) { done, total -> - observed += done to total + pipeline.upload(bundle) { done, total, label -> + observed += Triple(done, total, label) } } - // 2 renditions × 2 + 1 master = 5 uploads + // 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(1 to 5, 2 to 5, 3 to 5, 4 to 5, 5 to 5), + 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, ) } From 5a9298c603c6e68e4d817e46b02d4aa66e50141b Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 23:59:20 +0200 Subject: [PATCH 18/26] fix(hls): add "done" to the upload counter so 0 / N reads less like a stall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare "0 / 5" read like the pipeline was stuck before the first upload completed. Appending "done" makes the count read as a completion tally — "0 / 5 done" clearly says "nothing finished yet" rather than "0 steps remaining" — and matches the label's intent. Co-Authored-By: Claude Opus 4.5 --- amethyst/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4a05514350..7acdaa3310 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2106,8 +2106,8 @@ Publish HD video Publishing “%1$s”… Transcoding %1$s - Uploading %1$d / %2$d - Uploading %1$s (%2$d / %3$d) + Uploading %1$d / %2$d done + Uploading %1$s (%2$d / %3$d done) Publishing event… Video published Your HD video is live on Nostr. From d58dd4bcceb8cd88483bbeca6df85b1a1ef94a81 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 08:19:42 +0200 Subject: [PATCH 19/26] fix(hls): lay rendition checkboxes out horizontally via FlowRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five full-width rows eating half a screen was too much real estate for a five-item toggle. Replace the fillMaxWidth stack with a FlowRow where each item is a compact checkbox + short label ("360p", "540p", …), wrapping to the next line on narrow screens. The bitrate subline per rendition was cut — the values are public library defaults anyway (360/540/720/1080/4K ladder) and the secondary text was the biggest contributor to the vertical bloat. Above-source rungs remain disabled; the grey-out on the checkbox + label is still visible in the new layout. Co-Authored-By: Claude Opus 4.5 --- .../loggedIn/video/hls/NewHlsVideoScreen.kt | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 6627e91e1f..14396a5f0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -439,17 +440,19 @@ private fun RenditionsCheckboxes(vm: NewHlsVideoViewModel) { Spacer(Modifier.height(8.dp)) } - HlsLadder.default().renditions.forEach { rendition -> - val label = rendition.resolution.label - val aboveSource = sourceShortSide != null && rendition.resolution.shortSide > sourceShortSide - val enabled = !aboveSource - val checked = label in vm.selectedRenditionLabels && !aboveSource + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HlsLadder.default().renditions.forEach { rendition -> + val label = rendition.resolution.label + val aboveSource = sourceShortSide != null && rendition.resolution.shortSide > sourceShortSide + val enabled = !aboveSource + val checked = label in vm.selectedRenditionLabels && !aboveSource - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(enabled = enabled) { + Row( + modifier = + Modifier.clickable(enabled = enabled) { vm.selectedRenditionLabels = if (checked) { vm.selectedRenditionLabels - label @@ -457,34 +460,22 @@ private fun RenditionsCheckboxes(vm: NewHlsVideoViewModel) { vm.selectedRenditionLabels + label } }, - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = checked, - enabled = enabled, - onCheckedChange = { - vm.selectedRenditionLabels = - if (it) vm.selectedRenditionLabels + label else vm.selectedRenditionLabels - label - }, - ) - Column(modifier = Modifier.weight(1f)) { + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + enabled = enabled, + onCheckedChange = { + vm.selectedRenditionLabels = + if (it) vm.selectedRenditionLabels + label else vm.selectedRenditionLabels - label + }, + ) Text( text = label, style = MaterialTheme.typography.bodyLarge, color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, ) - val subline = - if (aboveSource) { - stringResource(R.string.hls_rendition_above_source) - } else { - stringResource(R.string.hls_rendition_bitrate_kbps_format, rendition.bitrateKbps) - } - Text( - text = subline, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } } From 90235e91f2ac15953c4643c79201eaf3759a5dc7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 09:07:11 +0200 Subject: [PATCH 20/26] 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 cd9d2f1389..a6050229e0 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 cae04678c2..0000000000 --- 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 463317b10f..0000000000 --- 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 2516b8b38c..0000000000 --- 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 b84e845d69..0000000000 --- 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 014ebd567e..cb64182610 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 4951847c6f..f031b5c6ec 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 fbd0b5d7c5..77722ba68a 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 b837e1a77e..0000000000 --- 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 3fda699b10..ff6b65f127 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 98e04c7f20..0000000000 --- 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 1b5e7b2df2..0000000000 --- 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 450fdc30e2..accecc54db 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 1c7c34c03d..49928a7471 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" From c62f7c82716d500f678740a24d69b054076dae15 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 09:07:40 +0200 Subject: [PATCH 21/26] fix(hls): pop publish screen when opening draft-note composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the "Draft note" button on the HLS success screen pushed the composer on top of the publish screen. When the composer popped itself after posting, the user landed back on the now-Idle HLS publish screen with the form still populated — confusing "I'm back where I started" UX. Use popUpTo so the publish screen is removed from the back stack as the composer opens; posting or backing out now drops the user on the screen they came from. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 14396a5f0f..28d30bb5cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -647,7 +647,10 @@ private fun SuccessBody( onClick = { val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl) vm.reset() - nav.nav(Route.NewShortNote(message = draft)) + // Pop the HLS publish screen off the back stack as we open the composer, + // so that after the user posts (or backs out of) the draft they land on + // the screen they came from, not back on the HLS publish flow. + nav.popUpTo(Route.NewShortNote(message = draft), Route.NewHlsVideo::class) }, modifier = Modifier.fillMaxWidth(), ) { From 21670b28346677476126e6f67bdf007b839a9487 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 09:29:21 +0200 Subject: [PATCH 22/26] fix(hls): pre-increment upload counter so it ticks through N of M MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the lambda set HlsPublishState.Uploading(done=uploadsDone) before running the upload and incremented after. So while upload #1 was actually in flight the state said "0 / 5 done", and StateFlow conflation ate the post-upload increment before the UI could paint it — the counter appeared stalled until the next rendition's onProgress flipped state back to Transcoding. Pre-incrementing means `done` now represents "working on item N of M" rather than "N already finished, N+1 silently in flight", so the first upload displays as "1 of 5" and each subsequent upload ticks to 2, 3, 4, 5 visibly. The earlier "done" wording existed to disambiguate 0/N from a stalled bar; with pre-increment we never show 0/N, so the string drops "done" and reads as the cleaner "Uploading X of Y". Confirmed via logcat on device: R1 sinkFinish=5669ms, R2 sinkFinish=6343ms — the "phantom upload" between R1 encoding and R2 encoding really was the R1 combined upload; the counter just wasn't reflecting it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../screen/loggedIn/video/hls/HlsPublishOrchestrator.kt | 9 +++++++-- amethyst/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) 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 f031b5c6ec..df739cc71b 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 @@ -125,6 +125,12 @@ class HlsPublishOrchestrator( val uploadResult = runUpload(config, listener) { file, suggestedFilename -> + // Pre-increment so `done` means "working on item N of total" rather than + // "N completed, with N+1 silently in flight". Without this, the counter + // is stuck on the previous value while the current upload runs, and + // StateFlow conflation eats the post-upload increment before the UI + // paints it — the net effect is a visibly stalled counter. + uploadsDone++ _state.value = HlsPublishState.Uploading( done = uploadsDone, @@ -138,12 +144,12 @@ class HlsPublishOrchestrator( HlsContentTypes.FMP4_SEGMENT } val result = uploader.upload(file, contentType) - uploadsDone++ segmentUploads[suggestedFilename] = result result.url ?: error("Uploader returned null URL for $suggestedFilename") } + uploadsDone++ _state.value = HlsPublishState.Uploading( done = uploadsDone, @@ -151,7 +157,6 @@ class HlsPublishOrchestrator( currentLabel = "master.m3u8", ) val masterUpload = uploadMaster(uploader, uploadResult.masterPlaylist) - uploadsDone++ val masterUrl = masterUpload.url ?: error("Uploader returned null URL for master playlist") diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7acdaa3310..8f26c16bfc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2106,8 +2106,8 @@ Publish HD video Publishing “%1$s”… Transcoding %1$s - Uploading %1$d / %2$d done - Uploading %1$s (%2$d / %3$d done) + Uploading %1$d of %2$d + Uploading %1$s (%2$d of %3$d) Publishing event… Video published Your HD video is live on Nostr. From 83d0dd90676c576ac53ab11895a302266effdedd Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 09:57:52 +0200 Subject: [PATCH 23/26] refactor(hls): adopt HlsUploadResult.uploads, drop side-channel map The library's HlsUploadHelper.run is now generic: the lambda returns HlsUploaded(url, metadata) and the result surfaces every upload as HlsUploadResult.uploads keyed by the same suggestedFilename the library handed the lambda. Amethyst threads MediaUploadResult through as T, so the per-rendition sha256 / size needed for NIP-71 imeta tags comes out of the helper's own return value instead of a hand-maintained side-channel MutableMap the orchestrator kept in parallel. - HlsPublishOrchestrator.runUpload closure signature: suspend (HlsConfig, HlsListener, suspend (File, String) -> String) -> HlsUploadResult becomes suspend (HlsConfig, HlsListener, suspend (File, String) -> HlsUploaded) -> HlsUploadResult - delete `segmentUploads = mutableMapOf()` inside publish(); read directly from uploadResult.uploads instead - HlsVideoPublishInput.segmentUploads: Map becomes uploads: Map>; event builder reads playlistUpload.url and combinedMetadata?.sha256/size via .metadata - HlsPublishOrchestratorFactory's HlsUploadHelper.run call uses the new generic form with T = MediaUploadResult - tests: fakeRunUpload now synthesizes the uploads map in a LinkedHashMap (matching the library's iteration-order contract) and returns HlsUploadResult Co-Authored-By: Claude Opus 4.6 (1M context) --- .../uploads/hls/HlsVideoEventBuilder.kt | 15 ++++++----- .../video/hls/HlsPublishOrchestrator.kt | 17 +++++++------ .../hls/HlsPublishOrchestratorFactory.kt | 3 ++- .../uploads/hls/HlsPublishOrchestratorTest.kt | 24 +++++++++++------- .../uploads/hls/HlsVideoEventBuilderTest.kt | 25 +++++++++++++------ 5 files changed, 51 insertions(+), 33 deletions(-) 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 cb64182610..b67cded173 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads.hls import com.davotoula.lightcompressor.hls.HlsContentTypes import com.davotoula.lightcompressor.hls.HlsRenditionSummary +import com.davotoula.lightcompressor.hls.HlsUploaded import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning @@ -38,7 +39,7 @@ import kotlin.uuid.Uuid data class HlsVideoPublishInput( val renditions: List, - val segmentUploads: Map, + val uploads: Map>, val masterUrl: String, val masterSha256: String?, val title: String, @@ -95,18 +96,16 @@ object HlsVideoEventBuilder { val combinedFilename = summary.combinedFilename ?: "${summary.rendition.resolution.label}.mp4" - val combinedUpload = input.segmentUploads[combinedFilename] + val combinedMetadata = input.uploads[combinedFilename]?.metadata val playlistUpload = - input.segmentUploads[summary.playlistFilename] + input.uploads[summary.playlistFilename] ?: error("No upload recorded for media playlist ${summary.playlistFilename}") VideoMeta( - url = - playlistUpload.url - ?: error("Uploader returned null URL for media playlist ${summary.playlistFilename}"), + url = playlistUpload.url, mimeType = HlsContentTypes.HLS_PLAYLIST, - hash = combinedUpload?.sha256, - size = combinedUpload?.size?.toInt(), + hash = combinedMetadata?.sha256, + size = combinedMetadata?.size?.toInt(), dimension = DimensionTag(summary.width, summary.height), ) } 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 df739cc71b..e8cdb34cf5 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 @@ -28,6 +28,7 @@ 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.HlsUploaded import com.davotoula.lightcompressor.hls.Rendition import com.davotoula.lightcompressor.hls.SimpleHlsListener import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult @@ -69,8 +70,8 @@ class HlsPublishOrchestrator( private val runUpload: suspend ( config: HlsConfig, listener: HlsListener, - uploadFile: suspend (File, String) -> String, - ) -> HlsUploadResult, + uploadFile: suspend (File, String) -> HlsUploaded, + ) -> HlsUploadResult, private val buildUploader: (ServerName) -> HlsBlobUploader, private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult, private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, @@ -91,7 +92,6 @@ class HlsPublishOrchestrator( val totalSegmentUploads = request.ladder.renditions.size * 2 val totalUploads = totalSegmentUploads + 1 var uploadsDone = 0 - val segmentUploads = mutableMapOf() val listener = object : SimpleHlsListener() { @@ -144,9 +144,12 @@ class HlsPublishOrchestrator( HlsContentTypes.FMP4_SEGMENT } val result = uploader.upload(file, contentType) - segmentUploads[suggestedFilename] = result - result.url - ?: error("Uploader returned null URL for $suggestedFilename") + HlsUploaded( + url = + result.url + ?: error("Uploader returned null URL for $suggestedFilename"), + metadata = result, + ) } uploadsDone++ @@ -165,7 +168,7 @@ class HlsPublishOrchestrator( HlsVideoEventBuilder.build( HlsVideoPublishInput( renditions = uploadResult.renditions, - segmentUploads = segmentUploads, + uploads = uploadResult.uploads, masterUrl = masterUrl, masterSha256 = masterUpload.sha256, title = request.title, 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 77722ba68a..a709db90e6 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 @@ -25,6 +25,7 @@ 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.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploaderFactory import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import kotlinx.coroutines.flow.MutableStateFlow @@ -48,7 +49,7 @@ fun createProductionHlsPublishOrchestrator( _state = state, runUpload = { config, listener, uploadFile -> val uri = uriProvider() ?: error("No video picked") - HlsUploadHelper.run( + HlsUploadHelper.run( context = context, uri = uri, config = config, 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 ff6b65f127..1276c9857e 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 @@ -25,6 +25,7 @@ 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.HlsUploaded import com.davotoula.lightcompressor.hls.Rendition import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName @@ -75,29 +76,32 @@ class HlsPublishOrchestratorTest { /** * 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 + * and once per media playlist, in the same order the real helper would, collects every + * returned [HlsUploaded] into the `uploads` map the real helper surfaces, and 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 = + uploadFile: suspend (File, String) -> HlsUploaded, + ) -> HlsUploadResult = { _, listener, uploadFile -> + val uploads = linkedMapOf>() 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!!) + uploads[summary.combinedFilename!!] = 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) + uploads[summary.playlistFilename] = uploadFile(playlistFile, summary.playlistFilename) } listener.onComplete("#EXTM3U\nfake-master-playlist") HlsUploadResult( masterPlaylist = "#EXTM3U\nrewritten-master-playlist", renditions = renditions, + uploads = uploads, ) } @@ -181,23 +185,25 @@ class HlsPublishOrchestratorTest { val capturingRunUpload: suspend ( com.davotoula.lightcompressor.hls.HlsConfig, com.davotoula.lightcompressor.hls.HlsListener, - suspend (File, String) -> String, - ) -> HlsUploadResult = { _, listener, uploadFile -> + suspend (File, String) -> HlsUploaded, + ) -> HlsUploadResult = { _, listener, uploadFile -> val summary = landscapeSummary() + val uploads = linkedMapOf>() 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") + uploads["360p.mp4"] = uploadFile(combinedFile, "360p.mp4") capturedDuringUpload += orchestrator.state.value val playlistFile = File(workDir, "360p-media.m3u8").apply { writeText("bytes") } - uploadFile(playlistFile, "360p/media.m3u8") + uploads["360p/media.m3u8"] = uploadFile(playlistFile, "360p/media.m3u8") listener.onComplete("#EXTM3U\nmaster") HlsUploadResult( masterPlaylist = "#EXTM3U\nrewritten", renditions = listOf(summary), + uploads = uploads, ) } 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 accecc54db..2e762a1ae5 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads.hls import com.davotoula.lightcompressor.Resolution import com.davotoula.lightcompressor.hls.HlsRenditionSummary +import com.davotoula.lightcompressor.hls.HlsUploaded import com.davotoula.lightcompressor.hls.Rendition import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -59,20 +60,28 @@ class HlsVideoEventBuilderTest { combinedFilename = "${resolution.label}.mp4", ) - private fun segmentUploadsFor(renditions: List): Map { - val uploads = mutableMapOf() + private fun uploadsFor(renditions: List): Map> { + val uploads = linkedMapOf>() renditions.forEach { r -> val label = r.rendition.resolution.label uploads["$label.mp4"] = - MediaUploadResult( + HlsUploaded( url = "https://cdn.test/$label.mp4", - sha256 = "$label-sha", - size = 1_000_000L, + metadata = + MediaUploadResult( + url = "https://cdn.test/$label.mp4", + sha256 = "$label-sha", + size = 1_000_000L, + ), ) uploads["$label/media.m3u8"] = - MediaUploadResult( + HlsUploaded( url = "https://cdn.test/$label-media.m3u8", - sha256 = "$label-playlist-sha", + metadata = + MediaUploadResult( + url = "https://cdn.test/$label-media.m3u8", + sha256 = "$label-playlist-sha", + ), ) } return uploads @@ -89,7 +98,7 @@ class HlsVideoEventBuilderTest { ): HlsVideoPublishInput = HlsVideoPublishInput( renditions = renditions, - segmentUploads = segmentUploadsFor(renditions), + uploads = uploadsFor(renditions), masterUrl = "https://cdn.test/master.m3u8", masterSha256 = "master-sha", title = title, From 0a649dbea6f46b51cfd98d8fe1b5ca3824ec76bc Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 10:48:39 +0200 Subject: [PATCH 24/26] fix(hls): stick the upload counter + flip past tense between uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related UX issues on the HLS publish progress screen: 1. The Uploading PhaseRow's label used a fallback of `stringResource(hls_state_uploading_format, 0, 0)` whenever state wasn't Uploading, so the row flickered back to "Uploading 0 of 0" during the Transcoding phase between rendition uploads. The counter appeared to reset mid-flow even though every upload was succeeding. 2. With the counter now persisted, it still read "Uploading 2 of 5" while we were actually transcoding the next rendition — the count was right but the verb tense implied an upload was in flight when none was. Persist lastDone + lastTotal in the composable via remember so the counter reads monotonically across state transitions, and split the label into two semantic variants: - "Uploading %s (%d of %d)" — present tense, only when an upload is actually in flight (state is HlsPublishState.Uploading). The %d is the pre-incremented in-flight index. - "Uploaded %d of %d" — past tense, used both in the gap between uploads (transcoding next rendition) and in the done/checkmark state. The %d is the last observed in-flight index, which after the lambda returns corresponds to the count that has actually finished. - "Upload" — only the pre-first-upload idle state. Also add a progressFraction that falls back to lastDone/lastTotal so the progress bar under the row sticks too, matching the label. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../loggedIn/video/hls/NewHlsVideoScreen.kt | 57 +++++++++++++------ amethyst/src/main/res/values/strings.xml | 2 + 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt index 28d30bb5cd..316aeaf623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/hls/NewHlsVideoScreen.kt @@ -67,7 +67,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -508,27 +510,50 @@ private fun ProgressBody( HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) - val uploadingFraction = - (state as? HlsPublishState.Uploading)?.let { up -> - if (up.total == 0) 0f else up.done.toFloat() / up.total + // Persist the last-observed upload count across transitions so the Uploading row + // doesn't flicker back to "0 of 0" whenever state flips to Transcoding (between + // renditions) or Publishing (after the last upload). The counter should read + // monotonically: 1 of N → 2 of N → … → N of N as uploads actually complete. + var lastDone by remember { mutableIntStateOf(0) } + var lastTotal by remember { mutableIntStateOf(0) } + LaunchedEffect(state) { + if (state is HlsPublishState.Uploading) { + lastDone = state.done + lastTotal = state.total } + } + + val uploadingFraction = + if (lastTotal > 0) lastDone.toFloat() / lastTotal else null val uploadingLabel = - when (state) { - is HlsPublishState.Uploading -> { - if (state.currentLabel.isNotBlank()) { - stringResource( - R.string.hls_state_uploading_with_label_format, - state.currentLabel, - state.done, - state.total, - ) - } else { - stringResource(R.string.hls_state_uploading_format, state.done, state.total) - } + when { + // Currently in flight: present-tense, file label in the line. + state is HlsPublishState.Uploading && state.currentLabel.isNotBlank() -> { + stringResource( + R.string.hls_state_uploading_with_label_format, + state.currentLabel, + state.done, + state.total, + ) } + // Currently in flight, no file label (unreachable in practice — orchestrator + // always sets a label — but kept for completeness). + state is HlsPublishState.Uploading -> { + stringResource(R.string.hls_state_uploading_format, state.done, state.total) + } + + // Between uploads or after all uploads finish: past-tense, monotonic count. + // The last uploaded file's index sticks on screen while the transcoder + // works on the next rendition, and lands on "Uploaded N of N" when the row + // flips to its checkmark/done state. + lastTotal > 0 -> { + stringResource(R.string.hls_state_uploaded_format, lastDone, lastTotal) + } + + // Nothing has started uploading yet (very beginning of publish flow). else -> { - stringResource(R.string.hls_state_uploading_format, 0, 0) + stringResource(R.string.hls_state_uploading_idle) } } PhaseRow( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8f26c16bfc..07ecc77b28 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2106,8 +2106,10 @@ Publish HD video Publishing “%1$s”… Transcoding %1$s + Upload Uploading %1$d of %2$d Uploading %1$s (%2$d of %3$d) + Uploaded %1$d of %2$d Publishing event… Video published Your HD video is live on Nostr. From 79d28d9e4ddb53913e8dbd1ae6932d9f1ebc24e4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 12:31:18 +0200 Subject: [PATCH 25/26] code review fixes --- .../video/hls/HlsPublishOrchestrator.kt | 51 +++++++++---------- .../hls/HlsPublishOrchestratorFactory.kt | 12 ++--- .../uploads/hls/HlsPublishOrchestratorTest.kt | 6 ++- 3 files changed, 33 insertions(+), 36 deletions(-) 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 e8cdb34cf5..66c0747f80 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 @@ -25,8 +25,6 @@ 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.HlsUploaded import com.davotoula.lightcompressor.hls.Rendition @@ -89,15 +87,22 @@ class HlsPublishOrchestrator( ladder = request.ladder, ) - val totalSegmentUploads = request.ladder.renditions.size * 2 - val totalUploads = totalSegmentUploads + 1 + val totalUploads = request.ladder.renditions.size * 2 + 1 var uploadsDone = 0 + // Dedup Transcoding state emissions so onProgress (which fires many times per + // integer percent) doesn't flood the StateFlow with identical values. + var lastTranscodingLabel: String? = null + var lastTranscodingPercent = -1 + val listener = object : SimpleHlsListener() { override fun onRenditionStart(rendition: Rendition) { - if (_state.value !is HlsPublishState.Uploading) { - _state.value = HlsPublishState.Transcoding(rendition.resolution.label, 0) + val label = rendition.resolution.label + if (lastTranscodingLabel != label || lastTranscodingPercent != 0) { + lastTranscodingLabel = label + lastTranscodingPercent = 0 + _state.value = HlsPublishState.Transcoding(label, 0) } } @@ -105,31 +110,20 @@ class HlsPublishOrchestrator( rendition: Rendition, percent: Float, ) { - _state.value = HlsPublishState.Transcoding(rendition.resolution.label, percent.toInt()) + val label = rendition.resolution.label + val p = percent.toInt() + if (lastTranscodingLabel != label || lastTranscodingPercent != p) { + lastTranscodingLabel = label + lastTranscodingPercent = p + _state.value = HlsPublishState.Transcoding(label, p) + } } - - 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 uploadResult = runUpload(config, listener) { file, suggestedFilename -> - // Pre-increment so `done` means "working on item N of total" rather than - // "N completed, with N+1 silently in flight". Without this, the counter - // is stuck on the previous value while the current upload runs, and - // StateFlow conflation eats the post-upload increment before the UI - // paints it — the net effect is a visibly stalled counter. + // Pre-increment: `done` tracks the in-flight index, not the finished + // count. StateFlow conflation would otherwise eat the post-upload tick. uploadsDone++ _state.value = HlsPublishState.Uploading( @@ -139,7 +133,7 @@ class HlsPublishOrchestrator( ) val contentType = if (suggestedFilename.endsWith(".m3u8")) { - HlsContentTypes.forPlaylist() + HlsContentTypes.HLS_PLAYLIST } else { HlsContentTypes.FMP4_SEGMENT } @@ -185,7 +179,8 @@ class HlsPublishOrchestrator( masterUrl = masterUrl, ) } catch (e: CancellationException) { - _state.value = HlsPublishState.Failure(message = "Cancelled") + // Cancellation is not a failure. Let the rethrow propagate up the coroutine + // scope; NewHlsVideoViewModel.cancel() calls reset() to put state back to Idle. throw e } catch (e: Throwable) { _state.value = HlsPublishState.Failure(message = e.message ?: e::class.simpleName.orEmpty()) 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 a709db90e6..c6dfa6bb93 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 @@ -61,21 +61,21 @@ fun createProductionHlsPublishOrchestrator( HlsBlobUploaderFactory.create(server, account, context) }, uploadMaster = { uploader, masterPlaylist -> - val masterFile = - File(context.cacheDir, "hls-master-${System.currentTimeMillis()}.m3u8") + val masterFile = File.createTempFile("hls-master-", ".m3u8", context.cacheDir) try { masterFile.writeText(masterPlaylist) - uploader.upload(masterFile, HlsContentTypes.forPlaylist()) + uploader.upload(masterFile, HlsContentTypes.HLS_PLAYLIST) } finally { masterFile.delete() } }, signAndPublish = { template -> - val signed = + val inner = when (template) { - is HlsVideoEventTemplate.Horizontal -> account.signer.sign(template.template) - is HlsVideoEventTemplate.Vertical -> account.signer.sign(template.template) + is HlsVideoEventTemplate.Horizontal -> template.template + is HlsVideoEventTemplate.Vertical -> template.template } + val signed = account.signer.sign(inner) account.sendAutomatic(signed) signed.id }, 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 1276c9857e..c1e6453584 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 @@ -89,10 +89,12 @@ class HlsPublishOrchestratorTest { val uploads = linkedMapOf>() listener.onStart(renditions.size) for (summary in renditions) { + val combinedFilename = + requireNotNull(summary.combinedFilename) { "test fixtures must use single-file mode" } listener.onRenditionStart(summary.rendition) listener.onProgress(summary.rendition, 50f) - val combinedFile = File(workDir, summary.combinedFilename!!).apply { writeText("combined-${summary.rendition.resolution.label}") } - uploads[summary.combinedFilename!!] = uploadFile(combinedFile, summary.combinedFilename!!) + val combinedFile = File(workDir, combinedFilename).apply { writeText("combined-${summary.rendition.resolution.label}") } + uploads[combinedFilename] = uploadFile(combinedFile, combinedFilename) val playlistFile = File(workDir, "${summary.rendition.resolution.label}-media.m3u8").apply { writeText("playlist-${summary.rendition.resolution.label}") } uploads[summary.playlistFilename] = uploadFile(playlistFile, summary.playlistFilename) From fee6607c38a4195e7c71b425d8b6b70a8c0d1ce4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Apr 2026 13:15:01 +0200 Subject: [PATCH 26/26] chore(hls): bump lightcompressor-enhanced to released 2.2.0 Moves off the 2.1.1-hls-SNAPSHOT mavenLocal iteration onto the released JitPack artifact. API surface is identical to the final SNAPSHOT Amethyst was already running against after the library code review. Co-Authored-By: Claude Opus 4.6 (1M context) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49928a7471..0d5f69f920 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.1-hls-SNAPSHOT" +lightcompressor-enhanced = "2.2.0" markdown = "f92ef49c9d" material3 = "1.9.0" materialIconsExtended = "1.7.3"