2026-06-09 21:14:54 +08:00
#!/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.
2026-07-10 18:40:53 +08:00
import { execFile } from 'node:child_process' ;
import { promisify } from 'node:util' ;
2026-06-09 21:14:54 +08:00
import { mkdirSync , rmSync , writeFileSync , readFileSync , statSync , existsSync } from 'node:fs' ;
import { createHash } from 'node:crypto' ;
import path from 'node:path' ;
2026-08-18 05:17:00 +00:00
import { pathToFileURL } from 'node:url' ;
2026-06-09 21:14:54 +08:00
// Bump when the bake recipe changes (capture geometry, timing, encoder, waits…)
// so every plugin re-bakes even though its page content is byte-identical.
2026-06-10 09:53:12 +08:00
// v2: deck mode (slide-walk for fixed-viewport PPT/slideshow pages) +
2026-06-10 03:04:46 +08:00
// force-load webfonts before capture (CJK templates were baking tofu).
2026-06-10 09:53:12 +08:00
// 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.
2026-06-10 13:43:50 +08:00
// 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.
2026-07-08 11:44:05 +08:00
// 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).
2026-08-18 05:17:00 +00:00
// 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.
2026-09-17 02:48:00 +00:00
// 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 ;
2026-06-09 21:14:54 +08:00
// ---- 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)
2026-08-18 05:17:00 +00:00
// 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.
2026-06-10 03:04:46 +08:00
const DECK_W = 1760 ;
2026-08-18 05:17:00 +00:00
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' ;
2026-06-10 03:04:46 +08:00
const SLIDE_MS = 1150 ; // deck per-slide dwell: ~.9s CSS transition + settle
const MAX_SLIDES = 6 ; // advance budget; HOLD + slides stays ~<=9s
2026-06-10 09:53:12 +08:00
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
2026-06-09 21:14:54 +08:00
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' );
2026-07-08 11:44:05 +08:00
// 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' ;
2026-06-09 21:14:54 +08:00
const sleep = ( ms ) => new Promise (( r ) => setTimeout ( r , ms ));
2026-07-10 18:40:53 +08:00
// 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 );
2026-06-09 21:14:54 +08:00
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.' );
}
2026-07-08 11:44:05 +08:00
// ---- 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.
2026-07-10 18:40:53 +08:00
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 ();
2026-07-08 11:44:05 +08:00
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 ;
2026-07-10 18:40:53 +08:00
async function clipLumaRange ( file ) {
2026-07-08 11:44:05 +08:00
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.
2026-07-10 18:40:53 +08:00
const { stdout : out } = await execFileP ( 'ffprobe' , [ '-v' , 'error' , '-f' , 'lavfi' ,
2026-07-08 11:44:05 +08:00
'-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 };
}
2026-06-09 21:14:54 +08:00
// ---- 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 ;
}
2026-06-10 13:43:50 +08:00
// Authors can declare how their preview should be captured via
2026-08-18 05:17:00 +00:00
// `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. Returns id -> motion (missing => null).
2026-06-10 13:43:50 +08:00
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 ;
2026-08-18 05:17:00 +00:00
if ( ! id ) continue ;
if ( m === 'scroll' || m === 'deck' || m === 'static' ) {
map [ id ] = m ;
} else if ( it . manifest ? . od ? . mode === 'deck' ) {
map [ id ] = 'deck' ;
}
2026-06-10 13:43:50 +08:00
}
} catch {}
return map ;
}
2026-06-10 03:04:46 +08:00
// ---- 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.
2026-06-10 09:53:12 +08:00
function deckSignal ( cap ) {
2026-06-10 03:04:46 +08:00
const parts = [ location . hash ];
2026-06-10 09:53:12 +08:00
// 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 ];
2026-06-10 03:04:46 +08:00
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 } ` );
2026-08-18 05:17:00 +00:00
if ( el . scrollHeight > el . clientHeight + 4 ) parts . push ( `st ${ el . scrollTop } ` );
2026-06-10 03:04:46 +08:00
}
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 );
}
2026-08-18 05:17:00 +00:00
// 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 };
}
2026-06-10 03:04:46 +08:00
async function driveDeck ( page , driver ) {
2026-08-18 05:17:00 +00:00
if ( driver === 'vertical' ) {
const state = await page . evaluate ( verticalDeckState , DECK_CAPTURE_SELECTOR , 'advance' );
return state . moved ;
}
2026-06-10 03:04:46 +08:00
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 ) {
2026-06-10 09:53:12 +08:00
if ( Date . now () - t0 > MAX_WALK_MS ) break ; // backstop so the clip never runs long
2026-08-18 05:17:00 +00:00
if ( driver === 'vertical' ) {
if ( ! ( await driveDeck ( page , driver ))) break ;
await sleep ( SLIDE_MS );
moved += 1 ;
continue ;
}
2026-06-10 09:53:12 +08:00
const before = await page . evaluate ( deckSignal , DECK_SCAN_CAP );
2026-06-10 03:04:46 +08:00
await driveDeck ( page , driver );
await sleep ( SLIDE_MS );
2026-06-10 09:53:12 +08:00
if (( await page . evaluate ( deckSignal , DECK_SCAN_CAP )) === before ) break ; // last slide
2026-06-10 03:04:46 +08:00
moved += 1 ;
}
return Math . max ( SLIDE_MS , Date . now () - t0 );
}
2026-09-17 02:48:00 +00:00
// 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 };
}
2026-06-10 13:43:50 +08:00
// 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
2026-09-17 02:48:00 +00:00
// deck as a side effect, so callers that go on to capture must reset the
// deck's persisted position (resetDeckEntryState) and reload first.
2026-06-10 13:43:50 +08:00
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' ;
2026-08-18 05:17:00 +00:00
const verticalState = await page . evaluate ( verticalDeckState , DECK_CAPTURE_SELECTOR , 'probe' );
if ( verticalState . hasStack ) return 'vertical' ;
2026-06-10 13:43:50 +08:00
return null ;
}
2026-08-18 05:17:00 +00:00
// 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 };
}
2026-06-09 21:14:54 +08:00
// ---- render + encode one plugin -------------------------------------------
2026-06-10 13:43:50 +08:00
async function bakeOne ( browser , id , hash , motion ) {
2026-06-09 21:14:54 +08:00
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 );
2026-06-10 13:43:50 +08:00
// Capture mode: an explicit `od.preview.motion` declaration wins; otherwise
// auto-detect.
let mode = motion ;
2026-06-10 03:04:46 +08:00
let deckDriver = null ;
2026-06-10 13:43:50 +08:00
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' ;
2026-06-10 03:04:46 +08:00
}
}
2026-06-10 13:43:50 +08:00
const isDeck = mode === 'deck' ;
const isStatic = mode === 'static' ;
2026-06-10 03:04:46 +08:00
let capW = RENDER_W , capH = VIEW_H ;
if ( isDeck ) {
capW = DECK_W ; capH = DECK_H ;
await page . setViewport ({ width : capW , height : capH , deviceScaleFactor : 1 });
2026-09-17 02:48:00 +00:00
// 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 {}
2026-06-10 03:04:46 +08:00
try { await page . reload ({ waitUntil : 'domcontentloaded' , timeout : 25000 }); } catch {}
await sleep ( 1000 );
}
2026-06-09 21:14:54 +08:00
// 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 ));
2026-06-10 03:04:46 +08:00
// 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 ();
2026-06-09 21:14:54 +08:00
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 );
2026-09-17 02:48:00 +00:00
// 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' };
}
2026-08-18 05:17:00 +00:00
if ( isDeck ) {
const crop = await cropDeckViewportToSlide ( page , capW , capH );
capW = crop . width ;
capH = crop . height ;
}
2026-06-09 21:14:54 +08:00
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 {}
});
2026-06-10 13:43:50 +08:00
const maxY = ( isDeck || isStatic ) ? 0 : await page . evaluate (() =>
2026-06-09 21:14:54 +08:00
Math . max ( 0 , document . documentElement . scrollHeight - window . innerHeight ));
2026-06-10 13:43:50 +08:00
// 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 ;
}));
2026-06-09 21:14:54 +08:00
await client . send ( 'Page.startScreencast' ,
2026-06-10 03:04:46 +08:00
{ 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.
2026-06-09 21:14:54 +08:00
await sleep ( HOLD_MS );
2026-06-10 03:04:46 +08:00
// 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 );
2026-06-10 13:43:50 +08:00
} else if ( maxY <= 0 ) {
// Static / single-screen page: just hold, capturing its in-place animation.
durMs = 2500 ;
await sleep ( durMs );
2026-06-10 03:04:46 +08:00
} 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.
2026-06-10 13:43:50 +08:00
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 );
2026-06-10 03:04:46 +08:00
}
2026-06-10 13:43:50 +08:00
}
2026-06-10 03:04:46 +08:00
}
2026-06-09 21:14:54 +08:00
await client . send ( 'Page.stopScreencast' );
await page . close ();
2026-07-08 11:44:05 +08:00
if ( frames . length === 0 ) { rmSync ( frameDir , { recursive : true , force : true }); return { id , skipped : 'frames 0' }; }
2026-06-09 21:14:54 +08:00
// 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' ));
2026-07-10 18:40:53 +08:00
// 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 ) } ` );
2026-06-09 21:14:54 +08:00
lines . push ( `file ' ${ fp } '` );
}
2026-07-08 11:44:05 +08:00
// 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 ) } ` );
2026-06-09 21:14:54 +08:00
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' ));
2026-06-23 23:39:16 +08:00
// 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 });
2026-07-10 18:40:53 +08:00
// `-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 });
2026-06-09 21:14:54 +08:00
// 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.
2026-07-10 18:40:53 +08:00
await ff ([ '-f' , 'concat' , '-safe' , '0' , '-i' , listPath ,
2026-06-09 21:14:54 +08:00
'-vf' , `scale= ${ OUT_W } :-2,fps= ${ FPS } ` , '-c:v' , 'libx264' , '-crf' , String ( CRF ),
'-pix_fmt' , 'yuv420p' , '-movflags' , '+faststart' , '-r' , String ( FPS ), '-an' , video ]);
2026-07-10 18:40:53 +08:00
await ff ([ '-i' , path . join ( frameDir , 'f-0000.jpg' ), '-vf' , `scale= ${ OUT_W } :-2` ,
2026-06-09 21:14:54 +08:00
'-q:v' , '5' , '-frames:v' , '1' , poster ]);
rmSync ( frameDir , { recursive : true , force : true });
2026-07-08 11:44:05 +08:00
// 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).
2026-07-10 18:40:53 +08:00
const luma = await clipLumaRange ( video );
2026-07-08 11:44:05 +08:00
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.
2026-07-10 18:40:53 +08:00
const encodedMs = await probeClipMs ( video );
2026-07-08 11:44:05 +08:00
return { id , durationMs : encodedMs , holdMs : Math . min ( HOLD_MS , encodedMs ), video : videoKey , poster : posterKey ,
2026-06-09 21:14:54 +08:00
bytes : statSync ( video ). size , posterBytes : statSync ( poster ). size };
}
// ---- main -----------------------------------------------------------------
2026-08-18 05:17:00 +00:00
if ( process . argv [ 1 ] && import . meta . url === pathToFileURL ( process . argv [ 1 ]). href ) {
const { default : puppeteer } = await import ( 'puppeteer-core' );
2026-06-09 21:14:54 +08:00
mkdirSync ( OUT , { recursive : true });
const ids = await discoverIds ();
2026-06-10 13:43:50 +08:00
const motionMap = await loadMotionMap ();
2026-07-10 18:40:53 +08:00
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 = {
2026-06-09 21:14:54 +08:00
executablePath : resolveChrome (), headless : 'new' ,
// Let muted hero background videos (.mp4/CloudFront, common on premium
2026-07-10 18:40:53 +08:00
// 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' ],
};
2026-06-09 21:14:54 +08:00
const manifestPath = path . join ( OUT , 'manifest.json' );
const previews = existsSync ( manifestPath )
? ( JSON . parse ( readFileSync ( manifestPath , 'utf8' )). previews || {}) : {};
let ok = 0 , skip = 0 , reused = 0 ;
2026-07-08 11:44:05 +08:00
// 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 : [] };
2026-07-10 18:40:53 +08:00
async function processOne ( id , getBrowser ) {
2026-06-09 21:14:54 +08:00
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 ();
2026-06-10 13:43:50 +08:00
hash = createHash ( 'sha256' ). update ( html ). update ( ` ${ BAKE_VERSION } ${ motionMap [ id ] || '' } ` ). digest ( 'hex' ). slice ( 0 , 16 );
2026-06-09 21:14:54 +08:00
} catch {}
2026-07-08 11:44:05 +08:00
// 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 );
2026-07-10 18:40:53 +08:00
return ;
2026-07-08 11:44:05 +08:00
}
2026-06-09 21:14:54 +08:00
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` );
2026-07-10 18:40:53 +08:00
return ;
2026-06-09 21:14:54 +08:00
}
let r ;
2026-07-10 18:40:53 +08:00
try { r = await bakeOne ( await getBrowser (), id , hash , motionMap [ id ]); } catch ( e ) { r = { id , skipped : `error ${ e . message } ` }; }
2026-07-08 11:44:05 +08:00
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 );
2026-07-10 18:40:53 +08:00
return ;
2026-07-08 11:44:05 +08:00
}
2026-06-09 21:14:54 +08:00
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)` );
2026-07-10 18:40:53 +08:00
// 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.
2026-06-09 21:14:54 +08:00
writeFileSync ( manifestPath , JSON . stringify ({ generatedAt : null , previews }, null , 2 ));
}
2026-07-10 18:40:53 +08:00
// 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 ()),
);
2026-07-08 11:44:05 +08:00
// 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 ));
2026-06-09 21:14:54 +08:00
console . log ( `done: ${ ok } baked, ${ reused } reused (unchanged), ${ skip } skipped -> ${ manifestPath } ` );
2026-07-08 11:44:05 +08:00
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 ( ', ' ) } ` );
}
2026-07-10 18:40:53 +08:00
const strictFail = STRICT && ( report . blank . length || report . stale . length || report . errors . length );
if ( strictFail ) {
2026-07-08 11:44:05 +08:00
console . error ( 'strict mode: failing on blank/stale/errored previews' );
}
2026-07-10 18:40:53 +08:00
// 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 );
2026-08-18 05:17:00 +00:00
}