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
This commit is contained in:
davotoula
2026-04-14 15:35:01 +02:00
parent 07ae5d3ac7
commit c008daee7e
12 changed files with 1824 additions and 0 deletions
@@ -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<HlsBundleRendition>,
)
data class HlsBundleRendition(
val label: String,
val combinedFile: File,
val mediaPlaylist: String,
val bitrateKbps: Int,
)
@@ -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
}
}
}
@@ -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<HlsBundle> = CompletableDeferred()
private val combinedByLabel = mutableMapOf<String, File>()
private val completed = mutableListOf<HlsBundleRendition>()
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)
@@ -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<HlsUploadedRendition>,
)
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/<sha256>), 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"
}
}
@@ -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<VideoHorizontalEvent>,
) : HlsVideoEventTemplate()
data class Vertical(
val template: EventTemplate<VideoVerticalEvent>,
) : 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<RenditionDimension> =
streamInfRegex
.findAll(masterPlaylist)
.map { RenditionDimension(it.groupValues[1].toInt(), it.groupValues[2].toInt()) }
.toList()
}
@@ -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>(HlsPublishState.Idle)
val state: StateFlow<HlsPublishState> = _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
}
@@ -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()
}
@@ -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<Uri?>(null)
private set
var sourceMetadata by mutableStateOf<HlsSourceMetadata?>(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<ServerName?>(null)
private var orchestrator: HlsPublishOrchestrator? = null
private var currentJob: Job? = null
val state: StateFlow<HlsPublishState>
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,
)
@@ -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<String> = 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<HlsVideoEventTemplate>()
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<HlsPublishState>()
val capturedDuringUpload = mutableListOf<HlsPublishState>()
val capturedDuringPublish = mutableListOf<HlsPublishState>()
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<HlsVideoEventTemplate>()
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<HlsVideoEventTemplate>()
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)
}
}
@@ -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<Pair<String, Int>>()
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())
}
}
@@ -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<Call>()
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<Triple<String, String, String>>()
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<String>): 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<Pair<Int, Int>>()
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)
}
}
@@ -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<HlsBundleRendition>): 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<Array<String>>.findTag(name: String): Array<String>? = firstOrNull { it.isNotEmpty() && it[0] == name }
private fun Array<Array<String>>.findAllTags(name: String): List<Array<String>> = 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])
}
}