mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): make vertical lane moves persist correctly and harden the z/lane pipeline
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)
This commit is contained in:
@@ -139,6 +139,26 @@ describe("persistTimelineBatchEdit", () => {
|
||||
);
|
||||
}
|
||||
|
||||
function moveMember(
|
||||
id: string,
|
||||
start: number,
|
||||
fromTrack: number,
|
||||
toTrack: number,
|
||||
): PersistTimelineBatchChange {
|
||||
return {
|
||||
element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
|
||||
};
|
||||
}
|
||||
|
||||
async function runBatch(changes: PersistTimelineBatchChange[]) {
|
||||
stubReadFileContent(SOURCE);
|
||||
const writes: Array<[string, string]> = [];
|
||||
await persistTimelineBatchEdit(batchInput(changes, writes));
|
||||
return writes;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -147,28 +167,12 @@ describe("persistTimelineBatchEdit", () => {
|
||||
// A track-insert renumber can include a member whose attributes already
|
||||
// hold the target values — its patch is string-identical. The batch must
|
||||
// skip it and still persist the members that DID change.
|
||||
stubReadFileContent(SOURCE);
|
||||
const writes: Array<[string, string]> = [];
|
||||
|
||||
await persistTimelineBatchEdit(
|
||||
batchInput(
|
||||
[
|
||||
{
|
||||
// no-op: data-start already "1", track already 0
|
||||
element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
|
||||
},
|
||||
{
|
||||
// real change: track 1 -> 2
|
||||
element: el({ id: "b", tag: "video", domId: "b", start: 2, track: 1 }),
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineMoveTimingPatch(original, target, 2, 5, 2),
|
||||
},
|
||||
],
|
||||
writes,
|
||||
),
|
||||
);
|
||||
const writes = await runBatch([
|
||||
// no-op: data-start already "1", track already 0
|
||||
moveMember("a", 1, 0, 0),
|
||||
// real change: track 1 -> 2
|
||||
moveMember("b", 2, 1, 2),
|
||||
]);
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]![0]).toBe("index.html");
|
||||
@@ -176,21 +180,7 @@ describe("persistTimelineBatchEdit", () => {
|
||||
});
|
||||
|
||||
it("saves nothing when every member is a no-op", async () => {
|
||||
stubReadFileContent(SOURCE);
|
||||
const writes: Array<[string, string]> = [];
|
||||
|
||||
await persistTimelineBatchEdit(
|
||||
batchInput(
|
||||
[
|
||||
{
|
||||
element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
|
||||
buildPatches: (original, target) =>
|
||||
buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
|
||||
},
|
||||
],
|
||||
writes,
|
||||
),
|
||||
);
|
||||
const writes = await runBatch([moveMember("a", 1, 0, 0)]);
|
||||
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user