diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b80ac021d..ecaeedb3b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -2,18 +2,24 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { Brackets, Bug, + ChevronLeft, + ChevronRight, + Copy, Crop, Download, FileDown, Film, + Gauge, Image, Info, LayoutPanelTop, Lock, MousePointerClick, Palette, + Scissors, SlidersHorizontal, Sparkles, + Spline, Star, Trash2, Unlock, @@ -44,6 +50,16 @@ import { Tooltip } from "@/components/ui/tooltip"; import { useScopedT } from "@/contexts/I18nContext"; import { getAssetPath } from "@/lib/assetPath"; import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; +import { + CURSOR_MOTION_EASINGS, + CURSOR_MOTION_PRESETS, + CURSOR_MOTION_SPEED_MAX, + CURSOR_MOTION_SPEED_MIN, + CURSOR_MOTION_SPEED_PRESETS, + type CursorMotionEasing, + type CursorMotionPreset, + type CursorMotionRegion, +} from "@/lib/cursor/cursorMotion"; import { CURSOR_THEMES, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import { @@ -323,6 +339,25 @@ interface SettingsPanelProps { selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; onSpeedDelete?: (id: string) => void; + selectedCursorMotionRegion?: CursorMotionRegion | null; + isEditingCursorMotion?: boolean; + onCursorMotionEditingChange?: (editing: boolean) => void; + onCursorMotionPresetChange?: (preset: CursorMotionPreset) => void; + onCursorMotionEasingChange?: (easing: CursorMotionEasing) => void; + onCursorMotionCyclesChange?: (cycles: number) => void; + onCursorMotionSpeedChange?: (speed: number, checkpoint?: boolean) => void; + onCursorMotionCommit?: () => void; + cursorMotionSectionIndex?: number; + cursorMotionSectionCount?: number; + canSelectPreviousCursorMotion?: boolean; + canSelectNextCursorMotion?: boolean; + onSelectPreviousCursorMotion?: () => void; + onSelectNextCursorMotion?: () => void; + onCursorMotionApplyToAllMoves?: () => void; + canSplitCursorMotion?: boolean; + onCursorMotionSplit?: () => void; + onCursorMotionAutoSplit?: () => void; + onCursorMotionDelete?: (id: string) => void; hasWebcam?: boolean; webcamLayoutPreset?: WebcamLayoutPreset; onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; @@ -365,6 +400,37 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; +const CURSOR_MOTION_PREVIEW_PATHS: Record = { + recorded: "M4 18 C12 8 18 21 27 11 S38 4 44 9", + straight: "M4 18 L44 6", + arc: "M4 18 Q24 1 44 18", + wave: "M4 13 C10 2 16 2 22 13 S34 24 44 13", + loop: "M4 16 C14 2 34 2 32 15 C30 25 14 23 18 13 C22 5 36 8 44 16", + overshoot: "M4 18 C24 18 37 5 46 8 C42 8 40 10 44 14", +}; + +function CursorMotionPresetPreview({ preset }: { preset: CursorMotionPreset }) { + return ( + + ); +} + type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline"; const MP4_EXPORT_SHORT_SIDES = { @@ -459,6 +525,25 @@ export function SettingsPanel({ selectedSpeedValue, onSpeedChange, onSpeedDelete, + selectedCursorMotionRegion, + isEditingCursorMotion = false, + onCursorMotionEditingChange, + onCursorMotionPresetChange, + onCursorMotionEasingChange, + onCursorMotionCyclesChange, + onCursorMotionSpeedChange, + onCursorMotionCommit, + cursorMotionSectionIndex = 0, + cursorMotionSectionCount = 0, + canSelectPreviousCursorMotion = false, + canSelectNextCursorMotion = false, + onSelectPreviousCursorMotion, + onSelectNextCursorMotion, + onCursorMotionApplyToAllMoves, + canSplitCursorMotion = false, + onCursorMotionSplit, + onCursorMotionAutoSplit, + onCursorMotionDelete, hasWebcam = false, webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset, onWebcamLayoutPresetChange, @@ -643,7 +728,9 @@ export function SettingsPanel({ const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); - const hasTimelineSelection = Boolean(selectedZoomId || selectedTrimId || selectedSpeedId); + const hasTimelineSelection = Boolean( + selectedZoomId || selectedTrimId || selectedSpeedId || selectedCursorMotionRegion, + ); const hasCursorPanel = showCursorSettings && hasCursorData; const panelModes: Array<{ id: SettingsPanelMode; @@ -675,7 +762,9 @@ export function SettingsPanel({ ? t("zoom.level") : selectedSpeedId ? t("speed.playbackSpeed") - : t("trim.deleteRegion") + : selectedCursorMotionRegion + ? t("cursorMotion.title") + : t("trim.deleteRegion") : activePanelMode === "timeline" ? t("timeline.title") : ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ?? @@ -1168,6 +1257,236 @@ export function SettingsPanel({ )} + {selectedCursorMotionRegion && ( +
+
+
+
+ + {t("cursorMotion.title")} +
+

+ {t("cursorMotion.description")} +

+
+ +
+ +
+ +
+ {t("cursorMotion.sectionCounter", { + current: String(cursorMotionSectionIndex), + total: String(cursorMotionSectionCount), + })} +
+ +
+ +
+ + {t("cursorMotion.preset")} + +
+ {CURSOR_MOTION_PRESETS.map((preset) => { + const isActive = selectedCursorMotionRegion.preset === preset; + return ( + + ); + })} +
+
+ + {selectedCursorMotionRegion.preset !== "recorded" && ( +
+
+ + + {t("cursorMotion.speed")} + + + {selectedCursorMotionRegion.speed.toFixed(1)}× + +
+
+ {CURSOR_MOTION_SPEED_PRESETS.map((speed) => ( + + ))} +
+ onCursorMotionSpeedChange?.(values[0], false)} + onValueCommit={(values) => onCursorMotionSpeedChange?.(values[0], true)} + className="relative flex w-full touch-none select-none items-center py-1" + > + + + + + +

+ {t("cursorMotion.speedHint")} +

+
+ )} + + {(selectedCursorMotionRegion.preset === "wave" || + selectedCursorMotionRegion.preset === "loop") && ( +
+
+ {t("cursorMotion.cycles")} + + {selectedCursorMotionRegion.cycles} + +
+ onCursorMotionCyclesChange?.(values[0])} + onValueCommit={() => onCursorMotionCommit?.()} + className="relative flex w-full touch-none select-none items-center py-1" + > + + + + + +
+ )} + +
+ + {t("cursorMotion.easing")} + + +
+ +
+ {t("cursorMotion.anchorHint")} +
+ +
+ + {t( + `cursorMotion.anchorKinds.${selectedCursorMotionRegion.startAnchorKind ?? "manual"}`, + )} + + + + {t( + `cursorMotion.anchorKinds.${selectedCursorMotionRegion.endAnchorKind ?? "click"}`, + )} + +
+ + + +
+ )} + {selectedSpeedId && (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b49197da4..739fd848d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -34,6 +34,23 @@ import { transcribeMono16kToSegments, trimLeadingSilenceMono16k, } from "@/lib/captioning"; +import { + applyCursorMotionSettingsToMoveRegions, + buildCursorMotionSegments, + type CursorMotionEasing, + type CursorMotionPoint, + type CursorMotionPreset, + type CursorMotionRegion, + clampCursorMotionCycles, + clampCursorMotionPoint, + clampCursorMotionSpeed, + createDefaultCursorMotionControlPoint, + DEFAULT_CURSOR_MOTION_SPEED, + resolveCursorMotionClickAnchoredRange, + sampleCursorMotionPath, + splitCursorMotionRegionAtTime, +} from "@/lib/cursor/cursorMotion"; +import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing"; import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { calculateEffectiveSourceDimensions, @@ -206,6 +223,7 @@ export default function VideoEditor() { const { zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -245,6 +263,8 @@ export default function VideoEditor() { const durationRef = useRef(duration); durationRef.current = duration; const [selectedZoomId, setSelectedZoomId] = useState(null); + const [selectedCursorMotionId, setSelectedCursorMotionId] = useState(null); + const [isEditingCursorMotion, setIsEditingCursorMotion] = useState(false); const [selectedCameraFullscreenId, setSelectedCameraFullscreenId] = useState(null); const [isPreviewingZoom, setIsPreviewingZoom] = useState(false); const [selectedTrimId, setSelectedTrimId] = useState(null); @@ -298,6 +318,10 @@ export default function VideoEditor() { .filter((sample) => isClickInteractionType(sample.interactionType)) .map((sample) => sample.timeMs); }, [cursorRecordingData, cursorTelemetry]); + const cursorMotionSamples = useMemo( + () => (cursorRecordingData?.samples.length ? cursorRecordingData.samples : cursorTelemetry), + [cursorRecordingData, cursorTelemetry], + ); // Cursor & motion blur visual settings (non-undoable preferences) const [showCursor, setShowCursor] = useState(DEFAULT_CURSOR_SETTINGS.show); @@ -316,6 +340,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); + const nextCursorMotionIdRef = useRef(1); const nextCameraFullscreenIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); @@ -330,6 +355,34 @@ export default function VideoEditor() { hasNativeCursorRecordingData(cursorRecordingData); const effectiveShowCursor = showCursor && hasEditableCursorRecording; const showCursorSettings = hasEditableCursorRecording; + const cursorMotionBasePath = useMemo( + () => getSmoothedCursorPath(cursorRecordingData, cursorSmoothing), + [cursorRecordingData, cursorSmoothing], + ); + const selectedCursorMotionRegion = useMemo( + () => + selectedCursorMotionId + ? (cursorMotionRegions.find((region) => region.id === selectedCursorMotionId) ?? null) + : null, + [cursorMotionRegions, selectedCursorMotionId], + ); + const orderedCursorMotionRegions = useMemo( + () => + [...cursorMotionRegions].sort( + (a, b) => a.startMs - b.startMs || a.endMs - b.endMs || a.id.localeCompare(b.id), + ), + [cursorMotionRegions], + ); + const selectedCursorMotionIndex = useMemo( + () => orderedCursorMotionRegions.findIndex((region) => region.id === selectedCursorMotionId), + [orderedCursorMotionRegions, selectedCursorMotionId], + ); + useEffect(() => { + if (selectedCursorMotionId && !selectedCursorMotionRegion) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); + } + }, [selectedCursorMotionId, selectedCursorMotionRegion]); const { locale, setLocale, t: rawT } = useI18n(); const t = useScopedT("editor"); const ts = useScopedT("settings"); @@ -393,6 +446,7 @@ export default function VideoEditor() { const inferredDurationMs = Math.max( 0, ...normalizedEditor.zoomRegions.map((region) => region.endMs), + ...normalizedEditor.cursorMotionRegions.map((region) => region.endMs), ...normalizedEditor.cameraFullscreenRegions.map((region) => region.endMs), ...normalizedEditor.trimRegions.map((region) => region.endMs), ...normalizedEditor.speedRegions.map((region) => region.endMs), @@ -430,6 +484,7 @@ export default function VideoEditor() { padding: normalizedEditor.padding, cropRegion: normalizedEditor.cropRegion, zoomRegions: normalizedEditor.zoomRegions, + cursorMotionRegions: normalizedEditor.cursorMotionRegions, cameraFullscreenRegions: normalizedEditor.cameraFullscreenRegions, autoZoomEnabled: normalizedEditor.autoZoomEnabled, autoFocusAll: normalizedEditor.autoFocusAll, @@ -452,6 +507,8 @@ export default function VideoEditor() { setCursorTheme(normalizedEditor.cursorTheme); setSelectedZoomId(null); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); @@ -462,6 +519,10 @@ export default function VideoEditor() { "zoom", normalizedEditor.zoomRegions.map((region) => region.id), ); + nextCursorMotionIdRef.current = deriveNextId( + "cursor-motion", + normalizedEditor.cursorMotionRegions.map((region) => region.id), + ); nextCameraFullscreenIdRef.current = deriveNextId( "camera-fullscreen", normalizedEditor.cameraFullscreenRegions.map((region) => region.id), @@ -513,6 +574,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -545,6 +607,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -674,6 +737,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -740,6 +804,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -878,6 +943,8 @@ export default function VideoEditor() { resetState(); // Reset non-undoable selection state. setSelectedZoomId(null); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); @@ -896,6 +963,7 @@ export default function VideoEditor() { setCursorTheme(DEFAULT_CURSOR_SETTINGS.theme); // Reset region ID counters. nextZoomIdRef.current = 1; + nextCursorMotionIdRef.current = 1; nextCameraFullscreenIdRef.current = 1; nextTrimIdRef.current = 1; nextSpeedIdRef.current = 1; @@ -1007,6 +1075,22 @@ export default function VideoEditor() { const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); + setSelectedCameraFullscreenId(null); + setSelectedTrimId(null); + setSelectedSpeedId(null); + setSelectedAnnotationId(null); + setSelectedBlurId(null); + } + }, []); + + const handleSelectCursorMotion = useCallback((id: string | null) => { + setSelectedCursorMotionId(id); + setIsEditingCursorMotion(Boolean(id)); + if (id) { + videoPlaybackRef.current?.pause(); + setSelectedZoomId(null); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); @@ -1018,6 +1102,8 @@ export default function VideoEditor() { const handleSelectCameraFullscreen = useCallback((id: string | null) => { setSelectedCameraFullscreenId(id); if (id) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); @@ -1029,6 +1115,8 @@ export default function VideoEditor() { const handleSelectTrim = useCallback((id: string | null) => { setSelectedTrimId(id); if (id) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedCameraFullscreenId(null); setSelectedSpeedId(null); @@ -1040,6 +1128,8 @@ export default function VideoEditor() { const handleSelectAnnotation = useCallback((id: string | null) => { setSelectedAnnotationId(id); if (id) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); @@ -1051,6 +1141,8 @@ export default function VideoEditor() { const handleSelectBlur = useCallback((id: string | null) => { setSelectedBlurId(id); if (id) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); @@ -1084,6 +1176,258 @@ export default function VideoEditor() { [pushState, autoFocusAll], ); + const handleCursorMotionAdded = useCallback( + (span: Span) => { + const anchoredRange = resolveCursorMotionClickAnchoredRange( + span.start, + cursorClickTimestamps, + ); + if (!anchoredRange) { + toast.error(ts("cursorMotion.noFollowingClick")); + return; + } + const { startMs, endMs } = anchoredRange; + const overlapsExisting = cursorMotionRegions.some( + (region) => region.endMs > startMs && region.startMs < endMs, + ); + if (overlapsExisting) { + toast.error(ts("cursorMotion.overlapExisting")); + return; + } + const segments = buildCursorMotionSegments({ + startMs, + endMs, + samples: cursorMotionSamples, + path: cursorMotionBasePath, + }); + if (segments.length === 0) { + toast.error(ts("cursorMotion.noCursorData")); + return; + } + + const newRegions: CursorMotionRegion[] = segments.map((segment) => ({ + id: `cursor-motion-${nextCursorMotionIdRef.current++}`, + ...segment, + preset: "recorded", + controlPoint: createDefaultCursorMotionControlPoint(segment.startPoint, segment.endPoint), + cycles: 2, + speed: DEFAULT_CURSOR_MOTION_SPEED, + easing: "ease-in-out", + })); + pushState((prev) => ({ + cursorMotionRegions: [...prev.cursorMotionRegions, ...newRegions], + })); + handleSelectCursorMotion(newRegions[0].id); + toast.success(ts("cursorMotion.createdSegments", { count: String(newRegions.length) })); + }, + [ + cursorClickTimestamps, + cursorMotionBasePath, + cursorMotionRegions, + cursorMotionSamples, + handleSelectCursorMotion, + pushState, + ts, + ], + ); + + const handleCursorMotionSplitAtPlayhead = useCallback(() => { + const region = selectedCursorMotionRegion; + if (!region) return; + const splitMs = Math.round(currentTime * 1000); + if (splitMs <= region.startMs + 1 || splitMs >= region.endMs - 1) { + toast.error(ts("cursorMotion.splitOutsideRegion")); + return; + } + const splitPoint = sampleCursorMotionPath(cursorMotionBasePath, [region], splitMs); + const startPoint = region.startPoint ?? cursorMotionBasePath?.sampleAt(region.startMs) ?? null; + const endPoint = region.endPoint ?? cursorMotionBasePath?.sampleAt(region.endMs) ?? null; + if (!splitPoint || !startPoint || !endPoint) { + toast.error(ts("cursorMotion.noCursorData")); + return; + } + + const rightId = `cursor-motion-${nextCursorMotionIdRef.current++}`; + const split = splitCursorMotionRegionAtTime({ + region, + splitMs, + splitPoint, + startPoint, + endPoint, + rightId, + }); + if (!split) return; + const [left, right] = split; + pushState((prev) => ({ + cursorMotionRegions: prev.cursorMotionRegions.flatMap((candidate) => + candidate.id === region.id ? [left, right] : [candidate], + ), + })); + handleSelectCursorMotion(rightId); + }, [ + currentTime, + cursorMotionBasePath, + handleSelectCursorMotion, + pushState, + selectedCursorMotionRegion, + ts, + ]); + + const handleCursorMotionAutoSplitSelected = useCallback(() => { + const region = selectedCursorMotionRegion; + if (!region) return; + const selectedPath = { + sampleAt: (timeMs: number) => sampleCursorMotionPath(cursorMotionBasePath, [region], timeMs), + }; + const segments = buildCursorMotionSegments({ + startMs: region.startMs, + endMs: region.endMs, + samples: cursorMotionSamples, + path: selectedPath, + }); + if (segments.length <= 1) { + toast.error(ts("cursorMotion.noSplitPoints")); + return; + } + const splitRegions = segments.map((segment, index) => { + const startPoint = + index === 0 ? (region.startPoint ?? segment.startPoint) : segment.startPoint; + const endPoint = + index === segments.length - 1 ? (region.endPoint ?? segment.endPoint) : segment.endPoint; + return { + ...region, + ...segment, + id: index === 0 ? region.id : `cursor-motion-${nextCursorMotionIdRef.current++}`, + startPoint, + endPoint, + startAnchorKind: + index === 0 + ? (region.startAnchorKind ?? segment.startAnchorKind) + : segment.startAnchorKind, + endAnchorKind: + index === segments.length - 1 + ? (region.endAnchorKind ?? segment.endAnchorKind) + : segment.endAnchorKind, + preset: segment.segmentKind === "hold" ? ("recorded" as const) : region.preset, + controlPoint: createDefaultCursorMotionControlPoint(startPoint, endPoint), + }; + }); + pushState((prev) => ({ + cursorMotionRegions: prev.cursorMotionRegions.flatMap((candidate) => + candidate.id === region.id ? splitRegions : [candidate], + ), + })); + handleSelectCursorMotion(splitRegions[0].id); + toast.success(ts("cursorMotion.createdSegments", { count: String(splitRegions.length) })); + }, [ + cursorMotionBasePath, + cursorMotionSamples, + handleSelectCursorMotion, + pushState, + selectedCursorMotionRegion, + ts, + ]); + + const updateSelectedCursorMotion = useCallback( + (update: (region: CursorMotionRegion) => CursorMotionRegion, checkpoint = true) => { + if (!selectedCursorMotionId) return; + const apply = checkpoint ? pushState : updateState; + apply((prev) => ({ + cursorMotionRegions: prev.cursorMotionRegions.map((region) => + region.id === selectedCursorMotionId ? update(region) : region, + ), + })); + }, + [pushState, selectedCursorMotionId, updateState], + ); + + const handleCursorMotionPresetChange = useCallback( + (preset: CursorMotionPreset) => { + updateSelectedCursorMotion((region) => ({ ...region, preset })); + }, + [updateSelectedCursorMotion], + ); + + const handleCursorMotionEasingChange = useCallback( + (easing: CursorMotionEasing) => { + updateSelectedCursorMotion((region) => ({ ...region, easing })); + }, + [updateSelectedCursorMotion], + ); + + const handleCursorMotionCyclesChange = useCallback( + (cycles: number) => { + updateSelectedCursorMotion( + (region) => ({ ...region, cycles: clampCursorMotionCycles(cycles) }), + false, + ); + }, + [updateSelectedCursorMotion], + ); + + const handleCursorMotionSpeedChange = useCallback( + (speed: number, checkpoint = false) => { + updateSelectedCursorMotion( + (region) => ({ ...region, speed: clampCursorMotionSpeed(speed) }), + checkpoint, + ); + }, + [updateSelectedCursorMotion], + ); + + const handleCursorMotionApplyToAllMoves = useCallback(() => { + const source = selectedCursorMotionRegion; + if (!source) return; + pushState((prev) => ({ + cursorMotionRegions: applyCursorMotionSettingsToMoveRegions(prev.cursorMotionRegions, source), + })); + toast.success(ts("cursorMotion.appliedToAllMoves")); + }, [pushState, selectedCursorMotionRegion, ts]); + + const handleCursorMotionNavigate = useCallback( + (direction: -1 | 1) => { + if (selectedCursorMotionIndex < 0) return; + const target = orderedCursorMotionRegions[selectedCursorMotionIndex + direction]; + if (!target) return; + setCurrentTime(target.startMs / 1000); + handleSelectCursorMotion(target.id); + }, + [handleSelectCursorMotion, orderedCursorMotionRegions, selectedCursorMotionIndex], + ); + + const handleCursorMotionControlPointChange = useCallback( + (_id: string, point: CursorMotionPoint) => { + updateSelectedCursorMotion( + (region) => ({ ...region, controlPoint: clampCursorMotionPoint(point) }), + false, + ); + }, + [updateSelectedCursorMotion], + ); + + const handleCursorMotionCommit = useCallback(() => { + commitState(); + }, [commitState]); + + const handleCursorMotionEditingChange = useCallback((editing: boolean) => { + setIsEditingCursorMotion(editing); + if (editing) { + videoPlaybackRef.current?.pause(); + } + }, []); + + const handleCursorMotionDelete = useCallback( + (id: string) => { + pushState((prev) => ({ + cursorMotionRegions: prev.cursorMotionRegions.filter((region) => region.id !== id), + })); + if (selectedCursorMotionId === id) { + handleSelectCursorMotion(null); + } + }, + [handleSelectCursorMotion, pushState, selectedCursorMotionId], + ); + const handleCameraFullscreenAdded = useCallback( (span: Span) => { const id = `camera-fullscreen-${nextCameraFullscreenIdRef.current++}`; @@ -1383,6 +1727,8 @@ export default function VideoEditor() { const handleSelectSpeed = useCallback((id: string | null) => { setSelectedSpeedId(id); if (id) { + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); @@ -1404,6 +1750,8 @@ export default function VideoEditor() { speedRegions: [...prev.speedRegions, newRegion], })); setSelectedSpeedId(id); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); @@ -2245,6 +2593,7 @@ export default function VideoEditor() { cursorRecordingData, cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, + cursorMotionRegions, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, @@ -2340,6 +2689,7 @@ export default function VideoEditor() { cursorRecordingData, cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, + cursorMotionRegions, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, @@ -2462,6 +2812,7 @@ export default function VideoEditor() { effectiveShowCursor, cursorSize, cursorSmoothing, + cursorMotionRegions, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, @@ -2963,7 +3314,10 @@ export default function VideoEditor() { onDurationChange={setDuration} onTimeUpdate={setCurrentTime} currentTime={currentTime} - onPlayStateChange={setIsPlaying} + onPlayStateChange={(playing) => { + setIsPlaying(playing); + if (playing) setIsEditingCursorMotion(false); + }} onError={setError} wallpaper={wallpaper} zoomRegions={zoomRegions} @@ -3000,6 +3354,11 @@ export default function VideoEditor() { showCursor={effectiveShowCursor} cursorSize={cursorSize} cursorSmoothing={cursorSmoothing} + cursorMotionRegions={cursorMotionRegions} + selectedCursorMotionId={selectedCursorMotionId} + isEditingCursorMotion={isEditingCursorMotion} + onCursorMotionControlPointChange={handleCursorMotionControlPointChange} + onCursorMotionControlPointCommit={handleCursorMotionCommit} cursorMotionBlur={cursorMotionBlur} cursorClickBounce={cursorClickBounce} cursorClipToBounds={cursorClipToBounds} @@ -3154,6 +3513,8 @@ export default function VideoEditor() { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); }} selectedAnnotationId={selectedAnnotationId} annotationRegions={annotationOnlyRegions} @@ -3176,6 +3537,32 @@ export default function VideoEditor() { } onSpeedChange={handleSpeedChange} onSpeedDelete={handleSpeedDelete} + selectedCursorMotionRegion={selectedCursorMotionRegion} + isEditingCursorMotion={isEditingCursorMotion} + onCursorMotionEditingChange={handleCursorMotionEditingChange} + onCursorMotionPresetChange={handleCursorMotionPresetChange} + onCursorMotionEasingChange={handleCursorMotionEasingChange} + onCursorMotionCyclesChange={handleCursorMotionCyclesChange} + onCursorMotionSpeedChange={handleCursorMotionSpeedChange} + onCursorMotionCommit={handleCursorMotionCommit} + cursorMotionSectionIndex={selectedCursorMotionIndex + 1} + cursorMotionSectionCount={orderedCursorMotionRegions.length} + canSelectPreviousCursorMotion={selectedCursorMotionIndex > 0} + canSelectNextCursorMotion={ + selectedCursorMotionIndex >= 0 && + selectedCursorMotionIndex < orderedCursorMotionRegions.length - 1 + } + onSelectPreviousCursorMotion={() => handleCursorMotionNavigate(-1)} + onSelectNextCursorMotion={() => handleCursorMotionNavigate(1)} + onCursorMotionApplyToAllMoves={handleCursorMotionApplyToAllMoves} + canSplitCursorMotion={Boolean( + selectedCursorMotionRegion && + Math.round(currentTime * 1000) > selectedCursorMotionRegion.startMs + 1 && + Math.round(currentTime * 1000) < selectedCursorMotionRegion.endMs - 1, + )} + onCursorMotionSplit={handleCursorMotionSplitAtPlayhead} + onCursorMotionAutoSplit={handleCursorMotionAutoSplitSelected} + onCursorMotionDelete={handleCursorMotionDelete} unsavedExport={unsavedExport} onSaveUnsavedExport={handleSaveUnsavedExport} onSaveDiagnostic={handleSaveDiagnostic} @@ -3242,6 +3629,11 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} selectedSpeedId={selectedSpeedId} onSelectSpeed={handleSelectSpeed} + cursorMotionRegions={cursorMotionRegions} + onCursorMotionAdded={handleCursorMotionAdded} + onCursorMotionDelete={handleCursorMotionDelete} + selectedCursorMotionId={selectedCursorMotionId} + onSelectCursorMotion={handleSelectCursorMotion} annotationRegions={annotationOnlyRegions} onAnnotationAdded={handleAnnotationAdded} onAnnotationSpanChange={handleAnnotationSpanChange} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 11c9e4061..a17fe07af 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -28,6 +28,12 @@ import { type WebcamLayoutPreset, type WebcamSizePreset, } from "@/lib/compositeLayout"; +import { + buildCursorMotionTrajectory, + type CursorMotionPoint, + type CursorMotionRegion, + sampleCursorMotionPath, +} from "@/lib/cursor/cursorMotion"; import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing"; import { createNativeCursorMotionBlurState, @@ -73,6 +79,7 @@ import { type ZoomFocus, type ZoomRegion, } from "./types"; +import { CursorMotionEditorOverlay } from "./videoPlayback/CursorMotionEditorOverlay"; import { computeCameraFullscreenProgress } from "./videoPlayback/cameraFullscreenUtils"; import { AUTO_FOLLOW_PARAMS, DEFAULT_FOCUS } from "./videoPlayback/constants"; import { advanceFollowFocus } from "./videoPlayback/cursorFollowUtils"; @@ -148,6 +155,11 @@ interface VideoPlaybackProps { showCursor?: boolean; cursorSize?: number; cursorSmoothing?: number; + cursorMotionRegions?: CursorMotionRegion[]; + selectedCursorMotionId?: string | null; + isEditingCursorMotion?: boolean; + onCursorMotionControlPointChange?: (id: string, point: CursorMotionPoint) => void; + onCursorMotionControlPointCommit?: () => void; cursorMotionBlur?: number; cursorClickBounce?: number; cursorClipToBounds?: boolean; @@ -303,6 +315,11 @@ const VideoPlayback = forwardRef( showCursor = false, cursorSize = DEFAULT_CURSOR_SETTINGS.size, cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing, + cursorMotionRegions = [], + selectedCursorMotionId = null, + isEditingCursorMotion = false, + onCursorMotionControlPointChange, + onCursorMotionControlPointCommit, cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur, cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce, cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds, @@ -329,6 +346,7 @@ const VideoPlayback = forwardRef( const [videoReady, setVideoReady] = useState(false); const [supplementalAudioPath, setSupplementalAudioPath] = useState(null); const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 }); + const [paintedRect, setPaintedRect] = useState({ x: 0, y: 0, width: 0, height: 0 }); const [overlayElement, setOverlayElement] = useState(null); const overlayRef = useRef(null); @@ -366,6 +384,7 @@ const VideoPlayback = forwardRef( const baseScaleRef = useRef(1); const baseOffsetRef = useRef({ x: 0, y: 0 }); const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); + const paintedRectRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); const maskGraphicsRef = useRef(null); const isPlayingRef = useRef(isPlaying); @@ -391,6 +410,8 @@ const VideoPlayback = forwardRef( const showCursorRef = useRef(showCursor); const cursorSizeRef = useRef(cursorSize); const cursorSmoothingRef = useRef(cursorSmoothing); + const cursorMotionRegionsRef = useRef(cursorMotionRegions); + const isEditingCursorMotionRef = useRef(isEditingCursorMotion); const cursorMotionBlurRef = useRef(cursorMotionBlur); const cursorClickBounceRef = useRef(cursorClickBounce); const cursorClipToBoundsRef = useRef(cursorClipToBounds); @@ -613,6 +634,8 @@ const VideoPlayback = forwardRef( baseScaleRef.current = result.baseScale; baseOffsetRef.current = result.baseOffset; baseMaskRef.current = result.maskRect; + paintedRectRef.current = result.paintedRect; + setPaintedRect(result.paintedRect); borderRadiusRef.current = result.maskBorderRadius; cropBoundsRef.current = result.cropBounds; webcamLayoutRef.current = result.webcamRect; @@ -828,6 +851,14 @@ const VideoPlayback = forwardRef( cursorTelemetryRef.current = cursorTelemetry; }, [cursorTelemetry]); + useEffect(() => { + cursorMotionRegionsRef.current = cursorMotionRegions; + }, [cursorMotionRegions]); + + useEffect(() => { + isEditingCursorMotionRef.current = isEditingCursorMotion; + }, [isEditingCursorMotion]); + useEffect(() => { cursorClickTimestampsRef.current = cursorClickTimestamps; }, [cursorClickTimestamps]); @@ -1495,7 +1526,8 @@ const VideoPlayback = forwardRef( const selectedId = selectedZoomIdRef.current; const hasSelectedZoom = selectedId !== null; const shouldShowUnzoomedView = - hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current; + isEditingCursorMotionRef.current || + (hasSelectedZoom && !isPlayingRef.current && !isPreviewingZoomRef.current); if (region && strength > 0 && !shouldShowUnzoomedView) { // Use getZoomScale (customScale-aware) to match export and the magnification @@ -1704,10 +1736,11 @@ const VideoPlayback = forwardRef( if (frame) { // Position comes from the precomputed offline-smoothed path; the frame still // supplies the cursor image, type, and click timing. - const smoothedPos = getSmoothedCursorPath( - cursorRecordingDataRef.current, - cursorSmoothingRef.current, - )?.sampleAt(timeMs); + const smoothedPos = sampleCursorMotionPath( + getSmoothedCursorPath(cursorRecordingDataRef.current, cursorSmoothingRef.current), + cursorMotionRegionsRef.current, + timeMs, + ); const displaySample = smoothedPos ? { ...frame.sample, cx: smoothedPos.cx, cy: smoothedPos.cy } : frame.sample; @@ -1716,7 +1749,7 @@ const VideoPlayback = forwardRef( const cropRegionValue = cropRegionRef.current ?? DEFAULT_CROP_REGION; const projectedLocalPoint = projectNativeCursorToLocal({ cropRegion: cropRegionValue, - maskRect: baseMaskRef.current, + maskRect: paintedRectRef.current, sample: displaySample, }); const projectedStagePoint = @@ -1724,7 +1757,7 @@ const VideoPlayback = forwardRef( ? projectNativeCursorToStage({ cameraContainer, cropRegion: cropRegionValue, - maskRect: baseMaskRef.current, + maskRect: paintedRectRef.current, videoContainerPosition: { x: videoContainer.x, y: videoContainer.y, @@ -1753,7 +1786,7 @@ const VideoPlayback = forwardRef( const crop = cropRegionRef.current ?? DEFAULT_CROP_REGION; const croppedVideoWidth = (videoRef.current?.videoWidth ?? 0) * crop.width; const sizeNorm = - croppedVideoWidth > 0 ? baseMaskRef.current.width / croppedVideoWidth : 1; + croppedVideoWidth > 0 ? paintedRectRef.current.width / croppedVideoWidth : 1; const transformedScale = scale * Math.abs(cameraContainer?.scale.x || 1) * sizeNorm; const blurPx = !isPlayingRef.current || isSeekingRef.current @@ -1926,6 +1959,92 @@ const VideoPlayback = forwardRef( () => getWebcamLayoutCssBoxShadow(webcamLayoutPreset), [webcamLayoutPreset], ); + const cursorMotionBasePath = useMemo( + () => getSmoothedCursorPath(cursorRecordingData, cursorSmoothing), + [cursorRecordingData, cursorSmoothing], + ); + const selectedCursorMotionRegion = useMemo( + () => + selectedCursorMotionId + ? (cursorMotionRegions.find((region) => region.id === selectedCursorMotionId) ?? null) + : null, + [cursorMotionRegions, selectedCursorMotionId], + ); + const cursorMotionEditorGeometry = useMemo(() => { + const region = selectedCursorMotionRegion; + const crop = cropRegion ?? DEFAULT_CROP_REGION; + if ( + !region || + !cursorMotionBasePath || + paintedRect.width <= 0 || + paintedRect.height <= 0 || + crop.width <= 0 || + crop.height <= 0 + ) { + return null; + } + + const project = (point: CursorMotionPoint) => ({ + x: paintedRect.x + ((point.cx - crop.x) / crop.width) * paintedRect.width, + y: paintedRect.y + ((point.cy - crop.y) / crop.height) * paintedRect.height, + }); + const trajectory = buildCursorMotionTrajectory(cursorMotionBasePath, region, 96).map(project); + const recordedTrajectory = + region.preset === "recorded" + ? [] + : Array.from({ length: 64 }, (_, index) => { + const progress = index / 63; + return cursorMotionBasePath.sampleAt( + region.startMs + (region.endMs - region.startMs) * progress, + ); + }) + .filter((point): point is CursorMotionPoint => point !== null) + .map(project); + + return { + trajectory, + recordedTrajectory, + controlPoint: project(region.controlPoint), + }; + }, [cursorMotionBasePath, cropRegion, paintedRect, selectedCursorMotionRegion]); + + const handleCursorMotionClientPoint = useCallback( + (clientX: number, clientY: number) => { + const region = selectedCursorMotionRegion; + const wrapper = outerWrapperRef.current; + const crop = cropRegion ?? DEFAULT_CROP_REGION; + if ( + !region || + !wrapper || + !onCursorMotionControlPointChange || + paintedRect.width <= 0 || + paintedRect.height <= 0 || + crop.width <= 0 || + crop.height <= 0 + ) { + return; + } + + const bounds = wrapper.getBoundingClientRect(); + if (bounds.width <= 0 || bounds.height <= 0) return; + const stageWidth = stageSizeRef.current.width || overlaySize.width; + const stageHeight = stageSizeRef.current.height || overlaySize.height; + const stageX = ((clientX - bounds.left) / bounds.width) * stageWidth; + const stageY = ((clientY - bounds.top) / bounds.height) * stageHeight; + onCursorMotionControlPointChange(region.id, { + cx: crop.x + ((stageX - paintedRect.x) / paintedRect.width) * crop.width, + cy: crop.y + ((stageY - paintedRect.y) / paintedRect.height) * crop.height, + }); + }, + [ + cropRegion, + onCursorMotionControlPointChange, + overlaySize.height, + overlaySize.width, + paintedRect, + selectedCursorMotionRegion, + ], + ); useEffect(() => { const webcamVideo = webcamVideoRef.current; @@ -2250,6 +2369,23 @@ const VideoPlayback = forwardRef( })()}
)} + {isEditingCursorMotion && + !isPlaying && + cursorMotionEditorGeometry && + selectedCursorMotionRegion && ( + onCursorMotionControlPointCommit?.()} + /> + )}
{/* Native cursor clip. Lives outside composite3DRef (preserve-3d) so clip-path keeps working during 3D zoom rotations; bounds are set dynamically. */} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 0671b3fae..04f0bacb5 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -23,7 +23,7 @@ describe("projectPersistence media compatibility", () => { }); }); - it("creates version 2 projects with explicit media", () => { + it("creates version 3 projects with explicit media", () => { const project = createProjectData( { screenVideoPath: "/tmp/screen.webm", @@ -63,6 +63,66 @@ describe("projectPersistence media compatibility", () => { expect(validateProjectData(project)).toBe(true); }); + it("normalizes cursor motion choreography safely", () => { + const editor = normalizeProjectEditor({ + cursorMotionRegions: [ + { + id: "cursor-motion-1", + startMs: 900, + endMs: 100, + startPoint: { cx: -2, cy: 0.4 }, + endPoint: { cx: 0.8, cy: 4 }, + startAnchorKind: "rest", + endAnchorKind: "click", + segmentKind: "hold", + preset: "wave", + controlPoint: { cx: 2, cy: -1 }, + cycles: 99, + speed: 99, + easing: "ease-in-out", + }, + { + id: "cursor-motion-2", + startMs: 0, + endMs: 500, + startAnchorKind: "invalid" as never, + endAnchorKind: "invalid" as never, + segmentKind: "invalid" as never, + preset: "invalid" as never, + controlPoint: { cx: Number.NaN, cy: 0.25 }, + cycles: Number.NaN, + speed: Number.NaN, + easing: "invalid" as never, + }, + ], + }); + + expect(editor.cursorMotionRegions[0]).toMatchObject({ + startMs: 100, + endMs: 900, + startPoint: { cx: 0, cy: 0.4 }, + endPoint: { cx: 0.8, cy: 1 }, + startAnchorKind: "rest", + endAnchorKind: "click", + segmentKind: "hold", + preset: "wave", + controlPoint: { cx: 1, cy: 0 }, + cycles: 6, + speed: 4, + easing: "ease-in-out", + }); + expect(editor.cursorMotionRegions[1]).toMatchObject({ + startAnchorKind: "manual", + endAnchorKind: "click", + segmentKind: "move", + preset: "recorded", + controlPoint: { cx: 0.5, cy: 0.25 }, + cycles: 1, + speed: 1, + easing: "ease-in-out", + }); + }); + it("normalizes webcam mask shape values safely", () => { expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded"); expect( diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 4727b685e..74dbee411 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,5 +1,15 @@ import { normalizeTextAnimation } from "@/lib/annotationTextAnimation"; import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; +import { + type CursorMotionRegion, + clampCursorMotionCycles, + clampCursorMotionPoint, + clampCursorMotionSpeed, + isCursorMotionAnchorKind, + isCursorMotionEasing, + isCursorMotionPreset, + isCursorMotionSegmentKind, +} from "@/lib/cursor/cursorMotion"; import { normalizeCursorThemeId } from "@/lib/cursor/cursorThemes"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; @@ -63,7 +73,7 @@ function normalizeWallpaperValue(value: string): string { return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER; } -export const PROJECT_VERSION = 2; +export const PROJECT_VERSION = 5; export interface ProjectEditorState { wallpaper: string; @@ -75,6 +85,7 @@ export interface ProjectEditorState { padding: number; cropRegion: CropRegion; zoomRegions: ZoomRegion[]; + cursorMotionRegions: CursorMotionRegion[]; cameraFullscreenRegions: CameraFullscreenRegion[]; autoZoomEnabled: boolean; autoFocusAll: boolean; @@ -273,6 +284,54 @@ export function normalizeProjectEditor(editor: Partial): Pro }) : []; + const normalizedCursorMotionRegions: CursorMotionRegion[] = Array.isArray( + editor.cursorMotionRegions, + ) + ? editor.cursorMotionRegions + .filter((region): region is CursorMotionRegion => + Boolean(region && typeof region.id === "string"), + ) + .map((region) => { + const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0; + const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000; + const startMs = Math.max(0, Math.min(rawStart, rawEnd)); + const endMs = Math.max(startMs + 1, Math.max(rawStart, rawEnd)); + const startPoint = + region.startPoint && typeof region.startPoint === "object" + ? clampCursorMotionPoint(region.startPoint) + : undefined; + const endPoint = + region.endPoint && typeof region.endPoint === "object" + ? clampCursorMotionPoint(region.endPoint) + : undefined; + return { + id: region.id, + startMs, + endMs, + ...(startPoint ? { startPoint } : {}), + ...(endPoint ? { endPoint } : {}), + startAnchorKind: isCursorMotionAnchorKind(region.startAnchorKind) + ? region.startAnchorKind + : "manual", + endAnchorKind: isCursorMotionAnchorKind(region.endAnchorKind) + ? region.endAnchorKind + : "click", + segmentKind: isCursorMotionSegmentKind(region.segmentKind) + ? region.segmentKind + : "move", + preset: isCursorMotionPreset(region.preset) ? region.preset : "recorded", + controlPoint: clampCursorMotionPoint( + region.controlPoint && typeof region.controlPoint === "object" + ? region.controlPoint + : { cx: 0.5, cy: 0.35 }, + ), + cycles: clampCursorMotionCycles(region.cycles), + speed: clampCursorMotionSpeed(region.speed), + easing: isCursorMotionEasing(region.easing) ? region.easing : "ease-in-out", + }; + }) + : []; + const normalizedCameraFullscreenRegions: CameraFullscreenRegion[] = Array.isArray( editor.cameraFullscreenRegions, ) @@ -506,6 +565,7 @@ export function normalizeProjectEditor(editor: Partial): Pro height: cropHeight, }, zoomRegions: normalizedZoomRegions, + cursorMotionRegions: normalizedCursorMotionRegions, cameraFullscreenRegions: normalizedCameraFullscreenRegions, // Default on for legacy projects so re-opens match the new default. The // on-load auto-suggest pass is gated separately, so this won't add zooms. diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index ec4ca084d..b209790b3 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,6 +1,14 @@ import type { Span } from "dnd-timeline"; import { useItem } from "dnd-timeline"; -import { Gauge, Maximize, MessageSquare, MousePointer2, Scissors, ZoomIn } from "lucide-react"; +import { + Gauge, + Maximize, + MessageSquare, + MousePointer2, + Scissors, + Spline, + ZoomIn, +} from "lucide-react"; import { useMemo } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; @@ -17,7 +25,15 @@ interface ItemProps { zoomCustomScale?: number; speedValue?: number; isAutoFocus?: boolean; - variant?: "zoom" | "camera-fullscreen" | "trim" | "annotation" | "speed" | "blur"; + disabled?: boolean; + variant?: + | "zoom" + | "camera-fullscreen" + | "trim" + | "annotation" + | "speed" + | "blur" + | "cursor-motion"; } // Map zoom depth to multiplier labels @@ -50,6 +66,7 @@ export default function Item({ zoomCustomScale, speedValue, isAutoFocus = false, + disabled = false, variant = "zoom", children, }: ItemProps) { @@ -57,6 +74,7 @@ export default function Item({ const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, + disabled, data: { rowId }, }); @@ -64,6 +82,7 @@ export default function Item({ const isCameraFullscreen = variant === "camera-fullscreen"; const isTrim = variant === "trim"; const isSpeed = variant === "speed"; + const isCursorMotion = variant === "cursor-motion"; const glassClass = isZoom ? glassStyles.glassGreen @@ -73,7 +92,9 @@ export default function Item({ ? glassStyles.glassRed : isSpeed ? glassStyles.glassAmber - : glassStyles.glassYellow; + : isCursorMotion + ? glassStyles.glassPurple + : glassStyles.glassYellow; const endCapColor = isZoom ? "#21916A" @@ -83,7 +104,9 @@ export default function Item({ ? "#ef4444" : isSpeed ? "#d97706" - : "#B4A046"; + : isCursorMotion + ? "#8b5cf6" + : "#B4A046"; const timeLabel = useMemo( () => `${formatMs(span.start)} – ${formatMs(span.end)}`, @@ -108,7 +131,8 @@ export default function Item({
-
-
+ {!disabled && ( + <> +
+
+ + )} {/* Content */}
@@ -178,6 +206,13 @@ export default function Item({ {speedValue !== undefined ? `${speedValue}×` : t("labels.speed")} + ) : isCursorMotion ? ( + <> + + + {children} + + ) : ( <> diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 94fcbadee..4d66ca362 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -138,6 +138,34 @@ z-index: 10; } +.glassPurple { + position: relative; + border-radius: 10px; + -corner-smoothing: antialiased; + background: linear-gradient(180deg, rgba(139, 92, 246, 0.3), rgba(91, 33, 182, 0.2)); + border: 1px solid rgba(167, 139, 250, 0.42); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.1) inset, + 0 8px 22px rgba(0, 0, 0, 0.22); + margin: 3px 0; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glassPurple:hover { + background: linear-gradient(180deg, rgba(139, 92, 246, 0.42), rgba(91, 33, 182, 0.28)); + border-color: rgba(196, 181, 253, 0.72); +} + +.glassPurple.selected { + background: linear-gradient(180deg, rgba(139, 92, 246, 0.55), rgba(91, 33, 182, 0.38)); + border-color: #a78bfa; + box-shadow: + 0 0 0 1px rgba(167, 139, 250, 0.95), + 0 0 0 4px rgba(139, 92, 246, 0.16), + 0 12px 26px rgba(0, 0, 0, 0.28); + z-index: 10; +} + .zoomEndCap { position: absolute; top: 0; @@ -160,7 +188,9 @@ .glassAmber:hover .zoomEndCap, .glassAmber.selected .zoomEndCap, .glassBlue:hover .zoomEndCap, -.glassBlue.selected .zoomEndCap { +.glassBlue.selected .zoomEndCap, +.glassPurple:hover .zoomEndCap, +.glassPurple.selected .zoomEndCap { opacity: 1; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index b6e323a31..e99c16634 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -26,6 +26,7 @@ import { import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; +import { type CursorMotionRegion, DEFAULT_CURSOR_MOTION_SPEED } from "@/lib/cursor/cursorMotion"; import { isTextEditingTarget, matchesShortcut } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils"; @@ -51,6 +52,7 @@ const TRIM_ROW_ID = "row-trim"; const ANNOTATION_ROW_ID = "row-annotation"; const BLUR_ROW_ID = "row-blur"; const SPEED_ROW_ID = "row-speed"; +const CURSOR_MOTION_ROW_ID = "row-cursor-motion"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; @@ -101,6 +103,11 @@ interface TimelineEditorProps { onSpeedDelete?: (id: string) => void; selectedSpeedId?: string | null; onSelectSpeed?: (id: string | null) => void; + cursorMotionRegions?: CursorMotionRegion[]; + onCursorMotionAdded?: (span: Span) => void; + onCursorMotionDelete?: (id: string) => void; + selectedCursorMotionId?: string | null; + onSelectCursorMotion?: (id: string | null) => void; aspectRatio: AspectRatio; onAspectRatioChange: (aspectRatio: AspectRatio) => void; videoUrl?: string; @@ -127,7 +134,14 @@ interface TimelineRenderItem { zoomCustomScale?: number; speedValue?: number; isAutoFocus?: boolean; - variant: "zoom" | "camera-fullscreen" | "trim" | "annotation" | "speed" | "blur"; + variant: + | "zoom" + | "camera-fullscreen" + | "trim" + | "annotation" + | "speed" + | "blur" + | "cursor-motion"; } const SCALE_CANDIDATES = [ @@ -579,12 +593,14 @@ function Timeline({ onSelectAnnotation, onSelectBlur, onSelectSpeed, + onSelectCursorMotion, selectedZoomId, selectedCameraFullscreenId, selectedTrimId, selectedAnnotationId, selectedBlurId, selectedSpeedId, + selectedCursorMotionId, keyframes = [], videoUrl, showTrimWaveform = false, @@ -600,12 +616,14 @@ function Timeline({ onSelectAnnotation?: (id: string | null) => void; onSelectBlur?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; + onSelectCursorMotion?: (id: string | null) => void; selectedZoomId: string | null; selectedCameraFullscreenId?: string | null; selectedTrimId?: string | null; selectedAnnotationId?: string | null; selectedBlurId?: string | null; selectedSpeedId?: string | null; + selectedCursorMotionId?: string | null; keyframes?: { id: string; time: number }[]; videoUrl?: string; showTrimWaveform?: boolean; @@ -650,6 +668,7 @@ function Timeline({ onSelectAnnotation?.(null); onSelectBlur?.(null); onSelectSpeed?.(null); + onSelectCursorMotion?.(null); }, [ onSelectZoom, onSelectCameraFullscreen, @@ -657,6 +676,7 @@ function Timeline({ onSelectAnnotation, onSelectBlur, onSelectSpeed, + onSelectCursorMotion, ]); const handleTimelineClick = useCallback( @@ -777,6 +797,7 @@ function Timeline({ const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID); const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID); const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID); + const cursorMotionItems = items.filter((item) => item.rowId === CURSOR_MOTION_ROW_ID); return (
))} + + + {cursorMotionItems.map((item) => ( + onSelectCursorMotion?.(item.id)} + variant="cursor-motion" + disabled + > + {item.label} + + ))} +
); } @@ -975,6 +1017,11 @@ export default function TimelineEditor({ onSpeedDelete, selectedSpeedId, onSelectSpeed, + cursorMotionRegions = [], + onCursorMotionAdded, + onCursorMotionDelete, + selectedCursorMotionId, + onSelectCursorMotion, aspectRatio, onAspectRatioChange, videoUrl, @@ -1073,6 +1120,12 @@ export default function TimelineEditor({ onSelectSpeed(null); }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]); + const deleteSelectedCursorMotion = useCallback(() => { + if (!selectedCursorMotionId || !onCursorMotionDelete || !onSelectCursorMotion) return; + onCursorMotionDelete(selectedCursorMotionId); + onSelectCursorMotion(null); + }, [selectedCursorMotionId, onCursorMotionDelete, onSelectCursorMotion]); + useEffect(() => { setRange(createInitialRange(totalMs)); }, [totalMs]); @@ -1318,6 +1371,18 @@ export default function TimelineEditor({ t, ]); + const handleAddCursorMotion = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onCursorMotionAdded) { + return; + } + + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + onCursorMotionAdded({ + start: startPos, + end: Math.min(totalMs, startPos + defaultRegionDurationMs), + }); + }, [currentTimeMs, defaultRegionDurationMs, onCursorMotionAdded, totalMs, videoDuration]); + const handleAddAnnotation = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { return; @@ -1419,6 +1484,8 @@ export default function TimelineEditor({ deleteSelectedBlur(); } else if (selectedSpeedId) { deleteSelectedSpeed(); + } else if (selectedCursorMotionId) { + deleteSelectedCursorMotion(); } } }; @@ -1439,6 +1506,7 @@ export default function TimelineEditor({ deleteSelectedAnnotation, deleteSelectedBlur, deleteSelectedSpeed, + deleteSelectedCursorMotion, selectedKeyframeId, selectedZoomId, selectedCameraFullscreenId, @@ -1446,6 +1514,7 @@ export default function TimelineEditor({ selectedAnnotationId, selectedBlurId, selectedSpeedId, + selectedCursorMotionId, annotationRegions, currentTime, onSelectAnnotation, @@ -1532,7 +1601,28 @@ export default function TimelineEditor({ variant: "speed", })); - return [...zooms, ...cameraFullscreens, ...trims, ...annotations, ...blurs, ...speeds]; + const cursorMotions: TimelineRenderItem[] = cursorMotionRegions.map((region, index) => ({ + id: region.id, + rowId: CURSOR_MOTION_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: + region.segmentKind === "hold" + ? `${t("cursorMotion.segmentKinds.hold")} ${index + 1}` + : region.preset === "recorded" + ? `${t("cursorMotion.presets.recorded")} ${index + 1}` + : `${t(`cursorMotion.presets.${region.preset}`)} ${region.speed ?? DEFAULT_CURSOR_MOTION_SPEED}× ${index + 1}`, + variant: "cursor-motion", + })); + + return [ + ...zooms, + ...cameraFullscreens, + ...trims, + ...annotations, + ...blurs, + ...speeds, + ...cursorMotions, + ]; }, [ zoomRegions, cameraFullscreenRegions, @@ -1540,6 +1630,7 @@ export default function TimelineEditor({ annotationRegions, blurRegions, speedRegions, + cursorMotionRegions, t, ]); @@ -1718,6 +1809,17 @@ export default function TimelineEditor({ > + {onCursorMotionAdded && ( + + )} {onGenerateCaptions && (