From f922d587f73ce7246dfebc22ac043dbbd85dda6c Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:33:37 +1100 Subject: [PATCH] extract cursor loop telemetry utility --- src/components/video-editor/VideoEditor.tsx | 2 +- .../videoPlayback/cursorLoopTelemetry.ts | 233 ++++++++++++++++++ .../videoPlayback/cursorRenderer.ts | 138 ----------- 3 files changed, 234 insertions(+), 139 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 007ce5a5..4eb7dafd 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -53,7 +53,7 @@ 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 { buildLoopedCursorTelemetry } from "./videoPlayback/cursorLoopTelemetry"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; const LOOP_CURSOR_END_WINDOW_MS = 670; diff --git a/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts b/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts new file mode 100644 index 00000000..fc66c518 --- /dev/null +++ b/src/components/video-editor/videoPlayback/cursorLoopTelemetry.ts @@ -0,0 +1,233 @@ +import type { CursorTelemetryPoint } from '../types'; + +const LOOP_CURSOR_FREEZE_DURATION_MS = 670; +const LOOP_CURSOR_RETURN_STEPS = 20; +const LOOP_CURSOR_SETTLE_DURATION_MS = 120; +const CURSOR_TRAILING_MOVEMENT_EPSILON = 0.0015; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function interpolateCursorPosition( + samples: CursorTelemetryPoint[], + timeMs: number, +): { cx: number; cy: number } | null { + if (!samples || samples.length === 0) return null; + + if (timeMs <= samples[0].timeMs) { + return { cx: samples[0].cx, cy: samples[0].cy }; + } + + if (timeMs >= samples[samples.length - 1].timeMs) { + return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy }; + } + + let lo = 0; + let hi = samples.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid; + } + } + + const a = samples[lo]; + const b = samples[hi]; + const span = b.timeMs - a.timeMs; + if (span <= 0) return { cx: a.cx, cy: a.cy }; + + const t = (timeMs - a.timeMs) / span; + return { + cx: a.cx + (b.cx - a.cx) * t, + cy: a.cy + (b.cy - a.cy) * t, + }; +} + +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; +} + +function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) { + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + for (let index = lo; index >= 0; index -= 1) { + const sample = samples[index]; + if (sample.timeMs > timeMs) { + continue; + } + + 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 findLatestSample(samples, timeMs)?.cursorType ?? 'arrow'; +} + +function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) { + if (samples.length === 0) return null; + + let lo = 0; + let hi = samples.length - 1; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return samples[lo]?.timeMs <= timeMs ? samples[lo] : null; +} + +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; +} \ No newline at end of file diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 83791de3..43f9015c 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -60,7 +60,6 @@ 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; @@ -68,9 +67,6 @@ 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 | null = null; let loadedCursorAssets: Partial> = {}; @@ -171,46 +167,6 @@ 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 () => { @@ -337,100 +293,6 @@ 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;