pan and zoom effects

This commit is contained in:
Siddharth
2025-11-08 20:00:00 -07:00
parent 31364066e7
commit 0d6845dd00
7 changed files with 909 additions and 153 deletions
+63 -2
View File
@@ -1,9 +1,12 @@
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useMemo, useState } from "react";
import Colorful from '@uiw/react-color-colorful';
import { hsvaToHex } from '@uiw/color-convert';
import type { ZoomDepth } from "./types";
import { ZOOM_DEPTH_SCALES } from "./types";
const WALLPAPER_COUNT = 12;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
@@ -25,14 +28,72 @@ const GRADIENTS = [
interface SettingsPanelProps {
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
}
export default function SettingsPanel({ selected, onWallpaperChange }: SettingsPanelProps) {
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string; description: string }> = [
{ depth: 1, label: "Subtle", description: "Gentle focus" },
{ depth: 2, label: "Medium", description: "Balanced zoom" },
{ depth: 3, label: "Deep", description: "Bold spotlight" },
];
export default function SettingsPanel({ selected, onWallpaperChange, selectedZoomDepth, onZoomDepthChange }: SettingsPanelProps) {
const [hsva, setHsva] = useState({ h: 0, s: 0, v: 68, a: 1 });
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const zoomEnabled = Boolean(selectedZoomDepth);
const scaleLabels = useMemo(() => {
return ZOOM_DEPTH_OPTIONS.reduce<Record<ZoomDepth, string>>((acc, option) => {
const scale = ZOOM_DEPTH_SCALES[option.depth];
acc[option.depth] = `${scale.toFixed(2)}×`;
return acc;
}, { 1: "", 2: "", 3: "" });
}, []);
return (
<div className="flex-[3] min-w-0 bg-card border border-border rounded-xl p-8 flex flex-col shadow-sm">
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-slate-600">Zoom Depth</span>
{zoomEnabled && selectedZoomDepth && (
<span className="text-xs uppercase tracking-wide text-slate-400">
Active · {scaleLabels[selectedZoomDepth]}
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{ZOOM_DEPTH_OPTIONS.map((option) => {
const isActive = selectedZoomDepth === option.depth;
return (
<Button
key={option.depth}
type="button"
variant="outline"
disabled={!zoomEnabled}
onClick={() => onZoomDepthChange?.(option.depth)}
className={cn(
"h-auto w-full rounded-xl border bg-muted/30 px-4 py-4 text-left shadow-sm transition-all",
"flex flex-col gap-2",
zoomEnabled ? "opacity-100" : "opacity-60",
isActive
? "border-primary/70 bg-primary/10 text-primary shadow-primary/20"
: "border-border/60 hover:border-primary/40 hover:bg-muted/60"
)}
>
<span className="text-sm font-semibold tracking-tight">{option.label}</span>
<span className="text-xs font-medium text-slate-500">
{scaleLabels[option.depth]}
</span>
<span className="text-xs text-slate-400 leading-snug">{option.description}</span>
</Button>
);
})}
</div>
{!zoomEnabled && (
<p className="text-xs text-slate-400 mt-2">Select a zoom region in the timeline to adjust its depth.</p>
)}
</div>
<div className="mb-6">
<div className="flex items-center gap-2 mb-4">
<Switch/>
+112 -4
View File
@@ -1,12 +1,20 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import SettingsPanel from "./SettingsPanel";
import type { Span } from "dnd-timeline";
import {
DEFAULT_ZOOM_DEPTH,
clampFocusToDepth,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 12;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
@@ -19,8 +27,11 @@ export default function VideoEditor() {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
useEffect(() => {
async function loadVideo() {
@@ -42,8 +53,14 @@ export default function VideoEditor() {
function togglePlayPause() {
const video = videoPlaybackRef.current?.video;
console.log('🎮 Toggle play/pause:', { hasVideo: !!video, isPlaying, action: isPlaying ? 'pause' : 'play' });
if (!video) return;
isPlaying ? video.pause() : video.play();
if (isPlaying) {
video.pause();
} else {
video.play().catch(err => console.error('❌ Video play failed:', err));
}
}
function handleSeek(time: number) {
@@ -52,6 +69,78 @@ export default function VideoEditor() {
video.currentTime = time;
}
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
}, []);
const handleZoomAdded = useCallback((span: Span) => {
const id = `zoom-${nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
focus: { cx: 0.5, cy: 0.5 },
};
console.log(' Zoom region added:', newRegion);
setZoomRegions((prev) => [...prev, newRegion]);
setSelectedZoomId(id);
}, []);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
console.log('⏱️ Zoom span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) });
setZoomRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
}, []);
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
setZoomRegions((prev) =>
prev.map((region) =>
region.id === id
? {
...region,
focus: clampFocusToDepth(focus, region.depth),
}
: region,
),
);
}, []);
const handleZoomDepthChange = useCallback((depth: ZoomDepth) => {
if (!selectedZoomId) return;
setZoomRegions((prev) =>
prev.map((region) =>
region.id === selectedZoomId
? {
...region,
depth,
focus: clampFocusToDepth(region.focus, depth),
}
: region,
),
);
}, [selectedZoomId]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
}, [selectedZoomId, zoomRegions]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
setSelectedZoomId(null);
}
}, [selectedZoomId, zoomRegions]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
@@ -83,6 +172,11 @@ export default function VideoEditor() {
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={zoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
isPlaying={isPlaying}
/>
</div>
<PlaybackControls
@@ -95,9 +189,23 @@ export default function VideoEditor() {
</>
)}
</div>
<TimelineEditor videoDuration={duration} currentTime={currentTime} onSeek={handleSeek} />
<TimelineEditor
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
zoomRegions={zoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSpanChange={handleZoomSpanChange}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
/>
</div>
<SettingsPanel selected={wallpaper} onWallpaperChange={setWallpaper} />
<SettingsPanel
selected={wallpaper}
onWallpaperChange={setWallpaper}
selectedZoomDepth={selectedZoom?.depth}
onZoomDepthChange={handleZoomDepthChange}
/>
</div>
);
}
+594 -46
View File
@@ -1,5 +1,50 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
import * as PIXI from 'pixi.js';
import { ZOOM_DEPTH_SCALES, clampFocusToDepth, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types";
const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
const TRANSITION_WINDOW_MS = 320;
const SMOOTHING_FACTOR = 0.12;
const MIN_DELTA = 0.0001;
const VIEWPORT_SCALE = 0.8;
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function smoothStep(t: number) {
const clamped = clamp01(t);
return clamped * clamped * (3 - 2 * clamped);
}
function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
if (timeMs < leadInStart || timeMs > leadOutEnd) {
return 0;
}
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
return Math.min(fadeIn, fadeOut);
}
function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
let bestRegion: ZoomRegion | null = null;
let bestStrength = 0;
for (const region of regions) {
const strength = computeRegionStrength(region, timeMs);
if (strength > bestStrength) {
bestStrength = strength;
bestRegion = region;
}
}
return { region: bestRegion, strength: bestStrength };
}
interface VideoPlaybackProps {
videoPath: string;
@@ -8,6 +53,11 @@ interface VideoPlaybackProps {
onPlayStateChange: (playing: boolean) => void;
onError: (error: string) => void;
wallpaper?: string;
zoomRegions: ZoomRegion[];
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
isPlaying: boolean;
}
export interface VideoPlaybackRef {
@@ -24,6 +74,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
onPlayStateChange,
onError,
wallpaper,
zoomRegions,
selectedZoomId,
onSelectZoom,
onZoomFocusChange,
isPlaying,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -33,6 +88,202 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy });
const blurFilterRef = useRef<PIXI.BlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
const baseScaleRef = useRef(1);
const baseOffsetRef = useRef({ x: 0, y: 0 });
const maskGraphicsRef = useRef<PIXI.Graphics | null>(null);
const isPlayingRef = useRef(isPlaying);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
const stageSize = stageSizeRef.current;
const videoSize = videoSizeRef.current;
const baseScale = baseScaleRef.current;
if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) {
return clampFocusToDepth(focus, depth);
}
const zoomScale = ZOOM_DEPTH_SCALES[depth];
const indicatorWidth = (videoSize.width / zoomScale) * baseScale;
const indicatorHeight = (videoSize.height / zoomScale) * baseScale;
const normalizedWidth = stageSize.width > 0 ? Math.min(1, indicatorWidth / stageSize.width) : 1;
const normalizedHeight = stageSize.height > 0 ? Math.min(1, indicatorHeight / stageSize.height) : 1;
const baseFocus = clampFocusToDepth(focus, depth);
const marginX = normalizedWidth >= 1 ? 0.5 : normalizedWidth / 2;
const marginY = normalizedHeight >= 1 ? 0.5 : normalizedHeight / 2;
const minX = marginX;
const maxX = normalizedWidth >= 1 ? 0.5 : 1 - marginX;
const minY = marginY;
const maxY = normalizedHeight >= 1 ? 0.5 : 1 - marginY;
return {
cx: Math.min(maxX, Math.max(minX, baseFocus.cx)),
cy: Math.min(maxY, Math.max(minY, baseFocus.cy)),
};
}, []);
const stageFocusToVideoSpace = useCallback((focus: ZoomFocus): ZoomFocus => {
const stageSize = stageSizeRef.current;
const videoSize = videoSizeRef.current;
const baseScale = baseScaleRef.current;
const baseOffset = baseOffsetRef.current;
if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) {
return focus;
}
const stageX = focus.cx * stageSize.width;
const stageY = focus.cy * stageSize.height;
const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale);
const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale);
return {
cx: videoNormX,
cy: videoNormY,
};
}, []);
const updateOverlayForRegion = useCallback((region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
const overlayEl = overlayRef.current;
const indicatorEl = focusIndicatorRef.current;
if (!overlayEl || !indicatorEl) {
return;
}
if (!region) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = 'none';
return;
}
const stageWidth = overlayEl.clientWidth;
const stageHeight = overlayEl.clientHeight;
if (!stageWidth || !stageHeight) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = 'none';
return;
}
stageSizeRef.current = { width: stageWidth, height: stageHeight };
const baseScale = baseScaleRef.current;
const videoSize = videoSizeRef.current;
if (!videoSize.width || !videoSize.height || baseScale <= 0) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = isPlayingRef.current ? 'none' : 'auto';
return;
}
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth);
const indicatorWidth = (videoSize.width / zoomScale) * baseScale;
const indicatorHeight = (videoSize.height / zoomScale) * baseScale;
const rawLeft = focus.cx * stageWidth - indicatorWidth / 2;
const rawTop = focus.cy * stageHeight - indicatorHeight / 2;
const adjustedLeft = indicatorWidth >= stageWidth
? (stageWidth - indicatorWidth) / 2
: Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft));
const adjustedTop = indicatorHeight >= stageHeight
? (stageHeight - indicatorHeight) / 2
: Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop));
indicatorEl.style.display = 'block';
indicatorEl.style.width = `${indicatorWidth}px`;
indicatorEl.style.height = `${indicatorHeight}px`;
indicatorEl.style.left = `${adjustedLeft}px`;
indicatorEl.style.top = `${adjustedTop}px`;
overlayEl.style.pointerEvents = isPlayingRef.current ? 'none' : 'auto';
}, [clampFocusToStage]);
const layoutVideoContent = useCallback(() => {
const container = containerRef.current;
const app = appRef.current;
const videoSprite = videoSpriteRef.current;
const maskGraphics = maskGraphicsRef.current;
const videoElement = videoRef.current;
if (!container || !app || !videoSprite || !videoElement) {
return;
}
const videoWidth = videoElement.videoWidth;
const videoHeight = videoElement.videoHeight;
if (!videoWidth || !videoHeight) {
return;
}
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) {
return;
}
app.renderer.resize(width, height);
app.canvas.style.width = '100%';
app.canvas.style.height = '100%';
const maxDisplayWidth = width * VIEWPORT_SCALE;
const maxDisplayHeight = height * VIEWPORT_SCALE;
const scale = Math.min(
maxDisplayWidth / videoWidth,
maxDisplayHeight / videoHeight,
1
);
videoSprite.scale.set(scale);
const displayWidth = videoWidth * scale;
const displayHeight = videoHeight * scale;
const offsetX = (width - displayWidth) / 2;
const offsetY = (height - displayHeight) / 2;
videoSprite.position.set(offsetX, offsetY);
stageSizeRef.current = { width, height };
videoSizeRef.current = { width: videoWidth, height: videoHeight };
baseScaleRef.current = scale;
baseOffsetRef.current = { x: offsetX, y: offsetY };
if (maskGraphics) {
const radius = Math.min(displayWidth, displayHeight) * 0.02;
maskGraphics.clear();
maskGraphics.roundRect(offsetX, offsetY, displayWidth, displayHeight, radius);
maskGraphics.fill({ color: 0xffffff });
}
const selectedId = selectedZoomIdRef.current;
const activeRegion = selectedId
? zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null
: null;
updateOverlayForRegion(activeRegion);
}, [updateOverlayForRegion]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
}, [zoomRegions, selectedZoomId]);
useImperativeHandle(ref, () => ({
video: videoRef.current,
@@ -41,6 +292,129 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoContainer: videoContainerRef.current,
}));
const updateFocusFromClientPoint = (clientX: number, clientY: number) => {
const overlayEl = overlayRef.current;
if (!overlayEl) return;
const regionId = selectedZoomIdRef.current;
if (!regionId) return;
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
if (!region) return;
const rect = overlayEl.getBoundingClientRect();
const stageWidth = rect.width;
const stageHeight = rect.height;
if (!stageWidth || !stageHeight) {
return;
}
stageSizeRef.current = { width: stageWidth, height: stageHeight };
const localX = clientX - rect.left;
const localY = clientY - rect.top;
const unclampedFocus: ZoomFocus = {
cx: clamp01(localX / stageWidth),
cy: clamp01(localY / stageHeight),
};
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
onZoomFocusChange(region.id, clampedFocus);
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
};
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (isPlayingRef.current) return;
const regionId = selectedZoomIdRef.current;
if (!regionId) return;
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
if (!region) return;
onSelectZoom(region.id);
event.preventDefault();
isDraggingFocusRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
updateFocusFromClientPoint(event.clientX, event.clientY);
};
const handleOverlayPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingFocusRef.current) return;
event.preventDefault();
updateFocusFromClientPoint(event.clientX, event.clientY);
};
const endFocusDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingFocusRef.current) return;
isDraggingFocusRef.current = false;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// ignore release errors when pointer capture is already cleared
}
};
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
endFocusDrag(event);
};
const handleOverlayPointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
endFocusDrag(event);
};
useEffect(() => {
zoomRegionsRef.current = zoomRegions;
}, [zoomRegions]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
useEffect(() => {
isPlayingRef.current = isPlaying;
}, [isPlaying]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
layoutVideoContent();
}, [pixiReady, videoReady, layoutVideoContent]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
const container = containerRef.current;
if (!container) return;
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => {
layoutVideoContent();
});
observer.observe(container);
return () => {
observer.disconnect();
};
}, [pixiReady, videoReady, layoutVideoContent]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
updateOverlayForRegion(selectedZoom);
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = 'default';
overlayEl.style.pointerEvents = 'none';
return;
}
overlayEl.style.cursor = isPlaying ? 'not-allowed' : 'grab';
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
}, [selectedZoom, isPlaying]);
// Initialize PixiJS application
useEffect(() => {
const container = containerRef.current;
@@ -120,67 +494,64 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
// Create rounded rectangle mask
const maskGraphics = new PIXI.Graphics();
videoContainer.addChild(videoSprite);
videoContainer.addChild(maskGraphics);
videoSprite.mask = maskGraphics;
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
};
const blurFilter = new PIXI.BlurFilter();
blurFilter.quality = 3;
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
videoContainer.filters = [blurFilter];
blurFilterRef.current = blurFilter;
// Position and scale video
const containerWidth = app.canvas.width / app.renderer.resolution;
const containerHeight = app.canvas.height / app.renderer.resolution;
const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
layoutVideoContent();
const scale = Math.min(
containerWidth / videoWidth,
containerHeight / videoHeight
);
videoSprite.width = videoWidth * scale;
videoSprite.height = videoHeight * scale;
videoSprite.x = (containerWidth - videoSprite.width) / 2;
videoSprite.y = (containerHeight - videoSprite.height) / 2;
// Draw rounded mask
const radius = Math.min(videoSprite.width, videoSprite.height) * 0.02;
maskGraphics.roundRect(
videoSprite.x,
videoSprite.y,
videoSprite.width,
videoSprite.height,
radius
);
maskGraphics.fill({ color: 0xffffff });
// Ensure Pixi does not trigger autoplay
video.pause();
// Ensure Pixi does not trigger autoplay
video.pause();
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
onTimeUpdate(timeValue);
};
function updateTime() {
if (!video) return;
onTimeUpdate(video.currentTime);
emitTime(video.currentTime);
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
}
const handlePlay = () => {
isPlayingRef.current = true;
onPlayStateChange(true);
updateTime();
};
const handlePause = () => {
isPlayingRef.current = false;
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
onTimeUpdate(video.currentTime);
emitTime(video.currentTime);
onPlayStateChange(false);
};
const handleSeeked = () => {
onTimeUpdate(video.currentTime);
emitTime(video.currentTime);
};
const handleSeeking = () => {
onTimeUpdate(video.currentTime);
emitTime(video.currentTime);
};
video.addEventListener('play', handlePlay);
@@ -209,11 +580,179 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoContainer.removeChild(maskGraphics);
maskGraphics.destroy();
}
videoContainer.mask = null;
maskGraphicsRef.current = null;
if (blurFilterRef.current) {
videoContainer.filters = [];
blurFilterRef.current.destroy();
blurFilterRef.current = null;
}
videoTexture.destroy(true);
videoSpriteRef.current = null;
};
}, [pixiReady, videoReady, onTimeUpdate]);
}, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
const app = appRef.current;
const videoSprite = videoSpriteRef.current;
const videoContainer = videoContainerRef.current;
if (!app || !videoSprite || !videoContainer) return;
const applyTransform = (motionIntensity: number) => {
const stageSize = stageSizeRef.current;
const videoSize = videoSizeRef.current;
const baseScale = baseScaleRef.current;
const baseOffset = baseOffsetRef.current;
const state = animationStateRef.current;
if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) {
return;
}
// Zoom scale determines how much we're zooming in
// scale=1 means show everything at normal size
// scale=2 means zoom in 2x (show half the stage, magnified 2x)
const zoomScale = state.scale;
// The focus point in stage coordinates (0-1 normalized to actual pixels)
const focusStagePxX = state.focusX * stageSize.width;
const focusStagePxY = state.focusY * stageSize.height;
// When zoomed, we want the focus point to remain at the center of the viewport
// The stage center in pixels
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
// Calculate the video's new scale and position
// The video should scale up by the zoom factor
const actualScale = baseScale * zoomScale;
videoSprite.scale.set(actualScale);
// To keep the focus point centered:
// 1. In the "virtual stage space", the focus is at (focusStagePxX, focusStagePxY)
// 2. We want this point to appear at the stage center after transformation
// 3. The video's position offset needs to shift so focus → center
// The video's base position at no zoom
const baseVideoX = baseOffset.x;
const baseVideoY = baseOffset.y;
// The focus point relative to the video's top-left (in stage pixels, no zoom)
const focusInVideoSpaceX = focusStagePxX - baseVideoX;
const focusInVideoSpaceY = focusStagePxY - baseVideoY;
// After scaling the video by zoomScale, the focus point in video would be at:
// (focusInVideoSpaceX * zoomScale, focusInVideoSpaceY * zoomScale) relative to video's top-left
// We want: videoPosition + focusInVideoSpace * zoomScale = stageCenterX
// So: videoPosition = stageCenterX - focusInVideoSpace * zoomScale
const newVideoX = stageCenterX - focusInVideoSpaceX * zoomScale;
const newVideoY = stageCenterY - focusInVideoSpaceY * zoomScale;
videoSprite.position.set(newVideoX, newVideoY);
if (blurFilterRef.current) {
const shouldBlur = isPlayingRef.current && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilterRef.current.blur = motionBlur;
}
const maskGraphics = maskGraphicsRef.current;
if (maskGraphics) {
const videoWidth = videoSize.width * actualScale;
const videoHeight = videoSize.height * actualScale;
const radius = Math.min(videoWidth, videoHeight) * 0.02;
maskGraphics.clear();
maskGraphics.roundRect(
newVideoX,
newVideoY,
videoWidth,
videoHeight,
radius
);
maskGraphics.fill({ color: 0xffffff });
}
};
const ticker = () => {
const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeRef.current);
// Default is to show the entire stage at center
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = defaultFocus;
if (region && strength > 0) {
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
// The region focus is already in stage space (0-1 normalized coordinates)
// We need to ensure it stays within valid bounds for the given zoom level
const regionFocus = clampFocusToStage(region.focus, region.depth);
// Interpolate scale: from 1 (no zoom) to zoomScale (full zoom)
targetScaleFactor = 1 + (zoomScale - 1) * strength;
// Interpolate focus position: from center to region focus
targetFocus = {
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
};
}
const state = animationStateRef.current;
const prevScale = state.scale;
const prevFocusX = state.focusX;
const prevFocusY = state.focusY;
const scaleDelta = targetScaleFactor - state.scale;
const focusXDelta = targetFocus.cx - state.focusX;
const focusYDelta = targetFocus.cy - state.focusY;
let nextScale = prevScale;
let nextFocusX = prevFocusX;
let nextFocusY = prevFocusY;
if (Math.abs(scaleDelta) > MIN_DELTA) {
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
} else {
nextScale = targetScaleFactor;
}
if (Math.abs(focusXDelta) > MIN_DELTA) {
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
} else {
nextFocusX = targetFocus.cx;
}
if (Math.abs(focusYDelta) > MIN_DELTA) {
nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
} else {
nextFocusY = targetFocus.cy;
}
state.scale = nextScale;
state.focusX = nextFocusX;
state.focusY = nextFocusY;
const motionIntensity = Math.max(
Math.abs(nextScale - prevScale),
Math.abs(nextFocusX - prevFocusX),
Math.abs(nextFocusY - prevFocusY)
);
applyTransform(motionIntensity);
};
app.ticker.add(ticker);
return () => {
app.ticker.remove(ticker);
};
}, [pixiReady, videoReady, stageFocusToVideoSpace, clampFocusToStage]);
// Handle video metadata loaded
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
@@ -221,6 +760,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
onDurationChange(video.duration);
video.currentTime = 0;
video.pause();
currentTimeRef.current = 0;
setVideoReady(true);
};
@@ -231,14 +771,25 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
return (
<div
className="aspect-video rounded-sm p-12 flex items-center justify-center overflow-hidden bg-cover bg-center"
style={{ ...backgroundStyle, width: '90%' }}
className="relative aspect-video rounded-sm overflow-hidden bg-cover bg-center"
style={{ ...backgroundStyle, width: '100%' }}
>
<div
ref={containerRef}
className="w-full h-full"
style={{ position: 'relative' }}
/>
<div ref={containerRef} className="absolute inset-0" />
<div
ref={overlayRef}
className="absolute inset-0 select-none"
style={{ pointerEvents: 'none' }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
onPointerLeave={handleOverlayPointerLeave}
>
<div
ref={focusIndicatorRef}
className="absolute rounded-md border border-sky-400/80 bg-sky-400/20 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]"
style={{ display: 'none', pointerEvents: 'none' }}
/>
</div>
<video
ref={videoRef}
src={videoPath}
@@ -250,9 +801,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
onDurationChange(e.currentTarget.duration);
}}
onError={() => onError('Failed to load video')}
onPlay={() => onPlayStateChange(true)}
onPause={() => onPlayStateChange(false)}
onEnded={() => onPlayStateChange(false)}
/>
</div>
);
+20 -4
View File
@@ -1,14 +1,17 @@
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
interface ItemProps {
id: string;
span: Span;
rowId: string;
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
}
export default function Item({ id, span, rowId, children }: ItemProps) {
export default function Item({ id, span, rowId, children, isSelected = false, onSelect }: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -16,11 +19,24 @@ export default function Item({ id, span, rowId, children }: ItemProps) {
});
return (
<div ref={setNodeRef} style={itemStyle} {...listeners} {...attributes}>
<div
ref={setNodeRef}
style={itemStyle}
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
>
<div style={itemContentStyle}>
<div
className="border border-indigo-400/40 rounded-lg shadow-sm w-full overflow-hidden flex items-center justify-center px-3 bg-indigo-600 hover:bg-indigo-700 transition-all duration-150 cursor-grab active:cursor-grabbing group relative"
<div
className={cn(
"border border-indigo-400/40 rounded-lg shadow-sm w-full overflow-hidden flex items-center justify-center px-3 transition-all duration-150 cursor-grab active:cursor-grabbing group relative",
isSelected ? "bg-indigo-600 ring-2 ring-indigo-300 shadow-xl" : "bg-indigo-500 hover:bg-indigo-600"
)}
style={{ height: 60 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
<span className="text-sm font-semibold text-white truncate relative z-10 drop-shadow-sm">
@@ -7,6 +7,7 @@ import TimelineWrapper from "./TimelineWrapper";
import Row from "./Row";
import Item from "./Item";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion } from "../types";
const ROW_ID = "row-1";
const FALLBACK_RANGE_MS = 1000;
@@ -16,12 +17,11 @@ interface TimelineEditorProps {
videoDuration: number;
currentTime: number;
onSeek?: (time: number) => void;
}
interface TimelineItem {
id: string;
rowId: string;
span: Span;
zoomRegions: ZoomRegion[];
onZoomAdded: (span: Span) => void;
onZoomSpanChange: (id: string, span: Span) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
}
interface TimelineScaleConfig {
@@ -32,6 +32,13 @@ interface TimelineScaleConfig {
minVisibleRangeMs: number;
}
interface TimelineRenderItem {
id: string;
rowId: string;
span: Span;
label: string;
}
const SCALE_CANDIDATES = [
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
@@ -255,18 +262,23 @@ function Timeline({
intervalMs,
currentTimeMs,
onSeek,
onSelectZoom,
selectedZoomId,
}: {
items: TimelineItem[];
items: TimelineRenderItem[];
videoDurationMs: number;
intervalMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onSelectZoom?: (id: string | null) => void;
selectedZoomId: string | null;
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
onSelectZoom?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -277,7 +289,7 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
}, [onSeek, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
}, [onSeek, onSelectZoom, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
return (
<div
@@ -295,8 +307,10 @@ function Timeline({
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
>
{`Zoom ${item.id.replace("item-", "")}`}
{item.label}
</Item>
))}
</Row>
@@ -304,10 +318,16 @@ function Timeline({
);
}
export default function TimelineEditor({ videoDuration, currentTime, onSeek }: TimelineEditorProps) {
const [items, setItems] = useState<TimelineItem[]>([]);
const [itemCounter, setItemCounter] = useState(1);
export default function TimelineEditor({
videoDuration,
currentTime,
onSeek,
zoomRegions,
onZoomAdded,
onZoomSpanChange,
selectedZoomId,
onSelectZoom,
}: TimelineEditorProps) {
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
@@ -319,59 +339,38 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
useEffect(() => {
const initialRange = createInitialRange(totalMs);
setRange(initialRange);
setRange(createInitialRange(totalMs));
}, [totalMs]);
useEffect(() => {
if (totalMs === 0) {
setItems([]);
setItemCounter(1);
if (totalMs === 0 || safeMinDurationMs <= 0) {
return;
}
setItems((prev) => {
if (safeMinDurationMs <= 0) {
return prev;
zoomRegions.forEach((region) => {
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
const minEnd = clampedStart + safeMinDurationMs;
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
}
let mutated = false;
const updated = prev
.map((item) => {
const clampedStart = Math.max(0, Math.min(item.span.start, totalMs));
const clampedEnd = Math.min(
totalMs,
Math.max(clampedStart + safeMinDurationMs, Math.min(item.span.end, totalMs)),
);
if (clampedStart !== item.span.start || clampedEnd !== item.span.end) {
mutated = true;
return {
...item,
span: {
start: Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)),
end: Math.max(0, clampedEnd),
},
};
}
return item;
})
.filter((item) => item.span.end > item.span.start);
return mutated ? updated : prev;
});
}, [safeMinDurationMs, totalMs]);
}, [zoomRegions, totalMs, safeMinDurationMs, onZoomSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
return items.some(item => {
if (item.id === excludeId) return false;
return !(newSpan.end <= item.span.start || newSpan.start >= item.span.end);
return zoomRegions.some((region) => {
if (region.id === excludeId) return false;
return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs);
});
}, [items]);
}, [zoomRegions]);
const addItem = useCallback(() => {
if (!videoDuration || videoDuration === 0) return;
const handleAddZoom = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
return;
}
const defaultDuration = Math.min(
Math.max(timelineScale.defaultItemDurationMs, safeMinDurationMs),
@@ -383,13 +382,13 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T
}
let startPos = 0;
const sortedItems = [...items].sort((a, b) => a.span.start - b.span.start);
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
for (const item of sortedItems) {
if (startPos + defaultDuration <= item.span.start) {
for (const region of sorted) {
if (startPos + defaultDuration <= region.startMs) {
break;
}
startPos = Math.max(startPos, item.span.end);
startPos = Math.max(startPos, region.endMs);
}
if (startPos + defaultDuration > totalMs) {
@@ -399,27 +398,31 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T
return;
}
const newItem: TimelineItem = {
id: `item-${itemCounter}`,
rowId: ROW_ID,
span: { start: startPos, end: startPos + defaultDuration },
};
setItems((prev) => [...prev, newItem]);
setItemCounter((c) => c + 1);
}, [itemCounter, items, safeMinDurationMs, timelineScale.defaultItemDurationMs, totalMs, videoDuration]);
onZoomAdded({ start: startPos, end: startPos + defaultDuration });
}, [videoDuration, totalMs, timelineScale.defaultItemDurationMs, safeMinDurationMs, zoomRegions, onZoomAdded]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
return range;
}
return {
start: Math.max(0, Math.min(range.start, totalMs)),
end: Math.min(range.end, totalMs),
};
}, [range, totalMs]);
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
return [...zoomRegions]
.sort((a, b) => a.startMs - b.startMs)
.map((region, index) => ({
id: region.id,
rowId: ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
}));
}, [zoomRegions]);
if (!videoDuration || videoDuration === 0) {
return (
<div className="flex-1 flex items-center justify-center bg-gray-50 border border-gray-300 rounded-lg">
@@ -431,7 +434,7 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T
return (
<div className="flex-1 flex flex-col bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-100 bg-gradient-to-b from-white to-slate-50/50">
<Button onClick={addItem} variant="outline" size="sm" className="gap-2 h-8 px-3 text-xs">
<Button onClick={handleAddZoom} variant="outline" size="sm" className="gap-2 h-8 px-3 text-xs">
<Plus className="w-3.5 h-3.5" />
Add Zoom
</Button>
@@ -449,22 +452,24 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T
</div>
</div>
<div className="flex-1 overflow-x-auto overflow-y-hidden">
<TimelineWrapper
setItems={setItems}
range={clampedRange}
<TimelineWrapper
range={clampedRange}
videoDuration={videoDuration}
hasOverlap={hasOverlap}
onRangeChange={setRange}
minItemDurationMs={timelineScale.minItemDurationMs}
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
gridSizeMs={timelineScale.gridMs}
onItemSpanChange={onZoomSpanChange}
>
<Timeline
items={items}
items={timelineItems}
videoDurationMs={totalMs}
intervalMs={timelineScale.intervalMs}
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onSelectZoom={onSelectZoom}
selectedZoomId={selectedZoomId}
/>
</TimelineWrapper>
</div>
@@ -3,15 +3,8 @@ import type { Dispatch, ReactNode, SetStateAction } from "react";
import { TimelineContext } from "dnd-timeline";
import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline";
interface TimelineItem {
id: string;
rowId: string;
span: Span;
}
interface TimelineWrapperProps {
children: ReactNode;
setItems: Dispatch<SetStateAction<TimelineItem[]>>;
range: Range;
videoDuration: number;
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
@@ -19,11 +12,11 @@ interface TimelineWrapperProps {
minItemDurationMs: number;
minVisibleRangeMs: number;
gridSizeMs: number;
onItemSpanChange: (id: string, span: Span) => void;
}
export default function TimelineWrapper({
children,
setItems,
range,
videoDuration,
hasOverlap,
@@ -31,6 +24,7 @@ export default function TimelineWrapper({
minItemDurationMs,
minVisibleRangeMs,
gridSizeMs,
onItemSpanChange,
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
@@ -105,14 +99,10 @@ export default function TimelineWrapper({
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
setItems((prev) =>
prev.map((item) =>
item.id === activeItemId ? { ...item, span: clampedSpan } : item
)
);
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, hasOverlap, minItemDurationMs, setItems, totalMs]
[clampSpanToBounds, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
);
const onDragEnd = useCallback(
@@ -128,15 +118,9 @@ export default function TimelineWrapper({
return;
}
setItems((prev) =>
prev.map((item) =>
item.id === activeItemId
? { ...item, rowId: activeRowId, span: clampedSpan }
: item
)
);
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, hasOverlap, setItems]
[clampSpanToBounds, hasOverlap, onItemSpanChange]
);
const handleRangeChange = useCallback(
+34
View File
@@ -0,0 +1,34 @@
export type ZoomDepth = 1 | 2 | 3;
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
}
export interface ZoomRegion {
id: string;
startMs: number;
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
}
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
1: 1.25,
2: 1.6,
3: 2.2,
};
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 2;
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
return {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
}
function clamp(value: number, min: number, max: number) {
if (Number.isNaN(value)) return (min + max) / 2;
return Math.min(max, Math.max(min, value));
}