fix(studio): a snap the saved clip would miss no longer moves it (#5006)

* fix(studio): a snap the saved clip would miss no longer moves it

* test(studio): walk that a deep-zoom move lands where it shows, with no jump on release

* test(studio): the release walk waits for the saved start, not the drag's own

* test(studio): find the drag landing slot by its test id
This commit is contained in:
Miguel Ángel
2026-10-04 14:25:37 +00:00
committed by GitHub
parent 5cee25efcb
commit 48bfe61803
8 changed files with 259 additions and 40 deletions
+5
View File
@@ -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()
@@ -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<HTMLElement>(".absolute.pointer-events-none"),
).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
const ghost = host.querySelector<HTMLElement>('[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<HTMLElement>("[data-timeline-new-track-lane]");
@@ -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 && (
<div
data-testid="timeline-drag-landing"
className="absolute pointer-events-none"
style={{
top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
@@ -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(
{
@@ -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;
}
}
@@ -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);
@@ -39,6 +39,19 @@
<div id="card-b" class="clip card" data-start="3.1" data-duration="1" data-track-index="1">
B
</div>
<!-- card-c lasts 31/30 s and is moved toward card-d's start at the deepest zoom. -->
<div
id="card-c"
class="clip card"
data-start="0.1"
data-duration="1.033333"
data-track-index="2"
>
C
</div>
<div id="card-d" class="clip card" data-start="1.35" data-duration="0.5" data-track-index="3">
D
</div>
</div>
</body>
</html>
@@ -0,0 +1,155 @@
#!/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();
// Release first shows the drag's own start; the saved one is always a whole centisecond.
await page.waitForFunction(
(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,
);
// The save re-fits the zoom and resets the view.
await scrollToD();
await frames();
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);
// 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);