diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 24aef1a..8ca7c80 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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(GRADIENTS[0]); + const zoomEnabled = Boolean(selectedZoomDepth); + const scaleLabels = useMemo(() => { + return ZOOM_DEPTH_OPTIONS.reduce>((acc, option) => { + const scale = ZOOM_DEPTH_SCALES[option.depth]; + acc[option.depth] = `${scale.toFixed(2)}×`; + return acc; + }, { 1: "", 2: "", 3: "" }); + }, []); + return (
+
+
+ Zoom Depth + {zoomEnabled && selectedZoomDepth && ( + + Active · {scaleLabels[selectedZoomDepth]} + + )} +
+
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + + ); + })} +
+ {!zoomEnabled && ( +

Select a zoom region in the timeline to adjust its depth.

+ )} +
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4faf4f9..4a65dab 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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(WALLPAPER_PATHS[0]); + const [zoomRegions, setZoomRegions] = useState([]); + const [selectedZoomId, setSelectedZoomId] = useState(null); const videoPlaybackRef = useRef(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 (
@@ -83,6 +172,11 @@ export default function VideoEditor() { onPlayStateChange={setIsPlaying} onError={setError} wallpaper={wallpaper} + zoomRegions={zoomRegions} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + onZoomFocusChange={handleZoomFocusChange} + isPlaying={isPlaying} />
)}
- +
- +
); } \ No newline at end of file diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index d05641d..950fed7 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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(({ onPlayStateChange, onError, wallpaper, + zoomRegions, + selectedZoomId, + onSelectZoom, + onZoomFocusChange, + isPlaying, }, ref) => { const videoRef = useRef(null); const containerRef = useRef(null); @@ -33,6 +88,202 @@ const VideoPlayback = forwardRef(({ const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); const [videoReady, setVideoReady] = useState(false); + const overlayRef = useRef(null); + const focusIndicatorRef = useRef(null); + const currentTimeRef = useRef(0); + const zoomRegionsRef = useRef([]); + const selectedZoomIdRef = useRef(null); + const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy }); + const blurFilterRef = useRef(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(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(({ 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) => { + 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) => { + if (!isDraggingFocusRef.current) return; + event.preventDefault(); + updateFocusFromClientPoint(event.clientX, event.clientY); + }; + + const endFocusDrag = (event: React.PointerEvent) => { + 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) => { + endFocusDrag(event); + }; + + const handleOverlayPointerLeave = (event: React.PointerEvent) => { + 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(({ // 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(({ 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) => { @@ -221,6 +760,7 @@ const VideoPlayback = forwardRef(({ onDurationChange(video.duration); video.currentTime = 0; video.pause(); + currentTimeRef.current = 0; setVideoReady(true); }; @@ -231,14 +771,25 @@ const VideoPlayback = forwardRef(({ return (
-
+
+
+
+
); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index bedd074..0952468 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -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 ( -
+
onSelect?.()} + >
-
{ + event.stopPropagation(); + onSelect?.(); + }} >
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 013d084..62d65ab 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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) => { 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 (
onSelectZoom?.(item.id)} > - {`Zoom ${item.id.replace("item-", "")}`} + {item.label} ))} @@ -304,10 +318,16 @@ function Timeline({ ); } -export default function TimelineEditor({ videoDuration, currentTime, onSeek }: TimelineEditorProps) { - const [items, setItems] = useState([]); - 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(() => 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(() => { 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(() => { + 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 (
@@ -431,7 +434,7 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T return (
- @@ -449,22 +452,24 @@ export default function TimelineEditor({ videoDuration, currentTime, onSeek }: T
-
diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx index 3f780aa..874f5fe 100644 --- a/src/components/video-editor/timeline/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -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>; 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( diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts new file mode 100644 index 0000000..653eb1d --- /dev/null +++ b/src/components/video-editor/types.ts @@ -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 = { + 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)); +}