From 96ab721f95b84d6e60497d0e95ea8c0c5c29a55c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 20 Oct 2026 18:19:47 -0700 Subject: [PATCH] feat(core,engine): report why blur is off, and what each render session did Tooling and logs need more than "off". Give the runtime's report a reason code for every way blur can be off (renderer-off, unsupported-browser, each html-in-canvas defect, capture-failures), and two calls for tooling: probe() runs the same scan a render would, without painting, and suspend() stops all painting for the rest of a session without changing the reported decision. The engine prints one line per session from what the page reports: `[motion-blur] on reasonCode=null movers=N failedFrames=K`. The first-frame line cannot know about later failures, so the session end prints the same line with "(session end)" when any frame fell back to plain DOM; a clean session stays quiet. A render where every capture failed no longer reads as "on" with nothing else. Add the first test that reads blurred pixels: real Chrome with html-in-canvas, the engine's frame order (seek, wait, composite, screenshot). It checks the trail along the motion line, that what does not move matches the sharp frame, that a box centered with translate is blurred where it really is, and that a flipped backface card renders the plain frame. It skips when Chrome or html-in-canvas is missing. Folded review fixes: R13 (runtime half), R17 (engine half), T1 (the guards that need reason codes), T3 (log-line and pixel tests). Co-Authored-By: Claude Sonnet 5.5 --- .../runtime/captureMotionBlur/index.test.ts | 318 +++++++++++++----- .../src/runtime/captureMotionBlur/index.ts | 86 +++-- .../captureMotionBlur.pixels.browser.test.ts | 252 ++++++++++++++ .../services/frameCapture-motionBlur.test.ts | 51 +++ packages/engine/src/services/frameCapture.ts | 14 +- .../src/services/pageMotionBlur.test.ts | 121 ++++++- .../engine/src/services/pageMotionBlur.ts | 52 +++ .../services/distributed/chunkFrames.test.ts | 19 ++ 8 files changed, 802 insertions(+), 111 deletions(-) create mode 100644 packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts create mode 100644 packages/producer/src/services/distributed/chunkFrames.test.ts diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index 76454bce9b3..66e8105d28f 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -19,6 +19,7 @@ type Win = Window & { moverNames: string[]; failedFrames: number; }; + suspend: () => void; }; __hf_page_composite_pending?: boolean; __hf_page_composite_resolve?: () => boolean; @@ -187,6 +188,7 @@ describe("opt-in and off switches", () => { expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off", reason: "the renderer turned it off (distributed-render)", + reasonCode: "renderer-off", }); }); @@ -233,8 +235,10 @@ describe("opt-in and off switches", () => { root.append(canvas); canvas.transferControlToOffscreen(); initCaptureMotionBlur(host(root)); - expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off" }); - expect(win.__hfMotionBlur?.report().reason).toMatch(/WebGL/); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "accelerated-canvas", + }); delete (HTMLCanvasElement.prototype as unknown as Record) .transferControlToOffscreen; }); @@ -252,24 +256,26 @@ describe("opt-in and off switches", () => { expect(win.__hfMotionBlur?.report().reason).toMatch(/WebGL/); }); - it("stays off for an iframe in the root, which reloads when the root is wrapped", () => { + it("stays off for an iframe in the root, which reloads when the root is wrapped (R9)", () => { installHtmlInCanvas(); const root = makeRoot(); root.append(document.createElement("iframe")); initCaptureMotionBlur(host(root)); expect(root.parentElement).toBe(document.body); - expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off" }); - expect(win.__hfMotionBlur?.report().reason).toMatch(/iframe/); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "iframe-in-root", + }); }); - it("turns off instead of wrapping when an iframe appears after init", () => { + it("turns off instead of wrapping when an iframe appears after init (R9)", () => { installHtmlInCanvas(); const { root, h } = movingHost(); initCaptureMotionBlur(h); root.append(document.createElement("iframe")); void paintCaptureMotionBlur(0.5, PLAY); expect(root.parentElement).toBe(document.body); - expect(win.__hfMotionBlur?.report().reason).toMatch(/iframe/); + expect(win.__hfMotionBlur?.report().reasonCode).toBe("iframe-in-root"); }); it("stays off for a hidden-backface card in a 3D context, but not for a flat one", () => { @@ -611,46 +617,6 @@ describe("wrapping", () => { expect(h.seek).not.toHaveBeenCalled(); }); - it("hides each move of the root from the runtime and re-seeks right after it (R9)", async () => { - installHtmlInCanvas(); - const { root, h } = movingHost(); - const events: string[] = []; - const base = h.seek; - const spied = { - ...h, - now: () => 0.5, - seek: (t: number, suppress: boolean) => { - base(t, suppress); - events.push(`seek:${t}:${suppress}`); - }, - ignoreMutations: (write: () => void) => { - events.push("quiet:begin"); - write(); - events.push(`quiet:end:${root.parentElement === document.body ? "plain" : "wrapped"}`); - }, - }; - initCaptureMotionBlur(spied); - events.length = 0; - void paintCaptureMotionBlur(0.5, PLAY); - expect(events.slice(events.indexOf("quiet:begin")).slice(0, 3)).toEqual([ - "quiet:begin", - "quiet:end:wrapped", - "seek:0.5:true", - ]); - events.length = 0; - expect(paintCaptureMotionBlur(0.5)).toBeNull(); // paused: unwraps - expect(events).toEqual(["quiet:begin", "quiet:end:plain", "seek:0.5:true"]); - }); - - it("does not re-seek when there was nothing to unwrap", () => { - installHtmlInCanvas(); - const { h } = movingHost(); - initCaptureMotionBlur(h); - h.seek.mockClear(); - expect(paintCaptureMotionBlur(0.5)).toBeNull(); - expect(h.seek).not.toHaveBeenCalled(); - }); - it("unwraps when paused, so Studio can select inside, and wraps again on play", async () => { installHtmlInCanvas(); const { root, el, h } = movingHost(); @@ -968,30 +934,6 @@ describe("painting", () => { expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off" }); }); - it("counts every frame that fell back to plain DOM, though the failure streak resets", async () => { - vi.useFakeTimers(); - installHtmlInCanvas(); - const { root, h } = movingHost(); - initCaptureMotionBlur(h); - const frame = async (t: number, fails: boolean): Promise => { - captureContext()?.drawElementImage.mockImplementation(() => { - if (fails) throw new Error("No cached paint record"); - }); - const done = paintCaptureMotionBlur(t, { engineMode: true }); - releaseCaptureMotionBlurFrame(); - captureCanvas(root).dispatchEvent(new Event("paint")); - captureCanvas(root).dispatchEvent(new Event("paint")); - await done; - }; - await frame(0.1, true); - await frame(0.2, false); - void paintCaptureMotionBlur(0.3, { engineMode: true }); - releaseCaptureMotionBlurFrame(); - await vi.advanceTimersByTimeAsync(2000); - expect(win.__hfMotionBlur?.report()).toMatchObject({ failures: 0, failedFrames: 2 }); - expect(Object.keys(win.__hfMotionBlur?.report() ?? {})).toContain("failedFrames"); - }); - describe("a block that its cause can lift", () => { /** A host whose root fades from 0.5 s while `fade.on`: the scan blocks it as root opacity. */ function fadingHost(fade: { on: boolean }, timeline: () => unknown = () => "tl") { @@ -1010,12 +952,14 @@ describe("painting", () => { const { root, host: h } = fadingHost(fade); initCaptureMotionBlur(h); void paintCaptureMotionBlur(0.2, { engineMode: true }); - expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off" }); - expect(win.__hfMotionBlur?.report().reason).toMatch(/opacity/); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "root-opacity", + }); fade.on = false; initCaptureMotionBlur(h); void paintCaptureMotionBlur(0.2, { engineMode: true }); - expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "on", reason: null }); + expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "on", reasonCode: null }); expect(root.parentElement).toBe(captureCanvas(root)); }); @@ -1027,8 +971,10 @@ describe("painting", () => { initCaptureMotionBlur(h); void paintCaptureMotionBlur(0.2, { engineMode: true }); expect(root.parentElement).toBe(document.body); - expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off" }); - expect(win.__hfMotionBlur?.report().reason).toMatch(/opacity/); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "root-opacity", + }); }); it("re-evaluates the block when the bound timeline changes", () => { @@ -1056,7 +1002,7 @@ describe("painting", () => { root.append(late); late.getContext("webgl"); void paintCaptureMotionBlur(0.5, { engineMode: true }); - expect(win.__hfMotionBlur?.report().reason).toMatch(/WebGL/); + expect(win.__hfMotionBlur?.report().reasonCode).toBe("accelerated-canvas"); late.remove(); initCaptureMotionBlur(h); void paintCaptureMotionBlur(0.5, PLAY); @@ -1082,11 +1028,35 @@ describe("painting", () => { expect(root.parentElement).toBe(document.body); expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off", - reason: "capture failed 3 times in a row", + reasonCode: "capture-failures", }); }); }); + it("counts every frame that fell back to plain DOM, though the failure streak resets", async () => { + vi.useFakeTimers(); + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + const frame = async (t: number, fails: boolean): Promise => { + captureContext()?.drawElementImage.mockImplementation(() => { + if (fails) throw new Error("No cached paint record"); + }); + const done = paintCaptureMotionBlur(t, { engineMode: true }); + releaseCaptureMotionBlurFrame(); + captureCanvas(root).dispatchEvent(new Event("paint")); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + }; + await frame(0.1, true); + await frame(0.2, false); + void paintCaptureMotionBlur(0.3, { engineMode: true }); + releaseCaptureMotionBlurFrame(); + await vi.advanceTimersByTimeAsync(2000); + expect(win.__hfMotionBlur?.report()).toMatchObject({ failures: 0, failedFrames: 2 }); + expect(Object.keys(win.__hfMotionBlur?.report() ?? {})).toContain("failedFrames"); + }); + it("keeps blurring in a render after repeated capture failures, each failed frame plain DOM", async () => { installHtmlInCanvas(); const { root, h } = movingHost(); @@ -1126,6 +1096,62 @@ describe("painting", () => { }); }); +describe("suspend (R13)", () => { + it("unwraps a wrapped root, stops all painting, and keeps the reported decision", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(root.parentElement?.tagName).toBe("CANVAS"); + const before = win.__hfMotionBlur?.report(); + win.__hfMotionBlur?.suspend(); + expect(root.parentElement).toBe(document.body); + expect(document.querySelectorAll("canvas")).toHaveLength(0); + expect(paintCaptureMotionBlur(0.6, PLAY)).toBeNull(); + expect(paintCaptureMotionBlur(0.7, { engineMode: true })).toBeNull(); + expect(root.parentElement).toBe(document.body); + expect(win.__hfMotionBlur?.report()).toEqual(before); + }); + + it("is idempotent, and suspends a page that has not wrapped yet", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + win.__hfMotionBlur?.suspend(); + h.seek.mockClear(); + win.__hfMotionBlur?.suspend(); + expect(h.seek).not.toHaveBeenCalled(); + expect(paintCaptureMotionBlur(0.5, { engineMode: true })).toBeNull(); + expect(root.parentElement).toBe(document.body); + expect(win.__hfMotionBlur?.report().state).toBe("on"); + }); + + it("outlives a re-init of the same root", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + win.__hfMotionBlur?.suspend(); + initCaptureMotionBlur(h); + expect(paintCaptureMotionBlur(0.5, PLAY)).toBeNull(); + expect(root.parentElement).toBe(document.body); + }); + + it("exists, and does nothing, where blur is off", () => { + const root = makeRoot(); + initCaptureMotionBlur(host(root)); + expect(() => win.__hfMotionBlur?.suspend()).not.toThrow(); + expect(win.__hfMotionBlur?.report().state).toBe("off"); + }); + + it("lets the probe still decide after suspending", () => { + installHtmlInCanvas(); + const { h } = movingHost(); + initCaptureMotionBlur(h); + win.__hfMotionBlur?.suspend(); + expect(win.__hfMotionBlur?.report().state).toBe("on"); + }); +}); + describe("device pixel ratio", () => { it("sizes the capture and output canvases in device pixels and keeps the CSS size", () => { installHtmlInCanvas(); @@ -1186,6 +1212,117 @@ describe("device pixel ratio", () => { }); }); +describe("probe", () => { + const probe = () => { + const api = win.__hfMotionBlur; + if (!api) throw new Error("no __hfMotionBlur"); + return api.probe(); + }; + + it("finds the movers without any paint, wrap or event and puts the playhead back", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur({ ...h, now: () => 0.4 }); + expect(win.__hfMotionBlur?.report().moverNames).toEqual([]); + const report = probe(); + expect(report).toMatchObject({ + state: "on", + reason: null, + reasonCode: null, + moverNames: ["div#slider"], + }); + expect(h.seek.mock.calls.at(-1)?.[0]).toBeCloseTo(0.4); + expect(root.parentElement).toBe(document.body); + expect(document.querySelector("canvas")).toBeNull(); + }); + + it("reports no movers for a composition where nothing moves", () => { + installHtmlInCanvas(); + initCaptureMotionBlur(host(makeRoot())); + expect(probe()).toMatchObject({ state: "on", movers: 0 }); + }); + + it("does not rescan on a second call, but does when the timeline changes", () => { + installHtmlInCanvas(); + const { h } = movingHost(); + let tl = "a"; + initCaptureMotionBlur({ ...h, timeline: () => tl }); + probe(); + const seeks = h.seek.mock.calls.length; + probe(); + expect(h.seek.mock.calls.length).toBe(seeks); + tl = "b"; + probe(); + expect(h.seek.mock.calls.length).toBeGreaterThan(seeks); + }); + + it("reports the defect a scan finds, with its code, and a later frame stays plain", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur({ + ...h, + seek: (t, quiet) => { + h.seek(t, quiet); + root.style.opacity = t > 0.5 ? "0.5" : "1"; + }, + }); + expect(probe()).toMatchObject({ state: "off", reasonCode: "root-opacity" }); + expect(probe().reasonCode).toBe("root-opacity"); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + expect(root.parentElement).toBe(document.body); + }); + + it("carries the code of a static defect and of an unsupported browser", () => { + installHtmlInCanvas(); + const root = makeRoot(); + root.innerHTML = + '
'; + initCaptureMotionBlur(host(root)); + expect(probe()).toMatchObject({ state: "off", reasonCode: "backface-visibility" }); + + resetCaptureMotionBlurForTests(); + vi.unstubAllGlobals(); + delete win.__hfMotionBlur; + initCaptureMotionBlur(host(makeRoot())); + expect(probe()).toMatchObject({ state: "off", reasonCode: "unsupported-browser" }); + }); + + it("does not scan again once blur is off, even when a rescan was requested", () => { + installHtmlInCanvas(); + const { h } = movingHost(); + initCaptureMotionBlur(h); + captureContext()?.drawElementImage.mockImplementation(() => { + throw new Error("boom"); + }); + for (let i = 0; i < 3; i++) { + void paintCaptureMotionBlur(0.5, PLAY); + document.querySelector("canvas")?.dispatchEvent(new Event("paint")); + } + initCaptureMotionBlur(h); + const seeks = h.seek.mock.calls.length; + expect(probe()).toMatchObject({ state: "off", reasonCode: "capture-failures" }); + expect(h.seek.mock.calls.length).toBe(seeks); + }); + + it("reports capture-failures after three failed frames", () => { + installHtmlInCanvas(); + const { h } = movingHost(); + initCaptureMotionBlur(h); + captureContext()?.drawElementImage.mockImplementation(() => { + throw new Error("boom"); + }); + for (let i = 0; i < 3; i++) { + void paintCaptureMotionBlur(0.5, PLAY); + const canvas = document.querySelector("canvas"); + canvas?.dispatchEvent(new Event("paint")); + } + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "capture-failures", + }); + }); +}); + describe("guards that once survived a deliberate break (T1)", () => { const frameOf = async (root: HTMLElement, t: number, fail: boolean): Promise => { captureContext()?.drawElementImage.mockImplementation(() => { @@ -1207,7 +1344,7 @@ describe("guards that once survived a deliberate break (T1)", () => { await frameOf(root, 0.3, true); expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off", - reason: "capture failed 3 times in a row", + reasonCode: "capture-failures", }); }); @@ -1235,6 +1372,29 @@ describe("guards that once survived a deliberate break (T1)", () => { expect(times.filter((t) => t < 0)).toEqual([]); }); + it("disables once: one warning, and the first reason stays when unwrapping throws", () => { + installHtmlInCanvas(); + const state = { fade: false, explode: false, timeline: "a" }; + const { root, h } = movingHost(); + const slide = h.seek; + h.seek = vi.fn((t: number, quiet: boolean) => { + if (state.explode && root.parentElement === document.body) throw new Error("seek blew up"); + slide(t, quiet); + root.style.opacity = state.fade && t > 0.5 ? "0.5" : "1"; + }); + h.timeline = () => state.timeline; + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.2, { engineMode: true }); + expect(root.parentElement).toBe(captureCanvas(root)); + Object.assign(state, { fade: true, explode: true, timeline: "b" }); + void paintCaptureMotionBlur(0.2, { engineMode: true }); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reasonCode: "root-opacity", + }); + }); + it("removes the hide rule and both canvases on unwrap, and the movers' hide attribute", () => { installHtmlInCanvas(); const { root, el, h } = movingHost(); diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index cafb88e5711..f3b19b0d2f8 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -59,6 +59,7 @@ interface Config { interface Report { state: "on" | "off"; reason: string | null; + reasonCode: string | null; movers: number; /** The blurred elements, as `tag#id.class`, for diagnosing what was (not) picked. */ moverNames: string[]; @@ -76,7 +77,7 @@ type DrawElementContext = CanvasRenderingContext2D & { }; type PaintCanvas = HTMLCanvasElement & { requestPaint: () => void }; type CompositeWindow = Window & { - __hfMotionBlur?: { report: () => Report }; + __hfMotionBlur?: { report: () => Report; probe: () => Report; suspend: () => void }; __hf_page_composite_pending?: boolean; __hf_page_composite_resolve?: () => boolean; }; @@ -127,15 +128,16 @@ export const trackAcceleratedCanvases = installAcceleratedCanvasTracker; const isAccelerated: DefectEnv["isAccelerated"] = isAcceleratedCanvas; -function unsupportedReason(root: HTMLElement): string | null { +function unsupported(root: HTMLElement): CaptureDefect | null { const off = (window as unknown as Record)[PAGE_MOTION_BLUR_OFF_GLOBAL]; - if (off) return `the renderer turned it off (${String(off)})`; + if (off) return { code: "renderer-off", reason: `the renderer turned it off (${String(off)})` }; if (!supportsCaptureMotionBlur()) { - return "html-in-canvas (drawElementImage) is unavailable in this browser"; + return { + code: "unsupported-browser", + reason: "html-in-canvas (drawElementImage) is unavailable in this browser", + }; } - return ( - findCaptureDefect(root, { isAccelerated, backface: scanBackfaceDefect(root) })?.reason ?? null - ); + return findCaptureDefect(root, { isAccelerated, backface: scanBackfaceDefect(root) }); } function describe(el: Element): string { @@ -199,6 +201,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI let failures = 0; let failedFrames = 0; let disabled: string | null = null; + let disabledCode: string | null = null; // Capture failures end blur for good; a defect block lasts until the scene is scanned again. let failedForGood = false; const timings: number[] = []; @@ -347,7 +350,8 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI countFailedFrame(err instanceof Error ? err.message : String(err)); // A render never gives up mid-video: this frame shows the plain DOM, the next wraps again. if (rendering) unwrap(); - else if (failures >= MAX_FAILURES) disable(`capture failed ${failures} times in a row`, true); + else if (failures >= MAX_FAILURES) + disable(`capture failed ${failures} times in a row`, "capture-failures", true); } /** This paint's half of the frame; false when the background capture is still to come. */ @@ -405,9 +409,10 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI src.addEventListener("paint", onPaint); - function disable(reason: string, forGood = false): void { + function disable(reason: string, code: string, forGood = false): void { if (disabled) return; disabled = reason; + disabledCode = code; failedForGood = forGood; console.warn(`[motion-blur] off: ${reason}`); teardown(); @@ -421,6 +426,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI function clearDefectBlock(): void { if (!disabled || failedForGood) return; disabled = null; + disabledCode = null; reader = null; src.addEventListener("paint", onPaint); } @@ -474,7 +480,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI /** Scan when the timeline is new; true when the scan found a defect (blur is then off). */ function blockedByScan(t: number): boolean { if (!reader || host.timeline() !== scannedTimeline) discover(t); - if (scanDefect) disable(scanDefect.reason); + if (scanDefect) disable(scanDefect.reason, scanDefect.code); return disabled !== null; } @@ -532,7 +538,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI }); } catch (err) { swallow("runtime.captureMotionBlur.paint", err); - disable(err instanceof Error ? err.message : String(err), true); + disable(err instanceof Error ? err.message : String(err), "capture-failures", true); return Promise.resolve(); } } @@ -542,6 +548,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI return { state: disabled ? "off" : "on", reason: disabled, + reasonCode: disabledCode, movers: movers.length, moverNames: movers.map(describe), scanMs, @@ -553,9 +560,16 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI }; } + function probe(): Report { + releaseStaleBlock(); + if (!disabled) blockedByScan(host.now()); + return report(); + } + return { root, paint, + probe, /** Paused in preview: show plain DOM so it can be selected. A render never rests. */ rest: (): boolean => { if (rendering) return false; @@ -574,6 +588,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI step(true); }, teardown, + suspend: unwrap, report, busy: (): boolean => pending !== null, rediscover: () => { @@ -585,6 +600,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config, form: HtmlI type Instance = ReturnType; let instance: Instance | null = null; +let suspended = false; let priorResolver: (() => boolean) | null = null; let ourResolver: (() => boolean) | null = null; @@ -606,28 +622,33 @@ export function initCaptureMotionBlur(host: CaptureMotionBlurHost): void { return; } const form = htmlInCanvasForm(); - const reason = unsupportedReason(host.root); - if (!form || reason) { + const why = unsupported(host.root); + if (!form || why) { + const reason = why?.reason ?? null; console.info(`[motion-blur] off: ${reason} (Chrome ${chromeMajor() ?? "unknown"})`); - w.__hfMotionBlur = { - report: () => ({ - state: "off", - reason, - movers: 0, - moverNames: [], - scanMs: 0, - shutterDeg: config.shutterDeg, - frames: 0, - meanMs: 0, - failures: 0, - failedFrames: 0, - }), - }; + const report = (): Report => ({ + state: "off", + reason, + reasonCode: why?.code ?? null, + movers: 0, + moverNames: [], + scanMs: 0, + shutterDeg: config.shutterDeg, + frames: 0, + meanMs: 0, + failures: 0, + failedFrames: 0, + }); + w.__hfMotionBlur = { report, probe: report, suspend: suspendCaptureMotionBlur }; return; } instance = createInstance(host, config, form); const current = instance; - w.__hfMotionBlur = { report: () => current.report() }; + w.__hfMotionBlur = { + report: () => current.report(), + probe: () => current.probe(), + suspend: suspendCaptureMotionBlur, + }; } /** @@ -641,7 +662,7 @@ export function paintCaptureMotionBlur( t: number, options?: { engineMode?: boolean; playing?: boolean }, ): Promise | null { - if (!instance) return null; + if (!instance || suspended) return null; const engineMode = options?.engineMode === true; if (!engineMode && !options?.playing && instance.rest()) return null; const done = instance.paint(t, engineMode); @@ -678,10 +699,17 @@ function armPageComposite(): void { w.__hf_page_composite_pending = true; } +/** Stop wrapping and painting for the rest of the page's life; the reported decision stays. */ +function suspendCaptureMotionBlur(): void { + suspended = true; + instance?.suspend(); +} + /** Test-only: drop module state between cases. */ export function resetCaptureMotionBlurForTests(): void { instance?.teardown(); instance = null; + suspended = false; priorResolver = null; ourResolver = null; } diff --git a/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts new file mode 100644 index 00000000000..7e2eae55ef1 --- /dev/null +++ b/packages/engine/src/services/captureMotionBlur.pixels.browser.test.ts @@ -0,0 +1,252 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import puppeteer, { type Browser, type Page } from "puppeteer-core"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { decodePng } from "../utils/alphaBlit.js"; +import { resolveHeadlessShellPath } from "./browserManager.js"; +import { closeTestChrome } from "./__fixtures__/loopback.js"; +import { waitForPendingSeekCompletion } from "./frameCapture.js"; + +const RUNTIME = resolve(import.meta.dirname, "../../../core/dist/hyperframe.runtime.iife.js"); +const chromePath = resolveHeadlessShellPath(); +const hasBrowser = + chromePath !== undefined && + existsSync(RUNTIME) && + spawnSync(chromePath, ["--version"]).status === 0; + +const W = 320; +const H = 180; +const FPS = 30; +/** One of the 16 taps is 255 / 16: a plateau may wobble by that much. */ +const TAP = 16; + +interface Frame { + width: number; + data: Uint8Array; +} +type Win = Window & { + __player?: { renderSeek?: (t: number) => void }; + __hf_page_composite_pending?: boolean; + __hf_page_composite_prepare?: () => Promise; + __hf_page_composite_resolve?: () => boolean; + __hfMotionBlur?: { report: () => { state: string; reasonCode: string | null; movers: number } }; +}; + +/** 40 px box moving 60 px per frame (1800 px over the 1 s composition). */ +const STYLE = ` + @keyframes run { to { transform: translateX(1800px) } } + @keyframes flip { to { transform: rotateY(180deg) } } + @keyframes hide { from, to { backface-visibility: hidden } } + @keyframes blink { 0%, 44% { backface-visibility: visible } 46%, 49% { backface-visibility: hidden } 51%, 100% { backface-visibility: visible } } + .box { position: absolute; width: 40px; height: 40px; background: #f00; animation: run 1s linear both } +`; +const BOX = `
`; +const STRIPES = `
`; +const CENTERED = `
`; +const FLIP = `
`; + +/** A card whose backface-visibility, if any, comes only from the given animation. */ +const card = (animation: string, perspective = "perspective:400px") => + `
`; +const WAAPI = ``; +const same = (a: Frame, b: Frame): boolean => Buffer.from(a.data).equals(Buffer.from(b.data)); + +function composition(body: string, blur: boolean): string { + const attrs = blur ? ' data-motion-blur="on" data-motion-blur-shutter="360"' : ""; + return ` +
${body}
`; +} + +const px = (f: Frame, x: number, y: number): [number, number, number] => { + const i = (y * f.width + x) * 4; + return [f.data[i] ?? 0, f.data[i + 1] ?? 0, f.data[i + 2] ?? 0]; +}; + +/** How far a pixel is from white along white-to-red: 0 for white, 255 for pure red. */ +const redness = (f: Frame, x: number, y: number): number => 255 - px(f, x, y)[1]; + +const row = (f: Frame, y: number): number[] => + Array.from({ length: W }, (_, x) => redness(f, x, y)); + +/** Columns that are neither the background nor the pure box colour. */ +const intermediate = (values: number[]): number[] => + values.flatMap((v, x) => (v > 0 && v < 255 ? [x] : [])); + +/** Total redness: a blur spreads the box's ink along the path without adding or losing any. */ +const ink = (values: number[]): number => values.reduce((s, v) => s + v, 0); + +const centroid = (values: number[]): number => { + const total = values.reduce((s, v) => s + v, 0); + return values.reduce((s, v, x) => s + v * (x + 0.5), 0) / total; +}; + +describe.skipIf(!hasBrowser)("capture motion blur pixels in Chrome", () => { + let browser: Browser; + let supported = false; + + beforeAll(async () => { + browser = await puppeteer.launch({ + executablePath: chromePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage", "--enable-features=CanvasDrawElement"], + }); + const probe = await browser.newPage(); + supported = await probe.evaluate( + () => + typeof Reflect.get(CanvasRenderingContext2D.prototype, "drawElementImage") === "function", + ); + await probe.close(); + }, 30_000); + afterAll(() => closeTestChrome(browser)); + + async function open(body: string, blur: boolean): Promise { + const page = await browser.newPage(); + await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); + await page.setContent(composition(body, blur)); + await page.addScriptTag({ content: `window.__HF_EXPORT_RENDER_SEEK_CONFIG={fps:${FPS}};` }); + await page.addScriptTag({ content: readFileSync(RUNTIME, "utf8") }); + await page.waitForFunction(() => { + const w = window as Win & { __playerReady?: boolean; __renderReady?: boolean }; + return w.__playerReady === true && w.__renderReady === true; + }); + return page; + } + + /** One frame the way the engine takes it: seek, wait for the page, composite, screenshot. */ + async function capture(page: Page, t: number): Promise { + const pending = await page.evaluate((time) => { + const w = window as Win; + w.__player?.renderSeek?.(time); + return w.__hf_page_composite_pending === true; + }, t); + await waitForPendingSeekCompletion(page); + if (pending) { + await page.evaluate(async () => { + const w = window as Win; + await w.__hf_page_composite_prepare?.(); + }); + const cdp = await page.createCDPSession(); + await cdp.send("Page.captureScreenshot", { + format: "jpeg", + quality: 1, + clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 }, + }); + await cdp.detach(); + await page.evaluate(() => (window as Win).__hf_page_composite_resolve?.()); + } + const png = Buffer.from(await page.screenshot({ type: "png" })); + const { width, data } = decodePng(png); + return { width, data }; + } + + async function frameOf(body: string, blur: boolean, t: number) { + const page = await open(body, blur); + try { + const frame = await capture(page, t); + const report = await page.evaluate(() => (window as Win).__hfMotionBlur?.report() ?? null); + return { frame, report }; + } finally { + await page.close(); + } + } + + const FRAME = 2 / FPS; + + it("leaves a trail along the motion line where the sharp frame has hard edges", async (ctx) => { + if (!supported) ctx.skip(); + const blurred = await frameOf(BOX, true, FRAME); + const sharp = await frameOf(BOX, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on", movers: 1 }); + + const sharpRow = row(sharp.frame, 40); + expect(sharpRow.filter((v) => v === 255)).toHaveLength(40); + expect(intermediate(sharpRow)).toEqual([]); + + const trail = row(blurred.frame, 40); + const span = intermediate(trail); + expect(span.length).toBeGreaterThanOrEqual(40); + const [first = 0] = span; + const last = span[span.length - 1] ?? 0; + expect(first).toBeLessThan(140); + expect(last).toBeGreaterThan(180); + const rising = trail.slice(first, 150); + const falling = trail.slice(170, last + 1); + expect(rising.every((v, i) => i === 0 || v >= (rising[i - 1] ?? 0) - TAP)).toBe(true); + expect(falling.every((v, i) => i === 0 || v <= (falling[i - 1] ?? 0) + TAP)).toBe(true); + expect(Math.max(...trail)).toBeGreaterThan(120); + expect(Math.abs(centroid(trail) - 160)).toBeLessThan(0.5); + expect(ink(trail) / ink(sharpRow)).toBeGreaterThan(0.97); + expect(ink(trail) / ink(sharpRow)).toBeLessThan(1.03); + }); + + it("leaves what does not move exactly as the sharp frame has it", async (ctx) => { + if (!supported) ctx.skip(); + const blurred = await frameOf(BOX + STRIPES, true, FRAME); + const sharp = await frameOf(BOX + STRIPES, false, FRAME); + expect(blurred.report).toMatchObject({ state: "on" }); + let differing = 0; + for (let y = 100; y < 140; y++) { + for (let x = 200; x < 240; x++) { + if (px(blurred.frame, x, y).join() !== px(sharp.frame, x, y).join()) differing++; + } + } + expect(differing).toBe(0); + expect(new Set(Array.from({ length: 40 }, (_, x) => px(sharp.frame, 200 + x, 110)[0]))).toEqual( + new Set([0, 255]), + ); + }); + + it("blurs a box centred with translate around where it really is", async (ctx) => { + if (!supported) ctx.skip(); + const blurred = await frameOf(CENTERED, true, 1 / FPS); + const sharp = await frameOf(CENTERED, false, 1 / FPS); + const trail = row(blurred.frame, 90); + expect(intermediate(trail).length).toBeGreaterThanOrEqual(40); + const trueCentre = centroid(row(sharp.frame, 90)); + expect(trueCentre).toBeCloseTo(220, 0); + expect(Math.abs(centroid(trail) - trueCentre)).toBeLessThan(0.5); + const column = Array.from({ length: H }, (_, y) => redness(blurred.frame, 220, y)); + expect(Math.abs(centroid(column) - 90)).toBeLessThan(0.5); + }); + + it("blocks a backface-hidden card in a 3D flip and renders the plain frame", async (ctx) => { + if (!supported) ctx.skip(); + const blurred = await frameOf(FLIP, true, 0.4); + const sharp = await frameOf(FLIP, false, 0.4); + expect(blurred.report).toMatchObject({ state: "off", reasonCode: "backface-visibility" }); + expect(Buffer.from(blurred.frame.data).equals(Buffer.from(sharp.frame.data))).toBe(true); + }); + + it("blocks a face whose backface-visibility only arrives through animation", async (ctx) => { + if (!supported) ctx.skip(); + const sharp = await frameOf(BOX + card(""), false, 0.4); + for (const body of [BOX + card("animation: hide 1s both"), BOX + card("") + WAAPI]) { + const blurred = await frameOf(body, true, 0.4); + expect(blurred.report).toMatchObject({ state: "off", reasonCode: "backface-visibility" }); + expect(same(blurred.frame, sharp.frame)).toBe(true); + } + }); + + it("renders only the frame plain where an animated face flips between scan samples", async (ctx) => { + if (!supported) ctx.skip(); + const slow = BOX.replace("top:20px", "top:20px;animation-duration:6s"); + const body = slow + card("animation: blink 1s both"); + for (const [frame, plain] of [ + [14, true], + [5, false], + ] as const) { + const t = frame / FPS; + const [blurred, sharp] = [await frameOf(body, true, t), await frameOf(body, false, t)]; + expect(blurred.report).toMatchObject({ state: "on" }); + expect(same(blurred.frame, sharp.frame)).toBe(plain); + } + }); + + it("leaves a flat animated backface-visibility hack blurred", async (ctx) => { + if (!supported) ctx.skip(); + const { report } = await frameOf(BOX + card("animation: hide 1s both", ""), true, 0.4); + expect(report).toMatchObject({ state: "on", movers: 1 }); + }); +}); diff --git a/packages/engine/src/services/frameCapture-motionBlur.test.ts b/packages/engine/src/services/frameCapture-motionBlur.test.ts index 8ef8aedd422..337cbb0b285 100644 --- a/packages/engine/src/services/frameCapture-motionBlur.test.ts +++ b/packages/engine/src/services/frameCapture-motionBlur.test.ts @@ -635,3 +635,54 @@ describe("a stall on a motion-blurred frame names the step in progress", () => { expect(session.captureStep).toBe("capturing the frame"); }); }); + +describe("the per-session blur line", () => { + const log = () => vi.spyOn(console, "log").mockImplementation(() => {}); + const askForBlur = (report: object) => { + const page = (globalThis as Record).window as Record; + page.__hfMotionBlur = { report: () => report }; + }; + + afterEach(() => vi.restoreAllMocks()); + + it("is logged once, after the session's first captured frame, from the page's report", async () => { + const spy = log(); + askForBlur({ state: "on", reasonCode: null, movers: 4, failedFrames: 0 }); + const session = makeSession({ motionBlur: undefined }); + await captureFrameToBuffer(session, 0, 0); + await captureFrameToBuffer(session, 1, 1 / 30); + await captureFrameToBuffer(session, 2, 2 / 30); + const lines = spy.mock.calls + .map(([line]) => line) + .filter((l) => `${l}`.includes("[motion-blur]")); + expect(lines).toEqual(["[motion-blur] on reasonCode=null movers=4 failedFrames=0"]); + }); + + it("says why blur is off for the session", async () => { + const spy = log(); + askForBlur({ state: "off", reasonCode: "renderer-off", movers: 0 }); + await captureFrameToBuffer(makeSession({ motionBlur: undefined }), 0, 0); + expect(spy).toHaveBeenCalledWith( + "[motion-blur] off reasonCode=renderer-off movers=0 failedFrames=0", + ); + }); + + it("stays silent for a page that never asked for blur, and survives a page that cannot answer", async () => { + const spy = log(); + await captureFrameToBuffer(makeSession({ motionBlur: undefined }), 0, 0); + const broken = makeSession({ motionBlur: undefined }); + const evaluate = broken.page.evaluate.bind(broken.page); + Object.assign(broken.page, { + evaluate: async (...args: Parameters) => { + if (String(args[0]).includes("__hfMotionBlur")) throw new Error("page is gone"); + return evaluate(...args); + }, + }); + await expect(captureFrameToBuffer(broken, 0, 0)).resolves.toMatchObject({ + captureTimeMs: expect.any(Number), + }); + expect( + spy.mock.calls.map(([line]) => `${line}`).filter((l) => l.includes("[motion-blur]")), + ).toEqual([]); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 0986628af56..88586c4ea10 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -40,7 +40,11 @@ import { resolveMotionBlurPlan, type MotionBlurPlan, } from "./motionBlur.js"; -import { installPageMotionBlur } from "./pageMotionBlur.js"; +import { + describePageMotionBlur, + describePageMotionBlurAtEnd, + installPageMotionBlur, +} from "./pageMotionBlur.js"; // ── Extracted modules ─────────────────────────────────────────────────────── import { @@ -4346,6 +4350,10 @@ async function captureFrameCore( session.capturePerf.screenshotMs += screenshotMs; session.capturePerf.totalMs += captureTimeMs; session.capturePerf.frameMs.push(captureTimeMs); + if (session.capturePerf.frames === 1) { + const blur = await describePageMotionBlur(session.page).catch(() => null); + if (blur) console.log(blur); + } // Retain this freshly-captured buffer so the following static frames can reuse it. if (session.staticFrames) { @@ -4792,6 +4800,10 @@ export async function closeCaptureSession(session: CaptureSession): Promise ({ evaluateOnNewDocument: vi.fn(async () => ({ identifier: "1" })) }); @@ -58,3 +63,115 @@ describe("installPageMotionBlur", () => { expect(page.evaluateOnNewDocument.mock.calls).toEqual([[acceleratedCanvasTrackerSource()]]); }); }); + +describe("describePageMotionBlur", () => { + afterEach(() => vi.unstubAllGlobals()); + // Runs the page-side function in this process, with `window` standing in for the page. + const inPage = { evaluate: async (fn: () => unknown) => fn() } as never; + + it("summarises the runtime's report in one line", async () => { + vi.stubGlobal("window", { + __hfMotionBlur: { report: () => ({ state: "off", reasonCode: "renderer-off", movers: 0 }) }, + }); + expect(await describePageMotionBlur(inPage)).toBe( + "[motion-blur] off reasonCode=renderer-off movers=0 failedFrames=0", + ); + }); + + it("reports a running blur with its movers", async () => { + vi.stubGlobal("window", { + __hfMotionBlur: { report: () => ({ state: "on", reasonCode: null, movers: 24 }) }, + }); + expect(await describePageMotionBlur(inPage)).toBe( + "[motion-blur] on reasonCode=null movers=24 failedFrames=0", + ); + }); + + it('carries the frames that fell back to plain DOM, so "on" cannot hide them', async () => { + vi.stubGlobal("window", { + __hfMotionBlur: { + report: () => ({ state: "on", reasonCode: null, movers: 3, failures: 0, failedFrames: 17 }), + }, + }); + expect(await describePageMotionBlur(inPage)).toBe( + "[motion-blur] on reasonCode=null movers=3 failedFrames=17", + ); + }); + + it("says nothing when the page never asked for blur", async () => { + vi.stubGlobal("window", {}); + expect(await describePageMotionBlur(inPage)).toBeNull(); + }); +}); + +describe("describePageMotionBlurAtEnd", () => { + afterEach(() => vi.unstubAllGlobals()); + const inPage = { evaluate: async (fn: () => unknown) => fn() } as never; + const report = (over: Record) => + vi.stubGlobal("window", { + __hfMotionBlur: { + report: () => ({ state: "on", reasonCode: null, movers: 2, failedFrames: 0, ...over }), + }, + }); + + it("stays quiet for a session that blurred every frame, or never could", async () => { + report({}); + expect(await describePageMotionBlurAtEnd(inPage)).toBeNull(); + report({ state: "off", reasonCode: "engine-motion-blur", movers: 0 }); + expect(await describePageMotionBlurAtEnd(inPage)).toBeNull(); + }); + + it("says so when frames fell back to plain DOM, even though blur is still on", async () => { + report({ failedFrames: 4 }); + expect(await describePageMotionBlurAtEnd(inPage)).toBe( + "[motion-blur] on reasonCode=null movers=2 failedFrames=4 (session end)", + ); + }); + + it("says so when the failures ended blur", async () => { + report({ state: "off", reasonCode: "capture-failures", failedFrames: 3 }); + expect(await describePageMotionBlurAtEnd(inPage)).toBe( + "[motion-blur] off reasonCode=capture-failures movers=2 failedFrames=3 (session end)", + ); + }); + + it("gives up on a page that does not answer, or is gone", async () => { + const stuck = { evaluate: () => new Promise(() => {}) } as never; + expect(await describePageMotionBlurAtEnd(stuck, 10)).toBeNull(); + const gone = { evaluate: async () => Promise.reject(new Error("closed")) } as never; + expect(await describePageMotionBlurAtEnd(gone)).toBeNull(); + }); +}); + +describe("closeCaptureSession", () => { + afterEach(() => vi.restoreAllMocks()); + + it("logs the blur's closing line once, before the page closes", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const order: string[] = []; + vi.stubGlobal("window", { + __hfMotionBlur: { + report: () => ({ state: "on", reasonCode: null, movers: 1, failedFrames: 2 }), + }, + }); + const session = { + page: { + evaluate: async (fn: () => unknown) => { + order.push("read"); + return fn(); + }, + close: async () => order.push("close"), + }, + pageReleased: false, + browserReleased: true, + staticDedupCount: 0, + }; + await closeCaptureSession(session as never); + await closeCaptureSession(session as never); + expect(log.mock.calls.map(([line]) => line)).toEqual([ + "[motion-blur] on reasonCode=null movers=1 failedFrames=2 (session end)", + ]); + expect(order).toEqual(["read", "close"]); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/engine/src/services/pageMotionBlur.ts b/packages/engine/src/services/pageMotionBlur.ts index 4e423992aed..844f5f0a55d 100644 --- a/packages/engine/src/services/pageMotionBlur.ts +++ b/packages/engine/src/services/pageMotionBlur.ts @@ -45,3 +45,55 @@ export async function installPageMotionBlur( off, ); } + +interface PageMotionBlurState { + state: string; + code: string; + movers: number; + failedFrames: number; +} + +async function readPageMotionBlur( + page: Pick, +): Promise { + return page.evaluate(() => { + const api: unknown = Reflect.get(window, "__hfMotionBlur"); + const report: unknown = api && typeof api === "object" ? Reflect.get(api, "report") : null; + const got: unknown = typeof report === "function" ? report() : null; + if (!got || typeof got !== "object") return null; + return { + state: String(Reflect.get(got, "state")), + code: String(Reflect.get(got, "reasonCode")), + movers: Number(Reflect.get(got, "movers")), + failedFrames: Number(Reflect.get(got, "failedFrames")) || 0, + }; + }); +} + +const formatPageMotionBlur = (r: PageMotionBlurState): string => + `[motion-blur] ${r.state} reasonCode=${r.code} movers=${r.movers} failedFrames=${r.failedFrames}`; + +/** One line saying what the page's blur did for this session; null when the page never asked for blur. */ +export async function describePageMotionBlur(page: Pick): Promise { + const r = await readPageMotionBlur(page); + return r && formatPageMotionBlur(r); +} + +/** The session's closing line, only when frames fell back to plain DOM: the first-frame line cannot know. */ +export async function describePageMotionBlurAtEnd( + page: Pick, + timeoutMs = 1500, +): Promise { + let timer: ReturnType | undefined; + const stuck = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs); + }); + try { + const r = await Promise.race([readPageMotionBlur(page), stuck]); + return r && r.failedFrames > 0 ? `${formatPageMotionBlur(r)} (session end)` : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/producer/src/services/distributed/chunkFrames.test.ts b/packages/producer/src/services/distributed/chunkFrames.test.ts new file mode 100644 index 00000000000..ce1b2dc7107 --- /dev/null +++ b/packages/producer/src/services/distributed/chunkFrames.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test"; +import { buildChunkCaptureOptions } from "./chunkFrames.js"; + +const input = { + dimensions: { width: 320, height: 240, fpsNum: 30, fpsDen: 1, format: "mp4" as const }, + encoder: { quality: "standard" as const, deviceScaleFactor: 1, variables: undefined }, + videoCount: 0, + html: "", +}; + +describe("buildChunkCaptureOptions", () => { + it("turns the page's capture motion blur off: a distributed render has no preview to match (R19)", () => { + expect(buildChunkCaptureOptions(input).pageMotionBlur).toBe("distributed-render"); + }); + + it("asks for no engine motion blur either", () => { + expect(buildChunkCaptureOptions(input).motionBlur).toBeUndefined(); + }); +});