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); + }); +});