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];