From 796b6c9ae6caa7870a3c651c7dc0116b770962d7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:06:19 -0400 Subject: [PATCH] Support per-variant duration and bitrate in NIP-71 metadata --- components/edit-video-modal.html | 32 +++++++- components/upload-modal.html | 67 ++++++++++++---- js/nostr/nip71.js | 114 ++++++++++++++++++++++++--- js/services/videoNotePayload.js | 30 ++++++- js/ui/components/nip71FormManager.js | 26 +++++- tests/nip71-builder.test.mjs | 25 ++++-- tests/nostr/nip71.test.js | 7 +- tests/video-note-payload.test.mjs | 22 ++++++ 8 files changed, 283 insertions(+), 40 deletions(-) diff --git a/components/edit-video-modal.html b/components/edit-video-modal.html index 52dc03b9..91cd83f0 100644 --- a/components/edit-video-modal.html +++ b/components/edit-video-modal.html @@ -146,12 +146,12 @@ type="number" id="editNip71Duration" min="0" - step="1" + step="0.001" data-nip71-input="duration" class="input" />

- Provide the runtime in whole seconds for timeline scrubbing. + Optional legacy fallback in seconds (fractions allowed) for clients that expect a top-level duration.

@@ -268,6 +268,34 @@ />

Optional magnet pointer if the variant is seeded via P2P.

+
+ + +

Fractional seconds supported; clients use this for precise timelines.

+
+
+ + +

Average bits per second for this variant.

+
diff --git a/components/upload-modal.html b/components/upload-modal.html index b545855d..bd54e099 100644 --- a/components/upload-modal.html +++ b/components/upload-modal.html @@ -193,12 +193,13 @@ type="number" id="nip71Duration" min="0" - step="1" + step="0.001" data-nip71-input="duration" class="input" />

- Provide the runtime in whole seconds for timeline scrubbing. + Optional legacy fallback in seconds (fractions allowed) for clients that + expect a top-level duration.

@@ -412,22 +413,54 @@ HTTPS variant URL shared across clients.

-
- - -

- Optional magnet pointer if the variant is seeded via - P2P. -

-
+
+ + +

+ Optional magnet pointer if the variant is seeded via + P2P. +

+
+ + +

+ Fractional seconds supported; clients use this for precise timelines. +

+
+
+ + +

+ Average bits per second for this variant. +

