Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime/window.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions packages/engine/src/services/frameCapture.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
formatHttpErrorDiagnostic,
formatConsoleDiagnostic,
formatNavigationFailureDiagnostic,
formatNavigationStartDiagnostic,
formatRequestFailureDiagnostic,
Expand Down Expand Up @@ -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(
Expand Down
57 changes: 35 additions & 22 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/producer/src/services/distributed/renderChunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
71 changes: 71 additions & 0 deletions packages/producer/src/services/fileServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,77 @@ describe("isPathInside", () => {
});

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");
Expand Down
Loading
Loading