From 550e2d352daf201373c1ba231cf87dce4ebc8f08 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 19:55:39 -0700 Subject: [PATCH 1/7] fix(engine): motion blur works over video by holding each video frame across samples Each output frame's injected video frame is now held for every sub-frame sample and probe of that frame, so footage stays as shot and only the layers over it blur. The refusal for sessions with a before-capture hook is removed. Fixes #5144 --- .../frameCapture-gpuCompletion.test.ts | 4 +- .../services/frameCapture-motionBlur.test.ts | 63 ++++++- packages/engine/src/services/frameCapture.ts | 42 +++-- .../producer/scripts/test-classification.mjs | 1 + .../motionBlurVideo.integration.test.ts | 167 ++++++++++++++++++ 5 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 packages/producer/src/services/render/motionBlurVideo.integration.test.ts diff --git a/packages/engine/src/services/frameCapture-gpuCompletion.test.ts b/packages/engine/src/services/frameCapture-gpuCompletion.test.ts index 0145762781..3f65e781c1 100644 --- a/packages/engine/src/services/frameCapture-gpuCompletion.test.ts +++ b/packages/engine/src/services/frameCapture-gpuCompletion.test.ts @@ -33,7 +33,9 @@ describe("WebGPU frame completion", () => { it("runs the completion wait after video injection and before screenshot capture", () => { const source = readFileSync(new URL("./frameCapture.ts", import.meta.url), "utf8"); - const injection = source.indexOf("await session.onBeforeCapture(page, quantizedTime)"); + const injection = source.indexOf( + "await session.onBeforeCapture(page, videoTime ?? quantizedTime)", + ); const completion = source.indexOf("await waitForPendingSeekCompletion(page)"); const screenshot = source.indexOf("async function captureFrameCore"); expect(injection).toBeGreaterThan(-1); diff --git a/packages/engine/src/services/frameCapture-motionBlur.test.ts b/packages/engine/src/services/frameCapture-motionBlur.test.ts index 320d6c3204..00cdd7732f 100644 --- a/packages/engine/src/services/frameCapture-motionBlur.test.ts +++ b/packages/engine/src/services/frameCapture-motionBlur.test.ts @@ -317,10 +317,65 @@ describe("resolveSessionMotionBlur rejects what accumulation cannot render", () expect(() => resolveSessionMotionBlur(session)).toThrow(/format "png"/); }); - it("rejects injected video frames, which cannot follow a sub-frame seek", () => { - expect(() => - resolveSessionMotionBlur(withOptions({ onBeforeCapture: async () => {} })), - ).toThrow(/video/); + it("accepts a session that injects video frames (#5144)", () => { + const plan = resolveSessionMotionBlur(withOptions({ onBeforeCapture: async () => {} })); + expect(plan).not.toBeUndefined(); + }); +}); + +describe("injected video is held for the whole shutter window (#5144)", () => { + /** Records each injector call's time and how many page seeks had run before it. */ + function recordInjections(): { + calls: Array<{ time: number; seeksBefore: number }>; + hook: CaptureSession["onBeforeCapture"]; + } { + const calls: Array<{ time: number; seeksBefore: number }> = []; + return { + calls, + hook: async (_page, time) => { + calls.push({ time, seeksBefore: seeks.length }); + }, + }; + } + + it("injects at the frame time before any sample, then gives every sample that same time", async () => { + const { calls, hook } = recordInjections(); + + await captureFrameToBuffer(makeSession({ onBeforeCapture: hook }), 10, 10 / 30); + + // One injection right after the eventful seek, then one per sample, all at frame 10. + expect(calls).toHaveLength(1 + 16); + expect(calls.every((call) => call.time === 10 / 30)).toBe(true); + expect(calls[0]?.seeksBefore).toBe(1); + // The samples themselves still seek 16 distinct sub-frame times. + const samples = seeks.filter((s) => s.subFrameDivisions !== undefined); + expect(new Set(samples.map((s) => s.time)).size).toBe(16); + }); + + it("holds the frame time for the adaptive probes too", async () => { + const { calls, hook } = recordInjections(); + + await captureFrameToBuffer( + makeSession({ onBeforeCapture: hook, motionBlur: resolveMotionBlurPlan({}) ?? undefined }), + 10, + 10 / 30, + ); + + const sampleCount = seeks.filter((s) => s.subFrameDivisions !== undefined).length; + expect(calls).toHaveLength(1 + sampleCount); + expect(calls.every((call) => call.time === 10 / 30)).toBe(true); + }); + + it("still gives a frame without blur its own time", async () => { + const { calls, hook } = recordInjections(); + + await captureFrameToBuffer( + makeSession({ onBeforeCapture: hook, motionBlur: undefined }), + 10, + 10 / 30, + ); + + expect(calls.map((call) => call.time)).toEqual([10 / 30]); }); }); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index c87c29933e..c22d3b5491 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -2805,6 +2805,7 @@ async function prepareFrameForCapture( frameIndex: number, time: number, seekOptions?: HfSeekOptions, + videoTime?: number, ): Promise<{ quantizedTime: number; seekMs: number; @@ -2832,10 +2833,11 @@ async function prepareFrameForCapture( // Before-capture hook (e.g. video frame injection) — runs before // page-side compositor clones so cloneNode picks up injected - // replacements for