From cf15892425245da96dc43d858a7478031533d2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 29 Oct 2026 06:44:51 +0000 Subject: [PATCH] fix(cli): a snapshot still shows what playback shows, whatever other times are asked for (#6720) * fix(cli): a snapshot still shows what playback shows, whatever other times are asked for * test(cli): pin that still-capture pages ask for the skipped-frame replay --- ...reCompositionFrame.browserFallback.test.ts | 9 ++++++ .../src/capture/captureCompositionFrame.ts | 1 + packages/core/src/runtime/init.test.ts | 31 +++++++++++++++++++ packages/core/src/runtime/init.ts | 3 +- packages/core/src/runtime/window.d.ts | 2 ++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts index 8400a665d4..f2f93673c7 100644 --- a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts @@ -269,4 +269,13 @@ describe("openSettledCompositionPage when nothing may listen", () => { expect(launched().args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); expect(opened.page.setRequestInterception).not.toHaveBeenCalled(); }); + + it("asks the page to replay skipped frames, so a still shows what playback shows", async () => { + const opened = fakeBrowser(); + mocks.launch.mockResolvedValue(opened.browser); + await openSettledCompositionPage(HTML, "http://127.0.0.1:3000", OPTIONS); + expect(opened.page.evaluateOnNewDocument).toHaveBeenCalledWith( + "window.__HF_REPLAY_SKIPPED_FRAMES = true", + ); + }); }); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 9110ab0a9d..5cf0ec6614 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -340,6 +340,7 @@ export async function openSettledCompositionPage( await import("@hyperframes/engine"); await installMediaRenderIdBridge(page); await page.evaluateOnNewDocument(forcePreservedDrawingBuffer); + await page.evaluateOnNewDocument("window.__HF_REPLAY_SKIPPED_FRAMES = true"); await page.setViewport(viewport); if (answer) await answerPageInProcess(page, url, answer); await options.beforeNavigate?.(page); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 8423788bdc..acb2a951ec 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -263,6 +263,7 @@ describe("initSandboxRuntimeModular", () => { delete window.__playerReady; delete window.__renderReady; delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG; + delete window.__HF_REPLAY_SKIPPED_FRAMES; delete window.__hfTimelinesBuilding; delete (window as { THREE?: unknown }).THREE; delete (window as { __hfAutoNoopRegistered?: boolean }).__hfAutoNoopRegistered; @@ -1200,6 +1201,36 @@ describe("initSandboxRuntimeModular", () => { }); }); + it("gives a still the value playback shows when its capture page asks to replay skipped frames", () => { + const zoomFilm = () => { + const params = { z: 1 }; + const timeline = gsap.timeline({ paused: true }); + timeline.to(params, { z: 1.45, duration: 0.3, ease: "power4.out" }, 0.573); + timeline.set(params, { z: 1.04 }, 0.8); + timeline.to(params, { z: 1.12, duration: 1.39, ease: "power1.in" }, 0.869); + return { params, timeline }; + }; + const played = zoomFilm(); + for (let frame = 0; frame <= 56; frame++) played.timeline.totalTime(frame / 60, true); + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "3"); + root.setAttribute("data-fps", "60"); + root.setAttribute("data-width", "640"); + root.setAttribute("data-height", "360"); + document.body.appendChild(root); + const still = zoomFilm(); + window.__timelines = { main: still.timeline as unknown as RuntimeTimelineLike }; + window.__HF_REPLAY_SKIPPED_FRAMES = true; + + initSandboxRuntimeModular(); + window.__player?.renderSeek(56 / 60); + + expect(still.params.z).toBeCloseTo(played.params.z, 9); + }); + it("activates a nested outro on frame 584 when its authored start rounds just above it", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 299c0f5874..4af3d1fcd3 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -549,8 +549,9 @@ export function initSandboxRuntimeModular(): void { ensureAudioGroupInertStyle(document); ensureColorGroupInertStyle(document); const exportRenderFps = resolveExportRenderFps(); - const renderFps = exportRenderFps.fps ?? undefined; state.canonicalFps = exportRenderFps.fps ?? compositionFps() ?? state.canonicalFps; + const renderFps = + exportRenderFps.fps ?? (window.__HF_REPLAY_SKIPPED_FRAMES ? state.canonicalFps : undefined); setRuntimeProtocolFps(state.canonicalFps); if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) { const fpsDetail = JSON.stringify({ diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 3b94f4b8e2..b68ebe1a1f 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -115,6 +115,8 @@ declare global { owner?: string; }; __HF_PARITY_MODE?: boolean; + /** Set by still-capture pages: seeks replay skipped frames at the composition fps, as a render does. */ + __HF_REPLAY_SKIPPED_FRAMES?: boolean; /** Legacy debug-only fps hint. Render-mode runtime fps uses __HF_EXPORT_RENDER_SEEK_CONFIG.fps. */ __HF_FPS?: number; __HF_MAX_DURATION_SEC?: number;