diff --git a/src/lib/cursor/cursorMotion.test.ts b/src/lib/cursor/cursorMotion.test.ts new file mode 100644 index 000000000..2c1dc2138 --- /dev/null +++ b/src/lib/cursor/cursorMotion.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; +import { + applyCursorMotionSpeed, + buildCursorMotionRegionDrafts, + type CursorMotionPath, + type CursorMotionPreset, + type CursorMotionRegion, + clampCursorMotionCycles, + clampCursorMotionSpeed, + findCursorMotionRegionAtSourceTime, + projectCursorMotionPointToCrop, + sampleCursorMotion, + sampleCursorMotionRegion, + unprojectCursorMotionPointFromCrop, +} from "./cursorMotion"; + +const owner = { clipId: "clip-1", assetId: "asset-1" }; +const start = { cx: 0.1, cy: 0.5 }; +const end = { cx: 0.8, cy: 0.5 }; + +function region(overrides: Partial = {}): CursorMotionRegion { + return { + id: "motion-1", + ...owner, + startMs: 2000, + endMs: 3000, + sourceStartMs: 100, + sourceEndMs: 1100, + startPoint: start, + endPoint: end, + controlPoints: [{ cx: 0.45, cy: 0.2 }], + startAnchor: "manual", + endAnchor: "click", + segmentKind: "move", + preset: "arc", + speed: 1, + cycles: 2, + easing: "linear", + ...overrides, + }; +} + +function linearPath(): CursorMotionPath { + return { + sampleAtSourceTime(sourceTimeMs) { + const progress = Math.max(0, Math.min(1, (sourceTimeMs - 100) / 1000)); + return { + cx: start.cx + (end.cx - start.cx) * progress, + cy: start.cy, + }; + }, + }; +} + +describe("cursor motion sampling", () => { + it("preserves identity crop coordinates by reference", () => { + const point = { cx: 0.25, cy: 0.75 }; + expect(projectCursorMotionPointToCrop(point, { x: 0, y: 0, width: 1, height: 1 })).toBe(point); + }); + + it("projects crop coordinates and keeps inclusive edges stable", () => { + const crop = { x: 0.2, y: 0.25, width: 0.5, height: 0.5 }; + expect(projectCursorMotionPointToCrop({ cx: 0.45, cy: 0.5 }, crop)).toEqual({ + cx: 0.5, + cy: 0.5, + }); + expect(projectCursorMotionPointToCrop({ cx: 0.2, cy: 0.25 }, crop)).toEqual({ + cx: 0, + cy: 0, + }); + expect(projectCursorMotionPointToCrop({ cx: 0.7, cy: 0.75 }, crop)).toEqual({ + cx: 1, + cy: 1, + }); + }); + + it("hides points outside the crop and rejects invalid crop spans", () => { + const crop = { x: 0.2, y: 0.25, width: 0.5, height: 0.5 }; + expect(projectCursorMotionPointToCrop({ cx: 0.199, cy: 0.5 }, crop)).toBeNull(); + expect(projectCursorMotionPointToCrop({ cx: 0.45, cy: 0.751 }, crop)).toBeNull(); + expect(projectCursorMotionPointToCrop({ cx: 0.45, cy: 0.5 }, { ...crop, width: 0 })).toBeNull(); + }); + + it("maps dragged crop-space controls back to source normalized coordinates", () => { + const crop = { x: 0.2, y: 0.25, width: 0.5, height: 0.5 }; + expect(unprojectCursorMotionPointFromCrop({ cx: 0.5, cy: 0.5 }, crop)).toEqual({ + cx: 0.45, + cy: 0.5, + }); + expect(unprojectCursorMotionPointFromCrop({ cx: -1, cy: 2 }, crop)).toEqual({ + cx: 0.2, + cy: 0.75, + }); + }); + + it("keeps every preset's normalized endpoints exact", () => { + const presets: CursorMotionPreset[] = [ + "recorded", + "straight", + "arc", + "wave", + "loop", + "overshoot", + ]; + for (const preset of presets) { + const motion = region({ preset, speed: 4, cycles: 6, easing: "ease-in-out" }); + expect(sampleCursorMotionRegion(motion, motion.sourceStartMs)).toBe(start); + expect(sampleCursorMotionRegion(motion, motion.sourceEndMs)).toBe(end); + } + }); + + it("preserves the recorded sample by reference until a creative preset is selected", () => { + const recorded = { cx: 0.37, cy: 0.61 }; + const path: CursorMotionPath = { sampleAtSourceTime: () => recorded }; + const result = sampleCursorMotion({ + path, + regions: [region({ preset: "recorded", speed: 4 })], + owner, + sourceTimeMs: 400, + }); + expect(result).toBe(recorded); + }); + + it("clamps and rounds speed, and accelerates without a frozen prefix", () => { + expect(clampCursorMotionSpeed(Number.NaN)).toBe(1); + expect(clampCursorMotionSpeed(Number.POSITIVE_INFINITY)).toBe(1); + expect(clampCursorMotionSpeed(0.2)).toBe(1); + expect(clampCursorMotionSpeed(2.26)).toBe(2.3); + expect(clampCursorMotionSpeed(99)).toBe(4); + expect(applyCursorMotionSpeed(0.25, 2)).toBeCloseTo(0.4375); + expect(applyCursorMotionSpeed(0, 4)).toBe(0); + expect(applyCursorMotionSpeed(1, 4)).toBe(1); + }); + + it("clamps wave and loop cycles to whole values from one through six", () => { + expect(clampCursorMotionCycles(Number.NaN)).toBe(1); + expect(clampCursorMotionCycles(0)).toBe(1); + expect(clampCursorMotionCycles(3.6)).toBe(4); + expect(clampCursorMotionCycles(20)).toBe(6); + }); + + it("uses a source-time half-open interval at adjacent boundaries", () => { + const first = region({ id: "first", sourceStartMs: 100, sourceEndMs: 500 }); + const second = region({ + id: "second", + sourceStartMs: 500, + sourceEndMs: 900, + startPoint: { cx: 0.45, cy: 0.5 }, + preset: "straight", + }); + expect(findCursorMotionRegionAtSourceTime([first, second], owner, 499.999)?.id).toBe("first"); + expect(findCursorMotionRegionAtSourceTime([first, second], owner, 500)?.id).toBe("second"); + expect(findCursorMotionRegionAtSourceTime([first, second], owner, 900)).toBeNull(); + }); + + it("does not apply a region owned by another clip or asset", () => { + const recorded = { cx: 0.42, cy: 0.42 }; + const path: CursorMotionPath = { sampleAtSourceTime: () => recorded }; + const motion = region({ preset: "wave" }); + expect( + sampleCursorMotion({ + path, + regions: [motion], + owner: { clipId: "clip-2", assetId: "asset-1" }, + sourceTimeMs: 500, + }), + ).toBe(recorded); + expect( + sampleCursorMotion({ + path, + regions: [motion], + owner: { clipId: "clip-1", assetId: "asset-2" }, + sourceTimeMs: 500, + }), + ).toBe(recorded); + }); + + it("produces distinct arc, wave, loop, and overshoot shapes", () => { + const arc = sampleCursorMotionRegion(region({ preset: "arc" }), 600); + const wave = sampleCursorMotionRegion(region({ preset: "wave" }), 300); + const loop = sampleCursorMotionRegion(region({ preset: "loop" }), 475); + const overshoot = Array.from({ length: 80 }, (_, index) => + sampleCursorMotionRegion( + region({ preset: "overshoot", controlPoints: [{ cx: 0.45, cy: 0.5 }] }), + 100 + (1000 * index) / 79, + ), + ); + expect(arc.cy).toBeLessThan(0.5); + expect(wave.cy).not.toBeCloseTo(0.5); + expect(loop.cy).not.toBeCloseTo(0.5); + expect(overshoot.some((point) => point.cx > end.cx)).toBe(true); + }); +}); + +describe("cursor motion draft builder", () => { + it("builds only through the next click and keeps virtual and source time separate", () => { + const drafts = buildCursorMotionRegionDrafts({ + owner, + currentSourceTimeMs: 500, + currentVirtualTimeMs: 4500, + clipSourceEndMs: 2000, + path: linearPath(), + samples: [ + { timeMs: 500, cx: 0.38, cy: 0.5 }, + { timeMs: 900, cx: 0.66, cy: 0.5, interactionType: "click" }, + { timeMs: 1500, cx: 0.8, cy: 0.5, interactionType: "click" }, + ], + }); + + expect(drafts).toHaveLength(1); + expect(drafts[0]).toMatchObject({ + ...owner, + startMs: 4500, + endMs: 4900, + sourceStartMs: 500, + sourceEndMs: 900, + startAnchor: "manual", + endAnchor: "click", + segmentKind: "move", + preset: "recorded", + speed: 1, + easing: "ease-in-out", + }); + }); + + it("splits a recorded stop into move, hold, and move drafts", () => { + const drafts = buildCursorMotionRegionDrafts({ + owner, + currentSourceTimeMs: 0, + currentVirtualTimeMs: 2000, + clipSourceEndMs: 1000, + samples: [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 100, cx: 0.25, cy: 0.5 }, + { timeMs: 200, cx: 0.4, cy: 0.5 }, + { timeMs: 350, cx: 0.4, cy: 0.5 }, + { timeMs: 500, cx: 0.401, cy: 0.5 }, + { timeMs: 650, cx: 0.4, cy: 0.5 }, + { timeMs: 800, cx: 0.7, cy: 0.5 }, + { timeMs: 1000, cx: 0.9, cy: 0.5, interactionType: "click" }, + ], + }); + + expect( + drafts.map((draft) => [ + draft.sourceStartMs, + draft.sourceEndMs, + draft.segmentKind, + draft.startAnchor, + draft.endAnchor, + ]), + ).toEqual([ + [0, 200, "move", "manual", "rest"], + [200, 650, "hold", "rest", "rest"], + [650, 1000, "move", "rest", "click"], + ]); + expect(drafts.every((draft) => draft.preset === "recorded" && draft.speed === 1)).toBe(true); + }); + + it("does not treat a telemetry gap longer than 150ms as a stop", () => { + const drafts = buildCursorMotionRegionDrafts({ + owner, + currentSourceTimeMs: 0, + currentVirtualTimeMs: 0, + clipSourceEndMs: 1000, + samples: [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 100, cx: 0.4, cy: 0.5 }, + { timeMs: 700, cx: 0.4, cy: 0.5 }, + { timeMs: 1000, cx: 0.8, cy: 0.5, interactionType: "click" }, + ], + }); + + expect(drafts).toHaveLength(1); + expect(drafts[0].segmentKind).toBe("move"); + }); + + it("does not confuse a native cursor atlas asset id with media ownership", () => { + const activeSamples = [ + { timeMs: 0, cx: 0.1, cy: 0.5, assetId: "cursor-arrow" }, + { + timeMs: 800, + cx: 0.7, + cy: 0.5, + interactionType: "click", + assetId: "cursor-pointer", + }, + ]; + const drafts = buildCursorMotionRegionDrafts({ + owner, + currentSourceTimeMs: 0, + currentVirtualTimeMs: 0, + clipSourceEndMs: 1000, + samples: activeSamples, + }); + + expect(drafts).toHaveLength(1); + expect(drafts[0].sourceEndMs).toBe(800); + }); + + it("returns no draft when the active clip has no following click", () => { + expect( + buildCursorMotionRegionDrafts({ + owner, + currentSourceTimeMs: 500, + currentVirtualTimeMs: 2500, + clipSourceEndMs: 1000, + samples: [ + { timeMs: 500, cx: 0.4, cy: 0.5, interactionType: "click" }, + { timeMs: 800, cx: 0.7, cy: 0.5 }, + ], + }), + ).toEqual([]); + }); + + it("does not create a microscopic segment for anchors within one millisecond", () => { + expect( + buildCursorMotionRegionDrafts({ + owner, + currentSourceTimeMs: 0, + currentVirtualTimeMs: 0, + clipSourceEndMs: 100, + samples: [ + { timeMs: 0, cx: 0.4, cy: 0.5 }, + { timeMs: 0.5, cx: 0.4, cy: 0.5, interactionType: "click" }, + ], + }), + ).toEqual([]); + }); +}); diff --git a/src/lib/cursor/cursorMotion.ts b/src/lib/cursor/cursorMotion.ts new file mode 100644 index 000000000..7137b3f13 --- /dev/null +++ b/src/lib/cursor/cursorMotion.ts @@ -0,0 +1,561 @@ +export const CURSOR_MOTION_PRESETS = [ + "recorded", + "straight", + "arc", + "wave", + "loop", + "overshoot", +] as const; + +export type CursorMotionPreset = (typeof CURSOR_MOTION_PRESETS)[number]; + +export const CURSOR_MOTION_EASINGS = ["linear", "ease-in", "ease-out", "ease-in-out"] as const; + +export type CursorMotionEasing = (typeof CURSOR_MOTION_EASINGS)[number]; + +export const CURSOR_MOTION_ANCHORS = ["manual", "rest", "click"] as const; + +export type CursorMotionAnchor = (typeof CURSOR_MOTION_ANCHORS)[number]; + +export const CURSOR_MOTION_SEGMENT_KINDS = ["move", "hold"] as const; + +export type CursorMotionSegmentKind = (typeof CURSOR_MOTION_SEGMENT_KINDS)[number]; + +export interface CursorMotionPoint { + cx: number; + cy: number; +} + +export interface CursorMotionCropRegion { + x: number; + y: number; + width: number; + height: number; +} + +export interface CursorMotionOwner { + clipId: string; + assetId: string; +} + +export interface CursorMotionTelemetrySample extends CursorMotionPoint { + timeMs: number; + visible?: boolean; + interactionType?: string | null; +} + +export interface CursorMotionPath { + sampleAtSourceTime(sourceTimeMs: number): CursorMotionPoint | null; +} + +export interface CursorMotionRegion extends CursorMotionOwner { + id: string; + startMs: number; + endMs: number; + sourceStartMs: number; + sourceEndMs: number; + startPoint: CursorMotionPoint; + endPoint: CursorMotionPoint; + controlPoints: CursorMotionPoint[]; + startAnchor: CursorMotionAnchor; + endAnchor: CursorMotionAnchor; + segmentKind: CursorMotionSegmentKind; + preset: CursorMotionPreset; + speed: number; + cycles: number; + easing: CursorMotionEasing; +} + +export type CursorMotionRegionDraft = Omit; + +export const CURSOR_MOTION_SPEED_MIN = 1; +export const CURSOR_MOTION_SPEED_MAX = 4; +export const DEFAULT_CURSOR_MOTION_SPEED = 1; +export const CURSOR_MOTION_CYCLES_MIN = 1; +export const CURSOR_MOTION_CYCLES_MAX = 6; + +const REST_MIN_DURATION_MS = 300; +const REST_MAX_DIAMETER = 0.009; +const REST_MAX_SAMPLE_GAP_MS = 150; +const MIN_SEGMENT_DURATION_MS = 2; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function clampCursorMotionPoint(point: CursorMotionPoint): CursorMotionPoint { + return { + cx: clamp(Number.isFinite(point.cx) ? point.cx : 0.5, 0, 1), + cy: clamp(Number.isFinite(point.cy) ? point.cy : 0.5, 0, 1), + }; +} + +export function projectCursorMotionPointToCrop( + point: CursorMotionPoint, + crop: CursorMotionCropRegion, +): CursorMotionPoint | null { + if ( + !Number.isFinite(point.cx) || + !Number.isFinite(point.cy) || + !Number.isFinite(crop.x) || + !Number.isFinite(crop.y) || + !Number.isFinite(crop.width) || + !Number.isFinite(crop.height) || + crop.width <= 0 || + crop.height <= 0 + ) { + return null; + } + const right = crop.x + crop.width; + const bottom = crop.y + crop.height; + if (point.cx < crop.x || point.cx > right || point.cy < crop.y || point.cy > bottom) { + return null; + } + if (crop.x === 0 && crop.y === 0 && crop.width === 1 && crop.height === 1) return point; + return { + cx: point.cx === crop.x ? 0 : point.cx === right ? 1 : (point.cx - crop.x) / crop.width, + cy: point.cy === crop.y ? 0 : point.cy === bottom ? 1 : (point.cy - crop.y) / crop.height, + }; +} + +export function unprojectCursorMotionPointFromCrop( + point: CursorMotionPoint, + crop: CursorMotionCropRegion, +): CursorMotionPoint | null { + if ( + !Number.isFinite(point.cx) || + !Number.isFinite(point.cy) || + !Number.isFinite(crop.x) || + !Number.isFinite(crop.y) || + !Number.isFinite(crop.width) || + !Number.isFinite(crop.height) || + crop.width <= 0 || + crop.height <= 0 + ) { + return null; + } + if (crop.x === 0 && crop.y === 0 && crop.width === 1 && crop.height === 1) { + return clampCursorMotionPoint(point); + } + return clampCursorMotionPoint({ + cx: crop.x + clamp(point.cx, 0, 1) * crop.width, + cy: crop.y + clamp(point.cy, 0, 1) * crop.height, + }); +} + +export function clampCursorMotionSpeed(speed: number | null | undefined): number { + if (!Number.isFinite(speed)) return DEFAULT_CURSOR_MOTION_SPEED; + return ( + Math.round(clamp(Number(speed), CURSOR_MOTION_SPEED_MIN, CURSOR_MOTION_SPEED_MAX) * 10) / 10 + ); +} + +export function clampCursorMotionCycles(cycles: number | null | undefined): number { + if (!Number.isFinite(cycles)) return CURSOR_MOTION_CYCLES_MIN; + return clamp(Math.round(Number(cycles)), CURSOR_MOTION_CYCLES_MIN, CURSOR_MOTION_CYCLES_MAX); +} + +export function applyCursorMotionSpeed(progress: number, speed: number | null | undefined): number { + const t = clamp(progress, 0, 1); + return 1 - (1 - t) ** clampCursorMotionSpeed(speed); +} + +function applyCursorMotionEasing(progress: number, easing: CursorMotionEasing): number { + const t = clamp(progress, 0, 1); + switch (easing) { + case "ease-in": + return t ** 3; + case "ease-out": + return 1 - (1 - t) ** 3; + case "ease-in-out": + return t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2; + default: + return t; + } +} + +function lerp(a: number, b: number, progress: number): number { + return a + (b - a) * progress; +} + +function cubicBezier( + start: number, + firstControl: number, + secondControl: number, + end: number, + progress: number, +): number { + const inverse = 1 - progress; + return ( + inverse ** 3 * start + + 3 * inverse ** 2 * progress * firstControl + + 3 * inverse * progress ** 2 * secondControl + + progress ** 3 * end + ); +} + +function easeOutBack(progress: number): number { + const overshoot = 1.70158; + const t = clamp(progress, 0, 1) - 1; + return 1 + (overshoot + 1) * t ** 3 + overshoot * t ** 2; +} + +export function createDefaultCursorMotionControlPoint( + start: CursorMotionPoint, + end: CursorMotionPoint, +): CursorMotionPoint { + const dx = end.cx - start.cx; + const dy = end.cy - start.cy; + const distance = Math.hypot(dx, dy); + const normalX = distance > 0.0001 ? dy / distance : 0; + const normalY = distance > 0.0001 ? -dx / distance : -1; + const amplitude = clamp(distance * 0.35, 0.06, 0.18); + return clampCursorMotionPoint({ + cx: (start.cx + end.cx) / 2 + normalX * amplitude, + cy: (start.cy + end.cy) / 2 + normalY * amplitude, + }); +} + +function cursorMotionControlOffset(region: CursorMotionRegion): CursorMotionPoint { + const fallback = createDefaultCursorMotionControlPoint(region.startPoint, region.endPoint); + const controls = region.controlPoints.length > 0 ? region.controlPoints : [fallback]; + const control = controls.reduce( + (sum, point) => ({ cx: sum.cx + point.cx, cy: sum.cy + point.cy }), + { cx: 0, cy: 0 }, + ); + const midpoint = { + cx: (region.startPoint.cx + region.endPoint.cx) / 2, + cy: (region.startPoint.cy + region.endPoint.cy) / 2, + }; + return { + cx: control.cx / controls.length - midpoint.cx, + cy: control.cy / controls.length - midpoint.cy, + }; +} + +export function sampleCursorMotionRegion( + region: CursorMotionRegion, + sourceTimeMs: number, +): CursorMotionPoint { + const durationMs = Math.max(Number.EPSILON, region.sourceEndMs - region.sourceStartMs); + const rawProgress = clamp((sourceTimeMs - region.sourceStartMs) / durationMs, 0, 1); + if (rawProgress === 0) return region.startPoint; + if (rawProgress === 1) return region.endPoint; + + const motionProgress = applyCursorMotionSpeed(rawProgress, region.speed); + const progress = applyCursorMotionEasing(motionProgress, region.easing); + const offset = cursorMotionControlOffset(region); + const base = { + cx: lerp(region.startPoint.cx, region.endPoint.cx, progress), + cy: lerp(region.startPoint.cy, region.endPoint.cy, progress), + }; + const envelope = Math.sin(Math.PI * motionProgress); + const cycles = clampCursorMotionCycles(region.cycles); + + let point: CursorMotionPoint; + switch (region.preset) { + case "arc": { + const first = region.controlPoints[0]; + const second = region.controlPoints[1]; + point = + first && second + ? { + cx: cubicBezier( + region.startPoint.cx, + first.cx, + second.cx, + region.endPoint.cx, + progress, + ), + cy: cubicBezier( + region.startPoint.cy, + first.cy, + second.cy, + region.endPoint.cy, + progress, + ), + } + : { cx: base.cx + offset.cx * envelope, cy: base.cy + offset.cy * envelope }; + break; + } + case "wave": { + const wave = Math.sin(Math.PI * 2 * cycles * motionProgress) * envelope; + point = { cx: base.cx + offset.cx * wave, cy: base.cy + offset.cy * wave }; + break; + } + case "loop": { + const phase = Math.PI * 2 * cycles * motionProgress; + const tangentOffset = Math.sin(phase) * envelope; + const normalOffset = ((1 - Math.cos(phase)) / 2) * envelope; + point = { + cx: base.cx + offset.cx * tangentOffset - offset.cy * normalOffset, + cy: base.cy + offset.cy * tangentOffset + offset.cx * normalOffset, + }; + break; + } + case "overshoot": { + const overshootProgress = easeOutBack(progress); + point = { + cx: + lerp(region.startPoint.cx, region.endPoint.cx, overshootProgress) + + offset.cx * envelope * 0.35, + cy: + lerp(region.startPoint.cy, region.endPoint.cy, overshootProgress) + + offset.cy * envelope * 0.35, + }; + break; + } + default: + point = base; + } + return clampCursorMotionPoint(point); +} + +export function findCursorMotionRegionAtSourceTime( + regions: readonly CursorMotionRegion[], + owner: CursorMotionOwner, + sourceTimeMs: number, +): CursorMotionRegion | null { + for (let index = regions.length - 1; index >= 0; index -= 1) { + const region = regions[index]; + if ( + region.clipId === owner.clipId && + region.assetId === owner.assetId && + sourceTimeMs >= region.sourceStartMs && + sourceTimeMs < region.sourceEndMs + ) { + return region; + } + } + return null; +} + +export function sampleCursorMotion(options: { + path: CursorMotionPath | null | undefined; + regions: readonly CursorMotionRegion[]; + owner: CursorMotionOwner; + sourceTimeMs: number; +}): CursorMotionPoint | null { + const recorded = options.path?.sampleAtSourceTime(options.sourceTimeMs) ?? null; + if (!recorded) return null; + const region = findCursorMotionRegionAtSourceTime( + options.regions, + options.owner, + options.sourceTimeMs, + ); + if (!region || region.preset === "recorded") return recorded; + return sampleCursorMotionRegion(region, options.sourceTimeMs); +} + +interface CursorMotionRestSpan { + startMs: number; + endMs: number; + point: CursorMotionPoint; +} + +interface CursorMotionBuilderAnchor { + timeMs: number; + point: CursorMotionPoint; + kind: CursorMotionAnchor; +} + +function isClickInteraction(interactionType: string | null | undefined): boolean { + return ( + interactionType === "click" || + interactionType === "double-click" || + interactionType === "right-click" || + interactionType === "middle-click" + ); +} + +function normalizeTelemetrySamples( + samples: readonly CursorMotionTelemetrySample[], +): CursorMotionTelemetrySample[] { + return samples + .filter( + (sample) => + sample.visible !== false && + Number.isFinite(sample.timeMs) && + Number.isFinite(sample.cx) && + Number.isFinite(sample.cy), + ) + .map((sample) => ({ ...sample, ...clampCursorMotionPoint(sample) })) + .sort((a, b) => a.timeMs - b.timeMs); +} + +function detectCursorMotionRestSpans( + samples: readonly CursorMotionTelemetrySample[], +): CursorMotionRestSpan[] { + const rests: CursorMotionRestSpan[] = []; + let startIndex = 0; + while (startIndex < samples.length - 1) { + let endIndex = startIndex + 1; + let minCx = samples[startIndex].cx; + let maxCx = minCx; + let minCy = samples[startIndex].cy; + let maxCy = minCy; + while (endIndex < samples.length) { + const sample = samples[endIndex]; + if (sample.timeMs - samples[endIndex - 1].timeMs > REST_MAX_SAMPLE_GAP_MS) break; + const nextMinCx = Math.min(minCx, sample.cx); + const nextMaxCx = Math.max(maxCx, sample.cx); + const nextMinCy = Math.min(minCy, sample.cy); + const nextMaxCy = Math.max(maxCy, sample.cy); + if (Math.hypot(nextMaxCx - nextMinCx, nextMaxCy - nextMinCy) > REST_MAX_DIAMETER) { + break; + } + minCx = nextMinCx; + maxCx = nextMaxCx; + minCy = nextMinCy; + maxCy = nextMaxCy; + endIndex += 1; + } + + const run = samples.slice(startIndex, endIndex); + if (run.at(-1)!.timeMs - run[0].timeMs >= REST_MIN_DURATION_MS) { + const click = run.find((sample) => isClickInteraction(sample.interactionType)); + const point = click + ? clampCursorMotionPoint(click) + : clampCursorMotionPoint({ + cx: run.reduce((total, sample) => total + sample.cx, 0) / run.length, + cy: run.reduce((total, sample) => total + sample.cy, 0) / run.length, + }); + rests.push({ startMs: run[0].timeMs, endMs: run.at(-1)!.timeMs, point }); + startIndex = endIndex; + } else { + startIndex += 1; + } + } + return rests; +} + +function nearestSamplePoint( + samples: readonly CursorMotionTelemetrySample[], + timeMs: number, +): CursorMotionPoint | null { + let nearest: CursorMotionTelemetrySample | null = null; + let distance = Number.POSITIVE_INFINITY; + for (const sample of samples) { + const candidateDistance = Math.abs(sample.timeMs - timeMs); + if (candidateDistance < distance) { + nearest = sample; + distance = candidateDistance; + } + } + return nearest ? clampCursorMotionPoint(nearest) : null; +} + +function anchorPriority(anchor: CursorMotionAnchor): number { + if (anchor === "click") return 3; + if (anchor === "rest") return 2; + return 1; +} + +function normalizeBuilderAnchors( + anchors: CursorMotionBuilderAnchor[], +): CursorMotionBuilderAnchor[] { + const sorted = [...anchors].sort( + (a, b) => a.timeMs - b.timeMs || anchorPriority(b.kind) - anchorPriority(a.kind), + ); + const normalized: CursorMotionBuilderAnchor[] = []; + for (const anchor of sorted) { + const previous = normalized.at(-1); + if (previous && Math.abs(previous.timeMs - anchor.timeMs) <= 1) { + if (anchorPriority(anchor.kind) > anchorPriority(previous.kind)) { + normalized[normalized.length - 1] = anchor; + } + continue; + } + normalized.push(anchor); + } + return normalized; +} + +function getCursorMotionSegmentKind( + start: CursorMotionBuilderAnchor, + end: CursorMotionBuilderAnchor, +): CursorMotionSegmentKind { + if (start.kind === "rest" && end.kind === "rest") return "hold"; + return Math.hypot(end.point.cx - start.point.cx, end.point.cy - start.point.cy) <= + REST_MAX_DIAMETER + ? "hold" + : "move"; +} + +export function buildCursorMotionRegionDrafts(options: { + owner: CursorMotionOwner; + currentSourceTimeMs: number; + currentVirtualTimeMs: number; + clipSourceEndMs: number; + samples: readonly CursorMotionTelemetrySample[]; + path?: CursorMotionPath | null; +}): CursorMotionRegionDraft[] { + if ( + !Number.isFinite(options.currentSourceTimeMs) || + !Number.isFinite(options.currentVirtualTimeMs) || + !Number.isFinite(options.clipSourceEndMs) + ) { + return []; + } + const sourceStartMs = Math.max(0, options.currentSourceTimeMs); + const clipSourceEndMs = Math.max(sourceStartMs, options.clipSourceEndMs); + if (sourceStartMs >= clipSourceEndMs) return []; + + const ownerSamples = normalizeTelemetrySamples(options.samples); + const endClick = ownerSamples.find( + (sample) => + sample.timeMs > sourceStartMs && + sample.timeMs <= clipSourceEndMs && + isClickInteraction(sample.interactionType), + ); + if (!endClick) return []; + + const sourceEndMs = endClick.timeMs; + const samples = ownerSamples.filter( + (sample) => sample.timeMs >= sourceStartMs && sample.timeMs <= sourceEndMs, + ); + const startPoint = + options.path?.sampleAtSourceTime(sourceStartMs) ?? nearestSamplePoint(samples, sourceStartMs); + if (!startPoint) return []; + + const rests = detectCursorMotionRestSpans(samples); + const anchors: CursorMotionBuilderAnchor[] = [ + { timeMs: sourceStartMs, point: clampCursorMotionPoint(startPoint), kind: "manual" }, + { timeMs: sourceEndMs, point: clampCursorMotionPoint(endClick), kind: "click" }, + ]; + for (const rest of rests) { + anchors.push( + { timeMs: rest.startMs, point: rest.point, kind: "rest" }, + { timeMs: rest.endMs, point: rest.point, kind: "rest" }, + ); + } + + const normalizedAnchors = normalizeBuilderAnchors(anchors).filter( + (anchor) => anchor.timeMs >= sourceStartMs && anchor.timeMs <= sourceEndMs, + ); + const drafts: CursorMotionRegionDraft[] = []; + for (let index = 1; index < normalizedAnchors.length; index += 1) { + const start = normalizedAnchors[index - 1]; + const end = normalizedAnchors[index]; + if (end.timeMs - start.timeMs < MIN_SEGMENT_DURATION_MS) continue; + const virtualOffset = options.currentVirtualTimeMs - sourceStartMs; + drafts.push({ + ...options.owner, + startMs: start.timeMs + virtualOffset, + endMs: end.timeMs + virtualOffset, + sourceStartMs: start.timeMs, + sourceEndMs: end.timeMs, + startPoint: start.point, + endPoint: end.point, + controlPoints: [createDefaultCursorMotionControlPoint(start.point, end.point)], + startAnchor: start.kind, + endAnchor: end.kind, + segmentKind: getCursorMotionSegmentKind(start, end), + preset: "recorded", + speed: DEFAULT_CURSOR_MOTION_SPEED, + cycles: CURSOR_MOTION_CYCLES_MIN, + easing: "ease-in-out", + }); + } + return drafts; +}