@hyperframes/studio 0.8.93 → 0.8.95

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 (49) hide show
  1. package/dist/assets/{hyperframes-player-CkV9KK6A.js → hyperframes-player-DJJKab4_.js} +1 -1
  2. package/dist/assets/{index-V3fzEfOD.js → index-C1dXTZvs.js} +1 -1
  3. package/dist/assets/{index-CYk0NwSv.js → index-Cc4by8r7.js} +183 -183
  4. package/dist/assets/{index-BlLFipOk.js → index-DE1dn3QB.js} +1 -1
  5. package/dist/index.d.ts +63 -46
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +196 -111
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/TimelineToolbar.test.tsx +17 -0
  11. package/src/components/TimelineToolbar.tsx +3 -1
  12. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +2 -0
  13. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -1
  14. package/src/components/editor/DomEditCropHandles.test.tsx +87 -1
  15. package/src/components/editor/DomEditCropHandles.tsx +64 -62
  16. package/src/components/editor/DomEditOverlay.test.ts +39 -0
  17. package/src/components/editor/DomEditOverlay.tsx +6 -1
  18. package/src/components/editor/DomEditSelectionChrome.test.tsx +114 -1
  19. package/src/components/editor/DomEditSelectionChrome.tsx +17 -10
  20. package/src/components/editor/PreviewOverlayProvider.test.tsx +19 -0
  21. package/src/components/editor/PreviewOverlayProvider.tsx +1 -1
  22. package/src/components/editor/SnapToolbar.tsx +1 -1
  23. package/src/components/editor/domEditOverlayGeometry.ts +2 -0
  24. package/src/components/editor/manualEditsSeekReapply.ts +5 -1
  25. package/src/components/nle/PreviewOverlays.tsx +3 -3
  26. package/src/hooks/gsapResizeIntercept.test.ts +40 -0
  27. package/src/hooks/gsapResizeIntercept.ts +8 -4
  28. package/src/index.ts +4 -0
  29. package/src/player/components/PlayerControls.tsx +2 -15
  30. package/src/player/components/ShortcutsPanel.test.tsx +33 -1
  31. package/src/player/components/ShortcutsPanel.tsx +26 -0
  32. package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
  33. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
  34. package/src/player/components/Timeline.test.ts +3 -3
  35. package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
  36. package/src/player/components/TimelineCanvas.tsx +44 -19
  37. package/src/player/components/TimelineProvider.tsx +1 -0
  38. package/src/player/components/TimelineTypes.ts +2 -0
  39. package/src/player/components/timelineDragDrop.test.tsx +14 -12
  40. package/src/player/components/timelineLayout.test.ts +87 -11
  41. package/src/player/components/timelineLayout.ts +28 -21
  42. package/src/player/components/useTimelineProviderState.tsx +5 -1
  43. package/src/player/components/useTimelineTrackLayout.ts +11 -2
  44. package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
  45. package/src/player/components/useTimelineVirtualRows.ts +2 -2
  46. package/src/utils/gsapSoftReload.test.ts +51 -2
  47. package/src/utils/gsapSoftReload.ts +24 -10
  48. package/src/utils/gsapUndoRestore.test.ts +49 -0
  49. package/src/utils/gsapUndoRestore.ts +15 -0
