#!/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 puppeteer from 'puppeteer-core'; import { execFileSync } from 'node:child_process'; import { mkdirSync, rmSync, writeFileSync, readFileSync, statSync, existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import path from 'node:path'; // 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. const BAKE_VERSION = 4; // ---- 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 (PPT/slideshow: a fixed 100vh page navigated by arrow keys or the wheel, // NOT vertical scroll) are captured at the SAME 1.31 tile aspect as everything // else — so the clip fills the card with no crop or letterbox — but at a larger // width. At the normal 1440 width decks hit a width breakpoint and collapse into // a compact variant (hero headline -> condensed strip); 1760 clears it. const DECK_W = 1760; const DECK_H = 1344; // 1760/1344 = 1.31, the tile aspect 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'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 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.'); } // ---- 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 and only // auto-detect when it's absent. Returns an id -> motion map (missing => null). async function loadMotionMap() { 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 m = it.manifest?.od?.preview?.motion; if (id && (m === 'scroll' || m === 'deck' || m === 'static')) map[id] = m; } } 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}`); } 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); } async function driveDeck(page, driver) { 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 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); } // 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 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'; return null; } // ---- render + encode one plugin ------------------------------------------- async function bakeOne(browser, id, hash, motion) { 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 = 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 }); 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; // -