diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 341fa911d..a05292339 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -97,16 +97,12 @@ async function captureSnapshots( 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 server = await serveStaticProjectHtml(projectDir, html); const savedPaths: string[] = []; try { - // 3. Launch headless Chrome const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); const chromeBrowser = await puppeteer.default.launch({ @@ -131,60 +127,33 @@ async function captureSnapshots( timeout: 10000, }); - // Wait for runtime to initialize and sub-compositions to load + // __renderReady is set after the player is constructed AND the root + // timeline is bound — waiting for it guarantees renderSeek will work. const timeoutMs = opts.timeout ?? 5000; - await page - .waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, { - timeout: timeoutMs, - }) - .catch(() => {}); + const runtimeReady = await page + .waitForFunction(() => !!(window as any).__renderReady, { timeout: timeoutMs }) + .then(() => true) + .catch(() => false); + + if (!runtimeReady) { + console.warn( + `\n ${c.warn("⚠")} Runtime did not become render-ready within ${timeoutMs}ms — snapshots may be inaccurate`, + ); + } - // Wait for ALL sub-compositions to be mounted by the runtime. - // The old check resolved when the first sub-timeline registered, causing - // "last beat black" bugs: beat-5's sub-comp hadn't loaded yet when the - // 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). - // - // Two failure modes existed with the previous overlay-only check: - // 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. + // Wait for shader transition pre-rendering (HyperShader IndexedDB hydration). + // Uses the ready state flag as primary signal, with the loading overlay + // display:none as a fallback for older builds. await page .waitForFunction( () => { const win = window as unknown as { __hf?: { shaderTransitions?: Record }; }; - // Primary: HyperShader ready state — authoritative for both cache paths const shaderTransitions = win.__hf?.shaderTransitions; if (shaderTransitions !== undefined) { 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( "[data-hyper-shader-loading]", ) as HTMLElement | null; @@ -193,9 +162,14 @@ async function captureSnapshots( }, { timeout: 90_000 }, ) - .catch(() => {}); + .catch(() => { + console.warn(` ${c.warn("⚠")} Shader transitions did not finish pre-rendering`); + }); - // Extra settle time for media, fonts, and animations to initialize + // Wait for fonts to finish loading before capturing + await page.evaluate(() => document.fonts.ready).catch(() => {}); + + // Extra settle time for media and animations to initialize await new Promise((r) => setTimeout(r, 1500)); // Font verification — report which fonts loaded vs fell back @@ -221,20 +195,14 @@ async function captureSnapshots( } } - // Get composition duration const duration = await page.evaluate(() => { const win = window as any; - const pd = win.__player?.duration; - if (pd != null) return typeof pd === "function" ? pd() : pd; + if (typeof win.__player?.getDuration === "function") { + const d = win.__player.getDuration(); + if (Number.isFinite(d) && d > 0) return d; + } const root = document.querySelector("[data-composition-id][data-duration]"); 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; }); @@ -249,27 +217,21 @@ async function captureSnapshots( ? [duration / 2] : 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"); mkdirSync(snapshotDir, { recursive: true }); try { - const { readdirSync, rmSync } = await import("node:fs"); + const { readdirSync } = await import("node:fs"); for (const file of readdirSync(snapshotDir)) { if (/\.(png|jpg|jpeg)$/i.test(file)) { rmSync(join(snapshotDir, file), { force: true }); } } } catch { - /* best-effort clear — proceed even if cleanup fails */ + /* best-effort — proceed even if cleanup fails */ } - // Lazily load the engine's -overlay injector. Chrome-headless cannot - // reliably advance