Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
{
Expand Down
79 changes: 42 additions & 37 deletions packages/studio/src/player/components/timelineClipDragPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions packages/studio/tests/e2e/fixtures/timeline-trim-snap/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
155 changes: 155 additions & 0 deletions packages/studio/tests/e2e/timeline-snap-release.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading