diff --git a/.gitignore b/.gitignore index 3784c0cf8..5c59a1678 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ electron/native/bin/*/whisper-runtime.json tmp-*.ps1 .tmp-*.ps1 gpu-export-probe.mp4 +.claude/ diff --git a/package-lock.json b/package-lock.json index 87299bfab..bd7d4cf59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5507,13 +5507,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", - "integrity": "sha512-qsJ8/X+UypqxHXN75M7dF88jNK37dLBRW7LeUzCPz+TNs37G8cfWy9nWzS+LS//g600zrt2le9KuXt0rWfDz5Q==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bcrypt-pbkdf": { @@ -5861,9 +5864,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001749", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", - "integrity": "sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, "funding": [ { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 7c93899a0..b2d61d7ba 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; +import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; +import { shouldRestoreHudMousePassthroughAfterDrag } from "../hudMousePassthrough"; const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; @@ -26,6 +27,7 @@ export function useWebcamPreviewOverlay({ const previewStreamRef = useRef(null); const previewDragMoveRafRef = useRef(null); const previewDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + const previewDragWindowCleanupRef = useRef<(() => void) | null>(null); const webcamPreviewDragStartRef = useRef<{ pointerId: number; startX: number; @@ -57,10 +59,130 @@ export function useWebcamPreviewOverlay({ } webcamPreviewDragStartRef.current = null; isWebcamPreviewDraggingRef.current = false; + previewDragWindowCleanupRef.current?.(); + previewDragWindowCleanupRef.current = null; setShowFloatingWebcamPreview(true); } }, [webcamEnabled]); + const updateWebcamPreviewDrag = useCallback( + (pointerId: number, clientX: number, clientY: number) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== pointerId) { + return; + } + + const deltaX = clientX - dragState.startX; + const deltaY = clientY - dragState.startY; + + if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) { + return; + } + + if (!dragState.dragging) { + dragState.dragging = true; + isWebcamPreviewDraggingRef.current = true; + } + + previewDragPendingPointerRef.current = { clientX, clientY }; + if (previewDragMoveRafRef.current !== null) { + return; + } + + previewDragMoveRafRef.current = requestAnimationFrame(() => { + previewDragMoveRafRef.current = null; + const latestDragState = webcamPreviewDragStartRef.current; + const pointer = previewDragPendingPointerRef.current; + if (!latestDragState || !pointer) { + return; + } + + const latestDeltaX = pointer.clientX - latestDragState.startX; + const latestDeltaY = pointer.clientY - latestDragState.startY; + const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); + const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); + const unclampedLeft = latestDragState.initialLeft + latestDeltaX; + const unclampedTop = latestDragState.initialTop + latestDeltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - latestDragState.previewWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - latestDragState.previewHeight), + ); + + const nextOffset = { + x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft), + y: latestDragState.originY + (clampedTop - latestDragState.initialTop), + }; + webcamPreviewOffsetRef.current = nextOffset; + if (recordingWebcamPreviewContainerRef.current) { + recordingWebcamPreviewContainerRef.current.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`; + } + }); + }, + [], + ); + + const finishWebcamPreviewDrag = useCallback( + ( + pointerId: number, + clientX: number, + clientY: number, + captureTarget?: HTMLDivElement | null, + ) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== pointerId) { + return; + } + + previewDragWindowCleanupRef.current?.(); + previewDragWindowCleanupRef.current = null; + + if (previewDragMoveRafRef.current !== null) { + cancelAnimationFrame(previewDragMoveRafRef.current); + previewDragMoveRafRef.current = null; + } + previewDragPendingPointerRef.current = null; + + const wasDragging = dragState.dragging; + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + setWebcamPreviewOffset({ ...webcamPreviewOffsetRef.current }); + + if (captureTarget) { + try { + if (captureTarget.hasPointerCapture(pointerId)) { + captureTarget.releasePointerCapture(pointerId); + } + } catch { + // Chromium can drop pointer capture while Electron toggles mouse passthrough. + } + } + + if (wasDragging) { + const bounds = recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(); + const shouldRestorePassthrough = shouldRestoreHudMousePassthroughAfterDrag( + bounds + ? { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + } + : null, + clientX, + clientY, + ); + if (shouldRestorePassthrough) { + setTimeout(() => window.electronAPI?.hudOverlaySetIgnoreMouse?.(true), 0); + } + } + }, + [], + ); + const handleWebcamPreviewPointerDown = useCallback( (event: PointerEvent) => { if (event.button !== 0) { @@ -71,6 +193,7 @@ export function useWebcamPreviewOverlay({ event.preventDefault(); window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + previewDragWindowCleanupRef.current?.(); webcamPreviewDragStartRef.current = { pointerId: event.pointerId, startX: event.clientX, @@ -83,90 +206,55 @@ export function useWebcamPreviewOverlay({ previewHeight: previewRect.height, dragging: false, }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [], - ); - - const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent) => { - const dragState = webcamPreviewDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - const deltaX = event.clientX - dragState.startX; - const deltaY = event.clientY - dragState.startY; - - if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) { - return; - } - - if (!dragState.dragging) { - dragState.dragging = true; - isWebcamPreviewDraggingRef.current = true; - } - previewDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY }; - if (previewDragMoveRafRef.current !== null) { - return; - } - - previewDragMoveRafRef.current = requestAnimationFrame(() => { - previewDragMoveRafRef.current = null; - const latestDragState = webcamPreviewDragStartRef.current; - const pointer = previewDragPendingPointerRef.current; - if (!latestDragState || !pointer) { - return; + const target = event.currentTarget; + try { + target.setPointerCapture(event.pointerId); + } catch { + // Keep dragging through window listeners if pointer capture is unavailable. } - const latestDeltaX = pointer.clientX - latestDragState.startX; - const latestDeltaY = pointer.clientY - latestDragState.startY; - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - const unclampedLeft = latestDragState.initialLeft + latestDeltaX; - const unclampedTop = latestDragState.initialTop + latestDeltaY; - const clampedLeft = Math.min( - Math.max(0, unclampedLeft), - Math.max(0, viewportWidth - latestDragState.previewWidth), - ); - const clampedTop = Math.min( - Math.max(0, unclampedTop), - Math.max(0, viewportHeight - latestDragState.previewHeight), - ); - - const nextOffset = { - x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft), - y: latestDragState.originY + (clampedTop - latestDragState.initialTop), + const handleWindowPointerMove = (moveEvent: globalThis.PointerEvent) => { + updateWebcamPreviewDrag(moveEvent.pointerId, moveEvent.clientX, moveEvent.clientY); }; - webcamPreviewOffsetRef.current = nextOffset; - if (recordingWebcamPreviewContainerRef.current) { - recordingWebcamPreviewContainerRef.current.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`; - } - }); - }, []); + const handleWindowPointerUp = (upEvent: globalThis.PointerEvent) => { + finishWebcamPreviewDrag( + upEvent.pointerId, + upEvent.clientX, + upEvent.clientY, + target, + ); + }; + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerUp); + previewDragWindowCleanupRef.current = () => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerUp); + }; + }, + [finishWebcamPreviewDrag, updateWebcamPreviewDrag], + ); - const handleWebcamPreviewPointerUp = useCallback((event: PointerEvent) => { - const dragState = webcamPreviewDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - if (previewDragMoveRafRef.current !== null) { - cancelAnimationFrame(previewDragMoveRafRef.current); - previewDragMoveRafRef.current = null; - } - previewDragPendingPointerRef.current = null; - - const wasDragging = dragState.dragging; - webcamPreviewDragStartRef.current = null; - isWebcamPreviewDraggingRef.current = false; - setWebcamPreviewOffset({ ...webcamPreviewOffsetRef.current }); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - if (wasDragging) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, []); + const handleWebcamPreviewPointerMove = useCallback( + (event: PointerEvent) => { + updateWebcamPreviewDrag(event.pointerId, event.clientX, event.clientY); + }, + [updateWebcamPreviewDrag], + ); + + const handleWebcamPreviewPointerUp = useCallback( + (event: PointerEvent) => { + finishWebcamPreviewDrag( + event.pointerId, + event.clientX, + event.clientY, + event.currentTarget, + ); + }, + [finishWebcamPreviewDrag], + ); const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => { const previewStream = previewStreamRef.current; @@ -204,8 +292,12 @@ export function useWebcamPreviewOverlay({ if (previewDragMoveRafRef.current !== null) { cancelAnimationFrame(previewDragMoveRafRef.current); } + previewDragWindowCleanupRef.current?.(); + previewDragWindowCleanupRef.current = null; previewDragMoveRafRef.current = null; previewDragPendingPointerRef.current = null; + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; }; }, []); @@ -225,12 +317,12 @@ export function useWebcamPreviewOverlay({ width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - } + } : { width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - }, + }, audio: false, }); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index cff898a53..cfc1d0e3f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -9,6 +9,7 @@ import { import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { Button } from "@/components/ui/button"; import { Select, @@ -40,7 +41,6 @@ import { isVideoWallpaperSource, } from "@/lib/wallpapers"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; -import minimalCursorUrl from "@/assets/cursors/custom/minimal-cursor.svg"; import { useI18n, useScopedT } from "../../contexts/I18nContext"; import type { AppLocale } from "../../i18n/config"; import { SUPPORTED_LOCALES } from "../../i18n/config"; @@ -64,8 +64,11 @@ import type { EditorEffectSection, FigureData, Padding, + WebcamFocusRegion, WebcamOverlaySettings, WebcamPositionPreset, + WebcamPositionRegion, + WebcamSizeRegion, ZoomDepth, ZoomMode, ZoomMotionBlurTuning, @@ -81,7 +84,13 @@ import { DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, DEFAULT_PADDING, + DEFAULT_WEBCAM_AVOID_CURSOR, DEFAULT_WEBCAM_CORNER_RADIUS, + DEFAULT_WEBCAM_FOCUS_SCREEN_MODE, + DEFAULT_WEBCAM_FOCUS_SCREEN_PIP_SIZE, + DEFAULT_WEBCAM_FOCUS_SIZE, + DEFAULT_WEBCAM_FOCUS_TRANSITION_IN_MS, + DEFAULT_WEBCAM_FOCUS_TRANSITION_OUT_MS, DEFAULT_WEBCAM_MARGIN, DEFAULT_WEBCAM_POSITION_PRESET, DEFAULT_WEBCAM_POSITION_X, @@ -89,6 +98,8 @@ import { DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE, + DEFAULT_WEBCAM_SIZE_TRANSITION_IN_MS, + DEFAULT_WEBCAM_SIZE_TRANSITION_OUT_MS, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_MOTION_BLUR_TUNING, DEFAULT_ZOOM_OUT_DURATION_MS, @@ -277,7 +288,11 @@ function ExtensionSettingsSection({
void; onUploadWebcam?: () => void; onClearWebcam?: () => void; + webcamSizeRegions?: WebcamSizeRegion[]; + selectedWebcamSizeRegionId?: string | null; + onAddWebcamSizeRegionAtPlayhead?: () => void; + onSelectWebcamSizeRegion?: (id: string | null) => void; + onWebcamSizeRegionSizeChange?: (id: string, size: number) => void; + onWebcamSizeRegionTransitionChange?: ( + id: string, + field: "transitionInMs" | "transitionOutMs", + durationMs: number, + ) => void; + onWebcamSizeRegionDelete?: (id: string) => void; + webcamFocusRegions?: WebcamFocusRegion[]; + selectedWebcamFocusRegionId?: string | null; + onAddWebcamFocusRegionAtPlayhead?: () => void; + onSelectWebcamFocusRegion?: (id: string | null) => void; + onWebcamFocusRegionSizeChange?: (id: string, focusSize: number) => void; + onWebcamFocusRegionPipSizeChange?: (id: string, screenPipSize: number) => void; + onWebcamFocusRegionScreenModeChange?: ( + id: string, + screenMode: WebcamFocusRegion["screenMode"], + ) => void; + onWebcamFocusRegionCornerChange?: ( + id: string, + screenPipCorner: WebcamFocusRegion["screenPipCorner"], + ) => void; + onWebcamFocusRegionTransitionChange?: ( + id: string, + field: "transitionInMs" | "transitionOutMs", + durationMs: number, + ) => void; + onWebcamFocusRegionDelete?: (id: string) => void; + onAddFullscreenWebcamRegionAtPlayhead?: () => void; + webcamPositionEnabled?: boolean; + onWebcamPositionEnabledChange?: (enabled: boolean) => void; + webcamPositionRegions?: WebcamPositionRegion[]; + selectedWebcamPositionRegionId?: string | null; + onSelectWebcamPositionRegion?: (id: string | null) => void; + onWebcamPositionRegionTransitionChange?: ( + id: string, + field: "transitionInMs" | "transitionOutMs", + durationMs: number, + ) => void; + onWebcamPositionRegionDelete?: (id: string) => void; padding?: Padding; onPaddingChange?: (padding: Padding) => void; frame?: string | null; @@ -608,6 +666,17 @@ const WEBCAM_POSITION_PRESETS: Array<{ { preset: "bottom-right", label: "↘" }, ]; +const WEBCAM_SHAPE_PRESETS: Array<{ + id: string; + label: string; + cornerRadius: number; +}> = [ + { id: "circle", label: "Circle", cornerRadius: 160 }, + { id: "squircle", label: "Squircle", cornerRadius: 90 }, + { id: "rounded", label: "Rounded", cornerRadius: 28 }, + { id: "rectangle", label: "Rectangle", cornerRadius: 0 }, +]; + type CursorStyleOption = { value: CursorStyle; label: string }; type WallpaperTile = { @@ -934,6 +1003,31 @@ export function SettingsPanel({ onWebcamChange, onUploadWebcam, onClearWebcam, + webcamSizeRegions = [], + selectedWebcamSizeRegionId = null, + onAddWebcamSizeRegionAtPlayhead, + onSelectWebcamSizeRegion, + onWebcamSizeRegionSizeChange, + onWebcamSizeRegionTransitionChange, + onWebcamSizeRegionDelete, + webcamFocusRegions = [], + selectedWebcamFocusRegionId = null, + onAddWebcamFocusRegionAtPlayhead, + onSelectWebcamFocusRegion, + onWebcamFocusRegionSizeChange, + onWebcamFocusRegionPipSizeChange, + onWebcamFocusRegionScreenModeChange, + onWebcamFocusRegionCornerChange, + onWebcamFocusRegionTransitionChange, + onWebcamFocusRegionDelete, + onAddFullscreenWebcamRegionAtPlayhead, + webcamPositionEnabled = false, + onWebcamPositionEnabledChange, + webcamPositionRegions = [], + selectedWebcamPositionRegionId = null, + onSelectWebcamPositionRegion, + onWebcamPositionRegionTransitionChange, + onWebcamPositionRegionDelete, padding = DEFAULT_PADDING, onPaddingChange, frame = null, @@ -1241,12 +1335,7 @@ export function SettingsPanel({ if (!isKnownWallpaper && isVideoWallpaperSource(selected)) { setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev])); } - }, [ - builtInWallpaperPaths, - extensionWallpaperPaths, - selected, - wallpaperPreviewPaths, - ]); + }, [builtInWallpaperPaths, extensionWallpaperPaths, selected, wallpaperPreviewPaths]); const imageWallpaperTiles = useMemo(() => { const imageWallpapers = builtInWallpapers.filter( @@ -3069,8 +3158,8 @@ export function SettingsPanel({ ); - const audioSectionContent = ( -
+ const audioSectionContent = ( +
{tSettings("audio.volumeTitle", "Audio")}
- onAudioVolumeChange?.(v)} formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100} + /> +
+ + {tSettings("audio.normalize", "Normalize")} + + onAudioNormalizeChange?.(v)} + className="data-[state=checked]:bg-[#2563EB] scale-75" /> -
- - {tSettings("audio.normalize", "Normalize")} - - onAudioNormalizeChange?.(v)} - className="data-[state=checked]:bg-[#2563EB] scale-75" - /> -
-
- ); +
+ + ); const clipSectionContent = (
@@ -3183,7 +3272,10 @@ export function SettingsPanel({ {hasClipSourceAudio && (
- {tSettings("clip.separateClipFromAudio", "Separate clip from audio")} + {tSettings( + "clip.separateClipFromAudio", + "Separate clip from audio", + )} - {selectedClipId && - hasClipSourceAudio && - sourceAudioTrackMeta.length > 0 && ( -
- {sourceAudioTrackMeta.map((track) => { - const settings = sourceAudioTrackSettings[track.id] ?? { - volume: 1, - normalize: false, - }; - return ( -
-
- - {track.label} - - -
-
- - {tSettings("audio.normalize", "Normalize")} - - - onSourceAudioTrackNormalizeChange?.(track.id, v) - } - className="data-[state=checked]:bg-[#06b6d4] scale-75" - /> -
- onSourceAudioTrackVolumeChange?.(track.id, v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(text) => - parseFloat(text.replace(/%$/, "")) / 100 + {selectedClipId && hasClipSourceAudio && sourceAudioTrackMeta.length > 0 && ( +
+ {sourceAudioTrackMeta.map((track) => { + const settings = sourceAudioTrackSettings[track.id] ?? { + volume: 1, + normalize: false, + }; + return ( +
+
+ + {track.label} + + +
+
+ + {tSettings("audio.normalize", "Normalize")} + + + onSourceAudioTrackNormalizeChange?.(track.id, v) } + className="data-[state=checked]:bg-[#06b6d4] scale-75" />
- ); - })} -
- )} + + onSourceAudioTrackVolumeChange?.(track.id, v) + } + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(text) => + parseFloat(text.replace(/%$/, "")) / 100 + } + /> +
+ ); + })} +
+ )}
); @@ -3466,17 +3561,739 @@ export function SettingsPanel({ className="data-[state=checked]:bg-[#2563EB] scale-75" /> - updateWebcam({ size: v })} - formatValue={(v) => `${Math.round(v)}%`} - parseInput={(text) => parseFloat(text.replace(/%$/, ""))} - /> +
+ + {tSettings("effects.webcamAvoidCursor", "Avoid cursor")} + + updateWebcam({ avoidCursor })} + className="data-[state=checked]:bg-[#2563EB] scale-75" + /> +
+ {(() => { + const selectedWebcamSizeRegion = selectedWebcamSizeRegionId + ? (webcamSizeRegions.find( + (region) => region.id === selectedWebcamSizeRegionId, + ) ?? null) + : null; + const displayedSize = + selectedWebcamSizeRegion?.size ?? + webcam?.size ?? + DEFAULT_WEBCAM_SIZE; + const handleSizeChange = (value: number) => { + if (selectedWebcamSizeRegion && onWebcamSizeRegionSizeChange) { + onWebcamSizeRegionSizeChange( + selectedWebcamSizeRegion.id, + value, + ); + return; + } + const currentSize = webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const currentHeight = webcam?.height ?? currentSize; + updateWebcam({ + size: value, + ...(Math.round(currentHeight) === Math.round(currentSize) + ? { height: value } + : {}), + }); + }; + const formatRegionTime = (ms: number) => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const millis = Math.max(0, Math.floor(ms % 1000)); + return `${minutes}:${seconds.toString().padStart(2, "0")}.${millis + .toString() + .padStart(3, "0")}`; + }; + + return ( + <> + `${Math.round(v)}%`} + parseInput={(text) => + parseFloat(text.replace(/%$/, "")) + } + /> + {selectedWebcamSizeRegion ? ( +
+
+ + {tSettings( + "effects.webcamSizeRegionEditing", + "Editing camera size region", + )} + + +
+
+ {formatRegionTime( + selectedWebcamSizeRegion.startMs, + )} + {" → "} + {formatRegionTime( + selectedWebcamSizeRegion.endMs, + )} +
+ + onWebcamSizeRegionTransitionChange?.( + selectedWebcamSizeRegion.id, + "transitionInMs", + value, + ) + } + formatValue={(v) => `${Math.round(v)}ms`} + parseInput={(text) => + parseFloat(text.replace(/ms$/, "")) + } + /> + + onWebcamSizeRegionTransitionChange?.( + selectedWebcamSizeRegion.id, + "transitionOutMs", + value, + ) + } + formatValue={(v) => `${Math.round(v)}ms`} + parseInput={(text) => + parseFloat(text.replace(/ms$/, "")) + } + /> + {onWebcamSizeRegionDelete ? ( + + ) : null} +
+ ) : onAddWebcamSizeRegionAtPlayhead ? ( + + ) : null} + {webcamSizeRegions.length > 0 ? ( +
+
+ {tSettings( + "effects.webcamSizeRegionList", + "Camera size regions", + )} +
+
+ {webcamSizeRegions.map((region) => { + const isSelected = + region.id === + selectedWebcamSizeRegionId; + return ( + + ); + })} +
+
+ ) : null} + {(() => { + const selectedWebcamFocusRegion = + selectedWebcamFocusRegionId + ? (webcamFocusRegions.find( + (region) => + region.id === + selectedWebcamFocusRegionId, + ) ?? null) + : null; + + return ( +
+
+
+ {tSettings( + "effects.webcamFocus", + "Camera focus", + )} +
+
+ {onAddFullscreenWebcamRegionAtPlayhead ? ( + + ) : null} + {onAddWebcamFocusRegionAtPlayhead ? ( + + ) : null} +
+
+ {selectedWebcamFocusRegion ? ( +
+
+ + {formatRegionTime( + selectedWebcamFocusRegion.startMs, + )} + {" -> "} + {formatRegionTime( + selectedWebcamFocusRegion.endMs, + )} + + +
+ + onWebcamFocusRegionSizeChange?.( + selectedWebcamFocusRegion.id, + value, + ) + } + formatValue={(v) => + `${Math.round(v)}%` + } + parseInput={(text) => + parseFloat( + text.replace(/%$/, ""), + ) + } + /> +
+ + {tSettings( + "effects.webcamFocusMode", + "Screen mode", + )} + + +
+ {selectedWebcamFocusRegion.screenMode !== + "hidden" ? ( + <> + + onWebcamFocusRegionPipSizeChange?.( + selectedWebcamFocusRegion.id, + value, + ) + } + formatValue={(v) => + `${Math.round(v)}%` + } + parseInput={(text) => + parseFloat( + text.replace( + /%$/, + "", + ), + ) + } + /> +
+ {[ + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ].map((corner) => ( + + ))} +
+ + ) : null} + + onWebcamFocusRegionTransitionChange?.( + selectedWebcamFocusRegion.id, + "transitionInMs", + value, + ) + } + formatValue={(v) => + `${Math.round(v)}ms` + } + parseInput={(text) => + parseFloat( + text.replace(/ms$/, ""), + ) + } + /> + + onWebcamFocusRegionTransitionChange?.( + selectedWebcamFocusRegion.id, + "transitionOutMs", + value, + ) + } + formatValue={(v) => + `${Math.round(v)}ms` + } + parseInput={(text) => + parseFloat( + text.replace(/ms$/, ""), + ) + } + /> + {onWebcamFocusRegionDelete ? ( + + ) : null} +
+ ) : null} + {webcamFocusRegions.length > 0 ? ( +
+ {webcamFocusRegions.map((region) => { + const isSelected = + region.id === + selectedWebcamFocusRegionId; + return ( + + ); + })} +
+ ) : null} +
+ ); + })()} + + ); + })()} +
+
+
+ {tSettings( + "effects.webcamPosition", + "Camera movement (timeline)", + )} +
+ +
+ {!webcamPositionEnabled ? ( +

+ {tSettings( + "effects.webcamPositionHint", + "Off. Enable to drag the camera in the preview and create position regions on the timeline.", + )} +

+ ) : webcamPositionRegions.length === 0 ? ( +

+ {tSettings( + "effects.webcamPositionEmpty", + "Drag the camera in the preview to create a position region.", + )} +

+ ) : ( +
+ {webcamPositionRegions.map((region) => { + const isSelected = + region.id === selectedWebcamPositionRegionId; + return ( +
+ + + {Math.round(region.positionX * 100)}, + {Math.round(region.positionY * 100)} + + {onWebcamPositionRegionDelete ? ( + + ) : null} +
+ ); + })} +
+ )} + {webcamPositionEnabled && + selectedWebcamPositionRegionId && + onWebcamPositionRegionTransitionChange + ? (() => { + const selected = webcamPositionRegions.find( + (r) => r.id === selectedWebcamPositionRegionId, + ); + if (!selected) return null; + return ( +
+
+ + {tSettings( + "effects.webcamTransitionIn", + "In", + )} + + + onWebcamPositionRegionTransitionChange( + selected.id, + "transitionInMs", + Number(event.target.value), + ) + } + className="w-16 rounded-md border border-foreground/10 bg-transparent px-1.5 py-0.5 text-[10px] text-foreground" + /> + + ms + +
+
+ + {tSettings( + "effects.webcamTransitionOut", + "Out", + )} + + + onWebcamPositionRegionTransitionChange( + selected.id, + "transitionOutMs", + Number(event.target.value), + ) + } + className="w-16 rounded-md border border-foreground/10 bg-transparent px-1.5 py-0.5 text-[10px] text-foreground" + /> + + ms + +
+
+ ); + })() + : null} +
@@ -3593,6 +4410,50 @@ export function SettingsPanel({ formatValue={(v) => `${Math.round(v)}px`} parseInput={(text) => parseFloat(text.replace(/px$/, ""))} /> +
+
+ {tSettings("effects.webcamShape", "Shape")} +
+
+ {WEBCAM_SHAPE_PRESETS.map((preset) => { + const currentRadius = + webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS; + const isActive = + Math.abs(currentRadius - preset.cornerRadius) <= 2; + return ( + + ); + })} +
+
( @@ -113,7 +110,9 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" }) ); +import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; import { extensionHost } from "@/lib/extensions"; +import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; import { ExportSettingsMenu } from "./ExportSettingsMenu"; @@ -140,7 +139,6 @@ import { validateProjectData, } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; -import { useVideoEditorAudio } from "./audio/useVideoEditorAudio"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, @@ -150,7 +148,6 @@ import { } from "./TutorialHelp"; import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor"; import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; -import type { SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; import { type AnnotationRegion, type AudioRegion, @@ -173,6 +170,10 @@ import { DEFAULT_CROP_REGION, DEFAULT_CURSOR_STYLE, DEFAULT_FIGURE_DATA, + DEFAULT_WEBCAM_AVOID_CURSOR, + DEFAULT_WEBCAM_FOCUS_SCREEN_MODE, + DEFAULT_WEBCAM_FOCUS_SCREEN_PIP_SIZE, + DEFAULT_WEBCAM_FOCUS_SIZE, DEFAULT_WEBCAM_OVERLAY, DEFAULT_WEBCAM_TIME_OFFSET_MS, DEFAULT_ZOOM_IN_DURATION_MS, @@ -192,7 +193,10 @@ import { type SpeedRegion, type TrimRegion, trimsToClips, + type WebcamFocusRegion, type WebcamOverlaySettings, + type WebcamPositionRegion, + type WebcamSizeRegion, type ZoomDepth, type ZoomFocus, type ZoomMode, @@ -205,6 +209,31 @@ import { buildLoopedCursorTelemetry, getDisplayedTimelineWindowMs, } from "./videoPlayback/cursorLoopTelemetry"; +import { + clampWebcamFocusRegionPipSize, + clampWebcamFocusRegionSize, + clampWebcamFocusRegionTransitionMs, + getActiveWebcamFocusRegion, + getNextWebcamFocusRegionId, + normalizeWebcamFocusRegions, + WEBCAM_FOCUS_REGION_MIN_DURATION_MS, +} from "./webcamFocusRegions"; +import { + clampWebcamPositionCoordinate, + clampWebcamPositionRegionTransitionMs, + getNextWebcamPositionRegionId, + normalizeWebcamPositionRegions, + WEBCAM_POSITION_REGION_MIN_DURATION_MS, +} from "./webcamPositionRegions"; +import { + clampWebcamSizeRegionHeight, + clampWebcamSizeRegionSize, + clampWebcamSizeRegionTransitionMs, + getActiveWebcamSizeRegion, + getNextWebcamSizeRegionId, + normalizeWebcamSizeRegions, + WEBCAM_SIZE_REGION_MIN_DURATION_MS, +} from "./webcamSizeRegions"; type EditorHistorySnapshot = { zoomRegions: ZoomRegion[]; @@ -213,10 +242,16 @@ type EditorHistorySnapshot = { annotationRegions: AnnotationRegion[]; audioRegions: AudioRegion[]; autoCaptions: CaptionCue[]; + webcamSizeRegions: WebcamSizeRegion[]; + webcamFocusRegions: WebcamFocusRegion[]; + webcamPositionRegions: WebcamPositionRegion[]; selectedZoomId: string | null; selectedClipId: string | null; selectedAnnotationId: string | null; selectedAudioId: string | null; + selectedWebcamSizeRegionId: string | null; + selectedWebcamFocusRegionId: string | null; + selectedWebcamPositionRegionId: string | null; }; type PendingExportSave = { @@ -254,6 +289,11 @@ type SmokeExportConfig = { fps?: ExportMp4FrameRate; }; +// Stable empty array so gated-off webcam position props keep a constant +// reference across renders (a fresh [] each render thrashes memoized +// callbacks/effects in VideoPlayback and loops play/pause). +const EMPTY_WEBCAM_POSITION_REGIONS: WebcamPositionRegion[] = []; + const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise { @@ -666,12 +706,28 @@ export default function VideoEditor() { const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [audioRegions, setAudioRegions] = useState([]); const [selectedAudioId, setSelectedAudioId] = useState(null); + const [webcamSizeRegions, setWebcamSizeRegions] = useState([]); + const [selectedWebcamSizeRegionId, setSelectedWebcamSizeRegionId] = useState( + null, + ); + const [webcamFocusRegions, setWebcamFocusRegions] = useState([]); + const [selectedWebcamFocusRegionId, setSelectedWebcamFocusRegionId] = useState( + null, + ); + const [webcamPositionRegions, setWebcamPositionRegions] = useState([]); + const [selectedWebcamPositionRegionId, setSelectedWebcamPositionRegionId] = useState< + string | null + >(null); + // Master gate for the "drag camera in preview -> timeline position + // region" feature. On by default. Turning it off hides the timeline + // row, makes the preview drag inert, and creates nothing. To fully + // revert the feature, delete this flag and the blocks guarded by it. + const [webcamPositionEnabled, setWebcamPositionEnabled] = useState(true); const [sourceAudioTrackSettingsByClip, setSourceAudioTrackSettingsByClip] = useState< Record >({}); - const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = useState< - SourceAudioTrackSettings - >({}); + const [defaultSourceAudioTrackSettings, setDefaultSourceAudioTrackSettings] = + useState({}); const [hasClipSourceAudio, setHasClipSourceAudio] = useState(false); const [autoCaptions, setAutoCaptions] = useState([]); const [autoCaptionSettings, setAutoCaptionSettings] = useState( @@ -783,6 +839,7 @@ export default function VideoEditor() { const projectAutosaveTimeoutRef = useRef(null); const projectSaveQueueRef = useRef>(Promise.resolve()); const smokeExportReadyStateRef = useRef>({}); + const webcamSizeResizeTargetRegionIdRef = useRef(null); const [historyVersion, setHistoryVersion] = useState(0); const timelineRef = useRef(null); @@ -794,6 +851,44 @@ export default function VideoEditor() { } const [timelineCollapsed, setTimelineCollapsed] = useState(false); + // User-resizable timeline panel height in px (null = use default 30%). + const [timelineHeightPx, setTimelineHeightPx] = useState(null); + const timelineResizeRef = useRef<{ + startY: number; + startHeight: number; + } | null>(null); + + const handleTimelineResizePointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + const panel = event.currentTarget.parentElement; + const startHeight = panel?.getBoundingClientRect().height ?? 240; + timelineResizeRef.current = { startY: event.clientY, startHeight }; + + const handleMove = (moveEvent: PointerEvent) => { + const drag = timelineResizeRef.current; + if (!drag) return; + // Dragging the handle upward grows the timeline. + const delta = drag.startY - moveEvent.clientY; + const viewportH = window.innerHeight || 900; + const next = Math.max( + 160, + Math.min(viewportH * 0.85, drag.startHeight + delta), + ); + setTimelineHeightPx(next); + }; + const handleUp = () => { + timelineResizeRef.current = null; + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleUp); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleUp); + }, + [], + ); useEffect(() => { void window.electronAPI?.getPlatform?.()?.then((platform) => { @@ -1231,6 +1326,9 @@ export default function VideoEditor() { webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, videoWidth: previewVideo.videoWidth, videoHeight: previewVideo.videoHeight, annotationRegions, @@ -1363,6 +1461,9 @@ export default function VideoEditor() { speedRegions, wallpaper, webcam, + webcamFocusRegions, + webcamPositionRegions, + webcamSizeRegions, zoomInDurationMs, zoomInEasing, zoomInOverlapMs, @@ -1753,6 +1854,9 @@ export default function VideoEditor() { frame: string | null; cropRegion: CropRegion; webcam: WebcamOverlaySettings; + webcamSizeRegions: WebcamSizeRegion[]; + webcamFocusRegions: WebcamFocusRegion[]; + webcamPositionRegions: WebcamPositionRegion[]; zoomRegions: ZoomRegion[]; trimRegions: TrimRegion[]; clipRegions: ClipRegion[]; @@ -1856,6 +1960,9 @@ export default function VideoEditor() { frame, cropRegion, webcam, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, zoomRegions, trimRegions, clipRegions, @@ -1917,6 +2024,9 @@ export default function VideoEditor() { padding, cropRegion, webcam, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, zoomRegions, trimRegions, clipRegions, @@ -1949,10 +2059,16 @@ export default function VideoEditor() { annotationRegions, audioRegions, autoCaptions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, selectedZoomId, selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, }; }, [ zoomRegions, @@ -1961,10 +2077,16 @@ export default function VideoEditor() { annotationRegions, audioRegions, autoCaptions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, selectedZoomId, selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, ]); const applyHistorySnapshot = useCallback( @@ -1977,10 +2099,16 @@ export default function VideoEditor() { setAnnotationRegions(cloned.annotationRegions); setAudioRegions(cloned.audioRegions); setAutoCaptions(cloned.autoCaptions); + setWebcamSizeRegions(cloned.webcamSizeRegions ?? []); + setWebcamFocusRegions(cloned.webcamFocusRegions ?? []); + setWebcamPositionRegions(cloned.webcamPositionRegions ?? []); setSelectedZoomId(cloned.selectedZoomId); setSelectedClipId(cloned.selectedClipId); setSelectedAnnotationId(cloned.selectedAnnotationId); setSelectedAudioId(cloned.selectedAudioId); + setSelectedWebcamSizeRegionId(cloned.selectedWebcamSizeRegionId ?? null); + setSelectedWebcamFocusRegionId(cloned.selectedWebcamFocusRegionId ?? null); + setSelectedWebcamPositionRegionId(cloned.selectedWebcamPositionRegionId ?? null); nextZoomIdRef.current = deriveNextId( "zoom", @@ -2125,7 +2253,12 @@ export default function VideoEditor() { setSpeedRegions(normalizedEditor.speedRegions); setAnnotationRegions(normalizedEditor.annotationRegions); setAudioRegions(normalizedEditor.audioRegions); - setSourceAudioTrackSettingsByClip(normalizedEditor.sourceAudioTrackSettingsByClip ?? {}); + setWebcamSizeRegions(normalizedEditor.webcamSizeRegions ?? []); + setWebcamFocusRegions(normalizedEditor.webcamFocusRegions ?? []); + setWebcamPositionRegions(normalizedEditor.webcamPositionRegions ?? []); + setSourceAudioTrackSettingsByClip( + normalizedEditor.sourceAudioTrackSettingsByClip ?? {}, + ); setDefaultSourceAudioTrackSettings( normalizedEditor.defaultSourceAudioTrackSettings ?? {}, ); @@ -2146,6 +2279,7 @@ export default function VideoEditor() { setSelectedClipId(null); setSelectedAnnotationId(null); setSelectedAudioId(null); + setSelectedWebcamSizeRegionId(null); nextZoomIdRef.current = deriveNextId( "zoom", @@ -2512,7 +2646,7 @@ export default function VideoEditor() { sessionVideoPath: session?.videoPath, videoSourcePath: videoSourcePath, match: session?.videoPath === videoSourcePath, - webcamPath: session?.webcamPath + webcamPath: session?.webcamPath, }); if (!session || session.videoPath !== videoSourcePath) { @@ -3344,6 +3478,573 @@ export default function VideoEditor() { () => getTimelineDurationMs(clipRegions, duration * 1000) / 1000, [clipRegions, duration], ); + const timelineDurationMs = useMemo( + () => Math.max(0, Math.round(timelineDuration * 1000)), + [timelineDuration], + ); + + const handleAddWebcamSizeRegionAtPlayhead = useCallback(() => { + if (timelineDurationMs <= 0) { + return; + } + + const minDurationMs = WEBCAM_SIZE_REGION_MIN_DURATION_MS; + const startMs = 0; + const endMs = timelineDurationMs; + + if (endMs - startMs < minDurationMs) { + return; + } + + setWebcamSizeRegions((current) => { + const nextRegion: WebcamSizeRegion = { + id: getNextWebcamSizeRegionId(current), + startMs, + endMs, + size: clampWebcamSizeRegionSize(webcam.size), + height: clampWebcamSizeRegionHeight(webcam.height, webcam.size), + }; + setSelectedWebcamSizeRegionId(nextRegion.id); + return normalizeWebcamSizeRegions([...current, nextRegion], timelineDurationMs); + }); + }, [timelineDurationMs, webcam.height, webcam.size]); + + const handleWebcamSizeRegionSizeChange = useCallback((id: string, size: number) => { + setWebcamSizeRegions((current) => + current.map((region) => { + if (region.id !== id) return region; + const nextSize = clampWebcamSizeRegionSize(size); + return { + ...region, + size: nextSize, + ...(region.height !== undefined && + Math.round(region.height) === Math.round(region.size) + ? { height: nextSize } + : {}), + }; + }), + ); + }, []); + + const handleWebcamSizeRegionHeightChange = useCallback((id: string, height: number) => { + setWebcamSizeRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + height: clampWebcamSizeRegionHeight(height, region.size), + } + : region, + ), + ); + }, []); + + const handleWebcamSizeRegionTransitionChange = useCallback( + (id: string, field: "transitionInMs" | "transitionOutMs", durationMs: number) => { + const clamped = clampWebcamSizeRegionTransitionMs(durationMs); + setWebcamSizeRegions((current) => + current.map((region) => + region.id === id && clamped !== undefined + ? { ...region, [field]: clamped } + : region, + ), + ); + }, + [], + ); + + const handleWebcamSizeRegionDelete = useCallback((id: string) => { + setWebcamSizeRegions((current) => current.filter((region) => region.id !== id)); + setSelectedWebcamSizeRegionId((selectedId) => (selectedId === id ? null : selectedId)); + }, []); + + const handleSelectWebcamSizeRegion = useCallback((id: string | null) => { + setSelectedWebcamSizeRegionId(id); + }, []); + + const handleWebcamSizeRegionSpanChange = useCallback((id: string, span: Span) => { + setWebcamSizeRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + startMs: Math.max(0, Math.round(span.start)), + endMs: Math.max(0, Math.round(span.end)), + } + : region, + ), + ); + }, []); + + const getWebcamSizeResizeTargetRegionId = useCallback(() => { + const playheadMs = Math.max( + 0, + Math.min(timelineDurationMs, Math.round((timelinePlayheadTime ?? currentTime) * 1000)), + ); + if (getActiveWebcamFocusRegion(webcamFocusRegions, playheadMs)) { + return null; + } + const selectedRegion = + selectedWebcamSizeRegionId !== null + ? (webcamSizeRegions.find((region) => region.id === selectedWebcamSizeRegionId) ?? + null) + : null; + if ( + selectedRegion && + playheadMs >= selectedRegion.startMs && + playheadMs < selectedRegion.endMs + ) { + return selectedRegion.id; + } + + const activeRegion = getActiveWebcamSizeRegion(webcamSizeRegions, playheadMs); + if (activeRegion) { + setSelectedWebcamSizeRegionId(activeRegion.id); + return activeRegion.id; + } + + const minDurationMs = WEBCAM_SIZE_REGION_MIN_DURATION_MS; + if (timelineDurationMs < minDurationMs) { + return null; + } + + const defaultDurationMs = Math.min(3000, timelineDurationMs); + const latestStartMs = Math.max(0, timelineDurationMs - minDurationMs); + const startMs = Math.min(playheadMs, latestStartMs); + const endMs = Math.min( + timelineDurationMs, + Math.max(startMs + minDurationMs, startMs + defaultDurationMs), + ); + if (endMs - startMs < minDurationMs) { + return null; + } + + const nextRegion: WebcamSizeRegion = { + id: getNextWebcamSizeRegionId(webcamSizeRegions), + startMs, + endMs, + size: clampWebcamSizeRegionSize(webcam.size), + height: clampWebcamSizeRegionHeight(webcam.height, webcam.size), + }; + setWebcamSizeRegions((current) => + normalizeWebcamSizeRegions([...current, nextRegion], timelineDurationMs), + ); + setSelectedWebcamSizeRegionId(nextRegion.id); + return nextRegion.id; + }, [ + currentTime, + selectedWebcamSizeRegionId, + timelineDurationMs, + timelinePlayheadTime, + webcam.height, + webcam.size, + webcamFocusRegions, + webcamSizeRegions, + ]); + + const handleWebcamSizePreviewResizeStart = useCallback(() => { + webcamSizeResizeTargetRegionIdRef.current = getWebcamSizeResizeTargetRegionId(); + }, [getWebcamSizeResizeTargetRegionId]); + + const handleWebcamSizePreviewResize = useCallback( + (size: number) => { + let targetId = webcamSizeResizeTargetRegionIdRef.current; + if (!targetId) { + targetId = getWebcamSizeResizeTargetRegionId(); + webcamSizeResizeTargetRegionIdRef.current = targetId; + } + if (targetId) { + handleWebcamSizeRegionSizeChange(targetId, size); + return; + } + setWebcam((previous) => ({ ...previous, size: clampWebcamSizeRegionSize(size) })); + }, + [getWebcamSizeResizeTargetRegionId, handleWebcamSizeRegionSizeChange], + ); + + const handleWebcamHeightPreviewResize = useCallback( + (height: number) => { + let targetId = webcamSizeResizeTargetRegionIdRef.current; + if (!targetId) { + targetId = getWebcamSizeResizeTargetRegionId(); + webcamSizeResizeTargetRegionIdRef.current = targetId; + } + if (targetId) { + handleWebcamSizeRegionHeightChange(targetId, height); + return; + } + setWebcam((previous) => ({ + ...previous, + height: clampWebcamSizeRegionHeight(height, previous.size), + })); + }, + [getWebcamSizeResizeTargetRegionId, handleWebcamSizeRegionHeightChange], + ); + + const handleWebcamSizePreviewResizeEnd = useCallback(() => { + webcamSizeResizeTargetRegionIdRef.current = null; + }, []); + + const addWebcamFocusRegionAtPlayhead = useCallback( + (overrides?: Partial>) => { + const minDurationMs = WEBCAM_FOCUS_REGION_MIN_DURATION_MS; + const startMs = 0; + const endMs = timelineDurationMs; + if (endMs - startMs < minDurationMs) { + return; + } + + setWebcamFocusRegions((current) => { + const nextRegion: WebcamFocusRegion = { + id: getNextWebcamFocusRegionId(current), + startMs, + endMs, + focusSize: overrides?.focusSize ?? DEFAULT_WEBCAM_FOCUS_SIZE, + screenMode: overrides?.screenMode ?? DEFAULT_WEBCAM_FOCUS_SCREEN_MODE, + screenPipSize: DEFAULT_WEBCAM_FOCUS_SCREEN_PIP_SIZE, + }; + setSelectedWebcamFocusRegionId(nextRegion.id); + return normalizeWebcamFocusRegions([...current, nextRegion], timelineDurationMs); + }); + }, + [timelineDurationMs], + ); + + const handleAddWebcamFocusRegionAtPlayhead = useCallback(() => { + addWebcamFocusRegionAtPlayhead(); + }, [addWebcamFocusRegionAtPlayhead]); + + const handleAddFullscreenWebcamRegionAtPlayhead = useCallback(() => { + addWebcamFocusRegionAtPlayhead({ focusSize: 100, screenMode: "hidden" }); + }, [addWebcamFocusRegionAtPlayhead]); + + const handleWebcamFocusRegionSizeChange = useCallback((id: string, focusSize: number) => { + setWebcamFocusRegions((current) => + current.map((region) => + region.id === id + ? { ...region, focusSize: clampWebcamFocusRegionSize(focusSize) } + : region, + ), + ); + }, []); + + const handleWebcamFocusRegionPipSizeChange = useCallback( + (id: string, screenPipSize: number) => { + setWebcamFocusRegions((current) => + current.map((region) => + region.id === id + ? { ...region, screenPipSize: clampWebcamFocusRegionPipSize(screenPipSize) } + : region, + ), + ); + }, + [], + ); + + const handleWebcamFocusRegionScreenModeChange = useCallback( + (id: string, screenMode: WebcamFocusRegion["screenMode"]) => { + setWebcamFocusRegions((current) => + current.map((region) => (region.id === id ? { ...region, screenMode } : region)), + ); + }, + [], + ); + + const handleWebcamFocusRegionCornerChange = useCallback( + (id: string, screenPipCorner: WebcamFocusRegion["screenPipCorner"]) => { + setWebcamFocusRegions((current) => + current.map((region) => + region.id === id ? { ...region, screenPipCorner } : region, + ), + ); + }, + [], + ); + + const handleWebcamFocusRegionTransitionChange = useCallback( + (id: string, field: "transitionInMs" | "transitionOutMs", durationMs: number) => { + const clamped = clampWebcamFocusRegionTransitionMs(durationMs); + setWebcamFocusRegions((current) => + current.map((region) => + region.id === id && clamped !== undefined + ? { ...region, [field]: clamped } + : region, + ), + ); + }, + [], + ); + + const handleWebcamFocusRegionDelete = useCallback((id: string) => { + setWebcamFocusRegions((current) => current.filter((region) => region.id !== id)); + setSelectedWebcamFocusRegionId((selectedId) => (selectedId === id ? null : selectedId)); + }, []); + + const handleSelectWebcamFocusRegion = useCallback((id: string | null) => { + setSelectedWebcamFocusRegionId(id); + }, []); + + const handleWebcamFocusRegionSpanChange = useCallback((id: string, span: Span) => { + setWebcamFocusRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + startMs: Math.max(0, Math.round(span.start)), + endMs: Math.max(0, Math.round(span.end)), + } + : region, + ), + ); + }, []); + + const handleAddWebcamPositionRegionAtPlayhead = useCallback( + (positionX?: number, positionY?: number) => { + const playheadMs = Math.max( + 0, + Math.min( + timelineDurationMs, + Math.round((timelinePlayheadTime ?? currentTime) * 1000), + ), + ); + + // Reuse the region under the playhead instead of stacking a new one + // on every drag. Only create a fresh region when none covers the + // current playhead. + const existing = webcamPositionRegions.find( + (region) => playheadMs >= region.startMs && playheadMs < region.endMs, + ); + if (existing) { + setSelectedWebcamPositionRegionId(existing.id); + if (positionX !== undefined && positionY !== undefined) { + setWebcamPositionRegions((current) => + current.map((region) => + region.id === existing.id + ? { + ...region, + positionX: clampWebcamPositionCoordinate( + positionX, + region.positionX, + ), + positionY: clampWebcamPositionCoordinate( + positionY, + region.positionY, + ), + } + : region, + ), + ); + } + return existing.id; + } + + const minDurationMs = WEBCAM_POSITION_REGION_MIN_DURATION_MS; + + // If a webcam size region covers the playhead, align the new + // position region to its exact span so size + position stay + // in sync for that moment. + const activeSizeRegion = webcamSizeRegions.find( + (region) => playheadMs >= region.startMs && playheadMs < region.endMs, + ); + + let startMs: number; + let endMs: number; + if (activeSizeRegion) { + startMs = activeSizeRegion.startMs; + endMs = activeSizeRegion.endMs; + } else { + startMs = 0; + endMs = timelineDurationMs; + } + if (endMs - startMs < minDurationMs) { + return null; + } + + let createdId: string | null = null; + setWebcamPositionRegions((current) => { + const nextRegion: WebcamPositionRegion = { + id: getNextWebcamPositionRegionId(current), + startMs, + endMs, + positionX: clampWebcamPositionCoordinate(positionX ?? webcam.positionX, 1), + positionY: clampWebcamPositionCoordinate(positionY ?? webcam.positionY, 1), + }; + createdId = nextRegion.id; + setSelectedWebcamPositionRegionId(nextRegion.id); + return normalizeWebcamPositionRegions([...current, nextRegion], timelineDurationMs); + }); + return createdId; + }, + [ + currentTime, + timelineDurationMs, + timelinePlayheadTime, + webcam.positionX, + webcam.positionY, + webcamPositionRegions, + webcamSizeRegions, + ], + ); + + const handleWebcamPositionRegionPositionChange = useCallback( + (id: string, positionX: number, positionY: number) => { + setWebcamPositionRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + positionX: clampWebcamPositionCoordinate( + positionX, + region.positionX, + ), + positionY: clampWebcamPositionCoordinate( + positionY, + region.positionY, + ), + } + : region, + ), + ); + }, + [], + ); + + const handleWebcamPositionRegionTransitionChange = useCallback( + (id: string, field: "transitionInMs" | "transitionOutMs", durationMs: number) => { + const clamped = clampWebcamPositionRegionTransitionMs(durationMs); + setWebcamPositionRegions((current) => + current.map((region) => + region.id === id && clamped !== undefined + ? { ...region, [field]: clamped } + : region, + ), + ); + }, + [], + ); + + const handleWebcamPositionRegionDelete = useCallback((id: string) => { + setWebcamPositionRegions((current) => current.filter((region) => region.id !== id)); + setSelectedWebcamPositionRegionId((selectedId) => (selectedId === id ? null : selectedId)); + }, []); + + const handleSelectWebcamPositionRegion = useCallback((id: string | null) => { + setSelectedWebcamPositionRegionId(id); + }, []); + + const handleWebcamPositionRegionSpanChange = useCallback((id: string, span: Span) => { + setWebcamPositionRegions((current) => + current.map((region) => + region.id === id + ? { + ...region, + startMs: Math.max(0, Math.round(span.start)), + endMs: Math.max(0, Math.round(span.end)), + } + : region, + ), + ); + }, []); + + useEffect(() => { + if (timelineDurationMs <= 0) return; + setWebcamSizeRegions((current) => { + const renormalized = normalizeWebcamSizeRegions(current, timelineDurationMs); + if ( + renormalized.length === current.length && + renormalized.every((region, index) => { + const previous = current[index]; + return ( + previous && + previous.id === region.id && + previous.startMs === region.startMs && + previous.endMs === region.endMs && + previous.size === region.size + ); + }) + ) { + return current; + } + return renormalized; + }); + }, [timelineDurationMs]); + + useEffect(() => { + if (timelineDurationMs <= 0) return; + setWebcamFocusRegions((current) => { + const renormalized = normalizeWebcamFocusRegions(current, timelineDurationMs); + if ( + renormalized.length === current.length && + renormalized.every((region, index) => { + const previous = current[index]; + return ( + previous && + previous.id === region.id && + previous.startMs === region.startMs && + previous.endMs === region.endMs && + previous.focusSize === region.focusSize && + previous.screenMode === region.screenMode && + previous.screenPipSize === region.screenPipSize && + previous.screenPipCorner === region.screenPipCorner + ); + }) + ) { + return current; + } + return renormalized; + }); + }, [timelineDurationMs]); + + useEffect(() => { + if (timelineDurationMs <= 0) return; + setWebcamPositionRegions((current) => { + const renormalized = normalizeWebcamPositionRegions(current, timelineDurationMs); + if ( + renormalized.length === current.length && + renormalized.every((region, index) => { + const previous = current[index]; + return ( + previous && + previous.id === region.id && + previous.startMs === region.startMs && + previous.endMs === region.endMs && + previous.positionX === region.positionX && + previous.positionY === region.positionY + ); + }) + ) { + return current; + } + return renormalized; + }); + }, [timelineDurationMs]); + + useEffect(() => { + if ( + selectedWebcamSizeRegionId && + !webcamSizeRegions.some((region) => region.id === selectedWebcamSizeRegionId) + ) { + setSelectedWebcamSizeRegionId(null); + } + }, [selectedWebcamSizeRegionId, webcamSizeRegions]); + + useEffect(() => { + if ( + selectedWebcamFocusRegionId && + !webcamFocusRegions.some((region) => region.id === selectedWebcamFocusRegionId) + ) { + setSelectedWebcamFocusRegionId(null); + } + }, [selectedWebcamFocusRegionId, webcamFocusRegions]); + + useEffect(() => { + if ( + selectedWebcamPositionRegionId && + !webcamPositionRegions.some((region) => region.id === selectedWebcamPositionRegionId) + ) { + setSelectedWebcamPositionRegionId(null); + } + }, [selectedWebcamPositionRegionId, webcamPositionRegions]); // Merge clip speeds into speed regions so playback + export respect per-clip speed const effectiveSpeedRegions = useMemo(() => { @@ -3407,21 +4108,27 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => { - const playback = videoPlaybackRef.current; - const video = playback?.video; - if (!video) return; + const handleSeek = useCallback( + (time: number, options: { pause?: boolean } = {}) => { + const playback = videoPlaybackRef.current; + const video = playback?.video; + if (!video) return; - if (options.pause && !video.paused) { - playback?.pause(); - } + if (options.pause && !video.paused) { + playback?.pause(); + } - video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; - }, [mapTimelineTimeToSourceTime]); + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; + }, + [mapTimelineTimeToSourceTime], + ); - const handleTimelineSeek = useCallback((time: number) => { - handleSeek(time, { pause: true }); - }, [handleSeek]); + const handleTimelineSeek = useCallback( + (time: number) => { + handleSeek(time, { pause: true }); + }, + [handleSeek], + ); const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); @@ -3818,17 +4525,17 @@ export default function VideoEditor() { } }, []); - const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { - const id = `audio-${nextAudioIdRef.current++}`; - const newRegion: AudioRegion = { - id, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - audioPath, - volume: 1, - normalize: false, - trackIndex, - }; + const handleAudioAdded = useCallback((span: Span, audioPath: string, trackIndex?: number) => { + const id = `audio-${nextAudioIdRef.current++}`; + const newRegion: AudioRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + audioPath, + volume: 1, + normalize: false, + trackIndex, + }; setAudioRegions((prev) => [...prev, newRegion]); setSelectedAudioId(id); setSelectedZoomId(null); @@ -3878,29 +4585,29 @@ export default function VideoEditor() { [selectedAudioId], ); - const handleAudioDelete = useCallback( - (id: string) => { + const handleAudioDelete = useCallback( + (id: string) => { setAudioRegions((prev) => prev.filter((region) => region.id !== id)); if (selectedAudioId === id) { setSelectedAudioId(null); } }, - [selectedAudioId], - ); + [selectedAudioId], + ); - const handleAudioNormalizeChange = useCallback( - (normalize: boolean) => { - if (!selectedAudioId) { - return; - } - setAudioRegions((prev) => - prev.map((region) => - region.id === selectedAudioId ? { ...region, normalize } : region, - ), - ); - }, - [selectedAudioId], - ); + const handleAudioNormalizeChange = useCallback( + (normalize: boolean) => { + if (!selectedAudioId) { + return; + } + setAudioRegions((prev) => + prev.map((region) => + region.id === selectedAudioId ? { ...region, normalize } : region, + ), + ); + }, + [selectedAudioId], + ); const handleAnnotationAdded = useCallback((span: Span, trackIndex = 0) => { const id = `annotation-${nextAnnotationIdRef.current++}`; @@ -4268,6 +4975,9 @@ export default function VideoEditor() { webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, annotationRegions, autoCaptions, autoCaptionSettings, @@ -4440,6 +5150,9 @@ export default function VideoEditor() { webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, annotationRegions, autoCaptions, autoCaptionSettings, @@ -5798,19 +6511,20 @@ export default function VideoEditor() { selectedClipId={selectedClipId} selectedClipSpeed={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId)?.speed ?? 1 + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.speed ?? 1) : null } selectedClipMuted={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId)?.muted ?? - false + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.muted ?? false) : null } selectedClipShowSourceAudio={ selectedClipId - ? clipRegions.find((c) => c.id === selectedClipId) - ?.showSourceAudio ?? false + ? (clipRegions.find((c) => c.id === selectedClipId) + ?.showSourceAudio ?? false) : null } onClipSpeedChange={handleClipSpeedChange} @@ -5819,7 +6533,9 @@ export default function VideoEditor() { onClipDelete={handleClipDelete} hasClipSourceAudio={hasClipSourceAudio} sourceAudioTrackMeta={audio.sourceAudioTrackMeta} - sourceAudioTrackSettings={audio.selectedClipSourceAudioTrackSettings} + sourceAudioTrackSettings={ + audio.selectedClipSourceAudioTrackSettings + } onSourceAudioTrackVolumeChange={ audio.onSelectedClipSourceAudioTrackVolumeChange } @@ -5827,21 +6543,21 @@ export default function VideoEditor() { audio.onSelectedClipSourceAudioTrackNormalizeChange } selectedAudioId={selectedAudioId} - selectedAudioVolume={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.volume ?? null) - : null - } - selectedAudioNormalize={ - selectedAudioId - ? (audioRegions.find((r) => r.id === selectedAudioId) - ?.normalize ?? false) - : null - } - onAudioVolumeChange={handleAudioVolumeChange} - onAudioNormalizeChange={handleAudioNormalizeChange} - onAudioDelete={handleAudioDelete} + selectedAudioVolume={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.volume ?? null) + : null + } + selectedAudioNormalize={ + selectedAudioId + ? (audioRegions.find((r) => r.id === selectedAudioId) + ?.normalize ?? false) + : null + } + onAudioVolumeChange={handleAudioVolumeChange} + onAudioNormalizeChange={handleAudioNormalizeChange} + onAudioDelete={handleAudioDelete} shadowIntensity={shadowIntensity} onShadowChange={setShadowIntensity} backgroundBlur={backgroundBlur} @@ -5927,6 +6643,49 @@ export default function VideoEditor() { onWebcamChange={setWebcam} onUploadWebcam={handleUploadWebcam} onClearWebcam={handleClearWebcam} + webcamSizeRegions={webcamSizeRegions} + selectedWebcamSizeRegionId={selectedWebcamSizeRegionId} + onAddWebcamSizeRegionAtPlayhead={ + handleAddWebcamSizeRegionAtPlayhead + } + onSelectWebcamSizeRegion={handleSelectWebcamSizeRegion} + onWebcamSizeRegionSizeChange={handleWebcamSizeRegionSizeChange} + onWebcamSizeRegionTransitionChange={ + handleWebcamSizeRegionTransitionChange + } + onWebcamSizeRegionDelete={handleWebcamSizeRegionDelete} + webcamFocusRegions={webcamFocusRegions} + selectedWebcamFocusRegionId={selectedWebcamFocusRegionId} + onAddWebcamFocusRegionAtPlayhead={ + handleAddWebcamFocusRegionAtPlayhead + } + onSelectWebcamFocusRegion={handleSelectWebcamFocusRegion} + onWebcamFocusRegionSizeChange={handleWebcamFocusRegionSizeChange} + onWebcamFocusRegionPipSizeChange={ + handleWebcamFocusRegionPipSizeChange + } + onWebcamFocusRegionScreenModeChange={ + handleWebcamFocusRegionScreenModeChange + } + onWebcamFocusRegionCornerChange={ + handleWebcamFocusRegionCornerChange + } + onWebcamFocusRegionTransitionChange={ + handleWebcamFocusRegionTransitionChange + } + onWebcamFocusRegionDelete={handleWebcamFocusRegionDelete} + onAddFullscreenWebcamRegionAtPlayhead={ + handleAddFullscreenWebcamRegionAtPlayhead + } + webcamPositionEnabled={webcamPositionEnabled} + onWebcamPositionEnabledChange={setWebcamPositionEnabled} + webcamPositionRegions={webcamPositionRegions} + selectedWebcamPositionRegionId={selectedWebcamPositionRegionId} + onSelectWebcamPositionRegion={handleSelectWebcamPositionRegion} + onWebcamPositionRegionTransitionChange={ + handleWebcamPositionRegionTransitionChange + } + onWebcamPositionRegionDelete={handleWebcamPositionRegionDelete} padding={padding} onPaddingChange={setPadding} frame={frame} @@ -6093,6 +6852,61 @@ export default function VideoEditor() { ? resolvedWebcamVideoUrl : null } + webcamSizeRegions={webcamSizeRegions} + webcamFocusRegions={webcamFocusRegions} + selectedWebcamFocusRegionId={ + selectedWebcamFocusRegionId + } + webcamPositionRegions={ + webcamPositionEnabled + ? webcamPositionRegions + : EMPTY_WEBCAM_POSITION_REGIONS + } + selectedWebcamPositionRegionId={ + webcamPositionEnabled + ? selectedWebcamPositionRegionId + : null + } + onSelectWebcamPositionRegion={ + webcamPositionEnabled + ? handleSelectWebcamPositionRegion + : undefined + } + onWebcamPositionDragStart={ + webcamPositionEnabled + ? handleAddWebcamPositionRegionAtPlayhead + : undefined + } + onWebcamPositionDrag={ + webcamPositionEnabled + ? handleWebcamPositionRegionPositionChange + : undefined + } + onWebcamSizeResizeStart={ + handleWebcamSizePreviewResizeStart + } + onWebcamSizeResize={handleWebcamSizePreviewResize} + onWebcamHeightResize={ + handleWebcamHeightPreviewResize + } + onWebcamSizeResizeEnd={ + handleWebcamSizePreviewResizeEnd + } + onWebcamMirrorToggle={() => + setWebcam((previous) => ({ + ...previous, + mirror: !(previous.mirror ?? true), + })) + } + onWebcamAvoidCursorToggle={() => + setWebcam((previous) => ({ + ...previous, + avoidCursor: !( + previous.avoidCursor ?? + DEFAULT_WEBCAM_AVOID_CURSOR + ), + })) + } trimRegions={trimRegions} speedRegions={effectiveSpeedRegions} annotationRegions={annotationRegions} @@ -6138,13 +6952,15 @@ export default function VideoEditor() { } cursorSway={cursorSway} volume={ - audio.shouldMutePreviewVideo || audio.isCurrentClipMuted + audio.shouldMutePreviewVideo || + audio.isCurrentClipMuted ? 0 : Math.max( 0, Math.min( 1, - previewVolume * audio.embeddedSourcePreviewGain, + previewVolume * + audio.embeddedSourcePreviewGain, ), ) } @@ -6381,10 +7197,23 @@ export default function VideoEditor() {
+ {!timelineCollapsed ? ( +
+
+
+ ) : null} c.showSourceAudio)} sourceAudioTrackSettings={audio.activeSourceAudioTrackSettings} getSourceAudioTrackSettingsForClip={ diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index c64e6b425..ff1478c4b 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,3 +1,4 @@ +import { CursorClick, FlipHorizontal } from "@phosphor-icons/react"; import { Application, Container, Graphics, Rectangle, Sprite, Texture, VideoSource } from "pixi.js"; import { MotionBlurFilter } from "pixi-filters/motion-blur"; import { ZoomBlurFilter } from "pixi-filters/zoom-blur"; @@ -40,7 +41,10 @@ import { type Padding, type SpeedRegion, type TrimRegion, + type WebcamFocusRegion, type WebcamOverlaySettings, + type WebcamPositionRegion, + type WebcamSizeRegion, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus, @@ -62,6 +66,8 @@ import { type SpringState, stepSpringValue, } from "./videoPlayback/motionSmoothing"; +import { getInterpolatedWebcamPositionAtTime } from "./webcamPositionRegions"; +import { getInterpolatedWebcamDimensionsAtTime } from "./webcamSizeRegions"; function getContributedCursorStylesSignature() { return extensionHost @@ -135,11 +141,13 @@ import { DEFAULT_CURSOR_CLICK_BOUNCE_DURATION, DEFAULT_CURSOR_MOTION_BLUR, DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_STYLE, DEFAULT_CURSOR_SWAY, DEFAULT_PADDING, + DEFAULT_WEBCAM_AVOID_CURSOR, DEFAULT_WEBCAM_CORNER_RADIUS, + DEFAULT_WEBCAM_HEIGHT, DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, DEFAULT_WEBCAM_SIZE, @@ -173,9 +181,18 @@ import { type MotionBlurState, } from "./videoPlayback/zoomTransform"; import { - getWebcamCropSourceRect, + getInterpolatedFocusStateAtTime, + getWebcamFocusScreenTransform, + type WebcamFocusState, +} from "./webcamFocusRegions"; +import { + clampWebcamOverlayPosition, + getSnappedWebcamPositionPoint, + getWebcamAvoidCursorPosition, + getWebcamCropDrawLayout, getWebcamOverlayPosition, getWebcamOverlaySizePx, + getWebcamSizePercentFromPx, } from "./webcamOverlay"; type PlaybackAnimationState = { @@ -209,7 +226,10 @@ const PIXI_RENDERER_INIT_TIMEOUT_MS = 8_000; function isCanvasRenderer(application: Application): boolean { const rendererName = application?.renderer?.constructor?.name?.toLowerCase(); - return Boolean(rendererName && (rendererName.includes("canvasrenderer") || rendererName.includes("canvas"))); + return Boolean( + rendererName && + (rendererName.includes("canvasrenderer") || rendererName.includes("canvas")), + ); } function toRendererErrorMessage(error: unknown): string { @@ -218,7 +238,10 @@ function toRendererErrorMessage(error: unknown): string { function isRendererUnavailableError(error: unknown): boolean { const message = toRendererErrorMessage(error).toLowerCase(); - return message.includes("canvasrenderer is not yet implemented") || message.includes("no available renderer"); + return ( + message.includes("canvasrenderer is not yet implemented") || + message.includes("no available renderer") + ); } function summarizeRendererAttempts(attempts: readonly PixiRendererAttempt[]): string { @@ -340,6 +363,20 @@ interface VideoPlaybackProps { cropRegion?: import("./types").CropRegion; webcam?: WebcamOverlaySettings; webcamVideoPath?: string | null; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + selectedWebcamFocusRegionId?: string | null; + webcamPositionRegions?: WebcamPositionRegion[]; + selectedWebcamPositionRegionId?: string | null; + onSelectWebcamPositionRegion?: (id: string | null) => void; + onWebcamPositionDragStart?: (positionX?: number, positionY?: number) => string | null; + onWebcamPositionDrag?: (id: string, positionX: number, positionY: number) => void; + onWebcamSizeResizeStart?: () => void; + onWebcamSizeResize?: (sizePercent: number) => void; + onWebcamHeightResize?: (heightPercent: number) => void; + onWebcamSizeResizeEnd?: () => void; + onWebcamMirrorToggle?: () => void; + onWebcamAvoidCursorToggle?: () => void; trimRegions?: TrimRegion[]; speedRegions?: SpeedRegion[]; aspectRatio: AspectRatio; @@ -418,6 +455,19 @@ const VideoPlayback = forwardRef( cropRegion, webcam, webcamVideoPath, + webcamSizeRegions = [], + webcamFocusRegions = [], + webcamPositionRegions = [], + selectedWebcamPositionRegionId = null, + onSelectWebcamPositionRegion, + onWebcamPositionDragStart, + onWebcamPositionDrag, + onWebcamSizeResizeStart, + onWebcamSizeResize, + onWebcamHeightResize, + onWebcamSizeResizeEnd, + onWebcamMirrorToggle, + onWebcamAvoidCursorToggle, trimRegions = [], speedRegions = [], aspectRatio, @@ -494,6 +544,21 @@ const VideoPlayback = forwardRef( const webcamVideoRef = useRef(null); const webcamBubbleRef = useRef(null); const webcamBubbleInnerRef = useRef(null); + const webcamCropContentRef = useRef(null); + const webcamResizeBadgeRef = useRef(null); + const webcamPositionDragRef = useRef<{ + regionId: string | null; + pointerId: number; + startClientX: number; + startClientY: number; + startPositionX: number; + startPositionY: number; + startLeftPx: number; + startTopPx: number; + startWidthPx: number; + startHeightPx: number; + active: boolean; + } | null>(null); const [webcamVideoDimensions, setWebcamVideoDimensions] = useState<{ width: number; height: number; @@ -570,6 +635,25 @@ const VideoPlayback = forwardRef( const zoomMotionBlurRef = useRef(zoomMotionBlur); const zoomMotionBlurTuningRef = useRef(zoomMotionBlurTuning); const lastEmittedClickTimeMsRef = useRef(-1); + const webcamSizeResizeRef = useRef<{ + handle: string; + startClientX: number; + startClientY: number; + startSizePx: number; + startWidthPx: number; + startHeightPx: number; + startLeftPx: number; + startTopPx: number; + startBottomPx: number; + positionRegionId: string | null; + currentPercent: number; + currentWidthPercent: number; + currentHeightPercent: number; + currentWidthPx: number; + currentHeightPx: number; + currentLeftPx: number; + currentTopPx: number; + } | null>(null); // Spring animation state for smooth zoom transitions const springScaleRef = useRef(createSpringState(1)); @@ -583,7 +667,9 @@ const VideoPlayback = forwardRef( ); const initializePixiRenderer = useCallback( - async (container: HTMLDivElement): Promise<{ + async ( + container: HTMLDivElement, + ): Promise<{ app: Application; backend: PixiPreviewBackend; }> => { @@ -603,7 +689,8 @@ const VideoPlayback = forwardRef( } const rendererApp = new Application(); - const initStarted = typeof performance === "undefined" ? Date.now() : performance.now(); + const initStarted = + typeof performance === "undefined" ? Date.now() : performance.now(); try { await initApplicationWithTimeout( rendererApp, @@ -622,7 +709,8 @@ const VideoPlayback = forwardRef( backend, ); const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); if (isCanvasRenderer(rendererApp)) { throw new Error( @@ -632,9 +720,13 @@ const VideoPlayback = forwardRef( return { app: rendererApp, backend }; } catch (error) { const elapsed = Math.round( - (typeof performance === "undefined" ? Date.now() : performance.now()) - initStarted, + (typeof performance === "undefined" ? Date.now() : performance.now()) - + initStarted, ); - attempts.push({ backend, message: `${toRendererErrorMessage(error)} (after ${elapsed}ms)` }); + attempts.push({ + backend, + message: `${toRendererErrorMessage(error)} (after ${elapsed}ms)`, + }); const statusMessage = isRendererUnavailableError(error) ? "renderer backend unavailable in this runtime" : "renderer init failed"; @@ -728,41 +820,58 @@ const VideoPlayback = forwardRef( const webcamEnabled = webcam?.enabled ?? false; const webcamMargin = webcam?.margin ?? 24; const webcamSize = webcam?.size ?? DEFAULT_WEBCAM_SIZE; + const webcamHeight = webcam?.height ?? webcamSize ?? DEFAULT_WEBCAM_HEIGHT; const webcamReactToZoom = webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM; const webcamPositionPreset = webcam?.positionPreset ?? webcam?.corner ?? "bottom-right"; const webcamPositionX = webcam?.positionX ?? 1; const webcamPositionY = webcam?.positionY ?? 1; const webcamCorner = webcam?.corner ?? "bottom-right"; const webcamCornerRadius = webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS; + const webcamAvoidCursor = webcam?.avoidCursor ?? DEFAULT_WEBCAM_AVOID_CURSOR; const webcamShadow = webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW; const webcamTimeOffsetMs = webcam?.timeOffsetMs; const webcamCropRegion = webcam?.cropRegion; const webcamMirror = webcam?.mirror ?? false; - const webcamCropPreviewContentStyle = useMemo(() => { - if (!webcamVideoDimensions) { - return { opacity: 0 }; - } - const { sx, sy, sw, sh } = getWebcamCropSourceRect( - webcamCropRegion, - webcamVideoDimensions.width, - webcamVideoDimensions.height, + const getCurrentWebcamFocusState = useCallback((): WebcamFocusState | null => { + return getInterpolatedFocusStateAtTime( + webcamSize, + webcamFocusRegions, + currentTimeRef.current, + webcamCorner, ); - const coverScale = Math.max(1 / sw, 1 / sh); - const drawWidth = webcamVideoDimensions.width * coverScale; - const drawHeight = webcamVideoDimensions.height * coverScale; - const drawX = (1 - sw * coverScale) / 2 - sx * coverScale; - const drawY = (1 - sh * coverScale) / 2 - sy * coverScale; - - return { - left: `${drawX * 100}%`, - top: `${drawY * 100}%`, - width: `${drawWidth * 100}%`, - height: `${drawHeight * 100}%`, - maxWidth: "none", - willChange: "left, top, width, height", - }; - }, [webcamCropRegion, webcamVideoDimensions]); + }, [webcamCorner, webcamFocusRegions, webcamSize]); + + const applyCameraFocusLayout = useCallback( + (focusState: WebcamFocusState | null) => { + const screenLayer = containerRef.current; + const overlay = overlayRef.current; + if (!screenLayer || !overlay) { + return; + } + + if (!focusState || focusState.progress <= 0.001) { + screenLayer.style.transform = ""; + screenLayer.style.transformOrigin = ""; + screenLayer.style.opacity = ""; + screenLayer.style.zIndex = ""; + return; + } + + const transform = getWebcamFocusScreenTransform({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + screenSizePercent: focusState.screenSize, + screenCorner: focusState.screenCorner, + margin: webcamMargin, + }); + screenLayer.style.transformOrigin = "top left"; + screenLayer.style.transform = `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`; + screenLayer.style.opacity = `${Math.max(0, Math.min(1, focusState.screenOpacity))}`; + screenLayer.style.zIndex = focusState.screenMode === "hidden" ? "0" : "2"; + }, + [webcamMargin], + ); const applyWebcamBubbleLayout = useCallback( (zoomScale: number) => { @@ -773,43 +882,177 @@ const VideoPlayback = forwardRef( if (bubble) { bubble.style.display = "none"; } + applyCameraFocusLayout(null); return; } - const scaledSize = getWebcamOverlaySizePx({ - containerWidth: overlay.clientWidth, - containerHeight: overlay.clientHeight, - sizePercent: webcamSize, - margin: webcamMargin, - zoomScale, - reactToZoom: webcamReactToZoom, - }); - const { x, y } = getWebcamOverlayPosition({ + const activeResize = webcamSizeResizeRef.current; + const focusState = activeResize ? null : getCurrentWebcamFocusState(); + const regionalWebcamDimensions = activeResize + ? { + size: activeResize.currentWidthPercent, + height: activeResize.currentHeightPercent, + } + : getInterpolatedWebcamDimensionsAtTime( + webcamSize, + webcamHeight, + webcamSizeRegions, + currentTimeRef.current, + ); + const effectiveWebcamWidth = + focusState?.webcamSize ?? regionalWebcamDimensions.size; + const effectiveWebcamHeight = + focusState?.webcamSize ?? regionalWebcamDimensions.height; + + const scaledWidth = + activeResize?.currentWidthPx ?? + getWebcamOverlaySizePx({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + sizePercent: effectiveWebcamWidth, + margin: webcamMargin, + zoomScale: focusState ? 1 : zoomScale, + reactToZoom: focusState ? false : webcamReactToZoom, + }); + const scaledHeight = + activeResize?.currentHeightPx ?? + getWebcamOverlaySizePx({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + sizePercent: effectiveWebcamHeight, + margin: webcamMargin, + zoomScale: focusState ? 1 : zoomScale, + reactToZoom: focusState ? false : webcamReactToZoom, + }); + const normalWidth = + activeResize?.currentWidthPx ?? + getWebcamOverlaySizePx({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + sizePercent: regionalWebcamDimensions.size, + margin: webcamMargin, + zoomScale, + reactToZoom: webcamReactToZoom, + }); + const normalHeight = + activeResize?.currentHeightPx ?? + getWebcamOverlaySizePx({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + sizePercent: regionalWebcamDimensions.height, + margin: webcamMargin, + zoomScale, + reactToZoom: webcamReactToZoom, + }); + const interpolatedPosition = getInterpolatedWebcamPositionAtTime( + { positionX: webcamPositionX, positionY: webcamPositionY }, + webcamPositionRegions, + currentTimeRef.current, + ); + const hasActivePositionRegion = webcamPositionRegions.length > 0; + const normalPosition = activeResize + ? { x: activeResize.currentLeftPx, y: activeResize.currentTopPx } + : getWebcamOverlayPosition({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + size: normalWidth, + height: normalHeight, + margin: webcamMargin, + positionPreset: hasActivePositionRegion + ? "custom" + : webcamPositionPreset, + positionX: interpolatedPosition.positionX, + positionY: interpolatedPosition.positionY, + legacyCorner: webcamCorner, + }); + const focusPosition = { + x: (overlay.clientWidth - scaledWidth) / 2, + y: (overlay.clientHeight - scaledHeight) / 2, + }; + const focusProgress = focusState?.progress ?? 0; + const blendedPosition = clampWebcamOverlayPosition({ containerWidth: overlay.clientWidth, containerHeight: overlay.clientHeight, - size: scaledSize, + size: scaledWidth, + height: scaledHeight, margin: webcamMargin, - positionPreset: webcamPositionPreset, - positionX: webcamPositionX, - positionY: webcamPositionY, - legacyCorner: webcamCorner, + position: { + x: normalPosition.x + (focusPosition.x - normalPosition.x) * focusProgress, + y: normalPosition.y + (focusPosition.y - normalPosition.y) * focusProgress, + }, }); + let x = blendedPosition.x; + let y = blendedPosition.y; + + if (webcamAvoidCursor && (!focusState || focusState.progress <= 0.001)) { + const cursor = getCursorPositionAtTime( + cursorTelemetryRef.current, + currentTimeRef.current, + { + maskRect: baseMaskRef.current, + canvasWidth: overlay.clientWidth, + canvasHeight: overlay.clientHeight, + }, + ); + const avoidedPosition = getWebcamAvoidCursorPosition({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + size: scaledWidth, + height: scaledHeight, + margin: webcamMargin, + currentPosition: { x, y }, + cursor: cursor + ? { + x: cursor.cx * overlay.clientWidth, + y: cursor.cy * overlay.clientHeight, + } + : null, + legacyCorner: webcamCorner, + }); + const clampedAvoidedPosition = clampWebcamOverlayPosition({ + containerWidth: overlay.clientWidth, + containerHeight: overlay.clientHeight, + size: scaledWidth, + height: scaledHeight, + margin: webcamMargin, + position: avoidedPosition, + }); + x = clampedAvoidedPosition.x; + y = clampedAvoidedPosition.y; + } bubble.style.display = "block"; bubble.style.left = `${x}px`; bubble.style.top = `${y}px`; - bubble.style.width = `${scaledSize}px`; - bubble.style.height = `${scaledSize}px`; - bubble.style.aspectRatio = "1 / 1"; + bubble.style.width = `${scaledWidth}px`; + bubble.style.height = `${scaledHeight}px`; + bubble.style.transition = ""; + bubble.style.zIndex = focusState ? "6" : ""; + bubble.style.aspectRatio = `${Math.max(1, scaledWidth)} / ${Math.max(1, scaledHeight)}`; + const cropContent = webcamCropContentRef.current; + if (cropContent && webcamVideoDimensions) { + const cropLayout = getWebcamCropDrawLayout({ + cropRegion: webcamCropRegion, + sourceWidth: webcamVideoDimensions.width, + sourceHeight: webcamVideoDimensions.height, + targetWidth: scaledWidth, + targetHeight: scaledHeight, + }); + cropContent.style.left = `${cropLayout.drawX}px`; + cropContent.style.top = `${cropLayout.drawY}px`; + cropContent.style.width = `${cropLayout.drawWidth}px`; + cropContent.style.height = `${cropLayout.drawHeight}px`; + } const squirclePath = getSquircleSvgPath({ x: 0, y: 0, - width: scaledSize, - height: scaledSize, + width: scaledWidth, + height: scaledHeight, radius: webcamCornerRadius, }); - bubble.style.filter = `drop-shadow(0 ${Math.round(scaledSize * 0.06)}px ${Math.round( - scaledSize * 0.22, + const shadowBasis = Math.max(scaledWidth, scaledHeight); + bubble.style.filter = `drop-shadow(0 ${Math.round(shadowBasis * 0.06)}px ${Math.round( + shadowBasis * 0.22, )}px rgba(0, 0, 0, ${webcamShadow}))`; bubble.style.borderRadius = "0px"; bubble.style.boxShadow = "none"; @@ -819,18 +1062,27 @@ const VideoPlayback = forwardRef( bubbleInner.style.contain = "paint"; bubbleInner.style.clipPath = `path('${squirclePath}')`; bubbleInner.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`); + applyCameraFocusLayout(focusState); }, [ + applyCameraFocusLayout, + webcamAvoidCursor, webcamCorner, webcamCornerRadius, webcamEnabled, + getCurrentWebcamFocusState, webcamMargin, webcamPositionPreset, + webcamPositionRegions, webcamPositionX, webcamPositionY, webcamReactToZoom, webcamShadow, + webcamCropRegion, + webcamHeight, webcamSize, + webcamSizeRegions, + webcamVideoDimensions, webcamVideoPath, ], ); @@ -1061,7 +1313,9 @@ const VideoPlayback = forwardRef( const activeFrameData = frame ? extensionHost.getFrames().find((registeredFrame) => registeredFrame.id === frame) : null; - const shouldRedrawDynamicFrame = Boolean(activeFrameData?.draw && frameSpriteRef.current); + const shouldRedrawDynamicFrame = Boolean( + activeFrameData?.draw && frameSpriteRef.current, + ); // Layout-only changes should not force texture/sprite recreation. if (frameReloadKeyRef.current === nextFrameReloadKey && !shouldRedrawDynamicFrame) { @@ -1694,6 +1948,14 @@ const VideoPlayback = forwardRef( applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); }, [applyWebcamBubbleLayout, pixiReady, videoReady]); + // Re-apply layout when the playhead moves while paused so that webcam + // size regions take effect on seek without waiting for the next zoom tick. + useEffect(() => { + if (!pixiReady || !videoReady) return; + if (!webcamSizeRegions || webcamSizeRegions.length === 0) return; + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + }, [currentTime, webcamSizeRegions, applyWebcamBubbleLayout, pixiReady, videoReady]); + const syncWebcamMedia = useCallback(() => { const webcamVideo = webcamVideoRef.current; if (!webcamVideo || !webcamEnabled || !webcamVideoPath) { @@ -2236,10 +2498,7 @@ const VideoPlayback = forwardRef( resetSpringState(springYRef.current, appliedY); } - applyTransform( - { scale: appliedScale, x: appliedX, y: appliedY }, - targetFocus, - ); + applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, targetFocus); applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); @@ -2704,6 +2963,409 @@ const VideoPlayback = forwardRef( return 16 / 9; })(); + const handleWebcamBubblePointerDown = (event: React.PointerEvent) => { + if (!onWebcamPositionDragStart && !onWebcamPositionDrag) { + return; + } + const target = event.target as HTMLElement | null; + if (target?.dataset.webcamResize || target?.closest("[data-webcam-control]")) { + return; + } + const focusState = getCurrentWebcamFocusState(); + if (focusState && focusState.progress > 0.001) { + return; + } + const bubble = webcamBubbleRef.current; + const overlay = overlayRef.current; + if (!bubble || !overlay) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + bubble.style.cursor = "grabbing"; + + const overlayRect = overlay.getBoundingClientRect(); + const bubbleRect = bubble.getBoundingClientRect(); + const startLeftPx = bubbleRect.left - overlayRect.left; + const startTopPx = bubbleRect.top - overlayRect.top; + const startWidthPx = bubbleRect.width; + const startHeightPx = bubbleRect.height; + const startAvailableWidth = Math.max( + 1, + overlay.clientWidth - startWidthPx - webcamMargin * 2, + ); + const startAvailableHeight = Math.max( + 1, + overlay.clientHeight - startHeightPx - webcamMargin * 2, + ); + const startPositionX = Math.max( + 0, + Math.min(1, (startLeftPx - webcamMargin) / startAvailableWidth), + ); + const startPositionY = Math.max( + 0, + Math.min(1, (startTopPx - webcamMargin) / startAvailableHeight), + ); + + webcamPositionDragRef.current = { + regionId: null, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startPositionX, + startPositionY, + startLeftPx, + startTopPx, + startWidthPx, + startHeightPx, + active: false, + }; + + const handleMove = (moveEvent: PointerEvent) => { + const drag = webcamPositionDragRef.current; + if (!drag || moveEvent.pointerId !== drag.pointerId) return; + const overlayElement = overlayRef.current; + if (!overlayElement) return; + + const deltaX = moveEvent.clientX - drag.startClientX; + const deltaY = moveEvent.clientY - drag.startClientY; + if (!drag.active && Math.abs(deltaX) < 4 && Math.abs(deltaY) < 4) { + return; + } + + const availableWidth = Math.max( + 1, + overlayElement.clientWidth - drag.startWidthPx - webcamMargin * 2, + ); + const availableHeight = Math.max( + 1, + overlayElement.clientHeight - drag.startHeightPx - webcamMargin * 2, + ); + const rawX = Math.max( + 0, + Math.min(1, (drag.startLeftPx + deltaX - webcamMargin) / availableWidth), + ); + const rawY = Math.max( + 0, + Math.min(1, (drag.startTopPx + deltaY - webcamMargin) / availableHeight), + ); + const snappedPosition = getSnappedWebcamPositionPoint({ x: rawX, y: rawY }); + const nextX = snappedPosition.x; + const nextY = snappedPosition.y; + + if (!drag.active) { + drag.active = true; + if (onWebcamPositionDragStart) { + drag.regionId = onWebcamPositionDragStart(nextX, nextY) ?? null; + } + } + + if (drag.regionId && onWebcamPositionDrag) { + onWebcamPositionDrag(drag.regionId, nextX, nextY); + } + }; + + const handleUp = (upEvent: PointerEvent) => { + const drag = webcamPositionDragRef.current; + if (!drag || upEvent.pointerId !== drag.pointerId) return; + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleUp); + if (webcamBubbleRef.current) { + webcamBubbleRef.current.style.cursor = + onWebcamPositionDragStart || onWebcamPositionDrag ? "grab" : ""; + } + if (!drag.active && onSelectWebcamPositionRegion) { + onSelectWebcamPositionRegion( + drag.regionId ?? selectedWebcamPositionRegionId ?? null, + ); + } + webcamPositionDragRef.current = null; + }; + + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleUp); + }; + + const handleWebcamResizePointerDown = ( + event: React.PointerEvent, + handle: string, + ) => { + if (!onWebcamSizeResize && !onWebcamHeightResize) { + return; + } + const focusState = getCurrentWebcamFocusState(); + if (focusState && focusState.progress > 0.001) { + return; + } + + const bubble = webcamBubbleRef.current; + const overlay = overlayRef.current; + if (!bubble || !overlay) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const rect = bubble.getBoundingClientRect(); + const overlayRect = overlay.getBoundingClientRect(); + const startLeftPx = rect.left - overlayRect.left; + const startTopPx = rect.top - overlayRect.top; + const startWidthPx = rect.width; + const startHeightPx = rect.height; + const startAvailableWidth = Math.max( + 1, + overlay.clientWidth - startWidthPx - webcamMargin * 2, + ); + const startAvailableHeight = Math.max( + 1, + overlay.clientHeight - startHeightPx - webcamMargin * 2, + ); + const startPositionX = Math.max( + 0, + Math.min(1, (startLeftPx - webcamMargin) / startAvailableWidth), + ); + const startPositionY = Math.max( + 0, + Math.min(1, (startTopPx - webcamMargin) / startAvailableHeight), + ); + const isVerticalStretch = handle === "top"; + const positionRegionId = onWebcamPositionDragStart + ? (onWebcamPositionDragStart(startPositionX, startPositionY) ?? null) + : null; + webcamSizeResizeRef.current = { + handle, + startClientX: event.clientX, + startClientY: event.clientY, + startSizePx: rect.width, + startWidthPx, + startHeightPx, + startLeftPx, + startTopPx, + startBottomPx: startTopPx + startHeightPx, + positionRegionId, + currentPercent: isVerticalStretch ? webcamHeight : webcamSize, + currentWidthPercent: webcamSize, + currentHeightPercent: webcamHeight, + currentWidthPx: startWidthPx, + currentHeightPx: startHeightPx, + currentLeftPx: startLeftPx, + currentTopPx: startTopPx, + }; + onWebcamSizeResizeStart?.(); + + const updateBadge = (clientX: number, clientY: number, label: string) => { + const badge = webcamResizeBadgeRef.current; + if (!badge) return; + badge.style.display = "block"; + badge.style.left = `${clientX + 12}px`; + badge.style.top = `${clientY + 12}px`; + badge.textContent = label; + }; + + const persistResizePosition = ( + drag: NonNullable, + activeOverlay: HTMLDivElement, + widthPx: number, + heightPx: number, + leftPx: number, + topPx: number, + ) => { + if (!drag.positionRegionId || !onWebcamPositionDrag) { + return; + } + const availableWidth = Math.max( + 1, + activeOverlay.clientWidth - widthPx - webcamMargin * 2, + ); + const availableHeight = Math.max( + 1, + activeOverlay.clientHeight - heightPx - webcamMargin * 2, + ); + const nextX = Math.max(0, Math.min(1, (leftPx - webcamMargin) / availableWidth)); + const nextY = Math.max(0, Math.min(1, (topPx - webcamMargin) / availableHeight)); + onWebcamPositionDrag(drag.positionRegionId, nextX, nextY); + }; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const drag = webcamSizeResizeRef.current; + const activeOverlay = overlayRef.current; + if (!drag || !activeOverlay) { + return; + } + + const deltaX = moveEvent.clientX - drag.startClientX; + const deltaY = moveEvent.clientY - drag.startClientY; + + if (drag.handle === "top") { + const maxHeightPx = Math.max( + 56, + Math.min(activeOverlay.clientWidth, activeOverlay.clientHeight) - + webcamMargin * 2, + ); + const nextHeightPx = Math.max( + 56, + Math.min(maxHeightPx, drag.startHeightPx - deltaY), + ); + const nextPercent = getWebcamSizePercentFromPx({ + sizePx: nextHeightPx, + containerWidth: activeOverlay.clientWidth, + containerHeight: activeOverlay.clientHeight, + zoomScale: animationStateRef.current.appliedScale || 1, + reactToZoom: webcamReactToZoom, + }); + drag.currentPercent = nextPercent; + drag.currentHeightPercent = nextPercent; + drag.currentHeightPx = nextHeightPx; + drag.currentTopPx = drag.startBottomPx - nextHeightPx; + const clampedPosition = clampWebcamOverlayPosition({ + containerWidth: activeOverlay.clientWidth, + containerHeight: activeOverlay.clientHeight, + size: drag.currentWidthPx, + height: nextHeightPx, + margin: webcamMargin, + position: { x: drag.currentLeftPx, y: drag.currentTopPx }, + }); + drag.currentLeftPx = clampedPosition.x; + drag.currentTopPx = clampedPosition.y; + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + onWebcamHeightResize?.(nextPercent); + persistResizePosition( + drag, + activeOverlay, + drag.currentWidthPx, + nextHeightPx, + drag.currentLeftPx, + drag.currentTopPx, + ); + + updateBadge( + moveEvent.clientX, + moveEvent.clientY, + `H ${Math.round(nextPercent)}%`, + ); + return; + } + + const horizontalDelta = drag.handle.includes("right") + ? deltaX + : drag.handle.includes("left") + ? -deltaX + : 0; + const verticalDelta = drag.handle.includes("bottom") + ? deltaY + : drag.handle.includes("top") + ? -deltaY + : 0; + const delta = + Math.abs(horizontalDelta) > Math.abs(verticalDelta) + ? horizontalDelta + : verticalDelta; + const maxSizePx = Math.max( + 56, + Math.min(activeOverlay.clientWidth, activeOverlay.clientHeight) - + webcamMargin * 2, + ); + const nextSizePx = Math.max(56, Math.min(maxSizePx, drag.startSizePx + delta)); + const nextPercent = getWebcamSizePercentFromPx({ + sizePx: nextSizePx, + containerWidth: activeOverlay.clientWidth, + containerHeight: activeOverlay.clientHeight, + zoomScale: animationStateRef.current.appliedScale || 1, + reactToZoom: webcamReactToZoom, + }); + drag.currentPercent = nextPercent; + drag.currentWidthPercent = nextPercent; + drag.currentHeightPercent = nextPercent; + drag.currentWidthPx = nextSizePx; + drag.currentHeightPx = nextSizePx; + const nextLeftPx = drag.handle.includes("left") + ? drag.startLeftPx + drag.startWidthPx - nextSizePx + : drag.handle.includes("right") + ? drag.startLeftPx + : drag.startLeftPx + (drag.startWidthPx - nextSizePx) / 2; + const nextTopPx = drag.handle.includes("top") + ? drag.startTopPx + drag.startHeightPx - nextSizePx + : drag.handle.includes("bottom") + ? drag.startTopPx + : drag.startTopPx + (drag.startHeightPx - nextSizePx) / 2; + const clampedPosition = clampWebcamOverlayPosition({ + containerWidth: activeOverlay.clientWidth, + containerHeight: activeOverlay.clientHeight, + size: nextSizePx, + height: nextSizePx, + margin: webcamMargin, + position: { x: nextLeftPx, y: nextTopPx }, + }); + drag.currentLeftPx = clampedPosition.x; + drag.currentTopPx = clampedPosition.y; + applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + onWebcamSizeResize?.(nextPercent); + onWebcamHeightResize?.(nextPercent); + persistResizePosition( + drag, + activeOverlay, + nextSizePx, + nextSizePx, + drag.currentLeftPx, + drag.currentTopPx, + ); + updateBadge(moveEvent.clientX, moveEvent.clientY, `${Math.round(nextPercent)}%`); + }; + + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + webcamSizeResizeRef.current = null; + if (webcamResizeBadgeRef.current) { + webcamResizeBadgeRef.current.style.display = "none"; + } + onWebcamSizeResizeEnd?.(); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp, { once: true }); + window.addEventListener("pointercancel", handlePointerUp, { once: true }); + }; + + const webcamResizeHandles = [ + { + id: "top-left", + className: "left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize", + }, + { + id: "top", + className: "left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize", + }, + { + id: "top-right", + className: "right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize", + }, + { + id: "right", + className: "right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize", + }, + { + id: "bottom-right", + className: "right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize", + }, + { + id: "bottom", + className: "left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-ns-resize", + }, + { + id: "bottom-left", + className: "left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize", + }, + { + id: "left", + className: "left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize", + }, + ]; + return (
( 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", + : "none", }} /> {hasRendererFallback && ( @@ -2750,7 +3412,8 @@ const VideoPlayback = forwardRef(
{`Pixi renderer unavailable on this environment (${pixiRendererBackend ?? "unknown"}).`}
- Fallback to 2D native preview so you can continue working while the GPU path is unavailable. + Fallback to 2D native preview so you can continue working while the GPU + path is unavailable.
)} @@ -2779,11 +3442,16 @@ const VideoPlayback = forwardRef( {webcam && webcamVideoPath ? (
( }} >
+
+ {onWebcamAvoidCursorToggle ? ( + + ) : null} + {onWebcamMirrorToggle ? ( + + ) : null} +
+
+ {webcamResizeHandles.map((handle) => ( +
+ handleWebcamResizePointerDown(event, handle.id) + } + /> + ))} +
) : null} +
{activeCaptionLayout && autoCaptionSettings ? (
; defaultSourceAudioTrackSettings?: SourceAudioTrackSettings; @@ -163,10 +174,7 @@ type PersistedDevMotionBlurSettings = { export function stripPersistedDevMotionBlurSettings( editor: T, ): Omit { - const { - zoomMotionBlurTuning: _zoomMotionBlurTuning, - ...persistedEditor - } = editor; + const { zoomMotionBlurTuning: _zoomMotionBlurTuning, ...persistedEditor } = editor; return persistedEditor; } @@ -652,6 +660,16 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; + const normalizedWebcamSizeRegions: WebcamSizeRegion[] = normalizeWebcamSizeRegions( + (editor as Partial).webcamSizeRegions, + ); + const normalizedWebcamFocusRegions: WebcamFocusRegion[] = normalizeWebcamFocusRegions( + (editor as Partial).webcamFocusRegions, + ); + const normalizedWebcamPositionRegions: WebcamPositionRegion[] = normalizeWebcamPositionRegions( + (editor as Partial).webcamPositionRegions, + ); + const normalizedAudioRegions: AudioRegion[] = Array.isArray( (editor as Partial).audioRegions, ) @@ -669,17 +687,17 @@ export function normalizeProjectEditor(editor: Partial): Pro const startMs = Math.max(0, Math.min(rawStart, rawEnd)); const endMs = Math.max(startMs + 1, rawEnd); - return { - id: region.id, - startMs, - endMs, - audioPath: typeof region.audioPath === "string" ? region.audioPath : "", - volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1, - normalize: Boolean(region.normalize), - trackIndex: isFiniteNumber(region.trackIndex) - ? Math.max(0, Math.floor(region.trackIndex)) - : 0, - }; + return { + id: region.id, + startMs, + endMs, + audioPath: typeof region.audioPath === "string" ? region.audioPath : "", + volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1, + normalize: Boolean(region.normalize), + trackIndex: isFiniteNumber(region.trackIndex) + ? Math.max(0, Math.floor(region.trackIndex)) + : 0, + }; }) : []; @@ -987,12 +1005,21 @@ export function normalizeProjectEditor(editor: Partial): Pro ? webcam.corner : DEFAULT_WEBCAM_OVERLAY.corner, size: isFiniteNumber(webcam.size) ? clamp(webcam.size, 10, 100) : DEFAULT_WEBCAM_SIZE, + height: isFiniteNumber(webcam.height) + ? clamp(webcam.height, 10, 100) + : isFiniteNumber(webcam.size) + ? clamp(webcam.size, 10, 100) + : DEFAULT_WEBCAM_HEIGHT, reactToZoom: typeof webcam.reactToZoom === "boolean" ? webcam.reactToZoom : legacyZoomScaleEffect != null ? legacyZoomScaleEffect > 0 : DEFAULT_WEBCAM_REACT_TO_ZOOM, + avoidCursor: + typeof webcam.avoidCursor === "boolean" + ? webcam.avoidCursor + : DEFAULT_WEBCAM_AVOID_CURSOR, cornerRadius: isFiniteNumber(webcam.cornerRadius) ? clamp(webcam.cornerRadius, 0, 160) : DEFAULT_WEBCAM_CORNER_RADIUS, @@ -1006,6 +1033,9 @@ export function normalizeProjectEditor(editor: Partial): Pro ? clamp(webcam.margin, 0, 96) : DEFAULT_WEBCAM_MARGIN, }, + webcamSizeRegions: normalizedWebcamSizeRegions, + webcamFocusRegions: normalizedWebcamFocusRegions, + webcamPositionRegions: normalizedWebcamPositionRegions, sourceAudioTrackSettingsByClip: editor.sourceAudioTrackSettingsByClip && typeof editor.sourceAudioTrackSettingsByClip === "object" diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index b38e42f76..2cc77bae3 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,4 +1,5 @@ import { + ArrowsOutSimple, FilmSlate as Film, Gauge, ChatCircle as MessageSquare, @@ -6,13 +7,14 @@ import { MouseLeftClickIcon as PhMouseLeftClick, Scissors, SpeakerX, + VideoCamera, MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; import { useMemo } from "react"; -import { cn } from "@/lib/utils"; import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; import AudioWaveform from "./components/waveform/AudioWaveform"; import type { AudioPeaksData } from "./core/timelineTypes"; import glassStyles from "./ItemGlass.module.css"; @@ -34,7 +36,19 @@ interface ItemProps { waveformGain?: number; waveformNormalize?: boolean; muted?: boolean; - variant?: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio"; + webcamSizePercent?: number; + webcamHeightPercent?: number; + webcamFocusPercent?: number; + variant?: + | "zoom" + | "trim" + | "clip" + | "annotation" + | "speed" + | "audio" + | "webcam-size" + | "webcam-focus" + | "webcam-position"; isLoading?: boolean; loadingLabel?: string; } @@ -75,6 +89,9 @@ export default function Item({ waveformGain = 1, waveformNormalize = false, muted = false, + webcamSizePercent, + webcamHeightPercent, + webcamFocusPercent, variant = "zoom", isLoading = false, loadingLabel, @@ -124,7 +141,17 @@ export default function Item({ const isClip = variant === "clip"; const isSpeed = variant === "speed"; const isAudio = variant === "audio"; + const isWebcamSize = variant === "webcam-size"; + const isWebcamFocus = variant === "webcam-focus"; + const isWebcamPosition = variant === "webcam-position"; const showAudioWaveform = isAudio && Boolean(waveformPeaks); + const webcamSizeLabel = + webcamSizePercent !== undefined + ? webcamHeightPercent !== undefined && + Math.round(webcamHeightPercent) !== Math.round(webcamSizePercent) + ? `${Math.round(webcamSizePercent)}x${Math.round(webcamHeightPercent)}%` + : `${Math.round(webcamSizePercent)}%` + : "Camera"; const glassClass = isZoom ? glassStyles.glassPurple @@ -136,7 +163,13 @@ export default function Item({ ? glassStyles.glassAmber : isAudio ? glassStyles.glassDarkGreen - : glassStyles.glassYellow; + : isWebcamSize + ? glassStyles.glassGreen + : isWebcamFocus + ? glassStyles.glassPink + : isWebcamPosition + ? (glassStyles.glassBlue ?? glassStyles.glassPink) + : glassStyles.glassYellow; const MIN_ITEM_PX = 6; const handleSelect = () => { @@ -249,6 +282,22 @@ export default function Item({ {children} + ) : isWebcamSize ? ( + <> + + + {webcamSizeLabel} + + + ) : isWebcamFocus ? ( + <> + + + {webcamFocusPercent !== undefined + ? `Focus ${Math.round(webcamFocusPercent)}%` + : "Focus"} + + ) : ( <> diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index d22257f84..262e41e70 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -6,6 +6,7 @@ interface RowProps extends RowDefinition { label?: string; hint?: string; isEmpty?: boolean; + baseline?: boolean; labelColor?: string; onMouseEnter?: React.MouseEventHandler; onMouseMove?: React.MouseEventHandler; @@ -20,6 +21,7 @@ export default function Row({ label, hint, isEmpty, + baseline, labelColor = "#666", onMouseEnter, onMouseMove, @@ -31,7 +33,7 @@ export default function Row({ return (
{label && ( @@ -49,7 +51,7 @@ export default function Row({ )}
+ {baseline && !isEmpty && ( +
+
+
+ )} {children}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 02b8b9b71..2341c17b9 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,20 +1,14 @@ -import type { Span } from "dnd-timeline"; import { Plus } from "@phosphor-icons/react"; -import { - forwardRef, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useScopedT } from "@/contexts/I18nContext"; -import { useShortcuts } from "@/contexts/ShortcutsContext"; -import { fromFileUrl } from "../projectPersistence"; +import type { Span } from "dnd-timeline"; +import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import type { SourceAudioTrackMeta, SourceAudioTrackSettings, SourceAudioTrackWithPeaks, } from "@/components/video-editor/audio/audioTypes"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useShortcuts } from "@/contexts/ShortcutsContext"; +import { fromFileUrl } from "../projectPersistence"; import type { AnnotationRegion, AudioRegion, @@ -22,16 +16,19 @@ import type { CursorTelemetryPoint, SpeedRegion, TrimRegion, + WebcamFocusRegion, + WebcamPositionRegion, + WebcamSizeRegion, ZoomFocus, ZoomRegion, } from "../types"; import KeyframeMarkers from "./components/markers/KeyframeMarkers"; +import TimelineCanvas from "./components/viewport/TimelineCanvas"; import TimelineWrapper from "./components/wrapper/TimelineWrapper"; -import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks"; import { calculateTimelineScale } from "./core/time"; +import { useTimelineAudioPeaks } from "./hooks/useTimelineAudioPeaks"; import { useTimelineEditorRuntime } from "./hooks/useTimelineEditorRuntime"; import { useTimelineRange } from "./hooks/useTimelineRange"; -import TimelineCanvas from "./components/viewport/TimelineCanvas"; export interface TimelineEditorProps { videoDuration: number; @@ -71,15 +68,28 @@ export interface TimelineEditorProps { onAudioDelete?: (id: string) => void; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; + webcamSizeRegions?: WebcamSizeRegion[]; + onWebcamSizeSpanChange?: (id: string, span: Span) => void; + onWebcamSizeDelete?: (id: string) => void; + selectedWebcamSizeRegionId?: string | null; + onSelectWebcamSize?: (id: string | null) => void; + webcamFocusRegions?: WebcamFocusRegion[]; + onWebcamFocusSpanChange?: (id: string, span: Span) => void; + onWebcamFocusDelete?: (id: string) => void; + selectedWebcamFocusRegionId?: string | null; + onSelectWebcamFocus?: (id: string | null) => void; + webcamPositionRegions?: WebcamPositionRegion[]; + onWebcamPositionSpanChange?: (id: string, span: Span) => void; + onWebcamPositionDelete?: (id: string) => void; + selectedWebcamPositionRegionId?: string | null; + onSelectWebcamPosition?: (id: string | null) => void; videoPath?: string | null; videoSourcePath?: string | null; cursorTelemetrySourcePath?: string | null; showSourceAudioTrack?: boolean; onSourceAudioAvailabilityChange?: (available: boolean) => void; sourceAudioTrackSettings?: SourceAudioTrackSettings; - getSourceAudioTrackSettingsForClip?: ( - clipId: string | null, - ) => SourceAudioTrackSettings; + getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings; onSourceAudioTracksMetaChange?: (tracks: SourceAudioTrackMeta) => void; } @@ -117,7 +127,6 @@ export interface TimelineEditorHandle { keyframes: { id: string; time: number }[]; } - const TimelineEditor = forwardRef( function TimelineEditor( { @@ -158,6 +167,21 @@ const TimelineEditor = forwardRef( onAudioDelete, selectedAudioId, onSelectAudio, + webcamSizeRegions = [], + onWebcamSizeSpanChange, + onWebcamSizeDelete, + selectedWebcamSizeRegionId = null, + onSelectWebcamSize, + webcamFocusRegions = [], + onWebcamFocusSpanChange, + onWebcamFocusDelete, + selectedWebcamFocusRegionId = null, + onSelectWebcamFocus, + webcamPositionRegions = [], + onWebcamPositionSpanChange, + onWebcamPositionDelete, + selectedWebcamPositionRegionId = null, + onSelectWebcamPosition, videoPath, videoSourcePath, cursorTelemetrySourcePath, @@ -209,9 +233,7 @@ const TimelineEditor = forwardRef( ...(newStart > oldClip.startMs ? [{ startMs: oldClip.startMs, endMs: newStart }] : []), - ...(newEnd < oldClip.endMs - ? [{ startMs: newEnd, endMs: oldClip.endMs }] - : []), + ...(newEnd < oldClip.endMs ? [{ startMs: newEnd, endMs: oldClip.endMs }] : []), ]; const startDelta = newStart - oldClip.startMs; @@ -245,9 +267,12 @@ const TimelineEditor = forwardRef( return { previewSpans, hiddenZoomIds }; }, [clipRegions, liveSpanPreviewById, zoomRegions]); const { shortcuts: keyShortcuts, isMac } = useShortcuts(); - const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks(videoPath, { - enableSourceSidecarFallback: true, - }); + const { peaks: sourceAudioPeaks, loading: sourceAudioLoading } = useTimelineAudioPeaks( + videoPath, + { + enableSourceSidecarFallback: true, + }, + ); const localSourcePath = useMemo(() => { if (!videoPath) return null; return ( @@ -263,8 +288,10 @@ const TimelineEditor = forwardRef( () => (localSourcePath ? buildSourceSidecarPath(localSourcePath, "system") : null), [localSourcePath], ); - const { peaks: micSidecarPeaks, loading: micSidecarLoading } = useTimelineAudioPeaks(micSidecarPath); - const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = useTimelineAudioPeaks(systemSidecarPath); + const { peaks: micSidecarPeaks, loading: micSidecarLoading } = + useTimelineAudioPeaks(micSidecarPath); + const { peaks: systemSidecarPeaks, loading: systemSidecarLoading } = + useTimelineAudioPeaks(systemSidecarPath); const sourceAudioTracks = useMemo(() => { if (systemSidecarPeaks || micSidecarPeaks) { const tracks: SourceAudioTrackWithPeaks[] = []; @@ -295,16 +322,26 @@ const TimelineEditor = forwardRef( const isLoading = useMemo(() => { // If we are still actively trying to load audio peaks (main or sidecars) - if (videoPath && (sourceAudioLoading || micSidecarLoading || systemSidecarLoading)) return true; + if (videoPath && (sourceAudioLoading || micSidecarLoading || systemSidecarLoading)) + return true; // Robust telemetry loading detection: // If a source path is set but telemetry hasn't arrived (or failed/retried) for it yet. if (videoSourcePath && cursorTelemetrySourcePath !== videoSourcePath) return true; return false; - }, [videoPath, videoSourcePath, cursorTelemetrySourcePath, sourceAudioLoading, micSidecarLoading, systemSidecarLoading]); + }, [ + videoPath, + videoSourcePath, + cursorTelemetrySourcePath, + sourceAudioLoading, + micSidecarLoading, + systemSidecarLoading, + ]); useEffect(() => { - onSourceAudioTracksMetaChange?.(sourceAudioTracks.map((t) => ({ id: t.id, label: t.label }))); + onSourceAudioTracksMetaChange?.( + sourceAudioTracks.map((t) => ({ id: t.id, label: t.label })), + ); }, [onSourceAudioTracksMetaChange, sourceAudioTracks]); void sourceAudioTrackSettings; useEffect(() => { @@ -323,6 +360,9 @@ const TimelineEditor = forwardRef( handleSelectClip, handleSelectAnnotation, handleSelectAudio, + handleSelectWebcamSize, + handleSelectWebcamFocus, + handleSelectWebcamPosition, hasOverlap, timelineItems, allRegionSpans, @@ -369,6 +409,21 @@ const TimelineEditor = forwardRef( onAudioDelete, selectedAudioId, onSelectAudio, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, + onWebcamSizeSpanChange, + onWebcamSizeDelete, + selectedWebcamSizeRegionId, + onSelectWebcamSize, + onWebcamFocusSpanChange, + onWebcamFocusDelete, + selectedWebcamFocusRegionId, + onSelectWebcamFocus, + onWebcamPositionSpanChange, + onWebcamPositionDelete, + selectedWebcamPositionRegionId, + onSelectWebcamPosition, isMac, keyShortcuts, isTimelineFocusedRef, @@ -394,7 +449,7 @@ const TimelineEditor = forwardRef(
{ isTimelineFocusedRef.current = true; @@ -461,10 +516,16 @@ const TimelineEditor = forwardRef( onSelectClip={handleSelectClip} onSelectAnnotation={handleSelectAnnotation} onSelectAudio={handleSelectAudio} + onSelectWebcamSize={handleSelectWebcamSize} + onSelectWebcamFocus={handleSelectWebcamFocus} + onSelectWebcamPosition={handleSelectWebcamPosition} selectedZoomId={selectedZoomId} selectedClipId={selectedClipId} selectedAnnotationId={selectedAnnotationId} selectedAudioId={selectedAudioId} + selectedWebcamSizeRegionId={selectedWebcamSizeRegionId} + selectedWebcamFocusRegionId={selectedWebcamFocusRegionId} + selectedWebcamPositionRegionId={selectedWebcamPositionRegionId} selectAllBlocksActive={selectAllBlocksActive} onClearBlockSelection={clearSelectedBlocks} keyframes={keyframes} diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index 589f56305..3b531b82c 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -1,31 +1,28 @@ import { Plus } from "@phosphor-icons/react"; import { useTimelineContext } from "dnd-timeline"; import { + type MouseEvent, + type MouseEventHandler, memo, useCallback, useEffect, useMemo, useRef, useState, - type MouseEvent, - type MouseEventHandler, } from "react"; -import { cn } from "@/lib/utils"; import type { SourceAudioTrackSettings, SourceAudioTrackWithPeaks, } from "@/components/video-editor/audio/audioTypes"; +import { cn } from "@/lib/utils"; import { - getTimelineContentMinHeightPx, - getTimelineRowsMinHeightPx, - getTimelineViewportStretchFactor, - TIMELINE_AXIS_HEIGHT_PX, -} from "../../timelineLayout"; -import glassStyles from "../../ItemGlass.module.css"; -import Item from "../../Item"; -import Row from "../../Row"; -import { CLIP_ROW_ID, SOURCE_AUDIO_ROW_ID, ZOOM_ROW_ID } from "../../core/constants"; -import type { TimelineRenderItem } from "../../core/timelineTypes"; + CLIP_ROW_ID, + SOURCE_AUDIO_ROW_ID, + WEBCAM_FOCUS_ROW_ID, + WEBCAM_POSITION_ROW_ID, + WEBCAM_SIZE_ROW_ID, + ZOOM_ROW_ID, +} from "../../core/constants"; import { getAnnotationTrackIndex, getAnnotationTrackRowId, @@ -34,10 +31,14 @@ import { isAnnotationTrackRowId, isAudioTrackRowId, } from "../../core/rows"; +import type { TimelineRenderItem } from "../../core/timelineTypes"; +import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks"; +import Item from "../../Item"; +import glassStyles from "../../ItemGlass.module.css"; +import Row from "../../Row"; import TimelineAxis from "../axis/TimelineAxis"; import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay"; import PlaybackCursor from "../playhead/PlaybackCursor"; -import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks"; const HINT_CLIP = "Press C to split clip"; const HINT_ANNOTATION = "Press A to add annotation"; @@ -53,18 +54,22 @@ interface TimelineCanvasProps { onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; + onSelectWebcamSize?: (id: string | null) => void; + onSelectWebcamFocus?: (id: string | null) => void; + onSelectWebcamPosition?: (id: string | null) => void; onAddZoomAtMs?: (startMs: number) => void; selectedZoomId: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; + selectedWebcamSizeRegionId?: string | null; + selectedWebcamFocusRegionId?: string | null; + selectedWebcamPositionRegionId?: string | null; selectAllBlocksActive?: boolean; onClearBlockSelection?: () => void; keyframes?: { id: string; time: number }[]; sourceAudioTracks?: SourceAudioTrackWithPeaks[]; - getSourceAudioTrackSettingsForClip?: ( - clipId: string | null, - ) => SourceAudioTrackSettings; + getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings; showSourceAudioTrack?: boolean; liveSpanPreviewById?: Record; liveHiddenItemIds?: string[]; @@ -103,7 +108,9 @@ function useTimelineHover({ (clientX: number, rect: DOMRect) => { const contentWidth = Math.max(1, rect.width - sidebarWidth); const contentX = - direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth; + direction === "rtl" + ? rect.right - sidebarWidth - clientX + : clientX - rect.left - sidebarWidth; const clampedX = Math.max(0, Math.min(contentX, contentWidth)); const ratio = clampedX / contentWidth; const nextMs = rangeStart + ratio * visibleDurationMs; @@ -194,7 +201,8 @@ function useTimelineHover({ : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs)); const ghostStartOffsetPx = ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart)); - const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart)); + const ghostEndOffsetPx = + ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart)); const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); const timelineGhostOffsetPx = timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart)); @@ -230,14 +238,18 @@ interface TimelineCanvasRowsProps { selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; + selectedWebcamSizeRegionId?: string | null; + selectedWebcamFocusRegionId?: string | null; + selectedWebcamPositionRegionId?: string | null; onSelectZoom?: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; + onSelectWebcamSize?: (id: string | null) => void; + onSelectWebcamFocus?: (id: string | null) => void; + onSelectWebcamPosition?: (id: string | null) => void; sourceAudioTracks?: SourceAudioTrackWithPeaks[]; - getSourceAudioTrackSettingsForClip?: ( - clipId: string | null, - ) => SourceAudioTrackSettings; + getSourceAudioTrackSettingsForClip?: (clipId: string | null) => SourceAudioTrackSettings; showSourceAudioTrack?: boolean; liveSpanPreviewById?: Record; liveHiddenItemIds?: string[]; @@ -275,18 +287,18 @@ function AudioItemWithWaveform({ return { start: 0, end: duration }; }, [waveformSpan.end, waveformSpan.start]); return ( - + {item.label} ); @@ -300,10 +312,16 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, + onSelectWebcamSize, + onSelectWebcamFocus, + onSelectWebcamPosition, sourceAudioTracks = [], getSourceAudioTrackSettingsForClip, showSourceAudioTrack = false, @@ -322,9 +340,20 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ isLoading = false, }: TimelineCanvasRowsProps) { const hiddenIds = useMemo(() => new Set(liveHiddenItemIds ?? []), [liveHiddenItemIds]); - const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => { + const { + clipItems, + zoomItems, + annotationRows, + audioRows, + webcamSizeItems, + webcamFocusItems, + webcamPositionItems, + } = useMemo(() => { const nextClipItems: TimelineRenderItem[] = []; const nextZoomItems: TimelineRenderItem[] = []; + const nextWebcamSizeItems: TimelineRenderItem[] = []; + const nextWebcamFocusItems: TimelineRenderItem[] = []; + const nextWebcamPositionItems: TimelineRenderItem[] = []; const annotationBuckets = new Map(); const audioBuckets = new Map(); @@ -337,6 +366,18 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ nextZoomItems.push(item); continue; } + if (item.rowId === WEBCAM_SIZE_ROW_ID) { + nextWebcamSizeItems.push(item); + continue; + } + if (item.rowId === WEBCAM_FOCUS_ROW_ID) { + nextWebcamFocusItems.push(item); + continue; + } + if (item.rowId === WEBCAM_POSITION_ROW_ID) { + nextWebcamPositionItems.push(item); + continue; + } if (isAnnotationTrackRowId(item.rowId)) { const trackIndex = getAnnotationTrackIndex(item.rowId); const bucket = annotationBuckets.get(trackIndex); @@ -370,6 +411,9 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ zoomItems: nextZoomItems, annotationRows: annotationRowsSorted, audioRows: audioRowsSorted, + webcamSizeItems: nextWebcamSizeItems, + webcamFocusItems: nextWebcamFocusItems, + webcamPositionItems: nextWebcamPositionItems, }; }, [items]); @@ -396,30 +440,34 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ {showSourceAudioTrack && sourceAudioTracks.map((track) => ( - {clipItems.filter(item => item.showSourceAudio).map((item) => { - const settings = getSourceAudioTrackSettingsForClip?.(item.id)?.[ - track.id - ] ?? { volume: 1, normalize: false }; - return ( - onSelectClip?.(item.id)} - variant="audio" - waveformPeaks={track.peaks} - waveformSegmentSpan={item.sourceSpan ?? item.span} - waveformGain={Math.max(0, Math.min(1, settings.volume))} - waveformNormalize={Boolean(settings.normalize)} - muted={item.muted} - > - {track.label} - - ); - })} + {clipItems + .filter((item) => item.showSourceAudio) + .map((item) => { + const settings = getSourceAudioTrackSettingsForClip?.(item.id)?.[ + track.id + ] ?? { volume: 1, normalize: false }; + return ( + onSelectClip?.(item.id)} + variant="audio" + waveformPeaks={track.peaks} + waveformSegmentSpan={item.sourceSpan ?? item.span} + waveformGain={Math.max(0, Math.min(1, settings.volume))} + waveformNormalize={Boolean(settings.normalize)} + muted={item.muted} + > + {track.label} + + ); + })} ))} @@ -438,8 +486,14 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]" style={ direction === "rtl" - ? { right: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` } - : { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` } + ? { + right: `${ghostStartOffsetPx}px`, + width: `${ghostWidthPx}px`, + } + : { + left: `${ghostStartOffsetPx}px`, + width: `${ghostWidthPx}px`, + } } >
!hiddenIds.has(item.id)) .map((item) => ( - - {item.label} - - ))} + + {item.label} + + ))} {annotationRows.map(({ rowId, items: rowItems }, index) => ( - + {rowItems.map((item) => ( ( - + {rowItems.map((item) => ( ))} + + 0} + > + {webcamSizeItems.map((item) => ( + + {item.label} + + ))} + + 0} + > + {webcamFocusItems.map((item) => ( + + {item.label} + + ))} + + 0} + > + {webcamPositionItems.map((item) => ( + + {item.label} + + ))} + ); }); @@ -523,10 +653,16 @@ export default function TimelineCanvas({ onSelectClip, onSelectAnnotation, onSelectAudio, + onSelectWebcamSize, + onSelectWebcamFocus, + onSelectWebcamPosition, selectedZoomId, selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, selectAllBlocksActive = false, onClearBlockSelection, keyframes = [], @@ -564,6 +700,8 @@ export default function TimelineCanvas({ onSelectClip?.(null); onSelectAnnotation?.(null); onSelectAudio?.(null); + onSelectWebcamSize?.(null); + onSelectWebcamFocus?.(null); } const rect = e.currentTarget.getBoundingClientRect(); @@ -584,6 +722,7 @@ export default function TimelineCanvas({ onSelectAnnotation, onSelectAudio, onClearBlockSelection, + onSelectWebcamFocus, videoDurationMs, sidebarWidth, direction, @@ -606,7 +745,8 @@ export default function TimelineCanvas({ const handleTimelineMouseDown = useCallback( (e: MouseEvent) => { - if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return; + if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) + return; if ((e.target as HTMLElement).closest("[data-timeline-item]")) { return; } @@ -618,6 +758,8 @@ export default function TimelineCanvas({ onSelectClip?.(null); onSelectAnnotation?.(null); onSelectAudio?.(null); + onSelectWebcamSize?.(null); + onSelectWebcamFocus?.(null); } const rect = localTimelineRef.current.getBoundingClientRect(); @@ -633,6 +775,7 @@ export default function TimelineCanvas({ onSelectAudio, onSelectClip, onSelectZoom, + onSelectWebcamFocus, videoDurationMs, ], ); @@ -642,7 +785,8 @@ export default function TimelineCanvas({ const flushSeek = () => { seekRafRef.current = null; - if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) return; + if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) + return; const rect = localTimelineRef.current.getBoundingClientRect(); onSeek(getAbsoluteMsFromClientX(pendingSeekClientXRef.current, rect) / 1000); }; @@ -680,19 +824,6 @@ export default function TimelineCanvas({ }; }, [getAbsoluteMsFromClientX, isSeeking, onSeek]); - const timelineRowCount = useMemo(() => { - const annotationRowIds = new Set(); - const audioRowIds = new Set(); - for (const item of items) { - if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId); - if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId); - } - const sourceAudioRows = showSourceAudioTrack ? sourceAudioTracks.length : 0; - return 2 + sourceAudioRows + annotationRowIds.size + audioRowIds.size; - }, [items, showSourceAudioTrack, sourceAudioTracks.length]); - const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); - const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); - const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount); const sideProperty = direction === "rtl" ? "right" : "left"; const { canShowGhostPlayhead, @@ -725,7 +856,7 @@ export default function TimelineCanvas({ ref={setRefs} style={{ ...style, - height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`, + height: "100%", }} className="select-none bg-editor-bg relative cursor-pointer group flex flex-col" onMouseDown={handleTimelineMouseDown} @@ -747,14 +878,18 @@ export default function TimelineCanvas({
-
+
)} -
+
void; onTrimSpanChange?: (id: string, span: Span) => void; onClipSpanChange?: (id: string, span: Span) => void; onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onSpeedSpanChange?: (id: string, span: Span) => void; onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onWebcamSizeSpanChange?: (id: string, span: Span) => void; + onWebcamFocusSpanChange?: (id: string, span: Span) => void; + onWebcamPositionSpanChange?: (id: string, span: Span) => void; } -type TimelineItemKind = "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio" | null; +type TimelineItemKind = + | "zoom" + | "trim" + | "clip" + | "annotation" + | "speed" + | "audio" + | "webcam-size" + | "webcam-focus" + | "webcam-position" + | null; export function useTimelineDndBindings({ zoomRegions, @@ -37,12 +61,18 @@ export function useTimelineDndBindings({ annotationRegions, speedRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, onZoomSpanChange, onTrimSpanChange, onClipSpanChange, onAnnotationSpanChange, onSpeedSpanChange, onAudioSpanChange, + onWebcamSizeSpanChange, + onWebcamFocusSpanChange, + onWebcamPositionSpanChange, }: UseTimelineDndBindingsParams) { const resolveItemKind = useCallback( (id: string): TimelineItemKind => { @@ -52,9 +82,22 @@ export function useTimelineDndBindings({ if (annotationRegions.some((r) => r.id === id)) return "annotation"; if (speedRegions.some((r) => r.id === id)) return "speed"; if (audioRegions.some((r) => r.id === id)) return "audio"; + if (webcamSizeRegions.some((r) => r.id === id)) return "webcam-size"; + if (webcamFocusRegions.some((r) => r.id === id)) return "webcam-focus"; + if (webcamPositionRegions.some((r) => r.id === id)) return "webcam-position"; return null; }, - [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions], + [ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, + ], ); const resolveTrackIndex = useCallback( @@ -77,6 +120,11 @@ export function useTimelineDndBindings({ const itemKind = resolveItemKind(excludeId); if (itemKind === "annotation") return false; + // Webcam size regions are allowed to overlap; the runtime resolver picks + // the active one by highest startMs. + if (itemKind === "webcam-size") return false; + if (itemKind === "webcam-focus") return false; + if (itemKind === "webcam-position") return false; const checkOverlap = ( regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[], @@ -118,8 +166,19 @@ export function useTimelineDndBindings({ clipRegions, annotationRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, }), - [zoomRegions, clipRegions, annotationRegions, audioRegions], + [ + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, + ], ); const allRegionSpans = useMemo( @@ -128,8 +187,18 @@ export function useTimelineDndBindings({ zoomRegions, clipRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, }), - [zoomRegions, clipRegions, audioRegions], + [ + zoomRegions, + clipRegions, + audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, + ], ); const getResolvedDropRowId = useCallback( @@ -154,6 +223,12 @@ export function useTimelineDndBindings({ } else if (itemKind === "audio") { const nextTrackIndex = resolveTrackIndex("audio", id, rowId); onAudioSpanChange?.(id, span, nextTrackIndex); + } else if (itemKind === "webcam-size") { + onWebcamSizeSpanChange?.(id, span); + } else if (itemKind === "webcam-focus") { + onWebcamFocusSpanChange?.(id, span); + } else if (itemKind === "webcam-position") { + onWebcamPositionSpanChange?.(id, span); } }, [ @@ -165,6 +240,9 @@ export function useTimelineDndBindings({ onAnnotationSpanChange, onSpeedSpanChange, onAudioSpanChange, + onWebcamSizeSpanChange, + onWebcamFocusSpanChange, + onWebcamPositionSpanChange, ], ); diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts index 15233f86e..e20f11d7a 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -1,13 +1,6 @@ import type { Span } from "dnd-timeline"; -import { useCallback, useImperativeHandle } from "react"; import type { ForwardedRef, RefObject } from "react"; -import type { TimelineShortcutBindings } from "../core/timelineTypes"; -import { useTimelineDndBindings } from "./useTimelineDndBindings"; -import { useTimelineAudioActions } from "./actions/useTimelineAudioActions"; -import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; -import { useTimelineNormalization } from "./useTimelineNormalization"; -import { useTimelineSelection } from "./useTimelineSelection"; -import { useTimelineZoomActions } from "./actions/useTimelineZoomActions"; +import { useCallback, useImperativeHandle } from "react"; import type { AnnotationRegion, AudioRegion, @@ -15,10 +8,20 @@ import type { CursorTelemetryPoint, SpeedRegion, TrimRegion, + WebcamFocusRegion, + WebcamPositionRegion, + WebcamSizeRegion, ZoomFocus, ZoomRegion, } from "../../types"; +import type { TimelineShortcutBindings } from "../core/timelineTypes"; import type { TimelineEditorHandle } from "../TimelineEditor"; +import { useTimelineAudioActions } from "./actions/useTimelineAudioActions"; +import { useTimelineZoomActions } from "./actions/useTimelineZoomActions"; +import { useTimelineDndBindings } from "./useTimelineDndBindings"; +import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; +import { useTimelineNormalization } from "./useTimelineNormalization"; +import { useTimelineSelection } from "./useTimelineSelection"; interface UseTimelineEditorRuntimeParams { ref: ForwardedRef; @@ -59,6 +62,21 @@ interface UseTimelineEditorRuntimeParams { onAudioDelete?: (id: string) => void; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; + webcamSizeRegions: WebcamSizeRegion[]; + webcamFocusRegions: WebcamFocusRegion[]; + webcamPositionRegions: WebcamPositionRegion[]; + onWebcamSizeSpanChange?: (id: string, span: Span) => void; + onWebcamSizeDelete?: (id: string) => void; + selectedWebcamSizeRegionId?: string | null; + onSelectWebcamSize?: (id: string | null) => void; + onWebcamFocusSpanChange?: (id: string, span: Span) => void; + onWebcamFocusDelete?: (id: string) => void; + selectedWebcamFocusRegionId?: string | null; + onSelectWebcamFocus?: (id: string | null) => void; + onWebcamPositionSpanChange?: (id: string, span: Span) => void; + onWebcamPositionDelete?: (id: string) => void; + selectedWebcamPositionRegionId?: string | null; + onSelectWebcamPosition?: (id: string | null) => void; isMac: boolean; keyShortcuts: TimelineShortcutBindings; isTimelineFocusedRef: RefObject; @@ -103,6 +121,21 @@ export function useTimelineEditorRuntime({ onAudioDelete, selectedAudioId, onSelectAudio, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, + onWebcamSizeSpanChange, + onWebcamSizeDelete, + selectedWebcamSizeRegionId, + onSelectWebcamSize, + onWebcamFocusSpanChange, + onWebcamFocusDelete, + selectedWebcamFocusRegionId, + onSelectWebcamFocus, + onWebcamPositionSpanChange, + onWebcamPositionDelete, + selectedWebcamPositionRegionId, + onSelectWebcamPosition, isMac, keyShortcuts, isTimelineFocusedRef, @@ -121,12 +154,18 @@ export function useTimelineEditorRuntime({ deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, + deleteSelectedWebcamSize, + deleteSelectedWebcamFocus, + deleteSelectedWebcamPosition, clearSelectedBlocks, deleteAllBlocks, handleSelectZoom, handleSelectClip, handleSelectAnnotation, handleSelectAudio, + handleSelectWebcamSize, + handleSelectWebcamFocus, + handleSelectWebcamPosition, cycleAnnotationsAtCurrentTime, } = useTimelineSelection({ totalMs, @@ -135,18 +174,30 @@ export function useTimelineEditorRuntime({ clipRegions, annotationRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, selectedZoomId, selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, onZoomDelete, onClipDelete, onAnnotationDelete, onAudioDelete, + onWebcamSizeDelete, + onWebcamFocusDelete, + onWebcamPositionDelete, onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, + onSelectWebcamSize, + onSelectWebcamFocus, + onSelectWebcamPosition, }); useTimelineNormalization({ @@ -156,39 +207,61 @@ export function useTimelineEditorRuntime({ trimRegions, speedRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange, + onWebcamSizeSpanChange, + onWebcamFocusSpanChange, + onWebcamPositionSpanChange, }); - const { hasOverlap, timelineItems, allRegionSpans, getResolvedDropRowId, handleItemSpanChange } = - useTimelineDndBindings({ - zoomRegions, - trimRegions, - clipRegions, - annotationRegions, - speedRegions, - audioRegions, - onZoomSpanChange, - onTrimSpanChange, - onClipSpanChange, - onAnnotationSpanChange, - onSpeedSpanChange, - onAudioSpanChange, - }); + const { + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + } = useTimelineDndBindings({ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + onWebcamSizeSpanChange, + onWebcamFocusSpanChange, + onWebcamPositionSpanChange, + }); - const { defaultRegionDurationMs, canPlaceZoomAtMs, addZoomAtMs, handleAddZoom, handleSuggestZooms } = - useTimelineZoomActions({ - timeline: { videoDuration, totalMs, currentTimeMs }, - regions: { zoom: zoomRegions, clip: clipRegions }, - cursorTelemetry, - options: { disableSuggestedZooms }, - autoSuggestZoomsTrigger, - onAutoSuggestZoomsConsumed, - onZoomAdded, - onZoomSuggested, - }); + const { + defaultRegionDurationMs, + canPlaceZoomAtMs, + addZoomAtMs, + handleAddZoom, + handleSuggestZooms, + } = useTimelineZoomActions({ + timeline: { videoDuration, totalMs, currentTimeMs }, + regions: { zoom: zoomRegions, clip: clipRegions }, + cursorTelemetry, + options: { disableSuggestedZooms }, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + onZoomAdded, + onZoomSuggested, + }); const handleSplitClip = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { @@ -233,6 +306,9 @@ export function useTimelineEditorRuntime({ selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, selectAllBlocksActive, setSelectAllBlocksActive, setSelectedKeyframeId, @@ -246,6 +322,9 @@ export function useTimelineEditorRuntime({ deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, + deleteSelectedWebcamSize, + deleteSelectedWebcamFocus, + deleteSelectedWebcamPosition, cycleAnnotationsAtCurrentTime, }); @@ -259,7 +338,14 @@ export function useTimelineEditorRuntime({ addAudio: handleAddAudio, keyframes, }), - [handleAddAnnotation, handleAddAudio, handleAddZoom, handleSuggestZooms, handleSplitClip, keyframes], + [ + handleAddAnnotation, + handleAddAudio, + handleAddZoom, + handleSuggestZooms, + handleSplitClip, + keyframes, + ], ); return { @@ -274,6 +360,9 @@ export function useTimelineEditorRuntime({ handleSelectClip, handleSelectAnnotation, handleSelectAudio, + handleSelectWebcamSize, + handleSelectWebcamFocus, + handleSelectWebcamPosition, hasOverlap, timelineItems, allRegionSpans, diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts index 55d2c0b6a..83c315543 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect, type RefObject } from "react"; +import { type RefObject, useEffect } from "react"; import { matchesShortcut } from "@/lib/shortcuts"; import type { TimelineShortcutBindings } from "../core/timelineTypes"; import { resolveDeleteSelectionTarget } from "./utils/timelineSelectionUtils"; @@ -14,6 +14,9 @@ interface UseTimelineKeyboardShortcutsParams { selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; + selectedWebcamSizeRegionId?: string | null; + selectedWebcamFocusRegionId?: string | null; + selectedWebcamPositionRegionId?: string | null; selectAllBlocksActive: boolean; setSelectAllBlocksActive: (active: boolean) => void; setSelectedKeyframeId: (id: string | null) => void; @@ -27,6 +30,9 @@ interface UseTimelineKeyboardShortcutsParams { deleteSelectedClip: () => void; deleteSelectedAnnotation: () => void; deleteSelectedAudio: () => void; + deleteSelectedWebcamSize: () => void; + deleteSelectedWebcamFocus: () => void; + deleteSelectedWebcamPosition: () => void; cycleAnnotationsAtCurrentTime: (backward?: boolean) => boolean; } @@ -41,6 +47,9 @@ export function useTimelineKeyboardShortcuts({ selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, selectAllBlocksActive, setSelectAllBlocksActive, setSelectedKeyframeId, @@ -54,6 +63,9 @@ export function useTimelineKeyboardShortcuts({ deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, + deleteSelectedWebcamSize, + deleteSelectedWebcamFocus, + deleteSelectedWebcamPosition, cycleAnnotationsAtCurrentTime, }: UseTimelineKeyboardShortcutsParams) { useEffect(() => { @@ -107,6 +119,9 @@ export function useTimelineKeyboardShortcuts({ selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, }); if (target !== "none") { e.preventDefault(); @@ -123,6 +138,12 @@ export function useTimelineKeyboardShortcuts({ deleteSelectedAnnotation(); } else if (target === "audio") { deleteSelectedAudio(); + } else if (target === "webcam-size") { + deleteSelectedWebcamSize(); + } else if (target === "webcam-focus") { + deleteSelectedWebcamFocus(); + } else if (target === "webcam-position") { + deleteSelectedWebcamPosition(); } } }; @@ -136,8 +157,11 @@ export function useTimelineKeyboardShortcuts({ deleteAllBlocks, deleteSelectedAnnotation, deleteSelectedAudio, + deleteSelectedWebcamFocus, + deleteSelectedWebcamPosition, deleteSelectedClip, deleteSelectedKeyframe, + deleteSelectedWebcamSize, deleteSelectedZoom, handleAddAnnotation, handleAddZoom, @@ -150,7 +174,10 @@ export function useTimelineKeyboardShortcuts({ selectedAnnotationId, selectedAudioId, selectedClipId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, selectedKeyframeId, + selectedWebcamSizeRegionId, selectedZoomId, setSelectAllBlocksActive, setSelectedKeyframeId, diff --git a/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts index 1b4fd4989..b95cf8f0e 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts @@ -1,6 +1,14 @@ import { useEffect } from "react"; +import type { + AudioRegion, + SpeedRegion, + TrimRegion, + WebcamFocusRegion, + WebcamPositionRegion, + WebcamSizeRegion, + ZoomRegion, +} from "../../types"; import { normalizeRegionSpan } from "../core/spans"; -import type { AudioRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../../types"; interface UseTimelineNormalizationParams { totalMs: number; @@ -9,10 +17,16 @@ interface UseTimelineNormalizationParams { trimRegions: TrimRegion[]; speedRegions: SpeedRegion[]; audioRegions: AudioRegion[]; + webcamSizeRegions: WebcamSizeRegion[]; + webcamFocusRegions: WebcamFocusRegion[]; + webcamPositionRegions: WebcamPositionRegion[]; onZoomSpanChange: (id: string, span: { start: number; end: number }) => void; onTrimSpanChange?: (id: string, span: { start: number; end: number }) => void; onSpeedSpanChange?: (id: string, span: { start: number; end: number }) => void; onAudioSpanChange?: (id: string, span: { start: number; end: number }) => void; + onWebcamSizeSpanChange?: (id: string, span: { start: number; end: number }) => void; + onWebcamFocusSpanChange?: (id: string, span: { start: number; end: number }) => void; + onWebcamPositionSpanChange?: (id: string, span: { start: number; end: number }) => void; } export function useTimelineNormalization({ @@ -22,10 +36,16 @@ export function useTimelineNormalization({ trimRegions, speedRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange, + onWebcamSizeSpanChange, + onWebcamFocusSpanChange, + onWebcamPositionSpanChange, }: UseTimelineNormalizationParams) { useEffect(() => { if (totalMs === 0 || safeMinDurationMs <= 0) { @@ -83,6 +103,45 @@ export function useTimelineNormalization({ onAudioSpanChange?.(region.id, normalized); } }); + + webcamSizeRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onWebcamSizeSpanChange?.(region.id, normalized); + } + }); + + webcamFocusRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onWebcamFocusSpanChange?.(region.id, normalized); + } + }); + + webcamPositionRegions.forEach((region) => { + const normalized = normalizeRegionSpan({ + startMs: region.startMs, + endMs: region.endMs, + totalMs, + minDurationMs: safeMinDurationMs, + }); + + if (normalized.start !== region.startMs || normalized.end !== region.endMs) { + onWebcamPositionSpanChange?.(region.id, normalized); + } + }); }, [ totalMs, safeMinDurationMs, @@ -90,9 +149,15 @@ export function useTimelineNormalization({ trimRegions, speedRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange, + onWebcamSizeSpanChange, + onWebcamFocusSpanChange, + onWebcamPositionSpanChange, ]); } diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts index 2d78162e6..9d46b59c4 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts @@ -9,18 +9,30 @@ interface UseTimelineSelectionParams { clipRegions: TimelineRegion[]; annotationRegions: (TimelineRegion & { zIndex: number })[]; audioRegions: TimelineRegion[]; + webcamSizeRegions: TimelineRegion[]; + webcamFocusRegions: TimelineRegion[]; + webcamPositionRegions: TimelineRegion[]; selectedZoomId: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; + selectedWebcamSizeRegionId?: string | null; + selectedWebcamFocusRegionId?: string | null; + selectedWebcamPositionRegionId?: string | null; onZoomDelete: (id: string) => void; onClipDelete?: (id: string) => void; onAnnotationDelete?: (id: string) => void; onAudioDelete?: (id: string) => void; + onWebcamSizeDelete?: (id: string) => void; + onWebcamFocusDelete?: (id: string) => void; + onWebcamPositionDelete?: (id: string) => void; onSelectZoom: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; + onSelectWebcamSize?: (id: string | null) => void; + onSelectWebcamFocus?: (id: string | null) => void; + onSelectWebcamPosition?: (id: string | null) => void; } export function useTimelineSelection({ @@ -30,18 +42,30 @@ export function useTimelineSelection({ clipRegions, annotationRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, selectedZoomId, selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, onZoomDelete, onClipDelete, onAnnotationDelete, onAudioDelete, + onWebcamSizeDelete, + onWebcamFocusDelete, + onWebcamPositionDelete, onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio, + onSelectWebcamSize, + onSelectWebcamFocus, + onSelectWebcamPosition, }: UseTimelineSelectionParams) { const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]); const [selectedKeyframeId, setSelectedKeyframeId] = useState(null); @@ -95,21 +119,62 @@ export function useTimelineSelection({ onSelectAudio(null); }, [selectedAudioId, onAudioDelete, onSelectAudio]); + const deleteSelectedWebcamSize = useCallback(() => { + if (!selectedWebcamSizeRegionId || !onWebcamSizeDelete || !onSelectWebcamSize) return; + onWebcamSizeDelete(selectedWebcamSizeRegionId); + onSelectWebcamSize(null); + }, [selectedWebcamSizeRegionId, onWebcamSizeDelete, onSelectWebcamSize]); + + const deleteSelectedWebcamFocus = useCallback(() => { + if (!selectedWebcamFocusRegionId || !onWebcamFocusDelete || !onSelectWebcamFocus) return; + onWebcamFocusDelete(selectedWebcamFocusRegionId); + onSelectWebcamFocus(null); + }, [selectedWebcamFocusRegionId, onWebcamFocusDelete, onSelectWebcamFocus]); + + const deleteSelectedWebcamPosition = useCallback(() => { + if (!selectedWebcamPositionRegionId || !onWebcamPositionDelete || !onSelectWebcamPosition) + return; + onWebcamPositionDelete(selectedWebcamPositionRegionId); + onSelectWebcamPosition(null); + }, [selectedWebcamPositionRegionId, onWebcamPositionDelete, onSelectWebcamPosition]); + const clearSelectedBlocks = useCallback(() => { onSelectZoom(null); onSelectClip?.(null); onSelectAnnotation?.(null); onSelectAudio?.(null); + onSelectWebcamSize?.(null); + onSelectWebcamFocus?.(null); + onSelectWebcamPosition?.(null); setSelectAllBlocksActive(false); - }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]); + }, [ + onSelectZoom, + onSelectClip, + onSelectAnnotation, + onSelectAudio, + onSelectWebcamSize, + onSelectWebcamFocus, + onSelectWebcamPosition, + ]); const hasAnyTimelineBlocks = useMemo( () => zoomRegions.length > 0 || clipRegions.length > 0 || annotationRegions.length > 0 || - audioRegions.length > 0, - [zoomRegions.length, clipRegions.length, annotationRegions.length, audioRegions.length], + audioRegions.length > 0 || + webcamSizeRegions.length > 0 || + webcamFocusRegions.length > 0 || + webcamPositionRegions.length > 0, + [ + zoomRegions.length, + clipRegions.length, + annotationRegions.length, + audioRegions.length, + webcamSizeRegions.length, + webcamFocusRegions.length, + webcamPositionRegions.length, + ], ); const deleteAllBlocks = useCallback(() => { @@ -117,6 +182,9 @@ export function useTimelineSelection({ clipRegions.map((r) => r.id).forEach((id) => onClipDelete?.(id)); annotationRegions.map((r) => r.id).forEach((id) => onAnnotationDelete?.(id)); audioRegions.map((r) => r.id).forEach((id) => onAudioDelete?.(id)); + webcamSizeRegions.map((r) => r.id).forEach((id) => onWebcamSizeDelete?.(id)); + webcamFocusRegions.map((r) => r.id).forEach((id) => onWebcamFocusDelete?.(id)); + webcamPositionRegions.map((r) => r.id).forEach((id) => onWebcamPositionDelete?.(id)); clearSelectedBlocks(); setSelectedKeyframeId(null); }, [ @@ -124,10 +192,16 @@ export function useTimelineSelection({ clipRegions, annotationRegions, audioRegions, + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, onZoomDelete, onClipDelete, onAnnotationDelete, onAudioDelete, + onWebcamSizeDelete, + onWebcamFocusDelete, + onWebcamPositionDelete, clearSelectedBlocks, ]); @@ -163,6 +237,30 @@ export function useTimelineSelection({ [onSelectAudio], ); + const handleSelectWebcamSize = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectWebcamSize?.(id); + }, + [onSelectWebcamSize], + ); + + const handleSelectWebcamFocus = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectWebcamFocus?.(id); + }, + [onSelectWebcamFocus], + ); + + const handleSelectWebcamPosition = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectWebcamPosition?.(id); + }, + [onSelectWebcamPosition], + ); + const cycleAnnotationsAtCurrentTime = useCallback( (backward = false) => { const overlapping = annotationRegions @@ -201,12 +299,18 @@ export function useTimelineSelection({ deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, + deleteSelectedWebcamSize, + deleteSelectedWebcamFocus, + deleteSelectedWebcamPosition, clearSelectedBlocks, deleteAllBlocks, handleSelectZoom, handleSelectClip, handleSelectAnnotation, handleSelectAudio, + handleSelectWebcamSize, + handleSelectWebcamFocus, + handleSelectWebcamPosition, cycleAnnotationsAtCurrentTime, }; } diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts index ac2b12996..cd6fd7d44 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts @@ -5,6 +5,9 @@ export type DeleteSelectionTarget = | "clip" | "annotation" | "audio" + | "webcam-size" + | "webcam-focus" + | "webcam-position" | "none"; interface ResolveDeleteSelectionTargetParams { @@ -14,6 +17,9 @@ interface ResolveDeleteSelectionTargetParams { selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; + selectedWebcamSizeRegionId?: string | null; + selectedWebcamFocusRegionId?: string | null; + selectedWebcamPositionRegionId?: string | null; } export function resolveDeleteSelectionTarget({ @@ -23,6 +29,9 @@ export function resolveDeleteSelectionTarget({ selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, }: ResolveDeleteSelectionTargetParams): DeleteSelectionTarget { if (selectAllBlocksActive) return "all"; if (selectedKeyframeId) return "keyframe"; @@ -30,5 +39,8 @@ export function resolveDeleteSelectionTarget({ if (selectedClipId) return "clip"; if (selectedAnnotationId) return "annotation"; if (selectedAudioId) return "audio"; + if (selectedWebcamSizeRegionId) return "webcam-size"; + if (selectedWebcamFocusRegionId) return "webcam-focus"; + if (selectedWebcamPositionRegionId) return "webcam-position"; return "none"; } diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts index 966813515..81336fe64 100644 --- a/src/components/video-editor/timeline/model/timelineModel.ts +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -2,10 +2,18 @@ import type { AnnotationRegion, AudioRegion, ClipRegion, + WebcamFocusRegion, + WebcamPositionRegion, + WebcamSizeRegion, ZoomRegion, } from "../../types"; -import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes"; -import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; +import { + CLIP_ROW_ID, + WEBCAM_FOCUS_ROW_ID, + WEBCAM_POSITION_ROW_ID, + WEBCAM_SIZE_ROW_ID, + ZOOM_ROW_ID, +} from "../core/constants"; import { getAnnotationTrackIndex, getAnnotationTrackRowId, @@ -14,6 +22,7 @@ import { isAnnotationTrackRowId, isAudioTrackRowId, } from "../core/rows"; +import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes"; export function getAnnotationLabel(region: AnnotationRegion): string { if (region.type === "text") { @@ -27,7 +36,12 @@ export function getAnnotationLabel(region: AnnotationRegion): string { } export function getAudioLabel(region: AudioRegion): string { - return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio"; + return ( + region.audioPath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || "Audio" + ); } export function buildTimelineItems(params: { @@ -35,8 +49,19 @@ export function buildTimelineItems(params: { clipRegions: ClipRegion[]; annotationRegions: AnnotationRegion[]; audioRegions: AudioRegion[]; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; }): TimelineRenderItem[] { - const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params; + const { + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + webcamSizeRegions = [], + webcamFocusRegions = [], + webcamPositionRegions = [], + } = params; const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ id: region.id, rowId: ZOOM_ROW_ID, @@ -83,15 +108,65 @@ export function buildTimelineItems(params: { variant: "audio", })); - return [...zooms, ...clips, ...annotations, ...audios]; + const webcamSizes: TimelineRenderItem[] = webcamSizeRegions.map((region) => ({ + id: region.id, + rowId: WEBCAM_SIZE_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: + region.height !== undefined && Math.round(region.height) !== Math.round(region.size) + ? `${Math.round(region.size)}x${Math.round(region.height)}%` + : `${Math.round(region.size)}%`, + webcamSizePercent: region.size, + webcamHeightPercent: region.height, + variant: "webcam-size", + })); + + const webcamFocuses: TimelineRenderItem[] = webcamFocusRegions.map((region) => ({ + id: region.id, + rowId: WEBCAM_FOCUS_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Focus ${Math.round(region.focusSize)}%`, + webcamFocusPercent: region.focusSize, + variant: "webcam-focus", + })); + + const webcamPositions: TimelineRenderItem[] = webcamPositionRegions.map((region) => ({ + id: region.id, + rowId: WEBCAM_POSITION_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `${Math.round(region.positionX * 100)},${Math.round(region.positionY * 100)}`, + webcamPositionX: region.positionX, + webcamPositionY: region.positionY, + variant: "webcam-position", + })); + + return [ + ...zooms, + ...clips, + ...annotations, + ...audios, + ...webcamSizes, + ...webcamFocuses, + ...webcamPositions, + ]; } export function buildAllRegionSpans(params: { zoomRegions: ZoomRegion[]; clipRegions: ClipRegion[]; audioRegions: AudioRegion[]; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; }): TimelineRegionSpan[] { - const { zoomRegions, clipRegions, audioRegions } = params; + const { + zoomRegions, + clipRegions, + audioRegions, + webcamSizeRegions = [], + webcamFocusRegions = [], + webcamPositionRegions = [], + } = params; const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, @@ -110,7 +185,25 @@ export function buildAllRegionSpans(params: { end: r.endMs, rowId: getAudioTrackRowId(r.trackIndex ?? 0), })); - return [...zooms, ...clips, ...audios]; + const webcamSizes = webcamSizeRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: WEBCAM_SIZE_ROW_ID, + })); + const webcamFocuses = webcamFocusRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: WEBCAM_FOCUS_ROW_ID, + })); + const webcamPositions = webcamPositionRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: WEBCAM_POSITION_ROW_ID, + })); + return [...zooms, ...clips, ...audios, ...webcamSizes, ...webcamFocuses, ...webcamPositions]; } export function resolveDropRowId( diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 30b3adaa0..71bd2ba98 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -90,12 +90,48 @@ export interface WebcamOverlaySettings { positionX: number; positionY: number; size: number; + height?: number; reactToZoom: boolean; + avoidCursor: boolean; cornerRadius: number; shadow: number; margin: number; } +export interface WebcamSizeRegion { + id: string; + startMs: number; + endMs: number; + size: number; + height?: number; + transitionInMs?: number; + transitionOutMs?: number; +} + +export type WebcamFocusScreenMode = "pip" | "hidden"; + +export interface WebcamFocusRegion { + id: string; + startMs: number; + endMs: number; + focusSize: number; + screenMode: WebcamFocusScreenMode; + screenPipSize?: number; + screenPipCorner?: WebcamCorner; + transitionInMs?: number; + transitionOutMs?: number; +} + +export interface WebcamPositionRegion { + id: string; + startMs: number; + endMs: number; + positionX: number; + positionY: number; + transitionInMs?: number; + transitionOutMs?: number; +} + export const DEFAULT_CURSOR_SIZE = 3.0; export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.4; @@ -132,13 +168,24 @@ export const DEFAULT_ZOOM_IN_EASING: ZoomTransitionEasing = "recordly"; export const DEFAULT_ZOOM_OUT_EASING: ZoomTransitionEasing = "recordly"; export const DEFAULT_CONNECTED_ZOOM_EASING: ZoomTransitionEasing = "glide"; export const DEFAULT_WEBCAM_SIZE = 40; +export const DEFAULT_WEBCAM_HEIGHT = DEFAULT_WEBCAM_SIZE; +export const DEFAULT_WEBCAM_SIZE_TRANSITION_IN_MS = 400; +export const DEFAULT_WEBCAM_SIZE_TRANSITION_OUT_MS = 400; +export const DEFAULT_WEBCAM_FOCUS_SIZE = 95; +export const DEFAULT_WEBCAM_FOCUS_SCREEN_MODE: WebcamFocusScreenMode = "pip"; +export const DEFAULT_WEBCAM_FOCUS_SCREEN_PIP_SIZE = 20; +export const DEFAULT_WEBCAM_FOCUS_TRANSITION_IN_MS = 400; +export const DEFAULT_WEBCAM_FOCUS_TRANSITION_OUT_MS = 400; export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true; +export const DEFAULT_WEBCAM_AVOID_CURSOR = false; export const DEFAULT_WEBCAM_CORNER_RADIUS = 90; export const DEFAULT_WEBCAM_SHADOW = 0.67; export const DEFAULT_WEBCAM_MARGIN = 24; export const DEFAULT_WEBCAM_POSITION_PRESET: WebcamPositionPreset = "bottom-right"; export const DEFAULT_WEBCAM_POSITION_X = 1; export const DEFAULT_WEBCAM_POSITION_Y = 1; +export const DEFAULT_WEBCAM_POSITION_TRANSITION_IN_MS = 400; +export const DEFAULT_WEBCAM_POSITION_TRANSITION_OUT_MS = 400; export const DEFAULT_WEBCAM_TIME_OFFSET_MS = 0; export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { @@ -152,7 +199,9 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = { positionX: DEFAULT_WEBCAM_POSITION_X, positionY: DEFAULT_WEBCAM_POSITION_Y, size: DEFAULT_WEBCAM_SIZE, + height: DEFAULT_WEBCAM_HEIGHT, reactToZoom: DEFAULT_WEBCAM_REACT_TO_ZOOM, + avoidCursor: DEFAULT_WEBCAM_AVOID_CURSOR, cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS, shadow: DEFAULT_WEBCAM_SHADOW, margin: DEFAULT_WEBCAM_MARGIN, @@ -466,7 +515,10 @@ export const DEFAULT_PADDING: Padding = { right: 20, linked: true, }; -export type { SourceAudioTrackSetting, SourceAudioTrackSettings } from "@/components/video-editor/audio/audioTypes"; +export type { + SourceAudioTrackSetting, + SourceAudioTrackSettings, +} from "@/components/video-editor/audio/audioTypes"; export interface AudioRegion { id: string; diff --git a/src/components/video-editor/webcamFocusRegions.test.ts b/src/components/video-editor/webcamFocusRegions.test.ts new file mode 100644 index 000000000..801f03841 --- /dev/null +++ b/src/components/video-editor/webcamFocusRegions.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import type { WebcamFocusRegion } from "./types"; +import { + getActiveWebcamFocusRegion, + getInterpolatedFocusStateAtTime, + getNextWebcamFocusRegionId, + normalizeWebcamFocusRegions, +} from "./webcamFocusRegions"; + +describe("webcamFocusRegions", () => { + it("normalizes persisted focus regions and drops invalid values", () => { + const normalized = normalizeWebcamFocusRegions( + [ + { id: "short", startMs: 0, endMs: 100, focusSize: 90 }, + { + id: "valid", + startMs: 1_000.2, + endMs: 3_000.7, + focusSize: 120, + screenMode: "pip", + screenPipSize: 5, + screenPipCorner: "top-left", + transitionInMs: 250.4, + transitionOutMs: 300.6, + }, + ], + 5_000, + ); + + expect(normalized).toEqual([ + { + id: "valid", + startMs: 1_000, + endMs: 3_001, + focusSize: 100, + screenMode: "pip", + screenPipSize: 8, + screenPipCorner: "top-left", + transitionInMs: 250, + transitionOutMs: 301, + }, + ]); + }); + + it("chooses the overlapping active region with the highest start", () => { + const regions: WebcamFocusRegion[] = [ + { id: "early", startMs: 1_000, endMs: 5_000, focusSize: 80, screenMode: "pip" }, + { id: "late", startMs: 2_000, endMs: 3_000, focusSize: 95, screenMode: "hidden" }, + ]; + + expect(getActiveWebcamFocusRegion(regions, 2_500)?.id).toBe("late"); + }); + + it("returns null outside active and transition windows", () => { + const regions: WebcamFocusRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 2_000, focusSize: 90, screenMode: "pip" }, + ]; + + expect(getInterpolatedFocusStateAtTime(40, regions, 300)).toBeNull(); + }); + + it("returns full focus state inside the region", () => { + const regions: WebcamFocusRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 2_000, + focusSize: 95, + screenMode: "pip", + screenPipSize: 20, + }, + ]; + + const state = getInterpolatedFocusStateAtTime(40, regions, 1_500, "bottom-right"); + expect(state?.webcamSize).toBe(95); + expect(state?.screenSize).toBe(20); + expect(state?.screenCorner).toBe("top-left"); + }); + + it("ramps in before a focus region starts", () => { + const regions: WebcamFocusRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 2_000, + focusSize: 95, + screenMode: "hidden", + transitionInMs: 400, + }, + ]; + + const state = getInterpolatedFocusStateAtTime(40, regions, 800); + expect(state?.webcamSize).toBeGreaterThan(40); + expect(state?.webcamSize).toBeLessThan(95); + expect(state?.screenOpacity).toBeGreaterThan(0); + expect(state?.screenOpacity).toBeLessThan(1); + }); + + it("does not restart the ramp at the focus region start", () => { + const regions: WebcamFocusRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 2_000, + focusSize: 95, + screenMode: "pip", + screenPipSize: 20, + transitionInMs: 400, + }, + ]; + + const state = getInterpolatedFocusStateAtTime(40, regions, 1_000); + expect(state?.webcamSize).toBe(95); + expect(state?.screenSize).toBe(20); + }); + + it("blends connected focus screen state instead of jumping to the next region", () => { + const regions: WebcamFocusRegion[] = [ + { + id: "r1", + startMs: 0, + endMs: 1_000, + focusSize: 80, + screenMode: "pip", + screenPipSize: 35, + transitionOutMs: 400, + }, + { + id: "r2", + startMs: 1_200, + endMs: 2_000, + focusSize: 100, + screenMode: "pip", + screenPipSize: 10, + transitionInMs: 400, + }, + ]; + + const state = getInterpolatedFocusStateAtTime(40, regions, 1_100); + expect(state?.webcamSize).toBeGreaterThan(80); + expect(state?.webcamSize).toBeLessThan(100); + expect(state?.screenSize).toBeGreaterThan(10); + expect(state?.screenSize).toBeLessThan(35); + }); + + it("ramps out after a focus region ends", () => { + const regions: WebcamFocusRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 2_000, + focusSize: 95, + screenMode: "pip", + screenPipSize: 20, + transitionOutMs: 400, + }, + ]; + + const state = getInterpolatedFocusStateAtTime(40, regions, 2_200); + expect(state?.webcamSize).toBeGreaterThan(40); + expect(state?.webcamSize).toBeLessThan(95); + expect(state?.screenSize).toBeGreaterThan(20); + expect(state?.screenSize).toBeLessThan(100); + }); + + it("generates focus ids without collisions", () => { + expect( + getNextWebcamFocusRegionId([ + { + id: "webcam-focus-1", + startMs: 0, + endMs: 1_000, + focusSize: 95, + screenMode: "pip", + }, + ]), + ).toBe("webcam-focus-2"); + }); +}); diff --git a/src/components/video-editor/webcamFocusRegions.ts b/src/components/video-editor/webcamFocusRegions.ts new file mode 100644 index 000000000..cdde69eda --- /dev/null +++ b/src/components/video-editor/webcamFocusRegions.ts @@ -0,0 +1,524 @@ +import type { WebcamCorner, WebcamFocusRegion, WebcamFocusScreenMode } from "./types"; +import { + DEFAULT_WEBCAM_FOCUS_SCREEN_MODE, + DEFAULT_WEBCAM_FOCUS_SCREEN_PIP_SIZE, + DEFAULT_WEBCAM_FOCUS_SIZE, + DEFAULT_WEBCAM_FOCUS_TRANSITION_IN_MS, + DEFAULT_WEBCAM_FOCUS_TRANSITION_OUT_MS, +} from "./types"; +import { clamp01, cubicBezier } from "./videoPlayback/mathUtils"; +import { clampWebcamSizeRegionSize } from "./webcamSizeRegions"; + +export const WEBCAM_FOCUS_REGION_MIN_DURATION_MS = 250; +export const WEBCAM_FOCUS_REGION_MIN_SIZE = 50; +export const WEBCAM_FOCUS_REGION_MAX_SIZE = 100; +export const WEBCAM_FOCUS_REGION_MIN_PIP_SIZE = 8; +export const WEBCAM_FOCUS_REGION_MAX_PIP_SIZE = 40; +export const WEBCAM_FOCUS_REGION_MIN_TRANSITION_MS = 0; +export const WEBCAM_FOCUS_REGION_MAX_TRANSITION_MS = 2000; + +export interface WebcamFocusTransitionDefaults { + transitionInMs: number; + transitionOutMs: number; +} + +export interface WebcamFocusState { + region: WebcamFocusRegion; + progress: number; + webcamSize: number; + screenMode: WebcamFocusScreenMode; + screenSize: number; + screenOpacity: number; + screenCorner: WebcamCorner; +} + +export interface WebcamFocusScreenTransform { + scale: number; + x: number; + y: number; +} + +const DEFAULT_TRANSITIONS: WebcamFocusTransitionDefaults = { + transitionInMs: DEFAULT_WEBCAM_FOCUS_TRANSITION_IN_MS, + transitionOutMs: DEFAULT_WEBCAM_FOCUS_TRANSITION_OUT_MS, +}; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function toIntegerMs(value: unknown): number | null { + if (!isFiniteNumber(value)) return null; + return Math.round(value); +} + +function lerp(start: number, end: number, amount: number) { + return start + (end - start) * amount; +} + +function easeFocusTransition(t: number): number { + return cubicBezier(0.4, 0.0, 0.2, 1.0, clamp01(t)); +} + +function isWebcamCorner(value: unknown): value is WebcamCorner { + return ( + value === "top-left" || + value === "top-right" || + value === "bottom-left" || + value === "bottom-right" + ); +} + +function isWebcamFocusScreenMode(value: unknown): value is WebcamFocusScreenMode { + return value === "pip" || value === "hidden"; +} + +function resolveDefaults( + defaults: WebcamFocusTransitionDefaults | undefined, +): WebcamFocusTransitionDefaults { + return { + transitionInMs: clamp( + Math.round(defaults?.transitionInMs ?? DEFAULT_TRANSITIONS.transitionInMs), + WEBCAM_FOCUS_REGION_MIN_TRANSITION_MS, + WEBCAM_FOCUS_REGION_MAX_TRANSITION_MS, + ), + transitionOutMs: clamp( + Math.round(defaults?.transitionOutMs ?? DEFAULT_TRANSITIONS.transitionOutMs), + WEBCAM_FOCUS_REGION_MIN_TRANSITION_MS, + WEBCAM_FOCUS_REGION_MAX_TRANSITION_MS, + ), + }; +} + +function getTransitionInMs( + region: WebcamFocusRegion, + defaults: WebcamFocusTransitionDefaults, +): number { + return clamp( + Math.round(region.transitionInMs ?? defaults.transitionInMs), + WEBCAM_FOCUS_REGION_MIN_TRANSITION_MS, + WEBCAM_FOCUS_REGION_MAX_TRANSITION_MS, + ); +} + +function getTransitionOutMs( + region: WebcamFocusRegion, + defaults: WebcamFocusTransitionDefaults, +): number { + return clamp( + Math.round(region.transitionOutMs ?? defaults.transitionOutMs), + WEBCAM_FOCUS_REGION_MIN_TRANSITION_MS, + WEBCAM_FOCUS_REGION_MAX_TRANSITION_MS, + ); +} + +export function clampWebcamFocusRegionSize(size: unknown): number { + if (!isFiniteNumber(size)) { + return DEFAULT_WEBCAM_FOCUS_SIZE; + } + + return clamp(size, WEBCAM_FOCUS_REGION_MIN_SIZE, WEBCAM_FOCUS_REGION_MAX_SIZE); +} + +export function clampWebcamFocusRegionPipSize(size: unknown): number { + if (!isFiniteNumber(size)) { + return DEFAULT_WEBCAM_FOCUS_SCREEN_PIP_SIZE; + } + + return clamp(size, WEBCAM_FOCUS_REGION_MIN_PIP_SIZE, WEBCAM_FOCUS_REGION_MAX_PIP_SIZE); +} + +export function clampWebcamFocusRegionTransitionMs(durationMs: unknown): number | undefined { + if (!isFiniteNumber(durationMs)) { + return undefined; + } + + return clamp( + Math.round(durationMs), + WEBCAM_FOCUS_REGION_MIN_TRANSITION_MS, + WEBCAM_FOCUS_REGION_MAX_TRANSITION_MS, + ); +} + +export function getOppositeWebcamCorner(corner: WebcamCorner): WebcamCorner { + switch (corner) { + case "top-left": + return "bottom-right"; + case "top-right": + return "bottom-left"; + case "bottom-left": + return "top-right"; + case "bottom-right": + return "top-left"; + } +} + +export function normalizeWebcamFocusRegions( + input: unknown, + totalDurationMs?: number, +): WebcamFocusRegion[] { + if (!Array.isArray(input)) { + return []; + } + + const hasDurationLimit = isFiniteNumber(totalDurationMs) && totalDurationMs > 0; + const maxEndMs = hasDurationLimit ? Math.round(totalDurationMs) : null; + const normalized: WebcamFocusRegion[] = []; + const usedIds = new Set(); + + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + if (!raw || typeof raw !== "object") { + continue; + } + + const candidate = raw as Partial; + const start = toIntegerMs(candidate.startMs); + const end = toIntegerMs(candidate.endMs); + if (start === null || end === null) { + continue; + } + + let startMs = Math.max(0, start); + let endMs = Math.max(0, end); + if (maxEndMs !== null) { + startMs = clamp(startMs, 0, maxEndMs); + endMs = clamp(endMs, 0, maxEndMs); + } + if (endMs - startMs < WEBCAM_FOCUS_REGION_MIN_DURATION_MS) { + continue; + } + + const transitionInMs = clampWebcamFocusRegionTransitionMs(candidate.transitionInMs); + const transitionOutMs = clampWebcamFocusRegionTransitionMs(candidate.transitionOutMs); + + let id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : `webcam-focus-${index + 1}`; + if (usedIds.has(id)) { + let suffix = index + 1; + let candidateId = `webcam-focus-${suffix}`; + while (usedIds.has(candidateId)) { + suffix += 1; + candidateId = `webcam-focus-${suffix}`; + } + id = candidateId; + } + usedIds.add(id); + + normalized.push({ + id, + startMs, + endMs, + focusSize: clampWebcamFocusRegionSize(candidate.focusSize), + screenMode: isWebcamFocusScreenMode(candidate.screenMode) + ? candidate.screenMode + : DEFAULT_WEBCAM_FOCUS_SCREEN_MODE, + screenPipSize: clampWebcamFocusRegionPipSize(candidate.screenPipSize), + ...(isWebcamCorner(candidate.screenPipCorner) + ? { screenPipCorner: candidate.screenPipCorner } + : {}), + ...(transitionInMs !== undefined ? { transitionInMs } : {}), + ...(transitionOutMs !== undefined ? { transitionOutMs } : {}), + }); + } + + const sorted = normalized.sort((left, right) => { + if (left.startMs !== right.startMs) return left.startMs - right.startMs; + return left.endMs - right.endMs; + }); + + // Resolve overlaps deterministically so preview and export always agree + // on a single active region per instant. Two phases: + // 1. Greedily keep regions, dropping any earlier one that would be left + // shorter than the minimum once clipped to a later region's start. + // Dropping re-checks the new tail, so a short middle region can't + // leave a false gap between its neighbours. + // 2. Clip every kept region to end where the next kept region begins. + const kept: WebcamFocusRegion[] = []; + for (const region of sorted) { + while (kept.length > 0) { + const previous = kept[kept.length - 1]; + if (region.startMs >= previous.endMs) { + break; + } + if (region.startMs - previous.startMs >= WEBCAM_FOCUS_REGION_MIN_DURATION_MS) { + break; + } + kept.pop(); + } + kept.push(region); + } + + return kept.map((region, index) => { + const next = kept[index + 1]; + return next && region.endMs > next.startMs + ? { ...region, endMs: next.startMs } + : region; + }); +} + +export function getActiveWebcamFocusRegion( + regions: readonly WebcamFocusRegion[] | undefined, + timeMs: number, +): WebcamFocusRegion | null { + if (!regions?.length || !Number.isFinite(timeMs)) { + return null; + } + + const roundedTimeMs = Math.round(timeMs); + let active: WebcamFocusRegion | null = null; + for (const region of regions) { + if (roundedTimeMs >= region.startMs && roundedTimeMs < region.endMs) { + if (!active || region.startMs >= active.startMs) { + active = region; + } + } + } + return active; +} + +function getPreviousRegion( + regions: readonly WebcamFocusRegion[], + timeMs: number, +): WebcamFocusRegion | null { + let previous: WebcamFocusRegion | null = null; + for (const region of regions) { + if (region.endMs <= timeMs && (!previous || region.endMs >= previous.endMs)) { + previous = region; + } + } + return previous; +} + +function getNextRegion( + regions: readonly WebcamFocusRegion[], + timeMs: number, +): WebcamFocusRegion | null { + let next: WebcamFocusRegion | null = null; + for (const region of regions) { + if (region.startMs >= timeMs && (!next || region.startMs < next.startMs)) { + next = region; + } + } + return next; +} + +function buildFocusState({ + baseWebcamSize, + region, + progress, + webcamCorner, +}: { + baseWebcamSize: number; + region: WebcamFocusRegion; + progress: number; + webcamCorner: WebcamCorner; +}): WebcamFocusState { + const easedProgress = clamp01(progress); + const screenMode = region.screenMode ?? DEFAULT_WEBCAM_FOCUS_SCREEN_MODE; + const targetPipSize = clampWebcamFocusRegionPipSize(region.screenPipSize); + const screenSize = screenMode === "pip" ? lerp(100, targetPipSize, easedProgress) : 100; + + return { + region, + progress: easedProgress, + webcamSize: lerp( + clampWebcamSizeRegionSize(baseWebcamSize), + clampWebcamFocusRegionSize(region.focusSize), + easedProgress, + ), + screenMode, + screenSize, + screenOpacity: screenMode === "hidden" ? 1 - easedProgress : 1, + screenCorner: region.screenPipCorner ?? getOppositeWebcamCorner(webcamCorner), + }; +} + +function buildFullFocusState({ + baseWebcamSize, + region, + webcamCorner, +}: { + baseWebcamSize: number; + region: WebcamFocusRegion; + webcamCorner: WebcamCorner; +}): WebcamFocusState { + return buildFocusState({ baseWebcamSize, region, progress: 1, webcamCorner }); +} + +function blendFocusStates( + fromState: WebcamFocusState, + toState: WebcamFocusState, + progress: number, +): WebcamFocusState { + const amount = clamp01(progress); + return { + region: amount < 0.5 ? fromState.region : toState.region, + progress: 1, + webcamSize: lerp(fromState.webcamSize, toState.webcamSize, amount), + screenMode: amount < 0.5 ? fromState.screenMode : toState.screenMode, + screenSize: lerp(fromState.screenSize, toState.screenSize, amount), + screenOpacity: lerp(fromState.screenOpacity, toState.screenOpacity, amount), + screenCorner: amount < 0.5 ? fromState.screenCorner : toState.screenCorner, + }; +} + +export function getInterpolatedFocusStateAtTime( + baseWebcamSize: number, + regions: readonly WebcamFocusRegion[] | undefined, + timeMs: number, + webcamCorner: WebcamCorner = "bottom-right", + defaults?: WebcamFocusTransitionDefaults, +): WebcamFocusState | null { + if (!Number.isFinite(timeMs) || !regions?.length) { + return null; + } + + const roundedTimeMs = Math.round(timeMs); + const resolvedDefaults = resolveDefaults(defaults); + const activeRegion = getActiveWebcamFocusRegion(regions, roundedTimeMs); + const nextRegion = getNextRegion(regions, roundedTimeMs); + + if (activeRegion) { + const activeState = buildFullFocusState({ + baseWebcamSize, + region: activeRegion, + webcamCorner, + }); + if (nextRegion && nextRegion.startMs > roundedTimeMs) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeFocusTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return blendFocusStates( + activeState, + buildFullFocusState({ baseWebcamSize, region: nextRegion, webcamCorner }), + progress, + ); + } + } + return activeState; + } + + const previousRegion = getPreviousRegion(regions, roundedTimeMs); + + if (previousRegion && nextRegion && previousRegion.endMs <= roundedTimeMs) { + const previousOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const nextInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const gapMs = nextRegion.startMs - previousRegion.endMs; + if ( + gapMs >= 0 && + gapMs <= previousOutMs + nextInMs && + roundedTimeMs <= nextRegion.startMs + ) { + // Use the same blend origin as the active-region branch so the + // curve is continuous across previousRegion.endMs even when + // nextRegion's transition-in started before the previous region + // ended. Falls back to the plain gap blend when it didn't. + const blendStartMs = Math.min( + previousRegion.endMs, + nextRegion.startMs - nextInMs, + ); + const blendDurationMs = nextRegion.startMs - blendStartMs; + const progress = + gapMs <= 0 || blendDurationMs <= 0 + ? 1 + : easeFocusTransition( + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), + ); + return blendFocusStates( + buildFullFocusState({ baseWebcamSize, region: previousRegion, webcamCorner }), + buildFullFocusState({ baseWebcamSize, region: nextRegion, webcamCorner }), + progress, + ); + } + } + + if (previousRegion) { + const transitionOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const transitionEndMs = previousRegion.endMs + transitionOutMs; + if (transitionOutMs > 0 && roundedTimeMs <= transitionEndMs) { + return buildFocusState({ + baseWebcamSize, + region: previousRegion, + progress: + 1 - + easeFocusTransition((roundedTimeMs - previousRegion.endMs) / transitionOutMs), + webcamCorner, + }); + } + } + + if (nextRegion) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + return buildFocusState({ + baseWebcamSize, + region: nextRegion, + progress: easeFocusTransition((roundedTimeMs - transitionStartMs) / transitionInMs), + webcamCorner, + }); + } + } + + return null; +} + +export function getWebcamFocusScreenTransform({ + containerWidth, + containerHeight, + screenSizePercent, + screenCorner, + margin = 24, +}: { + containerWidth: number; + containerHeight: number; + screenSizePercent: number; + screenCorner: WebcamCorner; + margin?: number; +}): WebcamFocusScreenTransform { + const scale = clamp(screenSizePercent, 0, 100) / 100; + const scaledWidth = containerWidth * scale; + const scaledHeight = containerHeight * scale; + const safeMargin = Math.max(0, margin); + const x = + screenCorner === "top-right" || screenCorner === "bottom-right" + ? containerWidth - scaledWidth - safeMargin + : safeMargin; + const y = + screenCorner === "bottom-left" || screenCorner === "bottom-right" + ? containerHeight - scaledHeight - safeMargin + : safeMargin; + + return { + scale, + x: Math.max(0, x), + y: Math.max(0, y), + }; +} + +export function getNextWebcamFocusRegionId(regions: readonly WebcamFocusRegion[]): string { + const usedNumbers = new Set(); + for (const region of regions) { + const match = /^webcam-focus-(\d+)$/.exec(region.id); + if (match) { + usedNumbers.add(Number(match[1])); + } + } + + let next = 1; + while (usedNumbers.has(next)) { + next += 1; + } + + return `webcam-focus-${next}`; +} diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts index a78b02685..b8c176551 100644 --- a/src/components/video-editor/webcamOverlay.test.ts +++ b/src/components/video-editor/webcamOverlay.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import { + clampWebcamOverlayPosition, + getSnappedWebcamPositionPoint, + getWebcamAvoidCursorPosition, + getWebcamCropDrawLayout, getWebcamCropSourceRect, + getWebcamOverlayPosition, isWebcamCropRegionDefault, normalizeWebcamCropRegion, } from "./webcamOverlay"; @@ -32,3 +37,123 @@ describe("getWebcamCropSourceRect", () => { }); }); }); + +describe("getWebcamAvoidCursorPosition", () => { + it("keeps the current position when the cursor is far away", () => { + const currentPosition = { x: 720, y: 420 }; + + expect( + getWebcamAvoidCursorPosition({ + containerWidth: 960, + containerHeight: 540, + size: 160, + margin: 24, + currentPosition, + cursor: { x: 120, y: 120 }, + legacyCorner: "bottom-right", + }), + ).toEqual(currentPosition); + }); + + it("moves to the corner farthest from the cursor when the cursor enters the bubble radius", () => { + expect( + getWebcamAvoidCursorPosition({ + containerWidth: 960, + containerHeight: 540, + size: 160, + margin: 24, + currentPosition: { x: 776, y: 356 }, + cursor: { x: 850, y: 430 }, + legacyCorner: "bottom-right", + }), + ).toEqual({ x: 24, y: 24 }); + }); +}); + +describe("getWebcamOverlayPosition", () => { + it("uses independent height when anchoring a stretched webcam", () => { + expect( + getWebcamOverlayPosition({ + containerWidth: 960, + containerHeight: 540, + size: 160, + height: 260, + margin: 24, + positionPreset: "bottom-right", + positionX: 1, + positionY: 1, + legacyCorner: "bottom-right", + }), + ).toEqual({ x: 776, y: 256 }); + }); +}); + +describe("clampWebcamOverlayPosition", () => { + it("keeps a resizing webcam inside the canvas bounds", () => { + expect( + clampWebcamOverlayPosition({ + containerWidth: 960, + containerHeight: 540, + size: 260, + height: 220, + margin: 24, + position: { x: 820, y: 400 }, + }), + ).toEqual({ x: 676, y: 296 }); + }); +}); + +describe("getWebcamCropDrawLayout", () => { + it("reveals more vertical source without increasing scale when the frame stretches upward", () => { + const square = getWebcamCropDrawLayout({ + sourceWidth: 100, + sourceHeight: 300, + targetWidth: 100, + targetHeight: 100, + }); + const stretched = getWebcamCropDrawLayout({ + sourceWidth: 100, + sourceHeight: 300, + targetWidth: 100, + targetHeight: 180, + }); + + expect(square.drawWidth).toBe(100); + expect(square.drawHeight).toBe(300); + expect(square.drawY).toBe(-100); + expect(stretched.drawWidth).toBe(100); + expect(stretched.drawHeight).toBe(300); + expect(stretched.drawY).toBe(-20); + }); + + it("falls back to cover when the source has no extra vertical image to reveal", () => { + const stretched = getWebcamCropDrawLayout({ + sourceWidth: 300, + sourceHeight: 100, + targetWidth: 100, + targetHeight: 180, + }); + + expect(stretched.drawWidth).toBeCloseTo(540); + expect(stretched.drawHeight).toBeCloseTo(180); + expect(stretched.drawY).toBeCloseTo(0); + }); +}); + +describe("getSnappedWebcamPositionPoint", () => { + it("snaps to corner and center presets within the magnetic threshold", () => { + expect(getSnappedWebcamPositionPoint({ x: 0.96, y: 0.97 })).toEqual({ x: 1, y: 1 }); + expect(getSnappedWebcamPositionPoint({ x: 0.52, y: 0.48 })).toEqual({ + x: 0.5, + y: 0.5, + }); + expect(getSnappedWebcamPositionPoint({ x: 0.51, y: 0.02 })).toEqual({ + x: 0.5, + y: 0, + }); + }); + + it("keeps freeform positions outside the magnetic threshold", () => { + expect(getSnappedWebcamPositionPoint({ x: 0.4, y: 0.72 })).toEqual({ x: 0.4, y: 0.72 }); + }); +}); diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts index 21cc7dd38..5f25a1fd7 100644 --- a/src/components/video-editor/webcamOverlay.ts +++ b/src/components/video-editor/webcamOverlay.ts @@ -6,6 +6,29 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +const WEBCAM_POSITION_SNAP_POINTS: Array<{ x: number; y: number }> = [ + { x: 0, y: 0 }, + { x: 0.5, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 0.5 }, + { x: 0.5, y: 0.5 }, + { x: 1, y: 0.5 }, + { x: 0, y: 1 }, + { x: 0.5, y: 1 }, + { x: 1, y: 1 }, +]; + +export interface WebcamCropDrawLayout { + sx: number; + sy: number; + sw: number; + sh: number; + drawX: number; + drawY: number; + drawWidth: number; + drawHeight: number; +} + export function getWebcamPositionForPreset(preset: WebcamPositionPreset): { x: number; y: number } { switch (preset) { case "top-left": @@ -78,10 +101,44 @@ export function getWebcamOverlaySizePx({ return Math.min(maxSize, Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, scaledSize)); } +/** + * Inverse of getWebcamOverlaySizePx: given an on-screen pixel size (the size + * the user dragged the bubble to), return the unscaled size percent that + * produces it. Used by the in-preview resize handles so a drag updates the + * persisted size in the same units the size slider uses. + */ +export function getWebcamSizePercentFromPx({ + sizePx, + containerWidth, + containerHeight, + zoomScale, + reactToZoom, +}: { + sizePx: number; + containerWidth: number; + containerHeight: number; + zoomScale: number; + reactToZoom: boolean; +}): number { + const minDimension = Math.min(containerWidth, containerHeight); + if (minDimension <= 0) { + return 10; + } + + const scale = getWebcamOverlayScale(zoomScale, reactToZoom); + if (scale <= 0) { + return 10; + } + + const percent = (sizePx / (minDimension * scale)) * 100; + return clamp(percent, 10, 100); +} + export function getWebcamOverlayPosition({ containerWidth, containerHeight, size, + height, margin, positionPreset, positionX, @@ -91,6 +148,7 @@ export function getWebcamOverlayPosition({ containerWidth: number; containerHeight: number; size: number; + height?: number; margin: number; positionPreset: WebcamPositionPreset; positionX: number; @@ -99,7 +157,8 @@ export function getWebcamOverlayPosition({ }): { x: number; y: number } { const safeMargin = Math.max(0, margin); const availableWidth = Math.max(0, containerWidth - size - safeMargin * 2); - const availableHeight = Math.max(0, containerHeight - size - safeMargin * 2); + const effectiveHeight = height ?? size; + const availableHeight = Math.max(0, containerHeight - effectiveHeight - safeMargin * 2); const presetPosition = positionPreset === "custom" ? { x: clamp(positionX, 0, 1), y: clamp(positionY, 0, 1) } @@ -111,6 +170,161 @@ export function getWebcamOverlayPosition({ }; } +export function clampWebcamOverlayPosition({ + containerWidth, + containerHeight, + size, + height, + margin, + position, +}: { + containerWidth: number; + containerHeight: number; + size: number; + height?: number; + margin: number; + position: { x: number; y: number }; +}): { x: number; y: number } { + const safeMargin = Math.max(0, margin); + const effectiveHeight = height ?? size; + const minX = Math.min(safeMargin, Math.max(0, containerWidth - size)); + const minY = Math.min(safeMargin, Math.max(0, containerHeight - effectiveHeight)); + const maxX = Math.max(minX, containerWidth - size - safeMargin); + const maxY = Math.max(minY, containerHeight - effectiveHeight - safeMargin); + + return { + x: clamp(position.x, minX, maxX), + y: clamp(position.y, minY, maxY), + }; +} + +export function getSnappedWebcamPositionPoint( + position: { x: number; y: number }, + threshold = 0.05, +): { x: number; y: number } { + const safeThreshold = Math.max(0, Math.min(1, threshold)); + const clampedPosition = { + x: clamp(position.x, 0, 1), + y: clamp(position.y, 0, 1), + }; + let bestPoint = clampedPosition; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const point of WEBCAM_POSITION_SNAP_POINTS) { + const distance = Math.max( + Math.abs(clampedPosition.x - point.x), + Math.abs(clampedPosition.y - point.y), + ); + if (distance <= safeThreshold && distance < bestDistance) { + bestDistance = distance; + bestPoint = point; + } + } + + return bestPoint; +} + +export function getWebcamAvoidCursorPosition({ + containerWidth, + containerHeight, + size, + height, + margin, + currentPosition, + cursor, + legacyCorner, +}: { + containerWidth: number; + containerHeight: number; + size: number; + height?: number; + margin: number; + currentPosition: { x: number; y: number }; + cursor: { x: number; y: number } | null | undefined; + legacyCorner: WebcamCorner; +}): { x: number; y: number } { + if (!cursor || containerWidth <= 0 || containerHeight <= 0 || size <= 0) { + return currentPosition; + } + + const centerX = currentPosition.x + size / 2; + const effectiveHeight = height ?? size; + const centerY = currentPosition.y + effectiveHeight / 2; + const distance = Math.hypot(cursor.x - centerX, cursor.y - centerY); + const triggerRadius = Math.max(72, Math.max(size, effectiveHeight) * 0.72); + if (distance > triggerRadius) { + return currentPosition; + } + + const corners: WebcamCorner[] = ["top-left", "top-right", "bottom-left", "bottom-right"]; + let bestPosition = currentPosition; + let bestScore = Number.NEGATIVE_INFINITY; + + for (const corner of corners) { + const position = getWebcamOverlayPosition({ + containerWidth, + containerHeight, + size, + height: effectiveHeight, + margin, + positionPreset: corner, + positionX: 0, + positionY: 0, + legacyCorner, + }); + const candidateCenterX = position.x + size / 2; + const candidateCenterY = position.y + effectiveHeight / 2; + const score = Math.hypot(cursor.x - candidateCenterX, cursor.y - candidateCenterY); + if (score > bestScore) { + bestScore = score; + bestPosition = position; + } + } + + return bestPosition; +} + +export function getWebcamCropDrawLayout({ + cropRegion, + sourceWidth, + sourceHeight, + targetWidth, + targetHeight, +}: { + cropRegion?: Partial | null; + sourceWidth: number; + sourceHeight: number; + targetWidth: number; + targetHeight: number; +}): WebcamCropDrawLayout { + const safeTargetWidth = Math.max(1, targetWidth); + const safeTargetHeight = Math.max(1, targetHeight); + const { sx, sy, sw, sh } = getWebcamCropSourceRect(cropRegion, sourceWidth, sourceHeight); + const coverScale = Math.max(safeTargetWidth / sw, safeTargetHeight / sh); + const baseSize = Math.min(safeTargetWidth, safeTargetHeight); + const revealScale = Math.max(safeTargetWidth / sw, baseSize / sh); + const canRevealVertically = + safeTargetHeight > safeTargetWidth + 0.5 && sh * revealScale >= safeTargetHeight; + const scale = canRevealVertically ? revealScale : coverScale; + const drawWidth = sw * scale; + const drawHeight = sh * scale; + const drawX = (safeTargetWidth - drawWidth) / 2; + const drawY = canRevealVertically + ? (baseSize - drawHeight) / 2 + (safeTargetHeight - baseSize) + : (safeTargetHeight - drawHeight) / 2; + + return { + sx, + sy, + sw, + sh, + drawX, + drawY, + drawWidth, + drawHeight, + }; +} + export function normalizeWebcamCropRegion(cropRegion?: Partial | null): CropRegion { const candidate = cropRegion ?? {}; const rawX = Number.isFinite(candidate.x) ? (candidate.x as number) : 0; diff --git a/src/components/video-editor/webcamPositionRegions.test.ts b/src/components/video-editor/webcamPositionRegions.test.ts new file mode 100644 index 000000000..8bcfa0144 --- /dev/null +++ b/src/components/video-editor/webcamPositionRegions.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import type { WebcamPositionRegion } from "./types"; +import { + clampWebcamPositionCoordinate, + getActiveWebcamPositionRegion, + getInterpolatedWebcamPositionAtTime, + getNextWebcamPositionRegionId, + getWebcamPositionAtTime, + normalizeWebcamPositionRegions, +} from "./webcamPositionRegions"; + +const base = { positionX: 1, positionY: 1 }; + +describe("webcamPositionRegions", () => { + it("uses base position when there are no active regions", () => { + expect(getWebcamPositionAtTime(base, [], 1_000)).toEqual(base); + }); + + it("uses base position when regions is undefined", () => { + expect(getWebcamPositionAtTime(base, undefined, 1_000)).toEqual(base); + }); + + it("uses active region position", () => { + const regions: WebcamPositionRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 2_000, positionX: 0.2, positionY: 0.3 }, + ]; + + expect(getWebcamPositionAtTime(base, regions, 1_500)).toEqual({ + positionX: 0.2, + positionY: 0.3, + }); + }); + + it("treats start as inclusive and end as exclusive", () => { + const regions: WebcamPositionRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 2_000, positionX: 0.4, positionY: 0.5 }, + ]; + + expect(getWebcamPositionAtTime(base, regions, 1_000)).toEqual({ + positionX: 0.4, + positionY: 0.5, + }); + expect(getWebcamPositionAtTime(base, regions, 2_000)).toEqual(base); + }); + + it("chooses the overlapping region with the highest startMs", () => { + const regions: WebcamPositionRegion[] = [ + { id: "early", startMs: 1_000, endMs: 5_000, positionX: 0.1, positionY: 0.1 }, + { id: "late", startMs: 2_000, endMs: 3_000, positionX: 0.8, positionY: 0.9 }, + ]; + + expect(getActiveWebcamPositionRegion(regions, 2_500)?.id).toBe("late"); + expect(getWebcamPositionAtTime(base, regions, 2_500)).toEqual({ + positionX: 0.8, + positionY: 0.9, + }); + }); + + it("falls back to base position when time is outside any region", () => { + const regions: WebcamPositionRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 2_000, positionX: 0.2, positionY: 0.2 }, + ]; + + expect(getActiveWebcamPositionRegion(regions, 500)).toBeNull(); + expect(getWebcamPositionAtTime(base, regions, 500)).toEqual(base); + }); + + it("clamps coordinates to the 0-1 range", () => { + expect(clampWebcamPositionCoordinate(-0.5, 1)).toBe(0); + expect(clampWebcamPositionCoordinate(1.5, 0)).toBe(1); + expect(clampWebcamPositionCoordinate(0.42, 1)).toBe(0.42); + expect(clampWebcamPositionCoordinate(Number.NaN, 0.7)).toBe(0.7); + }); + + it("normalizes valid persisted regions and drops invalid ones", () => { + const normalized = normalizeWebcamPositionRegions( + [ + { + id: "bad-duration", + startMs: 1_000, + endMs: 1_100, + positionX: 0.5, + positionY: 0.5, + }, + { + id: "valid", + startMs: 1_000.2, + endMs: 2_000.7, + positionX: 1.5, + positionY: -0.2, + transitionInMs: 250.2, + transitionOutMs: 300.7, + }, + { + id: "bad-time", + startMs: Number.NaN, + endMs: 3_000, + positionX: 0.3, + positionY: 0.3, + }, + ], + 10_000, + ); + + expect(normalized).toEqual([ + { + id: "valid", + startMs: 1_000, + endMs: 2_001, + positionX: 1, + positionY: 0, + transitionInMs: 250, + transitionOutMs: 301, + }, + ]); + }); + + it("drops regions whose duration falls below the minimum after clamping to total duration", () => { + const normalized = normalizeWebcamPositionRegions( + [{ id: "r1", startMs: 900, endMs: 2_000, positionX: 0.5, positionY: 0.5 }], + 1_000, + ); + + expect(normalized).toEqual([]); + }); + + it("returns an empty array for non-array input", () => { + expect(normalizeWebcamPositionRegions(undefined)).toEqual([]); + expect(normalizeWebcamPositionRegions(null)).toEqual([]); + expect(normalizeWebcamPositionRegions("oops")).toEqual([]); + }); + + it("sorts non-overlapping regions by start", () => { + const normalized = normalizeWebcamPositionRegions([ + { id: "b", startMs: 4_000, endMs: 5_000, positionX: 0.5, positionY: 0.5 }, + { id: "a", startMs: 1_000, endMs: 2_000, positionX: 0.5, positionY: 0.5 }, + { id: "c", startMs: 2_500, endMs: 3_000, positionX: 0.5, positionY: 0.5 }, + ]); + + expect(normalized.map((region) => region.id)).toEqual(["a", "c", "b"]); + }); + + it("clips an earlier region so it ends where the next one starts", () => { + const normalized = normalizeWebcamPositionRegions([ + { id: "a", startMs: 1_000, endMs: 5_000, positionX: 0.2, positionY: 0.2 }, + { id: "b", startMs: 3_000, endMs: 6_000, positionX: 0.8, positionY: 0.8 }, + ]); + + expect(normalized).toEqual([ + { id: "a", startMs: 1_000, endMs: 3_000, positionX: 0.2, positionY: 0.2 }, + { id: "b", startMs: 3_000, endMs: 6_000, positionX: 0.8, positionY: 0.8 }, + ]); + }); + + it("drops a region fully shadowed by an overlapping one at the same start", () => { + const normalized = normalizeWebcamPositionRegions([ + { id: "short", startMs: 1_000, endMs: 1_500, positionX: 0.2, positionY: 0.2 }, + { id: "long", startMs: 1_000, endMs: 4_000, positionX: 0.8, positionY: 0.8 }, + ]); + + expect(normalized.map((region) => region.id)).toEqual(["long"]); + }); + + it("clips the survivor to the next region after dropping a short middle overlap", () => { + const normalized = normalizeWebcamPositionRegions([ + { id: "a", startMs: 0, endMs: 2_000, positionX: 0.1, positionY: 0.1 }, + { id: "b", startMs: 1_900, endMs: 2_050, positionX: 0.5, positionY: 0.5 }, + { id: "c", startMs: 2_000, endMs: 4_000, positionX: 0.9, positionY: 0.9 }, + ]); + + expect(normalized).toEqual([ + { id: "a", startMs: 0, endMs: 2_000, positionX: 0.1, positionY: 0.1 }, + { id: "c", startMs: 2_000, endMs: 4_000, positionX: 0.9, positionY: 0.9 }, + ]); + }); + + it("does not let a fallback id collide with an existing persisted id", () => { + const normalized = normalizeWebcamPositionRegions([ + { id: "webcam-position-2", startMs: 0, endMs: 1_000, positionX: 0.1, positionY: 0.1 }, + { startMs: 2_000, endMs: 3_000, positionX: 0.5, positionY: 0.5 }, + ]); + + const ids = normalized.map((region) => region.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("generates unique ids that do not collide with existing ones", () => { + const existing: WebcamPositionRegion[] = [ + { id: "webcam-position-1", startMs: 0, endMs: 1_000, positionX: 1, positionY: 1 }, + { id: "webcam-position-3", startMs: 2_000, endMs: 3_000, positionX: 1, positionY: 1 }, + ]; + + expect(getNextWebcamPositionRegionId(existing)).toBe("webcam-position-2"); + expect(getNextWebcamPositionRegionId([])).toBe("webcam-position-1"); + }); + + describe("getInterpolatedWebcamPositionAtTime", () => { + it("uses the base position when there are no regions", () => { + expect(getInterpolatedWebcamPositionAtTime(base, [], 1_000)).toEqual(base); + }); + + it("returns the exact region position in the center of a region", () => { + const regions: WebcamPositionRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 3_000, positionX: 0.2, positionY: 0.7 }, + ]; + + expect(getInterpolatedWebcamPositionAtTime(base, regions, 2_000)).toEqual({ + positionX: 0.2, + positionY: 0.7, + }); + }); + + it("returns an intermediate value in the middle of the ramp-in", () => { + const regions: WebcamPositionRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 3_000, + positionX: 0.2, + positionY: 0.2, + transitionInMs: 400, + }, + ]; + + const value = getInterpolatedWebcamPositionAtTime(base, regions, 800); + expect(value.positionX).toBeGreaterThan(0.2); + expect(value.positionX).toBeLessThan(1); + expect(value.positionY).toBeGreaterThan(0.2); + expect(value.positionY).toBeLessThan(1); + }); + + it("applies easing monotonically through the ramp-in", () => { + const regions: WebcamPositionRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 3_000, + positionX: 0, + positionY: 0, + transitionInMs: 400, + }, + ]; + const samples = [600, 700, 800, 900, 1_000].map((timeMs) => + getInterpolatedWebcamPositionAtTime({ positionX: 1, positionY: 1 }, regions, timeMs), + ); + + for (let index = 1; index < samples.length; index += 1) { + expect(samples[index].positionX).toBeLessThanOrEqual(samples[index - 1].positionX); + expect(samples[index].positionY).toBeLessThanOrEqual(samples[index - 1].positionY); + } + expect(samples[0].positionX).toBeCloseTo(1, 1); + expect(samples[samples.length - 1].positionX).toBeCloseTo(0, 1); + }); + }); +}); diff --git a/src/components/video-editor/webcamPositionRegions.ts b/src/components/video-editor/webcamPositionRegions.ts new file mode 100644 index 000000000..a9e41368b --- /dev/null +++ b/src/components/video-editor/webcamPositionRegions.ts @@ -0,0 +1,415 @@ +import type { WebcamPositionRegion } from "./types"; +import { + DEFAULT_WEBCAM_POSITION_TRANSITION_IN_MS, + DEFAULT_WEBCAM_POSITION_TRANSITION_OUT_MS, + DEFAULT_WEBCAM_POSITION_X, + DEFAULT_WEBCAM_POSITION_Y, +} from "./types"; +import { clamp01, cubicBezier } from "./videoPlayback/mathUtils"; + +export const WEBCAM_POSITION_REGION_MIN_DURATION_MS = 250; +export const WEBCAM_POSITION_REGION_MIN_TRANSITION_MS = 0; +export const WEBCAM_POSITION_REGION_MAX_TRANSITION_MS = 2000; + +export interface WebcamPositionTransitionDefaults { + transitionInMs: number; + transitionOutMs: number; +} + +export interface WebcamPositionPoint { + positionX: number; + positionY: number; +} + +const DEFAULT_TRANSITIONS: WebcamPositionTransitionDefaults = { + transitionInMs: DEFAULT_WEBCAM_POSITION_TRANSITION_IN_MS, + transitionOutMs: DEFAULT_WEBCAM_POSITION_TRANSITION_OUT_MS, +}; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function toIntegerMs(value: unknown): number | null { + if (!isFiniteNumber(value)) return null; + return Math.round(value); +} + +function lerp(start: number, end: number, amount: number) { + return start + (end - start) * amount; +} + +function easeWebcamPositionTransition(t: number): number { + return cubicBezier(0.4, 0.0, 0.2, 1.0, clamp01(t)); +} + +function resolveDefaults( + defaults: WebcamPositionTransitionDefaults | undefined, +): WebcamPositionTransitionDefaults { + return { + transitionInMs: clamp( + Math.round(defaults?.transitionInMs ?? DEFAULT_TRANSITIONS.transitionInMs), + WEBCAM_POSITION_REGION_MIN_TRANSITION_MS, + WEBCAM_POSITION_REGION_MAX_TRANSITION_MS, + ), + transitionOutMs: clamp( + Math.round(defaults?.transitionOutMs ?? DEFAULT_TRANSITIONS.transitionOutMs), + WEBCAM_POSITION_REGION_MIN_TRANSITION_MS, + WEBCAM_POSITION_REGION_MAX_TRANSITION_MS, + ), + }; +} + +function getTransitionInMs( + region: WebcamPositionRegion, + defaults: WebcamPositionTransitionDefaults, +): number { + return clamp( + Math.round(region.transitionInMs ?? defaults.transitionInMs), + WEBCAM_POSITION_REGION_MIN_TRANSITION_MS, + WEBCAM_POSITION_REGION_MAX_TRANSITION_MS, + ); +} + +function getTransitionOutMs( + region: WebcamPositionRegion, + defaults: WebcamPositionTransitionDefaults, +): number { + return clamp( + Math.round(region.transitionOutMs ?? defaults.transitionOutMs), + WEBCAM_POSITION_REGION_MIN_TRANSITION_MS, + WEBCAM_POSITION_REGION_MAX_TRANSITION_MS, + ); +} + +export function clampWebcamPositionCoordinate(value: unknown, fallback: number): number { + if (!isFiniteNumber(value)) { + return clamp(fallback, 0, 1); + } + + return clamp(value, 0, 1); +} + +export function clampWebcamPositionRegionTransitionMs( + durationMs: unknown, +): number | undefined { + if (!isFiniteNumber(durationMs)) { + return undefined; + } + + return clamp( + Math.round(durationMs), + WEBCAM_POSITION_REGION_MIN_TRANSITION_MS, + WEBCAM_POSITION_REGION_MAX_TRANSITION_MS, + ); +} + +export function normalizeWebcamPositionRegions( + input: unknown, + totalDurationMs?: number, +): WebcamPositionRegion[] { + if (!Array.isArray(input)) { + return []; + } + + const hasDurationLimit = isFiniteNumber(totalDurationMs) && totalDurationMs > 0; + const maxEndMs = hasDurationLimit ? Math.round(totalDurationMs) : null; + + const normalized: WebcamPositionRegion[] = []; + const usedIds = new Set(); + + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + + if (!raw || typeof raw !== "object") { + continue; + } + + const candidate = raw as Partial; + + const start = toIntegerMs(candidate.startMs); + const end = toIntegerMs(candidate.endMs); + + if (start === null || end === null) { + continue; + } + + let startMs = Math.max(0, start); + let endMs = Math.max(0, end); + + if (maxEndMs !== null) { + startMs = clamp(startMs, 0, maxEndMs); + endMs = clamp(endMs, 0, maxEndMs); + } + + if (endMs - startMs < WEBCAM_POSITION_REGION_MIN_DURATION_MS) { + continue; + } + + let id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : `webcam-position-${index + 1}`; + if (usedIds.has(id)) { + let suffix = index + 1; + let candidateId = `webcam-position-${suffix}`; + while (usedIds.has(candidateId)) { + suffix += 1; + candidateId = `webcam-position-${suffix}`; + } + id = candidateId; + } + usedIds.add(id); + const transitionInMs = clampWebcamPositionRegionTransitionMs(candidate.transitionInMs); + const transitionOutMs = clampWebcamPositionRegionTransitionMs(candidate.transitionOutMs); + + normalized.push({ + id, + startMs, + endMs, + positionX: clampWebcamPositionCoordinate(candidate.positionX, DEFAULT_WEBCAM_POSITION_X), + positionY: clampWebcamPositionCoordinate(candidate.positionY, DEFAULT_WEBCAM_POSITION_Y), + ...(transitionInMs !== undefined ? { transitionInMs } : {}), + ...(transitionOutMs !== undefined ? { transitionOutMs } : {}), + }); + } + + const sorted = normalized.sort((left, right) => { + if (left.startMs !== right.startMs) { + return left.startMs - right.startMs; + } + + return left.endMs - right.endMs; + }); + + // Resolve overlaps deterministically so preview and export always agree + // on a single active region per instant. Two phases: + // 1. Greedily keep regions, dropping any earlier one that would be left + // shorter than the minimum once clipped to a later region's start. + // Dropping re-checks the new tail, so a short middle region can't + // leave a false gap between its neighbours. + // 2. Clip every kept region to end where the next kept region begins. + const kept: WebcamPositionRegion[] = []; + for (const region of sorted) { + while (kept.length > 0) { + const previous = kept[kept.length - 1]; + if (region.startMs >= previous.endMs) { + break; + } + if (region.startMs - previous.startMs >= WEBCAM_POSITION_REGION_MIN_DURATION_MS) { + break; + } + kept.pop(); + } + kept.push(region); + } + + return kept.map((region, index) => { + const next = kept[index + 1]; + return next && region.endMs > next.startMs + ? { ...region, endMs: next.startMs } + : region; + }); +} + +export function getActiveWebcamPositionRegion( + regions: readonly WebcamPositionRegion[] | undefined, + timeMs: number, +): WebcamPositionRegion | null { + if (!regions?.length || !Number.isFinite(timeMs)) { + return null; + } + + const roundedTimeMs = Math.round(timeMs); + let active: WebcamPositionRegion | null = null; + + for (const region of regions) { + if (roundedTimeMs >= region.startMs && roundedTimeMs < region.endMs) { + if (!active || region.startMs >= active.startMs) { + active = region; + } + } + } + + return active; +} + +function getPreviousRegion( + regions: readonly WebcamPositionRegion[], + timeMs: number, +): WebcamPositionRegion | null { + let previous: WebcamPositionRegion | null = null; + for (const region of regions) { + if (region.endMs <= timeMs && (!previous || region.endMs >= previous.endMs)) { + previous = region; + } + } + return previous; +} + +function getNextRegion( + regions: readonly WebcamPositionRegion[], + timeMs: number, +): WebcamPositionRegion | null { + let next: WebcamPositionRegion | null = null; + for (const region of regions) { + if (region.startMs >= timeMs && (!next || region.startMs < next.startMs)) { + next = region; + } + } + return next; +} + +function regionToPoint(region: WebcamPositionRegion): WebcamPositionPoint { + return { + positionX: clampWebcamPositionCoordinate(region.positionX, DEFAULT_WEBCAM_POSITION_X), + positionY: clampWebcamPositionCoordinate(region.positionY, DEFAULT_WEBCAM_POSITION_Y), + }; +} + +function lerpPoint(start: WebcamPositionPoint, end: WebcamPositionPoint, amount: number) { + return { + positionX: lerp(start.positionX, end.positionX, amount), + positionY: lerp(start.positionY, end.positionY, amount), + }; +} + +export function getWebcamPositionAtTime( + base: WebcamPositionPoint, + regions: readonly WebcamPositionRegion[] | undefined, + timeMs: number, +): WebcamPositionPoint { + const basePoint: WebcamPositionPoint = { + positionX: clampWebcamPositionCoordinate(base.positionX, DEFAULT_WEBCAM_POSITION_X), + positionY: clampWebcamPositionCoordinate(base.positionY, DEFAULT_WEBCAM_POSITION_Y), + }; + + if (!Number.isFinite(timeMs)) { + return basePoint; + } + + const active = getActiveWebcamPositionRegion(regions, Math.round(timeMs)); + return active ? regionToPoint(active) : basePoint; +} + +/** + * Deterministic position resolver used by preview and export. Transitions start + * before a region begins and finish after it ends; when neighboring transition + * windows overlap, the position blends directly between the two region anchors. + */ +export function getInterpolatedWebcamPositionAtTime( + base: WebcamPositionPoint, + regions: readonly WebcamPositionRegion[] | undefined, + timeMs: number, + defaults?: WebcamPositionTransitionDefaults, +): WebcamPositionPoint { + const basePoint: WebcamPositionPoint = { + positionX: clampWebcamPositionCoordinate(base.positionX, DEFAULT_WEBCAM_POSITION_X), + positionY: clampWebcamPositionCoordinate(base.positionY, DEFAULT_WEBCAM_POSITION_Y), + }; + + if (!Number.isFinite(timeMs) || !regions?.length) { + return basePoint; + } + + const roundedTimeMs = Math.round(timeMs); + const resolvedDefaults = resolveDefaults(defaults); + const activeRegion = getActiveWebcamPositionRegion(regions, roundedTimeMs); + const nextRegion = getNextRegion(regions, roundedTimeMs); + + if (activeRegion) { + if (nextRegion && nextRegion.startMs > roundedTimeMs) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeWebcamPositionTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return lerpPoint(regionToPoint(activeRegion), regionToPoint(nextRegion), progress); + } + } + + return regionToPoint(activeRegion); + } + + const previousRegion = getPreviousRegion(regions, roundedTimeMs); + + if (previousRegion && nextRegion && previousRegion.endMs <= roundedTimeMs) { + const previousOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const nextInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const gapMs = nextRegion.startMs - previousRegion.endMs; + const transitionsOverlap = + gapMs >= 0 && gapMs <= previousOutMs + nextInMs && roundedTimeMs <= nextRegion.startMs; + + if (transitionsOverlap) { + if (gapMs <= 0) { + return regionToPoint(nextRegion); + } + + // Use the same blend origin as the active-region branch so the + // curve is continuous across previousRegion.endMs even when + // nextRegion's transition-in started before the previous region + // ended. Falls back to the plain gap blend when it didn't. + const blendStartMs = Math.min( + previousRegion.endMs, + nextRegion.startMs - nextInMs, + ); + const blendDurationMs = nextRegion.startMs - blendStartMs; + const progress = easeWebcamPositionTransition( + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), + ); + return lerpPoint(regionToPoint(previousRegion), regionToPoint(nextRegion), progress); + } + } + + if (previousRegion) { + const transitionOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const transitionEndMs = previousRegion.endMs + transitionOutMs; + + if (transitionOutMs > 0 && roundedTimeMs <= transitionEndMs) { + const progress = easeWebcamPositionTransition( + (roundedTimeMs - previousRegion.endMs) / transitionOutMs, + ); + return lerpPoint(regionToPoint(previousRegion), basePoint, progress); + } + } + + if (nextRegion) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeWebcamPositionTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return lerpPoint(basePoint, regionToPoint(nextRegion), progress); + } + } + + return basePoint; +} + +export function getNextWebcamPositionRegionId( + regions: readonly WebcamPositionRegion[], +): string { + const usedNumbers = new Set(); + + for (const region of regions) { + const match = /^webcam-position-(\d+)$/.exec(region.id); + if (match) { + usedNumbers.add(Number(match[1])); + } + } + + let next = 1; + while (usedNumbers.has(next)) { + next += 1; + } + + return `webcam-position-${next}`; +} diff --git a/src/components/video-editor/webcamSizeRegions.test.ts b/src/components/video-editor/webcamSizeRegions.test.ts new file mode 100644 index 000000000..416f615a1 --- /dev/null +++ b/src/components/video-editor/webcamSizeRegions.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; +import type { WebcamSizeRegion } from "./types"; +import { + clampWebcamSizeRegionSize, + getActiveWebcamSizeRegion, + getInterpolatedWebcamDimensionsAtTime, + getInterpolatedWebcamSizeAtTime, + getNextWebcamSizeRegionId, + getWebcamSizeAtTime, + normalizeWebcamSizeRegions, +} from "./webcamSizeRegions"; + +describe("webcamSizeRegions", () => { + it("uses base size when there are no active regions", () => { + expect(getWebcamSizeAtTime(35, [], 1_000)).toBe(35); + }); + + it("uses base size when regions is undefined", () => { + expect(getWebcamSizeAtTime(35, undefined, 1_000)).toBe(35); + }); + + it("uses active region size", () => { + const regions: WebcamSizeRegion[] = [{ id: "r1", startMs: 1_000, endMs: 2_000, size: 70 }]; + + expect(getWebcamSizeAtTime(35, regions, 1_500)).toBe(70); + }); + + it("treats start as inclusive and end as exclusive", () => { + const regions: WebcamSizeRegion[] = [{ id: "r1", startMs: 1_000, endMs: 2_000, size: 70 }]; + + expect(getWebcamSizeAtTime(35, regions, 1_000)).toBe(70); + expect(getWebcamSizeAtTime(35, regions, 2_000)).toBe(35); + }); + + it("chooses the overlapping region with the highest startMs", () => { + const regions: WebcamSizeRegion[] = [ + { id: "early", startMs: 1_000, endMs: 5_000, size: 50 }, + { id: "late", startMs: 2_000, endMs: 3_000, size: 80 }, + ]; + + expect(getActiveWebcamSizeRegion(regions, 2_500)?.id).toBe("late"); + expect(getWebcamSizeAtTime(35, regions, 2_500)).toBe(80); + }); + + it("falls back to base size when time is outside any region", () => { + const regions: WebcamSizeRegion[] = [{ id: "r1", startMs: 1_000, endMs: 2_000, size: 70 }]; + + expect(getActiveWebcamSizeRegion(regions, 500)).toBeNull(); + expect(getWebcamSizeAtTime(35, regions, 500)).toBe(35); + }); + + it("clamps sizes to the valid range", () => { + expect(clampWebcamSizeRegionSize(5)).toBe(10); + expect(clampWebcamSizeRegionSize(150)).toBe(100); + expect(clampWebcamSizeRegionSize(55)).toBe(55); + }); + + it("normalizes valid persisted regions and drops invalid ones", () => { + const normalized = normalizeWebcamSizeRegions( + [ + { id: "bad-duration", startMs: 1_000, endMs: 1_100, size: 50 }, + { + id: "valid", + startMs: 1_000.2, + endMs: 2_000.7, + size: 120, + height: 80.4, + transitionInMs: 250.2, + transitionOutMs: 300.7, + }, + { id: "bad-time", startMs: Number.NaN, endMs: 3_000, size: 40 }, + ], + 10_000, + ); + + expect(normalized).toEqual([ + { + id: "valid", + startMs: 1_000, + endMs: 2_001, + size: 100, + height: 80.4, + transitionInMs: 250, + transitionOutMs: 301, + }, + ]); + }); + + it("drops regions whose duration falls below the minimum after clamping to total duration", () => { + const normalized = normalizeWebcamSizeRegions( + [{ id: "r1", startMs: 900, endMs: 2_000, size: 60 }], + 1_000, + ); + + expect(normalized).toEqual([]); + }); + + it("keeps regions when the clamped duration still meets the minimum", () => { + const normalized = normalizeWebcamSizeRegions( + [{ id: "r1", startMs: 500, endMs: 5_000, size: 60 }], + 1_000, + ); + + expect(normalized).toEqual([{ id: "r1", startMs: 500, endMs: 1_000, size: 60 }]); + }); + + it("returns an empty array for non-array input", () => { + expect(normalizeWebcamSizeRegions(undefined)).toEqual([]); + expect(normalizeWebcamSizeRegions(null)).toEqual([]); + expect(normalizeWebcamSizeRegions("oops")).toEqual([]); + expect(normalizeWebcamSizeRegions({ foo: 1 })).toEqual([]); + }); + + it("sorts non-overlapping regions by start", () => { + const normalized = normalizeWebcamSizeRegions([ + { id: "b", startMs: 4_000, endMs: 5_000, size: 50 }, + { id: "a", startMs: 1_000, endMs: 2_000, size: 50 }, + { id: "c", startMs: 2_500, endMs: 3_000, size: 50 }, + ]); + + expect(normalized.map((region) => region.id)).toEqual(["a", "c", "b"]); + }); + + it("clips an earlier region so it ends where the next one starts", () => { + const normalized = normalizeWebcamSizeRegions([ + { id: "a", startMs: 1_000, endMs: 5_000, size: 50 }, + { id: "b", startMs: 3_000, endMs: 6_000, size: 70 }, + ]); + + expect(normalized).toEqual([ + { id: "a", startMs: 1_000, endMs: 3_000, size: 50 }, + { id: "b", startMs: 3_000, endMs: 6_000, size: 70 }, + ]); + }); + + it("drops a region fully shadowed by an overlapping one at the same start", () => { + const normalized = normalizeWebcamSizeRegions([ + { id: "short", startMs: 1_000, endMs: 1_500, size: 50 }, + { id: "long", startMs: 1_000, endMs: 4_000, size: 70 }, + ]); + + expect(normalized.map((region) => region.id)).toEqual(["long"]); + }); + + it("clips the survivor to the next region after dropping a short middle overlap", () => { + const normalized = normalizeWebcamSizeRegions([ + { id: "a", startMs: 0, endMs: 2_000, size: 40 }, + { id: "b", startMs: 1_900, endMs: 2_050, size: 60 }, + { id: "c", startMs: 2_000, endMs: 4_000, size: 80 }, + ]); + + // b is too short once clipped, so it is dropped and a is clipped to + // c's start with no false gap left between a and c. + expect(normalized).toEqual([ + { id: "a", startMs: 0, endMs: 2_000, size: 40 }, + { id: "c", startMs: 2_000, endMs: 4_000, size: 80 }, + ]); + }); + + it("does not let a fallback id collide with an existing persisted id", () => { + const normalized = normalizeWebcamSizeRegions([ + { id: "webcam-size-2", startMs: 0, endMs: 1_000, size: 40 }, + { startMs: 2_000, endMs: 3_000, size: 50 }, + ]); + + const ids = normalized.map((region) => region.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("generates unique ids that do not collide with existing ones", () => { + const existing: WebcamSizeRegion[] = [ + { id: "webcam-size-1", startMs: 0, endMs: 1_000, size: 40 }, + { id: "webcam-size-3", startMs: 2_000, endMs: 3_000, size: 40 }, + ]; + + expect(getNextWebcamSizeRegionId(existing)).toBe("webcam-size-2"); + expect(getNextWebcamSizeRegionId([])).toBe("webcam-size-1"); + }); + + describe("getInterpolatedWebcamSizeAtTime", () => { + it("uses the base size when there are no regions", () => { + expect(getInterpolatedWebcamSizeAtTime(35, [], 1_000)).toBe(35); + }); + + it("returns the exact region size in the center of a region", () => { + const regions: WebcamSizeRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 3_000, size: 70 }, + ]; + + expect(getInterpolatedWebcamSizeAtTime(35, regions, 2_000)).toBe(70); + }); + + it("returns an intermediate value in the middle of the ramp-in", () => { + const regions: WebcamSizeRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 3_000, size: 70, transitionInMs: 400 }, + ]; + + const value = getInterpolatedWebcamSizeAtTime(35, regions, 800); + expect(value).toBeGreaterThan(35); + expect(value).toBeLessThan(70); + }); + + it("blends consecutive overlapping ramps directly instead of passing through base", () => { + const regions: WebcamSizeRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 2_000, + size: 80, + transitionOutMs: 500, + }, + { + id: "r2", + startMs: 2_300, + endMs: 3_000, + size: 60, + transitionInMs: 500, + }, + ]; + + const between = getInterpolatedWebcamSizeAtTime(40, regions, 2_150); + expect(between).toBeGreaterThan(60); + expect(between).toBeLessThan(80); + }); + + it("stays continuous across a region boundary when the next transition-in started early", () => { + // r2's transition-in (600ms) begins at 4600, before r1 ends at + // 5000, so the active branch is already blending toward r2 when + // r1 ends. The blend must not snap back at 5000. + const regions: WebcamSizeRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 5_000, size: 40 }, + { id: "r2", startMs: 5_200, endMs: 8_000, size: 80, transitionInMs: 600 }, + ]; + + const before = getInterpolatedWebcamSizeAtTime(30, regions, 4_999); + const at = getInterpolatedWebcamSizeAtTime(30, regions, 5_000); + const after = getInterpolatedWebcamSizeAtTime(30, regions, 5_001); + + // No discontinuity: 1ms steps should move by well under a size unit. + expect(Math.abs(at - before)).toBeLessThan(1); + expect(Math.abs(after - at)).toBeLessThan(1); + // And it should be mid-blend (between the two region sizes), not + // reset to 40. + expect(at).toBeGreaterThan(40); + expect(at).toBeLessThan(80); + }); + + it("applies easing monotonically through the ramp-in", () => { + const regions: WebcamSizeRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 3_000, size: 80, transitionInMs: 400 }, + ]; + const samples = [600, 700, 800, 900, 1_000].map((timeMs) => + getInterpolatedWebcamSizeAtTime(40, regions, timeMs), + ); + + for (let index = 1; index < samples.length; index += 1) { + expect(samples[index]).toBeGreaterThanOrEqual(samples[index - 1]); + } + expect(samples[0]).toBeCloseTo(40, 1); + expect(samples[samples.length - 1]).toBeCloseTo(80, 1); + }); + }); + + describe("getInterpolatedWebcamDimensionsAtTime", () => { + it("uses independent height values when a region stretches the webcam", () => { + const regions: WebcamSizeRegion[] = [ + { id: "r1", startMs: 1_000, endMs: 3_000, size: 40, height: 80 }, + ]; + + expect(getInterpolatedWebcamDimensionsAtTime(40, 40, regions, 2_000)).toEqual({ + size: 40, + height: 80, + }); + }); + + it("eases height back to the base height after a stretched region", () => { + const regions: WebcamSizeRegion[] = [ + { + id: "r1", + startMs: 1_000, + endMs: 2_000, + size: 40, + height: 80, + transitionOutMs: 400, + }, + ]; + + const value = getInterpolatedWebcamDimensionsAtTime(40, 40, regions, 2_200); + expect(value.size).toBeCloseTo(40, 1); + expect(value.height).toBeGreaterThan(40); + expect(value.height).toBeLessThan(80); + }); + }); +}); diff --git a/src/components/video-editor/webcamSizeRegions.ts b/src/components/video-editor/webcamSizeRegions.ts new file mode 100644 index 000000000..65eb10060 --- /dev/null +++ b/src/components/video-editor/webcamSizeRegions.ts @@ -0,0 +1,533 @@ +import type { WebcamSizeRegion } from "./types"; +import { + DEFAULT_WEBCAM_SIZE, + DEFAULT_WEBCAM_SIZE_TRANSITION_IN_MS, + DEFAULT_WEBCAM_SIZE_TRANSITION_OUT_MS, +} from "./types"; +import { clamp01, cubicBezier } from "./videoPlayback/mathUtils"; + +export const WEBCAM_SIZE_REGION_MIN_SIZE = 10; +export const WEBCAM_SIZE_REGION_MAX_SIZE = 100; +export const WEBCAM_SIZE_REGION_MIN_DURATION_MS = 250; +export const WEBCAM_SIZE_REGION_MIN_TRANSITION_MS = 0; +export const WEBCAM_SIZE_REGION_MAX_TRANSITION_MS = 2000; + +export interface WebcamSizeTransitionDefaults { + transitionInMs: number; + transitionOutMs: number; +} + +export interface WebcamSizeDimensions { + size: number; + height: number; +} + +const DEFAULT_TRANSITIONS: WebcamSizeTransitionDefaults = { + transitionInMs: DEFAULT_WEBCAM_SIZE_TRANSITION_IN_MS, + transitionOutMs: DEFAULT_WEBCAM_SIZE_TRANSITION_OUT_MS, +}; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function toIntegerMs(value: unknown): number | null { + if (!isFiniteNumber(value)) return null; + return Math.round(value); +} + +function lerp(start: number, end: number, amount: number) { + return start + (end - start) * amount; +} + +function easeWebcamSizeTransition(t: number): number { + return cubicBezier(0.4, 0.0, 0.2, 1.0, clamp01(t)); +} + +function resolveDefaults( + defaults: WebcamSizeTransitionDefaults | undefined, +): WebcamSizeTransitionDefaults { + return { + transitionInMs: clamp( + Math.round(defaults?.transitionInMs ?? DEFAULT_TRANSITIONS.transitionInMs), + WEBCAM_SIZE_REGION_MIN_TRANSITION_MS, + WEBCAM_SIZE_REGION_MAX_TRANSITION_MS, + ), + transitionOutMs: clamp( + Math.round(defaults?.transitionOutMs ?? DEFAULT_TRANSITIONS.transitionOutMs), + WEBCAM_SIZE_REGION_MIN_TRANSITION_MS, + WEBCAM_SIZE_REGION_MAX_TRANSITION_MS, + ), + }; +} + +function getTransitionInMs( + region: WebcamSizeRegion, + defaults: WebcamSizeTransitionDefaults, +): number { + return clamp( + Math.round(region.transitionInMs ?? defaults.transitionInMs), + WEBCAM_SIZE_REGION_MIN_TRANSITION_MS, + WEBCAM_SIZE_REGION_MAX_TRANSITION_MS, + ); +} + +function getTransitionOutMs( + region: WebcamSizeRegion, + defaults: WebcamSizeTransitionDefaults, +): number { + return clamp( + Math.round(region.transitionOutMs ?? defaults.transitionOutMs), + WEBCAM_SIZE_REGION_MIN_TRANSITION_MS, + WEBCAM_SIZE_REGION_MAX_TRANSITION_MS, + ); +} + +export function clampWebcamSizeRegionSize(size: unknown): number { + if (!isFiniteNumber(size)) { + return DEFAULT_WEBCAM_SIZE; + } + + return clamp(size, WEBCAM_SIZE_REGION_MIN_SIZE, WEBCAM_SIZE_REGION_MAX_SIZE); +} + +export function clampWebcamSizeRegionHeight(height: unknown, fallback?: unknown): number { + if (!isFiniteNumber(height)) { + return clampWebcamSizeRegionSize(fallback); + } + + return clamp(height, WEBCAM_SIZE_REGION_MIN_SIZE, WEBCAM_SIZE_REGION_MAX_SIZE); +} + +export function clampWebcamSizeRegionTransitionMs(durationMs: unknown): number | undefined { + if (!isFiniteNumber(durationMs)) { + return undefined; + } + + return clamp( + Math.round(durationMs), + WEBCAM_SIZE_REGION_MIN_TRANSITION_MS, + WEBCAM_SIZE_REGION_MAX_TRANSITION_MS, + ); +} + +export function normalizeWebcamSizeRegions( + input: unknown, + totalDurationMs?: number, +): WebcamSizeRegion[] { + if (!Array.isArray(input)) { + return []; + } + + const hasDurationLimit = isFiniteNumber(totalDurationMs) && totalDurationMs > 0; + const maxEndMs = hasDurationLimit ? Math.round(totalDurationMs) : null; + + const normalized: WebcamSizeRegion[] = []; + const usedIds = new Set(); + + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + + if (!raw || typeof raw !== "object") { + continue; + } + + const candidate = raw as Partial; + + const start = toIntegerMs(candidate.startMs); + const end = toIntegerMs(candidate.endMs); + + if (start === null || end === null) { + continue; + } + + let startMs = Math.max(0, start); + let endMs = Math.max(0, end); + + if (maxEndMs !== null) { + startMs = clamp(startMs, 0, maxEndMs); + endMs = clamp(endMs, 0, maxEndMs); + } + + if (endMs - startMs < WEBCAM_SIZE_REGION_MIN_DURATION_MS) { + continue; + } + + let id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : `webcam-size-${index + 1}`; + if (usedIds.has(id)) { + let suffix = index + 1; + let candidateId = `webcam-size-${suffix}`; + while (usedIds.has(candidateId)) { + suffix += 1; + candidateId = `webcam-size-${suffix}`; + } + id = candidateId; + } + usedIds.add(id); + const transitionInMs = clampWebcamSizeRegionTransitionMs(candidate.transitionInMs); + const transitionOutMs = clampWebcamSizeRegionTransitionMs(candidate.transitionOutMs); + const height = isFiniteNumber(candidate.height) + ? clampWebcamSizeRegionHeight(candidate.height, candidate.size) + : undefined; + + normalized.push({ + id, + startMs, + endMs, + size: clampWebcamSizeRegionSize(candidate.size), + ...(height !== undefined ? { height } : {}), + ...(transitionInMs !== undefined ? { transitionInMs } : {}), + ...(transitionOutMs !== undefined ? { transitionOutMs } : {}), + }); + } + + const sorted = normalized.sort((left, right) => { + if (left.startMs !== right.startMs) { + return left.startMs - right.startMs; + } + + return left.endMs - right.endMs; + }); + + // Resolve overlaps deterministically so preview and export always agree + // on a single active region per instant. Two phases: + // 1. Greedily keep regions, dropping any earlier one that would be left + // shorter than the minimum once clipped to a later region's start. + // Dropping re-checks the new tail, so a short middle region can't + // leave a false gap between its neighbours. + // 2. Clip every kept region to end where the next kept region begins. + const kept: WebcamSizeRegion[] = []; + for (const region of sorted) { + while (kept.length > 0) { + const previous = kept[kept.length - 1]; + if (region.startMs >= previous.endMs) { + break; + } + if (region.startMs - previous.startMs >= WEBCAM_SIZE_REGION_MIN_DURATION_MS) { + break; + } + kept.pop(); + } + kept.push(region); + } + + return kept.map((region, index) => { + const next = kept[index + 1]; + return next && region.endMs > next.startMs + ? { ...region, endMs: next.startMs } + : region; + }); +} + +export function getActiveWebcamSizeRegion( + regions: readonly WebcamSizeRegion[] | undefined, + timeMs: number, +): WebcamSizeRegion | null { + if (!regions?.length || !Number.isFinite(timeMs)) { + return null; + } + + const roundedTimeMs = Math.round(timeMs); + let active: WebcamSizeRegion | null = null; + + for (const region of regions) { + if (roundedTimeMs >= region.startMs && roundedTimeMs < region.endMs) { + if (!active || region.startMs >= active.startMs) { + active = region; + } + } + } + + return active; +} + +function rawWebcamSizeAt( + baseSize: number, + regions: readonly WebcamSizeRegion[] | undefined, + timeMs: number, +): number { + const activeRegion = getActiveWebcamSizeRegion(regions, timeMs); + if (!activeRegion) { + return clampWebcamSizeRegionSize(baseSize); + } + return clampWebcamSizeRegionSize(activeRegion.size); +} + +function getPreviousRegion( + regions: readonly WebcamSizeRegion[], + timeMs: number, +): WebcamSizeRegion | null { + let previous: WebcamSizeRegion | null = null; + for (const region of regions) { + if (region.endMs <= timeMs && (!previous || region.endMs >= previous.endMs)) { + previous = region; + } + } + return previous; +} + +function getNextRegion( + regions: readonly WebcamSizeRegion[], + timeMs: number, +): WebcamSizeRegion | null { + let next: WebcamSizeRegion | null = null; + for (const region of regions) { + if (region.startMs >= timeMs && (!next || region.startMs < next.startMs)) { + next = region; + } + } + return next; +} + +export function getWebcamSizeAtTime( + baseSize: number, + regions: readonly WebcamSizeRegion[] | undefined, + timeMs: number, +): number { + if (!Number.isFinite(timeMs)) { + return clampWebcamSizeRegionSize(baseSize); + } + + return rawWebcamSizeAt(baseSize, regions, Math.round(timeMs)); +} + +/** + * Deterministic size resolver used by preview and export. Transitions start + * before a region begins and finish after it ends; when neighboring transition + * windows overlap, the size blends directly between the two region sizes. + */ +export function getInterpolatedWebcamSizeAtTime( + baseSize: number, + regions: readonly WebcamSizeRegion[] | undefined, + timeMs: number, + defaults?: WebcamSizeTransitionDefaults, +): number { + const base = clampWebcamSizeRegionSize(baseSize); + if (!Number.isFinite(timeMs) || !regions?.length) { + return base; + } + + const roundedTimeMs = Math.round(timeMs); + const resolvedDefaults = resolveDefaults(defaults); + const activeRegion = getActiveWebcamSizeRegion(regions, roundedTimeMs); + const nextRegion = getNextRegion(regions, roundedTimeMs); + + if (activeRegion) { + if (nextRegion && nextRegion.startMs > roundedTimeMs) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeWebcamSizeTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return lerp( + clampWebcamSizeRegionSize(activeRegion.size), + clampWebcamSizeRegionSize(nextRegion.size), + progress, + ); + } + } + + return clampWebcamSizeRegionSize(activeRegion.size); + } + + const previousRegion = getPreviousRegion(regions, roundedTimeMs); + + if (previousRegion && nextRegion && previousRegion.endMs <= roundedTimeMs) { + const previousOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const nextInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const gapMs = nextRegion.startMs - previousRegion.endMs; + const transitionsOverlap = + gapMs >= 0 && gapMs <= previousOutMs + nextInMs && roundedTimeMs <= nextRegion.startMs; + + if (transitionsOverlap) { + if (gapMs <= 0) { + return clampWebcamSizeRegionSize(nextRegion.size); + } + + // Use the same blend origin as the active-region branch so the + // curve is continuous across previousRegion.endMs even when + // nextRegion's transition-in started before the previous region + // ended. Falls back to the plain gap blend when it didn't. + const blendStartMs = Math.min( + previousRegion.endMs, + nextRegion.startMs - nextInMs, + ); + const blendDurationMs = nextRegion.startMs - blendStartMs; + const progress = easeWebcamSizeTransition( + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), + ); + return lerp( + clampWebcamSizeRegionSize(previousRegion.size), + clampWebcamSizeRegionSize(nextRegion.size), + progress, + ); + } + } + + if (previousRegion) { + const transitionOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const transitionEndMs = previousRegion.endMs + transitionOutMs; + + if (transitionOutMs > 0 && roundedTimeMs <= transitionEndMs) { + const progress = easeWebcamSizeTransition( + (roundedTimeMs - previousRegion.endMs) / transitionOutMs, + ); + return lerp(clampWebcamSizeRegionSize(previousRegion.size), base, progress); + } + } + + if (nextRegion) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeWebcamSizeTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return lerp(base, clampWebcamSizeRegionSize(nextRegion.size), progress); + } + } + + return base; +} + +function getRegionDimensions(region: WebcamSizeRegion): WebcamSizeDimensions { + const size = clampWebcamSizeRegionSize(region.size); + return { + size, + height: clampWebcamSizeRegionHeight(region.height, size), + }; +} + +function lerpDimensions( + start: WebcamSizeDimensions, + end: WebcamSizeDimensions, + amount: number, +): WebcamSizeDimensions { + return { + size: lerp(start.size, end.size, amount), + height: lerp(start.height, end.height, amount), + }; +} + +export function getInterpolatedWebcamDimensionsAtTime( + baseSize: number, + baseHeight: number, + regions: readonly WebcamSizeRegion[] | undefined, + timeMs: number, + defaults?: WebcamSizeTransitionDefaults, +): WebcamSizeDimensions { + const base: WebcamSizeDimensions = { + size: clampWebcamSizeRegionSize(baseSize), + height: clampWebcamSizeRegionHeight(baseHeight, baseSize), + }; + if (!Number.isFinite(timeMs) || !regions?.length) { + return base; + } + + const roundedTimeMs = Math.round(timeMs); + const resolvedDefaults = resolveDefaults(defaults); + const activeRegion = getActiveWebcamSizeRegion(regions, roundedTimeMs); + const nextRegion = getNextRegion(regions, roundedTimeMs); + + if (activeRegion) { + const activeDimensions = getRegionDimensions(activeRegion); + if (nextRegion && nextRegion.startMs > roundedTimeMs) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeWebcamSizeTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return lerpDimensions(activeDimensions, getRegionDimensions(nextRegion), progress); + } + } + + return activeDimensions; + } + + const previousRegion = getPreviousRegion(regions, roundedTimeMs); + + if (previousRegion && nextRegion && previousRegion.endMs <= roundedTimeMs) { + const previousOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const nextInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const gapMs = nextRegion.startMs - previousRegion.endMs; + const transitionsOverlap = + gapMs >= 0 && gapMs <= previousOutMs + nextInMs && roundedTimeMs <= nextRegion.startMs; + + if (transitionsOverlap) { + if (gapMs <= 0) { + return getRegionDimensions(nextRegion); + } + + // Same continuous blend origin as getInterpolatedWebcamSizeAtTime. + const blendStartMs = Math.min( + previousRegion.endMs, + nextRegion.startMs - nextInMs, + ); + const blendDurationMs = nextRegion.startMs - blendStartMs; + const progress = easeWebcamSizeTransition( + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), + ); + return lerpDimensions( + getRegionDimensions(previousRegion), + getRegionDimensions(nextRegion), + progress, + ); + } + } + + if (previousRegion) { + const transitionOutMs = getTransitionOutMs(previousRegion, resolvedDefaults); + const transitionEndMs = previousRegion.endMs + transitionOutMs; + + if (transitionOutMs > 0 && roundedTimeMs <= transitionEndMs) { + const progress = easeWebcamSizeTransition( + (roundedTimeMs - previousRegion.endMs) / transitionOutMs, + ); + return lerpDimensions(getRegionDimensions(previousRegion), base, progress); + } + } + + if (nextRegion) { + const transitionInMs = getTransitionInMs(nextRegion, resolvedDefaults); + const transitionStartMs = nextRegion.startMs - transitionInMs; + + if (transitionInMs > 0 && roundedTimeMs >= transitionStartMs) { + const progress = easeWebcamSizeTransition( + (roundedTimeMs - transitionStartMs) / transitionInMs, + ); + return lerpDimensions(base, getRegionDimensions(nextRegion), progress); + } + } + + return base; +} + +export function getNextWebcamSizeRegionId(regions: readonly WebcamSizeRegion[]): string { + const usedNumbers = new Set(); + + for (const region of regions) { + const match = /^webcam-size-(\d+)$/.exec(region.id); + if (match) { + usedNumbers.add(Number(match[1])); + } + } + + let next = 1; + while (usedNumbers.has(next)) { + next += 1; + } + + return `webcam-size-${next}`; +} diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 4ebc12059..ffb5a7ce4 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -10,7 +10,10 @@ import type { CursorTelemetryPoint, Padding, SpeedRegion, + WebcamFocusRegion, WebcamOverlaySettings, + WebcamPositionRegion, + WebcamSizeRegion, ZoomMotionBlurTuning, ZoomRegion, ZoomTransitionEasing, @@ -50,10 +53,19 @@ import { type MotionBlurState, } from "@/components/video-editor/videoPlayback/zoomTransform"; import { - getWebcamCropSourceRect, + getInterpolatedFocusStateAtTime, + getWebcamFocusScreenTransform, + type WebcamFocusState, +} from "@/components/video-editor/webcamFocusRegions"; +import { + clampWebcamOverlayPosition, + getWebcamAvoidCursorPosition, + getWebcamCropDrawLayout, getWebcamOverlayPosition, getWebcamOverlaySizePx, } from "@/components/video-editor/webcamOverlay"; +import { getInterpolatedWebcamPositionAtTime } from "@/components/video-editor/webcamPositionRegions"; +import { getInterpolatedWebcamDimensionsAtTime } from "@/components/video-editor/webcamSizeRegions"; import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; import { @@ -108,6 +120,9 @@ interface FrameRenderConfig { cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; videoWidth: number; videoHeight: number; annotationRegions?: AnnotationRegion[]; @@ -283,6 +298,7 @@ export class FrameRenderer { private motionBlurState: MotionBlurState; private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; + private currentTimelineTimeMs = 0; private springScale: SpringState; private springX: SpringState; private springY: SpringState; @@ -1459,6 +1475,7 @@ export class FrameRenderer { } this.currentVideoTime = timestamp / 1000000; + this.currentTimelineTimeMs = Math.max(0, Math.round(backgroundTimelineTimestamp / 1000)); // Create or update video sprite from VideoFrame if (!this.videoSprite) { @@ -1512,7 +1529,7 @@ export class FrameRenderer { : null, ); - this.drawFrame(temporalSnapshot.sceneTransform); + this.drawFrame(this.getFocusSceneTransform(temporalSnapshot.sceneTransform)); if ( this.config.annotationRegions && @@ -1690,11 +1707,13 @@ export class FrameRenderer { this.compositeWithShadows(); // Draw device frame overlay on top of video content - this.drawFrame({ - scale: this.animationState.appliedScale, - x: this.animationState.x, - y: this.animationState.y, - }); + this.drawFrame( + this.getFocusSceneTransform({ + scale: this.animationState.appliedScale, + x: this.animationState.x, + y: this.animationState.y, + }), + ); // Render annotations on top if present if ( @@ -2102,6 +2121,7 @@ export class FrameRenderer { } this.currentVideoTime = timestamp / 1_000_000; + this.currentTimelineTimeMs = Math.max(0, Math.round(backgroundTimelineTimestamp / 1000)); if (this.webcamForwardFrameSource || this.webcamVideoElement) { await this.syncWebcamFrame(Math.max(0, this.currentVideoTime)); @@ -2242,6 +2262,7 @@ export class FrameRenderer { const ctx = this.compositeCtx; const w = this.compositeCanvas.width; const h = this.compositeCanvas.height; + const focusState = this.getCurrentWebcamFocusState(); // Clear composite canvas ctx.clearRect(0, 0, w, h); @@ -2264,6 +2285,35 @@ export class FrameRenderer { console.warn("[FrameRenderer] No background sprite found during compositing!"); } + const drawVideoLayer = (targetCtx: CanvasRenderingContext2D) => { + if (!focusState || focusState.progress <= 0.001) { + targetCtx.drawImage(videoCanvas, 0, 0, w, h); + return; + } + + if (focusState.screenMode === "hidden") { + targetCtx.save(); + targetCtx.globalAlpha = Math.max(0, Math.min(1, focusState.screenOpacity)); + targetCtx.drawImage(videoCanvas, 0, 0, w, h); + targetCtx.restore(); + return; + } + + const transform = getWebcamFocusScreenTransform({ + containerWidth: w, + containerHeight: h, + screenSizePercent: focusState.screenSize, + screenCorner: focusState.screenCorner, + margin: this.config.webcam?.margin ?? 24, + }); + targetCtx.save(); + targetCtx.globalAlpha = Math.max(0, Math.min(1, focusState.screenOpacity)); + targetCtx.translate(transform.x, transform.y); + targetCtx.scale(transform.scale, transform.scale); + targetCtx.drawImage(videoCanvas, 0, 0, w, h); + targetCtx.restore(); + }; + // Draw video layer with shadows on top of background if ( this.config.showShadow && @@ -2288,16 +2338,55 @@ export class FrameRenderer { const baseOffset = 12 * intensity; shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`; - shadowCtx.drawImage(videoCanvas, 0, 0, w, h); + drawVideoLayer(shadowCtx); shadowCtx.restore(); ctx.drawImage(this.shadowCanvas, 0, 0, w, h); } else { - ctx.drawImage(videoCanvas, 0, 0, w, h); + drawVideoLayer(ctx); } this.drawWebcamOverlay(ctx, w, h); } + private getCurrentWebcamFocusState(): WebcamFocusState | null { + const webcam = this.config.webcam; + if (!webcam?.enabled) { + return null; + } + + return getInterpolatedFocusStateAtTime( + webcam.size ?? 50, + this.config.webcamFocusRegions, + this.currentTimelineTimeMs, + webcam.corner ?? "bottom-right", + ); + } + + private getFocusSceneTransform(sceneTransform: { scale: number; x: number; y: number }): { + scale: number; + x: number; + y: number; + } { + const focusState = this.getCurrentWebcamFocusState(); + if (!focusState || focusState.progress <= 0.001 || focusState.screenMode === "hidden") { + return sceneTransform; + } + + const transform = getWebcamFocusScreenTransform({ + containerWidth: this.config.width, + containerHeight: this.config.height, + screenSizePercent: focusState.screenSize, + screenCorner: focusState.screenCorner, + margin: this.config.webcam?.margin ?? 24, + }); + + return { + scale: sceneTransform.scale * transform.scale, + x: transform.x + sceneTransform.x * transform.scale, + y: transform.y + sceneTransform.y * transform.scale, + }; + } + private drawFrame(sceneTransform?: { scale: number; x: number; y: number }): void { if ((!this.frameImage && !this.frameDraw) || !this.compositeCtx || !this.layoutCache) return; @@ -2306,8 +2395,16 @@ export class FrameRenderer { const maskRect = this.layoutCache.maskRect; const insets = this.frameInsets; const transform = sceneTransform ?? { scale: 1, x: 0, y: 0 }; + const focusState = this.getCurrentWebcamFocusState(); + const screenOpacity = + focusState?.screenMode === "hidden" + ? Math.max(0, Math.min(1, focusState.screenOpacity)) + : 1; + if (screenOpacity <= 0.001) return; + const drawWithTransform = (draw: () => void) => { ctx.save(); + ctx.globalAlpha *= screenOpacity; applyCanvasSceneTransform(ctx, transform); draw(); ctx.restore(); @@ -2389,31 +2486,118 @@ export class FrameRenderer { } const margin = webcam.margin ?? 24; - const size = getWebcamOverlaySizePx({ + const focusState = this.getCurrentWebcamFocusState(); + const regionalDimensionsPercent = getInterpolatedWebcamDimensionsAtTime( + webcam.size ?? 50, + webcam.height ?? webcam.size ?? 50, + this.config.webcamSizeRegions, + this.currentTimelineTimeMs, + ); + const effectiveWidthPercent = focusState?.webcamSize ?? regionalDimensionsPercent.size; + const effectiveHeightPercent = focusState?.webcamSize ?? regionalDimensionsPercent.height; + const bubbleWidth = getWebcamOverlaySizePx({ + containerWidth: width, + containerHeight: height, + sizePercent: effectiveWidthPercent, + margin, + zoomScale: focusState ? 1 : this.animationState.appliedScale || 1, + reactToZoom: focusState ? false : (webcam.reactToZoom ?? true), + }); + const bubbleHeight = getWebcamOverlaySizePx({ + containerWidth: width, + containerHeight: height, + sizePercent: effectiveHeightPercent, + margin, + zoomScale: focusState ? 1 : this.animationState.appliedScale || 1, + reactToZoom: focusState ? false : (webcam.reactToZoom ?? true), + }); + const normalWidth = getWebcamOverlaySizePx({ containerWidth: width, containerHeight: height, - sizePercent: webcam.size ?? 50, + sizePercent: regionalDimensionsPercent.size, margin, zoomScale: this.animationState.appliedScale || 1, reactToZoom: webcam.reactToZoom ?? true, }); - const { x, y } = getWebcamOverlayPosition({ + const normalHeight = getWebcamOverlaySizePx({ containerWidth: width, containerHeight: height, - size, + sizePercent: regionalDimensionsPercent.height, margin, - positionPreset: webcam.positionPreset ?? webcam.corner, - positionX: webcam.positionX ?? 1, - positionY: webcam.positionY ?? 1, + zoomScale: this.animationState.appliedScale || 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const interpolatedWebcamPosition = getInterpolatedWebcamPositionAtTime( + { positionX: webcam.positionX ?? 1, positionY: webcam.positionY ?? 1 }, + this.config.webcamPositionRegions, + this.currentTimelineTimeMs, + ); + const hasActiveWebcamPositionRegion = (this.config.webcamPositionRegions ?? []).length > 0; + const normalPosition = getWebcamOverlayPosition({ + containerWidth: width, + containerHeight: height, + size: normalWidth, + height: normalHeight, + margin, + positionPreset: hasActiveWebcamPositionRegion + ? "custom" + : (webcam.positionPreset ?? webcam.corner), + positionX: interpolatedWebcamPosition.positionX, + positionY: interpolatedWebcamPosition.positionY, legacyCorner: webcam.corner, }); + const focusPosition = { + x: (width - bubbleWidth) / 2, + y: (height - bubbleHeight) / 2, + }; + const focusProgress = focusState?.progress ?? 0; + const blendedPosition = clampWebcamOverlayPosition({ + containerWidth: width, + containerHeight: height, + size: bubbleWidth, + height: bubbleHeight, + margin, + position: { + x: normalPosition.x + (focusPosition.x - normalPosition.x) * focusProgress, + y: normalPosition.y + (focusPosition.y - normalPosition.y) * focusProgress, + }, + }); + let x = blendedPosition.x; + let y = blendedPosition.y; + if (webcam.avoidCursor && (!focusState || focusState.progress <= 0.001)) { + const cursor = this.getCursorPosition(this.currentTimelineTimeMs); + const avoidedPosition = getWebcamAvoidCursorPosition({ + containerWidth: width, + containerHeight: height, + size: bubbleWidth, + height: bubbleHeight, + margin, + currentPosition: { x, y }, + cursor: cursor ? { x: cursor.cx * width, y: cursor.cy * height } : null, + legacyCorner: webcam.corner, + }); + const clampedAvoidedPosition = clampWebcamOverlayPosition({ + containerWidth: width, + containerHeight: height, + size: bubbleWidth, + height: bubbleHeight, + margin, + position: avoidedPosition, + }); + x = clampedAvoidedPosition.x; + y = clampedAvoidedPosition.y; + } const radius = Math.max(0, webcam.cornerRadius ?? 18); const bubbleCanvas = this.webcamBubbleCanvas ?? document.createElement("canvas"); - const bubbleSize = Math.max(1, Math.ceil(size)); - if (bubbleCanvas.width !== bubbleSize || bubbleCanvas.height !== bubbleSize) { - bubbleCanvas.width = bubbleSize; - bubbleCanvas.height = bubbleSize; + const bubbleCanvasWidth = Math.max(1, Math.ceil(bubbleWidth)); + const bubbleCanvasHeight = Math.max(1, Math.ceil(bubbleHeight)); + if ( + bubbleCanvas.width !== bubbleCanvasWidth || + bubbleCanvas.height !== bubbleCanvasHeight + ) { + bubbleCanvas.width = bubbleCanvasWidth; + bubbleCanvas.height = bubbleCanvasHeight; } this.webcamBubbleCanvas = bubbleCanvas; const bubbleCtx = @@ -2484,68 +2668,72 @@ export class FrameRenderer { ? webcamFrameSource.displayWidth : "videoWidth" in webcamFrameSource ? webcamFrameSource.videoWidth - : webcamFrameSource.width) || size; + : webcamFrameSource.width) || bubbleWidth; const sourceHeight = ("displayHeight" in webcamFrameSource ? webcamFrameSource.displayHeight : "videoHeight" in webcamFrameSource ? webcamFrameSource.videoHeight - : webcamFrameSource.height) || size; - const { sx, sy, sw, sh } = getWebcamCropSourceRect( - webcam.cropRegion, + : webcamFrameSource.height) || bubbleHeight; + const cropLayout = getWebcamCropDrawLayout({ + cropRegion: webcam.cropRegion, sourceWidth, sourceHeight, - ); - const coverScale = Math.max(size / sw, size / sh); - const drawWidth = sw * coverScale; - const drawHeight = sh * coverScale; - const drawX = (size - drawWidth) / 2; - const drawY = (size - drawHeight) / 2; + targetWidth: bubbleWidth, + targetHeight: bubbleHeight, + }); bubbleCtx.save(); - drawSquircleOnCanvas(bubbleCtx, { x: 0, y: 0, width: size, height: size, radius }); + drawSquircleOnCanvas(bubbleCtx, { + x: 0, + y: 0, + width: bubbleWidth, + height: bubbleHeight, + radius, + }); bubbleCtx.clip(); if (webcam.mirror) { bubbleCtx.save(); - bubbleCtx.translate(size, 0); + bubbleCtx.translate(bubbleWidth, 0); bubbleCtx.scale(-1, 1); bubbleCtx.drawImage( webcamFrameSource, - sx, - sy, - sw, - sh, - drawX, - drawY, - drawWidth, - drawHeight, + cropLayout.sx, + cropLayout.sy, + cropLayout.sw, + cropLayout.sh, + cropLayout.drawX, + cropLayout.drawY, + cropLayout.drawWidth, + cropLayout.drawHeight, ); bubbleCtx.restore(); } else { bubbleCtx.drawImage( webcamFrameSource, - sx, - sy, - sw, - sh, - drawX, - drawY, - drawWidth, - drawHeight, + cropLayout.sx, + cropLayout.sy, + cropLayout.sw, + cropLayout.sh, + cropLayout.drawX, + cropLayout.drawY, + cropLayout.drawWidth, + cropLayout.drawHeight, ); } bubbleCtx.restore(); if ((webcam.shadow ?? 0) > 0) { const shadow = Math.max(0, Math.min(1, webcam.shadow)); + const shadowBasis = Math.max(bubbleWidth, bubbleHeight); ctx.save(); - ctx.filter = `drop-shadow(0 ${Math.round(size * 0.06)}px ${Math.round(size * 0.22)}px rgba(0,0,0,${shadow}))`; - ctx.drawImage(bubbleCanvas, x, y, size, size); + ctx.filter = `drop-shadow(0 ${Math.round(shadowBasis * 0.06)}px ${Math.round(shadowBasis * 0.22)}px rgba(0,0,0,${shadow}))`; + ctx.drawImage(bubbleCanvas, x, y, bubbleWidth, bubbleHeight); ctx.restore(); return; } - ctx.drawImage(bubbleCanvas, x, y, size, size); + ctx.drawImage(bubbleCanvas, x, y, bubbleWidth, bubbleHeight); } private closeWebcamDecodedFrame(): void { diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts index 4c8338529..6753449de 100644 --- a/src/lib/exporter/gifExporter.ts +++ b/src/lib/exporter/gifExporter.ts @@ -9,7 +9,10 @@ import type { Padding, SpeedRegion, TrimRegion, + WebcamFocusRegion, WebcamOverlaySettings, + WebcamPositionRegion, + WebcamSizeRegion, ZoomMotionBlurTuning, ZoomRegion, ZoomTransitionEasing, @@ -62,6 +65,9 @@ interface GifExporterConfig { cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; annotationRegions?: AnnotationRegion[]; autoCaptions?: CaptionCue[]; autoCaptionSettings?: AutoCaptionSettings; @@ -187,6 +193,9 @@ export class GifExporter { cropRegion: this.config.cropRegion, webcam: this.config.webcam, webcamUrl: this.config.webcamUrl, + webcamSizeRegions: this.config.webcamSizeRegions, + webcamFocusRegions: this.config.webcamFocusRegions, + webcamPositionRegions: this.config.webcamPositionRegions, videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions, diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 5372937fd..581e03bc5 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -20,7 +20,10 @@ import type { CursorTelemetryPoint, Padding, SpeedRegion, + WebcamFocusRegion, WebcamOverlaySettings, + WebcamPositionRegion, + WebcamSizeRegion, ZoomMotionBlurTuning, ZoomRegion, ZoomTransitionEasing, @@ -56,11 +59,21 @@ import { type MotionBlurState, } from "@/components/video-editor/videoPlayback/zoomTransform"; import { + getInterpolatedFocusStateAtTime, + getWebcamFocusScreenTransform, + type WebcamFocusState, +} from "@/components/video-editor/webcamFocusRegions"; +import { + clampWebcamOverlayPosition, + getWebcamAvoidCursorPosition, + getWebcamCropDrawLayout, getWebcamCropSourceRect, getWebcamOverlayPosition, getWebcamOverlaySizePx, isWebcamCropRegionDefault, } from "@/components/video-editor/webcamOverlay"; +import { getInterpolatedWebcamPositionAtTime } from "@/components/video-editor/webcamPositionRegions"; +import { getInterpolatedWebcamDimensionsAtTime } from "@/components/video-editor/webcamSizeRegions"; import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath"; import { extensionHost } from "@/lib/extensions"; import { @@ -126,6 +139,9 @@ interface FrameRenderConfig { cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; videoWidth: number; videoHeight: number; annotationRegions?: AnnotationRegion[]; @@ -206,6 +222,7 @@ interface WebcamLayoutCache { sourceWidth: number; sourceHeight: number; size: number; + height: number; positionX: number; positionY: number; radius: number; @@ -382,6 +399,33 @@ function applyCoverLayoutToSprite( sprite.scale.set(coverScale * (mirror ? -1 : 1), coverScale); } +function applyWebcamRevealLayoutToSprite( + sprite: Sprite, + sourceWidth: number, + sourceHeight: number, + targetWidth: number, + targetHeight: number, + mirror = false, +): void { + const layout = getWebcamCropDrawLayout({ + sourceWidth, + sourceHeight, + targetWidth, + targetHeight, + }); + const scale = layout.drawWidth / Math.max(1, sourceWidth); + + sprite.anchor.set(0); + if (mirror) { + sprite.position.set(layout.drawX + layout.drawWidth, layout.drawY); + sprite.scale.set(-scale, scale); + return; + } + + sprite.position.set(layout.drawX, layout.drawY); + sprite.scale.set(scale, scale); +} + function clampUnitInterval(value: number): number { return Math.min(1, Math.max(0, value)); } @@ -475,6 +519,7 @@ export class FrameRenderer { private lastContentTimeMs: number | null = null; private layoutCache: LayoutCache | null = null; private currentVideoTime = 0; + private currentTimelineTimeMs = 0; private cursorOverlay: PixiCursorOverlay | null = null; private lastSyncedWebcamTime: number | null = null; private webcamRenderMode: "hidden" | "live" | "cached" = "hidden"; @@ -2588,6 +2633,7 @@ export class FrameRenderer { areNearlyEqual(previousLayout.sourceWidth, nextLayout.sourceWidth) && areNearlyEqual(previousLayout.sourceHeight, nextLayout.sourceHeight) && areNearlyEqual(previousLayout.size, nextLayout.size) && + areNearlyEqual(previousLayout.height, nextLayout.height) && areNearlyEqual(previousLayout.positionX, nextLayout.positionX) && areNearlyEqual(previousLayout.positionY, nextLayout.positionY) && areNearlyEqual(previousLayout.radius, nextLayout.radius) && @@ -2602,14 +2648,12 @@ export class FrameRenderer { this.webcamRootContainer.position.set(nextLayout.positionX, nextLayout.positionY); - applyCoverLayoutToSprite( + applyWebcamRevealLayoutToSprite( this.webcamSprite, nextLayout.sourceWidth, nextLayout.sourceHeight, nextLayout.size, - nextLayout.size, - nextLayout.size / 2, - nextLayout.size / 2, + nextLayout.height, nextLayout.mirror, ); @@ -2618,7 +2662,7 @@ export class FrameRenderer { x: 0, y: 0, width: nextLayout.size, - height: nextLayout.size, + height: nextLayout.height, radius: nextLayout.radius, }); this.webcamMaskGraphics.fill({ color: 0xffffff }); @@ -2629,16 +2673,17 @@ export class FrameRenderer { continue; } - const offsetY = nextLayout.size * layer.offsetScale * nextLayout.shadowStrength; + const shadowBasis = Math.max(nextLayout.size, nextLayout.height); + const offsetY = shadowBasis * layer.offsetScale * nextLayout.shadowStrength; this.rasterizeShadowLayer(layer, { x: 0, y: 0, width: nextLayout.size, - height: nextLayout.size, + height: nextLayout.height, radius: nextLayout.radius, offsetY, alpha: layer.alphaScale * nextLayout.shadowStrength, - blur: Math.max(0, nextLayout.size * layer.blurScale * nextLayout.shadowStrength), + blur: Math.max(0, shadowBasis * layer.blurScale * nextLayout.shadowStrength), }); } @@ -2828,6 +2873,50 @@ export class FrameRenderer { } } + private getCurrentWebcamFocusState(): WebcamFocusState | null { + const webcam = this.config.webcam; + if (!webcam?.enabled) { + return null; + } + + return getInterpolatedFocusStateAtTime( + webcam.size ?? 50, + this.config.webcamFocusRegions, + this.currentTimelineTimeMs, + webcam.corner ?? "bottom-right", + ); + } + + private applyFocusScreenTransform(): void { + if (!this.cameraContainer) { + return; + } + + const focusState = this.getCurrentWebcamFocusState(); + if (!focusState || focusState.progress <= 0.001) { + this.cameraContainer.alpha = 1; + return; + } + + this.cameraContainer.alpha = Math.max(0, Math.min(1, focusState.screenOpacity)); + if (focusState.screenMode === "hidden") { + return; + } + + const transform = getWebcamFocusScreenTransform({ + containerWidth: this.config.width, + containerHeight: this.config.height, + screenSizePercent: focusState.screenSize, + screenCorner: focusState.screenCorner, + margin: this.config.webcam?.margin ?? 24, + }); + this.cameraContainer.scale.set(this.animationState.appliedScale * transform.scale); + this.cameraContainer.position.set( + transform.x + this.animationState.x * transform.scale, + transform.y + this.animationState.y * transform.scale, + ); + } + private updateWebcamOverlay(referenceTimeSeconds = this.currentVideoTime): void { const webcam = this.config.webcam; if (!webcam?.enabled || !this.webcamRootContainer || !this.webcamMaskGraphics) { @@ -2887,24 +2976,107 @@ export class FrameRenderer { } const margin = webcam.margin ?? 24; + const focusState = this.getCurrentWebcamFocusState(); + const regionalDimensionsPercent = getInterpolatedWebcamDimensionsAtTime( + webcam.size ?? 50, + webcam.height ?? webcam.size ?? 50, + this.config.webcamSizeRegions, + this.currentTimelineTimeMs, + ); + const effectiveWidthPercent = focusState?.webcamSize ?? regionalDimensionsPercent.size; + const effectiveHeightPercent = focusState?.webcamSize ?? regionalDimensionsPercent.height; const size = getWebcamOverlaySizePx({ containerWidth: this.config.width, containerHeight: this.config.height, - sizePercent: webcam.size ?? 50, + sizePercent: effectiveWidthPercent, + margin, + zoomScale: focusState ? 1 : this.animationState.appliedScale || 1, + reactToZoom: focusState ? false : (webcam.reactToZoom ?? true), + }); + const webcamHeight = getWebcamOverlaySizePx({ + containerWidth: this.config.width, + containerHeight: this.config.height, + sizePercent: effectiveHeightPercent, + margin, + zoomScale: focusState ? 1 : this.animationState.appliedScale || 1, + reactToZoom: focusState ? false : (webcam.reactToZoom ?? true), + }); + const normalSize = getWebcamOverlaySizePx({ + containerWidth: this.config.width, + containerHeight: this.config.height, + sizePercent: regionalDimensionsPercent.size, margin, zoomScale: this.animationState.appliedScale || 1, reactToZoom: webcam.reactToZoom ?? true, }); - const position = getWebcamOverlayPosition({ + const normalHeight = getWebcamOverlaySizePx({ containerWidth: this.config.width, containerHeight: this.config.height, - size, + sizePercent: regionalDimensionsPercent.height, margin, - positionPreset: webcam.positionPreset ?? webcam.corner, - positionX: webcam.positionX ?? 1, - positionY: webcam.positionY ?? 1, + zoomScale: this.animationState.appliedScale || 1, + reactToZoom: webcam.reactToZoom ?? true, + }); + const interpolatedWebcamPosition = getInterpolatedWebcamPositionAtTime( + { positionX: webcam.positionX ?? 1, positionY: webcam.positionY ?? 1 }, + this.config.webcamPositionRegions, + this.currentTimelineTimeMs, + ); + const hasActiveWebcamPositionRegion = (this.config.webcamPositionRegions ?? []).length > 0; + const normalPosition = getWebcamOverlayPosition({ + containerWidth: this.config.width, + containerHeight: this.config.height, + size: normalSize, + height: normalHeight, + margin, + positionPreset: hasActiveWebcamPositionRegion + ? "custom" + : (webcam.positionPreset ?? webcam.corner), + positionX: interpolatedWebcamPosition.positionX, + positionY: interpolatedWebcamPosition.positionY, legacyCorner: webcam.corner, }); + const focusPosition = { + x: (this.config.width - size) / 2, + y: (this.config.height - webcamHeight) / 2, + }; + const focusProgress = focusState?.progress ?? 0; + const position = clampWebcamOverlayPosition({ + containerWidth: this.config.width, + containerHeight: this.config.height, + size, + height: webcamHeight, + margin, + position: { + x: normalPosition.x + (focusPosition.x - normalPosition.x) * focusProgress, + y: normalPosition.y + (focusPosition.y - normalPosition.y) * focusProgress, + }, + }); + if (webcam.avoidCursor && (!focusState || focusState.progress <= 0.001)) { + const cursor = this.getCursorPosition(this.currentTimelineTimeMs); + const avoidedPosition = getWebcamAvoidCursorPosition({ + containerWidth: this.config.width, + containerHeight: this.config.height, + size, + height: webcamHeight, + margin, + currentPosition: position, + cursor: cursor + ? { x: cursor.cx * this.config.width, y: cursor.cy * this.config.height } + : null, + legacyCorner: webcam.corner, + }); + const clampedAvoidedPosition = clampWebcamOverlayPosition({ + containerWidth: this.config.width, + containerHeight: this.config.height, + size, + height: webcamHeight, + margin, + position: avoidedPosition, + }); + position.x = clampedAvoidedPosition.x; + position.y = clampedAvoidedPosition.y; + } const radius = Math.max(0, webcam.cornerRadius ?? 18); const shadowStrength = clampUnitInterval(webcam.shadow ?? 0); @@ -2914,6 +3086,7 @@ export class FrameRenderer { sourceWidth: renderableWebcamSource.width, sourceHeight: renderableWebcamSource.height, size, + height: webcamHeight, positionX: position.x, positionY: position.y, radius, @@ -2940,6 +3113,7 @@ export class FrameRenderer { } this.currentVideoTime = timestamp / 1_000_000; + this.currentTimelineTimeMs = Math.max(0, Math.round(backgroundTimelineTimestamp / 1000)); const webcamRenderTimeSeconds = Math.max( 0, webcamTimeSecondsOverride ?? this.currentVideoTime, @@ -2989,6 +3163,7 @@ export class FrameRenderer { motionBlurState: this.motionBlurState, frameTimeMs: timeMs, }); + this.applyFocusScreenTransform(); if (includeOverlayLayers) { this.updateAnnotationLayer(timeMs); @@ -3132,6 +3307,7 @@ export class FrameRenderer { } this.currentVideoTime = timestamp / 1_000_000; + this.currentTimelineTimeMs = Math.max(0, Math.round(backgroundTimelineTimestamp / 1000)); const resolvedVideoSource = await this.resolveDetachedVideoFrameSource( videoFrame, @@ -3238,6 +3414,7 @@ export class FrameRenderer { motionBlurState: this.motionBlurState, frameTimeMs: timeMs, }); + this.applyFocusScreenTransform(); this.updateAnnotationLayer(timeMs); this.updateCaptionLayer(timeMs); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 679c9a9da..ecb019bf4 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -11,7 +11,10 @@ import type { SpeedRegion, SourceAudioTrackSettings, TrimRegion, + WebcamFocusRegion, WebcamOverlaySettings, + WebcamPositionRegion, + WebcamSizeRegion, ZoomMotionBlurTuning, ZoomRegion, ZoomTransitionEasing, @@ -115,6 +118,9 @@ interface VideoExporterConfig extends ExportConfig { cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; annotationRegions?: AnnotationRegion[]; autoCaptions?: CaptionCue[]; autoCaptionSettings?: AutoCaptionSettings; @@ -593,6 +599,9 @@ export class ModernVideoExporter { cropRegion: this.config.cropRegion, webcam: this.config.webcam, webcamUrl: this.config.webcamUrl, + webcamSizeRegions: this.config.webcamSizeRegions, + webcamFocusRegions: this.config.webcamFocusRegions, + webcamPositionRegions: this.config.webcamPositionRegions, videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions, @@ -1512,6 +1521,22 @@ export class ModernVideoExporter { if (this.config.webcam?.enabled && !this.getNativeWebcamSourcePath()) { reasons.push("unsupported-webcam-source"); } + if ((this.config.webcamSizeRegions ?? []).length > 0) { + reasons.push("unsupported-webcam-size-regions"); + } + if ((this.config.webcamFocusRegions ?? []).length > 0) { + reasons.push("unsupported-webcam-focus-regions"); + } + if (this.config.webcam?.enabled && this.config.webcam.avoidCursor) { + reasons.push("unsupported-webcam-avoid-cursor"); + } + if ( + this.config.webcam?.enabled && + Math.round(this.config.webcam.height ?? this.config.webcam.size ?? 40) !== + Math.round(this.config.webcam.size ?? 40) + ) { + reasons.push("unsupported-webcam-vertical-stretch"); + } if (this.config.frame) { reasons.push("unsupported-frame-overlay"); @@ -1966,6 +1991,9 @@ export class ModernVideoExporter { if (!inputPath) { return null; } + if (Math.round(webcam.height ?? webcam.size ?? 40) !== Math.round(webcam.size ?? 40)) { + return null; + } const margin = webcam.margin ?? 24; const rawSize = getWebcamOverlaySizePx({ diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index b99f5f96c..538ac778f 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -8,10 +8,13 @@ import type { CursorStyle, CursorTelemetryPoint, Padding, - SpeedRegion, SourceAudioTrackSettings, + SpeedRegion, TrimRegion, + WebcamFocusRegion, WebcamOverlaySettings, + WebcamPositionRegion, + WebcamSizeRegion, ZoomMotionBlurTuning, ZoomRegion, ZoomTransitionEasing, @@ -71,6 +74,9 @@ interface VideoExporterConfig extends ExportConfig { cropRegion: CropRegion; webcam?: WebcamOverlaySettings; webcamUrl?: string | null; + webcamSizeRegions?: WebcamSizeRegion[]; + webcamFocusRegions?: WebcamFocusRegion[]; + webcamPositionRegions?: WebcamPositionRegion[]; annotationRegions?: AnnotationRegion[]; autoCaptions?: CaptionCue[]; autoCaptionSettings?: AutoCaptionSettings; @@ -241,6 +247,9 @@ export class VideoExporter { cropRegion: this.config.cropRegion, webcam: this.config.webcam, webcamUrl: this.config.webcamUrl, + webcamSizeRegions: this.config.webcamSizeRegions, + webcamFocusRegions: this.config.webcamFocusRegions, + webcamPositionRegions: this.config.webcamPositionRegions, videoWidth: videoInfo.width, videoHeight: videoInfo.height, annotationRegions: this.config.annotationRegions,