mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.
- timelineMoveAdapter deliberately stripped the track from lane-reorder
persists ('z-only reorder path' — the old z-driven lane model). Lane =
authored data-track-index now: lane-reorder and track-insert both persist
the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
number — silently re-targeting the wrong row in any non-0-contiguous file.
Elements now record their authoredTrack when remapped; a lane change
persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
div) onto separate renumbered tracks at discovery, so authored indices
never round-tripped ('drop onto an existing track' bounced back). Removed:
data-track-index is honored verbatim (render never reads it); kind-based
row presentation belongs in the display layer if ever wanted.
Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
ancestor z decides paint order; the sync now partitions by
stackingContextId and never patches across contexts
Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
(wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
steps added for far zoom-out, tick positions computed as exact multiples
(no float drift)
82 lines
3.3 KiB
TypeScript
82 lines
3.3 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { TimelineElement } from "../player";
|
|
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
|
|
|
|
type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
|
|
|
|
const element = (id: string, track: number): TimelineElement => ({
|
|
id,
|
|
key: id,
|
|
tag: "div",
|
|
start: 0,
|
|
duration: 2,
|
|
track,
|
|
});
|
|
|
|
const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
|
|
{ element: element("a", 0), updates: { start: 1, track: 1 } },
|
|
{ element: element("b", bTrack), updates: { start: 3, track: 2 } },
|
|
];
|
|
|
|
const movedPair = (edits: MoveArgs[0]) => [
|
|
{ element: edits[0].element, start: 1, track: 1 },
|
|
{ element: edits[1].element, start: 3, track: 2 },
|
|
];
|
|
|
|
const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
|
|
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
|
|
await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
|
|
handleTimelineGroupMove,
|
|
});
|
|
return handleTimelineGroupMove;
|
|
};
|
|
|
|
describe("persistTimelineMoveEditsAtomically", () => {
|
|
it("persists two vertical edits as one group with the gesture coalesce key", async () => {
|
|
const edits = twoLaneEdits(1);
|
|
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
|
|
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
|
|
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
|
|
coalesceKey: "clip-lane-move:7",
|
|
});
|
|
});
|
|
|
|
it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
|
|
const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
|
|
const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
|
|
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
|
|
coalesceKey: undefined,
|
|
});
|
|
});
|
|
|
|
it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
|
|
// Lane = authored data-track-index; a vertical move that never hits disk
|
|
// snaps back on the next normalize, so the lane change MUST persist.
|
|
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
|
|
const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
|
|
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
|
|
[{ element: edit.element, start: 1, track: 1 }],
|
|
{ coalesceKey: "clip-lane-move:7" },
|
|
);
|
|
});
|
|
|
|
it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
|
|
const edits = twoLaneEdits(2);
|
|
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
|
|
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
|
|
coalesceKey: "clip-lane-move:7",
|
|
});
|
|
});
|
|
|
|
it("rejects without retrying individual members when the atomic batch fails", async () => {
|
|
const failure = new Error("batch failed");
|
|
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
|
|
await expect(
|
|
persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
|
|
handleTimelineGroupMove,
|
|
}),
|
|
).rejects.toBe(failure);
|
|
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|