diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 51514f023ed..75ead8efa5f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -437,4 +437,4 @@ export { type HfVfxParam, type HfVfxParamValues, } from "./vfx.js"; -export { PAGE_MOTION_BLUR_OFF_GLOBAL } from "./pageMotionBlur.js"; +export { PAGE_DEVICE_SCALE_GLOBAL, PAGE_MOTION_BLUR_OFF_GLOBAL } from "./pageMotionBlur.js"; diff --git a/packages/core/src/pageMotionBlur.ts b/packages/core/src/pageMotionBlur.ts index d540d046cf0..4b422e1cef1 100644 --- a/packages/core/src/pageMotionBlur.ts +++ b/packages/core/src/pageMotionBlur.ts @@ -1,6 +1,8 @@ // Set by the engine: its own sub-frame blur runs, or the Studio preview could not blur. export const PAGE_MOTION_BLUR_OFF_GLOBAL = "__HF_PAGE_MOTION_BLUR_OFF__"; +export const PAGE_DEVICE_SCALE_GLOBAL = "__HF_DEVICE_SCALE_FACTOR__"; + export function supportsCaptureMotionBlur(win: Window = window): boolean { const w = win as Window & { CanvasRenderingContext2D?: { prototype: object }; diff --git a/packages/core/src/runtime/adapters/waapi.test.ts b/packages/core/src/runtime/adapters/waapi.test.ts index a7a8e9375d8..2e394f70880 100644 --- a/packages/core/src/runtime/adapters/waapi.test.ts +++ b/packages/core/src/runtime/adapters/waapi.test.ts @@ -80,6 +80,42 @@ describe("waapi adapter", () => { expect(animation.pauseCalls).toBe(1); }); + describe("an animation that went idle", () => { + class FakeCssAnimation {} + const idleScenario = (make: () => ReturnType & { playState: string }) => { + const old = make(); + const fresh = makeAnimation(); + const live: unknown[] = [old, fresh]; + (document as any).getAnimations = vi.fn(() => live); + const adapter = createWaapiAdapter(); + adapter.seek({ time: 1 }); + old.playState = "idle"; + live.shift(); + old.pause.mockClear(); + adapter.seek({ time: 2 }); + return { old, fresh }; + }; + + it("drops a browser-created CSS animation: it is not revived, a fresh one is driven", () => { + vi.stubGlobal("CSSAnimation", FakeCssAnimation); + const { old, fresh } = idleScenario(() => + Object.assign(new FakeCssAnimation(), makeAnimation(), { playState: "running" }), + ); + expect(old.pause).not.toHaveBeenCalled(); + expect(old.currentTime).toBe(1000); + expect(fresh.currentTime).toBe(2000); + vi.unstubAllGlobals(); + }); + + it("still drives an idle script-created animation, as before", () => { + vi.stubGlobal("CSSAnimation", FakeCssAnimation); + const { old } = idleScenario(() => ({ ...makeAnimation(), playState: "running" })); + expect(old.pause).toHaveBeenCalled(); + expect(old.currentTime).toBe(2000); + vi.unstubAllGlobals(); + }); + }); + it("seek clamps negative time to 0", () => { const mockAnim = makeAnimation(); setAnimations([mockAnim]); diff --git a/packages/core/src/runtime/adapters/waapi.ts b/packages/core/src/runtime/adapters/waapi.ts index 2bde3eb20ba..254cad093f1 100644 --- a/packages/core/src/runtime/adapters/waapi.ts +++ b/packages/core/src/runtime/adapters/waapi.ts @@ -2,6 +2,10 @@ import type { RuntimeDeterministicAdapter } from "../types"; import { swallow } from "../diagnostics"; import { holdAnimationAt } from "./_holdAnimationAt"; +const isBrowserCreatedCss = (animation: Animation): boolean => + (typeof CSSAnimation !== "undefined" && animation instanceof CSSAnimation) || + (typeof CSSTransition !== "undefined" && animation instanceof CSSTransition); + export function createWaapiAdapter(): RuntimeDeterministicAdapter { let didDiscover = false; let lastSeekTimeMs = 0; @@ -158,6 +162,10 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { trackAnimations(snapshotAnimations(ctx.pageAnimations), didDiscover ? timeMs : 0); } for (const animation of animations) { + if (animation.playState === "idle" && isBrowserCreatedCss(animation)) { + animations.delete(animation); + continue; + } const baseline = didDiscover ? ensureBaseline(animation, timeMs) : ensureBaseline(animation, 0); diff --git a/packages/core/src/runtime/captureMotionBlur/composite.test.ts b/packages/core/src/runtime/captureMotionBlur/composite.test.ts index 3fd0033873f..efcdc190ee9 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.test.ts @@ -61,7 +61,7 @@ describe("createCompositor", () => { const kept = c.crop(capture, movers); expect(kept).toEqual([true]); const crop = offscreen.at(-1); - expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0); + expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0, 1000, 100); const out = fakeContext(); const stats = c.draw(background, out as never, movers, kept); const accum = offscreen[0]; @@ -77,4 +77,27 @@ describe("createCompositor", () => { const c = createCompositor(100, 100); expect(c.crop(capture, [mover(1000, 1000, slide(0, 100))])).toEqual([false]); }); + + it("works in device pixels: scaled buffers, transforms and dirty rect", () => { + const c = createCompositor(1000, 100, 2); + const movers = [mover(10, 10, slide(0, 100))]; + const kept = c.crop(capture, movers); + const crop = offscreen.at(-1); + // now = translate(50, 0): the inverse maps the capture into the mover's local space. + expect(crop?.setTransform.mock.calls.at(-1)?.map((v: number) => v + 0)).toEqual([ + 2, 0, 0, 2, -100, 0, + ]); + expect(crop?.clearRect).toHaveBeenCalledWith(0, 0, 20, 20); + expect(crop?.drawImage).toHaveBeenCalledWith(capture, 0, 0, 1000, 100); + const out = fakeContext(); + c.draw(background, out as never, movers, kept); + const accum = offscreen[0]; + const lastTap = accum?.setTransform.mock.calls.at(-1); + expect(lastTap?.slice(0, 4)).toEqual([2, 0, 0, 2]); + expect(accum?.drawImage.mock.calls[0]).toEqual([expect.anything(), 0, 0, 20, 20, 0, 0, 10, 10]); + const [, sx, sy, sw, sh, dx, dy, dw, dh] = out.drawImage.mock.calls[1] ?? []; + expect([sx, sy, sh, dx, dy, dh]).toEqual([2, 0, 24, 2, 0, 24]); + expect(sw).toBe(dw); + expect(sw).toBeGreaterThan(200); + }); }); diff --git a/packages/core/src/runtime/captureMotionBlur/composite.ts b/packages/core/src/runtime/captureMotionBlur/composite.ts index 81532fad777..49da8ad5e2d 100644 --- a/packages/core/src/runtime/captureMotionBlur/composite.ts +++ b/packages/core/src/runtime/captureMotionBlur/composite.ts @@ -51,18 +51,23 @@ function moves(mover: MoverFrame, frameW: number, frameH: number): boolean { return rect !== null && travel >= 1; } -export function createCompositor(width: number, height: number) { - const accum = makeLayer(width, height); +/** `width`/`height` are CSS px; the buffers hold `dpr` device pixels per CSS px. */ +export function createCompositor(width: number, height: number, dpr = 1) { + const accumW = Math.max(1, Math.round(width * dpr)); + const accumH = Math.max(1, Math.round(height * dpr)); + const accum = makeLayer(accumW, accumH); const crops: Layer[] = []; // A mover box this many times the frame is too big to copy every frame; it is left sharp. const maxCropArea = width * height * 4; const cropLayer = (i: number, w: number, h: number): Layer => { + const dw = Math.ceil(w * dpr); + const dh = Math.ceil(h * dpr); let layer = crops[i]; - if (!layer || layer.canvas.width < w || layer.canvas.height < h) { + if (!layer || layer.canvas.width < dw || layer.canvas.height < dh) { layer = makeLayer( - Math.max(w, layer?.canvas.width ?? 1), - Math.max(h, layer?.canvas.height ?? 1), + Math.max(dw, layer?.canvas.width ?? 1), + Math.max(dh, layer?.canvas.height ?? 1), ); crops[i] = layer; } @@ -79,9 +84,16 @@ export function createCompositor(width: number, height: number) { c.setTransform(1, 0, 0, 1, 0, 0); c.globalCompositeOperation = "source-over"; c.globalAlpha = 1; - c.clearRect(0, 0, w, h); - c.setTransform(back.a, back.b, back.c, back.d, back.e, back.f); - c.drawImage(src, 0, 0); + c.clearRect(0, 0, Math.ceil(w * dpr), Math.ceil(h * dpr)); + c.setTransform( + dpr * back.a, + dpr * back.b, + dpr * back.c, + dpr * back.d, + dpr * back.e, + dpr * back.f, + ); + c.drawImage(src, 0, 0, width, height); return true; }); } @@ -91,18 +103,23 @@ export function createCompositor(width: number, height: number) { const { travel, rect } = sweep(mover.taps, w, h, width, height); if (!rect) return 0; const taps = thinTaps(mover.taps, travel); + const rx = Math.max(0, Math.floor(rect.x * dpr)); + const ry = Math.max(0, Math.floor(rect.y * dpr)); + const rw = Math.min(accumW, Math.ceil((rect.x + rect.w) * dpr)) - rx; + const rh = Math.min(accumH, Math.ceil((rect.y + rect.h) * dpr)) - ry; + if (rw <= 0 || rh <= 0) return 0; const a = accum.ctx; a.setTransform(1, 0, 0, 1, 0, 0); a.globalCompositeOperation = "source-over"; a.globalAlpha = 1; - a.clearRect(rect.x, rect.y, rect.w, rect.h); + a.clearRect(rx, ry, rw, rh); a.globalCompositeOperation = "lighter"; a.globalAlpha = 1 / taps.length; for (const m of taps) { - a.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); - a.drawImage(layer.canvas, 0, 0, w, h, 0, 0, w, h); + a.setTransform(dpr * m.a, dpr * m.b, dpr * m.c, dpr * m.d, dpr * m.e, dpr * m.f); + a.drawImage(layer.canvas, 0, 0, w * dpr, h * dpr, 0, 0, w, h); } - out.drawImage(accum.canvas, rect.x, rect.y, rect.w, rect.h, rect.x, rect.y, rect.w, rect.h); + out.drawImage(accum.canvas, rx, ry, rw, rh, rx, ry, rw, rh); return taps.length; } diff --git a/packages/core/src/runtime/captureMotionBlur/index.test.ts b/packages/core/src/runtime/captureMotionBlur/index.test.ts index ccd7a8c55df..13590878cfb 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.test.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type CaptureMotionBlurHost, + captureMotionBlurBusy, initCaptureMotionBlur, paintCaptureMotionBlur, releaseCaptureMotionBlurFrame, @@ -36,6 +37,7 @@ function fakeContext() { }; } let contexts: Array> = []; +const captureContext = () => contexts[0]; function installHtmlInCanvas(): void { vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => { @@ -80,7 +82,7 @@ function host( root: HTMLElement, seek = vi.fn(), ): CaptureMotionBlurHost & { seek: ReturnType } { - return { root, fps: 30, durationSeconds: () => 1, timeline: () => "tl", seek }; + return { root, fps: 30, durationSeconds: () => 1, timeline: () => "tl", now: () => 0, seek }; } const captureCanvas = (root: HTMLElement) => root.parentElement as HTMLCanvasElement; @@ -101,6 +103,7 @@ afterEach(() => { delete win.__hfMotionBlur; delete win.__hf_page_composite_pending; delete win.__hf_page_composite_resolve; + Reflect.deleteProperty(document, "visibilityState"); document.body.innerHTML = ""; }); @@ -150,6 +153,14 @@ describe("opt-in and off switches", () => { }); }); + it("stays off in a browser with canvas but without drawElementImage (stable Chrome)", () => { + installHtmlInCanvas(); + vi.stubGlobal("CanvasRenderingContext2D", class {}); + const root = makeRoot(); + initCaptureMotionBlur(host(root)); + expect(root.parentElement).toBe(document.body); + }); + it("stays off while the engine's own sub-frame blur is on (never both)", () => { installHtmlInCanvas(); win.__HF_PAGE_MOTION_BLUR_OFF__ = "engine-motion-blur"; @@ -160,6 +171,21 @@ describe("opt-in and off switches", () => { expect(win.__hfMotionBlur?.report().reason).toContain("engine"); }); + it("says why it is off to a root that asked for it", () => { + initCaptureMotionBlur(host(makeRoot())); + expect(console.info).toHaveBeenCalledWith(expect.stringContaining("html-in-canvas")); + }); + + it("stays off when the composition already nests a capture canvas", () => { + installHtmlInCanvas(); + const root = makeRoot(); + const inner = document.createElement("canvas"); + inner.setAttribute("layoutsubtree", ""); + root.append(inner); + initCaptureMotionBlur(host(root)); + expect(root.parentElement).toBe(document.body); + }); + it("stays off for a WebGL canvas, which html-in-canvas captures frozen, but not for a 2D one", () => { installHtmlInCanvas(); trackAcceleratedCanvases(); @@ -190,6 +216,13 @@ describe("opt-in and off switches", () => { expect(root.parentElement).toBe(document.body); expect(win.__hfMotionBlur?.report().reason).toMatch(/WebGL/); }); + + it("clamps the shutter angle", () => { + installHtmlInCanvas(); + const root = makeRoot({ "data-motion-blur": "on", "data-motion-blur-shutter": "9999" }); + initCaptureMotionBlur(host(root)); + expect(win.__hfMotionBlur?.report().shutterDeg).toBe(720); + }); }); describe("wrapping", () => { @@ -210,6 +243,18 @@ describe("wrapping", () => { expect(out.style.pointerEvents).toBe("none"); }); + it("unwraps when the root opts back out", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + root.removeAttribute("data-motion-blur"); + initCaptureMotionBlur(h); + expect(root.parentElement).toBe(document.body); + expect(document.querySelectorAll("canvas")).toHaveLength(0); + expect(win.__hfMotionBlur).toBeUndefined(); + }); + it("turns off instead of wrapping when a capture canvas appears after init", () => { installHtmlInCanvas(); const { root, h } = movingHost(); @@ -221,6 +266,585 @@ describe("wrapping", () => { expect(root.parentElement).toBe(document.body); expect(win.__hfMotionBlur?.report().state).toBe("off"); }); + + it("never wraps a composition where nothing moves", () => { + installHtmlInCanvas(); + const root = makeRoot(); + initCaptureMotionBlur(host(root)); + void paintCaptureMotionBlur(0.5, PLAY); + expect(root.parentElement).toBe(document.body); + }); + + 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("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(); + initCaptureMotionBlur(h); + const inFlight = paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); // movers hidden mid-frame + expect(paintCaptureMotionBlur(0.5)).toBeNull(); + await inFlight; // the dropped frame still settles + expect(root.parentElement).toBe(document.body); + expect(el.hasAttribute(HIDE)).toBe(false); + expect(document.querySelectorAll("canvas")).toHaveLength(0); + void paintCaptureMotionBlur(0.6, PLAY); + expect(captureCanvas(root).tagName).toBe("CANVAS"); + }); + + it("never unwraps once the engine drives frames", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + // A paused-style request from the transport during a render: it still paints. + expect(paintCaptureMotionBlur(0.5)).not.toBeNull(); + expect(captureCanvas(root).tagName).toBe("CANVAS"); + }); + + it("shows the first frame after wrapping sharp, not the last one from before", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(contexts[1]?.drawImage).toHaveBeenCalledWith(captureCanvas(root), 0, 0); + }); +}); + +const HIDE = "data-hf-motion-blur-hide"; + +describe("two-phase frames", () => { + it("hides the movers for a background capture, then reveals them and settles", async () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + const done = paintCaptureMotionBlur(0.5, PLAY); + expect(win.__hfMotionBlur?.report().moverNames).toEqual(["div#slider"]); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(el.hasAttribute(HIDE)).toBe(true); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(1); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + expect(el.hasAttribute(HIDE)).toBe(false); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(2); + }); + + it("is busy from the first paint request until the frame is drawn (R11)", async () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + expect(captureMotionBlurBusy()).toBe(false); + initCaptureMotionBlur(h); + expect(captureMotionBlurBusy()).toBe(false); + const done = paintCaptureMotionBlur(0.5, PLAY); + expect(captureMotionBlurBusy()).toBe(true); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(captureMotionBlurBusy()).toBe(true); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + expect(captureMotionBlurBusy()).toBe(false); + }); + + it("is no longer busy when a paused request unwraps a frame in flight", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(paintCaptureMotionBlur(0.5)).toBeNull(); + expect(captureMotionBlurBusy()).toBe(false); + }); + + it("does not abandon a frame in flight: newer requests collapse into the latest", async () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + const first = paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); // movers cropped, now hidden + const seeksBefore = h.seek.mock.calls.length; + const second = paintCaptureMotionBlur(0.6, PLAY); + const third = paintCaptureMotionBlur(0.7, PLAY); + // Queued, not started: no pose reads yet, and the first frame keeps its movers hidden. + expect(h.seek.mock.calls.length).toBe(seeksBefore); + expect(el.hasAttribute(HIDE)).toBe(true); + captureCanvas(root).dispatchEvent(new Event("paint")); // first frame done → 0.7 starts + await first; + expect(h.seek.mock.calls.at(-1)?.[0]).toBe(0.7); + captureCanvas(root).dispatchEvent(new Event("paint")); + captureCanvas(root).dispatchEvent(new Event("paint")); + await Promise.all([second, third]); + expect(el.hasAttribute(HIDE)).toBe(false); + }); + + it("reveals the movers if the second paint never comes", async () => { + vi.useFakeTimers(); + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(el.hasAttribute(HIDE)).toBe(true); + await vi.advanceTimersByTimeAsync(2000); + expect(el.hasAttribute(HIDE)).toBe(false); + }); + + it("finishes in one capture when the engine's step runs before any paint", () => { + installHtmlInCanvas(); + const { el, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + win.__hf_page_composite_resolve?.(); + expect(el.hasAttribute(HIDE)).toBe(false); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(1); + }); + + it("lets the engine's composite step finish a half-done frame", () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + releaseCaptureMotionBlurFrame(); + captureCanvas(root).dispatchEvent(new Event("paint")); + win.__hf_page_composite_resolve?.(); + expect(el.hasAttribute(HIDE)).toBe(false); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(2); + }); + + it("starts an engine frame's paint wait on release, so slow video injection cannot drop it", async () => { + vi.useFakeTimers(); + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + await vi.advanceTimersByTimeAsync(5000); + releaseCaptureMotionBlurFrame(); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(1); + }); + + it("gives up on a released engine frame whose paint never comes", async () => { + vi.useFakeTimers(); + installHtmlInCanvas(); + const { h } = movingHost(); + initCaptureMotionBlur(h); + let settled = false; + void paintCaptureMotionBlur(0.5, { engineMode: true })?.then(() => (settled = true)); + releaseCaptureMotionBlurFrame(); + await vi.advanceTimersByTimeAsync(2000); + expect(settled).toBe(true); + }); + + it("leaves the engine's composite step idle when nothing moves", () => { + installHtmlInCanvas(); + initCaptureMotionBlur(host(makeRoot())); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + expect(win.__hf_page_composite_pending).toBeUndefined(); + }); + + it("holds an engine frame until released, so injected video frames are in its capture", () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + initCaptureMotionBlur(h); + const requestPaint = vi.fn(); + (HTMLCanvasElement.prototype as unknown as Record).requestPaint = requestPaint; + void paintCaptureMotionBlur(0.5, { engineMode: true }); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(requestPaint).not.toHaveBeenCalled(); + expect(captureContext()?.drawElementImage).not.toHaveBeenCalled(); + releaseCaptureMotionBlurFrame(); + expect(requestPaint).toHaveBeenCalledTimes(1); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(el.hasAttribute(HIDE)).toBe(true); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(1); + }); +}); + +describe("painting", () => { + it("captures the root on the next paint and settles the frame", async () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + const done = paintCaptureMotionBlur(0.5, PLAY); + expect(done).not.toBeNull(); + captureCanvas(root).dispatchEvent(new Event("paint")); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + expect(captureContext()?.drawElementImage).toHaveBeenCalledWith(root, 0, 0); + // Reset before each capture and again after it: the capture canvas sits under the output + // and must never show its own bitmap (a transparent composition would show it doubled). + expect(captureContext()?.reset).toHaveBeenCalledTimes(4); + // The discovery scan's last word is the frame time: the page is left where it was seeked. + expect(h.seek.mock.calls.at(-1)?.[0]).toBe(0.5); + }); + + it("seeks pose reads with events suppressed unless the root allows them", () => { + installHtmlInCanvas(); + const quiet = host(makeRoot()); + initCaptureMotionBlur(quiet); + void paintCaptureMotionBlur(0.5, PLAY); + expect(quiet.seek.mock.calls.every(([, suppress]) => suppress === true)).toBe(true); + + resetCaptureMotionBlurForTests(); + const eventful = movingHost({ + "data-motion-blur": "on", + "data-motion-blur-events": "on", + }).h; + initCaptureMotionBlur(eventful); + void paintCaptureMotionBlur(0.5, PLAY); + const calls = eventful.seek.mock.calls; + expect(calls.at(-1)?.[1]).toBe(false); + expect(calls.some(([, suppress]) => suppress === false)).toBe(true); + }); + + it("never fires timeline callbacks during discovery, even with events on", () => { + installHtmlInCanvas(); + const { h } = movingHost({ "data-motion-blur": "on", "data-motion-blur-events": "on" }); + const fired: number[] = []; + const callAtOnePointSeven = (t: number, suppress: boolean) => { + h.seek(t, suppress); + if (!suppress && t >= 0.7) fired.push(t); + }; + initCaptureMotionBlur({ ...h, seek: callAtOnePointSeven }); + void paintCaptureMotionBlur(0.1, PLAY); + expect(win.__hfMotionBlur?.report().movers).toBe(1); + expect(fired).toEqual([]); + const quiet = h.seek.mock.calls.filter(([, suppress]) => suppress === true); + expect(quiet.some(([t]) => t === 0.1)).toBe(true); + }); + + it("in engine mode composes with the prior composite owner and draws even without a paint event", () => { + installHtmlInCanvas(); + const prior = vi.fn(() => true); + win.__hf_page_composite_resolve = prior; + const { h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + expect(win.__hf_page_composite_pending).toBe(true); + win.__hf_page_composite_resolve?.(); + expect(prior).toHaveBeenCalled(); + expect(captureContext()?.drawElementImage).toHaveBeenCalledTimes(1); + expect(win.__hf_page_composite_pending).toBe(false); + }); + + it("re-scans for movers when the runtime binds a different timeline", () => { + installHtmlInCanvas(); + let timeline = "first"; + const h = { ...host(makeRoot()), timeline: () => timeline }; + initCaptureMotionBlur(h); + const frame = (t: number): void => { + void paintCaptureMotionBlur(t, PLAY); + captureCanvas(h.root).dispatchEvent(new Event("paint")); + }; + frame(0.5); + const afterFirst = h.seek.mock.calls.length; + frame(0.6); + const steady = h.seek.mock.calls.length - afterFirst; + timeline = "second"; + frame(0.7); + // A scan seeks once per frame of the composition (30 here); a plain frame seeks far less. + expect(h.seek.mock.calls.length - afterFirst - steady).toBeGreaterThan(steady + 30); + }); + + it("gives up on a frame whose paint never comes", async () => { + vi.useFakeTimers(); + installHtmlInCanvas(); + initCaptureMotionBlur(movingHost().h); + const done = paintCaptureMotionBlur(0.5, PLAY); + let settled = false; + void done?.then(() => (settled = true)); + await vi.advanceTimersByTimeAsync(1999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(settled).toBe(true); + }); + + it("rests on plain DOM in a hidden tab, with no paint requests, and resumes when visible (W1)", () => { + installHtmlInCanvas(); + const requestPaint = vi.fn(); + (HTMLCanvasElement.prototype as unknown as Record).requestPaint = requestPaint; + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + const visibility = (v: string) => + Object.defineProperty(document, "visibilityState", { value: v, configurable: true }); + visibility("hidden"); + void paintCaptureMotionBlur(0.5, PLAY); + expect(root.parentElement).toBe(document.body); + expect(requestPaint).not.toHaveBeenCalled(); + void paintCaptureMotionBlur(0.5, { engineMode: true }); + expect(root.parentElement).toBe(captureCanvas(root)); + visibility("visible"); + void paintCaptureMotionBlur(0.6, PLAY); + expect(root.parentElement).toBe(captureCanvas(root)); + expect(win.__hfMotionBlur?.report().state).toBe("on"); + }); + + it("turns itself off and unwraps after repeated capture failures", async () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + for (let i = 0; i < 3; i++) { + const done = paintCaptureMotionBlur(i / 30, PLAY); + captureContext()?.drawElementImage.mockImplementation(() => { + throw new Error("No cached paint record"); + }); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + } + expect(root.parentElement).toBe(document.body); + expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "off" }); + }); + + it("keeps blurring in a render after repeated capture failures, each failed frame plain DOM", async () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + const engineFrame = async (i: number): Promise => { + const done = paintCaptureMotionBlur(i / 30, { engineMode: true }); + releaseCaptureMotionBlurFrame(); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + }; + captureContext()?.drawElementImage.mockImplementation(() => { + throw new Error("No cached paint record"); + }); + for (let i = 0; i < 5; i++) { + await engineFrame(i); + expect(root.parentElement).toBe(document.body); + } + expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "on", failures: 5 }); + captureContext()?.drawElementImage.mockReset(); + const done = paintCaptureMotionBlur(0.5, { engineMode: true }); + expect(captureCanvas(root).tagName).toBe("CANVAS"); + releaseCaptureMotionBlurFrame(); + captureCanvas(root).dispatchEvent(new Event("paint")); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "on", failures: 0, frames: 6 }); + expect(contexts[1]?.drawImage.mock.calls.length).toBeGreaterThan(1); + }); +}); + +describe("device pixel ratio", () => { + it("sizes the capture and output canvases in device pixels and keeps the CSS size", () => { + installHtmlInCanvas(); + vi.stubGlobal("devicePixelRatio", 2); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + const src = captureCanvas(root); + const out = src.nextElementSibling as HTMLCanvasElement; + for (const canvas of [src, out]) { + expect([canvas.width, canvas.height]).toEqual([1280, 720]); + expect([canvas.style.width, canvas.style.height]).toEqual(["640px", "360px"]); + } + }); + + it("builds the accumulation buffer in device pixels", () => { + installHtmlInCanvas(); + vi.stubGlobal("devicePixelRatio", 2); + const sizes: number[][] = []; + const Fake = ( + globalThis as unknown as { OffscreenCanvas: new (w: number, h: number) => object } + ).OffscreenCanvas; + vi.stubGlobal( + "OffscreenCanvas", + class extends Fake { + constructor(w: number, h: number) { + super(w, h); + sizes.push([w, h]); + } + }, + ); + initCaptureMotionBlur(movingHost().h); + expect(sizes[0]).toEqual([1280, 720]); + }); + + it("prefers the engine's injected scale over a devicePixelRatio that fell back to 1", () => { + installHtmlInCanvas(); + vi.stubGlobal("devicePixelRatio", 1); + vi.stubGlobal("__HF_DEVICE_SCALE_FACTOR__", 2); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + expect(captureCanvas(root).width).toBe(1280); + }); + + it("rebuilds the canvases when the ratio changes between frames", () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + const first = paintCaptureMotionBlur(0.5, PLAY); + expect(captureCanvas(root).width).toBe(640); + captureCanvas(root).dispatchEvent(new Event("paint")); + captureCanvas(root).dispatchEvent(new Event("paint")); + void first; + vi.stubGlobal("devicePixelRatio", 2); + void paintCaptureMotionBlur(0.6, PLAY); + expect(captureCanvas(root).width).toBe(1280); + }); +}); + +describe("guards that once survived a deliberate break (T1)", () => { + const frameOf = async (root: HTMLElement, t: number, fail: boolean): Promise => { + captureContext()?.drawElementImage.mockImplementation(() => { + if (fail) throw new Error("No cached paint record"); + }); + const done = paintCaptureMotionBlur(t, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); + captureCanvas(root).dispatchEvent(new Event("paint")); + await done; + }; + + it("keeps blurring through two failed frames and gives up on exactly the third", async () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + await frameOf(root, 0.1, true); + await frameOf(root, 0.2, true); + expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "on", failures: 2 }); + await frameOf(root, 0.3, true); + expect(win.__hfMotionBlur?.report()).toMatchObject({ + state: "off", + reason: "capture failed 3 times in a row", + }); + }); + + it("a good frame between failures restarts the count", async () => { + installHtmlInCanvas(); + const { root, h } = movingHost(); + initCaptureMotionBlur(h); + await frameOf(root, 0.1, true); + await frameOf(root, 0.2, true); + await frameOf(root, 0.3, false); + await frameOf(root, 0.4, true); + await frameOf(root, 0.5, true); + expect(win.__hfMotionBlur?.report()).toMatchObject({ state: "on", failures: 2 }); + }); + + it("never seeks below zero for the pose reads of a frame near the start", () => { + installHtmlInCanvas(); + const { h } = movingHost(); + initCaptureMotionBlur(h); + h.seek.mockClear(); + void paintCaptureMotionBlur(0.001, PLAY); + void paintCaptureMotionBlur(0, { engineMode: true }); + const times = h.seek.mock.calls.map((call) => Number(call[0])); + expect(times.length).toBeGreaterThan(4); + expect(times.filter((t) => t < 0)).toEqual([]); + }); + + it("removes the hide rule and both canvases on unwrap, and the movers' hide attribute", () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + const hideRules = () => + [...document.head.querySelectorAll("style")].filter((s) => s.textContent?.includes(HIDE)); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(hideRules()).toHaveLength(1); + expect(el.hasAttribute(HIDE)).toBe(true); + expect(paintCaptureMotionBlur(0.5)).toBeNull(); + expect(hideRules()).toHaveLength(0); + expect(document.querySelectorAll("canvas")).toHaveLength(0); + expect(el.hasAttribute(HIDE)).toBe(false); + expect(root.parentElement).toBe(document.body); + }); + + it("does not blur a mover that is not visible", () => { + installHtmlInCanvas(); + const { root, el, h } = movingHost(); + const visible = { value: false }; + const checkVisibility = vi.fn(() => visible.value); + Object.defineProperty(el, "checkVisibility", { value: checkVisibility, configurable: true }); + initCaptureMotionBlur(h); + void paintCaptureMotionBlur(0.5, PLAY); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(checkVisibility).toHaveBeenCalledWith({ visibilityProperty: true }); + expect(el.hasAttribute(HIDE)).toBe(false); + visible.value = true; + void paintCaptureMotionBlur(0.6, { engineMode: true }); + releaseCaptureMotionBlurFrame(); + captureCanvas(root).dispatchEvent(new Event("paint")); + expect(el.hasAttribute(HIDE)).toBe(true); + }); }); describe("paint timeouts", () => { diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index 9324a8f22c1..1261dc81b8c 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -11,7 +11,11 @@ * Needs html-in-canvas (`drawElementImage`); without it nothing changes. */ -import { PAGE_MOTION_BLUR_OFF_GLOBAL, supportsCaptureMotionBlur } from "../../pageMotionBlur"; +import { + PAGE_DEVICE_SCALE_GLOBAL, + PAGE_MOTION_BLUR_OFF_GLOBAL, + supportsCaptureMotionBlur, +} from "../../pageMotionBlur"; import { swallow } from "../diagnostics"; import { createCompositor, type MoverFrame } from "./composite"; import { discoverMovers } from "./discover"; @@ -23,8 +27,10 @@ export interface CaptureMotionBlurHost { durationSeconds: () => number; /** The bound root timeline; movers are re-scanned when it changes. */ timeline: () => unknown; + now: () => number; /** Seek the timeline and adapters (not media) for a pose read. */ seek: (t: number, suppressEvents: boolean) => void; + ignoreMutations?: (write: () => void) => void; } interface Config { @@ -158,10 +164,16 @@ function rootSize(root: HTMLElement): { width: number; height: number } { return { width: Math.max(1, Math.round(w)), height: Math.max(1, Math.round(h)) }; } +function readDpr(): number { + const injected = Number((window as unknown as Record)[PAGE_DEVICE_SCALE_GLOBAL]); + const dpr = injected > 0 ? injected : window.devicePixelRatio; + return Number.isFinite(dpr) && dpr > 0 ? dpr : 1; +} + function makeCanvas(width: number, height: number, role: string): HTMLCanvasElement { const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; + canvas.width = Math.max(1, Math.round(width * readDpr())); + canvas.height = Math.max(1, Math.round(height * readDpr())); canvas.setAttribute("data-hf-ignore", ""); canvas.setAttribute("data-hf-motion-blur", role); // ponytail: assumes the root sits at its parent's origin (true for compositions); place the @@ -183,7 +195,8 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { if (!captureContext || !outputContext) throw new Error("capture motion blur: no 2D context"); const sctx: DrawElementContext = captureContext; const octx: CanvasRenderingContext2D = outputContext; - const compositor = createCompositor(width, height); + let dpr = readDpr(); + let compositor = createCompositor(width, height, dpr); // Hides movers for the background capture without touching author or GSAP opacity. const hideRule = document.createElement("style"); hideRule.setAttribute("data-hf-ignore", ""); @@ -208,12 +221,19 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { // Once the engine drives frames, paused-style requests come from the transport, not a user. let rendering = false; + function moveRootThenResync(move: () => void): void { + (host.ignoreMutations ?? ((write: () => void) => write()))(move); + host.seek(host.now(), true); + } + function wrap(): void { if (wrapped) return; - root.before(src); - src.append(root); - src.after(out); - document.head.append(hideRule); + moveRootThenResync(() => { + root.before(src); + src.append(root); + src.after(out); + document.head.append(hideRule); + }); wrapped = true; staleOutput = true; } @@ -229,13 +249,17 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { } if (!wrapped) return; wrapped = false; - hideRule.remove(); - src.before(root); - src.remove(); - out.remove(); + moveRootThenResync(() => { + hideRule.remove(); + src.before(root); + src.remove(); + out.remove(); + }); } const seek = (t: number): void => host.seek(t, !config.events); + // The scan walks the whole timeline: callbacks must not fire (or leave their effects) there. + const scanSeek = (t: number): void => host.seek(t, true); function discover(restoreTo: number): void { scannedTimeline = host.timeline(); @@ -244,9 +268,9 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { scene: root, durationSeconds: host.durationSeconds(), fps: host.fps, - seek, + seek: scanSeek, }); - seek(restoreTo); + scanSeek(restoreTo); movers = found.movers; reader = createPoseReader(root, found.dynamic); scanMs = Math.round(performance.now() - s0); @@ -305,9 +329,7 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { done = captureAndDraw(job, finalStep); failures = 0; } catch (err) { - failures++; - swallow("runtime.captureMotionBlur.compose", err); - if (failures >= MAX_FAILURES) disable(`capture failed ${failures} times in a row`); + onCaptureFailure(err); } job.ms += performance.now() - t0; if (!done && !disabled) { @@ -319,6 +341,15 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { finishJob(job); } + function onCaptureFailure(err: unknown): void { + failures++; + swallow("runtime.captureMotionBlur.compose", err); + 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`); + } + /** This paint's half of the frame; false when the background capture is still to come. */ function captureAndDraw(job: Job, finalStep: boolean): boolean { capture(); @@ -394,6 +425,11 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { function paint(t: number, engineMode: boolean): Promise { if (disabled) return Promise.resolve(); if (engineMode) rendering = true; + // A hidden tab paints nothing: rest on plain DOM instead of waiting on paints that never come. + if (!rendering && document.visibilityState === "hidden") { + unwrap(); + return Promise.resolve(); + } if (pending && !engineMode) { const previous = queued; return new Promise((resolve) => { @@ -410,8 +446,21 @@ function createInstance(host: CaptureMotionBlurHost, config: Config) { return start(t, engineMode); } + function syncDensity(): void { + const next = readDpr(); + if (next === dpr) return; + dpr = next; + for (const canvas of [src, out]) { + canvas.width = Math.max(1, Math.round(width * dpr)); + canvas.height = Math.max(1, Math.round(height * dpr)); + } + compositor = createCompositor(width, height, dpr); + staleOutput = true; + } + function start(t: number, engineMode: boolean): Promise { try { + syncDensity(); if (!reader || host.timeline() !== scannedTimeline) discover(t); if (movers.length === 0) { unwrap(); @@ -573,6 +622,11 @@ export function paintCaptureMotionBlur( return done; } +/** True while a blurred frame is between its two captures: the page must hold still. */ +export function captureMotionBlurBusy(): boolean { + return instance?.busy() ?? false; +} + /** Start capturing a deferred engine frame: the page (injected video frames included) is final. */ export function releaseCaptureMotionBlurFrame(): void { instance?.arm(); diff --git a/packages/core/src/runtime/init.blurHold.test.ts b/packages/core/src/runtime/init.blurHold.test.ts new file mode 100644 index 00000000000..4d40a6d36fa --- /dev/null +++ b/packages/core/src/runtime/init.blurHold.test.ts @@ -0,0 +1,81 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { initSandboxRuntimeModular } from "./init"; +import { createMockTimeline, resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; + +const blur = vi.hoisted(() => ({ busy: false, paints: [] as number[] })); + +vi.mock("./captureMotionBlur", async (original) => ({ + ...(await original()), + captureMotionBlurBusy: () => blur.busy, + paintCaptureMotionBlur: (t: number) => { + blur.paints.push(t); + return null; + }, +})); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + blur.busy = false; + blur.paints = []; +}); + +function mountPlaying() { + resetRuntimeFixtureDom(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] }); + const frames: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => frames.push(callback)); + document.body.innerHTML = + '
'; + const main = createMockTimeline(60); + const seeks: number[] = []; + const totalTime = main.totalTime; + main.totalTime = (time?: number) => { + if (time !== undefined) seeks.push(time); + return totalTime(time); + }; + window.__timelines = { main }; + initSandboxRuntimeModular(); + const tick = (count = 1) => { + for (let i = 0; i < count; i += 1) { + for (const callback of frames.splice(0)) callback(performance.now()); + vi.advanceTimersByTime(20); + } + }; + tick(10); + window.__player!.play(); + tick(2); + return { tick, seeks }; +} + +it("holds playback seeks and paints while a blurred frame is in flight, then catches up", () => { + const { tick, seeks } = mountPlaying(); + blur.busy = false; + tick(3); + expect(seeks.length).toBeGreaterThan(0); + expect(blur.paints.length).toBeGreaterThan(0); + + blur.busy = true; + seeks.length = 0; + blur.paints.length = 0; + tick(5); + expect(seeks).toEqual([]); + expect(blur.paints).toEqual([]); + + blur.busy = false; + tick(2); + expect(seeks.length).toBeGreaterThan(0); + expect(blur.paints.length).toBeGreaterThan(0); + window.__player!.pause(); +}); + +it("never holds a seek or a paused paint", () => { + const { tick, seeks } = mountPlaying(); + window.__player!.pause(); + tick(2); + blur.busy = true; + seeks.length = 0; + window.__player!.seek(5); + tick(2); + expect(seeks).toContain(5); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7661f27b8ed..fb701b89217 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -126,6 +126,7 @@ import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorG import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "../colorGrading"; import { initVfx, paintVfx } from "./vfx"; import { + captureMotionBlurBusy, initCaptureMotionBlur, paintCaptureMotionBlur, releaseCaptureMotionBlurFrame, @@ -1396,6 +1397,13 @@ export function initSandboxRuntimeModular(): void { invalidateCompositionTimingCaches(); }; + const withoutOwnCompositionRecords = (write: () => void) => { + const pending = compositionTimingObserver?.takeRecords() ?? []; + write(); + compositionTimingObserver?.takeRecords(); + if (pending.length > 0) invalidateCompositionTimingCaches(); + }; + const watchCompositionTimingInputs = () => { if (compositionTimingObserver || typeof MutationObserver === "undefined") return; compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches); @@ -3677,9 +3685,11 @@ export function initSandboxRuntimeModular(): void { Number(root.getAttribute("data-duration")) || 0, timeline: () => state.capturedTimeline, + now: () => Math.max(0, state.currentTime || 0), seek: (t, suppressEvents) => { seekRootChildrenAndAdapters(state.capturedTimeline, t, { suppressEvents }); }, + ignoreMutations: withoutOwnCompositionRecords, }); } const settleSceneDom = (): Promise => { @@ -5187,14 +5197,17 @@ export function initSandboxRuntimeModular(): void { // affected — the seek runs whenever the clock is playing. const isPlaying = clock.isPlaying(); const manualEditOwnsPausedFrame = !isPlaying && hasActiveStudioManualEditGesture(); + // Both captures of a blurred frame must see the same page: the next tick catches up. + const blurHoldsFrame = isPlaying && captureMotionBlurBusy(); if (manualEditOwnsPausedFrame) { // Force one reconciliation after drop/cancel even though the playhead did not move. pausedSeekDeferredByManualGesture = true; } else if ( - isPlaying || - pausedSeekDeferredByManualGesture || - t !== lastTransportSeekTime || - state.capturedTimeline !== lastTransportSeekTimeline + !blurHoldsFrame && + (isPlaying || + pausedSeekDeferredByManualGesture || + t !== lastTransportSeekTime || + state.capturedTimeline !== lastTransportSeekTimeline) ) { seekTimelineAndAdapters(t, { revision: timingRevision }); // Playback: draw and move on; nothing waits on this frame's blur. Paused, it unwraps.