mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 11:22:25 +00:00
Support per-variant duration and bitrate in NIP-71 metadata
This commit is contained in:
1 parent
3bf9c2c0d5
commit
796b6c9ae6
8 files changed
+283
-40
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 !== ""),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user