@hyperframes/studio 0.7.56 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -1,6 +1,8 @@
1
1
  import { memo } from "react";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
3
  import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
4
+ import { usePlayerStore } from "../store/playerStore";
5
+ import { secondsToFrame } from "../lib/time";
4
6
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
5
7
 
6
8
  interface TimelineRulerProps {
@@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
26
28
  theme,
27
29
  beatAnalysis,
28
30
  }: TimelineRulerProps) {
31
+ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
29
32
  const beatTimes = beatAnalysis?.beatTimes ?? [];
30
33
  const beatStrengths = beatAnalysis?.beatStrengths ?? [];
31
34
 
@@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({
38
41
 
39
42
  return (
40
43
  <>
41
- {/* Grid lines (major ticks + beat lines) behind the tracks (background).
42
- Opaque track rows hide them; only the beat dots show on tracks. */}
44
+ {/* Background SVG beat lines only; major-tick gridlines removed so only
45
+ the ruler's own small ticks mark intervals (no full-height lines). */}
43
46
  <svg
44
47
  className="absolute pointer-events-none"
45
48
  style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
46
49
  height={totalH}
47
50
  >
48
- {major.map((t) => {
49
- const x = t * pps;
50
- return (
51
- <line
52
- key={`g-${t}`}
53
- x1={x}
54
- y1={RULER_H}
55
- x2={x}
56
- y2={totalH}
57
- stroke={theme.tickMinor}
58
- strokeWidth="1"
59
- />
60
- );
61
- })}
62
51
  {showBeats &&
63
52
  beatTimes.map((t, i) => {
64
53
  const x = t * pps;
@@ -79,41 +68,62 @@ export const TimelineRuler = memo(function TimelineRuler({
79
68
  })}
80
69
  </svg>
81
70
 
82
- {/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
83
- calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
84
- past the content when zoomed out. Ticks stay at composition coordinates. */}
71
+ {/* Ruler sticky so the timestamps stay visible while the tracks scroll
72
+ vertically. Opaque background (plus the gutter corner block) so clips
73
+ scrolling underneath don't bleed through; z-index sits above the track
74
+ rows and drag overlays but below the playhead (z 100). */}
85
75
  <div
86
- className="relative overflow-hidden"
87
- style={{
88
- height: RULER_H,
89
- marginLeft: GUTTER,
90
- width: `calc(100% - ${GUTTER}px)`,
91
- background: theme.gutterBackground,
92
- borderBottom: `1px solid ${theme.rulerBorder}`,
93
- }}
76
+ className="sticky top-0 flex"
77
+ style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
94
78
  >
95
- {minor.map((t) => (
96
- <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
97
- <div className="w-px h-2" style={{ background: theme.tickMinor }} />
98
- </div>
99
- ))}
79
+ <div
80
+ className="sticky left-0 z-[12] flex-shrink-0"
81
+ style={{
82
+ width: GUTTER,
83
+ // Ruler corner uses the panel surface — same as the ruler strip itself.
84
+ background: theme.shellBackground,
85
+ borderRight: `1px solid ${theme.gutterBorder}`,
86
+ }}
87
+ />
88
+ <div
89
+ className="relative overflow-hidden"
90
+ style={{
91
+ height: RULER_H,
92
+ width: trackContentWidth,
93
+ // Ruler background = panel surface (#0A0A0B) — no bottom border,
94
+ // no tick lines (CapCut-style clean ruler, labels only).
95
+ background: theme.shellBackground,
96
+ }}
97
+ >
98
+ {/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
99
+ t * pps — matching the playhead line, which is also centered on
100
+ GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
101
+ a tick spans [x, x+1) and its center is half a pixel right. */}
102
+ {minor.map((t) => (
103
+ <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
104
+ <div className="w-px h-2" style={{ background: theme.tickMinor }} />
105
+ </div>
106
+ ))}
100
107
 
101
- {major.map((t) => (
102
- <div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
103
- <span
104
- className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
105
- style={{
106
- color: theme.tickText,
107
- left: 5,
108
- top: 5,
109
- fontSize: 10,
110
- }}
111
- >
112
- {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
113
- </span>
114
- <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
115
- </div>
116
- ))}
108
+ {major.map((t) => (
109
+ <div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
110
+ <span
111
+ className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
112
+ style={{
113
+ color: theme.tickText,
114
+ left: 5,
115
+ top: 5,
116
+ fontSize: 10,
117
+ }}
118
+ >
119
+ {timeDisplayMode === "frame"
120
+ ? secondsToFrame(t)
121
+ : formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
122
+ </span>
123
+ <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
124
+ </div>
125
+ ))}
126
+ </div>
117
127
  </div>
118
128
  </>
119
129
  );
@@ -219,6 +219,88 @@ describe("commitDraggedClipMove", () => {
219
219
  expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
220
220
  });
221
221
 
222
+ it("persists a lane change in AUTHORED track space when the file is sparse", () => {
223
+ // Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
224
+ // (authoredTrack records the file value). Moving 'a' onto b's lane must
225
+ // write b's AUTHORED track (2) — writing the display lane (1) would target
226
+ // a's own authored row and silently no-op in the file.
227
+ const elements = [
228
+ { ...el("a", 0, 0, 3), authoredTrack: 1 },
229
+ { ...el("b", 1, 10, 3), authoredTrack: 2 },
230
+ ];
231
+ const { updateElement, onMoveElements } = runClipMove(
232
+ drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
233
+ { elements, trackOrder: [0, 1] },
234
+ );
235
+ // Store stays in display-lane space, but authoredTrack is refreshed to the
236
+ // value just written to the file so a SECOND drag before any reload resolves
237
+ // authored tracks from current data, not the stale pre-edit value.
238
+ expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
239
+ // ...while the persist is translated to the target lane's authored track.
240
+ const map = editMap(onMoveElements.mock.calls[0][0]);
241
+ expect(map.a).toEqual({ start: 20, track: 2 });
242
+ });
243
+
244
+ it("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
245
+ // Expanded sub-comp children live on synthetic display lanes next to host
246
+ // clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
247
+ // occupied by a clip from a DIFFERENT file must never lend its authored
248
+ // value. Here lane 1 holds both a host-file clip (authored 12) and a
249
+ // same-file sibling (authored 7): the sibling answers.
250
+ const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
251
+ const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
252
+ const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
253
+ const elements = [child3, child7, hostClip];
254
+ const { onMoveElements } = runClipMove(
255
+ drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
256
+ { elements, trackOrder: [0, 1] },
257
+ );
258
+ const map = editMap(onMoveElements.mock.calls[0][0]);
259
+ expect(map.c3).toEqual({ start: 0, track: 7 });
260
+ });
261
+
262
+ it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
263
+ // Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
264
+ // and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
265
+ // only another file's clip. Persisting 6 (the display integer) or 12 (the
266
+ // foreign authored value) would corrupt the sparse file; the fallback
267
+ // offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
268
+ // one lane further down → 8.
269
+ const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
270
+ const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
271
+ const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
272
+ const elements = [child3, child7, foreign];
273
+ const { onMoveElements } = runClipMove(
274
+ drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
275
+ { elements, trackOrder: [4, 5, 6] },
276
+ );
277
+ const map = editMap(onMoveElements.mock.calls[0][0]);
278
+ expect(map.c3.track).not.toBe(6); // not the display-lane integer
279
+ expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
280
+ expect(map.c3).toEqual({ start: 0, track: 8 });
281
+ });
282
+
283
+ it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
284
+ // Authored track 2 holds two time-overlapping clips, which packTrackLanes
285
+ // spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
286
+ // 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
287
+ // persisted value is the shared authored track (2), even though the clip
288
+ // may re-pack onto a different sub-lane on the next normalize.
289
+ const elements = normalizeToZones([
290
+ { ...el("a", 0, 30, 3), authoredTrack: 1 },
291
+ { ...el("b1", 2, 0, 5), authoredTrack: 2 },
292
+ { ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
293
+ ]);
294
+ expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
295
+ const spillLane = elements.find((e) => e.id === "b2")!.track;
296
+ const { onMoveElements } = runClipMove(
297
+ drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
298
+ { elements, trackOrder: [0, 1, 2] },
299
+ );
300
+ const map = editMap(onMoveElements.mock.calls[0][0]);
301
+ expect(map.a).toEqual({ start: 30, track: 2 });
302
+ });
303
+
222
304
  it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
223
305
  const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
224
306
  // Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
@@ -283,8 +365,9 @@ describe("commitDraggedClipMove", () => {
283
365
  const map = editMap(onMoveElements.mock.calls[0][0]);
284
366
  // Lanes are contiguous and distinct (no two overlapping clips share a lane).
285
367
  expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
286
- // The z-aware normalization may reverse the authored lane numbers, but the
287
- // insert must still leave three distinct, contiguous visual lanes.
368
+ expect(map.a.track).toBe(0); // above the insert unchanged
369
+ expect(map.c.track).toBe(1); // dragged clip lands on the new lane
370
+ expect(map.b.track).toBe(2); // at/below the insert → +1 shift
288
371
  });
289
372
 
290
373
  describe("lane ↔ stacking sync", () => {
@@ -440,9 +523,9 @@ describe("commitDraggedClipMove", () => {
440
523
  drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
441
524
  [0, 1],
442
525
  );
443
- // Non-overlapping clips retain their authored/z-derived ordering; a lane
444
- // gesture cannot invent a DOM stacking relationship where none overlaps.
445
- expect(lane.dragged).toBe(2);
526
+ expect(lane.dragged).toBe(0); // aimed at the very top
527
+ expect(lane.top).toBe(1);
528
+ expect(lane.mid).toBe(2);
446
529
  });
447
530
 
448
531
  it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
@@ -453,8 +536,8 @@ describe("commitDraggedClipMove", () => {
453
536
  [0, 1, 2],
454
537
  );
455
538
  expect(lane.a).toBe(0);
456
- expect(lane.b).toBe(1);
457
- expect(lane.x).toBe(2);
539
+ expect(lane.x).toBe(1); // between a and b, as aimed
540
+ expect(lane.b).toBe(2);
458
541
  });
459
542
 
460
543
  it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
@@ -466,7 +549,7 @@ describe("commitDraggedClipMove", () => {
466
549
  drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
467
550
  [0, 1, 2],
468
551
  );
469
- expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
552
+ expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
470
553
  });
471
554
 
472
555
  it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
@@ -13,6 +13,13 @@ type StartTrack = Pick<TimelineElement, "start" | "track">;
13
13
  export interface TimelineMoveEdit {
14
14
  element: TimelineElement;
15
15
  updates: StartTrack;
16
+ /**
17
+ * File-space track override for the persist. The store's `updates.track` is a
18
+ * DISPLAY lane; when the source file's numbering is sparse (authored tracks
19
+ * 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
20
+ * silently re-targets the wrong row. Omitted → persist `updates.track` as-is.
21
+ */
22
+ persistTrack?: number;
16
23
  }
17
24
 
18
25
  export interface DragCommitDeps {
@@ -114,21 +121,41 @@ function persistMoveEdits(
114
121
  key: keyOf(e.element),
115
122
  start: e.element.start,
116
123
  track: e.element.track,
124
+ authoredTrack: e.element.authoredTrack,
117
125
  }));
118
126
  const revision = beginTimelineOptimisticGesture(
119
127
  updateElement,
120
128
  edits.map((edit) => keyOf(edit.element)),
121
129
  );
122
- for (const e of edits) updateElement(keyOf(e.element), e.updates);
130
+ // The file write below targets `persistTrack` (authored space) when supplied,
131
+ // or `updates.track` on a genuine lane write (track insert renumber). Mirror
132
+ // that written value into the store's `authoredTrack` so a SECOND drag before
133
+ // any reload resolves authored tracks from what the file now says, not stale
134
+ // pre-edit data. Pure time-moves leave authoredTrack untouched.
135
+ for (const e of edits) {
136
+ const writtenTrack =
137
+ e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
138
+ updateElement(
139
+ keyOf(e.element),
140
+ writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
141
+ );
142
+ }
143
+ // The store above gets DISPLAY lanes; the file below gets the authored-space
144
+ // track when one was resolved (see TimelineMoveEdit.persistTrack).
145
+ const persistEdits = edits.map((e) =>
146
+ e.persistTrack == null || e.persistTrack === e.updates.track
147
+ ? e
148
+ : { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
149
+ );
123
150
  const persisted = onMoveElements
124
- ? onMoveElements(edits, coalesceKey, operation)
125
- : Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
151
+ ? onMoveElements(persistEdits, coalesceKey, operation)
152
+ : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
126
153
  return Promise.resolve(persisted).then(
127
154
  () => true,
128
155
  (error) => {
129
156
  for (const p of prev) {
130
157
  if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
131
- updateElement(p.key, { start: p.start, track: p.track });
158
+ updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
132
159
  }
133
160
  }
134
161
  console.error("[Timeline] Failed to persist clip edits", error);
@@ -143,6 +170,56 @@ function persistMoveEdits(
143
170
  * then compacts it to a distinct integer lane between its neighbours, and the
144
171
  * clips at/below the insert shift down by one — the sanctioned index-renumber.
145
172
  */
173
+ /** Same-source-file predicate: authored track numbers only compare within ONE
174
+ * file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
175
+ * file, not the host timeline's). `undefined` means the active composition. */
176
+ const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
177
+ (a.sourceFile ?? null) === (b.sourceFile ?? null);
178
+
179
+ /**
180
+ * Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
181
+ * `dragged`. Occupants are consulted ONLY from the dragged clip's own source
182
+ * file — an occupant from a different file (e.g. an expanded sub-comp child, or
183
+ * a host clip next to expanded rows) carries authored values in a different
184
+ * coordinate space, and borrowing them would write a foreign file's numbering.
185
+ *
186
+ * Lane semantics after normalizeToZones: each distinct authored track owns one
187
+ * base lane, and time-overlapping same-track clips spill onto adjacent display
188
+ * sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
189
+ * trackOrder lists it): its occupants share the base lane's authored track by
190
+ * construction, so the same-file occupant lookup returns that authored track and
191
+ * the drop persists as a same-track join. The clip may then DISPLAY on a
192
+ * different sub-lane than it was dropped on — the spill re-packs
193
+ * deterministically by stable id, first-fit — but the persisted track is
194
+ * correct.
195
+ *
196
+ * Fallbacks when the lane has no same-file occupant (e.g. an expanded child
197
+ * dropped on a lane holding only other files' clips — the display-lane integer
198
+ * must NOT be persisted into a sparse file):
199
+ * 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
200
+ * preserving "one lane up = one authored track up" in the clip's own file.
201
+ * 2. No same-file peers at all → the lane value itself (single-clip files:
202
+ * display and authored spaces coincide for want of any other anchor).
203
+ * Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
204
+ * never here.
205
+ */
206
+ function authoredTrackForLane(
207
+ lane: number,
208
+ elements: TimelineElement[],
209
+ dragged: TimelineElement,
210
+ ): number {
211
+ const dragKey = keyOf(dragged);
212
+ const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
213
+ const occupant = peers.find((e) => e.track === lane);
214
+ if (occupant) return occupant.authoredTrack ?? occupant.track;
215
+ let nearest: TimelineElement | null = null;
216
+ for (const p of peers) {
217
+ if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
218
+ }
219
+ if (!nearest) return lane;
220
+ return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
221
+ }
222
+
146
223
  function insertTrackValue(trackOrder: number[], insertRow: number): number {
147
224
  if (trackOrder.length === 0) return 0;
148
225
  if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -251,6 +328,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
251
328
  const dragEdit: TimelineMoveEdit = {
252
329
  element: drag.element,
253
330
  updates: { start: drag.previewStart, track: drag.previewTrack },
331
+ persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
254
332
  };
255
333
  const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
256
334
 
@@ -265,9 +343,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
265
343
  // The drop-intent set for the z-sync: the dragged clip at its new lane, others
266
344
  // as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
267
345
  // move; computeStackingPatches only compares lanes relatively.
268
- const candidate = elements.map((e) =>
269
- keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e,
270
- );
346
+ const candidate = elements.map((e) => {
347
+ if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
348
+ // Selection members shift in time with the drag — the z-sync must reason on
349
+ // their POST-move overlap sets, same as the insert branch's candidate.
350
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
351
+ return e;
352
+ });
271
353
  const multiKeys = multi ? multi.keys : null;
272
354
  void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
273
355
  if (moved && isVertical) {
@@ -292,6 +374,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
292
374
  * The whole affected set is persisted atomically (single undo), and the deliberate
293
375
  * vertical move syncs the dragged clip's stacking afterwards.
294
376
  */
377
+ // fallow-ignore-next-line complexity
295
378
  function commitTrackInsert(
296
379
  drag: DraggedClipState,
297
380
  deps: DragCommitDeps,
@@ -314,12 +397,25 @@ function commitTrackInsert(
314
397
  // shifts the at/below clips down by one — the sanctioned +1 index renumber.
315
398
  const normalized = normalizeToZones(candidate);
316
399
  const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
400
+ // The renumber is only correct as a WHOLE-SET write: skipping an unwritable
401
+ // clip whose lane shifts leaves its track colliding with a renumbered
402
+ // neighbour, and the next normalize merges the two lanes. If any shifted clip
403
+ // can't be written, refuse the insert instead of persisting a broken layout.
404
+ for (const norm of normalized) {
405
+ const src = bySrc.get(keyOf(norm));
406
+ if (src && !canMoveElement(src) && norm.track !== src.track) {
407
+ console.warn(
408
+ `[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
409
+ );
410
+ return;
411
+ }
412
+ }
317
413
  const edits: TimelineMoveEdit[] = [];
318
414
  for (const norm of normalized) {
319
415
  const src = bySrc.get(keyOf(norm));
320
416
  if (!src) continue;
321
- // Capabilities gate: never write a locked/implicit clip, even one only swept
322
- // along by the renumber (not just a marquee member).
417
+ // Capabilities gate (unchanged-lane clips only reach here now): never write
418
+ // a locked/implicit clip.
323
419
  if (!canMoveElement(src)) continue;
324
420
  const start =
325
421
  keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
@@ -391,6 +487,7 @@ function syncStackingForEdit(
391
487
  zIndex: readZIndex(el),
392
488
  isAudio: classifyZone(el) === "audio",
393
489
  domIndex,
490
+ stackingContextId: el.stackingContextId ?? null,
394
491
  }));
395
492
 
396
493
  const editedKeys = [dragKey];
@@ -67,51 +67,105 @@ export const DRAG_EXTEND_MARGIN_PX = 160;
67
67
  * with 60s of ruler after it.
68
68
  */
69
69
  export const MIN_TIMELINE_EXTENT_S = 60;
70
+ /**
71
+ * Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
72
+ * duration — onto the viewport, so the composition ends at ~83% of the width
73
+ * and the trailing ~17% stays empty ruler + droppable lane surface (room to
74
+ * drag clips past the current end without first zooming out). Applied ONLY
75
+ * inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
76
+ * lanes, playhead, marquee, and drag math all inherit it consistently. Manual
77
+ * zoom percentages stay defined relative to this fit basis (100% == fit).
78
+ */
79
+ export const FIT_ZOOM_HEADROOM = 1.2;
70
80
 
71
81
  /* ── Tick generation ──────────────────────────────────────────────── */
72
- function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
73
- const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
82
+ // fallow-ignore-next-line complexity
83
+ function getMajorTickInterval(
84
+ duration: number,
85
+ pixelsPerSecond?: number,
86
+ frameRate?: number,
87
+ ): number {
88
+ // "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
89
+ // minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
90
+ // readable (non-colliding) labels instead of the old 10m fallback everywhere.
91
+ const zoomIntervals = [
92
+ 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
93
+ ];
94
+ let interval: number;
74
95
  if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
75
96
  const targetMajorPx = 88;
76
- return (
77
- zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
78
- );
97
+ interval =
98
+ zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
99
+ 3600;
100
+ } else {
101
+ const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
102
+ const target = duration / 6;
103
+ interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
79
104
  }
80
- const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
81
- const target = duration / 6;
82
- return durationIntervals.find((interval) => interval >= target) ?? 60;
105
+ // Frame display mode: labels are frame numbers, so a major step must be a
106
+ // WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
107
+ // (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
108
+ // the label spacing never drops below the readability target.
109
+ if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
110
+ const fps = frameRate ?? 0;
111
+ return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
112
+ }
113
+ return interval;
83
114
  }
84
115
 
85
116
  // How many equal parts to split each major interval into for minor ticks. Prefer
86
117
  // quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
87
- // none (0) as ticks get too dense to read (< ~8px apart).
88
- function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
118
+ // none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
119
+ // the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
120
+ // sub-frame time is not a seekable position), so only divisors of the major
121
+ // step's frame count qualify — quarters, then fifths (15/30-frame majors),
122
+ // thirds, halves.
123
+ // fallow-ignore-next-line complexity
124
+ function getMinorSubdivisions(
125
+ majorInterval: number,
126
+ pixelsPerSecond?: number,
127
+ frameRate?: number,
128
+ ): number {
89
129
  const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
90
130
  if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
91
- if ((majorInterval / 4) * pps >= 8) return 4;
92
- if ((majorInterval / 2) * pps >= 8) return 2;
131
+ const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
132
+ const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
133
+ const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
134
+ for (const parts of candidates) {
135
+ if (fps > 0 && majorFrames % parts !== 0) continue;
136
+ if ((majorInterval / parts) * pps >= 8) return parts;
137
+ }
93
138
  return 0;
94
139
  }
95
140
 
141
+ // Ticks are exact multiples of the interval (multiplied per index, never
142
+ // accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
143
+ // keep values/keys clean without disturbing frame-exact positions like 2/30s.
144
+ function roundTickValue(t: number): number {
145
+ return Math.round(t * 1e6) / 1e6;
146
+ }
147
+
96
148
  export function generateTicks(
97
149
  duration: number,
98
150
  pixelsPerSecond?: number,
151
+ frameRate?: number,
99
152
  ): { major: number[]; minor: number[] } {
100
- if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
153
+ if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
101
154
  return { major: [], minor: [] };
102
- const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
103
- const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
155
+ const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
156
+ const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
104
157
  const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
105
158
  const major: number[] = [];
106
159
  const minor: number[] = [];
107
160
  const maxTicks = 2000; // Safety cap to prevent runaway tick generation
108
- for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
109
- const rounded = Math.round(t * 100) / 100;
110
- major.push(rounded);
161
+ for (let i = 0; major.length < maxTicks; i++) {
162
+ const t = i * majorInterval;
163
+ if (t > duration + 0.001) break;
164
+ major.push(roundTickValue(t));
111
165
  // Emit the (subdivisions - 1) minor ticks between this major and the next.
112
166
  for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
113
- const m = Math.round((t + k * minorInterval) * 100) / 100;
114
- if (m <= duration + 0.001) minor.push(m);
167
+ const m = t + k * minorInterval;
168
+ if (m <= duration + 0.001) minor.push(roundTickValue(m));
115
169
  }
116
170
  }
117
171
  return { major, minor };
@@ -144,15 +198,17 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
144
198
 
145
199
  /* ── Width / duration derivation ──────────────────────────────────── */
146
200
  /**
147
- * Fit-mode pixels-per-second: fill the viewport with the composition, but
148
- * never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
149
- * takes a fraction of the width and the remaining ruler runs to 1:00.
201
+ * Fit-mode pixels-per-second: fill the viewport with the composition plus
202
+ * FIT_ZOOM_HEADROOM trailing headroom (CapCut-style the comp never slams
203
+ * into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
204
+ * seconds onto it — a short comp takes a fraction of the width and the
205
+ * remaining ruler runs to 1:00.
150
206
  * Manual zoom multiplies this base, so the floor only anchors the default.
151
207
  */
152
208
  export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
153
209
  const safeDuration =
154
210
  Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
155
- const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
211
+ const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
156
212
  if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
157
213
  return (viewportWidth - GUTTER - 2) / span;
158
214
  }
@@ -227,9 +283,26 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
227
283
  }
228
284
 
229
285
  /* ── Playhead / canvas ────────────────────────────────────────────── */
286
+ /**
287
+ * Width of the playhead wrapper element (== the diamond head chip's layout
288
+ * width, which the wrapper shrink-wraps to). The 1px vertical line inside
289
+ * PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
290
+ * shifted LEFT by half this width for the line's center to land exactly on
291
+ * `GUTTER + time * pps` — see {@link getTimelinePlayheadLeft}.
292
+ */
293
+ export const PLAYHEAD_HEAD_W = 9;
294
+
295
+ /**
296
+ * The `left` for the playhead WRAPPER such that the vertical line's CENTER
297
+ * sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
298
+ * on) at every zoom level. Without the half-head offset the line sat
299
+ * `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
300
+ */
230
301
  export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
231
- if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
232
- return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
302
+ if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
303
+ return GUTTER - PLAYHEAD_HEAD_W / 2;
304
+ }
305
+ return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
233
306
  }
234
307
 
235
308
  export function getTimelineCanvasHeight(trackCount: number): number {