@@ -0,0 +1,157 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
+ import { usePlayerStore } from "../store/playerStore";
9
+ import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
10
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
11
+ import type { TimelineProps } from "./TimelineTypes";
12
+
13
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ installTimelineMountEnv();
16
+
17
+ afterEach(() => {
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ async function mountCanvas(trackPadding?: TimelineProps["trackPadding"]) {
22
+ usePlayerStore.setState({
23
+ duration: 10,
24
+ currentTime: 0,
25
+ timelineReady: true,
26
+ gsapAnimations: new Map(),
27
+ elements: [{ id: "card", label: "Card", tag: "div", start: 0, duration: 4, track: 0 }],
28
+ });
29
+ const host = document.createElement("div");
30
+ document.body.append(host);
31
+ const root = createRoot(host);
32
+ await act(async () => root.render(<Timeline trackPadding={trackPadding} />));
33
+ const canvas = Array.from(host.querySelectorAll<HTMLElement>("div.relative")).find(
34
+ (el) => el.style.height && el.style.width,
35
+ );
36
+ const height = canvas?.style.height;
37
+ const firstRowTop = host.querySelector<HTMLElement>('[data-timeline-row="0"]')?.style.top;
38
+ act(() => root.unmount());
39
+ return { height, firstRowTop };
40
+ }
41
+
42
+ describe("Timeline trackPadding", () => {
43
+ it("keeps Studio's pads by default", async () => {
44
+ expect(await mountCanvas()).toEqual({
45
+ height: `${RULER_H + TRACKS_TOP_PAD + TRACK_H + TRACKS_BOTTOM_PAD}px`,
46
+ firstRowTop: `${RULER_H + TRACKS_TOP_PAD}px`,
47
+ });
48
+ });
49
+
50
+ it("places the first row and sizes the canvas from the host's pads", async () => {
51
+ expect(await mountCanvas({ top: 0, bottom: TRACK_H })).toEqual({
52
+ height: `${RULER_H + 2 * TRACK_H}px`,
53
+ firstRowTop: `${RULER_H}px`,
54
+ });
55
+ });
56
+
57
+ // With no top pad, y = 96 is the middle of row 1; any reader still on the default pad sees row 0.
58
+ const ROW1_MID = RULER_H + TRACK_H + TRACK_H / 2;
59
+ const hostPads = { top: 0, bottom: TRACK_H };
60
+
61
+ async function mountThreeTracks(props: TimelineProps = {}) {
62
+ usePlayerStore.setState({
63
+ duration: 10,
64
+ currentTime: 0,
65
+ timelineReady: true,
66
+ gsapAnimations: new Map(),
67
+ selectedElementId: null,
68
+ selectedElementIds: new Set(),
69
+ elements: [0, 1, 2].map((track) => ({
70
+ id: `c${track}`,
71
+ key: `c${track}`,
72
+ domId: `c${track}`,
73
+ tag: "div",
74
+ start: 0,
75
+ duration: 2,
76
+ track,
77
+ })),
78
+ });
79
+ const host = document.createElement("div");
80
+ document.body.append(host);
81
+ const root = createRoot(host);
82
+ await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
83
+ const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
84
+ return { host, root, viewport };
85
+ }
86
+
87
+ const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
88
+
89
+ it("draws the gap strip, drop preview and insert line, and drops, on the host's rows", async () => {
90
+ const onAssetDrop = vi.fn();
91
+ const { host, root, viewport } = await mountThreeTracks({ onAssetDrop });
92
+ act(() => usePlayerStore.getState().setSelectedElementId("c1"));
93
+ expect(top(host.querySelector('[style*="--timeline-accent-faint"]'))).toBe(
94
+ `${RULER_H + TRACK_H + CLIP_Y}px`,
95
+ );
96
+
97
+ const drag = (type: string, clientY: number) => {
98
+ const event = new Event(type, { bubbles: true, cancelable: true });
99
+ Object.defineProperties(event, {
100
+ clientX: { value: 400 },
101
+ clientY: { value: clientY },
102
+ dataTransfer: {
103
+ value: {
104
+ types: [TIMELINE_ASSET_MIME],
105
+ files: [],
106
+ dropEffect: "none",
107
+ getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
108
+ },
109
+ },
110
+ });
111
+ act(() => void viewport.dispatchEvent(event));
112
+ };
113
+ drag("dragover", ROW1_MID);
114
+ expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
115
+ `${RULER_H + TRACK_H + CLIP_Y}px`,
116
+ );
117
+ drag("dragover", RULER_H / 2);
118
+ expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
119
+ `${RULER_H - 0.5}px`,
120
+ );
121
+ drag("drop", ROW1_MID);
122
+ expect(onAssetDrop).toHaveBeenCalledWith("a.png", expect.objectContaining({ track: 1 }));
123
+ act(() => root.unmount());
124
+ });
125
+
126
+ it("drags a clip onto the host's rows and opens a new track in the bottom pad", async () => {
127
+ const { host, root } = await mountThreeTracks({
128
+ onMoveElement: vi.fn(),
129
+ onResizeElement: vi.fn(),
130
+ });
131
+ const pointer = (target: EventTarget, type: string, clientY: number) =>
132
+ act(
133
+ () =>
134
+ void target.dispatchEvent(
135
+ new PointerEvent(type, {
136
+ bubbles: true,
137
+ button: 0,
138
+ pointerId: 1,
139
+ clientX: 400,
140
+ clientY,
141
+ }),
142
+ ),
143
+ );
144
+ const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
145
+ pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
146
+ pointer(window, "pointermove", ROW1_MID);
147
+ const ghost = Array.from(
148
+ host.querySelectorAll<HTMLElement>(".absolute.pointer-events-none"),
149
+ ).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
150
+ expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
151
+ pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
152
+ expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
153
+ `${RULER_H + 3 * TRACK_H - 0.5}px`,
154
+ );
155
+ act(() => root.unmount());
156
+ });
157
+ });
@@ -2,16 +2,7 @@ import { memo } from "react";
2
2
  import { TimelineRulerPart } from "./TimelineRulerPart";
