#!/usr/bin/env node
// Bake a looping preview video + poster for each plugin's example.html.
//
// The Home "Community" gallery renders every plugin as a live, scaled
// `example.html` iframe that auto-pans on hover. That is GPU-expensive at
// scale (each tile is its own out-of-process document re-compositing a tall
// page). This script pre-renders the SAME hover-pan as a tiny VP9 .webm plus a
// first-frame .jpg poster, so the gallery can show a cheap `
` poster idle
// and play the clip on hover (see MediaSurface) instead of mounting a live
// iframe per tile.
//
// Pipeline (validated end-to-end before productionising):
// 1. Headless Chrome loads the *served* preview URL (so the daemon's
// asset-cache rewriting + CSP apply, exactly as the gallery sees it).
// 2. Pre-scroll the whole page to trigger lazy-loaded / cross-border-CDN
// images, then wait for them — otherwise the pan reaches a section before
// its images load and the frame shows empty boxes.
// 3. CDP screencast captures frames in REAL TIME (so animation plays at true
// speed) while a constant-velocity (linear) scroll pans top -> bottom.
// 4. ffmpeg encodes the VFR frames to a 60fps VP9 .webm + a poster .jpg.
//
// Runtime deps are provided by the environment, NOT the repo: `puppeteer-core`
// (the CI step `npm i puppeteer-core` ephemerally, or runs inside the
// ghcr.io/puppeteer/puppeteer image), a Chrome/Chromium binary (CHROME env, or
// auto-detected), and `ffmpeg` on PATH. We deliberately keep them out of
// package.json so the daemon/web bundles never pull in a headless browser.
//
// Run against a running daemon/web (the preview endpoint):
// CHROME=/path/to/chrome BASE_URL=http://127.0.0.1:17579 \
// node scripts/bake-plugin-previews.mjs --out .tmp/plugin-previews [--id ...] [--limit N]
//
// Output: /.mp4, /.poster.jpg, and /manifest.json
// ({ generatedAt, previews: { : { video, poster, durationMs, holdMs } } }).
// Uploading to R2 + committing the manifest is the CI step's job; this
// script only renders + encodes so it stays runnable locally and in CI alike.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdirSync, rmSync, writeFileSync, readFileSync, statSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// Bump when the bake recipe changes (capture geometry, timing, encoder, waits…)
// so every plugin re-bakes even though its page content is byte-identical.
// v2: deck mode (slide-walk for fixed-viewport PPT/slideshow pages) +
// force-load webfonts before capture (CJK templates were baking tofu).
// v3: bound the slide-walk — cap deckSignal's DOM scan + a wall-time cap — so a
// deck that renders every slide in one giant rail (#deck{width:10000vw})
// no longer drags the walk, and the clip, out to 20s+ in CI.
// v4: honor an `od.preview.motion` ('scroll'|'deck'|'static') declaration;
// auto-detect deck-vs-scroll by viewport height (not by probing, which
// misread tall pages with a horizontal marquee as decks); pan via REAL
// wheel events so scroll-hijack landing pages (custom/transform scroll)
// actually move.
// v5: validate the encoded output — reject blank (never-rendered) clips,
// record durationMs from the FILE (not the intended walk time), and
// clamp holdMs to the encoded duration so the gallery's idle loop never
// points past the end of the clip; encode static pages (screencast
// delivers <5 frames because nothing repaints) as a held still instead
// of skipping them forever. Bumped so every entry's metadata is
// corrected in one sweep (23 entries carried holdMs > real duration,
// 5 static-page entries could never refresh at all).
// v6: capture decks in their native 16:9 frame so the gallery does not bake
// the outer black canvas into posters/clips; a plugin declared with
// `od.mode: deck` now takes the slide-walk path by default instead of
// being misclassified as a vertically scrolling page.
// v7: return a deck to its FIRST slide before capture. Probing the advancing
// input moves the deck on, and a deck that persists its position (`#/2`
// in the URL hash, or web storage) reopened there after the reload — so
// 24 of the official decks baked their agenda page as the poster instead
// of the cover (OPEND-2702). Bumped so every deck re-bakes.
const BAKE_VERSION = 7;
// ---- config ---------------------------------------------------------------
const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:17579';
const RENDER_W = 1440; // pages lay out at their desktop width
const VIEW_H = 1099; // 1.31-aspect window showing the FULL width (no clip)
// Decks are authored and displayed at 16:9. Capture that frame directly rather
// than the old 1.31 page viewport, which recorded the stage's outer black canvas
// above/beside the slide. The wider viewport still avoids compact breakpoints.
const DECK_W = 1760;
const DECK_H = 990; // 1760/990 = 16:9, the slide/card aspect
const DECK_CAPTURE_SELECTOR =
'.slide, [data-screen-label], .deck-slide, .ppt-slide, .slide-frame';
const SLIDE_MS = 1150; // deck per-slide dwell: ~.9s CSS transition + settle
const MAX_SLIDES = 6; // advance budget; HOLD + slides stays ~<=9s
const MAX_WALK_MS = 8000; // hard wall-time cap on the walk: even when signal
// reads are slow (huge-DOM decks), the clip stays
// bounded (HOLD + walk ~<=10.5s)
const DECK_SCAN_CAP = 600; // deckSignal scans only the first N elements — the
// slide rail/track is a structural element near the
// DOM top, so this stays cheap on decks that render
// every slide in one giant rail
const OUT_W = Number(process.env.PREVIEW_W || 640); // small — tile renders ~393px
const FPS = Number(process.env.PREVIEW_FPS || 30); // 30 is smooth for a gentle pan, half the bytes of 60
const VELOCITY = 0.30; // px/ms — base pan pace (snappy but readable)
const MAX_PAN = 7500; // hard cap on the pan; tall pages get auto-sped-up
// beyond VELOCITY so the pan always finishes within
// it, keeping the whole clip (HOLD + pan) <= ~10s.
const HOLD_MS = 2500; // dwell at the top first, capturing in-place
// animation — the gallery loops THIS span while
// idle, then plays on past it (the pan) on hover.
const CRF = Number(process.env.PREVIEW_CRF || 28); // H.264 CRF — higher = smaller; 28 stays readable at tile size
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
function argList(name) {
const out = [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === `--${name}` && process.argv[i + 1]) out.push(process.argv[i + 1]);
}
return out;
}
const OUT = path.resolve(arg('out', '.tmp/plugin-previews'));
const LIMIT = Number(arg('limit', '0')) || 0;
const ONLY = argList('id');
// Strict mode (--strict or PREVIEW_STRICT=1): exit non-zero when any bake left
// broken metadata behind — a stale entry (source changed but the re-bake
// failed, so the manifest keeps serving the OLD clip), a blank clip, or an
// unexpected error (thrown exception, e.g. ffprobe unavailable). The
// pre-merge validation workflow runs strict so a PR that breaks its own
// preview fails visibly; the post-merge/nightly path stays lenient and only
// reports, so one broken plugin never blocks publishing the rest.
const STRICT = process.argv.includes('--strict') || process.env.PREVIEW_STRICT === '1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// How many plugins to render concurrently. The per-clip cost is dominated by
// wall-clock waits (HOLD dwell + slide walk / pan + font/image settle) that
// yield to the event loop, so overlapping N renders scales the sweep near-
// linearly until the runner's cores saturate. Default 4 matches the standard
// GitHub-hosted ubuntu runner (4 vCPU / 16GB); dial down via PREVIEW_CONCURRENCY
// if capture quality degrades under contention, or up on a bigger runner.
const CONCURRENCY = Math.max(1,
Number(process.env.PREVIEW_CONCURRENCY || arg('concurrency', '')) || 4);
// ffmpeg/ffprobe as promises, NOT execFileSync: a synchronous encode/probe
// blocks the event loop, which stalls the CDP `screencastFrameAck` of every
// OTHER concurrently-capturing page (Chrome only sends the next screencast
// frame after the ack), starving their clips of frames. Async keeps every
// page's capture flowing while one plugin encodes.
const execFileP = promisify(execFile);
function resolveChrome() {
if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
const candidates = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium',
];
for (const c of candidates) if (existsSync(c)) return c;
throw new Error('No Chrome found. Set CHROME=/path/to/chrome.');
}
// ---- baked-output validation ----------------------------------------------
// The gallery faithfully renders whatever the manifest names, so a broken bake
// must be rejected HERE — once an entry lands, the card shows the broken clip
// until the next content change (see the "preview shows wrong content" class
// of reports: stale clips, blank cards).
// Real duration of the encoded clip. `durMs` upstream is only the *intended*
// wall-clock walk; the VFR concat + fps resample can land somewhere else
// entirely (observed 1155ms recorded for a 3667ms clip), and holdMs — the span
// the gallery loops while idle — must never exceed what the file actually has.
//
// Both probes THROW on failure instead of degrading: ffprobe is required
// validation infrastructure (the workflow installs ffmpeg explicitly), and a
// swallowed probe error would silently disable exactly the checks this
// pipeline exists for. A thrown error becomes an `error …` skip for that
// plugin, lands in bake-report.json, and fails strict mode.
async function probeClipMs(file) {
const { stdout } = await execFileP('ffprobe', ['-v', 'error', '-show_entries', 'format=duration',
'-of', 'csv=p=0', file], { encoding: 'utf8' });
const out = stdout.trim();
const s = Number(out);
if (!Number.isFinite(s) || s <= 0) throw new Error(`ffprobe returned no duration for ${file}: "${out}"`);
return Math.round(s * 1000);
}
// Aggregate luma range across every frame of the clip. If no frame ever gets
// brighter than BLANK_MAX_Y (~9% grey), nothing rendered — the page 404'd into
// a dark shell, a CDN dependency never loaded, fonts raced, … — and the mirror
// check catches an all-white nothing. Thresholds are deliberately extreme so a
// legitimately dark (or light) design never trips them: any visible text or
// artwork moves YMAX/YMIN far past these bounds.
const BLANK_MAX_Y = 24;
const BLANK_MIN_Y = 232;
async function clipLumaRange(file) {
const esc = file.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:');
// nk=0 keeps the tag names in the output: ffprobe emits the tags in
// signalstats' own order (YMIN before YMAX), not the -show_entries order,
// so parse by key instead of by position.
const { stdout: out } = await execFileP('ffprobe', ['-v', 'error', '-f', 'lavfi',
'-i', `movie='${esc}',signalstats`,
'-show_entries', 'frame_tags=lavfi.signalstats.YMAX,lavfi.signalstats.YMIN',
'-of', 'csv=p=0:nk=0'], { encoding: 'utf8' });
let maxY = 0, minY = 255, frames = 0;
for (const match of out.matchAll(/signalstats\.(YMAX|YMIN)=([0-9.]+)/g)) {
const value = Number(match[2]);
if (!Number.isFinite(value)) continue;
if (match[1] === 'YMAX') { frames += 1; if (value > maxY) maxY = value; }
else if (value < minY) minY = value;
}
if (frames === 0) throw new Error(`ffprobe signalstats returned no frames for ${file}`);
return { maxY, minY };
}
// ---- discover the html-preview plugins ------------------------------------
async function discoverIds() {
if (ONLY.length) return ONLY;
const res = await fetch(`${BASE_URL}/api/plugins`);
const data = await res.json();
const items = Array.isArray(data) ? data : (data.plugins || data.items || data.available || []);
const ids = items
.map((it) => (typeof it === 'string' ? it : it.id || it.slug))
.filter(Boolean);
return LIMIT ? ids.slice(0, LIMIT) : ids;
}
// Authors can declare how their preview should be captured via
// `od.preview.motion` ('scroll' | 'deck' | 'static'); we honor it first. When
// absent, an `od.mode: deck` declaration is authoritative — vertically stacked
// deck DOM must still be walked page-by-page, not recorded as one long scroll.
// Everything else remains auto-detected. An opted-in GSAP preview can also
// choose a still frame, so a long-running film never bakes its black end slate.
async function loadPreviewOptions() {
const map = {};
try {
const res = await fetch(`${BASE_URL}/api/plugins`);
const data = await res.json();
const items = Array.isArray(data) ? data : (data.plugins || data.items || data.available || []);
for (const it of items) {
if (!it || typeof it !== 'object') continue;
const id = it.id || it.slug;
const preview = it.manifest?.od?.preview;
const m = preview?.motion;
if (!id) continue;
const options = {};
if (m === 'scroll' || m === 'deck' || m === 'static') {
options.motion = m;
} else if (it.manifest?.od?.mode === 'deck') {
options.motion = 'deck';
}
if (typeof preview?.bakeStillAtSec === 'number'
&& Number.isFinite(preview.bakeStillAtSec) && preview.bakeStillAtSec >= 0) {
options.bakeStillAtSec = preview.bakeStillAtSec;
}
if (Object.keys(options).length) map[id] = options;
}
} catch {}
return map;
}
// ---- deck (PPT/slideshow) driving -----------------------------------------
// A structural fingerprint of the current slide, robust across deck styles:
// the transform of any slide-rail wider/taller than the viewport, the scroll
// offset of a horizontal track, and any active-slide marker. Deliberately does
// NOT read canvas pixels, so a continuously-animating WebGL background doesn't
// look like a slide change. Runs in the page; must stay self-contained.
function deckSignal(cap) {
const parts = [location.hash];
// getBoundingClientRect + getComputedStyle force layout/style per element, so
// scanning ALL of them is pathological on a deck that renders every slide in
// one rail (thousands of nodes). The rail/track is structural and near the top
// of the DOM, so the first `cap` elements in document order suffice.
const els = document.querySelectorAll('*');
const n = Math.min(els.length, cap || 600);
for (let i = 0; i < n; i += 1) {
const el = els[i];
const r = el.getBoundingClientRect();
if (r.width > window.innerWidth * 1.5 || r.height > window.innerHeight * 1.5) {
parts.push(getComputedStyle(el).transform);
}
if (el.scrollWidth > el.clientWidth + 4) parts.push(`sl${el.scrollLeft}`);
if (el.scrollHeight > el.clientHeight + 4) parts.push(`st${el.scrollTop}`);
}
const a = document.querySelector('.active,.is-active,[aria-current="true"],[data-active="true"]');
if (a) parts.push((a.id || '') + (a.className || ''));
return parts.join('|').slice(0, 4000);
}
// Locate and optionally advance a vertically stacked deck. Some templates keep
// html/body fixed and put the actual slide rail in an inner overflow-y
// container, so window.scrollY alone cannot describe or drive them. Runs in the
// page; must stay self-contained.
export function verticalDeckState(selector, action) {
const slides = Array.from(document.querySelectorAll(selector)).filter((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width > 1 && rect.height > 1 && style.display !== 'none' && style.visibility !== 'hidden';
});
if (slides.length < 2) return { hasStack: false, moved: false };
const isRoot = (el) => el === document.scrollingElement || el === document.documentElement || el === document.body;
let scroller = null;
let node = slides[0].parentElement;
while (node && !isRoot(node)) {
const containsAll = slides.every((slide) => node.contains(slide));
const overflowY = String(getComputedStyle(node).overflowY || '').toLowerCase();
if (
containsAll &&
node.scrollHeight > node.clientHeight + 1 &&
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
) {
scroller = node;
break;
}
node = node.parentElement;
}
const root = !scroller;
scroller ||= document.scrollingElement || document.documentElement || document.body;
const current = root
? Math.max(
Number(window.scrollY || window.pageYOffset || 0),
Number(document.documentElement?.scrollTop || 0),
Number(document.body?.scrollTop || 0),
)
: Number(scroller.scrollTop || 0);
const origin = root ? 0 : Number(scroller.getBoundingClientRect().top || 0);
const tops = slides.map((el) => current + Number(el.getBoundingClientRect().top || 0) - origin);
const span = Math.max(1, root ? window.innerHeight : scroller.clientHeight);
const hasStack = Math.max(...tops) - Math.min(...tops) > span * 0.5;
if (!hasStack || action !== 'advance') return { hasStack, moved: false };
const currentIndex = Number(window.__odBakeVerticalSlideIndex || 0);
const nextIndex = currentIndex + 1;
if (nextIndex >= slides.length) return { hasStack, moved: false };
const top = tops[nextIndex];
window.__odBakeVerticalSlideIndex = nextIndex;
if (root) {
window.scrollTo({ left: window.scrollX, top, behavior: 'auto' });
} else {
try {
scroller.scrollTo({ left: scroller.scrollLeft, top, behavior: 'auto' });
} catch {
scroller.scrollTop = top;
}
}
return { hasStack, moved: true };
}
async function driveDeck(page, driver) {
if (driver === 'vertical') {
const state = await page.evaluate(verticalDeckState, DECK_CAPTURE_SELECTOR, 'advance');
return state.moved;
}
if (driver === 'arrow') { await page.keyboard.press('ArrowRight'); return; }
if (driver === 'wheel') {
await page.evaluate(() => {
const mk = () => new WheelEvent('wheel', { deltaY: 800, bubbles: true, cancelable: true });
(document.querySelector('#deck,main,section') || document.body).dispatchEvent(mk());
window.dispatchEvent(mk());
});
return;
}
await page.evaluate(() => {
const b = document.querySelector('[class*="next" i],[aria-label*="next" i],[data-dir="next"],.arrow-right,.next');
if (b) b.click();
});
}
// Walk a deck through its slides with the driver detection already found (arrow
// or wheel), played past the HOLD on hover. Stops at the last slide (an input
// that no longer changes deckSignal). A null driver is a fixed single slide with
// no navigation — just hold. Returns the wall-clock span (the manifest durationMs).
async function walkSlides(page, driver) {
if (!driver) return SLIDE_MS;
let moved = 0;
const t0 = Date.now();
for (let s = 0; s < MAX_SLIDES; s += 1) {
if (Date.now() - t0 > MAX_WALK_MS) break; // backstop so the clip never runs long
if (driver === 'vertical') {
if (!(await driveDeck(page, driver))) break;
await sleep(SLIDE_MS);
moved += 1;
continue;
}
const before = await page.evaluate(deckSignal, DECK_SCAN_CAP);
await driveDeck(page, driver);
await sleep(SLIDE_MS);
if ((await page.evaluate(deckSignal, DECK_SCAN_CAP)) === before) break; // last slide
moved += 1;
}
return Math.max(SLIDE_MS, Date.now() - t0);
}
// Runs INSIDE the page (via page.evaluate — keep it self-contained, no outer
// scope). Undo whatever the driver probe persisted about the deck's position,
// so the reload that follows opens the deck on its first slide again.
// Two places a deck keeps that: the URL fragment (`#/2` — reloads preserve it,
// so a hash-routed deck reopened on the slide the probe left it at and baked
// that as its poster) and web storage (a "resume where you were" key). Both
// are cleared; the fragment through replaceState so the reload is a plain one
// and no `hashchange` fires first. Storage access can throw (disabled, opaque
// origin), which is reported rather than fatal — the hash is the common case.
export function resetDeckEntryState() {
const hadHash = Boolean(window.location.hash);
if (hadHash) {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
let clearedStorage = false;
try {
window.localStorage.clear();
window.sessionStorage.clear();
clearedStorage = true;
} catch {}
return { hadHash, clearedStorage };
}
// Probe which input advances a fixed-viewport deck: press the arrow key, then
// nudge the wheel, watching deckSignal for a real slide change. Returns 'arrow',
// 'wheel', or null when nothing moves it (a single static screen). Advances the
// deck as a side effect, so callers that go on to capture must reset the
// deck's persisted position (resetDeckEntryState) and reload first.
async function probeDeckDriver(page) {
const sig0 = await page.evaluate(deckSignal, DECK_SCAN_CAP);
await driveDeck(page, 'arrow');
await sleep(900);
if ((await page.evaluate(deckSignal, DECK_SCAN_CAP)) !== sig0) return 'arrow';
await driveDeck(page, 'wheel');
await sleep(900);
if ((await page.evaluate(deckSignal, DECK_SCAN_CAP)) !== sig0) return 'wheel';
const verticalState = await page.evaluate(verticalDeckState, DECK_CAPTURE_SELECTOR, 'probe');
if (verticalState.hasStack) return 'vertical';
return null;
}
// Size and position the screencast viewport against the authored slide itself,
// not the surrounding stage/body. This is what removes baked-in black canvas:
// matching only the slide's aspect ratio is insufficient when the page adds
// padding or renders a fixed 1280×720 canvas at a smaller CSS transform.
async function cropDeckViewportToSlide(page, fallbackW, fallbackH) {
const measured = await page.evaluate((selector) => {
const slides = Array.from(document.querySelectorAll(selector));
const candidates = slides.map((el, index) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const active = el.matches('.active,.is-active,[aria-current="true"],[data-active="true"],[data-od-deck-active]');
const visible = rect.width > 1 && rect.height > 1 && style.display !== 'none' && style.visibility !== 'hidden';
const onscreen = rect.right > 0 && rect.bottom > 0 && rect.left < innerWidth && rect.top < innerHeight;
return { index, active, visible, onscreen, width: rect.width, height: rect.height };
});
return candidates.find((item) => item.active && item.visible)
|| candidates.find((item) => item.visible && item.onscreen)
|| candidates.find((item) => item.visible)
|| null;
}, DECK_CAPTURE_SELECTOR);
if (!measured) return { width: fallbackW, height: fallbackH };
const width = Math.max(320, Math.min(2560, Math.round(measured.width)));
const height = Math.max(180, Math.min(1440, Math.round(measured.height)));
const aspect = width / height;
if (!Number.isFinite(aspect) || aspect < 1.2 || aspect > 2.4) {
return { width: fallbackW, height: fallbackH };
}
if (width !== fallbackW || height !== fallbackH) {
await page.setViewport({ width, height, deviceScaleFactor: 1 });
await sleep(250);
}
await page.evaluate(({ selector, index }) => {
document.documentElement.style.setProperty('overflow', 'auto', 'important');
document.body.style.setProperty('overflow', 'visible', 'important');
const target = document.querySelectorAll(selector)[index];
if (!target) return;
const rect = target.getBoundingClientRect();
window.__odBakeVerticalSlideIndex = 0;
window.scrollTo({
left: window.scrollX + rect.left,
top: window.scrollY + rect.top,
behavior: 'auto',
});
}, { selector: DECK_CAPTURE_SELECTOR, index: measured.index });
await sleep(100);
return { width, height };
}
// ---- render + encode one plugin -------------------------------------------
async function bakeOne(browser, id, hash, options) {
const page = await browser.newPage();
await page.setViewport({ width: RENDER_W, height: VIEW_H, deviceScaleFactor: 1 });
try {
const res = await page.goto(`${BASE_URL}/api/plugins/${encodeURIComponent(id)}/preview`,
{ waitUntil: 'domcontentloaded', timeout: 25000 });
if (!res || !res.ok()) { await page.close(); return { id, skipped: `status ${res ? res.status() : 'none'}` }; }
} catch (e) { await page.close(); return { id, skipped: `load ${e.message}` }; }
await sleep(1000);
// Capture mode: an explicit `od.preview.motion` declaration wins; otherwise
// auto-detect.
let mode = options?.bakeStillAtSec != null ? 'static' : options?.motion;
let deckDriver = null;
if (mode === 'scroll' || mode === 'deck' || mode === 'static') {
// Explicit declaration: a deck still needs its advancing input probed.
if (mode === 'deck') deckDriver = await probeDeckDriver(page);
} else {
// A vertically-scrollable page is a landing page (pan it) — even with a
// horizontal marquee/carousel sub-component; classifying by scroll height,
// not by probing, keeps a tall page off the deck path (a wheel probe would
// scroll it and the scroll-driven animation reads as a slide change, which
// walked precision-farming pages sideways). A fixed-viewport page is a deck
// only if an input actually advances it; otherwise it's a single static
// screen that just holds its in-place animation (default viewport, no walk).
const vScrollable = await page.evaluate(
() => document.documentElement.scrollHeight > window.innerHeight * 1.15,
);
if (vScrollable) {
mode = 'scroll';
} else {
deckDriver = await probeDeckDriver(page);
mode = deckDriver ? 'deck' : 'static';
}
}
const isDeck = mode === 'deck';
const isStatic = mode === 'static';
let capW = RENDER_W, capH = VIEW_H;
if (isDeck) {
capW = DECK_W; capH = DECK_H;
await page.setViewport({ width: capW, height: capH, deviceScaleFactor: 1 });
// The probe advanced the deck; the reload alone does not bring it back when
// the deck remembers its slide (see resetDeckEntryState) — the poster is
// the first captured frame, and it must be the cover.
try { await page.evaluate(resetDeckEntryState); } catch {}
try { await page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }); } catch {}
await sleep(1000);
}
// Trigger lazy images by scrolling through, then wait for them, then reset.
await page.evaluate(async () => {
const h = document.documentElement.scrollHeight;
for (let y = 0; y <= h; y += Math.round(window.innerHeight * 0.8)) {
window.scrollTo(0, y); await new Promise((r) => setTimeout(r, 120));
}
window.scrollTo(0, 0);
});
// Wait for the page's VISUAL resources to settle before recording — capped so
// rAF-driven animation loops (which never "finish") don't hang the bake:
// - web fonts (document.fonts.ready) — else the clip bakes in a fallback
// font that swaps the instant a real user opens the page;
// - every
(incl. the lazy ones the pre-scroll just triggered);
// - CSS background-images — heroes routinely use these and they are NOT in
// document.images, so they'd otherwise be captured half-loaded/blank;
// -