Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { runInNewContext } from "node:vm";
import { MEDIA_RENDER_ID_ATTR } from "@hyperframes/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureDeVerificationFrames, type CaptureSession } from "./frameCapture.js";
import { pageScreenshotCapture } from "./screenshotService.js";

vi.mock("./screenshotService.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./screenshotService.js")>()),
pageScreenshotCapture: vi.fn().mockResolvedValue(Buffer.from("frame")),
}));

function makePage() {
const seek = vi.fn();
const context = {
window: { __hf: { duration: 100, seek } },
document: { querySelectorAll: () => [], querySelector: () => null, images: [] },
__name: <T>(fn: T) => fn,
};
return {
seek,
evaluateOnNewDocument: vi.fn().mockResolvedValue(undefined),
setViewport: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn(
async (callback: string | ((...args: unknown[]) => unknown), ...args: unknown[]) =>
typeof callback === "string"
? true
: runInNewContext(`(${callback.toString()})(...args)`, { ...context, args }),
),
close: vi.fn().mockResolvedValue(undefined),
};
}

function makeSession() {
const capturePage = makePage();
const verificationPage = makePage();
const onBeforeCapture = vi.fn().mockResolvedValue(undefined);
const browser = { newPage: vi.fn().mockResolvedValue(verificationPage) };
// The page boundary is mocked; evaluate executes the actual browser callbacks in separate contexts.
const session = {
browser,
page: capturePage,
serverUrl: "http://127.0.0.1:3000",
subTimelineWaitMemo: {},
scriptLoadFailures: [],
onBeforeCapture,
options: {
width: 1920,
height: 1080,
fps: { num: 30, den: 1 },
format: "jpeg",
compositionDurationSeconds: 100,
deVerifySamples: 2,
},
} as unknown as CaptureSession;
return { session, capturePage, verificationPage, browser, onBeforeCapture };
}

afterEach(() => {
vi.unstubAllEnvs();
vi.mocked(pageScreenshotCapture).mockReset().mockResolvedValue(Buffer.from("frame"));
});

