diff --git a/src/components/video-editor/settings/WebcamSettings.tsx b/src/components/video-editor/settings/WebcamSettings.tsx
index 333ae267..2604fe8b 100644
--- a/src/components/video-editor/settings/WebcamSettings.tsx
+++ b/src/components/video-editor/settings/WebcamSettings.tsx
@@ -397,15 +397,12 @@ export function WebcamSettings({
- {tSettings("effects.webcamPreviewFrame", "Preview frame")}
+ {tSettings("effects.webcamPreviewFrame", "Frame")}
-
- {tSettings("effects.webcamPreviewOnly", "Preview only")}
-
{
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 20ae0b6a..26204543 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -149,7 +149,7 @@ export type WebcamPositionPreset =
export interface WebcamOverlaySettings {
/** Missing in legacy projects, which retain the squircle mask. */
shape?: WebcamShape;
- /** Editor-only frame trial; exported webcam rendering is unchanged. */
+ /** Shared edge appearance in the editor and exported video. */
previewFrame?: "standard" | "soft-blur";
/** Source-time intervals containing webcam footage in an imported sequence. */
visibleRanges?: { startMs: number; endMs: number }[];
@@ -220,6 +220,7 @@ export const DEFAULT_WEBCAM_POSITION_Y = 1;
export const DEFAULT_WEBCAM_TIME_OFFSET_MS = 0;
export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
+ previewFrame: "soft-blur",
shape: "circle",
enabled: false,
sourcePath: null,
diff --git a/src/components/video-editor/videoPlayback/PlaybackView.tsx b/src/components/video-editor/videoPlayback/PlaybackView.tsx
index b9fec0e0..2ceca869 100644
--- a/src/components/video-editor/videoPlayback/PlaybackView.tsx
+++ b/src/components/video-editor/videoPlayback/PlaybackView.tsx
@@ -10,7 +10,6 @@ import type { usePlaybackWallpaper } from "./usePlaybackWallpaper";
import type { usePlaybackWebcamLayout } from "./usePlaybackWebcamLayout";
import type { usePlaybackWebcamSync } from "./usePlaybackWebcamSync";
import { isWebcamVisibleAtSourceTime } from "./webcamSync";
-import { WebcamFeatherPreview } from "./WebcamFeatherPreview";
export type PlaybackViewInput = Pick<
ReturnType &
PlaybackProps &
@@ -215,14 +214,11 @@ export function PlaybackView(input: PlaybackViewInput) {
className="absolute rounded-md border border-[#2563EB]/80 bg-[#2563EB]/20 shadow-[0_0_0_1px_rgba(37,99,235,0.35)]"
style={{ display: "none", pointerEvents: "none" }}
/>
-
+ <>
) : null}
-
+ >
(null);
- const pathRef = useRef
(null);
- useLayoutEffect(() => {
- const layer = layerRef.current;
- if (!layer) return;
- const update = () => {
- const camera = layer.querySelector("[data-webcam-overlay]");
- if (!camera || !pathRef.current) return;
- const width = Number.parseFloat(camera.style.width);
- const height = Number.parseFloat(camera.style.height);
- const x = Number.parseFloat(camera.style.left);
- const y = Number.parseFloat(camera.style.top);
- if (![width, height, x, y].every(Number.isFinite) || width <= 0 || height <= 0) return;
- const inset = Math.min(8, width / 8, height / 8);
- const radius = getWebcamCornerRadiusPx(roundness, width, height, shape);
- const path = getWebcamShapeSvgPath({
- x: x + inset,
- y: y + inset,
- width: width - inset * 2,
- height: height - inset * 2,
- radius: Math.max(0, radius - inset),
- shape,
- });
- if (pathRef.current.getAttribute("d") !== path) pathRef.current.setAttribute("d", path);
- };
- update();
- const observer = new MutationObserver(update);
- observer.observe(layer, {
- attributes: true,
- attributeFilter: ["style"],
- childList: true,
- subtree: true,
- });
- return () => observer.disconnect();
- }, [shape, roundness]);
- return (
- <>
-
-
- {children}
- {enabled && (
-
- )}
-
- >
- );
-}
diff --git a/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts b/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts
index 5f72de81..86a3abfd 100644
--- a/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts
+++ b/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts
@@ -8,6 +8,7 @@ import {
DEFAULT_WEBCAM_SHADOW,
DEFAULT_WEBCAM_SIZE,
} from "../types";
+import { getWebcamEdgeFeatherPixels } from "../webcamEdgeFeather";
import {
getCropMatchedWebcamHeightPercent,
getWebcamClipPath,
@@ -228,6 +229,10 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
if (path.getAttribute("d") !== contour) path.setAttribute("d", contour);
}
}
+ const edgeFeatherPx = getWebcamEdgeFeatherPixels(
+ webcam?.previewFrame,
+ overlay.clientWidth,
+ );
const video = webcamVideoRef.current;
if (!video || video.readyState < 2 || !webcamMediaSynchronized) {
webcamMotionBlurRef.current.reset();
@@ -237,7 +242,10 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
? webcamMotionBlurRef.current.render({
// Keep one renderer throughout playback, including tiny zoom steps
// and frames at rest, so edges/shadows never switch mid-animation.
- renderStill: isPlayingRef.current && !isSeekingRef.current,
+ renderStill:
+ edgeFeatherPx > 0 ||
+ (isPlayingRef.current && !isSeekingRef.current),
+ edgeFeatherPx,
layout: { x, y, ...scaledDimensions, radius: scaledRadius },
// Supersample small cameras even on standard-density displays.
resolution: Math.max(2, window.devicePixelRatio || 1),
diff --git a/src/components/video-editor/webcamEdgeFeather.ts b/src/components/video-editor/webcamEdgeFeather.ts
new file mode 100644
index 00000000..30789deb
--- /dev/null
+++ b/src/components/video-editor/webcamEdgeFeather.ts
@@ -0,0 +1,86 @@
+import type { WebcamOverlaySettings } from "./types";
+import { scaleWebcamOverlayPixels } from "./webcamOverlay";
+import { traceWebcamShapeOnCanvas } from "./webcamShapePath";
+
+/** Keep the edge transition the same size relative to the scene in preview and export. */
+export function getWebcamEdgeFeatherPixels(
+ frame: WebcamOverlaySettings["previewFrame"],
+ viewportWidth: number,
+): number {
+ return frame === "standard" ? 0 : scaleWebcamOverlayPixels(16, viewportWidth);
+}
+
+/** Blends a blurred perimeter into a sharp center, then feathers its alpha. */
+export class WebcamEdgeFeather {
+ private source: HTMLCanvasElement | null = null;
+ private mask: HTMLCanvasElement | null = null;
+
+ destroy() {
+ for (const canvas of [this.source, this.mask]) {
+ if (canvas) {
+ canvas.width = 0;
+ canvas.height = 0;
+ }
+ }
+ this.source = null;
+ this.mask = null;
+ }
+
+ apply(
+ canvas: HTMLCanvasElement,
+ contour: Parameters[1],
+ featherPixels: number,
+ ) {
+ const inset = Math.min(featherPixels, contour.width / 8, contour.height / 8);
+ if (!Number.isFinite(inset) || inset <= 0) return;
+ this.source ??= document.createElement("canvas");
+ this.mask ??= document.createElement("canvas");
+ for (const scratch of [this.source, this.mask]) {
+ if (scratch.width !== canvas.width) scratch.width = canvas.width;
+ if (scratch.height !== canvas.height) scratch.height = canvas.height;
+ }
+ const output = canvas.getContext("2d");
+ const source = this.source.getContext("2d");
+ const mask = this.mask.getContext("2d");
+ if (!output || !source || !mask) return;
+ const blur = `blur(${(inset * 3) / 8}px)`;
+ const drawMask = (distance: number) => {
+ mask.clearRect(0, 0, canvas.width, canvas.height);
+ mask.save();
+ mask.filter = blur;
+ mask.fillStyle = "white";
+ mask.beginPath();
+ traceWebcamShapeOnCanvas(mask, {
+ ...contour,
+ x: contour.x + distance,
+ y: contour.y + distance,
+ width: contour.width - distance * 2,
+ height: contour.height - distance * 2,
+ radius: Math.max(0, contour.radius - distance),
+ });
+ mask.fill();
+ mask.restore();
+ };
+
+ source.clearRect(0, 0, canvas.width, canvas.height);
+ source.drawImage(canvas, 0, 0);
+ output.clearRect(0, 0, canvas.width, canvas.height);
+ output.save();
+ output.filter = blur;
+ output.drawImage(this.source, 0, 0);
+ output.filter = "none";
+
+ // Retain the original pixels in the center instead of blurring the whole camera.
+ drawMask(inset * 2);
+ source.save();
+ source.globalCompositeOperation = "destination-in";
+ source.drawImage(this.mask, 0, 0);
+ source.restore();
+ output.drawImage(this.source, 0, 0);
+
+ drawMask(inset);
+ output.globalCompositeOperation = "destination-in";
+ output.drawImage(this.mask, 0, 0);
+ output.restore();
+ }
+}
diff --git a/src/components/video-editor/webcamMotionBlur.ts b/src/components/video-editor/webcamMotionBlur.ts
index a396994d..de057e09 100644
--- a/src/components/video-editor/webcamMotionBlur.ts
+++ b/src/components/video-editor/webcamMotionBlur.ts
@@ -1,6 +1,7 @@
import { getWebcamShadowFilter, getWebcamShadowPadding } from "@/lib/exporter/shadowProfile";
import { drawWebcamGlassRim } from "./glassRim";
import type { CropRegion, WebcamShape } from "./types";
+import { WebcamEdgeFeather } from "./webcamEdgeFeather";
import { getWebcamCropSourceRect } from "./webcamOverlay";
import { traceWebcamShapeOnCanvas } from "./webcamShapePath";
@@ -52,6 +53,8 @@ export function getWebcamMotionSamples(
interface RenderOptions {
/** Physical pixels per layout pixel; exports already use output-pixel coordinates. */
resolution?: number;
+ /** Width of the soft border in layout pixels; zero keeps the standard frame. */
+ edgeFeatherPx?: number;
// Composite a single clipped frame and its shadow when there is no shutter.
renderStill?: boolean;
shape?: WebcamShape;
@@ -68,6 +71,7 @@ interface RenderOptions {
}
export class WebcamMotionBlur {
+ private edgeFeather = new WebcamEdgeFeather();
private previous: { layout: WebcamMotionLayout; timeMs: number } | null = null;
private samples: WebcamMotionLayout[] = [];
private lastMotionTimeMs: number | null = null;
@@ -84,6 +88,7 @@ export class WebcamMotionBlur {
destroy() {
this.reset();
+ this.edgeFeather.destroy();
for (const canvas of [this.accumulation, this.output]) {
if (canvas) {
canvas.width = 0;
@@ -197,6 +202,9 @@ export class WebcamMotionBlur {
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = 1 / this.samples.length;
const { sx, sy, sw, sh } = getWebcamCropSourceRect(cropRegion, sourceWidth, sourceHeight);
+ const edgeFeatherPx = Number.isFinite(options.edgeFeatherPx)
+ ? Math.max(0, options.edgeFeatherPx!)
+ : 0;
for (const sample of this.samples) {
// Reuse the output as scratch until the final shadow pass. The rim must
// composite over each pose before weighted accumulation, not add light to it.
@@ -234,8 +242,23 @@ export class WebcamMotionBlur {
sh * scale,
);
out.restore();
- drawWebcamGlassRim(out, { ...sample, x: 0, y: 0, shape: options.shape });
+ if (edgeFeatherPx === 0)
+ drawWebcamGlassRim(out, { ...sample, x: 0, y: 0, shape: options.shape });
out.restore();
+ if (edgeFeatherPx > 0) {
+ this.edgeFeather.apply(
+ this.output,
+ {
+ x: (sample.x - x) * resolution,
+ y: (sample.y - y) * resolution,
+ width: sample.width * resolution,
+ height: sample.height * resolution,
+ radius: sample.radius * resolution,
+ shape: options.shape,
+ },
+ edgeFeatherPx * resolution,
+ );
+ }
ctx.drawImage(
this.output,
0,
diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json
index bdd16338..d8acc134 100644
--- a/src/i18n/locales/de/settings.json
+++ b/src/i18n/locales/de/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "Unten mittig",
"bottom-right": "Unten rechts"
},
- "webcamPreviewFrame": "Vorschaurahmen",
+ "webcamPreviewFrame": "Rahmen",
"webcamFrameStandard": "Standard",
- "webcamFrameSoftBlur": "Weicher Rand",
- "webcamPreviewOnly": "Nur Vorschau"
+ "webcamFrameSoftBlur": "Weicher Rand"
},
"sections": {
"scene": "Szene",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 07cc52b6..dfba4799 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -217,7 +217,7 @@
"cursorSpringMassMultiplier": "Spring mass",
"classicZoom": "Classic Animation",
"webcamShape": "Shape",
- "webcamPreviewFrame": "Preview frame",
+ "webcamPreviewFrame": "Frame",
"webcamFrameStandard": "Standard",
"webcamFrameSoftBlur": "Soft blur",
"webcamCircle": "Circle",
@@ -227,8 +227,7 @@
"webcamCustomPosition": "Custom position",
"webcamHorizontal": "Horizontal",
"webcamVertical": "Vertical",
- "webcamMargin": "Margin",
- "webcamPreviewOnly": "Preview only"
+ "webcamMargin": "Margin"
},
"sections": {
"scene": "Scene",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 84f4a38a..52c18830 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "Abajo en el centro",
"bottom-right": "Abajo a la derecha"
},
- "webcamPreviewFrame": "Marco de vista previa",
+ "webcamPreviewFrame": "Marco",
"webcamFrameStandard": "Estándar",
- "webcamFrameSoftBlur": "Desenfoque suave",
- "webcamPreviewOnly": "Solo vista previa"
+ "webcamFrameSoftBlur": "Desenfoque suave"
},
"sections": {
"scene": "Escena",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index 25105ed1..fdaf6562 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "En bas au centre",
"bottom-right": "En bas à droite"
},
- "webcamPreviewFrame": "Cadre d’aperçu",
+ "webcamPreviewFrame": "Cadre",
"webcamFrameStandard": "Standard",
- "webcamFrameSoftBlur": "Flou doux",
- "webcamPreviewOnly": "Aperçu uniquement"
+ "webcamFrameSoftBlur": "Flou doux"
},
"sections": {
"scene": "Scène",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index af2095ba..e5f7a0d1 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "In basso al centro",
"bottom-right": "In basso a destra"
},
- "webcamPreviewFrame": "Cornice di anteprima",
+ "webcamPreviewFrame": "Cornice",
"webcamFrameStandard": "Standard",
- "webcamFrameSoftBlur": "Sfocatura morbida",
- "webcamPreviewOnly": "Solo anteprima"
+ "webcamFrameSoftBlur": "Sfocatura morbida"
},
"sections": {
"scene": "Scena",
diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json
index 0ce94cc5..eea5a602 100644
--- a/src/i18n/locales/ja/settings.json
+++ b/src/i18n/locales/ja/settings.json
@@ -225,10 +225,9 @@
"bottom-center": "下中央",
"bottom-right": "右下"
},
- "webcamPreviewFrame": "プレビューフレーム",
+ "webcamPreviewFrame": "フレーム",
"webcamFrameStandard": "標準",
- "webcamFrameSoftBlur": "ソフトぼかし",
- "webcamPreviewOnly": "プレビューのみ"
+ "webcamFrameSoftBlur": "ソフトぼかし"
},
"sections": {
"scene": "シーン",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 391ea83d..1e1595a3 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "가운데 아래",
"bottom-right": "오른쪽 아래"
},
- "webcamPreviewFrame": "미리보기 프레임",
+ "webcamPreviewFrame": "프레임",
"webcamFrameStandard": "기본",
- "webcamFrameSoftBlur": "부드러운 흐림",
- "webcamPreviewOnly": "미리보기 전용"
+ "webcamFrameSoftBlur": "부드러운 흐림"
},
"sections": {
"scene": "장면",
diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json
index ef36cb92..b3e6ff28 100644
--- a/src/i18n/locales/nl/settings.json
+++ b/src/i18n/locales/nl/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "Middenonder",
"bottom-right": "Rechtsonder"
},
- "webcamPreviewFrame": "Voorbeeldkader",
+ "webcamPreviewFrame": "Frame",
"webcamFrameStandard": "Standaard",
- "webcamFrameSoftBlur": "Zachte vervaging",
- "webcamPreviewOnly": "Alleen voorbeeld"
+ "webcamFrameSoftBlur": "Zachte vervaging"
},
"sections": {
"scene": "Scène",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 7f65661e..992a0f87 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "Inferior central",
"bottom-right": "Inferior direito"
},
- "webcamPreviewFrame": "Moldura de prévia",
+ "webcamPreviewFrame": "Moldura",
"webcamFrameStandard": "Padrão",
- "webcamFrameSoftBlur": "Desfoque suave",
- "webcamPreviewOnly": "Somente prévia"
+ "webcamFrameSoftBlur": "Desfoque suave"
},
"sections": {
"scene": "Cena",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index d6d7b197..f0c7a54a 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "下中",
"bottom-right": "右下"
},
- "webcamPreviewFrame": "预览边框",
+ "webcamPreviewFrame": "边框",
"webcamFrameStandard": "标准",
- "webcamFrameSoftBlur": "柔和模糊",
- "webcamPreviewOnly": "仅预览"
+ "webcamFrameSoftBlur": "柔和模糊"
},
"sections": {
"scene": "场景",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 4762bf89..e331e131 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -207,10 +207,9 @@
"bottom-center": "下中",
"bottom-right": "右下"
},
- "webcamPreviewFrame": "預覽邊框",
+ "webcamPreviewFrame": "邊框",
"webcamFrameStandard": "標準",
- "webcamFrameSoftBlur": "柔和模糊",
- "webcamPreviewOnly": "僅預覽"
+ "webcamFrameSoftBlur": "柔和模糊"
},
"sections": {
"scene": "場景",
diff --git a/src/lib/exporter/legacyFrameComposite.ts b/src/lib/exporter/legacyFrameComposite.ts
index b4c5c9b1..54032e8b 100644
--- a/src/lib/exporter/legacyFrameComposite.ts
+++ b/src/lib/exporter/legacyFrameComposite.ts
@@ -4,6 +4,7 @@ import {
getWebcamMediaTargetTimeSeconds,
isWebcamVisibleAtSourceTime,
} from "@/components/video-editor/videoPlayback/webcamSync";
+import { getWebcamEdgeFeatherPixels } from "@/components/video-editor/webcamEdgeFeather";
import {
getCropMatchedWebcamHeightPercent,
getWebcamCornerRadiusPx,
@@ -246,6 +247,7 @@ export function drawWebcamOverlay(
// bubble canvas and stretching it back shifts the clipped video at the edge.
const overlay = context.webcamMotionBlur.render({
renderStill: true,
+ edgeFeatherPx: getWebcamEdgeFeatherPixels(webcam.previewFrame, width),
layout: { x, y, ...dimensions, radius },
shape: webcamShape,
timeMs: context.webcamMotionTimeMs,
diff --git a/src/lib/exporter/modernFrameWebcam.ts b/src/lib/exporter/modernFrameWebcam.ts
index ae36df48..7c9b4341 100644
--- a/src/lib/exporter/modernFrameWebcam.ts
+++ b/src/lib/exporter/modernFrameWebcam.ts
@@ -1,6 +1,7 @@
import { Sprite } from "pixi.js";
import { DEFAULT_WEBCAM_ROUNDNESS } from "@/components/video-editor/types";
import { isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync";
+import { getWebcamEdgeFeatherPixels } from "@/components/video-editor/webcamEdgeFeather";
import { WebcamMotionBlur } from "@/components/video-editor/webcamMotionBlur";
import {
getCropMatchedWebcamHeightPercent,
@@ -279,6 +280,10 @@ export class FrameWebcam {
// silhouette and a GPU stencil have different edge coverage, exposing a rim.
const blur = this.webcamMotionBlur.render({
renderStill: true,
+ edgeFeatherPx: getWebcamEdgeFeatherPixels(
+ webcam.previewFrame,
+ this.context.config.width,
+ ),
layout: { x: position.x, y: position.y, ...dimensions, radius },
shape: webcamShape,
timeMs: frameTimeMs,
diff --git a/tests/ui/webcam-feather-probe.ts b/tests/ui/webcam-feather-probe.ts
new file mode 100644
index 00000000..78b33842
--- /dev/null
+++ b/tests/ui/webcam-feather-probe.ts
@@ -0,0 +1,21 @@
+/** Inspect rendered pixels rather than the presence of a CSS mask declaration. */
+export function readPreviewFeather() {
+ const canvas = document.querySelector("[data-webcam-motion-blur]")!;
+ const camera = document.querySelector("[data-webcam-overlay]")!;
+ const density = canvas.width / parseFloat(canvas.style.width);
+ const x = Math.round(
+ (parseFloat(camera.style.left) -
+ parseFloat(canvas.style.left) +
+ parseFloat(camera.style.width) / 2) *
+ density,
+ );
+ const y = Math.ceil((parseFloat(camera.style.top) - parseFloat(canvas.style.top)) * density);
+ const height = Math.floor((parseFloat(camera.style.height) / 2) * density);
+ const pixels = canvas.getContext("2d")!.getImageData(x, y, 1, height).data;
+ const alpha = Array.from({ length: height }, (_, i) => pixels[i * 4 + 3]);
+ return {
+ transparentEdge: alpha[0] < 10,
+ opaqueCenter: alpha.at(-1) === 255,
+ gradualFade: alpha.filter((value) => value > 10 && value < 245).length >= 5 * density,
+ };
+}
diff --git a/tests/ui/webcam-feather.html b/tests/ui/webcam-feather.html
new file mode 100644
index 00000000..924d5961
--- /dev/null
+++ b/tests/ui/webcam-feather.html
@@ -0,0 +1 @@
+Webcam edge render regression
diff --git a/tests/ui/webcam-feather.spec.ts b/tests/ui/webcam-feather.spec.ts
new file mode 100644
index 00000000..c0002710
--- /dev/null
+++ b/tests/ui/webcam-feather.spec.ts
@@ -0,0 +1,74 @@
+import { expect, test } from "@playwright/test";
+import { installDesktopBridge } from "./bridge";
+import type { featherChecks } from "./webcam-feather";
+import { readPreviewFeather } from "./webcam-feather-probe";
+
+test("both export compositors feather every webcam border and preserve the sharp center", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(60_000);
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-feather.html");
+ const results = await page.evaluate(() =>
+ (window as Window & { featherChecks: typeof featherChecks }).featherChecks(),
+ );
+ for (const soft of results.filter((result) => result.frame === "soft-blur")) {
+ const standard = results.find(
+ (result) =>
+ result.name === soft.name &&
+ result.shape === soft.shape &&
+ result.frame === "standard",
+ )!;
+ for (const edge of soft.edges) {
+ expect(
+ edge[0],
+ `${soft.name}/${soft.shape}: border blends into the background`,
+ ).toBeLessThan(65);
+ expect(
+ edge.filter((value) => value > 40 && value < 245).length,
+ `${soft.name}/${soft.shape}: gradual transition`,
+ ).toBeGreaterThanOrEqual(4);
+ expect(edge.at(-1)).toBe(255);
+ }
+ expect(
+ Math.max(
+ ...soft.center.map((value, index) => Math.abs(value - standard.center[index])),
+ ),
+ `${soft.name}/${soft.shape}: center stays sharp`,
+ ).toBeLessThanOrEqual(1);
+ const other = results.find(
+ (result) =>
+ result.name !== soft.name &&
+ result.shape === soft.shape &&
+ result.frame === "soft-blur",
+ )!;
+ expect(
+ Math.max(
+ ...soft.edges
+ .flat()
+ .map((value, index) => Math.abs(value - other.edges.flat()[index])),
+ ),
+ `${soft.shape}: export backends match`,
+ ).toBeLessThanOrEqual(3);
+ }
+ await page.screenshot({ path: testInfo.outputPath("webcam-export-edges.png"), fullPage: true });
+});
+
+test("soft webcam edges stay rendered during playback and after pausing", async ({ page }) => {
+ await installDesktopBridge(page, "filmstrip.mp4");
+ await page.goto("/tests/ui/webcam-motion-blur.html");
+ await expect(page.locator("main")).toHaveAttribute("data-ready", "true");
+ await page.getByRole("button", { name: "Soft frame", exact: true }).click();
+ const layer = page.locator('[data-webcam-frame-style="soft-blur"]');
+ await expect(layer).toBeVisible();
+ const expected = { transparentEdge: true, opaqueCenter: true, gradualFade: true };
+ await expect.poll(() => page.evaluate(readPreviewFeather)).toEqual(expected);
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect
+ .poll(async () => Number(await page.locator("main").getAttribute("data-time")))
+ .toBeGreaterThan(1.5);
+ await expect.poll(() => page.evaluate(readPreviewFeather)).toEqual(expected);
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect(layer).toBeVisible();
+ await expect.poll(() => page.evaluate(readPreviewFeather)).toEqual(expected);
+});
diff --git a/tests/ui/webcam-feather.ts b/tests/ui/webcam-feather.ts
new file mode 100644
index 00000000..c7ab2cd8
--- /dev/null
+++ b/tests/ui/webcam-feather.ts
@@ -0,0 +1,108 @@
+import {
+ DEFAULT_WEBCAM_OVERLAY,
+ type WebcamOverlaySettings,
+ type WebcamShape,
+} from "@/components/video-editor/types";
+import { FrameRenderer as LegacyRenderer } from "@/lib/exporter/frameRenderer";
+import { FrameRenderer as ModernRenderer } from "@/lib/exporter/modernFrameRenderer";
+import type {
+ ExportRendererProbe,
+ LegacyRendererProbe,
+ ModernRendererProbe,
+} from "./exportRendererProbe";
+
+export async function featherChecks() {
+ const source = document.createElement("canvas");
+ source.width = source.height = 200;
+ const sourceContext = source.getContext("2d")!;
+ sourceContext.fillStyle = "white";
+ sourceContext.fillRect(0, 0, 200, 200);
+ for (let y = 80; y < 120; y += 4) {
+ for (let x = 80; x < 120; x += 4) {
+ sourceContext.fillStyle = (x + y) % 8 === 0 ? "black" : "white";
+ sourceContext.fillRect(x, y, 4, 4);
+ }
+ }
+ const results = [];
+ for (const [name, Renderer] of [
+ ["modern", ModernRenderer],
+ ["legacy", LegacyRenderer],
+ ] as const) {
+ const renderer = new Renderer({
+ width: 640,
+ height: 360,
+ videoWidth: 640,
+ videoHeight: 360,
+ wallpaper: "#202020",
+ zoomRegions: [],
+ showShadow: false,
+ shadowIntensity: 0,
+ backgroundBlur: 0,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ webcam: {
+ ...DEFAULT_WEBCAM_OVERLAY,
+ enabled: true,
+ width: 50,
+ height: 50,
+ shadow: 0,
+ roundness: 0,
+ },
+ zoomMotionBlur: 0,
+ showCursor: false,
+ preferredRenderBackend: "webgl",
+ }) as unknown as ExportRendererProbe & { config: { webcam: WebcamOverlaySettings } };
+ const modern = renderer as unknown as ModernRendererProbe;
+ const legacy = renderer as unknown as LegacyRendererProbe;
+ if (name === "modern") await modern.initialize();
+ const frames = name === "modern" ? modern.webcamFrames : legacy;
+ frames.webcamFrameCacheCanvas = source;
+ frames.webcamFrameCacheCtx = source.getContext("2d");
+ if (name === "legacy") legacy.webcamVideoElement = { readyState: 0 };
+ const target = document.createElement("canvas");
+ target.width = 640;
+ target.height = 360;
+ for (const shape of ["circle", "rectangle", "squircle"] as WebcamShape[]) {
+ for (const frame of ["standard", "soft-blur"] as const) {
+ renderer.config.webcam = { ...renderer.config.webcam, shape, previewFrame: frame };
+ let canvas: HTMLCanvasElement;
+ if (name === "modern") {
+ modern.webcam.updateWebcamOverlay(0, 0);
+ modern.scene.app.render();
+ canvas = renderer.getCanvas();
+ } else {
+ const context = target.getContext("2d")!;
+ context.fillStyle = "#202020";
+ context.fillRect(0, 0, 640, 360);
+ legacy.drawWebcamOverlay(context, 640, 360);
+ canvas = target;
+ }
+ const copy = document.createElement("canvas");
+ copy.width = 640;
+ copy.height = 360;
+ const context = copy.getContext("2d")!;
+ context.drawImage(canvas, 0, 0);
+ const pixel = (x: number, y: number) => context.getImageData(x, y, 1, 1).data[0];
+ // The fixture's 180px camera occupies (452,172)–(632,352).
+ const edges = [
+ Array.from({ length: 40 }, (_, i) => pixel(542, 172 + i)),
+ Array.from({ length: 40 }, (_, i) => pixel(631 - i, 262)),
+ Array.from({ length: 40 }, (_, i) => pixel(542, 351 - i)),
+ Array.from({ length: 40 }, (_, i) => pixel(452 + i, 262)),
+ ];
+ results.push({
+ name,
+ shape,
+ frame,
+ edges,
+ center: Array.from(context.getImageData(538, 258, 8, 8).data),
+ });
+ copy.dataset.export = `${name}-${shape}-${frame}`;
+ document.body.append(copy);
+ }
+ }
+ if (name === "legacy") legacy.webcamVideoElement = null;
+ renderer.destroy();
+ }
+ return results;
+}
+Object.assign(window, { featherChecks });
diff --git a/tests/ui/webcam-motion-blur.spec.ts b/tests/ui/webcam-motion-blur.spec.ts
index 23cd7f3d..a25f04cd 100644
--- a/tests/ui/webcam-motion-blur.spec.ts
+++ b/tests/ui/webcam-motion-blur.spec.ts
@@ -41,7 +41,9 @@ test("preview camera blurs on shrink and grow, then clears on pause", async ({ p
await page.getByRole("button", { name: "Play", exact: true }).click();
await expect(page.locator("[data-webcam-motion-blur]")).toBeVisible({ timeout: 5000 });
await expect
- .poll(async () => Number(await page.locator("main").getAttribute("data-time")))
+ .poll(async () => Number(await page.locator("main").getAttribute("data-time")), {
+ intervals: [25],
+ })
.toBeGreaterThan(1.35);
await page.getByRole("button", { name: "Pause", exact: true }).click();
await expect(page.locator("[data-webcam-motion-blur]")).toBeHidden();
diff --git a/tests/ui/webcam-motion-blur.tsx b/tests/ui/webcam-motion-blur.tsx
index 72a55aa6..c4edb30d 100644
--- a/tests/ui/webcam-motion-blur.tsx
+++ b/tests/ui/webcam-motion-blur.tsx
@@ -26,12 +26,14 @@ const zooms: ZoomRegion[] = [
];
const webcam = {
...DEFAULT_WEBCAM_OVERLAY,
+ previewFrame: "standard" as const,
enabled: true,
width: 50,
height: 50,
shadow: 0,
reactToZoom: true,
};
+const softWebcam = { ...webcam, previewFrame: "soft-blur" as const };
const noop = () => undefined;
const fail = (error: string) => {
throw new Error(error);
@@ -43,16 +45,20 @@ function Preview() {
const [ready, setReady] = useState(false);
const [playing, setPlaying] = useState(false);
const [portrait, setPortrait] = useState(false);
+ const [softFrame, setSoftFrame] = useState(false);
return (
+
page.evaluate(
@@ -49,8 +52,15 @@ test("webcam preview frame choices persist and inline crop updates immediately",
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
await expect(standard).toHaveAttribute("aria-selected", "true");
await soft.click();
- await expect(layer).not.toHaveCSS("mask-image", "none");
- await expect(page.locator("[data-webcam-overlay]")).toBeVisible();
+ await expect(layer).toBeVisible();
+ await expect(bubble).toHaveAttribute("data-webcam-ready", "true");
+ await expect
+ .poll(() => page.evaluate(readPreviewFeather))
+ .toEqual({
+ transparentEdge: true,
+ opaqueCenter: true,
+ gradualFade: true,
+ });
await choices.screenshot({
path: testInfo.outputPath("webcam-frame-selector.png"),
animations: "disabled",