fix(runtime): honor render fps when seeking (#1739)

This commit is contained in:
Miguel Ángel
2026-06-26 12:28:41 -04:00
committed by GitHub
parent 88fffb04d1
commit c9e8dd3862
12 changed files with 326 additions and 26 deletions
+94
View File
@@ -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");
+41
View File
@@ -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;
+11
View File
@@ -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;
@@ -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(
+35 -22
View File
@@ -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<void> {
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) => {
@@ -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 = {
@@ -315,6 +315,77 @@ describe("parseRangeHeader", () => {
});
describe("createFileServer", () => {
async function expectInjectedRenderFps(
fps: Parameters<typeof createFileServer>[0]["fps"],
expected: {
value: string;
source: "render-options" | "default";
fallbackReason?: "missing" | "invalid";
},
): Promise<void> {
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");
+27 -2
View File
@@ -16,6 +16,7 @@ import { readFile } from "node:fs/promises";
import { Readable } from "node:stream";
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";
@@ -390,7 +391,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
@@ -399,11 +415,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() {
@@ -499,6 +521,7 @@ const RENDER_MODE_SCRIPT = `(function() {
}
waitForPlayer();
})();`;
}
/**
* Early stub: ensures `window.__hf` exists *before* any user `<script>` in
@@ -640,6 +663,8 @@ export interface FileServerOptions {
headScripts?: string[];
/** Scripts injected before </body> of index.html. Default: render mode extension. */
bodyScripts?: string[];
/** Actual render fps so page-side runtime quantization matches the output container. */
fps?: Fps;
/** Strip embedded runtime scripts from HTML before injection. Default: true. */
stripEmbeddedRuntime?: boolean;
}
@@ -687,7 +712,7 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
const preHeadScripts = [HF_EARLY_STUB, ...(options.preHeadScripts ?? [])];
// Default scripts: Hyperframe runtime in <head>, render mode in </body>
const headScripts = options.headScripts ?? [getVerifiedHyperframeRuntimeSource()];
const bodyScripts = options.bodyScripts ?? [RENDER_MODE_SCRIPT, HF_BRIDGE_SCRIPT];
const bodyScripts = options.bodyScripts ?? [buildRenderModeScript(options.fps), HF_BRIDGE_SCRIPT];
const app = new Hono();
@@ -166,6 +166,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
fps: job.config.fps,
});
assertNotAborted();
@@ -1227,6 +1227,7 @@ export async function executeRenderJob(
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
fps: job.config.fps,
});
assertNotAborted();
observability.stageEnd("file_server", fileServerStart);
+5 -1
View File
@@ -238,7 +238,11 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({ projectDir, port: 0 });
const fileServer = await createFileServer({
projectDir,
port: 0,
fps: { num: 30, den: 1 },
});
try {
const session = await createCaptureSession(fileServer.url, framesDir, {
width,
+5 -1
View File
@@ -141,7 +141,11 @@ async function generateThumbnail(templateId: string, projectDir: string): Promis
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({ projectDir, port: 0 });
const fileServer = await createFileServer({
projectDir,
port: 0,
fps: { num: 30, den: 1 },
});
try {
const session = await createCaptureSession(fileServer.url, framesDir, {
width: config.width,