1828 lines
57 KiB
TypeScript
1828 lines
57 KiB
TypeScript
import {
|
||
Application,
|
||
BlurFilter,
|
||
Container,
|
||
Graphics,
|
||
Sprite,
|
||
Texture,
|
||
VideoSource,
|
||
} from "pixi.js";
|
||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||
import type React from "react";
|
||
import {
|
||
forwardRef,
|
||
useCallback,
|
||
useEffect,
|
||
useImperativeHandle,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
getWebcamLayoutCssBoxShadow,
|
||
type Size,
|
||
type StyledRenderRect,
|
||
type WebcamLayoutPreset,
|
||
type WebcamSizePreset,
|
||
} from "@/lib/compositeLayout";
|
||
import {
|
||
hasNativeCursorRecordingData,
|
||
projectNativeCursorToStage,
|
||
resolveInterpolatedNativeCursorFrame,
|
||
resolveNativeCursorRenderAsset,
|
||
} from "@/lib/cursor/nativeCursor";
|
||
import { classifyWallpaper, DEFAULT_WALLPAPER, resolveImageWallpaperUrl } from "@/lib/wallpaper";
|
||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||
import type { CursorRecordingData } from "@/native/contracts";
|
||
import {
|
||
type AspectRatio,
|
||
formatAspectRatioForCSS,
|
||
getNativeAspectRatioValue,
|
||
} from "@/utils/aspectRatioUtils";
|
||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||
import {
|
||
type AnnotationRegion,
|
||
type BlurData,
|
||
type CursorTelemetryPoint,
|
||
computeRotation3DContainScale,
|
||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||
DEFAULT_CURSOR_MOTION_BLUR,
|
||
DEFAULT_CURSOR_SIZE,
|
||
DEFAULT_CURSOR_SMOOTHING,
|
||
DEFAULT_ROTATION_3D,
|
||
getZoomScale,
|
||
isRotation3DIdentity,
|
||
lerpRotation3D,
|
||
rotation3DPerspective,
|
||
type SpeedRegion,
|
||
type TrimRegion,
|
||
type ZoomFocus,
|
||
type ZoomRegion,
|
||
} from "./types";
|
||
import {
|
||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||
DEFAULT_FOCUS,
|
||
ZOOM_SCALE_DEADZONE,
|
||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||
} from "./videoPlayback/constants";
|
||
import {
|
||
adaptiveSmoothFactor,
|
||
interpolateCursorAt,
|
||
smoothCursorFocus,
|
||
} from "./videoPlayback/cursorFollowUtils";
|
||
import {
|
||
type CursorHighlightConfig,
|
||
clickEmphasisAlpha,
|
||
DEFAULT_CURSOR_HIGHLIGHT,
|
||
drawCursorHighlightGraphics,
|
||
} from "./videoPlayback/cursorHighlight";
|
||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||
import {
|
||
applyZoomTransform,
|
||
computeFocusFromTransform,
|
||
computeZoomTransform,
|
||
createMotionBlurState,
|
||
type MotionBlurState,
|
||
} from "./videoPlayback/zoomTransform";
|
||
|
||
type BlurPreviewCanvasSource = {
|
||
clientHeight?: number;
|
||
clientWidth?: number;
|
||
height: number;
|
||
width: number;
|
||
};
|
||
|
||
interface VideoPlaybackProps {
|
||
videoPath: string;
|
||
webcamVideoPath?: string;
|
||
webcamLayoutPreset: WebcamLayoutPreset;
|
||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||
webcamSizePreset?: WebcamSizePreset;
|
||
webcamPosition?: { cx: number; cy: number } | null;
|
||
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||
onWebcamPositionDragEnd?: () => void;
|
||
onDurationChange: (duration: number) => void;
|
||
onTimeUpdate: (time: number) => void;
|
||
currentTime: number;
|
||
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;
|
||
onZoomFocusDragEnd?: () => void;
|
||
isPlaying: boolean;
|
||
showShadow?: boolean;
|
||
shadowIntensity?: number;
|
||
showBlur?: boolean;
|
||
motionBlurAmount?: number;
|
||
borderRadius?: number;
|
||
padding?: number;
|
||
cropRegion?: import("./types").CropRegion;
|
||
trimRegions?: TrimRegion[];
|
||
speedRegions?: SpeedRegion[];
|
||
aspectRatio: AspectRatio;
|
||
cursorRecordingData?: CursorRecordingData | null;
|
||
annotationRegions?: AnnotationRegion[];
|
||
selectedAnnotationId?: string | null;
|
||
onSelectAnnotation?: (id: string | null) => void;
|
||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||
blurRegions?: AnnotationRegion[];
|
||
selectedBlurId?: string | null;
|
||
onSelectBlur?: (id: string | null) => void;
|
||
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||
onBlurDataCommit?: () => void;
|
||
cursorTelemetry?: CursorTelemetryPoint[];
|
||
cursorHighlight?: CursorHighlightConfig;
|
||
cursorClickTimestamps?: number[];
|
||
showCursor?: boolean;
|
||
cursorSize?: number;
|
||
cursorSmoothing?: number;
|
||
cursorMotionBlur?: number;
|
||
cursorClickBounce?: number;
|
||
}
|
||
|
||
export interface VideoPlaybackRef {
|
||
video: HTMLVideoElement | null;
|
||
app: Application | null;
|
||
videoSprite: Sprite | null;
|
||
videoContainer: Container | null;
|
||
containerRef: React.RefObject<HTMLDivElement>;
|
||
play: () => Promise<void>;
|
||
pause: () => void;
|
||
}
|
||
|
||
function getResolvedVideoDuration(video: HTMLVideoElement): number | null {
|
||
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||
return video.duration;
|
||
}
|
||
|
||
if (video.seekable.length > 0) {
|
||
const lastRangeIndex = video.seekable.length - 1;
|
||
const seekableEnd = video.seekable.end(lastRangeIndex);
|
||
if (Number.isFinite(seekableEnd) && seekableEnd > 0) {
|
||
return seekableEnd;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||
(
|
||
{
|
||
videoPath,
|
||
webcamVideoPath,
|
||
webcamLayoutPreset,
|
||
webcamMaskShape,
|
||
webcamSizePreset,
|
||
webcamPosition,
|
||
onWebcamPositionChange,
|
||
onWebcamPositionDragEnd,
|
||
onDurationChange,
|
||
onTimeUpdate,
|
||
currentTime,
|
||
onPlayStateChange,
|
||
onError,
|
||
wallpaper,
|
||
zoomRegions,
|
||
selectedZoomId,
|
||
onSelectZoom,
|
||
onZoomFocusChange,
|
||
onZoomFocusDragEnd,
|
||
isPlaying,
|
||
showShadow,
|
||
shadowIntensity = 0,
|
||
showBlur,
|
||
motionBlurAmount = 0,
|
||
borderRadius = 0,
|
||
padding = 50,
|
||
cropRegion,
|
||
trimRegions = [],
|
||
speedRegions = [],
|
||
aspectRatio,
|
||
cursorRecordingData,
|
||
annotationRegions = [],
|
||
selectedAnnotationId,
|
||
onSelectAnnotation,
|
||
onAnnotationPositionChange,
|
||
onAnnotationSizeChange,
|
||
blurRegions = [],
|
||
selectedBlurId,
|
||
onSelectBlur,
|
||
onBlurPositionChange,
|
||
onBlurSizeChange,
|
||
onBlurDataChange,
|
||
onBlurDataCommit,
|
||
cursorTelemetry = [],
|
||
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
|
||
cursorClickTimestamps = [],
|
||
showCursor = false,
|
||
cursorSize = DEFAULT_CURSOR_SIZE,
|
||
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
|
||
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
|
||
cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
|
||
},
|
||
ref,
|
||
) => {
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const webcamVideoRef = 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 cursorContainerRef = useRef<Container | null>(null);
|
||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||
const [pixiReady, setPixiReady] = useState(false);
|
||
const [videoReady, setVideoReady] = useState(false);
|
||
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
|
||
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
|
||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||
const composite3DRef = useRef<HTMLDivElement | null>(null);
|
||
const outerWrapperRef = useRef<HTMLDivElement | null>(null);
|
||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||
const currentTimeRef = useRef(0);
|
||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||
const cursorTelemetryRef = useRef<CursorTelemetryPoint[]>([]);
|
||
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
|
||
const cursorClickTimestampsRef = useRef<number[]>([]);
|
||
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
|
||
const selectedZoomIdRef = useRef<string | null>(null);
|
||
const animationStateRef = useRef({
|
||
scale: 1,
|
||
focusX: DEFAULT_FOCUS.cx,
|
||
focusY: DEFAULT_FOCUS.cy,
|
||
progress: 0,
|
||
x: 0,
|
||
y: 0,
|
||
appliedScale: 1,
|
||
});
|
||
const blurFilterRef = useRef<BlurFilter | null>(null);
|
||
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
||
const isDraggingFocusRef = useRef(false);
|
||
const isDraggingWebcamRef = useRef(false);
|
||
const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 });
|
||
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 isScrubbingRef = useRef(false);
|
||
const scrubEndTimerRef = useRef<number | null>(null);
|
||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||
const allowPlaybackRef = useRef(false);
|
||
const lockedVideoDimensionsRef = useRef<{
|
||
width: number;
|
||
height: number;
|
||
} | null>(null);
|
||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||
const motionBlurAmountRef = useRef(motionBlurAmount);
|
||
const cursorOverlayRef = useRef<PixiCursorOverlay | null>(null);
|
||
const showCursorRef = useRef(showCursor);
|
||
const cursorSizeRef = useRef(cursorSize);
|
||
const cursorSmoothingRef = useRef(cursorSmoothing);
|
||
const cursorMotionBlurRef = useRef(cursorMotionBlur);
|
||
const cursorClickBounceRef = useRef(cursorClickBounce);
|
||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||
const videoReadyRafRef = useRef<number | null>(null);
|
||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||
const prevTargetProgressRef = useRef(0);
|
||
const blurPreviewSnapshotRef = useRef<{
|
||
bucket: number;
|
||
canvas: BlurPreviewCanvasSource | null;
|
||
height: number;
|
||
width: number;
|
||
}>({ bucket: -1, canvas: null, height: 0, width: 0 });
|
||
|
||
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,
|
||
borderRadius,
|
||
padding,
|
||
webcamDimensions,
|
||
webcamLayoutPreset,
|
||
webcamSizePreset,
|
||
webcamPosition,
|
||
webcamMaskShape,
|
||
});
|
||
|
||
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;
|
||
setWebcamLayout(result.webcamRect);
|
||
|
||
// 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,
|
||
borderRadius,
|
||
padding,
|
||
webcamDimensions,
|
||
webcamLayoutPreset,
|
||
webcamSizePreset,
|
||
webcamPosition,
|
||
webcamMaskShape,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
layoutVideoContentRef.current = layoutVideoContent;
|
||
}, [layoutVideoContent]);
|
||
|
||
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
|
||
overlayRef.current = node;
|
||
setOverlayElement(node);
|
||
}, []);
|
||
|
||
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,
|
||
containerRef,
|
||
play: async () => {
|
||
const vid = videoRef.current;
|
||
if (!vid) return;
|
||
try {
|
||
allowPlaybackRef.current = true;
|
||
await vid.play().catch((err) => {
|
||
console.log("PLAY ERROR:", err);
|
||
throw err;
|
||
});
|
||
} 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 = clampFocusToScale(unclampedFocus, getZoomScale(region));
|
||
|
||
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;
|
||
if (region.focusMode === "auto") 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 {
|
||
// Pointer may already be released.
|
||
}
|
||
onZoomFocusDragEnd?.();
|
||
};
|
||
|
||
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
endFocusDrag(event);
|
||
};
|
||
|
||
const handleOverlayPointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
endFocusDrag(event);
|
||
};
|
||
|
||
// ── Webcam PiP drag handlers ──
|
||
|
||
const handleWebcamPointerDown = (event: React.PointerEvent<HTMLVideoElement>) => {
|
||
if (isPlayingRef.current) return;
|
||
if (webcamLayoutPreset !== "picture-in-picture") return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
isDraggingWebcamRef.current = true;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
|
||
const webcamEl = event.currentTarget;
|
||
const webcamRect = webcamEl.getBoundingClientRect();
|
||
webcamDragOffsetRef.current = {
|
||
dx: event.clientX - (webcamRect.left + webcamRect.width / 2),
|
||
dy: event.clientY - (webcamRect.top + webcamRect.height / 2),
|
||
};
|
||
};
|
||
|
||
const handleWebcamPointerMove = (event: React.PointerEvent<HTMLVideoElement>) => {
|
||
if (!isDraggingWebcamRef.current) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const containerEl = containerRef.current;
|
||
if (!containerEl || !onWebcamPositionChange) return;
|
||
|
||
const containerRect = containerEl.getBoundingClientRect();
|
||
const cx = clamp01(
|
||
(event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width,
|
||
);
|
||
const cy = clamp01(
|
||
(event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height,
|
||
);
|
||
onWebcamPositionChange({ cx, cy });
|
||
};
|
||
|
||
const handleWebcamPointerUp = (event: React.PointerEvent<HTMLVideoElement>) => {
|
||
if (!isDraggingWebcamRef.current) return;
|
||
isDraggingWebcamRef.current = false;
|
||
try {
|
||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||
} catch {
|
||
// Pointer may already be released.
|
||
}
|
||
onWebcamPositionDragEnd?.();
|
||
};
|
||
|
||
useEffect(() => {
|
||
zoomRegionsRef.current = zoomRegions;
|
||
}, [zoomRegions]);
|
||
|
||
useEffect(() => {
|
||
cursorTelemetryRef.current = cursorTelemetry;
|
||
}, [cursorTelemetry]);
|
||
|
||
useEffect(() => {
|
||
cursorHighlightRef.current = cursorHighlight;
|
||
if (cursorHighlightGraphicsRef.current) {
|
||
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
|
||
}
|
||
}, [cursorHighlight]);
|
||
|
||
useEffect(() => {
|
||
cursorClickTimestampsRef.current = cursorClickTimestamps;
|
||
}, [cursorClickTimestamps]);
|
||
|
||
useEffect(() => {
|
||
selectedZoomIdRef.current = selectedZoomId;
|
||
}, [selectedZoomId]);
|
||
|
||
useEffect(() => {
|
||
isPlayingRef.current = isPlaying;
|
||
}, [isPlaying]);
|
||
|
||
useEffect(() => {
|
||
trimRegionsRef.current = trimRegions;
|
||
}, [trimRegions]);
|
||
|
||
useEffect(() => {
|
||
speedRegionsRef.current = speedRegions;
|
||
}, [speedRegions]);
|
||
|
||
useEffect(() => {
|
||
motionBlurAmountRef.current = motionBlurAmount;
|
||
}, [motionBlurAmount]);
|
||
|
||
useEffect(() => {
|
||
cursorTelemetryRef.current = cursorTelemetry;
|
||
}, [cursorTelemetry]);
|
||
|
||
useEffect(() => {
|
||
showCursorRef.current = showCursor;
|
||
}, [showCursor]);
|
||
|
||
useEffect(() => {
|
||
hasNativeCursorRecordingRef.current = hasNativeCursorRecording;
|
||
}, [hasNativeCursorRecording]);
|
||
|
||
useEffect(() => {
|
||
cursorRecordingDataRef.current = cursorRecordingData;
|
||
}, [cursorRecordingData]);
|
||
|
||
useEffect(() => {
|
||
cropRegionRef.current = cropRegion;
|
||
}, [cropRegion]);
|
||
|
||
useEffect(() => {
|
||
cursorSizeRef.current = cursorSize;
|
||
}, [cursorSize]);
|
||
|
||
useEffect(() => {
|
||
cursorSmoothingRef.current = cursorSmoothing;
|
||
}, [cursorSmoothing]);
|
||
|
||
useEffect(() => {
|
||
cursorMotionBlurRef.current = cursorMotionBlur;
|
||
}, [cursorMotionBlur]);
|
||
|
||
useEffect(() => {
|
||
cursorClickBounceRef.current = cursorClickBounce;
|
||
}, [cursorClickBounce]);
|
||
|
||
// Sync cursor overlay config when settings change
|
||
useEffect(() => {
|
||
const overlay = cursorOverlayRef.current;
|
||
if (!overlay) return;
|
||
overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize);
|
||
overlay.setSmoothingFactor(cursorSmoothing);
|
||
overlay.setMotionBlur(cursorMotionBlur);
|
||
overlay.setClickBounce(cursorClickBounce);
|
||
overlay.reset();
|
||
}, [cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce]);
|
||
|
||
useEffect(() => {
|
||
onTimeUpdateRef.current = onTimeUpdate;
|
||
}, [onTimeUpdate]);
|
||
|
||
useEffect(() => {
|
||
onPlayStateChangeRef.current = onPlayStateChange;
|
||
}, [onPlayStateChange]);
|
||
|
||
useEffect(() => {
|
||
if (!pixiReady || !videoReady) return;
|
||
const el = overlayRef.current;
|
||
if (!el) return;
|
||
|
||
// Seed immediately so overlays never start at 800×600
|
||
setOverlaySize({ width: el.clientWidth, height: el.clientHeight });
|
||
|
||
const observer = new ResizeObserver((entries) => {
|
||
if (!entries[0]) return;
|
||
const { width, height } = entries[0].contentRect;
|
||
setOverlaySize((prev) => {
|
||
if (prev.width === width && prev.height === height) return prev;
|
||
return { width, height };
|
||
});
|
||
});
|
||
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, [pixiReady, videoReady]);
|
||
|
||
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]);
|
||
|
||
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
|
||
// navigating, not previewing) and restore native DPR on play/idle so the
|
||
// preview stays faithful. Mutating renderer.resolution per-frame would
|
||
// thrash texture uploads; we only do it on scrub-state transitions.
|
||
useEffect(() => {
|
||
if (!pixiReady) return;
|
||
const app = appRef.current;
|
||
const container = containerRef.current;
|
||
if (!app || !container) return;
|
||
|
||
const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1;
|
||
if (app.renderer.resolution === targetResolution) return;
|
||
|
||
app.renderer.resolution = targetResolution;
|
||
app.renderer.resize(container.clientWidth, container.clientHeight);
|
||
layoutVideoContentRef.current?.();
|
||
}, [isScrubbing, pixiReady]);
|
||
|
||
useEffect(() => {
|
||
if (!pixiReady || !videoReady) return;
|
||
updateOverlayForRegion(selectedZoom);
|
||
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
|
||
|
||
useEffect(() => {
|
||
if (!pixiReady || !videoReady) return;
|
||
const overlayEl = overlayElement;
|
||
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, pixiReady, videoReady, overlayElement]);
|
||
|
||
useEffect(() => {
|
||
const overlayEl = overlayElement;
|
||
if (!overlayEl) return;
|
||
|
||
const updateOverlaySize = () => {
|
||
const width = overlayEl.clientWidth || 800;
|
||
const height = overlayEl.clientHeight || 600;
|
||
setOverlaySize((prev) => {
|
||
if (prev.width === width && prev.height === height) return prev;
|
||
return { width, height };
|
||
});
|
||
};
|
||
|
||
updateOverlaySize();
|
||
|
||
if (typeof ResizeObserver !== "undefined") {
|
||
const observer = new ResizeObserver(() => {
|
||
updateOverlaySize();
|
||
});
|
||
observer.observe(overlayEl);
|
||
return () => observer.disconnect();
|
||
}
|
||
|
||
window.addEventListener("resize", updateOverlaySize);
|
||
return () => window.removeEventListener("resize", updateOverlaySize);
|
||
}, [overlayElement]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
|
||
let mounted = true;
|
||
let app: Application | null = null;
|
||
|
||
(async () => {
|
||
let cursorOverlayEnabled = true;
|
||
try {
|
||
await preloadCursorAssets();
|
||
} catch {
|
||
cursorOverlayEnabled = false;
|
||
}
|
||
|
||
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);
|
||
|
||
// Cursor container - rendered above video
|
||
const cursorContainer = new Container();
|
||
cursorContainerRef.current = cursorContainer;
|
||
cameraContainer.addChild(cursorContainer);
|
||
|
||
// Cursor overlay - rendered above the masked video
|
||
if (cursorOverlayEnabled) {
|
||
const cursorOverlay = new PixiCursorOverlay({
|
||
dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current,
|
||
smoothingFactor: cursorSmoothingRef.current,
|
||
motionBlur: cursorMotionBlurRef.current,
|
||
clickBounce: cursorClickBounceRef.current,
|
||
});
|
||
cursorOverlayRef.current = cursorOverlay;
|
||
cursorContainer.addChild(cursorOverlay.container);
|
||
}
|
||
|
||
setPixiReady(true);
|
||
})();
|
||
|
||
return () => {
|
||
mounted = false;
|
||
setPixiReady(false);
|
||
if (cursorOverlayRef.current) {
|
||
cursorOverlayRef.current.destroy();
|
||
cursorOverlayRef.current = null;
|
||
}
|
||
if (app && app.renderer) {
|
||
app.destroy(true, {
|
||
children: true,
|
||
texture: true,
|
||
textureSource: true,
|
||
});
|
||
}
|
||
appRef.current = null;
|
||
cameraContainerRef.current = null;
|
||
videoContainerRef.current = null;
|
||
cursorContainerRef.current = null;
|
||
videoSpriteRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!videoPath) {
|
||
lastResolvedDurationRef.current = null;
|
||
isResolvingDurationRef.current = false;
|
||
setVideoReady(false);
|
||
return;
|
||
}
|
||
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
video.pause();
|
||
video.currentTime = 0;
|
||
allowPlaybackRef.current = false;
|
||
lockedVideoDimensionsRef.current = null;
|
||
lastResolvedDurationRef.current = null;
|
||
isResolvingDurationRef.current = false;
|
||
if (durationResolutionTimeoutRef.current) {
|
||
clearTimeout(durationResolutionTimeoutRef.current);
|
||
durationResolutionTimeoutRef.current = null;
|
||
}
|
||
setVideoReady(false);
|
||
if (videoReadyRafRef.current) {
|
||
cancelAnimationFrame(videoReadyRafRef.current);
|
||
videoReadyRafRef.current = null;
|
||
}
|
||
video.load();
|
||
}, [videoPath]);
|
||
|
||
useEffect(() => {
|
||
if (!pixiReady || !videoReady) return;
|
||
|
||
const video = videoRef.current;
|
||
const app = appRef.current;
|
||
const videoContainer = videoContainerRef.current;
|
||
const cursorContainer = cursorContainerRef.current;
|
||
|
||
if (!video || !app || !videoContainer || !cursorContainer) 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;
|
||
if (cursorOverlayRef.current) {
|
||
cursorContainer.addChild(cursorOverlayRef.current.container);
|
||
}
|
||
|
||
const cursorHighlightGraphics = new Graphics();
|
||
cursorHighlightGraphics.visible = false;
|
||
videoContainer.addChild(cursorHighlightGraphics);
|
||
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
|
||
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
|
||
|
||
animationStateRef.current = {
|
||
scale: 1,
|
||
focusX: DEFAULT_FOCUS.cx,
|
||
focusY: DEFAULT_FOCUS.cy,
|
||
progress: 0,
|
||
x: 0,
|
||
y: 0,
|
||
appliedScale: 1,
|
||
};
|
||
|
||
const blurFilter = new BlurFilter();
|
||
blurFilter.quality = 3;
|
||
blurFilter.resolution = app.renderer.resolution;
|
||
blurFilter.blur = 0;
|
||
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
|
||
blurFilterRef.current = blurFilter;
|
||
motionBlurFilterRef.current = motionBlurFilter;
|
||
|
||
layoutVideoContentRef.current?.();
|
||
video.pause();
|
||
|
||
const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({
|
||
video,
|
||
isSeekingRef,
|
||
isPlayingRef,
|
||
allowPlaybackRef,
|
||
currentTimeRef,
|
||
timeUpdateAnimationRef,
|
||
onPlayStateChange: (playing) => onPlayStateChangeRef.current(playing),
|
||
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
|
||
trimRegionsRef,
|
||
speedRegionsRef,
|
||
isScrubbingRef,
|
||
scrubEndTimerRef,
|
||
onScrubChange: (scrubbing) => setIsScrubbing(scrubbing),
|
||
});
|
||
|
||
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();
|
||
}
|
||
if (cursorHighlightGraphicsRef.current) {
|
||
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
|
||
cursorHighlightGraphicsRef.current.destroy();
|
||
cursorHighlightGraphicsRef.current = null;
|
||
}
|
||
videoContainer.mask = null;
|
||
maskGraphicsRef.current = null;
|
||
if (blurFilterRef.current) {
|
||
videoContainer.filters = null;
|
||
blurFilterRef.current.destroy();
|
||
blurFilterRef.current = null;
|
||
}
|
||
if (motionBlurFilterRef.current) {
|
||
motionBlurFilterRef.current.destroy();
|
||
motionBlurFilterRef.current = null;
|
||
}
|
||
videoTexture.destroy(true);
|
||
|
||
videoSpriteRef.current = null;
|
||
};
|
||
}, [pixiReady, videoReady]);
|
||
|
||
useEffect(() => {
|
||
if (!pixiReady || !videoReady) return;
|
||
|
||
const app = appRef.current;
|
||
const videoSprite = videoSpriteRef.current;
|
||
const videoContainer = videoContainerRef.current;
|
||
if (!app || !videoSprite || !videoContainer) return;
|
||
|
||
const applyTransformFn = (
|
||
transform: { scale: number; x: number; y: number },
|
||
targetFocus: ZoomFocus,
|
||
motionIntensity: number,
|
||
motionVector: { x: number; y: number },
|
||
) => {
|
||
const cameraContainer = cameraContainerRef.current;
|
||
if (!cameraContainer) return;
|
||
|
||
const state = animationStateRef.current;
|
||
|
||
const appliedTransform = applyZoomTransform({
|
||
cameraContainer,
|
||
blurFilter: blurFilterRef.current,
|
||
motionBlurFilter: motionBlurFilterRef.current,
|
||
stageSize: stageSizeRef.current,
|
||
baseMask: baseMaskRef.current,
|
||
zoomScale: state.scale,
|
||
zoomProgress: state.progress,
|
||
focusX: targetFocus.cx,
|
||
focusY: targetFocus.cy,
|
||
motionIntensity,
|
||
motionVector,
|
||
isPlaying: isPlayingRef.current,
|
||
motionBlurAmount: motionBlurAmountRef.current,
|
||
transformOverride: transform,
|
||
motionBlurState: motionBlurStateRef.current,
|
||
frameTimeMs: performance.now(),
|
||
});
|
||
|
||
state.x = appliedTransform.x;
|
||
state.y = appliedTransform.y;
|
||
state.appliedScale = appliedTransform.scale;
|
||
};
|
||
|
||
let lastMotionBlurActive: boolean | null = null;
|
||
let lastTransformIsIdentity = true;
|
||
let lastPerspectiveValue = 0;
|
||
const ticker = () => {
|
||
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
|
||
zoomRegionsRef.current,
|
||
currentTimeRef.current,
|
||
{
|
||
connectZooms: true,
|
||
cursorTelemetry: cursorTelemetryRef.current,
|
||
},
|
||
);
|
||
|
||
const defaultFocus = DEFAULT_FOCUS;
|
||
let targetScaleFactor = 1;
|
||
let targetFocus = defaultFocus;
|
||
let targetProgress = 0;
|
||
|
||
// If a zoom is selected but video is not playing, show default unzoomed view
|
||
const selectedId = selectedZoomIdRef.current;
|
||
const hasSelectedZoom = selectedId !== null;
|
||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||
|
||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||
const regionFocus = region.focus;
|
||
|
||
targetScaleFactor = zoomScale;
|
||
targetFocus = regionFocus;
|
||
targetProgress = strength;
|
||
|
||
// Apply adaptive smoothing for auto-follow mode
|
||
if (region.focusMode === "auto" && !transition) {
|
||
const raw = targetFocus;
|
||
const isZoomingIn =
|
||
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
|
||
if (targetProgress >= 0.999) {
|
||
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
|
||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||
const factor = adaptiveSmoothFactor(
|
||
raw,
|
||
prev,
|
||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||
);
|
||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||
smoothedAutoFocusRef.current = smoothed;
|
||
targetFocus = smoothed;
|
||
} else if (isZoomingIn) {
|
||
// Zoom-in: track cursor directly so zoom always aims at current cursor
|
||
// position; keep ref in sync to avoid snap when full-zoom begins
|
||
smoothedAutoFocusRef.current = raw;
|
||
} else {
|
||
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
|
||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||
const factor = adaptiveSmoothFactor(
|
||
raw,
|
||
prev,
|
||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||
);
|
||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||
smoothedAutoFocusRef.current = smoothed;
|
||
targetFocus = smoothed;
|
||
}
|
||
} else if (region.focusMode !== "auto") {
|
||
smoothedAutoFocusRef.current = null;
|
||
}
|
||
prevTargetProgressRef.current = targetProgress;
|
||
|
||
// Handle connected zoom transitions (pan between adjacent zoom regions)
|
||
if (transition) {
|
||
const startTransform = computeZoomTransform({
|
||
stageSize: stageSizeRef.current,
|
||
baseMask: baseMaskRef.current,
|
||
zoomScale: transition.startScale,
|
||
zoomProgress: 1,
|
||
focusX: transition.startFocus.cx,
|
||
focusY: transition.startFocus.cy,
|
||
});
|
||
const endTransform = computeZoomTransform({
|
||
stageSize: stageSizeRef.current,
|
||
baseMask: baseMaskRef.current,
|
||
zoomScale: transition.endScale,
|
||
zoomProgress: 1,
|
||
focusX: transition.endFocus.cx,
|
||
focusY: transition.endFocus.cy,
|
||
});
|
||
|
||
const interpolatedTransform = {
|
||
scale:
|
||
startTransform.scale +
|
||
(endTransform.scale - startTransform.scale) * transition.progress,
|
||
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
|
||
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
|
||
};
|
||
|
||
targetScaleFactor = interpolatedTransform.scale;
|
||
targetFocus = computeFocusFromTransform({
|
||
stageSize: stageSizeRef.current,
|
||
baseMask: baseMaskRef.current,
|
||
zoomScale: interpolatedTransform.scale,
|
||
x: interpolatedTransform.x,
|
||
y: interpolatedTransform.y,
|
||
});
|
||
targetProgress = 1;
|
||
}
|
||
}
|
||
|
||
const state = animationStateRef.current;
|
||
const prevScale = state.appliedScale;
|
||
const prevX = state.x;
|
||
const prevY = state.y;
|
||
|
||
state.scale = targetScaleFactor;
|
||
state.focusX = targetFocus.cx;
|
||
state.focusY = targetFocus.cy;
|
||
state.progress = targetProgress;
|
||
|
||
const projectedTransform = computeZoomTransform({
|
||
stageSize: stageSizeRef.current,
|
||
baseMask: baseMaskRef.current,
|
||
zoomScale: state.scale,
|
||
zoomProgress: state.progress,
|
||
focusX: state.focusX,
|
||
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;
|
||
|
||
const motionIntensity = Math.max(
|
||
Math.abs(appliedScale - prevScale),
|
||
Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width),
|
||
Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height),
|
||
);
|
||
|
||
const motionVector = {
|
||
x: appliedX - prevX,
|
||
y: appliedY - prevY,
|
||
};
|
||
|
||
applyTransformFn(
|
||
{ scale: appliedScale, x: appliedX, y: appliedY },
|
||
targetFocus,
|
||
motionIntensity,
|
||
motionVector,
|
||
);
|
||
|
||
const cursorGraphics = cursorHighlightGraphicsRef.current;
|
||
const cursorConfig = cursorHighlightRef.current;
|
||
const lockedDims = lockedVideoDimensionsRef.current;
|
||
if (cursorGraphics) {
|
||
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
|
||
const emphasisAlpha = clickEmphasisAlpha(
|
||
currentTimeRef.current,
|
||
cursorClickTimestampsRef.current,
|
||
cursorConfig,
|
||
);
|
||
const cursorPoint =
|
||
emphasisAlpha > 0
|
||
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
|
||
: null;
|
||
if (cursorPoint) {
|
||
const baseScale = baseScaleRef.current;
|
||
const baseOffset = baseOffsetRef.current;
|
||
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
|
||
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
|
||
cursorGraphics.position.set(
|
||
baseOffset.x + cx * lockedDims.width * baseScale,
|
||
baseOffset.y + cy * lockedDims.height * baseScale,
|
||
);
|
||
cursorGraphics.alpha = emphasisAlpha;
|
||
cursorGraphics.visible = true;
|
||
} else {
|
||
cursorGraphics.visible = false;
|
||
}
|
||
} else {
|
||
cursorGraphics.visible = false;
|
||
}
|
||
}
|
||
|
||
const isMotionBlurActive =
|
||
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
|
||
|
||
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
|
||
if (isMotionBlurActive) {
|
||
if (blurFilterRef.current && motionBlurFilterRef.current) {
|
||
videoContainerRef.current.filters = [
|
||
blurFilterRef.current,
|
||
motionBlurFilterRef.current,
|
||
];
|
||
lastMotionBlurActive = true;
|
||
}
|
||
} else {
|
||
videoContainerRef.current.filters = null;
|
||
lastMotionBlurActive = false;
|
||
}
|
||
}
|
||
|
||
// Update cursor overlay
|
||
const cursorOverlay = cursorOverlayRef.current;
|
||
if (cursorOverlay) {
|
||
const timeMs = currentTimeRef.current; // already in ms
|
||
cursorOverlay.update(
|
||
cursorTelemetryRef.current,
|
||
timeMs,
|
||
baseMaskRef.current,
|
||
showCursorRef.current && !hasNativeCursorRecordingRef.current,
|
||
!isPlayingRef.current || isSeekingRef.current,
|
||
);
|
||
}
|
||
|
||
// Update native cursor image position at ticker rate (60fps)
|
||
const nativeCursorImg = nativeCursorImgRef.current;
|
||
if (nativeCursorImg) {
|
||
const cameraContainerRc = cameraContainerRef.current;
|
||
const videoContainerRc = videoContainerRef.current;
|
||
if (
|
||
hasNativeCursorRecordingRef.current &&
|
||
showCursorRef.current &&
|
||
cameraContainerRc &&
|
||
videoContainerRc
|
||
) {
|
||
const timeMs = currentTimeRef.current; // already in ms
|
||
const frame = resolveInterpolatedNativeCursorFrame(
|
||
cursorRecordingDataRef.current,
|
||
timeMs,
|
||
);
|
||
if (frame) {
|
||
const projectedPoint = projectNativeCursorToStage({
|
||
cameraContainer: cameraContainerRc,
|
||
cropRegion: cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 },
|
||
maskRect: baseMaskRef.current,
|
||
videoContainerPosition: {
|
||
x: videoContainerRc.x,
|
||
y: videoContainerRc.y,
|
||
},
|
||
sample: frame.sample,
|
||
});
|
||
if (projectedPoint) {
|
||
const renderAsset = resolveNativeCursorRenderAsset(
|
||
frame.asset,
|
||
window.devicePixelRatio || 1,
|
||
frame.sample,
|
||
);
|
||
const scale = Math.max(0, cursorSizeRef.current);
|
||
if (nativeCursorImg.dataset.cursorId !== renderAsset.id) {
|
||
nativeCursorImg.src = renderAsset.imageDataUrl;
|
||
nativeCursorImg.dataset.cursorId = renderAsset.id;
|
||
}
|
||
nativeCursorImg.style.left = `${projectedPoint.x - renderAsset.hotspotX * scale}px`;
|
||
nativeCursorImg.style.top = `${projectedPoint.y - renderAsset.hotspotY * scale}px`;
|
||
nativeCursorImg.style.width = `${renderAsset.width * scale}px`;
|
||
nativeCursorImg.style.height = `${renderAsset.height * scale}px`;
|
||
nativeCursorImg.style.display = "block";
|
||
} else {
|
||
nativeCursorImg.style.display = "none";
|
||
}
|
||
} else {
|
||
nativeCursorImg.style.display = "none";
|
||
}
|
||
} else {
|
||
nativeCursorImg.style.display = "none";
|
||
}
|
||
}
|
||
|
||
const composite3D = composite3DRef.current;
|
||
const outerWrapper = outerWrapperRef.current;
|
||
if (composite3D && outerWrapper) {
|
||
const effectiveRotation =
|
||
region && targetProgress > 0 && !shouldShowUnzoomedView
|
||
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress)
|
||
: DEFAULT_ROTATION_3D;
|
||
const isIdentity = isRotation3DIdentity(effectiveRotation);
|
||
if (isIdentity) {
|
||
if (!lastTransformIsIdentity) {
|
||
composite3D.style.transform = "";
|
||
composite3D.style.willChange = "auto";
|
||
lastTransformIsIdentity = true;
|
||
}
|
||
if (lastPerspectiveValue !== 0) {
|
||
outerWrapper.style.perspective = "";
|
||
lastPerspectiveValue = 0;
|
||
}
|
||
} else {
|
||
const wrapperW = outerWrapper.clientWidth || 1;
|
||
const wrapperH = outerWrapper.clientHeight || 1;
|
||
const persp = rotation3DPerspective(wrapperW, wrapperH);
|
||
const containScale = computeRotation3DContainScale(
|
||
effectiveRotation,
|
||
wrapperW,
|
||
wrapperH,
|
||
persp,
|
||
);
|
||
composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`;
|
||
composite3D.style.willChange = "transform";
|
||
lastTransformIsIdentity = false;
|
||
if (persp !== lastPerspectiveValue) {
|
||
outerWrapper.style.perspective = `${persp}px`;
|
||
lastPerspectiveValue = persp;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
app.ticker.add(ticker);
|
||
return () => {
|
||
if (app && app.ticker) {
|
||
app.ticker.remove(ticker);
|
||
}
|
||
};
|
||
}, [pixiReady, videoReady]);
|
||
|
||
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||
const video = e.currentTarget;
|
||
const hasResolvedDuration = syncResolvedDuration(video);
|
||
if (!hasResolvedDuration) {
|
||
forceResolveDuration(video);
|
||
} else {
|
||
video.currentTime = 0;
|
||
}
|
||
video.pause();
|
||
allowPlaybackRef.current = false;
|
||
currentTimeRef.current = 0;
|
||
|
||
if (videoReadyRafRef.current) {
|
||
cancelAnimationFrame(videoReadyRafRef.current);
|
||
videoReadyRafRef.current = null;
|
||
}
|
||
|
||
const waitForRenderableFrame = () => {
|
||
const hasDimensions = video.videoWidth > 0 && video.videoHeight > 0;
|
||
const hasData = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
||
if (!syncResolvedDuration(video)) {
|
||
forceResolveDuration(video);
|
||
}
|
||
if (hasDimensions && hasData) {
|
||
videoReadyRafRef.current = null;
|
||
setVideoReady(true);
|
||
return;
|
||
}
|
||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||
};
|
||
|
||
videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame);
|
||
};
|
||
|
||
const resolvedWallpaper = useMemo<string | null>(() => {
|
||
const source = wallpaper || DEFAULT_WALLPAPER;
|
||
const classified = classifyWallpaper(source);
|
||
if (classified.kind !== "image") return classified.value;
|
||
try {
|
||
return resolveImageWallpaperUrl(classified.path);
|
||
} catch (err) {
|
||
console.warn("[VideoPlayback] wallpaper resolve failed:", err);
|
||
return null;
|
||
}
|
||
}, [wallpaper]);
|
||
const webcamCssBoxShadow = useMemo(
|
||
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
|
||
[webcamLayoutPreset],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const webcamVideo = webcamVideoRef.current;
|
||
if (!webcamVideo || !webcamVideoPath) {
|
||
setWebcamDimensions(null);
|
||
return;
|
||
}
|
||
|
||
const handleLoadedMetadata = () => {
|
||
if (webcamVideo.videoWidth > 0 && webcamVideo.videoHeight > 0) {
|
||
setWebcamDimensions({
|
||
width: webcamVideo.videoWidth,
|
||
height: webcamVideo.videoHeight,
|
||
});
|
||
}
|
||
};
|
||
|
||
webcamVideo.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||
handleLoadedMetadata();
|
||
return () => {
|
||
webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||
};
|
||
}, [webcamVideoPath]);
|
||
|
||
useEffect(() => {
|
||
const webcamVideo = webcamVideoRef.current;
|
||
if (!webcamVideo || !webcamVideoPath) {
|
||
return;
|
||
}
|
||
|
||
const activeSpeedRegion =
|
||
speedRegions.find(
|
||
(region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
|
||
) ?? null;
|
||
webcamVideo.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||
|
||
if (!isPlaying) {
|
||
webcamVideo.pause();
|
||
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.05) {
|
||
webcamVideo.currentTime = currentTime;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.15) {
|
||
webcamVideo.currentTime = currentTime;
|
||
}
|
||
|
||
webcamVideo.play().catch(() => {
|
||
// Ignore webcam autoplay restoration failures.
|
||
});
|
||
}, [currentTime, isPlaying, speedRegions, webcamVideoPath]);
|
||
|
||
useEffect(() => {
|
||
const webcamVideo = webcamVideoRef.current;
|
||
if (!webcamVideo || !webcamVideoPath) {
|
||
return;
|
||
}
|
||
|
||
webcamVideo.pause();
|
||
webcamVideo.currentTime = 0;
|
||
}, [webcamVideoPath]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (videoReadyRafRef.current) {
|
||
cancelAnimationFrame(videoReadyRafRef.current);
|
||
videoReadyRafRef.current = null;
|
||
}
|
||
if (scrubEndTimerRef.current !== null) {
|
||
window.clearTimeout(scrubEndTimerRef.current);
|
||
scrubEndTimerRef.current = null;
|
||
}
|
||
if (durationResolutionTimeoutRef.current) {
|
||
clearTimeout(durationResolutionTimeoutRef.current);
|
||
durationResolutionTimeoutRef.current = null;
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
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
|
||
ref={outerWrapperRef}
|
||
className="relative rounded-sm overflow-hidden"
|
||
style={{
|
||
width: "100%",
|
||
aspectRatio: formatAspectRatioForCSS(
|
||
aspectRatio,
|
||
aspectRatio === "native"
|
||
? getNativeAspectRatioValue(
|
||
lockedVideoDimensionsRef.current?.width || 1920,
|
||
lockedVideoDimensionsRef.current?.height || 1080,
|
||
cropRegion,
|
||
)
|
||
: undefined,
|
||
),
|
||
}}
|
||
>
|
||
{/* 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={composite3DRef}
|
||
className="absolute inset-0"
|
||
style={{
|
||
transformStyle: "preserve-3d",
|
||
transformOrigin: "center center",
|
||
}}
|
||
>
|
||
<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",
|
||
}}
|
||
/>
|
||
{webcamVideoPath &&
|
||
(() => {
|
||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||
const useClipPath = !!clipPath;
|
||
return (
|
||
<div
|
||
className="absolute"
|
||
style={{
|
||
left: webcamLayout?.x ?? 0,
|
||
top: webcamLayout?.y ?? 0,
|
||
width: webcamLayout?.width ?? 0,
|
||
height: webcamLayout?.height ?? 0,
|
||
zIndex: 20,
|
||
opacity: webcamLayout ? 1 : 0,
|
||
filter:
|
||
useClipPath && webcamCssBoxShadow !== "none"
|
||
? `drop-shadow(${webcamCssBoxShadow})`
|
||
: undefined,
|
||
}}
|
||
>
|
||
<video
|
||
ref={webcamVideoRef}
|
||
src={webcamVideoPath}
|
||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||
style={{
|
||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||
clipPath: clipPath ?? undefined,
|
||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||
backgroundColor: "#000",
|
||
}}
|
||
onPointerDown={handleWebcamPointerDown}
|
||
onPointerMove={handleWebcamPointerMove}
|
||
onPointerUp={handleWebcamPointerUp}
|
||
onPointerLeave={handleWebcamPointerUp}
|
||
muted
|
||
preload="metadata"
|
||
playsInline
|
||
/>
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||
{pixiReady && videoReady && (
|
||
<div
|
||
ref={setOverlayRefs}
|
||
className="absolute inset-0 select-none"
|
||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||
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" }}
|
||
/>
|
||
{hasNativeCursorRecording ? (
|
||
<img
|
||
ref={nativeCursorImgRef}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute select-none"
|
||
style={{
|
||
display: "none",
|
||
pointerEvents: "none",
|
||
userSelect: "none",
|
||
}}
|
||
/>
|
||
) : null}
|
||
{(() => {
|
||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||
if (
|
||
typeof annotation.startMs !== "number" ||
|
||
typeof annotation.endMs !== "number"
|
||
)
|
||
return false;
|
||
|
||
if (annotation.id === selectedAnnotationId) return true;
|
||
|
||
const timeMs = Math.round(currentTime * 1000);
|
||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||
});
|
||
|
||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||
if (
|
||
typeof blurRegion.startMs !== "number" ||
|
||
typeof blurRegion.endMs !== "number"
|
||
)
|
||
return false;
|
||
|
||
if (blurRegion.id === selectedBlurId) return true;
|
||
|
||
const timeMs = Math.round(currentTime * 1000);
|
||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||
});
|
||
|
||
const sorted = [
|
||
...filteredAnnotations.map((annotation) => ({
|
||
kind: "annotation" as const,
|
||
region: annotation,
|
||
})),
|
||
...filteredBlurRegions.map((blurRegion) => ({
|
||
kind: "blur" as const,
|
||
region: blurRegion,
|
||
})),
|
||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||
const previewSnapshotBucket = Math.floor(currentTime * 10);
|
||
const previewSnapshotCanvas =
|
||
filteredBlurRegions.length > 0
|
||
? (() => {
|
||
const cached = blurPreviewSnapshotRef.current;
|
||
if (
|
||
cached.bucket === previewSnapshotBucket &&
|
||
cached.width === overlaySize.width &&
|
||
cached.height === overlaySize.height
|
||
) {
|
||
return cached.canvas;
|
||
}
|
||
|
||
const app = appRef.current;
|
||
if (!app?.renderer?.extract) return cached.canvas;
|
||
try {
|
||
const canvas = app.renderer.extract.canvas(app.stage);
|
||
blurPreviewSnapshotRef.current = {
|
||
bucket: previewSnapshotBucket,
|
||
canvas,
|
||
height: overlaySize.height,
|
||
width: overlaySize.width,
|
||
};
|
||
return canvas;
|
||
} catch {
|
||
return cached.canvas;
|
||
}
|
||
})()
|
||
: null;
|
||
|
||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||
const handleAnnotationClick = (clickedId: string) => {
|
||
if (!onSelectAnnotation) return;
|
||
|
||
// If clicking on already selected annotation and there are multiple overlapping
|
||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||
// Find current index and cycle to next
|
||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||
} else {
|
||
// First click or clicking different annotation
|
||
onSelectAnnotation(clickedId);
|
||
}
|
||
};
|
||
|
||
const handleBlurClick = (clickedId: string) => {
|
||
if (!onSelectBlur) return;
|
||
|
||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||
} else {
|
||
onSelectBlur(clickedId);
|
||
}
|
||
};
|
||
|
||
return sorted.map((item) => (
|
||
<AnnotationOverlay
|
||
key={
|
||
item.kind === "blur"
|
||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||
}
|
||
annotation={item.region}
|
||
isSelected={
|
||
item.kind === "blur"
|
||
? item.region.id === selectedBlurId
|
||
: item.region.id === selectedAnnotationId
|
||
}
|
||
containerWidth={overlaySize.width}
|
||
containerHeight={overlaySize.height}
|
||
onPositionChange={(id, position) =>
|
||
item.kind === "blur"
|
||
? onBlurPositionChange?.(id, position)
|
||
: onAnnotationPositionChange?.(id, position)
|
||
}
|
||
onSizeChange={(id, size) =>
|
||
item.kind === "blur"
|
||
? onBlurSizeChange?.(id, size)
|
||
: onAnnotationSizeChange?.(id, size)
|
||
}
|
||
onBlurDataChange={
|
||
item.kind === "blur"
|
||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||
: undefined
|
||
}
|
||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||
zIndex={item.region.zIndex}
|
||
isSelectedBoost={
|
||
item.kind === "blur"
|
||
? item.region.id === selectedBlurId
|
||
: item.region.id === selectedAnnotationId
|
||
}
|
||
previewSourceCanvas={previewSnapshotCanvas}
|
||
previewFrameVersion={previewSnapshotBucket}
|
||
/>
|
||
));
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoPath}
|
||
className="hidden"
|
||
preload="auto"
|
||
muted
|
||
playsInline
|
||
onLoadedMetadata={handleLoadedMetadata}
|
||
onDurationChange={(e) => {
|
||
if (!syncResolvedDuration(e.currentTarget)) {
|
||
forceResolveDuration(e.currentTarget);
|
||
}
|
||
}}
|
||
onLoadedData={(e) => {
|
||
if (!syncResolvedDuration(e.currentTarget)) {
|
||
forceResolveDuration(e.currentTarget);
|
||
}
|
||
}}
|
||
onCanPlay={(e) => {
|
||
if (!syncResolvedDuration(e.currentTarget)) {
|
||
forceResolveDuration(e.currentTarget);
|
||
}
|
||
}}
|
||
onError={() => onError("Failed to load video")}
|
||
/>
|
||
</div>
|
||
);
|
||
},
|
||
);
|
||
|
||
VideoPlayback.displayName = "VideoPlayback";
|
||
|
||
export default VideoPlayback;
|