diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index dcd41d3..42976ef 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -80,6 +80,8 @@ const VideoPlayback = forwardRef(({ const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); const allowPlaybackRef = useRef(false); + const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null); + const layoutVideoContentRef = useRef<(() => void) | null>(null); const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { return clampFocusToStageUtil(focus, depth, stageSizeRef.current); @@ -123,6 +125,14 @@ const VideoPlayback = forwardRef(({ return; } + // Lock video dimensions on first layout to prevent resize issues + if (!lockedVideoDimensionsRef.current && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + lockedVideoDimensionsRef.current = { + width: videoElement.videoWidth, + height: videoElement.videoHeight, + }; + } + const result = layoutVideoContentUtil({ container, app, @@ -130,6 +140,7 @@ const VideoPlayback = forwardRef(({ maskGraphics, videoElement, cropRegion, + lockedVideoDimensions: lockedVideoDimensionsRef.current, }); if (result) { @@ -151,7 +162,14 @@ const VideoPlayback = forwardRef(({ updateOverlayForRegion(activeRegion); } - }, [updateOverlayForRegion, cropRegion]); const selectedZoom = useMemo(() => { + }, [updateOverlayForRegion, cropRegion]); + + // Keep layoutVideoContent ref updated + useEffect(() => { + layoutVideoContentRef.current = layoutVideoContent; + }, [layoutVideoContent]); + + const selectedZoom = useMemo(() => { if (!selectedZoomId) return null; return zoomRegions.find((region) => region.id === selectedZoomId) ?? null; }, [zoomRegions, selectedZoomId]); @@ -267,9 +285,84 @@ const VideoPlayback = forwardRef(({ isPlayingRef.current = isPlaying; }, [isPlaying]); + // Reset animation state and transforms when crop changes useEffect(() => { if (!pixiReady || !videoReady) return; - layoutVideoContent(); + + const app = appRef.current; + const cameraContainer = cameraContainerRef.current; + const video = videoRef.current; + + if (!app || !cameraContainer || !video) return; + + const tickerWasStarted = app.ticker?.started || false; + if (tickerWasStarted && app.ticker) { + app.ticker.stop(); + } + + const wasPlaying = !video.paused; + if (wasPlaying) { + video.pause(); + } + + // Reset animation state so the ticker starts from identity once it resumes + animationStateRef.current = { + scale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + }; + + if (blurFilterRef.current) { + blurFilterRef.current.blur = 0; + } + + // Defer layout to the next frame so DOM measurements include the new crop UI state + requestAnimationFrame(() => { + const container = cameraContainerRef.current; + const videoStage = videoContainerRef.current; + const sprite = videoSpriteRef.current; + const currentApp = appRef.current; + if (!container || !videoStage || !sprite || !currentApp) { + return; + } + + // Reset all transform hierarchies to identity + container.scale.set(1); + container.position.set(0, 0); + videoStage.scale.set(1); + videoStage.position.set(0, 0); + sprite.scale.set(1); + sprite.position.set(0, 0); + + // Now layoutVideoContent will apply the correct transforms for the new crop + layoutVideoContent(); + + // Apply an explicit identity transform to ensure no residual camera offset + applyZoomTransform({ + cameraContainer: container, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: 1, + focusX: DEFAULT_FOCUS.cx, + focusY: DEFAULT_FOCUS.cy, + motionIntensity: 0, + isPlaying: false, + }); + + // Restart ticker on a second frame to avoid running mid-layout + requestAnimationFrame(() => { + const finalApp = appRef.current; + if (wasPlaying && video) { + video.play().catch(() => { + /* ignore */ + }); + } + if (tickerWasStarted && finalApp?.ticker) { + finalApp.ticker.start(); + } + }); + }); }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); useEffect(() => { @@ -559,7 +652,9 @@ const VideoPlayback = forwardRef(({ app.ticker.add(ticker); return () => { - app.ticker.remove(ticker); + if (app && app.ticker) { + app.ticker.remove(ticker); + } }; }, [pixiReady, videoReady, clampFocusToStage]); diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index fd69289..6305d2d 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -9,6 +9,7 @@ interface LayoutParams { maskGraphics: PIXI.Graphics; videoElement: HTMLVideoElement; cropRegion?: CropRegion; + lockedVideoDimensions?: { width: number; height: number } | null; } interface LayoutResult { @@ -21,10 +22,11 @@ interface LayoutResult { } export function layoutVideoContent(params: LayoutParams): LayoutResult | null { - const { container, app, videoSprite, maskGraphics, videoElement, cropRegion } = params; + const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions } = params; - const videoWidth = videoElement.videoWidth; - const videoHeight = videoElement.videoHeight; + // Use locked dimensions if available, otherwise use current video dimensions + const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth; + const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight; if (!videoWidth || !videoHeight) { return null; diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index 515c9c0..439e0d2 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -1,3 +1,5 @@ +import type React from 'react'; + interface VideoEventHandlersParams { video: HTMLVideoElement; isSeekingRef: React.MutableRefObject; @@ -46,21 +48,22 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { return; } - allowPlaybackRef.current = false; isPlayingRef.current = true; onPlayStateChange(true); - updateTime(); + if (timeUpdateAnimationRef.current) { + cancelAnimationFrame(timeUpdateAnimationRef.current); + } + timeUpdateAnimationRef.current = requestAnimationFrame(updateTime); }; - const handlePause = () => { - allowPlaybackRef.current = false; + const handlePause = () => { isPlayingRef.current = false; + onPlayStateChange(false); if (timeUpdateAnimationRef.current) { cancelAnimationFrame(timeUpdateAnimationRef.current); timeUpdateAnimationRef.current = null; } emitTime(video.currentTime); - onPlayStateChange(false); }; const handleSeeked = () => {