feat(hls): auto-publish kind:1 sibling note with rich imeta

This commit is contained in:
davotoula
2026-05-09 21:30:49 +02:00
parent 7550e82283
commit 48d08f5895
9 changed files with 204 additions and 123 deletions
@@ -0,0 +1,91 @@
/*
* 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.HlsContentTypes
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.references.reference
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip71Video.blurhash
import com.vitorpamplona.quartz.nip71Video.dims
import com.vitorpamplona.quartz.nip71Video.hash
import com.vitorpamplona.quartz.nip71Video.image
import com.vitorpamplona.quartz.nip71Video.mimeType
import com.vitorpamplona.quartz.nip71Video.thumbhash
import com.vitorpamplona.quartz.nip92IMeta.imeta
import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Builds the kind:1 short-note that's published as a sibling of the NIP-71 video event from
* Amethyst's HLS publish flow. Receivers that don't speak NIP-71 can still render a rich
* preview (poster + dim + blurhash/thumbhash) from the kind:1 alone, while NIP-71-aware
* receivers can hop to the addressable form via the `a` tag.
*
* Tag layout:
* - One `imeta` mirroring the master rendition (url=master m3u8, m=hls, x=master sha256,
* dim=master dim, image=poster, blurhash, thumbhash).
* - One `r` tag with the master m3u8 URL (idiomatic kind:1 reference).
*
* Content mirrors the previous "Draft note" layout: `title\n\ndescription\n\nmasterUrl`,
* blank fields skipped.
*
* Intentionally omits an `a` tag back-reference to the NIP-71 event: in practice Amethyst's
* note renderer prefers the embedded addressable form when an `a` tag is present, and falls
* back to a placeholder if the relay set hasn't returned it yet — masking the rich imeta we
* just added. Without `a`, the kind:1 renders as a vanilla rich-imeta video note across
* clients, which is what users expect from a shared video link.
*/
object HlsKind1SiblingBuilder {
fun build(
title: String,
description: String,
masterUrl: String,
masterSha256: String?,
masterDimension: DimensionTag?,
posterUrl: String?,
blurhashValue: String?,
thumbhashValue: String?,
createdAt: Long? = null,
): EventTemplate<TextNoteEvent> {
val content =
listOf(title, description, masterUrl)
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n\n")
val masterImeta =
imetaTagBuilder(masterUrl) {
mimeType(HlsContentTypes.HLS_PLAYLIST)
masterSha256?.let { hash(it) }
masterDimension?.let { dims(it) }
posterUrl?.let { image(it) }
blurhashValue?.let { blurhash(it) }
thumbhashValue?.let { thumbhash(it) }
}
return TextNoteEvent.build(content, createdAt ?: TimeUtils.now()) {
imeta(masterImeta)
reference(masterUrl)
}
}
}
@@ -71,6 +71,20 @@ sealed class HlsVideoEventTemplate {
) : HlsVideoEventTemplate() ) : HlsVideoEventTemplate()
} }
/**
* Result of [HlsVideoEventBuilder.build]. Exposes the unsigned NIP-71 [template] plus the
* resolved coordinates the caller needs to construct a matching kind:1 sibling note: the
* orientation [kind] (34235/34236), the addressable [dTag] (replayable into an `a` tag), and
* the master [masterDimension] (largest rendition's WxH) so the kind:1 imeta carries the same
* dim as the NIP-71 master imeta.
*/
data class HlsBuiltTemplate(
val template: HlsVideoEventTemplate,
val kind: Int,
val dTag: String,
val masterDimension: DimensionTag?,
)
/** /**
* Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload * Assembles a NIP-71 VideoHorizontalEvent / VideoVerticalEvent template from an HLS upload
* result. Orientation is decided from the first rendition's width/height: portrait * result. Orientation is decided from the first rendition's width/height: portrait
@@ -86,7 +100,7 @@ sealed class HlsVideoEventTemplate {
*/ */
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
object HlsVideoEventBuilder { object HlsVideoEventBuilder {
fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate { fun build(input: HlsVideoPublishInput): HlsBuiltTemplate {
val firstRendition = input.renditions.firstOrNull() val firstRendition = input.renditions.firstOrNull()
val isVertical = firstRendition != null && firstRendition.height > firstRendition.width val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
@@ -131,7 +145,8 @@ object HlsVideoEventBuilder {
val dTag = input.dTag ?: Uuid.random().toString() val dTag = input.dTag ?: Uuid.random().toString()
val createdAt = input.createdAt ?: TimeUtils.now() val createdAt = input.createdAt ?: TimeUtils.now()
return if (isVertical) { val template =
if (isVertical) {
HlsVideoEventTemplate.Vertical( HlsVideoEventTemplate.Vertical(
VideoVerticalEvent.build(input.description, dTag, createdAt) { VideoVerticalEvent.build(input.description, dTag, createdAt) {
videoIMetas(videoMetas) videoIMetas(videoMetas)
@@ -150,5 +165,8 @@ object HlsVideoEventBuilder {
}, },
) )
} }
val kind = if (isVertical) VideoVerticalEvent.KIND else VideoHorizontalEvent.KIND
return HlsBuiltTemplate(template, kind, dTag, masterDimension)
} }
} }
@@ -31,10 +31,13 @@ import com.davotoula.lightcompressor.hls.Rendition
import com.davotoula.lightcompressor.hls.SimpleHlsListener import com.davotoula.lightcompressor.hls.SimpleHlsListener
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader import com.vitorpamplona.amethyst.service.uploads.hls.HlsBlobUploader
import com.vitorpamplona.amethyst.service.uploads.hls.HlsKind1SiblingBuilder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventBuilder
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoEventTemplate
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -88,7 +91,15 @@ class HlsPublishOrchestrator(
) -> HlsUploadResult<MediaUploadResult>, ) -> HlsUploadResult<MediaUploadResult>,
private val buildUploader: (ServerName) -> HlsBlobUploader, private val buildUploader: (ServerName) -> HlsBlobUploader,
private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult, private val uploadMaster: suspend (HlsBlobUploader, String) -> MediaUploadResult,
private val signAndPublish: suspend (HlsVideoEventTemplate) -> String, // Signs and broadcasts the NIP-71 video event AND the kind:1 sibling note. The
// orchestrator hands the primary template plus a lazily-evaluated [buildSibling] closure
// that produces the kind:1 template — lazy so the orchestrator can capture the master
// metadata in scope without forcing kind:1 construction before the NIP-71 sign. Returns
// the NIP-71 event id.
private val signAndPublish: suspend (
primary: HlsVideoEventTemplate,
buildSibling: () -> EventTemplate<TextNoteEvent>,
) -> String,
// Generates a poster JPEG from the picked source video and uploads it via the supplied // Generates a poster JPEG from the picked source video and uploads it via the supplied
// uploader, returning the public URL. Returns null if poster generation isn't possible // uploader, returning the public URL. Returns null if poster generation isn't possible
// (unsupported source, decode failure, no readable frame). Failures here must NOT fail // (unsupported source, decode failure, no readable frame). Failures here must NOT fail
@@ -231,7 +242,7 @@ class HlsPublishOrchestrator(
} }
_state.value = HlsPublishState.Publishing _state.value = HlsPublishState.Publishing
val template = val built =
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
HlsVideoPublishInput( HlsVideoPublishInput(
renditions = uploadResult.renditions, renditions = uploadResult.renditions,
@@ -247,7 +258,19 @@ class HlsPublishOrchestrator(
thumbhash = posterResult?.thumbhash, thumbhash = posterResult?.thumbhash,
), ),
) )
val eventId = signAndPublish(template) val buildSibling: () -> EventTemplate<TextNoteEvent> = {
HlsKind1SiblingBuilder.build(
title = request.title,
description = request.description,
masterUrl = masterUrl,
masterSha256 = masterUpload.sha256,
masterDimension = built.masterDimension,
posterUrl = posterResult?.url,
blurhashValue = posterResult?.blurhash,
thumbhashValue = posterResult?.thumbhash,
)
}
val eventId = signAndPublish(built.template, buildSibling)
_state.value = _state.value =
HlsPublishState.Success( HlsPublishState.Success(
@@ -88,7 +88,7 @@ fun createProductionHlsPublishOrchestrator(
} }
} }
}, },
signAndPublish = { template -> signAndPublish = { template, buildSibling ->
val inner = val inner =
when (template) { when (template) {
is HlsVideoEventTemplate.Horizontal -> template.template is HlsVideoEventTemplate.Horizontal -> template.template
@@ -96,6 +96,21 @@ fun createProductionHlsPublishOrchestrator(
} }
val signed = account.signer.sign(inner) val signed = account.signer.sign(inner)
account.sendAutomatic(signed) account.sendAutomatic(signed)
// Auto-publish the kind:1 sibling note so receivers that don't speak NIP-71 still
// see a rich preview (poster + dim + blurhash/thumbhash) and can hop to the
// addressable form via the imeta + `a` tag. Publishing the sibling must not throw
// out of the orchestrator on signer failure; the NIP-71 event is already broadcast
// and surfacing as a partial success is more useful than a hard fail.
val siblingTemplate = buildSibling()
try {
val signedSibling = account.signer.sign(siblingTemplate)
account.sendAutomatic(signedSibling)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Throwable) {
Log.w(TAG) { "kind:1 sibling sign/publish failed: ${e.message}" }
}
signed.id signed.id
}, },
uploadPoster = { uploader -> uploadPoster = { uploader ->
@@ -83,7 +83,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -323,31 +322,6 @@ private fun FormFields(vm: NewHlsVideoViewModel) {
) )
} }
Spacer(Modifier.height(8.dp))
// 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_draft_note_after_upload),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(R.string.hls_draft_note_after_upload_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = vm.draftNoteAfterUpload,
onCheckedChange = { vm.draftNoteAfterUpload = it },
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Server picker — reads the user's configured Blossom servers from the account // Server picker — reads the user's configured Blossom servers from the account
@@ -686,31 +660,6 @@ private fun SuccessBody(
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (vm.draftNoteAfterUpload) {
Button(
onClick = {
val draft = buildDraftNoteText(vm.title, vm.description, state.masterUrl)
vm.reset()
// 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(),
) {
Text(stringResource(R.string.hls_draft_note_button))
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = {
vm.reset()
nav.popBack()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_done))
}
} else {
Button( Button(
onClick = { onClick = {
vm.reset() vm.reset()
@@ -722,17 +671,6 @@ 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 @Composable
private fun FailureBody( private fun FailureBody(
@@ -64,7 +64,6 @@ open class NewHlsVideoViewModel : ViewModel() {
var sensitiveContent by mutableStateOf(false) var sensitiveContent by mutableStateOf(false)
var contentWarningReason by mutableStateOf("") var contentWarningReason by mutableStateOf("")
var useH265 by mutableStateOf(true) var useH265 by mutableStateOf(true)
var draftNoteAfterUpload by mutableStateOf(true)
var selectedServer by mutableStateOf<ServerName?>(null) var selectedServer by mutableStateOf<ServerName?>(null)
var selectedRenditionLabels by mutableStateOf( var selectedRenditionLabels by mutableStateOf(
-3
View File
@@ -2586,9 +2586,6 @@
<string name="hls_view_note">View note</string> <string name="hls_view_note">View note</string>
<string name="hls_done">Done</string> <string name="hls_done">Done</string>
<string name="hls_try_again">Try again</string> <string name="hls_try_again">Try again</string>
<string name="hls_draft_note_after_upload">Draft note after upload</string>
<string name="hls_draft_note_after_upload_explainer">Open the note composer pre-filled with the title, description and video link so you can tweak it before posting.</string>
<string name="hls_draft_note_button">Draft note</string>
<string name="pack_actions_dialog_title">Pack Actions</string> <string name="pack_actions_dialog_title">Pack Actions</string>
<string name="list_actions_dialog_title">List Actions</string> <string name="list_actions_dialog_title">List Actions</string>
@@ -161,7 +161,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(), runUpload = fakeRunUpload(),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl, _ ->
publishedTemplates += tpl publishedTemplates += tpl
"signed-event-id" "signed-event-id"
}, },
@@ -221,7 +221,7 @@ class HlsPublishOrchestratorTest {
runUpload = capturingRunUpload, runUpload = capturingRunUpload,
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { signAndPublish = { _, _ ->
capturedDuringPublish += orchestrator.state.value capturedDuringPublish += orchestrator.state.value
"event-id" "event-id"
}, },
@@ -247,7 +247,7 @@ class HlsPublishOrchestratorTest {
runUpload = { _, _, _ -> throw RuntimeException("decode failed") }, runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" }, signAndPublish = { _, _ -> "never" },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -267,7 +267,7 @@ class HlsPublishOrchestratorTest {
HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") } HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") }
}, },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" }, signAndPublish = { _, _ -> "never" },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -286,7 +286,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(), runUpload = fakeRunUpload(),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") }, uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
signAndPublish = { "never" }, signAndPublish = { _, _ -> "never" },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -305,7 +305,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(), runUpload = fakeRunUpload(),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { throw RuntimeException("relay rejected") }, signAndPublish = { _, _ -> throw RuntimeException("relay rejected") },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -325,7 +325,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(), runUpload = fakeRunUpload(),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl, _ ->
captured += tpl captured += tpl
"event-id" "event-id"
}, },
@@ -363,7 +363,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(portrait), runUpload = fakeRunUpload(portrait),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl, _ ->
captured += tpl captured += tpl
"event-id" "event-id"
}, },
@@ -384,7 +384,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(), runUpload = fakeRunUpload(),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl, _ ->
captured += tpl captured += tpl
"event-id" "event-id"
}, },
@@ -412,7 +412,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(), runUpload = fakeRunUpload(),
buildUploader = { canned }, buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned), uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl -> signAndPublish = { tpl, _ ->
captured += tpl captured += tpl
"event-id" "event-id"
}, },
@@ -440,7 +440,7 @@ class HlsPublishOrchestratorTest {
runUpload = { _, _, _ -> throw RuntimeException("boom") }, runUpload = { _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() }, buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") }, uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" }, signAndPublish = { _, _ -> "never" },
) )
runBlocking { orchestrator.publish(newRequest()) } runBlocking { orchestrator.publish(newRequest()) }
@@ -120,8 +120,8 @@ class HlsVideoEventBuilderTest {
fun landscapeRenditionsBuildHorizontalTemplateKind34235() { fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal) assertTrue("expected Horizontal template", result.template is HlsVideoEventTemplate.Horizontal)
val template = (result as HlsVideoEventTemplate.Horizontal).template val template = (result.template as HlsVideoEventTemplate.Horizontal).template
assertEquals(VideoHorizontalEvent.KIND, template.kind) assertEquals(VideoHorizontalEvent.KIND, template.kind)
assertEquals("A cool video", template.content) assertEquals("A cool video", template.content)
} }
@@ -130,15 +130,15 @@ class HlsVideoEventBuilderTest {
fun portraitRenditionsBuildVerticalTemplateKind34236() { fun portraitRenditionsBuildVerticalTemplateKind34236() {
val result = HlsVideoEventBuilder.build(input(portraitRenditions)) val result = HlsVideoEventBuilder.build(input(portraitRenditions))
assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical) assertTrue("expected Vertical template", result.template is HlsVideoEventTemplate.Vertical)
val template = (result as HlsVideoEventTemplate.Vertical).template val template = (result.template as HlsVideoEventTemplate.Vertical).template
assertEquals(VideoVerticalEvent.KIND, template.kind) assertEquals(VideoVerticalEvent.KIND, template.kind)
} }
@Test @Test
fun horizontalTemplateHasTitleAndDTag() { fun horizontalTemplateHasTitleAndDTag() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val title = tags.findTag("title") val title = tags.findTag("title")
assertNotNull(title) assertNotNull(title)
@@ -152,7 +152,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun templateContainsOneImetaForMasterAndOnePerRendition() { fun templateContainsOneImetaForMasterAndOnePerRendition() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val imetas = tags.findAllTags("imeta") val imetas = tags.findAllTags("imeta")
// 1 master + 2 renditions // 1 master + 2 renditions
@@ -181,7 +181,7 @@ class HlsVideoEventBuilderTest {
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
input(landscapeRenditions, duration = 123), input(landscapeRenditions, duration = 123),
) )
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val duration = tags.findTag("duration") val duration = tags.findTag("duration")
assertNotNull(duration) assertNotNull(duration)
@@ -191,7 +191,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun noDurationTagWhenNotProvided() { fun noDurationTagWhenNotProvided() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
assertNull(tags.findTag("duration")) assertNull(tags.findTag("duration"))
} }
@@ -201,7 +201,7 @@ class HlsVideoEventBuilderTest {
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
input(landscapeRenditions, contentWarning = "NSFW"), input(landscapeRenditions, contentWarning = "NSFW"),
) )
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val warning = tags.findTag("content-warning") val warning = tags.findTag("content-warning")
assertNotNull(warning) assertNotNull(warning)
@@ -211,14 +211,14 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun noContentWarningTagWhenNull() { fun noContentWarningTagWhenNull() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
assertNull(tags.findTag("content-warning")) assertNull(tags.findTag("content-warning"))
} }
@Test @Test
fun horizontalTemplateCarriesAutoGeneratedAltTag() { fun horizontalTemplateCarriesAutoGeneratedAltTag() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val alt = tags.findTag("alt") val alt = tags.findTag("alt")
assertNotNull(alt) assertNotNull(alt)
assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1]) assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1])
@@ -227,7 +227,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun verticalTemplateCarriesVerticalAltTag() { fun verticalTemplateCarriesVerticalAltTag() {
val result = HlsVideoEventBuilder.build(input(portraitRenditions)) val result = HlsVideoEventBuilder.build(input(portraitRenditions))
val tags = (result as HlsVideoEventTemplate.Vertical).template.tags val tags = (result.template as HlsVideoEventTemplate.Vertical).template.tags
val alt = tags.findTag("alt") val alt = tags.findTag("alt")
assertNotNull(alt) assertNotNull(alt)
assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1]) assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1])
@@ -239,7 +239,7 @@ class HlsVideoEventBuilderTest {
HlsVideoEventBuilder.build( HlsVideoEventBuilder.build(
input(landscapeRenditions, posterUrl = "https://cdn.test/poster.jpg"), input(landscapeRenditions, posterUrl = "https://cdn.test/poster.jpg"),
) )
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val imetas = tags.findAllTags("imeta") val imetas = tags.findAllTags("imeta")
// 1 master + 2 renditions, each must carry image <posterUrl> // 1 master + 2 renditions, each must carry image <posterUrl>
@@ -253,7 +253,7 @@ class HlsVideoEventBuilderTest {
@Test @Test
fun noPosterUrlMeansNoImagePropertyOnImetas() { fun noPosterUrlMeansNoImagePropertyOnImetas() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions)) val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
val tags = (result as HlsVideoEventTemplate.Horizontal).template.tags val tags = (result.template as HlsVideoEventTemplate.Horizontal).template.tags
val imetas = tags.findAllTags("imeta") val imetas = tags.findAllTags("imeta")
imetas.forEach { imeta -> imetas.forEach { imeta ->