mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-10 07:53:57 +00:00
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:
1 parent
48389cf854
commit
08075f161b
12 files changed
+407
-27
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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%));
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user