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.
@@ -412,22 +413,54 @@
HTTPS variant URL shared across clients.
-
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", () => {