From 75ee15c08a3162b25661436d7e08effe2cecfa84 Mon Sep 17 00:00:00 2001 From: Warren Walters Date: Sun, 31 May 2026 23:15:05 -0400 Subject: [PATCH] feat: cursor follow crop with text cursor focus mode Adds a "Track cursor" crop mode that pans the viewport to keep the mouse cursor inside a configurable safe zone during playback and export. A new "Text cursor focus" toggle locks the viewport over the typing area when the I-beam cursor is active and the mouse is stationary, then smoothly switches back to mouse tracking once movement is detected. Mouse always wins; a 700ms debounce prevents jarring transitions between modes. Co-Authored-By: Claude Sonnet 4.6 --- src/components/video-editor/CropControl.tsx | 536 ++++++++++++++---- src/components/video-editor/VideoEditor.tsx | 18 + src/components/video-editor/VideoPlayback.tsx | 63 ++ .../video-editor/projectPersistence.ts | 26 + src/components/video-editor/types.ts | 26 + .../videoPlayback/cursorFollowCrop.test.ts | 173 ++++++ .../videoPlayback/cursorFollowCrop.ts | 272 +++++++++ src/lib/exporter/frameRenderer.ts | 43 ++ src/lib/exporter/gifExporter.ts | 2 + src/lib/exporter/modernFrameRenderer.ts | 43 ++ src/lib/exporter/modernVideoExporter.ts | 7 + src/lib/exporter/videoExporter.ts | 3 + 12 files changed, 1093 insertions(+), 119 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/cursorFollowCrop.test.ts create mode 100644 src/components/video-editor/videoPlayback/cursorFollowCrop.ts diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 0e00d8d9f..8cc97d7e4 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -1,6 +1,12 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { + computeCursorFollowCrop, + createCursorFollowCropState, + resetCursorFollowCropState, +} from "./videoPlayback/cursorFollowCrop"; +import type { CursorFollowCropSettings, CursorTelemetryPoint } from "./types"; interface CropRegion { x: number; // 0-1 normalized @@ -14,17 +20,91 @@ interface CropControlProps { cropRegion: CropRegion; onCropChange: (region: CropRegion) => void; aspectRatio: AspectRatio; + cursorFollow?: CursorFollowCropSettings; + onCursorFollowChange?: (settings: CursorFollowCropSettings) => void; + cursorTelemetry?: CursorTelemetryPoint[]; + currentTimeMs?: number; } type DragHandle = "top" | "right" | "bottom" | "left" | null; -export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { +interface ResolutionPreset { + id: string; + label: string; + width: number; + height: number; +} + +const OUTPUT_RESOLUTION_PRESETS: ResolutionPreset[] = [ + { id: "1080p", label: "1080p", width: 1920, height: 1080 }, + { id: "720p", label: "720p", width: 1280, height: 720 }, + { id: "480p", label: "480p", width: 854, height: 480 }, +]; + +const RESOLUTION_MATCH_EPSILON = 0.005; + +function matchActiveResolutionPreset( + crop: CropRegion, + sourceWidth: number, + sourceHeight: number, +): string | null { + if (!sourceWidth || !sourceHeight) return null; + for (const preset of OUTPUT_RESOLUTION_PRESETS) { + const w = Math.min(1, preset.width / sourceWidth); + const h = Math.min(1, preset.height / sourceHeight); + if ( + Math.abs(crop.width - w) < RESOLUTION_MATCH_EPSILON && + Math.abs(crop.height - h) < RESOLUTION_MATCH_EPSILON + ) { + return preset.id; + } + } + return null; +} + +export function CropControl({ + videoElement, + cropRegion, + onCropChange, + cursorFollow, + onCursorFollowChange, + cursorTelemetry, + currentTimeMs, +}: CropControlProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(null); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [initialCrop, setInitialCrop] = useState(cropRegion); + const followEnabled = cursorFollow?.enabled === true; + const previewMode = cursorFollow?.previewMode ?? "source"; + const showOutputMode = followEnabled && previewMode === "output"; + + const followStateRef = useRef(createCursorFollowCropState()); + const [effectiveCrop, setEffectiveCrop] = useState(cropRegion); + + useEffect(() => { + resetCursorFollowCropState(followStateRef.current); + }, [followEnabled, cropRegion.width, cropRegion.height]); + + useEffect(() => { + if (!followEnabled || !cursorFollow) { + setEffectiveCrop(cropRegion); + return; + } + const samples = cursorTelemetry ?? []; + const t = typeof currentTimeMs === "number" ? currentTimeMs : 0; + const next = computeCursorFollowCrop( + followStateRef.current, + samples, + t, + cropRegion, + cursorFollow, + ); + setEffectiveCrop(next); + }, [followEnabled, cursorFollow, cropRegion, cursorTelemetry, currentTimeMs]); + useEffect(() => { if (!videoElement || !canvasRef.current) return; @@ -32,8 +112,10 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont const ctx = canvas.getContext("2d", { alpha: false }); if (!ctx) return; - canvas.width = videoElement.videoWidth || 1920; - canvas.height = videoElement.videoHeight || 1080; + const sourceWidth = videoElement.videoWidth || 1920; + const sourceHeight = videoElement.videoHeight || 1080; + canvas.width = sourceWidth; + canvas.height = sourceHeight; let animationFrameId = 0; let isCancelled = false; @@ -45,7 +127,15 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont if (videoElement.readyState >= 2) { ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + if (showOutputMode) { + const sx = effectiveCrop.x * sourceWidth; + const sy = effectiveCrop.y * sourceHeight; + const sw = Math.max(1, effectiveCrop.width * sourceWidth); + const sh = Math.max(1, effectiveCrop.height * sourceHeight); + ctx.drawImage(videoElement, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); + } else { + ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + } } animationFrameId = requestAnimationFrame(draw); }; @@ -55,7 +145,7 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont isCancelled = true; cancelAnimationFrame(animationFrameId); }; - }, [videoElement]); + }, [videoElement, showOutputMode, effectiveCrop]); const getContainerRect = () => { return ( @@ -101,33 +191,62 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont let newCrop = { ...initialCrop }; - switch (isDragging) { - case "top": { - const newY = Math.max(0, initialCrop.y + deltaY); - const bottom = initialCrop.y + initialCrop.height; - newCrop.y = Math.min(newY, bottom - 0.1); - newCrop.height = bottom - newCrop.y; - break; + if (followEnabled) { + // When tracking cursor, drag handles only resize the viewport — never + // reposition. The viewport's top-left is computed per-frame from cursor + // telemetry, so we keep crop.x/y as the home position unchanged and only + // adjust width/height. + switch (isDragging) { + case "top": { + const nextHeight = Math.max(0.1, Math.min(1, initialCrop.height - deltaY)); + newCrop.height = nextHeight; + break; + } + case "bottom": { + const nextHeight = Math.max(0.1, Math.min(1, initialCrop.height + deltaY)); + newCrop.height = nextHeight; + break; + } + case "left": { + const nextWidth = Math.max(0.1, Math.min(1, initialCrop.width - deltaX)); + newCrop.width = nextWidth; + break; + } + case "right": { + const nextWidth = Math.max(0.1, Math.min(1, initialCrop.width + deltaX)); + newCrop.width = nextWidth; + break; + } } - case "bottom": - newCrop.height = Math.max( - 0.1, - Math.min(initialCrop.height + deltaY, 1 - initialCrop.y), - ); - break; - case "left": { - const newX = Math.max(0, initialCrop.x + deltaX); - const right = initialCrop.x + initialCrop.width; - newCrop.x = Math.min(newX, right - 0.1); - newCrop.width = right - newCrop.x; - break; + } else { + switch (isDragging) { + case "top": { + const newY = Math.max(0, initialCrop.y + deltaY); + const bottom = initialCrop.y + initialCrop.height; + newCrop.y = Math.min(newY, bottom - 0.1); + newCrop.height = bottom - newCrop.y; + break; + } + case "bottom": + newCrop.height = Math.max( + 0.1, + Math.min(initialCrop.height + deltaY, 1 - initialCrop.y), + ); + break; + case "left": { + const newX = Math.max(0, initialCrop.x + deltaX); + const right = initialCrop.x + initialCrop.width; + newCrop.x = Math.min(newX, right - 0.1); + newCrop.width = right - newCrop.x; + break; + } + case "right": + newCrop.width = Math.max( + 0.1, + Math.min(initialCrop.width + deltaX, 1 - initialCrop.x), + ); + break; } - case "right": - newCrop.width = Math.max( - 0.1, - Math.min(initialCrop.width + deltaX, 1 - initialCrop.x), - ); - break; } onCropChange(newCrop); @@ -144,10 +263,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont setIsDragging(null); }; - const cropPixelX = cropRegion.x * 100; - const cropPixelY = cropRegion.y * 100; - const cropPixelWidth = cropRegion.width * 100; - const cropPixelHeight = cropRegion.height * 100; + const overlayCrop = followEnabled ? effectiveCrop : cropRegion; + const cropPixelX = overlayCrop.x * 100; + const cropPixelY = overlayCrop.y * 100; + const cropPixelWidth = overlayCrop.width * 100; + const cropPixelHeight = overlayCrop.height * 100; const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16 / 9; @@ -155,8 +275,180 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont const maxContainerWidth = isVideoPortrait ? "40vw" : "75vw"; const maxContainerHeight = "75vh"; + const handleToggleFollow = (next: boolean) => { + if (!cursorFollow || !onCursorFollowChange) return; + onCursorFollowChange({ ...cursorFollow, enabled: next }); + }; + const handleSafeZoneChange = (value: number) => { + if (!cursorFollow || !onCursorFollowChange) return; + onCursorFollowChange({ ...cursorFollow, safeZoneRatio: value }); + }; + const handleSmoothnessChange = (value: number) => { + if (!cursorFollow || !onCursorFollowChange) return; + onCursorFollowChange({ ...cursorFollow, smoothness: value }); + }; + const handlePreviewModeChange = (mode: "source" | "output") => { + if (!cursorFollow || !onCursorFollowChange) return; + onCursorFollowChange({ ...cursorFollow, previewMode: mode }); + }; + const handleTrackTextCursorChange = (value: boolean) => { + if (!cursorFollow || !onCursorFollowChange) return; + onCursorFollowChange({ ...cursorFollow, trackTextCursor: value }); + }; + + const sourceWidthPx = videoElement?.videoWidth ?? 0; + const sourceHeightPx = videoElement?.videoHeight ?? 0; + const activePresetId = matchActiveResolutionPreset(cropRegion, sourceWidthPx, sourceHeightPx); + const currentOutputWidthPx = Math.max(1, Math.round(cropRegion.width * sourceWidthPx)); + const currentOutputHeightPx = Math.max(1, Math.round(cropRegion.height * sourceHeightPx)); + + const handleResolutionPreset = (preset: ResolutionPreset) => { + if (!sourceWidthPx || !sourceHeightPx) return; + const width = Math.min(1, preset.width / sourceWidthPx); + const height = Math.min(1, preset.height / sourceHeightPx); + // Keep the crop centered on its current center where possible. + const cx = cropRegion.x + cropRegion.width / 2; + const cy = cropRegion.y + cropRegion.height / 2; + const x = Math.max(0, Math.min(1 - width, cx - width / 2)); + const y = Math.max(0, Math.min(1 - height, cy - height / 2)); + onCropChange({ x, y, width, height }); + }; + + const showOverlay = !showOutputMode; + const showHandles = !showOutputMode && (!followEnabled ? true : true); + + const controlsAvailable = useMemo( + () => Boolean(cursorFollow && onCursorFollowChange), + [cursorFollow, onCursorFollowChange], + ); + return (
+ {controlsAvailable && cursorFollow ? ( +
+ + {cursorFollow.enabled ? ( + <> +
+ Output size +
+ {OUTPUT_RESOLUTION_PRESETS.map((preset) => { + const fits = + sourceWidthPx >= preset.width && + sourceHeightPx >= preset.height; + const active = activePresetId === preset.id; + return ( + + ); + })} +
+ + {sourceWidthPx && sourceHeightPx + ? `${currentOutputWidthPx}×${currentOutputHeightPx}` + : "—"} + +
+
+ Safe zone + + handleSafeZoneChange(Number(e.target.value)) + } + className="flex-1" + /> + + {Math.round(cursorFollow.safeZoneRatio * 100)}% + +
+
+ Smoothness + + handleSmoothnessChange(Number(e.target.value)) + } + className="flex-1" + /> + + {Math.round(cursorFollow.smoothness * 100)}% + +
+ +
+ Preview +
+ + +
+
+ + ) : null} +
+ ) : null}
-
- - - - - - - - - -
+ > + + + + + + + + +
+ ) : null} -
handlePointerDown(e, "top")} - /> + {showHandles ? ( + <> +
handlePointerDown(e, "top")} + /> -
handlePointerDown(e, "bottom")} - /> +
handlePointerDown(e, "bottom")} + /> -
handlePointerDown(e, "left")} - /> +
handlePointerDown(e, "left")} + /> -
handlePointerDown(e, "right")} - /> +
handlePointerDown(e, "right")} + /> + + ) : null}
); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 12908a6dd..b19ea4bd5 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -174,6 +174,7 @@ import { type ClipRegion, type CropRegion, type CursorClickEffectStyle, + type CursorFollowCropSettings, type CursorStyle, type CursorTelemetryPoint, clampFocusToDepth, @@ -187,6 +188,7 @@ import { DEFAULT_CONNECTED_ZOOM_EASING, DEFAULT_CONNECTED_ZOOM_GAP_MS, DEFAULT_CROP_REGION, + DEFAULT_CURSOR_FOLLOW_CROP, DEFAULT_CURSOR_STYLE, DEFAULT_FIGURE_DATA, DEFAULT_WEBCAM_OVERLAY, @@ -489,6 +491,9 @@ export default function VideoEditor() { const [padding, setPadding] = useState(initialEditorPreferences.padding); const [frame, setFrame] = useState(initialEditorPreferences.frame); const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); + const [cursorFollowCrop, setCursorFollowCrop] = useState( + DEFAULT_CURSOR_FOLLOW_CROP, + ); const [webcam, setWebcam] = useState( initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY, ); @@ -1095,6 +1100,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + cursorFollowCrop, webcam, webcamUrl: resolvedWebcamVideoUrl ?? @@ -1629,6 +1635,7 @@ export default function VideoEditor() { padding: Padding; frame: string | null; cropRegion: CropRegion; + cursorFollowCrop: CursorFollowCropSettings; webcam: WebcamOverlaySettings; zoomRegions: ZoomRegion[]; trimRegions: TrimRegion[]; @@ -1737,6 +1744,7 @@ export default function VideoEditor() { padding, frame, cropRegion, + cursorFollowCrop, webcam, zoomRegions, trimRegions, @@ -1803,6 +1811,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + cursorFollowCrop, webcam, zoomRegions, trimRegions, @@ -1993,6 +2002,7 @@ export default function VideoEditor() { setPadding(normalizedEditor.padding); setFrame(normalizedEditor.frame); setCropRegion(normalizedEditor.cropRegion); + setCursorFollowCrop(normalizedEditor.cursorFollowCrop); setWebcam(normalizedEditor.webcam); setZoomRegions(normalizedEditor.zoomRegions); setTrimRegions(normalizedEditor.trimRegions); @@ -4220,6 +4230,7 @@ export default function VideoEditor() { padding, videoPadding: padding, cropRegion, + cursorFollowCrop, webcam, webcamUrl: resolvedWebcamVideoUrl ?? @@ -4403,6 +4414,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + cursorFollowCrop, webcam, webcamUrl: resolvedWebcamVideoUrl ?? @@ -4704,6 +4716,7 @@ export default function VideoEditor() { borderRadius, padding, cropRegion, + cursorFollowCrop, webcam, resolvedWebcamVideoUrl, annotationRegions, @@ -5118,6 +5131,7 @@ export default function VideoEditor() { padding={padding} frame={frame} cropRegion={cropRegion} + cursorFollowCrop={cursorFollowCrop} webcam={webcam} webcamVideoPath={webcam.sourcePath ? resolvedWebcamVideoUrl : null} trimRegions={trimRegions} @@ -6338,6 +6352,10 @@ export default function VideoEditor() { cropRegion={cropRegion} onCropChange={setCropRegion} aspectRatio={aspectRatio} + cursorFollow={cursorFollowCrop} + onCursorFollowChange={setCursorFollowCrop} + cursorTelemetry={cursorTelemetry} + currentTimeMs={currentTime * 1000} />