aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js75
-rw-r--r--tools/pager-no-poster-test.js33
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);