mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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);
|
||||
Reference in New Issue
Block a user