@hyperframes/studio 0.7.56 → 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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.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 +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  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 +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  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/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
3
3
 
4
4
  /**
5
5
  * Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
6
- * — layering is CSS z-index (the renderer ignores track index), so the timeline's
7
- * only job is to keep visual clips grouped above audio clips.
6
+ * — canvas layering is CSS z-index (the renderer ignores track index), so the
7
+ * timeline's only job is to keep visual clips grouped above audio clips.
8
8
  */
9
9
  export type TrackZone = "visual" | "audio";
10
10
 
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
16
16
 
17
17
  const keyOf = (el: TimelineElement) => el.key ?? el.id;
18
18
 
19
- /** Stacking order for a clip: missing / "auto" z is treated as 0. */
20
- const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
21
-
22
19
  const EPS = 1e-6;
23
20
 
24
21
  /** Two clips overlap when their half-open [start, end) intervals intersect. */
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
26
23
  return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
27
24
  }
28
25
 
29
- /** A clip paired with its position in the discovery/document (input) order. */
30
- interface IndexedClip {
31
- el: TimelineElement;
32
- /** Index in the input `elements` array = discovery/DOM order. */
33
- domIndex: number;
34
- }
35
-
36
- /** One display lane: the clips packed onto it, in placement order. */
37
- interface Lane {
38
- occupants: IndexedClip[];
39
- /** The single authored track all occupants share, or null once mixed (never
40
- * happens — we only ever add same-track clips to an existing lane). */
41
- track: number;
42
- }
43
-
44
- /**
45
- * Lowest lane index a clip may occupy: strictly above every already-placed lane
46
- * holding a clip it overlaps in time (all of which out-stack it by the z-desc
47
- * placement order).
48
- */
49
- function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
50
- let minLane = 0;
51
- for (let i = 0; i < lanes.length; i++) {
52
- if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
53
- }
54
- return minLane;
55
- }
56
-
57
- /**
58
- * First lane at index ≥ minLane that holds solely this clip's authored track and
59
- * nothing overlapping (so sequential same-track clips share a lane); -1 when none
60
- * qualifies and a fresh lane must open.
61
- */
62
- function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
63
- for (let i = minLane; i < lanes.length; i++) {
64
- const lane = lanes[i];
65
- if (lane.track !== item.el.track) continue;
66
- if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
67
- return i;
68
- }
69
- return -1;
26
+ /** Deterministic order on the stable clip id (never the mutated lane/track). */
27
+ function byStableId(a: TimelineElement, b: TimelineElement): number {
28
+ const ka = keyOf(a);
29
+ const kb = keyOf(b);
30
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
70
31
  }
71
32
 
72
33
  /**
73
- * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
74
- * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
75
- * equals canvas stacking order. This replaces the old two-stage
76
- * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
77
- * tracks by their MAX z and so lifted a low-z clip above a clip that covers it
78
- * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
79
- * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
80
- * whole-track mapping can fix that; the mapping must be per-clip.
34
+ * Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
35
+ * share a lane. Clips are ordered by their STABLE id (a function of the input, not
36
+ * of the lane being computed the historical oscillation bug tie-broke on the
37
+ * mutated track) and placed first-fit, so sequential (non-overlapping) clips
38
+ * collapse onto a single lane and only genuine time overlaps spill onto adjacent
39
+ * sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
40
+ * number of lanes used (≥ 1 when non-empty).
81
41
  *
82
- * Algorithm:
83
- * 1. Order clips by z DESC; z-tie INPUT-ARRAY-INDEX (DOM order) DESC (CSS
84
- * paints equal-z siblings by DOM order LATER in DOM paints on top, so it
85
- * must place first / upper); final tie → stable key. NEVER tie-break on the
86
- * mutated lane/track index (historic oscillation bug — the tie-break must be
87
- * a stable function of the input, not of the output being computed).
88
- * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
89
- * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
90
- * clip out-stacks this one (higher z, or equal z but later in DOM), so this
91
- * guarantees lane order == stacking order for every overlapping pair.
92
- * 3. To preserve the "distinct authored tracks stay distinct / sequential
93
- * same-track clips share a lane" feel, reuse an existing lane at index ≥ that
94
- * minimum ONLY when the lane's occupants are all from the SAME authored track
95
- * AND none overlaps this clip in time; otherwise open a fresh lane.
42
+ * The editor enforces no per-track time overlap, so the spill only fires on legacy
43
+ * files. It is DISPLAY-ONLY a drag commit persists just the dragged clip, never
44
+ * this re-laneso it never rewrites the source.
96
45
  *
97
- * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
98
- * returns the number of lanes used (≥ 1 when non-empty).
46
+ * Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
47
+ * display lane). Because every occupant of a sub-lane shares the base lane's
48
+ * authored track by construction, dropping a clip onto one persists that shared
49
+ * authored track — a legitimate same-track join. On the next normalize the
50
+ * joined track re-packs (stable-id first-fit), so the clip may display on a
51
+ * DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
52
+ * the persisted source value is correct either way.
99
53
  */
