crop effect

This commit is contained in:
Siddharth
2025-11-09 00:43:45 -07:00
parent 307ac02ec3
commit 0e946fb260
7 changed files with 436 additions and 32 deletions
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
}
interface CropControlProps {
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
}
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
// Draw video preview at high quality
useEffect(() => {
if (!videoElement || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
// Set canvas to actual video dimensions for high quality
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
const draw = () => {
if (videoElement.readyState >= 2) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(draw);
};
const rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, [videoElement]);
const getContainerRect = () => {
return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 };
};
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(handle);
const rect = getContainerRect();
setDragStart({
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
});
setInitialCrop(cropRegion);
// Capture pointer for smooth dragging
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
const rect = getContainerRect();
const currentX = (e.clientX - rect.left) / rect.width;
const currentY = (e.clientY - rect.top) / rect.height;
const deltaX = currentX - dragStart.x;
const deltaY = currentY - dragStart.y;
let newCrop = { ...initialCrop };
switch (isDragging) {
case 'top': {
// Calculate new y position
const newY = Math.max(0, initialCrop.y + deltaY);
// Calculate the bottom edge (which should stay fixed)
const bottom = initialCrop.y + initialCrop.height;
// Ensure minimum height of 0.1
newCrop.y = Math.min(newY, bottom - 0.1);
newCrop.height = bottom - newCrop.y;
break;
}
case 'bottom':
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
break;
case 'left': {
// Calculate new x position
const newX = Math.max(0, initialCrop.x + deltaX);
// Calculate the right edge (which should stay fixed)
const right = initialCrop.x + initialCrop.width;
// Ensure minimum width of 0.1
newCrop.x = Math.min(newX, right - 0.1);
newCrop.width = right - newCrop.x;
break;
}
case 'right':
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
}
onCropChange(newCrop);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
}
setIsDragging(null);
};
const cropPixelX = cropRegion.x * 100;
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<canvas
ref={canvasRef}
className="w-full h-full rounded-lg"
style={{ imageRendering: 'auto' }}
/>
{/* Dark overlay outside crop */}
<div className="absolute inset-0 pointer-events-none">
<svg width="100%" height="100%" className="absolute inset-0">
<defs>
<mask id="cropMask">
<rect width="100%" height="100%" fill="white" />
<rect
x={`${cropPixelX}%`}
y={`${cropPixelY}%`}
width={`${cropPixelWidth}%`}
height={`${cropPixelHeight}%`}
fill="black"
/>
</mask>
</defs>
<rect width="100%" height="100%" fill="black" fillOpacity="0.6" mask="url(#cropMask)" />
</svg>
</div>
{/* Crop region outline */}
<div
className="absolute border-2 border-white shadow-2xl pointer-events-none transition-none"
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
height: `${cropPixelHeight}%`,
willChange: 'left, top, width, height',
}}
>
{/* Grid lines */}
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3 pointer-events-none">
{[...Array(9)].map((_, i) => (
<div key={i} className="border border-white/20" />
))}
</div>
</div>
{/* Side handles */}
{/* Top handle */}
<div
className={cn(
"absolute w-20 h-1 bg-white/90 cursor-ns-resize z-20 pointer-events-auto shadow-md",
"hover:bg-white hover:h-1.5 transition-all",
isDragging === 'top' && "bg-white h-2"
)}
style={{
left: `${cropPixelX + cropPixelWidth / 2}%`,
top: `${cropPixelY}%`,
transform: 'translate(-50%, -50%)',
willChange: 'transform',
}}
onPointerDown={(e) => handlePointerDown(e, 'top')}
/>
{/* Bottom handle */}
<div
className={cn(
"absolute w-20 h-1 bg-white/90 cursor-ns-resize z-20 pointer-events-auto shadow-md",
"hover:bg-white hover:h-1.5 transition-all",
isDragging === 'bottom' && "bg-white h-2"
)}
style={{
left: `${cropPixelX + cropPixelWidth / 2}%`,
top: `${cropPixelY + cropPixelHeight}%`,
transform: 'translate(-50%, -50%)',
willChange: 'transform',
}}
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
/>
{/* Left handle */}
<div
className={cn(
"absolute w-1 h-20 bg-white/90 cursor-ew-resize z-20 pointer-events-auto shadow-md",
"hover:bg-white hover:w-1.5 transition-all",
isDragging === 'left' && "bg-white w-2"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY + cropPixelHeight / 2}%`,
transform: 'translate(-50%, -50%)',
willChange: 'transform',
}}
onPointerDown={(e) => handlePointerDown(e, 'left')}
/>
{/* Right handle */}
<div
className={cn(
"absolute w-1 h-20 bg-white/90 cursor-ew-resize z-20 pointer-events-auto shadow-md",
"hover:bg-white hover:w-1.5 transition-all",
isDragging === 'right' && "bg-white w-2"
)}
style={{
left: `${cropPixelX + cropPixelWidth}%`,
top: `${cropPixelY + cropPixelHeight / 2}%`,
transform: 'translate(-50%, -50%)',
willChange: 'transform',
}}
onPointerDown={(e) => handlePointerDown(e, 'right')}
/>
</div>
</div>
);
}
+83 -9
View File
@@ -5,8 +5,9 @@ import { Button } from "@/components/ui/button";
import { useState } from "react";
import Colorful from '@uiw/react-color-colorful';
import { hsvaToHex } from '@uiw/color-convert';
import { Trash2 } from "lucide-react";
import type { ZoomDepth } from "./types";
import { Trash2, Download, Crop, X } from "lucide-react";
import type { ZoomDepth, CropRegion } from "./types";
import { CropControl } from "./CropControl";
const WALLPAPER_COUNT = 12;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
@@ -34,6 +35,11 @@ interface SettingsPanelProps {
onZoomDelete?: (id: string) => void;
showShadow?: boolean;
onShadowChange?: (showShadow: boolean) => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
videoElement?: HTMLVideoElement | null;
}
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
@@ -44,9 +50,10 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
{ depth: 5, label: "3.5×" },
];
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange }: SettingsPanelProps) {
export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange, selectedZoomId, onZoomDelete, showShadow, onShadowChange, showBlur, onBlurChange, cropRegion, onCropChange, videoElement }: SettingsPanelProps) {
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const zoomEnabled = Boolean(selectedZoomDepth);
@@ -107,14 +114,71 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
)}
</div>
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<Switch
checked={showShadow}
onCheckedChange={onShadowChange}
/>
<div className="text-sm">Shadow</div>
<div className="flex flex-col gap-3 mb-4">
<div className="flex items-center gap-2">
<Switch
checked={showShadow}
onCheckedChange={onShadowChange}
/>
<div className="text-sm">Shadow</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
/>
<div className="text-sm">Blur Background</div>
</div>
</div>
</div>
<div className="mb-6">
<Button
onClick={() => setShowCropDropdown(!showCropDropdown)}
variant="outline"
className="w-full gap-2"
>
<Crop className="w-4 h-4" />
Crop Video
</Button>
</div>
{showCropDropdown && cropRegion && onCropChange && (
<>
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40 animate-in fade-in duration-200"
onClick={() => setShowCropDropdown(false)}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-card rounded-2xl shadow-2xl border border-border/50 p-8 w-[90vw] max-w-5xl animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-foreground">Crop Video</span>
<p className="text-sm text-muted-foreground mt-2">Drag the white handles on each side to adjust the crop area. Changes apply to the entire video.</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setShowCropDropdown(false)}
className="hover:bg-muted"
>
<X className="w-5 h-5" />
</Button>
</div>
<CropControl
videoElement={videoElement || null}
cropRegion={cropRegion}
onCropChange={onCropChange}
/>
<div className="mt-6 flex justify-end">
<Button
onClick={() => setShowCropDropdown(false)}
size="lg"
>
Done
</Button>
</div>
</div>
</>
)}
<Tabs defaultValue="image" className="mb-6">
<TabsList className="mb-4">
<TabsTrigger value="image">Image</TabsTrigger>
@@ -171,6 +235,16 @@ export function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth,
</div>
</TabsContent>
</Tabs>
<div className="mt-auto pt-4">
<Button
type="button"
size="lg"
className="w-full py-5 text-lg flex items-center justify-center gap-3 bg-primary text-white rounded-xl shadow-lg hover:bg-primary/90 transition-all"
>
<Download className="w-6 h-6" />
<span className="text-lg">Export Video</span>
</Button>
</div>
</div>
);
}
@@ -11,9 +11,11 @@ import type { Span } from "dnd-timeline";
import {
DEFAULT_ZOOM_DEPTH,
clampFocusToDepth,
DEFAULT_CROP_REGION,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
type CropRegion,
} from "./types";
const WALLPAPER_COUNT = 12;
@@ -28,6 +30,8 @@ export default function VideoEditor() {
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [showShadow, setShowShadow] = useState(false);
const [showBlur, setShowBlur] = useState(false);
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
@@ -187,6 +191,8 @@ export default function VideoEditor() {
onZoomFocusChange={handleZoomFocusChange}
isPlaying={isPlaying}
showShadow={showShadow}
showBlur={showBlur}
cropRegion={cropRegion}
/>
</div>
<PlaybackControls
@@ -220,6 +226,11 @@ export default function VideoEditor() {
onZoomDelete={handleZoomDelete}
showShadow={showShadow}
onShadowChange={setShowShadow}
showBlur={showBlur}
onBlurChange={setShowBlur}
cropRegion={cropRegion}
onCropChange={setCropRegion}
videoElement={videoPlaybackRef.current?.video || null}
/>
</div>
);
+18 -6
View File
@@ -24,6 +24,8 @@ interface VideoPlaybackProps {
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
isPlaying: boolean;
showShadow?: boolean;
showBlur?: boolean;
cropRegion?: import('./types').CropRegion;
}
export interface VideoPlaybackRef {
@@ -46,6 +48,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
onZoomFocusChange,
isPlaying,
showShadow,
showBlur,
cropRegion,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -67,6 +71,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const videoSizeRef = useRef({ width: 0, height: 0 });
const baseScaleRef = useRef(1);
const baseOffsetRef = useRef({ x: 0, y: 0 });
const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
const maskGraphicsRef = useRef<PIXI.Graphics | null>(null);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
@@ -118,6 +123,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoSprite,
maskGraphics,
videoElement,
cropRegion,
});
if (result) {
@@ -125,6 +131,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoSizeRef.current = result.videoSize;
baseScaleRef.current = result.baseScale;
baseOffsetRef.current = result.baseOffset;
baseMaskRef.current = result.maskRect;
const selectedId = selectedZoomIdRef.current;
const activeRegion = selectedId
@@ -133,7 +140,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
updateOverlayForRegion(activeRegion);
}
}, [updateOverlayForRegion]);
}, [updateOverlayForRegion, cropRegion]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
@@ -232,7 +239,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
useEffect(() => {
if (!pixiReady || !videoReady) return;
layoutVideoContent();
}, [pixiReady, videoReady, layoutVideoContent]);
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -433,6 +440,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoSize: videoSizeRef.current,
baseScale: baseScaleRef.current,
baseOffset: baseOffsetRef.current,
baseMask: baseMaskRef.current,
zoomScale: state.scale,
focusX: state.focusX,
focusY: state.focusY,
@@ -526,10 +534,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
: { background: wallpaper || '/wallpapers/wallpaper1.jpg' };
return (
<div
className="relative aspect-video rounded-sm overflow-hidden bg-cover bg-center"
style={{ ...backgroundStyle, width: '100%' }}
>
<div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}>
<div
className="absolute inset-0 bg-cover bg-center"
style={{
...backgroundStyle,
filter: showBlur ? 'blur(2px)' : 'none',
}}
/>
<div
ref={containerRef}
className="absolute inset-0"
+14
View File
@@ -13,6 +13,20 @@ export interface ZoomRegion {
focus: ZoomFocus;
}
export interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
}
export const DEFAULT_CROP_REGION: CropRegion = {
x: 0,
y: 0,
width: 1,
height: 1,
};
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
1: 1.25,
2: 1.5,
@@ -1,5 +1,6 @@
import * as PIXI from 'pixi.js';
import { VIEWPORT_SCALE } from "./constants";
import type { CropRegion } from '../types';
interface LayoutParams {
container: HTMLDivElement;
@@ -7,6 +8,7 @@ interface LayoutParams {
videoSprite: PIXI.Sprite;
maskGraphics: PIXI.Graphics;
videoElement: HTMLVideoElement;
cropRegion?: CropRegion;
}
interface LayoutResult {
@@ -14,10 +16,11 @@ interface LayoutResult {
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
}
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const { container, app, videoSprite, maskGraphics, videoElement } = params;
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion } = params;
const videoWidth = videoElement.videoWidth;
const videoHeight = videoElement.videoHeight;
@@ -37,32 +40,60 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
app.canvas.style.width = '100%';
app.canvas.style.height = '100%';
// Apply crop region
const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 };
// Calculate the cropped dimensions
const croppedVideoWidth = videoWidth * crop.width;
const croppedVideoHeight = videoHeight * crop.height;
// Calculate scale to fit the cropped area in the viewport
const maxDisplayWidth = width * VIEWPORT_SCALE;
const maxDisplayHeight = height * VIEWPORT_SCALE;
const scale = Math.min(
maxDisplayWidth / videoWidth,
maxDisplayHeight / videoHeight,
maxDisplayWidth / croppedVideoWidth,
maxDisplayHeight / croppedVideoHeight,
1
);
videoSprite.scale.set(scale);
const displayWidth = videoWidth * scale;
const displayHeight = videoHeight * scale;
// Calculate display size of the full video at this scale
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const offsetX = (width - displayWidth) / 2;
const offsetY = (height - displayHeight) / 2;
videoSprite.position.set(offsetX, offsetY);
// Center the cropped region in the container
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
// Position the full video sprite so that when we apply the mask,
// the cropped region appears centered
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
// We want that point to be at centerOffsetX, centerOffsetY
const spriteX = centerOffsetX - (crop.x * fullVideoDisplayWidth);
const spriteY = centerOffsetY - (crop.y * fullVideoDisplayHeight);
videoSprite.position.set(spriteX, spriteY);
const radius = Math.min(displayWidth, displayHeight) * 0.02;
// Create a mask that only shows the cropped region (centered in container)
const maskX = centerOffsetX;
const maskY = centerOffsetY;
const radius = Math.min(croppedDisplayWidth, croppedDisplayHeight) * 0.02;
maskGraphics.clear();
maskGraphics.roundRect(offsetX, offsetY, displayWidth, displayHeight, radius);
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, radius);
maskGraphics.fill({ color: 0xffffff });
return {
stageSize: { width, height },
videoSize: { width: videoWidth, height: videoHeight },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: offsetX, y: offsetY },
baseOffset: { x: spriteX, y: spriteY },
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
};
}
@@ -8,6 +8,7 @@ interface TransformParams {
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
focusX: number;
focusY: number;
@@ -24,6 +25,7 @@ export function applyZoomTransform(params: TransformParams) {
videoSize,
baseScale,
baseOffset,
baseMask,
zoomScale,
focusX,
focusY,
@@ -31,7 +33,15 @@ export function applyZoomTransform(params: TransformParams) {
isPlaying,
} = params;
if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) {
if (
!stageSize.width ||
!stageSize.height ||
!videoSize.width ||
!videoSize.height ||
baseScale <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0
) {
return;
}
@@ -46,6 +56,8 @@ export function applyZoomTransform(params: TransformParams) {
// Keep the focus point centered in viewport after zoom transformation
const baseVideoX = baseOffset.x;
const baseVideoY = baseOffset.y;
const baseMaskX = baseMask.x;
const baseMaskY = baseMask.y;
const focusInVideoSpaceX = focusStagePxX - baseVideoX;
const focusInVideoSpaceY = focusStagePxY - baseVideoY;
@@ -61,10 +73,12 @@ export function applyZoomTransform(params: TransformParams) {
blurFilter.blur = motionBlur;
}
const videoWidth = videoSize.width * actualScale;
const videoHeight = videoSize.height * actualScale;
const radius = Math.min(videoWidth, videoHeight) * 0.02;
const maskWidth = baseMask.width * zoomScale;
const maskHeight = baseMask.height * zoomScale;
const maskX = baseMaskX + (newVideoX - baseVideoX);
const maskY = baseMaskY + (newVideoY - baseVideoY);
const radius = Math.min(maskWidth, maskHeight) * 0.02;
maskGraphics.clear();
maskGraphics.roundRect(newVideoX, newVideoY, videoWidth, videoHeight, radius);
maskGraphics.roundRect(maskX, maskY, maskWidth, maskHeight, radius);
maskGraphics.fill({ color: 0xffffff });
}