@hyperframes/studio 0.7.56 → 0.7.57

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.
Files changed (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import { classifyZone, normalizeToZones } from "./timelineZones";
4
- import { computeStackingPatches, type StackingElement } from "./timelineStackingSync";
5
4
 
6
5
  function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
7
6
  return { id, tag, start: 0, duration, track };
@@ -29,16 +28,6 @@ function expectZoningIdempotent(input: TimelineElement[]): void {
29
28
  for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
30
29
  }
31
30
 
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
31
  describe("classifyZone", () => {
43
32
  it("audio → audio; video / image / everything else → visual", () => {
44
33
  expect(classifyZone(el("m", "audio", 3))).toBe("audio");
@@ -66,52 +55,77 @@ describe("classifyZone", () => {
66
55
  });
67
56
  });
68
57
 
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.)
58
+ describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
59
+ it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
60
+ // img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
61
+ // index: the LOWER visual track owns the upper lane. z is irrelevant (absent).
75
62
  const out = normalizeToZones([
76
63
  el("img", "img", 0),
77
64
  el("vid", "video", 2),
78
65
  el("mus", "audio", 5),
79
66
  ]);
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)
67
+ expect(trackOf(out, "img")).toBe(0); // track 0 → top lane
68
+ expect(trackOf(out, "vid")).toBe(1); // track 2 → below it
69
+ expect(trackOf(out, "mus")).toBe(2); // audio bottom
83
70
  });
84
71
 
85
- it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => {
86
- // All three z=0, start=0mutually 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);
72
+ it("compacts sparse visual track-indexes to contiguous lanes, preserving ascending order", () => {
73
+ // Distinct authored tracks 0, 3, 7 three adjacent lanes in the same order.
74
+ const out = normalizeToZones([el("a", "video", 7), el("b", "img", 0), el("c", "video", 3)]);
75
+ expect(trackOf(out, "b")).toBe(0); // track 0
76
+ expect(trackOf(out, "c")).toBe(1); // track 3
77
+ expect(trackOf(out, "a")).toBe(2); // track 7
78
+ });
79
+
80
+ it("drops audio below the visual lanes even when it holds a LOWER authored index", () => {
81
+ // Audio authored at track 0, video at track 5 — audio must still sink below.
82
+ const out = normalizeToZones([zClip("a", 0, 10, 0, 0, "audio"), zClip("v", 0, 10, 5, 0)]);
83
+ expect(trackOf(out, "v")).toBe(0); // visual on top
84
+ expect(trackOf(out, "a")).toBe(1); // audio below, despite its lower track index
93
85
  });
94
86
 
95
87
  it("drops audio below the visual lanes even when sharing a track index", () => {
96
88
  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
89
+ expect(trackOf(out, "v")).toBe(0);
90
+ expect(trackOf(out, "a")).toBe(1);
99
91
  });
100
92
 
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)]);
93
+ it("groups multiple audio tracks at the bottom, ordered by their track index", () => {
94
+ const out = normalizeToZones([el("v", "video", 0), el("a2", "audio", 4), el("a1", "audio", 1)]);
103
95
  expect(trackOf(out, "v")).toBe(0);
104
- expect(trackOf(out, "a1")).toBe(1);
96
+ expect(trackOf(out, "a1")).toBe(1); // audio track 1 above audio track 4
105
97
  expect(trackOf(out, "a2")).toBe(2);
106
98
  });
107
99
 
100
+ it("ignores z-index entirely — a high-z clip does NOT jump above a lower-track clip", () => {
101
+ // lo on track 0 with z=1; hi on track 1 with z=99. They fully overlap in time.
102
+ // The old z-rank pack lifted hi above lo; the CapCut rule keeps lane = track.
103
+ const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 1, 99)]);
104
+ expect(trackOf(out, "lo")).toBe(0); // track 0 stays on top
105
+ expect(trackOf(out, "hi")).toBe(1); // higher z does NOT lift it
106
+ });
107
+
108
+ it("ignores z-index across authored tracks (scattered z, lanes still by track)", () => {
109
+ const out = normalizeToZones([
110
+ zClip("t0", 0, 10, 0, 3),
111
+ zClip("t1", 0, 10, 1, 26),
112
+ zClip("t2", 0, 10, 2, 0),
113
+ ]);
114
+ expect(trackOf(out, "t0")).toBe(0);
115
+ expect(trackOf(out, "t1")).toBe(1);
116
+ expect(trackOf(out, "t2")).toBe(2);
117
+ });
118
+
119
+ it("sequential (non-overlapping) same-track clips share a lane", () => {
120
+ const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
121
+ expect(trackOf(out, "a")).toBe(0);
122
+ expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of z
123
+ });
124
+
108
125
  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)];
126
+ // i on track 0, v on track 1, a (audio) on track 2 — already contiguous, visual
127
+ // above audio, so re-zoning is a no-op and the SAME reference comes back.
128
+ const input = [el("i", "img", 0), el("v", "video", 1), el("a", "audio", 2)];
115
129
  expect(normalizeToZones(input)).toBe(input);
116
130
  });
117
131
 
@@ -125,301 +139,99 @@ describe("normalizeToZones", () => {
125
139
  expectZoningIdempotent(input);
126
140
  });
127
141
 
