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 `