added cursor loop feature and update readme

This commit is contained in:
webadderall committed 2026-03-15 13:29:10 +11:00
1 parent e938a4dc37
commit bf08f8ed3f
4 files changed
+260 -11

No files matched your search

+16 -2
View File
@@ -65,6 +65,8 @@ interface SettingsPanelProps {
onConnectZoomsChange?: (enabled: boolean) => void;
showCursor?: boolean;
onShowCursorChange?: (enabled: boolean) => void;
loopCursor?: boolean;
onLoopCursorChange?: (enabled: boolean) => void;
cursorSize?: number;
onCursorSizeChange?: (size: number) => void;
cursorSmoothing?: number;
@@ -139,6 +141,8 @@ export function SettingsPanel({
onConnectZoomsChange,
showCursor = false,
onShowCursorChange,
loopCursor = false,
onLoopCursorChange,
cursorSize = 5,
onCursorSizeChange,
cursorSmoothing = 2,
@@ -424,6 +428,16 @@ export function SettingsPanel({
className="data-[state=checked]:bg-[#2563EB] scale-90"
/>
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div>
<div className="text-[10px] font-medium text-slate-300">Loop cursor</div>
</div>
<Switch
checked={loopCursor}
onCheckedChange={onLoopCursorChange}
className="data-[state=checked]:bg-[#2563EB] scale-90"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Background Blur</div>
@@ -902,7 +916,7 @@ export function SettingsPanel({
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/webadderall/bug-free-succotash/issues/new/choose');
window.electronAPI?.openExternalUrl('https://github.com/webadderall/Recordly/issues/new/choose');
}}
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
@@ -912,7 +926,7 @@ export function SettingsPanel({
<button
type="button"
onClick={() => {
window.electronAPI?.openExternalUrl('https://github.com/webadderall/bug-free-succotash');
window.electronAPI?.openExternalUrl('https://github.com/webadderall/Recordly');
}}
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
+67 -9
View File
@@ -53,6 +53,10 @@ import { getAssetPath } from "@/lib/assetPath";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
import { buildLoopedCursorTelemetry } from "./videoPlayback/cursorRenderer";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
const LOOP_CURSOR_END_WINDOW_MS = 670;
type EditorHistorySnapshot = {
zoomRegions: ZoomRegion[];
@@ -85,6 +89,7 @@ export default function VideoEditor() {
const [zoomMotionBlur, setZoomMotionBlur] = useState(DEFAULT_ZOOM_MOTION_BLUR);
const [connectZooms, setConnectZooms] = useState(true);
const [showCursor, setShowCursor] = useState(true);
const [loopCursor, setLoopCursor] = useState(false);
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
@@ -239,6 +244,7 @@ export default function VideoEditor() {
setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
setConnectZooms(normalizedEditor.connectZooms);
setShowCursor(normalizedEditor.showCursor);
setLoopCursor(normalizedEditor.loopCursor);
setCursorSize(normalizedEditor.cursorSize);
setCursorSmoothing(normalizedEditor.cursorSmoothing);
setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
@@ -289,6 +295,7 @@ export default function VideoEditor() {
zoomMotionBlur,
connectZooms,
showCursor,
loopCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
@@ -317,6 +324,7 @@ export default function VideoEditor() {
zoomMotionBlur,
connectZooms,
showCursor,
loopCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
@@ -424,6 +432,7 @@ export default function VideoEditor() {
zoomMotionBlur,
connectZooms,
showCursor,
loopCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
@@ -477,6 +486,7 @@ export default function VideoEditor() {
zoomMotionBlur,
connectZooms,
showCursor,
loopCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
@@ -603,6 +613,52 @@ export default function VideoEditor() {
return normalizeCursorTelemetry(cursorTelemetry, totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER);
}, [cursorTelemetry, duration]);
const effectiveCursorTelemetry = useMemo(() => {
if (!loopCursor) {
return normalizedCursorTelemetry;
}
const totalMs = Math.max(0, Math.round(duration * 1000));
if (normalizedCursorTelemetry.length < 2 || totalMs <= 0) {
return normalizedCursorTelemetry;
}
return buildLoopedCursorTelemetry(normalizedCursorTelemetry, totalMs);
}, [loopCursor, normalizedCursorTelemetry, duration]);
const effectiveZoomRegions = useMemo(() => {
if (!loopCursor || zoomRegions.length === 0) {
return zoomRegions;
}
const totalMs = Math.max(0, Math.round(duration * 1000));
if (totalMs <= 0) {
return zoomRegions;
}
const dominantAtStart = findDominantRegion(zoomRegions, 0, { connectZooms }).region;
if (!dominantAtStart) {
return zoomRegions;
}
const endWindowStartMs = Math.max(0, totalMs - LOOP_CURSOR_END_WINDOW_MS);
const loopEndRegion: ZoomRegion = {
id: `${dominantAtStart.id}__loop-end-sync`,
startMs: endWindowStartMs,
endMs: totalMs,
depth: dominantAtStart.depth,
focus: {
cx: dominantAtStart.focus.cx,
cy: dominantAtStart.focus.cy,
},
};
return [
...zoomRegions.filter((region) => region.id !== loopEndRegion.id),
loopEndRegion,
];
}, [loopCursor, zoomRegions, duration, connectZooms]);
useEffect(() => {
if (!videoPath || duration <= 0 || zoomRegions.length > 0 || normalizedCursorTelemetry.length < 2) {
return;
@@ -1178,7 +1234,6 @@ export default function VideoEditor() {
loop: settings.gifConfig.loop,
sizePreset: settings.gifConfig.sizePreset,
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
@@ -1191,7 +1246,8 @@ export default function VideoEditor() {
videoPadding: padding,
cropRegion,
annotationRegions,
cursorTelemetry: normalizedCursorTelemetry,
zoomRegions: effectiveZoomRegions,
cursorTelemetry: effectiveCursorTelemetry,
showCursor,
cursorSize,
cursorSmoothing,
@@ -1320,7 +1376,6 @@ export default function VideoEditor() {
bitrate,
codec: 'avc1.640033',
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
showShadow: shadowIntensity > 0,
@@ -1332,7 +1387,8 @@ export default function VideoEditor() {
padding,
cropRegion,
annotationRegions,
cursorTelemetry: normalizedCursorTelemetry,
zoomRegions: effectiveZoomRegions,
cursorTelemetry: effectiveCursorTelemetry,
showCursor,
cursorSize,
cursorSmoothing,
@@ -1394,7 +1450,7 @@ export default function VideoEditor() {
setShowExportDialog(keepExportDialogOpen);
setExportProgress(null);
}
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, connectZooms, showCursor, normalizedCursorTelemetry, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality, showExportSuccessToast]);
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, connectZooms, showCursor, effectiveCursorTelemetry, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality, showExportSuccessToast]);
const handleOpenExportDialog = useCallback(() => {
if (!videoPath) {
@@ -1567,7 +1623,7 @@ export default function VideoEditor() {
onPlayStateChange={setIsPlaying}
onError={setError}
wallpaper={wallpaper}
zoomRegions={zoomRegions}
zoomRegions={effectiveZoomRegions}
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
@@ -1587,7 +1643,7 @@ export default function VideoEditor() {
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
cursorTelemetry={normalizedCursorTelemetry}
cursorTelemetry={effectiveCursorTelemetry}
showCursor={showCursor}
cursorSize={cursorSize}
cursorSmoothing={cursorSmoothing}
@@ -1622,8 +1678,8 @@ export default function VideoEditor() {
videoDuration={duration}
currentTime={currentTime}
onSeek={handleSeek}
cursorTelemetry={normalizedCursorTelemetry}
zoomRegions={zoomRegions}
cursorTelemetry={effectiveCursorTelemetry}
zoomRegions={effectiveZoomRegions}
onZoomAdded={handleZoomAdded}
onZoomSuggested={handleZoomSuggested}
onZoomSpanChange={handleZoomSpanChange}
@@ -1676,6 +1732,8 @@ export default function VideoEditor() {
onConnectZoomsChange={setConnectZooms}
showCursor={showCursor}
onShowCursorChange={setShowCursor}
loopCursor={loopCursor}
onLoopCursorChange={setLoopCursor}
cursorSize={cursorSize}
onCursorSizeChange={setCursorSize}
cursorSmoothing={cursorSmoothing}
@@ -30,6 +30,7 @@ export interface ProjectEditorState {
zoomMotionBlur: number;
connectZooms: boolean;
showCursor: boolean;
loopCursor: boolean;
cursorSize: number;
cursorSmoothing: number;
cursorMotionBlur: number;
@@ -307,6 +308,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
zoomMotionBlur: normalizedZoomMotionBlur,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
cursorSize: isFiniteNumber(editor.cursorSize) ? clamp(editor.cursorSize, 0.5, 10) : DEFAULT_CURSOR_SIZE,
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
? clamp(editor.cursorSmoothing, 0, 2)
@@ -57,6 +57,10 @@ const CLICK_ANIMATION_MS = 140;
const CLICK_RING_FADE_MS = 240;
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
const CURSOR_TIME_DISCONTINUITY_MS = 100;
const CURSOR_STATIONARY_TARGET_EPSILON = 0.0008;
const CURSOR_STATIONARY_SNAP_DELAY_MS = 52;
const CURSOR_STATIONARY_SNAP_DISTANCE = 0.012;
const CURSOR_TRAILING_MOVEMENT_EPSILON = 0.0015;
const CURSOR_SVG_DROP_SHADOW_FILTER = 'drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))';
const CURSOR_SHADOW_COLOR = 0x000000;
const CURSOR_SHADOW_ALPHA = 0.35;
@@ -64,6 +68,9 @@ const CURSOR_SHADOW_OFFSET_X = 0;
const CURSOR_SHADOW_OFFSET_Y = 2;
const CURSOR_SHADOW_BLUR = 3;
const CURSOR_SHADOW_PADDING = 12;
const LOOP_CURSOR_FREEZE_DURATION_MS = 670;
const LOOP_CURSOR_RETURN_STEPS = 20;
const LOOP_CURSOR_SETTLE_DURATION_MS = 120;
let cursorAssetsPromise: Promise<void> | null = null;
let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {};
@@ -164,6 +171,46 @@ function getAvailableCursorKeys(): CursorAssetKey[] {
return loadedKeys.length > 0 ? loadedKeys : ['arrow'];
}
function findFirstStableCursorType(samples: CursorTelemetryPoint[]) {
for (const sample of samples) {
if (!sample.cursorType) {
continue;
}
if (sample.interactionType === 'click'
|| sample.interactionType === 'double-click'
|| sample.interactionType === 'right-click'
|| sample.interactionType === 'middle-click') {
continue;
}
return sample.cursorType;
}
return samples[0]?.cursorType ?? 'arrow';
}
function easeOutQuint(progress: number) {
return 1 - ((1 - progress) ** 5);
}
function findLastMovingSampleTime(samples: CursorTelemetryPoint[]) {
if (samples.length <= 1) {
return samples[0]?.timeMs ?? 0;
}
for (let index = samples.length - 1; index > 0; index -= 1) {
const curr = samples[index];
const prev = samples[index - 1];
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
if (distance > CURSOR_TRAILING_MOVEMENT_EPSILON) {
return curr.timeMs;
}
}
return samples[samples.length - 1].timeMs;
}
export async function preloadCursorAssets() {
if (!cursorAssetsPromise) {
cursorAssetsPromise = (async () => {
@@ -290,6 +337,100 @@ export function interpolateCursorPosition(
};
}
export function buildLoopedCursorTelemetry(
samples: CursorTelemetryPoint[],
totalDurationMs: number,
): CursorTelemetryPoint[] {
if (!samples || samples.length === 0) {
return [];
}
const timelineEndMs = Math.max(0, Math.round(totalDurationMs));
if (timelineEndMs <= 0) {
return samples;
}
const firstSample = samples[0];
const lastSample = samples[samples.length - 1];
const maxFreezeWindowMs = timelineEndMs - firstSample.timeMs;
if (maxFreezeWindowMs <= 1) {
return samples;
}
const freezeDurationMs = Math.min(LOOP_CURSOR_FREEZE_DURATION_MS, maxFreezeWindowMs);
const motionEndMs = timelineEndMs - freezeDurationMs;
const clampedSettleDurationMs = Math.min(LOOP_CURSOR_SETTLE_DURATION_MS, Math.max(0, freezeDurationMs - 1));
const returnMotionDurationMs = Math.max(1, freezeDurationMs - clampedSettleDurationMs);
const sourceStartMs = firstSample.timeMs;
const sourceEndMs = Math.max(sourceStartMs, findLastMovingSampleTime(samples));
const sourceDurationMs = Math.max(1, sourceEndMs - sourceStartMs);
const playbackWindowMs = Math.max(1, motionEndMs);
const startingCursorType = findFirstStableCursorType(samples);
const loopedSamples: CursorTelemetryPoint[] = [
{
...firstSample,
timeMs: 0,
interactionType: undefined,
cursorType: startingCursorType,
},
];
for (const sample of samples) {
const progress = clamp((sample.timeMs - sourceStartMs) / sourceDurationMs, 0, 1);
const mappedTimeMs = Math.round(playbackWindowMs * progress);
if (mappedTimeMs <= loopedSamples[loopedSamples.length - 1].timeMs) {
loopedSamples[loopedSamples.length - 1] = {
...sample,
timeMs: loopedSamples[loopedSamples.length - 1].timeMs,
};
continue;
}
loopedSamples.push({
...sample,
timeMs: mappedTimeMs,
});
}
const returnStartPoint = interpolateCursorPosition(loopedSamples, motionEndMs)
?? { cx: lastSample.cx, cy: lastSample.cy };
const returnStartCursorType = findLatestStableCursorType(loopedSamples, motionEndMs);
const returnMotionStartMs = motionEndMs;
for (let step = 0; step <= LOOP_CURSOR_RETURN_STEPS; step += 1) {
const progress = step / LOOP_CURSOR_RETURN_STEPS;
const easedProgress = easeOutQuint(progress);
const timeMs = Math.round(returnMotionStartMs + returnMotionDurationMs * progress);
loopedSamples.push({
timeMs,
cx: returnStartPoint.cx + (firstSample.cx - returnStartPoint.cx) * easedProgress,
cy: returnStartPoint.cy + (firstSample.cy - returnStartPoint.cy) * easedProgress,
interactionType: progress > 0 ? 'move' : undefined,
cursorType: progress >= 1 ? startingCursorType : progress <= 0 ? returnStartCursorType : undefined,
});
}
if (clampedSettleDurationMs > 0) {
const settleSteps = Math.max(2, Math.round((clampedSettleDurationMs / LOOP_CURSOR_FREEZE_DURATION_MS) * LOOP_CURSOR_RETURN_STEPS));
const settleStartMs = returnMotionStartMs + returnMotionDurationMs;
for (let step = 1; step <= settleSteps; step += 1) {
const progress = step / settleSteps;
const timeMs = Math.round(settleStartMs + clampedSettleDurationMs * progress);
loopedSamples.push({
timeMs,
cx: firstSample.cx,
cy: firstSample.cy,
interactionType: 'move',
cursorType: startingCursorType,
});
}
}
return loopedSamples;
}
function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
if (samples.length === 0) return null;
@@ -399,6 +540,9 @@ export class SmoothedCursorState {
private trailLength: number;
private initialized = false;
private lastTimeMs: number | null = null;
private lastTargetX = 0.5;
private lastTargetY = 0.5;
private stationaryTargetSinceMs: number | null = null;
private xSpring = createSpringState(0.5);
private ySpring = createSpringState(0.5);
@@ -413,6 +557,9 @@ export class SmoothedCursorState {
this.y = targetY;
this.initialized = true;
this.lastTimeMs = timeMs;
this.lastTargetX = targetX;
this.lastTargetY = targetY;
this.stationaryTargetSinceMs = timeMs;
this.xSpring.value = targetX;
this.ySpring.value = targetY;
this.xSpring.velocity = 0;
@@ -428,6 +575,28 @@ export class SmoothedCursorState {
return;
}
const targetDelta = Math.hypot(targetX - this.lastTargetX, targetY - this.lastTargetY);
if (targetDelta <= CURSOR_STATIONARY_TARGET_EPSILON) {
if (this.stationaryTargetSinceMs === null) {
this.stationaryTargetSinceMs = this.lastTimeMs ?? timeMs;
}
} else {
this.stationaryTargetSinceMs = null;
}
this.lastTargetX = targetX;
this.lastTargetY = targetY;
if (this.stationaryTargetSinceMs !== null) {
const stationaryDurationMs = Math.max(0, timeMs - this.stationaryTargetSinceMs);
const distanceToTarget = Math.hypot(targetX - this.x, targetY - this.y);
if (stationaryDurationMs >= CURSOR_STATIONARY_SNAP_DELAY_MS
&& distanceToTarget <= CURSOR_STATIONARY_SNAP_DISTANCE) {
this.snapTo(targetX, targetY, timeMs);
return;
}
}
this.trail.unshift({ x: this.x, y: this.y });
if (this.trail.length > this.trailLength) {
this.trail.length = this.trailLength;
@@ -450,6 +619,9 @@ export class SmoothedCursorState {
this.y = targetY;
this.initialized = true;
this.lastTimeMs = timeMs;
this.lastTargetX = targetX;
this.lastTargetY = targetY;
this.stationaryTargetSinceMs = timeMs;
this.xSpring.value = targetX;
this.ySpring.value = targetY;
this.xSpring.velocity = 0;
@@ -462,6 +634,9 @@ export class SmoothedCursorState {
reset(): void {
this.initialized = false;
this.lastTimeMs = null;
this.lastTargetX = this.x;
this.lastTargetY = this.y;
this.stationaryTargetSinceMs = null;
this.trail = [];
resetSpringState(this.xSpring, this.x);
resetSpringState(this.ySpring, this.y);