aboutsummaryrefslogtreecommitdiffstats
path: root/tools
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 /tools
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
Diffstat (limited to 'tools')
-rw-r--r--tools/feed-video-aspect-test.js117
1 files changed, 117 insertions, 0 deletions
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);