Fix Recordly 2.0 part 8 follow-up review findings

Preserve existing PR history and integrate validated current-cycle fixes.
This commit is contained in:
webadderall committed 2026-11-08 14:28:59 +11:00
1 parent 48389cf854
commit 08075f161b
12 files changed
+407 -27

No files matched your search

+149 -3
View File
@@ -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");
+22 -4
View File
@@ -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. */
@@ -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
}
>
<WallpaperGlassFilter id={filterId} />
{children}
@@ -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%));
}
+38
View File
@@ -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();
}
@@ -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) {
/>
</div>
</div>
<svg
data-webcam-glass-rim
aria-hidden="true"
focusable="false"
className="pointer-events-none absolute inset-0 h-full w-full"
style={{
opacity:
webcamVideoDimensions && webcamMediaSynchronized
? 1
: 0,
}}
>
<path fill="none" stroke={GLASS_RIM_COLOR} />
</svg>
</div>
</div>
) : null}
@@ -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<SVGSVGElement>("[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();
@@ -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();
@@ -262,6 +262,17 @@ vi.mock("react", async (importOriginal) => ({
function previewWebcamFixture(shape?: "circle" | "rectangle" | "squircle") {
previewMemo.dependencies = null;
const bubble = { style: {} as Record<string, string> };
const rimAttributes = new Map<string, string>();
const pathAttributes = new Map<string, string>();
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<typeof usePlaybackWebcamLayout>[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)}`,
);
});
}
+10 -1
View File
@@ -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) {
+10 -4
View File
@@ -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);
+53 -2
View File
@@ -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"),
});
});