Files
openscreen/src/components/video-editor/VideoPlayback.tsx
T
2025-11-25 21:18:57 -07:00

782 lines
25 KiB
TypeScript

import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
import { getAssetPath } from "@/lib/assetPath";
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
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;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
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;
showShadow?: boolean;
shadowIntensity?: number;
showBlur?: boolean;
cropRegion?: import('./types').CropRegion;
}
export interface VideoPlaybackRef {
video: HTMLVideoElement | null;
app: Application | null;
videoSprite: Sprite | null;
videoContainer: Container | null;
play: () => Promise<void>;
pause: () => void;
}
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoPath,
onDurationChange,
onTimeUpdate,
onPlayStateChange,
onError,
wallpaper,
zoomRegions,
selectedZoomId,
onSelectZoom,
onZoomFocusChange,
isPlaying,
showShadow,
shadowIntensity = 0,
showBlur,
cropRegion,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoContainerRef = useRef<Container | null>(null);
const cameraContainerRef = useRef<Container | null>(null);
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<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 baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 });
const maskGraphicsRef = useRef<Graphics | null>(null);
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);
}, []);
const updateOverlayForRegion = useCallback((region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
const overlayEl = overlayRef.current;
const indicatorEl = focusIndicatorRef.current;
if (!overlayEl || !indicatorEl) {
return;
}
// Update stage size from overlay dimensions
const stageWidth = overlayEl.clientWidth;
const stageHeight = overlayEl.clientHeight;
if (stageWidth && stageHeight) {
stageSizeRef.current = { width: stageWidth, height: stageHeight };
}
updateOverlayIndicator({
overlayEl,
indicatorEl,
region,
focusOverride,
videoSize: videoSizeRef.current,
baseScale: baseScaleRef.current,
isPlaying: isPlayingRef.current,
});
}, []);
const layoutVideoContent = useCallback(() => {
const container = containerRef.current;
const app = appRef.current;
const videoSprite = videoSpriteRef.current;
const maskGraphics = maskGraphicsRef.current;
const videoElement = videoRef.current;
const cameraContainer = cameraContainerRef.current;
if (!container || !app || !videoSprite || !maskGraphics || !videoElement || !cameraContainer) {
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,
videoSprite,
maskGraphics,
videoElement,
cropRegion,
lockedVideoDimensions: lockedVideoDimensionsRef.current,
});
if (result) {
stageSizeRef.current = result.stageSize;
videoSizeRef.current = result.videoSize;
baseScaleRef.current = result.baseScale;
baseOffsetRef.current = result.baseOffset;
baseMaskRef.current = result.maskRect;
cropBoundsRef.current = result.cropBounds;
// Reset camera container to identity
cameraContainer.scale.set(1);
cameraContainer.position.set(0, 0);
const selectedId = selectedZoomIdRef.current;
const activeRegion = selectedId
? zoomRegionsRef.current.find((region) => region.id === selectedId) ?? null
: null;
updateOverlayForRegion(activeRegion);
}
}, [updateOverlayForRegion, cropRegion]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
}, [zoomRegions, selectedZoomId]);
useImperativeHandle(ref, () => ({
video: videoRef.current,
app: appRef.current,
videoSprite: videoSpriteRef.current,
videoContainer: videoContainerRef.current,
play: async () => {
const video = videoRef.current;
if (!video) {
allowPlaybackRef.current = false;
return;
}
allowPlaybackRef.current = true;
try {
await video.play();
} catch (error) {
allowPlaybackRef.current = false;
throw error;
}
},
pause: () => {
const video = videoRef.current;
allowPlaybackRef.current = false;
if (!video) {
return;
}
video.pause();
},
}));
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 {
}
};
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;
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();
}
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
};
if (blurFilterRef.current) {
blurFilterRef.current.blur = 0;
}
requestAnimationFrame(() => {
const container = cameraContainerRef.current;
const videoStage = videoContainerRef.current;
const sprite = videoSpriteRef.current;
const currentApp = appRef.current;
if (!container || !videoStage || !sprite || !currentApp) {
return;
}
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);
layoutVideoContent();
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,
});
requestAnimationFrame(() => {
const finalApp = appRef.current;
if (wasPlaying && video) {
video.play().catch(() => {
});
}
if (tickerWasStarted && finalApp?.ticker) {
finalApp.ticker.start();
}
});
});
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
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]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let mounted = true;
let app: Application | null = null;
(async () => {
app = new Application();
await app.init({
width: container.clientWidth,
height: container.clientHeight,
backgroundAlpha: 0,
antialias: true,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
});
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, { children: true, texture: true, textureSource: true });
return;
}
appRef.current = app;
container.appendChild(app.canvas);
// Camera container - this will be scaled/positioned for zoom
const cameraContainer = new Container();
cameraContainerRef.current = cameraContainer;
app.stage.addChild(cameraContainer);
// Video container - holds the masked video sprite
const videoContainer = new Container();
videoContainerRef.current = videoContainer;
cameraContainer.addChild(videoContainer);
setPixiReady(true);
})();
return () => {
mounted = false;
setPixiReady(false);
if (app && app.renderer) {
app.destroy(true, { children: true, texture: true, textureSource: true });
}
appRef.current = null;
cameraContainerRef.current = null;
videoContainerRef.current = null;
videoSpriteRef.current = null;
};
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
video.currentTime = 0;
allowPlaybackRef.current = false;
}, [videoPath]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
const video = videoRef.current;
const app = appRef.current;
const videoContainer = videoContainerRef.current;
if (!video || !app || !videoContainer) return;
if (video.videoWidth === 0 || video.videoHeight === 0) return;
const source = VideoSource.from(video);
if ('autoPlay' in source) {
(source as { autoPlay?: boolean }).autoPlay = false;
}
if ('autoUpdate' in source) {
(source as { autoUpdate?: boolean }).autoUpdate = true;
}
const videoTexture = Texture.from(source);
const videoSprite = new Sprite(videoTexture);
videoSpriteRef.current = videoSprite;
const maskGraphics = new Graphics();
videoContainer.addChild(videoSprite);
videoContainer.addChild(maskGraphics);
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
animationStateRef.current = {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
};
const blurFilter = new BlurFilter();
blurFilter.quality = 3;
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
videoContainer.filters = [blurFilter];
blurFilterRef.current = blurFilter;
layoutVideoContent();
video.pause();
const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({
video,
isSeekingRef,
isPlayingRef,
allowPlaybackRef,
currentTimeRef,
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
});
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handlePause);
video.addEventListener('seeked', handleSeeked);
video.addEventListener('seeking', handleSeeking);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handlePause);
video.removeEventListener('seeked', handleSeeked);
video.removeEventListener('seeking', handleSeeking);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
if (videoSprite) {
videoContainer.removeChild(videoSprite);
videoSprite.destroy();
}
if (maskGraphics) {
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, 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 cameraContainer = cameraContainerRef.current;
if (!cameraContainer) return;
const state = animationStateRef.current;
applyZoomTransform({
cameraContainer,
blurFilter: blurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: state.scale,
focusX: state.focusX,
focusY: state.focusY,
motionIntensity,
isPlaying: isPlayingRef.current,
});
};
const ticker = () => {
const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeRef.current);
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = defaultFocus;
// If a zoom is selected but video is not playing, show default unzoomed view
// (the overlay will show where the zoom will be)
const selectedId = selectedZoomIdRef.current;
const hasSelectedZoom = selectedId !== null;
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
if (region && strength > 0 && !shouldShowUnzoomedView) {
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = clampFocusToStage(region.focus, region.depth);
// Interpolate scale and focus based on region strength
targetScaleFactor = 1 + (zoomScale - 1) * strength;
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 () => {
if (app && app.ticker) {
app.ticker.remove(ticker);
}
};
}, [pixiReady, videoReady, clampFocusToStage]);
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
onDurationChange(video.duration);
video.currentTime = 0;
video.pause();
allowPlaybackRef.current = false;
currentTimeRef.current = 0;
// hacky fix: To ensure video is fully ready for PixiJS
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setVideoReady(true);
});
});
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
useEffect(() => {
let mounted = true
;(async () => {
try {
if (!wallpaper) {
const def = await getAssetPath('wallpapers/wallpaper1.jpg')
if (mounted) setResolvedWallpaper(def)
return
}
if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
if (mounted) setResolvedWallpaper(wallpaper)
return
}
// If it's a data URL (custom uploaded image), use as-is
if (wallpaper.startsWith('data:')) {
if (mounted) setResolvedWallpaper(wallpaper)
return
}
// If it's an absolute web/http or file path, use as-is
if (wallpaper.startsWith('http') || wallpaper.startsWith('file://') || wallpaper.startsWith('/')) {
// If it's an absolute server path (starts with '/'), resolve via getAssetPath as well
if (wallpaper.startsWith('/')) {
const rel = wallpaper.replace(/^\//, '')
const p = await getAssetPath(rel)
if (mounted) setResolvedWallpaper(p)
return
}
if (mounted) setResolvedWallpaper(wallpaper)
return
}
const p = await getAssetPath(wallpaper.replace(/^\//, ''))
if (mounted) setResolvedWallpaper(p)
} catch (err) {
if (mounted) setResolvedWallpaper(wallpaper || '/wallpapers/wallpaper1.jpg')
}
})()
return () => { mounted = false }
}, [wallpaper])
const isImageUrl = Boolean(resolvedWallpaper && (resolvedWallpaper.startsWith('file://') || resolvedWallpaper.startsWith('http') || resolvedWallpaper.startsWith('/') || resolvedWallpaper.startsWith('data:')))
const backgroundStyle = isImageUrl
? { backgroundImage: `url(${resolvedWallpaper || ''})` }
: { background: resolvedWallpaper || '' };
return (
<div className="relative aspect-video rounded-sm overflow-hidden" style={{ width: '100%' }}>
{/* Background layer - always render as DOM element with blur */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
...backgroundStyle,
filter: showBlur ? 'blur(2px)' : 'none',
}}
/>
<div
ref={containerRef}
className="absolute inset-0"
style={{
filter: (showShadow && shadowIntensity > 0)
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
: 'none',
}}
/>
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<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-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
style={{ display: 'none', pointerEvents: 'none' }}
/>
</div>
)}
<video
ref={videoRef}
src={videoPath}
className="hidden"
preload="metadata"
playsInline
onLoadedMetadata={handleLoadedMetadata}
onDurationChange={e => {
onDurationChange(e.currentTarget.duration);
}}
onError={() => onError('Failed to load video')}
/>
</div>
);
});
VideoPlayback.displayName = 'VideoPlayback';
export default VideoPlayback;