128
- it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
142
+ it("re-derives identical lanes from fresh objects carrying the same tracks (reload-stable)", () => {
143
+ const build = (): TimelineElement[] => [
144
+ zClip("hi", 0, 10, 0, 9),
145
+ zClip("lo", 0, 10, 1, 1),
146
+ zClip("mid", 3, 5, 2, 5),
147
+ ];
148
+ const first = normalizeToZones(build());
149
+ const second = normalizeToZones(build());
150
+ for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
151
+ });
152
+ });
153
+
154
+ describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => {
155
+ it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => {
156
+ // a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1.
157
+ // The editor forbids per-track overlap, but a legacy file can carry it; the
158
+ // spill orders by stable id (a, b, c) and first-fits: a→lane0, b overlaps a→
159
+ // lane1, c fits back on lane0 (no overlap with a).
129
160
  const clip = (id: string, start: number, duration: number): TimelineElement => ({
130
161
  id,
131
162
  tag: "video",
132
163
  start,
133
164
  duration,
134
- track: 1, // all authored on the SAME track, some overlapping in time
165
+ track: 1,
135
166
  });
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
167
  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 cbelow 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).
168
+ expect(trackOf(out, "a")).toBe(0);
169
+ expect(trackOf(out, "b")).toBe(1); // overlaps aadjacent sub-lane
170
+ expect(trackOf(out, "c")).toBe(0); // sequential to a shares lane 0
171
+ // No two time-overlapping clips share a lane.
149
172
  expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
150
- expect(trackOf(out, "b")).not.toBe(trackOf(out, "c"));
151
-
152
173
  // Idempotent: re-laying the split result changes nothing.
153
174
  const twice = normalizeToZones(out);
154
175
  for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
155
176
  });
156
- });
157
177
 
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.
178
+ it("spills two fully-overlapping same-track clips by stable id (a above b)", () => {
209
179
  const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
210
180
  expect(trackOf(out, "a")).toBe(0); // "a" < "b"
211
181
  expect(trackOf(out, "b")).toBe(1);
182
+ // Survives re-normalization (stable id tie-break, never the mutated lane).
212
183
  const twice = normalizeToZones(out);
213
184
  for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
214
185
  });
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
186
  });
240
187
 
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"),
188
+ describe("normalizeToZones — legacy file with scattered z (requirement 6)", () => {
189
+ // Mirrors /tmp/hf-fixwave/userproj/index.html: many visual clips on contiguous
190
+ // authored tracks (0..17) each carrying an unrelated, scattered inline z-index,
191
+ // and audio on the highest tracks (18..). The display must follow the
192
+ // track-index, NOT the z, and a well-formed (contiguous, audio-last) legacy file
193
+ // must not be re-laned at all — normalize is the identity.
194
+ const legacy = (): TimelineElement[] => [
195
+ zClip("sub-0", 3, 1.15, 0, 0, "div"), // no explicit z (0)
196
+ zClip("cap-hit", 4.51, 1.73, 3, 26, "div"), // scattered z
197
+ zClip("cap-send", 5.85, 1.27, 4, 25, "div"),
198
+ zClip("avatar", 6.4, 1.148, 1, 26),
199
+ zClip("v-opener", 0, 3, 15, 12),
200
+ zClip("v-letters", 30.08, 4.39, 5, 25),
201
+ zClip("music", 3, 42.95, 18, 10, "audio"),
202
+ zClip("vo", 3.2, 10.3, 19, 4, "audio"),
252
203
  ];
253
204
 
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);
205
+ it("lanes follow the track-index, not the scattered z", () => {
206
+ const out = normalizeToZones(legacy());
207
+ // Visual tracks 0,1,3,4,5,15 compact to lanes 0..5 in ascending track order
208
+ // z (0,26,25,26,12,25) is ignored.
209
+ expect(trackOf(out, "sub-0")).toBe(0); // track 0
210
+ expect(trackOf(out, "avatar")).toBe(1); // track 1
211
+ expect(trackOf(out, "cap-hit")).toBe(2); // track 3
212
+ expect(trackOf(out, "cap-send")).toBe(3); // track 4
213
+ expect(trackOf(out, "v-letters")).toBe(4); // track 5
214
+ expect(trackOf(out, "v-opener")).toBe(5); // track 15
215
+ // Audio stays below every visual lane.
216
+ expect(trackOf(out, "music")).toBe(6);
217
+ expect(trackOf(out, "vo")).toBe(7);
218
+ // The z=26 caption does NOT ride above the z=0 subtitle on track 0.
219
+ expect(trackOf(out, "cap-hit")).toBeGreaterThan(trackOf(out, "sub-0"));
220
+ });
221
+
222
+ it("does not rewrite a well-formed (contiguous, audio-last) legacy set — identity", () => {
223
+ // Same shape but authored tracks already contiguous 0..7 with audio last.
224
+ const input = [
225
+ zClip("a", 0, 3, 0, 12),
226
+ zClip("b", 0, 3, 1, 26),
227
+ zClip("c", 0, 3, 2, 3),
228
+ zClip("m", 0, 3, 3, 9, "audio"),
229
+ ];
230
+ // Every clip already sits on its track-index lane, so normalize is a no-op.
231
+ expect(normalizeToZones(input)).toBe(input);
386
232
  });
387
233
 
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);
234
+ it("is idempotent on the scattered-z legacy shape (no drift on re-discovery)", () => {
235
+ expectZoningIdempotent(legacy());
424
236
  });
425
237
  });