From ab533b3161fd3f0df58a580abe309f9eac3be900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 08:53:44 -0700 Subject: [PATCH 1/3] fix(engine): preserve video frame replacement geometry --- .../src/services/screenshotService.test.ts | 101 +++++++++++++++++- .../engine/src/services/screenshotService.ts | 59 +++++----- 2 files changed, 134 insertions(+), 26 deletions(-) diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index 30a711400..4ab92a2ce 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -1,7 +1,12 @@ // @vitest-environment node import { describe, it, expect, vi } from "vitest"; +import { parseHTML } from "linkedom"; import { type Page } from "puppeteer-core"; -import { pageScreenshotCapture, cdpSessionCache } from "./screenshotService.js"; +import { + pageScreenshotCapture, + cdpSessionCache, + injectVideoFramesBatch, +} from "./screenshotService.js"; // Stub a Page + CDPSession just enough that pageScreenshotCapture can call // `client.send("Page.captureScreenshot", ...)` and we can inspect the args. @@ -90,3 +95,97 @@ describe("pageScreenshotCapture supersample plumbing", () => { expect(params.clip?.scale).toBe(3); }); }); + +describe("injectVideoFramesBatch replacement layout", () => { + it("does not copy opposing inset constraints onto the injected frame image", async () => { + const { window, document } = parseHTML( + '
', + ); + + Object.defineProperty(window.HTMLImageElement.prototype, "decode", { + configurable: true, + value: () => Promise.resolve(), + }); + + const video = document.getElementById("clip") as HTMLVideoElement; + Object.defineProperties(video, { + offsetLeft: { configurable: true, get: () => 0 }, + offsetTop: { configurable: true, get: () => 0 }, + offsetWidth: { configurable: true, get: () => 1920 }, + offsetHeight: { configurable: true, get: () => 1080 }, + }); + video.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 1920, + bottom: 1080, + width: 1920, + height: 1080, + toJSON: () => ({}), + }) as DOMRect; + + const computedStyle = document.createElement("div").style; + computedStyle.position = "absolute"; + computedStyle.width = "1920px"; + computedStyle.height = "1080px"; + computedStyle.top = "0px"; + computedStyle.left = "0px"; + computedStyle.right = "0px"; + computedStyle.bottom = "0px"; + computedStyle.inset = "0px"; + computedStyle.objectFit = "cover"; + computedStyle.objectPosition = "center center"; + computedStyle.zIndex = "3"; + computedStyle.opacity = "1"; + Object.defineProperty(window, "getComputedStyle", { + configurable: true, + value: () => computedStyle, + }); + + const globals = globalThis as unknown as { + window?: typeof window; + document?: Document; + }; + const previousWindow = globals.window; + const previousDocument = globals.document; + globals.window = window; + globals.document = document; + try { + const page = { + evaluate: async ( + fn: ( + updates: Array<{ videoId: string; dataUri: string }>, + visualProperties: string[], + ) => Promise, + updates: Array<{ videoId: string; dataUri: string }>, + visualProperties: string[], + ) => fn(updates, visualProperties), + } as unknown as Page; + + await injectVideoFramesBatch(page, [ + { + videoId: "clip", + dataUri: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=", + }, + ]); + } finally { + globals.window = previousWindow; + globals.document = previousDocument; + } + + const img = video.nextElementSibling as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img?.style.position).toBe("absolute"); + expect(img?.style.left).toBe("0px"); + expect(img?.style.top).toBe("0px"); + expect(img?.style.width).toBe("1920px"); + expect(img?.style.height).toBe("1080px"); + expect(img?.style.right).toBe("auto"); + expect(img?.style.bottom).toBe("auto"); + expect(img?.style.inset).toBe("auto"); + }); +}); diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index e2ff30bcd..a4a88a85f 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -382,6 +382,15 @@ export async function injectVideoFramesBatch( await page.evaluate( async (items: Array<{ videoId: string; dataUri: string }>, visualProperties: string[]) => { const pendingDecodes: Array> = []; + const replacementLayoutProperties = new Set([ + "width", + "height", + "top", + "left", + "right", + "bottom", + "inset", + ]); for (const item of items) { const video = document.getElementById(item.videoId) as HTMLVideoElement | null; if (!video) continue; @@ -395,7 +404,6 @@ export async function injectVideoFramesBatch( // and accurately reflects the user's intent on every frame. const opacityParsed = parseFloat(computedStyle.opacity); const computedOpacity = Number.isNaN(opacityParsed) ? 1 : opacityParsed; - const sourceIsStatic = !computedStyle.position || computedStyle.position === "static"; if (isNewImage) { img = document.createElement("img"); @@ -406,10 +414,35 @@ export async function injectVideoFramesBatch( } if (!img) continue; + for (const property of visualProperties) { + // Opacity is handled explicitly via `computedOpacity` below — copying + // via the generic loop would race against the opacity:0 hide applied + // to the