Skip to content

Commit c9e8dd3

Browse files
fix(runtime): honor render fps when seeking (#1739)
1 parent 88fffb0 commit c9e8dd3

12 files changed

Lines changed: 326 additions & 26 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ describe("initSandboxRuntimeModular", () => {
109109
delete window.__player;
110110
delete window.__playerReady;
111111
delete window.__renderReady;
112+
delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG;
112113
delete window.__hfTimelinesBuilding;
113114
delete (window as { THREE?: unknown }).THREE;
114115
vi.restoreAllMocks();
@@ -146,6 +147,99 @@ describe("initSandboxRuntimeModular", () => {
146147
expect(child.style.visibility).toBe("visible");
147148
});
148149

150+
it("uses export render fps when quantizing renderSeek", () => {
151+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
152+
const root = document.createElement("div");
153+
root.setAttribute("data-composition-id", "main");
154+
root.setAttribute("data-root", "true");
155+
root.setAttribute("data-start", "0");
156+
root.setAttribute("data-duration", "1");
157+
root.setAttribute("data-width", "1920");
158+
root.setAttribute("data-height", "1080");
159+
document.body.appendChild(root);
160+
161+
const timeline = createMockTimeline(1);
162+
window.__timelines = { main: timeline };
163+
(
164+
window as {
165+
__HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: "render-options" };
166+
}
167+
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
168+
fps: 60,
169+
fpsSource: "render-options",
170+
};
171+
172+
initSandboxRuntimeModular();
173+
174+
window.__player?.renderSeek(1 / 60);
175+
176+
expect(timeline.time()).toBeCloseTo(1 / 60, 6);
177+
expect(infoSpy).toHaveBeenCalledWith(
178+
"[hyperframes] render runtime fps",
179+
expect.objectContaining({
180+
canonicalFps: 60,
181+
source: "render-options",
182+
rawFpsSource: "render-options",
183+
rawFps: 60,
184+
}),
185+
);
186+
});
187+
188+
it("surfaces unknown export render fps sources without collapsing them to render-options", () => {
189+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
190+
const root = document.createElement("div");
191+
root.setAttribute("data-composition-id", "main");
192+
root.setAttribute("data-root", "true");
193+
root.setAttribute("data-start", "0");
194+
root.setAttribute("data-duration", "1");
195+
root.setAttribute("data-width", "1920");
196+
root.setAttribute("data-height", "1080");
197+
document.body.appendChild(root);
198+
199+
window.__timelines = { main: createMockTimeline(1) };
200+
(
201+
window as {
202+
__HF_EXPORT_RENDER_SEEK_CONFIG?: { fps: number; fpsSource: string };
203+
}
204+
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
205+
fps: 60,
206+
fpsSource: "future-source",
207+
};
208+
209+
initSandboxRuntimeModular();
210+
211+
expect(infoSpy).toHaveBeenCalledWith(
212+
"[hyperframes] render runtime fps",
213+
expect.objectContaining({
214+
canonicalFps: 60,
215+
source: "unknown",
216+
rawFpsSource: "future-source",
217+
}),
218+
);
219+
});
220+
221+
it("keeps the default 30fps renderSeek grid when export render fps is absent", () => {
222+
const root = document.createElement("div");
223+
root.setAttribute("data-composition-id", "main");
224+
root.setAttribute("data-root", "true");
225+
root.setAttribute("data-start", "0");
226+
root.setAttribute("data-duration", "1");
227+
root.setAttribute("data-width", "1920");
228+
root.setAttribute("data-height", "1080");
229+
document.body.appendChild(root);
230+
231+
const timeline = createMockTimeline(1);
232+
window.__timelines = { main: timeline };
233+
234+
initSandboxRuntimeModular();
235+
236+
// This is the originally broken 60fps render sample under the historical
237+
// 30fps runtime default: floor((1 / 60) * 30) / 30 = 0.
238+
window.__player?.renderSeek(1 / 60);
239+
240+
expect(timeline.time()).toBe(0);
241+
});
242+
149243
it("uses live child timeline duration when a composition host has no authored duration", () => {
150244
const root = document.createElement("div");
151245
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,49 @@ import { swallow } from "./diagnostics";
4040
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
4141
const AUTHORED_END_ATTR = "data-hf-authored-end";
4242

43+
type ExportRenderFpsResolution = {
44+
fps: number | null;
45+
source: "render-options" | "default" | "unknown";
46+
rawFpsSource: unknown;
47+
rawFps: unknown;
48+
fallbackReason?: "missing" | "invalid";
49+
};
50+
51+
function resolveExportRenderFps(): ExportRenderFpsResolution {
52+
const config = window.__HF_EXPORT_RENDER_SEEK_CONFIG;
53+
const rawFps = config?.fps;
54+
const rawFpsSource = config?.fpsSource;
55+
const fps = Number(rawFps);
56+
if (!config || rawFps == null) {
57+
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "missing" };
58+
}
59+
if (!Number.isFinite(fps) || fps <= 0) {
60+
return { fps: null, source: "default", rawFpsSource, rawFps, fallbackReason: "invalid" };
61+
}
62+
const source =
63+
rawFpsSource === "render-options" || rawFpsSource === "default" ? rawFpsSource : "unknown";
64+
return {
65+
fps,
66+
source,
67+
rawFpsSource,
68+
rawFps,
69+
fallbackReason: config.fpsFallbackReason,
70+
};
71+
}
72+
4373
export function initSandboxRuntimeModular(): void {
4474
const state = createRuntimeState();
75+
const exportRenderFps = resolveExportRenderFps();
76+
state.canonicalFps = exportRenderFps.fps ?? state.canonicalFps;
77+
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) {
78+
console.info("[hyperframes] render runtime fps", {
79+
canonicalFps: state.canonicalFps,
80+
source: exportRenderFps.source,
81+
rawFpsSource: exportRenderFps.rawFpsSource,
82+
rawFps: exportRenderFps.rawFps,
83+
fallbackReason: exportRenderFps.fallbackReason,
84+
});
85+
}
4586
let colorGradingRuntime: RuntimeColorGradingApi | null = null;
4687
let runtimeErrorListener: ((event: ErrorEvent) => void) | null = null;
4788
let runtimeUnhandledRejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;

packages/core/src/runtime/window.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,18 @@ declare global {
3939
__playerReady?: boolean;
4040
__renderReady?: boolean;
4141
__hfRuntimeTeardown?: (() => void) | null;
42+
__HF_EXPORT_RENDER_SEEK_CONFIG?: {
43+
mode?: string;
44+
diagnostics?: boolean;
45+
step?: number;
46+
offsetFraction?: number;
47+
fps?: number;
48+
fpsSource?: "render-options" | "default";
49+
fpsFallbackReason?: "missing" | "invalid";
50+
owner?: string;
51+
};
4252
__HF_PARITY_MODE?: boolean;
53+
/** Legacy debug-only fps hint. Render-mode runtime fps uses __HF_EXPORT_RENDER_SEEK_CONFIG.fps. */
4354
__HF_FPS?: number;
4455
__HF_MAX_DURATION_SEC?: number;
4556
__hfThreeTime?: number;

packages/engine/src/services/frameCapture.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import {
33
formatHttpErrorDiagnostic,
4+
formatConsoleDiagnostic,
45
formatNavigationFailureDiagnostic,
56
formatNavigationStartDiagnostic,
67
formatRequestFailureDiagnostic,
@@ -118,6 +119,37 @@ describe("isFontResourceError", () => {
118119
});
119120
});
120121

122+
describe("formatConsoleDiagnostic", () => {
123+
it("surfaces HyperFrames page logs with a dedicated host prefix", () => {
124+
expect(
125+
formatConsoleDiagnostic("info", "[hyperframes] render runtime fps JSHandle@object", ""),
126+
).toEqual({
127+
text: "[HyperFrames] render runtime fps JSHandle@object",
128+
suppressHostLog: false,
129+
});
130+
});
131+
132+
it("keeps font load errors in diagnostics but suppresses host log noise", () => {
133+
expect(
134+
formatConsoleDiagnostic(
135+
"error",
136+
"Failed to load resource: net::ERR_FAILED",
137+
"https://fonts.googleapis.com/css2?family=Inter",
138+
),
139+
).toEqual({
140+
text: "[Browser] Failed to load resource: net::ERR_FAILED",
141+
suppressHostLog: true,
142+
});
143+
});
144+
145+
it("preserves existing browser prefixes for generic logs", () => {
146+
expect(formatConsoleDiagnostic("warn", "careful", "")).toEqual({
147+
text: "[Browser:WARN] careful",
148+
suppressHostLog: false,
149+
});
150+
});
151+
});
152+
121153
describe("navigation diagnostics", () => {
122154
it("redacts credentials, query strings, and fragments from diagnostic URLs", () => {
123155
expect(

packages/engine/src/services/frameCapture.ts

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,36 @@ export function isFontResourceError(type: string, text: string, locationUrl: str
506506
);
507507
}
508508

509+
export function formatConsoleDiagnostic(
510+
type: string,
511+
text: string,
512+
locationUrl: string,
513+
): { text: string; suppressHostLog: boolean } {
514+
const isFontLoadError = isFontResourceError(type, text, locationUrl);
515+
if (isFontLoadError) return { text: `[Browser] ${text}`, suppressHostLog: true };
516+
517+
if (text.startsWith("[hyperframes]")) {
518+
return {
519+
text: `[HyperFrames] ${text.slice("[hyperframes]".length).trim()}`,
520+
suppressHostLog: false,
521+
};
522+
}
523+
524+
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
525+
// favicon, sourcemaps, optional assets). Prefix them so users know they
526+
// are harmless and don't confuse them with real render errors.
527+
const isResourceLoadError = type === "error" && text.startsWith("Failed to load resource");
528+
const prefix = isResourceLoadError
529+
? "[non-blocking]"
530+
: type === "error"
531+
? "[Browser:ERROR]"
532+
: type === "warn"
533+
? "[Browser:WARN]"
534+
: "[Browser]";
535+
536+
return { text: `${prefix} ${text}`, suppressHostLog: false };
537+
}
538+
509539
async function pollPageExpression(
510540
page: Page,
511541
expression: string,
@@ -866,32 +896,15 @@ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Prom
866896
export async function initializeSession(session: CaptureSession): Promise<void> {
867897
const { page, serverUrl } = session;
868898

869-
// Forward browser console to host with [Browser] prefix
870-
// fallow-ignore-next-line complexity
899+
// Forward browser console to host. HyperFrames runtime logs get a dedicated
900+
// prefix so page-context observability is visible in producer stdout.
871901
page.on("console", (msg: ConsoleMessage) => {
872902
const type = msg.type();
873903
const text = msg.text();
874904
const locationUrl = msg.location()?.url ?? "";
875-
const isFontLoadError = isFontResourceError(type, text, locationUrl);
876-
877-
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
878-
// favicon, sourcemaps, optional assets). Prefix them so users know they
879-
// are harmless and don't confuse them with real render errors.
880-
const isResourceLoadError =
881-
type === "error" && text.startsWith("Failed to load resource") && !isFontLoadError;
882-
883-
const prefix = isResourceLoadError
884-
? "[non-blocking]"
885-
: type === "error"
886-
? "[Browser:ERROR]"
887-
: type === "warn"
888-
? "[Browser:WARN]"
889-
: "[Browser]";
890-
if (!isFontLoadError) {
891-
console.log(`${prefix} ${text}`);
892-
}
893-
894-
appendBrowserDiagnostic(session, `${prefix} ${text}`);
905+
const diagnostic = formatConsoleDiagnostic(type, text, locationUrl);
906+
if (!diagnostic.suppressHostLog) console.log(diagnostic.text);
907+
appendBrowserDiagnostic(session, diagnostic.text);
895908
});
896909

897910
page.on("pageerror", (err) => {

packages/producer/src/services/distributed/renderChunk.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,9 @@ export async function renderChunk(
500500
compiledDir,
501501
port: 0,
502502
preHeadScripts: [buildVirtualTimeShim({ seedRandomFromFrame: true })],
503+
// These dimensions are frozen by the controller from the render job, so
504+
// chunk runtime seek quantization stays on the same fps grid as capture.
505+
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
503506
});
504507

505508
const captureOptions: CaptureOptions = {

packages/producer/src/services/fileServer.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,77 @@ describe("parseRangeHeader", () => {
315315
});
316316

317317
describe("createFileServer", () => {
318+
async function expectInjectedRenderFps(
319+
fps: Parameters<typeof createFileServer>[0]["fps"],
320+
expected: {
321+
value: string;
322+
source: "render-options" | "default";
323+
fallbackReason?: "missing" | "invalid";
324+
},
325+
): Promise<void> {
326+
const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-render-fps-"));
327+
328+
try {
329+
writeEmptyIndex(projectDir);
330+
const server = await createFileServer({
331+
projectDir,
332+
preHeadScripts: [],
333+
headScripts: [],
334+
...(fps ? { fps } : {}),
335+
});
336+
try {
337+
const response = await fetch(`${server.url}/index.html`);
338+
expect(response.status).toBe(200);
339+
const html = await response.text();
340+
expect(html).toContain("window.__HF_EXPORT_RENDER_SEEK_CONFIG");
341+
expect(html).toContain(`var __renderFps = ${expected.value}`);
342+
expect(html).toContain(`var __renderFpsSource = "${expected.source}"`);
343+
if (expected.fallbackReason) {
344+
expect(html).toContain(`var __renderFpsFallbackReason = "${expected.fallbackReason}"`);
345+
} else {
346+
expect(html).toContain("var __renderFpsFallbackReason = null");
347+
}
348+
expect(html).toContain("fps: __renderFps");
349+
expect(html).toContain("fpsSource: __renderFpsSource");
350+
expect(html).not.toContain("[hyperframes] render fps defaulted");
351+
} finally {
352+
server.close();
353+
}
354+
} finally {
355+
rmSync(projectDir, { recursive: true, force: true });
356+
}
357+
}
358+
359+
it("injects the requested render fps into the page render config", async () => {
360+
await expectInjectedRenderFps({ num: 60, den: 1 }, { value: "60", source: "render-options" });
361+
});
362+
363+
it("injects fractional render fps without rounding", async () => {
364+
await expectInjectedRenderFps(
365+
{ num: 24000, den: 1001 },
366+
{ value: "23.976023976023978", source: "render-options" },
367+
);
368+
});
369+
370+
it("marks missing render fps as an explicit 30fps default", async () => {
371+
await expectInjectedRenderFps(undefined, {
372+
value: "30",
373+
source: "default",
374+
fallbackReason: "missing",
375+
});
376+
});
377+
378+
it("marks invalid render fps as an explicit 30fps default", async () => {
379+
await expectInjectedRenderFps(
380+
{ num: 60, den: 0 },
381+
{
382+
value: "30",
383+
source: "default",
384+
fallbackReason: "invalid",
385+
},
386+
);
387+
});
388+
318389
it("serves asset files through project-root symlinked directories", async () => {
319390
const workspaceDir = mkdtempSync(join(tmpdir(), "hf-file-server-symlink-assets-"));
320391
const adsDir = join(workspaceDir, "Ads");

0 commit comments

Comments
 (0)