Files
open-design/scripts/bake-plugin-previews.mjs

957 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 `<img>` 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 <id>...] [--limit N]
//
// Output: <out>/<id>.mp4, <out>/<id>.poster.jpg, and <out>/manifest.json
// ({ generatedAt, previews: { <id>: { video, poster, durationMs, holdMs } } }).
// Uploading <out> 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 <img> (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;
// - <video> backgrounds (.mp4/CloudFront): force muted playback + a frame.
try {
await page.evaluate((capMs) => {
const cap = new Promise((r) => setTimeout(r, capMs));
// CJK-heavy templates pull Noto Serif/Sans SC from Google Fonts with
// display=swap; awaiting fonts.ready alone can resolve on the swap
// fallback. Force every registered face to load, THEN await ready, so the
// capture isn't a fallback/tofu render. (The CI bake also installs
// fonts-noto-cjk so the fallback itself carries CJK glyphs if a webfont is
// slow or blocked, instead of rendering missing-glyph boxes.)
// Double-pass: await ready FIRST so faces registered by a late-arriving
// Google Fonts stylesheet are present, force-load any still unloaded, then
// await ready again — a single pass can enumerate before display=swap
// faces exist and capture the fallback (e.g. a Shrikhand/Playfair heading).
const fonts = document.fonts
? document.fonts.ready
.catch(() => {})
.then(() => Promise.all(
Array.from(document.fonts).map((f) =>
f.status === 'loaded' ? null : f.load().catch(() => {}),
),
))
.then(() => document.fonts.ready)
.catch(() => {})
: Promise.resolve();
const imgs = Array.from(document.images)
.filter((i) => !i.complete)
.map((i) => new Promise((res) => {
i.addEventListener('load', res, { once: true });
i.addEventListener('error', res, { once: true });
}));
const vids = Array.from(document.querySelectorAll('video')).map((v) => {
try { v.muted = true; const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch {}
return v.readyState >= 2 ? Promise.resolve() : new Promise((res) => {
v.addEventListener('loadeddata', res, { once: true });
v.addEventListener('error', res, { once: true });
});
});
const bgUrls = new Set();
document.querySelectorAll('*').forEach((el) => {
const bg = getComputedStyle(el).backgroundImage;
if (!bg || bg === 'none') return;
const re = /url\((['"]?)(.*?)\1\)/g;
let m;
while ((m = re.exec(bg))) if (m[2] && !m[2].startsWith('data:')) bgUrls.add(m[2]);
});
const bgs = Array.from(bgUrls).map((u) => new Promise((res) => {
const im = new Image();
im.onload = im.onerror = res;
im.src = u;
}));
return Promise.race([cap, Promise.all([fonts, ...imgs, ...vids, ...bgs])]);
}, 12000);
} catch {}
await sleep(600);
// Animated previews can hold capture until their opening sequence finishes.
// Pages without the marker keep the existing capture timing. Never publish
// a loading-screen poster when an opted-in page fails to become ready.
try {
await page.waitForFunction(
() => document.documentElement.getAttribute('data-od-preview-ready') !== 'false',
{ timeout: 15000 },
);
} catch {
await page.close();
return { id, skipped: 'preview readiness timeout' };
}
if (options?.bakeStillAtSec != null) {
// A chosen frame must be captured at the composition's native 16:9 size,
// after the page's own loading/font waits. Pause before seeking so the
// timeline cannot run on to its black end slate during the screencast.
capW = 1920; capH = 1080;
await page.setViewport({ width: capW, height: capH, deviceScaleFactor: 1 });
const sought = await page.evaluate((seconds) => {
const timeline = Object.values(window.__timelines ?? {})[0];
if (!timeline || typeof timeline.pause !== 'function'
|| typeof timeline.time !== 'function') return false;
document.documentElement.classList.add('od-export');
timeline.pause();
timeline.time(seconds);
return Math.abs(timeline.time() - seconds) < 0.05;
}, options.bakeStillAtSec);
if (!sought) {
await page.close();
throw new Error(`could not seek ${id} to ${options.bakeStillAtSec}s for bake`);
}
await sleep(100);
}
if (isDeck) {
const crop = await cropDeckViewportToSlide(page, capW, capH);
capW = crop.width;
capH = crop.height;
}
const frameDir = path.join(OUT, `.frames-${id}`);
rmSync(frameDir, { recursive: true, force: true }); mkdirSync(frameDir, { recursive: true });
const client = await page.createCDPSession();
const frames = [];
client.on('Page.screencastFrame', async (e) => {
frames.push({ data: e.data, ts: e.metadata.timestamp });
try { await client.send('Page.screencastFrameAck', { sessionId: e.sessionId }); } catch {}
});
const maxY = (isDeck || isStatic) ? 0 : await page.evaluate(() =>
Math.max(0, document.documentElement.scrollHeight - window.innerHeight));
// Detect the scroll mechanism BEFORE recording: this probe jumps the page
// (scrollTo 160 -> 0), which once the screencast is running would bake a
// visible lurch at the head of the pan. Ordinary pages scroll via
// window.scrollTo; scroll-hijack landing pages (custom / transform scroll)
// ignore it and need real wheel events to drive their own scroll handler.
const windowScrolls = maxY > 0 && (await page.evaluate(() => {
const before = window.scrollY || document.documentElement.scrollTop;
window.scrollTo(0, 160);
const moved = (window.scrollY || document.documentElement.scrollTop) > before + 40;
window.scrollTo(0, 0);
return moved;
}));
await client.send('Page.startScreencast',
{ format: 'jpeg', quality: 80, everyNthFrame: 1, maxWidth: capW, maxHeight: capH });
// Phase 1 — HOLD on the first slide / page top, capturing in-place animation.
// The gallery loops this leading span while idle (no advance), so animated
// pages still look alive without auto-playing.
await sleep(HOLD_MS);
// Phase 2 — played past the hold on hover: decks walk their slides; scrollable
// pages linear-pan (constant velocity) top -> bottom.
let durMs;
if (isDeck) {
durMs = await walkSlides(page, deckDriver);
} else if (maxY <= 0) {
// Static / single-screen page: just hold, capturing its in-place animation.
durMs = 2500;
await sleep(durMs);
} else {
// Pre-computed from the measured page height so the pan always reaches the
// bottom within MAX_PAN (whole clip stays ~<=10s): base VELOCITY for normal
// pages, auto-sped-up (capped duration) for tall ones.
durMs = Math.min(MAX_PAN, Math.round(maxY / VELOCITY));
// Smooth rAF window.scrollTo for ordinary pages; real wheel events for
// scroll-hijack pages (windowScrolls probed above, before recording).
if (windowScrolls) {
await page.evaluate((dur, my) => new Promise((res) => {
let start = null;
function step(t) {
if (start === null) start = t;
const e = Math.min(1, (t - start) / dur);
window.scrollTo(0, Math.round(my * e)); // linear = constant velocity
if (e < 1) requestAnimationFrame(step); else res();
}
requestAnimationFrame(step);
}), durMs, maxY);
} else {
await page.mouse.move(Math.round(capW / 2), Math.round(capH / 2));
const tick = 40;
const perTick = Math.max(8, Math.round(maxY / (durMs / tick)));
const t0 = Date.now();
while (Date.now() - t0 < durMs) {
await page.mouse.wheel({ deltaY: perTick });
await sleep(tick);
}
}
}
await client.send('Page.stopScreencast');
await page.close();
if (frames.length === 0) { rmSync(frameDir, { recursive: true, force: true }); return { id, skipped: 'frames 0' }; }
// VFR concat list (real per-frame durations) -> correct real-time speed.
const lines = [];
for (let i = 0; i < frames.length; i += 1) {
const fp = path.join(frameDir, `f-${String(i).padStart(4, '0')}.jpg`);
writeFileSync(fp, Buffer.from(frames[i].data, 'base64'));
// Clamp to >=0: CDP screencast frame timestamps are occasionally NON-
// monotonic (a later frame carries an earlier `metadata.timestamp`),
// yielding a negative inter-frame duration that ffmpeg's concat demuxer
// rejects outright ("Invalid data found when processing input"), failing
// the whole clip — and, in strict pre-merge, the PR. A 0-duration frame is
// harmless (the fps filter resamples the timeline anyway). CPU contention
// from concurrent renders makes the non-monotonic blips more frequent, so
// this guard matters more once the sweep runs in parallel.
if (i > 0) lines.push(`duration ${Math.max(0, frames[i].ts - frames[i - 1].ts).toFixed(4)}`);
lines.push(`file '${fp}'`);
}
// A static page repaints nothing after its first paint, so the screencast
// delivers only a frame or two. That is a still, not a failure: hold the
// last frame for the idle-loop span so the encode yields a real clip.
// (The old `frames < 5` guard skipped these pages on EVERY bake, so their
// manifest entries could never refresh — five entries on main were
// permanently stale because of it.) Half the hold here because the trailing
// repeated `file` below inherits the last `duration` directive, so the two
// sum to ~HOLD_MS.
if (frames.length < 5) lines.push(`duration ${(HOLD_MS / 2000).toFixed(4)}`);
lines.push(`file '${path.join(frameDir, `f-${String(frames.length - 1).padStart(4, '0')}.jpg`)}'`);
const listPath = path.join(frameDir, 'list.txt');
writeFileSync(listPath, lines.join('\n'));
// Directory-layered, content-addressed keys: `<id>/<fingerprint>/preview.mp4`
// (+ `poster.jpg`). Immutable — a content change yields a NEW `<fingerprint>`
// directory (hence a new URL the CDN can cache forever), while a re-bake of the
// same source resolves to the same keys. The manifest stores these
// PREFIX-RELATIVE keys; it must NOT repeat the `plugin-previews/` segment that
// the base URL already carries, or `${base}/${key}` would double it and 404
// (see specs/change/20260618-plugin-preview-bake-pipeline/spec.md §0). The
// daemon resolves both `${base}/${key}` and `path.join(dir, key)`, which handle
// the nested path transparently — no consumer change needed. Existing flat
// `<id>.<hash>.mp4` entries are left untouched on reuse (additive migration).
const dirKey = hash ? `${id}/${hash}` : id;
const videoKey = `${dirKey}/preview.mp4`;
const posterKey = `${dirKey}/poster.jpg`;
const video = path.join(OUT, videoKey);
const poster = path.join(OUT, posterKey);
mkdirSync(path.dirname(video), { recursive: true });
// `-loglevel error -nostats` is the promisified-execFile equivalent of the
// old execFileSync `stdio: 'ignore'`: promisified execFile BUFFERS stderr
// (there is no `stdio: 'ignore'`), and ffmpeg's default banner + per-frame
// progress could otherwise grow past maxBuffer and reject a good encode with
// ENOBUFS. Quiet ffmpeg to errors only, and keep a generous buffer as a
// backstop. This changes only console chatter, never the encoded output.
const ff = (a) => execFileP('ffmpeg', ['-y', '-loglevel', 'error', '-nostats', ...a],
{ maxBuffer: 64 * 1024 * 1024 });
// H.264 MP4, constant frame rate (the fps filter resamples the concat's
// real-time timeline to a constant FPS). H.264 decodes reliably in both
// browsers and Electron — VP9 encoded from this frame pipeline intermittently
// tripped Chromium/Electron's decoder (PIPELINE_ERROR_DECODE). `+faststart`
// moves the moov atom up front so playback can begin before the full download.
await ff(['-f', 'concat', '-safe', '0', '-i', listPath,
'-vf', `scale=${OUT_W}:-2,fps=${FPS}`, '-c:v', 'libx264', '-crf', String(CRF),
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-r', String(FPS), '-an', video]);
await ff(['-i', path.join(frameDir, 'f-0000.jpg'), '-vf', `scale=${OUT_W}:-2`,
'-q:v', '5', '-frames:v', '1', poster]);
rmSync(frameDir, { recursive: true, force: true });
// Reject a clip that never rendered anything — an entry for it would show a
// blank card until the plugin's next content change. Deleting the outputs
// keeps the broken clip out of the R2 upload (the CI step copies OUT
// recursively).
const luma = await clipLumaRange(video);
if (luma.maxY < BLANK_MAX_Y || luma.minY > BLANK_MIN_Y) {
rmSync(path.dirname(video), { recursive: true, force: true });
return { id, skipped: `blank clip (luma ${luma.minY}..${luma.maxY})` };
}
// Manifest metadata must describe the FILE, not the intent: durationMs is
// the encoded duration, and holdMs (the span the gallery loops while idle)
// is clamped to it so the idle loop never points past the end of the clip.
const encodedMs = await probeClipMs(video);
return { id, durationMs: encodedMs, holdMs: Math.min(HOLD_MS, encodedMs), video: videoKey, poster: posterKey,
bytes: statSync(video).size, posterBytes: statSync(poster).size };
}
// ---- main -----------------------------------------------------------------
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const { default: puppeteer } = await import('puppeteer-core');
mkdirSync(OUT, { recursive: true });
const ids = await discoverIds();
const previewOptions = await loadPreviewOptions();
console.log(`baking ${ids.length} plugin previews from ${BASE_URL} -> ${OUT} (concurrency ${CONCURRENCY})`);
// Browser launch options, reused per worker below. NOT a single shared browser:
// CDP screencast only follows the ACTIVE tab, so capturing a page in a shared
// browser while another page is foreground starves it of frames (a frames-0
// skip, or a near-empty clip). Each concurrent worker therefore drives its OWN
// browser, where its page is always the sole foreground tab.
const launchOptions = {
executablePath: resolveChrome(), headless: 'new',
// Let muted hero background videos (.mp4/CloudFront, common on premium
// landing pages) autoplay so the capture isn't a frozen first frame. The
// backgrounding flags are belt-and-suspenders: a worker holds one page at a
// time so it is already foreground, but these keep painting alive if a page's
// window is ever treated as occluded.
args: ['--no-sandbox', '--hide-scrollbars', '--autoplay-policy=no-user-gesture-required',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding'],
};
const manifestPath = path.join(OUT, 'manifest.json');
const previews = existsSync(manifestPath)
? (JSON.parse(readFileSync(manifestPath, 'utf8')).previews || {}) : {};
let ok = 0, skip = 0, reused = 0;
// Machine-readable outcome for the workflows: which plugins were skipped and
// why, and — the metadata-sync failure this pipeline used to swallow — which
// committed entries are now STALE: their source changed but the re-bake
// failed, so the manifest keeps serving a clip of the OLD content. `errors`
// separates unexpected failures (thrown exceptions: ffprobe missing, encoder
// crash, …) from the routine skips every sweep has (non-html plugins 404 the
// preview route); strict mode fails on the former, never on the latter.
const report = { skipped: [], stale: [], blank: [], errors: [] };
async function processOne(id, getBrowser) {
const t0 = Date.now();
// Content-hash skip: a plugin whose preview HTML (and the bake recipe) is
// unchanged reuses its existing clip — no render, and the CI step re-uploads
// nothing. Editing the page or bumping BAKE_VERSION invalidates the hash.
let hash = null;
try {
const html = await (await fetch(`${BASE_URL}/api/plugins/${encodeURIComponent(id)}/preview`)).text();
const options = previewOptions[id];
const still = options?.bakeStillAtSec == null ? '' : ` still:${options.bakeStillAtSec}`;
hash = createHash('sha256').update(html).update(` ${BAKE_VERSION} ${options?.motion || ''}${still}`).digest('hex').slice(0, 16);
} catch {}
// No fingerprint -> no bake. Rendering anyway used to persist an entry with
// `hash: null` and un-fingerprinted keys — metadata the reuse skip can never
// match again (and the manifest guard rejects). A failed fingerprint fetch
// is an infrastructure error: report it, fail strict mode, and leave any
// committed entry untouched for the next sweep to refresh.
if (!hash) {
skip += 1;
const reason = 'error preview fingerprint fetch failed';
console.log(` ~ ${id}: skip (${reason})`);
report.skipped.push({ id, reason });
report.errors.push(id);
return;
}
const prev = previews[id];
// In CI the unchanged clips already live on R2 (not on disk), so PREVIEW_REMOTE
// trusts the manifest hash without a local-file check; locally we also confirm
// the files are actually present before reusing.
const filesPresent = process.env.PREVIEW_REMOTE === '1'
|| (prev && existsSync(path.join(OUT, prev.video)) && existsSync(path.join(OUT, prev.poster)));
if (hash && prev && prev.hash === hash && filesPresent) {
reused += 1;
console.log(` = ${id}: unchanged, reused`);
return;
}
let r;
try { r = await bakeOne(await getBrowser(), id, hash, previewOptions[id]); } catch (e) { r = { id, skipped: `error ${e.message}` }; }
if (r.skipped) {
skip += 1;
console.log(` ~ ${id}: skip (${r.skipped})`);
report.skipped.push({ id, reason: r.skipped });
if (r.skipped.startsWith('blank clip')) report.blank.push(id);
if (r.skipped.startsWith('error ')) report.errors.push(id);
if (prev && hash && prev.hash !== hash) report.stale.push(id);
return;
}
previews[id] = { video: r.video, poster: r.poster, durationMs: r.durationMs, holdMs: r.holdMs, hash };
ok += 1;
console.log(` + ${id}: ${(r.bytes / 1024).toFixed(0)}KB mp4, ${(r.posterBytes / 1024).toFixed(0)}KB poster (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
// Shared state (previews, counters, report) is only ever mutated in the
// synchronous span between awaits, so the single-threaded event loop
// serializes it — no lock needed. Rewriting the FULL manifest after each
// success is last-complete-wins under concurrency: whoever writes last
// serializes the most complete `previews`, and every earlier write is a
// subset it supersedes.
writeFileSync(manifestPath, JSON.stringify({ generatedAt: null, previews }, null, 2));
}
// Bounded worker pool: CONCURRENCY workers pull ids from a shared cursor so up
// to CONCURRENCY plugins render at once. The per-clip cost is mostly wall-clock
// waits that yield to the event loop, so overlapping them scales the sweep
// near-linearly until the runner's cores saturate (see CONCURRENCY above). An
// all-cached sweep just races the cheap hash fetches through the same pool.
let cursor = 0;
async function worker() {
// Each worker owns its browser (see launchOptions) and processes its ids
// sequentially, reusing that browser across them — launching it inside the
// worker is what keeps every captured page foreground. Lazy: a worker that
// only hits the reuse fast-path (an all-cached sweep, the common post-merge
// case) never pays a browser launch.
let browser = null;
const getBrowser = async () => (browser ??= await puppeteer.launch(launchOptions));
try {
for (;;) {
const i = cursor;
cursor += 1;
if (i >= ids.length) break;
await processOne(ids[i], getBrowser);
}
} finally {
if (browser) await browser.close();
}
}
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, ids.length) }, () => worker()),
);
// Written next to the manifest (the R2 upload step excludes it, same as
// manifest.json) so both the strict pre-merge job and a human reading a
// nightly run can see exactly which entries are broken or lagging.
writeFileSync(path.join(OUT, 'bake-report.json'),
JSON.stringify({ baked: ok, reused, ...report }, null, 2));
console.log(`done: ${ok} baked, ${reused} reused (unchanged), ${skip} skipped -> ${manifestPath}`);
if (report.blank.length) {
console.error(`BLANK bakes rejected (nothing ever rendered; entry NOT written): ${report.blank.join(', ')}`);
}
if (report.stale.length) {
console.error(`STALE manifest entries (source changed but the re-bake failed — the gallery keeps showing the OLD clip until a bake succeeds): ${report.stale.join(', ')}`);
}
if (report.errors.length) {
console.error(`ERRORED bakes (unexpected exception — validation infrastructure problem or renderer crash, see the skip reasons above): ${report.errors.join(', ')}`);
}
const strictFail = STRICT && (report.blank.length || report.stale.length || report.errors.length);
if (strictFail) {
console.error('strict mode: failing on blank/stale/errored previews');
}
// Exit explicitly. Puppeteer can leave the event loop non-empty after
// browser.close() (a lingering child-process/transport handle), and with one
// browser PER worker that reliably keeps this CLI alive after all work is done
// — on CI that means the step hangs until the 90-min job timeout instead of
// finishing in minutes. Everything above is persisted synchronously
// (writeFileSync), so the only thing left to protect is the piped stdout/stderr
// on CI: drain both so the final summary / strict message is never truncated,
// then hard-exit with the strict-aware code.
await Promise.all([process.stdout, process.stderr].map(
(stream) => new Promise((resolve) => stream.write('', resolve)),
));
process.exit(strictFail ? 1 : 0);
}