mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add NIP-71 video builder and dual publish workflow
This commit is contained in:
1 parent
fd400f6fb2
commit
62fe113bf4
6 files changed
+695
-128
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user