From f1cadf225c43261163caf9de9796cb2d57f51ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 26 Jun 2026 15:28:21 +0000 Subject: [PATCH] fix(runtime): honor render fps when seeking --- packages/core/src/runtime/init.test.ts | 94 +++++++++++++++++++ packages/core/src/runtime/init.ts | 41 ++++++++ packages/core/src/runtime/window.d.ts | 11 +++ .../engine/src/services/frameCapture.test.ts | 32 +++++++ packages/engine/src/services/frameCapture.ts | 57 ++++++----- .../src/services/distributed/renderChunk.ts | 3 + .../producer/src/services/fileServer.test.ts | 71 ++++++++++++++ packages/producer/src/services/fileServer.ts | 29 +++++- .../src/services/render/stages/probeStage.ts | 1 + .../src/services/renderOrchestrator.ts | 1 + scripts/generate-catalog-previews.ts | 6 +- scripts/generate-template-previews.ts | 6 +- 12 files changed, 326 insertions(+), 26 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 1b272e7941..2e73d4e79e 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -109,6 +109,7 @@ describe("initSandboxRuntimeModular", () => { delete window.__player; delete window.__playerReady; delete window.__renderReady; + delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG; delete window.__hfTimelinesBuilding; delete (window as { THREE?: unknown }).THREE; vi.restoreAllMocks(); @@ -146,6 +147,99 @@ describe("initSandboxRuntimeModular", () => { expect(child.style.visibility).toBe("visible"); }); + it("uses export render fps when quantizing renderSeek", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + 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", "1"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const timeline = createMockTimeline(1); + window.__timelines = { main: timeline }; + ( + window as { + __HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: "render-options" }; + } + ).__HF_EXPORT_RENDER_SEEK_CONFIG = { + fps: 60, + fpsSource: "render-options", + }; + + initSandboxRuntimeModular(); + + window.__player?.renderSeek(1 / 60); + + expect(timeline.time()).toBeCloseTo(1 / 60, 6); + expect(infoSpy).toHaveBeenCalledWith( + "[hyperframes] render runtime fps", + expect.objectContaining({ + canonicalFps: 60, + source: "render-options", + rawFpsSource: "render-options", + rawFps: 60, + }), + ); + }); + + it("surfaces unknown export render fps sources without collapsing them to render-options", () => { + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + 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", "1"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { main: createMockTimeline(1) }; + ( + window as { + __HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: string }; + } + ).__HF_EXPORT_RENDER_SEEK_CONFIG = { + fps: 60, + fpsSource: "future-source", + }; + + initSandboxRuntimeModular(); + + expect(infoSpy).toHaveBeenCalledWith( + "[hyperframes] render runtime fps", + expect.objectContaining({ + canonicalFps: 60, + source: "unknown", + rawFpsSource: "future-source", + }), + ); + }); + + it("keeps the default 30fps renderSeek grid when export render fps is absent", () => { + 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", "1"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const timeline = createMockTimeline(1); + window.__timelines = { main: timeline }; + + initSandboxRuntimeModular(); + + // This is the originally broken 60fps render sample under the historical + // 30fps runtime default: floor((1 / 60) * 30) / 30 = 0. + window.__player?.renderSeek(1 / 60); + + expect(timeline.time()).toBe(0); + }); + it("uses live child timeline duration when a composition host has no authored duration", () => { 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 1c5e5f82a1..f02de66d51 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -40,8 +40,49 @@ import { swallow } from "./diagnostics"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; +type ExportRenderFpsResolution = { + fps: number | null; + source: "render-options" | "default" | "unknown"; + rawFpsSource: unknown; + rawFps: unknown; + fallbackReason?: "missing" | "invalid"; +}; + +function resolveExportRenderFps(): ExportRenderFpsResolution { + const config = window.__HF_EXPORT_RENDER_SEEK_CONFIG; + const rawFps = config?.fps; + const rawFpsSource = config?.fpsSource; + const fps = Number(rawFps); + if (!config || rawFps == null) { + return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "missing" }; + } + if (!Number.isFinite(fps) || fps <= 0) { + return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "invalid" }; + } + const source = + rawFpsSource === "render-options" || rawFpsSource === "default" ? rawFpsSource : "unknown"; + return { + fps, + source, + rawFpsSource, + rawFps, + fallbackReason: config.fpsFallbackReason, + }; +} + export function initSandboxRuntimeModular(): void { const state = createRuntimeState(); + const exportRenderFps = resolveExportRenderFps(); + state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps; + if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) { + console.info("[hyperframes] render runtime fps", { + canonicalFps: state.canonicalFps, + source: exportRenderFps.source, + rawFpsSource: exportRenderFps.rawFpsSource, + rawFps: exportRenderFps.rawFps, + fallbackReason: exportRenderFps.fallbackReason, + }); + } let colorGradingRuntime: RuntimeColorGradingApi | null = null; let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null; let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index ff1f846e4e..789ed477ee 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -39,7 +39,18 @@ declare global { __playerReady?: boolean; __renderReady?: boolean; __hfRuntimeTeardown?: (() => void) | null; + __HF_EXPORT_RENDER_SEEK_CONFIG?: { + mode?: string; + diagnostics?: boolean; + step?: number; + offsetFraction?: number; + fps?: number; + fpsSource?: "render-options" | "default"; + fpsFallbackReason?: "missing" | "invalid"; + owner?: string; + }; __HF_PARITY_MODE?: 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; __hfThreeTime?: number; diff --git a/packages/engine/src/services/frameCapture.test.ts b/packages/engine/src/services/frameCapture.test.ts index 8e1ada294f..49e39c30bc 100644 --- a/packages/engine/src/services/frameCapture.test.ts +++ b/packages/engine/src/services/frameCapture.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { formatHttpErrorDiagnostic, + formatConsoleDiagnostic, formatNavigationFailureDiagnostic, formatNavigationStartDiagnostic, formatRequestFailureDiagnostic, @@ -118,6 +119,37 @@ describe("isFontResourceError", () => { }); }); +describe("formatConsoleDiagnostic", () => { + it("surfaces HyperFrames page logs with a dedicated host prefix", () => { + expect( + formatConsoleDiagnostic("info", "[hyperframes] render runtime fps JSHandle@object", ""), + ).toEqual({ + text: "[HyperFrames] render runtime fps JSHandle@object", + suppressHostLog: false, + }); + }); + + it("keeps font load errors in diagnostics but suppresses host log noise", () => { + expect( + formatConsoleDiagnostic( + "error", + "Failed to load resource: net::ERR_FAILED", + "https://fonts.googleapis.com/css2?family=Inter", + ), + ).toEqual({ + text: "[Browser] Failed to load resource: net::ERR_FAILED", + suppressHostLog: true, + }); + }); + + it("preserves existing browser prefixes for generic logs", () => { + expect(formatConsoleDiagnostic("warn", "careful", "")).toEqual({ + text: "[Browser:WARN] careful", + suppressHostLog: false, + }); + }); +}); + describe("navigation diagnostics", () => { it("redacts credentials, query strings, and fragments from diagnostic URLs", () => { expect( diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 8baa527b11..d9fe500f64 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -506,6 +506,36 @@ export function isFontResourceError(type: string, text: string, locationUrl: str ); } +export function formatConsoleDiagnostic( + type: string, + text: string, + locationUrl: string, +): { text: string; suppressHostLog: boolean } { + const isFontLoadError = isFontResourceError(type, text, locationUrl); + if (isFontLoadError) return { text: `[Browser] ${text}`, suppressHostLog: true }; + + if (text.startsWith("[hyperframes]")) { + return { + text: `[HyperFrames] ${text.slice("[hyperframes]".length).trim()}`, + suppressHostLog: false, + }; + } + + // Other "Failed to load resource" 404s are typically non-blocking (e.g. + // favicon, sourcemaps, optional assets). Prefix them so users know they + // are harmless and don't confuse them with real render errors. + const isResourceLoadError = type === "error" && text.startsWith("Failed to load resource"); + const prefix = isResourceLoadError + ? "[non-blocking]" + : type === "error" + ? "[Browser:ERROR]" + : type === "warn" + ? "[Browser:WARN]" + : "[Browser]"; + + return { text: `${prefix} ${text}`, suppressHostLog: false }; +} + async function pollPageExpression( page: Page, expression: string, @@ -866,32 +896,15 @@ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Prom export async function initializeSession(session: CaptureSession): Promise { const { page, serverUrl } = session; - // Forward browser console to host with [Browser] prefix - // fallow-ignore-next-line complexity + // Forward browser console to host. HyperFrames runtime logs get a dedicated + // prefix so page-context observability is visible in producer stdout. page.on("console", (msg: ConsoleMessage) => { const type = msg.type(); const text = msg.text(); const locationUrl = msg.location()?.url ?? ""; - const isFontLoadError = isFontResourceError(type, text, locationUrl); - - // Other "Failed to load resource" 404s are typically non-blocking (e.g. - // favicon, sourcemaps, optional assets). Prefix them so users know they - // are harmless and don't confuse them with real render errors. - const isResourceLoadError = - type === "error" && text.startsWith("Failed to load resource") && !isFontLoadError; - - const prefix = isResourceLoadError - ? "[non-blocking]" - : type === "error" - ? "[Browser:ERROR]" - : type === "warn" - ? "[Browser:WARN]" - : "[Browser]"; - if (!isFontLoadError) { - console.log(`${prefix} ${text}`); - } - - appendBrowserDiagnostic(session, `${prefix} ${text}`); + const diagnostic = formatConsoleDiagnostic(type, text, locationUrl); + if (!diagnostic.suppressHostLog) console.log(diagnostic.text); + appendBrowserDiagnostic(session, diagnostic.text); }); page.on("pageerror", (err) => { diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index c77d98b3a3..373a2719c2 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -500,6 +500,9 @@ export async function renderChunk( compiledDir, port: 0, preHeadScripts: [buildVirtualTimeShim({ seedRandomFromFrame: true })], + // These dimensions are frozen by the controller from the render job, so + // chunk runtime seek quantization stays on the same fps grid as capture. + fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen }, }); const captureOptions: CaptureOptions = { diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index 12cbed80e3..49f5829b6e 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -226,6 +226,77 @@ describe("isPathInside", () => { }); describe("createFileServer", () => { + async function expectInjectedRenderFps( + fps: Parameters[0]["fps"], + expected: { + value: string; + source: "render-options" | "default"; + fallbackReason?: "missing" | "invalid"; + }, + ): Promise { + const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-render-fps-")); + + try { + writeEmptyIndex(projectDir); + const server = await createFileServer({ + projectDir, + preHeadScripts: [], + headScripts: [], + ...(fps ? { fps } : {}), + }); + try { + const response = await fetch(`${server.url}/index.html`); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("window.__HF_EXPORT_RENDER_SEEK_CONFIG"); + expect(html).toContain(`var __renderFps = ${expected.value}`); + expect(html).toContain(`var __renderFpsSource = "${expected.source}"`); + if (expected.fallbackReason) { + expect(html).toContain(`var __renderFpsFallbackReason = "${expected.fallbackReason}"`); + } else { + expect(html).toContain("var __renderFpsFallbackReason = null"); + } + expect(html).toContain("fps: __renderFps"); + expect(html).toContain("fpsSource: __renderFpsSource"); + expect(html).not.toContain("[hyperframes] render fps defaulted"); + } finally { + server.close(); + } + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + } + + it("injects the requested render fps into the page render config", async () => { + await expectInjectedRenderFps({ num: 60, den: 1 }, { value: "60", source: "render-options" }); + }); + + it("injects fractional render fps without rounding", async () => { + await expectInjectedRenderFps( + { num: 24000, den: 1001 }, + { value: "23.976023976023978", source: "render-options" }, + ); + }); + + it("marks missing render fps as an explicit 30fps default", async () => { + await expectInjectedRenderFps(undefined, { + value: "30", + source: "default", + fallbackReason: "missing", + }); + }); + + it("marks invalid render fps as an explicit 30fps default", async () => { + await expectInjectedRenderFps( + { num: 60, den: 0 }, + { + value: "30", + source: "default", + fallbackReason: "invalid", + }, + ); + }); + it("serves asset files through project-root symlinked directories", async () => { const workspaceDir = mkdtempSync(join(tmpdir(), "hf-file-server-symlink-assets-")); const adsDir = join(workspaceDir, "Ads"); diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 829e3759c3..5fbdf7dd6b 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -14,6 +14,7 @@ import type { IncomingMessage } from "node:http"; import { readFileSync, existsSync, realpathSync, statSync } from "node:fs"; import { join, extname, resolve, sep } from "node:path"; import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler"; +import { fpsToNumber, type Fps } from "@hyperframes/core"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js"; import { defaultLogger, type ProducerLogger } from "../logger.js"; @@ -308,7 +309,22 @@ const RENDER_SEEK_OFFSET_FRACTION = Math.max( Math.min(0.95, Number(process.env.PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION || 0.5)), ); -const RENDER_MODE_SCRIPT = `(function() { +function resolveRenderFpsConfig(fps: Fps | undefined): { + value: number; + source: "render-options" | "default"; + fallbackReason?: "missing" | "invalid"; +} { + if (!fps) return { value: 30, source: "default", fallbackReason: "missing" }; + const value = fpsToNumber(fps); + if (!Number.isFinite(value) || value <= 0) { + return { value: 30, source: "default", fallbackReason: "invalid" }; + } + return { value, source: "render-options" }; +} + +function buildRenderModeScript(fps: Fps | undefined): string { + const renderFps = resolveRenderFpsConfig(fps); + return `(function() { var __realSetTimeout = window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetTimeout === "function" ? window.__HF_VIRTUAL_TIME__.originalSetTimeout @@ -317,11 +333,17 @@ const RENDER_MODE_SCRIPT = `(function() { var __seekDiagnostics = ${RENDER_SEEK_DIAGNOSTICS ? "true" : "false"}; var __seekStep = ${RENDER_SEEK_STEP}; var __seekOffsetFraction = ${RENDER_SEEK_OFFSET_FRACTION}; + var __renderFps = ${renderFps.value}; + var __renderFpsSource = ${JSON.stringify(renderFps.source)}; + var __renderFpsFallbackReason = ${JSON.stringify(renderFps.fallbackReason ?? null)}; window.__HF_EXPORT_RENDER_SEEK_CONFIG = { mode: __seekMode, diagnostics: __seekDiagnostics, step: __seekStep, offsetFraction: __seekOffsetFraction, + fps: __renderFps, + fpsSource: __renderFpsSource, + fpsFallbackReason: __renderFpsFallbackReason || undefined, owner: "runtime", }; function installMediaFallbackPlayer() { @@ -417,6 +439,7 @@ const RENDER_MODE_SCRIPT = `(function() { } waitForPlayer(); })();`; +} /** * Early stub: ensures `window.__hf` exists *before* any user `