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 && (
-
-
- {(() => {
- const filtered = (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;
+ 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);
+ }
+
+ animationStateRef.current = createPlaybackAnimationState();
+
+ const blurFilter = new BlurFilter();
+ blurFilter.quality = 3;
+ blurFilter.resolution = app.renderer.resolution;
+ blurFilter.blur = 0;
+ const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
+ videoContainer.filters = [blurFilter, motionBlurFilter];
+ blurFilterRef.current = blurFilter;
+ motionBlurFilterRef.current = motionBlurFilter;
+
+ layoutVideoContent();
+ video.pause();
+
+ const { handlePlay, handlePause, handleSeeked, handleSeeking } =
+ createVideoEventHandlers({
+ video,
+ isSeekingRef,
+ isPlayingRef,
+ allowPlaybackRef,
+ currentTimeRef,
+ timeUpdateAnimationRef,
+ onPlayStateChange,
+ onTimeUpdate,
+ trimRegionsRef,
+ speedRegionsRef,
+ });
+
+ 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;
+ }
+ if (motionBlurFilterRef.current) {
+ motionBlurFilterRef.current.destroy();
+ motionBlurFilterRef.current = null;
+ }
+ videoTexture.destroy(false);
+
+ 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 = (
+ transform: { scale: number; x: number; y: number },
+ focus: 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,
+ stageSize: stageSizeRef.current,
+ baseMask: baseMaskRef.current,
+ zoomScale: state.scale,
+ zoomProgress: state.progress,
+ focusX: focus.cx,
+ focusY: focus.cy,
+ motionIntensity,
+ motionVector,
+ isPlaying: isPlayingRef.current,
+ motionBlurAmount: zoomMotionBlurRef.current,
+ motionBlurFilter: motionBlurFilterRef.current,
+ transformOverride: transform,
+ motionBlurState: motionBlurStateRef.current,
+ frameTimeMs: performance.now(),
+ });
+
+ state.x = appliedTransform.x;
+ state.y = appliedTransform.y;
+ state.appliedScale = appliedTransform.scale;
+ };
+
+ const ticker = () => {
+ const { region, strength, blendedScale, transition } =
+ findDominantRegion(zoomRegionsRef.current, currentTimeRef.current, {
+ connectZooms: connectZoomsRef.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
+ // (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 = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
+ const regionFocus = region.focus;
+
+ targetScaleFactor = zoomScale;
+ targetFocus = regionFocus;
+ targetProgress = strength;
+
+ 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,
});
-
- // Sort by z-index (lowest to highest) so higher z-index renders on top
- const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
-
- // 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 && sorted.length > 1) {
- // Find current index and cycle to next
- const currentIndex = sorted.findIndex(a => a.id === clickedId);
- const nextIndex = (currentIndex + 1) % sorted.length;
- onSelectAnnotation(sorted[nextIndex].id);
- } else {
- // First click or clicking different annotation
- onSelectAnnotation(clickedId);
- }
- };
-
- return sorted.map((annotation) => (
-
onAnnotationPositionChange?.(id, position)}
- onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
- onClick={handleAnnotationClick}
- zIndex={annotation.zIndex}
- isSelectedBoost={annotation.id === selectedAnnotationId}
- />
- ));
- })()}
-