diff --git a/packages/core/src/inline-scripts/parityContract.ts b/packages/core/src/inline-scripts/parityContract.ts index 785ea7b525a..1d733c98756 100644 --- a/packages/core/src/inline-scripts/parityContract.ts +++ b/packages/core/src/inline-scripts/parityContract.ts @@ -14,6 +14,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [ "filter", "mix-blend-mode", "backdrop-filter", + "border-width", + "border-style", + "border-color", "border-radius", "overflow", "clip-path", diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index fb12542a384..8c7b7004f3b 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -1058,10 +1058,9 @@ async function initDrawElementOrTransparentBackground( const cssFx = await detectCssEffectRisk(page); if (cssFx) { session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`; - // Full specific effect ("filter:blur" / "filter:drop-shadow" / - // "backdrop-filter" / "clip-path") — `deGateReason` sanitizes - // this to the low-cardinality prefix; `deFallbackTrigger` keeps - // the fine-grained value for the diagnostic profile emission. + // Full specific effect from `detectCssEffectRisk` (e.g. "filter:blur", + // "mix-blend-mode", "webgl-context") — `deGateReason` keeps its + // low-cardinality prefix, `deFallbackTrigger` the full value. session.deFallbackTrigger = cssFx; console.log( `[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` + diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index 0a9784fbc5c..0ade72519a0 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -735,6 +735,15 @@ export async function injectVideoFramesBatch( ? 1 : opacityParsed; + // Measure first: an in-flow bordered sibling would shrink the video's flex box. + const videoRect = video.getBoundingClientRect(); + const videoBox = { + left: Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0, + top: Number.isFinite(video.offsetTop) ? video.offsetTop : 0, + width: video.offsetWidth > 0 ? video.offsetWidth : videoRect.width, + height: video.offsetHeight > 0 ? video.offsetHeight : videoRect.height, + }; + if (isNewImage) { img = document.createElement("img"); img.classList.add("__render_frame__"); @@ -770,24 +779,16 @@ export async function injectVideoFramesBatch( // instead of flowing below it. With position:relative, both elements // stack vertically — the lands below the video and gets clipped // by any overflow:hidden ancestor (e.g., border-radius wrappers). - // - // Apply this after visual style copying so the measured used box is - // the final authority for replacement frame geometry. - { - const videoRect = video.getBoundingClientRect(); - const offsetLeft = Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0; - const offsetTop = Number.isFinite(video.offsetTop) ? video.offsetTop : 0; - const offsetWidth = video.offsetWidth > 0 ? video.offsetWidth : videoRect.width; - const offsetHeight = video.offsetHeight > 0 ? video.offsetHeight : videoRect.height; - img.style.position = "absolute"; - img.style.inset = "auto"; - img.style.left = `${offsetLeft}px`; - img.style.top = `${offsetTop}px`; - img.style.right = "auto"; - img.style.bottom = "auto"; - img.style.width = `${offsetWidth}px`; - img.style.height = `${offsetHeight}px`; - } + img.style.position = "absolute"; + img.style.inset = "auto"; + img.style.left = `${videoBox.left}px`; + img.style.top = `${videoBox.top}px`; + img.style.right = "auto"; + img.style.bottom = "auto"; + img.style.width = `${videoBox.width}px`; + img.style.height = `${videoBox.height}px`; + // `videoBox` is a border-box even when the video is content-box. + img.style.boxSizing = "border-box"; img.style.objectFit = computedStyle.objectFit; img.style.objectPosition = computedStyle.objectPosition; img.style.zIndex = computedStyle.zIndex; diff --git a/packages/producer/scripts/test-classification.mjs b/packages/producer/scripts/test-classification.mjs index 903bfdf2edd..3c3969bca96 100644 --- a/packages/producer/scripts/test-classification.mjs +++ b/packages/producer/scripts/test-classification.mjs @@ -34,6 +34,7 @@ const INTEGRATION_TEST_FILES = new Set([ "src/services/render/renderCancel.integration.test.ts", "src/services/render/stages/compileStage.mediaType.test.ts", "src/services/render/stages/gifEncodeArgs.test.ts", + "src/services/videoFrameBorderClip.test.ts", "src/utils/audioRegression.test.ts", "src/utils/streamDurationParity.test.ts", ]); diff --git a/packages/producer/src/services/videoFrameBorderClip.test.ts b/packages/producer/src/services/videoFrameBorderClip.test.ts new file mode 100644 index 00000000000..78d5eb389dd --- /dev/null +++ b/packages/producer/src/services/videoFrameBorderClip.test.ts @@ -0,0 +1,177 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import puppeteer, { type Browser, type Page } from "puppeteer"; +import { injectVideoFramesBatch } from "@hyperframes/engine"; + +/** Colour channels of one screenshot pixel. Alpha is always opaque here, so it is dropped. */ +type Rgb = { r: number; g: number; b: number }; + +/** A probe coordinate in screenshot space. */ +type Point = readonly [x: number, y: number]; + +const VIDEO_ID = "v1"; + +// Solid blue 1x1 PNG, stretched over the whole video box by the UA default +// `object-fit: fill` — makes injected video content trivially distinguishable +// from the white page background and the red border in a screenshot. +const BLUE_PIXEL_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/5ncLrgAAAABJRU5ErkJggg=="; + +const BOX_WIDTH = 200; +const BOX_HEIGHT = 120; +const BORDER_WIDTH = 8; +const CORNER_RADIUS = 24; + +/** Mid-way down the left border edge, far below the rounded corner's arc. */ +const BORDER_EDGE: Point = [BORDER_WIDTH / 2, BOX_HEIGHT / 2]; +/** Dead centre of the box, well inside the injected frame's content. */ +const VIDEO_CENTER: Point = [BOX_WIDTH / 2, BOX_HEIGHT / 2]; +/** Inside the corner that both the border-radius curve and the clip-path inset remove. */ +const CLIPPED_CORNER: Point = [1, 1]; + +async function readPixel(page: Page, screenshotBase64: string, [x, y]: Point): Promise { + return page.evaluate( + async (dataUri, probeX, probeY) => { + const img = new Image(); + img.src = dataUri; + await img.decode(); + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2d context unavailable"); + ctx.drawImage(img, 0, 0); + const [r, g, b] = ctx.getImageData(probeX, probeY, 1, 1).data; + if (r === undefined || g === undefined || b === undefined) { + throw new Error(`no pixel data at (${probeX}, ${probeY})`); + } + return { r, g, b }; + }, + `data:image/png;base64,${screenshotBase64}`, + x, + y, + ); +} + +/** Renders the styled `