From 7431a2efc4a1bce8b80bd2b67c4ad8bd40a31fe6 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:12:50 +1100 Subject: [PATCH] Fix countdown overlay and squircle corners --- electron/electron-env.d.ts | 1 + electron/ipc/handlers.ts | 24 +++- electron/preload.ts | 1 + src/components/countdown/CountdownOverlay.tsx | 6 + src/components/video-editor/VideoPlayback.tsx | 12 +- .../video-editor/videoPlayback/layoutUtils.ts | 9 +- src/lib/exporter/frameRenderer.test.ts | 4 +- src/lib/exporter/frameRenderer.ts | 18 +-- src/lib/geometry/squircle.ts | 130 ++++++++++++++++++ 9 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 src/lib/geometry/squircle.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 7fc9b092..b5a14efd 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -209,6 +209,7 @@ interface Window { setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; cancelCountdown: () => Promise<{ success: boolean }>; + getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; onCountdownTick: (callback: (seconds: number) => void) => () => void; }; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9f8288bd..a7c8f260 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -99,6 +99,7 @@ let cachedSystemCursorAssetsSourceMtimeMs: number | null = null let countdownTimer: ReturnType | null = null let countdownCancelled = false let countdownInProgress = false +let countdownRemaining: number | null = null type SystemCursorAsset = { dataUrl: string @@ -3357,17 +3358,21 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} countdownInProgress = true countdownCancelled = false + countdownRemaining = seconds const countdownWin = createCountdownWindow() - await new Promise((resolve) => { - countdownWin.webContents.once('did-finish-load', () => { - resolve() + if (countdownWin.webContents.isLoadingMainFrame()) { + await new Promise((resolve) => { + countdownWin.webContents.once('did-finish-load', () => { + resolve() + }) }) - }) + } return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { let remaining = seconds + countdownRemaining = remaining countdownWin.webContents.send('countdown-tick', remaining) @@ -3379,11 +3384,13 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } closeCountdownWindow() countdownInProgress = false + countdownRemaining = null resolve({ success: false, cancelled: true }) return } remaining-- + countdownRemaining = remaining if (remaining <= 0) { if (countdownTimer) { @@ -3392,6 +3399,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } closeCountdownWindow() countdownInProgress = false + countdownRemaining = null resolve({ success: true }) } else { const win = getCountdownWindow() @@ -3406,6 +3414,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} ipcMain.handle('cancel-countdown', () => { countdownCancelled = true countdownInProgress = false + countdownRemaining = null if (countdownTimer) { clearInterval(countdownTimer) countdownTimer = null @@ -3413,5 +3422,12 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} closeCountdownWindow() return { success: true } }) + + ipcMain.handle('get-active-countdown', () => { + return { + success: true, + seconds: countdownInProgress ? countdownRemaining : null, + } + }) } diff --git a/electron/preload.ts b/electron/preload.ts index 94080f7e..6686e9fb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -233,6 +233,7 @@ contextBridge.exposeInMainWorld("electronAPI", { setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay), startCountdown: (seconds: number) => ipcRenderer.invoke("start-countdown", seconds), cancelCountdown: () => ipcRenderer.invoke("cancel-countdown"), + getActiveCountdown: () => ipcRenderer.invoke("get-active-countdown"), onCountdownTick: (callback: (seconds: number) => void) => { const listener = (_event: Electron.IpcRendererEvent, seconds: number) => callback(seconds); ipcRenderer.on("countdown-tick", listener); diff --git a/src/components/countdown/CountdownOverlay.tsx b/src/components/countdown/CountdownOverlay.tsx index 50c68e29..53e652f3 100644 --- a/src/components/countdown/CountdownOverlay.tsx +++ b/src/components/countdown/CountdownOverlay.tsx @@ -4,6 +4,12 @@ export function CountdownOverlay() { const [countdown, setCountdown] = useState(null); useEffect(() => { + void window.electronAPI.getActiveCountdown().then((result) => { + if (result.success && typeof result.seconds === "number") { + setCountdown(result.seconds); + } + }); + const cleanup = window.electronAPI.onCountdownTick((seconds: number) => { setCountdown(seconds); }); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 27849cba..d82d5f23 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -72,6 +72,7 @@ import { DEFAULT_CURSOR_SWAY, } from "./types"; import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "./webcamOverlay"; +import { getSquircleSvgPath } from "@/lib/geometry/squircle"; type PlaybackAnimationState = { scale: number; @@ -300,7 +301,16 @@ const VideoPlayback = forwardRef( bubble.style.top = `${y}px`; bubble.style.width = `${scaledSize}px`; bubble.style.height = `${scaledSize}px`; - bubble.style.borderRadius = `${webcam.cornerRadius ?? 18}px`; + const squirclePath = getSquircleSvgPath({ + x: 0, + y: 0, + width: scaledSize, + height: scaledSize, + radius: webcam.cornerRadius ?? 18, + }); + bubble.style.borderRadius = "0px"; + bubble.style.clipPath = `path('${squirclePath}')`; + bubble.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`); bubble.style.boxShadow = `0 ${Math.round(scaledSize * 0.06)}px ${Math.round( scaledSize * 0.22, )}px rgba(0, 0, 0, ${webcam.shadow ?? 0.35})`; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index b4acfb5a..77e6cd7f 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -1,5 +1,6 @@ import { Application, Sprite, Graphics } from 'pixi.js'; import type { CropRegion } from '../types'; +import { drawSquircleOnGraphics } from '@/lib/geometry/squircle'; interface LayoutParams { container: HTMLDivElement; @@ -103,7 +104,13 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { // Apply border radius maskGraphics.clear(); - maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius); + drawSquircleOnGraphics(maskGraphics, { + x: maskX, + y: maskY, + width: croppedDisplayWidth, + height: croppedDisplayHeight, + radius: borderRadius, + }); maskGraphics.fill({ color: 0xffffff }); return { diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts index 7fb85ba6..c43427a1 100644 --- a/src/lib/exporter/frameRenderer.test.ts +++ b/src/lib/exporter/frameRenderer.test.ts @@ -150,7 +150,9 @@ class FakeVideoElement { function createMockContext() { return { beginPath: vi.fn(), - roundRect: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), clip: vi.fn(), drawImage: vi.fn(), save: vi.fn(), diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index ef730f34..fb5d049f 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -38,6 +38,7 @@ import { } from "@/components/video-editor/videoPlayback/cursorRenderer"; import { clampMediaTimeToDuration } from "@/lib/mediaTiming"; import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "@/components/video-editor/webcamOverlay"; +import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle"; import { ForwardFrameSource } from "./forwardFrameSource"; import { resolveMediaElementSource } from "./localMediaSource"; @@ -842,13 +843,13 @@ export class FrameRenderer { const scaledBorderRadius = borderRadius * canvasScaleFactor; this.maskGraphics.clear(); - this.maskGraphics.roundRect( - centerOffsetX, - centerOffsetY, - croppedDisplayWidth, - croppedDisplayHeight, - scaledBorderRadius, - ); + drawSquircleOnGraphics(this.maskGraphics, { + x: centerOffsetX, + y: centerOffsetY, + width: croppedDisplayWidth, + height: croppedDisplayHeight, + radius: scaledBorderRadius, + }); this.maskGraphics.fill({ color: 0xffffff }); // Cache layout info @@ -1172,8 +1173,7 @@ export class FrameRenderer { const drawY = (size - drawHeight) / 2; bubbleCtx.save(); - bubbleCtx.beginPath(); - bubbleCtx.roundRect(0, 0, size, size, radius); + drawSquircleOnCanvas(bubbleCtx, { x: 0, y: 0, width: size, height: size, radius }); bubbleCtx.clip(); if (webcam.mirror) { bubbleCtx.save(); diff --git a/src/lib/geometry/squircle.ts b/src/lib/geometry/squircle.ts new file mode 100644 index 00000000..d317d692 --- /dev/null +++ b/src/lib/geometry/squircle.ts @@ -0,0 +1,130 @@ +import type { Graphics } from "pixi.js"; + +interface SquircleRect { + x: number; + y: number; + width: number; + height: number; + radius: number; +} + +interface SquirclePoint { + x: number; + y: number; +} + +const SQUIRCLE_EXPONENT = 4.5; +const SQUIRCLE_SEGMENTS_PER_CORNER = 10; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getClampedRadius(width: number, height: number, radius: number) { + return clamp(radius, 0, Math.min(width, height) / 2); +} + +function getSuperellipsePoint( + centerX: number, + centerY: number, + radius: number, + angle: number, +) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const exponent = 2 / SQUIRCLE_EXPONENT; + + return { + x: centerX + Math.sign(cos) * radius * Math.pow(Math.abs(cos), exponent), + y: centerY + Math.sign(sin) * radius * Math.pow(Math.abs(sin), exponent), + }; +} + +export function getSquirclePathPoints({ + x, + y, + width, + height, + radius, +}: SquircleRect): SquirclePoint[] { + if (width <= 0 || height <= 0) { + return []; + } + + const clampedRadius = getClampedRadius(width, height, radius); + if (clampedRadius <= 0.5) { + return [ + { x, y }, + { x: x + width, y }, + { x: x + width, y: y + height }, + { x, y: y + height }, + ]; + } + + const points: SquirclePoint[] = [{ x: x + clampedRadius, y }]; + const corners = [ + { centerX: x + width - clampedRadius, centerY: y + clampedRadius, start: -Math.PI / 2, end: 0 }, + { centerX: x + width - clampedRadius, centerY: y + height - clampedRadius, start: 0, end: Math.PI / 2 }, + { centerX: x + clampedRadius, centerY: y + height - clampedRadius, start: Math.PI / 2, end: Math.PI }, + { centerX: x + clampedRadius, centerY: y + clampedRadius, start: Math.PI, end: (Math.PI * 3) / 2 }, + ]; + + for (const corner of corners) { + for (let index = 1; index <= SQUIRCLE_SEGMENTS_PER_CORNER; index += 1) { + const t = index / SQUIRCLE_SEGMENTS_PER_CORNER; + const angle = corner.start + (corner.end - corner.start) * t; + points.push( + getSuperellipsePoint( + corner.centerX, + corner.centerY, + clampedRadius, + angle, + ), + ); + } + } + + return points; +} + +export function getSquircleSvgPath(rect: SquircleRect) { + const points = getSquirclePathPoints(rect); + if (points.length === 0) { + return ""; + } + + const [firstPoint, ...remainingPoints] = points; + return `M ${firstPoint.x} ${firstPoint.y} ${remainingPoints + .map((point) => `L ${point.x} ${point.y}`) + .join(" ")} Z`; +} + +export function drawSquircleOnCanvas( + ctx: CanvasRenderingContext2D, + rect: SquircleRect, +) { + const points = getSquirclePathPoints(rect); + if (points.length === 0) { + return; + } + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let index = 1; index < points.length; index += 1) { + ctx.lineTo(points[index].x, points[index].y); + } + ctx.closePath(); +} + +export function drawSquircleOnGraphics(graphics: Graphics, rect: SquircleRect) { + const points = getSquirclePathPoints(rect); + if (points.length === 0) { + return; + } + + graphics.moveTo(points[0].x, points[0].y); + for (let index = 1; index < points.length; index += 1) { + graphics.lineTo(points[index].x, points[index].y); + } + graphics.closePath(); +} \ No newline at end of file