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"), + }); });