+
+
diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 26bc5fc9..0024dbb7 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -42,23 +42,60 @@ function normalizeUnixSeconds(value) { return ""; } -function normalizeDurationSeconds(value) { +function parseNonNegativeNumber(value, { allowFloat = false } = {}) { + if (typeof value === "number" && Number.isFinite(value)) { + if (!allowFloat && !Number.isInteger(value)) { + return null; + } + return value >= 0 ? value : null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const numeric = Number(trimmed); + if (!Number.isFinite(numeric)) { + return null; + } + if (!allowFloat && !Number.isInteger(numeric)) { + return null; + } + return numeric >= 0 ? numeric : null; + } + return null; +} + +function formatNonNegativeNumber(value, { allowFloat = false } = {}) { if (value === null || value === undefined || value === "") { return ""; } - if (typeof value === "number" && Number.isFinite(value)) { - return String(Math.max(0, Math.floor(value))); - } + if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return ""; } const numeric = Number(trimmed); - if (Number.isFinite(numeric)) { - return String(Math.max(0, Math.floor(numeric))); + if (!Number.isFinite(numeric) || numeric < 0) { + return ""; } + if (!allowFloat && !Number.isInteger(numeric)) { + return ""; + } + if (/^\d+(?:\.\d+)?$/.test(trimmed)) { + return trimmed; + } + return allowFloat ? String(numeric) : String(Math.trunc(numeric)); } + + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + if (!allowFloat && !Number.isInteger(value)) { + return ""; + } + return String(value); + } + return ""; } @@ -96,6 +133,12 @@ function buildImetaTags(variants) { const url = stringFromInput(variant.url); const x = stringFromInput(variant.x); const mime = stringFromInput(variant.m); + const duration = formatNonNegativeNumber(variant.duration, { + allowFloat: true, + }); + const bitrate = formatNonNegativeNumber(variant.bitrate, { + allowFloat: false, + }); const image = Array.isArray(variant.image) ? variant.image.map(stringFromInput).filter(Boolean) : []; @@ -106,7 +149,17 @@ function buildImetaTags(variants) { ? variant.service.map(stringFromInput).filter(Boolean) : []; - if (!dim && !url && !x && !mime && !image.length && !fallback.length && !service.length) { + if ( + !dim && + !url && + !x && + !mime && + !duration && + !bitrate && + !image.length && + !fallback.length && + !service.length + ) { continue; } @@ -123,6 +176,12 @@ function buildImetaTags(variants) { if (mime) { entries.push(`m ${mime}`); } + if (duration) { + entries.push(`duration ${duration}`); + } + if (bitrate) { + entries.push(`bitrate ${bitrate}`); + } image.forEach((value) => entries.push(`image ${value}`)); fallback.forEach((value) => entries.push(`fallback ${value}`)); service.forEach((value) => entries.push(`service ${value}`)); @@ -204,11 +263,6 @@ export function buildNip71MetadataTags(metadata) { tags.push(...imetaTags); } - const duration = normalizeDurationSeconds(metadata.duration); - if (duration) { - tags.push(["duration", duration]); - } - if (Array.isArray(metadata.textTracks)) { metadata.textTracks.forEach((track) => { const tag = buildTextTrackTag(track); @@ -312,6 +366,18 @@ function parseImetaTag(tag) { case "m": variant.m = parsed.value; break; + case "duration": { + const numeric = parseNonNegativeNumber(parsed.value, { + allowFloat: true, + }); + variant.duration = numeric ?? parsed.value; + break; + } + case "bitrate": { + const numeric = parseNonNegativeNumber(parsed.value); + variant.bitrate = numeric ?? parsed.value; + break; + } case "image": variant.image.push(parsed.value); break; @@ -331,6 +397,8 @@ function parseImetaTag(tag) { Boolean(variant.url) || Boolean(variant.x) || Boolean(variant.m) || + (variant.duration !== undefined && variant.duration !== null && variant.duration !== "") || + (variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== "") || variant.image.length > 0 || variant.fallback.length > 0 || variant.service.length > 0; @@ -348,6 +416,12 @@ function parseImetaTag(tag) { if (!variant.service.length) { delete variant.service; } + if (variant.duration === "" || variant.duration === undefined) { + delete variant.duration; + } + if (variant.bitrate === "" || variant.bitrate === undefined) { + delete variant.bitrate; + } return variant; } @@ -595,6 +669,22 @@ export function extractNip71MetadataFromTags(event) { if (imeta.length) { metadata.imeta = imeta; + + const legacyDuration = parseNonNegativeNumber(metadata.duration, { + allowFloat: true, + }); + + if (legacyDuration !== null) { + metadata.duration = legacyDuration; + } else { + const variantDurations = imeta + .map((variant) => parseNonNegativeNumber(variant?.duration, { allowFloat: true })) + .filter((value) => value !== null); + + if (variantDurations.length) { + metadata.duration = Math.max(...variantDurations); + } + } } if (textTracks.length) { metadata.textTracks = textTracks; diff --git a/js/services/videoNotePayload.js b/js/services/videoNotePayload.js index bca2ebab..95510373 100644 --- a/js/services/videoNotePayload.js +++ b/js/services/videoNotePayload.js @@ -92,6 +92,24 @@ function normalizeImetaVariant(variant) { if (url) normalized.url = url; const x = normalizeString(variant.x); if (x) normalized.x = x; + const durationValue = parseNumberOrNull(variant.duration); + if (durationValue !== null && durationValue >= 0) { + normalized.duration = durationValue; + } else { + const durationString = normalizeString(variant.duration); + if (durationString) { + normalized.duration = durationString; + } + } + const bitrateValue = parseNumberOrNull(variant.bitrate); + if (bitrateValue !== null && bitrateValue >= 0) { + normalized.bitrate = bitrateValue; + } else { + const bitrateString = normalizeString(variant.bitrate); + if (bitrateString) { + normalized.bitrate = bitrateString; + } + } const image = normalizeStringArray(variant.image); if (image.length) normalized.image = image; const fallback = normalizeStringArray(variant.fallback); @@ -241,6 +259,14 @@ function normalizeNip71Metadata(rawMetadata) { : []; if (imeta.length) { normalized.imeta = imeta; + if (normalized.duration === undefined) { + const variantDurations = imeta + .map((variant) => parseNumberOrNull(variant.duration)) + .filter((value) => value !== null && value >= 0); + if (variantDurations.length) { + normalized.duration = Math.max(...variantDurations); + } + } } const textTracks = normalizeTextTracks(rawMetadata.textTracks); @@ -331,7 +357,9 @@ export function normalizeVideoNotePayload(input) { (variant.dim && variant.dim.length) || (Array.isArray(variant.image) && variant.image.length > 0) || (Array.isArray(variant.fallback) && variant.fallback.length > 0) || - (Array.isArray(variant.service) && variant.service.length > 0), + (Array.isArray(variant.service) && variant.service.length > 0) || + (variant.duration !== undefined && variant.duration !== null && variant.duration !== "") || + (variant.bitrate !== undefined && variant.bitrate !== null && variant.bitrate !== ""), ), ), ); diff --git a/js/ui/components/nip71FormManager.js b/js/ui/components/nip71FormManager.js index 2d1428e6..6c7dc589 100644 --- a/js/ui/components/nip71FormManager.js +++ b/js/ui/components/nip71FormManager.js @@ -199,6 +199,8 @@ export class Nip71FormManager { this.setFieldValue(entry, "dim", value.dim); this.setFieldValue(entry, "url", value.url); this.setFieldValue(entry, "x", value.x); + this.setFieldValue(entry, "duration", value.duration); + this.setFieldValue(entry, "bitrate", value.bitrate); this.hydrateNested(entry, "image", value.image); this.hydrateNested(entry, "fallback", value.fallback); this.hydrateNested(entry, "service", value.service); @@ -302,6 +304,26 @@ export class Nip71FormManager { service: this.collectNestedValues(entry, "service"), }; + const durationValue = this.getFieldValue(entry, "duration"); + if (durationValue !== "") { + const parsed = this.normalizeNumber(durationValue); + if (parsed != null && parsed >= 0) { + variant.duration = parsed; + } else { + variant.duration = durationValue; + } + } + + const bitrateValue = this.getFieldValue(entry, "bitrate"); + if (bitrateValue !== "") { + const parsed = this.normalizeNumber(bitrateValue); + if (parsed != null && parsed >= 0) { + variant.bitrate = parsed; + } else { + variant.bitrate = bitrateValue; + } + } + const hasContent = variant.m || variant.dim || @@ -309,7 +331,9 @@ export class Nip71FormManager { variant.x || variant.image.length > 0 || variant.fallback.length > 0 || - variant.service.length > 0; + variant.service.length > 0 || + variant.duration !== undefined || + variant.bitrate !== undefined; return hasContent ? variant : null; }); diff --git a/tests/nip71-builder.test.mjs b/tests/nip71-builder.test.mjs index d4860da9..2b305fdc 100644 --- a/tests/nip71-builder.test.mjs +++ b/tests/nip71-builder.test.mjs @@ -12,7 +12,7 @@ test("buildNip71VideoEvent assembles rich metadata", () => { summary: "Custom summary", publishedAt: "1700000000", alt: "Alt text for accessibility", - duration: 123, + duration: 123.456, contentWarning: "Flashing lights", imeta: [ { @@ -20,6 +20,8 @@ test("buildNip71VideoEvent assembles rich metadata", () => { url: "https://cdn.example/video-1080.mp4", x: "abc123", m: "video/mp4", + duration: 123.456, + bitrate: 4500000, image: [ "https://cdn.example/video-1080.jpg", "https://backup.example/video-1080.jpg", @@ -30,6 +32,8 @@ test("buildNip71VideoEvent assembles rich metadata", () => { { url: "https://cdn.example/video-hls.m3u8", m: "application/x-mpegURL", + duration: "45.789", + bitrate: "2200000", }, ], textTracks: [ @@ -76,7 +80,7 @@ test("buildNip71VideoEvent assembles rich metadata", () => { assert.deepEqual(tags.get("title"), ["title", "Test Video"]); assert.deepEqual(tags.get("published_at"), ["published_at", "1700000000"]); assert.deepEqual(tags.get("alt"), ["alt", "Alt text for accessibility"]); - assert.deepEqual(tags.get("duration"), ["duration", "123"]); + assert.ok(!tags.has("duration"), "duration should no longer be emitted as a top-level tag"); assert.deepEqual(tags.get("content-warning"), [ "content-warning", "Flashing lights", @@ -90,6 +94,8 @@ test("buildNip71VideoEvent assembles rich metadata", () => { "url https://cdn.example/video-1080.mp4", "x abc123", "m video/mp4", + "duration 123.456", + "bitrate 4500000", "image https://cdn.example/video-1080.jpg", "image https://backup.example/video-1080.jpg", "fallback https://fallback.example/video-1080.mp4", @@ -99,6 +105,8 @@ test("buildNip71VideoEvent assembles rich metadata", () => { "imeta", "url https://cdn.example/video-hls.m3u8", "m application/x-mpegURL", + "duration 45.789", + "bitrate 2200000", ]); const textTrackTags = event.tags.filter((tag) => tag[0] === "text-track"); @@ -209,9 +217,14 @@ test("extractNip71MetadataFromTags parses metadata and pointers", () => { ["title", "Metadata Title"], ["published_at", "1700000100"], ["alt", "alt text"], - ["duration", "321"], ["content-warning", "Bright lights"], - ["imeta", "url https://cdn.example/video.mp4", "m video/mp4"], + [ + "imeta", + "url https://cdn.example/video.mp4", + "m video/mp4", + "duration 321.125", + "bitrate 2500000", + ], ["text-track", "https://cdn.example/captions.vtt", "captions", "en"], ["segment", "00:00", "00:10", "Intro", "https://cdn.example/thumb.jpg"], ["t", "nostr"], @@ -228,13 +241,15 @@ test("extractNip71MetadataFromTags parses metadata and pointers", () => { assert.ok(parsed, "parser should produce a result"); assert.deepEqual(parsed.metadata.title, "Metadata Title"); assert.deepEqual(parsed.metadata.summary, "Summary text"); - assert.equal(parsed.metadata.duration, 321); + assert.equal(parsed.metadata.duration, 321.125); assert.equal(parsed.metadata.alt, "alt text"); assert.equal(parsed.metadata.contentWarning, "Bright lights"); assert.deepEqual(parsed.metadata.hashtags, ["nostr"]); assert.deepEqual(parsed.metadata.references, ["https://example.com/info"]); assert.equal(parsed.metadata.textTracks.length, 1); assert.equal(parsed.metadata.imeta.length, 1); + assert.equal(parsed.metadata.imeta[0].duration, 321.125); + assert.equal(parsed.metadata.imeta[0].bitrate, 2500000); assert(parsed.pointers.videoRootIds.has("root-123")); assert(parsed.pointers.videoEventIds.has("event-456")); assert(parsed.pointers.dTags.has("d-pointer")); diff --git a/tests/nostr/nip71.test.js b/tests/nostr/nip71.test.js index d6169e04..87136e2d 100644 --- a/tests/nostr/nip71.test.js +++ b/tests/nostr/nip71.test.js @@ -21,12 +21,13 @@ test("buildNip71MetadataTags normalizes structured fields", () => { { dim: " 1920x1080 ", url: " https://cdn.example/thumb.jpg ", + duration: " 29.5 ", + bitrate: " 3000000 ", image: [" https://cdn.example/fallback-1.jpg ", ""], fallback: [" https://cdn.example/fallback-2.jpg "], service: [" thumb "], }, ], - duration: " 123 ", textTracks: [ { url: " https://cdn.example/captions.vtt ", @@ -54,12 +55,14 @@ test("buildNip71MetadataTags normalizes structured fields", () => { tag[0] === "imeta" && tag.includes("dim 1920x1080") && tag.includes("url https://cdn.example/thumb.jpg") && + tag.includes("duration 29.5") && + tag.includes("bitrate 3000000") && tag.includes("image https://cdn.example/fallback-1.jpg") && tag.includes("fallback https://cdn.example/fallback-2.jpg") && tag.includes("service thumb"), ), ); - assert.ok(tags.some((tag) => tag[0] === "duration" && tag[1] === "123")); + assert.ok(!tags.some((tag) => tag[0] === "duration")); assert.ok( tags.some( (tag) => diff --git a/tests/video-note-payload.test.mjs b/tests/video-note-payload.test.mjs index 25b4adac..d8c3c904 100644 --- a/tests/video-note-payload.test.mjs +++ b/tests/video-note-payload.test.mjs @@ -81,6 +81,8 @@ test("normalizes nip71 metadata collections", () => { imeta: [ { url: " https://cdn.example.com/hls/playlist.m3u8 ", + duration: " 120.5 ", + bitrate: " 2100000 ", service: [" wss://relay.example.com "], }, ], @@ -144,6 +146,26 @@ test("normalizes nip71 metadata collections", () => { ]); assert.equal(nip71.imeta[0].url, "https://cdn.example.com/hls/playlist.m3u8"); assert.deepEqual(nip71.imeta[0].service, ["wss://relay.example.com"]); + assert.equal(nip71.imeta[0].duration, 120.5); + assert.equal(nip71.imeta[0].bitrate, 2100000); +}); + +test("derives legacy duration fallback from imeta variants", () => { + const { payload, errors } = normalizeVideoNotePayload({ + title: "Duration Fallback", + nip71: { + imeta: [ + { url: "https://cdn.example.com/a.mp4", duration: 12.5 }, + { url: "https://cdn.example.com/b.mp4", duration: "45.75" }, + ], + }, + }); + + assert.deepEqual(errors, []); + assert.ok(payload.nip71, "Expected nip71 payload"); + assert.equal(payload.nip71.duration, 45.75); + assert.equal(payload.nip71.imeta[0].duration, 12.5); + assert.equal(payload.nip71.imeta[1].duration, 45.75); }); test("reports validation errors for missing fields", () => {