Add NIP-71 video builder and dual publish workflow

This commit is contained in:
thePR0M3TH3AN committed 2025-10-04 21:14:41 -04:00
1 parent fd400f6fb2
commit 62fe113bf4
6 files changed
+695 -128

No files matched your search

+12
View File
@@ -47,6 +47,8 @@ read/write split.
| --- | --- | --- | --- |
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) |
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
| NIP-71 video (`NOTE_TYPES.NIP71_VIDEO`) | `21` | `['title', <title>]`, optional `['published_at', <unix seconds>]`, optional `['alt', <text>]`, repeated `['imeta', ...]` entries describing NIP-92 media variants, optional `['duration', <seconds>]`, repeated `['text-track', <url>, <kind>, <language>]`, optional `['content-warning', <reason>]`, repeated `['segment', <start>, <end>, <title>, <thumbnail>]`, repeated hashtags `['t', <tag>]`, repeated participants `['p', <pubkey>, <relay?>]`, repeated references `['r', <url>]` | Plain text summary carried in the content field. Publishing is gated by the `FEATURE_PUBLISH_NIP71` runtime flag while the rollout stabilizes. |
| NIP-71 short video (`NOTE_TYPES.NIP71_SHORT_VIDEO`) | `22` | Same as `NOTE_TYPES.NIP71_VIDEO`; the kind differentiates short-form presentations. | Plain text summary; gated by `FEATURE_PUBLISH_NIP71`. |
| Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', <relay url>]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content |
| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message |
| Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', <id>]`, `['chunks', <total>]`, repeated `['a', <chunk address>]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) |
@@ -61,6 +63,16 @@ If you introduce a new Nostr feature, add its schema to
`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing
builders inherit the same debugging knobs.
### NIP-71 rollout
BitVid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short
form) whenever the `FEATURE_PUBLISH_NIP71` flag is enabled. The builder converts
structured upload metadata—image variants, captions, segments, hashtags, and
participant pointers—into canonical tags so other clients can discover the same
video. The legacy kind `30078` post remains the source of truth during the
transition, and schema overrides can adjust either kind if relays need
experimentation.
### Watch history identifiers
The encrypted watch history pipeline is gated by the `FEATURE_WATCH_HISTORY_V2`
+22
View File
@@ -3,6 +3,7 @@ const DEFAULT_FLAGS = Object.freeze({
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
VIEW_FILTER_INCLUDE_LEGACY_VIDEO: false,
FEATURE_WATCH_HISTORY_V2: false,
FEATURE_PUBLISH_NIP71: false,
WSS_TRACKERS: Object.freeze([
"wss://tracker.openwebtorrent.com",
"wss://tracker.fastcast.nz",
@@ -23,6 +24,7 @@ const runtimeFlags = (() => {
VIEW_FILTER_INCLUDE_LEGACY_VIDEO:
DEFAULT_FLAGS.VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
FEATURE_WATCH_HISTORY_V2: DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2,
FEATURE_PUBLISH_NIP71: DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71,
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
};
if (globalScope) {
@@ -108,6 +110,11 @@ export let FEATURE_WATCH_HISTORY_V2 = coerceBoolean(
DEFAULT_FLAGS.FEATURE_WATCH_HISTORY_V2
);
export let FEATURE_PUBLISH_NIP71 = coerceBoolean(
runtimeFlags.FEATURE_PUBLISH_NIP71,
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
);
export let WSS_TRACKERS = freezeTrackers(
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
);
@@ -162,6 +169,20 @@ Object.defineProperty(runtimeFlags, "FEATURE_WATCH_HISTORY_V2", {
},
});
Object.defineProperty(runtimeFlags, "FEATURE_PUBLISH_NIP71", {
configurable: true,
enumerable: true,
get() {
return FEATURE_PUBLISH_NIP71;
},
set(next) {
FEATURE_PUBLISH_NIP71 = coerceBoolean(
next,
DEFAULT_FLAGS.FEATURE_PUBLISH_NIP71
);
},
});
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
configurable: true,
enumerable: true,
@@ -178,6 +199,7 @@ runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
runtimeFlags.VIEW_FILTER_INCLUDE_LEGACY_VIDEO = VIEW_FILTER_INCLUDE_LEGACY_VIDEO;
runtimeFlags.FEATURE_WATCH_HISTORY_V2 = FEATURE_WATCH_HISTORY_V2;
runtimeFlags.FEATURE_PUBLISH_NIP71 = FEATURE_PUBLISH_NIP71;
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
export function setUrlFirstEnabled(next) {
+450 -126
View File
@@ -16,6 +16,7 @@ import {
} from "./config.js";
import {
ACCEPT_LEGACY_V1,
FEATURE_PUBLISH_NIP71,
VIEW_FILTER_INCLUDE_LEGACY_VIDEO,
} from "./constants.js";
import { accessControl } from "./accessControl.js";
@@ -194,6 +195,329 @@ function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request ti
});
}
function stringFromInput(value) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
return String(value).trim();
}
function normalizeUnixSeconds(value) {
if (value === null || value === undefined || value === "") {
return "";
}
if (typeof value === "number" && Number.isFinite(value)) {
const normalized = value > 1e12 ? value / 1000 : value;
return String(Math.floor(normalized));
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
const normalized = numeric > 1e12 ? numeric / 1000 : numeric;
return String(Math.floor(normalized));
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return String(Math.floor(parsed / 1000));
}
}
return "";
}
function normalizeDurationSeconds(value) {
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)));
}
}
return "";
}
function normalizeNip71Kind(value) {
const numeric =
typeof value === "string"
? Number(value.trim())
: typeof value === "number"
? value
: Number.NaN;
if (numeric === 22) {
return 22;
}
return 21;
}
function trimTrailingEmpty(values) {
const trimmed = [...values];
while (trimmed.length && !trimmed[trimmed.length - 1]) {
trimmed.pop();
}
return trimmed;
}
function buildImetaTags(variants) {
if (!Array.isArray(variants)) {
return [];
}
const tags = [];
for (const variant of variants) {
if (!variant || typeof variant !== "object") {
continue;
}
const entries = ["imeta"];
const dim = stringFromInput(variant.dim);
if (dim) {
entries.push(`dim ${dim}`);
}
const url = stringFromInput(variant.url);
if (url) {
entries.push(`url ${url}`);
}
const x = stringFromInput(variant.x);
if (x) {
entries.push(`x ${x}`);
}
const mime = stringFromInput(variant.m);
if (mime) {
entries.push(`m ${mime}`);
}
if (Array.isArray(variant.image)) {
variant.image
.map(stringFromInput)
.filter(Boolean)
.forEach((imageUrl) => {
entries.push(`image ${imageUrl}`);
});
}
if (Array.isArray(variant.fallback)) {
variant.fallback
.map(stringFromInput)
.filter(Boolean)
.forEach((fallbackUrl) => {
entries.push(`fallback ${fallbackUrl}`);
});
}
if (Array.isArray(variant.service)) {
variant.service
.map(stringFromInput)
.filter(Boolean)
.forEach((service) => {
entries.push(`service ${service}`);
});
}
if (entries.length > 1) {
tags.push(entries);
}
}
return tags;
}
function buildTextTrackTag(track) {
if (!track || typeof track !== "object") {
return null;
}
const url = stringFromInput(track.url);
const type = stringFromInput(track.type);
const language = stringFromInput(track.language);
if (!url && !type && !language) {
return null;
}
const values = trimTrailingEmpty([url, type, language]);
return ["text-track", ...values];
}
function buildSegmentTag(segment) {
if (!segment || typeof segment !== "object") {
return null;
}
const start = stringFromInput(segment.start);
const end = stringFromInput(segment.end);
const title = stringFromInput(segment.title);
const thumbnail = stringFromInput(segment.thumbnail);
if (!start && !end && !title && !thumbnail) {
return null;
}
const values = trimTrailingEmpty([start, end, title, thumbnail]);
return ["segment", ...values];
}
function buildParticipantTag(participant) {
if (!participant || typeof participant !== "object") {
return null;
}
const pubkey = stringFromInput(participant.pubkey);
if (!pubkey) {
return null;
}
const relay = stringFromInput(participant.relay);
const values = ["p", pubkey];
if (relay) {
values.push(relay);
}
return values;
}
function extractVideoPublishPayload(rawPayload) {
let videoData = rawPayload;
let nip71Metadata = null;
if (rawPayload && typeof rawPayload === "object") {
if (rawPayload.nip71 && typeof rawPayload.nip71 === "object") {
nip71Metadata = rawPayload.nip71;
}
if (
rawPayload.legacyFormData &&
typeof rawPayload.legacyFormData === "object"
) {
videoData = rawPayload.legacyFormData;
} else if (
Object.prototype.hasOwnProperty.call(rawPayload, "legacyFormData")
) {
videoData = rawPayload.legacyFormData || {};
}
}
if (!videoData || typeof videoData !== "object") {
videoData = {};
}
return { videoData, nip71Metadata };
}
export function buildNip71VideoEvent({
metadata,
pubkey = "",
title,
summaryFallback = "",
createdAt = Math.floor(Date.now() / 1000),
} = {}) {
if (!metadata || typeof metadata !== "object") {
return null;
}
const normalizedTitle = stringFromInput(title);
if (!normalizedTitle) {
return null;
}
const summaryCandidates = [
stringFromInput(metadata.summary),
stringFromInput(summaryFallback),
normalizedTitle,
];
const summary = summaryCandidates.find((value) => Boolean(value)) || "";
const tags = [];
tags.push(["title", normalizedTitle]);
const publishedAt = normalizeUnixSeconds(metadata.publishedAt);
if (publishedAt) {
tags.push(["published_at", publishedAt]);
}
const alt = stringFromInput(metadata.alt);
if (alt) {
tags.push(["alt", alt]);
}
const imetaTags = buildImetaTags(metadata.imeta);
if (imetaTags.length) {
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);
if (tag) {
tags.push(tag);
}
});
}
const contentWarning = stringFromInput(metadata.contentWarning);
if (contentWarning) {
tags.push(["content-warning", contentWarning]);
}
if (Array.isArray(metadata.segments)) {
metadata.segments.forEach((segment) => {
const tag = buildSegmentTag(segment);
if (tag) {
tags.push(tag);
}
});
}
if (Array.isArray(metadata.hashtags)) {
metadata.hashtags
.map(stringFromInput)
.filter(Boolean)
.forEach((value) => {
tags.push(["t", value]);
});
}
if (Array.isArray(metadata.participants)) {
metadata.participants.forEach((participant) => {
const tag = buildParticipantTag(participant);
if (tag) {
tags.push(tag);
}
});
}
if (Array.isArray(metadata.references)) {
metadata.references
.map(stringFromInput)
.filter(Boolean)
.forEach((url) => {
tags.push(["r", url]);
});
}
if (!tags.length) {
return null;
}
const normalizedPubkey = stringFromInput(pubkey);
const timestamp = Number.isFinite(createdAt)
? Math.floor(createdAt)
: Math.floor(Date.now() / 1000);
return {
kind: normalizeNip71Kind(metadata.kind),
pubkey: normalizedPubkey,
created_at: timestamp,
tags,
content: summary,
};
}
function sanitizeRelayList(list) {
const seen = new Set();
const sanitized = [];
@@ -4048,30 +4372,72 @@ class NostrClient {
/**
* Publish a new video using the v3 content schema.
*/
async signAndPublishEvent(
event,
{
context = "event",
logName = context,
devLogLabel = logName,
rejectionLogLevel = "error",
} = {}
) {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log(`Signed ${devLogLabel} event:`, signedEvent);
}
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, { context });
} catch (publishError) {
if (publishError?.relayFailures?.length) {
const logLevel = rejectionLogLevel === "warn" ? "warn" : "error";
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console[logLevel](
`[nostr] ${logName} rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
if (isDevMode) {
publishSummary.accepted.forEach(({ url }) => {
console.log(`${logName} published to ${url}`);
});
}
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] ${logName} not accepted by ${url}: ${reason}`,
relayError
);
});
}
return { signedEvent, summary: publishSummary };
}
async publishVideo(videoPayload, pubkey) {
if (!pubkey) throw new Error("Not logged in to publish video.");
let nip71Metadata = null;
let videoData = videoPayload;
if (
videoPayload &&
typeof videoPayload === "object" &&
(Object.prototype.hasOwnProperty.call(videoPayload, "legacyFormData") ||
Object.prototype.hasOwnProperty.call(videoPayload, "nip71"))
) {
if (videoPayload.nip71 && typeof videoPayload.nip71 === "object") {
nip71Metadata = videoPayload.nip71;
}
if (
videoPayload.legacyFormData &&
typeof videoPayload.legacyFormData === "object"
) {
videoData = videoPayload.legacyFormData;
} else {
videoData = {};
}
}
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
if (isDevMode) {
console.log("Publishing new video with data:", videoData);
@@ -4148,56 +4514,11 @@ class NostrClient {
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) console.log("Signed event:", signedEvent);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedEvent
);
let publishSummary;
try {
publishSummary = assertAnyRelayAccepted(publishResults, {
context: "video note",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.error(
`[nostr] Video note rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
}
throw publishError;
}
const { accepted: acceptedRelays, failed: failedRelays } = publishSummary;
if (isDevMode) {
acceptedRelays.forEach(({ url }) =>
console.log(`Video published to ${url}`)
);
}
if (failedRelays.length) {
failedRelays.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] Video note not accepted by ${url}: ${reason}`,
relayError
);
});
}
const { signedEvent } = await this.signAndPublishEvent(event, {
context: "video note",
logName: "Video note",
devLogLabel: "video note",
});
if (finalUrl) {
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
@@ -4234,59 +4555,12 @@ class NostrClient {
}
try {
const signedMirrorEvent = await window.nostr.signEvent(mirrorEvent);
if (isDevMode) {
console.log("Signed NIP-94 mirror event:", signedMirrorEvent);
}
const mirrorResults = await publishEventToRelays(
this.pool,
this.relays,
signedMirrorEvent
);
try {
const mirrorSummary = assertAnyRelayAccepted(mirrorResults, {
context: "NIP-94 mirror",
});
if (isDevMode) {
mirrorSummary.accepted.forEach(({ url }) =>
console.log(`NIP-94 mirror published to ${url}`)
);
}
if (mirrorSummary.failed.length) {
mirrorSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
console.warn(
`[nostr] NIP-94 mirror not accepted by ${url}: ${reason}`,
relayError
);
});
}
} catch (mirrorError) {
if (mirrorError?.relayFailures?.length) {
mirrorError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
console.warn(
`[nostr] NIP-94 mirror rejected by ${url}: ${reason}`,
relayError || reason
);
}
);
} else if (isDevMode) {
console.warn(
"[nostr] NIP-94 mirror rejected by all relays:",
mirrorError
);
}
}
await this.signAndPublishEvent(mirrorEvent, {
context: "NIP-94 mirror",
logName: "NIP-94 mirror",
devLogLabel: "NIP-94 mirror",
rejectionLogLevel: "warn",
});
if (isDevMode) {
console.log(
@@ -4296,8 +4570,8 @@ class NostrClient {
}
} catch (mirrorError) {
if (isDevMode) {
console.error(
"Failed to sign/publish NIP-94 mirror event:",
console.warn(
"[nostr] NIP-94 mirror rejected by all relays:",
mirrorError
);
}
@@ -4312,6 +4586,56 @@ class NostrClient {
}
}
async publishNip71Video(videoPayload, pubkey) {
if (!FEATURE_PUBLISH_NIP71) {
return null;
}
if (!pubkey) {
throw new Error("Not logged in to publish video.");
}
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
if (!nip71Metadata || typeof nip71Metadata !== "object") {
if (isDevMode) {
console.log("[nostr] Skipping NIP-71 publish: metadata missing.");
}
return null;
}
const title = stringFromInput(videoData?.title);
const description = stringFromInput(videoData?.description);
const event = buildNip71VideoEvent({
metadata: nip71Metadata,
pubkey,
title,
summaryFallback: description,
createdAt: Math.floor(Date.now() / 1000),
});
if (!event) {
if (isDevMode) {
console.warn("[nostr] Skipping NIP-71 publish: builder produced no event.");
}
return null;
}
if (isDevMode) {
console.log("Prepared NIP-71 video event:", event);
}
const { signedEvent } = await this.signAndPublishEvent(event, {
context: "NIP-71 video",
logName: "NIP-71 video",
devLogLabel: "NIP-71 video",
rejectionLogLevel: "warn",
});
return signedEvent;
}
/**
* Edits a video by creating a *new event* with a brand-new d tag,
* but reuses the same videoRootId as the original.
+26
View File
@@ -8,6 +8,8 @@ import {
export const NOTE_TYPES = Object.freeze({
VIDEO_POST: "videoPost",
VIDEO_MIRROR: "videoMirror",
NIP71_VIDEO: "nip71Video",
NIP71_SHORT_VIDEO: "nip71ShortVideo",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
WATCH_HISTORY_INDEX: "watchHistoryIndex",
@@ -131,6 +133,30 @@ const BASE_SCHEMAS = {
description: "Optional alt text carried alongside hosted URL metadata.",
},
},
[NOTE_TYPES.NIP71_VIDEO]: {
type: NOTE_TYPES.NIP71_VIDEO,
label: "NIP-71 video (normal)",
kind: 21,
featureFlag: "FEATURE_PUBLISH_NIP71",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Summary or description for the video body; tags carry structured metadata.",
},
},
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
label: "NIP-71 video (short)",
kind: 22,
featureFlag: "FEATURE_PUBLISH_NIP71",
appendTags: DEFAULT_APPEND_TAGS,
content: {
format: "text",
description:
"Summary or description for the short-form video; structured fields live in tags.",
},
},
[NOTE_TYPES.RELAY_LIST]: {
type: NOTE_TYPES.RELAY_LIST,
label: "Relay list metadata",
+21 -2
View File
@@ -357,8 +357,7 @@ export class NostrService {
}
async publishVideoNote(publishPayload, pubkey) {
const result = await this.nostrClient.publishVideo(publishPayload, pubkey);
const detail = { pubkey, result };
const detail = { pubkey };
if (publishPayload && typeof publishPayload === "object") {
detail.payload = publishPayload;
if (publishPayload.legacyFormData) {
@@ -370,6 +369,26 @@ export class NostrService {
} else {
detail.formData = publishPayload;
}
let nip71Result = null;
let legacyResult = null;
try {
nip71Result = await this.nostrClient.publishNip71Video(
publishPayload,
pubkey
);
legacyResult = await this.nostrClient.publishVideo(
publishPayload,
pubkey
);
} catch (error) {
detail.error = error;
throw error;
}
const result = { legacy: legacyResult, nip71: nip71Result };
detail.result = result;
this.emit("videos:published", detail);
return result;
}
+164
View File
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildNip71VideoEvent } from "../js/nostr.js";
test("buildNip71VideoEvent assembles rich metadata", () => {
const metadata = {
kind: 21,
summary: "Custom summary",
publishedAt: "1700000000",
alt: "Alt text for accessibility",
duration: 123,
contentWarning: "Flashing lights",
imeta: [
{
dim: "1920x1080",
url: "https://cdn.example/video-1080.mp4",
x: "abc123",
m: "video/mp4",
image: [
"https://cdn.example/video-1080.jpg",
"https://backup.example/video-1080.jpg",
],
fallback: ["https://fallback.example/video-1080.mp4"],
service: ["nip96"],
},
{
url: "https://cdn.example/video-hls.m3u8",
m: "application/x-mpegURL",
},
],
textTracks: [
{
url: "https://cdn.example/captions-en.vtt",
type: "captions",
language: "en",
},
{
url: "https://cdn.example/captions-es.vtt",
language: "es",
},
],
segments: [
{ start: "00:00:00", end: "00:01:00", title: "Intro" },
{ start: 120, title: "Middle" },
],
hashtags: ["bitvid", "nostr"],
participants: [
{ pubkey: "a".repeat(64), relay: "wss://relay.example" },
{ pubkey: "b".repeat(64) },
],
references: [
"https://example.com/info",
"https://example.com/docs",
],
};
const event = buildNip71VideoEvent({
metadata,
pubkey: "f".repeat(64),
title: "Test Video",
summaryFallback: "Fallback summary",
createdAt: 42,
});
assert.ok(event, "builder should return an event");
assert.equal(event.kind, 21, "kind should honor metadata");
assert.equal(event.pubkey, "f".repeat(64));
assert.equal(event.created_at, 42);
assert.equal(event.content, "Custom summary");
const tags = new Map(event.tags.map((tag) => [tag[0], tag]));
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.deepEqual(tags.get("content-warning"), [
"content-warning",
"Flashing lights",
]);
const imetaTags = event.tags.filter((tag) => tag[0] === "imeta");
assert.equal(imetaTags.length, 2, "should include both imeta variants");
assert.deepEqual(imetaTags[0], [
"imeta",
"dim 1920x1080",
"url https://cdn.example/video-1080.mp4",
"x abc123",
"m video/mp4",
"image https://cdn.example/video-1080.jpg",
"image https://backup.example/video-1080.jpg",
"fallback https://fallback.example/video-1080.mp4",
"service nip96",
]);
assert.deepEqual(imetaTags[1], [
"imeta",
"url https://cdn.example/video-hls.m3u8",
"m application/x-mpegURL",
]);
const textTrackTags = event.tags.filter((tag) => tag[0] === "text-track");
assert.deepEqual(textTrackTags, [
[
"text-track",
"https://cdn.example/captions-en.vtt",
"captions",
"en",
],
["text-track", "https://cdn.example/captions-es.vtt", "", "es"],
]);
const segmentTags = event.tags.filter((tag) => tag[0] === "segment");
assert.deepEqual(segmentTags, [
["segment", "00:00:00", "00:01:00", "Intro"],
["segment", "120", "", "Middle"],
]);
const hashtagTags = event.tags.filter((tag) => tag[0] === "t");
assert.deepEqual(hashtagTags, [
["t", "bitvid"],
["t", "nostr"],
]);
const participantTags = event.tags.filter((tag) => tag[0] === "p");
assert.deepEqual(participantTags, [
["p", "a".repeat(64), "wss://relay.example"],
["p", "b".repeat(64)],
]);
const referenceTags = event.tags.filter((tag) => tag[0] === "r");
assert.deepEqual(referenceTags, [
["r", "https://example.com/info"],
["r", "https://example.com/docs"],
]);
});
test("buildNip71VideoEvent falls back to summary and selects kind", () => {
const metadata = {
kind: "22",
publishedAt: "2024-01-01T12:00:00Z",
imeta: [{ url: "https://cdn.example/video.mp4" }],
};
const event = buildNip71VideoEvent({
metadata,
pubkey: "c".repeat(64),
title: "Fallback Title",
summaryFallback: "Fallback description",
createdAt: 100,
});
assert.ok(event, "builder should create minimal event");
assert.equal(event.kind, 22, "string kind should coerce to number");
const expectedPublished = String(Math.floor(Date.parse("2024-01-01T12:00:00Z") / 1000));
assert.deepEqual(event.tags.find((tag) => tag[0] === "published_at"), [
"published_at",
expectedPublished,
]);
assert.equal(
event.content,
"Fallback description",
"should use fallback summary when metadata summary missing"
);
});