diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index fbf287e..0277f7c 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,50 +1,15 @@ 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 }; -} +import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth } from "./types"; +import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants"; +import { clamp01 } from "./videoPlayback/mathUtils"; +import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; +import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; +import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; +import { applyZoomTransform } from "./videoPlayback/zoomTransform"; +import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; interface VideoPlaybackProps { videoPath: string; @@ -105,112 +70,34 @@ const VideoPlayback = forwardRef(({ const isSeekingRef = useRef(false); const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - const stageSize = stageSizeRef.current; - - if (!stageSize.width || !stageSize.height) { - return clampFocusToDepth(focus, depth); - } - - const zoomScale = ZOOM_DEPTH_SCALES[depth]; - - // The zoom window dimensions in stage space - const windowWidth = stageSize.width / zoomScale; - const windowHeight = stageSize.height / zoomScale; - - // Calculate margins - the focus point must stay far enough from edges - // so that the zoom window doesn't go out of bounds - const marginX = windowWidth / (2 * stageSize.width); - const marginY = windowHeight / (2 * stageSize.height); - - const baseFocus = clampFocusToDepth(focus, depth); - - // Clamp the focus to ensure the zoom window stays within stage bounds - return { - cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)), - cy: Math.max(marginY, Math.min(1 - marginY, 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, - }; + return clampFocusToStageUtil(focus, depth, stageSizeRef.current); }, []); 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; - } - + // Update stage size from overlay dimensions const stageWidth = overlayEl.clientWidth; const stageHeight = overlayEl.clientHeight; - if (!stageWidth || !stageHeight) { - indicatorEl.style.display = 'none'; - overlayEl.style.pointerEvents = 'none'; - return; + if (stageWidth && stageHeight) { + stageSizeRef.current = { width: stageWidth, height: stageHeight }; } - 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); - - // The zoom window should show what portion of the STAGE will be visible after zooming - // When we zoom by zoomScale, we're showing 1/zoomScale of the stage dimensions - const indicatorWidth = stageWidth / zoomScale; - const indicatorHeight = stageHeight / zoomScale; - - 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]); + updateOverlayIndicator({ + overlayEl, + indicatorEl, + region, + focusOverride, + videoSize: videoSizeRef.current, + baseScale: baseScaleRef.current, + isPlaying: isPlayingRef.current, + }); + }, []); const layoutVideoContent = useCallback(() => { const container = containerRef.current; @@ -219,63 +106,31 @@ const VideoPlayback = forwardRef(({ const maskGraphics = maskGraphicsRef.current; const videoElement = videoRef.current; - if (!container || !app || !videoSprite || !videoElement) { + if (!container || !app || !videoSprite || !maskGraphics || !videoElement) { return; } - const videoWidth = videoElement.videoWidth; - const videoHeight = videoElement.videoHeight; + const result = layoutVideoContentUtil({ + container, + app, + videoSprite, + maskGraphics, + videoElement, + }); - if (!videoWidth || !videoHeight) { - return; + if (result) { + stageSizeRef.current = result.stageSize; + videoSizeRef.current = result.videoSize; + baseScaleRef.current = result.baseScale; + baseOffsetRef.current = result.baseOffset; + + const selectedId = selectedZoomIdRef.current; + const activeRegion = selectedId + ? zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null + : null; + + updateOverlayForRegion(activeRegion); } - - 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(() => { @@ -442,7 +297,6 @@ const VideoPlayback = forwardRef(({ appRef.current = app; container.appendChild(app.canvas); - // Create a container for the video (this will hold animations later) const videoContainer = new PIXI.Container(); videoContainerRef.current = videoContainer; app.stage.addChild(videoContainer); @@ -450,7 +304,6 @@ const VideoPlayback = forwardRef(({ setPixiReady(true); })(); - // Cleanup return () => { mounted = false; setPixiReady(false); @@ -463,7 +316,6 @@ const VideoPlayback = forwardRef(({ }; }, []); - // Ensure video starts paused whenever the source changes useEffect(() => { const video = videoRef.current; if (!video) return; @@ -471,7 +323,6 @@ const VideoPlayback = forwardRef(({ video.currentTime = 0; }, [videoPath]); - // Setup video sprite when both PixiJS and video are ready useEffect(() => { if (!pixiReady || !videoReady) return; @@ -482,15 +333,12 @@ const VideoPlayback = forwardRef(({ if (!video || !app || !videoContainer) return; if (video.videoWidth === 0 || video.videoHeight === 0) return; - // Create texture from video element const source = PIXI.VideoSource.from(video); const videoTexture = PIXI.Texture.from(source); - // Create sprite with the video texture const videoSprite = new PIXI.Sprite(videoTexture); videoSpriteRef.current = videoSprite; - // Create rounded rectangle mask const maskGraphics = new PIXI.Graphics(); videoContainer.addChild(videoSprite); videoContainer.addChild(maskGraphics); @@ -511,65 +359,17 @@ const VideoPlayback = forwardRef(({ blurFilterRef.current = blurFilter; layoutVideoContent(); - - // Ensure Pixi does not trigger autoplay video.pause(); - const emitTime = (timeValue: number) => { - currentTimeRef.current = timeValue * 1000; - onTimeUpdate(timeValue); - }; - - function updateTime() { - if (!video) return; - emitTime(video.currentTime); - if (!video.paused && !video.ended) { - timeUpdateAnimationRef.current = requestAnimationFrame(updateTime); - } - } - - const handlePlay = () => { - // If we're seeking and the video auto-plays, pause it immediately - if (isSeekingRef.current) { - video.pause(); - return; - } - isPlayingRef.current = true; - onPlayStateChange(true); - updateTime(); - }; - - const handlePause = () => { - isPlayingRef.current = false; - if (timeUpdateAnimationRef.current) { - cancelAnimationFrame(timeUpdateAnimationRef.current); - timeUpdateAnimationRef.current = null; - } - emitTime(video.currentTime); - onPlayStateChange(false); - }; - - const handleSeeked = () => { - // Mark seeking as complete - isSeekingRef.current = false; - - // Ensure video stays paused if it wasn't playing before the seek - if (!isPlayingRef.current && !video.paused) { - video.pause(); - } - emitTime(video.currentTime); - }; - - const handleSeeking = () => { - // Mark that we're in the middle of a seek operation - isSeekingRef.current = true; - - // Prevent autoplay during seeking if video was paused - if (!isPlayingRef.current && !video.paused) { - video.pause(); - } - emitTime(video.currentTime); - }; + const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({ + video, + isSeekingRef, + isPlayingRef, + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + }); video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); @@ -588,7 +388,6 @@ const VideoPlayback = forwardRef(({ cancelAnimationFrame(timeUpdateAnimationRef.current); } - // Clean up PixiJS resources if (videoSprite) { videoContainer.removeChild(videoSprite); videoSprite.destroy(); @@ -619,101 +418,40 @@ const VideoPlayback = forwardRef(({ 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 maskGraphics = maskGraphicsRef.current; + if (!maskGraphics) return; + 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 }); - } + applyZoomTransform({ + videoSprite, + maskGraphics, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + videoSize: videoSizeRef.current, + baseScale: baseScaleRef.current, + baseOffset: baseOffsetRef.current, + zoomScale: state.scale, + focusX: state.focusX, + focusY: state.focusY, + motionIntensity, + isPlaying: isPlayingRef.current, + }); }; 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) + // Interpolate scale and focus based on region strength 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, @@ -769,9 +507,8 @@ const VideoPlayback = forwardRef(({ return () => { app.ticker.remove(ticker); }; - }, [pixiReady, videoReady, stageFocusToVideoSpace, clampFocusToStage]); + }, [pixiReady, videoReady, clampFocusToStage]); - // Handle video metadata loaded const handleLoadedMetadata = (e: React.SyntheticEvent) => { const video = e.currentTarget; onDurationChange(video.duration); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 653eb1d..fd9e897 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -15,8 +15,8 @@ export interface ZoomRegion { export const ZOOM_DEPTH_SCALES: Record = { 1: 1.25, - 2: 1.6, - 3: 2.2, + 2: 1.5, + 3: 2.5, }; export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 2; diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts new file mode 100644 index 0000000..e0dbf6b --- /dev/null +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -0,0 +1,7 @@ +import type { ZoomFocus } from "../types"; + +export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 }; +export const TRANSITION_WINDOW_MS = 320; +export const SMOOTHING_FACTOR = 0.12; +export const MIN_DELTA = 0.0001; +export const VIEWPORT_SCALE = 0.8; diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts new file mode 100644 index 0000000..8228f3e --- /dev/null +++ b/src/components/video-editor/videoPlayback/focusUtils.ts @@ -0,0 +1,55 @@ +import { ZOOM_DEPTH_SCALES, clampFocusToDepth, type ZoomFocus, type ZoomDepth } from "../types"; + +interface StageSize { + width: number; + height: number; +} + +export function clampFocusToStage( + focus: ZoomFocus, + depth: ZoomDepth, + stageSize: StageSize +): ZoomFocus { + if (!stageSize.width || !stageSize.height) { + return clampFocusToDepth(focus, depth); + } + + const zoomScale = ZOOM_DEPTH_SCALES[depth]; + + const windowWidth = stageSize.width / zoomScale; + const windowHeight = stageSize.height / zoomScale; + + // Calculate margins - focus must stay far enough from edges so zoom window stays within stage bounds + const marginX = windowWidth / (2 * stageSize.width); + const marginY = windowHeight / (2 * stageSize.height); + + const baseFocus = clampFocusToDepth(focus, depth); + + return { + cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)), + cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)), + }; +} + +export function stageFocusToVideoSpace( + focus: ZoomFocus, + stageSize: StageSize, + videoSize: { width: number; height: number }, + baseScale: number, + baseOffset: { x: number; y: number } +): ZoomFocus { + 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, + }; +} diff --git a/src/components/video-editor/videoPlayback/index.ts b/src/components/video-editor/videoPlayback/index.ts new file mode 100644 index 0000000..e5be6aa --- /dev/null +++ b/src/components/video-editor/videoPlayback/index.ts @@ -0,0 +1,8 @@ +export * from './constants'; +export * from './mathUtils'; +export * from './zoomRegionUtils'; +export * from './focusUtils'; +export * from './overlayUtils'; +export * from './layoutUtils'; +export * from './zoomTransform'; +export * from './videoEventHandlers'; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts new file mode 100644 index 0000000..0caef3f --- /dev/null +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -0,0 +1,68 @@ +import * as PIXI from 'pixi.js'; +import { VIEWPORT_SCALE } from "./constants"; + +interface LayoutParams { + container: HTMLDivElement; + app: PIXI.Application; + videoSprite: PIXI.Sprite; + maskGraphics: PIXI.Graphics; + videoElement: HTMLVideoElement; +} + +interface LayoutResult { + stageSize: { width: number; height: number }; + videoSize: { width: number; height: number }; + baseScale: number; + baseOffset: { x: number; y: number }; +} + +export function layoutVideoContent(params: LayoutParams): LayoutResult | null { + const { container, app, videoSprite, maskGraphics, videoElement } = params; + + const videoWidth = videoElement.videoWidth; + const videoHeight = videoElement.videoHeight; + + if (!videoWidth || !videoHeight) { + return null; + } + + const width = container.clientWidth; + const height = container.clientHeight; + + if (!width || !height) { + return null; + } + + 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); + + const radius = Math.min(displayWidth, displayHeight) * 0.02; + maskGraphics.clear(); + maskGraphics.roundRect(offsetX, offsetY, displayWidth, displayHeight, radius); + maskGraphics.fill({ color: 0xffffff }); + + return { + stageSize: { width, height }, + videoSize: { width: videoWidth, height: videoHeight }, + baseScale: scale, + baseOffset: { x: offsetX, y: offsetY }, + }; +} diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts new file mode 100644 index 0000000..c629d59 --- /dev/null +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -0,0 +1,8 @@ +export function clamp01(value: number) { + return Math.max(0, Math.min(1, value)); +} + +export function smoothStep(t: number) { + const clamped = clamp01(t); + return clamped * clamped * (3 - 2 * clamped); +} diff --git a/src/components/video-editor/videoPlayback/overlayUtils.ts b/src/components/video-editor/videoPlayback/overlayUtils.ts new file mode 100644 index 0000000..9acdca8 --- /dev/null +++ b/src/components/video-editor/videoPlayback/overlayUtils.ts @@ -0,0 +1,66 @@ +import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus } from "../types"; +import { clampFocusToStage } from "./focusUtils"; + +interface OverlayUpdateParams { + overlayEl: HTMLDivElement; + indicatorEl: HTMLDivElement; + region: ZoomRegion | null; + focusOverride?: ZoomFocus; + videoSize: { width: number; height: number }; + baseScale: number; + isPlaying: boolean; +} + +export function updateOverlayIndicator(params: OverlayUpdateParams) { + const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params; + + 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; + } + + if (!videoSize.width || !videoSize.height || baseScale <= 0) { + indicatorEl.style.display = 'none'; + overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; + return; + } + + const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; + const focus = clampFocusToStage( + focusOverride ?? region.focus, + region.depth, + { width: stageWidth, height: stageHeight } + ); + + // Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions) + const indicatorWidth = stageWidth / zoomScale; + const indicatorHeight = stageHeight / zoomScale; + + 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 = isPlaying ? 'none' : 'auto'; +} diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts new file mode 100644 index 0000000..06e6dec --- /dev/null +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -0,0 +1,82 @@ +interface VideoEventHandlersParams { + video: HTMLVideoElement; + isSeekingRef: React.MutableRefObject; + isPlayingRef: React.MutableRefObject; + currentTimeRef: React.MutableRefObject; + timeUpdateAnimationRef: React.MutableRefObject; + onPlayStateChange: (playing: boolean) => void; + onTimeUpdate: (time: number) => void; +} + +export function createVideoEventHandlers(params: VideoEventHandlersParams) { + const { + video, + isSeekingRef, + isPlayingRef, + currentTimeRef, + timeUpdateAnimationRef, + onPlayStateChange, + onTimeUpdate, + } = params; + + const emitTime = (timeValue: number) => { + currentTimeRef.current = timeValue * 1000; + onTimeUpdate(timeValue); + }; + + function updateTime() { + if (!video) return; + emitTime(video.currentTime); + if (!video.paused && !video.ended) { + timeUpdateAnimationRef.current = requestAnimationFrame(updateTime); + } + } + + const handlePlay = () => { + // Prevent autoplay during seek operations + if (isSeekingRef.current) { + video.pause(); + return; + } + isPlayingRef.current = true; + onPlayStateChange(true); + updateTime(); + }; + + const handlePause = () => { + isPlayingRef.current = false; + if (timeUpdateAnimationRef.current) { + cancelAnimationFrame(timeUpdateAnimationRef.current); + timeUpdateAnimationRef.current = null; + } + emitTime(video.currentTime); + onPlayStateChange(false); + }; + + const handleSeeked = () => { + isSeekingRef.current = false; + + // Keep video paused after seek if it wasn't playing + if (!isPlayingRef.current && !video.paused) { + video.pause(); + } + emitTime(video.currentTime); + }; + + const handleSeeking = () => { + isSeekingRef.current = true; + + // Prevent autoplay during seek if video was paused + if (!isPlayingRef.current && !video.paused) { + video.pause(); + } + emitTime(video.currentTime); + }; + + return { + handlePlay, + handlePause, + handleSeeked, + handleSeeking, + }; +} diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts new file mode 100644 index 0000000..26a3817 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -0,0 +1,31 @@ +import type { ZoomRegion } from "../types"; +import { smoothStep } from "./mathUtils"; +import { TRANSITION_WINDOW_MS } from "./constants"; + +export 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); +} + +export 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 }; +} diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts new file mode 100644 index 0000000..dce5fa3 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -0,0 +1,70 @@ +import * as PIXI from 'pixi.js'; + +interface TransformParams { + videoSprite: PIXI.Sprite; + maskGraphics: PIXI.Graphics; + blurFilter: PIXI.BlurFilter | null; + stageSize: { width: number; height: number }; + videoSize: { width: number; height: number }; + baseScale: number; + baseOffset: { x: number; y: number }; + zoomScale: number; + focusX: number; + focusY: number; + motionIntensity: number; + isPlaying: boolean; +} + +export function applyZoomTransform(params: TransformParams) { + const { + videoSprite, + maskGraphics, + blurFilter, + stageSize, + videoSize, + baseScale, + baseOffset, + zoomScale, + focusX, + focusY, + motionIntensity, + isPlaying, + } = params; + + if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) { + return; + } + + const focusStagePxX = focusX * stageSize.width; + const focusStagePxY = focusY * stageSize.height; + const stageCenterX = stageSize.width / 2; + const stageCenterY = stageSize.height / 2; + + const actualScale = baseScale * zoomScale; + videoSprite.scale.set(actualScale); + + // Keep the focus point centered in viewport after zoom transformation + const baseVideoX = baseOffset.x; + const baseVideoY = baseOffset.y; + const focusInVideoSpaceX = focusStagePxX - baseVideoX; + const focusInVideoSpaceY = focusStagePxY - baseVideoY; + + // Position formula: stageCenterX - focusInVideoSpace * zoomScale + const newVideoX = stageCenterX - focusInVideoSpaceX * zoomScale; + const newVideoY = stageCenterY - focusInVideoSpaceY * zoomScale; + + videoSprite.position.set(newVideoX, newVideoY); + + if (blurFilter) { + const shouldBlur = isPlaying && motionIntensity > 0.0005; + const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0; + blurFilter.blur = motionBlur; + } + + 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 }); +}