describe("drawElement verification page", () => {
it("captures ground truth without seeking or injecting into the live animation", async () => {
const { session, capturePage, verificationPage, browser, onBeforeCapture } = makeSession();
await expect(captureDeVerificationFrames(session, session.page, vi.fn())).resolves.toBe(true);

expect(capturePage.seek).not.toHaveBeenCalled();
expect(browser.newPage).toHaveBeenCalledOnce();
expect(verificationPage.seek.mock.calls.map(([time]) => time)).toEqual([0, 50, 95]);
expect(onBeforeCapture.mock.calls).toEqual([
[verificationPage, 50],
[verificationPage, 95],
]);
expect(
vi.mocked(pageScreenshotCapture).mock.calls.every(([page]) => page === verificationPage),
).toBe(true);
expect([...(session.deVerifyFrames?.keys() ?? [])]).toEqual([1500, 2850]);
expect(verificationPage.close).toHaveBeenCalledOnce();
expect(capturePage.close).not.toHaveBeenCalled();
});

it("closes the verification page and propagates a capture failure", async () => {
const { session, capturePage, verificationPage } = makeSession();
vi.mocked(pageScreenshotCapture).mockRejectedValueOnce(new Error("screenshot failed"));

await expect(captureDeVerificationFrames(session, session.page, vi.fn())).rejects.toThrow(
"screenshot failed",
);
expect(verificationPage.close).toHaveBeenCalledOnce();
expect(capturePage.seek).not.toHaveBeenCalled();
expect(session.deVerifyFrames).toBeUndefined();
});

it("closes the verification page if video injection fails", async () => {
const { session, capturePage, verificationPage, onBeforeCapture } = makeSession();
onBeforeCapture.mockRejectedValueOnce(new Error("injection failed"));

await expect(captureDeVerificationFrames(session, session.page, vi.fn())).rejects.toThrow(
"injection failed",
);
expect(verificationPage.close).toHaveBeenCalledOnce();
expect(capturePage.seek).not.toHaveBeenCalled();
expect(session.deVerifyFrames).toBeUndefined();
});

it("installs the media render-id resolvers on the verification page", async () => {
const { session, verificationPage } = makeSession();
await captureDeVerificationFrames(session, session.page, vi.fn());
expect(verificationPage.evaluateOnNewDocument).toHaveBeenCalledWith(
expect.any(Function),
MEDIA_RENDER_ID_ATTR,
);
});

it("reports a verification page that fails to load instead of throwing", async () => {
const { session, capturePage, verificationPage } = makeSession();
verificationPage.goto.mockRejectedValueOnce(new Error("Navigation timeout"));

await expect(captureDeVerificationFrames(session, session.page, vi.fn())).resolves.toBe(false);
expect(verificationPage.close).toHaveBeenCalledOnce();
expect(capturePage.seek).not.toHaveBeenCalled();
expect(session.deVerifyFrames).toBeUndefined();
});

it("does not allocate a page when verification is disabled", async () => {
vi.stubEnv("HF_DE_VERIFY", "0");
const { session, browser } = makeSession();
await captureDeVerificationFrames(session, session.page, vi.fn());
expect(browser.newPage).not.toHaveBeenCalled();
expect(pageScreenshotCapture).not.toHaveBeenCalled();
});
});
143 changes: 80 additions & 63 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,8 +1177,17 @@ async function finalizeDrawElementInit(
// not the live DOM (see the Lim 6 boundary-screenshot note).
{
const verifyStart = Date.now();
await captureDeVerificationFrames(session, page, logInitPhase);
const keepDrawElement = await captureDeVerificationFrames(session, page, logInitPhase);
session.deVerifyInitMs = Date.now() - verifyStart;
// A verification page that cannot load leaves drawElement without its
// safety net, so the session stays on its launch capture mode instead.
if (!keepDrawElement) {
session.deFallbackTrigger = "verify_page_failed";
if (transparent) {
await initTransparentBackground(session.page);
}
return;
}
}
await injectDrawElementCanvas(page, session.options.width, session.options.height);
if (transparent) {
Expand Down Expand Up @@ -3382,7 +3391,7 @@ interface StaticVerificationDependencies {
}

/**
* Prepare an isolated page for static-frame verification. Verification seeks
* Prepare an isolated page for capture verification. Verification seeks
* intentionally visit frames out of capture order on a SEPARATE page that is
* never used for the real capture — out-of-order event-callback side effects
* are contained to this disposable page and cannot corrupt sequential capture.
Expand All @@ -3400,6 +3409,7 @@ export async function createStaticVerificationPage(session: CaptureSession): Pro
w.__name = <T>(fn: T, _name: string): T => fn;
}
});
await installMediaRenderIdBridge(page);
if (session.options.variables && Object.keys(session.options.variables).length > 0) {
const variablesJson = JSON.stringify(session.options.variables);
await page.evaluateOnNewDocument((json: string) => {
Expand Down Expand Up @@ -4874,17 +4884,17 @@ export function computeDeVerifySampleFractions(k: number): number[] {
return [...Array.from({ length: k - 1 }, (_, i) => (i + 1) / k), 0.95];
}

async function captureDeVerificationFrames(
export async function captureDeVerificationFrames(
session: CaptureSession,
page: Page,
capturePage: Page,
logInitPhase: (phase: string) => void,
): Promise<void> {
): Promise<boolean> {
// Explicit HF_DE_VERIFY wins; otherwise the session's own sample count
// (raised by the parallel coordinator for multi-worker DE), then default 4.
const kRaw = Number(process.env.HF_DE_VERIFY ?? session.options.deVerifySamples ?? "4");
const k = Number.isFinite(kRaw) ? Math.max(0, Math.min(8, Math.floor(kRaw))) : 4;
if (k === 0 || process.env.HF_FORCE_DRAWELEMENT === "1") return;
if (session.options.format === "png") return; // worker-encode drain (the consumer) is jpeg-only
if (k === 0 || process.env.HF_FORCE_DRAWELEMENT === "1") return true;
if (session.options.format === "png") return true; // worker-encode drain (the consumer) is jpeg-only
const fps = fpsToNumber(session.options.fps);
// Prefer the producer-resolved duration (the range that will actually be
// drained). The page's raw __hf.duration can exceed it — timelines outrun
Expand All @@ -4893,81 +4903,88 @@ async function captureDeVerificationFrames(
// verification for exactly the comps that need it.
const duration =
session.options.compositionDurationSeconds ??
(await page.evaluate(
(await capturePage.evaluate(
() => (window as unknown as { __hf?: { duration?: number } }).__hf?.duration ?? 0,
));
const totalFrames = Math.floor(duration * fps);
if (totalFrames < 10) return;
if (totalFrames < 10) return true;
if (duration > 3600) {
// No producer duration and the page reports an implausible one.
logInitPhase(`drawElement self-verify skipped: implausible duration ${duration}s`);
return;
return true;
}
// Ground truth must show what the real capture paths would show: <video>
// pixels come from the onBeforeCapture injector. When this session has no
// injector (e.g. a probe session initialized before the render wires one
// up) a video comp's truth would screenshot black boxes and every sample
// would false-positive into the screenshot fallback — skip instead.
if (!session.onBeforeCapture) {
const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
const hasVideos = await capturePage.evaluate(() => document.querySelector("video") !== null);
if (hasVideos) {
logInitPhase("drawElement self-verify skipped: video comp without frame injector");
return;
return true;
}
}
const boundary = await computeClipBoundaryFrames(page, fps);
// Ascending order, and seek frame 0 first: GSAP .from()/overlapping tweens
// lazily record their start values on FIRST seek — scrubbing mid-timeline
// before the render's frame-0 seek corrupts those caches for the whole
// render (the detectCssEffectRisk lesson), and because DE frames and truth
// would share the corruption, PSNR would pass on the damaged output.
// Seeking 0 → ascending reproduces the render's own seek order.
const fractions = computeDeVerifySampleFractions(k);
const seekTo = async (t: number): Promise<void> => {
await page.evaluate((tt: number) => {
const hf = (
window as unknown as {
__hf?: { seek?: (x: number, options?: { suppressEvents?: boolean }) => void };
}
).__hf;
if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: true });
}, t);
};
await seekTo(quantizeTimeToFrame(0, fps));
// Force one frame so lazy tween initialization paints at t=0 state.
await pageScreenshotCapture(page, session.options);
const frames = new Map<number, Buffer>();
for (const f of fractions) {
let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
// Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
// if the nudge saturates on a boundary index, skip the sample entirely.
let guard = 0;
while (boundary.has(idx) && guard++ < 6) idx = Math.min(totalFrames - 1, idx + 2);
if (boundary.has(idx)) continue;
if (frames.has(idx)) continue;
const t = quantizeTimeToFrame(idx / fps, fps);
await seekTo(t);
// Video frame injection (same hook the real capture paths run) — without
// it, <video> elements screenshot black and every video comp would
// false-positive into the screenshot fallback.
if (session.onBeforeCapture) await session.onBeforeCapture(page, t);
// Double-capture: the first screenshot forces a frame, which is what runs
// rAF-driven callbacks (count-up text counters land a tick after seek()
// returns — a single immediate screenshot captures stale text and
// false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
// values while the DE frame was correct). NOTE: waiting on rAF via
// evaluate instead deadlocks — headless only fires rAF when a frame is
// produced, and nothing produces one until a screenshot asks.
// A late-starting lt-side-rule in a 100s comp reproduces this: verification
// seeks cache inherited visibility:hidden in its outro tween (#5415).
// Keep those seeks off the live capture page.
let page: Page;
try {
page = await createStaticVerificationPage(session);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logInitPhase(`drawElement self-verify page failed to load: ${reason}`);
return false;
}
try {
const boundary = await computeClipBoundaryFrames(page, fps);
const fractions = computeDeVerifySampleFractions(k);
const seekTo = async (t: number): Promise<void> => {
await page.evaluate((tt: number) => {
const hf = (
window as unknown as {
__hf?: { seek?: (x: number, options?: { suppressEvents?: boolean }) => void };
}
).__hf;
if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: true });
}, t);
};
await seekTo(quantizeTimeToFrame(0, fps));
// Force one frame so lazy tween initialization paints at t=0 state.
await pageScreenshotCapture(page, session.options);
frames.set(idx, await pageScreenshotCapture(page, session.options));
const frames = new Map<number, Buffer>();
for (const f of fractions) {
let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
// Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
// if the nudge saturates on a boundary index, skip the sample entirely.
let guard = 0;
while (boundary.has(idx) && guard++ < 6) idx = Math.min(totalFrames - 1, idx + 2);
if (boundary.has(idx)) continue;
if (frames.has(idx)) continue;
const t = quantizeTimeToFrame(idx / fps, fps);
await seekTo(t);
// Video frame injection (same hook the real capture paths run) — without
// it, <video> elements screenshot black and every video comp would
// false-positive into the screenshot fallback.
if (session.onBeforeCapture) await session.onBeforeCapture(page, t);
// Double-capture: the first screenshot forces a frame, which is what runs
// rAF-driven callbacks (count-up text counters land a tick after seek()
// returns — a single immediate screenshot captures stale text and
// false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
// values while the DE frame was correct). NOTE: waiting on rAF via
// evaluate instead deadlocks — headless only fires rAF when a frame is
// produced, and nothing produces one until a screenshot asks.
await pageScreenshotCapture(page, session.options);
frames.set(idx, await pageScreenshotCapture(page, session.options));
}
session.deVerifyFrames = frames;
logInitPhase(
`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`,
);
return true;
} finally {
await page.close().catch(() => {});
}
// Leave the page at frame 0 so the render's first seek starts from the
// same state as an unverified render.
await seekTo(quantizeTimeToFrame(0, fps));
session.deVerifyFrames = frames;
logInitPhase(
`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`,
);
}

function medianOf(samples: number[]): number {
Expand Down
18 changes: 18 additions & 0 deletions packages/engine/src/services/videoFrameInjector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,24 @@ describe("createVideoFrameInjector cache hygiene against page-side skips", () =>
expect(injectVideoFramesBatchMock.mock.calls[1]?.[0]).toBe(screenshotPage);
});

it("injects on a second page that one hook reaches at the same frame index", async () => {
// drawElement verification runs the session's hook on a throwaway page
// before capture starts on the real page.
const hook = createVideoFrameInjector(
fakeTable({ videoId: "clip", framePath: "/clip-7", frameIndex: 7 }),
{ frameSrcResolver: inlineResolver },
);
const verificationPage = { evaluate: async () => undefined } as unknown as Page;
const capturePage = { evaluate: async () => undefined } as unknown as Page;

injectVideoFramesBatchMock.mockResolvedValue(["clip"]);
await hook!(verificationPage, 0.7);
await hook!(capturePage, 0.7);

expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2);
expect(injectVideoFramesBatchMock.mock.calls[1]?.[0]).toBe(capturePage);
});

// Regression: WebGL/WebGPU compositions that sample a <video> as a texture
// render on `hf-seek` BEFORE frames are injected. After injecting the
// decoded frames, the hook must re-render the GPU adapters at the same time
Expand Down
7 changes: 6 additions & 1 deletion packages/engine/src/services/videoFrameInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function createVideoFrameInjector(
);
const bytesLimit = bytesLimitMb * 1024 * 1024;
const frameCache = createFrameSourceCache(entryLimit, bytesLimit, config?.frameSrcResolver);
const lastInjectedFrameByVideo = new Map<string, number>();
const lastInjectedFrameByPage = new WeakMap<Page, Map<string, number>>();
const lastCacheTouchByDir = new Map<string, number>();

/**
Expand All @@ -205,6 +205,11 @@ export function createVideoFrameInjector(

// fallow-ignore-next-line complexity
return async (page: Page, time: number, heldVideoTime?: number) => {
let lastInjectedFrameByVideo = lastInjectedFrameByPage.get(page);
if (!lastInjectedFrameByVideo) {
lastInjectedFrameByVideo = new Map<string, number>();
lastInjectedFrameByPage.set(page, lastInjectedFrameByVideo);
}
for (const cacheDir of frameLookup.frameDirs()) renewCacheLease(cacheDir);
const activePayloads = frameLookup.getActiveFramePayloads(time);
if (heldVideoTime !== undefined) {
Expand Down