mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge pull request #3090 from vitorpamplona/claude/exciting-meitner-KeUBf
Add NIP-71 audio-track support with language and bitrate tags
This commit is contained in:
+20
@@ -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<Float>) = 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))
|
||||
|
||||
@@ -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<Float>? = 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
|
||||
|
||||
@@ -47,4 +47,17 @@ interface VideoEvent : IEvent {
|
||||
fun hash(): String?
|
||||
|
||||
fun imetaTags(): List<VideoMeta>
|
||||
|
||||
/**
|
||||
* 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<VideoMeta> = 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<VideoMeta> = imetaTags().filter { it.isAudio }
|
||||
}
|
||||
|
||||
@@ -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<String> = emptyList(),
|
||||
val image: List<String> = emptyList(),
|
||||
val thumbhash: String? = null,
|
||||
// NIP-71 PR #2255 — audio-track imeta properties
|
||||
val bitrate: Int? = null,
|
||||
val duration: Double? = null,
|
||||
val waveform: List<Float>? = null,
|
||||
val language: LanguageImetaTag? = null,
|
||||
) {
|
||||
val isAudio: Boolean
|
||||
get() = mimeType?.startsWith("audio/") == true
|
||||
|
||||
val isVideo: Boolean
|
||||
get() = mimeType?.startsWith("video/") == true
|
||||
|
||||
fun toIMetaArray(): Array<String> =
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+65
@@ -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 <code> <standard> [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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user