From 580676bbf46d2d6c5a39df0ccb6860407521adda Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 8 Jul 2026 19:42:03 -0400 Subject: [PATCH] fix(studio): freeze timeline zoom during extend-drag so clips don't jump Dragging a clip past the video end fed back on itself: the growing preview length shrank the fit-to-width zoom, which remapped the pointer, moved the clip, and grew the length again (visible jumping). Split the committed basis duration (drives zoom) from the displayed effective duration (adds the live preview for ruler + width). Zoom holds fixed through the gesture and the extra length scrolls; it re-fits once on drop. Duration math extracted to pure tested helpers in timelineLayout. --- .../studio/src/player/components/Timeline.tsx | 40 +++++++++++-------- .../player/components/timelineLayout.test.ts | 34 ++++++++++++++++ .../src/player/components/timelineLayout.ts | 31 ++++++++++++++ 3 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 packages/studio/src/player/components/timelineLayout.test.ts diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 91a4b6fa8..88b6a7863 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -30,6 +30,8 @@ import { generateTicks, getTimelineCanvasHeight, shouldShowTimelineShortcutHint, + computeTimelineBasisDuration, + computeTimelineEffectiveDuration, } from "./timelineLayout"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; @@ -228,20 +230,25 @@ export const Timeline = memo(function Timeline({ setRangeSelectionRef, }); - const effectiveDuration = useMemo(() => { - const safeDur = Number.isFinite(duration) ? duration : 0; - let maxEnd = safeDur; - if (rawElements.length > 0) { - maxEnd = Math.max(maxEnd, ...rawElements.map((el) => el.start + el.duration)); - } - if (draggedClip?.started) { - maxEnd = Math.max(maxEnd, draggedClip.previewStart + draggedClip.element.duration); - } - if (resizingClip?.started) { - maxEnd = Math.max(maxEnd, resizingClip.previewStart + resizingClip.previewDuration); - } - return Number.isFinite(maxEnd) ? maxEnd : safeDur; - }, [rawElements, duration, draggedClip, resizingClip]); + // basisDuration drives the zoom (committed, no live preview); effectiveDuration + // adds the active drag/resize preview so the ruler/width follow a past-end drag. + // See timelineLayout for why the split prevents jump-during-extend. + const basisDuration = useMemo( + () => + computeTimelineBasisDuration( + duration, + rawElements.map((el) => el.start + el.duration), + ), + [rawElements, duration], + ); + const effectiveDuration = useMemo( + () => + computeTimelineEffectiveDuration(basisDuration, [ + draggedClip?.started ? draggedClip.previewStart + draggedClip.element.duration : null, + resizingClip?.started ? resizingClip.previewStart + resizingClip.previewDuration : null, + ]), + [basisDuration, draggedClip, resizingClip], + ); durationRef.current = effectiveDuration; const displayTrackOrder = useMemo(() => { @@ -273,9 +280,10 @@ export const Timeline = memo(function Timeline({ const selectedElementRef = useRef(selectedElement); selectedElementRef.current = selectedElement; + // Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom. const fitPps = - viewportWidth > GUTTER && effectiveDuration > 0 - ? (viewportWidth - GUTTER - 2) / effectiveDuration + viewportWidth > GUTTER && basisDuration > 0 + ? (viewportWidth - GUTTER - 2) / basisDuration : 100; const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent); ppsRef.current = pps; diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts new file mode 100644 index 000000000..98f8217eb --- /dev/null +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout"; + +describe("computeTimelineBasisDuration", () => { + it("uses the root duration when it exceeds every clip end", () => { + expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12); + }); + + it("grows to the furthest committed clip end past the root duration", () => { + expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18); + }); + + it("falls back to the root duration with no clips / non-finite ends", () => { + expect(computeTimelineBasisDuration(10, [])).toBe(10); + expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0); + }); +}); + +describe("computeTimelineEffectiveDuration", () => { + it("returns the basis when there is no active preview", () => { + expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12); + }); + + it("extends to a drag/resize preview end beyond the basis", () => { + expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20); + expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16); + }); + + it("never shrinks below the basis for a preview inside the current length", () => { + // The invariant behind the jump fix: the basis (which drives zoom) is + // independent of the preview, and a smaller preview end can't reduce it. + expect(computeTimelineEffectiveDuration(12, [8])).toBe(12); + }); +}); diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 9944d3c95..69f372cb6 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -9,6 +9,37 @@ export const CLIP_Y = 3; export const CLIP_HANDLE_W = 18; const TIMELINE_SCROLL_BUFFER = 20; +/* ── Timeline duration ─────────────────────────────────────────────── */ + +// Committed timeline length: root duration or the furthest committed clip end, +// with NO live drag/resize preview. This drives the zoom (fit-to-width pps) so +// the pixels-per-second mapping stays fixed while you drag — otherwise a clip +// dragged past the end grows the duration, shrinks pps, and jumps under the +// pointer (a positive-feedback loop). The zoom re-fits once on drop. +export function computeTimelineBasisDuration( + rootDuration: number, + clipEnds: readonly number[], +): number { + const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0; + if (clipEnds.length === 0) return safeDur; + const maxEnd = Math.max(safeDur, ...clipEnds); + return Number.isFinite(maxEnd) ? maxEnd : safeDur; +} + +// Displayed length: the basis plus any active drag/resize preview end, so the +// ruler and track width grow to follow a clip dragged past the current end +// (with the zoom held fixed, the extra length becomes scrollable content). +export function computeTimelineEffectiveDuration( + basisDuration: number, + previewEnds: readonly (number | null)[], +): number { + let maxEnd = basisDuration; + for (const end of previewEnds) { + if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end); + } + return maxEnd; +} + /* ── Tick generation ──────────────────────────────────────────────── */ function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number { const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];