From e92e3e3d056fedee2fc7709657b05b55d447275f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 08:59:49 +0200 Subject: [PATCH] fix(zoom): auto-placed zoom regions follow the cursor for their whole span Auto zoom regions (created by the magic-wand cursor-dwell suggestion pass) were built with focusMode left undefined unless the separate, global "Auto Focus All" toggle was on. That meant the pan/zoom used the static dwell-centroid focus captured at suggestion time and never tracked the cursor afterward, even though the cursor-follow interpolation logic (zoomRegionUtils/cursorFollowUtils) already existed and just wasn't wired up by default for these regions. Auto-suggested regions now always get focusMode "auto" so they pan to track the cursor telemetry across their whole span, independent of the "Auto Focus All" toggle (which still only controls the default for manually-drawn zoom regions). Fixes #72 Co-Authored-By: Claude Sonnet 5 --- src/components/video-editor/VideoEditor.tsx | 9 ++- .../videoPlayback/zoomRegionUtils.test.ts | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/components/video-editor/videoPlayback/zoomRegionUtils.test.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index b49197da4c..15bae7cddc 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1136,6 +1136,11 @@ export default function VideoEditor() { // Builds fresh "auto" zoom regions from cursor telemetry without overlapping // existing ones. Used by both the on-load auto-suggest pass and the wand toggle. + // These regions always follow the cursor for their whole span (focusMode "auto") — + // that's the entire point of an auto-placed zoom: it should pan to track the cursor + // as it moves, not freeze at the dwell point that triggered the suggestion. This is + // independent of the global "Auto Focus All" toggle, which only affects the default + // for manually-drawn zoom regions. const buildAutoZoomRegions = useCallback( (existingRegions: ZoomRegion[]): ZoomRegion[] => { const totalMs = Math.round(duration * 1000); @@ -1152,11 +1157,11 @@ export default function VideoEditor() { depth: DEFAULT_ZOOM_DEPTH, customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], focus: clampFocusToDepth(suggestion.focus, DEFAULT_ZOOM_DEPTH), - focusMode: autoFocusAll ? ("auto" as const) : undefined, + focusMode: "auto" as const, source: "auto" as const, })); }, - [cursorTelemetry, duration, autoFocusAll], + [cursorTelemetry, duration], ); // Auto-suggest zooms once per fresh recording (no existing zooms, telemetry diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.test.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.test.ts new file mode 100644 index 0000000000..d5d58cbf41 --- /dev/null +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { CursorTelemetryPoint, ZoomRegion } from "../types"; +import { DEFAULT_ZOOM_DEPTH, ZOOM_DEPTH_SCALES } from "../types"; +import { findDominantRegion } from "./zoomRegionUtils"; + +/** + * Regression coverage for issue #72: an auto-placed zoom region must pan to follow + * the cursor for its whole span, not freeze at the focus point captured when the + * region was created/suggested. + */ +describe("findDominantRegion — auto-zoom cursor following", () => { + const baseRegion: ZoomRegion = { + id: "zoom-1", + startMs: 0, + endMs: 4000, + depth: DEFAULT_ZOOM_DEPTH, + customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH], + // The static focus captured at suggestion time (e.g. the dwell centroid) — should + // be ignored in favor of the live cursor position once focusMode is "auto". Kept + // within the depth-3 focus bounds (~0.28-0.72) so clamping doesn't distort assertions. + focus: { cx: 0.35, cy: 0.5 }, + focusMode: "auto", + source: "auto", + }; + + // Cursor sweeps steadily from the left edge to the right edge across the region. + const movingTelemetry: CursorTelemetryPoint[] = [ + { timeMs: 0, cx: 0.1, cy: 0.5 }, + { timeMs: 2000, cx: 0.5, cy: 0.5 }, + { timeMs: 4000, cx: 0.9, cy: 0.5 }, + ]; + + it("tracks the cursor across the region instead of freezing at the initial focus", () => { + const early = findDominantRegion([baseRegion], 200, { cursorTelemetry: movingTelemetry }); + const mid = findDominantRegion([baseRegion], 2000, { cursorTelemetry: movingTelemetry }); + const late = findDominantRegion([baseRegion], 3800, { cursorTelemetry: movingTelemetry }); + + expect(early.region).not.toBeNull(); + expect(mid.region).not.toBeNull(); + expect(late.region).not.toBeNull(); + + // The focus must move meaningfully between samples (cursor-following), not stay pinned. + expect(mid.region?.focus.cx).toBeGreaterThan(early.region?.focus.cx ?? 0); + expect(late.region?.focus.cx).toBeGreaterThan(mid.region?.focus.cx ?? 0); + + // And it must not equal the static creation-time focus baked into the region. + expect(mid.region?.focus.cx).not.toBeCloseTo(baseRegion.focus.cx, 2); + }); + + it("stays frozen at the static focus when focusMode is not auto (manual regions unaffected)", () => { + const manualRegion: ZoomRegion = { ...baseRegion, focusMode: "manual", source: "manual" }; + + const early = findDominantRegion([manualRegion], 200, { cursorTelemetry: movingTelemetry }); + const late = findDominantRegion([manualRegion], 3800, { cursorTelemetry: movingTelemetry }); + + expect(early.region?.focus.cx).toBeCloseTo(manualRegion.focus.cx, 5); + expect(late.region?.focus.cx).toBeCloseTo(manualRegion.focus.cx, 5); + }); +});