mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
Fix countdown overlay and squircle corners
This commit is contained in:
Vendored
+1
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user