feat(video): capture & persist dimensions/duration at upload (enables shorts)

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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-22 11:44:02 -04:00
1 parent 382da3085b
commit 115521928c
6 files changed
+318

No files matched your search

+12
View File
@@ -1520,6 +1520,15 @@ export function convertEventToVideo(event = {}) {
.filter((value) => typeof value === "string" && value.trim())
.map((value) => value.trim())
: [];
const toPositiveInt = (value) => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
};
const width = toPositiveInt(parsedContent.width);
const height = toPositiveInt(parsedContent.height);
const durationValue = Number(parsedContent.duration);
const duration =
Number.isFinite(durationValue) && durationValue > 0 ? durationValue : undefined;
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
const wsField = safeTrim(parsedContent.ws);
const xsField = safeTrim(parsedContent.xs);
@@ -1624,6 +1633,9 @@ export function convertEventToVideo(event = {}) {
mode,
deleted,
hashtags,
width,
height,
duration,
ws,
xs,
storagePointer,
+41
View File
@@ -8,6 +8,18 @@ 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) {
@@ -191,6 +203,21 @@ export async function prepareVideoPublishPayload(videoPayload, pubkey, { timesta
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
);
@@ -484,6 +511,20 @@ export function prepareVideoEditPayload(params) {
}
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) {
+32
View File
@@ -1,6 +1,7 @@
// components/UploadModal.js
import { createModalAccessibility } from "./modalAccessibility.js";
import { Nip71FormManager } from "./nip71FormManager.js";
import { probeVideoMetadata } from "../../utils/videoProbe.js";
import { MediaUploader } from "./mediaUploader.js";
import { devLogger, userLogger } from "../../utils/logger.js";
import {
@@ -483,10 +484,35 @@ export class UploadModal {
// --- Immediate Upload Handlers ---
// Best-effort: read the file's dimensions/duration so we can persist them
// (orientation → 34236 short selection + a future "shorts" feed). Never blocks
// upload; failures just leave the metadata unset.
async captureVideoMetadata(file) {
this.capturedMetadata = null;
if (!file || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
return;
}
const objectUrl = URL.createObjectURL(file);
try {
this.capturedMetadata = await probeVideoMetadata(objectUrl);
} catch (error) {
this.capturedMetadata = null;
} finally {
try {
URL.revokeObjectURL(objectUrl);
} catch (revokeError) {
// ignore
}
}
}
async handleVideoSelection(e) {
const file = e.target.files?.[0];
if (!file) return;
// Probe dimensions/duration in the background while the upload proceeds.
void this.captureVideoMetadata(file);
if (!this.storageConfigured) {
alert("Please configure storage before selecting a file.");
e.target.value = ""; // Clear selection
@@ -900,6 +926,12 @@ export class UploadModal {
enableComments: this.toggles.comments?.checked || true,
...audienceFlags,
// Auto-captured at file-select (best-effort; absent ⇒ omitted). Drives
// 34236 short selection + a future "shorts" feed (height > width).
width: this.capturedMetadata?.width || 0,
height: this.capturedMetadata?.height || 0,
duration: this.capturedMetadata?.duration || 0,
// These might be empty depending on mode, filled below
url: "",
magnet: "",
+80
View File
@@ -0,0 +1,80 @@
// Auto-capture a video's dimensions + duration by loading just its metadata into
// a hidden <video> element. Used at upload time so bitvid can persist width/height
// (orientation → 34236 short selection + a future "shorts" feed) and duration.
//
// Element creation is injectable so the resolution logic is unit-testable without
// a DOM or a real media file. Always resolves (never rejects): null on failure.
export function probeVideoMetadata(src, { createVideoEl, timeoutMs = 8000 } = {}) {
return new Promise((resolve) => {
const source = typeof src === "string" ? src.trim() : "";
if (!source) {
resolve(null);
return;
}
let el = null;
try {
el =
typeof createVideoEl === "function"
? createVideoEl()
: typeof document !== "undefined" && document.createElement
? document.createElement("video")
: null;
} catch (error) {
el = null;
}
if (!el || typeof el.addEventListener !== "function") {
resolve(null);
return;
}
let settled = false;
const cleanup = () => {
try {
el.removeAttribute?.("src");
el.load?.();
} catch (error) {
// ignore
}
};
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
cleanup();
resolve(result);
};
const timer = setTimeout(() => finish(null), timeoutMs);
try {
el.preload = "metadata";
el.muted = true;
} catch (error) {
// ignore (fakes may not allow these)
}
el.addEventListener("loadedmetadata", () => {
const width = Number(el.videoWidth);
const height = Number(el.videoHeight);
const durationRaw = Number(el.duration);
finish({
width: Number.isFinite(width) && width > 0 ? Math.floor(width) : 0,
height: Number.isFinite(height) && height > 0 ? Math.floor(height) : 0,
duration: Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : 0,
});
});
el.addEventListener("error", () => finish(null));
try {
el.src = source;
} catch (error) {
finish(null);
}
});
}
export default probeVideoMetadata;
+94
View File
@@ -0,0 +1,94 @@
// Dimension capture: width/height/duration are persisted in the 30078 content,
// survive edits, parse back onto the video, and drive the NIP-71 mirror's
// normal(34235)-vs-short(34236) selection + imeta dim. (Foundation for a future
// "shorts" vertical feed, which filters on height > width.)
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import {
prepareVideoPublishPayload,
prepareVideoEditPayload,
normalizeVideoDimension,
} from "../js/nostr/videoPayloadBuilder.js";
import { convertEventToVideo } from "../js/nostr/nip71.js";
import {
buildNip71MirrorEvent,
NIP71_NORMAL_VIDEO_KIND,
NIP71_SHORT_VIDEO_KIND,
} from "../js/nostr/nip71Mirror.js";
const PUBKEY = "a".repeat(64);
function publish(extra) {
return prepareVideoPublishPayload(
{ title: "Hi", url: "https://cdn.example.com/v.mp4", ...extra },
PUBKEY,
{ timestamp: 1000 },
);
}
test("normalizeVideoDimension: positive int or 0", () => {
assert.equal(normalizeVideoDimension(1920), 1920);
assert.equal(normalizeVideoDimension("1080"), 1080);
assert.equal(normalizeVideoDimension(0), 0);
assert.equal(normalizeVideoDimension(-5), 0);
assert.equal(normalizeVideoDimension("abc"), 0);
});
test("publish persists width/height/duration in content; parser exposes them", async () => {
const { event } = await publish({
videoRootId: "root-1",
width: 1080,
height: 1920,
duration: 42.5,
});
const content = JSON.parse(event.content);
assert.equal(content.width, 1080);
assert.equal(content.height, 1920);
assert.equal(content.duration, 42.5);
const video = convertEventToVideo(event);
assert.equal(video.width, 1080);
assert.equal(video.height, 1920);
assert.equal(video.duration, 42.5);
});
test("a portrait (height>width) video mirrors as 34236 short with imeta dim", async () => {
const { event } = await publish({ videoRootId: "root-2", width: 1080, height: 1920 });
const video = convertEventToVideo(event);
const res = buildNip71MirrorEvent({ ...video, pubkey: PUBKEY });
assert.equal(res.event.kind, NIP71_SHORT_VIDEO_KIND, "portrait => 34236");
const imeta = res.event.tags.find((t) => t[0] === "imeta");
assert.ok(imeta.some((e) => e === "dim 1080x1920"), "carries imeta dim");
});
test("a landscape video mirrors as 34235 normal", async () => {
const { event } = await publish({ videoRootId: "root-3", width: 1920, height: 1080 });
const video = convertEventToVideo(event);
const res = buildNip71MirrorEvent({ ...video, pubkey: PUBKEY });
assert.equal(res.event.kind, NIP71_NORMAL_VIDEO_KIND, "landscape => 34235");
});
test("editing preserves dimensions when the field is omitted", () => {
const { contentObject } = prepareVideoEditPayload({
baseEvent: {
id: "e1",
videoRootId: "root-9",
title: "Old",
url: "https://cdn.example.com/v.mp4",
width: 1280,
height: 720,
duration: 30,
created_at: 1000,
tags: [],
},
originalEventStub: { id: "e1" },
updatedData: { title: "New" },
userPubkey: PUBKEY,
resolveEventDTag: () => "root-9",
});
assert.equal(contentObject.width, 1280);
assert.equal(contentObject.height, 720);
assert.equal(contentObject.duration, 30);
});
+59
View File
@@ -0,0 +1,59 @@
// probeVideoMetadata resolves a video's dimensions/duration from a hidden element,
// or null on error/timeout. Uses an injected fake element so it's deterministic.
import assert from "node:assert/strict";
import test from "node:test";
import { probeVideoMetadata } from "../js/utils/videoProbe.js";
// Minimal fake <video>: fires the named event on the next tick when src is set.
function fakeVideo({ fire = "loadedmetadata", videoWidth = 0, videoHeight = 0, duration = 0 } = {}) {
const handlers = {};
return {
videoWidth,
videoHeight,
duration,
addEventListener(name, h) {
handlers[name] = h;
},
removeAttribute() {},
load() {},
set src(_v) {
setTimeout(() => handlers[fire]?.(), 0);
},
};
}
test("resolves width/height/duration from loadedmetadata", async () => {
const result = await probeVideoMetadata("blob:abc", {
createVideoEl: () => fakeVideo({ videoWidth: 1080, videoHeight: 1920, duration: 12.3 }),
});
assert.deepEqual(result, { width: 1080, height: 1920, duration: 12.3 });
});
test("a portrait probe yields height > width (drives short selection)", async () => {
const r = await probeVideoMetadata("blob:abc", {
createVideoEl: () => fakeVideo({ videoWidth: 720, videoHeight: 1280 }),
});
assert.ok(r.height > r.width);
assert.equal(r.duration, 0, "missing duration normalizes to 0");
});
test("resolves null on a media error", async () => {
const r = await probeVideoMetadata("blob:bad", {
createVideoEl: () => fakeVideo({ fire: "error" }),
});
assert.equal(r, null);
});
test("resolves null for an empty source and times out gracefully", async () => {
assert.equal(await probeVideoMetadata("", { createVideoEl: () => fakeVideo() }), null);
// never fires any event -> timeout path
const neverFires = {
addEventListener() {},
removeAttribute() {},
load() {},
set src(_v) {},
};
const r = await probeVideoMetadata("blob:x", { createVideoEl: () => neverFires, timeoutMs: 20 });
assert.equal(r, null);
});