aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js76
-rw-r--r--tools/feed-video-aspect-test.js117
2 files changed, 191 insertions, 2 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 856a4bd..a378f1d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4696,6 +4696,61 @@ export function messagesFeature(ctx) {
mediaWarming.set(url, task);
return task;
};
+ // A video's shape, so the feed paints its box at the clip's own aspect
+ // instead of a 16:9 frame with black bands either side of a portrait clip.
+ // The post's imeta `dim` says it for free (Amethyst, Primal and others
+ // publish it); otherwise the metadata alone is fetched — a few KB for a
+ // moov-first file — with a short cap, since it gates the row. Remembered
+ // on disk so a clip seen once is never probed again.
+ const VIDEO_RE = /\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i;
+ const VIDEO_PROBE_MS = 3000, VIDEO_DIMS_KEEP = 400, VIDEO_DIMS_KEY = 'coinos-video-dims';
+ const videoDims = new Map(); // url -> { width, height }
+ const videoUnknown = new Set(); // probed this session and no answer: paint 16:9, don't ask again
+ try { for (const [u, d] of JSON.parse(localStorage.getItem(VIDEO_DIMS_KEY) || '[]')) videoDims.set(u, d); } catch {}
+ let videoDimsFlush = null;
+ const rememberVideo = (url, width, height) => {
+ if (!(width > 0 && height > 0)) return;
+ const d = { width, height };
+ mediaReady.set(url, d);
+ if (videoDims.get(url)?.width === width && videoDims.get(url)?.height === height) return;
+ videoDims.delete(url); videoDims.set(url, d);
+ if (videoDimsFlush) return;
+ videoDimsFlush = setTimeout(() => {
+ videoDimsFlush = null;
+ try { localStorage.setItem(VIDEO_DIMS_KEY, JSON.stringify([...videoDims].slice(-VIDEO_DIMS_KEEP))); } catch {}
+ }, 1000);
+ };
+ const imetaDim = (ev, url) => {
+ for (const tag of ev?.tags || []) {
+ if (tag[0] !== 'imeta' || !tag.includes('url ' + url)) continue;
+ const m = /^dim (\d+)x(\d+)$/.exec(tag.find((x) => /^dim /.test(x)) || '');
+ if (m) return { width: +m[1], height: +m[2] };
+ }
+ return null;
+ };
+ const warmVideo = (url, dim) => {
+ if (!url || mediaReady.has(url)) return Promise.resolve();
+ const known = videoDims.get(url) || dim;
+ if (known) { rememberVideo(url, known.width, known.height); return Promise.resolve(); }
+ if (typeof document === 'undefined' || videoUnknown.has(url)) return Promise.resolve();
+ if (mediaWarming.has(url)) return mediaWarming.get(url);
+ const task = new Promise((resolve) => {
+ const v = document.createElement('video');
+ const done = () => {
+ clearTimeout(timer);
+ v.onloadedmetadata = v.onerror = null;
+ if (v.videoWidth && v.videoHeight) rememberVideo(url, v.videoWidth, v.videoHeight);
+ else videoUnknown.add(url);
+ v.removeAttribute('src'); try { v.load(); } catch {} // let go of the connection
+ resolve();
+ };
+ const timer = setTimeout(done, VIDEO_PROBE_MS);
+ v.onloadedmetadata = done; v.onerror = done;
+ v.muted = true; v.preload = 'metadata'; v.src = url;
+ }).finally(() => mediaWarming.delete(url));
+ mediaWarming.set(url, task);
+ return task;
+ };
// ---- link previews --------------------------------------------------
// A plain link in a post becomes the card its page describes (Open Graph:
// title, a line, a picture), fetched through the registrar because a
@@ -4778,6 +4833,15 @@ export function messagesFeature(ctx) {
}
return urls;
}
+ function noteVideoUrls(content) {
+ const urls = [];
+ for (const part of String(content || '').split(NOTE_SPLIT)) {
+ const md = part && MD_PARTS.exec(part);
+ const url = md ? md[3] : (/^https?:\/\//i.test(part || '') ? part : null);
+ if (url && !(md && md[1]) && VIDEO_RE.test(url)) urls.push(url);
+ }
+ return urls;
+ }
const avatarUrl = (p, pk) => p?.picture
? localPunk(p.picture) || (p.thumbFor === p.picture && p.thumb) || p.picture
: punkSmallUrl(pk);
@@ -4792,6 +4856,7 @@ export function messagesFeature(ctx) {
}
async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map(warmMedia),
+ ...noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
@@ -5323,11 +5388,18 @@ export function messagesFeature(ctx) {
// window unmounting the row and mounting it again, a quoted note loading.
// Pinning the box alone left every one of those a muted restart.
const unmutedClips = new Set();
+ // A feed box takes the clip's own shape when it's known, as wide as the
+ // column allows but never taller than three quarters of the screen.
+ const videoBoxStyle = (d) => d && d.width && d.height
+ ? `width:min(100%, calc(75vh * ${d.width} / ${d.height}));aspect-ratio:${d.width}/${d.height};max-height:none;object-fit:contain`
+ : 'width:100%;aspect-ratio:16/9;object-fit:contain';
function videoNode(url, { stable = false } = {}) {
const loud = unmutedClips.has(url);
const v = h('video', { src: url, class: 'note-video', controls: true,
preload: 'metadata', playsinline: true, muted: loud ? undefined : true, // play() in view fetches; a window of videos must not all stream
- style: stable ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined,
+ style: stable ? videoBoxStyle(feedMedia(url)) : undefined,
+ // learned now, used by the next paint of any row with this clip
+ onLoadedmetadata: (e) => rememberVideo(url, e.target.videoWidth, e.target.videoHeight),
onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
const box = h('div', { class: 'note-video-box' }, v);
@@ -5572,7 +5644,7 @@ export function messagesFeature(ctx) {
return x >= 0 && x <= w && y >= 0 && y <= hh;
}
function urlNode(url, { label = null, isImage = false } = {}) {
- if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
+ if (VIDEO_RE.test(url)) {
return videoNode(url, { stable: !!feedPaint });
}
if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) {
diff --git a/tools/feed-video-aspect-test.js b/tools/feed-video-aspect-test.js
new file mode 100644
index 0000000..214b977
--- /dev/null
+++ b/tools/feed-video-aspect-test.js
@@ -0,0 +1,117 @@
+// A video in the feed paints at its own shape, not a 16:9 frame with black
+// bands either side: from the post's imeta `dim` when it says, else from the
+// clip's metadata fetched before the row is admitted. A clip with no known
+// shape still gets the 16:9 box.
+// Run: bun tools/feed-video-aspect-test.js (CHROME=<path> to pick a browser)
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { mkdtempSync } from 'fs';
+import { tmpdir } from 'os';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const dir = mkdtempSync(tmpdir() + '/vid-');
+const clip = (name, size) => {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `testsrc=size=${size}:rate=10`, '-t', '1',
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${name}`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+};
+clip('tall.mp4', '360x480'); clip('wide.mp4', '640x360'); clip('told.mp4', '360x640');
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ // this one's metadata arrives too late for a probe: only the imeta dim can shape it
+ if (path === '/v/told.mp4') await sleep(6000);
+ if (path.startsWith('/v/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'video/mp4' } });
+ if (path === '/broken.mp4') return new Response('nope', { status: 404 });
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const base = server.url.origin;
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
+const notes = [
+ post(0, 'tall clip ' + base + '/v/tall.mp4'),
+ post(1, 'wide clip ' + base + '/v/wide.mp4'),
+ // imeta says 9:16; the file is too, but the probe must not be needed
+ post(2, 'told clip ' + base + '/v/told.mp4', [['imeta', 'url ' + base + '/v/told.mp4', 'm video/mp4', 'dim 1080x1920']]),
+ post(3, 'broken clip ' + base + '/broken.mp4'),
+];
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+const videoReqs = [];
+page.on('request', (r) => { if (/\/v\/told\.mp4/.test(r.url())) videoReqs.push(Date.now()); });
+await page.evaluateOnNewDocument(() => {
+ // no autoplay: a clip that played keeps its first box, and this test is
+ // about the box the settled feed row paints
+ HTMLMediaElement.prototype.play = function () { return Promise.reject(new Error('no autoplay in this test')); };
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+// each clip's box, as painted in the feed (before any metadata of its own)
+const boxes = () => page.evaluate(() => Object.fromEntries([...document.querySelectorAll('.notes-feed video.note-video')].map((v) => {
+ const r = v.getBoundingClientRect();
+ return [(v.getAttribute('src') || v.getAttribute('data-lazy-src') || '').split('/').pop(), { w: Math.round(r.width), h: Math.round(r.height), style: v.getAttribute('style') }];
+})));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(base, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const key = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clipper', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed', 'following');
+ }, [key, PK, notes]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ videoReqs.length = 0;
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await page.waitForSelector('.notes-feed:not([data-booting]) video.note-video', { timeout: 20000 });
+ const b = await boxes();
+ const ratio = (x) => x && x.h ? +(x.w / x.h).toFixed(2) : 0;
+ check('a 3:4 clip, measured ahead, paints at 3:4 full width', ratio(b['tall.mp4']) === 0.75 && b['tall.mp4'].w > 300 && /aspect-ratio:360\/480/.test(b['tall.mp4'].style || ''), JSON.stringify(b['tall.mp4']));
+ check('a 16:9 clip stays 16:9', Math.abs(ratio(b['wide.mp4']) - 1.78) < 0.02 && /aspect-ratio:640\/360/.test(b['wide.mp4'].style || ''), JSON.stringify(b['wide.mp4']));
+ check('a clip whose post gives imeta dim paints at that shape, without waiting on the file', Math.abs(ratio(b['told.mp4']) - 0.56) < 0.02 && /aspect-ratio:1080\/1920/.test(b['told.mp4'].style || ''), JSON.stringify(b['told.mp4']));
+ check('...capped at three quarters of the screen height', b['told.mp4'] && b['told.mp4'].h <= Math.ceil(844 * 0.75) + 1, JSON.stringify(b['told.mp4']));
+ check('a clip that will not load keeps the 16:9 box', /aspect-ratio:16\/9/.test(b['broken.mp4']?.style || ''), JSON.stringify(b['broken.mp4']));
+ await sleep(1500);
+ check('shapes are remembered on the device', await page.evaluate(() => (JSON.parse(localStorage.getItem('coinos-video-dims') || '[]')).length >= 3));
+ check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300));
+} catch (e) {
+ console.log('✗ threw', e.stack); ok = false;
+} finally {
+ await browser.close(); server.stop(true);
+}
+console.log(ok ? '\n✅ feed videos paint at their own shape' : '\n❌ failed');
+process.exit(ok ? 0 : 1);