100
- function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
101
- const ordered = [...clips].sort(
102
- (a, b) =>
103
- zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
104
- );
105
- const lanes: Lane[] = [];
106
- for (const item of ordered) {
107
- const minLane = lowestAllowedLane(lanes, item);
108
- let placed = findReusableLane(lanes, minLane, item);
109
- if (placed === -1) {
110
- placed = lanes.length;
111
- lanes.push({ occupants: [], track: item.el.track });
54
+ function packTrackLanes(
55
+ clips: TimelineElement[],
56
+ base: number,
57
+ laneOf: Map<string, number>,
58
+ ): number {
59
+ const ordered = [...clips].sort(byStableId);
60
+ const lanes: TimelineElement[][] = [];
61
+ for (const el of ordered) {
62
+ let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
63
+ if (sub === -1) {
64
+ sub = lanes.length;
65
+ lanes.push([]);
112
66
  }
113
- lanes[placed].occupants.push(item);
114
- laneOf.set(keyOf(item.el), base + placed);
67
+ lanes[sub].push(el);
68
+ laneOf.set(keyOf(el), base + sub);
115
69
  }
116
- return lanes.length;
70
+ return Math.max(1, lanes.length);
117
71
  }
118
72
 
119
73
  /**
120
- * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
121
- * authored track's clips onto sub-lanes so no two overlap in time sequential
122
- * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
123
- * start (then stable key) so the layout is deterministic and idempotent. Returns
124
- * the number of lanes used (≥ 1 when non-empty).
74
+ * Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
75
+ * follow the authored `data-track-index` (ASCENDING; ties by stable id)NEVER a
76
+ * z-rank. Each distinct authored track owns its own lane (in ascending order);
77
+ * sequential same-track clips share it; time-overlapping same-track clips spill to
78
+ * adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
79
+ *
80
+ * This REPLACES the old global-z-rank interval pack. That pack ordered visual
81
+ * lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
82
+ * whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
83
+ * product decision is the opposite: a clip's lane is its track, period — z is
84
+ * canvas paint order only, and lane assignment must ignore it.
125
85
  */
126
- function packAudioTrackLanes(
127
- clips: IndexedClip[],
86
+ function packZoneLanes(
87
+ clips: TimelineElement[],
128
88
  base: number,
129
89
  laneOf: Map<string, number>,
130
90
  ): number {
131
- const ordered = [...clips].sort(
132
- (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
133
- );
134
- const lanes: IndexedClip[][] = [];
135
- for (const item of ordered) {
136
- let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
137
- if (sub === -1) {
138
- sub = lanes.length;
139
- lanes.push([]);
140
- }
141
- lanes[sub].push(item);
142
- laneOf.set(keyOf(item.el), base + sub);
91
+ const byTrack = new Map<number, TimelineElement[]>();
92
+ for (const el of clips) {
93
+ const list = byTrack.get(el.track);
94
+ if (list) list.push(el);
95
+ else byTrack.set(el.track, [el]);
143
96
  }
144
- return Math.max(1, lanes.length);
97
+ let used = 0;
98
+ for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
99
+ used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
100
+ }
101
+ return used;
145
102
  }
146
103
 
147
104
  /**
148
105
  * Assign display lanes for the timeline: visual lanes on top, audio lanes below.
149
106
  *
150
- * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
151
- * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
152
- * low-z clip sinks below a clip that covers it even if it shares an authored track
153
- * with a higher-z clip. Time-overlapping clips still split onto separate lanes
154
- * (standard NLE), sequential same-track clips still share a lane, and distinct
155
- * authored tracks stay distinct.
156
- *
157
- * The AUDIO zone keeps the original behavior — authored-track order, per-track
158
- * interval packing — because audio has no z / stacking semantics.
107
+ * Both zones are packed the SAME way by authored track-index, ascending (see
108
+ * packZoneLanes) so the timeline's vertical order follows each clip's track and
109
+ * nothing else. z-index does not participate in lane assignment (it is canvas
110
+ * paint order only; the lane z stacking sync in timelineStackingSync runs the
111
+ * other direction, only on a deliberate vertical edit). Time-overlapping same-track
112
+ * clips still split onto separate sub-lanes (legacy files only — the editor forbids
113
+ * per-track overlap), and that split is display-only, never persisted.
159
114
  *
160
115
  * Pure — returns a new array; unchanged clips keep their identity. Display-only
161
116
  * (runs on discovery); it does not rewrite the source. Idempotent (running it on
162
- * its own output is a fixed point).
117
+ * its own output is a fixed point): the lanes it emits are contiguous integers in
118
+ * ascending track order, and re-running groups by those same integers unchanged.
163
119
  */
164
120
  export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
165
121
  if (elements.length === 0) return elements;
166
122
 
167
123
  const laneOf = new Map<string, number>();
168
- let nextLane = 0;
169
-
170
- const visual: IndexedClip[] = [];
171
- const audio: IndexedClip[] = [];
172
- elements.forEach((el, domIndex) => {
173
- (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
174
- });
124
+ const visual: TimelineElement[] = [];
125
+ const audio: TimelineElement[] = [];
126
+ for (const el of elements) {
127
+ (classifyZone(el) === "audio" ? audio : visual).push(el);
128
+ }
175
129
 
130
+ let nextLane = 0;
176
131
  nextLane += packZoneLanes(visual, nextLane, laneOf);
177
-
178
- // Audio: preserve legacy behavior — group by authored track (ascending), pack
179
- // each track's overlapping clips onto sub-lanes.
180
- const audioByTrack = new Map<number, IndexedClip[]>();
181
- for (const item of audio) {
182
- const list = audioByTrack.get(item.el.track);
183
- if (list) list.push(item);
184
- else audioByTrack.set(item.el.track, [item]);
185
- }
186
- for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
187
- nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
188
- }
132
+ packZoneLanes(audio, nextLane, laneOf);
189
133
 
190
134
  let changed = false;
191
135
  const remapped = elements.map((el) => {
192
136
  const lane = laneOf.get(keyOf(el));
193
137
  if (lane == null || lane === el.track) return el;
194
138
  changed = true;
195
- return { ...el, track: lane };
139
+ // Record the source-file track the first time a clip is remapped so lane
140
+ // edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
141
+ // Re-normalizing already-remapped elements must keep the original value.
142
+ return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
196
143
  });
197
144
  return changed ? remapped : elements;
198
145
  }
@@ -45,11 +45,11 @@ describe("getTimelinePixelsPerSecond", () => {
45
45
 
46
46
  describe("getNextTimelineZoomPercent", () => {
47
47
  it("zooms out from fit relative to 100 percent", () => {
48
- expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(80);
48
+ expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
49
49
  });
50
50
 
51
51
  it("zooms in from fit relative to 100 percent", () => {
52
- expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(125);
52
+ expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
53
53
  });
54
54
 
55
55
  it("clamps the lower bound", () => {
@@ -2,9 +2,13 @@ import type { ZoomMode } from "../store/playerStore";
2
2
 
3
3
  export const MIN_TIMELINE_ZOOM_PERCENT = 10;
4
4
  export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
5
- const ZOOM_OUT_FACTOR = 0.8;
6
- const ZOOM_IN_FACTOR = 1.25;
7
- const PINCH_ZOOM_SENSITIVITY = 0.0035;
5
+ // CapCut-strength steps: one button press / pinch gesture moves the zoom
6
+ // meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
7
+ // "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
8
+ // Kept reciprocal (2 × 0.5 = 1) so in+out round-trips.
9
+ const ZOOM_OUT_FACTOR = 0.5;
10
+ const ZOOM_IN_FACTOR = 2;
11
+ const PINCH_ZOOM_SENSITIVITY = 0.014;
8
12
 
9
13
  export function clampTimelineZoomPercent(percent: number): number {
10
14
  if (!Number.isFinite(percent)) return 100;
@@ -76,7 +76,7 @@ interface UseTimelineClipDragInput {
76
76
  * commitDraggedClipMove. Both optional → absent = no-op (backward compatible).
77
77
  */
78
78
  readZIndex?: (element: TimelineElement) => number;
79
- onStackingPatches?: (patches: StackingPatch[]) => void;
79
+ onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
80
80
  }
81
81
 
82
82
  export function useTimelineClipDrag({
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { buildTimelineGapStrips } from "./useTimelineGapHighlights";
4
+
5
+ function el(id: string, track: number, start: number, duration: number): TimelineElement {
6
+ return { id, key: id, tag: "video", start, duration, track, domId: id };
7
+ }
8
+
9
+ const laneA = [el("a1", 0, 1, 2), el("a2", 0, 5, 2)]; // gaps: [0,1), [3,5)
10
+ const laneB = [el("b1", 1, 0, 2), el("b2", 1, 2, 3)]; // contiguous
11
+ const tracks: [number, TimelineElement[]][] = [
12
+ [0, laneA],
13
+ [1, laneB],
14
+ ];
15
+ const expandedElements = [...laneA, ...laneB];
16
+
17
+ const base = {
18
+ gapHighlight: null,
19
+ tracks,
20
+ selectedElementId: null,
21
+ selectedElementIds: new Set<string>(),
22
+ expandedElements,
23
+ dragActive: false,
24
+ displayDuration: 60,
25
+ };
26
+
27
+ describe("buildTimelineGapStrips", () => {
28
+ it("emits a loud hover strip set from the gap-menu highlight", () => {
29
+ const strips = buildTimelineGapStrips({
30
+ ...base,
31
+ gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
32
+ });
33
+ expect(strips).toEqual([{ track: 0, intervals: [{ start: 3, end: 5 }], kind: "hover" }]);
34
+ });
35
+
36
+ it("click-selection lights the WHOLE lane minus clips, trailing space included", () => {
37
+ const strips = buildTimelineGapStrips({ ...base, selectedElementId: "a1" });
38
+ expect(strips).toEqual([
39
+ {
40
+ track: 0,
41
+ intervals: [
42
+ { start: 0, end: 1 },
43
+ { start: 3, end: 5 },
44
+ { start: 7, end: 60 },
45
+ ],
46
+ kind: "selected",
47
+ },
48
+ ]);
49
+ });
50
+
51
+ it("a contiguous lane still lights its trailing open space", () => {
52
+ expect(buildTimelineGapStrips({ ...base, selectedElementId: "b1" })).toEqual([
53
+ { track: 1, intervals: [{ start: 5, end: 60 }], kind: "selected" },
54
+ ]);
55
+ });
56
+
57
+ it("a lane filling the whole rendered extent emits nothing", () => {
58
+ const laneFull = [el("f1", 5, 0, 60)];
59
+ const strips = buildTimelineGapStrips({
60
+ ...base,
61
+ tracks: [...tracks, [5, laneFull]],
62
+ expandedElements: [...expandedElements, ...laneFull],
63
+ selectedElementId: "f1",
64
+ });
65
+ expect(strips).toEqual([]);
66
+ });
67
+
68
+ it("a one-member selectedElementIds mirror of the click still counts as single", () => {
69
+ // The store mirrors a plain click into selectedElementIds = {clicked}.
70
+ const strips = buildTimelineGapStrips({
71
+ ...base,
72
+ selectedElementId: "a1",
73
+ selectedElementIds: new Set(["a1"]),
74
+ });
75
+ expect(strips).toHaveLength(1);
76
+ expect(strips[0].kind).toBe("selected");
77
+ });
78
+
79
+ it("marquee multi-selection never emits the selected hint", () => {
80
+ const strips = buildTimelineGapStrips({
81
+ ...base,
82
+ selectedElementId: "a1",
83
+ selectedElementIds: new Set(["a1", "b1"]),
84
+ });
85
+ expect(strips).toEqual([]);
86
+ });
87
+
88
+ it("hover wins on its own lane — no doubled strips for the same track", () => {
89
+ const strips = buildTimelineGapStrips({
90
+ ...base,
91
+ gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
92
+ selectedElementId: "a1",
93
+ });
94
+ expect(strips).toHaveLength(1);
95
+ expect(strips[0].kind).toBe("hover");
96
+ });
97
+
98
+ it("hover and selection on DIFFERENT lanes coexist", () => {
99
+ const laneC = [el("c1", 2, 4, 2)];
100
+ const strips = buildTimelineGapStrips({
101
+ ...base,
102
+ tracks: [...tracks, [2, laneC]],
103
+ expandedElements: [...expandedElements, ...laneC],
104
+ gapHighlight: { track: 2, intervals: [{ start: 0, end: 4 }] },
105
+ selectedElementId: "a1",
106
+ });
107
+ expect(strips.map((s) => s.kind)).toEqual(["hover", "selected"]);
108
+ });
109
+
110
+ it("a live drag suppresses every strip", () => {
111
+ const strips = buildTimelineGapStrips({
112
+ ...base,
113
+ dragActive: true,
114
+ gapHighlight: { track: 0, intervals: [{ start: 3, end: 5 }] },
115
+ selectedElementId: "a1",
116
+ });
117
+ expect(strips).toEqual([]);
118
+ });
119
+ });
@@ -0,0 +1,110 @@
1
+ import { useMemo } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { laneGapFloor, resolveLaneEmptyIntervals, type TrackGapInterval } from "./timelineGaps";
4
+ import type { TrackGapHighlight } from "./useTrackGapMenu";
5
+
6
+ /**
7
+ * One lane's gap strips for the TimelineCanvas overlay.
8
+ *
9
+ * kind "hover" — the gap(s) a hovered "Close gap" / "Close all gaps" menu
10
+ * row would collapse: the loud affordance.
11
+ * kind "selected" — every empty interval on a click-selected clip's lane: the
12
+ * quiet always-on hint (single click-selection only — a
13
+ * marquee multi-select spans lanes and would paint noise).
14
+ */
15
+ export interface TimelineLaneGapStrips {
16
+ track: number;
17
+ intervals: TrackGapInterval[];
18
+ kind: "hover" | "selected";
19
+ }
20
+
21
+ /**
22
+ * Derive the gap strips TimelineCanvas paints. The menu-hover highlight wins
23
+ * on its lane (painting both would just double the same strips); the
24
+ * selected-clip hint renders on the selection's lane otherwise. Suppressed
25
+ * entirely during a live drag — the drop placeholder / insert line own that
26
+ * moment, and the lane set is in flux.
27
+ */
28
+ interface GapHighlightInput {
29
+ gapHighlight: TrackGapHighlight | null;
30
+ tracks: [number, TimelineElement[]][];
31
+ selectedElementId: string | null;
32
+ selectedElementIds: ReadonlySet<string>;
33
+ expandedElements: TimelineElement[];
34
+ dragActive: boolean;
35
+ /** Rendered timeline extent (seconds) — the selected-lane highlight spans the
36
+ * WHOLE lane minus its clips, trailing open space included. */
37
+ displayDuration: number;
38
+ }
39
+
40
+ /**
41
+ * Single selection only: the store mirrors a plain click into a one-member
42
+ * selectedElementIds set (setSelectedElementId collapses the multi-select),
43
+ * so "single" means empty OR exactly the selected clip itself. A marquee
44
+ * multi-select spans lanes and stays hint-free.
45
+ */
46
+ function isSingleSelection(selectedElementId: string, ids: ReadonlySet<string>): boolean {
47
+ return ids.size === 0 || (ids.size === 1 && ids.has(selectedElementId));
48
+ }
49
+
50
+ /** The subtle strips for a single click-selected clip's lane, or null. */
51
+ function selectedLaneStrips(input: GapHighlightInput): TimelineLaneGapStrips | null {
52
+ const { selectedElementId, selectedElementIds, expandedElements, tracks, gapHighlight } = input;
53
+ if (!selectedElementId || !isSingleSelection(selectedElementId, selectedElementIds)) return null;
54
+ const selected = expandedElements.find((el) => (el.key ?? el.id) === selectedElementId);
55
+ if (!selected || selected.track === gapHighlight?.track) return null;
56
+ const laneElements = tracks.find(([t]) => t === selected.track)?.[1] ?? [];
57
+ const intervals = resolveLaneEmptyIntervals(
58
+ laneElements,
59
+ input.displayDuration,
60
+ undefined,
61
+ laneGapFloor(laneElements),
62
+ );
63
+ return intervals.length > 0 ? { track: selected.track, intervals, kind: "selected" } : null;
64
+ }
65
+
66
+ /** Pure strip derivation — exported for direct unit testing. */
67
+ export function buildTimelineGapStrips(input: GapHighlightInput): TimelineLaneGapStrips[] {
68
+ if (input.dragActive) return [];
69
+ const strips: TimelineLaneGapStrips[] = [];
70
+ if (input.gapHighlight && input.gapHighlight.intervals.length > 0) {
71
+ strips.push({ ...input.gapHighlight, kind: "hover" });
72
+ }
73
+ // Single click-selection → subtle gap hint on that clip's lane.
74
+ const selected = selectedLaneStrips(input);
75
+ if (selected) strips.push(selected);
76
+ return strips;
77
+ }
78
+
79
+ export function useTimelineGapHighlights(input: GapHighlightInput): TimelineLaneGapStrips[] {
80
+ const {
81
+ gapHighlight,
82
+ tracks,
83
+ selectedElementId,
84
+ selectedElementIds,
85
+ expandedElements,
86
+ dragActive,
87
+ displayDuration,
88
+ } = input;
89
+ return useMemo(
90
+ () =>
91
+ buildTimelineGapStrips({
92
+ gapHighlight,
93
+ tracks,
94
+ selectedElementId,
95
+ selectedElementIds,
96
+ expandedElements,
97
+ dragActive,
98
+ displayDuration,
99
+ }),
100
+ [
101
+ gapHighlight,
102
+ tracks,
103
+ selectedElementId,
104
+ selectedElementIds,
105
+ expandedElements,
106
+ dragActive,
107
+ displayDuration,
108
+ ],
109
+ );
110
+ }
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { getPinchTimelineZoomPercent } from "./timelineZoom";
5
5
  import {
6
6
  GUTTER,
7
+ TRACKS_LEFT_PAD,
7
8
  getTimelinePlayheadLeft,
8
9
  getTimelineScrollLeftForZoomTransition,
9
10
  getTimelineScrollLeftForZoomAnchor,
@@ -73,7 +74,7 @@ export function useTimelinePlayhead({
73
74
  const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
74
75
  pointerX: scroll.clientWidth / 2,
75
76
  currentScrollLeft: scroll.scrollLeft,
76
- gutter: GUTTER,
77
+ gutter: GUTTER + TRACKS_LEFT_PAD,
77
78
  currentPixelsPerSecond: prevPps,
78
79
  nextPixelsPerSecond: pps,
79
80
  duration: durationRef.current,
@@ -129,7 +130,7 @@ export function useTimelinePlayhead({
129
130
  const el = scrollRef.current;
130
131
  if (!el || effectiveDuration <= 0) return;
131
132
  const rect = el.getBoundingClientRect();
132
- const x = clientX - rect.left + el.scrollLeft - GUTTER;
133
+ const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
133
134
  if (x < 0) return;
134
135
  const time = Math.max(0, Math.min(effectiveDuration, x / pps));
135
136
  liveTime.notify(time);
@@ -185,7 +186,7 @@ export function useTimelinePlayhead({
185
186
  const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({
186
187
  pointerX: e.clientX - rect.left,
187
188
  currentScrollLeft: scroll.scrollLeft,
188
- gutter: GUTTER,
189
+ gutter: GUTTER + TRACKS_LEFT_PAD,
189
190
  currentPixelsPerSecond: ppsRef.current,
190
191
  nextPixelsPerSecond: nextPps,
191
192
  duration: durationRef.current,
@@ -7,7 +7,7 @@ import {
7
7
  } from "./timelineEditing";
8
8
  import type { TimelineElement } from "../store/playerStore";
9
9
  import { liveTime, usePlayerStore } from "../store/playerStore";
10
- import { GUTTER } from "./timelineLayout";
10
+ import { GUTTER, TRACKS_LEFT_PAD } from "./timelineLayout";
11
11
  import {
12
12
  computeMarqueeSelection,
13
13
  getMarqueeRect,
@@ -228,7 +228,8 @@ export function useTimelineRangeSelection({
228
228
  setShowPopover(false);
229
229
  const rect = scrollRef.current?.getBoundingClientRect();
230
230
  if (rect) {
231
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
231
+ const x =
232
+ e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
232
233
  const time = Math.max(0, x / pps);
233
234
  rangeAnchorTime.current = time;
234
235
  setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
@@ -285,7 +286,7 @@ export function useTimelineRangeSelection({
285
286
  const el = scrollRef.current;
286
287
  if (el) {
287
288
  const rect = el.getBoundingClientRect();
288
- const x = clientX - rect.left + el.scrollLeft - GUTTER;
289
+ const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
289
290
  if (x >= 0) {
290
291
  const dur = el.scrollWidth / pps;
291
292
  liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
@@ -309,7 +310,8 @@ export function useTimelineRangeSelection({
309
310
  if (isRangeSelecting.current) {
310
311
  const rect = scrollRef.current?.getBoundingClientRect();
311
312
  if (rect) {
312
- const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
313
+ const x =
314
+ e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER - TRACKS_LEFT_PAD;
313
315
  setRangeSelection((prev) =>
314
316
  prev
315
317
  ? { ...prev, end: Math.max(0, x / pps), anchorX: e.clientX, anchorY: e.clientY }