@hyperframes/studio 0.7.54 → 0.7.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
- package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +48 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/hooks/useRazorSplit.history.test.tsx +173 -0
- package/src/player/components/timelineCollision.test.ts +437 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
4
|
+
import { computeStackingPatches, type StackingElement } from "./timelineStackingSync";
|
|
5
|
+
|
|
6
|
+
function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
|
|
7
|
+
return { id, tag, start: 0, duration, track };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function zClip(
|
|
11
|
+
id: string,
|
|
12
|
+
start: number,
|
|
13
|
+
duration: number,
|
|
14
|
+
track: number,
|
|
15
|
+
zIndex: number,
|
|
16
|
+
tag = "video",
|
|
17
|
+
): TimelineElement {
|
|
18
|
+
return { id, tag, start, duration, track, zIndex };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function trackOf(els: TimelineElement[], id: string): number {
|
|
22
|
+
return els.find((e) => e.id === id)!.track;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Assert normalizeToZones is idempotent: re-zoning keeps every clip's lane. */
|
|
26
|
+
function expectZoningIdempotent(input: TimelineElement[]): void {
|
|
27
|
+
const once = normalizeToZones(input);
|
|
28
|
+
const twice = normalizeToZones(once);
|
|
29
|
+
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */
|
|
33
|
+
function qaCleanRepro(): TimelineElement[] {
|
|
34
|
+
return [
|
|
35
|
+
zClip("blue-logo", 6.37, 3, 0, 3, "img"),
|
|
36
|
+
zClip("ralu", 6.37, 3, 0, 0, "img"),
|
|
37
|
+
zClip("black-logo", 11.92, 3, 1, 1, "img"),
|
|
38
|
+
zClip("video", 0.84, 20, 3, 2, "video"),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("classifyZone", () => {
|
|
43
|
+
it("audio → audio; video / image / everything else → visual", () => {
|
|
44
|
+
expect(classifyZone(el("m", "audio", 3))).toBe("audio");
|
|
45
|
+
expect(classifyZone(el("v", "video", 1))).toBe("visual");
|
|
46
|
+
expect(classifyZone(el("i", "img", 0))).toBe("visual");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("zone identity invariant: normalizeToZones preserves each clip's zone (mixed input)", () => {
|
|
50
|
+
// normalizeToZones only remaps lanes — it must never reclassify a clip's zone.
|
|
51
|
+
const input = [
|
|
52
|
+
el("v", "video", 0),
|
|
53
|
+
el("a1", "audio", 1),
|
|
54
|
+
el("i", "img", 2),
|
|
55
|
+
el("a2", "audio", 3),
|
|
56
|
+
];
|
|
57
|
+
const out = normalizeToZones(input);
|
|
58
|
+
for (const e of input) {
|
|
59
|
+
expect(classifyZone(out.find((o) => o.id === e.id)!)).toBe(classifyZone(e));
|
|
60
|
+
}
|
|
61
|
+
// And the partition holds: every visual lane sits above every audio lane.
|
|
62
|
+
const laneOf = (id: string) => trackOf(out, id);
|
|
63
|
+
const maxVisual = Math.max(laneOf("v"), laneOf("i"));
|
|
64
|
+
const minAudio = Math.min(laneOf("a1"), laneOf("a2"));
|
|
65
|
+
expect(maxVisual).toBeLessThan(minAudio);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("normalizeToZones", () => {
|
|
70
|
+
it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => {
|
|
71
|
+
// img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints
|
|
72
|
+
// equal-z siblings by DOM order (later paints on top), so the later-in-array
|
|
73
|
+
// `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order,
|
|
74
|
+
// which contradicts the canvas — updated for per-clip DOM-order tie-break.)
|
|
75
|
+
const out = normalizeToZones([
|
|
76
|
+
el("img", "img", 0),
|
|
77
|
+
el("vid", "video", 2),
|
|
78
|
+
el("mus", "audio", 5),
|
|
79
|
+
]);
|
|
80
|
+
expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane
|
|
81
|
+
expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below
|
|
82
|
+
expect(trackOf(out, "mus")).toBe(2); // audio (bottom)
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => {
|
|
86
|
+
// All three z=0, start=0 → mutually overlapping. DOM order (later on top)
|
|
87
|
+
// decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored
|
|
88
|
+
// order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.)
|
|
89
|
+
const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]);
|
|
90
|
+
expect(trackOf(out, "v3")).toBe(0);
|
|
91
|
+
expect(trackOf(out, "i1")).toBe(1);
|
|
92
|
+
expect(trackOf(out, "v0")).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("drops audio below the visual lanes even when sharing a track index", () => {
|
|
96
|
+
const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]);
|
|
97
|
+
expect(trackOf(out, "v")).toBe(0); // visual
|
|
98
|
+
expect(trackOf(out, "a")).toBe(1); // audio, below
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("groups multiple audio tracks at the bottom preserving relative order", () => {
|
|
102
|
+
const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]);
|
|
103
|
+
expect(trackOf(out, "v")).toBe(0);
|
|
104
|
+
expect(trackOf(out, "a1")).toBe(1);
|
|
105
|
+
expect(trackOf(out, "a2")).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns the same array (identity) when already zoned", () => {
|
|
109
|
+
// A fixed-point layout: the two overlapping equal-z visual clips are already
|
|
110
|
+
// in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so
|
|
111
|
+
// re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1,
|
|
112
|
+
// which the new per-clip DOM-order pack would flip — so it wasn't a fixed
|
|
113
|
+
// point under the corrected canvas semantics; swapped to the stable order.)
|
|
114
|
+
const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)];
|
|
115
|
+
expect(normalizeToZones(input)).toBe(input);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is idempotent (no drift on re-zoning)", () => {
|
|
119
|
+
const input = [
|
|
120
|
+
el("img", "img", 1),
|
|
121
|
+
el("v", "video", 3),
|
|
122
|
+
el("a1", "audio", 2),
|
|
123
|
+
el("a2", "audio", 6),
|
|
124
|
+
];
|
|
125
|
+
expectZoningIdempotent(input);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
|
|
129
|
+
const clip = (id: string, start: number, duration: number): TimelineElement => ({
|
|
130
|
+
id,
|
|
131
|
+
tag: "video",
|
|
132
|
+
start,
|
|
133
|
+
duration,
|
|
134
|
+
track: 1, // all authored on the SAME track, some overlapping in time
|
|
135
|
+
});
|
|
136
|
+
// a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent).
|
|
137
|
+
// Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the
|
|
138
|
+
// top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier
|
|
139
|
+
// in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's
|
|
140
|
+
// lane 0 even though it doesn't overlap c, because that would place a ABOVE b
|
|
141
|
+
// in lane order while a paints below b — the canvas-correct constraint the old
|
|
142
|
+
// whole-track packer ignored (it shared a/c on lane 0, contradicting paint).
|
|
143
|
+
const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
|
|
144
|
+
expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane
|
|
145
|
+
expect(trackOf(out, "b")).toBe(1); // overlaps c → below it
|
|
146
|
+
expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b
|
|
147
|
+
|
|
148
|
+
// No two time-overlapping clips share a lane (the real NLE invariant).
|
|
149
|
+
expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
|
|
150
|
+
expect(trackOf(out, "b")).not.toBe(trackOf(out, "c"));
|
|
151
|
+
|
|
152
|
+
// Idempotent: re-laying the split result changes nothing.
|
|
153
|
+
const twice = normalizeToZones(out);
|
|
154
|
+
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("normalizeToZones — reverse z→lane mapping", () => {
|
|
159
|
+
it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => {
|
|
160
|
+
// lo (z=1) and hi (z=9) fully overlap in time on the same authored track.
|
|
161
|
+
const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]);
|
|
162
|
+
expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top)
|
|
163
|
+
expect(trackOf(out, "lo")).toBe(1); // lower z → below
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("orders three overlapping clips strictly by descending z", () => {
|
|
167
|
+
const out = normalizeToZones([
|
|
168
|
+
zClip("mid", 0, 10, 0, 5),
|
|
169
|
+
zClip("top", 0, 10, 0, 8),
|
|
170
|
+
zClip("bot", 0, 10, 0, 2),
|
|
171
|
+
]);
|
|
172
|
+
expect(trackOf(out, "top")).toBe(0);
|
|
173
|
+
expect(trackOf(out, "mid")).toBe(1);
|
|
174
|
+
expect(trackOf(out, "bot")).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => {
|
|
178
|
+
// a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant.
|
|
179
|
+
const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
|
|
180
|
+
expect(trackOf(out, "a")).toBe(0);
|
|
181
|
+
expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("leaves the audio zone unaffected by z", () => {
|
|
185
|
+
const out = normalizeToZones([
|
|
186
|
+
zClip("v", 0, 10, 0, 1),
|
|
187
|
+
zClip("m1", 0, 10, 1, 99, "audio"),
|
|
188
|
+
zClip("m2", 0, 10, 1, 0, "audio"),
|
|
189
|
+
]);
|
|
190
|
+
// Two overlapping audio clips split onto lanes below the visual clip; their
|
|
191
|
+
// relative z does not lift one above a visual clip.
|
|
192
|
+
expect(trackOf(out, "v")).toBe(0);
|
|
193
|
+
expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v"));
|
|
194
|
+
expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v"));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => {
|
|
198
|
+
const out = normalizeToZones([
|
|
199
|
+
{ id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex
|
|
200
|
+
zClip("pos", 0, 10, 0, 3),
|
|
201
|
+
]);
|
|
202
|
+
expect(trackOf(out, "pos")).toBe(0); // z=3 → upper
|
|
203
|
+
expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => {
|
|
207
|
+
// Equal z + full overlap: order must be deterministic (id asc) and survive
|
|
208
|
+
// re-normalization — the historical oscillation bug tie-broke on the track.
|
|
209
|
+
const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
|
|
210
|
+
expect(trackOf(out, "a")).toBe(0); // "a" < "b"
|
|
211
|
+
expect(trackOf(out, "b")).toBe(1);
|
|
212
|
+
const twice = normalizeToZones(out);
|
|
213
|
+
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => {
|
|
217
|
+
const input = [
|
|
218
|
+
zClip("hi", 0, 10, 0, 9),
|
|
219
|
+
zClip("lo", 0, 10, 0, 1),
|
|
220
|
+
zClip("mid", 2, 6, 0, 5),
|
|
221
|
+
zClip("seq", 12, 4, 0, 7),
|
|
222
|
+
zClip("music", 0, 16, 1, 3, "audio"),
|
|
223
|
+
];
|
|
224
|
+
expectZoningIdempotent(input);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => {
|
|
228
|
+
// Simulate two independent discovery passes producing fresh element objects
|
|
229
|
+
// carrying the same z — lane assignment must be stable across reloads.
|
|
230
|
+
const build = (): TimelineElement[] => [
|
|
231
|
+
zClip("hi", 0, 10, 0, 9),
|
|
232
|
+
zClip("lo", 0, 10, 0, 1),
|
|
233
|
+
zClip("mid", 3, 5, 0, 5),
|
|
234
|
+
];
|
|
235
|
+
const first = normalizeToZones(build());
|
|
236
|
+
const second = normalizeToZones(build());
|
|
237
|
+
for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => {
|
|
242
|
+
// Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0
|
|
243
|
+
// (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and
|
|
244
|
+
// background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of
|
|
245
|
+
// the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes.
|
|
246
|
+
const realProject = (): TimelineElement[] => [
|
|
247
|
+
zClip("ralu", 6.14, 3, 3, 5, "img"),
|
|
248
|
+
zClip("video", 1, 20, 0, 0, "video"),
|
|
249
|
+
zClip("blueLogo", 5.93, 3, 1, 26, "img"),
|
|
250
|
+
zClip("blackLogo", 1, 3, 1, 0, "img"),
|
|
251
|
+
zClip("music", 8.93, 8, 2, 0, "audio"),
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => {
|
|
255
|
+
const out = normalizeToZones(realProject());
|
|
256
|
+
// Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0).
|
|
257
|
+
expect(trackOf(out, "blueLogo")).toBe(0);
|
|
258
|
+
expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane
|
|
259
|
+
expect(trackOf(out, "ralu")).toBe(1);
|
|
260
|
+
expect(trackOf(out, "video")).toBe(2);
|
|
261
|
+
// Audio stays at the very bottom regardless of its authored track index.
|
|
262
|
+
expect(trackOf(out, "music")).toBe(3);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => {
|
|
266
|
+
const out = normalizeToZones(realProject());
|
|
267
|
+
expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo"));
|
|
268
|
+
expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu"));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => {
|
|
272
|
+
const once = normalizeToZones(realProject());
|
|
273
|
+
const twice = normalizeToZones(once);
|
|
274
|
+
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => {
|
|
278
|
+
const first = normalizeToZones(realProject());
|
|
279
|
+
const second = normalizeToZones(realProject());
|
|
280
|
+
for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => {
|
|
284
|
+
// Was: "keeps ascending authored track order when all tracks share z" — that
|
|
285
|
+
// pinned t0=0/t1=1/t3=2 to the authored track index. But these three all
|
|
286
|
+
// start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last)
|
|
287
|
+
// on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0),
|
|
288
|
+
// which is what the canvas actually shows. Lanes stay contiguous 0..2.
|
|
289
|
+
const out = normalizeToZones([
|
|
290
|
+
zClip("t0", 0, 2, 0, 0),
|
|
291
|
+
zClip("t1", 0, 2, 1, 0),
|
|
292
|
+
zClip("t3", 0, 2, 3, 0),
|
|
293
|
+
]);
|
|
294
|
+
expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top
|
|
295
|
+
expect(trackOf(out, "t1")).toBe(1);
|
|
296
|
+
expect(trackOf(out, "t0")).toBe(2);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => {
|
|
301
|
+
// The live repro from /tmp/hf-dnd-qa/qa-clean/index.html:
|
|
302
|
+
// blue-logo authored track 0, z=3, 6.37–9.37
|
|
303
|
+
// ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo)
|
|
304
|
+
// black-logo authored track 1, z=1, 11.92–14.92
|
|
305
|
+
// video authored track 3, z=2, 0.84–20.84
|
|
306
|
+
// Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer
|
|
307
|
+
// ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the
|
|
308
|
+
// z=2 video — the timeline↔canvas contradiction. Array order below = DOM order.
|
|
309
|
+
|
|
310
|
+
it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => {
|
|
311
|
+
const out = normalizeToZones(qaCleanRepro());
|
|
312
|
+
expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top
|
|
313
|
+
expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it
|
|
314
|
+
expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84)
|
|
315
|
+
expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => {
|
|
319
|
+
const out = normalizeToZones(qaCleanRepro());
|
|
320
|
+
// ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW
|
|
321
|
+
// the video (z=2) that overlaps and paints over it — the whole-track bug.
|
|
322
|
+
expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video"));
|
|
323
|
+
// black-logo (z=1) below video (z=2) because they overlap in time.
|
|
324
|
+
expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video"));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("FIXED POINT: running the NEW pack on its own output changes nothing", () => {
|
|
328
|
+
const once = normalizeToZones(qaCleanRepro());
|
|
329
|
+
const twice = normalizeToZones(once);
|
|
330
|
+
for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
331
|
+
// And a third pass, to be sure convergence is genuine.
|
|
332
|
+
const thrice = normalizeToZones(twice);
|
|
333
|
+
for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("z ↔ lane round-trip convergence (both directions agree)", () => {
|
|
338
|
+
// Project a normalized TimelineElement onto the StackingElement view the
|
|
339
|
+
// forward (lane→z) mapping reasons over.
|
|
340
|
+
const toStacking = (els: TimelineElement[]): StackingElement[] =>
|
|
341
|
+
els.map((e, domIndex) => ({
|
|
342
|
+
key: e.key ?? e.id,
|
|
343
|
+
start: e.start,
|
|
344
|
+
duration: e.duration,
|
|
345
|
+
track: e.track,
|
|
346
|
+
zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0,
|
|
347
|
+
isAudio: classifyZone(e) === "audio",
|
|
348
|
+
domIndex,
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => {
|
|
352
|
+
// Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5).
|
|
353
|
+
const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)];
|
|
354
|
+
const normalized = normalizeToZones(authored);
|
|
355
|
+
// z→lane placed b (z=5) on the upper lane 0, a on lane 1.
|
|
356
|
+
expect(trackOf(normalized, "b")).toBe(0);
|
|
357
|
+
expect(trackOf(normalized, "a")).toBe(1);
|
|
358
|
+
|
|
359
|
+
// USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1).
|
|
360
|
+
const afterMove = normalized.map((e) =>
|
|
361
|
+
e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// FORWARD: a lane-move writes the minimal z patch for the edited clip.
|
|
365
|
+
const patches = computeStackingPatches(toStacking(afterMove), ["a"]);
|
|
366
|
+
expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5)
|
|
367
|
+
|
|
368
|
+
// Apply the z patch back onto the elements (what handleDomZIndexReorderCommit
|
|
369
|
+
// persists; next discovery re-reads it as TimelineElement.zIndex).
|
|
370
|
+
const rediscovered = afterMove.map((e) => {
|
|
371
|
+
const p = patches.find((pp) => pp.key === (e.key ?? e.id));
|
|
372
|
+
return p ? { ...e, zIndex: p.zIndex } : e;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane —
|
|
376
|
+
// the same lane the user moved it to. Directions converge, they do not fight.
|
|
377
|
+
const renormalized = normalizeToZones(rediscovered);
|
|
378
|
+
expect(trackOf(renormalized, "a")).toBe(0);
|
|
379
|
+
expect(trackOf(renormalized, "b")).toBe(1);
|
|
380
|
+
|
|
381
|
+
// FIXED POINT: forward on the converged state produces NO further patch, and
|
|
382
|
+
// reverse is idempotent — the round-trip is stable.
|
|
383
|
+
expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]);
|
|
384
|
+
const twice = normalizeToZones(renormalized);
|
|
385
|
+
for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => {
|
|
389
|
+
// EXACT repro fixture (array order = DOM order).
|
|
390
|
+
const normalized = normalizeToZones(qaCleanRepro());
|
|
391
|
+
// Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3.
|
|
392
|
+
expect(trackOf(normalized, "video")).toBe(1);
|
|
393
|
+
expect(trackOf(normalized, "ralu")).toBe(3);
|
|
394
|
+
|
|
395
|
+
// USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at
|
|
396
|
+
// lane 3, so video goes to a lane strictly greater — model it as lane 4.
|
|
397
|
+
const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e));
|
|
398
|
+
|
|
399
|
+
// FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits
|
|
400
|
+
// strictly below 0, so the tie-aware sync cascades: video→0 and the clips that
|
|
401
|
+
// must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as
|
|
402
|
+
// needed so video paints below ralu with all z ≥ 0.
|
|
403
|
+
const patches = computeStackingPatches(toStacking(afterMove), ["video"]);
|
|
404
|
+
const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex]));
|
|
405
|
+
// Video was moved; it must now be strictly below ralu in paint order.
|
|
406
|
+
const zAfter = (id: string): number =>
|
|
407
|
+
patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number);
|
|
408
|
+
expect(zAfter("video")).toBeLessThan(zAfter("ralu"));
|
|
409
|
+
expect(zAfter("video")).toBeGreaterThanOrEqual(0);
|
|
410
|
+
expect(patchByKey.size).toBeGreaterThan(0);
|
|
411
|
+
|
|
412
|
+
// Apply patches and re-pack: video's lane must now be BELOW ralu's.
|
|
413
|
+
const rediscovered = afterMove.map((e) => {
|
|
414
|
+
const z = patchByKey.get(e.id);
|
|
415
|
+
return z != null ? { ...e, zIndex: z } : e;
|
|
416
|
+
});
|
|
417
|
+
const renormalized = normalizeToZones(rediscovered);
|
|
418
|
+
expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu"));
|
|
419
|
+
|
|
420
|
+
// FIXED POINT: re-running BOTH directions on the converged state is a no-op.
|
|
421
|
+
expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]);
|
|
422
|
+
const twice = normalizeToZones(renormalized);
|
|
423
|
+
for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
|
|
6
|
+
* — layering is CSS z-index (the renderer ignores track index), so the timeline's
|
|
7
|
+
* only job is to keep visual clips grouped above audio clips.
|
|
8
|
+
*/
|
|
9
|
+
export type TrackZone = "visual" | "audio";
|
|
10
|
+
|
|
11
|
+
/** Which zone a clip belongs to: audio elements sink to the bottom, everything
|
|
12
|
+
* else (video / image / text / sub-comp) is a visual lane on top. */
|
|
13
|
+
export function classifyZone(el: TimelineElement): TrackZone {
|
|
14
|
+
return isAudioTimelineElement(el) ? "audio" : "visual";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
18
|
+
|
|
19
|
+
/** Stacking order for a clip: missing / "auto" z is treated as 0. */
|
|
20
|
+
const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
|
|
21
|
+
|
|
22
|
+
const EPS = 1e-6;
|
|
23
|
+
|
|
24
|
+
/** Two clips overlap when their half-open [start, end) intervals intersect. */
|
|
25
|
+
function overlaps(a: TimelineElement, b: TimelineElement): boolean {
|
|
26
|
+
return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A clip paired with its position in the discovery/document (input) order. */
|
|
30
|
+
interface IndexedClip {
|
|
31
|
+
el: TimelineElement;
|
|
32
|
+
/** Index in the input `elements` array = discovery/DOM order. */
|
|
33
|
+
domIndex: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One display lane: the clips packed onto it, in placement order. */
|
|
37
|
+
interface Lane {
|
|
38
|
+
occupants: IndexedClip[];
|
|
39
|
+
/** The single authored track all occupants share, or null once mixed (never
|
|
40
|
+
* happens — we only ever add same-track clips to an existing lane). */
|
|
41
|
+
track: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lowest lane index a clip may occupy: strictly above every already-placed lane
|
|
46
|
+
* holding a clip it overlaps in time (all of which out-stack it by the z-desc
|
|
47
|
+
* placement order).
|
|
48
|
+
*/
|
|
49
|
+
function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
|
|
50
|
+
let minLane = 0;
|
|
51
|
+
for (let i = 0; i < lanes.length; i++) {
|
|
52
|
+
if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
|
|
53
|
+
}
|
|
54
|
+
return minLane;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* First lane at index ≥ minLane that holds solely this clip's authored track and
|
|
59
|
+
* nothing overlapping (so sequential same-track clips share a lane); -1 when none
|
|
60
|
+
* qualifies and a fresh lane must open.
|
|
61
|
+
*/
|
|
62
|
+
function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
|
|
63
|
+
for (let i = minLane; i < lanes.length; i++) {
|
|
64
|
+
const lane = lanes[i];
|
|
65
|
+
if (lane.track !== item.el.track) continue;
|
|
66
|
+
if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
|
|
67
|
+
return i;
|
|
68
|
+
}
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
|
|
74
|
+
* that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
|
|
75
|
+
* equals canvas stacking order. This replaces the old two-stage
|
|
76
|
+
* `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
|
|
77
|
+
* tracks by their MAX z and so lifted a low-z clip above a clip that covers it
|
|
78
|
+
* whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
|
|
79
|
+
* low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
|
|
80
|
+
* whole-track mapping can fix that; the mapping must be per-clip.
|
|
81
|
+
*
|
|
82
|
+
* Algorithm:
|
|
83
|
+
* 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS
|
|
84
|
+
* paints equal-z siblings by DOM order — LATER in DOM paints on top, so it
|
|
85
|
+
* must place first / upper); final tie → stable key. NEVER tie-break on the
|
|
86
|
+
* mutated lane/track index (historic oscillation bug — the tie-break must be
|
|
87
|
+
* a stable function of the input, not of the output being computed).
|
|
88
|
+
* 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
|
|
89
|
+
* OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
|
|
90
|
+
* clip out-stacks this one (higher z, or equal z but later in DOM), so this
|
|
91
|
+
* guarantees lane order == stacking order for every overlapping pair.
|
|
92
|
+
* 3. To preserve the "distinct authored tracks stay distinct / sequential
|
|
93
|
+
* same-track clips share a lane" feel, reuse an existing lane at index ≥ that
|
|
94
|
+
* minimum ONLY when the lane's occupants are all from the SAME authored track
|
|
95
|
+
* AND none overlaps this clip in time; otherwise open a fresh lane.
|
|
96
|
+
*
|
|
97
|
+
* Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
|
|
98
|
+
* returns the number of lanes used (≥ 1 when non-empty).
|
|
99
|
+
*/
|
|
100
|
+
function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
|
|
101
|
+
const ordered = [...clips].sort(
|
|
102
|
+
(a, b) =>
|
|
103
|
+
zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
|
|
104
|
+
);
|
|
105
|
+
const lanes: Lane[] = [];
|
|
106
|
+
for (const item of ordered) {
|
|
107
|
+
const minLane = lowestAllowedLane(lanes, item);
|
|
108
|
+
let placed = findReusableLane(lanes, minLane, item);
|
|
109
|
+
if (placed === -1) {
|
|
110
|
+
placed = lanes.length;
|
|
111
|
+
lanes.push({ occupants: [], track: item.el.track });
|
|
112
|
+
}
|
|
113
|
+
lanes[placed].occupants.push(item);
|
|
114
|
+
laneOf.set(keyOf(item.el), base + placed);
|
|
115
|
+
}
|
|
116
|
+
return lanes.length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
|
|
121
|
+
* authored track's clips onto sub-lanes so no two overlap in time — sequential
|
|
122
|
+
* clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
|
|
123
|
+
* start (then stable key) so the layout is deterministic and idempotent. Returns
|
|
124
|
+
* the number of lanes used (≥ 1 when non-empty).
|
|
125
|
+
*/
|
|
126
|
+
function packAudioTrackLanes(
|
|
127
|
+
clips: IndexedClip[],
|
|
128
|
+
base: number,
|
|
129
|
+
laneOf: Map<string, number>,
|
|
130
|
+
): number {
|
|
131
|
+
const ordered = [...clips].sort(
|
|
132
|
+
(a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
|
|
133
|
+
);
|
|
134
|
+
const lanes: IndexedClip[][] = [];
|
|
135
|
+
for (const item of ordered) {
|
|
136
|
+
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
|
|
137
|
+
if (sub === -1) {
|
|
138
|
+
sub = lanes.length;
|
|
139
|
+
lanes.push([]);
|
|
140
|
+
}
|
|
141
|
+
lanes[sub].push(item);
|
|
142
|
+
laneOf.set(keyOf(item.el), base + sub);
|
|
143
|
+
}
|
|
144
|
+
return Math.max(1, lanes.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Assign display lanes for the timeline: visual lanes on top, audio lanes below.
|
|
149
|
+
*
|
|
150
|
+
* The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
|
|
151
|
+
* order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
|
|
152
|
+
* low-z clip sinks below a clip that covers it even if it shares an authored track
|
|
153
|
+
* with a higher-z clip. Time-overlapping clips still split onto separate lanes
|
|
154
|
+
* (standard NLE), sequential same-track clips still share a lane, and distinct
|
|
155
|
+
* authored tracks stay distinct.
|
|
156
|
+
*
|
|
157
|
+
* The AUDIO zone keeps the original behavior — authored-track order, per-track
|
|
158
|
+
* interval packing — because audio has no z / stacking semantics.
|
|
159
|
+
*
|
|
160
|
+
* Pure — returns a new array; unchanged clips keep their identity. Display-only
|
|
161
|
+
* (runs on discovery); it does not rewrite the source. Idempotent (running it on
|
|
162
|
+
* its own output is a fixed point).
|
|
163
|
+
*/
|
|
164
|
+
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
|
|
165
|
+
if (elements.length === 0) return elements;
|
|
166
|
+
|
|
167
|
+
const laneOf = new Map<string, number>();
|
|
168
|
+
let nextLane = 0;
|
|
169
|
+
|
|
170
|
+
const visual: IndexedClip[] = [];
|
|
171
|
+
const audio: IndexedClip[] = [];
|
|
172
|
+
elements.forEach((el, domIndex) => {
|
|
173
|
+
(classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
nextLane += packZoneLanes(visual, nextLane, laneOf);
|
|
177
|
+
|
|
178
|
+
// Audio: preserve legacy behavior — group by authored track (ascending), pack
|
|
179
|
+
// each track's overlapping clips onto sub-lanes.
|
|
180
|
+
const audioByTrack = new Map<number, IndexedClip[]>();
|
|
181
|
+
for (const item of audio) {
|
|
182
|
+
const list = audioByTrack.get(item.el.track);
|
|
183
|
+
if (list) list.push(item);
|
|
184
|
+
else audioByTrack.set(item.el.track, [item]);
|
|
185
|
+
}
|
|
186
|
+
for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
|
|
187
|
+
nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let changed = false;
|
|
191
|
+
const remapped = elements.map((el) => {
|
|
192
|
+
const lane = laneOf.get(keyOf(el));
|
|
193
|
+
if (lane == null || lane === el.track) return el;
|
|
194
|
+
changed = true;
|
|
195
|
+
return { ...el, track: lane };
|
|
196
|
+
});
|
|
197
|
+
return changed ? remapped : elements;
|
|
198
|
+
}
|