refactor(cli): clean up snapshot readiness, duration, and diagnostics

- Fix broken duration getter: use getDuration() (PlayerAPI method)
  instead of .duration (property doesn't exist, always fell through
  to the DOM attribute fallback)
- Remove redundant sub-composition wait: __renderReady already
  guarantees all timelines are bound
- Warn on readiness timeout instead of silently capturing garbage
- Warn when shader transitions don't finish pre-rendering
- Warn when no player API is available (seeks will be no-ops)
- Remove redundant node:fs re-import (already imported at top)
- Remove stale step numbering comments
- Trim verbose comments that restate the code
This commit is contained in:
Miguel Ángel
2026-05-24 13:20:16 -04:00
parent b2828e48e5
commit e8af1e4b9d
+43 -109
View File
@@ -97,16 +97,12 @@ async function captureSnapshots(
const numFrames = opts.frames ?? 5; const numFrames = opts.frames ?? 5;
// 1. Bundle. `bundleToSingleHtml` now inlines the runtime IIFE by default,
// so the previous post-bundle runtime substitution is no longer needed.
const html = await bundleToSingleHtml(projectDir); const html = await bundleToSingleHtml(projectDir);
const server = await serveStaticProjectHtml(projectDir, html); const server = await serveStaticProjectHtml(projectDir, html);
const savedPaths: string[] = []; const savedPaths: string[] = [];
try { try {
// 3. Launch headless Chrome
const browser = await ensureBrowser(); const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core"); const puppeteer = await import("puppeteer-core");
const chromeBrowser = await puppeteer.default.launch({ const chromeBrowser = await puppeteer.default.launch({
@@ -131,63 +127,33 @@ async function captureSnapshots(
timeout: 10000, timeout: 10000,
}); });
// Wait for the runtime to be fully render-ready: player constructed // __renderReady is set after the player is constructed AND the root
// AND root timeline bound. __renderReady is only set after the timeline // timeline is bound — waiting for it guarantees renderSeek will work.
// binding completes (synchronous or deferred), so this guarantees
// renderSeek will operate on the real timeline.
const timeoutMs = opts.timeout ?? 5000; const timeoutMs = opts.timeout ?? 5000;
await page const runtimeReady = await page
.waitForFunction(() => !!(window as any).__renderReady, { .waitForFunction(() => !!(window as any).__renderReady, { timeout: timeoutMs })
timeout: timeoutMs, .then(() => true)
}) .catch(() => false);
.catch(() => {});
// Wait for ALL sub-compositions to be mounted by the runtime. if (!runtimeReady) {
// The old check resolved when the first sub-timeline registered, causing console.warn(
// "last beat black" bugs: beat-5's sub-comp hadn't loaded yet when the `\n ${c.warn("⚠")} Runtime did not become render-ready within ${timeoutMs}ms — snapshots may be inaccurate`,
// snapshot seeked into its time range. Now we count data-composition-src );
// host elements and wait until we have a matching number of sub-timelines. }
await page
.waitForFunction(
() => {
const tls = (window as any).__timelines;
if (!tls) return false;
const hosts = document.querySelectorAll("[data-composition-src]").length;
if (hosts === 0) return Object.keys(tls).length >= 1;
const subKeys = Object.keys(tls).filter((k) => k !== "main");
return subKeys.length >= hosts;
},
{ timeout: timeoutMs },
)
.catch(() => {});
// Wait for shader transition pre-rendering to complete (if active). // Wait for shader transition pre-rendering (HyperShader IndexedDB hydration).
// // Uses the ready state flag as primary signal, with the loading overlay
// Two failure modes existed with the previous overlay-only check: // display:none as a fallback for older builds.
// 1. Cold cache: HyperShader creates [data-hyper-shader-loading] but never
// removes it from the DOM — it only sets display:none. Checking for
// element *absence* never resolved, so the wait always timed out at 60s.
// 2. Warm cache: HyperShader loads frames from IndexedDB without showing
// the overlay at all. Checking for element absence resolved instantly
// (no element) while hydration was still running in the background.
//
// Fix: use window.__hf.shaderTransitions[].ready as the primary signal
// (set after both warm and cold cache paths complete), with the overlay
// display:none as a fallback for older builds that lack the ready state.
await page await page
.waitForFunction( .waitForFunction(
() => { () => {
const win = window as unknown as { const win = window as unknown as {
__hf?: { shaderTransitions?: Record<string, { ready?: boolean }> }; __hf?: { shaderTransitions?: Record<string, { ready?: boolean }> };
}; };
// Primary: HyperShader ready state — authoritative for both cache paths
const shaderTransitions = win.__hf?.shaderTransitions; const shaderTransitions = win.__hf?.shaderTransitions;
if (shaderTransitions !== undefined) { if (shaderTransitions !== undefined) {
return Object.values(shaderTransitions).every((s) => s.ready === true); return Object.values(shaderTransitions).every((s) => s.ready === true);
} }
// Fallback: overlay visibility (older builds without ready state).
// Check display:none rather than element absence — element stays in
// the DOM when hidden.
const overlay = document.querySelector( const overlay = document.querySelector(
"[data-hyper-shader-loading]", "[data-hyper-shader-loading]",
) as HTMLElement | null; ) as HTMLElement | null;
@@ -196,7 +162,9 @@ async function captureSnapshots(
}, },
{ timeout: 90_000 }, { timeout: 90_000 },
) )
.catch(() => {}); .catch(() => {
console.warn(` ${c.warn("⚠")} Shader transitions did not finish pre-rendering`);
});
// Wait for fonts to finish loading before capturing // Wait for fonts to finish loading before capturing
await page.evaluate(() => document.fonts.ready).catch(() => {}); await page.evaluate(() => document.fonts.ready).catch(() => {});
@@ -227,20 +195,14 @@ async function captureSnapshots(
} }
} }
// Get composition duration
const duration = await page.evaluate(() => { const duration = await page.evaluate(() => {
const win = window as any; const win = window as any;
const pd = win.__player?.duration; if (typeof win.__player?.getDuration === "function") {
if (pd != null) return typeof pd === "function" ? pd() : pd; const d = win.__player.getDuration();
if (Number.isFinite(d) && d > 0) return d;
}
const root = document.querySelector("[data-composition-id][data-duration]"); const root = document.querySelector("[data-composition-id][data-duration]");
if (root) return parseFloat(root.getAttribute("data-duration") ?? "0"); if (root) return parseFloat(root.getAttribute("data-duration") ?? "0");
const tls = win.__timelines;
if (tls) {
for (const key in tls) {
const d = tls[key]?.duration;
if (d != null) return typeof d === "function" ? d() : d;
}
}
return 0; return 0;
}); });
@@ -255,27 +217,21 @@ async function captureSnapshots(
? [duration / 2] ? [duration / 2]
: Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration); : Array.from({ length: numFrames }, (_, i) => (i / (numFrames - 1)) * duration);
// Create output directory and clear previous frames so old captures
// don't mix with the current run in contact sheets.
const snapshotDir = join(projectDir, "snapshots"); const snapshotDir = join(projectDir, "snapshots");
mkdirSync(snapshotDir, { recursive: true }); mkdirSync(snapshotDir, { recursive: true });
try { try {
const { readdirSync, rmSync } = await import("node:fs"); const { readdirSync } = await import("node:fs");
for (const file of readdirSync(snapshotDir)) { for (const file of readdirSync(snapshotDir)) {
if (/\.(png|jpg|jpeg)$/i.test(file)) { if (/\.(png|jpg|jpeg)$/i.test(file)) {
rmSync(join(snapshotDir, file), { force: true }); rmSync(join(snapshotDir, file), { force: true });
} }
} }
} catch { } catch {
/* best-effort clear — proceed even if cleanup fails */ /* best-effort — proceed even if cleanup fails */
} }
// Lazily load the engine's <img>-overlay injector. Chrome-headless cannot // Chrome-headless ignores programmatic <video>.currentTime writes, so
// reliably advance <video>.currentTime mid-seek (the setter is accepted but // we extract frames via FFmpeg and overlay them as <img> elements.
// the decoder ignores it without user activation), so the render pipeline
// already extracts each frame via FFmpeg and injects it as an <img> sibling
// over the <video>. We reuse that same primitive here so `snapshot` and
// `render` behave identically for timed <video data-start> elements.
type InjectFn = ( type InjectFn = (
page: unknown, page: unknown,
updates: Array<{ videoId: string; dataUri: string }>, updates: Array<{ videoId: string; dataUri: string }>,
@@ -312,30 +268,30 @@ async function captureSnapshots(
return pending; return pending;
}; };
// Seek and capture each frame const hasPlayer = await page.evaluate(() => !!(window as any).__player);
if (!hasPlayer) {
console.warn(` ${c.warn("⚠")} No player API — seeks will be skipped`);
}
for (let i = 0; i < positions.length; i++) { for (let i = 0; i < positions.length; i++) {
const time = positions[i]!; const time = positions[i]!;
await page.evaluate((t: number) => { await page.evaluate((t: number) => {
const win = window as any; const player = (window as any).__player;
const player = win.__player; if (!player) return;
if (player) { const safe = Math.max(0, Number(t) || 0);
const fps = 30; const frame = Math.floor(safe * 30 + 1e-9);
const safe = Math.max(0, Number(t) || 0); const quantized = frame / 30;
const frame = Math.floor(safe * fps + 1e-9); if (typeof player.renderSeek === "function") {
const quantized = frame / fps; player.renderSeek(quantized);
if (typeof player.renderSeek === "function") { } else if (typeof player.seek === "function") {
player.renderSeek(quantized); player.seek(quantized);
} else if (typeof player.seek === "function") {
player.seek(quantized);
}
} }
if (win.gsap?.ticker?.tick) { if ((window as any).gsap?.ticker?.tick) {
win.gsap.ticker.tick(); (window as any).gsap.ticker.tick();
} }
}, time); }, time);
// Wait for rendering to settle — match the parity harness pattern
await page.evaluate(`new Promise(function(r) { await page.evaluate(`new Promise(function(r) {
var settled = false; var settled = false;
function finish() { if (settled) return; settled = true; r(); } function finish() { if (settled) return; settled = true; r(); }
@@ -343,18 +299,7 @@ async function captureSnapshots(
requestAnimationFrame(function() { requestAnimationFrame(finish); }); requestAnimationFrame(function() { requestAnimationFrame(finish); });
})`); })`);
// ─── Inject real video frames over any active <video data-start> ───
// Without this, Chrome-headless renders them blank/first-frame because
// it silently drops programmatic `currentTime` writes during capture.
// No-op when the composition has no timed videos (basecamp, linear, etc.)
if (injectVideoFramesBatch && syncVideoFrameVisibility) { if (injectVideoFramesBatch && syncVideoFrameVisibility) {
// Mirror the runtime's media math in packages/core/src/runtime/media.ts
// so clips with non-1 `defaultPlaybackRate` get the right active
// window and the right `relTime`:
// playbackRate = clamp(defaultPlaybackRate, 0.1, 5) — default 1
// duration fallback = (sourceDuration - mediaStart) / playbackRate
// relTime = (t - start) * playbackRate + mediaStart
// active = t >= start && t < start+duration && relTime >= 0
const active = await page.evaluate((t: number) => { const active = await page.evaluate((t: number) => {
return Array.from(document.querySelectorAll("video[data-start]")) return Array.from(document.querySelectorAll("video[data-start]"))
.map((el) => { .map((el) => {
@@ -390,11 +335,6 @@ async function captureSnapshots(
const updates: Array<{ videoId: string; dataUri: string }> = []; const updates: Array<{ videoId: string; dataUri: string }> = [];
for (const v of active) { for (const v of active) {
// The page-served URL (http://127.0.0.1:PORT/relative/path.mp4)
// maps 1:1 to <projectDir>/relative/path.mp4. decodeURIComponent
// the pathname — the file server decodes inbound requests, so a
// file with spaces in its path lives at the decoded name on disk
// while `new URL().pathname` preserves the %-encoding.
let filePath: string | null = null; let filePath: string | null = null;
try { try {
const url = new URL(v.src); const url = new URL(v.src);
@@ -420,12 +360,7 @@ async function captureSnapshots(
}); });
} }
// Always run the visibility sync — even when `active` is empty and // Sync visibility even when empty — clears stale overlays from prior seeks
// no new updates were injected. Without this, stale __render_frame__
// <img> overlays left by a previous seek (where different clips were
// active) remain visible in later snapshots, because the runtime's
// visibility toggles act on the <video> element but not its injected
// <img> sibling.
try { try {
if (updates.length > 0) { if (updates.length > 0) {
await injectVideoFramesBatch(page, updates); await injectVideoFramesBatch(page, updates);
@@ -435,8 +370,7 @@ async function captureSnapshots(
active.map((a) => a.id), active.map((a) => a.id),
); );
} catch { } catch {
// If either step fails, fall through to the plain screenshot /* fall through to plain screenshot */
// no worse than the pre-fix behaviour.
} }
} }