diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index e0a1d584..cbf9b54f 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -42,9 +42,14 @@ import { } from "./types"; import { DEFAULT_FOCUS, - ZOOM_SCALE_DEADZONE, - ZOOM_TRANSLATION_DEADZONE_PX, } from "./videoPlayback/constants"; +import { + type SpringState, + createSpringState, + resetSpringState, + stepSpringValue, + getZoomSpringConfig, +} from "./videoPlayback/motionSmoothing"; import { DEFAULT_CURSOR_CONFIG, PixiCursorOverlay, @@ -64,6 +69,13 @@ import { } from "./captionStyle"; import { clamp01 } from "./videoPlayback/mathUtils"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { + type CursorFollowCameraState, + createCursorFollowCameraState, + resetCursorFollowCamera, + computeCursorFollowFocus, + SNAP_TO_EDGES_RATIO_AUTO, +} from "./videoPlayback/cursorFollowCamera"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; @@ -199,6 +211,7 @@ interface VideoPlaybackProps { cursorStyle?: CursorStyle; cursorSize?: number; cursorSmoothing?: number; + zoomSmoothness?: number; cursorMotionBlur?: number; cursorClickBounce?: number; cursorClickBounceDuration?: number; @@ -266,6 +279,7 @@ const VideoPlayback = forwardRef( cursorStyle = "tahoe", cursorSize = DEFAULT_CURSOR_SIZE, cursorSmoothing = DEFAULT_CURSOR_SMOOTHING, + zoomSmoothness = 1.0, cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR, cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE, cursorClickBounceDuration = DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, @@ -351,6 +365,14 @@ const VideoPlayback = forwardRef( const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration); const cursorSwayRef = useRef(cursorSway); + // Spring animation state for smooth zoom transitions + const springScaleRef = useRef(createSpringState(1)); + const springXRef = useRef(createSpringState(0)); + const springYRef = useRef(createSpringState(0)); + const lastTickTimeRef = useRef(null); + const zoomSmoothnessRef = useRef(zoomSmoothness); + const cursorFollowCameraRef = useRef(createCursorFollowCameraState()); + const activeCaptionLayout = useMemo(() => { if (!autoCaptionSettings?.enabled || autoCaptions.length === 0 || typeof document === "undefined") { return null; @@ -753,6 +775,14 @@ const VideoPlayback = forwardRef( useEffect(() => { isPlayingRef.current = isPlaying; + // Snap springs to current position when pausing so scrubbing is instant + if (!isPlaying) { + resetSpringState(springScaleRef.current); + resetSpringState(springXRef.current); + resetSpringState(springYRef.current); + resetCursorFollowCamera(cursorFollowCameraRef.current); + lastTickTimeRef.current = null; + } const bgVideo = bgVideoRef.current; if (bgVideo) { if (isPlaying) { @@ -840,6 +870,10 @@ const VideoPlayback = forwardRef( cursorSmoothingRef.current = cursorSmoothing; }, [cursorSmoothing]); + useEffect(() => { + zoomSmoothnessRef.current = zoomSmoothness; + }, [zoomSmoothness]); + useEffect(() => { cursorMotionBlurRef.current = cursorMotionBlur; }, [cursorMotionBlur]); @@ -1310,7 +1344,20 @@ const VideoPlayback = forwardRef( if (region && strength > 0 && !shouldShowUnzoomedView) { const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = region.focus; + + // Cursor follow: use cursor-follow camera for non-manual zoom regions + let regionFocus = region.focus; + if (region.mode !== 'manual' && cursorTelemetryRef.current.length > 0) { + regionFocus = computeCursorFollowFocus( + cursorFollowCameraRef.current, + cursorTelemetryRef.current, + currentTimeRef.current, + zoomScale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } targetScaleFactor = zoomScale; targetFocus = regionFocus; @@ -1378,18 +1425,33 @@ const VideoPlayback = forwardRef( focusY: state.focusY, }); - const appliedScale = - Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE - ? projectedTransform.scale - : projectedTransform.scale; - const appliedX = - Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.x - : projectedTransform.x; - const appliedY = - Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX - ? projectedTransform.y - : projectedTransform.y; + // Spring-driven zoom animation + const now = performance.now(); + const deltaMs = lastTickTimeRef.current !== null + ? now - lastTickTimeRef.current + : 1000 / 60; + lastTickTimeRef.current = now; + + const zoomSpringConfig = getZoomSpringConfig(zoomSmoothnessRef.current); + const useSpring = isPlayingRef.current && !isSeekingRef.current; + + let appliedScale: number; + let appliedX: number; + let appliedY: number; + + if (useSpring) { + appliedScale = stepSpringValue(springScaleRef.current, projectedTransform.scale, deltaMs, zoomSpringConfig); + appliedX = stepSpringValue(springXRef.current, projectedTransform.x, deltaMs, zoomSpringConfig); + appliedY = stepSpringValue(springYRef.current, projectedTransform.y, deltaMs, zoomSpringConfig); + } else { + // Snap instantly when paused or seeking + appliedScale = projectedTransform.scale; + appliedX = projectedTransform.x; + appliedY = projectedTransform.y; + resetSpringState(springScaleRef.current, appliedScale); + resetSpringState(springXRef.current, appliedX); + resetSpringState(springYRef.current, appliedY); + } const motionIntensity = Math.max( Math.abs(appliedScale - prevScale),