refactoring

This commit is contained in:
Siddharth
2025-11-08 22:19:56 -07:00
parent 61137c3233
commit 4cc1ae7a56
11 changed files with 469 additions and 337 deletions
+72 -335
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
setPixiReady(true);
})();
// Cleanup
return () => {
mounted = false;
setPixiReady(false);
@@ -463,7 +316,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
};
}, []);
// Ensure video starts paused whenever the source changes
useEffect(() => {
const video = videoRef.current;
if (!video) return;
@@ -471,7 +323,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
// Clean up PixiJS resources
if (videoSprite) {
videoContainer.removeChild(videoSprite);
videoSprite.destroy();
@@ -619,101 +418,40 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
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<VideoPlaybackRef, VideoPlaybackProps>(({
return () => {
app.ticker.remove(ticker);
};
}, [pixiReady, videoReady, stageFocusToVideoSpace, clampFocusToStage]);
}, [pixiReady, videoReady, clampFocusToStage]);
// Handle video metadata loaded
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
onDurationChange(video.duration);
+2 -2
View File
@@ -15,8 +15,8 @@ export interface ZoomRegion {
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
1: 1.25,
2: 1.6,
3: 2.2,
2: 1.5,
3: 2.5,
};
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 2;
@@ -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;
@@ -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,
};
}
@@ -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';
@@ -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 },
};
}
@@ -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);
}
@@ -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';
}
@@ -0,0 +1,82 @@
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
isPlayingRef: React.MutableRefObject<boolean>;
currentTimeRef: React.MutableRefObject<number>;
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
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,
};
}
@@ -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 };
}
@@ -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 });
}