From 96f04ea57d9612d4dce80b94e9d0373da9f47803 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 02:27:46 -0700 Subject: [PATCH 1/4] fix(studio): a snap the saved clip would miss no longer moves it --- .../timelineClipDragPreview.test.ts | 31 ++++++++ .../components/timelineClipDragPreview.ts | 79 ++++++++++--------- .../hooks/useTimelinePlayer.seek.test.ts | 11 +++ 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts index d68f2fc672f..63ac78380a9 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts @@ -321,6 +321,20 @@ describe("guideIfSaved — a guide only where the clip saves", () => { expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull(); }); + it("keeps the pointer's start when a move's snap would save off the target", () => { + // 1440 px/s: a 31/30 s clip whose end snaps to 3.25s would save at 2.22s and jump 4px on release. + const el = clip("a", 0, 1, 31 / 30, 0, "div"); + const { drag } = horizontalDrag(el, 0.5, 0); + const context = { + ...ctx(undefined, [el]), + pps: 1440, + buildSnapTargets: () => [{ time: 3.25, type: "clip-edge" as const }], + }; + const x = drag.originClientX + 1.22 * 1440; + const next = computeDragPreview(drag, x, drag.originClientY, context); + expect(next).toMatchObject({ previewStart: 2.22, snapTime: null, snapType: null }); + }); + it("draws no guide for a move whose saved start misses the target", () => { const nested = { ...moodboard, parentCompositionStart: 1 / 30 }; const { drag } = horizontalDrag(nested, 0.5, 0); @@ -388,6 +402,23 @@ describe("computeResizePreview — composition source continuity", () => { expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null }); }); + it("keeps the pointer's end when a tail snap would save off the target", () => { + // 1440 px/s: snapping 1.125s + 2.12s to 3.25s needs a 2.125s duration, which saves 7px off. + const result = computeResizePreview( + { + element: clip("a", 0, 1.125, 2, 0, "div"), + edge: "end", + originClientX: 0, + previewStart: 1.125, + previewDuration: 2, + started: true, + }, + 173, + { scroll: fakeScroll(), pps: 1440, buildSnapTargets: () => [{ time: 3.25, type: "grid" }] }, + ); + expect(result).toMatchObject({ previewDuration: 2.12, snapTime: null, snapType: null }); + }); + it("does not let a tail snap shrink a clip below the drag's minimum duration", () => { const result = computeResizePreview( { diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts index 0842c98eea1..1ac562bef7d 100644 --- a/packages/studio/src/player/components/timelineClipDragPreview.ts +++ b/packages/studio/src/player/components/timelineClipDragPreview.ts @@ -241,7 +241,7 @@ export function computeDragPreview( drag.element.key ?? drag.element.id, !isMusicTrack(drag.element), ); - const snap = snapMoveToTargets( + const snapped = snapMoveToTargets( nextMove.start, drag.element.duration, targets, @@ -251,6 +251,11 @@ export function computeDragPreview( dragMaxStart + drag.element.duration, ctx.gridStep, ); + // A snap the saved clip would miss jumps on release, so the clip stays where the pointer put it. + const target = snapTarget(snapped); + const missed = + target !== null && !guideIfSaved(drag.element, { start: snapped.start }, target, pps); + const snap = missed ? { start: nextMove.start, snapTime: null, snapType: null } : snapped; // A group moves rigidly: the grabbed clip stops where any mover would cross its host's start. const group = resolveGroupDrag(drag, ctx); const dragKey = drag.element.key ?? drag.element.id; @@ -268,11 +273,7 @@ export function computeDragPreview( return { ...drag, started: true }; } const { track: previewTrack, insertRow } = placement; - const target = - placement.start === snap.start && snap.snapTime !== null && snap.snapType !== null - ? { time: snap.snapTime, type: snap.snapType } - : null; - const guide = guideIfSaved(drag.element, { start: snap.start }, target, pps); + const guide = placement.start === snap.start ? snapTarget(snap) : null; return { ...drag, started: true, @@ -289,6 +290,9 @@ export function computeDragPreview( }; } +const snapTarget = (s: { snapTime: number | null; snapType: TimelineSnapType | null }) => + s.snapTime !== null && s.snapType !== null ? { time: s.snapTime, type: s.snapType } : null; + /** A snap's guide, kept only when the clip's edge as its file will save it is within a pixel of it. */ export function guideIfSaved( element: TimelineElement, @@ -392,37 +396,38 @@ export function computeResizePreview( ); const gridStep = ctx.gridStep ?? 0; let snap: TimelineSnapTarget | null = null; - if (trimTargets.length > 0 || gridStep > 0) { - const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1); - if (resize.edge === "end") { - const edgeTime = nextResize.start + nextResize.duration; - const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs, gridStep); - // 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 ( - target && - snapped <= maxEnd + 1e-6 && - snappedDuration >= resolveTimelineMinDuration() - 1e-6 - ) { - // An edge already on the target still owns the guide; only move it when off. - if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration }; - snap = guideIfSaved(resize.element, nextResize, target, pps); - } - } else { - const { time: snapped, target } = snapTimelineTime( - nextResize.start, - trimTargets, - snapSecs, - gridStep, - ); - const clip = { ...nextResize, playbackRate: resize.element.playbackRate }; - const delta = snapped - nextResize.start; - const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration()); - if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) { - if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta); - snap = guideIfSaved(resize.element, nextResize, target, pps); - } + const snapSecs = TIMELINE_SNAP_PX / Math.max(pps, 1); + if (resize.edge === "end") { + const edgeTime = nextResize.start + nextResize.duration; + const { time: snapped, target } = snapTimelineTime(edgeTime, trimTargets, snapSecs, gridStep); + // 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 ( + target && + snapped <= maxEnd + 1e-6 && + snappedDuration >= resolveTimelineMinDuration() - 1e-6 + ) { + const onTarget = + snapped === edgeTime ? nextResize : { ...nextResize, duration: snappedDuration }; + snap = guideIfSaved(resize.element, onTarget, target, pps); + if (snap) nextResize = onTarget; + } + } else { + const { time: snapped, target } = snapTimelineTime( + nextResize.start, + trimTargets, + snapSecs, + gridStep, + ); + const clip = { ...nextResize, playbackRate: resize.element.playbackRate }; + const delta = snapped - nextResize.start; + const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration()); + if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) { + const onTarget = + snapped === nextResize.start ? nextResize : applyClipStartTrimDelta(clip, delta); + snap = guideIfSaved(resize.element, onTarget, target, pps); + if (snap) nextResize = onTarget; } } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index 500b2a895f5..8ebbb40ff46 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -406,6 +406,17 @@ describe("useTimelinePlayer preview frame (a trim's dragged edge)", () => { unmountWithAct(root); }); + it("reads the playing transport's own time, not the playhead's, under a preview frame", () => { + const { api, root, adapter } = renderAttachedTimelinePlayer(); + seekWithAct(api, 2); + setStorePlaying(); + previewFrame(5); + adapter.seek(7); + act(() => api.pause()); + expect(usePlayerStore.getState().currentTime).toBe(7); + unmountWithAct(root); + }); + it("leaves live playback alone", () => { const { api, root, adapter } = renderAttachedTimelinePlayer(); seekWithAct(api, 2); From 48a594827bb7e6f2c60335f5badb3d7d46ae2f05 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 04:48:12 -0700 Subject: [PATCH 2/4] test(studio): walk that a deep-zoom move lands where it shows, with no jump on release --- .github/workflows/ci.yml | 5 + .../src/player/components/TimelineCanvas.tsx | 1 + .../fixtures/timeline-trim-snap/index.html | 13 ++ .../tests/e2e/timeline-snap-release.mjs | 144 ++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 packages/studio/tests/e2e/timeline-snap-release.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37cdea751c4..75dc5cdbace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -987,6 +987,11 @@ jobs: node packages/studio/tests/e2e/thumbnail-save.mjs || rc=1 STUDIO_URL="http://localhost:5316/#project/timeline-trim-snap" \ node packages/studio/tests/e2e/timeline-trim-snap.mjs || rc=1 + rm -rf packages/studio/data/projects/timeline-trim-snap + cp -R packages/studio/tests/e2e/fixtures/timeline-trim-snap \ + packages/studio/data/projects/timeline-trim-snap + STUDIO_URL="http://localhost:5316/#project/timeline-trim-snap" \ + node packages/studio/tests/e2e/timeline-snap-release.mjs || rc=1 exit "$rc" - name: Measure the caret of a text edited in place if: success() || failure() diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index dd9739fc8fb..923844a59b5 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -134,6 +134,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() { lane + snapped start), parallel to the ghost. Hidden in insert mode. */} {draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
B
+ +
+ C +
+
+ D +
diff --git a/packages/studio/tests/e2e/timeline-snap-release.mjs b/packages/studio/tests/e2e/timeline-snap-release.mjs new file mode 100644 index 00000000000..e5c0e39b32c --- /dev/null +++ b/packages/studio/tests/e2e/timeline-snap-release.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +// At the deepest zoom a snap the saved clip would miss must not move the clip, or it jumps on +// release: card-c (31/30 s) is moved until its end is 4.8 px past card-d's start. +import { launchStudioChrome } from "./chrome-executable.mjs"; + +const STUDIO_URL = process.env.STUDIO_URL; +const OUT_DIR = process.env.OUT_DIR; +const JUMP_TOLERANCE_PX = 1; +const C_START_S = 0.1; +const D_START_S = 1.35; +const MOVE_TO_START_S = 0.32; + +if (!STUDIO_URL) { + console.error("STUDIO_URL is required and must point at the timeline-trim-snap fixture"); + process.exit(2); +} +const clipSelector = (id) => `.timeline-clip[data-el-id$="${id}"]`; +const { browser } = await launchStudioChrome(); +const failures = []; +const evidence = {}; +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded" }); + await page.waitForSelector(clipSelector("card-d"), { timeout: 60_000 }); + + const frames = () => + page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + // A view shot plus the close-up box at card-d's start (card-c's row above, card-d's below). + const closeUp = async (name, d) => { + if (!OUT_DIR) return; + await page.screenshot({ path: `${OUT_DIR}/${name}.png` }); + evidence[name] = { x: d.x - 80, y: d.y - 56, width: 160, height: 104 }; + }; + const box = (selector) => + page.$eval(selector, (el) => { + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }); + + evidence.step = "zoom"; + // Deepest zoom, then card-d's start three quarters across, so the drag stays clear of the edges. + await page.$eval('input[aria-label="Timeline zoom"]', (input) => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; + setValue.call(input, "100"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await page.waitForFunction( + (sel) => document.querySelector(sel)?.getBoundingClientRect().width > 1500, + { timeout: 10_000 }, + clipSelector("card-a"), + ); + // The scroll width grows as the view moves, so re-aim each frame until card-d is drawn in place. + const scrollToD = () => + page.waitForFunction( + (aSel, dSel, dStart) => { + const viewport = document.querySelector("[data-timeline-scroll-viewport]"); + const view = viewport.getBoundingClientRect(); + const d = document.querySelector(dSel)?.getBoundingClientRect(); + if (d && d.x > view.x + view.width * 0.7 && d.x < view.x + view.width * 0.8) return true; + const a = document.querySelector(aSel); + const r = a.getBoundingClientRect(); + const pps = r.width / (Number(a.dataset.clipEnd) - Number(a.dataset.clipStart)); + const dStartX = r.x + (dStart - Number(a.dataset.clipStart)) * pps; + viewport.scrollLeft += dStartX - (view.x + view.width * 0.75); + return false; + }, + { timeout: 10_000, polling: "raf" }, + clipSelector("card-a"), + clipSelector("card-d"), + D_START_S, + ); + await scrollToD(); + // Selecting a clip scrolls its start into view, so select card-c first, then aim at card-d again. + const cTail = await box(clipSelector("card-c")); + await page.mouse.click(cTail.x + cTail.width - 100, cTail.y + cTail.height / 2); + await page.waitForSelector(`${clipSelector("card-c")}[aria-pressed="true"]`, { timeout: 10_000 }); + await frames(); + await scrollToD(); + await frames(); + + evidence.step = "drag"; + const d0 = await box(clipSelector("card-d")); + const c0 = await box(clipSelector("card-c")); + const pps = d0.width / 0.5; + const scrollLeft = () => page.$eval("[data-timeline-scroll-viewport]", (v) => v.scrollLeft); + const scroll0 = await scrollLeft(); + // Grab near c's tail, mid-view: a pointer near the edge would scroll the timeline. + const grabX = c0.x + c0.width - 100; + const grabY = c0.y + c0.height / 2; + const dx = (MOVE_TO_START_S - C_START_S) * pps; + evidence.step = "press"; + await page.mouse.move(grabX, grabY); + await page.mouse.down(); + for (const part of [0.25, 0.5, 0.75, 1]) { + await page.mouse.move(grabX + dx * part, grabY); + await frames(); + } + const landing = await box('[data-testid="timeline-drag-landing"]'); + // card-d does not move, so its place mid-drag is its place at the press less any scroll since. + const dDuring = { ...d0, x: d0.x - ((await scrollLeft()) - scroll0) }; + if (OUT_DIR) await page.screenshot({ path: `${OUT_DIR}/during-move.png` }); + // The dragged clip covers its landing slot; hide it for the close-up only. + const actorSel = '[data-timeline-gesture-actor$="card-c"]'; + await page.$eval(actorSel, (el) => (el.style.visibility = "hidden")); + await closeUp("landing-during", dDuring); + await page.$eval(actorSel, (el) => (el.style.visibility = "")); + evidence.step = "release"; + await page.mouse.up(); + await page.waitForFunction( + (sel, start) => Number(document.querySelector(sel)?.dataset.clipStart) !== start, + { timeout: 10_000 }, + clipSelector("card-c"), + C_START_S, + ); + // The save re-fits the zoom and resets the view. + await scrollToD(); + await frames(); + const c1 = await box(clipSelector("card-c")); + const d1 = await box(clipSelector("card-d")); + if (OUT_DIR) await page.screenshot({ path: `${OUT_DIR}/after-move.png` }); + await closeUp("landing-after", d1); + + // Where the drag shows card-c landing, then where it saved, both relative to card-d. + const duringPx = (landing.x - dDuring.x) * (d1.width / dDuring.width); + const afterPx = c1.x - d1.x; + evidence.move = { + pps, + savedStart: Number(await page.$eval(clipSelector("card-c"), (el) => el.dataset.clipStart)), + startToDDuringPx: duringPx, + startToDAfterPx: afterPx, + jumpPx: afterPx - duringPx, + }; + if (Math.abs(afterPx - duringPx) > JUMP_TOLERANCE_PX) { + failures.push(`card-c jumped ${(afterPx - duringPx).toFixed(1)}px on release`); + } +} catch (error) { + failures.push(`walk stopped at "${evidence.step}": ${error.message}`); + if (OUT_DIR) await (await browser.pages()).at(-1)?.screenshot({ path: `${OUT_DIR}/failed.png` }); +} finally { + await browser.close(); +} +console.log(JSON.stringify({ evidence, failures }, null, 2)); +if (failures.length > 0) process.exit(1); From 9dc547abe42ca025940e613edc5579d7bff10ff8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 05:10:43 -0700 Subject: [PATCH 3/4] test(studio): the release walk waits for the saved start, not the drag's own --- .../studio/tests/e2e/timeline-snap-release.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/studio/tests/e2e/timeline-snap-release.mjs b/packages/studio/tests/e2e/timeline-snap-release.mjs index e5c0e39b32c..65befdced32 100644 --- a/packages/studio/tests/e2e/timeline-snap-release.mjs +++ b/packages/studio/tests/e2e/timeline-snap-release.mjs @@ -107,8 +107,12 @@ try { await page.$eval(actorSel, (el) => (el.style.visibility = "")); evidence.step = "release"; await page.mouse.up(); + // Release first shows the drag's own start; the saved one is always a whole centisecond. await page.waitForFunction( - (sel, start) => Number(document.querySelector(sel)?.dataset.clipStart) !== start, + (sel, start) => { + const saved = Number(document.querySelector(sel)?.dataset.clipStart); + return saved !== start && Math.abs(saved * 100 - Math.round(saved * 100)) < 1e-6; + }, { timeout: 10_000 }, clipSelector("card-c"), C_START_S, @@ -116,8 +120,15 @@ try { // The save re-fits the zoom and resets the view. await scrollToD(); await frames(); - const c1 = await box(clipSelector("card-c")); - const d1 = await box(clipSelector("card-d")); + const [c1, d1] = await page.evaluate( + (...sels) => + sels.map((sel) => { + const r = document.querySelector(sel).getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }), + clipSelector("card-c"), + clipSelector("card-d"), + ); if (OUT_DIR) await page.screenshot({ path: `${OUT_DIR}/after-move.png` }); await closeUp("landing-after", d1); From 4662776a8cd64f05122a78a36a6c5e9c2791fe4d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 4 Oct 2026 05:38:43 -0700 Subject: [PATCH 4/4] test(studio): find the drag landing slot by its test id --- .../src/player/components/Timeline.trackPadding.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.trackPadding.test.tsx b/packages/studio/src/player/components/Timeline.trackPadding.test.tsx index b73d0d6254f..df6116d999d 100644 --- a/packages/studio/src/player/components/Timeline.trackPadding.test.tsx +++ b/packages/studio/src/player/components/Timeline.trackPadding.test.tsx @@ -99,9 +99,7 @@ describe("Timeline trackPadding", () => { const clip = host.querySelector('[data-clip][data-el-id="c0"]')!; pointer(clip, "pointerdown", RULER_H + TRACK_H / 2); pointer(window, "pointermove", ROW1_MID); - const ghost = Array.from( - host.querySelectorAll(".absolute.pointer-events-none"), - ).find((el) => el.style.zIndex === "30" && !el.dataset.testid); + const ghost = host.querySelector('[data-testid="timeline-drag-landing"]'); expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`); pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2); const lane = host.querySelector("[data-timeline-new-track-lane]");