pan and zoom effects
This commit is contained in:
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user