From 7efe6fdf2a7e9270cf1b88c9c2aa70bffa7c1461 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 21:15:08 +0000 Subject: [PATCH] feat(nip71): support audio-track imeta variants per nostr-protocol/nips#2255 Adds the audio-track imeta properties from NIP-71 PR #2255 so video events can advertise external audio tracks (multi-language, alternate bitrates) alongside video variants: - New imeta properties: bitrate, duration (float seconds), waveform, and l [ov] for language with an original-version flag - Extends VideoMeta with bitrate, duration, waveform, language fields plus isAudio/isVideo helpers - VideoEvent exposes audioTracks()/videoTracks() so players can prefer separate audio tracks over in-video audio while switching resolution - Round-trip test against the PR's spec example --- .../quartz/nip71Video/IMetaTagBuilderExt.kt | 20 +++ .../quartz/nip71Video/IMetaTagExt.kt | 19 +++ .../quartz/nip71Video/VideoEvent.kt | 13 ++ .../quartz/nip71Video/VideoMeta.kt | 20 +++ .../quartz/nip71Video/tags/BitrateTag.kt | 29 ++++ .../nip71Video/tags/LanguageImetaTag.kt | 65 +++++++++ .../quartz/nip71Video/VideoAudioImetaTest.kt | 129 ++++++++++++++++++ 7 files changed, 295 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt index 06672d7b49..4fc6694b63 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagBuilderExt.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nip71Video import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -38,6 +41,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag /** * Contains the IMeta tags that are used by Video events. @@ -73,3 +77,19 @@ fun IMetaTagBuilder.summary(summary: HexKey) = add(SummaryTag.TAG_NAME, summary) fun IMetaTagBuilder.fallback(fallback: HexKey) = add(FallbackTag.TAG_NAME, fallback) fun IMetaTagBuilder.service(service: HexKey) = add(ServiceTag.TAG_NAME, service) + +fun IMetaTagBuilder.bitrate(bitsPerSecond: Int) = add(BitrateTag.TAG_NAME, bitsPerSecond.toString()) + +fun IMetaTagBuilder.duration(seconds: Double) = add(DurationTag.TAG_NAME, seconds.toString()) + +fun IMetaTagBuilder.duration(seconds: Int) = add(DurationTag.TAG_NAME, seconds.toString()) + +fun IMetaTagBuilder.waveform(wave: List) = add(WaveformTag.TAG_NAME, WaveformTag.assembleWaveFloat(wave)) + +fun IMetaTagBuilder.language(language: LanguageImetaTag) = add(LanguageImetaTag.TAG_NAME, language.toPropertyValue()) + +fun IMetaTagBuilder.language( + code: String, + standard: String = LanguageImetaTag.DEFAULT_STANDARD, + originalVersion: Boolean = false, +) = language(LanguageImetaTag(code, standard, originalVersion)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt index 42d0e2addd..714dfc7d97 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/IMetaTagExt.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.nip71Video import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip71Video.tags.BitrateTag +import com.vitorpamplona.quartz.nip71Video.tags.DurationTag +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.BlurhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -37,6 +40,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.SummaryTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.ThumbhashTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.TorrentInfoHash +import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.WaveformTag /** * Contains the IMeta tags that are used by Video events. @@ -72,3 +76,18 @@ fun IMetaTag.summary() = properties.get(SummaryTag.TAG_NAME) fun IMetaTag.fallback() = properties.get(FallbackTag.TAG_NAME) fun IMetaTag.service() = properties.get(ServiceTag.TAG_NAME) + +fun IMetaTag.bitrate(): Int? = properties.get(BitrateTag.TAG_NAME)?.firstOrNull()?.toIntOrNull() + +fun IMetaTag.duration(): Double? = properties.get(DurationTag.TAG_NAME)?.firstOrNull()?.toDoubleOrNull() + +fun IMetaTag.waveform(): List? = properties.get(WaveformTag.TAG_NAME)?.firstOrNull()?.let { WaveformTag.parseWave(it) } + +fun IMetaTag.language(): LanguageImetaTag? = properties.get(LanguageImetaTag.TAG_NAME)?.firstNotNullOfOrNull { LanguageImetaTag.parseValue(it) } + +// NIP-71 PR #2255: audio-only variants are advertised as `imeta` entries +// with an `m audio/...` property so clients can prefer them over in-video +// audio while switching video resolution. +fun IMetaTag.isAudioTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("audio/") == true + +fun IMetaTag.isVideoTrack(): Boolean = mimeType()?.firstOrNull()?.startsWith("video/") == true diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index efd1c8c032..f0003229f5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -47,4 +47,17 @@ interface VideoEvent : IEvent { fun hash(): String? fun imetaTags(): List + + /** + * NIP-71 PR #2255: imeta entries whose mime type starts with `video/` + * (or entries without a mime type, treated as legacy video variants). + */ + fun videoTracks(): List = imetaTags().filter { it.isVideo || it.mimeType == null } + + /** + * NIP-71 PR #2255: imeta entries whose mime type starts with `audio/`. + * Clients should prefer these over in-video audio to enable smooth + * resolution switching without interrupting audio. + */ + fun audioTracks(): List = imetaTags().filter { it.isAudio } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt index 66d4d334c0..967d3cf114 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/VideoMeta.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip71Video +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -36,7 +37,18 @@ data class VideoMeta( val fallback: List = emptyList(), val image: List = emptyList(), val thumbhash: String? = null, + // NIP-71 PR #2255 — audio-track imeta properties + val bitrate: Int? = null, + val duration: Double? = null, + val waveform: List? = null, + val language: LanguageImetaTag? = null, ) { + val isAudio: Boolean + get() = mimeType?.startsWith("audio/") == true + + val isVideo: Boolean + get() = mimeType?.startsWith("video/") == true + fun toIMetaArray(): Array = IMetaTagBuilder(url) .apply { @@ -50,6 +62,10 @@ data class VideoMeta( service?.let { service(it) } fallback.forEach { fallback(it) } image.forEach { image(it) } + bitrate?.let { bitrate(it) } + duration?.let { duration(it) } + waveform?.let { waveform(it) } + language?.let { language(it) } }.build() .toTagArray() @@ -67,6 +83,10 @@ data class VideoMeta( fallback = iMeta.fallback() ?: emptyList(), image = iMeta.image() ?: emptyList(), thumbhash = iMeta.thumbhash()?.firstOrNull(), + bitrate = iMeta.bitrate(), + duration = iMeta.duration(), + waveform = iMeta.waveform(), + language = iMeta.language(), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt new file mode 100644 index 0000000000..1b82711f9a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/BitrateTag.kt @@ -0,0 +1,29 @@ +/* + * 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.quartz.nip71Video.tags + +class BitrateTag { + companion object { + const val TAG_NAME = "bitrate" + + fun assemble(bitsPerSecond: Int) = arrayOf(TAG_NAME, bitsPerSecond.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt new file mode 100644 index 0000000000..2926e1c85f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/tags/LanguageImetaTag.kt @@ -0,0 +1,65 @@ +/* + * 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.quartz.nip71Video.tags + +import androidx.compose.runtime.Immutable + +/** + * NIP-71 audio-track language tag (lives inside an `imeta` tag). + * + * Format: `l [ov]` + * + * - `code` — language code, e.g. `en` + * - `standard` — namespace, e.g. `ISO-639-1` (default when missing) + * - `ov` — optional flag marking the original-version track + */ +@Immutable +data class LanguageImetaTag( + val code: String, + val standard: String = DEFAULT_STANDARD, + val originalVersion: Boolean = false, +) { + fun toPropertyValue(): String = + buildString { + append(code) + append(' ') + append(standard) + if (originalVersion) { + append(' ') + append(ORIGINAL_VERSION_MARKER) + } + } + + companion object { + const val TAG_NAME = "l" + const val DEFAULT_STANDARD = "ISO-639-1" + const val ORIGINAL_VERSION_MARKER = "ov" + + fun parseValue(value: String): LanguageImetaTag? { + val parts = value.split(' ').filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + val code = parts[0] + val standard = parts.getOrNull(1) ?: DEFAULT_STANDARD + val ov = parts.drop(2).any { it == ORIGINAL_VERSION_MARKER } + return LanguageImetaTag(code, standard, ov) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt new file mode 100644 index 0000000000..1826bf65ab --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/VideoAudioImetaTest.kt @@ -0,0 +1,129 @@ +/* + * 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.quartz.nip71Video + +import com.vitorpamplona.quartz.nip71Video.tags.LanguageImetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class VideoAudioImetaTest { + /** + * NIP-71 PR #2255 spec example for an audio-only imeta variant. + */ + @Test + fun audioImetaRoundTripMatchesSpecExample() { + val waveform = + listOf( + 0f, + 7f, + 35f, + 8f, + 100f, + 100f, + 49f, + 8f, + 4f, + 16f, + 8f, + 10f, + 7f, + 2f, + 20f, + 10f, + 100f, + 100f, + 100f, + 100f, + ) + + val original = + VideoMeta( + url = "https://myaudio.com/audio/en/12345.mp3", + mimeType = "audio/mp3", + hash = "b2e0a7a82ac9f3f3a71f1d9a78c381d5be9d1cf19dce258765c17c8a76287c93", + service = "nip96", + fallback = + listOf( + "https://myotherserver.com/audio/en/12345.mp3", + "https://andanotherserver.com/audio/en/12345.mp3", + ), + bitrate = 320000, + duration = 29.24, + waveform = waveform, + language = LanguageImetaTag("en", "ISO-639-1", originalVersion = true), + ) + + val tag = original.toIMetaArray() + val parsed = IMetaTag.parse(tag)?.firstOrNull() + assertNotNull(parsed) + val round = VideoMeta.parse(parsed) + + assertEquals(original.url, round.url) + assertEquals("audio/mp3", round.mimeType) + assertEquals(original.hash, round.hash) + assertEquals("nip96", round.service) + assertEquals(original.fallback, round.fallback) + assertEquals(320000, round.bitrate) + assertEquals(29.24, round.duration) + assertEquals(waveform, round.waveform) + assertEquals(LanguageImetaTag("en", "ISO-639-1", originalVersion = true), round.language) + assertTrue(round.isAudio) + } + + @Test + fun audioAndVideoTracksAreSplitByMimeType() { + val video = + VideoMeta( + url = "https://example.com/video.mp4", + mimeType = "video/mp4", + ) + val audio = + VideoMeta( + url = "https://example.com/audio.mp3", + mimeType = "audio/mp3", + language = LanguageImetaTag("en"), + ) + + assertTrue(video.isVideo) + assertTrue(audio.isAudio) + } + + @Test + fun languageImetaTagParsesOriginalVersionFlag() { + val parsed = LanguageImetaTag.parseValue("en ISO-639-1 ov") + assertNotNull(parsed) + assertEquals("en", parsed.code) + assertEquals("ISO-639-1", parsed.standard) + assertTrue(parsed.originalVersion) + } + + @Test + fun languageImetaTagDefaultsStandardWhenMissing() { + val parsed = LanguageImetaTag.parseValue("pt") + assertNotNull(parsed) + assertEquals("pt", parsed.code) + assertEquals("ISO-639-1", parsed.standard) + assertEquals(false, parsed.originalVersion) + } +}