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()
}
/**
* 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
* result. Orientation is decided from the first rendition's width/height: portrait
@@ -86,7 +100,7 @@ sealed class HlsVideoEventTemplate {
*/
@OptIn(ExperimentalUuidApi::class)
object HlsVideoEventBuilder {
fun build(input: HlsVideoPublishInput): HlsVideoEventTemplate {
fun build(input: HlsVideoPublishInput): HlsBuiltTemplate {
val firstRendition = input.renditions.firstOrNull()
val isVertical = firstRendition != null && firstRendition.height > firstRendition.width
@@ -131,24 +145,28 @@ object HlsVideoEventBuilder {
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) }
},
)
}
val template =
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) }
},
)
}
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.vitorpamplona.amethyst.service.uploads.MediaUploadResult
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.HlsVideoEventTemplate
import com.vitorpamplona.amethyst.service.uploads.hls.HlsVideoPublishInput
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
@@ -88,7 +91,15 @@ class HlsPublishOrchestrator(
) -> HlsUploadResult<MediaUploadResult>,
private val buildUploader: (ServerName) -> HlsBlobUploader,
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
// 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
@@ -231,7 +242,7 @@ class HlsPublishOrchestrator(
}
_state.value = HlsPublishState.Publishing
val template =
val built =
HlsVideoEventBuilder.build(
HlsVideoPublishInput(
renditions = uploadResult.renditions,
@@ -247,7 +258,19 @@ class HlsPublishOrchestrator(
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 =
HlsPublishState.Success(
@@ -88,7 +88,7 @@ fun createProductionHlsPublishOrchestrator(
}
}
},
signAndPublish = { template ->
signAndPublish = { template, buildSibling ->
val inner =
when (template) {
is HlsVideoEventTemplate.Horizontal -> template.template
@@ -96,6 +96,21 @@ fun createProductionHlsPublishOrchestrator(
}
val signed = account.signer.sign(inner)
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
},
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.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
@@ -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))
// Server picker — reads the user's configured Blossom servers from the account
@@ -686,54 +660,18 @@ private fun SuccessBody(
)
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(
onClick = {
vm.reset()
nav.popBack()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_done))
}
Button(
onClick = {
vm.reset()
nav.popBack()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.hls_done))
}
}
}
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,
@@ -64,7 +64,6 @@ open class NewHlsVideoViewModel : ViewModel() {
var sensitiveContent by mutableStateOf(false)
var contentWarningReason by mutableStateOf("")
var useH265 by mutableStateOf(true)
var draftNoteAfterUpload by mutableStateOf(true)
var selectedServer by mutableStateOf<ServerName?>(null)
var selectedRenditionLabels by mutableStateOf(
-3
View File
@@ -2586,9 +2586,6 @@
<string name="hls_view_note">View note</string>
<string name="hls_done">Done</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="list_actions_dialog_title">List Actions</string>
@@ -161,7 +161,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
signAndPublish = { tpl, _ ->
publishedTemplates += tpl
"signed-event-id"
},
@@ -221,7 +221,7 @@ class HlsPublishOrchestratorTest {
runUpload = capturingRunUpload,
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = {
signAndPublish = { _, _ ->
capturedDuringPublish += orchestrator.state.value
"event-id"
},
@@ -247,7 +247,7 @@ class HlsPublishOrchestratorTest {
runUpload = { _, _, _ -> throw RuntimeException("decode failed") },
buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },
signAndPublish = { _, _ -> "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
@@ -267,7 +267,7 @@ class HlsPublishOrchestratorTest {
HlsBlobUploader { _, _, _ -> throw RuntimeException("server 500") }
},
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },
signAndPublish = { _, _ -> "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
@@ -286,7 +286,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = { _, _ -> throw RuntimeException("master upload failed") },
signAndPublish = { "never" },
signAndPublish = { _, _ -> "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
@@ -305,7 +305,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { throw RuntimeException("relay rejected") },
signAndPublish = { _, _ -> throw RuntimeException("relay rejected") },
)
runBlocking { orchestrator.publish(newRequest()) }
@@ -325,7 +325,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
signAndPublish = { tpl, _ ->
captured += tpl
"event-id"
},
@@ -363,7 +363,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(portrait),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
signAndPublish = { tpl, _ ->
captured += tpl
"event-id"
},
@@ -384,7 +384,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
signAndPublish = { tpl, _ ->
captured += tpl
"event-id"
},
@@ -412,7 +412,7 @@ class HlsPublishOrchestratorTest {
runUpload = fakeRunUpload(),
buildUploader = { canned },
uploadMaster = fakeUploadMaster(canned),
signAndPublish = { tpl ->
signAndPublish = { tpl, _ ->
captured += tpl
"event-id"
},
@@ -440,7 +440,7 @@ class HlsPublishOrchestratorTest {
runUpload = { _, _, _ -> throw RuntimeException("boom") },
buildUploader = { CannedUploader() },
uploadMaster = { _, _ -> MediaUploadResult(url = "never") },
signAndPublish = { "never" },
signAndPublish = { _, _ -> "never" },
)
runBlocking { orchestrator.publish(newRequest()) }
@@ -120,8 +120,8 @@ class HlsVideoEventBuilderTest {
fun landscapeRenditionsBuildHorizontalTemplateKind34235() {
val result = HlsVideoEventBuilder.build(input(landscapeRenditions))
assertTrue("expected Horizontal template", result is HlsVideoEventTemplate.Horizontal)
val template = (result as HlsVideoEventTemplate.Horizontal).template
assertTrue("expected Horizontal template", result.template is HlsVideoEventTemplate.Horizontal)
val template = (result.template as HlsVideoEventTemplate.Horizontal).template
assertEquals(VideoHorizontalEvent.KIND, template.kind)
assertEquals("A cool video", template.content)
}
@@ -130,15 +130,15 @@ class HlsVideoEventBuilderTest {
fun portraitRenditionsBuildVerticalTemplateKind34236() {
val result = HlsVideoEventBuilder.build(input(portraitRenditions))
assertTrue("expected Vertical template", result is HlsVideoEventTemplate.Vertical)
val template = (result as HlsVideoEventTemplate.Vertical).template
assertTrue("expected Vertical template", result.template is HlsVideoEventTemplate.Vertical)
val template = (result.template as HlsVideoEventTemplate.Vertical).template
assertEquals(VideoVerticalEvent.KIND, template.kind)
}
@Test
fun horizontalTemplateHasTitleAndDTag() {
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")
assertNotNull(title)
@@ -152,7 +152,7 @@ class HlsVideoEventBuilderTest {
@Test
fun templateContainsOneImetaForMasterAndOnePerRendition() {
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")
// 1 master + 2 renditions
@@ -181,7 +181,7 @@ class HlsVideoEventBuilderTest {
HlsVideoEventBuilder.build(
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")
assertNotNull(duration)
@@ -191,7 +191,7 @@ class HlsVideoEventBuilderTest {
@Test
fun noDurationTagWhenNotProvided() {
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"))
}
@@ -201,7 +201,7 @@ class HlsVideoEventBuilderTest {
HlsVideoEventBuilder.build(
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")
assertNotNull(warning)
@@ -211,14 +211,14 @@ class HlsVideoEventBuilderTest {
@Test
fun noContentWarningTagWhenNull() {
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"))
}
@Test
fun horizontalTemplateCarriesAutoGeneratedAltTag() {
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")
assertNotNull(alt)
assertEquals(VideoHorizontalEvent.ALT_DESCRIPTION, alt!![1])
@@ -227,7 +227,7 @@ class HlsVideoEventBuilderTest {
@Test
fun verticalTemplateCarriesVerticalAltTag() {
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")
assertNotNull(alt)
assertEquals(VideoVerticalEvent.ALT_DESCRIPTION, alt!![1])
@@ -239,7 +239,7 @@ class HlsVideoEventBuilderTest {
HlsVideoEventBuilder.build(
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")
// 1 master + 2 renditions, each must carry image <posterUrl>
@@ -253,7 +253,7 @@ class HlsVideoEventBuilderTest {
@Test
fun noPosterUrlMeansNoImagePropertyOnImetas() {
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")
imetas.forEach { imeta ->