mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 08:52:25 +00:00
Auto-captures a video's width/height/duration at file-select and persists them on
the 30078 — the durable foundation for a future "shorts" vertical feed (filter on
height > width) and for the NIP-71 mirror's short-kind selection.
- js/utils/videoProbe.js: probeVideoMetadata(src) loads metadata into a hidden
<video> (element injectable for tests); resolves {width,height,duration} or null.
- UploadModal: probes the selected file (objectURL) in the background and threads
width/height/duration into the publish payload.
- videoPayloadBuilder: persists width/height/duration in content (publish) and
preserves them across edits; normalizers exported.
- convertEventToVideo: exposes video.width/height/duration → the mirror builder
already uses them (portrait ⇒ 34236 short + imeta dim).
tests: probe resolution (incl portrait + error/timeout → null); content persist +
parse; portrait⇒34236 / landscape⇒34235 mirror selection; edit preserves dims.
Mutation-verified earlier on the mirror short-selection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
589 lines
18 KiB
JavaScript
589 lines
18 KiB
JavaScript
import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js";
|
|
import { infoHashFromMagnet } from "../magnets.js";
|
|
import { resolveStoragePointerValue, getStoragePointerFromTags } from "../utils/storagePointer.js";
|
|
import { buildNip71MetadataTags, extractNip71MetadataFromTags } from "./nip71.js";
|
|
import { buildVideoPostEvent } from "../nostrEventSchemas.js";
|
|
import { computeSha256HexFromValue } from "../utils/cryptoUtils.js";
|
|
import { inferMimeTypeFromUrl } from "../utils/mime.js";
|
|
import { normalizeHashtag } from "../utils/hashtagNormalization.js";
|
|
import { devLogger } from "../utils/logger.js";
|
|
|
|
// Pixel dimensions: positive integer or 0 (= "unknown / omit").
|
|
export function normalizeVideoDimension(value) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
}
|
|
|
|
// Duration in seconds: positive (float ok) or 0 (= omit).
|
|
export function normalizeVideoDuration(value) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
}
|
|
|
|
// Normalize free-form hashtag input (array or comma/space string) to a deduped,
|
|
// lowercased list. Drops the reserved "video" topic tag and caps the count.
|
|
export function sanitizeVideoHashtags(input) {
|
|
const raw = Array.isArray(input)
|
|
? input
|
|
: typeof input === "string"
|
|
? input.split(/[\s,]+/)
|
|
: [];
|
|
const out = [];
|
|
const seen = new Set();
|
|
for (const item of raw) {
|
|
const tag = normalizeHashtag(item);
|
|
if (!tag || tag === "video" || seen.has(tag)) {
|
|
continue;
|
|
}
|
|
seen.add(tag);
|
|
out.push(tag);
|
|
if (out.length >= 20) {
|
|
break;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export 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 = {};
|
|
}
|
|
|
|
const normalizedVideoData = { ...videoData };
|
|
const isNsfw = videoData.isNsfw === true;
|
|
normalizedVideoData.isNsfw = isNsfw;
|
|
normalizedVideoData.isForKids =
|
|
videoData.isForKids === true && !isNsfw;
|
|
|
|
return { videoData: normalizedVideoData, nip71Metadata };
|
|
}
|
|
|
|
export async function prepareVideoPublishPayload(videoPayload, pubkey, { timestamp = Math.floor(Date.now() / 1000) } = {}) {
|
|
const normalizedPubkey = typeof pubkey === "string" ? pubkey.trim() : "";
|
|
if (!normalizedPubkey) {
|
|
throw new Error("Not logged in to publish video.");
|
|
}
|
|
|
|
const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload);
|
|
|
|
devLogger.log("Publishing new video with data:", videoData);
|
|
if (nip71Metadata) {
|
|
devLogger.log("Including NIP-71 metadata:", nip71Metadata);
|
|
}
|
|
|
|
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
|
|
const finalMagnet = rawMagnet.trim();
|
|
const finalUrl =
|
|
typeof videoData.url === "string" ? videoData.url.trim() : "";
|
|
const finalThumbnail =
|
|
typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : "";
|
|
const finalDescription =
|
|
typeof videoData.description === "string"
|
|
? videoData.description.trim()
|
|
: "";
|
|
const finalTitle =
|
|
typeof videoData.title === "string" ? videoData.title.trim() : "";
|
|
const providedMimeType =
|
|
typeof videoData.mimeType === "string"
|
|
? videoData.mimeType.trim().toLowerCase()
|
|
: "";
|
|
|
|
const createdAt = timestamp;
|
|
|
|
const seriesIdentifierCandidates = [
|
|
videoData.videoRootId,
|
|
videoData.seriesId,
|
|
videoData.seriesIdentifier,
|
|
];
|
|
let seriesIdentifier = "";
|
|
for (const candidate of seriesIdentifierCandidates) {
|
|
const normalized = typeof candidate === "string" ? candidate.trim() : "";
|
|
if (normalized) {
|
|
seriesIdentifier = normalized;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!seriesIdentifier) {
|
|
seriesIdentifier = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
const videoRootId = seriesIdentifier;
|
|
const dTagValue = seriesIdentifier;
|
|
|
|
const finalEnableComments =
|
|
videoData.enableComments === false ? false : true;
|
|
const finalIsNsfw = videoData.isNsfw === true;
|
|
const finalIsForKids =
|
|
videoData.isForKids === true && !finalIsNsfw;
|
|
const wantPrivate = videoData.isPrivate === true;
|
|
const finalWs =
|
|
typeof videoData.ws === "string" ? videoData.ws.trim() : "";
|
|
const finalXs =
|
|
typeof videoData.xs === "string" ? videoData.xs.trim() : "";
|
|
const infoHashCandidates = [videoData.infoHash];
|
|
let infoHash = "";
|
|
for (const candidate of infoHashCandidates) {
|
|
const normalized = infoHashFromMagnet(candidate);
|
|
if (normalized) {
|
|
infoHash = normalized;
|
|
break;
|
|
}
|
|
}
|
|
if (!infoHash && finalMagnet) {
|
|
infoHash = infoHashFromMagnet(finalMagnet) || "";
|
|
}
|
|
const fileSha256 = normalizeHexHash(videoData.fileSha256);
|
|
const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256);
|
|
const storagePointer = resolveStoragePointerValue({
|
|
storagePointer: videoData.storagePointer,
|
|
url: finalUrl,
|
|
infoHash,
|
|
fallbackId: videoRootId,
|
|
provider: videoData.storageProvider,
|
|
});
|
|
|
|
const contentObject = {
|
|
version: 3,
|
|
title: finalTitle,
|
|
url: finalUrl,
|
|
magnet: finalMagnet,
|
|
thumbnail: finalThumbnail,
|
|
description: finalDescription,
|
|
mode: videoData.mode || "live",
|
|
videoRootId,
|
|
deleted: false,
|
|
isPrivate: videoData.isPrivate ?? false,
|
|
isNsfw: finalIsNsfw,
|
|
isForKids: finalIsForKids,
|
|
enableComments: finalEnableComments,
|
|
};
|
|
|
|
if (infoHash) {
|
|
contentObject.infoHash = infoHash;
|
|
}
|
|
|
|
if (fileSha256) {
|
|
contentObject.fileSha256 = fileSha256;
|
|
}
|
|
|
|
if (originalFileSha256) {
|
|
contentObject.originalFileSha256 = originalFileSha256;
|
|
}
|
|
|
|
if (finalWs) {
|
|
contentObject.ws = finalWs;
|
|
}
|
|
|
|
if (finalXs) {
|
|
contentObject.xs = finalXs;
|
|
}
|
|
|
|
const hashtags = sanitizeVideoHashtags(videoData.hashtags);
|
|
if (hashtags.length) {
|
|
contentObject.hashtags = hashtags;
|
|
}
|
|
|
|
// Captured-at-upload dimensions/duration: persist so a video's orientation is
|
|
// known durably (drives 34236 short selection + a future "shorts" feed filter).
|
|
const width = normalizeVideoDimension(videoData.width);
|
|
const height = normalizeVideoDimension(videoData.height);
|
|
const duration = normalizeVideoDuration(videoData.duration);
|
|
if (width) {
|
|
contentObject.width = width;
|
|
}
|
|
if (height) {
|
|
contentObject.height = height;
|
|
}
|
|
if (duration) {
|
|
contentObject.duration = duration;
|
|
}
|
|
|
|
const nip71Tags = buildNip71MetadataTags(
|
|
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null
|
|
);
|
|
|
|
// Emit hashtags as `t` tags on the 30078 so bitvid's own feed scoring (and
|
|
// relay queries) pick them up, in addition to persisting them in content.
|
|
const hashtagTags = hashtags.map((tag) => ["t", tag]);
|
|
|
|
const additionalTags = [
|
|
...(storagePointer ? [["s", storagePointer]] : []),
|
|
...hashtagTags,
|
|
...nip71Tags,
|
|
];
|
|
|
|
const event = buildVideoPostEvent({
|
|
pubkey: normalizedPubkey,
|
|
created_at: createdAt,
|
|
dTagValue,
|
|
content: contentObject,
|
|
additionalTags,
|
|
});
|
|
|
|
return {
|
|
event,
|
|
videoData,
|
|
nip71Metadata,
|
|
finalUrl,
|
|
finalMagnet,
|
|
finalThumbnail,
|
|
finalDescription,
|
|
finalTitle,
|
|
mimeType: providedMimeType,
|
|
fileSha256,
|
|
originalFileSha256,
|
|
videoRootId,
|
|
dTagValue,
|
|
createdAt,
|
|
contentObject,
|
|
wantPrivate,
|
|
normalizedPubkey,
|
|
videoPayload
|
|
};
|
|
}
|
|
|
|
export async function prepareVideoMirrorOptions(params) {
|
|
const {
|
|
videoData,
|
|
videoPayload,
|
|
finalUrl,
|
|
finalMagnet,
|
|
finalThumbnail,
|
|
finalDescription,
|
|
finalTitle,
|
|
mimeType: providedMimeType,
|
|
fileSha256: initialFileSha256,
|
|
originalFileSha256: initialOriginalFileSha256,
|
|
pubkey,
|
|
createdAt,
|
|
isPrivate
|
|
} = params;
|
|
|
|
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
|
|
const mimeTypeSource =
|
|
providedMimeType ||
|
|
inferredMimeType ||
|
|
"application/octet-stream";
|
|
const mimeType = mimeTypeSource.toLowerCase();
|
|
|
|
const fileHashCandidates = [
|
|
initialFileSha256,
|
|
videoData.fileSha256,
|
|
videoData.uploadedFileSha256,
|
|
videoPayload?.legacyFormData?.fileSha256,
|
|
videoPayload?.fileSha256,
|
|
];
|
|
let fileSha256 = "";
|
|
for (const candidate of fileHashCandidates) {
|
|
const normalized = normalizeHexHash(candidate);
|
|
if (normalized) {
|
|
fileSha256 = normalized;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const originalHashCandidates = [
|
|
initialOriginalFileSha256,
|
|
videoData.originalFileSha256,
|
|
videoPayload?.legacyFormData?.originalFileSha256,
|
|
videoPayload?.originalFileSha256,
|
|
];
|
|
let originalFileSha256 = "";
|
|
for (const candidate of originalHashCandidates) {
|
|
const normalized = normalizeHexHash(candidate);
|
|
if (normalized) {
|
|
originalFileSha256 = normalized;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const uploadedFile =
|
|
videoData?.uploadedFile ||
|
|
videoData?.file ||
|
|
videoPayload?.legacyFormData?.uploadedFile ||
|
|
videoPayload?.legacyFormData?.file ||
|
|
videoPayload?.uploadedFile ||
|
|
videoPayload?.file ||
|
|
null;
|
|
|
|
const originalFile =
|
|
videoData?.originalFile ||
|
|
videoPayload?.legacyFormData?.originalFile ||
|
|
videoPayload?.originalFile ||
|
|
uploadedFile;
|
|
|
|
if (!fileSha256 && uploadedFile) {
|
|
fileSha256 = await computeSha256HexFromValue(uploadedFile);
|
|
}
|
|
|
|
if (!originalFileSha256 && originalFile) {
|
|
originalFileSha256 = await computeSha256HexFromValue(originalFile);
|
|
}
|
|
|
|
if (!originalFileSha256 && fileSha256) {
|
|
originalFileSha256 = fileSha256;
|
|
}
|
|
|
|
const mirrorOptions = {
|
|
url: finalUrl,
|
|
magnet: finalMagnet,
|
|
thumbnail: finalThumbnail,
|
|
description: finalDescription,
|
|
title: finalTitle,
|
|
mimeType,
|
|
isPrivate: isPrivate,
|
|
actorPubkey: pubkey,
|
|
created_at: createdAt,
|
|
};
|
|
|
|
if (fileSha256) {
|
|
mirrorOptions.fileSha256 = fileSha256;
|
|
}
|
|
|
|
if (originalFileSha256) {
|
|
mirrorOptions.originalFileSha256 = originalFileSha256;
|
|
}
|
|
|
|
return mirrorOptions;
|
|
}
|
|
|
|
export function prepareVideoEditPayload(params) {
|
|
const {
|
|
baseEvent,
|
|
originalEventStub,
|
|
updatedData,
|
|
userPubkey,
|
|
resolveEventDTag // Injected dependency to resolve D tag
|
|
} = params;
|
|
|
|
const userPubkeyLower = userPubkey.toLowerCase();
|
|
|
|
const nip71Metadata =
|
|
updatedData && typeof updatedData === "object" ? updatedData.nip71 : null;
|
|
|
|
const oldMagnet =
|
|
typeof baseEvent.rawMagnet === "string" && baseEvent.rawMagnet.trim()
|
|
? baseEvent.rawMagnet.trim()
|
|
: typeof baseEvent.magnet === "string"
|
|
? baseEvent.magnet.trim()
|
|
: "";
|
|
const oldUrl = baseEvent.url || "";
|
|
|
|
// Determine if the updated note should be private
|
|
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
|
const wantNsfw = updatedData.isNsfw ?? baseEvent.isNsfw ?? false;
|
|
const wantForKids = updatedData.isForKids ?? baseEvent.isForKids ?? false;
|
|
const finalIsNsfw = wantNsfw === true;
|
|
const finalIsForKids = finalIsNsfw ? false : wantForKids === true;
|
|
|
|
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
|
const magnetEdited = updatedData.magnetEdited === true;
|
|
const newMagnetValue =
|
|
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
|
|
const finalMagnet = magnetEdited ? newMagnetValue : oldMagnet;
|
|
|
|
const urlEdited = updatedData.urlEdited === true;
|
|
const newUrlValue =
|
|
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
|
|
const finalUrl = urlEdited ? newUrlValue : oldUrl;
|
|
|
|
const wsEdited = updatedData.wsEdited === true;
|
|
const xsEdited = updatedData.xsEdited === true;
|
|
const newWsValue =
|
|
typeof updatedData.ws === "string" ? updatedData.ws.trim() : "";
|
|
const newXsValue =
|
|
typeof updatedData.xs === "string" ? updatedData.xs.trim() : "";
|
|
const baseWs =
|
|
typeof baseEvent.ws === "string" ? baseEvent.ws.trim() : "";
|
|
const baseXs =
|
|
typeof baseEvent.xs === "string" ? baseEvent.xs.trim() : "";
|
|
const finalWs = wsEdited ? newWsValue : baseWs;
|
|
const finalXs = xsEdited ? newXsValue : baseXs;
|
|
const finalEnableComments =
|
|
typeof updatedData.enableComments === "boolean"
|
|
? updatedData.enableComments
|
|
: baseEvent.enableComments === false
|
|
? false
|
|
: true;
|
|
|
|
const updatedInfoHash = infoHashFromMagnet(updatedData?.infoHash || "");
|
|
let infoHash =
|
|
updatedInfoHash ||
|
|
infoHashFromMagnet(finalMagnet) ||
|
|
infoHashFromMagnet(baseEvent.infoHash) ||
|
|
"";
|
|
const fileSha256 =
|
|
normalizeHexHash(updatedData?.fileSha256) ||
|
|
normalizeHexHash(baseEvent.fileSha256);
|
|
const originalFileSha256 =
|
|
normalizeHexHash(updatedData?.originalFileSha256) ||
|
|
normalizeHexHash(baseEvent.originalFileSha256);
|
|
|
|
// Use the existing videoRootId (or fall back to the base event's ID)
|
|
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
|
const storagePointer = resolveStoragePointerValue({
|
|
storagePointer:
|
|
updatedData?.storagePointer ||
|
|
baseEvent.storagePointer ||
|
|
getStoragePointerFromTags(baseEvent.tags),
|
|
url: finalUrl,
|
|
infoHash,
|
|
fallbackId: oldRootId,
|
|
provider: updatedData?.storageProvider || baseEvent.storageProvider,
|
|
});
|
|
|
|
const preservedDTag = resolveEventDTag(baseEvent, originalEventStub);
|
|
const fallbackDTag =
|
|
(typeof baseEvent.id === "string" && baseEvent.id.trim()) ||
|
|
(typeof originalEventStub?.id === "string" && originalEventStub.id.trim()) ||
|
|
"";
|
|
const finalDTagValue =
|
|
(typeof preservedDTag === "string" && preservedDTag.trim()) || fallbackDTag;
|
|
|
|
if (!finalDTagValue) {
|
|
throw new Error("Unable to determine a stable d tag for this edit.");
|
|
}
|
|
|
|
// Build the updated content object
|
|
const contentObject = {
|
|
videoRootId: oldRootId,
|
|
version: updatedData.version ?? baseEvent.version ?? 2,
|
|
deleted: false,
|
|
isPrivate: wantPrivate,
|
|
isNsfw: finalIsNsfw,
|
|
isForKids: finalIsForKids,
|
|
title: updatedData.title ?? baseEvent.title,
|
|
url: finalUrl,
|
|
magnet: finalMagnet,
|
|
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
|
description: updatedData.description ?? baseEvent.description,
|
|
mode: updatedData.mode ?? baseEvent.mode ?? "live",
|
|
enableComments: finalEnableComments,
|
|
};
|
|
|
|
if (infoHash) {
|
|
contentObject.infoHash = infoHash;
|
|
}
|
|
|
|
if (fileSha256) {
|
|
contentObject.fileSha256 = fileSha256;
|
|
}
|
|
|
|
if (originalFileSha256) {
|
|
contentObject.originalFileSha256 = originalFileSha256;
|
|
}
|
|
|
|
if (finalWs) {
|
|
contentObject.ws = finalWs;
|
|
}
|
|
|
|
if (finalXs) {
|
|
contentObject.xs = finalXs;
|
|
}
|
|
|
|
// Prefer edited hashtags, else preserve the base event's so an edit that omits
|
|
// the field never strips them.
|
|
const hashtags = sanitizeVideoHashtags(
|
|
updatedData?.hashtags !== undefined ? updatedData.hashtags : baseEvent.hashtags,
|
|
);
|
|
if (hashtags.length) {
|
|
contentObject.hashtags = hashtags;
|
|
}
|
|
const hashtagTags = hashtags.map((tag) => ["t", tag]);
|
|
|
|
// Preserve captured dimensions/duration across edits (prefer edited values).
|
|
const width = normalizeVideoDimension(updatedData?.width ?? baseEvent.width);
|
|
const height = normalizeVideoDimension(updatedData?.height ?? baseEvent.height);
|
|
const duration = normalizeVideoDuration(updatedData?.duration ?? baseEvent.duration);
|
|
if (width) {
|
|
contentObject.width = width;
|
|
}
|
|
if (height) {
|
|
contentObject.height = height;
|
|
}
|
|
if (duration) {
|
|
contentObject.duration = duration;
|
|
}
|
|
|
|
let metadataForTags =
|
|
nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null;
|
|
if (!metadataForTags) {
|
|
if (baseEvent?.nip71 && typeof baseEvent.nip71 === "object") {
|
|
metadataForTags = baseEvent.nip71;
|
|
} else {
|
|
const extracted = extractNip71MetadataFromTags(baseEvent);
|
|
if (extracted?.metadata) {
|
|
metadataForTags = extracted.metadata;
|
|
}
|
|
}
|
|
}
|
|
|
|
const nip71Tags = buildNip71MetadataTags(metadataForTags);
|
|
|
|
const additionalTags = [
|
|
...(storagePointer ? [["s", storagePointer]] : []),
|
|
...hashtagTags,
|
|
...nip71Tags,
|
|
];
|
|
|
|
// A replaceable event only wins if its created_at is strictly greater than
|
|
// the version it replaces (NIP-01 keeps the highest created_at; ties go to
|
|
// the lowest id). A device clock that's behind, or a second edit within the
|
|
// same second, would otherwise produce an edit that relays silently keep
|
|
// BEHIND the original. Guarantee monotonicity against the base event.
|
|
const baseCreatedAt = Number.isFinite(baseEvent?.created_at)
|
|
? Math.floor(baseEvent.created_at)
|
|
: 0;
|
|
const createdAt = Math.max(Math.floor(Date.now() / 1000), baseCreatedAt + 1);
|
|
|
|
const event = buildVideoPostEvent({
|
|
pubkey: userPubkeyLower,
|
|
created_at: createdAt,
|
|
dTagValue: finalDTagValue,
|
|
content: contentObject,
|
|
additionalTags,
|
|
});
|
|
|
|
return {
|
|
event,
|
|
contentObject,
|
|
nip71Metadata,
|
|
wantPrivate,
|
|
videoRootId: oldRootId,
|
|
dTagValue: finalDTagValue,
|
|
normalizedPubkey: userPubkeyLower,
|
|
userPubkeyLower,
|
|
createdAt,
|
|
finalUrl,
|
|
finalMagnet,
|
|
finalThumbnail: contentObject.thumbnail,
|
|
finalDescription: contentObject.description,
|
|
finalTitle: contentObject.title,
|
|
mimeType: updatedData.mimeType || "",
|
|
fileSha256,
|
|
originalFileSha256,
|
|
videoData: updatedData,
|
|
videoPayload: updatedData,
|
|
};
|
|
}
|