@hyperframes/studio 0.7.57 → 0.7.58

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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,221 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import {
4
+ laneGapFloor,
5
+ resolveAllGapIntervals,
6
+ resolveAllTrackGaps,
7
+ resolveCloseGapShifts,
8
+ resolveTrackGapAt,
9
+ trackHasGaps,
10
+ } from "./timelineGaps";
11
+
12
+ function el(id: string, start: number, duration: number): TimelineElement {
13
+ return { id, tag: "video", start, duration, track: 0, domId: id };
14
+ }
15
+
16
+ describe("resolveTrackGapAt", () => {
17
+ it("resolves a middle gap between two clips", () => {
18
+ const els = [el("a", 0, 2), el("b", 5, 3)];
19
+ const gap = resolveTrackGapAt(els, 3);
20
+ expect(gap).toEqual({ gapStart: 2, gapEnd: 5, followingKeys: ["b"] });
21
+ });
22
+
23
+ it("resolves the leading gap before the first clip (gapStart = 0)", () => {
24
+ const els = [el("a", 2, 3), el("b", 6, 1)];
25
+ const gap = resolveTrackGapAt(els, 1);
26
+ expect(gap).toEqual({ gapStart: 0, gapEnd: 2, followingKeys: ["a", "b"] });
27
+ });
28
+
29
+ it("includes EVERY clip at/after the gap in followingKeys", () => {
30
+ const els = [el("a", 0, 1), el("b", 3, 1), el("c", 5, 1), el("d", 8, 1)];
31
+ const gap = resolveTrackGapAt(els, 2);
32
+ expect(gap).toEqual({ gapStart: 1, gapEnd: 3, followingKeys: ["b", "c", "d"] });
33
+ });
34
+
35
+ it("returns null when there is no clip to the right of the point", () => {
36
+ const els = [el("a", 0, 2)];
37
+ expect(resolveTrackGapAt(els, 5)).toBeNull();
38
+ expect(resolveTrackGapAt([], 1)).toBeNull(); // empty lane
39
+ });
40
+
41
+ it("returns null when the point is inside a clip (half-open interval)", () => {
42
+ const els = [el("a", 1, 2), el("b", 5, 1)];
43
+ expect(resolveTrackGapAt(els, 2)).toBeNull(); // strictly inside
44
+ expect(resolveTrackGapAt(els, 1)).toBeNull(); // at clip start (occupied)
45
+ // At clip END (half-open) the point is free — the gap to "b" resolves.
46
+ expect(resolveTrackGapAt(els, 3)).toEqual({ gapStart: 3, gapEnd: 5, followingKeys: ["b"] });
47
+ });
48
+
49
+ it("resolves the leading gap for a single clip", () => {
50
+ const gap = resolveTrackGapAt([el("a", 4, 2)], 1);
51
+ expect(gap).toEqual({ gapStart: 0, gapEnd: 4, followingKeys: ["a"] });
52
+ });
53
+
54
+ it("treats epsilon-adjacent clips as gapless (float drift)", () => {
55
+ // 8.4 + 2.7 = 11.100000000000001 — the classic drift: no point near the
56
+ // seam resolves a gap.
57
+ const drifted = [el("a", 8.4, 2.7), el("b", 11.1, 2)];
58
+ expect(resolveTrackGapAt(drifted, 11.0999)).toBeNull();
59
+ expect(resolveTrackGapAt(drifted, 11.1005)).toBeNull();
60
+ // A sub-epsilon sliver between clips is not a closable gap either.
61
+ const sliver = [el("a", 0, 2.0004), el("b", 2.001, 1)];
62
+ expect(resolveTrackGapAt(sliver, 1.9995)).toBeNull();
63
+ });
64
+
65
+ it("handles overlapping clips sanely (uses the max end left of the point)", () => {
66
+ const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
67
+ const gap = resolveTrackGapAt(els, 5);
68
+ expect(gap).toEqual({ gapStart: 4, gapEnd: 6, followingKeys: ["c"] });
69
+ });
70
+
71
+ it("prefers the key over the id when present", () => {
72
+ const withKey = { ...el("a", 3, 1), key: "a-key" };
73
+ const gap = resolveTrackGapAt([withKey], 1);
74
+ expect(gap?.followingKeys).toEqual(["a-key"]);
75
+ });
76
+ });
77
+
78
+ describe("resolveCloseGapShifts", () => {
79
+ it("shifts the following clips left by exactly the gap width", () => {
80
+ const els = [el("a", 0, 2), el("b", 5, 3), el("c", 10, 1)];
81
+ const gap = resolveTrackGapAt(els, 3)!;
82
+ expect(resolveCloseGapShifts(els, gap)).toEqual([
83
+ { key: "b", newStart: 2 },
84
+ { key: "c", newStart: 7 },
85
+ ]);
86
+ });
87
+
88
+ it("closing the leading gap lands the first clip at 0", () => {
89
+ const els = [el("a", 2, 3), el("b", 6, 1)];
90
+ const gap = resolveTrackGapAt(els, 1)!;
91
+ expect(resolveCloseGapShifts(els, gap)).toEqual([
92
+ { key: "a", newStart: 0 },
93
+ { key: "b", newStart: 4 },
94
+ ]);
95
+ });
96
+
97
+ it("rounds shifted starts to millisecond precision", () => {
98
+ const els = [el("a", 0, 1.1), el("b", 3.3000000000000003, 1)];
99
+ const gap = resolveTrackGapAt(els, 2)!;
100
+ const shifts = resolveCloseGapShifts(els, gap);
101
+ expect(shifts).toEqual([{ key: "b", newStart: 1.1 }]);
102
+ });
103
+ });
104
+
105
+ describe("resolveAllTrackGaps", () => {
106
+ it("compacts every gap: contiguous from 0, order and durations preserved", () => {
107
+ const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
108
+ expect(resolveAllTrackGaps(els)).toEqual([
109
+ { key: "a", newStart: 0 },
110
+ { key: "b", newStart: 2 },
111
+ { key: "c", newStart: 5 },
112
+ ]);
113
+ });
114
+
115
+ it("includes the leading gap for a single clip", () => {
116
+ expect(resolveAllTrackGaps([el("a", 4, 2)])).toEqual([{ key: "a", newStart: 0 }]);
117
+ });
118
+
119
+ it("returns only the clips whose start actually changes", () => {
120
+ const els = [el("a", 0, 2), el("b", 2, 1), el("c", 5, 1)];
121
+ expect(resolveAllTrackGaps(els)).toEqual([{ key: "c", newStart: 3 }]);
122
+ });
123
+
124
+ it("returns [] for an already-contiguous track and for an empty lane", () => {
125
+ expect(resolveAllTrackGaps([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
126
+ expect(resolveAllTrackGaps([])).toEqual([]);
127
+ });
128
+
129
+ it("ignores epsilon-level drift instead of emitting no-op shifts", () => {
130
+ const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
131
+ expect(resolveAllTrackGaps(els)).toEqual([]);
132
+ });
133
+
134
+ it("serializes overlapping clips in start order (sum-of-durations rule)", () => {
135
+ const els = [el("a", 0, 4), el("b", 2, 2)];
136
+ expect(resolveAllTrackGaps(els)).toEqual([{ key: "b", newStart: 4 }]);
137
+ });
138
+
139
+ it("is deterministic for identical starts (key tie-break)", () => {
140
+ const els = [el("b", 3, 1), el("a", 3, 2)];
141
+ expect(resolveAllTrackGaps(els)).toEqual([
142
+ { key: "a", newStart: 0 },
143
+ { key: "b", newStart: 2 },
144
+ ]);
145
+ });
146
+ });
147
+
148
+ describe("resolveAllGapIntervals", () => {
149
+ it("reports every current gap, leading gap included, left to right", () => {
150
+ const els = [el("a", 1, 2), el("b", 5, 3), el("c", 10, 1)];
151
+ expect(resolveAllGapIntervals(els)).toEqual([
152
+ { start: 0, end: 1 },
153
+ { start: 3, end: 5 },
154
+ { start: 8, end: 10 },
155
+ ]);
156
+ });
157
+
158
+ it("returns [] for contiguous or empty lanes", () => {
159
+ expect(resolveAllGapIntervals([el("a", 0, 2), el("b", 2, 3)])).toEqual([]);
160
+ expect(resolveAllGapIntervals([])).toEqual([]);
161
+ });
162
+
163
+ it("never fabricates an interval from overlapping clips (cursor = max end)", () => {
164
+ const els = [el("a", 0, 4), el("b", 1, 2), el("c", 6, 1)];
165
+ expect(resolveAllGapIntervals(els)).toEqual([{ start: 4, end: 6 }]);
166
+ });
167
+
168
+ it("ignores epsilon-level drift seams", () => {
169
+ const els = [el("a", 0, 8.4), el("b", 8.4, 2.7), el("c", 11.100000000000001, 2)];
170
+ expect(resolveAllGapIntervals(els)).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe("trackHasGaps", () => {
175
+ it("detects gaps, including the leading gap", () => {
176
+ expect(trackHasGaps([el("a", 1, 2)])).toBe(true);
177
+ expect(trackHasGaps([el("a", 0, 2), el("b", 4, 1)])).toBe(true);
178
+ });
179
+
180
+ it("is false for contiguous or empty tracks", () => {
181
+ expect(trackHasGaps([el("a", 0, 2), el("b", 2, 1)])).toBe(false);
182
+ expect(trackHasGaps([])).toBe(false);
183
+ });
184
+ });
185
+
186
+ describe("lane floor (expanded sub-comp children)", () => {
187
+ const child = (id: string, start: number, duration: number): TimelineElement => ({
188
+ ...el(id, start, duration),
189
+ expandedParentStart: 16,
190
+ sourceFile: "scene.html",
191
+ });
192
+
193
+ it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => {
194
+ expect(laneGapFloor([el("a", 0, 2)])).toBe(0);
195
+ expect(laneGapFloor([child("c1", 16.5, 2), child("c2", 20, 2)])).toBe(16);
196
+ });
197
+
198
+ it("compaction lands the first child at the HOST window start, never absolute 0", () => {
199
+ const lane = [child("c1", 18, 2), child("c2", 22, 2)];
200
+ expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([
201
+ { key: "c1", newStart: 16 },
202
+ { key: "c2", newStart: 18 },
203
+ ]);
204
+ });
205
+
206
+ it("the leading gap starts at the floor for both close-one and the highlight intervals", () => {
207
+ const lane = [child("c1", 18, 2)];
208
+ const floor = laneGapFloor(lane);
209
+ expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({
210
+ gapStart: 16,
211
+ gapEnd: 18,
212
+ followingKeys: ["c1"],
213
+ });
214
+ expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]);
215
+ });
216
+
217
+ it("a child lane contiguous from its host start has no gaps", () => {
218
+ const lane = [child("c1", 16, 2), child("c2", 18, 2)];
219
+ expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false);
220
+ });
221
+ });
@@ -0,0 +1,182 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ /**
4
+ * Pure gap math for a single timeline display lane (CapCut/Premiere-style
5
+ * "Close gap" / "Close all gaps"). Operates on the DISPLAY element set the
6
+ * timeline renders for one lane — the caller passes the clips of the
7
+ * right-clicked lane only; cross-lane behavior is out of scope by design.
8
+ *
9
+ * Conventions:
10
+ * - A clip occupies the half-open interval [start, start + duration).
11
+ * - Comparisons are epsilon-tolerant ({@link TRACK_GAP_EPSILON_S}) so float
12
+ * drift (e.g. 8.4 + 2.7 = 11.100000000000001) never fabricates a sliver gap.
13
+ * - Computed starts are rounded to millisecond precision, matching the drag
14
+ * commit's `round3`.
15
+ */
16
+ const TRACK_GAP_EPSILON_S = 1e-3;
17
+
18
+ const keyOf = (e: TimelineElement) => e.key ?? e.id;
19
+
20
+ /**
21
+ * The lane's time ORIGIN — the earliest start a clip on this lane may take.
22
+ * 0 for ordinary lanes; for a lane of expanded sub-comp children (post-
23
+ * collision-fix a lane is always single-origin) it is the children's host
24
+ * window start (`expandedParentStart`): display times are host-absolute, so
25
+ * compacting toward absolute 0 would drag a child BEFORE its host's window
26
+ * and persist a wrong (even negative) local time.
27
+ */
28
+ export function laneGapFloor(elements: readonly TimelineElement[]): number {
29
+ return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
30
+ }
31
+ const round3 = (v: number) => Math.round(v * 1000) / 1000;
32
+ const endOf = (e: TimelineElement) => e.start + e.duration;
33
+
34
+ /** Lane clips sorted by start (key as a deterministic tie-break). */
35
+ function sortedLaneClips(elements: readonly TimelineElement[]): TimelineElement[] {
36
+ return [...elements].sort((a, b) => a.start - b.start || keyOf(a).localeCompare(keyOf(b)));
37
+ }
38
+
39
+ export interface TrackGapAt {
40
+ /** Gap left edge: the max end of the clips left of the point (0 for the leading gap). */
41
+ gapStart: number;
42
+ /** Gap right edge: the start of the next clip on the lane. */
43
+ gapEnd: number;
44
+ /** Keys of the next clip and every clip after it on the lane, in start order. */
45
+ followingKeys: string[];
46
+ }
47
+
48
+ /**
49
+ * Resolve the gap under a right-clicked point on one lane.
50
+ *
51
+ * Returns null when the point sits inside a clip, when there is no clip to the
52
+ * right of the point (nothing to close), or when the neighbouring clips are
53
+ * epsilon-adjacent (no real gap).
54
+ */
55
+ export function resolveTrackGapAt(
56
+ elements: readonly TimelineElement[],
57
+ time: number,
58
+ epsilon: number = TRACK_GAP_EPSILON_S,
59
+ floor: number = 0,
60
+ ): TrackGapAt | null {
61
+ const clips = sortedLaneClips(elements);
62
+ // Point inside a clip's half-open [start, end) → not empty space.
63
+ const occupied = clips.some((c) => time >= c.start - epsilon && time < endOf(c) - epsilon);
64
+ if (occupied) return null;
65
+
66
+ const following = clips.filter((c) => c.start > time - epsilon);
67
+ if (following.length === 0) return null; // nothing to the right — nothing to close
68
+
69
+ const gapEnd = following[0].start;
70
+ // Max end among clips left of the point (they all end at/before it since the
71
+ // point is unoccupied); the lane floor for the leading gap before the first
72
+ // clip (0 for ordinary lanes, the host window start for expanded children).
73
+ const gapStart = Math.max(
74
+ floor,
75
+ ...clips.filter((c) => c.start <= time - epsilon).map((c) => endOf(c)),
76
+ );
77
+ if (gapEnd - gapStart <= epsilon) return null; // epsilon-adjacent — no gap
78
+
79
+ return { gapStart, gapEnd, followingKeys: following.map(keyOf) };
80
+ }
81
+
82
+ export interface TrackGapShift {
83
+ key: string;
84
+ newStart: number;
85
+ }
86
+
87
+ /** An empty interval on the lane, [start, end) in seconds. */
88
+ export interface TrackGapInterval {
89
+ start: number;
90
+ end: number;
91
+ }
92
+
93
+ /**
94
+ * Every CURRENT empty interval on the lane, leading gap included — the regions
95
+ * "Close all gaps" would collapse, in left-to-right order. Purely descriptive
96
+ * (for the hover-highlight overlay): unlike {@link resolveAllTrackGaps} it
97
+ * reports the gaps as they are now, not the post-compaction clip starts.
98
+ * Overlapping clips never fabricate a negative interval (the cursor tracks the
99
+ * max end seen so far).
100
+ */
101
+ export function resolveAllGapIntervals(
102
+ elements: readonly TimelineElement[],
103
+ epsilon: number = TRACK_GAP_EPSILON_S,
104
+ floor: number = 0,
105
+ ): TrackGapInterval[] {
106
+ const gaps: TrackGapInterval[] = [];
107
+ let cursor = floor;
108
+ for (const clip of sortedLaneClips(elements)) {
109
+ if (clip.start - cursor > epsilon) gaps.push({ start: cursor, end: clip.start });
110
+ cursor = Math.max(cursor, endOf(clip));
111
+ }
112
+ return gaps;
113
+ }
114
+
115
+ /**
116
+ * Compact the whole lane: every clip lands at the sum of the durations of the
117
+ * clips before it (contiguous from 0, order and durations preserved).
118
+ * Overlapping clips (spill lanes) are serialized in start order — sane, if
119
+ * lossy for deliberate overlaps; the display lane set should not contain them.
120
+ *
121
+ * Returns ONLY the clips whose start actually changes (beyond epsilon).
122
+ */
123
+ export function resolveAllTrackGaps(
124
+ elements: readonly TimelineElement[],
125
+ epsilon: number = TRACK_GAP_EPSILON_S,
126
+ floor: number = 0,
127
+ ): TrackGapShift[] {
128
+ const shifts: TrackGapShift[] = [];
129
+ let cursor = floor;
130
+ for (const clip of sortedLaneClips(elements)) {
131
+ const newStart = round3(cursor);
132
+ if (Math.abs(newStart - clip.start) > epsilon) {
133
+ shifts.push({ key: keyOf(clip), newStart });
134
+ }
135
+ cursor += clip.duration;
136
+ }
137
+ return shifts;
138
+ }
139
+
140
+ /**
141
+ * Every empty interval on the lane across the FULL rendered extent `[0, end)`:
142
+ * the closable gaps plus the open region after the last clip. Powers the
143
+ * click-selected lane highlight ("light the whole track except the clips") —
144
+ * unlike {@link resolveAllGapIntervals} it is not limited to what a gap-close
145
+ * could collapse.
146
+ */
147
+ export function resolveLaneEmptyIntervals(
148
+ elements: readonly TimelineElement[],
149
+ end: number,
150
+ epsilon: number = TRACK_GAP_EPSILON_S,
151
+ floor: number = 0,
152
+ ): TrackGapInterval[] {
153
+ const gaps = resolveAllGapIntervals(elements, epsilon, floor);
154
+ const maxEnd = Math.max(floor, ...elements.map(endOf));
155
+ if (end - maxEnd > epsilon) gaps.push({ start: maxEnd, end });
156
+ return gaps;
157
+ }
158
+
159
+ /** Whether the lane has any gap "Close all gaps" would collapse. */
160
+ export function trackHasGaps(
161
+ elements: readonly TimelineElement[],
162
+ epsilon: number = TRACK_GAP_EPSILON_S,
163
+ floor: number = 0,
164
+ ): boolean {
165
+ return resolveAllTrackGaps(elements, epsilon, floor).length > 0;
166
+ }
167
+
168
+ /**
169
+ * Per-clip shifts for closing ONE gap: the next clip and every clip after it
170
+ * on the lane move left by the gap's width. Starts are clamped at 0 (float
171
+ * safety; real shifts never cross the gap's own left edge).
172
+ */
173
+ export function resolveCloseGapShifts(
174
+ elements: readonly TimelineElement[],
175
+ gap: TrackGapAt,
176
+ ): TrackGapShift[] {
177
+ const width = gap.gapEnd - gap.gapStart;
178
+ const followSet = new Set(gap.followingKeys);
179
+ return sortedLaneClips(elements)
180
+ .filter((c) => followSet.has(keyOf(c)))
181
+ .map((c) => ({ key: keyOf(c), newStart: Math.max(gap.gapStart, round3(c.start - width)) }));
182
+ }
@@ -5,6 +5,7 @@ import {
5
5
  TRACKS_TOP_PAD,
6
6
  TRACKS_BOTTOM_PAD,
7
7
  GUTTER,
8
+ TRACKS_LEFT_PAD,
8
9
  getTimelineRowTop,
9
10
  getTimelineRowFromY,
10
11
  getTimelineCanvasHeight,
@@ -85,7 +86,7 @@ describe("track-area breathing pad y-math", () => {
85
86
 
86
87
  it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
87
88
  const clientY = getTimelineRowTop(0) + TRACK_H / 2;
88
- const clientX = GUTTER + 100; // t = 1s
89
+ const clientX = GUTTER + TRACKS_LEFT_PAD + 100; // t = 1s
89
90
  const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
90
91
  expect(track).toBe(0);
91
92
  expect(start).toBe(1);
@@ -31,6 +31,17 @@ export const INSERT_BOUNDARY_BAND = CLIP_Y / TRACK_H;
31
31
  */
32
32
  export const TRACKS_TOP_PAD = 50;
33
33
  export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
34
+ /**
35
+ * Breathing room LEFT of t=0 (CapCut-style), inside the scroll content — the
36
+ * horizontal sibling of TRACKS_TOP_PAD: empty lane surface between the sticky
37
+ * gutter and where the ruler's 00:00 / the clips actually start, scrolling
38
+ * WITH the content. Time↔pixel mapping: content x = GUTTER + TRACKS_LEFT_PAD
39
+ * + t·pps, and every pointer→time inverse subtracts it symmetrically. The
40
+ * lanes and the ruler realize it as a plain flow spacer between the sticky
41
+ * gutter cell and the time-mapped content div, so all content-relative math
42
+ * (clip left = t·pps, beat lines, lane-menu time) is untouched.
43
+ */
44
+ export const TRACKS_LEFT_PAD = 48;
34
45
 
35
46
  /**
36
47
  * The y (content-space) of the top edge of track ROW index `row` (0 = first
@@ -209,8 +220,8 @@ export function getTimelineFitPps(viewportWidth: number, effectiveDuration: numb
209
220
  const safeDuration =
210
221
  Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
211
222
  const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
212
- if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
213
- return (viewportWidth - GUTTER - 2) / span;
223
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER + TRACKS_LEFT_PAD) return 100;
224
+ return (viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2) / span;
214
225
  }
215
226
 
216
227
  /**
@@ -230,7 +241,7 @@ export function getTimelineDisplayContentWidth(input: {
230
241
  const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
231
242
  return Math.max(
232
243
  input.trackContentWidth,
233
- input.viewportWidth - GUTTER - 2,
244
+ input.viewportWidth - GUTTER - TRACKS_LEFT_PAD - 2,
234
245
  input.dragGhostEndPx ?? 0,
235
246
  input.resizeGhostEndPx ?? 0,
236
247
  MIN_TIMELINE_EXTENT_S * safePps,
@@ -300,9 +311,14 @@ export const PLAYHEAD_HEAD_W = 9;
300
311
  */
301
312
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
302
313
  if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
303
- return GUTTER - PLAYHEAD_HEAD_W / 2;
314
+ return GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2;
304
315
  }
305
- return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
316
+ return (
317
+ GUTTER +
318
+ TRACKS_LEFT_PAD +
319
+ Math.max(0, time) * Math.max(0, pixelsPerSecond) -
320
+ PLAYHEAD_HEAD_W / 2
321
+ );
306
322
  }
307
323
 
308
324
  export function getTimelineCanvasHeight(trackCount: number): number {
@@ -373,7 +389,7 @@ export function resolveTimelineAssetDrop(
373
389
  clientX: number,
374
390
  clientY: number,
375
391
  ): { start: number; track: number } {
376
- const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
392
+ const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
377
393
  const contentY = clientY - input.rectTop + input.scrollTop;
378
394
  const start = Math.max(
379
395
  0,
@@ -7,7 +7,17 @@ import {
7
7
  getTimelineClipRect,
8
8
  computeMarqueeSelection,
9
9
  } from "./timelineMarquee";
10
- import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
10
+ import {
11
+ GUTTER,
12
+ TRACK_H,
13
+ RULER_H,
14
+ CLIP_Y,
15
+ TRACKS_LEFT_PAD,
16
+ getTimelineRowTop,
17
+ } from "./timelineLayout";
18
+
19
+ // Canvas-space time origin: right edge of the sticky gutter + the left pad.
20
+ const ORIGIN = GUTTER + TRACKS_LEFT_PAD;
11
21
 
12
22
  describe("isTimelineRulerPress", () => {
13
23
  const rectTop = 500; // scroll container's viewport top
@@ -86,7 +96,7 @@ describe("getTimelineClipRect", () => {
86
96
  it("maps start/duration to x via pps and the track row to y via the shared row→y helper", () => {
87
97
  const rect = getTimelineClipRect({ start: 2, duration: 3, track: 2 }, trackOrder, 100);
88
98
  expect(rect).toEqual({
89
- left: GUTTER + 200,
99
+ left: ORIGIN + 200,
90
100
  top: getTimelineRowTop(1) + CLIP_Y,
91
101
  width: 300,
92
102
  height: TRACK_H - CLIP_Y * 2,
@@ -96,7 +106,7 @@ describe("getTimelineClipRect", () => {
96
106
  it("places the first visible track below the ruler + top breathing pad", () => {
97
107
  const rect = getTimelineClipRect({ start: 0, duration: 1, track: 0 }, trackOrder, 50);
98
108
  expect(rect?.top).toBe(getTimelineRowTop(0) + CLIP_Y);
99
- expect(rect?.left).toBe(GUTTER);
109
+ expect(rect?.left).toBe(ORIGIN);
100
110
  });
101
111
 
102
112
  it("uses the row index in trackOrder, not the raw track number", () => {
@@ -129,26 +139,26 @@ describe("computeMarqueeSelection", () => {
129
139
  const row1Top = getTimelineRowTop(1) + CLIP_Y;
130
140
 
131
141
  it("selects only the clips the marquee rect intersects", () => {
132
- const marquee = { left: GUTTER, top: row0Top, width: 50, height: 10 };
142
+ const marquee = { left: ORIGIN, top: row0Top, width: 50, height: 10 };
133
143
  const { ids, primaryId } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
134
144
  expect(ids).toEqual(new Set(["a"]));
135
145
  expect(primaryId).toBe("a");
136
146
  });
137
147
 
138
148
  it("selects across tracks when the rect spans multiple rows", () => {
139
- const marquee = { left: GUTTER, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
149
+ const marquee = { left: ORIGIN, top: row0Top, width: 60, height: row1Top - row0Top + 5 };
140
150
  const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
141
151
  expect(ids).toEqual(new Set(["a", "c"]));
142
152
  });
143
153
 
144
154
  it("excludes clips outside the rect horizontally", () => {
145
- const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
155
+ const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
146
156
  const { ids } = computeMarqueeSelection({ clips, trackOrder, pps, marquee });
147
157
  expect(ids).toEqual(new Set());
148
158
  });
149
159
 
150
160
  it("returns null primaryId and keeps the base when nothing is hit (additive)", () => {
151
- const marquee = { left: GUTTER + 140, top: row0Top, width: 50, height: 10 };
161
+ const marquee = { left: ORIGIN + 140, top: row0Top, width: 50, height: 10 };
152
162
  const { ids, primaryId } = computeMarqueeSelection({
153
163
  clips,
154
164
  trackOrder,
@@ -161,7 +171,7 @@ describe("computeMarqueeSelection", () => {
161
171
  });
162
172
 
163
173
  it("unions additive base selection with new hits; primary comes from the marquee", () => {
164
- const marquee = { left: GUTTER, top: row1Top, width: 100, height: 10 };
174
+ const marquee = { left: ORIGIN, top: row1Top, width: 100, height: 10 };
165
175
  const { ids, primaryId } = computeMarqueeSelection({
166
176
  clips,
167
177
  trackOrder,
@@ -174,8 +184,8 @@ describe("computeMarqueeSelection", () => {
174
184
  });
175
185
 
176
186
  it("shrinking the rect live drops clips it no longer covers", () => {
177
- const wide = { left: GUTTER, top: row0Top, width: 320, height: 10 };
178
- const narrow = { left: GUTTER, top: row0Top, width: 80, height: 10 };
187
+ const wide = { left: ORIGIN, top: row0Top, width: 320, height: 10 };
188
+ const narrow = { left: ORIGIN, top: row0Top, width: 80, height: 10 };
179
189
  expect(computeMarqueeSelection({ clips, trackOrder, pps, marquee: wide }).ids).toEqual(
180
190
  new Set(["a", "b"]),
181
191
  );
@@ -1,4 +1,11 @@
1
- import { GUTTER, TRACK_H, RULER_H, CLIP_Y, getTimelineRowTop } from "./timelineLayout";
1
+ import {
2
+ GUTTER,
3
+ TRACK_H,
4
+ RULER_H,
5
+ CLIP_Y,
6
+ TRACKS_LEFT_PAD,
7
+ getTimelineRowTop,
8
+ } from "./timelineLayout";
2
9
  import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
3
10
 
4
11
  /** Pointer must travel at least this far (either axis) before a pointerdown on
@@ -73,7 +80,7 @@ export function getTimelineClipRect(
73
80
  const row = trackOrder.indexOf(clip.track);
74
81
  if (row < 0 || !Number.isFinite(pps) || pps <= 0) return null;
75
82
  return {
76
- left: GUTTER + clip.start * pps,
83
+ left: GUTTER + TRACKS_LEFT_PAD + clip.start * pps,
77
84
  top: getTimelineRowTop(row) + CLIP_Y,
78
85
  width: Math.max(clip.duration * pps, MIN_CLIP_W),
79
86
  height: TRACK_H - CLIP_Y * 2,
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
2
+ import {
3
+ computeStackingPatches,
4
+ laneIsAbove,
5
+ samePaintScope,
6
+ type StackingElement,
7
+ } from "./timelineStackingSync";
3
8
 
4
9
  function el(
5
10
  key: string,
@@ -20,6 +25,42 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
20
25
  }
21
26
 
22
27
  describe("stacking-context partitioning", () => {
28
+ it("uses source file and normalized stacking context as the canonical paint scope", () => {
29
+ expect(samePaintScope({}, { stackingContextId: null })).toBe(true);
30
+ expect(samePaintScope({}, { sourceFile: "index.html" })).toBe(false);
31
+ expect(
32
+ samePaintScope(
33
+ { sourceFile: "scene.html", stackingContextId: "card" },
34
+ { sourceFile: "scene.html", stackingContextId: "modal" },
35
+ ),
36
+ ).toBe(false);
37
+ });
38
+
39
+ it("never compares or patches across source files in the root context", () => {
40
+ const root: StackingElement = {
41
+ key: "root",
42
+ track: 0,
43
+ start: 0,
44
+ duration: 5,
45
+ zIndex: 1,
46
+ isAudio: false,
47
+ sourceFile: "index.html",
48
+ stackingContextId: null,
49
+ };
50
+ const scene: StackingElement = {
51
+ key: "scene",
52
+ track: 1,
53
+ start: 0,
54
+ duration: 5,
55
+ zIndex: 10,
56
+ isAudio: false,
57
+ sourceFile: "scenes/scene.html",
58
+ stackingContextId: null,
59
+ };
60
+
61
+ expect(patchMap([root, scene], ["root"])).toEqual({});
62
+ });
63
+
23
64
  it("never compares or patches across stacking contexts", () => {
24
65
  // X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
25
66
  // with a lower leaf z, overlapping in time. Their leaf z values are NOT