From 08075f161b401ed97610910f35f494aacd7040e0 Mon Sep 17 00:00:00 2001
From: webadderall <131426131+webadderall@users.noreply.github.com>
Date: Sun, 8 Nov 2026 14:28:59 +1100
Subject: [PATCH] Fix Recordly 2.0 part 8 follow-up review findings
Preserve existing PR history and integrate validated current-cycle fixes.
---
electron/editorCommandMenu.test.ts | 152 +++++++++++++++++-
electron/editorCommandMenu.ts | 26 ++-
.../video-editor/WallpaperGlassGrid.tsx | 8 +-
src/components/video-editor/WallpaperGrid.css | 2 +-
src/components/video-editor/glassRim.ts | 38 +++++
.../videoPlayback/PlaybackView.tsx | 15 ++
.../videoPlayback/usePlaybackWebcamLayout.ts | 20 +++
.../video-editor/webcamMotionBlur.ts | 41 +++--
.../video-editor/webcamOverlay.test.ts | 52 +++++-
.../video-editor/webcamShapePath.ts | 11 +-
src/lib/exporter/frameRenderer.test.ts | 14 +-
tests/ui/webcam-defaults.spec.ts | 55 ++++++-
12 files changed, 407 insertions(+), 27 deletions(-)
create mode 100644 src/components/video-editor/glassRim.ts
diff --git a/electron/editorCommandMenu.test.ts b/electron/editorCommandMenu.test.ts
index 0596de58..fbda6ff3 100644
--- a/electron/editorCommandMenu.test.ts
+++ b/electron/editorCommandMenu.test.ts
@@ -11,7 +11,10 @@ import {
} from "./editorCommandMenu";
const translate = nativeT;
-afterEach(() => setNativeLocale("en"));
+afterEach(() => {
+ setNativeLocale("en");
+ vi.useRealTimers();
+});
const click = (item: MenuItemConstructorOptions) => (item.click as () => void)();
function fixture() {
@@ -67,6 +70,12 @@ function fixture() {
menus,
onProjectAction,
events,
+ setWindowFocused: (value: boolean) => {
+ focused = value;
+ },
+ setContentsFocused: (value: boolean) => {
+ contentsFocused = value;
+ },
close: () => {
alive = false;
},
@@ -221,20 +230,26 @@ describe("hidden-Dock editor command access", () => {
expect(f.onProjectAction).not.toHaveBeenCalled();
});
- it("does nothing if native window focus does not arrive", () => {
+ it("does nothing if native window focus does not arrive", async () => {
+ vi.useFakeTimers();
const f = fixture();
vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-denied"));
click(f.menus[1].submenu[4]);
+ await vi.advanceTimersByTimeAsync(400);
expect(f.events).toEqual(["restore", "show", "focus-denied"]);
+ expect(vi.getTimerCount()).toBe(0);
});
- it("does not focus or edit when the captured contents cannot receive focus", () => {
+ it("does not focus or edit when the captured contents cannot receive focus", async () => {
+ vi.useFakeTimers();
const f = fixture();
vi.mocked(f.window.webContents.focus).mockImplementation(() =>
f.events.push("contents-focus-denied"),
);
click(f.menus[1].submenu[4]);
+ await vi.advanceTimersByTimeAsync(400);
expect(f.events).toEqual(["restore", "show", "focus", "contents-focus-denied"]);
+ expect(vi.getTimerCount()).toBe(0);
});
it.each([
@@ -260,6 +275,137 @@ describe("hidden-Dock editor command access", () => {
expect(f.onProjectAction).not.toHaveBeenCalled();
});
+ it("waits for late native window focus, then executes exactly once without refocusing", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request"));
+ click(f.menus[0].submenu[2]);
+ await vi.advanceTimersByTimeAsync(39);
+ expect(f.onProjectAction).not.toHaveBeenCalled();
+ f.setWindowFocused(true);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(f.onProjectAction).toHaveBeenCalledExactlyOnceWith("menu-save-project-as", f.window);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.window.focus).toHaveBeenCalledOnce();
+ expect(f.window.show).toHaveBeenCalledOnce();
+ expect(f.window.webContents.focus).toHaveBeenCalledOnce();
+ expect(f.onProjectAction).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("waits for late contents focus after the native window, then targets the same contents", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ vi.mocked(f.window.webContents.focus).mockImplementation(() =>
+ f.events.push("contents-focus-request"),
+ );
+ click(f.menus[1].submenu[0]);
+ await vi.advanceTimersByTimeAsync(59);
+ expect(f.events).not.toContain("undo");
+ f.setContentsFocused(true);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(f.events.at(-1)).toBe("undo");
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.events.filter((event) => event === "undo")).toHaveLength(1);
+ expect(f.window.focus).toHaveBeenCalledOnce();
+ expect(f.window.webContents.focus).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("does not run a timed-out command even if focus arrives later", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request"));
+ click(f.menus[0].submenu[1]);
+ await vi.advanceTimersByTimeAsync(400);
+ expect(vi.getTimerCount()).toBe(0);
+ f.setWindowFocused(true);
+ f.setContentsFocused(true);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.onProjectAction).not.toHaveBeenCalled();
+ expect(f.window.focus).toHaveBeenCalledOnce();
+ });
+
+ it.each([
+ "closed",
+ "contents-destroyed",
+ "owner-lost",
+ "navigation",
+ ])("cancels a delayed command when the target becomes %s", async (change) => {
+ vi.useFakeTimers();
+ const f = fixture();
+ vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request"));
+ click(f.menus[0].submenu[1]);
+ await vi.advanceTimersByTimeAsync(10);
+ if (change === "closed") f.close();
+ if (change === "contents-destroyed") f.destroyContents();
+ if (change === "owner-lost") f.registry.clear();
+ if (change === "navigation") f.navigate();
+ f.setWindowFocused(true);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(f.onProjectAction).not.toHaveBeenCalled();
+ expect(f.window.webContents.focus).not.toHaveBeenCalled();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("does not reclaim native focus lost while waiting for contents focus", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ vi.mocked(f.window.webContents.focus).mockImplementation(() =>
+ f.events.push("contents-focus-request"),
+ );
+ click(f.menus[1].submenu[0]);
+ await vi.advanceTimersByTimeAsync(10);
+ f.setWindowFocused(false);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(vi.getTimerCount()).toBe(0);
+ f.setWindowFocused(true);
+ f.setContentsFocused(true);
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.events).not.toContain("undo");
+ expect(f.window.focus).toHaveBeenCalledOnce();
+ expect(f.window.webContents.focus).toHaveBeenCalledOnce();
+ });
+
+ it("revalidates after contents focus before executing a deferred action", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ vi.mocked(f.window.focus).mockImplementation(() => f.events.push("focus-request"));
+ vi.mocked(f.window.webContents.focus).mockImplementation(() => {
+ f.events.push("contents-focus-and-navigate");
+ f.setContentsFocused(true);
+ f.navigate();
+ });
+ click(f.menus[0].submenu[0]);
+ f.setWindowFocused(true);
+ await vi.advanceTimersByTimeAsync(20);
+ expect(f.onProjectAction).not.toHaveBeenCalled();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("does not dispatch if a native focus call returns after the deadline", () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ const started = Date.now();
+ vi.mocked(f.window.webContents.focus).mockImplementation(() => {
+ f.setContentsFocused(true);
+ vi.setSystemTime(started + 401);
+ });
+ click(f.menus[0].submenu[1]);
+ expect(f.onProjectAction).not.toHaveBeenCalled();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("keeps an already-focused command immediate with no timer", () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.setWindowFocused(true);
+ f.setContentsFocused(true);
+ click(f.menus[0].submenu[0]);
+ expect(f.onProjectAction).toHaveBeenCalledExactlyOnceWith("menu-load-project", f.window);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
it("uses the current existing native translations for both menus", () => {
const send = vi.fn();
setNativeLocale("it");
diff --git a/electron/editorCommandMenu.ts b/electron/editorCommandMenu.ts
index 7868e2c6..2039e2cf 100644
--- a/electron/editorCommandMenu.ts
+++ b/electron/editorCommandMenu.ts
@@ -34,15 +34,33 @@ export function captureEditorMenuTarget(
function runOnTarget(target: Target, action: (window: BrowserWindow) => void) {
const window = target.window;
if (!window || !target.isValid(window)) return;
+ const deadline = Date.now() + 400;
if (window.isMinimized()) window.restore();
if (!target.isValid(window)) return;
window.show();
if (!target.isValid(window)) return;
window.focus();
- if (!target.isValid(window) || !window.isFocused()) return;
- window.webContents.focus();
- if (!target.isValid(window) || !window.isFocused() || !window.webContents.isFocused()) return;
- action(window);
+ let contentsFocusRequested = false;
+ const checkFocus = () => {
+ if (!target.isValid(window) || Date.now() > deadline) return;
+ if (!window.isFocused()) {
+ // Once acquired, losing focus cancels the command instead of reclaiming it.
+ if (contentsFocusRequested) return;
+ } else {
+ if (!contentsFocusRequested) {
+ contentsFocusRequested = true;
+ window.webContents.focus();
+ }
+ if (!target.isValid(window) || !window.isFocused()) return;
+ if (window.webContents.isFocused()) {
+ if (target.isValid(window) && Date.now() <= deadline) action(window);
+ return;
+ }
+ }
+ const remaining = deadline - Date.now();
+ if (remaining > 0) setTimeout(checkFocus, Math.min(20, remaining));
+ };
+ checkFocus();
}
/** Shared labels/actions; the ordinary application menu retains its native Edit roles. */
diff --git a/src/components/video-editor/WallpaperGlassGrid.tsx b/src/components/video-editor/WallpaperGlassGrid.tsx
index bf8c8ae9..397e3a7b 100644
--- a/src/components/video-editor/WallpaperGlassGrid.tsx
+++ b/src/components/video-editor/WallpaperGlassGrid.tsx
@@ -1,5 +1,6 @@
import { type CSSProperties, type HTMLAttributes, useId } from "react";
import { useTheme } from "@/contexts/ThemeContext";
+import { GLASS_RIM_COLOR } from "./glassRim";
import { WallpaperGlassFilter } from "./WallpaperGlassFilter";
import "./WallpaperGrid.css";
@@ -15,7 +16,12 @@ export function WallpaperGlassGrid({
{...props}
className="relative grid grid-cols-5 gap-2"
data-wallpaper-glass-theme={theme}
- style={{ "--wallpaper-glass-filter": `url(#${filterId})` } as CSSProperties}
+ style={
+ {
+ "--wallpaper-glass-filter": `url(#${filterId})`,
+ "--glass-rim-color": GLASS_RIM_COLOR,
+ } as CSSProperties
+ }
>
{children}
diff --git a/src/components/video-editor/WallpaperGrid.css b/src/components/video-editor/WallpaperGrid.css
index 94799c5a..a1b74b25 100644
--- a/src/components/video-editor/WallpaperGrid.css
+++ b/src/components/video-editor/WallpaperGrid.css
@@ -39,5 +39,5 @@
linear-gradient(#fff 0 0);
mask-composite: exclude;
padding: 1px;
- background: rgb(255 255 255 / 26%);
+ background: var(--glass-rim-color, rgb(255 255 255 / 26%));
}
diff --git a/src/components/video-editor/glassRim.ts b/src/components/video-editor/glassRim.ts
new file mode 100644
index 00000000..c34c721a
--- /dev/null
+++ b/src/components/video-editor/glassRim.ts
@@ -0,0 +1,38 @@
+import type { WebcamShape } from "./types";
+import { getWebcamShapeSvgPath, traceWebcamShapeOnCanvas } from "./webcamShapePath";
+
+/** The wallpaper tiles' translucent outline; image refraction is a separate effect. */
+export const GLASS_RIM_COLOR = "rgb(255 255 255 / 26%)";
+/** Layout pixels at a 300px short edge; proportional scaling preserves export parity. */
+export const WEBCAM_GLASS_RIM_WIDTH = 0.75;
+
+export function getWebcamGlassRimWidth(width: number, height: number): number {
+ const size = Math.min(width, height);
+ return Number.isFinite(size) ? (Math.max(0, size) / 300) * WEBCAM_GLASS_RIM_WIDTH : 0;
+}
+
+interface WebcamGlassContour {
+ x?: number;
+ y?: number;
+ width: number;
+ height: number;
+ radius: number;
+ shape?: WebcamShape;
+}
+
+export function getWebcamGlassRimPath(contour: WebcamGlassContour): string {
+ return getWebcamShapeSvgPath({ x: 0, y: 0, ...contour });
+}
+
+/** Call inside the footage's shape clip, so the centered stroke paints inward only. */
+export function drawWebcamGlassRim(ctx: CanvasRenderingContext2D, contour: WebcamGlassContour) {
+ const width = getWebcamGlassRimWidth(contour.width, contour.height);
+ if (width <= 0) return;
+ ctx.save();
+ ctx.beginPath();
+ traceWebcamShapeOnCanvas(ctx, { x: 0, y: 0, ...contour });
+ ctx.strokeStyle = GLASS_RIM_COLOR;
+ ctx.lineWidth = width * 2;
+ ctx.stroke();
+ ctx.restore();
+}
diff --git a/src/components/video-editor/videoPlayback/PlaybackView.tsx b/src/components/video-editor/videoPlayback/PlaybackView.tsx
index a42e9d5a..0ef6a51e 100644
--- a/src/components/video-editor/videoPlayback/PlaybackView.tsx
+++ b/src/components/video-editor/videoPlayback/PlaybackView.tsx
@@ -1,4 +1,5 @@
import { formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
+import { GLASS_RIM_COLOR } from "../glassRim";
import { PlaybackAnnotations } from "./PlaybackAnnotations";
import { PlaybackCaptions } from "./PlaybackCaptions";
import type { PlaybackProps } from "./playbackProps";
@@ -272,6 +273,20 @@ export function PlaybackView(input: PlaybackViewInput) {
/>
+
) : null}
diff --git a/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts b/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts
index 029471bb..5f72de81 100644
--- a/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts
+++ b/src/components/video-editor/videoPlayback/usePlaybackWebcamLayout.ts
@@ -1,6 +1,7 @@
import type React from "react";
import { useCallback, useEffect, useMemo } from "react";
import { getWebcamShadowFilter } from "@/lib/exporter/shadowProfile";
+import { getWebcamGlassRimPath, getWebcamGlassRimWidth } from "../glassRim";
import {
DEFAULT_WEBCAM_REACT_TO_ZOOM,
DEFAULT_WEBCAM_ROUNDNESS,
@@ -208,6 +209,25 @@ export function usePlaybackWebcamLayout(input: usePlaybackWebcamLayoutInput) {
bubbleInner.style.contain = "paint";
bubbleInner.style.clipPath = webcamClipPath;
bubbleInner.style.setProperty("-webkit-clip-path", webcamClipPath);
+ const rim = bubbleInner.querySelector("[data-webcam-glass-rim]");
+ if (rim) {
+ const viewBox = `0 0 ${scaledDimensions.width} ${scaledDimensions.height}`;
+ if (rim.getAttribute("viewBox") !== viewBox) rim.setAttribute("viewBox", viewBox);
+ const path = rim.querySelector("path");
+ const contour = getWebcamGlassRimPath({
+ ...scaledDimensions,
+ radius: scaledRadius,
+ shape: webcamShape,
+ });
+ if (path) {
+ const strokeWidth = String(
+ getWebcamGlassRimWidth(scaledDimensions.width, scaledDimensions.height) * 2,
+ );
+ if (path.getAttribute("stroke-width") !== strokeWidth)
+ path.setAttribute("stroke-width", strokeWidth);
+ if (path.getAttribute("d") !== contour) path.setAttribute("d", contour);
+ }
+ }
const video = webcamVideoRef.current;
if (!video || video.readyState < 2 || !webcamMediaSynchronized) {
webcamMotionBlurRef.current.reset();
diff --git a/src/components/video-editor/webcamMotionBlur.ts b/src/components/video-editor/webcamMotionBlur.ts
index fe4124aa..a396994d 100644
--- a/src/components/video-editor/webcamMotionBlur.ts
+++ b/src/components/video-editor/webcamMotionBlur.ts
@@ -1,4 +1,5 @@
import { getWebcamShadowFilter, getWebcamShadowPadding } from "@/lib/exporter/shadowProfile";
+import { drawWebcamGlassRim } from "./glassRim";
import type { CropRegion, WebcamShape } from "./types";
import { getWebcamCropSourceRect } from "./webcamOverlay";
import { traceWebcamShapeOnCanvas } from "./webcamShapePath";
@@ -197,22 +198,31 @@ export class WebcamMotionBlur {
ctx.globalAlpha = 1 / this.samples.length;
const { sx, sy, sw, sh } = getWebcamCropSourceRect(cropRegion, sourceWidth, sourceHeight);
for (const sample of this.samples) {
- ctx.save();
- ctx.translate(sample.x - x, sample.y - y);
- ctx.beginPath();
- traceWebcamShapeOnCanvas(ctx, {
+ // 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.
+ out.clearRect(0, 0, pixelWidth, pixelHeight);
+ out.save();
+ out.scale(resolution, resolution);
+ out.imageSmoothingEnabled = true;
+ out.imageSmoothingQuality = "high";
+ out.globalCompositeOperation = "source-over";
+ out.globalAlpha = 1;
+ out.translate(sample.x - x, sample.y - y);
+ out.beginPath();
+ traceWebcamShapeOnCanvas(out, {
...sample,
x: 0,
y: 0,
shape: options.shape,
});
- ctx.clip();
+ out.clip();
+ out.save();
if (mirror) {
- ctx.translate(sample.width, 0);
- ctx.scale(-1, 1);
+ out.translate(sample.width, 0);
+ out.scale(-1, 1);
}
const scale = Math.max(sample.width / sw, sample.height / sh);
- ctx.drawImage(
+ out.drawImage(
source,
sx,
sy,
@@ -223,7 +233,20 @@ export class WebcamMotionBlur {
sw * scale,
sh * scale,
);
- ctx.restore();
+ out.restore();
+ drawWebcamGlassRim(out, { ...sample, x: 0, y: 0, shape: options.shape });
+ out.restore();
+ ctx.drawImage(
+ this.output,
+ 0,
+ 0,
+ pixelWidth,
+ pixelHeight,
+ 0,
+ 0,
+ pixelWidth / resolution,
+ pixelHeight / resolution,
+ );
}
ctx.restore();
diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts
index f730a9e4..38609a50 100644
--- a/src/components/video-editor/webcamOverlay.test.ts
+++ b/src/components/video-editor/webcamOverlay.test.ts
@@ -262,6 +262,17 @@ vi.mock("react", async (importOriginal) => ({
function previewWebcamFixture(shape?: "circle" | "rectangle" | "squircle") {
previewMemo.dependencies = null;
const bubble = { style: {} as Record };
+ const rimAttributes = new Map();
+ const pathAttributes = new Map();
+ const rimPath = {
+ getAttribute: (name: string) => pathAttributes.get(name) ?? null,
+ setAttribute: vi.fn((name: string, value: string) => pathAttributes.set(name, value)),
+ };
+ const rim = {
+ getAttribute: (name: string) => rimAttributes.get(name) ?? null,
+ setAttribute: vi.fn((name: string, value: string) => rimAttributes.set(name, value)),
+ querySelector: vi.fn(() => rimPath),
+ };
const input = {
webcam: {
enabled: true,
@@ -273,7 +284,9 @@ function previewWebcamFixture(shape?: "circle" | "rectangle" | "squircle") {
webcamVideoDimensions: { width: 1920, height: 1080 },
webcamBubbleRef: { current: bubble },
webcamBlurCanvasRef: { current: null },
- webcamBubbleInnerRef: { current: { style: { setProperty: vi.fn() } } },
+ webcamBubbleInnerRef: {
+ current: { style: { setProperty: vi.fn() }, querySelector: vi.fn(() => rim) },
+ },
overlayRef: { current: { clientWidth: 1920, clientHeight: 1080 } },
webcamVideoPath: "/camera.webm",
timelineTimeRef: { current: 0 },
@@ -290,7 +303,7 @@ function previewWebcamFixture(shape?: "circle" | "rectangle" | "squircle") {
} as unknown as Parameters[0];
// This harness evaluates geometry with mocked hooks, including dependency-aware memoization.
const useLayout = () => usePlaybackWebcamLayout(input);
- return { input, bubble, layout: useLayout };
+ return { input, bubble, rim, rimPath, rimAttributes, pathAttributes, layout: useLayout };
}
it("keeps circle preview content proportional after switching from unequal rectangle dimensions", () => {
@@ -319,3 +332,38 @@ it("keeps direct preview inputs with an omitted shape square despite their sourc
expect(fixture.bubble.style.width).toBe("324px");
expect(fixture.bubble.style.height).toBe("324px");
});
+
+for (const shape of ["circle", "squircle", "rectangle"] as const) {
+ it(`updates a stable ${shape} preview rim with the camera without changing its footprint`, () => {
+ const fixture = previewWebcamFixture(shape);
+ fixture.layout().applyWebcamBubbleLayout(1);
+ const initialPath = fixture.pathAttributes.get("d");
+ expect(initialPath).toMatch(/^M/);
+ expect(Number(fixture.pathAttributes.get("stroke-width"))).toBeCloseTo(
+ Math.min(
+ Number.parseFloat(fixture.bubble.style.width),
+ Number.parseFloat(fixture.bubble.style.height),
+ ) * 0.005,
+ );
+ expect(fixture.rimAttributes.get("viewBox")).toBe(
+ `0 0 ${Number.parseFloat(fixture.bubble.style.width)} ${Number.parseFloat(fixture.bubble.style.height)}`,
+ );
+ fixture.rim.setAttribute.mockClear();
+ fixture.rimPath.setAttribute.mockClear();
+ fixture.layout().applyWebcamBubbleLayout(1);
+ expect(fixture.rim.setAttribute).not.toHaveBeenCalled();
+ expect(fixture.rimPath.setAttribute).not.toHaveBeenCalled();
+ fixture.input.webcam = { ...fixture.input.webcam!, width: 40, height: 40, roundness: 69 };
+ fixture.layout().applyWebcamBubbleLayout(1);
+ expect(fixture.pathAttributes.get("d")).not.toBe(initialPath);
+ expect(Number(fixture.pathAttributes.get("stroke-width"))).toBeCloseTo(
+ Math.min(
+ Number.parseFloat(fixture.bubble.style.width),
+ Number.parseFloat(fixture.bubble.style.height),
+ ) * 0.005,
+ );
+ expect(fixture.rimAttributes.get("viewBox")).toBe(
+ `0 0 ${Number.parseFloat(fixture.bubble.style.width)} ${Number.parseFloat(fixture.bubble.style.height)}`,
+ );
+ });
+}
diff --git a/src/components/video-editor/webcamShapePath.ts b/src/components/video-editor/webcamShapePath.ts
index d702880b..d425dbcd 100644
--- a/src/components/video-editor/webcamShapePath.ts
+++ b/src/components/video-editor/webcamShapePath.ts
@@ -1,4 +1,4 @@
-import { getSquirclePathPoints } from "@/lib/geometry/squircle";
+import { getSquirclePathPoints, getSquircleSvgPath } from "@/lib/geometry/squircle";
import type { WebcamShape } from "./types";
import { getWebcamShapeExponent } from "./webcamOverlay";
@@ -11,6 +11,15 @@ interface WebcamContour {
shape?: WebcamShape;
}
+/** The same rounded arcs and square contour used by the Canvas compositor. */
+export function getWebcamShapeSvgPath(contour: WebcamContour): string {
+ const { x, y, width, height, shape } = contour;
+ if (shape !== "circle" && shape !== "rectangle")
+ return getSquircleSvgPath({ ...contour, exponent: getWebcamShapeExponent(shape) });
+ const r = Math.max(0, Math.min(contour.radius, width / 2, height / 2));
+ return `M ${x + r} ${y} H ${x + width - r} A ${r} ${r} 0 0 1 ${x + width} ${y + r} V ${y + height - r} A ${r} ${r} 0 0 1 ${x + width - r} ${y + height} H ${x + r} A ${r} ${r} 0 0 1 ${x} ${y + height - r} V ${y + r} A ${r} ${r} 0 0 1 ${x + r} ${y} Z`;
+}
+
// Keep preview/compositor clipping consistent with CSS circle()/inset() curves.
/** Append one closed contour; callers begin the path and choose clip/fill. */
export function traceWebcamShapeOnCanvas(ctx: CanvasRenderingContext2D, contour: WebcamContour) {
diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts
index 093e2dd6..40186ae8 100644
--- a/src/lib/exporter/frameRenderer.test.ts
+++ b/src/lib/exporter/frameRenderer.test.ts
@@ -105,6 +105,7 @@ type MockContext = {
arcTo: MockFunction;
closePath: MockFunction;
clip: MockFunction;
+ stroke: MockFunction;
drawImage: MockFunction;
fillRect: MockFunction;
save: MockFunction;
@@ -250,6 +251,7 @@ function createMockContext() {
arcTo: vi.fn(),
closePath: vi.fn(),
clip: vi.fn(),
+ stroke: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
save: vi.fn(),
@@ -529,7 +531,7 @@ describe("FrameRenderer webcam export path", () => {
renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720);
- const bubbleCanvas = createdCanvases[0];
+ const bubbleCanvas = createdCanvases.at(-1)!;
expect(bubbleCanvas).toBeDefined();
expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(
cachedFrameCanvas,
@@ -561,7 +563,7 @@ describe("FrameRenderer webcam export path", () => {
renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720);
- const bubbleCanvas = createdCanvases[0];
+ const bubbleCanvas = createdCanvases.at(-1)!;
expect(bubbleCanvas).toBeDefined();
expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(
cachedFrameCanvas,
@@ -589,10 +591,14 @@ describe("FrameRenderer webcam export path", () => {
renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720);
const cacheCanvas = createdCanvases[0];
- const bubbleCanvas = createdCanvases[1];
+ const bubbleCanvas = createdCanvases.at(-1)!;
expect(cacheCanvas).toBeDefined();
expect((cacheCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(webcamVideo);
expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(cacheCanvas);
+ expect(bubbleCanvas.context.stroke).toHaveBeenCalledOnce();
+ expect(bubbleCanvas.context.drawImage.mock.invocationCallOrder[0]).toBeLessThan(
+ bubbleCanvas.context.stroke.mock.invocationCallOrder[0],
+ );
expect((outputContext as MockContext).drawImage.mock.calls[0][0]).toBe(
createdCanvases.at(-1),
);
@@ -623,7 +629,7 @@ describe("FrameRenderer webcam export path", () => {
renderer.drawWebcamOverlay(outputContext as unknown as CanvasRenderingContext2D, 1280, 720);
const cacheCanvas = createdCanvases[0];
- const bubbleCanvas = createdCanvases[1];
+ const bubbleCanvas = createdCanvases.at(-1)!;
expect(cacheCanvas).toBeDefined();
expect((cacheCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(webcamVideo);
expect((bubbleCanvas.context as MockContext).drawImage.mock.calls[0][0]).toBe(cacheCanvas);
diff --git a/tests/ui/webcam-defaults.spec.ts b/tests/ui/webcam-defaults.spec.ts
index df9cf23f..ee659c73 100644
--- a/tests/ui/webcam-defaults.spec.ts
+++ b/tests/ui/webcam-defaults.spec.ts
@@ -47,6 +47,7 @@ for (const savedRoundness of [undefined, 0, 69, 100]) {
}),
)
.toBe(expected);
+ await expect(page.locator("[data-webcam-glass-rim] path")).toHaveAttribute("d", /^M/);
// Full roundness should soften the corners while keeping a squircle.
if (expected === 100) {
const shape = await mask.evaluate((node) => {
@@ -102,6 +103,7 @@ test("new recordings default to a circle and retain the squircle option", async
await expect(page.getByRole("slider", { name: "Webcam Shadow" })).toHaveValue("0.2");
const mask = page.locator('div[style*="clip-path"][style*="contain: paint"]:has(video)');
await expect(mask).toHaveCSS("clip-path", "circle(50% at 50% 50%)");
+ await expect(page.locator("[data-webcam-glass-rim] path")).toHaveAttribute("d", /^M/);
await expect(page.getByRole("slider", { name: "Webcam Roundness" })).toHaveCount(0);
const width = page.getByRole("slider", { name: "Webcam Width" });
await expect(width).toHaveValue("30");
@@ -127,9 +129,10 @@ test("new recordings default to a circle and retain the squircle option", async
await circle.click();
await expect(width).toHaveValue("30");
await expect(mask).toHaveCSS("clip-path", "circle(50% at 50% 50%)");
+ await expect(page.locator("[data-webcam-glass-rim] path")).toHaveAttribute("d", /^M/);
});
-test("Rectangle starts at 30% height in 16:9 and keeps Square roundness without a border", async ({
+test("Rectangle starts at 30% height in 16:9 and keeps Square roundness with a thin glass rim", async ({
page,
}) => {
await installDesktopBridge(page);
@@ -168,7 +171,24 @@ test("Rectangle starts at 30% height in 16:9 and keeps Square roundness without
.toBeCloseTo(16 / 9, 2);
await expect(roundness).toHaveValue("25");
const rim = page.locator("[data-webcam-glass-rim]");
- await expect(rim).toHaveCount(0);
+ await expect(rim).toBeVisible();
+ await expect(rim.locator("path")).toHaveAttribute("fill", "none");
+ await expect(rim.locator("path")).toHaveAttribute("stroke", "rgb(255 255 255 / 26%)");
+ await expect
+ .poll(() =>
+ rim.evaluate((element) => {
+ const bounds = element.getBoundingClientRect();
+ return (
+ Number(element.querySelector("path")!.getAttribute("stroke-width")) /
+ Math.min(bounds.width, bounds.height)
+ );
+ }),
+ )
+ .toBeCloseTo(0.005, 5);
+ await expect(rim.locator("path")).toHaveAttribute("d", /^M/);
+ const rectanglePath = await rim.locator("path").getAttribute("d");
+ const originalRim = await rim.elementHandle();
+ const originalFootage = await page.locator("[data-webcam-overlay] video").elementHandle();
await expect(page.locator("[data-webcam-overlay]")).toHaveAttribute(
"data-webcam-ready",
"true",
@@ -188,6 +208,17 @@ test("Rectangle starts at 30% height in 16:9 and keeps Square roundness without
await page.getByRole("row", { name: "Square", exact: true }).click();
await expect(page.getByRole("slider", { name: "Webcam Width" })).toHaveValue("30");
await expect(roundness).toHaveValue("25");
+ await expect(rim.locator("path")).not.toHaveAttribute("d", rectanglePath!);
+ expect(
+ await originalRim!.evaluate(
+ (node) => node === document.querySelector("[data-webcam-glass-rim]"),
+ ),
+ ).toBe(true);
+ expect(
+ await originalFootage!.evaluate(
+ (node) => node === document.querySelector("[data-webcam-overlay] video"),
+ ),
+ ).toBe(true);
await expect
.poll(() =>
page.locator("[data-webcam-overlay]").evaluate((element) => {
@@ -196,4 +227,24 @@ test("Rectangle starts at 30% height in 16:9 and keeps Square roundness without
}),
)
.toBeLessThan(1);
+ const renderedCamera = page.locator("[data-webcam-motion-blur]");
+ await page.getByRole("button", { name: "Play", exact: true }).click();
+ await expect(renderedCamera).toBeVisible();
+ await expect(rim).toBeHidden();
+ await page.getByRole("button", { name: "Pause", exact: true }).click();
+ await expect(renderedCamera).toBeHidden();
+ await expect(rim).toBeVisible();
+ expect(
+ await originalRim!.evaluate(
+ (node) => node === document.querySelector("[data-webcam-glass-rim]"),
+ ),
+ ).toBe(true);
+ expect(
+ await originalFootage!.evaluate(
+ (node) => node === document.querySelector("[data-webcam-overlay] video"),
+ ),
+ ).toBe(true);
+ await page.locator("[data-webcam-overlay]").screenshot({
+ path: test.info().outputPath("paused-camera-rim.png"),
+ });
});