From 8919eea1c78519131d648e61ef8bce03300e9f87 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 21:16:02 -0400 Subject: [PATCH 1/5] feat(studio): trimming a clip shows the snap guide and the frame at the dragged edge --- .../studio/src/player/components/Timeline.tsx | 1 + .../src/player/components/TimelineCanvas.tsx | 14 +++-- .../components/timelineClipDragPreview.ts | 57 +++++++++++------ .../components/timelineClipDragTypes.ts | 3 + .../components/timelineSnapping.test.ts | 15 +++++ .../src/player/components/timelineSnapping.ts | 10 +++ .../useTimelineClipDrag.resize.test.tsx | 63 ++++++++++++++++++- .../player/components/useTimelineClipDrag.ts | 14 +++++ 8 files changed, 149 insertions(+), 28 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index fcc8d61bc9..23b84eeec7 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -233,6 +233,7 @@ export const Timeline = memo(function Timeline({ onResizeElement: pinnedOnResizeElement, onResizeElements: pinnedOnResizeElements, onBlockedEditAttempt, + onSeek, setShowPopover, setRangeSelectionRef, readZIndex: zSyncEnabled ? readClipZIndex : undefined, diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 34caaeb0c8..eb00ab91cc 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -23,6 +23,7 @@ import type { TimelineLaneBaseProps } from "./timelineLaneProps"; import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { TimelineGestureOverlay } from "./TimelineGestureOverlay"; +import { resolveSnapGuide } from "./timelineSnapping"; interface TimelineCanvasProps extends TimelineLaneBaseProps { major: number[]; @@ -47,7 +48,8 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps { const DROP_PREVIEW_SECONDS = 3; export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) { - const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props; + const { draggedClip, resizingClip, scrollRef, selectedElementIds, displayTrackOrder } = props; + const snapGuide = resolveSnapGuide(draggedClip, resizingClip); const draggedRowIndex = draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; const dropTrackIndex = props.dropPreview @@ -218,18 +220,18 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas /> )} - {/* Snap guide for non-beat targets during clip drag */} - {draggedClip?.started && draggedClip.snapTime != null && draggedClip.snapType !== "beat" && ( + {/* Snap guide for non-beat targets during a clip move or trim */} + {snapGuide && snapGuide.type !== "beat" && (
0) { const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1); if (resize.edge === "end") { const edgeTime = nextResize.start + nextResize.duration; - const snapped = snapTimelineTime(edgeTime, trimTargets, snapSecs).time; + const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs); // Stay within [start+minDuration, maxEnd] so the snap can't create a // degenerate clip or run past the source/composition limit. const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000; - if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) { - nextResize = { ...nextResize, duration: snappedDuration }; + if (target && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) { + // An edge already on the target still owns the guide; only move it when off. + if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration }; + snap = target; } } else { - const snapped = snapTimelineTime(nextResize.start, trimTargets, snapSecs).time; + const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs); const delta = nextResize.start - snapped; // >0 when snapping left // Leftward snap reveals more source; cap so playbackStart can't go < 0. const maxLeftDelta = @@ -318,22 +333,20 @@ export function computeResizePreview( // Also require the resulting duration to stay >= minDuration so a rightward // snap (delta < 0) can't collapse the clip to zero/negative. const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000; - if ( - snapped !== nextResize.start && - snapped >= 0 && - delta <= maxLeftDelta + 1e-6 && - snappedDuration >= 0.05 - ) { - nextResize = { - ...nextResize, - start: snapped, - duration: snappedDuration, - playbackStart: - nextResize.playbackStart != null - ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) / - 1000 - : undefined, - }; + if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) { + if (snapped !== nextResize.start) { + nextResize = { + ...nextResize, + start: snapped, + duration: snappedDuration, + playbackStart: + nextResize.playbackStart != null + ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) / + 1000 + : undefined, + }; + } + snap = target; } } } @@ -343,6 +356,8 @@ export function computeResizePreview( previewStart: nextResize.start, previewDuration: nextResize.duration, previewPlaybackStart: nextResize.playbackStart, + snapTime: snap?.time ?? null, + snapType: snap?.type ?? null, }; } @@ -364,6 +379,8 @@ export function previewGroupResize( previewStart: grabbedChange?.start ?? next.previewStart, previewDuration: grabbedChange?.duration ?? next.previewDuration, previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart, + snapTime: next.snapTime, + snapType: next.snapType, groupPreview: session.changes, }); } diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts index 251a299f6d..87bc4b1227 100644 --- a/packages/studio/src/player/components/timelineClipDragTypes.ts +++ b/packages/studio/src/player/components/timelineClipDragTypes.ts @@ -57,6 +57,9 @@ export interface ResizingClipState { previewStart: number; previewDuration: number; previewPlaybackStart?: number; + /** Snap target the trimmed edge landed on, for the guide highlight. */ + snapTime?: number | null; + snapType?: TimelineSnapType | null; /** Coordinator-owned group projection; canonical elements change only on commit. */ groupPreview?: readonly { key: string; diff --git a/packages/studio/src/player/components/timelineSnapping.test.ts b/packages/studio/src/player/components/timelineSnapping.test.ts index 35980b85e3..dc59cfc422 100644 --- a/packages/studio/src/player/components/timelineSnapping.test.ts +++ b/packages/studio/src/player/components/timelineSnapping.test.ts @@ -4,6 +4,7 @@ import { collectTimelineSnapTargets, snapMoveToTargets, snapTimelineTime, + resolveSnapGuide, } from "./timelineSnapping"; describe("collectTimelineSnapTargets", () => { @@ -132,3 +133,17 @@ describe("snapMoveToTargets", () => { expect(r.snapTime).toBeNull(); }); }); + +describe("resolveSnapGuide", () => { + it("prefers a started move, falls back to a trim, and is null when neither snapped", () => { + const move = { started: true, snapTime: 2, snapType: "playhead" as const }; + const trim = { snapTime: 5, snapType: "clip-edge" as const }; + expect(resolveSnapGuide(move, trim)).toEqual({ time: 2, type: "playhead" }); + expect(resolveSnapGuide({ ...move, started: false }, trim)).toEqual({ + time: 5, + type: "clip-edge", + }); + expect(resolveSnapGuide(null, { snapTime: null, snapType: null })).toBeNull(); + expect(resolveSnapGuide(null, null)).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/timelineSnapping.ts b/packages/studio/src/player/components/timelineSnapping.ts index 30d1175273..294beec58b 100644 --- a/packages/studio/src/player/components/timelineSnapping.ts +++ b/packages/studio/src/player/components/timelineSnapping.ts @@ -7,6 +7,16 @@ export interface TimelineSnapTarget { type: TimelineSnapType; } +/** The guide the canvas draws: the live move's snap target, else the live trim's. */ +export function resolveSnapGuide( + moving: { started: boolean; snapTime: number | null; snapType: TimelineSnapType | null } | null, + trimming: { snapTime?: number | null; snapType?: TimelineSnapType | null } | null, +): TimelineSnapTarget | null { + const source = moving?.started ? moving : trimming; + if (source?.snapTime == null || source.snapType == null) return null; + return { time: source.snapTime, type: source.snapType }; +} + /** Pixel radius within which a time snaps to a target (matches historical beat snap). */ export const TIMELINE_SNAP_PX = 8; diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index 7aae044a72..080203a94b 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -31,10 +31,10 @@ afterEach(() => { function renderResizeHarness( elements: TimelineElement[], selected: string[], - options: { wireGroupResize?: boolean } = {}, + options: { wireGroupResize?: boolean; snap?: boolean; onSeek?: (time: number) => void } = {}, ) { usePlayerStore.getState().setElements(elements); - usePlayerStore.setState({ timelineSnapEnabled: false }); + usePlayerStore.setState({ timelineSnapEnabled: options.snap === true }); usePlayerStore.getState().setSelectedElementIds(new Set(selected)); const scroll = document.createElement("div"); @@ -61,6 +61,7 @@ function renderResizeHarness( onMoveElement, onBlockedEditAttempt, onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements, + onSeek: options.onSeek, setShowPopover: vi.fn(), setRangeSelectionRef: { current: vi.fn() }, sessionEpoch, @@ -92,6 +93,9 @@ function renderResizeHarness( getResizeProjection() { return resizingClip?.groupPreview ?? []; }, + getResizingClip() { + return resizingClip; + }, getBlockedClip() { return blockedClipRef?.current ?? null; }, @@ -441,3 +445,58 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => { h.unmount(); }); }); + +describe("useTimelineClipDrag — trim guide and preview frame", () => { + /** Clip a (0-2s) with neighbour b starting at 5s, snapping on, a's end edge grabbed. */ + function trimAEndBesideB() { + const a = el("a", { start: 0, duration: 2 }); + const b = el("b", { start: 5, duration: 2 }); + const h = renderResizeHarness([a, b], [], { snap: true }); + h.startResize(a, "end"); + return h; + } + + it("publishes the snap target while a trimmed edge is on a neighbour edge", () => { + const h = trimAEndBesideB(); + h.movePointer(296); // a's end lands at 4.96s, 4px from b's start + expect(h.getResizingClip()).toMatchObject({ snapTime: 5, snapType: "clip-edge" }); + h.unmount(); + }); + + it("keeps the guide when the trimmed edge sits exactly on the neighbour edge", () => { + const h = trimAEndBesideB(); + h.movePointer(300); + expect(h.getResizingClip()).toMatchObject({ snapTime: 5, previewDuration: 5 }); + h.unmount(); + }); + + it("publishes no snap target when the trimmed edge is free", () => { + const h = trimAEndBesideB(); + h.movePointer(150); + expect(h.getResizingClip()).toMatchObject({ snapTime: null, snapType: null }); + h.unmount(); + }); + + it("shows the frame at the dragged edge, then puts the playhead back on release", async () => { + usePlayerStore.setState({ currentTime: 1.25 }); + const onSeek = vi.fn(); + const a = el("a", { start: 1, duration: 2 }); + const h = renderResizeHarness([a], [], { onSeek }); + h.startResize(a, "end"); + h.movePointer(50); + expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30); + await h.dropPointer(); + expect(onSeek).toHaveBeenLastCalledWith(1.25); + h.unmount(); + }); + + it("previews the new in-point when the start edge is trimmed", () => { + const onSeek = vi.fn(); + const a = el("a", { start: 1, duration: 2 }); + const h = renderResizeHarness([a], [], { onSeek }); + h.startResize(a, "start"); + h.movePointer(50); + expect(onSeek).toHaveBeenLastCalledWith(1.5); + h.unmount(); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index ea44dda075..3f10f4d627 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -17,6 +17,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { computeDragPreview, computeResizePreview, + trimPreviewTime, previewGroupResize, type ResizePreviewResult, } from "./timelineClipDragPreview"; @@ -64,6 +65,8 @@ interface UseTimelineClipDragInput { ) => Promise | void; onResizeElements?: NonNullable; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void; + /** Seeks the preview; a trim shows the frame at its dragged edge. */ + onSeek?: (time: number) => void; setShowPopover: (show: boolean) => void; /** Stable ref to the range selection setter — wired after mount to break circular dependency. */ setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>; @@ -92,6 +95,7 @@ export function useTimelineClipDrag({ onResizeElement, onResizeElements, onBlockedEditAttempt, + onSeek, setShowPopover, setRangeSelectionRef, readZIndex, @@ -244,6 +248,10 @@ export function useTimelineClipDrag({ onResizeElementRef.current = onResizeElement; const onResizeElementsRef = useRef(onResizeElements); onResizeElementsRef.current = onResizeElements; + const onSeekRef = useRef(onSeek); + onSeekRef.current = onSeek; + // Playhead time before the first trim preview seek; restored when the gesture ends. + const trimSeekOriginRef = useRef(null); const readZIndexRef = useRef(readZIndex); readZIndexRef.current = readZIndex; const onStackingPatchesRef = useRef(onStackingPatches); @@ -292,6 +300,8 @@ export function useTimelineClipDrag({ pps: ppsRef.current, buildSnapTargets, }); + trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime; + onSeekRef.current?.(trimPreviewTime(resize.edge, next.previewStart, next.previewDuration)); const setResizeState = (v: ResizePreviewResult) => publishResizingClip( resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null, @@ -338,6 +348,10 @@ export function useTimelineClipDrag({ cancelAnimationFrame(clipDragScrollRaf.current); clipDragScrollRaf.current = 0; } + if (trimSeekOriginRef.current != null) { + onSeekRef.current?.(trimSeekOriginRef.current); + trimSeekOriginRef.current = null; + } // Gesture teardown: drop frozen caches so the next gesture reads fresh state. snapTargetsCacheRef.current.clear(); dragAudioTracksRef.current = null; From 5136b58d150f2275b01c3dc575293d7a08e077db Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 00:24:40 -0400 Subject: [PATCH 2/5] fix(studio): guide and preview stay correct under group trim and beat snaps Independent review found: the group-trim guide and preview seek read the raw single-clip snap even when a member clamp moved the rendered edge; a beat-snapped trim never drew a guide because the beat highlight still read the old move-only draggedClip prop; the 1/30 frame lead duplicated STUDIO_PREVIEW_FPS. --- .../src/player/components/TimelineCanvas.tsx | 1 + .../player/components/TimelineLanes.test.tsx | 32 +++++++++++++++ .../src/player/components/TimelineLanes.tsx | 7 +--- .../components/timelineClipDragPreview.ts | 18 ++++++--- .../player/components/timelineLaneProps.ts | 3 ++ .../useTimelineClipDrag.resize.test.tsx | 40 +++++++++++++++++++ .../player/components/useTimelineClipDrag.ts | 5 ++- 7 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index eb00ab91cc..4d46512a97 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -119,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas void; hoveredClip?: string | null; renderClipContent?: React.ComponentProps["renderClipContent"]; + snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null; } function renderLanes(options: RenderLanesOptions = {}): { @@ -141,6 +142,7 @@ function renderLanes(options: RenderLanesOptions = {}): { renderClipContent={next.renderClipContent} draggedClip={next.draggedClip ?? null} draggedElement={null} + snapGuide={next.snapGuide ?? null} multiDragPreview={next.multiDragPreview ?? null} blockedClipRef={createRef()} suppressClickRef={{ current: false }} @@ -179,6 +181,36 @@ function visibilityLabels(host: HTMLElement): (string | null)[] { ); } +/** The beat guide's own highlight div, keyed by the green glow every other beat lacks. */ +function beatHighlight(host: HTMLElement): HTMLElement | undefined { + return Array.from(host.querySelectorAll("div")).find((div) => + (div.style.boxShadow ?? "").includes("34,197,94"), + ); +} + +describe("TimelineLanes beat guide", () => { + it("draws the beat highlight from snapGuide, not from the stale draggedClip prop", () => { + const view = renderLanes({ + elements: [element("clip-a", TRACK_A)], + snapGuide: { time: 1.5, type: "beat" }, + }); + + expect(beatHighlight(view.host)?.style.left).toBe("150px"); + act(() => view.root.unmount()); + }); + + it("clears the highlight once the trim it belonged to ends", () => { + const view = renderLanes({ + elements: [element("clip-a", TRACK_A)], + snapGuide: { time: 1.5, type: "beat" }, + }); + view.rerender({ elements: [element("clip-a", TRACK_A)], snapGuide: null }); + + expect(beatHighlight(view.host)).toBeUndefined(); + act(() => view.root.unmount()); + }); +}); + describe("TimelineLanes track numbering", () => { // Screen readers literally announced "Hide track 0.16666666666666666". it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => { diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 2243a79673..3921bece03 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -59,6 +59,7 @@ export function TimelineLanes({ hoveredClip, draggedClip, draggedElement, + snapGuide, multiDragPreview, blockedClipRef, suppressClickRef, @@ -331,11 +332,7 @@ export function TimelineLanes({ beatTimes={beatAnalysis?.beatTimes} beatStrengths={beatAnalysis?.beatStrengths} pps={pps} - highlightTime={ - draggedClip?.started && draggedClip.snapType === "beat" - ? draggedClip.snapTime - : null - } + highlightTime={snapGuide?.type === "beat" ? snapGuide.time : null} renderTimeRange={rowsVirtualized ? renderTimeRange : undefined} /> {/* Beat dots on the active track (the one holding the selection), diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 1af366869e..c38f94dbfd 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -22,6 +22,7 @@ import { import { clampGroupMoveDelta } from "./timelineMultiDragPreview"; import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes"; import { resolveDragLandingStart } from "./timelineDragLanding"; +import { STUDIO_PREVIEW_FPS } from "../lib/time"; /** Snap-target builder closure supplied by the hook (closes over refs + store). */ type BuildSnapTargets = ( @@ -224,8 +225,8 @@ export function computeDragPreview( }; } -/** One 30 fps frame: the last visible frame of a clip sits just before its end time. */ -const TRIM_END_FRAME_LEAD_S = 1 / 30; +/** One frame: the last visible frame of a clip sits just before its end time. */ +const TRIM_END_FRAME_LEAD_S = 1 / STUDIO_PREVIEW_FPS; /** The composition time whose frame a trim shows: the edge being dragged. */ export function trimPreviewTime(edge: "start" | "end", start: number, duration: number): number { @@ -374,13 +375,18 @@ export function previewGroupResize( ) => void, ): void { const grabbedChange = applyTimelineGroupResizePreview(session, next); + const previewStart = grabbedChange?.start ?? next.previewStart; + const previewDuration = grabbedChange?.duration ?? next.previewDuration; + // A member clamp can pull the grabbed edge off the raw snap target; then no guide. + const edgeTime = session.edge === "end" ? previewStart + previewDuration : previewStart; + const stillSnapped = next.snapTime != null && Math.abs(edgeTime - next.snapTime) < 1e-3; setResizeState({ originScrollLeft: next.originScrollLeft, - previewStart: grabbedChange?.start ?? next.previewStart, - previewDuration: grabbedChange?.duration ?? next.previewDuration, + previewStart, + previewDuration, previewPlaybackStart: grabbedChange?.playbackStart ?? next.previewPlaybackStart, - snapTime: next.snapTime, - snapType: next.snapType, + snapTime: stillSnapped ? next.snapTime : null, + snapType: stillSnapped ? next.snapType : null, groupPreview: session.changes, }); } diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts index be09495287..5c997850bf 100644 --- a/packages/studio/src/player/components/timelineLaneProps.ts +++ b/packages/studio/src/player/components/timelineLaneProps.ts @@ -8,6 +8,7 @@ import type { TrackVisualStyle } from "./timelineIcons"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex"; import type { TimelineRowGeometry } from "./timelineLayout"; +import type { TimelineSnapTarget } from "./timelineSnapping"; import type { TimelineVirtualRow } from "./useTimelineVirtualRows"; import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; @@ -112,6 +113,8 @@ export interface TimelineLaneBaseProps { export interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ draggedElement: TimelineElement | null; + /** Live move or trim snap target, resolved once by TimelineCanvas. */ + snapGuide: TimelineSnapTarget | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index 080203a94b..4bc0361e22 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -499,4 +499,44 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { expect(onSeek).toHaveBeenLastCalledWith(1.5); h.unmount(); }); + + it("publishes the guide when the start edge snaps to a neighbour's end", () => { + // playbackStart leaves source to reveal when the in-point moves left. + const a = el("a", { start: 3, duration: 2, playbackStart: 5 }); + const b = el("b", { start: 0, duration: 2 }); + const h = renderResizeHarness([a, b], [], { snap: true }); + h.startResize(a, "start"); + h.movePointer(-96); // a's start lands at 2.04s, 4px from b's end + expect(h.getResizingClip()).toMatchObject({ snapTime: 2, previewStart: 2 }); + h.unmount(); + }); + + it("restores the playhead it had before the first preview seek, not a seeked time", async () => { + usePlayerStore.setState({ currentTime: 1.25 }); + const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t })); + const a = el("a", { start: 1, duration: 2 }); + const h = renderResizeHarness([a], [], { onSeek }); + h.startResize(a, "end"); + h.movePointer(50); + h.movePointer(80); + h.movePointer(120); + await h.dropPointer(); + expect(onSeek).toHaveBeenLastCalledWith(1.25); + h.unmount(); + }); + + it("draws no guide and seeks to the rendered edge when a group member clamps the trim", () => { + const onSeek = vi.fn(); + const a = el("a", { start: 0, duration: 4 }); + const b = el("b", { start: 0, duration: 1 }); + const c = el("c", { start: 2, duration: 1 }); + const h = renderResizeHarness([a, b, c], ["a", "b"], { snap: true, onSeek }); + h.startResize(a, "end"); + h.movePointer(-198); // a's raw end 2.02s snaps to c at 2s; b clamps the shared delta + const clip = h.getResizingClip()!; + expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3); + expect(clip).toMatchObject({ snapTime: null, snapType: null }); + expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3)); + h.unmount(); + }); }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 3f10f4d627..66faa79279 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -301,11 +301,12 @@ export function useTimelineClipDrag({ buildSnapTargets, }); trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime; - onSeekRef.current?.(trimPreviewTime(resize.edge, next.previewStart, next.previewDuration)); - const setResizeState = (v: ResizePreviewResult) => + const setResizeState = (v: ResizePreviewResult) => { + onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration)); publishResizingClip( resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null, ); + }; // Group resize: a capability-clean multi-selection resizes rigidly by one // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip From 42fbf816903d8414563c3fc3355a1194580a27f5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 01:21:40 -0400 Subject: [PATCH 3/5] fix(studio): a trim never changes play state and never snaps to the playhead Both are regressions from the guide/preview seek this branch adds: the preview and restore seeks now carry keepPlaying, so seek() only resumes playback if it was already running; the playhead is dropped from the trim snap set since the dragged edge drives its own preview seek, so snapping to it was circular. Neighbour clip edges and beats remain valid targets. --- .../src/player/components/TimelineTypes.ts | 3 +- .../components/timelineClipDragPreview.ts | 11 +++---- .../components/timelineSnapping.test.ts | 12 ++++++++ .../src/player/components/timelineSnapping.ts | 5 +++- .../useTimelineClipDrag.resize.test.tsx | 30 +++++++++++++++---- .../player/components/useTimelineClipDrag.ts | 19 ++++++++---- 6 files changed, 62 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index eb2e6ebb4c..da7a727046 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -12,7 +12,8 @@ export interface TimelineClipRenderContext { export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides { /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */ sessionEpoch?: number; - onSeek?: (time: number) => void; + /** keepPlaying: true preserves the current play state across the seek. */ + onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void; onDrillDown?: (element: TimelineElement) => void; renderClipContent?: ( element: TimelineElement, diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index c38f94dbfd..a53f7e8764 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -28,6 +28,7 @@ import { STUDIO_PREVIEW_FPS } from "../lib/time"; type BuildSnapTargets = ( excludeElementKey: string | null, includeBeats: boolean, + includePlayhead?: boolean, ) => TimelineSnapTarget[]; export interface DragPreviewContext { @@ -300,14 +301,14 @@ export function computeResizePreview( effectiveClientX, ); - // Snap edge to unified targets (beats + clip edges + playhead) when available. - // The snap must stay inside the same limits resolveTimelineResize enforces, or - // it would push the edge past the available source media / composition end. - // The music track defines the beats, so it must not snap to them — but it - // still snaps to the playhead and other clip edges. + // Snap to beats and clip edges, never the playhead (the dragged edge drives + // its own preview seek, so that would be circular). Stay inside the same + // limits resolveTimelineResize enforces. The music track defines the + // beats, so it must not snap to them, but still snaps to clip edges. const trimTargets = buildSnapTargets( resize.element.key ?? resize.element.id, !isMusicTrack(resize.element), + false, ); let snap: TimelineSnapTarget | null = null; if (trimTargets.length > 0) { diff --git a/packages/studio/src/player/components/timelineSnapping.test.ts b/packages/studio/src/player/components/timelineSnapping.test.ts index dc59cfc422..1d881ff1fb 100644 --- a/packages/studio/src/player/components/timelineSnapping.test.ts +++ b/packages/studio/src/player/components/timelineSnapping.test.ts @@ -27,6 +27,18 @@ describe("collectTimelineSnapTargets", () => { expect(targets).toContainEqual({ time: 0.5, type: "beat" }); }); + it("omits the playhead when includePlayhead is false, for a trim", () => { + const targets = collectTimelineSnapTargets({ + elements, + playheadTime: 7.25, + beatTimes: [0.5], + includePlayhead: false, + }); + expect(targets.some((t) => t.type === "playhead")).toBe(false); + expect(targets).toContainEqual({ time: 2, type: "clip-edge" }); + expect(targets).toContainEqual({ time: 0.5, type: "beat" }); + }); + it("excludes the dragged element's own edges", () => { const targets = collectTimelineSnapTargets({ elements, diff --git a/packages/studio/src/player/components/timelineSnapping.ts b/packages/studio/src/player/components/timelineSnapping.ts index 294beec58b..8f11ab67e1 100644 --- a/packages/studio/src/player/components/timelineSnapping.ts +++ b/packages/studio/src/player/components/timelineSnapping.ts @@ -31,6 +31,8 @@ export function collectTimelineSnapTargets(input: { playheadTime: number | null; beatTimes: readonly number[]; excludeElementKey?: string | null; + /** A trim excludes the playhead: the dragged edge drives it, so snapping to it is circular. */ + includePlayhead?: boolean; }): TimelineSnapTarget[] { const byTime = new Map(); const add = (time: number, type: TimelineSnapType) => { @@ -48,7 +50,8 @@ export function collectTimelineSnapTargets(input: { add(el.start, "clip-edge"); add(el.start + el.duration, "clip-edge"); } - if (input.playheadTime != null) add(input.playheadTime, "playhead"); + if (input.playheadTime != null && input.includePlayhead !== false) + add(input.playheadTime, "playhead"); return Array.from(byTime.values()).sort((a, b) => a.time - b.time); } diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index 4bc0361e22..3dfb47c3cf 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -31,7 +31,11 @@ afterEach(() => { function renderResizeHarness( elements: TimelineElement[], selected: string[], - options: { wireGroupResize?: boolean; snap?: boolean; onSeek?: (time: number) => void } = {}, + options: { + wireGroupResize?: boolean; + snap?: boolean; + onSeek?: (time: number, seekOptions?: { keepPlaying?: boolean }) => void; + } = {}, ) { usePlayerStore.getState().setElements(elements); usePlayerStore.setState({ timelineSnapEnabled: options.snap === true }); @@ -477,6 +481,20 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { h.unmount(); }); + it("never snaps a trimmed edge to the playhead — the edge drives its own seek", () => { + usePlayerStore.setState({ currentTime: 5 }); // no clip edge or beat nearby, only the playhead + const a = el("a", { start: 0, duration: 2 }); + const h = renderResizeHarness([a], [], { snap: true }); + h.startResize(a, "end"); + h.movePointer(296); // a's end lands at 4.96s, 4px from the playhead + expect(h.getResizingClip()).toMatchObject({ + snapTime: null, + snapType: null, + previewDuration: 4.96, + }); + h.unmount(); + }); + it("shows the frame at the dragged edge, then puts the playhead back on release", async () => { usePlayerStore.setState({ currentTime: 1.25 }); const onSeek = vi.fn(); @@ -484,9 +502,9 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { const h = renderResizeHarness([a], [], { onSeek }); h.startResize(a, "end"); h.movePointer(50); - expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30); + expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true }); await h.dropPointer(); - expect(onSeek).toHaveBeenLastCalledWith(1.25); + expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true }); h.unmount(); }); @@ -496,7 +514,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { const h = renderResizeHarness([a], [], { onSeek }); h.startResize(a, "start"); h.movePointer(50); - expect(onSeek).toHaveBeenLastCalledWith(1.5); + expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true }); h.unmount(); }); @@ -521,7 +539,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { h.movePointer(80); h.movePointer(120); await h.dropPointer(); - expect(onSeek).toHaveBeenLastCalledWith(1.25); + expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true }); h.unmount(); }); @@ -536,7 +554,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { const clip = h.getResizingClip()!; expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3); expect(clip).toMatchObject({ snapTime: null, snapType: null }); - expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3)); + expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), { keepPlaying: true }); h.unmount(); }); }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 66faa79279..91e56251b3 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -66,7 +66,7 @@ interface UseTimelineClipDragInput { onResizeElements?: NonNullable; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void; /** Seeks the preview; a trim shows the frame at its dragged edge. */ - onSeek?: (time: number) => void; + onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void; setShowPopover: (show: boolean) => void; /** Stable ref to the range selection setter — wired after mount to break circular dependency. */ setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>; @@ -153,11 +153,15 @@ export function useTimelineClipDrag({ const dragAudioTracksRef = useRef | null>(null); const buildSnapTargets = useCallback( - (excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => { + ( + excludeElementKey: string | null, + includeBeats: boolean, + includePlayhead = true, + ): TimelineSnapTarget[] => { // Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle // back on starts scanning immediately (preserves the existing skip). if (!snapContextRef.current.enabled) return []; - const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`; + const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}|${includePlayhead ? 1 : 0}`; const cached = snapTargetsCacheRef.current.get(cacheKey); if (cached) return cached; const targets = collectTimelineSnapTargets({ @@ -165,6 +169,7 @@ export function useTimelineClipDrag({ playheadTime: usePlayerStore.getState().currentTime, beatTimes: includeBeats ? snapContextRef.current.beatTimes : [], excludeElementKey, + includePlayhead, }); snapTargetsCacheRef.current.set(cacheKey, targets); return targets; @@ -302,7 +307,11 @@ export function useTimelineClipDrag({ }); trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime; const setResizeState = (v: ResizePreviewResult) => { - onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration)); + // A trim never changes the play state: keepPlaying lets seek() decide, + // and it only resumes playback if it was already playing. + onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), { + keepPlaying: true, + }); publishResizingClip( resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null, ); @@ -350,7 +359,7 @@ export function useTimelineClipDrag({ clipDragScrollRaf.current = 0; } if (trimSeekOriginRef.current != null) { - onSeekRef.current?.(trimSeekOriginRef.current); + onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true }); trimSeekOriginRef.current = null; } // Gesture teardown: drop frozen caches so the next gesture reads fresh state. From ff0a7c2820cd836478f9687131d1a3d95a95158b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 04:24:53 -0400 Subject: [PATCH 4/5] fix(studio): releasing a trim while playing no longer rewinds the playhead --- .../useTimelineClipDrag.resize.test.tsx | 16 ++++++++++++++++ .../src/player/components/useTimelineClipDrag.ts | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index 3dfb47c3cf..8d0c93125c 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -543,6 +543,22 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { h.unmount(); }); + it("leaves the playhead where playback is when a trim is released while playing", async () => { + usePlayerStore.setState({ currentTime: 1.25, isPlaying: true }); + const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t })); + const a = el("a", { start: 1, duration: 2 }); + const h = renderResizeHarness([a], [], { onSeek }); + h.startResize(a, "end"); + h.movePointer(50); + h.movePointer(120); + const callsBeforeRelease = onSeek.mock.calls.length; + await h.dropPointer(); + expect(onSeek).toHaveBeenCalledTimes(callsBeforeRelease); + expect(onSeek).not.toHaveBeenCalledWith(1.25, expect.anything()); + usePlayerStore.setState({ isPlaying: false }); + h.unmount(); + }); + it("draws no guide and seeks to the rendered edge when a group member clamps the trim", () => { const onSeek = vi.fn(); const a = el("a", { start: 0, duration: 4 }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 91e56251b3..7c49eda117 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -359,7 +359,10 @@ export function useTimelineClipDrag({ clipDragScrollRaf.current = 0; } if (trimSeekOriginRef.current != null) { - onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true }); + // Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback. + if (!usePlayerStore.getState().isPlaying) { + onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true }); + } trimSeekOriginRef.current = null; } // Gesture teardown: drop frozen caches so the next gesture reads fresh state. From eadcae6bb4dc12c395ada2ee21bc5519f9221c78 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 05:40:15 -0400 Subject: [PATCH 5/5] refactor(studio): share trim-with-seeking-playhead setup in resize tests --- .../useTimelineClipDrag.resize.test.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index 8d0c93125c..fd5cb0fadd 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -451,6 +451,17 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => { }); describe("useTimelineClipDrag — trim guide and preview frame", () => { + /** Playhead at 1.25s that follows preview seeks; clip a's end edge grabbed and dragged once. */ + function trimEndWithSeekingPlayhead(state: { isPlaying?: boolean } = {}) { + usePlayerStore.setState({ currentTime: 1.25, ...state }); + const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t })); + const a = el("a", { start: 1, duration: 2 }); + const h = renderResizeHarness([a], [], { onSeek }); + h.startResize(a, "end"); + h.movePointer(50); + return { onSeek, h }; + } + /** Clip a (0-2s) with neighbour b starting at 5s, snapping on, a's end edge grabbed. */ function trimAEndBesideB() { const a = el("a", { start: 0, duration: 2 }); @@ -530,12 +541,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { }); it("restores the playhead it had before the first preview seek, not a seeked time", async () => { - usePlayerStore.setState({ currentTime: 1.25 }); - const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t })); - const a = el("a", { start: 1, duration: 2 }); - const h = renderResizeHarness([a], [], { onSeek }); - h.startResize(a, "end"); - h.movePointer(50); + const { onSeek, h } = trimEndWithSeekingPlayhead(); h.movePointer(80); h.movePointer(120); await h.dropPointer(); @@ -544,12 +550,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { }); it("leaves the playhead where playback is when a trim is released while playing", async () => { - usePlayerStore.setState({ currentTime: 1.25, isPlaying: true }); - const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t })); - const a = el("a", { start: 1, duration: 2 }); - const h = renderResizeHarness([a], [], { onSeek }); - h.startResize(a, "end"); - h.movePointer(50); + const { onSeek, h } = trimEndWithSeekingPlayhead({ isPlaying: true }); h.movePointer(120); const callsBeforeRelease = onSeek.mock.calls.length; await h.dropPointer();