Skip to content

Commit 75ee15c

Browse files
walters954claude
andcommitted
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 <noreply@anthropic.com>
1 parent 1f9912b commit 75ee15c

12 files changed

Lines changed: 1093 additions & 119 deletions

src/components/video-editor/CropControl.tsx

Lines changed: 417 additions & 119 deletions
Large diffs are not rendered by default.

src/components/video-editor/VideoEditor.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ import {
174174
type ClipRegion,
175175
type CropRegion,
176176
type CursorClickEffectStyle,
177+
type CursorFollowCropSettings,
177178
type CursorStyle,
178179
type CursorTelemetryPoint,
179180
clampFocusToDepth,
@@ -187,6 +188,7 @@ import {
187188
DEFAULT_CONNECTED_ZOOM_EASING,
188189
DEFAULT_CONNECTED_ZOOM_GAP_MS,
189190
DEFAULT_CROP_REGION,
191+
DEFAULT_CURSOR_FOLLOW_CROP,
190192
DEFAULT_CURSOR_STYLE,
191193
DEFAULT_FIGURE_DATA,
192194
DEFAULT_WEBCAM_OVERLAY,
@@ -489,6 +491,9 @@ export default function VideoEditor() {
489491
const [padding, setPadding] = useState(initialEditorPreferences.padding);
490492
const [frame, setFrame] = useState<string | null>(initialEditorPreferences.frame);
491493
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
494+
const [cursorFollowCrop, setCursorFollowCrop] = useState<CursorFollowCropSettings>(
495+
DEFAULT_CURSOR_FOLLOW_CROP,
496+
);
492497
const [webcam, setWebcam] = useState<WebcamOverlaySettings>(
493498
initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY,
494499
);
@@ -1095,6 +1100,7 @@ export default function VideoEditor() {
10951100
borderRadius,
10961101
padding,
10971102
cropRegion,
1103+
cursorFollowCrop,
10981104
webcam,
10991105
webcamUrl:
11001106
resolvedWebcamVideoUrl ??
@@ -1629,6 +1635,7 @@ export default function VideoEditor() {
16291635
padding: Padding;
16301636
frame: string | null;
16311637
cropRegion: CropRegion;
1638+
cursorFollowCrop: CursorFollowCropSettings;
16321639
webcam: WebcamOverlaySettings;
16331640
zoomRegions: ZoomRegion[];
16341641
trimRegions: TrimRegion[];
@@ -1737,6 +1744,7 @@ export default function VideoEditor() {
17371744
padding,
17381745
frame,
17391746
cropRegion,
1747+
cursorFollowCrop,
17401748
webcam,
17411749
zoomRegions,
17421750
trimRegions,
@@ -1803,6 +1811,7 @@ export default function VideoEditor() {
18031811
borderRadius,
18041812
padding,
18051813
cropRegion,
1814+
cursorFollowCrop,
18061815
webcam,
18071816
zoomRegions,
18081817
trimRegions,
@@ -1993,6 +2002,7 @@ export default function VideoEditor() {
19932002
setPadding(normalizedEditor.padding);
19942003
setFrame(normalizedEditor.frame);
19952004
setCropRegion(normalizedEditor.cropRegion);
2005+
setCursorFollowCrop(normalizedEditor.cursorFollowCrop);
19962006
setWebcam(normalizedEditor.webcam);
19972007
setZoomRegions(normalizedEditor.zoomRegions);
19982008
setTrimRegions(normalizedEditor.trimRegions);
@@ -4220,6 +4230,7 @@ export default function VideoEditor() {
42204230
padding,
42214231
videoPadding: padding,
42224232
cropRegion,
4233+
cursorFollowCrop,
42234234
webcam,
42244235
webcamUrl:
42254236
resolvedWebcamVideoUrl ??
@@ -4403,6 +4414,7 @@ export default function VideoEditor() {
44034414
borderRadius,
44044415
padding,
44054416
cropRegion,
4417+
cursorFollowCrop,
44064418
webcam,
44074419
webcamUrl:
44084420
resolvedWebcamVideoUrl ??
@@ -4704,6 +4716,7 @@ export default function VideoEditor() {
47044716
borderRadius,
47054717
padding,
47064718
cropRegion,
4719+
cursorFollowCrop,
47074720
webcam,
47084721
resolvedWebcamVideoUrl,
47094722
annotationRegions,
@@ -5118,6 +5131,7 @@ export default function VideoEditor() {
51185131
padding={padding}
51195132
frame={frame}
51205133
cropRegion={cropRegion}
5134+
cursorFollowCrop={cursorFollowCrop}
51215135
webcam={webcam}
51225136
webcamVideoPath={webcam.sourcePath ? resolvedWebcamVideoUrl : null}
51235137
trimRegions={trimRegions}
@@ -6338,6 +6352,10 @@ export default function VideoEditor() {
63386352
cropRegion={cropRegion}
63396353
onCropChange={setCropRegion}
63406354
aspectRatio={aspectRatio}
6355+
cursorFollow={cursorFollowCrop}
6356+
onCursorFollowChange={setCursorFollowCrop}
6357+
cursorTelemetry={cursorTelemetry}
6358+
currentTimeMs={currentTime * 1000}
63416359
/>
63426360
<div className="mt-6 flex justify-end">
63436361
<Button

src/components/video-editor/VideoPlayback.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ import {
165165
resetCursorFollowCamera,
166166
SNAP_TO_EDGES_RATIO_AUTO,
167167
} from "./videoPlayback/cursorFollowCamera";
168+
import {
169+
type CursorFollowCropState,
170+
computeCursorFollowCrop,
171+
createCursorFollowCropState,
172+
resetCursorFollowCropState,
173+
} from "./videoPlayback/cursorFollowCrop";
168174
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
169175
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
170176
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -350,6 +356,7 @@ interface VideoPlaybackProps {
350356
padding?: Padding | number;
351357
frame?: string | null;
352358
cropRegion?: import("./types").CropRegion;
359+
cursorFollowCrop?: import("./types").CursorFollowCropSettings;
353360
webcam?: WebcamOverlaySettings;
354361
webcamVideoPath?: string | null;
355362
trimRegions?: TrimRegion[];
@@ -433,6 +440,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
433440
padding = DEFAULT_PADDING,
434441
frame = null,
435442
cropRegion,
443+
cursorFollowCrop,
436444
webcam,
437445
webcamVideoPath,
438446
trimRegions = [],
@@ -611,6 +619,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
611619
const cursorFollowCameraRef = useRef<CursorFollowCameraState>(
612620
createCursorFollowCameraState(),
613621
);
622+
const cursorFollowCropRef = useRef(cursorFollowCrop);
623+
const cursorFollowCropStateRef = useRef<CursorFollowCropState>(
624+
createCursorFollowCropState(),
625+
);
626+
const baseCropRegionRef = useRef(cropRegion);
627+
const effectiveCropRegionRef = useRef(cropRegion);
614628

615629
const initializePixiRenderer = useCallback(
616630
async (
@@ -1534,6 +1548,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
15341548
connectedZoomEasingRef.current = connectedZoomEasing;
15351549
}, [connectedZoomEasing]);
15361550

1551+
useEffect(() => {
1552+
cursorFollowCropRef.current = cursorFollowCrop;
1553+
resetCursorFollowCropState(cursorFollowCropStateRef.current);
1554+
}, [cursorFollowCrop?.enabled, cursorFollowCrop?.safeZoneRatio, cursorFollowCrop?.smoothness, cursorFollowCrop?.trackTextCursor]);
1555+
1556+
useEffect(() => {
1557+
baseCropRegionRef.current = cropRegion ?? { x: 0, y: 0, width: 1, height: 1 };
1558+
effectiveCropRegionRef.current = baseCropRegionRef.current;
1559+
resetCursorFollowCropState(cursorFollowCropStateRef.current);
1560+
}, [cropRegion]);
1561+
15371562
useEffect(() => {
15381563
cursorTelemetryRef.current = cursorTelemetry;
15391564
// Push to extension host for query APIs
@@ -2194,6 +2219,44 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
21942219
return;
21952220
}
21962221

2222+
// Cursor-follow crop: per-frame viewport pan within the source video.
2223+
// When enabled, recompute the effective crop top-left from cursor
2224+
// telemetry and shift the video sprite to keep the cursor framed.
2225+
const followSettings = cursorFollowCropRef.current;
2226+
const baseCrop = baseCropRegionRef.current;
2227+
const sprite = videoSpriteRef.current;
2228+
const lockedDims = lockedVideoDimensionsRef.current;
2229+
if (followSettings?.enabled && sprite && lockedDims && baseCrop) {
2230+
const effectiveCrop = computeCursorFollowCrop(
2231+
cursorFollowCropStateRef.current,
2232+
cursorTelemetryRef.current,
2233+
currentTimeRef.current,
2234+
baseCrop,
2235+
followSettings,
2236+
);
2237+
effectiveCropRegionRef.current = effectiveCrop;
2238+
const fullVideoDisplayWidth = lockedDims.width * baseScaleRef.current;
2239+
const fullVideoDisplayHeight = lockedDims.height * baseScaleRef.current;
2240+
const dx = (effectiveCrop.x - baseCrop.x) * fullVideoDisplayWidth;
2241+
const dy = (effectiveCrop.y - baseCrop.y) * fullVideoDisplayHeight;
2242+
sprite.position.set(
2243+
baseOffsetRef.current.x - dx,
2244+
baseOffsetRef.current.y - dy,
2245+
);
2246+
cropBoundsRef.current = {
2247+
startX: effectiveCrop.x * lockedDims.width,
2248+
endX: effectiveCrop.x * lockedDims.width + effectiveCrop.width * lockedDims.width,
2249+
startY: effectiveCrop.y * lockedDims.height,
2250+
endY: effectiveCrop.y * lockedDims.height + effectiveCrop.height * lockedDims.height,
2251+
};
2252+
if (baseMaskRef.current.sourceCrop) {
2253+
baseMaskRef.current.sourceCrop = { ...effectiveCrop };
2254+
}
2255+
} else if (sprite) {
2256+
effectiveCropRegionRef.current = baseCrop;
2257+
sprite.position.set(baseOffsetRef.current.x, baseOffsetRef.current.y);
2258+
}
2259+
21972260
const { region, strength, blendedScale, transition } = findDominantRegion(
21982261
zoomRegionsRef.current,
21992262
currentTimeRef.current,

src/components/video-editor/projectPersistence.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import {
3131
type ClipRegion,
3232
type CropRegion,
3333
type CursorClickEffectStyle,
34+
type CursorFollowCropPreviewMode,
35+
type CursorFollowCropSettings,
3436
type CursorStyle,
3537
DEFAULT_ANNOTATION_POSITION,
3638
DEFAULT_ANNOTATION_SIZE,
@@ -45,6 +47,7 @@ import {
4547
DEFAULT_CURSOR_CLICK_EFFECT_DURATION_MS,
4648
DEFAULT_CURSOR_CLICK_EFFECT_OPACITY,
4749
DEFAULT_CURSOR_CLICK_EFFECT_SCALE,
50+
DEFAULT_CURSOR_FOLLOW_CROP,
4851
DEFAULT_CURSOR_STYLE,
4952
DEFAULT_CURSOR_SWAY,
5053
DEFAULT_FIGURE_DATA,
@@ -129,6 +132,7 @@ export interface ProjectEditorState {
129132
/** Selected frame ID (e.g. "recordly.frames/browser-dark"), or null for none */
130133
frame: string | null;
131134
cropRegion: CropRegion;
135+
cursorFollowCrop: CursorFollowCropSettings;
132136
zoomRegions: ZoomRegion[];
133137
trimRegions: TrimRegion[];
134138
clipRegions: ClipRegion[];
@@ -816,6 +820,27 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
816820
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
817821
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
818822

823+
const rawCursorFollowCrop = (editor as Partial<ProjectEditorState>).cursorFollowCrop;
824+
const cursorFollowCropPreviewMode: CursorFollowCropPreviewMode =
825+
rawCursorFollowCrop?.previewMode === "output" ? "output" : "source";
826+
const normalizedCursorFollowCrop: CursorFollowCropSettings = {
827+
enabled:
828+
typeof rawCursorFollowCrop?.enabled === "boolean"
829+
? rawCursorFollowCrop.enabled
830+
: DEFAULT_CURSOR_FOLLOW_CROP.enabled,
831+
safeZoneRatio: isFiniteNumber(rawCursorFollowCrop?.safeZoneRatio)
832+
? clamp(rawCursorFollowCrop.safeZoneRatio, 0, 0.49)
833+
: DEFAULT_CURSOR_FOLLOW_CROP.safeZoneRatio,
834+
smoothness: isFiniteNumber(rawCursorFollowCrop?.smoothness)
835+
? clamp(rawCursorFollowCrop.smoothness, 0, 1)
836+
: DEFAULT_CURSOR_FOLLOW_CROP.smoothness,
837+
previewMode: cursorFollowCropPreviewMode,
838+
trackTextCursor:
839+
typeof rawCursorFollowCrop?.trackTextCursor === "boolean"
840+
? rawCursorFollowCrop.trackTextCursor
841+
: DEFAULT_CURSOR_FOLLOW_CROP.trackTextCursor,
842+
};
843+
819844
const webcam: Partial<WebcamOverlaySettings> =
820845
editor.webcam && typeof editor.webcam === "object" ? editor.webcam : {};
821846
const webcamSourcePath = typeof webcam.sourcePath === "string" ? webcam.sourcePath : null;
@@ -977,6 +1002,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
9771002
width: cropWidth,
9781003
height: cropHeight,
9791004
},
1005+
cursorFollowCrop: normalizedCursorFollowCrop,
9801006
zoomRegions: normalizedZoomRegions,
9811007
trimRegions: normalizedTrimRegions,
9821008
clipRegions: normalizedClipRegions,

src/components/video-editor/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,32 @@ export const DEFAULT_CROP_REGION: CropRegion = {
497497
height: 1,
498498
};
499499

500+
export type CursorFollowCropPreviewMode = "source" | "output";
501+
502+
export interface CursorFollowCropSettings {
503+
enabled: boolean;
504+
/** Safe-zone inset 0..0.49 — fraction of viewport edge before camera pans. */
505+
safeZoneRatio: number;
506+
/** 0..1, smoothing applied per frame (higher = slower follow, less jitter). */
507+
smoothness: number;
508+
/** Editor-only: which view to show in the crop panel. */
509+
previewMode: CursorFollowCropPreviewMode;
510+
/**
511+
* When true: viewport locks to the text I-beam position while typing and
512+
* only aggressively follows the mouse when it's actively moving. Mouse
513+
* always wins; transitions are debounced to avoid jarring cuts.
514+
*/
515+
trackTextCursor: boolean;
516+
}
517+
518+
export const DEFAULT_CURSOR_FOLLOW_CROP: CursorFollowCropSettings = {
519+
enabled: false,
520+
safeZoneRatio: 0.25,
521+
smoothness: 0.5,
522+
previewMode: "source",
523+
trackTextCursor: false,
524+
};
525+
500526
export interface Padding {
501527
top: number;
502528
bottom: number;

0 commit comments

Comments
 (0)