Skip to content

Commit 2bfe0d4

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 8d27eff commit 2bfe0d4

12 files changed

Lines changed: 1094 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
@@ -171,6 +171,7 @@ import {
171171
type CaptionCue,
172172
type ClipRegion,
173173
type CropRegion,
174+
type CursorFollowCropSettings,
174175
type CursorStyle,
175176
type CursorTelemetryPoint,
176177
clampFocusToDepth,
@@ -184,6 +185,7 @@ import {
184185
DEFAULT_CONNECTED_ZOOM_EASING,
185186
DEFAULT_CONNECTED_ZOOM_GAP_MS,
186187
DEFAULT_CROP_REGION,
188+
DEFAULT_CURSOR_FOLLOW_CROP,
187189
DEFAULT_CURSOR_STYLE,
188190
DEFAULT_FIGURE_DATA,
189191
DEFAULT_WEBCAM_OVERLAY,
@@ -475,6 +477,9 @@ export default function VideoEditor() {
475477
const [padding, setPadding] = useState(initialEditorPreferences.padding);
476478
const [frame, setFrame] = useState<string | null>(initialEditorPreferences.frame);
477479
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
480+
const [cursorFollowCrop, setCursorFollowCrop] = useState<CursorFollowCropSettings>(
481+
DEFAULT_CURSOR_FOLLOW_CROP,
482+
);
478483
const [webcam, setWebcam] = useState<WebcamOverlaySettings>(
479484
initialEditorPreferences.webcam ?? DEFAULT_WEBCAM_OVERLAY,
480485
);
@@ -1065,6 +1070,7 @@ export default function VideoEditor() {
10651070
borderRadius,
10661071
padding,
10671072
cropRegion,
1073+
cursorFollowCrop,
10681074
webcam,
10691075
webcamUrl:
10701076
resolvedWebcamVideoUrl ??
@@ -1576,6 +1582,7 @@ export default function VideoEditor() {
15761582
padding: Padding;
15771583
frame: string | null;
15781584
cropRegion: CropRegion;
1585+
cursorFollowCrop: CursorFollowCropSettings;
15791586
webcam: WebcamOverlaySettings;
15801587
zoomRegions: ZoomRegion[];
15811588
trimRegions: TrimRegion[];
@@ -1679,6 +1686,7 @@ export default function VideoEditor() {
16791686
padding,
16801687
frame,
16811688
cropRegion,
1689+
cursorFollowCrop,
16821690
webcam,
16831691
zoomRegions,
16841692
trimRegions,
@@ -1740,6 +1748,7 @@ export default function VideoEditor() {
17401748
borderRadius,
17411749
padding,
17421750
cropRegion,
1751+
cursorFollowCrop,
17431752
webcam,
17441753
zoomRegions,
17451754
trimRegions,
@@ -1925,6 +1934,7 @@ export default function VideoEditor() {
19251934
setPadding(normalizedEditor.padding);
19261935
setFrame(normalizedEditor.frame);
19271936
setCropRegion(normalizedEditor.cropRegion);
1937+
setCursorFollowCrop(normalizedEditor.cursorFollowCrop);
19281938
setWebcam(normalizedEditor.webcam);
19291939
setZoomRegions(normalizedEditor.zoomRegions);
19301940
setTrimRegions(normalizedEditor.trimRegions);
@@ -4086,6 +4096,7 @@ export default function VideoEditor() {
40864096
padding,
40874097
videoPadding: padding,
40884098
cropRegion,
4099+
cursorFollowCrop,
40894100
webcam,
40904101
webcamUrl:
40914102
resolvedWebcamVideoUrl ??
@@ -4264,6 +4275,7 @@ export default function VideoEditor() {
42644275
borderRadius,
42654276
padding,
42664277
cropRegion,
4278+
cursorFollowCrop,
42674279
webcam,
42684280
webcamUrl:
42694281
resolvedWebcamVideoUrl ??
@@ -4555,6 +4567,7 @@ export default function VideoEditor() {
45554567
borderRadius,
45564568
padding,
45574569
cropRegion,
4570+
cursorFollowCrop,
45584571
webcam,
45594572
resolvedWebcamVideoUrl,
45604573
annotationRegions,
@@ -5853,6 +5866,7 @@ export default function VideoEditor() {
58535866
padding={padding}
58545867
frame={frame}
58555868
cropRegion={cropRegion}
5869+
cursorFollowCrop={cursorFollowCrop}
58565870
webcam={webcam}
58575871
webcamVideoPath={
58585872
webcam.sourcePath
@@ -6236,6 +6250,10 @@ export default function VideoEditor() {
62366250
cropRegion={cropRegion}
62376251
onCropChange={setCropRegion}
62386252
aspectRatio={aspectRatio}
6253+
cursorFollow={cursorFollowCrop}
6254+
onCursorFollowChange={setCursorFollowCrop}
6255+
cursorTelemetry={cursorTelemetry}
6256+
currentTimeMs={currentTime * 1000}
62396257
/>
62406258
<div className="mt-6 flex justify-end">
62416259
<Button

src/components/video-editor/VideoPlayback.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ import {
159159
resetCursorFollowCamera,
160160
SNAP_TO_EDGES_RATIO_AUTO,
161161
} from "./videoPlayback/cursorFollowCamera";
162+
import {
163+
type CursorFollowCropState,
164+
computeCursorFollowCrop,
165+
createCursorFollowCropState,
166+
resetCursorFollowCropState,
167+
} from "./videoPlayback/cursorFollowCrop";
162168
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
163169
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
164170
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
@@ -338,6 +344,7 @@ interface VideoPlaybackProps {
338344
padding?: Padding | number;
339345
frame?: string | null;
340346
cropRegion?: import("./types").CropRegion;
347+
cursorFollowCrop?: import("./types").CursorFollowCropSettings;
341348
webcam?: WebcamOverlaySettings;
342349
webcamVideoPath?: string | null;
343350
trimRegions?: TrimRegion[];
@@ -416,6 +423,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
416423
padding = DEFAULT_PADDING,
417424
frame = null,
418425
cropRegion,
426+
cursorFollowCrop,
419427
webcam,
420428
webcamVideoPath,
421429
trimRegions = [],
@@ -581,6 +589,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
581589
const cursorFollowCameraRef = useRef<CursorFollowCameraState>(
582590
createCursorFollowCameraState(),
583591
);
592+
const cursorFollowCropRef = useRef(cursorFollowCrop);
593+
const cursorFollowCropStateRef = useRef<CursorFollowCropState>(
594+
createCursorFollowCropState(),
595+
);
596+
const baseCropRegionRef = useRef(cropRegion);
597+
const effectiveCropRegionRef = useRef(cropRegion);
584598

585599
const initializePixiRenderer = useCallback(
586600
async (container: HTMLDivElement): Promise<{
@@ -1489,6 +1503,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
14891503
connectedZoomEasingRef.current = connectedZoomEasing;
14901504
}, [connectedZoomEasing]);
14911505

1506+
useEffect(() => {
1507+
cursorFollowCropRef.current = cursorFollowCrop;
1508+
resetCursorFollowCropState(cursorFollowCropStateRef.current);
1509+
}, [cursorFollowCrop?.enabled, cursorFollowCrop?.safeZoneRatio, cursorFollowCrop?.smoothness, cursorFollowCrop?.trackTextCursor]);
1510+
1511+
useEffect(() => {
1512+
baseCropRegionRef.current = cropRegion ?? { x: 0, y: 0, width: 1, height: 1 };
1513+
effectiveCropRegionRef.current = baseCropRegionRef.current;
1514+
resetCursorFollowCropState(cursorFollowCropStateRef.current);
1515+
}, [cropRegion]);
1516+
14921517
useEffect(() => {
14931518
cursorTelemetryRef.current = cursorTelemetry;
14941519
// Push to extension host for query APIs
@@ -2077,6 +2102,44 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
20772102
return;
20782103
}
20792104

2105+
// Cursor-follow crop: per-frame viewport pan within the source video.
2106+
// When enabled, recompute the effective crop top-left from cursor
2107+
// telemetry and shift the video sprite to keep the cursor framed.
2108+
const followSettings = cursorFollowCropRef.current;
2109+
const baseCrop = baseCropRegionRef.current;
2110+
const sprite = videoSpriteRef.current;
2111+
const lockedDims = lockedVideoDimensionsRef.current;
2112+
if (followSettings?.enabled && sprite && lockedDims && baseCrop) {
2113+
const effectiveCrop = computeCursorFollowCrop(
2114+
cursorFollowCropStateRef.current,
2115+
cursorTelemetryRef.current,
2116+
currentTimeRef.current,
2117+
baseCrop,
2118+
followSettings,
2119+
);
2120+
effectiveCropRegionRef.current = effectiveCrop;
2121+
const fullVideoDisplayWidth = lockedDims.width * baseScaleRef.current;
2122+
const fullVideoDisplayHeight = lockedDims.height * baseScaleRef.current;
2123+
const dx = (effectiveCrop.x - baseCrop.x) * fullVideoDisplayWidth;
2124+
const dy = (effectiveCrop.y - baseCrop.y) * fullVideoDisplayHeight;
2125+
sprite.position.set(
2126+
baseOffsetRef.current.x - dx,
2127+
baseOffsetRef.current.y - dy,
2128+
);
2129+
cropBoundsRef.current = {
2130+
startX: effectiveCrop.x * lockedDims.width,
2131+
endX: effectiveCrop.x * lockedDims.width + effectiveCrop.width * lockedDims.width,
2132+
startY: effectiveCrop.y * lockedDims.height,
2133+
endY: effectiveCrop.y * lockedDims.height + effectiveCrop.height * lockedDims.height,
2134+
};
2135+
if (baseMaskRef.current.sourceCrop) {
2136+
baseMaskRef.current.sourceCrop = { ...effectiveCrop };
2137+
}
2138+
} else if (sprite) {
2139+
effectiveCropRegionRef.current = baseCrop;
2140+
sprite.position.set(baseOffsetRef.current.x, baseOffsetRef.current.y);
2141+
}
2142+
20802143
const { region, strength, blendedScale, transition } = findDominantRegion(
20812144
zoomRegionsRef.current,
20822145
currentTimeRef.current,

src/components/video-editor/projectPersistence.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
type CaptionCueWord,
3131
type ClipRegion,
3232
type CropRegion,
33+
type CursorFollowCropPreviewMode,
34+
type CursorFollowCropSettings,
3335
type CursorStyle,
3436
DEFAULT_ANNOTATION_POSITION,
3537
DEFAULT_ANNOTATION_SIZE,
@@ -39,6 +41,7 @@ import {
3941
DEFAULT_CONNECTED_ZOOM_EASING,
4042
DEFAULT_CONNECTED_ZOOM_GAP_MS,
4143
DEFAULT_CROP_REGION,
44+
DEFAULT_CURSOR_FOLLOW_CROP,
4245
DEFAULT_CURSOR_STYLE,
4346
DEFAULT_CURSOR_SWAY,
4447
DEFAULT_FIGURE_DATA,
@@ -116,6 +119,7 @@ export interface ProjectEditorState {
116119
/** Selected frame ID (e.g. "recordly.frames/browser-dark"), or null for none */
117120
frame: string | null;
118121
cropRegion: CropRegion;
122+
cursorFollowCrop: CursorFollowCropSettings;
119123
zoomRegions: ZoomRegion[];
120124
trimRegions: TrimRegion[];
121125
clipRegions: ClipRegion[];
@@ -806,6 +810,27 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
806810
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
807811
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
808812

813+
const rawCursorFollowCrop = (editor as Partial<ProjectEditorState>).cursorFollowCrop;
814+
const cursorFollowCropPreviewMode: CursorFollowCropPreviewMode =
815+
rawCursorFollowCrop?.previewMode === "output" ? "output" : "source";
816+
const normalizedCursorFollowCrop: CursorFollowCropSettings = {
817+
enabled:
818+
typeof rawCursorFollowCrop?.enabled === "boolean"
819+
? rawCursorFollowCrop.enabled
820+
: DEFAULT_CURSOR_FOLLOW_CROP.enabled,
821+
safeZoneRatio: isFiniteNumber(rawCursorFollowCrop?.safeZoneRatio)
822+
? clamp(rawCursorFollowCrop.safeZoneRatio, 0, 0.49)
823+
: DEFAULT_CURSOR_FOLLOW_CROP.safeZoneRatio,
824+
smoothness: isFiniteNumber(rawCursorFollowCrop?.smoothness)
825+
? clamp(rawCursorFollowCrop.smoothness, 0, 1)
826+
: DEFAULT_CURSOR_FOLLOW_CROP.smoothness,
827+
previewMode: cursorFollowCropPreviewMode,
828+
trackTextCursor:
829+
typeof rawCursorFollowCrop?.trackTextCursor === "boolean"
830+
? rawCursorFollowCrop.trackTextCursor
831+
: DEFAULT_CURSOR_FOLLOW_CROP.trackTextCursor,
832+
};
833+
809834
const webcam: Partial<WebcamOverlaySettings> =
810835
editor.webcam && typeof editor.webcam === "object" ? editor.webcam : {};
811836
const webcamSourcePath = typeof webcam.sourcePath === "string" ? webcam.sourcePath : null;
@@ -936,6 +961,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
936961
width: cropWidth,
937962
height: cropHeight,
938963
},
964+
cursorFollowCrop: normalizedCursorFollowCrop,
939965
zoomRegions: normalizedZoomRegions,
940966
trimRegions: normalizedTrimRegions,
941967
clipRegions: normalizedClipRegions,

src/components/video-editor/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,32 @@ export const DEFAULT_CROP_REGION: CropRegion = {
451451
height: 1,
452452
};
453453

454+
export type CursorFollowCropPreviewMode = "source" | "output";
455+
456+
export interface CursorFollowCropSettings {
457+
enabled: boolean;
458+
/** Safe-zone inset 0..0.49 — fraction of viewport edge before camera pans. */
459+
safeZoneRatio: number;
460+
/** 0..1, smoothing applied per frame (higher = slower follow, less jitter). */
461+
smoothness: number;
462+
/** Editor-only: which view to show in the crop panel. */
463+
previewMode: CursorFollowCropPreviewMode;
464+
/**
465+
* When true: viewport locks to the text I-beam position while typing and
466+
* only aggressively follows the mouse when it's actively moving. Mouse
467+
* always wins; transitions are debounced to avoid jarring cuts.
468+
*/
469+
trackTextCursor: boolean;
470+
}
471+
472+
export const DEFAULT_CURSOR_FOLLOW_CROP: CursorFollowCropSettings = {
473+
enabled: false,
474+
safeZoneRatio: 0.25,
475+
smoothness: 0.5,
476+
previewMode: "source",
477+
trackTextCursor: false,
478+
};
479+
454480
export interface Padding {
455481
top: number;
456482
bottom: number;

0 commit comments

Comments
 (0)