From 9c23f439910612eded2d673200ba4cb9e58ec966 Mon Sep 17 00:00:00 2001 From: reedaaz Date: Sat, 16 May 2026 14:48:00 +0200 Subject: [PATCH 1/7] @ feat(video-editor): webcam camera regions (size/focus/position) + timeline UX Camera-as-timeline-regions feature set: - Webcam size & focus regions (deterministic interpolation, preview+export). - New webcam position regions: drag camera in preview to create/update an animated position region at the playhead; snaps to corners/center; reuses the region under the playhead; copies an active size region span. - Fullscreen webcam button (focus region focusSize=100, screen hidden). - webcamPositionEnabled gate (on by default, fully reversible). - Timeline: dedicated rows, select/drag/resize/delete, keyboard delete, fit-to-height (no vertical scroll, all tracks visible), draggable height bar. - Backward-compatible persistence (optional arrays, no migration). - Threaded through all 5 export paths; stable empty-array ref to avoid a preview play/pause render loop. Tests: webcam region suites 45/45 green; tsc clean; no new suite failures (4 pre-existing unrelated failures untouched by design). Co-Authored-By: Claude Opus 4.7 @ --- .gitignore | 1 + package-lock.json | 17 +- .../launch/hooks/useWebcamPreviewOverlay.ts | 256 ++-- src/components/video-editor/SettingsPanel.tsx | 1047 +++++++++++++++-- src/components/video-editor/VideoEditor.tsx | 1027 ++++++++++++++-- src/components/video-editor/VideoPlayback.tsx | 878 +++++++++++++- .../video-editor/projectPersistence.ts | 62 +- src/components/video-editor/timeline/Item.tsx | 55 +- src/components/video-editor/timeline/Row.tsx | 4 +- .../video-editor/timeline/TimelineEditor.tsx | 119 +- .../components/viewport/TimelineCanvas.tsx | 324 +++-- .../video-editor/timeline/core/constants.ts | 3 + .../timeline/core/timelineTypes.ts | 18 +- .../timeline/hooks/useTimelineDndBindings.ts | 90 +- .../hooks/useTimelineEditorRuntime.ts | 159 ++- .../hooks/useTimelineKeyboardShortcuts.ts | 29 +- .../hooks/useTimelineNormalization.ts | 67 +- .../timeline/hooks/useTimelineSelection.ts | 110 +- .../hooks/utils/timelineSelectionUtils.ts | 12 + .../timeline/model/timelineModel.ts | 107 +- src/components/video-editor/types.ts | 54 +- .../video-editor/webcamFocusRegions.test.ts | 179 +++ .../video-editor/webcamFocusRegions.ts | 471 ++++++++ .../video-editor/webcamOverlay.test.ts | 125 ++ src/components/video-editor/webcamOverlay.ts | 216 +++- .../webcamPositionRegions.test.ts | 211 ++++ .../video-editor/webcamPositionRegions.ts | 366 ++++++ .../video-editor/webcamSizeRegions.test.ts | 226 ++++ .../video-editor/webcamSizeRegions.ts | 478 ++++++++ src/lib/exporter/frameRenderer.ts | 290 ++++- src/lib/exporter/gifExporter.ts | 9 + src/lib/exporter/modernFrameRenderer.ts | 205 +++- src/lib/exporter/modernVideoExporter.ts | 28 + src/lib/exporter/videoExporter.ts | 11 +- 34 files changed, 6664 insertions(+), 590 deletions(-) create mode 100644 src/components/video-editor/webcamFocusRegions.test.ts create mode 100644 src/components/video-editor/webcamFocusRegions.ts create mode 100644 src/components/video-editor/webcamPositionRegions.test.ts create mode 100644 src/components/video-editor/webcamPositionRegions.ts create mode 100644 src/components/video-editor/webcamSizeRegions.test.ts create mode 100644 src/components/video-editor/webcamSizeRegions.ts 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,600 @@ 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 playheadMs = Math.max( + 0, + Math.min(timelineDurationMs, Math.round((timelinePlayheadTime ?? currentTime) * 1000)), + ); + const defaultDurationMs = 3000; + const minDurationMs = WEBCAM_SIZE_REGION_MIN_DURATION_MS; + + 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; + } + + 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); + }); + }, [currentTime, timelineDurationMs, timelinePlayheadTime, 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 playheadMs = Math.max( + 0, + Math.min( + timelineDurationMs, + Math.round((timelinePlayheadTime ?? currentTime) * 1000), + ), + ); + const defaultDurationMs = 3000; + const minDurationMs = WEBCAM_FOCUS_REGION_MIN_DURATION_MS; + 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; + } + + 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); + }); + }, + [currentTime, timelineDurationMs, timelinePlayheadTime], + ); + + 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 { + const defaultDurationMs = 3000; + const latestStartMs = Math.max(0, timelineDurationMs - minDurationMs); + startMs = Math.min(playheadMs, latestStartMs); + endMs = Math.min( + timelineDurationMs, + Math.max(startMs + minDurationMs, startMs + defaultDurationMs), + ); + } + 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 +4135,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 +4552,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 +4612,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 +5002,9 @@ export default function VideoEditor() { webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, annotationRegions, autoCaptions, autoCaptionSettings, @@ -4440,6 +5177,9 @@ export default function VideoEditor() { webcamUrl: resolvedWebcamVideoUrl ?? (webcam.sourcePath ? toFileUrl(webcam.sourcePath) : null), + webcamSizeRegions, + webcamFocusRegions, + webcamPositionRegions, annotationRegions, autoCaptions, autoCaptionSettings, @@ -5798,19 +6538,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 +6560,9 @@ export default function VideoEditor() { onClipDelete={handleClipDelete} hasClipSourceAudio={hasClipSourceAudio} sourceAudioTrackMeta={audio.sourceAudioTrackMeta} - sourceAudioTrackSettings={audio.selectedClipSourceAudioTrackSettings} + sourceAudioTrackSettings={ + audio.selectedClipSourceAudioTrackSettings + } onSourceAudioTrackVolumeChange={ audio.onSelectedClipSourceAudioTrackVolumeChange } @@ -5827,21 +6570,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 +6670,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 +6879,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 +6979,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 +7224,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..6f90a31e5 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -31,7 +31,7 @@ export default function Row({ return (
{label && ( @@ -49,7 +49,7 @@ export default function Row({ )}
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..f8fe81721 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) => ( ))} + + + {webcamSizeItems.map((item) => ( + + {item.label} + + ))} + + + {webcamFocusItems.map((item) => ( + + {item.label} + + ))} + + + {webcamPositionItems.map((item) => ( + + {item.label} + + ))} + ); }); @@ -523,10 +644,16 @@ export default function TimelineCanvas({ onSelectClip, onSelectAnnotation, onSelectAudio, + onSelectWebcamSize, + onSelectWebcamFocus, + onSelectWebcamPosition, selectedZoomId, selectedClipId, selectedAnnotationId, selectedAudioId, + selectedWebcamSizeRegionId, + selectedWebcamFocusRegionId, + selectedWebcamPositionRegionId, selectAllBlocksActive = false, onClearBlockSelection, keyframes = [], @@ -564,6 +691,8 @@ export default function TimelineCanvas({ onSelectClip?.(null); onSelectAnnotation?.(null); onSelectAudio?.(null); + onSelectWebcamSize?.(null); + onSelectWebcamFocus?.(null); } const rect = e.currentTarget.getBoundingClientRect(); @@ -584,6 +713,7 @@ export default function TimelineCanvas({ onSelectAnnotation, onSelectAudio, onClearBlockSelection, + onSelectWebcamFocus, videoDurationMs, sidebarWidth, direction, @@ -606,7 +736,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 +749,8 @@ export default function TimelineCanvas({ onSelectClip?.(null); onSelectAnnotation?.(null); onSelectAudio?.(null); + onSelectWebcamSize?.(null); + onSelectWebcamFocus?.(null); } const rect = localTimelineRef.current.getBoundingClientRect(); @@ -633,6 +766,7 @@ export default function TimelineCanvas({ onSelectAudio, onSelectClip, onSelectZoom, + onSelectWebcamFocus, videoDurationMs, ], ); @@ -642,7 +776,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 +815,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 +847,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 +869,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..287f814db --- /dev/null +++ b/src/components/video-editor/webcamFocusRegions.ts @@ -0,0 +1,471 @@ +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, 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[] = []; + + 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); + + normalized.push({ + id: + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : `webcam-focus-${index + 1}`, + 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 } : {}), + }); + } + + return normalized.sort((left, right) => { + if (left.startMs !== right.startMs) return left.startMs - right.startMs; + return left.endMs - right.endMs; + }); +} + +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 + ) { + const progress = + gapMs <= 0 + ? 1 + : easeFocusTransition((roundedTimeMs - previousRegion.endMs) / gapMs); + 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..fd298466a --- /dev/null +++ b/src/components/video-editor/webcamPositionRegions.test.ts @@ -0,0 +1,211 @@ +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 regions by start and end", () => { + 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: 1_000, endMs: 1_500, positionX: 0.5, positionY: 0.5 }, + ]); + + expect(normalized.map((region) => region.id)).toEqual(["c", "a", "b"]); + }); + + 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..f6178950b --- /dev/null +++ b/src/components/video-editor/webcamPositionRegions.ts @@ -0,0 +1,366 @@ +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, 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[] = []; + + 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; + } + + const id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : `webcam-position-${index + 1}`; + 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 } : {}), + }); + } + + return normalized.sort((left, right) => { + if (left.startMs !== right.startMs) { + return left.startMs - right.startMs; + } + + return left.endMs - right.endMs; + }); +} + +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); + } + + const progress = easeWebcamPositionTransition( + clamp01((roundedTimeMs - previousRegion.endMs) / gapMs), + ); + 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..6c4550113 --- /dev/null +++ b/src/components/video-editor/webcamSizeRegions.test.ts @@ -0,0 +1,226 @@ +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 regions by start and end", () => { + 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: 1_000, endMs: 1_500, size: 50 }, + ]); + + expect(normalized.map((region) => region.id)).toEqual(["c", "a", "b"]); + }); + + 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("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..c307201cf --- /dev/null +++ b/src/components/video-editor/webcamSizeRegions.ts @@ -0,0 +1,478 @@ +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, 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[] = []; + + 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; + } + + const id = + typeof candidate.id === "string" && candidate.id.trim().length > 0 + ? candidate.id + : `webcam-size-${index + 1}`; + 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 } : {}), + }); + } + + return normalized.sort((left, right) => { + if (left.startMs !== right.startMs) { + return left.startMs - right.startMs; + } + + return left.endMs - right.endMs; + }); +} + +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); + } + + const progress = easeWebcamSizeTransition( + clamp01((roundedTimeMs - previousRegion.endMs) / gapMs), + ); + 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); + } + + const progress = easeWebcamSizeTransition( + clamp01((roundedTimeMs - previousRegion.endMs) / gapMs), + ); + 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, From 7fbc6756d3a4643337c9f333b5661447542295d0 Mon Sep 17 00:00:00 2001 From: reedaaz Date: Mon, 18 May 2026 13:22:44 +0200 Subject: [PATCH 2/7] feat(timeline): webcam regions default to full timeline duration Webcam size/focus/position regions now span the entire timeline when first created instead of a fixed 3-second window, so the default state covers the whole recording without manual resizing. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/VideoEditor.tsx | 43 ++++----------------- 1 file changed, 8 insertions(+), 35 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ff7fd41ad..1c2e22af6 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3488,19 +3488,9 @@ export default function VideoEditor() { return; } - const playheadMs = Math.max( - 0, - Math.min(timelineDurationMs, Math.round((timelinePlayheadTime ?? currentTime) * 1000)), - ); - const defaultDurationMs = 3000; const minDurationMs = WEBCAM_SIZE_REGION_MIN_DURATION_MS; - - const latestStartMs = Math.max(0, timelineDurationMs - minDurationMs); - const startMs = Math.min(playheadMs, latestStartMs); - const endMs = Math.min( - timelineDurationMs, - Math.max(startMs + minDurationMs, startMs + defaultDurationMs), - ); + const startMs = 0; + const endMs = timelineDurationMs; if (endMs - startMs < minDurationMs) { return; @@ -3517,7 +3507,7 @@ export default function VideoEditor() { setSelectedWebcamSizeRegionId(nextRegion.id); return normalizeWebcamSizeRegions([...current, nextRegion], timelineDurationMs); }); - }, [currentTime, timelineDurationMs, timelinePlayheadTime, webcam.height, webcam.size]); + }, [timelineDurationMs, webcam.height, webcam.size]); const handleWebcamSizeRegionSizeChange = useCallback((id: string, size: number) => { setWebcamSizeRegions((current) => @@ -3697,21 +3687,9 @@ export default function VideoEditor() { const addWebcamFocusRegionAtPlayhead = useCallback( (overrides?: Partial>) => { - const playheadMs = Math.max( - 0, - Math.min( - timelineDurationMs, - Math.round((timelinePlayheadTime ?? currentTime) * 1000), - ), - ); - const defaultDurationMs = 3000; const minDurationMs = WEBCAM_FOCUS_REGION_MIN_DURATION_MS; - const latestStartMs = Math.max(0, timelineDurationMs - minDurationMs); - const startMs = Math.min(playheadMs, latestStartMs); - const endMs = Math.min( - timelineDurationMs, - Math.max(startMs + minDurationMs, startMs + defaultDurationMs), - ); + const startMs = 0; + const endMs = timelineDurationMs; if (endMs - startMs < minDurationMs) { return; } @@ -3729,7 +3707,7 @@ export default function VideoEditor() { return normalizeWebcamFocusRegions([...current, nextRegion], timelineDurationMs); }); }, - [currentTime, timelineDurationMs, timelinePlayheadTime], + [timelineDurationMs], ); const handleAddWebcamFocusRegionAtPlayhead = useCallback(() => { @@ -3875,13 +3853,8 @@ export default function VideoEditor() { startMs = activeSizeRegion.startMs; endMs = activeSizeRegion.endMs; } else { - const defaultDurationMs = 3000; - const latestStartMs = Math.max(0, timelineDurationMs - minDurationMs); - startMs = Math.min(playheadMs, latestStartMs); - endMs = Math.min( - timelineDurationMs, - Math.max(startMs + minDurationMs, startMs + defaultDurationMs), - ); + startMs = 0; + endMs = timelineDurationMs; } if (endMs - startMs < minDurationMs) { return null; From 81710bb0c838326003af159337210dc83fffabb1 Mon Sep 17 00:00:00 2001 From: reedaaz Date: Mon, 18 May 2026 15:17:37 +0200 Subject: [PATCH 3/7] fix(webcam-regions): resolve overlaps deterministically + clamp easing inputs normalize* now clips an earlier region to end where the next one begins (dropping it if the clip falls below the min duration), so preview and export always agree on a single active region per instant instead of relying on an arbitrary tie-break. Also clamp easing transition input to [0,1] at the source so no branch can feed an out-of-domain progress value into the cubic-bezier. Co-Authored-By: Claude Opus 4.7 --- .../video-editor/webcamFocusRegions.ts | 23 ++++++++++++++-- .../webcamPositionRegions.test.ts | 27 ++++++++++++++++--- .../video-editor/webcamPositionRegions.ts | 23 ++++++++++++++-- .../video-editor/webcamSizeRegions.test.ts | 27 ++++++++++++++++--- .../video-editor/webcamSizeRegions.ts | 24 +++++++++++++++-- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/components/video-editor/webcamFocusRegions.ts b/src/components/video-editor/webcamFocusRegions.ts index 287f814db..bca8fa729 100644 --- a/src/components/video-editor/webcamFocusRegions.ts +++ b/src/components/video-editor/webcamFocusRegions.ts @@ -61,7 +61,7 @@ function lerp(start: number, end: number, amount: number) { } function easeFocusTransition(t: number): number { - return cubicBezier(0.4, 0.0, 0.2, 1.0, t); + return cubicBezier(0.4, 0.0, 0.2, 1.0, clamp01(t)); } function isWebcamCorner(value: unknown): value is WebcamCorner { @@ -215,10 +215,29 @@ export function normalizeWebcamFocusRegions( }); } - return normalized.sort((left, right) => { + const sorted = normalized.sort((left, right) => { if (left.startMs !== right.startMs) return left.startMs - right.startMs; return left.endMs - right.endMs; }); + + // Resolve overlaps deterministically: clip an earlier region to end + // where the next one starts so preview and export agree on a single + // active region per instant. + const resolved: WebcamFocusRegion[] = []; + for (const region of sorted) { + const previous = resolved[resolved.length - 1]; + if (previous && region.startMs < previous.endMs) { + const clippedEndMs = region.startMs; + if (clippedEndMs - previous.startMs < WEBCAM_FOCUS_REGION_MIN_DURATION_MS) { + resolved.pop(); + } else { + resolved[resolved.length - 1] = { ...previous, endMs: clippedEndMs }; + } + } + resolved.push(region); + } + + return resolved; } export function getActiveWebcamFocusRegion( diff --git a/src/components/video-editor/webcamPositionRegions.test.ts b/src/components/video-editor/webcamPositionRegions.test.ts index fd298466a..e7e9dc53c 100644 --- a/src/components/video-editor/webcamPositionRegions.test.ts +++ b/src/components/video-editor/webcamPositionRegions.test.ts @@ -130,14 +130,35 @@ describe("webcamPositionRegions", () => { expect(normalizeWebcamPositionRegions("oops")).toEqual([]); }); - it("sorts regions by start and end", () => { + 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: 1_000, endMs: 1_500, 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(["c", "a", "b"]); + 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("generates unique ids that do not collide with existing ones", () => { diff --git a/src/components/video-editor/webcamPositionRegions.ts b/src/components/video-editor/webcamPositionRegions.ts index f6178950b..3d0277da4 100644 --- a/src/components/video-editor/webcamPositionRegions.ts +++ b/src/components/video-editor/webcamPositionRegions.ts @@ -44,7 +44,7 @@ function lerp(start: number, end: number, amount: number) { } function easeWebcamPositionTransition(t: number): number { - return cubicBezier(0.4, 0.0, 0.2, 1.0, t); + return cubicBezier(0.4, 0.0, 0.2, 1.0, clamp01(t)); } function resolveDefaults( @@ -167,13 +167,32 @@ export function normalizeWebcamPositionRegions( }); } - return normalized.sort((left, right) => { + const sorted = normalized.sort((left, right) => { if (left.startMs !== right.startMs) { return left.startMs - right.startMs; } return left.endMs - right.endMs; }); + + // Resolve overlaps deterministically: clip an earlier region to end + // where the next one starts so preview and export agree on a single + // active region per instant. + const resolved: WebcamPositionRegion[] = []; + for (const region of sorted) { + const previous = resolved[resolved.length - 1]; + if (previous && region.startMs < previous.endMs) { + const clippedEndMs = region.startMs; + if (clippedEndMs - previous.startMs < WEBCAM_POSITION_REGION_MIN_DURATION_MS) { + resolved.pop(); + } else { + resolved[resolved.length - 1] = { ...previous, endMs: clippedEndMs }; + } + } + resolved.push(region); + } + + return resolved; } export function getActiveWebcamPositionRegion( diff --git a/src/components/video-editor/webcamSizeRegions.test.ts b/src/components/video-editor/webcamSizeRegions.test.ts index 6c4550113..af917639e 100644 --- a/src/components/video-editor/webcamSizeRegions.test.ts +++ b/src/components/video-editor/webcamSizeRegions.test.ts @@ -111,14 +111,35 @@ describe("webcamSizeRegions", () => { expect(normalizeWebcamSizeRegions({ foo: 1 })).toEqual([]); }); - it("sorts regions by start and end", () => { + 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: 1_000, endMs: 1_500, size: 50 }, + { id: "c", startMs: 2_500, endMs: 3_000, size: 50 }, ]); - expect(normalized.map((region) => region.id)).toEqual(["c", "a", "b"]); + 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("generates unique ids that do not collide with existing ones", () => { diff --git a/src/components/video-editor/webcamSizeRegions.ts b/src/components/video-editor/webcamSizeRegions.ts index c307201cf..ba6cab08a 100644 --- a/src/components/video-editor/webcamSizeRegions.ts +++ b/src/components/video-editor/webcamSizeRegions.ts @@ -45,7 +45,7 @@ function lerp(start: number, end: number, amount: number) { } function easeWebcamSizeTransition(t: number): number { - return cubicBezier(0.4, 0.0, 0.2, 1.0, t); + return cubicBezier(0.4, 0.0, 0.2, 1.0, clamp01(t)); } function resolveDefaults( @@ -177,13 +177,33 @@ export function normalizeWebcamSizeRegions( }); } - return normalized.sort((left, right) => { + const sorted = normalized.sort((left, right) => { if (left.startMs !== right.startMs) { return left.startMs - right.startMs; } return left.endMs - right.endMs; }); + + // Resolve overlaps deterministically: an earlier-starting region is + // clipped so it ends where the next region begins. This keeps preview + // and export in sync (both rely on a single active region per instant) + // and removes the ambiguity of overlapping spans. + const resolved: WebcamSizeRegion[] = []; + for (const region of sorted) { + const previous = resolved[resolved.length - 1]; + if (previous && region.startMs < previous.endMs) { + const clippedEndMs = region.startMs; + if (clippedEndMs - previous.startMs < WEBCAM_SIZE_REGION_MIN_DURATION_MS) { + resolved.pop(); + } else { + resolved[resolved.length - 1] = { ...previous, endMs: clippedEndMs }; + } + } + resolved.push(region); + } + + return resolved; } export function getActiveWebcamSizeRegion( From 4b2a5d24e8914857bb2a5d872b7694145380e739 Mon Sep 17 00:00:00 2001 From: reedaaz Date: Mon, 18 May 2026 17:00:55 +0200 Subject: [PATCH 4/7] fix(webcam-regions): two-phase overlap resolution + unique fallback ids Address CodeRabbit review on the overlap normalization: - The single-pass clip left a false gap when a short middle region was dropped (it was never re-checked against the new tail). Replace with a two-phase pass: greedily keep regions (dropping any predecessor left below the minimum once clipped, re-checking the new tail), then clip each kept region to the next one's start. This fully resolves the A/B/C example where B is dropped and A must extend to C. - normalize* now tracks used ids and generates a collision-free numeric suffix for blank ids, so a fallback id can't duplicate a persisted one and break keyed rendering / id-targeted mutations. Adds regression tests for both cases. Co-Authored-By: Claude Opus 4.7 --- .../video-editor/webcamFocusRegions.ts | 57 +++++++++++++------ .../webcamPositionRegions.test.ts | 23 ++++++++ .../video-editor/webcamPositionRegions.ts | 49 +++++++++++----- .../video-editor/webcamSizeRegions.test.ts | 25 ++++++++ .../video-editor/webcamSizeRegions.ts | 50 +++++++++++----- 5 files changed, 158 insertions(+), 46 deletions(-) diff --git a/src/components/video-editor/webcamFocusRegions.ts b/src/components/video-editor/webcamFocusRegions.ts index bca8fa729..5a45cbf91 100644 --- a/src/components/video-editor/webcamFocusRegions.ts +++ b/src/components/video-editor/webcamFocusRegions.ts @@ -168,6 +168,7 @@ export function normalizeWebcamFocusRegions( 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]; @@ -195,11 +196,23 @@ export function normalizeWebcamFocusRegions( 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: - typeof candidate.id === "string" && candidate.id.trim().length > 0 - ? candidate.id - : `webcam-focus-${index + 1}`, + id, startMs, endMs, focusSize: clampWebcamFocusRegionSize(candidate.focusSize), @@ -220,24 +233,34 @@ export function normalizeWebcamFocusRegions( return left.endMs - right.endMs; }); - // Resolve overlaps deterministically: clip an earlier region to end - // where the next one starts so preview and export agree on a single - // active region per instant. - const resolved: WebcamFocusRegion[] = []; + // 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) { - const previous = resolved[resolved.length - 1]; - if (previous && region.startMs < previous.endMs) { - const clippedEndMs = region.startMs; - if (clippedEndMs - previous.startMs < WEBCAM_FOCUS_REGION_MIN_DURATION_MS) { - resolved.pop(); - } else { - resolved[resolved.length - 1] = { ...previous, endMs: clippedEndMs }; + 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(); } - resolved.push(region); + kept.push(region); } - return resolved; + return kept.map((region, index) => { + const next = kept[index + 1]; + return next && region.endMs > next.startMs + ? { ...region, endMs: next.startMs } + : region; + }); } export function getActiveWebcamFocusRegion( diff --git a/src/components/video-editor/webcamPositionRegions.test.ts b/src/components/video-editor/webcamPositionRegions.test.ts index e7e9dc53c..8bcfa0144 100644 --- a/src/components/video-editor/webcamPositionRegions.test.ts +++ b/src/components/video-editor/webcamPositionRegions.test.ts @@ -161,6 +161,29 @@ describe("webcamPositionRegions", () => { 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 }, diff --git a/src/components/video-editor/webcamPositionRegions.ts b/src/components/video-editor/webcamPositionRegions.ts index 3d0277da4..760fe52f1 100644 --- a/src/components/video-editor/webcamPositionRegions.ts +++ b/src/components/video-editor/webcamPositionRegions.ts @@ -120,6 +120,7 @@ export function normalizeWebcamPositionRegions( 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]; @@ -149,10 +150,20 @@ export function normalizeWebcamPositionRegions( continue; } - const id = + 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); @@ -175,24 +186,34 @@ export function normalizeWebcamPositionRegions( return left.endMs - right.endMs; }); - // Resolve overlaps deterministically: clip an earlier region to end - // where the next one starts so preview and export agree on a single - // active region per instant. - const resolved: WebcamPositionRegion[] = []; + // 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) { - const previous = resolved[resolved.length - 1]; - if (previous && region.startMs < previous.endMs) { - const clippedEndMs = region.startMs; - if (clippedEndMs - previous.startMs < WEBCAM_POSITION_REGION_MIN_DURATION_MS) { - resolved.pop(); - } else { - resolved[resolved.length - 1] = { ...previous, endMs: clippedEndMs }; + 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(); } - resolved.push(region); + kept.push(region); } - return resolved; + return kept.map((region, index) => { + const next = kept[index + 1]; + return next && region.endMs > next.startMs + ? { ...region, endMs: next.startMs } + : region; + }); } export function getActiveWebcamPositionRegion( diff --git a/src/components/video-editor/webcamSizeRegions.test.ts b/src/components/video-editor/webcamSizeRegions.test.ts index af917639e..b26f36644 100644 --- a/src/components/video-editor/webcamSizeRegions.test.ts +++ b/src/components/video-editor/webcamSizeRegions.test.ts @@ -142,6 +142,31 @@ describe("webcamSizeRegions", () => { 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 }, diff --git a/src/components/video-editor/webcamSizeRegions.ts b/src/components/video-editor/webcamSizeRegions.ts index ba6cab08a..f9e32ee7a 100644 --- a/src/components/video-editor/webcamSizeRegions.ts +++ b/src/components/video-editor/webcamSizeRegions.ts @@ -127,6 +127,7 @@ export function normalizeWebcamSizeRegions( 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]; @@ -156,10 +157,20 @@ export function normalizeWebcamSizeRegions( continue; } - const id = + 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) @@ -185,25 +196,34 @@ export function normalizeWebcamSizeRegions( return left.endMs - right.endMs; }); - // Resolve overlaps deterministically: an earlier-starting region is - // clipped so it ends where the next region begins. This keeps preview - // and export in sync (both rely on a single active region per instant) - // and removes the ambiguity of overlapping spans. - const resolved: WebcamSizeRegion[] = []; + // 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) { - const previous = resolved[resolved.length - 1]; - if (previous && region.startMs < previous.endMs) { - const clippedEndMs = region.startMs; - if (clippedEndMs - previous.startMs < WEBCAM_SIZE_REGION_MIN_DURATION_MS) { - resolved.pop(); - } else { - resolved[resolved.length - 1] = { ...previous, endMs: clippedEndMs }; + 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(); } - resolved.push(region); + kept.push(region); } - return resolved; + return kept.map((region, index) => { + const next = kept[index + 1]; + return next && region.endMs > next.startMs + ? { ...region, endMs: next.startMs } + : region; + }); } export function getActiveWebcamSizeRegion( From 7b0863651f4c7a477b334194109ad808811a7beb Mon Sep 17 00:00:00 2001 From: reedaaz Date: Mon, 18 May 2026 18:04:09 +0200 Subject: [PATCH 5/7] fix(webcam-regions): continuous blend across region boundary The active-region branch blends toward the next region from `nextStart - transitionIn`, but the overlap branch restarted progress from `previousRegion.endMs`. When the next region's transition-in began before the previous region ended, size/position/focus snapped at the boundary (e.g. ~71 -> 40 in 1ms). Unify the blend origin to `min(previousRegion.endMs, nextStart - nextIn)` with `nextStart - blendStart` as the duration, so the overlap branch is C0-continuous with the active branch. When the transition-in did not start early this reduces exactly to the previous gap blend, so no behaviour change in that case. Applies to getInterpolatedWebcamSizeAtTime, getInterpolatedWebcam DimensionsAtTime, the position resolver and the focus resolver. Adds a boundary-continuity regression test. Addresses CodeRabbit review. Co-Authored-By: Claude Opus 4.7 --- .../video-editor/webcamFocusRegions.ts | 15 +++++++++++-- .../video-editor/webcamPositionRegions.ts | 11 +++++++++- .../video-editor/webcamSizeRegions.test.ts | 22 +++++++++++++++++++ .../video-editor/webcamSizeRegions.ts | 19 ++++++++++++++-- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/webcamFocusRegions.ts b/src/components/video-editor/webcamFocusRegions.ts index 5a45cbf91..cdde69eda 100644 --- a/src/components/video-editor/webcamFocusRegions.ts +++ b/src/components/video-editor/webcamFocusRegions.ts @@ -419,10 +419,21 @@ export function getInterpolatedFocusStateAtTime( 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 + gapMs <= 0 || blendDurationMs <= 0 ? 1 - : easeFocusTransition((roundedTimeMs - previousRegion.endMs) / gapMs); + : easeFocusTransition( + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), + ); return blendFocusStates( buildFullFocusState({ baseWebcamSize, region: previousRegion, webcamCorner }), buildFullFocusState({ baseWebcamSize, region: nextRegion, webcamCorner }), diff --git a/src/components/video-editor/webcamPositionRegions.ts b/src/components/video-editor/webcamPositionRegions.ts index 760fe52f1..a9e41368b 100644 --- a/src/components/video-editor/webcamPositionRegions.ts +++ b/src/components/video-editor/webcamPositionRegions.ts @@ -351,8 +351,17 @@ export function getInterpolatedWebcamPositionAtTime( 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 - previousRegion.endMs) / gapMs), + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), ); return lerpPoint(regionToPoint(previousRegion), regionToPoint(nextRegion), progress); } diff --git a/src/components/video-editor/webcamSizeRegions.test.ts b/src/components/video-editor/webcamSizeRegions.test.ts index b26f36644..416f615a1 100644 --- a/src/components/video-editor/webcamSizeRegions.test.ts +++ b/src/components/video-editor/webcamSizeRegions.test.ts @@ -223,6 +223,28 @@ describe("webcamSizeRegions", () => { 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 }, diff --git a/src/components/video-editor/webcamSizeRegions.ts b/src/components/video-editor/webcamSizeRegions.ts index f9e32ee7a..65eb10060 100644 --- a/src/components/video-editor/webcamSizeRegions.ts +++ b/src/components/video-editor/webcamSizeRegions.ts @@ -353,8 +353,17 @@ export function getInterpolatedWebcamSizeAtTime( 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 - previousRegion.endMs) / gapMs), + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), ); return lerp( clampWebcamSizeRegionSize(previousRegion.size), @@ -461,8 +470,14 @@ export function getInterpolatedWebcamDimensionsAtTime( 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 - previousRegion.endMs) / gapMs), + clamp01((roundedTimeMs - blendStartMs) / blendDurationMs), ); return lerpDimensions( getRegionDimensions(previousRegion), From f3803362308a580f2da00b5e1e2913fb589e1afd Mon Sep 17 00:00:00 2001 From: reedaaz Date: Mon, 18 May 2026 11:03:21 +0200 Subject: [PATCH 6/7] feat(timeline): baseline strip for webcam camera tracks Render a continuous dimmed baseline behind size/focus/position regions so gaps read as the default camera state instead of harsh black. Pure render change keyed off whether the row has regions, so it also fixes existing projects. Co-Authored-By: Claude Opus 4.7 --- src/components/video-editor/timeline/Row.tsx | 7 +++++++ .../timeline/components/viewport/TimelineCanvas.tsx | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 6f90a31e5..20d62bd72 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, @@ -57,6 +59,11 @@ export default function Row({ onMouseDown={onMouseDown} onClick={onClick} > + {baseline && !isEmpty && ( +
+
+
+ )} {children}
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index f8fe81721..3b531b82c 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -573,7 +573,11 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ ))} - + 0} + > {webcamSizeItems.map((item) => ( ))} - + 0} + > {webcamFocusItems.map((item) => ( 0} > {webcamPositionItems.map((item) => ( Date: Mon, 18 May 2026 13:23:11 +0200 Subject: [PATCH 7/7] feat(timeline): tighten baseline strip color to zinc-700 Replace the near-invisible foreground/12 tint with an explicit zinc-700/60 background + zinc-600 border so the baseline reads clearly on dark editor backgrounds. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/timeline/Row.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 20d62bd72..262e41e70 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -60,8 +60,8 @@ export default function Row({ onClick={onClick} > {baseline && !isEmpty && ( -
-
+
+
)} {children}