3
3
  import { PlayheadIndicator } from "./PlayheadIndicator";
4
4
  import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
5
- import {
6
- RULER_H,
7
- CLIP_Y,
8
- TRACKS_TOP_PAD,
9
- TRACKS_BOTTOM_PAD,
10
- TRACK_H,
11
- PLAYHEAD_HEAD_W,
12
- getTimelineRowTop,
13
- getTimelineRowHeight,
14
- } from "./timelineLayout";
5
+ import { RULER_H, CLIP_Y, TRACK_H, PLAYHEAD_HEAD_W, getTimelineRowHeight } from "./timelineLayout";
15
6
  import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform";
16
7
  import { TimelineLanes } from "./TimelineLanes";
17
8
  import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
@@ -33,6 +24,13 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
33
24
  const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
34
25
  const insertLineRow =
35
26
  (draggedClip?.started ? draggedClip.insertRow : null) ?? props.dropPreview?.insertRow ?? null;
27
+ const ghostLaneActive =
28
+ (insertLineRow ?? -1) >= props.trackOrder.length ||
29
+ (props.dropPreview?.insertRow == null && props.dropPreview != null && dropTrackIndex < 0);
30
+ // A host bottom pad shorter than a track leaves no lane to draw.
31
+ const showGhostLane = props.rowGeometry.padding.bottom >= TRACK_H;
32
+ // Past the last committed track: a drag's new bottom-track preview row is the lane itself.
33
+ const ghostLaneRow = props.rowGeometry.getRowIndex(props.trackOrder.at(-1) ?? Number.NaN) + 1;
36
34
  const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
37
35
  // A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
38
36
  // occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
@@ -49,8 +47,11 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
49
47
  <TimelineRulerPart />
50
48
 
51
49
  {/* Breathing room between the sticky ruler and the first track lane — the
52
- top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
53
- <div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />
50
+ top half of the CapCut-style padding (see TimelineRowGeometry.padding). */}
51
+ <div
52
+ aria-hidden="true"
53
+ style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.top }}
54
+ />
54
55
 
55
56
  <TimelineLanes
56
57
  {...props}
@@ -67,10 +68,34 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
67
68
  onRazorSplitAll={props.onRazorSplitAll}
68
69
  />
69
70
 
70
- {/* Breathing room below the last track lane (~1.5 track heights) — a real
71
+ {/* Breathing room below the last track lane (one track by default) — a real
71
72
  scrollable surface, so a clip can be dragged into the void to create a
72
- new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
73
- <div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />
73
+ new bottom track (see TimelineRowGeometry.padding / canvasHeight). */}
74
+ <div
75
+ aria-hidden="true"
76
+ style={{ height: props.rowsVirtualized ? 0 : props.rowGeometry.padding.bottom }}
77
+ />
78
+
79
+ {showGhostLane && (
80
+ <div
81
+ aria-hidden="true"
82
+ data-testid="timeline-ghost-lane"
83
+ data-active={ghostLaneActive || undefined}
84
+ className={`pointer-events-none absolute flex items-center justify-center rounded-lg border border-dashed text-[11px] transition-colors duration-150 ${
85
+ ghostLaneActive
86
+ ? "border-studio-accent/60 bg-studio-accent/6 text-studio-accent"
87
+ : "border-neutral-700/50 text-neutral-500"
88
+ }`}
89
+ style={{
90
+ top: props.rowGeometry.getRowTop(ghostLaneRow) + CLIP_Y,
91
+ left: props.contentOrigin,
92
+ width: props.trackContentWidth,
93
+ height: TRACK_H - CLIP_Y * 2,
94
+ }}
95
+ >
96
+ {props.acceptsMediaDrop && "Drop media here"}
97
+ </div>
98
+ )}
74
99
 
