Fix countdown overlay and squircle corners

This commit is contained in:
webadderall
2026-03-20 20:08:47 +11:00
parent 3b96608c14
commit 7431a2efc4
9 changed files with 189 additions and 16 deletions
+1
View File
@@ -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;
};
}
+20 -4
View File
@@ -99,6 +99,7 @@ let cachedSystemCursorAssetsSourceMtimeMs: number | null = null
let countdownTimer: ReturnType<typeof setInterval> | 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<void>((resolve) => {
countdownWin.webContents.once('did-finish-load', () => {
resolve()
if (countdownWin.webContents.isLoadingMainFrame()) {
await new Promise<void>((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,
}
})
}
+1
View File
@@ -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);
@@ -4,6 +4,12 @@ export function CountdownOverlay() {
const [countdown, setCountdown] = useState<number | null>(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);
});
+11 -1
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
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})`;
@@ -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 {
+3 -1
View File
@@ -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(),
+9 -9
View File
@@ -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();
+130
View File
@@ -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();
}