2114 lines
67 KiB
TypeScript
2114 lines
67 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 {
|
||
createNativeCursorMotionBlurState,
|
||
createNativeCursorSmoothingState,
|
||
getNativeCursorClickBounceProgress,
|
||
getNativeCursorClickBounceScale,
|
||
getNativeCursorMotionBlurPx,
|
||
hasNativeCursorRecordingData,
|
||
projectNativeCursorToLocal,
|
||
projectNativeCursorToStage,
|
||
resetNativeCursorMotionBlurState,
|
||
resetNativeCursorSmoothingState,
|
||
resolveInterpolatedNativeCursorFrame,
|
||
resolveNativeCursorRenderAsset,
|
||
smoothNativeCursorSample,
|
||
} 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 {
|
||
DEFAULT_CURSOR_SETTINGS,
|
||
DEFAULT_EDITOR_LAYOUT_SETTINGS,
|
||
DEFAULT_SOURCE_DIMENSIONS,
|
||
} from "./editorDefaults";
|
||
import {
|
||
type AnnotationRegion,
|
||
type BlurData,
|
||
type CursorTelemetryPoint,
|
||
computeRotation3DContainScale,
|
||
DEFAULT_ROTATION_3D,
|
||
isRotation3DIdentity,
|
||
lerpRotation3D,
|
||
rotation3DPerspective,
|
||
type SpeedRegion,
|
||
type TrimRegion,
|
||
ZOOM_DEPTH_SCALES,
|
||
type ZoomDepth,
|
||
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, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||
import {
|
||
DEFAULT_CURSOR_CONFIG,
|
||
PixiCursorOverlay,
|
||
preloadCursorAssets,
|
||
} from "./videoPlayback/cursorRenderer";
|
||
import { clampFocusToStage as clampFocusToStageUtil } 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";
|
||
|
||
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[];
|
||
cursorClickTimestamps?: number[];
|
||
showCursor?: boolean;
|
||
cursorSize?: number;
|
||
cursorSmoothing?: number;
|
||
cursorMotionBlur?: number;
|
||
cursorClickBounce?: number;
|
||
cursorClipToBounds?: boolean;
|
||
// When true, render the selected zoom at the playhead even while paused —
|
||
// lets the editor preview the zoom effect without leaving the focus-edit view.
|
||
isPreviewingZoom?: boolean;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function getEndedVideoDuration(video: HTMLVideoElement): number | null {
|
||
const currentTime = video.currentTime;
|
||
if (!Number.isFinite(currentTime) || currentTime <= 0) {
|
||
return null;
|
||
}
|
||
|
||
if (video.ended) {
|
||
return currentTime;
|
||
}
|
||
|
||
const resolvedDuration = getResolvedVideoDuration(video);
|
||
const durationEpsilonSeconds = 0.05;
|
||
if (resolvedDuration && currentTime >= resolvedDuration - durationEpsilonSeconds) {
|
||
return resolvedDuration;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
type AudioTrackListLike = {
|
||
length: number;
|
||
[index: number]: { enabled: boolean };
|
||
};
|
||
|
||
type VideoElementWithAudioTracks = HTMLVideoElement & {
|
||
audioTracks?: AudioTrackListLike;
|
||
};
|
||
|
||
function enableAllPreviewAudioTracks(video: HTMLVideoElement) {
|
||
const audioTracks = (video as VideoElementWithAudioTracks).audioTracks;
|
||
if (!audioTracks || audioTracks.length <= 1) {
|
||
return;
|
||
}
|
||
|
||
for (let index = 0; index < audioTracks.length; index += 1) {
|
||
audioTracks[index].enabled = true;
|
||
}
|
||
}
|
||
|
||
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 = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||
cropRegion,
|
||
trimRegions = [],
|
||
speedRegions = [],
|
||
aspectRatio,
|
||
cursorRecordingData,
|
||
annotationRegions = [],
|
||
selectedAnnotationId,
|
||
onSelectAnnotation,
|
||
onAnnotationPositionChange,
|
||
onAnnotationSizeChange,
|
||
blurRegions = [],
|
||
selectedBlurId,
|
||
onSelectBlur,
|
||
onBlurPositionChange,
|
||
onBlurSizeChange,
|
||
onBlurDataChange,
|
||
onBlurDataCommit,
|
||
cursorTelemetry = [],
|
||
cursorClickTimestamps = [],
|
||
showCursor = false,
|
||
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
|
||
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
|
||
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
|
||
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
|
||
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
|
||
isPreviewingZoom = false,
|
||
},
|
||
ref,
|
||
) => {
|
||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
const supplementalAudioRef = useRef<HTMLAudioElement | 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 timeUpdateAnimationRef = useRef<number | null>(null);
|
||
const [pixiReady, setPixiReady] = useState(false);
|
||
const [videoReady, setVideoReady] = useState(false);
|
||
const [supplementalAudioPath, setSupplementalAudioPath] = useState<string | null>(null);
|
||
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 cursorClickTimestampsRef = useRef<number[]>([]);
|
||
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 cursorClipToBoundsRef = useRef(cursorClipToBounds);
|
||
const isPreviewingZoomRef = useRef(isPreviewingZoom);
|
||
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 durationResolutionTimeoutRef = useRef<number | null>(null);
|
||
const lastResolvedDurationRef = useRef<number | null>(null);
|
||
const isResolvingDurationRef = useRef(false);
|
||
const hasNativeCursorRecordingRef = useRef(false);
|
||
const cursorRecordingDataRef = useRef(cursorRecordingData);
|
||
const cropRegionRef = useRef(cropRegion);
|
||
const nativeCursorSpriteRef = useRef<Sprite | null>(null);
|
||
const nativeCursorTextureIdRef = useRef<string | null>(null);
|
||
const nativeCursorImageRef = useRef<HTMLImageElement | null>(null);
|
||
const nativeCursorImageIdRef = useRef<string | null>(null);
|
||
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
|
||
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
|
||
const nativeCursorClipRef = useRef<HTMLDivElement | null>(null);
|
||
const borderRadiusRef = useRef<number>(0);
|
||
|
||
const hasNativeCursorRecording = useMemo(
|
||
() => hasNativeCursorRecordingData(cursorRecordingData),
|
||
[cursorRecordingData],
|
||
);
|
||
|
||
const syncResolvedDuration = useCallback(
|
||
(video: HTMLVideoElement) => {
|
||
const resolvedDuration = getResolvedVideoDuration(video);
|
||
if (!resolvedDuration) {
|
||
return false;
|
||
}
|
||
|
||
const normalizedDuration = Math.round(resolvedDuration * 1000) / 1000;
|
||
if (lastResolvedDurationRef.current !== normalizedDuration) {
|
||
lastResolvedDurationRef.current = normalizedDuration;
|
||
onDurationChange(normalizedDuration);
|
||
}
|
||
|
||
return true;
|
||
},
|
||
[onDurationChange],
|
||
);
|
||
|
||
const forceResolveDuration = useCallback(
|
||
(video: HTMLVideoElement) => {
|
||
if (isResolvingDurationRef.current) {
|
||
return;
|
||
}
|
||
|
||
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||
return;
|
||
}
|
||
|
||
isResolvingDurationRef.current = true;
|
||
const previousMuted = video.muted;
|
||
|
||
const finalize = () => {
|
||
video.removeEventListener("durationchange", handleProgress);
|
||
video.removeEventListener("timeupdate", handleProgress);
|
||
video.removeEventListener("loadeddata", handleProgress);
|
||
video.removeEventListener("ended", handleProgress);
|
||
if (durationResolutionTimeoutRef.current) {
|
||
clearTimeout(durationResolutionTimeoutRef.current);
|
||
durationResolutionTimeoutRef.current = null;
|
||
}
|
||
video.muted = previousMuted;
|
||
isResolvingDurationRef.current = false;
|
||
};
|
||
|
||
const resolveCurrentDuration = () => {
|
||
if (syncResolvedDuration(video)) {
|
||
return true;
|
||
}
|
||
|
||
const endedDuration = getEndedVideoDuration(video);
|
||
if (endedDuration) {
|
||
lastResolvedDurationRef.current = null;
|
||
onDurationChange(Math.round(endedDuration * 1000) / 1000);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
};
|
||
|
||
const handleProgress = () => {
|
||
if (!resolveCurrentDuration()) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
video.pause();
|
||
video.currentTime = 0;
|
||
} catch {
|
||
// no-op
|
||
}
|
||
currentTimeRef.current = 0;
|
||
finalize();
|
||
};
|
||
|
||
video.addEventListener("durationchange", handleProgress);
|
||
video.addEventListener("timeupdate", handleProgress);
|
||
video.addEventListener("loadeddata", handleProgress);
|
||
video.addEventListener("ended", handleProgress);
|
||
durationResolutionTimeoutRef.current = window.setTimeout(() => {
|
||
handleProgress();
|
||
finalize();
|
||
}, 1500);
|
||
video.muted = true;
|
||
|
||
const playAttempt = video.play();
|
||
if (playAttempt && typeof playAttempt.catch === "function") {
|
||
playAttempt.catch(() => {
|
||
try {
|
||
video.currentTime = Math.max(video.currentTime, 0.1);
|
||
} catch {
|
||
finalize();
|
||
}
|
||
});
|
||
}
|
||
|
||
try {
|
||
video.currentTime = Math.max(video.currentTime, 0.1);
|
||
} catch {
|
||
finalize();
|
||
}
|
||
},
|
||
[onDurationChange, syncResolvedDuration],
|
||
);
|
||
|
||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||
}, []);
|
||
|
||
const updateOverlayForRegion = useCallback(
|
||
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
|
||
const overlayEl = overlayRef.current;
|
||
const indicatorEl = focusIndicatorRef.current;
|
||
|
||
if (!overlayEl || !indicatorEl) {
|
||
return;
|
||
}
|
||
|
||
// Update stage size from overlay dimensions
|
||
const stageWidth = overlayEl.clientWidth;
|
||
const stageHeight = overlayEl.clientHeight;
|
||
if (stageWidth && stageHeight) {
|
||
stageSizeRef.current = { width: stageWidth, height: stageHeight };
|
||
}
|
||
|
||
updateOverlayIndicator({
|
||
overlayEl,
|
||
indicatorEl,
|
||
region,
|
||
focusOverride,
|
||
videoSize: videoSizeRef.current,
|
||
baseScale: baseScaleRef.current,
|
||
isPlaying: isPlayingRef.current,
|
||
});
|
||
},
|
||
[],
|
||
);
|
||
|
||
const layoutVideoContent = useCallback(() => {
|
||
const container = containerRef.current;
|
||
const app = appRef.current;
|
||
const videoSprite = videoSpriteRef.current;
|
||
const maskGraphics = maskGraphicsRef.current;
|
||
const videoElement = videoRef.current;
|
||
const cameraContainer = cameraContainerRef.current;
|
||
|
||
if (
|
||
!container ||
|
||
!app ||
|
||
!videoSprite ||
|
||
!maskGraphics ||
|
||
!videoElement ||
|
||
!cameraContainer
|
||
) {
|
||
return;
|
||
}
|
||
|
||
// Lock video dimensions on first layout to prevent resize issues
|
||
if (
|
||
!lockedVideoDimensionsRef.current &&
|
||
videoElement.videoWidth > 0 &&
|
||
videoElement.videoHeight > 0
|
||
) {
|
||
lockedVideoDimensionsRef.current = {
|
||
width: videoElement.videoWidth,
|
||
height: videoElement.videoHeight,
|
||
};
|
||
}
|
||
|
||
const result = layoutVideoContentUtil({
|
||
container,
|
||
app,
|
||
videoSprite,
|
||
maskGraphics,
|
||
videoElement,
|
||
cropRegion,
|
||
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
||
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;
|
||
borderRadiusRef.current = result.maskBorderRadius;
|
||
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;
|
||
enableAllPreviewAudioTracks(vid);
|
||
await vid.play().catch((err) => {
|
||
console.log("PLAY ERROR:", err);
|
||
throw err;
|
||
});
|
||
const supplementalAudio = supplementalAudioRef.current;
|
||
if (supplementalAudio) {
|
||
supplementalAudio.currentTime = vid.currentTime;
|
||
supplementalAudio.playbackRate = vid.playbackRate;
|
||
await supplementalAudio.play().catch(() => {
|
||
// The main video remains the source of truth for playback state.
|
||
});
|
||
}
|
||
} catch (error) {
|
||
allowPlaybackRef.current = false;
|
||
throw error;
|
||
}
|
||
},
|
||
pause: () => {
|
||
const video = videoRef.current;
|
||
allowPlaybackRef.current = false;
|
||
if (!video) {
|
||
return;
|
||
}
|
||
video.pause();
|
||
supplementalAudioRef.current?.pause();
|
||
},
|
||
}));
|
||
|
||
const updateFocusFromClientPoint = (clientX: number, clientY: number) => {
|
||
const overlayEl = overlayRef.current;
|
||
if (!overlayEl) return;
|
||
|
||
const regionId = selectedZoomIdRef.current;
|
||
if (!regionId) return;
|
||
|
||
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
|
||
if (!region) return;
|
||
|
||
const rect = overlayEl.getBoundingClientRect();
|
||
const stageWidth = rect.width;
|
||
const stageHeight = rect.height;
|
||
|
||
if (!stageWidth || !stageHeight) {
|
||
return;
|
||
}
|
||
|
||
stageSizeRef.current = { width: stageWidth, height: stageHeight };
|
||
|
||
const localX = clientX - rect.left;
|
||
const localY = clientY - rect.top;
|
||
|
||
const unclampedFocus: ZoomFocus = {
|
||
cx: clamp01(localX / stageWidth),
|
||
cy: clamp01(localY / stageHeight),
|
||
};
|
||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||
|
||
onZoomFocusChange(region.id, clampedFocus);
|
||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||
};
|
||
|
||
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (isPlayingRef.current) return;
|
||
const regionId = selectedZoomIdRef.current;
|
||
if (!regionId) return;
|
||
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
|
||
if (!region) return;
|
||
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(() => {
|
||
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;
|
||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||
}, [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]);
|
||
|
||
useEffect(() => {
|
||
cursorClipToBoundsRef.current = cursorClipToBounds;
|
||
}, [cursorClipToBounds]);
|
||
|
||
useEffect(() => {
|
||
isPreviewingZoomRef.current = isPreviewingZoom;
|
||
}, [isPreviewingZoom]);
|
||
|
||
// 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 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;
|
||
}
|
||
|
||
setPixiReady(true);
|
||
})();
|
||
|
||
return () => {
|
||
mounted = false;
|
||
setPixiReady(false);
|
||
if (cursorOverlayRef.current) {
|
||
cursorOverlayRef.current.destroy();
|
||
cursorOverlayRef.current = null;
|
||
}
|
||
nativeCursorSpriteRef.current = null;
|
||
nativeCursorTextureIdRef.current = null;
|
||
nativeCursorImageIdRef.current = null;
|
||
if (app && app.renderer) {
|
||
app.destroy(true, {
|
||
children: true,
|
||
texture: true,
|
||
textureSource: true,
|
||
});
|
||
}
|
||
appRef.current = null;
|
||
cameraContainerRef.current = null;
|
||
videoContainerRef.current = null;
|
||
videoSpriteRef.current = null;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!videoPath) {
|
||
lastResolvedDurationRef.current = null;
|
||
isResolvingDurationRef.current = false;
|
||
setVideoReady(false);
|
||
setSupplementalAudioPath(null);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
window.electronAPI
|
||
?.preparePreviewAudioTrack?.(videoPath)
|
||
.then((result) => {
|
||
if (!cancelled) {
|
||
setSupplementalAudioPath(result.success ? (result.path ?? null) : null);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setSupplementalAudioPath(null);
|
||
}
|
||
});
|
||
|
||
const video = videoRef.current;
|
||
if (!video) {
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}
|
||
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();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [videoPath]);
|
||
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
const supplementalAudio = supplementalAudioRef.current;
|
||
if (!video || !supplementalAudio || !supplementalAudioPath) {
|
||
return;
|
||
}
|
||
|
||
const activeSpeedRegion =
|
||
speedRegions.find(
|
||
(region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
|
||
) ?? null;
|
||
supplementalAudio.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||
|
||
if (!isPlaying) {
|
||
supplementalAudio.pause();
|
||
if (Math.abs(supplementalAudio.currentTime - currentTime) > 0.05) {
|
||
supplementalAudio.currentTime = currentTime;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (Math.abs(supplementalAudio.currentTime - video.currentTime) > 0.15) {
|
||
supplementalAudio.currentTime = video.currentTime;
|
||
}
|
||
|
||
supplementalAudio.play().catch(() => {
|
||
// Keep video playback running even if supplemental preview audio is unavailable.
|
||
});
|
||
}, [currentTime, isPlaying, speedRegions, supplementalAudioPath]);
|
||
|
||
useEffect(() => {
|
||
if (!pixiReady || !videoReady) return;
|
||
|
||
const video = videoRef.current;
|
||
const app = appRef.current;
|
||
const videoContainer = videoContainerRef.current;
|
||
|
||
if (!video || !app || !videoContainer) return;
|
||
if (video.videoWidth === 0 || video.videoHeight === 0) return;
|
||
|
||
const source = VideoSource.from(video);
|
||
if ("autoPlay" in source) {
|
||
(source as { autoPlay?: boolean }).autoPlay = false;
|
||
}
|
||
if ("autoUpdate" in source) {
|
||
(source as { autoUpdate?: boolean }).autoUpdate = true;
|
||
}
|
||
const videoTexture = Texture.from(source);
|
||
|
||
const videoSprite = new Sprite(videoTexture);
|
||
videoSpriteRef.current = videoSprite;
|
||
|
||
const maskGraphics = new Graphics();
|
||
videoContainer.addChild(videoSprite);
|
||
videoContainer.addChild(maskGraphics);
|
||
videoContainer.mask = maskGraphics;
|
||
maskGraphicsRef.current = maskGraphics;
|
||
const nativeCursorSprite = new Sprite(Texture.EMPTY);
|
||
nativeCursorSprite.visible = false;
|
||
nativeCursorSprite.eventMode = "none";
|
||
nativeCursorSpriteRef.current = nativeCursorSprite;
|
||
if (cursorOverlayRef.current) {
|
||
videoContainer.addChild(cursorOverlayRef.current.container);
|
||
}
|
||
|
||
videoContainer.addChild(nativeCursorSprite);
|
||
|
||
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 (nativeCursorSpriteRef.current) {
|
||
videoContainer.removeChild(nativeCursorSpriteRef.current);
|
||
nativeCursorSpriteRef.current.destroy();
|
||
nativeCursorSpriteRef.current = null;
|
||
nativeCursorTextureIdRef.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 && !isPreviewingZoomRef.current;
|
||
|
||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||
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 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,
|
||
);
|
||
}
|
||
|
||
// Keep the native cursor preview in the same transformed coordinate space as PIXI.
|
||
const nativeCursorSprite = nativeCursorSpriteRef.current;
|
||
const nativeCursorImage = nativeCursorImageRef.current;
|
||
const hideNativeCursorPreview = () => {
|
||
if (nativeCursorSprite) {
|
||
nativeCursorSprite.visible = false;
|
||
}
|
||
if (nativeCursorImage) {
|
||
nativeCursorImage.style.display = "none";
|
||
nativeCursorImage.style.filter = "none";
|
||
}
|
||
if (nativeCursorClipRef.current) {
|
||
nativeCursorClipRef.current.style.clipPath = "";
|
||
}
|
||
resetNativeCursorSmoothingState(nativeCursorSmoothingStateRef.current);
|
||
resetNativeCursorMotionBlurState(nativeCursorMotionBlurStateRef.current);
|
||
};
|
||
if (nativeCursorImage) {
|
||
if (hasNativeCursorRecordingRef.current && showCursorRef.current) {
|
||
const timeMs = currentTimeRef.current; // already in ms
|
||
const frame = resolveInterpolatedNativeCursorFrame(
|
||
cursorRecordingDataRef.current,
|
||
timeMs,
|
||
);
|
||
if (frame) {
|
||
const displaySample = smoothNativeCursorSample({
|
||
forceSnap: !isPlayingRef.current || isSeekingRef.current,
|
||
sample: frame.sample,
|
||
smoothing: cursorSmoothingRef.current,
|
||
state: nativeCursorSmoothingStateRef.current,
|
||
timeMs,
|
||
});
|
||
const cameraContainer = cameraContainerRef.current;
|
||
const videoContainer = videoContainerRef.current;
|
||
const cropRegionValue = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 };
|
||
const projectedLocalPoint = projectNativeCursorToLocal({
|
||
cropRegion: cropRegionValue,
|
||
maskRect: baseMaskRef.current,
|
||
sample: displaySample,
|
||
});
|
||
const projectedStagePoint =
|
||
cameraContainer && videoContainer
|
||
? projectNativeCursorToStage({
|
||
cameraContainer,
|
||
cropRegion: cropRegionValue,
|
||
maskRect: baseMaskRef.current,
|
||
videoContainerPosition: {
|
||
x: videoContainer.x,
|
||
y: videoContainer.y,
|
||
},
|
||
sample: displaySample,
|
||
})
|
||
: null;
|
||
if (projectedLocalPoint && projectedStagePoint) {
|
||
// Pass deviceScaleFactor=1 — asset.scaleFactor already encodes DPR.
|
||
// Size is normalized below so preview matches export proportionally.
|
||
const renderAsset = resolveNativeCursorRenderAsset(frame.asset, 1, displaySample);
|
||
const bounceProgress = getNativeCursorClickBounceProgress(
|
||
cursorRecordingDataRef.current,
|
||
timeMs,
|
||
);
|
||
const scale =
|
||
Math.max(0, cursorSizeRef.current) *
|
||
getNativeCursorClickBounceScale(cursorClickBounceRef.current, bounceProgress);
|
||
// Normalize cursor size to the displayed video width so the cursor
|
||
// appears at the same fraction of the video in both preview and export.
|
||
const crop = cropRegionRef.current ?? { x: 0, y: 0, width: 1, height: 1 };
|
||
const croppedVideoWidth = (videoRef.current?.videoWidth ?? 0) * crop.width;
|
||
const sizeNorm =
|
||
croppedVideoWidth > 0 ? baseMaskRef.current.width / croppedVideoWidth : 1;
|
||
const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1) * sizeNorm;
|
||
const blurPx =
|
||
!isPlayingRef.current || isSeekingRef.current
|
||
? 0
|
||
: getNativeCursorMotionBlurPx({
|
||
motionBlur: cursorMotionBlurRef.current,
|
||
point: projectedStagePoint,
|
||
state: nativeCursorMotionBlurStateRef.current,
|
||
timeMs,
|
||
});
|
||
if (nativeCursorImageIdRef.current !== renderAsset.id) {
|
||
nativeCursorImage.src = renderAsset.imageDataUrl;
|
||
nativeCursorImageIdRef.current = renderAsset.id;
|
||
}
|
||
nativeCursorImage.style.display = "block";
|
||
// Update clip-path on nativeCursorClipRef to the camera-aware video boundary.
|
||
// clip-path works correctly here because nativeCursorClipRef is outside preserve-3d.
|
||
// When cursorClipToBounds is off, allow the cursor to overflow the canvas.
|
||
if (nativeCursorClipRef.current) {
|
||
if (!cursorClipToBoundsRef.current) {
|
||
nativeCursorClipRef.current.style.clipPath = "none";
|
||
} else {
|
||
const mask = baseMaskRef.current;
|
||
const stage = stageSizeRef.current;
|
||
const br = borderRadiusRef.current;
|
||
const s = cameraContainer ? Math.abs(cameraContainer.scale.x) : 1;
|
||
const camX = cameraContainer ? cameraContainer.position.x : 0;
|
||
const camY = cameraContainer ? cameraContainer.position.y : 0;
|
||
const clipLeft = camX + s * mask.x;
|
||
const clipTop = camY + s * mask.y;
|
||
const clipRight = camX + s * (mask.x + mask.width);
|
||
const clipBottom = camY + s * (mask.y + mask.height);
|
||
nativeCursorClipRef.current.style.clipPath = `inset(${clipTop}px ${stage.width - clipRight}px ${stage.height - clipBottom}px ${clipLeft}px round ${br * s}px)`;
|
||
}
|
||
}
|
||
nativeCursorImage.style.width = `${renderAsset.width * transformedScale}px`;
|
||
nativeCursorImage.style.height = `${renderAsset.height * transformedScale}px`;
|
||
nativeCursorImage.style.filter =
|
||
blurPx > 0 ? `blur(${blurPx.toFixed(2)}px)` : "none";
|
||
// translate3d is relative to nativeCursorClipRef (absolute inset-0 = stage origin).
|
||
// projectedStagePoint.x is the stage-space cursor position — no offset needed.
|
||
nativeCursorImage.style.transform = `translate3d(${
|
||
projectedStagePoint.x - renderAsset.hotspotX * transformedScale
|
||
}px, ${projectedStagePoint.y - renderAsset.hotspotY * transformedScale}px, 0)`;
|
||
if (nativeCursorSprite) {
|
||
nativeCursorSprite.visible = false;
|
||
if (nativeCursorTextureIdRef.current !== renderAsset.id) {
|
||
nativeCursorSprite.texture = Texture.from(renderAsset.imageDataUrl);
|
||
nativeCursorTextureIdRef.current = renderAsset.id;
|
||
}
|
||
nativeCursorSprite.position.set(
|
||
projectedLocalPoint.x - renderAsset.hotspotX * scale,
|
||
projectedLocalPoint.y - renderAsset.hotspotY * scale,
|
||
);
|
||
nativeCursorSprite.width = renderAsset.width * scale;
|
||
nativeCursorSprite.height = renderAsset.height * scale;
|
||
}
|
||
} else {
|
||
hideNativeCursorPreview();
|
||
}
|
||
} else {
|
||
hideNativeCursorPreview();
|
||
}
|
||
} else {
|
||
hideNativeCursorPreview();
|
||
}
|
||
} else {
|
||
hideNativeCursorPreview();
|
||
}
|
||
|
||
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 (nativeCursorClipRef.current) {
|
||
nativeCursorClipRef.current.style.transform = "";
|
||
}
|
||
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";
|
||
if (nativeCursorClipRef.current) {
|
||
nativeCursorClipRef.current.style.transform = composite3D.style.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;
|
||
enableAllPreviewAudioTracks(video);
|
||
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 || DEFAULT_SOURCE_DIMENSIONS.width,
|
||
lockedVideoDimensionsRef.current?.height || DEFAULT_SOURCE_DIMENSIONS.height,
|
||
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" }}
|
||
/>
|
||
{(() => {
|
||
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 needsPreviewSnapshot =
|
||
filteredBlurRegions.length > 0 ||
|
||
filteredAnnotations.some((annotation) => annotation.type === "magnifier");
|
||
const previewSnapshotCanvas = needsPreviewSnapshot
|
||
? (() => {
|
||
const app = appRef.current;
|
||
if (!app?.renderer?.extract) return null;
|
||
try {
|
||
return app.renderer.extract.canvas(app.stage);
|
||
} catch {
|
||
return null;
|
||
}
|
||
})()
|
||
: 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={Math.round(currentTime * 1000)}
|
||
/>
|
||
));
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Clip the native cursor overlay to the exact video canvas boundary.
|
||
Placed OUTSIDE composite3DRef (preserve-3d) so clip-path works
|
||
correctly even during 3D zoom rotation regions.
|
||
clip-path is set dynamically to the camera-aware video bounds. */}
|
||
<div
|
||
ref={nativeCursorClipRef}
|
||
className="absolute inset-0"
|
||
style={{ zIndex: 18, pointerEvents: "none" }}
|
||
>
|
||
<img
|
||
ref={nativeCursorImageRef}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute left-0 top-0 select-none"
|
||
style={{
|
||
display: "none",
|
||
pointerEvents: "none",
|
||
transformOrigin: "0 0",
|
||
}}
|
||
/>
|
||
</div>
|
||
<video
|
||
ref={videoRef}
|
||
src={videoPath}
|
||
className="hidden"
|
||
preload="auto"
|
||
playsInline
|
||
onLoadedMetadata={handleLoadedMetadata}
|
||
onDurationChange={(e) => {
|
||
enableAllPreviewAudioTracks(e.currentTarget);
|
||
if (!syncResolvedDuration(e.currentTarget)) {
|
||
forceResolveDuration(e.currentTarget);
|
||
}
|
||
}}
|
||
onLoadedData={(e) => {
|
||
enableAllPreviewAudioTracks(e.currentTarget);
|
||
if (!syncResolvedDuration(e.currentTarget)) {
|
||
forceResolveDuration(e.currentTarget);
|
||
}
|
||
}}
|
||
onCanPlay={(e) => {
|
||
enableAllPreviewAudioTracks(e.currentTarget);
|
||
if (!syncResolvedDuration(e.currentTarget)) {
|
||
forceResolveDuration(e.currentTarget);
|
||
}
|
||
}}
|
||
onError={() => onError("Failed to load video")}
|
||
/>
|
||
{supplementalAudioPath && (
|
||
<audio ref={supplementalAudioRef} src={supplementalAudioPath} preload="auto" />
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
);
|
||
|
||
VideoPlayback.displayName = "VideoPlayback";
|
||
|
||
export default VideoPlayback;
|