@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
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ computeRevealScroll,
4
+ REVEAL_SCROLL_PADDING_PX,
5
+ type RevealScrollInput,
6
+ } from "./timelineRevealScroll";
7
+
8
+ /** A 1000×400 viewport with a 32px sticky gutter and 24px sticky ruler. */
9
+ function makeInput(overrides: Partial<RevealScrollInput> = {}): RevealScrollInput {
10
+ return {
11
+ scrollLeft: 0,
12
+ scrollTop: 0,
13
+ viewportWidth: 1000,
14
+ viewportHeight: 400,
15
+ clipLeft: 100,
16
+ clipRight: 200,
17
+ clipTop: 100,
18
+ clipBottom: 148,
19
+ stickyLeft: 32,
20
+ stickyTop: 24,
21
+ allowHorizontal: true,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("computeRevealScroll", () => {
27
+ it("returns null on both axes when the clip is fully visible", () => {
28
+ expect(computeRevealScroll(makeInput())).toEqual({ left: null, top: null });
29
+ });
30
+
31
+ it("scrolls right minimally when the clip end is past the right edge", () => {
32
+ const result = computeRevealScroll(makeInput({ clipLeft: 1500, clipRight: 1600 }));
33
+ // Clip end lands padding px inside the right edge.
34
+ expect(result.left).toBe(1600 - 1000 + REVEAL_SCROLL_PADDING_PX);
35
+ expect(result.top).toBeNull();
36
+ });
37
+
38
+ it("scrolls left when the clip start is hidden (including under the sticky gutter)", () => {
39
+ const result = computeRevealScroll(
40
+ makeInput({ scrollLeft: 500, clipLeft: 510, clipRight: 610 }),
41
+ );
42
+ // clipLeft 510 sits under the 32px sticky gutter (window starts at 500+32+pad).
43
+ expect(result.left).toBe(510 - 32 - REVEAL_SCROLL_PADDING_PX);
44
+ });
45
+
46
+ it("aligns the start edge when the clip is wider than the viewport", () => {
47
+ const result = computeRevealScroll(makeInput({ clipLeft: 2000, clipRight: 4000 }));
48
+ expect(result.left).toBe(2000 - 32 - REVEAL_SCROLL_PADDING_PX);
49
+ });
50
+
51
+ it("never returns a negative scroll target", () => {
52
+ const result = computeRevealScroll(
53
+ makeInput({
54
+ scrollLeft: 300,
55
+ clipLeft: 10,
56
+ clipRight: 60,
57
+ scrollTop: 200,
58
+ clipTop: 4,
59
+ clipBottom: 20,
60
+ }),
61
+ );
62
+ expect(result.left).toBe(0);
63
+ expect(result.top).toBe(0);
64
+ });
65
+
66
+ it("suppresses horizontal scroll when allowHorizontal is false (fit zoom)", () => {
67
+ const result = computeRevealScroll(
68
+ makeInput({
69
+ allowHorizontal: false,
70
+ clipLeft: 1500,
71
+ clipRight: 1600,
72
+ clipTop: 700,
73
+ clipBottom: 748,
74
+ }),
75
+ );
76
+ expect(result.left).toBeNull();
77
+ // Vertical reveal still happens.
78
+ expect(result.top).toBe(748 - 400 + REVEAL_SCROLL_PADDING_PX);
79
+ });
80
+
81
+ it("scrolls up when the clip lane is hidden under the sticky ruler", () => {
82
+ const result = computeRevealScroll(
83
+ makeInput({ scrollTop: 200, clipTop: 210, clipBottom: 258 }),
84
+ );
85
+ // clipTop 210 sits under the 24px sticky ruler (window starts at 200+24+pad).
86
+ expect(result.top).toBe(210 - 24 - REVEAL_SCROLL_PADDING_PX);
87
+ });
88
+
89
+ it("scrolls down minimally when the clip lane is below the viewport", () => {
90
+ const result = computeRevealScroll(makeInput({ clipTop: 500, clipBottom: 548 }));
91
+ expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
92
+ expect(result.left).toBeNull();
93
+ });
94
+
95
+ it("never scrolls on an axis whose visible window is degenerate", () => {
96
+ // Horizontal window collapses: 40px viewport minus the 32px gutter and
97
+ // 2x12px padding is negative; vertical is intact and still reveals.
98
+ const result = computeRevealScroll(
99
+ makeInput({
100
+ viewportWidth: 40,
101
+ clipLeft: 1500,
102
+ clipRight: 1600,
103
+ clipTop: 500,
104
+ clipBottom: 548,
105
+ }),
106
+ );
107
+ expect(result.left).toBeNull();
108
+ expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
109
+
110
+ // Exactly-zero window (viewport == sticky + 2x padding) is degenerate too.
111
+ const zero = computeRevealScroll(
112
+ makeInput({
113
+ viewportWidth: 32 + 2 * REVEAL_SCROLL_PADDING_PX,
114
+ clipLeft: 1500,
115
+ clipRight: 1600,
116
+ }),
117
+ );
118
+ expect(zero.left).toBeNull();
119
+
120
+ // Both axes degenerate: no scroll at all.
121
+ const both = computeRevealScroll(
122
+ makeInput({
123
+ viewportWidth: 10,
124
+ viewportHeight: 10,
125
+ clipLeft: 1500,
126
+ clipRight: 1600,
127
+ clipTop: 500,
128
+ clipBottom: 548,
129
+ }),
130
+ );
131
+ expect(both).toEqual({ left: null, top: null });
132
+ });
133
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Pure scroll-target math for revealing a timeline clip inside the timeline's
3
+ * scroll container (the overflow div in Timeline.tsx).
4
+ *
5
+ * Coordinates are content-space: a clip edge measured from the scroll
6
+ * container's content origin (rect delta + current scroll offset). The visible
7
+ * window on each axis is reduced by the sticky chrome that occludes it — the
8
+ * track gutter on the left (GUTTER) and the ruler on top (RULER_H) — so a clip
9
+ * "hidden" under the sticky gutter still counts as off-screen.
10
+ *
11
+ * Scrolls minimally: an axis already fully visible returns null for that axis;
12
+ * otherwise the nearest edge is brought just inside the window (plus padding).
13
+ * A clip larger than the window aligns its start edge. Pure — unit-tested.
14
+ */
15
+
16
+ export interface RevealScrollInput {
17
+ scrollLeft: number;
18
+ scrollTop: number;
19
+ /** Scroll container clientWidth / clientHeight. */
20
+ viewportWidth: number;
21
+ viewportHeight: number;
22
+ /** Clip bounds in content-space (relative to the scroll content origin). */
23
+ clipLeft: number;
24
+ clipRight: number;
25
+ clipTop: number;
26
+ clipBottom: number;
27
+ /** Width of the sticky left gutter occluding the viewport's left edge. */
28
+ stickyLeft: number;
29
+ /** Height of the sticky ruler occluding the viewport's top edge. */
30
+ stickyTop: number;
31
+ /** False in "fit" zoom mode, where horizontal scrolling is disabled. */
32
+ allowHorizontal: boolean;
33
+ }
34
+
35
+ export interface RevealScrollTarget {
36
+ /** Target scrollLeft, or null when the horizontal axis needs no scroll. */
37
+ left: number | null;
38
+ /** Target scrollTop, or null when the vertical axis needs no scroll. */
39
+ top: number | null;
40
+ }
41
+
42
+ /** Breathing room between the revealed clip edge and the window edge. */
43
+ export const REVEAL_SCROLL_PADDING_PX = 12;
44
+
45
+ /**
46
+ * Minimal scroll on one axis to bring [start, end] inside the visible window
47
+ * [scroll + stickyStart, scroll + viewport], with padding. Returns null when
48
+ * the range is already fully visible.
49
+ */
50
+ function revealAxis(
51
+ scroll: number,
52
+ viewport: number,
53
+ stickyStart: number,
54
+ start: number,
55
+ end: number,
56
+ ): number | null {
57
+ const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
58
+ const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
59
+ const windowSize = windowEnd - windowStart;
60
+ // Degenerate viewport (container smaller than the sticky chrome + padding):
61
+ // there is no visible window to reveal into, so never scroll on this axis.
62
+ if (windowSize <= 0) return null;
63
+ if (start >= windowStart && end <= windowEnd) return null;
64
+ // Oversized range (or start hidden): align the start edge to the window start.
65
+ if (end - start > windowSize || start < windowStart) {
66
+ return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
67
+ }
68
+ // Only the end is clipped: pull it just inside the window's far edge.
69
+ return Math.max(0, end - viewport + REVEAL_SCROLL_PADDING_PX);
70
+ }
71
+
72
+ export function computeRevealScroll(input: RevealScrollInput): RevealScrollTarget {
73
+ return {
74
+ left: input.allowHorizontal
75
+ ? revealAxis(
76
+ input.scrollLeft,
77
+ input.viewportWidth,
78
+ input.stickyLeft,
79
+ input.clipLeft,
80
+ input.clipRight,
81
+ )
82
+ : null,
83
+ top: revealAxis(
84
+ input.scrollTop,
85
+ input.viewportHeight,
86
+ input.stickyTop,
87
+ input.clipTop,
88
+ input.clipBottom,
89
+ ),
90
+ };
91
+ }
@@ -19,6 +19,66 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
19
19
  return out;
20
20
  }
21
21
 
22
+ describe("stacking-context partitioning", () => {
23
+ it("never compares or patches across stacking contexts", () => {
24
+ // X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
25
+ // with a lower leaf z, overlapping in time. Their leaf z values are NOT
26
+ // comparable (the ancestors' z decides paint order), so moving X's lane above
27
+ // Y must not reason on Y or patch either based on the 10-vs-5 comparison.
28
+ const x: StackingElement = {
29
+ key: "x",
30
+ track: 0,
31
+ start: 0,
32
+ duration: 5,
33
+ zIndex: 10,
34
+ isAudio: false,
35
+ stackingContextId: "scene-1",
36
+ };
37
+ const y: StackingElement = {
38
+ key: "y",
39
+ track: 1,
40
+ start: 0,
41
+ duration: 5,
42
+ zIndex: 5,
43
+ isAudio: false,
44
+ stackingContextId: null,
45
+ };
46
+ // X edited: only same-context neighbours participate — none here, so X keeps
47
+ // its z (nothing to fix WITHIN its context) and Y is never touched.
48
+ expect(patchMap([x, y], ["x"])).toEqual({});
49
+ });
50
+
51
+ it("still resolves within the edited clip's own context", () => {
52
+ const a: StackingElement = {
53
+ key: "a",
54
+ track: 1,
55
+ start: 0,
56
+ duration: 5,
57
+ zIndex: 1,
58
+ isAudio: false,
59
+ stackingContextId: "scene-1",
60
+ };
61
+ const b: StackingElement = {
62
+ key: "b",
63
+ track: 0,
64
+ start: 0,
65
+ duration: 5,
66
+ zIndex: 5,
67
+ isAudio: false,
68
+ stackingContextId: "scene-1",
69
+ };
70
+ // 'a' moved BELOW b's lane... a (track 1) is under b (track 0): a's z (1)
71
+ // is already below b's (5) — consistent, no patch. Move a ABOVE (track -1
72
+ // relative ordering) is covered by existing suites; here we just prove the
73
+ // same-context pair still participates (no patch ≠ no participation: verify
74
+ // by flipping z so a MUST be lifted).
75
+ const aWrong = { ...a, track: 0, zIndex: 1 };
76
+ const bLow = { ...b, track: 1, zIndex: 5 };
77
+ const patches = patchMap([aWrong, bLow], ["a"]);
78
+ expect(patches.a).toBeGreaterThan(5);
79
+ });
80
+ });
81
+
22
82
  describe("laneIsAbove", () => {
23
83
  it("lower track renders above (top of timeline wins)", () => {
24
84
  expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
@@ -43,6 +43,13 @@ export interface StackingElement {
43
43
  zIndex: number;
44
44
  /** Audio clips have no visual stacking and are excluded from the computation. */
45
45
  isAudio: boolean;
46
+ /**
47
+ * CSS stacking context the clip's node lives in (TimelineElement.stackingContextId).
48
+ * Leaf z-indexes are only comparable WITHIN one context — across contexts the
49
+ * ancestors' z decides paint order — so the sync partitions by this key and
50
+ * never patches across contexts. Null/undefined ⇒ the root context.
51
+ */
52
+ stackingContextId?: string | null;
46
53
  /**
47
54
  * Discovery / DOM document position (optional). Two clips with EQUAL z paint by
48
55
  * DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
@@ -62,6 +69,14 @@ export interface StackingPatch {
62
69
 
63
70
  const EPS = 1e-6;
64
71
 
72
+ /**
73
+ * Canonical stacking-context key: null/undefined both mean the root context.
74
+ * The ONLY place the normalization lives — context partitioning, membership
75
+ * checks, and pairwise equality must all go through it.
76
+ */
77
+ const contextKey = (el: { stackingContextId?: string | null }): string | null =>
78
+ el.stackingContextId ?? null;
79
+
65
80
  /**
66
81
  * Two clips overlap in time when their half-open [start, end) intervals intersect.
67
82
  *
@@ -284,7 +299,14 @@ export function computeStackingPatches(
284
299
  // z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
285
300
  // unresolved clip is neither a neighbour nor resolvable as an edit, so it is
286
301
  // excluded outright (item 13).
287
- const resolved = elements.filter((e) => Number.isFinite(e.zIndex));
302
+ const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
303
+
304
+ // Leaf z is only meaningful within ONE stacking context: across contexts the
305
+ // ancestor contexts' z decides paint order, so comparing (or patching) leaf
306
+ // values across contexts is nonsense. Restrict the computation to the edited
307
+ // clips' own context(s); cross-context lane relations are out of scope.
308
+ const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
309
+ const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
288
310
 
289
311
  // Mutable z snapshot so edits + cascaded bumps see each other's applied z.
290
312
  const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
@@ -304,8 +326,11 @@ export function computeStackingPatches(
304
326
  // The full live set, so the transitive cascade can reach clips that overlap a
305
327
  // LIFTED neighbour without overlapping the edited clip itself (#2198).
306
328
  const all = [...byKey.values()];
329
+ const sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
307
330
  const overlappersOf = (clip: MutZ): MutZ[] =>
308
- all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
331
+ all.filter(
332
+ (o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
333
+ );
309
334
 
310
335
  for (const clip of edited) {
311
336
  resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { ClipManifestClip } from "../lib/playbackTypes";
4
+ import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
+ import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
6
+ import { normalizeToZones } from "./timelineZones";
7
+ import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
8
+ import type { DraggedClipState } from "./useTimelineClipDrag";
9
+
10
+ /**
11
+ * Pipeline test across the REAL manifest→element boundary: no hand-injected
12
+ * `authoredTrack` / `stackingContextId`. A runtime-manifest-shaped payload with
13
+ * SPARSE authored tracks flows through createTimelineElementFromManifestClip →
14
+ * (normalizeToZones | buildChildElements) → commitDraggedClipMove, and the
15
+ * persisted data-track-index must be the AUTHORED target, never the display lane.
16
+ */
17
+
18
+ const manifestClip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
19
+ id: "x",
20
+ label: "x",
21
+ start: 0,
22
+ duration: 2,
23
+ track: 0,
24
+ stackingContextId: "root",
25
+ kind: "element",
26
+ tagName: "div",
27
+ compositionId: null,
28
+ parentCompositionId: null,
29
+ compositionSrc: null,
30
+ assetUrl: null,
31
+ ...over,
32
+ });
33
+
34
+ function fromManifest(clips: ClipManifestClip[]): TimelineElement[] {
35
+ return clips.map((clip, index) =>
36
+ createTimelineElementFromManifestClip({ clip, fallbackIndex: index }),
37
+ );
38
+ }
39
+
40
+ function drag(
41
+ element: TimelineElement,
42
+ opts: { previewStart: number; previewTrack: number },
43
+ ): DraggedClipState {
44
+ return {
45
+ element,
46
+ originClientX: 0,
47
+ originClientY: 0,
48
+ originScrollLeft: 0,
49
+ originScrollTop: 0,
50
+ pointerClientX: 0,
51
+ pointerClientY: 0,
52
+ pointerOffsetX: 0,
53
+ pointerOffsetY: 0,
54
+ previewStart: opts.previewStart,
55
+ previewTrack: opts.previewTrack,
56
+ desiredTrack: opts.previewTrack,
57
+ insertRow: null,
58
+ snapTime: null,
59
+ snapType: null,
60
+ started: true,
61
+ };
62
+ }
63
+
64
+ /** Commit a lane-change drag and return the single persisted edit batch. */
65
+ function commitLaneChange(
66
+ element: TimelineElement,
67
+ previewTrack: number,
68
+ elements: TimelineElement[],
69
+ trackOrder: number[],
70
+ ): TimelineMoveEdit[] {
71
+ const onMoveElements = vi.fn();
72
+ commitDraggedClipMove(drag(element, { previewStart: element.start, previewTrack }), {
73
+ elements,
74
+ trackOrder,
75
+ updateElement: vi.fn(),
76
+ onMoveElements,
77
+ });
78
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
79
+ return onMoveElements.mock.calls[0][0] as TimelineMoveEdit[];
80
+ }
81
+
82
+ describe("track persist pipeline (manifest → factory → lanes → drag commit)", () => {
83
+ // Sparse authored tracks 3 and 7 (mixed kinds), plus audio on 5, exactly as a
84
+ // runtime manifest would ship them (clip.track is the verbatim data-track-index).
85
+ const sparseManifest = [
86
+ manifestClip({ id: "v", kind: "video", tagName: "video", track: 3, start: 0 }),
87
+ manifestClip({ id: "g", kind: "element", tagName: "div", track: 7, start: 10 }),
88
+ manifestClip({ id: "m", kind: "audio", tagName: "audio", track: 5, start: 0 }),
89
+ ];
90
+
91
+ it("factory records the authored track and stacking context from the manifest clip", () => {
92
+ const [v, g, m] = fromManifest(sparseManifest);
93
+ expect([v.authoredTrack, g.authoredTrack, m.authoredTrack]).toEqual([3, 7, 5]);
94
+ expect(v.stackingContextId).toBe("root");
95
+ });
96
+
97
+ it("a lane change on a sparse file persists the AUTHORED target track, not the display lane", () => {
98
+ // normalizeToZones packs visual tracks {3, 7} onto display lanes {0, 1} and
99
+ // the audio track 5 onto lane 2, preserving the factory-set authoredTrack.
100
+ const elements = normalizeToZones(fromManifest(sparseManifest));
101
+ const byId = new Map(elements.map((e) => [e.id, e]));
102
+ expect(byId.get("v")).toMatchObject({ track: 0, authoredTrack: 3 });
103
+ expect(byId.get("g")).toMatchObject({ track: 1, authoredTrack: 7 });
104
+ expect(byId.get("m")).toMatchObject({ track: 2, authoredTrack: 5 });
105
+
106
+ // Drag the video (lane 0) onto the div's lane (display 1, authored 7).
107
+ const down = commitLaneChange(byId.get("v")!, 1, elements, [0, 1, 2]);
108
+ expect(down).toHaveLength(1);
109
+ expect(down[0].updates.track).toBe(7); // authored, NOT display lane 1
110
+ expect(down[0].updates.track).not.toBe(1);
111
+
112
+ // And the reverse: the div (lane 1) onto the video's lane (display 0, authored 3).
113
+ const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
114
+ expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
115
+ });
116
+
117
+ it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
118
+ // Host timeline: the sub-comp host plus a root clip, discovered through the
119
+ // factory and lane-normalized like the store does.
120
+ const hostManifest = [
121
+ manifestClip({
122
+ id: "scene",
123
+ kind: "composition",
124
+ tagName: "div",
125
+ track: 0,
126
+ start: 0,
127
+ duration: 10,
128
+ compositionId: "scene",
129
+ compositionSrc: "scene.html",
130
+ }),
131
+ manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
132
+ ];
133
+ // scene.html has SPARSE authored tracks 3 and 7.
134
+ const childClips = [
135
+ manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
136
+ manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
137
+ ];
138
+ const storeElements = normalizeToZones(fromManifest(hostManifest));
139
+ const parentMap = new Map([
140
+ ["c3", "scene"],
141
+ ["c7", "scene"],
142
+ ]);
143
+ const expanded = buildExpandedElements(
144
+ storeElements,
145
+ [...hostManifest, ...childClips],
146
+ parentMap,
147
+ "scene",
148
+ "scene",
149
+ );
150
+
151
+ // The children replaced the host row: synthetic display lanes, but the
152
+ // authored track (in scene.html's coordinate space) survived the expansion.
153
+ const c3 = expanded.find((e) => e.domId === "c3")!;
154
+ const c7 = expanded.find((e) => e.domId === "c7")!;
155
+ expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
156
+ expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
157
+ expect(c3.stackingContextId).toBe("root");
158
+ expect(c3.track).not.toBe(3); // display row is synthetic
159
+
160
+ // Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
161
+ // the written track must be c7's authored 7 — not the display-lane integer.
162
+ const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
163
+ const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
164
+ expect(edits).toHaveLength(1);
165
+ expect(edits[0].updates.track).toBe(7);
166
+ expect(edits[0].updates.track).not.toBe(c7.track);
167
+ });
168
+ });