From c4e9d35bb9513ac7be3ede061a8ef5bf333ef686 Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:16:15 +0900 Subject: [PATCH 1/4] feat(editor): add editable cursor motion paths --- src/components/video-editor/SettingsPanel.tsx | 179 +++++++++++++- src/components/video-editor/VideoEditor.tsx | 222 ++++++++++++++++- src/components/video-editor/VideoPlayback.tsx | 146 +++++++++++- .../video-editor/projectPersistence.test.ts | 46 +++- .../video-editor/projectPersistence.ts | 49 +++- src/components/video-editor/timeline/Item.tsx | 89 ++++--- .../timeline/ItemGlass.module.css | 32 ++- .../video-editor/timeline/TimelineEditor.tsx | 104 +++++++- .../CursorMotionEditorOverlay.test.tsx | 61 +++++ .../CursorMotionEditorOverlay.tsx | 152 ++++++++++++ .../video-editor/videoPlayback/layoutUtils.ts | 8 + src/hooks/useEditorHistory.ts | 3 + src/i18n/locales/ar/settings.json | 26 ++ src/i18n/locales/ar/timeline.json | 11 + src/i18n/locales/en/settings.json | 26 ++ src/i18n/locales/en/timeline.json | 11 + src/i18n/locales/es/settings.json | 26 ++ src/i18n/locales/es/timeline.json | 11 + src/i18n/locales/fr/settings.json | 26 ++ src/i18n/locales/fr/timeline.json | 11 + src/i18n/locales/it/settings.json | 26 ++ src/i18n/locales/it/timeline.json | 11 + src/i18n/locales/ja-JP/settings.json | 26 ++ src/i18n/locales/ja-JP/timeline.json | 11 + src/i18n/locales/ko-KR/settings.json | 26 ++ src/i18n/locales/ko-KR/timeline.json | 11 + src/i18n/locales/pt-BR/settings.json | 26 ++ src/i18n/locales/pt-BR/timeline.json | 11 + src/i18n/locales/ru/settings.json | 26 ++ src/i18n/locales/ru/timeline.json | 11 + src/i18n/locales/tr/settings.json | 26 ++ src/i18n/locales/tr/timeline.json | 11 + src/i18n/locales/vi/settings.json | 26 ++ src/i18n/locales/vi/timeline.json | 11 + src/i18n/locales/zh-CN/settings.json | 26 ++ src/i18n/locales/zh-CN/timeline.json | 11 + src/i18n/locales/zh-TW/settings.json | 26 ++ src/i18n/locales/zh-TW/timeline.json | 11 + src/lib/cursor/cursorMotion.test.ts | 121 ++++++++++ src/lib/cursor/cursorMotion.ts | 225 ++++++++++++++++++ src/lib/exporter/frameRenderer.ts | 11 +- src/lib/exporter/gifExporter.ts | 3 + src/lib/exporter/videoExporter.ts | 3 + 43 files changed, 1888 insertions(+), 47 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx create mode 100644 src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx create mode 100644 src/lib/cursor/cursorMotion.test.ts create mode 100644 src/lib/cursor/cursorMotion.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b80ac021d..0e2e939d6 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -14,6 +14,7 @@ import { Palette, SlidersHorizontal, Sparkles, + Spline, Star, Trash2, Unlock, @@ -44,6 +45,13 @@ 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, + 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 +331,14 @@ 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; + onCursorMotionCommit?: () => void; + onCursorMotionDelete?: (id: string) => void; hasWebcam?: boolean; webcamLayoutPreset?: WebcamLayoutPreset; onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; @@ -365,6 +381,36 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ { depth: 6, label: "5×" }, ]; +const CURSOR_MOTION_PREVIEW_PATHS: Record = { + 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 +505,14 @@ export function SettingsPanel({ selectedSpeedValue, onSpeedChange, onSpeedDelete, + selectedCursorMotionRegion, + isEditingCursorMotion = false, + onCursorMotionEditingChange, + onCursorMotionPresetChange, + onCursorMotionEasingChange, + onCursorMotionCyclesChange, + onCursorMotionCommit, + onCursorMotionDelete, hasWebcam = false, webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset, onWebcamLayoutPresetChange, @@ -643,7 +697,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 +731,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 +1226,123 @@ export function SettingsPanel({ )} + {selectedCursorMotionRegion && ( +
+
+
+
+ + {t("cursorMotion.title")} +
+

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

+
+ +
+ +
+ + {t("cursorMotion.preset")} + +
+ {CURSOR_MOTION_PRESETS.map((preset) => { + const isActive = selectedCursorMotionRegion.preset === preset; + return ( + + ); + })} +
+
+ + {(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")} +
+ +
+ )} + {selectedSpeedId && (
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b49197da4..bac42bdf8 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -34,6 +34,17 @@ import { transcribeMono16kToSegments, trimLeadingSilenceMono16k, } from "@/lib/captioning"; +import { + type CursorMotionEasing, + type CursorMotionPoint, + type CursorMotionPreset, + type CursorMotionRegion, + clampCursorMotionCycles, + clampCursorMotionPoint, + createDefaultCursorMotionControlPoint, + resolveCursorMotionClickAnchoredSpan, +} from "@/lib/cursor/cursorMotion"; +import { getSmoothedCursorPath } from "@/lib/cursor/cursorPathSmoothing"; import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor"; import { calculateEffectiveSourceDimensions, @@ -206,6 +217,7 @@ export default function VideoEditor() { const { zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -245,6 +257,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); @@ -316,6 +330,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 +345,23 @@ 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], + ); + 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 +425,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 +463,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 +486,8 @@ export default function VideoEditor() { setCursorTheme(normalizedEditor.cursorTheme); setSelectedZoomId(null); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedCameraFullscreenId(null); setSelectedTrimId(null); setSelectedSpeedId(null); @@ -462,6 +498,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 +553,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -545,6 +586,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -674,6 +716,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -740,6 +783,7 @@ export default function VideoEditor() { padding, cropRegion, zoomRegions, + cursorMotionRegions, cameraFullscreenRegions, autoZoomEnabled, autoFocusAll, @@ -878,6 +922,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 +942,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 +1054,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 +1081,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 +1094,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 +1107,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 +1120,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 +1155,125 @@ export default function VideoEditor() { [pushState, autoFocusAll], ); + const handleCursorMotionAdded = useCallback( + (span: Span) => { + const anchoredSpan = resolveCursorMotionClickAnchoredSpan(span.start, cursorClickTimestamps); + if (!anchoredSpan) { + toast.error(ts("cursorMotion.noFollowingClick")); + return; + } + const { startMs, endMs } = anchoredSpan; + const start = cursorMotionBasePath?.sampleAt(startMs) ?? null; + const clickSample = cursorRecordingData?.samples.find( + (sample) => + isClickInteractionType(sample.interactionType) && + Math.abs(Math.round(sample.timeMs) - endMs) <= 1, + ); + const end = clickSample + ? { cx: clickSample.cx, cy: clickSample.cy } + : (cursorMotionBasePath?.sampleAt(endMs) ?? null); + if (!start || !end) { + toast.error(ts("cursorMotion.noCursorData")); + return; + } + + const id = `cursor-motion-${nextCursorMotionIdRef.current++}`; + const newRegion: CursorMotionRegion = { + id, + startMs, + endMs, + startPoint: start, + endPoint: end, + preset: "wave", + controlPoint: createDefaultCursorMotionControlPoint(start, end), + cycles: 2, + easing: "ease-in-out", + }; + pushState((prev) => ({ + cursorMotionRegions: [...prev.cursorMotionRegions, newRegion], + })); + handleSelectCursorMotion(id); + }, + [ + cursorClickTimestamps, + cursorMotionBasePath, + cursorRecordingData, + handleSelectCursorMotion, + pushState, + 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 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 +1573,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 +1596,8 @@ export default function VideoEditor() { speedRegions: [...prev.speedRegions, newRegion], })); setSelectedSpeedId(id); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); setSelectedZoomId(null); setSelectedTrimId(null); setSelectedAnnotationId(null); @@ -2245,6 +2439,7 @@ export default function VideoEditor() { cursorRecordingData, cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, + cursorMotionRegions, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, @@ -2340,6 +2535,7 @@ export default function VideoEditor() { cursorRecordingData, cursorScale: effectiveShowCursor ? cursorSize : 0, cursorSmoothing, + cursorMotionRegions, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, @@ -2462,6 +2658,7 @@ export default function VideoEditor() { effectiveShowCursor, cursorSize, cursorSmoothing, + cursorMotionRegions, cursorMotionBlur, cursorClickBounce, cursorClipToBounds, @@ -2963,7 +3160,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 +3200,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 +3359,8 @@ export default function VideoEditor() { setSelectedZoomId(null); setSelectedTrimId(null); setSelectedSpeedId(null); + setSelectedCursorMotionId(null); + setIsEditingCursorMotion(false); }} selectedAnnotationId={selectedAnnotationId} annotationRegions={annotationOnlyRegions} @@ -3176,6 +3383,14 @@ export default function VideoEditor() { } onSpeedChange={handleSpeedChange} onSpeedDelete={handleSpeedDelete} + selectedCursorMotionRegion={selectedCursorMotionRegion} + isEditingCursorMotion={isEditingCursorMotion} + onCursorMotionEditingChange={handleCursorMotionEditingChange} + onCursorMotionPresetChange={handleCursorMotionPresetChange} + onCursorMotionEasingChange={handleCursorMotionEasingChange} + onCursorMotionCyclesChange={handleCursorMotionCyclesChange} + onCursorMotionCommit={handleCursorMotionCommit} + onCursorMotionDelete={handleCursorMotionDelete} unsavedExport={unsavedExport} onSaveUnsavedExport={handleSaveUnsavedExport} onSaveDiagnostic={handleSaveDiagnostic} @@ -3242,6 +3457,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..c752956c6 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,89 @@ 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 = 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 +2366,20 @@ 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..6557f408c 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,50 @@ 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 }, + preset: "wave", + controlPoint: { cx: 2, cy: -1 }, + cycles: 99, + easing: "ease-in-out", + }, + { + id: "cursor-motion-2", + startMs: 0, + endMs: 500, + preset: "invalid" as never, + controlPoint: { cx: Number.NaN, cy: 0.25 }, + cycles: 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 }, + preset: "wave", + controlPoint: { cx: 1, cy: 0 }, + cycles: 6, + easing: "ease-in-out", + }); + expect(editor.cursorMotionRegions[1]).toMatchObject({ + preset: "arc", + controlPoint: { cx: 0.5, cy: 0.25 }, + cycles: 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..4b52c65f8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,5 +1,12 @@ import { normalizeTextAnimation } from "@/lib/annotationTextAnimation"; import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects"; +import { + type CursorMotionRegion, + clampCursorMotionCycles, + clampCursorMotionPoint, + isCursorMotionEasing, + isCursorMotionPreset, +} 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 +70,7 @@ function normalizeWallpaperValue(value: string): string { return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER; } -export const PROJECT_VERSION = 2; +export const PROJECT_VERSION = 3; export interface ProjectEditorState { wallpaper: string; @@ -75,6 +82,7 @@ export interface ProjectEditorState { padding: number; cropRegion: CropRegion; zoomRegions: ZoomRegion[]; + cursorMotionRegions: CursorMotionRegion[]; cameraFullscreenRegions: CameraFullscreenRegion[]; autoZoomEnabled: boolean; autoFocusAll: boolean; @@ -273,6 +281,44 @@ 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 } : {}), + preset: isCursorMotionPreset(region.preset) ? region.preset : "arc", + controlPoint: clampCursorMotionPoint( + region.controlPoint && typeof region.controlPoint === "object" + ? region.controlPoint + : { cx: 0.5, cy: 0.35 }, + ), + cycles: clampCursorMotionCycles(region.cycles), + easing: isCursorMotionEasing(region.easing) ? region.easing : "ease-in-out", + }; + }) + : []; + const normalizedCameraFullscreenRegions: CameraFullscreenRegion[] = Array.isArray( editor.cameraFullscreenRegions, ) @@ -506,6 +552,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..6be3e9229 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -7,6 +7,7 @@ import { Gauge, Maximize, MessageSquare, + MousePointer2, Plus, ScanEye, Scissors, @@ -26,6 +27,7 @@ import { import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; +import type { CursorMotionRegion } 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 +53,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 +104,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 +135,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 +594,14 @@ function Timeline({ onSelectAnnotation, onSelectBlur, onSelectSpeed, + onSelectCursorMotion, selectedZoomId, selectedCameraFullscreenId, selectedTrimId, selectedAnnotationId, selectedBlurId, selectedSpeedId, + selectedCursorMotionId, keyframes = [], videoUrl, showTrimWaveform = false, @@ -600,12 +617,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 +669,7 @@ function Timeline({ onSelectAnnotation?.(null); onSelectBlur?.(null); onSelectSpeed?.(null); + onSelectCursorMotion?.(null); }, [ onSelectZoom, onSelectCameraFullscreen, @@ -657,6 +677,7 @@ function Timeline({ onSelectAnnotation, onSelectBlur, onSelectSpeed, + onSelectCursorMotion, ]); const handleTimelineClick = useCallback( @@ -777,6 +798,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 +1018,11 @@ export default function TimelineEditor({ onSpeedDelete, selectedSpeedId, onSelectSpeed, + cursorMotionRegions = [], + onCursorMotionAdded, + onCursorMotionDelete, + selectedCursorMotionId, + onSelectCursorMotion, aspectRatio, onAspectRatioChange, videoUrl, @@ -1073,6 +1121,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 +1372,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 +1485,8 @@ export default function TimelineEditor({ deleteSelectedBlur(); } else if (selectedSpeedId) { deleteSelectedSpeed(); + } else if (selectedCursorMotionId) { + deleteSelectedCursorMotion(); } } }; @@ -1439,6 +1507,7 @@ export default function TimelineEditor({ deleteSelectedAnnotation, deleteSelectedBlur, deleteSelectedSpeed, + deleteSelectedCursorMotion, selectedKeyframeId, selectedZoomId, selectedCameraFullscreenId, @@ -1446,6 +1515,7 @@ export default function TimelineEditor({ selectedAnnotationId, selectedBlurId, selectedSpeedId, + selectedCursorMotionId, annotationRegions, currentTime, onSelectAnnotation, @@ -1532,7 +1602,23 @@ 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: `${t(`cursorMotion.presets.${region.preset}`)} ${index + 1}`, + variant: "cursor-motion", + })); + + return [ + ...zooms, + ...cameraFullscreens, + ...trims, + ...annotations, + ...blurs, + ...speeds, + ...cursorMotions, + ]; }, [ zoomRegions, cameraFullscreenRegions, @@ -1540,6 +1626,7 @@ export default function TimelineEditor({ annotationRegions, blurRegions, speedRegions, + cursorMotionRegions, t, ]); @@ -1718,6 +1805,17 @@ export default function TimelineEditor({ > + {onCursorMotionAdded && ( + + )} {onGenerateCaptions && ( + )} {onGenerateCaptions && ( diff --git a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx index 4082eb928..a2a2bb6dd 100644 --- a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx +++ b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx @@ -20,9 +20,31 @@ describe("CursorMotionEditorOverlay", () => { ); expect(screen.getByLabelText("Cursor motion path editor")).not.toBeNull(); + expect(screen.getByLabelText("Manual cursor anchor")).not.toBeNull(); + expect(screen.getByLabelText("Recorded click anchor")).not.toBeNull(); expect(screen.getByLabelText("Motion curve handle").getAttribute("cx")).toBe("400"); }); + it("shows cursor stops as distinct square anchors", () => { + render( + , + ); + + expect(screen.getAllByLabelText("Cursor stop anchor")).toHaveLength(2); + }); + it("previews pointer movement and commits once when released", () => { const onChange = vi.fn(); const onCommit = vi.fn(); diff --git a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx index 9644fd539..7d398ac40 100644 --- a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx +++ b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx @@ -1,4 +1,5 @@ import { useMemo, useRef } from "react"; +import type { CursorMotionAnchorKind } from "@/lib/cursor/cursorMotion"; export interface CursorMotionEditorPoint { x: number; @@ -11,6 +12,8 @@ interface CursorMotionEditorOverlayProps { trajectory: readonly CursorMotionEditorPoint[]; recordedTrajectory?: readonly CursorMotionEditorPoint[]; controlPoint: CursorMotionEditorPoint; + startAnchorKind?: CursorMotionAnchorKind; + endAnchorKind?: CursorMotionAnchorKind; onControlPointChange: (clientX: number, clientY: number) => void; onControlPointCommit: () => void; } @@ -19,12 +22,59 @@ function pointsAttribute(points: readonly CursorMotionEditorPoint[]) { return points.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" "); } +function CursorMotionAnchorMarker({ + point, + kind, +}: { + point: CursorMotionEditorPoint; + kind: CursorMotionAnchorKind; +}) { + const label = + kind === "click" + ? "Recorded click anchor" + : kind === "rest" + ? "Cursor stop anchor" + : "Manual cursor anchor"; + return ( + + {kind === "rest" ? ( + + ) : ( + <> + + {kind === "click" && } + + )} + + ); +} + export function CursorMotionEditorOverlay({ width, height, trajectory, recordedTrajectory = [], controlPoint, + startAnchorKind = "manual", + endAnchorKind = "click", onControlPointChange, onControlPointCommit, }: CursorMotionEditorOverlayProps) { @@ -97,24 +147,8 @@ export function CursorMotionEditorOverlay({ strokeDasharray="3 4" vectorEffect="non-scaling-stroke" /> - - + + { }); expect(resolveCursorMotionClickAnchoredSpan(1800, [500, 900, 1800])).toBeNull(); }); + + it("builds an editable range through every recorded click after the playhead", () => { + expect(resolveCursorMotionClickAnchoredRange(500.4, [1800, 500, 900])).toEqual({ + startMs: 500, + endMs: 1800, + }); + expect(resolveCursorMotionClickAnchoredRange(1800, [500, 900, 1800])).toBeNull(); + }); + + it("splits a range into independent sections at every click", () => { + const segments = buildCursorMotionSegments({ + startMs: 0, + endMs: 1000, + path: linearPath(), + samples: [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 250, cx: 0.3, cy: 0.5 }, + { timeMs: 500, cx: 0.5, cy: 0.5, interactionType: "click" }, + { timeMs: 750, cx: 0.7, cy: 0.5 }, + { timeMs: 1000, cx: 0.9, cy: 0.5, interactionType: "click" }, + ], + }); + + expect(segments.map((segment) => [segment.startMs, segment.endMs])).toEqual([ + [0, 500], + [500, 1000], + ]); + expect(segments[0].endAnchorKind).toBe("click"); + expect(segments[1].startAnchorKind).toBe("click"); + }); + + it("keeps a detected cursor stop as its own hold section", () => { + const segments = buildCursorMotionSegments({ + startMs: 0, + endMs: 1000, + path: linearPath(), + samples: [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 100, cx: 0.25, cy: 0.5 }, + { timeMs: 200, cx: 0.4, cy: 0.5 }, + { timeMs: 350, cx: 0.4, cy: 0.5 }, + { timeMs: 500, cx: 0.401, cy: 0.5 }, + { timeMs: 650, cx: 0.4, cy: 0.5 }, + { timeMs: 800, cx: 0.7, cy: 0.5 }, + { timeMs: 1000, cx: 0.9, cy: 0.5, interactionType: "click" }, + ], + }); + + expect( + segments.map((segment) => [segment.startMs, segment.endMs, segment.segmentKind]), + ).toEqual([ + [0, 200, "move"], + [200, 650, "hold"], + [650, 1000, "move"], + ]); + expect(segments[0].endAnchorKind).toBe("rest"); + expect(segments[2].startAnchorKind).toBe("rest"); + }); + + it("preserves a click inside a stop as a separate boundary", () => { + const segments = buildCursorMotionSegments({ + startMs: 0, + endMs: 1000, + path: linearPath(), + samples: [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 200, cx: 0.4, cy: 0.5 }, + { timeMs: 350, cx: 0.4, cy: 0.5 }, + { timeMs: 500, cx: 0.4, cy: 0.5, interactionType: "click" }, + { timeMs: 650, cx: 0.4, cy: 0.5 }, + { timeMs: 800, cx: 0.7, cy: 0.5 }, + { timeMs: 1000, cx: 0.9, cy: 0.5, interactionType: "click" }, + ], + }); + + expect(segments.map((segment) => [segment.startMs, segment.endMs])).toEqual([ + [0, 200], + [200, 500], + [500, 650], + [650, 1000], + ]); + expect(segments[1].endAnchorKind).toBe("click"); + expect(segments[2].startAnchorKind).toBe("click"); + expect(segments[1].segmentKind).toBe("hold"); + expect(segments[2].segmentKind).toBe("hold"); + }); + + it("does not mistake a cursor telemetry gap for a stop", () => { + const segments = buildCursorMotionSegments({ + startMs: 0, + endMs: 1000, + path: linearPath(), + samples: [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 100, cx: 0.4, cy: 0.5 }, + { timeMs: 700, cx: 0.4, cy: 0.5 }, + { timeMs: 1000, cx: 0.9, cy: 0.5, interactionType: "click" }, + ], + }); + + expect(segments).toHaveLength(1); + expect(segments[0].segmentKind).toBe("move"); + }); + + it("manually splits one section into two independently editable sections", () => { + const result = splitCursorMotionRegionAtTime({ + region: region({ + startPoint: start, + endPoint: end, + startAnchorKind: "rest", + endAnchorKind: "click", + }), + splitMs: 600, + splitPoint: { cx: 0.5, cy: 0.5 }, + startPoint: start, + endPoint: end, + rightId: "motion-2", + }); + + expect(result?.[0]).toMatchObject({ + id: "motion-1", + endMs: 600, + endAnchorKind: "manual", + }); + expect(result?.[1]).toMatchObject({ + id: "motion-2", + startMs: 600, + startAnchorKind: "manual", + endAnchorKind: "click", + }); + expect( + splitCursorMotionRegionAtTime({ + region: region(), + splitMs: 100, + splitPoint: start, + startPoint: start, + endPoint: end, + rightId: "motion-2", + }), + ).toBeNull(); + }); }); diff --git a/src/lib/cursor/cursorMotion.ts b/src/lib/cursor/cursorMotion.ts index 125fae478..ad3236e67 100644 --- a/src/lib/cursor/cursorMotion.ts +++ b/src/lib/cursor/cursorMotion.ts @@ -11,12 +11,39 @@ export interface CursorMotionPoint { cy: number; } +export const CURSOR_MOTION_ANCHOR_KINDS = ["manual", "rest", "click"] as const; + +export type CursorMotionAnchorKind = (typeof CURSOR_MOTION_ANCHOR_KINDS)[number]; + +export const CURSOR_MOTION_SEGMENT_KINDS = ["move", "hold"] as const; + +export type CursorMotionSegmentKind = (typeof CURSOR_MOTION_SEGMENT_KINDS)[number]; + +export interface CursorMotionTelemetrySample extends CursorMotionPoint { + timeMs: number; + visible?: boolean; + interactionType?: string | null; +} + +export interface CursorMotionSegmentDefinition { + startMs: number; + endMs: number; + startPoint: CursorMotionPoint; + endPoint: CursorMotionPoint; + startAnchorKind: CursorMotionAnchorKind; + endAnchorKind: CursorMotionAnchorKind; + segmentKind: CursorMotionSegmentKind; +} + export interface CursorMotionRegion { id: string; startMs: number; endMs: number; startPoint?: CursorMotionPoint; endPoint?: CursorMotionPoint; + startAnchorKind?: CursorMotionAnchorKind; + endAnchorKind?: CursorMotionAnchorKind; + segmentKind?: CursorMotionSegmentKind; preset: CursorMotionPreset; controlPoint: CursorMotionPoint; cycles: number; @@ -29,6 +56,11 @@ export interface CursorMotionPath { const MIN_CYCLES = 1; const MAX_CYCLES = 6; +const REST_MIN_DURATION_MS = 300; +const REST_MAX_DIAMETER = 0.009; +const REST_MAX_SAMPLE_GAP_MS = 150; +const HOLD_MAX_DISTANCE = 0.012; +const MIN_SEGMENT_DURATION_MS = 2; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -41,6 +73,13 @@ export function clampCursorMotionPoint(point: CursorMotionPoint): CursorMotionPo }; } +export function getCursorMotionSegmentKind( + start: CursorMotionPoint, + end: CursorMotionPoint, +): CursorMotionSegmentKind { + return Math.hypot(end.cx - start.cx, end.cy - start.cy) <= HOLD_MAX_DISTANCE ? "hold" : "move"; +} + export function clampCursorMotionCycles(cycles: number) { return clamp(Math.round(Number.isFinite(cycles) ? cycles : 1), MIN_CYCLES, MAX_CYCLES); } @@ -53,6 +92,14 @@ export function isCursorMotionEasing(value: unknown): value is CursorMotionEasin return CURSOR_MOTION_EASINGS.includes(value as CursorMotionEasing); } +export function isCursorMotionAnchorKind(value: unknown): value is CursorMotionAnchorKind { + return CURSOR_MOTION_ANCHOR_KINDS.includes(value as CursorMotionAnchorKind); +} + +export function isCursorMotionSegmentKind(value: unknown): value is CursorMotionSegmentKind { + return CURSOR_MOTION_SEGMENT_KINDS.includes(value as CursorMotionSegmentKind); +} + export function resolveCursorMotionClickAnchoredSpan( startMs: number, clickTimestamps: readonly number[], @@ -68,6 +115,229 @@ export function resolveCursorMotionClickAnchoredSpan( }; } +export function resolveCursorMotionClickAnchoredRange( + startMs: number, + clickTimestamps: readonly number[], +): { startMs: number; endMs: number } | null { + const safeStartMs = Math.max(0, Math.round(Number.isFinite(startMs) ? startMs : 0)); + const followingClicks = clickTimestamps + .filter((timeMs) => Number.isFinite(timeMs) && timeMs > safeStartMs + 1) + .sort((a, b) => a - b); + const finalClick = followingClicks.at(-1); + if (finalClick === undefined) return null; + return { + startMs: safeStartMs, + endMs: Math.max(safeStartMs + 1, Math.round(finalClick)), + }; +} + +interface CursorMotionAnchor { + timeMs: number; + point: CursorMotionPoint; + kind: CursorMotionAnchorKind; +} + +interface CursorMotionRestSpan { + startMs: number; + endMs: number; + point: CursorMotionPoint; +} + +function isClickInteractionType(interactionType: string | null | undefined) { + return ( + interactionType === "click" || + interactionType === "double-click" || + interactionType === "right-click" || + interactionType === "middle-click" + ); +} + +function normalizeMotionSamples( + samples: readonly CursorMotionTelemetrySample[], + startMs: number, + endMs: number, +) { + return samples + .filter( + (sample) => + sample.visible !== false && + Number.isFinite(sample.timeMs) && + Number.isFinite(sample.cx) && + Number.isFinite(sample.cy) && + sample.timeMs >= startMs && + sample.timeMs <= endMs, + ) + .map((sample) => ({ + ...sample, + timeMs: Math.round(sample.timeMs), + ...clampCursorMotionPoint(sample), + })) + .sort((a, b) => a.timeMs - b.timeMs); +} + +function detectCursorMotionRestSpans( + samples: readonly CursorMotionTelemetrySample[], +): CursorMotionRestSpan[] { + const rests: CursorMotionRestSpan[] = []; + let startIndex = 0; + + while (startIndex < samples.length - 1) { + let endIndex = startIndex + 1; + let minCx = samples[startIndex].cx; + let maxCx = minCx; + let minCy = samples[startIndex].cy; + let maxCy = minCy; + + while (endIndex < samples.length) { + const sample = samples[endIndex]; + if (sample.timeMs - samples[endIndex - 1].timeMs > REST_MAX_SAMPLE_GAP_MS) { + break; + } + const nextMinCx = Math.min(minCx, sample.cx); + const nextMaxCx = Math.max(maxCx, sample.cx); + const nextMinCy = Math.min(minCy, sample.cy); + const nextMaxCy = Math.max(maxCy, sample.cy); + if (Math.hypot(nextMaxCx - nextMinCx, nextMaxCy - nextMinCy) > REST_MAX_DIAMETER) { + break; + } + minCx = nextMinCx; + maxCx = nextMaxCx; + minCy = nextMinCy; + maxCy = nextMaxCy; + endIndex += 1; + } + + const lastIndex = endIndex - 1; + const start = samples[startIndex]; + const end = samples[lastIndex]; + if (end.timeMs - start.timeMs >= REST_MIN_DURATION_MS) { + const run = samples.slice(startIndex, endIndex); + const click = run.find((sample) => isClickInteractionType(sample.interactionType)); + const point = click + ? clampCursorMotionPoint(click) + : clampCursorMotionPoint({ + cx: run.reduce((sum, sample) => sum + sample.cx, 0) / run.length, + cy: run.reduce((sum, sample) => sum + sample.cy, 0) / run.length, + }); + rests.push({ startMs: start.timeMs, endMs: end.timeMs, point }); + startIndex = endIndex; + } else { + startIndex += 1; + } + } + + return rests; +} + +function anchorPriority(kind: CursorMotionAnchorKind) { + if (kind === "click") return 3; + if (kind === "rest") return 2; + return 1; +} + +function normalizeCursorMotionAnchors(anchors: CursorMotionAnchor[]) { + const sorted = [...anchors].sort( + (a, b) => a.timeMs - b.timeMs || anchorPriority(b.kind) - anchorPriority(a.kind), + ); + const normalized: CursorMotionAnchor[] = []; + for (const anchor of sorted) { + const previous = normalized.at(-1); + if (previous && Math.abs(previous.timeMs - anchor.timeMs) <= 1) { + if (anchorPriority(anchor.kind) > anchorPriority(previous.kind)) { + normalized[normalized.length - 1] = anchor; + } + continue; + } + normalized.push(anchor); + } + return normalized; +} + +function nearestSamplePoint( + samples: readonly CursorMotionTelemetrySample[], + timeMs: number, +): CursorMotionPoint | null { + let nearest: CursorMotionTelemetrySample | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const sample of samples) { + const distance = Math.abs(sample.timeMs - timeMs); + if (distance < nearestDistance) { + nearest = sample; + nearestDistance = distance; + } + } + return nearest ? clampCursorMotionPoint(nearest) : null; +} + +export function buildCursorMotionSegments(options: { + startMs: number; + endMs: number; + samples: readonly CursorMotionTelemetrySample[]; + path: CursorMotionPath | null | undefined; +}): CursorMotionSegmentDefinition[] { + const startMs = Math.max(0, Math.round(options.startMs)); + const endMs = Math.max(startMs + 1, Math.round(options.endMs)); + const samples = normalizeMotionSamples(options.samples, startMs, endMs); + if (samples.length === 0) return []; + + const startPoint = options.path?.sampleAt(startMs) ?? nearestSamplePoint(samples, startMs); + const endClick = [...samples] + .reverse() + .find( + (sample) => + isClickInteractionType(sample.interactionType) && Math.abs(sample.timeMs - endMs) <= 1, + ); + const endPoint = endClick + ? clampCursorMotionPoint(endClick) + : (options.path?.sampleAt(endMs) ?? nearestSamplePoint(samples, endMs)); + if (!startPoint || !endPoint) return []; + + const rests = detectCursorMotionRestSpans(samples); + const anchors: CursorMotionAnchor[] = [ + { timeMs: startMs, point: clampCursorMotionPoint(startPoint), kind: "manual" }, + { timeMs: endMs, point: clampCursorMotionPoint(endPoint), kind: endClick ? "click" : "manual" }, + ]; + + for (const rest of rests) { + anchors.push( + { timeMs: rest.startMs, point: rest.point, kind: "rest" }, + { timeMs: rest.endMs, point: rest.point, kind: "rest" }, + ); + } + for (const sample of samples) { + if (!isClickInteractionType(sample.interactionType)) continue; + const containingRest = rests.find( + (rest) => sample.timeMs >= rest.startMs && sample.timeMs <= rest.endMs, + ); + anchors.push({ + timeMs: sample.timeMs, + point: containingRest?.point ?? clampCursorMotionPoint(sample), + kind: "click", + }); + } + + const normalizedAnchors = normalizeCursorMotionAnchors(anchors).filter( + (anchor) => anchor.timeMs >= startMs && anchor.timeMs <= endMs, + ); + const segments: CursorMotionSegmentDefinition[] = []; + for (let index = 1; index < normalizedAnchors.length; index += 1) { + const start = normalizedAnchors[index - 1]; + const end = normalizedAnchors[index]; + if (end.timeMs - start.timeMs < MIN_SEGMENT_DURATION_MS) continue; + segments.push({ + startMs: start.timeMs, + endMs: end.timeMs, + startPoint: start.point, + endPoint: end.point, + startAnchorKind: start.kind, + endAnchorKind: end.kind, + segmentKind: getCursorMotionSegmentKind(start.point, end.point), + }); + } + + return segments; +} + function lerp(a: number, b: number, progress: number) { return a + (b - a) * progress; } @@ -109,6 +379,40 @@ export function createDefaultCursorMotionControlPoint( }); } +export function splitCursorMotionRegionAtTime(options: { + region: CursorMotionRegion; + splitMs: number; + splitPoint: CursorMotionPoint; + startPoint: CursorMotionPoint; + endPoint: CursorMotionPoint; + rightId: string; +}): [CursorMotionRegion, CursorMotionRegion] | null { + const { region, rightId } = options; + const splitMs = Math.round(options.splitMs); + if (splitMs <= region.startMs + 1 || splitMs >= region.endMs - 1) return null; + const splitPoint = clampCursorMotionPoint(options.splitPoint); + const startPoint = clampCursorMotionPoint(options.startPoint); + const endPoint = clampCursorMotionPoint(options.endPoint); + const left: CursorMotionRegion = { + ...region, + endMs: splitMs, + endPoint: splitPoint, + endAnchorKind: "manual", + segmentKind: getCursorMotionSegmentKind(startPoint, splitPoint), + controlPoint: createDefaultCursorMotionControlPoint(startPoint, splitPoint), + }; + const right: CursorMotionRegion = { + ...region, + id: rightId, + startMs: splitMs, + startPoint: splitPoint, + startAnchorKind: "manual", + segmentKind: getCursorMotionSegmentKind(splitPoint, endPoint), + controlPoint: createDefaultCursorMotionControlPoint(splitPoint, endPoint), + }; + return [left, right]; +} + export function sampleCursorMotionRegion( region: CursorMotionRegion, start: CursorMotionPoint, From e1d0203d58962f63a0003e995777e1c3fd01100c Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:22:25 +0900 Subject: [PATCH 3/4] feat(editor): add cursor motion speed controls --- src/components/video-editor/SettingsPanel.tsx | 104 ++++++++++++++++++ src/components/video-editor/VideoEditor.tsx | 56 ++++++++++ .../video-editor/projectPersistence.test.ts | 4 + .../video-editor/projectPersistence.ts | 4 +- .../video-editor/timeline/TimelineEditor.tsx | 9 +- src/i18n/locales/ar/settings.json | 9 +- src/i18n/locales/en/settings.json | 9 +- src/i18n/locales/es/settings.json | 9 +- src/i18n/locales/fr/settings.json | 9 +- src/i18n/locales/it/settings.json | 9 +- src/i18n/locales/ja-JP/settings.json | 9 +- src/i18n/locales/ko-KR/settings.json | 9 +- src/i18n/locales/pt-BR/settings.json | 9 +- src/i18n/locales/ru/settings.json | 9 +- src/i18n/locales/tr/settings.json | 9 +- src/i18n/locales/vi/settings.json | 9 +- src/i18n/locales/zh-CN/settings.json | 9 +- src/i18n/locales/zh-TW/settings.json | 9 +- src/lib/cursor/cursorMotion.test.ts | 40 +++++++ src/lib/cursor/cursorMotion.ts | 46 +++++++- 20 files changed, 356 insertions(+), 24 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index e72b47f3a..8958a47a9 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -2,10 +2,14 @@ import * as SliderPrimitive from "@radix-ui/react-slider"; import { Brackets, Bug, + ChevronLeft, + ChevronRight, + Copy, Crop, Download, FileDown, Film, + Gauge, Image, Info, LayoutPanelTop, @@ -49,6 +53,9 @@ 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, @@ -338,7 +345,15 @@ interface SettingsPanelProps { 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; @@ -515,7 +530,15 @@ export function SettingsPanel({ onCursorMotionPresetChange, onCursorMotionEasingChange, onCursorMotionCyclesChange, + onCursorMotionSpeedChange, onCursorMotionCommit, + cursorMotionSectionIndex = 0, + cursorMotionSectionCount = 0, + canSelectPreviousCursorMotion = false, + canSelectNextCursorMotion = false, + onSelectPreviousCursorMotion, + onSelectNextCursorMotion, + onCursorMotionApplyToAllMoves, canSplitCursorMotion = false, onCursorMotionSplit, onCursorMotionAutoSplit, @@ -1259,6 +1282,33 @@ export function SettingsPanel({
+
+ +
+ {t("cursorMotion.sectionCounter", { + current: String(cursorMotionSectionIndex), + total: String(cursorMotionSectionCount), + })} +
+ +
+
{t("cursorMotion.preset")} @@ -1288,6 +1338,52 @@ export function SettingsPanel({
+
+
+ + + {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") && (
@@ -1338,6 +1434,14 @@ export function SettingsPanel({
{t("cursorMotion.anchorHint")}
+
{t( diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index bde8a8471..9aba6099c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -35,6 +35,7 @@ import { trimLeadingSilenceMono16k, } from "@/lib/captioning"; import { + applyCursorMotionSettingsToMoveRegions, buildCursorMotionSegments, type CursorMotionEasing, type CursorMotionPoint, @@ -42,7 +43,9 @@ import { type CursorMotionRegion, clampCursorMotionCycles, clampCursorMotionPoint, + clampCursorMotionSpeed, createDefaultCursorMotionControlPoint, + DEFAULT_CURSOR_MOTION_SPEED, resolveCursorMotionClickAnchoredRange, sampleCursorMotionPath, splitCursorMotionRegionAtTime, @@ -363,6 +366,17 @@ export default function VideoEditor() { : 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); @@ -1197,6 +1211,7 @@ export default function VideoEditor() { preset: segment.segmentKind === "hold" ? "straight" : "wave", controlPoint: createDefaultCursorMotionControlPoint(segment.startPoint, segment.endPoint), cycles: 2, + speed: DEFAULT_CURSOR_MOTION_SPEED, easing: "ease-in-out", })); pushState((prev) => ({ @@ -1350,6 +1365,36 @@ export default function VideoEditor() { [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( @@ -3498,7 +3543,18 @@ export default function VideoEditor() { 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 && diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 7c7db04c0..8f930f2f7 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -78,6 +78,7 @@ describe("projectPersistence media compatibility", () => { preset: "wave", controlPoint: { cx: 2, cy: -1 }, cycles: 99, + speed: 99, easing: "ease-in-out", }, { @@ -90,6 +91,7 @@ describe("projectPersistence media compatibility", () => { preset: "invalid" as never, controlPoint: { cx: Number.NaN, cy: 0.25 }, cycles: Number.NaN, + speed: Number.NaN, easing: "invalid" as never, }, ], @@ -106,6 +108,7 @@ describe("projectPersistence media compatibility", () => { preset: "wave", controlPoint: { cx: 1, cy: 0 }, cycles: 6, + speed: 4, easing: "ease-in-out", }); expect(editor.cursorMotionRegions[1]).toMatchObject({ @@ -115,6 +118,7 @@ describe("projectPersistence media compatibility", () => { preset: "arc", controlPoint: { cx: 0.5, cy: 0.25 }, cycles: 1, + speed: 2, easing: "ease-in-out", }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 61157dd6b..843242dc9 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -4,6 +4,7 @@ import { type CursorMotionRegion, clampCursorMotionCycles, clampCursorMotionPoint, + clampCursorMotionSpeed, isCursorMotionAnchorKind, isCursorMotionEasing, isCursorMotionPreset, @@ -72,7 +73,7 @@ function normalizeWallpaperValue(value: string): string { return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER; } -export const PROJECT_VERSION = 4; +export const PROJECT_VERSION = 5; export interface ProjectEditorState { wallpaper: string; @@ -325,6 +326,7 @@ export function normalizeProjectEditor(editor: Partial): Pro : { cx: 0.5, cy: 0.35 }, ), cycles: clampCursorMotionCycles(region.cycles), + speed: clampCursorMotionSpeed(region.speed), easing: isCursorMotionEasing(region.easing) ? region.easing : "ease-in-out", }; }) diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 7ac1cf0ca..c312bcda0 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -26,7 +26,7 @@ import { import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { useAudioPeaks } from "@/hooks/useAudioPeaks"; -import type { CursorMotionRegion } from "@/lib/cursor/cursorMotion"; +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"; @@ -1605,11 +1605,10 @@ export default function TimelineEditor({ id: region.id, rowId: CURSOR_MOTION_ROW_ID, span: { start: region.startMs, end: region.endMs }, - label: `${ + label: region.segmentKind === "hold" - ? t("cursorMotion.segmentKinds.hold") - : t(`cursorMotion.presets.${region.preset}`) - } ${index + 1}`, + ? `${t("cursorMotion.segmentKinds.hold")} ${index + 1}` + : `${t(`cursorMotion.presets.${region.preset}`)} ${region.speed ?? DEFAULT_CURSOR_MOTION_SPEED}× ${index + 1}`, variant: "cursor-motion", })); diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 09d8c6c0a..28b77c693 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -236,7 +236,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "نمط المؤشر", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index cb7812b01..8fbb36aa0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -246,7 +246,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Cursor Style", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index a1f52df33..302b82554 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -245,7 +245,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Estilo del cursor", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 49818889e..c36ee929d 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -245,7 +245,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Style du curseur", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index a9021904e..95f6f859f 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -244,7 +244,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Stile del cursore", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 251d5798b..75f5f681e 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -245,7 +245,14 @@ "ease-in-out": "なめらか", "ease-in": "加速", "ease-out": "減速" - } + }, + "speed": "移動速度", + "speedHint": "速くしてもクリック時刻は変わりません。開始前の待機を増やし、直前の移動だけを高速化します。", + "applyToAllMoves": "全移動区間に設定を適用", + "appliedToAllMoves": "プリセット・速度・回転数・タイミングを全移動区間へ適用しました。", + "previousSection": "前のカーソル区間", + "nextSection": "次のカーソル区間", + "sectionCounter": "区間 {{current}} / {{total}}" }, "cursor": { "theme": "カーソルのスタイル", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 76e55e77e..7203426d3 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -245,7 +245,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "커서 스타일", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 5b270a70e..306294da3 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -230,7 +230,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Estilo do cursor", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index fc44bb1d5..c2d92b5bf 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -245,7 +245,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Стиль курсора", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 19a83f42a..e74c7c72d 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -236,7 +236,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "İmleç Stili", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 0e235aa9e..8d6034d95 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -236,7 +236,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "Kiểu con trỏ", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index b89a80dc8..5cfb3bef2 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -236,7 +236,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "光标样式", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 66f74c43f..b7b49798a 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -237,7 +237,14 @@ "ease-in-out": "Smooth", "ease-in": "Accelerate", "ease-out": "Decelerate" - } + }, + "speed": "Movement speed", + "speedHint": "Faster motion waits longer, then still lands on the recorded click at the original time.", + "applyToAllMoves": "Apply path settings to all move sections", + "appliedToAllMoves": "Applied the preset, speed, turns, and timing to every move section.", + "previousSection": "Previous cursor section", + "nextSection": "Next cursor section", + "sectionCounter": "Section {{current}} / {{total}}" }, "cursor": { "theme": "游標樣式", diff --git a/src/lib/cursor/cursorMotion.test.ts b/src/lib/cursor/cursorMotion.test.ts index 4231b1e49..5df4685b2 100644 --- a/src/lib/cursor/cursorMotion.test.ts +++ b/src/lib/cursor/cursorMotion.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { + applyCursorMotionSettingsToMoveRegions, buildCursorMotionSegments, buildCursorMotionTrajectory, type CursorMotionPath, type CursorMotionRegion, + clampCursorMotionSpeed, createDefaultCursorMotionControlPoint, findCursorMotionRegionAtTime, resolveCursorMotionClickAnchoredRange, @@ -24,6 +26,7 @@ function region(overrides: Partial = {}): CursorMotionRegion preset: "arc", controlPoint: { cx: 0.5, cy: 0.2 }, cycles: 2, + speed: 1, easing: "linear", ...overrides, }; @@ -49,6 +52,43 @@ describe("cursor motion choreography", () => { expect(sampleCursorMotionRegion(motion, start, end, motion.endMs)).toEqual(end); }); + it("speeds up the move near the click without changing the click time", () => { + const fast = region({ preset: "straight", speed: 2 }); + expect(sampleCursorMotionRegion(fast, start, end, 350)).toEqual(start); + expect(sampleCursorMotionRegion(fast, start, end, 600)).toEqual(start); + expect(sampleCursorMotionRegion(fast, start, end, 850).cx).toBeCloseTo(0.5, 6); + expect(sampleCursorMotionRegion(fast, start, end, fast.endMs)).toEqual(end); + }); + + it("clamps cursor motion speed to the supported range", () => { + expect(clampCursorMotionSpeed(Number.NaN)).toBe(2); + expect(clampCursorMotionSpeed(0.2)).toBe(1); + expect(clampCursorMotionSpeed(2.26)).toBe(2.3); + expect(clampCursorMotionSpeed(99)).toBe(4); + }); + + it("applies one section's timing to moves without changing holds or path handles", () => { + const source = region({ + id: "source", + preset: "loop", + cycles: 4, + speed: 3, + easing: "ease-out", + }); + const move = region({ + id: "move", + preset: "arc", + controlPoint: { cx: 0.2, cy: 0.8 }, + segmentKind: "move", + }); + const hold = region({ id: "hold", preset: "straight", segmentKind: "hold" }); + const result = applyCursorMotionSettingsToMoveRegions([move, hold], source); + + expect(result[0]).toMatchObject({ preset: "loop", cycles: 4, speed: 3, easing: "ease-out" }); + expect(result[0].controlPoint).toEqual(move.controlPoint); + expect(result[1]).toBe(hold); + }); + it("uses persisted anchors instead of moving them when smoothing changes", () => { const anchored = region({ startPoint: { cx: 0.2, cy: 0.3 }, diff --git a/src/lib/cursor/cursorMotion.ts b/src/lib/cursor/cursorMotion.ts index ad3236e67..8fe2b938b 100644 --- a/src/lib/cursor/cursorMotion.ts +++ b/src/lib/cursor/cursorMotion.ts @@ -47,6 +47,7 @@ export interface CursorMotionRegion { preset: CursorMotionPreset; controlPoint: CursorMotionPoint; cycles: number; + speed: number; easing: CursorMotionEasing; } @@ -56,6 +57,10 @@ export interface CursorMotionPath { const MIN_CYCLES = 1; const MAX_CYCLES = 6; +export const CURSOR_MOTION_SPEED_MIN = 1; +export const CURSOR_MOTION_SPEED_MAX = 4; +export const DEFAULT_CURSOR_MOTION_SPEED = 2; +export const CURSOR_MOTION_SPEED_PRESETS = [1, 1.5, 2, 3, 4] as const; const REST_MIN_DURATION_MS = 300; const REST_MAX_DIAMETER = 0.009; const REST_MAX_SAMPLE_GAP_MS = 150; @@ -84,6 +89,18 @@ export function clampCursorMotionCycles(cycles: number) { return clamp(Math.round(Number.isFinite(cycles) ? cycles : 1), MIN_CYCLES, MAX_CYCLES); } +export function clampCursorMotionSpeed(speed: number | null | undefined) { + const value = Number.isFinite(speed) ? Number(speed) : DEFAULT_CURSOR_MOTION_SPEED; + return Math.round(clamp(value, CURSOR_MOTION_SPEED_MIN, CURSOR_MOTION_SPEED_MAX) * 10) / 10; +} + +export function applyCursorMotionSpeed(progress: number, speed: number | null | undefined) { + const t = clamp(progress, 0, 1); + const multiplier = clampCursorMotionSpeed(speed); + const motionStart = 1 - 1 / multiplier; + return clamp((t - motionStart) * multiplier, 0, 1); +} + export function isCursorMotionPreset(value: unknown): value is CursorMotionPreset { return CURSOR_MOTION_PRESETS.includes(value as CursorMotionPreset); } @@ -413,6 +430,23 @@ export function splitCursorMotionRegionAtTime(options: { return [left, right]; } +export function applyCursorMotionSettingsToMoveRegions( + regions: readonly CursorMotionRegion[], + source: CursorMotionRegion, +): CursorMotionRegion[] { + return regions.map((region) => + region.segmentKind === "hold" + ? region + : { + ...region, + preset: source.preset, + cycles: source.cycles, + speed: source.speed, + easing: source.easing, + }, + ); +} + export function sampleCursorMotionRegion( region: CursorMotionRegion, start: CursorMotionPoint, @@ -423,7 +457,9 @@ export function sampleCursorMotionRegion( const rawProgress = clamp((timeMs - region.startMs) / duration, 0, 1); if (rawProgress === 0) return start; if (rawProgress === 1) return end; - const progress = easeProgress(rawProgress, region.easing); + const motionProgress = applyCursorMotionSpeed(rawProgress, region.speed); + if (motionProgress === 0) return start; + const progress = easeProgress(motionProgress, region.easing); const midpoint = { cx: (start.cx + end.cx) / 2, cy: (start.cy + end.cy) / 2, @@ -435,7 +471,7 @@ export function sampleCursorMotionRegion( if (region.preset === "overshoot") { const overshootProgress = easeOutBack(progress); - const envelope = Math.sin(Math.PI * rawProgress); + const envelope = Math.sin(Math.PI * motionProgress); return { cx: lerp(start.cx, end.cx, overshootProgress) + offset.cx * envelope * 0.35, cy: lerp(start.cy, end.cy, overshootProgress) + offset.cy * envelope * 0.35, @@ -446,7 +482,7 @@ export function sampleCursorMotionRegion( cx: lerp(start.cx, end.cx, progress), cy: lerp(start.cy, end.cy, progress), }; - const envelope = Math.sin(Math.PI * rawProgress); + const envelope = Math.sin(Math.PI * motionProgress); const cycles = clampCursorMotionCycles(region.cycles); switch (region.preset) { @@ -456,14 +492,14 @@ export function sampleCursorMotionRegion( cy: base.cy + offset.cy * envelope, }; case "wave": { - const wave = Math.sin(Math.PI * 2 * cycles * rawProgress) * envelope; + const wave = Math.sin(Math.PI * 2 * cycles * motionProgress) * envelope; return { cx: base.cx + offset.cx * wave, cy: base.cy + offset.cy * wave, }; } case "loop": { - const phase = Math.PI * 2 * cycles * rawProgress; + const phase = Math.PI * 2 * cycles * motionProgress; const tangentOffset = Math.sin(phase) * envelope; const normalOffset = (1 - Math.cos(phase)) * 0.5 * envelope; return { From 85de956dfde6b32dbf0985eff12a17bec31e1b94 Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:30:41 +0900 Subject: [PATCH 4/4] fix(editor): preserve recorded cursor motion by default --- src/components/video-editor/SettingsPanel.tsx | 93 ++++++++++--------- src/components/video-editor/VideoEditor.tsx | 4 +- src/components/video-editor/VideoPlayback.tsx | 20 ++-- .../video-editor/projectPersistence.test.ts | 4 +- .../video-editor/projectPersistence.ts | 2 +- .../video-editor/timeline/TimelineEditor.tsx | 4 +- .../CursorMotionEditorOverlay.test.tsx | 21 +++++ .../CursorMotionEditorOverlay.tsx | 90 +++++++++--------- src/i18n/locales/ar/timeline.json | 1 + src/i18n/locales/en/timeline.json | 1 + src/i18n/locales/es/timeline.json | 1 + src/i18n/locales/fr/timeline.json | 1 + src/i18n/locales/it/timeline.json | 1 + src/i18n/locales/ja-JP/timeline.json | 1 + src/i18n/locales/ko-KR/timeline.json | 1 + src/i18n/locales/pt-BR/timeline.json | 1 + src/i18n/locales/ru/timeline.json | 1 + src/i18n/locales/tr/timeline.json | 1 + src/i18n/locales/vi/timeline.json | 1 + src/i18n/locales/zh-CN/timeline.json | 1 + src/i18n/locales/zh-TW/timeline.json | 1 + src/lib/cursor/cursorMotion.test.ts | 30 +++++- src/lib/cursor/cursorMotion.ts | 24 ++++- 23 files changed, 195 insertions(+), 110 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 8958a47a9..ecaeedb3b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -401,6 +401,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ ]; 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", @@ -422,7 +423,7 @@ function CursorMotionPresetPreview({ preset }: { preset: CursorMotionPreset }) { @@ -1338,51 +1339,53 @@ export function SettingsPanel({
-
-
- - - {t("cursorMotion.speed")} - - - {selectedCursorMotionRegion.speed.toFixed(1)}× - -
-
- {CURSOR_MOTION_SPEED_PRESETS.map((speed) => ( - - ))} + {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")} +

- 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") && ( diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9aba6099c..739fd848d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1208,7 +1208,7 @@ export default function VideoEditor() { const newRegions: CursorMotionRegion[] = segments.map((segment) => ({ id: `cursor-motion-${nextCursorMotionIdRef.current++}`, ...segment, - preset: segment.segmentKind === "hold" ? "straight" : "wave", + preset: "recorded", controlPoint: createDefaultCursorMotionControlPoint(segment.startPoint, segment.endPoint), cycles: 2, speed: DEFAULT_CURSOR_MOTION_SPEED, @@ -1308,7 +1308,7 @@ export default function VideoEditor() { index === segments.length - 1 ? (region.endAnchorKind ?? segment.endAnchorKind) : segment.endAnchorKind, - preset: segment.segmentKind === "hold" ? ("straight" as const) : region.preset, + preset: segment.segmentKind === "hold" ? ("recorded" as const) : region.preset, controlPoint: createDefaultCursorMotionControlPoint(startPoint, endPoint), }; }); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index e0a4e31ba..a17fe07af 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1989,14 +1989,17 @@ const VideoPlayback = forwardRef( y: paintedRect.y + ((point.cy - crop.y) / crop.height) * paintedRect.height, }); const trajectory = buildCursorMotionTrajectory(cursorMotionBasePath, region, 96).map(project); - const recordedTrajectory = 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); + 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, @@ -2378,6 +2381,7 @@ const VideoPlayback = forwardRef( controlPoint={cursorMotionEditorGeometry.controlPoint} startAnchorKind={selectedCursorMotionRegion.startAnchorKind} endAnchorKind={selectedCursorMotionRegion.endAnchorKind} + editable={selectedCursorMotionRegion.preset !== "recorded"} onControlPointChange={handleCursorMotionClientPoint} onControlPointCommit={() => onCursorMotionControlPointCommit?.()} /> diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 8f930f2f7..04f0bacb5 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -115,10 +115,10 @@ describe("projectPersistence media compatibility", () => { startAnchorKind: "manual", endAnchorKind: "click", segmentKind: "move", - preset: "arc", + preset: "recorded", controlPoint: { cx: 0.5, cy: 0.25 }, cycles: 1, - speed: 2, + speed: 1, easing: "ease-in-out", }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 843242dc9..74dbee411 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -319,7 +319,7 @@ export function normalizeProjectEditor(editor: Partial): Pro segmentKind: isCursorMotionSegmentKind(region.segmentKind) ? region.segmentKind : "move", - preset: isCursorMotionPreset(region.preset) ? region.preset : "arc", + preset: isCursorMotionPreset(region.preset) ? region.preset : "recorded", controlPoint: clampCursorMotionPoint( region.controlPoint && typeof region.controlPoint === "object" ? region.controlPoint diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index c312bcda0..e99c16634 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1608,7 +1608,9 @@ export default function TimelineEditor({ label: region.segmentKind === "hold" ? `${t("cursorMotion.segmentKinds.hold")} ${index + 1}` - : `${t(`cursorMotion.presets.${region.preset}`)} ${region.speed ?? DEFAULT_CURSOR_MOTION_SPEED}× ${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", })); diff --git a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx index a2a2bb6dd..257d47e9f 100644 --- a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx +++ b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.test.tsx @@ -45,6 +45,27 @@ describe("CursorMotionEditorOverlay", () => { expect(screen.getAllByLabelText("Cursor stop anchor")).toHaveLength(2); }); + it("shows the recorded path without an editable curve handle", () => { + render( + , + ); + + expect(screen.queryByLabelText("Motion curve handle")).toBeNull(); + expect(screen.getByLabelText("Cursor motion path editor")).not.toBeNull(); + }); + it("previews pointer movement and commits once when released", () => { const onChange = vi.fn(); const onCommit = vi.fn(); diff --git a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx index 7d398ac40..20cb084d2 100644 --- a/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx +++ b/src/components/video-editor/videoPlayback/CursorMotionEditorOverlay.tsx @@ -14,6 +14,7 @@ interface CursorMotionEditorOverlayProps { controlPoint: CursorMotionEditorPoint; startAnchorKind?: CursorMotionAnchorKind; endAnchorKind?: CursorMotionAnchorKind; + editable?: boolean; onControlPointChange: (clientX: number, clientY: number) => void; onControlPointCommit: () => void; } @@ -75,6 +76,7 @@ export function CursorMotionEditorOverlay({ controlPoint, startAnchorKind = "manual", endAnchorKind = "click", + editable = true, onControlPointChange, onControlPointCommit, }: CursorMotionEditorOverlayProps) { @@ -137,50 +139,54 @@ export function CursorMotionEditorOverlay({ strokeLinecap="round" vectorEffect="non-scaling-stroke" /> - + {editable && ( + + )} - { - if (!event.isPrimary || (event.pointerType === "mouse" && event.button !== 0)) return; - activePointerIdRef.current = event.pointerId; - event.currentTarget.setPointerCapture(event.pointerId); - onControlPointChange(event.clientX, event.clientY); - event.preventDefault(); - event.stopPropagation(); - }} - onPointerMove={(event) => { - if (activePointerIdRef.current !== event.pointerId) return; - onControlPointChange(event.clientX, event.clientY); - event.preventDefault(); - event.stopPropagation(); - }} - onPointerUp={stopDrag} - onPointerCancel={stopDrag} - onLostPointerCapture={(event) => { - if (activePointerIdRef.current !== event.pointerId) return; - activePointerIdRef.current = null; - onControlPointCommit(); - }} - /> + {editable && ( + { + if (!event.isPrimary || (event.pointerType === "mouse" && event.button !== 0)) return; + activePointerIdRef.current = event.pointerId; + event.currentTarget.setPointerCapture(event.pointerId); + onControlPointChange(event.clientX, event.clientY); + event.preventDefault(); + event.stopPropagation(); + }} + onPointerMove={(event) => { + if (activePointerIdRef.current !== event.pointerId) return; + onControlPointChange(event.clientX, event.clientY); + event.preventDefault(); + event.stopPropagation(); + }} + onPointerUp={stopDrag} + onPointerCancel={stopDrag} + onLostPointerCapture={(event) => { + if (activePointerIdRef.current !== event.pointerId) return; + activePointerIdRef.current = null; + onControlPointCommit(); + }} + /> + )} ); } diff --git a/src/i18n/locales/ar/timeline.json b/src/i18n/locales/ar/timeline.json index 0abec224b..ed403275e 100644 --- a/src/i18n/locales/ar/timeline.json +++ b/src/i18n/locales/ar/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json index 045587d7f..29f6de3ae 100644 --- a/src/i18n/locales/en/timeline.json +++ b/src/i18n/locales/en/timeline.json @@ -28,6 +28,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json index 7f7535ffb..1bb9cc6e0 100644 --- a/src/i18n/locales/es/timeline.json +++ b/src/i18n/locales/es/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/fr/timeline.json b/src/i18n/locales/fr/timeline.json index 83c89a1eb..f4ac0dc2f 100644 --- a/src/i18n/locales/fr/timeline.json +++ b/src/i18n/locales/fr/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/it/timeline.json b/src/i18n/locales/it/timeline.json index e92ef6c2f..2ab8e228a 100644 --- a/src/i18n/locales/it/timeline.json +++ b/src/i18n/locales/it/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/ja-JP/timeline.json b/src/i18n/locales/ja-JP/timeline.json index b943188cd..0034dc2eb 100644 --- a/src/i18n/locales/ja-JP/timeline.json +++ b/src/i18n/locales/ja-JP/timeline.json @@ -24,6 +24,7 @@ "hold": "停止" }, "presets": { + "recorded": "録画どおり", "straight": "直線", "arc": "弧", "wave": "波", diff --git a/src/i18n/locales/ko-KR/timeline.json b/src/i18n/locales/ko-KR/timeline.json index cdce1f8c1..e156d4b48 100644 --- a/src/i18n/locales/ko-KR/timeline.json +++ b/src/i18n/locales/ko-KR/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/pt-BR/timeline.json b/src/i18n/locales/pt-BR/timeline.json index 0a6e474ec..a87ced516 100644 --- a/src/i18n/locales/pt-BR/timeline.json +++ b/src/i18n/locales/pt-BR/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index d969d1e0b..c3c0f224d 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/tr/timeline.json b/src/i18n/locales/tr/timeline.json index c8d4fef78..07f7e2d54 100644 --- a/src/i18n/locales/tr/timeline.json +++ b/src/i18n/locales/tr/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/vi/timeline.json b/src/i18n/locales/vi/timeline.json index fdc72c56f..46b6d27f2 100644 --- a/src/i18n/locales/vi/timeline.json +++ b/src/i18n/locales/vi/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/zh-CN/timeline.json b/src/i18n/locales/zh-CN/timeline.json index 8d54a2073..2292fb4b5 100644 --- a/src/i18n/locales/zh-CN/timeline.json +++ b/src/i18n/locales/zh-CN/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index 130f819a4..a8f797c0a 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -24,6 +24,7 @@ "hold": "Hold" }, "presets": { + "recorded": "Recorded", "straight": "Straight", "arc": "Arc", "wave": "Wave", diff --git a/src/lib/cursor/cursorMotion.test.ts b/src/lib/cursor/cursorMotion.test.ts index 5df4685b2..f72717ec5 100644 --- a/src/lib/cursor/cursorMotion.test.ts +++ b/src/lib/cursor/cursorMotion.test.ts @@ -52,16 +52,16 @@ describe("cursor motion choreography", () => { expect(sampleCursorMotionRegion(motion, start, end, motion.endMs)).toEqual(end); }); - it("speeds up the move near the click without changing the click time", () => { + it("speeds up immediately without freezing the start of the move", () => { const fast = region({ preset: "straight", speed: 2 }); - expect(sampleCursorMotionRegion(fast, start, end, 350)).toEqual(start); - expect(sampleCursorMotionRegion(fast, start, end, 600)).toEqual(start); - expect(sampleCursorMotionRegion(fast, start, end, 850).cx).toBeCloseTo(0.5, 6); + expect(sampleCursorMotionRegion(fast, start, end, 350).cx).toBeCloseTo(0.45, 6); + expect(sampleCursorMotionRegion(fast, start, end, 600).cx).toBeCloseTo(0.7, 6); + expect(sampleCursorMotionRegion(fast, start, end, 850).cx).toBeCloseTo(0.85, 6); expect(sampleCursorMotionRegion(fast, start, end, fast.endMs)).toEqual(end); }); it("clamps cursor motion speed to the supported range", () => { - expect(clampCursorMotionSpeed(Number.NaN)).toBe(2); + expect(clampCursorMotionSpeed(Number.NaN)).toBe(1); expect(clampCursorMotionSpeed(0.2)).toBe(1); expect(clampCursorMotionSpeed(2.26)).toBe(2.3); expect(clampCursorMotionSpeed(99)).toBe(4); @@ -102,6 +102,26 @@ describe("cursor motion choreography", () => { ); }); + it("keeps the recorded cursor path untouched until a creative preset is selected", () => { + const recordedPath: CursorMotionPath = { + sampleAt(timeMs) { + const progress = Math.max(0, Math.min(1, (timeMs - 100) / 1000)); + return { + cx: start.cx + (end.cx - start.cx) * progress, + cy: 0.5 + Math.sin(progress * Math.PI * 2) * 0.2, + }; + }, + }; + const original = region({ preset: "recorded", speed: 4 }); + + expect(sampleCursorMotionPath(recordedPath, [original], 350)).toEqual( + recordedPath.sampleAt(350), + ); + expect(buildCursorMotionTrajectory(recordedPath, original, 5)).toEqual( + [100, 350, 600, 850, 1100].map((timeMs) => recordedPath.sampleAt(timeMs)), + ); + }); + it("creates an editable arc through the control-point side", () => { const above = sampleCursorMotionRegion(region(), start, end, 600); const below = sampleCursorMotionRegion( diff --git a/src/lib/cursor/cursorMotion.ts b/src/lib/cursor/cursorMotion.ts index 8fe2b938b..92c100cc4 100644 --- a/src/lib/cursor/cursorMotion.ts +++ b/src/lib/cursor/cursorMotion.ts @@ -1,4 +1,11 @@ -export const CURSOR_MOTION_PRESETS = ["straight", "arc", "wave", "loop", "overshoot"] as const; +export const CURSOR_MOTION_PRESETS = [ + "recorded", + "straight", + "arc", + "wave", + "loop", + "overshoot", +] as const; export type CursorMotionPreset = (typeof CURSOR_MOTION_PRESETS)[number]; @@ -59,7 +66,7 @@ const MIN_CYCLES = 1; const MAX_CYCLES = 6; export const CURSOR_MOTION_SPEED_MIN = 1; export const CURSOR_MOTION_SPEED_MAX = 4; -export const DEFAULT_CURSOR_MOTION_SPEED = 2; +export const DEFAULT_CURSOR_MOTION_SPEED = 1; export const CURSOR_MOTION_SPEED_PRESETS = [1, 1.5, 2, 3, 4] as const; const REST_MIN_DURATION_MS = 300; const REST_MAX_DIAMETER = 0.009; @@ -97,8 +104,10 @@ export function clampCursorMotionSpeed(speed: number | null | undefined) { export function applyCursorMotionSpeed(progress: number, speed: number | null | undefined) { const t = clamp(progress, 0, 1); const multiplier = clampCursorMotionSpeed(speed); - const motionStart = 1 - 1 / multiplier; - return clamp((t - motionStart) * multiplier, 0, 1); + // Higher speeds advance immediately and settle near the destination sooner. + // Do not create a leading frozen span: it made the cursor look broken and + // hid most short moves at the default 2x setting. + return clamp(1 - (1 - t) ** multiplier, 0, 1); } export function isCursorMotionPreset(value: unknown): value is CursorMotionPreset { @@ -535,6 +544,7 @@ export function sampleCursorMotionPath( const region = findCursorMotionRegionAtTime(regions, timeMs); if (!region) return current; + if (region.preset === "recorded") return current; const start = region.startPoint ?? path?.sampleAt(region.startMs) ?? null; const end = region.endPoint ?? path?.sampleAt(region.endMs) ?? null; @@ -553,6 +563,12 @@ export function buildCursorMotionTrajectory( if (!start || !end) return []; const count = clamp(Math.round(sampleCount), 2, 240); + if (region.preset === "recorded") { + return Array.from({ length: count }, (_, index) => { + const progress = index / (count - 1); + return path?.sampleAt(region.startMs + (region.endMs - region.startMs) * progress) ?? null; + }).filter((point): point is CursorMotionPoint => point !== null); + } return Array.from({ length: count }, (_, index) => { const progress = index / (count - 1); return sampleCursorMotionRegion(