Merge pull request #999 from PR0M3TH3AN/codex/update-nip-71-metadata-handling-and-ui

Support per-variant duration and bitrate in NIP-71 metadata
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-29 17:07:08 -04:00
commit 83279c7953
8 files changed
+283 -40

No files matched your search

+30 -2
View File
@@ -146,12 +146,12 @@
type="number"
id="editNip71Duration"
min="0"
step="1"
step="0.001"
data-nip71-input="duration"
class="input"
/>
<p class="text-xs text-muted">
Provide the runtime in whole seconds for timeline scrubbing.
Optional legacy fallback in seconds (fractions allowed) for clients that expect a top-level duration.
</p>
</div>
<div class="space-y-2">
@@ -268,6 +268,34 @@
/>
<p class="text-3xs text-muted">Optional magnet pointer if the variant is seeded via P2P.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Duration seconds (<code>duration</code>)
</label>
<input
type="number"
data-nip71-field="duration"
min="0"
step="0.001"
placeholder="29.97"
class="input"
/>
<p class="text-3xs text-muted">Fractional seconds supported; clients use this for precise timelines.</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Bitrate bps (<code>bitrate</code>)
</label>
<input
type="number"
data-nip71-field="bitrate"
min="0"
step="1"
placeholder="3500000"
class="input"
/>
<p class="text-3xs text-muted">Average bits per second for this variant.</p>
</div>
</div>
<div class="space-y-3" data-nip71-nested="image">
+50 -17
View File
@@ -193,12 +193,13 @@
type="number"
id="nip71Duration"
min="0"
step="1"
step="0.001"
data-nip71-input="duration"
class="input"
/>
<p class="text-xs text-muted">
Provide the runtime in whole seconds for timeline scrubbing.
Optional legacy fallback in seconds (fractions allowed) for clients that
expect a top-level duration.
</p>
</div>
<div class="space-y-2">
@@ -412,22 +413,54 @@
HTTPS variant URL shared across clients.
</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Magnet (<code>x</code>)
</label>
<input
type="text"
data-nip71-field="x"
placeholder="magnet:?xt=urn:btih:..."
class="input"
/>
<p class="text-3xs text-muted">
Optional magnet pointer if the variant is seeded via
P2P.
</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Magnet (<code>x</code>)
</label>
<input
type="text"
data-nip71-field="x"
placeholder="magnet:?xt=urn:btih:..."
class="input"
/>
<p class="text-3xs text-muted">
Optional magnet pointer if the variant is seeded via
P2P.
</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Duration seconds (<code>duration</code>)
</label>
<input
type="number"
data-nip71-field="duration"
min="0"
step="0.001"
placeholder="29.97"
class="input"
/>
<p class="text-3xs text-muted">
Fractional seconds supported; clients use this for precise timelines.
</p>
</div>
<div class="space-y-2">
<label class="block text-xs font-medium text-muted-strong">
Bitrate bps (<code>bitrate</code>)
</label>
<input
type="number"
data-nip71-field="bitrate"
min="0"
step="1"
placeholder="3500000"
class="input"
/>
<p class="text-3xs text-muted">
Average bits per second for this variant.
</p>
</div>
</div>
<div class="space-y-3" data-nip71-nested="image">
<div class="flex items-center justify-between">
+102 -12
View File
@@ -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;
+29 -1
View File
@@ -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 !== ""),
),
),
);
+25 -1
View File
@@ -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;
});
+20 -5
View File
@@ -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"));
+5 -2
View File
@@ -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) =>
+22
View File
@@ -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", () => {