diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 44f2c09c96b..69fa311aa85 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -16,6 +16,8 @@ import { resolveCropRegion, runFfmpegOnce, seekCompositionTimeline, + seekCompositionTimelineOnly, + settleCompositionSeek, waitForRuntimeReady, type CompositionSeekPage, } from "./captureCompositionFrame.js"; @@ -116,6 +118,18 @@ describe("seekCompositionTimeline", () => { expect(evaluate).toHaveBeenNthCalledWith(4, expect.any(Function), { ms: 500, frames: 1 }); }); + it("can seek without finishing the frame, so overlays land before a blurred frame is drawn", async () => { + const { page, evaluate } = fakeSeekPage(); + + await seekCompositionTimelineOnly(page, 1.25); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false, false, false); + + await settleCompositionSeek(page); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function), { videoFrames: true }); + expect(evaluate).toHaveBeenCalledTimes(4); + }); + it("waitForFontsMs: 0 disables the post-seek font wait", async () => { const { page, evaluate } = fakeSeekPage(); @@ -285,7 +299,7 @@ describe("seekCompositionTimeline", () => { }).then(() => { settled = true; }); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(evaluateCall).toBe(2); expect(settled).toBe(false); completeGpu?.(); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index a06cb228029..065bb610bc9 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -510,6 +510,16 @@ export async function seekCompositionTimeline( page: CompositionSeekPage, timeSeconds: number, options: SeekCompositionTimelineOptions = {}, +): Promise { + await seekCompositionTimelineOnly(page, timeSeconds, options); + await settleCompositionSeek(page, options); +} + +/** The seek alone; `settleCompositionSeek` must follow, which is what finishes a blurred frame. */ +export async function seekCompositionTimelineOnly( + page: CompositionSeekPage, + timeSeconds: number, + options: SeekCompositionTimelineOptions = {}, ): Promise { if (options.waitForPreferredSeekTargetMs !== undefined) { await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs); @@ -522,7 +532,12 @@ export async function seekCompositionTimeline( options.exactTime === true, options.skipWebglDraws === true, ); +} +export async function settleCompositionSeek( + page: CompositionSeekPage, + options: SeekCompositionTimelineOptions = {}, +): Promise { await page.evaluate( async (barrier: SeekBarrierOptions) => { const waitForCompletion = Reflect.get(window, "__hfWaitForSeekCompletion"); diff --git a/packages/cli/src/commands/snapshot-contactSheet.test.ts b/packages/cli/src/commands/snapshot-contactSheet.test.ts index 0f0b4e7a702..de1114f8c21 100644 --- a/packages/cli/src/commands/snapshot-contactSheet.test.ts +++ b/packages/cli/src/commands/snapshot-contactSheet.test.ts @@ -31,6 +31,8 @@ vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({ renderReadyTimedOut: false, })), seekCompositionTimeline: capture.seek, + seekCompositionTimelineOnly: capture.seek, + settleCompositionSeek: vi.fn(async () => undefined), })); vi.mock("../utils/staticProjectServer.js", () => ({ diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 130de77f0ab..df0413aa021 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -14,13 +14,15 @@ const snapshotState = vi.hoisted(() => ({ seek: vi.fn(async (_page: unknown, _time: number, _options?: unknown): Promise => { throw new Error("seek reached"); }), + settle: vi.fn(async (_page: unknown, _options?: unknown): Promise => undefined), closeServer: vi.fn(async () => undefined), })); vi.mock("../capture/captureCompositionFrame.js", async (importOriginal) => ({ ...(await importOriginal()), openSettledCompositionPage: snapshotState.openSettledPage, - seekCompositionTimeline: snapshotState.seek, + seekCompositionTimelineOnly: snapshotState.seek, + settleCompositionSeek: snapshotState.settle, })); vi.mock("../utils/staticProjectServer.js", () => ({ @@ -192,6 +194,57 @@ describe("snapshot --at seeks", () => { }); }); +describe("snapshot video overlays and the blurred frame", () => { + // The runtime draws a blurred frame when the seek settles, so the video overlays must + // be in by then (the engine injects before it waits, too). + it("settles the seek only after the video overlays were read and injected", async () => { + const project = mkdtempSync(join(tmpdir(), "hf-snapshot-blur-order-")); + writeFileSync( + join(project, "index.html"), + `
Visible
`, + ); + const log: string[] = []; + let calls = 0; + const evaluate = vi.fn(async () => { + calls += 1; + if (calls === 1) return { loaded: [], errored: [], unused: [] }; + if (calls === 2) return 3; + if (calls === 3) return true; + if (calls === 4) { + log.push("read videos"); + return []; + } + return undefined; + }); + snapshotState.openSettledPage.mockResolvedValueOnce({ + browser: { close: vi.fn(async () => undefined) }, + page: { evaluate }, + renderReadyTimedOut: false, + }); + snapshotState.seek.mockReset(); + snapshotState.seek.mockImplementation(async () => { + log.push("seek"); + }); + snapshotState.settle.mockClear(); + snapshotState.settle.mockImplementation(async () => { + log.push("settle"); + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + try { + await expect( + snapshotCommand.run?.({ + args: { dir: project, at: "1", end: false, output: join(project, "out") }, + } as never), + ).rejects.toBeDefined(); + expect(log).toEqual(["seek", "read videos", "settle"]); + } finally { + logSpy.mockRestore(); + rmSync(project, { recursive: true, force: true }); + } + }); +}); + describe("resolveSnapshotVideoFrameTime", () => { it("holds a clip ending with the composition on its last decodable frame", () => { expect( diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index b7f12f92012..e382eeabed6 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -31,7 +31,8 @@ import { parseZoomTarget, resolveCropRegion, runFfmpegOnce, - seekCompositionTimeline, + seekCompositionTimelineOnly, + settleCompositionSeek, type ZoomTarget, } from "../capture/captureCompositionFrame.js"; import { @@ -748,7 +749,8 @@ async function captureSnapshots( for (let i = 0; i < positions.length; i++) { const time = positions[i]!; - await seekCompositionTimeline(page, time, { exactTime: true }); + const seekOptions = { exactTime: true }; + await seekCompositionTimelineOnly(page, time, seekOptions); if (cameraExpr) await page.evaluate(cameraExpr); @@ -892,6 +894,7 @@ async function captureSnapshots( } } + await settleCompositionSeek(page, seekOptions); await recaptureSnapshotComposite(page); const timeLabel = formatSnapshotTimestamp(time); diff --git a/packages/cli/src/server/studioServer.renderBrowser.test.ts b/packages/cli/src/server/studioServer.renderBrowser.test.ts index b5b2539d149..5377205ede5 100644 --- a/packages/cli/src/server/studioServer.renderBrowser.test.ts +++ b/packages/cli/src/server/studioServer.renderBrowser.test.ts @@ -103,3 +103,37 @@ describe("Studio render GPU encoding", () => { expect(mocks.createRenderJob).toHaveBeenCalledWith(expect.objectContaining({ useGpu: true })); }); }); + +describe("Studio render motion blur", () => { + it("hands the route's pageMotionBlur to the job", async () => { + mocks.resolveRenderBrowser.mockResolvedValue({ + executablePath: "/opt/chrome", + source: "cache", + }); + + startStudioRender({ pageMotionBlur: "off" }); + await vi.waitFor(() => expect(mocks.createRenderJob).toHaveBeenCalledTimes(1), { + timeout: 10_000, + }); + + expect(mocks.createRenderJob).toHaveBeenCalledWith( + expect.objectContaining({ pageMotionBlur: "off" }), + ); + }); + + it("sends none when the route sent none", async () => { + mocks.resolveRenderBrowser.mockResolvedValue({ + executablePath: "/opt/chrome", + source: "cache", + }); + + startStudioRender(); + await vi.waitFor(() => expect(mocks.createRenderJob).toHaveBeenCalledTimes(1), { + timeout: 10_000, + }); + + expect(mocks.createRenderJob).not.toHaveBeenCalledWith( + expect.objectContaining({ pageMotionBlur: expect.anything() }), + ); + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 69373906393..c12f01f2e0f 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -642,6 +642,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { ...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}), ...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}), ...(opts.useGpu ? { useGpu: true } : {}), + ...(opts.pageMotionBlur ? { pageMotionBlur: opts.pageMotionBlur } : {}), }); renderJob = job; const onProgress = (j: { progress: number; currentStage?: string }) => { diff --git a/packages/core/src/runtime/captureMotionBlur/discover.test.ts b/packages/core/src/runtime/captureMotionBlur/discover.test.ts index 0f0aac55a3f..2d74f448b0a 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.test.ts @@ -2,13 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { discoverMovers } from "./discover"; // jsdom lays nothing out: each element's rect follows the x its fake timeline writes. -function element(parent: Element, id: string): HTMLElement { - const el = document.createElement("div"); +function element(parent: Element, id: string, size = 10, tag = "div"): HTMLElement { + const el = document.createElement(tag); el.id = id; parent.append(el); el.getBoundingClientRect = () => { const x = parseFloat(el.style.transform.replace("translateX(", "")) || 0; - return { left: x, top: 0, right: x + 10, bottom: 10, width: 10, height: 10 } as DOMRect; + return { left: x, top: 0, right: x + size, bottom: size, width: size, height: size } as DOMRect; }; return el; } @@ -86,4 +86,159 @@ describe("discoverMovers", () => { ]) expect(discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers).toEqual([]); }); + + it("drops a mover with a 3D rotate axis or a z translate", () => { + for (const style of [{ rotate: "x 30deg" }, { translate: "1px 2px 3px" }]) { + const { scene, fast, seek } = setup(); + const real = window.getComputedStyle; + vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => + el === fast + ? ({ + transform: "none", + transformStyle: "flat", + perspective: "none", + ...style, + } as CSSStyleDeclaration) + : real(el), + ); + expect(discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers).toEqual([]); + vi.restoreAllMocks(); + } + }); + + describe("an element holding more of the scene than it owns", () => { + // A 100 x 100 scene; `big` is 100 x 100 and slides 10 px/frame, `small` rides inside it. + function sceneWith(tag = "div") { + const scene = document.createElement("div"); + scene.getBoundingClientRect = () => ({ width: 100, height: 100 }) as DOMRect; + const big = element(scene, "big", 100, tag); + const small = element(big, "small"); + const seek = (t: number): void => { + big.style.transform = `translateX(${300 * t}px)`; + small.style.transform = `translateX(${300 * t}px)`; + }; + seek(0); + const movers = (): HTMLElement[] => + discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers; + return { big, small, movers }; + } + + it("is no mover when it is a translucent sweep", () => { + const { big, movers } = sceneWith(); + big.style.opacity = "0.5"; + big.style.backgroundImage = "linear-gradient(90deg, rgba(0, 0, 0, 0), red)"; + expect(movers()).not.toContain(big); + }); + + it("is no mover when its gradient fades to transparent", () => { + const { big, movers } = sceneWith(); + big.style.backgroundImage = "linear-gradient(90deg, rgba(255, 0, 0, 0), rgb(255, 0, 0))"; + expect(movers()).not.toContain(big); + }); + + it("is no mover when its background colour is see-through", () => { + const { big, movers } = sceneWith(); + big.style.backgroundColor = "rgba(0, 0, 0, 0.4)"; + expect(movers()).not.toContain(big); + }); + + it("is no mover when it has no paint of its own", () => { + const { big, movers } = sceneWith(); + expect(movers()).not.toContain(big); + }); + + it("is no mover when it fades", () => { + const { big, movers } = sceneWith(); + big.style.backgroundColor = "rgb(10, 10, 10)"; + big.style.opacity = "0.6"; + expect(movers()).not.toContain(big); + }); + + it("hands the blur to what it holds", () => { + const { big, small, movers } = sceneWith(); + big.style.opacity = "0.5"; + expect(movers()).toEqual([small]); + }); + + it("stays a mover when opaque", () => { + const { big, movers } = sceneWith(); + big.style.backgroundColor = "rgb(10, 10, 10)"; + expect(movers()).toEqual([big]); + }); + + it("stays a mover when it is an opaque gradient", () => { + const { big, movers } = sceneWith(); + big.style.backgroundImage = "linear-gradient(90deg, rgb(0, 0, 0), rgb(255, 0, 0))"; + expect(movers()).toEqual([big]); + }); + + // jsdom has no canvas: the alpha probe's canvas is faked, `alphas` being the pixels it reads. + function withImage(tag: "img" | "canvas", alphas: number[] | "tainted") { + const sc = sceneWith(tag); + if (tag === "img") { + Object.defineProperty(sc.big, "complete", { value: true }); + Object.defineProperty(sc.big, "naturalWidth", { value: 1600, configurable: true }); + } + const draws = vi.fn(); + const realCreate = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((name: string) => { + if (name !== "canvas") return realCreate(name); + const ctx = { + drawImage: draws, + getImageData: () => { + if (alphas === "tainted") throw new DOMException("tainted", "SecurityError"); + return { data: Uint8ClampedArray.from(alphas.flatMap((a) => [0, 0, 0, a])) }; + }, + }; + return { getContext: () => ctx } as unknown as HTMLCanvasElement; + }); + return { ...sc, draws }; + } + + it("stays a mover when it is an opaque image", () => { + const { big, movers, draws } = withImage("img", [255, 255, 255, 255]); + expect(movers()).toEqual([big]); + expect(draws).toHaveBeenCalledTimes(1); + }); + + it("is no mover when it is an image with alpha", () => { + const { big, movers } = withImage("img", [255, 255, 128, 255]); + expect(movers()).not.toContain(big); + }); + + it("is no mover when it is a canvas with alpha", () => { + const { big, movers } = withImage("canvas", [0, 255]); + expect(movers()).not.toContain(big); + }); + + it("is no mover when its pixels cannot be read", () => { + const { big, movers } = withImage("img", "tainted"); + expect(movers()).not.toContain(big); + }); + + it("is no mover when the image is not decoded", () => { + const { big, movers } = withImage("img", [255]); + Object.defineProperty(big, "naturalWidth", { value: 0, configurable: true }); + expect(movers()).not.toContain(big); + }); + + it("stays a mover when it is a video", () => { + const { big, movers } = sceneWith("video"); + expect(movers()).toEqual([big]); + }); + + it("stays a mover while it is off screen at zero opacity", () => { + const { big, movers } = sceneWith(); + big.style.backgroundColor = "rgb(10, 10, 10)"; + big.style.opacity = "0"; + expect(movers()).toEqual([big]); + }); + }); + + it("keeps a small box without paint of its own: its crop matches the background", () => { + const { scene, fast, seek } = setup(); + scene.getBoundingClientRect = () => ({ width: 100, height: 100 }) as DOMRect; + fast.style.opacity = "0.5"; + expect(discoverMovers({ scene, durationSeconds: 1, fps: 30, seek }).movers).toEqual([fast]); + }); }); diff --git a/packages/core/src/runtime/captureMotionBlur/discover.ts b/packages/core/src/runtime/captureMotionBlur/discover.ts index 18ac0cc84f5..dc758efde65 100644 --- a/packages/core/src/runtime/captureMotionBlur/discover.ts +++ b/packages/core/src/runtime/captureMotionBlur/discover.ts @@ -3,14 +3,19 @@ * * One scan: candidates are elements whose inline style changes or that a CSS / WAAPI animation * moves; they must reach MIN_SPEED px/frame outside any 3D context (the redraw is 2D), and only - * the outermost of nested candidates is kept. + * the outermost of nested candidates is kept. A large element that is see-through while it moves + * (a sweep, a light leak) is no mover: its crop would carry the scene beneath it along its path. */ import { isHtmlElement } from "../domRealm"; +import { individualIs3d } from "./pose"; /** Under this peak speed (px per frame) a smear is invisible, so the element stays sharp. */ const MIN_SPEED = 2; +/** A box this share of the scene or more holds other content, whatever sits in it. */ +const LARGE_SHARE = 0.5; + /** Scan resolution: at most this many timeline samples, however long the composition. */ const SCAN_STEPS = 240; @@ -56,7 +61,8 @@ function in3d(el: Element, scene: Element): boolean { cs.transform.startsWith("matrix3d") || cs.transformStyle === "preserve-3d" || cs.perspective !== "none" || - (cs.offsetPath ?? "none") !== "none" + (cs.offsetPath ?? "none") !== "none" || + individualIs3d(cs) ) { return true; } @@ -79,6 +85,61 @@ function pageAnimated(scene: Element): Set { return out; } +// Alpha of a computed colour (`rgba(r, g, b, a)` or `rgb(r g b / a)`); 1 when it has none. +function alphaOf(color: string): number { + const m = /^rgba\(.*,\s*([\d.]+)\)$/.exec(color) ?? /\/\s*([\d.]+)(%?)\s*\)$/.exec(color); + if (!m) return 1; + return m[2] ? Number(m[1]) / 100 : Number(m[1]); +} + +type Bitmap = HTMLImageElement | HTMLCanvasElement; + +const isBitmap = (el: HTMLElement): el is Bitmap => el.tagName === "IMG" || el.tagName === "CANVAS"; + +const ALPHA_PROBE = 32; + +/** Whether any pixel of a bitmap is not fully opaque; an undecoded or unreadable one counts. */ +function bitmapHasAlpha(el: Bitmap): boolean { + if ("naturalWidth" in el && !(el.complete && el.naturalWidth > 0)) return true; + try { + const probe = el.ownerDocument.createElement("canvas"); + probe.width = probe.height = ALPHA_PROBE; + const ctx = probe.getContext("2d", { willReadFrequently: true }); + if (!ctx) return true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(el, 0, 0, ALPHA_PROBE, ALPHA_PROBE); + const { data } = ctx.getImageData(0, 0, ALPHA_PROBE, ALPHA_PROBE); + for (let i = 3; i < data.length; i += 4) if ((data[i] ?? 0) < 255) return true; + return false; + } catch { + return true; + } +} + +/** Whether the element, on screen right now, lets what is beneath its box show through. */ +function seesThrough(el: HTMLElement, scene: Element, hasAlpha: Map): boolean { + let alpha = 1; + for (let n: Element | null = el; n && n !== scene; n = n.parentElement) { + alpha *= parseFloat(getComputedStyle(n).opacity); + } + if (alpha < 0.05) return false; + if (alpha < 0.99) return true; + if (isBitmap(el)) { + if (!hasAlpha.has(el)) hasAlpha.set(el, bitmapHasAlpha(el)); + return hasAlpha.get(el) === true; + } + return !/^(VIDEO|PICTURE)$/.test(el.tagName) && paintSeesThrough(el); +} + +function paintSeesThrough(el: HTMLElement): boolean { + const cs = getComputedStyle(el); + if (alphaOf(cs.backgroundColor) >= 0.99) return false; + if (cs.backgroundImage === "none") return true; + return [...cs.backgroundImage.matchAll(/rgba\([^)]*,\s*([\d.]+)\)/g)].some( + (m) => Number(m[1]) < 0.99, + ); +} + /** How far a box's farthest-moving edge travelled between two reads; 0 when either is empty. */ function edgeShift(a: DOMRect | undefined, b: DOMRect | undefined): number { if (!a || !b || !a.width || !b.width) return 0; @@ -101,6 +162,10 @@ export function discoverMovers({ scene, durationSeconds, fps, seek }: DiscoverOp const framesPerStep = frames / steps; const fast = new Set(); const threeD = new Set(); + const seeThrough = new Set(); + const alphaCache = new Map(); + const sceneRect = scene.getBoundingClientRect(); + const largeArea = sceneRect.width * sceneRect.height * LARGE_SHARE; let prev: Array | null = null; for (let i = 0; i <= steps; i++) { @@ -111,14 +176,25 @@ export function discoverMovers({ scene, durationSeconds, fps, seek }: DiscoverOp if (!dynamic.has(el) && styleKey(el) === first[j]) return; dynamic.add(el); rects[j] = el.getBoundingClientRect(); - if (edgeShift(prev?.[j], rects[j]) / framesPerStep >= MIN_SPEED) fast.add(el); + if (edgeShift(prev?.[j], rects[j]) / framesPerStep < MIN_SPEED) return; + fast.add(el); + const box = rects[j]; + if ( + box && + largeArea > 0 && + box.width * box.height >= largeArea && + seesThrough(el, scene, alphaCache) + ) + seeThrough.add(el); }); // Style reads up the chain are the costly part; every 10th step is enough to see a 3D turn. if (i % 10 === 0 || i === steps) for (const el of fast) if (in3d(el, scene)) threeD.add(el); prev = rects; } - const candidates = all.filter((el) => fast.has(el) && !threeD.has(el) && !optedOut(el, scene)); + const candidates = all.filter( + (el) => fast.has(el) && !threeD.has(el) && !seeThrough.has(el) && !optedOut(el, scene), + ); const movers = candidates.filter((el) => !candidates.some((o) => o !== el && o.contains(el))); return { movers, dynamic }; } diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 0e462537fb8..0986628af56 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -40,6 +40,7 @@ import { resolveMotionBlurPlan, type MotionBlurPlan, } from "./motionBlur.js"; +import { installPageMotionBlur } from "./pageMotionBlur.js"; // ── Extracted modules ─────────────────────────────────────────────────────── import { @@ -1509,6 +1510,7 @@ async function constructCaptureSession( } }, variablesJson); } + await installPageMotionBlur(page, options); const browserVersion = await browser.version(); const sessionOptions = resolveCaptureSessionOptions(options, browserVersion); const expectedMajor = config?.expectedChromiumMajor; @@ -3434,6 +3436,7 @@ export async function createStaticVerificationPage(session: CaptureSession): Pro JSON.parse(json); }, variablesJson); } + await installPageMotionBlur(page, session.options); await page.setViewport({ width: session.options.width, height: session.options.height, diff --git a/packages/engine/src/services/pageMotionBlur.test.ts b/packages/engine/src/services/pageMotionBlur.test.ts new file mode 100644 index 00000000000..cde3d5ab929 --- /dev/null +++ b/packages/engine/src/services/pageMotionBlur.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL } from "@hyperframes/core"; +import { installPageMotionBlur } from "./pageMotionBlur"; + +const fakePage = () => ({ evaluateOnNewDocument: vi.fn(async () => ({ identifier: "1" })) }); + +describe("installPageMotionBlur", () => { + it("tells the page to stand down when the engine blurs", async () => { + const page = fakePage(); + await installPageMotionBlur(page, { motionBlur: { samplesPerFrame: 16 } }); + expect(page.evaluateOnNewDocument).toHaveBeenCalledTimes(1); + expect(page.evaluateOnNewDocument).toHaveBeenCalledWith( + expect.any(Function), + PAGE_MOTION_BLUR_OFF_GLOBAL, + "engine-motion-blur", + ); + }); + + it("turns the page off when the Studio preview could not blur", async () => { + const page = fakePage(); + await installPageMotionBlur(page, { pageMotionBlur: "off" }); + expect(page.evaluateOnNewDocument).toHaveBeenCalledWith( + expect.any(Function), + PAGE_MOTION_BLUR_OFF_GLOBAL, + "preview-unsupported", + ); + }); + + it("tells the page the render's device scale when it is not 1", async () => { + const page = fakePage(); + await installPageMotionBlur(page, { deviceScaleFactor: 2 }); + expect(page.evaluateOnNewDocument).toHaveBeenCalledWith( + expect.any(Function), + PAGE_DEVICE_SCALE_GLOBAL, + 2, + ); + }); + + it("leaves the page alone otherwise", async () => { + const page = fakePage(); + await installPageMotionBlur(page, {}); + expect(page.evaluateOnNewDocument).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/engine/src/services/pageMotionBlur.ts b/packages/engine/src/services/pageMotionBlur.ts new file mode 100644 index 00000000000..e161d43fe1f --- /dev/null +++ b/packages/engine/src/services/pageMotionBlur.ts @@ -0,0 +1,38 @@ +/** + * Tells the page's capture motion blur (`data-motion-blur="on"`) to stand down, before any of + * its scripts run: when the engine blurs (it would average frames that are already blurred) or + * when the render must match a Studio preview that could not blur (`pageMotionBlur: "off"`). + */ + +import { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL } from "@hyperframes/core"; +import type { Page } from "puppeteer-core"; +import type { CaptureOptions } from "../types.js"; + +export async function installPageMotionBlur( + page: Pick, + options: Pick, +): Promise { + const scale = options.deviceScaleFactor ?? 1; + if (scale > 1) { + await page.evaluateOnNewDocument( + (key: string, v: number) => { + (window as unknown as Record)[key] = v; + }, + PAGE_DEVICE_SCALE_GLOBAL, + scale, + ); + } + const off = options.motionBlur + ? "engine-motion-blur" + : options.pageMotionBlur === "off" + ? "preview-unsupported" + : null; + if (!off) return; + await page.evaluateOnNewDocument( + (key: string, v: string) => { + (window as unknown as Record)[key] = v; + }, + PAGE_MOTION_BLUR_OFF_GLOBAL, + off, + ); +} diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index ef71e03e5a4..873b674d3cb 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -171,6 +171,7 @@ export interface CaptureOptions { * screenshot capture mode; see `services/motionBlur.ts`. */ motionBlur?: MotionBlurOptions; + pageMotionBlur?: "off"; deviceScaleFactor?: number; /** * Opt into Chrome's capture-beyond-viewport screenshot path. Leave undefined diff --git a/packages/producer/src/renderRequest.ts b/packages/producer/src/renderRequest.ts index 63121520a17..5c5630887af 100644 --- a/packages/producer/src/renderRequest.ts +++ b/packages/producer/src/renderRequest.ts @@ -57,6 +57,7 @@ export interface RenderRequestOptions { videoBitrate?: string; videoFrameFormat?: VideoFrameFormat; hdrMode?: RenderConfig["hdrMode"]; + pageMotionBlur?: RenderConfig["pageMotionBlur"]; variables?: Record; outputResolution?: CanvasResolution; outputResolutionAspectAgnostic?: boolean; @@ -180,6 +181,7 @@ function assertRequestOptionScalars(options: Record): void { } assertOptionalEnum(options, "strictness", ["strict", "best-effort"]); assertOptionalEnum(options, "hdrMode", ["auto", "force-hdr", "force-sdr"]); + assertOptionalEnum(options, "pageMotionBlur", ["off"]); if (options.videoFrameFormat !== undefined && !isVideoFrameFormat(options.videoFrameFormat)) { throw new Error("Render request videoFrameFormat is invalid"); } diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index 8ba76396993..20f5973ffa7 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -659,6 +659,59 @@ describe("detectRenderModeHints", () => { expect(result.recommendScreenshot).toBe(true); }); + it("routes a capture-motion-blur root to screenshot capture", () => { + const html = ` + +
+`; + + const result = detectRenderModeHints(html); + + expect(result.reasons.map((reason) => reason.code)).toEqual(["captureMotionBlur"]); + expect(result.recommendScreenshot).toBe(true); + }); + + it("does not route a root whose motion blur is off", () => { + const html = ` + +
+`; + + expect(detectRenderModeHints(html).reasons).toEqual([]); + }); + + describe("capture motion blur root selection", () => { + const codes = (html: string) => detectRenderModeHints(html).reasons.map((r) => r.code); + + it("does not route when only a sub-composition opted in", () => { + const html = `
`; + expect(codes(html)).toEqual([]); + }); + + it("routes when the outermost root opted in, whatever its sub-compositions say", () => { + const html = `
`; + expect(codes(html)).toEqual(["captureMotionBlur"]); + }); + + it("reads data-root=true before the first composition", () => { + const first = `
`; + const marked = `
`; + expect(codes(first)).toEqual([]); + expect(codes(marked)).toEqual(["captureMotionBlur"]); + }); + }); + + it("does not route a moving root that did not opt in", () => { + const tween = ``; + const plain = `
${tween}`; + expect(detectRenderModeHints(plain).reasons).toEqual([]); + }); + + it("does not route an opted-in root when the render turns page motion blur off", () => { + const html = `
`; + expect(detectRenderModeHints(html, "off").reasons).toEqual([]); + }); + it("does not flag htmlInCanvas for plain canvas elements without layoutsubtree", () => { const html = ` diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 5e08891826d..cc66a131a01 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -290,7 +290,11 @@ function assertSubCompositionsUsable( } } -export type RenderModeHintCode = "iframe" | "requestAnimationFrame" | "htmlInCanvas"; +export type RenderModeHintCode = + | "iframe" + | "requestAnimationFrame" + | "htmlInCanvas" + | "captureMotionBlur"; export interface RenderModeHint { code: RenderModeHintCode; @@ -320,7 +324,15 @@ function stripCompilerMountBootstrap(source: string): string { ); } -export function detectRenderModeHints(html: string): RenderModeHints { +/** The composition the runtime reads data-motion-blur from: `data-root="true"`, else the first. */ +function runtimeRoot(document: ParentNode): Element | null { + return ( + document.querySelector('[data-composition-id][data-root="true"]') ?? + document.querySelector("[data-composition-id]") + ); +} + +export function detectRenderModeHints(html: string, pageMotionBlur?: "off"): RenderModeHints { const reasons: RenderModeHint[] = []; const { document } = parseHTML(html); @@ -330,6 +342,16 @@ export function detectRenderModeHints(html: string): RenderModeHints { message: "Detected html-in-canvas API (layoutsubtree canvas). Chrome does not support concurrent drawElementImage across multiple workers; render is pinned to a single worker.", }); + } else if ( + pageMotionBlur !== "off" && + runtimeRoot(document)?.getAttribute("data-motion-blur") === "on" + ) { + // Not pinned to one worker: k3-promo measured identical across 1 and 4 workers, 2× faster. + reasons.push({ + code: "captureMotionBlur", + message: + 'Detected capture motion blur (data-motion-blur="on"), which draws each frame with html-in-canvas; routed through screenshot capture mode.', + }); } if (document.querySelector("iframe")) { @@ -1927,6 +1949,7 @@ export interface CompileForRenderOptions { * `window.__hfVariables` injection covers script reads, not var() in CSS. */ variables?: Record; + pageMotionBlur?: "off"; } const GSAP_CDN_BASE = gsapCdnDist(); @@ -2045,7 +2068,7 @@ export async function compileForRender( /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi, "$1", ); - const renderModeHints = detectRenderModeHints(sanitizedHtml); + const renderModeHints = detectRenderModeHints(sanitizedHtml, options.pageMotionBlur); const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml); // Detected BEFORE inlineExternalScripts: GSAP's own source contains // `transformPerspective`, so scanning post-inline HTML would flag every diff --git a/packages/producer/src/services/render/stages/compileStage.ts b/packages/producer/src/services/render/stages/compileStage.ts index ca8684cb9a3..a4a4b013093 100644 --- a/packages/producer/src/services/render/stages/compileStage.ts +++ b/packages/producer/src/services/render/stages/compileStage.ts @@ -91,6 +91,8 @@ export interface CompileStageInput { variables?: Record; /** Absolute project paths not on disk, readable at these URLs instead. */ urlByAbsentLocalPath?: ReadonlyMap; + /** See RenderConfig.pageMotionBlur; decides whether the page renders through screenshots. */ + pageMotionBlur?: "off"; } export interface CompileStageResult { @@ -174,6 +176,7 @@ export async function runCompileStage(input: CompileStageInput): Promise { }); }); +describe("runProbeStage — page motion blur", () => { + // The probe session is reused for capture, so it must carry the render's "off" like any session. + it("passes pageMotionBlur to the probe session so a reused session installs the off global", async () => { + capturedOptions.length = 0; + const { runProbeStage } = await import("./probeStage.js"); + const input = makeProbeInput({ stageForceScreenshot: false }); + input.job.config.pageMotionBlur = "off"; + + await runProbeStage(input); + + expect(capturedOptions[0]).toMatchObject({ pageMotionBlur: "off" }); + }); + + it("leaves pageMotionBlur unset when the render does not ask for off", async () => { + capturedOptions.length = 0; + const { runProbeStage } = await import("./probeStage.js"); + + await runProbeStage(makeProbeInput({ stageForceScreenshot: false })); + + expect(capturedOptions[0]).not.toHaveProperty("pageMotionBlur", "off"); + }); +}); + describe("runProbeStage — capture jpeg quality", () => { // A sequential disk render reuses this session for every frame, so its quality is the render's. for (const [preset, expected] of [ diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index 5ecff9bd416..4ec75788e9a 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -384,6 +384,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise { expect(log.warn).toHaveBeenCalledOnce(); }); + it("does not pin a capture-motion-blur page to one worker", () => { + const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + + const workers = resolveRenderWorkerCount( + 900, + 4, + cfg, + { + hasShaderTransitions: false, + renderModeHints: { + recommendScreenshot: true, + reasons: [{ code: "captureMotionBlur", message: "data-motion-blur" }], + }, + }, + log, + ); + + expect(workers).toBe(4); + }); + // fallow-ignore-next-line code-duplication it("pins to 1 worker in low-memory mode when no explicit --workers is set", () => { const log = { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 2faf76cbc12..45e18c5bebd 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -396,6 +396,7 @@ export interface RenderConfig { * and JPEG samples would be averaged after a lossy quantization. */ motionBlur?: MotionBlurOptions; + pageMotionBlur?: "off"; workers?: number; useGpu?: boolean; debug?: boolean; @@ -3055,6 +3056,7 @@ async function executeRenderPipeline(input: { log, assertNotAborted, variables: job.config.variables, + pageMotionBlur: job.config.pageMotionBlur, }), ); let compiled = compileResult.compiled; @@ -3495,6 +3497,7 @@ async function executeRenderPipeline(input: { variables: job.config.variables, deviceScaleFactor, motionBlur: job.config.motionBlur, + pageMotionBlur: job.config.pageMotionBlur, ...(videoCaptureBeyondViewport !== undefined ? { captureBeyondViewport: videoCaptureBeyondViewport } : {}), diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index 9db0ee5499f..75adffe67fd 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -785,6 +785,34 @@ describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => { } }); + // WYSIWYG: the browser says whether its preview could blur; only `false` is a signal, and the + // route hands every adapter the final render value. + it.each([ + [false, "off"], + [true, undefined], + ["false", undefined], + [undefined, undefined], + ])("maps previewMotionBlur %s to pageMotionBlur %s", async (flag, expected) => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + fps: 30, + quality: "standard", + format: "mp4", + previewMotionBlur: flag, + }), + }); + expect(spy.mock.calls[0][0].pageMotionBlur).toBe(expected); + expect(spy.mock.calls[0][0]).not.toHaveProperty("previewMotionBlur"); + } finally { + cleanup(); + } + }); + // An old client omits the flag, and a non-boolean is not a signal either. // Defaulting those to "opted out" would silently drop every pre-upgrade // render outcome. diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index c082db3c5b3..b041957c943 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -108,6 +108,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void // window.__hfVariables — same channel as `hyperframes render --variables`. variables?: Record; gpu?: boolean; + previewMotionBlur?: boolean; }; const VALID_FORMATS = new Set(["mp4", "webm", "mov"]); const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; @@ -172,6 +173,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void distinctId: typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined, telemetryOptOut: body.telemetryOptOut === true, + ...(body.previewMotionBlur === false ? { pageMotionBlur: "off" as const } : {}), }); (jobState as RenderJobState & { createdAt: number }).createdAt = Date.now(); renderJobs.set(jobId, jobState as RenderJobState & { createdAt: number }); diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index e38e69ba794..6d6ac952831 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -185,6 +185,7 @@ export interface StudioApiAdapter { * for a user who had opted out, under the CLI's own policy. */ telemetryOptOut?: boolean; + pageMotionBlur?: "off"; /** * Optional output resolution preset. See `resolveDeviceScaleFactor` in * the producer for the integer-scale + aspect + HDR constraints. diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 860bd542ba3..0e24c194f86 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -10,6 +10,13 @@ import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus"; import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger"; import { studioApiFetch } from "../../utils/studioApiFetch"; +// WYSIWYG: the preview iframe runs in this browser, so a render blurs only if this one can. +function canPreviewMotionBlur(): boolean { + const ctx = globalThis.CanvasRenderingContext2D?.prototype; + if (!ctx || typeof document === "undefined") return false; + return "drawElementImage" in ctx && "requestPaint" in document.createElement("canvas"); +} + export interface RenderJob { id: string; status: "rendering" | "complete" | "failed" | "cancelled"; @@ -238,10 +245,12 @@ export function useRenderQueue( gpu?: boolean; telemetryDistinctId?: string; telemetryOptOut?: boolean; + previewMotionBlur: boolean; } = { fps, quality, format, + previewMotionBlur: canPreviewMotionBlur(), }; // The id is MINTED by getAnonymousId(), so calling it unconditionally // created a telemetry identity for a profile that had opted out — and diff --git a/packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx b/packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx new file mode 100644 index 00000000000..485a8bfbc82 --- /dev/null +++ b/packages/studio/src/components/renders/useRenderQueueMotionBlur.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom + +// WYSIWYG: a render only blurs what the Studio preview could show, and only the browser knows +// whether it has html-in-canvas. The request body is how it tells the server. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness"; + +vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() })); + +const { useRenderQueue } = await import("./useRenderQueue"); + +let queue: MountedQueue | null = null; + +async function startRenderBody(): Promise> { + const started = await startRenderAndReadBody(useRenderQueue, { + opts: { fps: 30, quality: "standard", format: "mp4" }, + }); + queue = started.queue; + return started.body; +} + +afterEach(() => { + queue?.unmount(); + queue = null; + document.body.innerHTML = ""; + delete (HTMLCanvasElement.prototype as unknown as Record).requestPaint; + vi.unstubAllGlobals(); +}); + +describe("render request previewMotionBlur", () => { + it("is false in a browser without html-in-canvas", async () => { + expect((await startRenderBody())["previewMotionBlur"]).toBe(false); + }); + + it("is true in a browser with html-in-canvas", async () => { + vi.stubGlobal( + "CanvasRenderingContext2D", + class { + drawElementImage(): void {} + }, + ); + (HTMLCanvasElement.prototype as unknown as Record).requestPaint = () => {}; + expect((await startRenderBody())["previewMotionBlur"]).toBe(true); + }); +}); diff --git a/packages/studio/vite.adapter.render.test.ts b/packages/studio/vite.adapter.render.test.ts new file mode 100644 index 00000000000..308771bd2de --- /dev/null +++ b/packages/studio/vite.adapter.render.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ViteDevServer } from "vite"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter"; + +const mocks = vi.hoisted(() => ({ + createRenderJob: vi.fn(() => ({ id: "job", perfSummary: undefined })), + executeRenderJob: vi.fn(async () => {}), +})); + +vi.mock("@hyperframes/producer", () => mocks); + +const roots: string[] = []; +afterEach(() => { + vi.clearAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function startRender(extra: Record) { + const root = mkdtempSync(join(tmpdir(), "hf-vite-render-")); + roots.push(root); + const adapter = createViteAdapter( + root, + {} as ViteDevServer, + createProjectSignatureCache({ compute: () => "test" }), + { historyRoot: join(root, "history") }, + ); + adapter.startRender({ + project: { dir: root, id: "p", title: "p" }, + outputPath: join(root, "out.mp4"), + format: "mp4", + fps: { num: 30, den: 1 }, + quality: "draft", + jobId: "job", + ...extra, + } as never); + return vi.waitFor(() => expect(mocks.createRenderJob).toHaveBeenCalledTimes(1), { + timeout: 10_000, + }); +} + +describe("Studio dev server render jobs", { timeout: 30_000 }, () => { + it("hands the route's pageMotionBlur to the job, so a preview that could not blur renders unblurred", async () => { + await startRender({ pageMotionBlur: "off" }); + expect(mocks.createRenderJob).toHaveBeenCalledWith( + expect.objectContaining({ pageMotionBlur: "off" }), + ); + }); + + it("leaves the job's blur to the composition when the route sends none", async () => { + await startRender({}); + expect(mocks.createRenderJob.mock.calls[0]?.[0]).not.toHaveProperty("pageMotionBlur"); + }); +}); diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 340bacebb9d..0c765411827 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -356,6 +356,7 @@ export function createViteAdapter( ...(opts.videoFrameFormat ? { videoFrameFormat: opts.videoFrameFormat } : {}), ...(opts.hdrMode ? { hdrMode: opts.hdrMode } : {}), ...(opts.useGpu ? { useGpu: true } : {}), + ...(opts.pageMotionBlur ? { pageMotionBlur: opts.pageMotionBlur } : {}), }); const onProgress = (j: { progress: number; currentStage?: string }) => { state.progress = j.progress; diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 3ccb012ef2f..20e087ca845 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -1,7 +1,7 @@ { "@hyperframes/aws-lambda": { "packed": 70874, "unpacked": 258166 }, "@hyperframes/cli": { "packed": 7902966, "unpacked": 23722874 }, - "@hyperframes/core": { "packed": 1636442, "unpacked": 3903211 }, + "@hyperframes/core": { "packed": 1636442, "unpacked": 3930000 }, "@hyperframes/engine": { "packed": 428347, "unpacked": 1436293 }, "@hyperframes/gcp-cloud-run": { "packed": 49412, "unpacked": 192672 }, "@hyperframes/inspector": { "packed": 808905, "unpacked": 3645432 },