diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts index 6994025d..c7514668 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -10,16 +10,19 @@ function makeClick(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { return { timeMs, cx, cy, interactionType: "click" }; } -/** Wraps a list of click samples with surrounding move events so the telemetry - * passes the `normalizedSamples.length < 2` guard. */ +function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { + return { timeMs, cx, cy, interactionType: "move" }; +} + +/** Wraps click samples with surrounding move events to mimic real mixed telemetry. */ function withMoves( clicks: CursorTelemetryPoint[], totalMs: number, ): CursorTelemetryPoint[] { return [ - { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" }, + makeMove(0), ...clicks, - { timeMs: totalMs, cx: 0.5, cy: 0.5, interactionType: "move" }, + makeMove(totalMs), ]; } @@ -43,6 +46,17 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { expect(s.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); }); + it("accepts a single explicit click sample without needing surrounding moves", () => { + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: [makeClick(5_000)], + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + }); + it("merges two clicks within 2500ms into one zoom track", () => { const telemetry = withMoves( [makeClick(4_000), makeClick(4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1)], @@ -123,6 +137,24 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { expect(result.suggestions).toHaveLength(0); }); + it("ignores dwell-derived click-like heuristics when there are no explicit clicks", () => { + const telemetry: CursorTelemetryPoint[] = [ + makeMove(0, 0.5, 0.5), + makeMove(200, 0.5005, 0.5005), + makeMove(400, 0.5008, 0.5008), + makeMove(600, 0.501, 0.501), + ]; + + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: telemetry, + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("no-interactions"); + expect(result.suggestions).toHaveLength(0); + }); + it("skips clusters that overlap reserved spans", () => { const click = 5_000; diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts index 10de1548..52eddfe8 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -19,6 +19,7 @@ export interface CursorInteractionCandidate extends ZoomDwellCandidate { | "dropdown-open" | "text-selection" | "text-field-click"; + source: "explicit" | "heuristic"; } export interface SuggestedZoomRegion { @@ -213,6 +214,7 @@ export function detectInteractionCandidates( focus: { cx: clickSample.cx, cy: clickSample.cy }, strength: baseStrength, kind, + source: "explicit", }); } @@ -220,12 +222,12 @@ export function detectInteractionCandidates( const dwellCandidates = detectZoomDwellCandidates(samples).map( (candidate) => { if (candidate.strength >= 1100) { - return { ...candidate, kind: "text-focus-like" }; + return { ...candidate, kind: "text-focus-like", source: "heuristic" }; } if (candidate.strength <= 800) { - return { ...candidate, kind: "click-like" }; + return { ...candidate, kind: "click-like", source: "heuristic" }; } - return { ...candidate, kind: "dwell" }; + return { ...candidate, kind: "dwell", source: "heuristic" }; }, ); @@ -249,6 +251,7 @@ export function detectInteractionCandidates( }, strength: prev.strength + curr.strength + 500, kind: "double-click-like", + source: "heuristic", }); } } @@ -345,13 +348,23 @@ export function buildInteractionZoomSuggestions(params: { } const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); - if (normalizedSamples.length < 2) { + if (normalizedSamples.length === 0) { + return { status: "no-telemetry", suggestions: [] }; + } + + if ( + normalizedSamples.length === 1 && + normalizedSamples[0].interactionType !== "click" && + normalizedSamples[0].interactionType !== "double-click" && + normalizedSamples[0].interactionType !== "right-click" && + normalizedSamples[0].interactionType !== "middle-click" + ) { return { status: "no-telemetry", suggestions: [] }; } // Only use explicit click events (uiohook telemetry) – ignore dwell heuristics const clickCandidates = detectInteractionCandidates(normalizedSamples).filter( - (c) => c.kind === "click-like" || c.kind === "double-click-like" || c.kind === "dropdown-open" || c.kind === "text-field-click" || c.kind === "text-selection", + (candidate) => candidate.source === "explicit", ); if (clickCandidates.length === 0) {