75
100
  {/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
76
101
  menu row would collapse; a quiet tint for every gap on the selected
@@ -91,7 +116,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
91
116
  key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
92
117
  className="pointer-events-none absolute"
93
118
  style={{
94
- top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y,
119
+ top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
95
120
  left: props.contentOrigin + gap.start * props.pps,
96
121
  width: Math.max((gap.end - gap.start) * props.pps, 2),
97
122
  height: TRACK_H - CLIP_Y * 2,
@@ -109,7 +134,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
109
134
  <div
110
135
  className="absolute pointer-events-none"
111
136
  style={{
112
- top: getTimelineRowTop(draggedRowIndex, props.rowHeights) + CLIP_Y,
137
+ top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
113
138
  left: props.contentOrigin + draggedClip.previewStart * props.pps,
114
139
  width: Math.max(draggedClip.element.duration * props.pps, 4),
115
140
  height: draggedClipHeight,
@@ -129,7 +154,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
129
154
  data-testid="timeline-drop-preview"
130
155
  className="absolute pointer-events-none"
131
156
  style={{
132
- top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
157
+ top: props.rowGeometry.getRowTop(dropRowIndex) + CLIP_Y,
133
158
  left: props.contentOrigin + props.dropPreview.start * props.pps,
134
159
  width: DROP_PREVIEW_SECONDS * props.pps,
135
160
  height: TRACK_H - CLIP_Y * 2,
@@ -148,7 +173,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
148
173
  data-testid="timeline-insert-line"
149
174
  className="absolute pointer-events-none"
150
175
  style={{
151
- top: getTimelineRowTop(insertLineRow, props.rowHeights) - 0.5,
176
+ top: props.rowGeometry.getRowTop(insertLineRow) - 0.5,
152
177
  left: props.contentOrigin,
153
178
  width: props.trackContentWidth,
154
179
  height: 1,
@@ -51,6 +51,7 @@ export type TimelineCanvasState = Omit<
51
51
  playheadRef: React.RefObject<HTMLDivElement | null>;
52
52
  laneGapStrips: TimelineLaneGapStrips[];
53
53
  dropPreview: TimelineDropPlacement | null;
54
+ acceptsMediaDrop: boolean;
54
55
  setRangeSelection: (value: TimelineRangeSelection | null) => void;
55
56
  setResizingClip: (value: ResizingClipState | null) => void;
56
57
  setDraggedClip: (value: TimelineLaneBaseProps["draggedClip"]) => void;
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import type { TimelineTimeRange } from "../store/rangeSelectionSlice";
4
4
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
5
5
  import type { TimelineTheme } from "./timelineTheme";
6
+ import type { TimelineTrackPadding } from "./timelineLayout";
6
7
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
7
8
  import type { TimelineStackingSyncProps } from "./useTimelineStackingSync";
8
9
 
@@ -35,4 +36,5 @@ export interface TimelineProps
35
36
  theme?: Partial<TimelineTheme>;
36
37
  showAudioEffects?: boolean;
37
38
  showKeyframes?: boolean;
39
+ trackPadding?: TimelineTrackPadding;
38
40
  }
@@ -12,6 +12,8 @@ import { configureTimelineTestViewport } from "./timelineTestViewport";
12
12
 
13
13
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
14
 
15
+ const ROW0_MID_Y = getTimelineRowTop(0) + TRACK_H / 2;
16
+
15
17
  interface DropTransfer {
16
18
  types: string[];
17
19
  files: File[];
@@ -124,7 +126,7 @@ describe("useTimelineAssetDrop", () => {
124
126
  const parent = document.createElement("div");
125
127
  const child = document.createElement("div");
126
128
  parent.append(child);
127
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
129
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
128
130
  act(() =>
129
131
  view.api.handleAssetDragLeave({
130
132
  relatedTarget: child,
@@ -160,8 +162,8 @@ describe("useTimelineAssetDrop", () => {
160
162
  const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
161
163
 
162
164
  act(() => {
163
- view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
164
- view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
165
+ view.api.handleAssetDragOver(dragEvent(transfer, 80, ROW0_MID_Y));
166
+ view.api.handleAssetDrop(dragEvent(transfer, 80, ROW0_MID_Y));
165
167
  });
166
168
 
167
169
  // pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
@@ -175,12 +177,12 @@ describe("useTimelineAssetDrop", () => {
175
177
  const view = renderHarness(onAssetDrop, 1);
176
178
  const transfer = assetTransfer("not-json");
177
179
 
178
- act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, 100)));
180
+ act(() => view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y)));
179
181
  expect(view.api.isDragOver).toBe(true);
180
182
  view.rerender(2);
181
183
  expect(view.api.isDragOver).toBe(false);
182
184
 
183
- act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, 100)));
185
+ act(() => view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y)));
184
186
  expect(onAssetDrop).not.toHaveBeenCalled();
185
187
  act(() => view.root.unmount());
186
188
  });
@@ -202,8 +204,8 @@ describe("useTimelineAssetDrop", () => {
202
204
  };
203
205
 
204
206
  act(() => {
205
- view.api.handleAssetDragOver(dragEvent(transfer, 400, 100));
206
- view.api.handleAssetDrop(dragEvent(transfer, 400, 100));
207
+ view.api.handleAssetDragOver(dragEvent(transfer, 400, ROW0_MID_Y));
208
+ view.api.handleAssetDrop(dragEvent(transfer, 400, ROW0_MID_Y));
207
209
  });
208
210
 
209
211
  expect(onAssetDrop).not.toHaveBeenCalled();
@@ -214,7 +216,7 @@ describe("useTimelineAssetDrop", () => {
214
216
 
215
217
  it("clears an escaped drag after StrictMode effect replay", () => {
216
218
  const view = renderHarness(vi.fn(), 1, { strict: true });
217
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
219
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
218
220
  expect(view.api.isDragOver).toBe(true);
219
221
 
220
222
  act(() => window.dispatchEvent(new Event("dragend")));
@@ -227,11 +229,11 @@ describe("useTimelineAssetDrop", () => {
227
229
  const view = renderHarness(onAssetDrop);
228
230
  const payload = JSON.stringify({ path: "assets/a.png" });
229
231
 
230
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
232
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
231
233
  const preview = view.api.dropPreview;
232
234
  expect(preview).toEqual({ start: 10, track: 0 });
233
235
 
234
- act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
236
+ act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, ROW0_MID_Y)));
235
237
  expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
236
238
  expect(view.api.dropPreview).toBeNull();
237
239
  act(() => view.root.unmount());
@@ -239,8 +241,8 @@ describe("useTimelineAssetDrop", () => {
239
241
 
240
242
  it("moves the preview with the pointer and drops it when the drag leaves", () => {
241
243
  const view = renderHarness(vi.fn());
242
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
243
- act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
244
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, ROW0_MID_Y)));
245
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, ROW0_MID_Y)));
244
246
  expect(view.api.dropPreview?.start).toBe(20);
245
247
 
246
248
  act(() =>
@@ -19,6 +19,7 @@ import {
19
19
  getTimelineBeatEntries,
20
20
  } from "./timelineLayout";
21
21
  import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry";
22
+ import { resolveInsertRow } from "./timelineCollision";
22
23
  import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
23
24
 
24
25
  describe("horizontal timeline window", () => {
@@ -124,27 +125,27 @@ describe("variable timeline row geometry", () => {
124
125
 
125
126
  describe("collapsed timeline row geometry characterization", () => {
126
127
  it.each([
127
- [0, 74],
128
- [1, 122],
129
- [4, 266],
128
+ [0, 32],
129
+ [1, 80],
130
+ [4, 224],
130
131
  ])("keeps row %i at content y=%i", (row, expectedTop) => {
131
132
  expect(getTimelineRowTop(row)).toBe(expectedTop);
132
133
  });
133
134
 
134
135
  it.each([
135
- [74, 0],
136
- [86, 0.25],
137
- [146, 1.5],
138
- [290, 4.5],
136
+ [32, 0],
137
+ [44, 0.25],
138
+ [104, 1.5],
139
+ [248, 4.5],
139
140
  ])("maps content y=%i to fractional row %f", (contentY, expectedRow) => {
140
141
  expect(getTimelineRowFromY(contentY)).toBe(expectedRow);
141
142
  });
142
143
 
143
144
  it.each([
144
- [0, 146],
145
- [1, 194],
146
- [3, 290],
147
- [5, 386],
145
+ [0, 80],
146
+ [1, 128],
147
+ [3, 224],
148
+ [5, 320],
148
149
  ])("keeps the %i-track canvas height at %i", (trackCount, expectedHeight) => {
149
150
  expect(getTimelineCanvasHeight(baseRows(trackCount))).toBe(expectedHeight);
150
151
  });
@@ -247,6 +248,81 @@ describe("track-area breathing pad y-math", () => {
247
248
  });
248
249
  });
249
250
 
251
+ describe("host track padding", () => {
252
+ const geometry = createTimelineRowGeometry([0, 1, 2], baseRows(3), { top: 0, bottom: TRACK_H });
253
+ // Row 1 with no top pad, but still row 0 if any reader keeps the default pad.
254
+ const yInRow1 = RULER_H + TRACK_H + 10;
255
+
256
+ it("moves the first row up and sizes the canvas from the host pads", () => {
257
+ expect(geometry.getRowTop(0)).toBe(RULER_H);
258
+ expect(getTimelineRowTop(1, geometry.rowHeights)).toBe(RULER_H + TRACK_H);
259
+ expect(getTimelineCanvasHeight(geometry.rowHeights)).toBe(RULER_H + 4 * TRACK_H);
260
+ });
261
+
262
+ it("maps a drag pointer y to the row under it", () => {
263
+ expect(Math.floor(geometry.getRowFromY(yInRow1))).toBe(1);
264
+ expect(Math.floor(getTimelineRowFromY(yInRow1, geometry.rowHeights))).toBe(1);
265
+ });
266
+
267
+ it("drops onto the row under the pointer, and a new track in the bottom pad", () => {
268
+ const drop = {
269
+ rectLeft: 0,
270
+ rectTop: 0,
271
+ scrollLeft: 0,
272
+ scrollTop: 0,
273
+ contentOrigin: GUTTER,
274
+ pixelsPerSecond: 100,
275
+ rowHeights: geometry.rowHeights,
276
+ trackOrder: [0, 1, 2],
277
+ };
278
+ expect(resolveTimelineAssetDrop(drop, GUTTER, yInRow1).track).toBe(1);
279
+ const inBottomPad = RULER_H + 3 * TRACK_H + TRACK_H / 2;
280
+ expect(resolveTimelineAssetDrop(drop, GUTTER, inBottomPad).track).toBe(3);
281
+ });
282
+
283
+ it("clamps a negative pad to 0 and replaces a non-finite one with the default", () => {
284
+ const odd = createTimelineRowGeometry([0], baseRows(1), { top: -20, bottom: Number.NaN });
285
+ expect(odd.padding).toEqual({ top: 0, bottom: TRACKS_BOTTOM_PAD });
286
+ expect(odd.getRowTop(0)).toBe(RULER_H);
287
+ expect(odd.canvasHeight).toBe(RULER_H + TRACK_H + TRACKS_BOTTOM_PAD);
288
+ const infinite = createTimelineRowGeometry([0], baseRows(1), { top: Infinity });
289
+ expect(infinite.padding.top).toBe(TRACKS_TOP_PAD);
290
+ });
291
+
292
+ it("keeps the default pads when a host passes none", () => {
293
+ const defaults = createTimelineRowGeometry([0], baseRows(1));
294
+ expect(defaults.padding).toEqual({ top: TRACKS_TOP_PAD, bottom: TRACKS_BOTTOM_PAD });
295
+ });
296
+ });
297
+
298
+ describe("default track pads", () => {
299
+ const rows = [TRACK_H, TRACK_H, TRACK_H];
300
+ const lastLaneBottom = RULER_H + 8 + 3 * TRACK_H;
301
+
302
+ it("sits the first row 8px under the ruler and leaves one track below the last", () => {
303
+ expect(getTimelineRowTop(0)).toBe(RULER_H + 8);
304
+ expect(getTimelineCanvasHeight(rows)).toBe(lastLaneBottom + TRACK_H);
305
+ });
306
+
307
+ it("opens a new track from anywhere in the bottom lane", () => {
308
+ const drop = {
309
+ rectLeft: 0,
310
+ rectTop: 0,
311
+ scrollLeft: 0,
312
+ scrollTop: 0,
313
+ contentOrigin: GUTTER,
314
+ pixelsPerSecond: 100,
315
+ rowHeights: rows,
316
+ trackOrder: [0, 1, 2],
317
+ };
318
+ for (const y of [lastLaneBottom, getTimelineCanvasHeight(rows) - 1]) {
319
+ expect(resolveTimelineAssetDrop(drop, GUTTER, y).track).toBe(3);
320
+ expect(resolveInsertRow(getTimelineRowFromY(y, rows), rows.length)).toBe(3);
321
+ }
322
+ expect(resolveTimelineAssetDrop(drop, GUTTER, lastLaneBottom - 1).track).toBe(2);
323
+ });
324
+ });
325
+
250
326
  describe("getTimelineScrubTime", () => {
251
327
  const at = (clientX: number, duration = 10) =>
252
328
  getTimelineScrubTime({
@@ -55,17 +55,20 @@ export function getTimelineLaneTop(laneIndex: number): number {
55
55
  return TRACK_H + Math.max(0, Math.trunc(laneIndex)) * LANE_H;
56
56
  }
57
57
  /**
58
- * Breathing room INSIDE the scroll area (CapCut-style), threaded through every
59
- * track-row y computation via {@link getTimelineRowTop} — never inline a magic
60
- * offset; a track row's top is always ruler + top pad + cumulative row heights.
58
+ * Default breathing room INSIDE the scroll area (CapCut-style). A host overrides
59
+ * it per Timeline; rows always read TimelineRowGeometry.padding, never these.
61
60
  *
62
- * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
63
- * (~half a track height) so the first clip isn't jammed under the ruler.
64
- * - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
65
- * enough to comfortably drag a clip into the void to create a new bottom lane.
61
+ * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track,
62
+ * just enough that the first clip isn't jammed under the ruler.
63
+ * - TRACKS_BOTTOM_PAD: one empty track below the last, drawn as a ghost lane;
64
+ * dropping a clip or file there creates a new bottom track.
66
65
  */
67
- export const TRACKS_TOP_PAD = 50;
68
- export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5);
66
+ export const TRACKS_TOP_PAD = 8;
67
+ export const TRACKS_BOTTOM_PAD = TRACK_H;
68
+ export interface TimelineTrackPadding {
69
+ top?: number;
70
+ bottom?: number;
71
+ }
69
72
  /**
70
73
  * Breathing room LEFT of t=0 (CapCut-style), inside the scroll content — the
71
74
  * horizontal sibling of TRACKS_TOP_PAD: empty lane surface between the sticky
@@ -115,12 +118,17 @@ function validRowHeight(height: number | undefined): number {
115
118
  return height;
116
119
  }
117
120
 
121
+ function validPad(pad: number | undefined, fallback: number): number {
122
+ return pad !== undefined && Number.isFinite(pad) ? Math.max(0, pad) : fallback;
123
+ }
124
+
118
125
  export interface TimelineRowGeometry {
119
126
  readonly rowKeys: readonly number[];
120
127
  readonly rowHeights: readonly number[];
121
128
  /** Cumulative row boundaries, including the final bottom boundary. */
122
129
  readonly rowOffsets: readonly number[];
123
130
  readonly rowsHeight: number;
131
+ readonly padding: Readonly<Required<TimelineTrackPadding>>;
124
132
  readonly canvasHeight: number;
125
133
  getRowIndex(rowKey: number): number;
126
134
  getRowHeight(row: number): number;
@@ -141,7 +149,10 @@ const EMPTY_ROW_HEIGHTS: readonly number[] = Object.freeze([]);
141
149
  export function createTimelineRowGeometry(
142
150
  rowKeys: readonly number[],
143
151
  rowHeights: readonly number[],
152
+ padding: TimelineTrackPadding = {},
144
153
  ): TimelineRowGeometry {
154
+ const topPad = validPad(padding.top, TRACKS_TOP_PAD);
155
+ const bottomPad = validPad(padding.bottom, TRACKS_BOTTOM_PAD);
145
156
  const heights = Object.freeze(rowHeights.map(validRowHeight));
146
157
  const keys = Object.freeze(
147
158
  heights.map((_, row) => {
@@ -165,7 +176,7 @@ export function createTimelineRowGeometry(
165
176
  return (offsets[wholeRow] ?? 0) + (row - wholeRow) * getRowHeight(wholeRow);
166
177
  };
167
178
  const getRowFromY = (contentY: number) => {
168
- const y = contentY - RULER_H - TRACKS_TOP_PAD;
179
+ const y = contentY - RULER_H - topPad;
169
180
  if (heights.length === 0) return y / TRACK_H;
170
181
  if (y < 0) return y / getRowHeight(0);
171
182
  const rowsHeight = offsets[heights.length] ?? 0;
@@ -188,10 +199,11 @@ export function createTimelineRowGeometry(
188
199
  rowHeights: heights,
189
200
  rowOffsets: offsets,
190
201
  rowsHeight: offsets.at(-1) ?? 0,
191
- canvasHeight: RULER_H + TRACKS_TOP_PAD + (offsets.at(-1) ?? 0) + TRACKS_BOTTOM_PAD,
202
+ padding: Object.freeze({ top: topPad, bottom: bottomPad }),
203
+ canvasHeight: RULER_H + topPad + (offsets.at(-1) ?? 0) + bottomPad,
192
204
  getRowIndex: (rowKey) => rowIndexByKey.get(rowKey) ?? -1,
193
205
  getRowHeight,
194
- getRowTop: (row) => RULER_H + TRACKS_TOP_PAD + getRowOffset(row),
206
+ getRowTop: (row) => RULER_H + topPad + getRowOffset(row),
195
207
  getRowFromY,
196
208
  getRowPositionFromY: (contentY) => {
197
209
  const rowFloat = getRowFromY(contentY);
@@ -204,7 +216,7 @@ export function createTimelineRowGeometry(
204
216
  return frozenGeometry;
205
217
  }
206
218
 
207
- /** Compatibility accessor; repeated calls for one height-array reuse one snapshot. */
219
+ /** Compatibility accessor: a geometry's own rowHeights resolves back to it, pads included. */
208
220
  export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineRowGeometry {
209
221
  const cached = rowGeometryCache.get(rowHeights);
210
222
  if (cached) return cached;
@@ -228,10 +240,6 @@ export function getTimelineRowHeight(
228
240
  return validRowHeight(rowHeights[row]);
229
241
  }
230
242
 
231
- function getTimelineRowOffset(row: number, rowHeights: readonly number[]): number {
232
- return getTimelineRowGeometry(rowHeights).getRowTop(row) - RULER_H - TRACKS_TOP_PAD;
233
- }
234
-
235
243
  /**
236
244
  * The y (content-space) of the top edge of track ROW index `row` (0 = first
237
245
  * displayed lane). The single source of truth for row->y: the ruler height plus
@@ -243,7 +251,7 @@ export function getTimelineRowTop(
243
251
  row: number,
244
252
  rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS,
245
253
  ): number {
246
- return RULER_H + TRACKS_TOP_PAD + getTimelineRowOffset(row, rowHeights);
254
+ return getTimelineRowGeometry(rowHeights).getRowTop(row);
247
255
  }
248
256
 
249
257
  /**
@@ -484,9 +492,8 @@ export function getTimelinePlaybackFollowScrollLeft(input: {
484
492
  }
485
493
 
486
494
  export function getTimelineCanvasHeight(rowHeights: readonly number[]): number {
487
- // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
488
- // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
489
- // below the last lane is real scrollable surface, not a hidden buffer.
495
+ // RULER_H + top pad + lanes + bottom pad, read from the geometry's padding.
496
+ // The drag-into-void space below the last lane is real scrollable surface.
490
497
  return getTimelineRowGeometry(rowHeights).canvasHeight;
491
498
  }
492
499
 
@@ -65,7 +65,8 @@ export function useTimelineProviderState({
65
65
  theme: themeOverrides,
66
66
  showAudioEffects = true,
67
67
  showKeyframes = true,
68
- sessionEpoch = 0,
68
+ trackPadding,
69
+ sessionEpoch: sessionEpochProp,
69
70
  previewIframeRef,
70
71
  onZIndexReorder,
71
72
  }: TimelineProps = {}): TimelineContextValue {
@@ -107,6 +108,7 @@ export function useTimelineProviderState({
107
108
  const currentTime = usePlayerStore((s) => s.currentTime);
108
109
  const beatDragging = usePlayerStore((s) => s.beatDragging);
109
110
  const timelineSessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
111
+ const sessionEpoch = sessionEpochProp ?? timelineSessionEpoch;
110
112
  const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment);
111
113
  const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
112
114
  const playheadRef = useRef<HTMLDivElement>(null);
@@ -143,6 +145,7 @@ export function useTimelineProviderState({
143
145
  gsapAnimations,
144
146
  selectedElementId,
145
147
  selectedElementIds,
148
+ trackPadding,
146
149
  );
147
150
  const timelineElementsRef = useRef(timelineElements);
148
151
  timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements
@@ -431,6 +434,7 @@ export function useTimelineProviderState({
431
434
  marqueeRect,
432
435
  laneGapStrips,
433
436
  dropPreview: assetDrop.dropPreview,
437
+ acceptsMediaDrop: !!(onFileDrop || onAssetDrop),
434
438
  theme,
435
439
  showAudioEffects,
436
440
  displayTrackOrder: displayLayout.displayTrackOrder,