aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-01 01:36:29 +0000
committerAdam Soltys <[email protected]>2026-10-01 01:36:29 +0000
commit7c9aaa3d7ddd743cb28767e5187f7ff71bd1aad4 (patch)
tree13a0ec65a435c5082edaa84224b2ca68fc5ca6ec
parentbe6e447c8960e0b74308137efa10b45d0763b922 (diff)
downloadcoinosv3-7c9aaa3d7ddd743cb28767e5187f7ff71bd1aad4.tar.gz
coinosv3-7c9aaa3d7ddd743cb28767e5187f7ff71bd1aad4.zip
A video in the feed paints at its own shape, not a 16:9 frame with black bands beside a portrait clip.
The shape comes from the post's imeta dim when it gives one (Amethyst, Primal and others publish it), else from the clip's metadata fetched while the row warms (3 s cap), and is remembered on the device. The box is as wide as the column, never taller than 75vh; an unknown shape keeps 16:9. tools/feed-video-aspect-test.js: 3:4 and 16:9 clips measured ahead, a 9:16 one shaped by imeta alone (its file served too late to probe), a broken one. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WMMwWYJkPQxLgfMsY7hWcU
-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);