feat(producer): auto-fallback screenshot capture for raf and iframes (#331)

* fix(core): drive adapter seeks when composition has no GSAP timeline

renderSeek returned early when deps.getTimeline() was null, skipping the
onDeterministicSeek call that drives all frame adapters (CSS, WAAPI,
Lottie, Three.js). That meant compositions using any non-GSAP animation
primitive froze on their initial frame during capture.

Now we still quantize the seek time and fire onDeterministicSeek even
without a timeline, so each adapter gets a chance to advance.

GSAP compositions are unaffected — timeline-driven seek still takes the
same path it did before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(producer): auto-fallback screenshot capture for raf and iframes

Co-Authored-By: Codex <codex@openai.com>

* test(producer): add render compatibility regression fixtures

Co-Authored-By: Codex <codex@openai.com>

* fix(core): scrub CSS animations via WAAPI currentTime

Co-Authored-By: Codex <codex@openai.com>

* test(producer): cover css keyframe renders

Co-Authored-By: Codex <codex@openai.com>

* fix(producer): propagate virtual time into iframe documents

Co-Authored-By: Codex <codex@openai.com>

* test(producer): refresh iframe docker golden

Co-Authored-By: Codex <codex@openai.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
James Russo
2026-04-18 15:08:52 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Codex
parent 59aa2c9ec9
commit ad11de698c
31 changed files with 951 additions and 50 deletions
+3 -1
View File
@@ -35,7 +35,9 @@ jobs:
matrix:
include:
- shard: fast
args: "--sequential --exclude-tags slow"
args: "--sequential --exclude-tags slow,render-compat"
- shard: render-compat
args: "--sequential gsap-letters-render-compat css-spinner-render-compat raf-ball-render-compat iframe-render-compat"
- shard: styles-a
args: "style-1-prod style-2-prod style-3-prod"
- shard: styles-b
@@ -37,6 +37,7 @@ describe("css adapter", () => {
const adapter = createCssAdapter();
adapter.discover();
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [];
adapter.seek({ time: 3 });
expect(el.style.animationPlayState).toBe("paused");
@@ -58,6 +59,7 @@ describe("css adapter", () => {
const adapter = createCssAdapter({ resolveStartSeconds: () => 2 });
adapter.discover();
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [];
adapter.seek({ time: 5 });
expect(el.style.animationPlayState).toBe("paused");
@@ -80,6 +82,7 @@ describe("css adapter", () => {
const adapter = createCssAdapter();
adapter.discover();
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [];
adapter.seek({ time: 1 });
expect(el.style.animationPlayState).toBe("paused");
@@ -96,4 +99,54 @@ describe("css adapter", () => {
// Should not crash when seeking after revert
expect(() => adapter.seek({ time: 1 })).not.toThrow();
});
it("seek drives CSS animations through WAAPI currentTime when available", () => {
const el = document.createElement("div");
el.setAttribute("data-start", "1");
el.style.animationName = "spin";
document.body.appendChild(el);
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
return { animationName: "spin" } as CSSStyleDeclaration;
});
const animation = { currentTime: 0, pause: vi.fn(), play: vi.fn() } as unknown as Animation;
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [animation];
const adapter = createCssAdapter();
adapter.discover();
adapter.seek({ time: 3 });
expect(animation.currentTime).toBe(2000);
expect(animation.pause).toHaveBeenCalled();
expect(el.style.animationDelay).toBe("");
expect(el.style.animationPlayState).toBe("");
document.body.removeChild(el);
vi.restoreAllMocks();
});
it("play resumes WAAPI animations and restores inline styles", () => {
const el = document.createElement("div");
el.style.animationName = "spin";
el.style.animationPlayState = "running";
document.body.appendChild(el);
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
return { animationName: "spin" } as CSSStyleDeclaration;
});
const animation = { currentTime: 0, pause: vi.fn(), play: vi.fn() } as unknown as Animation;
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [animation];
const adapter = createCssAdapter();
adapter.discover();
adapter.play?.();
expect(animation.play).toHaveBeenCalled();
expect(el.style.animationPlayState).toBe("running");
document.body.removeChild(el);
vi.restoreAllMocks();
});
});
+78 -4
View File
@@ -9,6 +9,63 @@ export function createCssAdapter(params?: {
basePlayState: string;
}> = [];
const getAnimationsForElement = (el: HTMLElement): Animation[] => {
if (typeof el.getAnimations !== "function") return [];
try {
return el.getAnimations();
} catch {
return [];
}
};
const seekAnimations = (animations: Animation[], timeMs: number) => {
for (const animation of animations) {
try {
animation.currentTime = timeMs;
} catch {
// ignore animations that reject currentTime writes
}
try {
animation.pause();
} catch {
// infinite unresolved animations can throw on pause before currentTime sticks
}
}
};
const playAnimations = (animations: Animation[]) => {
for (const animation of animations) {
try {
animation.play();
} catch {
// ignore animation edge-cases
}
}
};
const pauseAnimations = (animations: Animation[]) => {
for (const animation of animations) {
try {
animation.pause();
} catch {
// ignore animation edge-cases
}
}
};
const restoreInlineStyles = (entry: (typeof entries)[number]) => {
if (entry.baseDelay) {
entry.el.style.animationDelay = entry.baseDelay;
} else {
entry.el.style.removeProperty("animation-delay");
}
if (entry.basePlayState) {
entry.el.style.animationPlayState = entry.basePlayState;
} else {
entry.el.style.removeProperty("animation-play-state");
}
};
return {
name: "css",
discover: () => {
@@ -32,16 +89,33 @@ export function createCssAdapter(params?: {
const start = params?.resolveStartSeconds
? params.resolveStartSeconds(entry.el)
: Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0;
const localTime = Math.max(0, time - start);
const localTimeMs = Math.max(0, time - start) * 1000;
const animations = getAnimationsForElement(entry.el);
if (animations.length > 0) {
seekAnimations(animations, localTimeMs);
continue;
}
// Fallback for environments without WAAPI-backed CSS animation handles.
entry.el.style.animationPlayState = "paused";
entry.el.style.animationDelay = `-${localTime.toFixed(3)}s`;
entry.el.style.animationDelay = `-${(localTimeMs / 1000).toFixed(3)}s`;
}
},
pause: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
entry.el.style.animationPlayState = entry.basePlayState || "paused";
if (entry.baseDelay) entry.el.style.animationDelay = entry.baseDelay;
const animations = getAnimationsForElement(entry.el);
if (animations.length > 0) {
pauseAnimations(animations);
}
restoreInlineStyles(entry);
}
},
play: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
restoreInlineStyles(entry);
playAnimations(getAnimationsForElement(entry.el));
}
},
revert: () => {
@@ -67,6 +67,22 @@ describe("waapi adapter", () => {
delete (document as any).getAnimations;
});
it("still sets currentTime when pause throws for an unresolved infinite animation", () => {
const mockAnim = {
pause: vi.fn(() => {
throw new Error("invalid state");
}),
currentTime: 0,
};
(document as any).getAnimations = vi.fn(() => [mockAnim]);
const adapter = createWaapiAdapter();
adapter.seek({ time: 1.25 });
expect(mockAnim.currentTime).toBe(1250);
delete (document as any).getAnimations;
});
it("discover is a no-op", () => {
const adapter = createWaapiAdapter();
expect(() => adapter.discover()).not.toThrow();
+6 -2
View File
@@ -9,10 +9,14 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
for (const animation of document.getAnimations()) {
try {
animation.pause();
animation.currentTime = timeMs;
} catch {
// ignore animation edge-cases
// ignore animations that reject currentTime writes
}
try {
animation.pause();
} catch {
// infinite unresolved animations can throw here until currentTime resolves
}
}
},
+1 -1
View File
@@ -1475,10 +1475,10 @@ export function initSandboxRuntimeModular(): void {
}
state.deterministicAdapters = [
createWaapiAdapter(),
createCssAdapter({
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
}),
createWaapiAdapter(),
createLottieAdapter(),
createThreeAdapter(),
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
+8 -6
View File
@@ -89,12 +89,14 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
},
renderSeek: (timeSeconds: number) => {
const timeline = deps.getTimeline();
if (!timeline) return;
const quantized = seekTimelineDeterministically(
timeline,
timeSeconds,
deps.getCanonicalFps(),
);
const canonicalFps = deps.getCanonicalFps();
// When a composition has no GSAP timeline (pure CSS / WAAPI / Lottie /
// Three.js adapters driving the animation), still seek the adapters so
// their animations advance. Without this, non-GSAP compositions freeze
// on their initial frame.
const quantized = timeline
? seekTimelineDeterministically(timeline, timeSeconds, canonicalFps)
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
deps.onDeterministicSeek(quantized);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
+31 -4
View File
@@ -163,6 +163,21 @@ export function isFontResourceError(type: string, text: string, locationUrl: str
);
}
async function pollPageExpression(
page: Page,
expression: string,
timeoutMs: number,
intervalMs: number = 100,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ready = Boolean(await page.evaluate(expression));
if (ready) return true;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return Boolean(await page.evaluate(expression));
}
export async function initializeSession(session: CaptureSession): Promise<void> {
const { page, serverUrl } = session;
@@ -213,17 +228,29 @@ export async function initializeSession(session: CaptureSession): Promise<void>
const pageReadyTimeout =
session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
await page.waitForFunction(
const pageReady = await pollPageExpression(
page,
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
{ timeout: pageReadyTimeout },
pageReadyTimeout,
);
if (!pageReady) {
throw new Error(
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
);
}
// Wait for all video elements to have loaded metadata (dimensions + duration)
// Without this, frame 0 captures videos at their 300x150 default size
await page.waitForFunction(
const videosReady = await pollPageExpression(
page,
`document.querySelectorAll("video").length === 0 || Array.from(document.querySelectorAll("video")).every(v => v.readyState >= 1)`,
{ timeout: pageReadyTimeout },
pageReadyTimeout,
);
if (!videosReady) {
throw new Error(
`[FrameCapture] video metadata not ready after ${pageReadyTimeout}ms. Video elements must load metadata before capture starts.`,
);
}
await page.evaluate(`document.fonts?.ready`);
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test";
import { HF_BRIDGE_SCRIPT, injectScriptsAtHeadStart, VIRTUAL_TIME_SHIM } from "./fileServer.js";
describe("injectScriptsIntoHtml", () => {
it("injects the virtual time shim into head content before authored scripts", () => {
const html = `<!DOCTYPE html>
<html>
<head><script>window.__order = ["authored-head"];</script></head>
<body><script>window.__order.push("authored-body");</script></body>
</html>`;
const injected = injectScriptsAtHeadStart(html, [VIRTUAL_TIME_SHIM]);
const injectedShimTag = `<script>${VIRTUAL_TIME_SHIM}</script>`;
const authoredHeadTag = `<script>window.__order = ["authored-head"];</script>`;
expect(injected.indexOf(injectedShimTag)).toBeGreaterThanOrEqual(0);
expect(injected.indexOf(injectedShimTag)).toBeLessThan(injected.indexOf(authoredHeadTag));
});
it("supports iframe html by injecting pre-head scripts without body scripts", () => {
const html =
"<!DOCTYPE html><html><head></head><body><script>window.targetLoaded = true;</script></body></html>";
const preInjected = injectScriptsAtHeadStart(html, [VIRTUAL_TIME_SHIM]);
const final = preInjected;
expect(final).toContain(VIRTUAL_TIME_SHIM);
expect(final).not.toContain("bodyOnly = true");
});
it("propagates virtual time seeks into same-origin iframe documents", () => {
expect(HF_BRIDGE_SCRIPT).toContain("function seekSameOriginChildFrames");
expect(HF_BRIDGE_SCRIPT).toContain("childWindow.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs)");
expect(HF_BRIDGE_SCRIPT).toContain("seekSameOriginChildFrames(window, nextTimeMs)");
});
});
+171 -9
View File
@@ -37,6 +37,103 @@ const MIME_TYPES: Record<string, string> = {
".otf": "font/otf",
};
const VIRTUAL_TIME_SHIM = String.raw`(function() {
if (window.__HF_VIRTUAL_TIME__) return;
var virtualNowMs = 0;
var rafId = 1;
var rafQueue = [];
var OriginalDate = Date;
var originalSetTimeout = window.setTimeout.bind(window);
var originalClearTimeout = window.clearTimeout.bind(window);
var originalSetInterval = window.setInterval.bind(window);
var originalClearInterval = window.clearInterval.bind(window);
var originalRequestAnimationFrame = window.requestAnimationFrame
? window.requestAnimationFrame.bind(window)
: null;
var originalCancelAnimationFrame = window.cancelAnimationFrame
? window.cancelAnimationFrame.bind(window)
: null;
function flushAnimationFrame() {
if (!rafQueue.length) return;
var current = rafQueue.slice();
rafQueue.length = 0;
for (var i = 0; i < current.length; i++) {
var entry = current[i];
if (entry.cancelled) continue;
try {
entry.callback(virtualNowMs);
} catch {}
}
}
function VirtualDate() {
var args = Array.prototype.slice.call(arguments);
if (!(this instanceof VirtualDate)) {
return OriginalDate.apply(null, args.length ? args : [virtualNowMs]);
}
var instance = args.length ? new (Function.prototype.bind.apply(OriginalDate, [null].concat(args)))() : new OriginalDate(virtualNowMs);
Object.setPrototypeOf(instance, VirtualDate.prototype);
return instance;
}
VirtualDate.prototype = OriginalDate.prototype;
Object.setPrototypeOf(VirtualDate, OriginalDate);
VirtualDate.now = function() { return virtualNowMs; };
VirtualDate.parse = OriginalDate.parse.bind(OriginalDate);
VirtualDate.UTC = OriginalDate.UTC.bind(OriginalDate);
try {
Object.defineProperty(window, "Date", {
configurable: true,
writable: true,
value: VirtualDate,
});
} catch {}
if (window.performance && typeof window.performance.now === "function") {
try {
Object.defineProperty(window.performance, "now", {
configurable: true,
value: function() { return virtualNowMs; },
});
} catch {}
}
window.requestAnimationFrame = function(callback) {
if (typeof callback !== "function") return 0;
var entry = { id: rafId++, callback: callback, cancelled: false };
rafQueue.push(entry);
return entry.id;
};
window.cancelAnimationFrame = function(id) {
for (var i = 0; i < rafQueue.length; i++) {
if (rafQueue[i].id === id) {
rafQueue[i].cancelled = true;
}
}
};
window.__HF_VIRTUAL_TIME__ = {
originalSetTimeout: originalSetTimeout,
originalClearTimeout: originalClearTimeout,
originalSetInterval: originalSetInterval,
originalClearInterval: originalClearInterval,
originalRequestAnimationFrame: originalRequestAnimationFrame,
originalCancelAnimationFrame: originalCancelAnimationFrame,
seekToTime: function(nextTimeMs) {
var safeTimeMs = Math.max(0, Number(nextTimeMs) || 0);
virtualNowMs = safeTimeMs;
flushAnimationFrame();
return virtualNowMs;
},
getTime: function() {
return virtualNowMs;
},
};
})();`;
/**
* Render mode extension -- adds renderSeek() for frame-accurate seeking
* without media sync (videos are replaced with frame images during render).
@@ -56,6 +153,10 @@ const RENDER_SEEK_OFFSET_FRACTION = Math.max(
);
const RENDER_MODE_SCRIPT = `(function() {
var __realSetTimeout =
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetTimeout === "function"
? window.__HF_VIRTUAL_TIME__.originalSetTimeout
: window.setTimeout.bind(window);
var __seekMode = ${JSON.stringify(RENDER_SEEK_MODE)};
var __seekDiagnostics = ${RENDER_SEEK_DIAGNOSTICS ? "true" : "false"};
var __seekStep = ${RENDER_SEEK_STEP};
@@ -149,13 +250,13 @@ const RENDER_MODE_SCRIPT = `(function() {
window.__renderReady = true;
return;
}
setTimeout(waitForPlayer, 50);
__realSetTimeout(waitForPlayer, 50);
return;
}
if (installMediaFallbackPlayer()) {
return;
}
setTimeout(waitForPlayer, 50);
__realSetTimeout(waitForPlayer, 50);
}
waitForPlayer();
})();`;
@@ -165,12 +266,45 @@ const RENDER_MODE_SCRIPT = `(function() {
* Injected after RENDER_MODE_SCRIPT so the engine's frameCapture can find window.__hf.
*/
const HF_BRIDGE_SCRIPT = `(function() {
var __realSetInterval =
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalSetInterval === "function"
? window.__HF_VIRTUAL_TIME__.originalSetInterval
: window.setInterval.bind(window);
var __realClearInterval =
window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.originalClearInterval === "function"
? window.__HF_VIRTUAL_TIME__.originalClearInterval
: window.clearInterval.bind(window);
function getDeclaredDuration() {
var root = document.querySelector('[data-composition-id]');
if (!root) return 0;
var d = Number(root.getAttribute('data-duration'));
return Number.isFinite(d) && d > 0 ? d : 0;
}
function seekSameOriginChildFrames(frameWindow, nextTimeMs) {
var frames;
try {
frames = frameWindow.frames;
} catch (_error) {
return;
}
if (!frames || typeof frames.length !== "number") return;
for (var i = 0; i < frames.length; i++) {
var childWindow = null;
try {
childWindow = frames[i];
if (!childWindow || childWindow === frameWindow) continue;
if (
childWindow.__HF_VIRTUAL_TIME__ &&
typeof childWindow.__HF_VIRTUAL_TIME__.seekToTime === "function"
) {
childWindow.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
}
} catch (_error) {
continue;
}
seekSameOriginChildFrames(childWindow, nextTimeMs);
}
}
function bridge() {
var p = window.__player;
if (!p || typeof p.renderSeek !== "function" || typeof p.getDuration !== "function") {
@@ -181,13 +315,20 @@ const HF_BRIDGE_SCRIPT = `(function() {
var d = p.getDuration();
return d > 0 ? d : getDeclaredDuration();
},
seek: function(t) { p.renderSeek(t); },
seek: function(t) {
p.renderSeek(t);
var nextTimeMs = (Math.max(0, Number(t) || 0)) * 1000;
if (window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.seekToTime === "function") {
window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
}
seekSameOriginChildFrames(window, nextTimeMs);
},
};
return true;
}
if (bridge()) return;
var iv = setInterval(function() {
if (bridge()) clearInterval(iv);
var iv = __realSetInterval(function() {
if (bridge()) __realClearInterval(iv);
}, 50);
})();`;
@@ -226,7 +367,7 @@ function stripEmbeddedRuntimeScripts(html: string): string {
return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
}
function injectScriptsIntoHtml(
export function injectScriptsIntoHtml(
html: string,
headScripts: string[],
bodyScripts: string[],
@@ -261,10 +402,24 @@ function injectScriptsIntoHtml(
return html;
}
export function injectScriptsAtHeadStart(html: string, scripts: string[]): string {
if (scripts.length === 0) return html;
const headTags = scripts.map((src) => `<script>${src}</script>`).join("\n");
if (html.includes("<head")) {
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${headTags}`);
}
if (html.includes("<body")) {
return html.replace("<body", () => `${headTags}\n<body`);
}
return headTags + "\n" + html;
}
export interface FileServerOptions {
projectDir: string;
compiledDir?: string;
port?: number;
/** Scripts injected into <head> of every served HTML file before authored scripts. */
preHeadScripts?: string[];
/** Scripts injected into <head> of index.html. Default: verified Hyperframe runtime. */
headScripts?: string[];
/** Scripts injected before </body> of index.html. Default: render mode extension. */
@@ -282,6 +437,7 @@ export interface FileServerHandle {
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
const preHeadScripts = 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];
@@ -313,9 +469,13 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
if (ext === ".html") {
const rawHtml = readFileSync(filePath, "utf-8");
const isIndex = relativePath === "index.html";
const html = isIndex
? injectScriptsIntoHtml(rawHtml, headScripts, bodyScripts, stripEmbeddedRuntime)
: rawHtml;
let html = rawHtml;
if (preHeadScripts.length > 0) {
html = injectScriptsAtHeadStart(html, preHeadScripts);
}
html = isIndex
? injectScriptsIntoHtml(html, headScripts, bodyScripts, stripEmbeddedRuntime)
: html;
return c.text(html, 200, { "Content-Type": contentType });
}
@@ -353,3 +513,5 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
});
});
}
export { HF_BRIDGE_SCRIPT, VIRTUAL_TIME_SHIM };
@@ -2,7 +2,11 @@ import { describe, expect, it, mock, beforeAll } from "bun:test";
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectExternalAssets, inlineExternalScripts } from "./htmlCompiler.js";
import {
collectExternalAssets,
detectRenderModeHints,
inlineExternalScripts,
} from "./htmlCompiler.js";
// ── collectExternalAssets ──────────────────────────────────────────────────
@@ -228,3 +232,55 @@ describe("inlineExternalScripts", () => {
}
});
});
describe("detectRenderModeHints", () => {
it("recommends screenshot mode for iframe compositions", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<iframe src="./target.html"></iframe>
</div>
</body></html>`;
const result = detectRenderModeHints(html);
expect(result.recommendScreenshot).toBe(true);
expect(result.reasons.map((reason) => reason.code)).toEqual(["iframe"]);
});
it("recommends screenshot mode for inline requestAnimationFrame loops", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
<script>
function tick() {
requestAnimationFrame(tick);
}
tick();
</script>
</body></html>`;
const result = detectRenderModeHints(html);
expect(result.recommendScreenshot).toBe(true);
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
});
it("ignores requestAnimationFrame inside comments and external scripts", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
<script src="./runtime.js"></script>
<script>
// requestAnimationFrame(loop);
/* requestAnimationFrame(otherLoop); */
const label = "safe";
</script>
</body></html>`;
const result = detectRenderModeHints(html);
expect(result.recommendScreenshot).toBe(false);
expect(result.reasons).toEqual([]);
});
});
@@ -47,6 +47,19 @@ export interface CompiledComposition {
width: number;
height: number;
staticDuration: number;
renderModeHints: RenderModeHints;
}
export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
export interface RenderModeHint {
code: RenderModeHintCode;
message: string;
}
export interface RenderModeHints {
recommendScreenshot: boolean;
reasons: RenderModeHint[];
}
function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
@@ -57,6 +70,45 @@ function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
return Array.from(deduped.values());
}
const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
function stripJsComments(source: string): string {
return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
}
export function detectRenderModeHints(html: string): RenderModeHints {
const reasons: RenderModeHint[] = [];
const { document } = parseHTML(html);
if (document.querySelector("iframe")) {
reasons.push({
code: "iframe",
message:
"Detected <iframe> in the composition DOM. Nested iframe animation is routed through screenshot capture mode for compatibility.",
});
}
let scriptMatch: RegExpExecArray | null;
const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
const attrs = scriptMatch[1] || "";
if (/\bsrc\s*=/i.test(attrs)) continue;
const content = stripJsComments(scriptMatch[2] || "");
if (!/requestAnimationFrame\s*\(/.test(content)) continue;
reasons.push({
code: "requestAnimationFrame",
message:
"Detected raw requestAnimationFrame() in an inline script. This render is routed through screenshot capture mode with virtual time enabled.",
});
break;
}
return {
recommendScreenshot: reasons.length > 0,
reasons,
};
}
async function resolveMediaDuration(
src: string,
mediaStart: number,
@@ -907,6 +959,7 @@ export async function compileForRender(
/(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
"$1",
);
const renderModeHints = detectRenderModeHints(sanitizedHtml);
const coalescedHtml = await injectDeterministicFontFaces(
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
@@ -984,6 +1037,7 @@ export async function compileForRender(
width,
height,
staticDuration,
renderModeHints,
};
}
@@ -1149,5 +1203,6 @@ export async function recompileWithResolutions(
videos,
audios,
unresolvedCompositions: remaining,
renderModeHints: compiled.renderModeHints,
};
}
@@ -1,9 +1,15 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { EngineConfig } from "@hyperframes/engine";
import type { CompiledComposition } from "./htmlCompiler.js";
import { extractStandaloneEntryFromIndex, writeCompiledArtifacts } from "./renderOrchestrator.js";
import {
applyRenderModeHints,
extractStandaloneEntryFromIndex,
writeCompiledArtifacts,
} from "./renderOrchestrator.js";
import { toExternalAssetKey } from "../utils/paths.js";
describe("extractStandaloneEntryFromIndex", () => {
@@ -66,12 +72,6 @@ describe("extractStandaloneEntryFromIndex", () => {
});
describe("writeCompiledArtifacts — external assets on Windows drive-letter paths (GH #321)", () => {
// End-to-end seam test: covers both `toExternalAssetKey` and
// `renderOrchestrator`'s copy step by simulating a Windows absolute
// path flowing through the full external-asset pipeline. The helpers
// are logically cross-platform, but this is the integration that
// guarantees they compose — catches any regression at the boundary.
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
@@ -94,18 +94,11 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
it("copies an external asset with a Windows-style drive-letter key into compileDir", () => {
const workDir = makeWorkDir();
// Simulate a real external asset: write a dummy file to an absolute
// path, then build the sanitised key the way `collectExternalAssets`
// would on Windows.
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
tempDirs.push(sourceDir);
const srcFile = join(sourceDir, "segment.wav");
writeFileSync(srcFile, "fake wav bytes");
// The simulated Windows input is a path with backslashes and a drive
// letter — even though the test runs on Unix, the helper is expressed
// with regex on the string so we can exercise the Windows code path
// deterministically.
const windowsStyleInput = "D:\\coder\\assets\\segment.wav";
const key = toExternalAssetKey(windowsStyleInput);
expect(key).toBe("hf-ext/D/coder/assets/segment.wav");
@@ -121,9 +114,13 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
width: 1920,
height: 1080,
staticDuration: 10,
renderModeHints: {
recommendScreenshot: false,
reasons: [],
},
};
writeCompiledArtifacts(compiled, workDir, /* includeSummary */ false);
writeCompiledArtifacts(compiled, workDir, false);
const landed = join(workDir, "compiled", key);
expect(existsSync(landed)).toBe(true);
@@ -131,8 +128,6 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
});
it("rejects a maliciously crafted key that tries to escape compileDir", () => {
// Defense-in-depth: if a buggy upstream produced a key with `..`
// components, `isPathInside` at copy time must catch it and skip.
const workDir = makeWorkDir();
const sourceDir = mkdtempSync(join(tmpdir(), "hf-src-"));
tempDirs.push(sourceDir);
@@ -150,14 +145,102 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
width: 1920,
height: 1080,
staticDuration: 10,
renderModeHints: {
recommendScreenshot: false,
reasons: [],
},
};
writeCompiledArtifacts(compiled, workDir, false);
// Assert that the file was NOT written outside compileDir (the
// attacker's target). We check the escape destination didn't
// materialise next to workDir.
const escapeTarget = join(workDir, "..", "..", "etc", "passwd");
expect(existsSync(escapeTarget)).toBe(false);
});
});
describe("applyRenderModeHints", () => {
function createCompiledComposition(
reasonCodes: Array<"iframe" | "requestAnimationFrame">,
): CompiledComposition {
return {
html: "<html></html>",
subCompositions: new Map(),
videos: [],
audios: [],
unresolvedCompositions: [],
externalAssets: new Map(),
width: 1920,
height: 1080,
staticDuration: 5,
renderModeHints: {
recommendScreenshot: reasonCodes.length > 0,
reasons: reasonCodes.map((code) => ({
code,
message: `reason: ${code}`,
})),
},
};
}
function createConfig(): EngineConfig {
return {
fps: 30,
quality: "standard",
format: "jpeg",
jpegQuality: 80,
concurrency: "auto",
coresPerWorker: 2.5,
minParallelFrames: 120,
largeRenderThreshold: 1000,
disableGpu: false,
enableBrowserPool: false,
browserTimeout: 120000,
protocolTimeout: 300000,
forceScreenshot: false,
enableChunkedEncode: false,
chunkSizeFrames: 360,
enableStreamingEncode: false,
ffmpegEncodeTimeout: 600000,
ffmpegProcessTimeout: 300000,
ffmpegStreamingTimeout: 600000,
audioGain: 1.35,
frameDataUriCacheLimit: 256,
playerReadyTimeout: 45000,
renderReadyTimeout: 15000,
verifyRuntime: true,
debug: false,
};
}
it("forces screenshot mode when compatibility hints recommend it", () => {
const cfg = createConfig();
const compiled = createCompiledComposition(["iframe", "requestAnimationFrame"]);
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
applyRenderModeHints(cfg, compiled, log);
expect(cfg.forceScreenshot).toBe(true);
expect(log.warn).toHaveBeenCalledOnce();
});
it("does nothing when screenshot mode is already forced", () => {
const cfg = createConfig();
cfg.forceScreenshot = true;
const compiled = createCompiledComposition(["iframe"]);
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
applyRenderModeHints(cfg, compiled, log);
expect(log.warn).not.toHaveBeenCalled();
});
});
@@ -59,7 +59,7 @@ import { join, dirname, resolve } from "path";
import { randomUUID } from "crypto";
import { freemem } from "os";
import { fileURLToPath } from "url";
import { createFileServer, type FileServerHandle } from "./fileServer.js";
import { createFileServer, type FileServerHandle, VIRTUAL_TIME_SHIM } from "./fileServer.js";
import {
compileForRender,
resolveCompositionDurations,
@@ -300,11 +300,26 @@ export function writeCompiledArtifacts(
mediaStart: a.mediaStart,
})),
subCompositions: Array.from(compiled.subCompositions.keys()),
renderModeHints: compiled.renderModeHints,
};
writeFileSync(join(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
}
}
export function applyRenderModeHints(
cfg: EngineConfig,
compiled: CompiledComposition,
log: ProducerLogger = defaultLogger,
): void {
if (cfg.forceScreenshot || !compiled.renderModeHints.recommendScreenshot) return;
cfg.forceScreenshot = true;
log.warn("Auto-selected screenshot capture mode for render compatibility", {
reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code),
reasons: compiled.renderModeHints.reasons.map((reason) => reason.message),
});
}
export function createRenderJob(config: RenderConfig): RenderJob {
return {
id: randomUUID(),
@@ -459,6 +474,7 @@ export async function executeRenderJob(
let compiled = await compileForRender(projectDir, htmlPath, join(workDir, "downloads"));
assertNotAborted();
perfStages.compileOnlyMs = Date.now() - compileStart;
applyRenderModeHints(cfg, compiled, log);
writeCompiledArtifacts(compiled, workDir, Boolean(job.config.debug));
log.info("Compiled composition metadata", {
@@ -468,6 +484,7 @@ export async function executeRenderJob(
height: compiled.height,
videoCount: compiled.videos.length,
audioCount: compiled.audios.length,
renderModeHints: compiled.renderModeHints,
});
const composition: CompositionMetadata = {
@@ -492,6 +509,7 @@ export async function executeRenderJob(
projectDir,
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
});
assertNotAborted();
@@ -790,6 +808,7 @@ export async function executeRenderJob(
projectDir,
compiledDir: join(workDir, "compiled"),
port: 0,
preHeadScripts: [VIRTUAL_TIME_SHIM],
});
assertNotAborted();
}
@@ -0,0 +1,13 @@
{
"name": "css-spinner-render-compat",
"description": "Regression test for pure CSS keyframe animations. The spinner arc must continue advancing under deterministic seek-driven renders.",
"tags": ["regression", "render-compat"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; background: #0a0a0f; width: 1920px; height: 1080px; color: #e0e0e0; font-family: system-ui, sans-serif; }
.stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 60px; }
.spinner { width: 200px; height: 200px; border: 16px solid rgba(255,255,255,0.12); border-top-color: #5eead4; border-radius: 50%; animation: spin 1s linear infinite; }
.label { font-size: 40px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="root" data-composition-id="css-spinner" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="stage clip" data-start="0" data-duration="5">
<div class="spinner"></div>
<div class="label">Loading</div>
</div>
</div>
<script>window.__timelines = window.__timelines || {};</script></body>
</html>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62973a843644f06edbb8c0c4ef342e78d3e3a164c7f8ad19bcccde4cc0bf2706
size 181125
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; background: #0a0a0f; width: 1920px; height: 1080px; color: #e0e0e0; font-family: system-ui, sans-serif; }
.stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 60px; }
.spinner { width: 200px; height: 200px; border: 16px solid rgba(255,255,255,0.12); border-top-color: #5eead4; border-radius: 50%; animation: spin 1s linear infinite; }
.label { font-size: 40px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="root" data-composition-id="css-spinner" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="stage clip" data-start="0" data-duration="5">
<div class="spinner"></div>
<div class="label">Loading</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
</script>
</body>
</html>
@@ -0,0 +1,13 @@
{
"name": "gsap-letters-render-compat",
"description": "Regression guard for the GSAP-only baseline. This suite must stay visually stable while render-compat fallback logic changes around it.",
"tags": ["regression", "render-compat"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:52d1b047b61edce8109ed49bf3812d73030c0d9d65b0075a9f788a4e31d11ce2
size 420071
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; background: #0a0a0f; width: 1920px; height: 1080px; font-family: "Inter", system-ui, sans-serif; color: #f5f5f5; }
.stage { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 18px; }
.letter { font-size: 180px; font-weight: 800; letter-spacing: -0.04em; display: inline-block; transform: translateY(-400px); opacity: 0; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head>
<body>
<div id="root" data-composition-id="gsap-letters"
data-width="1920" data-height="1080"
data-start="0" data-duration="4">
<div class="stage clip" data-start="0" data-duration="4">
<span class="letter">H</span><span class="letter">Y</span><span class="letter">P</span><span class="letter">E</span><span class="letter">R</span><span class="letter">F</span><span class="letter">R</span><span class="letter">A</span><span class="letter">M</span><span class="letter">E</span><span class="letter">S</span>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".letter", { y: 0, opacity: 1, duration: 0.5, stagger: 0.08, ease: "back.out(1.8)" }, 0);
tl.to(".letter", { rotation: 720, y: 400, opacity: 0, duration: 0.7, stagger: 0.05, ease: "power2.in" }, 2.5);
window.__timelines["gsap-letters"] = tl;
</script>
</body>
</html>
@@ -0,0 +1,13 @@
{
"name": "iframe-render-compat",
"description": "Regression test for nested iframe compositions. Linux renders must auto-fallback and keep child-document motion visible.",
"tags": ["regression", "render-compat"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<style>body { margin: 0; background: #000; width: 1920px; height: 1080px; } iframe { border: 0; display: block; }</style>
</head>
<body>
<div id="root" data-composition-id="iframe-test" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<iframe src="target.html" width="1920" height="1080" class="clip" data-start="0" data-duration="5"></iframe>
</div>
<script>window.__timelines = window.__timelines || {};</script></body>
</html>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b15b0eab1044a450bd347a314adf786c50a2e6d0826ddc7a572b6fb59d502e4
size 299896
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<style>body { margin: 0; background: #000; width: 1920px; height: 1080px; } iframe { border: 0; display: block; }</style>
</head>
<body>
<div id="root" data-composition-id="iframe-test" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<iframe src="target.html" width="1920" height="1080" class="clip" data-start="0" data-duration="5"></iframe>
</div>
<script>window.__timelines = window.__timelines || {};</script>
</body>
</html>
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; padding: 0; width: 1920px; height: 1080px; background: #111; color: #fff; font-family: system-ui, sans-serif; overflow: hidden; }
.stage { display:flex; align-items:center; justify-content:center; width:100%; height:100%; gap:120px; }
.dial {
width: 400px; height: 400px;
border: 20px solid rgba(255,255,255,0.12);
border-top-color: #60a5fa;
border-radius: 50%;
transform: rotate(0deg);
}
.bar {
width: 600px; height: 120px;
background: linear-gradient(90deg, #f472b6, #60a5fa);
transform: translateX(-300px);
}
.label { font-size: 80px; font-weight: 800; }
</style>
</head>
<body>
<div class="stage">
<div class="dial" id="dial"></div>
<div>
<div class="label">LIVE SITE</div>
<div class="bar" id="bar"></div>
</div>
</div>
<script>
const dial = document.getElementById("dial");
const bar = document.getElementById("bar");
function animate(timeMs) {
const rotation = (timeMs / 2000) * 360;
const offset = Math.sin((timeMs / 3000) * Math.PI) * 300;
dial.style.transform = `rotate(${rotation.toFixed(3)}deg)`;
bar.style.transform = `translateX(${offset.toFixed(3)}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
</script>
</body>
</html>
@@ -0,0 +1,13 @@
{
"name": "raf-ball-render-compat",
"description": "Regression test for inline requestAnimationFrame compositions. Linux renders must auto-fallback and preserve visible motion.",
"tags": ["regression", "render-compat"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<style>body { margin: 0; background: #000; width: 1920px; height: 1080px; } canvas { display: block; }</style>
</head>
<body>
<div id="root" data-composition-id="raf-ball" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<canvas id="c" width="1920" height="1080" class="clip" data-start="0" data-duration="5"></canvas>
</div>
<script>const ctx = document.getElementById('c').getContext('2d');
function draw(t) {
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 1920, 1080);
const x = 960 + Math.sin(t / 1000 * Math.PI) * 700;
ctx.fillStyle = '#f87171';
ctx.beginPath(); ctx.arc(x, 540, 80, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#9ca3af'; ctx.font = '32px system-ui, sans-serif';
ctx.fillText(`t = ${t.toFixed(0)} ms`, 40, 60);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
window.__timelines = window.__timelines || {};</script></body>
</html>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62d8f9eb45b68d96242e7c8cf01fe4e74a142c5dcfe82953724fd98d7567aa92
size 177730
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<style>body { margin: 0; background: #000; width: 1920px; height: 1080px; } canvas { display: block; }</style>
</head>
<body>
<div id="root" data-composition-id="raf-ball" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<canvas id="c" width="1920" height="1080" class="clip" data-start="0" data-duration="5"></canvas>
</div>
<script>
const ctx = document.getElementById('c').getContext('2d');
function draw(t) {
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 1920, 1080);
const x = 960 + Math.sin(t / 1000 * Math.PI) * 700;
ctx.fillStyle = '#f87171';
ctx.beginPath(); ctx.arc(x, 540, 80, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#9ca3af'; ctx.font = '32px system-ui, sans-serif';
ctx.fillText(`t = ${t.toFixed(0)} ms`, 40, 60);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
window.__timelines = window.__timelines || {};
</script>
</body>
</html>