From 201567a91f94bfef39c9d3a2973d910294f6bc10 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:39:18 +1100 Subject: [PATCH] Render webcam soft edges consistently in preview and export --- .../video-editor/settings/WebcamSettings.tsx | 7 +- src/components/video-editor/types.ts | 3 +- .../videoPlayback/PlaybackView.tsx | 10 +- .../videoPlayback/WebcamFeatherPreview.tsx | 114 ------------------ .../videoPlayback/usePlaybackWebcamLayout.ts | 10 +- .../video-editor/webcamEdgeFeather.ts | 86 +++++++++++++ .../video-editor/webcamMotionBlur.ts | 25 +++- src/i18n/locales/de/settings.json | 5 +- src/i18n/locales/en/settings.json | 5 +- src/i18n/locales/es/settings.json | 5 +- src/i18n/locales/fr/settings.json | 5 +- src/i18n/locales/it/settings.json | 5 +- src/i18n/locales/ja/settings.json | 5 +- src/i18n/locales/ko/settings.json | 5 +- src/i18n/locales/nl/settings.json | 5 +- src/i18n/locales/pt-BR/settings.json | 5 +- src/i18n/locales/zh-CN/settings.json | 5 +- src/i18n/locales/zh-TW/settings.json | 5 +- src/lib/exporter/legacyFrameComposite.ts | 2 + src/lib/exporter/modernFrameWebcam.ts | 5 + tests/ui/webcam-feather-probe.ts | 21 ++++ tests/ui/webcam-feather.html | 1 + tests/ui/webcam-feather.spec.ts | 74 ++++++++++++ tests/ui/webcam-feather.ts | 108 +++++++++++++++++ tests/ui/webcam-motion-blur.spec.ts | 4 +- tests/ui/webcam-motion-blur.tsx | 8 +- tests/ui/webcam-preview-frame.spec.ts | 22 +++- 27 files changed, 385 insertions(+), 170 deletions(-) delete mode 100644 src/components/video-editor/videoPlayback/WebcamFeatherPreview.tsx create mode 100644 src/components/video-editor/webcamEdgeFeather.ts create mode 100644 tests/ui/webcam-feather-probe.ts create mode 100644 tests/ui/webcam-feather.html create mode 100644 tests/ui/webcam-feather.spec.ts create mode 100644 tests/ui/webcam-feather.ts 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",