mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
382da3085b
commit
115521928c
6 files changed
+318
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user