mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 15:33:31 +00:00
Render webcam soft edges consistently in preview and export
This commit is contained in:
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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -225,10 +225,9 @@
|
||||
"bottom-center": "下中央",
|
||||
"bottom-right": "右下"
|
||||
},
|
||||
"webcamPreviewFrame": "プレビューフレーム",
|
||||
"webcamPreviewFrame": "フレーム",
|
||||
"webcamFrameStandard": "標準",
|
||||
"webcamFrameSoftBlur": "ソフトぼかし",
|
||||
"webcamPreviewOnly": "プレビューのみ"
|
||||
"webcamFrameSoftBlur": "ソフトぼかし"
|
||||
},
|
||||
"sections": {
|
||||
"scene": "シーン",
|
||||
|
||||
@@ -207,10 +207,9 @@
|
||||
"bottom-center": "가운데 아래",
|
||||
"bottom-right": "오른쪽 아래"
|
||||
},
|
||||
"webcamPreviewFrame": "미리보기 프레임",
|
||||
"webcamPreviewFrame": "프레임",
|
||||
"webcamFrameStandard": "기본",
|
||||
"webcamFrameSoftBlur": "부드러운 흐림",
|
||||
"webcamPreviewOnly": "미리보기 전용"
|
||||
"webcamFrameSoftBlur": "부드러운 흐림"
|
||||
},
|
||||
"sections": {
|
||||
"scene": "장면",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -207,10 +207,9 @@
|
||||
"bottom-center": "下中",
|
||||
"bottom-right": "右下"
|
||||
},
|
||||
"webcamPreviewFrame": "预览边框",
|
||||
"webcamPreviewFrame": "边框",
|
||||
"webcamFrameStandard": "标准",
|
||||
"webcamFrameSoftBlur": "柔和模糊",
|
||||
"webcamPreviewOnly": "仅预览"
|
||||
"webcamFrameSoftBlur": "柔和模糊"
|
||||
},
|
||||
"sections": {
|
||||
"scene": "场景",
|
||||
|
||||
@@ -207,10 +207,9 @@
|
||||
"bottom-center": "下中",
|
||||
"bottom-right": "右下"
|
||||
},
|
||||
"webcamPreviewFrame": "預覽邊框",
|
||||
"webcamPreviewFrame": "邊框",
|
||||
"webcamFrameStandard": "標準",
|
||||
"webcamFrameSoftBlur": "柔和模糊",
|
||||
"webcamPreviewOnly": "僅預覽"
|
||||
"webcamFrameSoftBlur": "柔和模糊"
|
||||
},
|
||||
"sections": {
|
||||
"scene": "場景",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user