From ebaec4226a49628ea0ce6e08e4b41480bb9327a7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 22:45:08 -0700 Subject: The pager glides onto the next clip's own first frame, and onto pictures already decoded — no black slide between. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clip ahead holds its own first frame: full size, frame 0, drawn from the opening of the file (the bytes the head start put in the cache, read again by range — never more of the file than the head start fetched), so a flick to it glides onto the very picture the player shows first, and the player's frame replaces it with no visible change. A dozen are kept, the oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the same decode when the tile has none yet. Only the next clip, not the ones beyond: their head start is half, and a frame from them would cost bytes. On a connection to be careful with the thumbnail poster stays, as before. Pictures: the slides near the reader are attached from two and a half screens out (was one and a half) and decoded as they are attached, so a flight lands on a picture rather than on a blank that fills in. (Adam, 2026-10-16: "could we actually have the next image or video scroll in while the previous scrolls out? Right now it goes black before the next one scrolls in.") pager-no-poster-test: the next clip holds a blob poster at the clip's full size and shape while the one after holds nothing; after a flick the next plays with its frame under it and the one after now holds its own; Data Saver keeps the thumbnails. pager-eager-test (the byte budget ahead of time) still passes; so do the picture pager and sharpen tests. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 75 +++++++++++++++++++++++++++++++++++++++++-- tools/pager-no-poster-test.js | 33 +++++++++++++------ 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/features/messages.js b/src/features/messages.js index 2bc034f..44450ea 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12065,6 +12065,68 @@ export function messagesFeature(ctx) { pumpFrames(); return task; } + // ---- the pager's first-frame posters ------------------------------------- + // The clip ahead holds its own first frame — full size, frame 0, drawn + // from the opening of the file (the bytes headStart put in the cache, + // read again by range) — so a flick to it glides onto the very picture + // the player will show first, and the player's frame replaces it with no + // visible change. (Adam, 2026-10-16: "could we actually have the next + // image or video scroll in while the previous scrolls out? Right now it + // goes black before the next one scrolls in.") A dozen are kept, the + // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the + // same decode when the tile has none yet. + const PAGER_POSTERS = 12, POSTER_W = 1100; + const pagerPosters = new Map(); // url -> object URL, oldest first + const posterGrabs = new Map(); // url -> promise + function pagerPoster(url) { + if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); } + if (posterGrabs.has(url)) return posterGrabs.get(url); + const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url)); + posterGrabs.set(url, task); + return task; + } + async function grabPoster(url) { + const d = videoInfo.get(url) || {}; + if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null; + // no more of the file than the head start fetched: the bytes come from the cache + const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1; + let r; + try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); } + catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket) + if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; } + const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' }); + const src = URL.createObjectURL(blob); + const v = document.createElement('video'); + v.muted = true; v.playsInline = true; v.preload = 'auto'; + try { + const shown = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(v.readyState >= 2), 6000); + v.onloadeddata = () => { clearTimeout(timer); resolve(true); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = src; + }); + if (!shown || !v.videoWidth) return null; + const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = hh; + cv.getContext('2d').drawImage(v, 0, 0, w, hh); + const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9)); + if (!out) return null; + const p = URL.createObjectURL(out); + pagerPosters.set(url, p); + while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); } + // the tile's frame too, from the same decode, when it has none + if (!videoThumbs.has(url)) { + await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } }); + await keepVideoThumb(v, url).catch(() => {}); + } + return p; + } finally { + v.onloadeddata = v.onseeked = v.onerror = null; + try { v.removeAttribute('src'); v.load(); } catch {} + URL.revokeObjectURL(src); + } + } function pumpFrames() { while (frameGrabbing < 2 && frameQueue.length) { const { url, resolve } = frameQueue.shift(); @@ -12761,7 +12823,10 @@ export function messagesFeature(ctx) { const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + const src = playSrc(v.getAttribute('data-src')); + headStart(src, k === 0 ? 1 : 0.5); + // the next clip holds its first frame: the flick glides onto it + if (k === 0 && !v.getAttribute('poster')) pagerPoster(src).then((p) => { if (p && pager && s.isConnected && !v.getAttribute('poster')) v.setAttribute('poster', p); }); return; } if (k === 0) { @@ -13237,9 +13302,13 @@ export function messagesFeature(ctx) { // pictures are cheap — a post's all at once, the ones along its row // too (out of sight sideways, they'd load only on arrival); clips // are pagerNeighbours' call - if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); + if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) { + pagerAttach(x); + const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank + if (im && im.decode) im.decode().catch(() => {}); + } } - }, { root: scroller, rootMargin: '150% 0px' }); + }, { root: scroller, rootMargin: '250% 0px' }); // a slide has come to rest on screen (or a flight has landed on it) pager.arrive = (slide) => { if (!pager) return; diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js index 4153bba..0930834 100644 --- a/tools/pager-no-poster-test.js +++ b/tools/pager-no-poster-test.js @@ -1,9 +1,11 @@ -// A clip that plays by itself in the video pager has no poster: every -// poster there is comes 480px wide and from some moment of the clip, so it -// showed soft for a beat and the clip's first frame replaced it — a flash -// on every flick. The slide is the pager's black until the player has its -// first frame. On a connection to be careful with (Data Saver), where a -// clip may never load, the poster stays. +// A clip that plays by itself in the video pager has no thumbnail poster: +// every poster there was came 480px wide and from some moment of the +// clip, so it showed soft for a beat and the clip's first frame replaced +// it — a flash on every flick. What the clip ahead holds instead is its +// own first frame, full size, drawn from the opening of the file: the +// flick glides onto the very picture the player shows first. On a +// connection to be careful with (Data Saver), where a clip may never load, +// the thumbnail poster stays. // Run: bun tools/pager-no-poster-test.js import puppeteer from './puppeteer.js'; import { cacheKeyFor } from '../src/wallet.js'; @@ -26,7 +28,13 @@ const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 0, fetch: (req) => { const path = new URL(req.url).pathname; const m = /^\/(wide|tall|last)\.webm$/.exec(path); - if (m) return new Response(Bun.file(CLIPS[m[1]]), { headers: { 'content-type': 'video/webm' } }); + if (m) { + // a host that serves ranges, as the real ones do + const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || ''); + if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } }); + const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1; + return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } }); + } if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } }); if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); return new Response(html, { headers: { 'content-type': 'text/html' } }); @@ -94,10 +102,15 @@ try { let s = await open(); check('the clip on screen plays', s.some((x) => x.here && x.playing), JSON.stringify(s)); check('…with no poster on it', s.filter((x) => x.here).every((x) => !x.poster), JSON.stringify(s.filter((x) => x.here))); - check('…nor on the clips ahead', s.filter((x) => !x.here).every((x) => !x.poster), JSON.stringify(s.filter((x) => !x.here))); + check('…no thumbnail on the clips ahead either', s.filter((x) => !x.here).every((x) => !/still\.png/.test(x.poster || '')), JSON.stringify(s.filter((x) => !x.here))); + check('the next clip holds its own first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000), JSON.stringify(await slides())); + const frame = await page.evaluate(() => new Promise((r) => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')).querySelector('video.mp-media'); const im = new Image(); im.onload = () => r({ w: im.naturalWidth, h: im.naturalHeight }); im.onerror = () => r(null); im.src = v.getAttribute('poster'); })); + check('…full size, the clip\'s own shape', !!frame && frame.w === 180 && frame.h === 320, JSON.stringify(frame)); + check('…and the clip after it, not yet near, holds nothing', s.some((x) => x.clip === 'last.webm' && !x.poster), JSON.stringify(s)); await page.keyboard.press('ArrowDown'); await sleep(900); s = await slides(); - check('the next clip plays, no poster either', s.some((x) => x.here && x.playing && !x.poster && x.clip === 'tall.webm'), JSON.stringify(s)); + check('the next clip plays, its first frame under it', s.some((x) => x.here && x.playing && /^blob:/.test(x.poster || '') && x.clip === 'tall.webm'), JSON.stringify(s)); + check('…and the one after now holds its first frame', await waitFor(() => { const v = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/last.webm'))?.querySelector('video.mp-media'); return !!v && /^blob:/.test(v.getAttribute('poster') || ''); }, null, 8000)); await page.keyboard.press('Escape'); await sleep(500); console.log('\n[a careful connection: the poster stays]'); @@ -106,5 +119,5 @@ try { check('the slides carry their posters', s.length >= 3 && s.every((x) => /still\.png$/.test(x.poster || '')), JSON.stringify(s)); check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); } catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } -console.log(ok ? '\n✅ an autoplaying clip starts on its own first frame, no poster' : '\n❌ failed'); +console.log(ok ? '\n✅ an autoplaying clip starts on its own first frame, the one ahead holds it already' : '\n❌ failed'); process.exit(ok ? 0 : 1); -- cgit v1.2.3