Render webcam soft edges consistently in preview and export

This commit is contained in:
webadderall committed 2026-11-09 19:39:18 +11:00
1 parent a4b1597273
commit 201567a91f
27 files changed
+385 -170

No files matched your search

@@ -397,15 +397,12 @@ export function WebcamSettings({
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium">
{tSettings("effects.webcamPreviewFrame", "Preview frame")}
{tSettings("effects.webcamPreviewFrame", "Frame")}
</p>
<span className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamPreviewOnly", "Preview only")}
</span>
</div>
<ChoiceGroup
type="single"
aria-label={tSettings("effects.webcamPreviewFrame", "Preview frame")}
aria-label={tSettings("effects.webcamPreviewFrame", "Frame")}
value={webcam.previewFrame ?? "soft-blur"}
className="grid grid-cols-2 gap-2"
onValueChange={(previewFrame) => {
+2 -1
View File
@@ -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,
@@ -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<typeof usePlaybackState> &
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" }}
/>
<WebcamFeatherPreview
enabled={webcam?.previewFrame !== "standard"}
shape={webcam?.shape}
roundness={webcam?.roundness}
>
<>
<canvas
ref={webcamBlurCanvasRef}
data-webcam-motion-blur
data-webcam-frame-style={webcam?.previewFrame ?? "soft-blur"}
aria-hidden="true"
className="pointer-events-none absolute"
style={{ display: "none" }}
@@ -296,7 +292,7 @@ export function PlaybackView(input: PlaybackViewInput) {
</div>
</div>
) : null}
</WebcamFeatherPreview>
</>
<PlaybackCaptions {...input} />
<div
className="absolute inset-0"
@@ -1,114 +0,0 @@
import { type ReactNode, useId, useLayoutEffect, useRef } from "react";
import { DEFAULT_WEBCAM_ROUNDNESS, type WebcamShape } from "../types";
import { getWebcamCornerRadiusPx } from "../webcamOverlay";
import { getWebcamShapeSvgPath } from "../webcamShapePath";
/** Geometry-based transparency gradient plus a little blur at the camera edge. */
export function WebcamFeatherPreview({
children,
enabled = true,
shape = "squircle",
roundness = DEFAULT_WEBCAM_ROUNDNESS,
}: {
children: ReactNode;
enabled?: boolean;
shape?: WebcamShape;
roundness?: number;
}) {
const id = `webcam-feather-${useId().replace(/:/g, "")}`;
const layerRef = useRef<HTMLDivElement>(null);
const pathRef = useRef<SVGPathElement>(null);
useLayoutEffect(() => {
const layer = layerRef.current;
if (!layer) return;
const update = () => {
const camera = layer.querySelector<HTMLElement>("[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 (
<>
<svg
width="100%"
height="100%"
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<defs>
<path id={`${id}-contour`} ref={pathRef} />
<filter id={`${id}-gradient`} colorInterpolationFilters="sRGB">
<feGaussianBlur stdDeviation="3" />
</filter>
<mask
id={`${id}-fade`}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="100%"
height="100%"
style={{ maskType: "alpha" }}
>
<use href={`#${id}-contour`} fill="white" filter={`url(#${id}-gradient)`} />
</mask>
<mask
id={`${id}-edge`}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="100%"
height="100%"
style={{ maskType: "alpha" }}
>
<use
href={`#${id}-contour`}
fill="none"
stroke="white"
strokeWidth="8"
filter={`url(#${id}-gradient)`}
/>
</mask>
</defs>
</svg>
<div
ref={layerRef}
data-webcam-feather-preview
className="pointer-events-none absolute inset-0"
style={{ mask: enabled ? `url(#${id}-fade)` : undefined }}
>
{children}
{enabled && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{ mask: `url(#${id}-edge)`, backdropFilter: "blur(3px)" }}
/>
)}
</div>
</>
);
}
@@ -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),
@@ -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<typeof traceWebcamShapeOnCanvas>[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();
}
}
@@ -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,
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -225,10 +225,9 @@
"bottom-center": "下中央",
"bottom-right": "右下"
},
"webcamPreviewFrame": "プレビューフレーム",
"webcamPreviewFrame": "フレーム",
"webcamFrameStandard": "標準",
"webcamFrameSoftBlur": "ソフトぼかし",
"webcamPreviewOnly": "プレビューのみ"
"webcamFrameSoftBlur": "ソフトぼかし"
},
"sections": {
"scene": "シーン",
+2 -3
View File
@@ -207,10 +207,9 @@
"bottom-center": "가운데 아래",
"bottom-right": "오른쪽 아래"
},
"webcamPreviewFrame": "미리보기 프레임",
"webcamPreviewFrame": "프레임",
"webcamFrameStandard": "기본",
"webcamFrameSoftBlur": "부드러운 흐림",
"webcamPreviewOnly": "미리보기 전용"
"webcamFrameSoftBlur": "부드러운 흐림"
},
"sections": {
"scene": "장면",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -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",
+2 -3
View File
@@ -207,10 +207,9 @@
"bottom-center": "下中",
"bottom-right": "右下"
},
"webcamPreviewFrame": "预览边框",
"webcamPreviewFrame": "边框",
"webcamFrameStandard": "标准",
"webcamFrameSoftBlur": "柔和模糊",
"webcamPreviewOnly": "仅预览"
"webcamFrameSoftBlur": "柔和模糊"
},
"sections": {
"scene": "场景",
+2 -3
View File
@@ -207,10 +207,9 @@
"bottom-center": "下中",
"bottom-right": "右下"
},
"webcamPreviewFrame": "預覽邊框",
"webcamPreviewFrame": "邊框",
"webcamFrameStandard": "標準",
"webcamFrameSoftBlur": "柔和模糊",
"webcamPreviewOnly": "僅預覽"
"webcamFrameSoftBlur": "柔和模糊"
},
"sections": {
"scene": "場景",
+2
View File
@@ -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,
+5
View File
@@ -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,
+21
View File
@@ -0,0 +1,21 @@
/** Inspect rendered pixels rather than the presence of a CSS mask declaration. */
export function readPreviewFeather() {
const canvas = document.querySelector<HTMLCanvasElement>("[data-webcam-motion-blur]")!;
const camera = document.querySelector<HTMLElement>("[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,
};
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8"/><title>Webcam edge render regression</title></head><body><script type="module" src="./webcam-feather.ts"></script></body></html>
+74
View File
@@ -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);
});
+108
View File
@@ -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 });
+3 -1
View File
@@ -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();
+7 -1
View File
@@ -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 (
<main style={{ width: portrait ? 160 : 640 }} data-ready={ready} data-time={time}>
<button onClick={() => setPortrait(true)}>Portrait</button>
<button aria-pressed={softFrame} onClick={() => setSoftFrame(!softFrame)}>
Soft frame
</button>
<button onClick={() => void playback.current?.play()}>Play</button>
<button onClick={() => playback.current?.pause()}>Pause</button>
<VideoPlayback
ref={playback}
videoPath="/tests/ui/fixtures/filmstrip.mp4"
webcamVideoPath="/tests/ui/fixtures/filmstrip.mp4"
webcam={webcam}
webcam={softFrame ? softWebcam : webcam}
clipRegions={clips}
currentTime={time}
onTimeUpdate={setTime}
+16 -6
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { installDesktopBridge, installDesktopBridgeOverrides } from "./bridge";
import { readPreviewFeather } from "./webcam-feather-probe";
test("webcam preview frame choices persist and inline crop updates immediately", async ({
page,
@@ -28,14 +29,16 @@ test("webcam preview frame choices persist and inline crop updates immediately",
timeout: 20000,
});
await page.getByRole("radio", { name: "Webcam", exact: true }).click();
const choices = page.getByRole("grid", { name: "Preview frame", exact: true });
const choices = page.getByRole("grid", { name: "Frame", exact: true });
const soft = choices.getByRole("row", { name: "Soft blur", exact: true });
const standard = choices.getByRole("row", { name: "Standard", exact: true });
const layer = page.locator("[data-webcam-feather-preview]");
const layer = page.locator("[data-webcam-motion-blur]");
const bubble = page.locator("[data-webcam-overlay]");
await expect(soft).toHaveAttribute("aria-selected", "true");
await expect(layer).not.toHaveCSS("mask-image", "none");
await expect(layer).toBeVisible();
await standard.click();
await expect(layer).toHaveCSS("mask-image", "none");
await expect(layer).toBeHidden();
await expect(bubble).toBeVisible();
await expect
.poll(() =>
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",