diff options
| -rw-r--r-- | src/features/messages.js | 76 | ||||
| -rw-r--r-- | tools/feed-video-aspect-test.js | 117 |
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); |
