@hyperframes/studio 0.8.111 → 0.8.112

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 (82) hide show
  1. package/dist/assets/{hyperframes-player-DvJeHBHi.js → hyperframes-player-CBpg41JM.js} +1 -1
  2. package/dist/assets/{index-Bt4vDkfw.js → index-BFitquwi.js} +1 -1
  3. package/dist/assets/{index-SFktf7O1.js → index-BKy_birQ.js} +194 -194
  4. package/dist/assets/{index--G6c4Q_x.js → index-CZfrSyU9.js} +1 -1
  5. package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
  6. package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
  7. package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
  8. package/dist/chunk-K47E2ZJA.js.map +1 -0
  9. package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
  10. package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
  11. package/dist/index.d.ts +80 -46
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +7294 -6844
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +10 -8
  18. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
  19. package/src/hooks/timelineEditingHelpers.ts +7 -0
  20. package/src/hooks/timelineMoveAdapter.test.ts +12 -0
  21. package/src/hooks/timelineMoveAdapter.ts +9 -4
  22. package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
  23. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  24. package/src/index.ts +7 -0
  25. package/src/main.tsx +4 -0
  26. package/src/player/components/ClipBadges.tsx +5 -5
  27. package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
  28. package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
  29. package/src/player/components/TimelineCanvas.tsx +6 -2
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
  31. package/src/player/components/TimelineClip.test.tsx +7 -7
  32. package/src/player/components/TimelineClip.tsx +23 -7
  33. package/src/player/components/TimelineClipFades.tsx +41 -43
  34. package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
  35. package/src/player/components/TimelineGestureOverlay.tsx +59 -21
  36. package/src/player/components/TimelineLanes.tsx +18 -16
  37. package/src/player/components/timelineCallbacks.ts +6 -4
  38. package/src/player/components/timelineClipChildren.tsx +7 -13
  39. package/src/player/components/timelineClipDragCommit.test.ts +51 -0
  40. package/src/player/components/timelineClipDragCommit.ts +63 -21
  41. package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
  42. package/src/player/components/timelineClipDragPreview.test.ts +47 -31
  43. package/src/player/components/timelineClipDragPreview.ts +81 -17
  44. package/src/player/components/timelineClipDragTypes.ts +3 -3
  45. package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
  46. package/src/player/components/timelineClipFadeGeometry.ts +74 -0
  47. package/src/player/components/timelineCollision.ts +51 -11
  48. package/src/player/components/timelineDragLanding.ts +20 -6
  49. package/src/player/components/timelineLaneProps.ts +3 -8
  50. package/src/player/components/timelineMotionStyles.test.ts +6 -13
  51. package/src/player/components/timelineMountFixtures.tsx +65 -0
  52. package/src/player/components/timelineOptimisticRevision.ts +15 -4
  53. package/src/player/components/timelineTheme.ts +2 -0
  54. package/src/player/components/timelineTrackDisplay.ts +1 -1
  55. package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
  56. package/src/player/components/timelineTrackInsertLayout.ts +71 -0
  57. package/src/player/components/timelineZMirror.ts +5 -11
  58. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
  59. package/src/player/components/useTimelineClipDrag.ts +116 -12
  60. package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
  61. package/src/player/components/useTimelineKeyboardActor.ts +86 -7
  62. package/src/player/components/useTimelineProviderState.tsx +3 -1
  63. package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
  64. package/src/player/components/useTimelineRowVirtualization.ts +3 -1
  65. package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
  66. package/src/player/components/useTimelineTrackDerivations.ts +32 -18
  67. package/src/player/components/useTimelineTrackLayout.ts +43 -14
  68. package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
  69. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  70. package/src/player/lib/automationStoreSync.test.ts +15 -0
  71. package/src/player/lib/automationStoreSync.ts +13 -5
  72. package/src/player/lib/storedVolume.ts +7 -0
  73. package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
  74. package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
  75. package/src/player/lib/thumbnailScheduler.ts +20 -2
  76. package/src/player/lib/timelineElementHelpers.ts +2 -8
  77. package/src/styles/components.css +71 -19
  78. package/src/styles/theme.css +4 -1
  79. package/dist/chunk-6LUNLTHR.js.map +0 -1
  80. /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
  81. /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
  82. /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
@@ -6,7 +6,12 @@ import {
6
6
  } from "./timelineGroupEditing";
7
7
  import type { TimelineElement } from "../store/playerStore";
8
8
  import { clampToHostStart } from "../store/timelineElement";
9
- import { getTimelineRowFromY } from "./timelineLayout";
9
+ import {
10
+ CLIP_Y,
11
+ TRACK_H,
12
+ getTimelineRowGeometry,
13
+ type TimelineRowGeometry,
14
+ } from "./timelineLayout";
10
15
  import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
11
16
  import {
12
17
  TIMELINE_SNAP_PX,
@@ -38,6 +43,9 @@ export interface DragPreviewContext {
38
43
  duration: number;
39
44
  trackOrder: number[];
40
45
  rowHeights?: readonly number[];
46
+ rowGeometry?: TimelineRowGeometry;
47
+ allowedInsertRows?: ReadonlySet<number>;
48
+ groupTracks?: ReadonlyMap<number, readonly number[]>;
41
49
  elements: TimelineElement[];
42
50
  selectedKeys: ReadonlySet<string>;
43
51
  buildSnapTargets: BuildSnapTargets;
@@ -50,6 +58,33 @@ export interface DragPreviewContext {
50
58
  audioTracks?: ReadonlySet<number>;
51
59
  }
52
60
 
61
+ export function createKeyboardClipDrag(
62
+ element: TimelineElement,
63
+ insertRow: number,
64
+ scrollLeft: number,
65
+ scrollTop: number,
66
+ ): DraggedClipState {
67
+ return {
68
+ pointerId: null,
69
+ element,
70
+ insertRow,
71
+ started: true,
72
+ previewStart: element.start,
73
+ previewTrack: element.track,
74
+ desiredTrack: element.track,
75
+ originClientX: 0,
76
+ originClientY: 0,
77
+ originScrollLeft: scrollLeft,
78
+ originScrollTop: scrollTop,
79
+ pointerClientX: 0,
80
+ pointerClientY: 0,
81
+ pointerOffsetX: 0,
82
+ pointerOffsetY: 0,
83
+ snapTime: null,
84
+ snapType: null,
85
+ };
86
+ }
87
+
53
88
  /** Content-space position for the stable viewport drag actor. */
54
89
  export function getTimelineDragOverlayPosition(
55
90
  drag: DraggedClipState,
@@ -73,22 +108,50 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
73
108
  return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
74
109
  }
75
110
 
111
+ const INSERT_SEAM_PX = CLIP_Y;
112
+
113
+ function physicalInsertRow(
114
+ rowFloat: number,
115
+ y: number,
116
+ ctx: Pick<DragPreviewContext, "trackOrder" | "allowedInsertRows">,
117
+ geometry: TimelineRowGeometry,
118
+ ) {
119
+ const boundary = Math.round(rowFloat);
120
+ const nearSeam = Math.abs(y - geometry.getRowTop(boundary)) <= INSERT_SEAM_PX;
121
+ const row = nearSeam
122
+ ? Math.max(0, Math.min(ctx.trackOrder.length, boundary))
123
+ : resolveInsertRow(rowFloat, ctx.trackOrder.length);
124
+ if (row !== null && ctx.allowedInsertRows && !ctx.allowedInsertRows.has(row)) return null;
125
+ return row;
126
+ }
127
+
128
+ function dragRowAim(
129
+ drag: DraggedClipState,
130
+ clientY: number,
131
+ ctx: DragPreviewContext,
132
+ geometry: TimelineRowGeometry,
133
+ ) {
134
+ let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
135
+ if (drag.started && drag.insertRow !== null) {
136
+ const top = geometry.getRowTop(drag.insertRow);
137
+ if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
138
+ return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
139
+ if (y > top + TRACK_H) y -= TRACK_H;
140
+ }
141
+ const rowFloat = geometry.getRowFromY(y);
142
+ return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
143
+ }
144
+
76
145
  /** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
77
146
  function resolveDropPlacement(
78
147
  drag: DraggedClipState,
79
- clientY: number,
148
+ aim: ReturnType<typeof dragRowAim>,
80
149
  previewStart: number,
81
150
  desiredTrack: number,
82
151
  ctx: DragPreviewContext,
83
152
  group: GroupDrag,
84
153
  ): { track: number; insertRow: number | null; start: number } {
85
- const { scroll, trackOrder, rowHeights, elements } = ctx;
86
- const rowFloat = scroll
87
- ? getTimelineRowFromY(
88
- clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
89
- rowHeights,
90
- )
91
- : 0;
154
+ const { trackOrder, elements } = ctx;
92
155
  const dragKey = drag.element.key ?? drag.element.id;
93
156
  const audioTracks =
94
157
  ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
@@ -97,7 +160,9 @@ function resolveDropPlacement(
97
160
  audioTracks,
98
161
  elements: group.obstacles,
99
162
  desiredTrack,
100
- deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
163
+ deliberateInsertRow: aim.insertRow,
164
+ allowedInsertRows: ctx.allowedInsertRows,
165
+ groupTracks: ctx.groupTracks,
101
166
  start: previewStart,
102
167
  duration: drag.element.duration,
103
168
  dragKey,
@@ -144,13 +209,12 @@ export function computeDragPreview(
144
209
  ): DraggedClipState {
145
210
  const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
146
211
  const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
147
- const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
148
212
  const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
149
- const originRow = getTimelineRowFromY(
150
- drag.originClientY - scrollRectTop + drag.originScrollTop,
151
- ctx.rowHeights,
152
- );
153
- const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
213
+ const geometry =
214
+ ctx.rowGeometry ?? getTimelineRowGeometry(ctx.rowHeights ?? ctx.trackOrder.map(() => TRACK_H));
215
+ const originRow = geometry.getRowFromY(drag.originClientY - scrollRectTop + drag.originScrollTop);
216
+ const aim = dragRowAim(drag, clientY, ctx, geometry);
217
+ const currentRow = aim.rowFloat;
154
218
  // resolveTimelineMove's vertical axis is row indices, which is why the pointer
155
219
  // and scroll pixels are folded into originRow/currentRow above.
156
220
  const nextMove = resolveTimelineMove(
@@ -196,7 +260,7 @@ export function computeDragPreview(
196
260
  const origin = drag.element.start;
197
261
  const floored = Math.max(snap.start, group.floor);
198
262
  const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
199
- const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
263
+ const placement = resolveDropPlacement(drag, aim, previewStart, nextMove.track, ctx, group);
200
264
  const placedShift = placement.start - origin;
201
265
  if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
202
266
  return { ...drag, started: true };
@@ -4,8 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
4
4
 
5
5
  /* ── Shared clip-drag state types ───────────────────────────────── */
6
6
  export interface DraggedClipState {
7
- /** Pointer captured by the stable viewport coordinator for this gesture. */
8
- pointerId: number;
7
+ /** Pointer captured by the viewport coordinator; null for a keyboard pickup. */
8
+ pointerId: number | null;
9
9
  element: TimelineElement;
10
10
  originClientX: number;
11
11
  originClientY: number;
@@ -26,7 +26,7 @@ export interface DraggedClipState {
26
26
  /**
27
27
  * When non-null, the drop inserts a NEW track at this visual row boundary
28
28
  * (0 = above the top lane, trackOrder.length = below the bottom) instead of
29
- * landing on previewTrack. Drives the insertion-line indicator.
29
+ * landing on previewTrack. Drives the opened preview lane.
30
30
  */
31
31
  insertRow: number | null;
32
32
  /** Snap target the clip will land on, for the guide highlight. */
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fadeHandleBoxes } from "./timelineClipFadeGeometry";
3
+
4
+ const flatClip = (width: number) => ({ width, height: 30, radius: 0, toolsLeft: null });
5
+
6
+ describe("fadeHandleBoxes", () => {
7
+ it("centres each handle on its fade's end", () => {
8
+ const boxes = fadeHandleBoxes({
9
+ widthPx: 1000,
10
+ inPx: 100,
11
+ outPx: 200,
12
+ clipBox: flatClip(1000),
13
+ drawn: { in: true, out: true },
14
+ });
15
+ expect(boxes.in).toEqual({ left: 88, width: 24, height: 24, tabLeft: 12, top: -7 });
16
+ expect(boxes.out).toEqual({ left: 788, width: 24, height: 24, tabLeft: 12, top: -7 });
17
+ });
18
+
19
+ it("splits two overlapping handles at the midpoint between their tabs", () => {
20
+ const input = { widthPx: 100, inPx: 45, outPx: 45, clipBox: flatClip(100) };
21
+ const boxes = fadeHandleBoxes({ ...input, drawn: { in: true, out: true } });
22
+ expect(boxes.in).toMatchObject({ left: 26, width: 24, tabLeft: 19 });
23
+ expect(boxes.out).toMatchObject({ left: 50, width: 24, tabLeft: 5 });
24
+
25
+ // With its twin hidden, a handle keeps its full centred box.
26
+ const alone = fadeHandleBoxes({ ...input, drawn: { in: true, out: false } });
27
+ expect(alone.in).toMatchObject({ left: 33, width: 24 });
28
+ });
29
+ });
@@ -0,0 +1,74 @@
1
+ import { CLIP_TRIM_HIT_PX } from "./timelineTheme";
2
+
3
+ export type FadeEdge = "in" | "out";
4
+
5
+ export const FADE_TAB_WIDTH = 4;
6
+ const FADE_HANDLE_HIT = 24;
7
+ export const FADE_TAB_CENTER_IN_HIT = 8;
8
+ const TAB_INSET = 7;
9
+
10
+ /** The clip as drawn; `toolsLeft` is where its FX badge starts, null when it has none. */
11
+ export interface FadeHandleClipBox {
12
+ width: number;
13
+ height: number;
14
+ radius: number;
15
+ toolsLeft: number | null;
16
+ }
17
+
18
+ /** A fade handle's hit box in px from the clip's top left; `tabLeft` is its tab's centre within it. */
19
+ export interface FadeHandleBox {
20
+ left: number;
21
+ width: number;
22
+ height: number;
23
+ tabLeft: number;
24
+ top: number;
25
+ }
26
+
27
+ function topEdgeY(x: number, widthPx: number, heightPx: number, radiusPx: number): number {
28
+ const r = Math.min(radiusPx, widthPx / 2, heightPx / 2);
29
+ const d = x < r ? r - x : x > widthPx - r ? x - (widthPx - r) : 0;
30
+ return d > 0 ? r - Math.sqrt(Math.max(0, r * r - d * d)) : 0;
31
+ }
32
+
33
+ /**
34
+ * Where the fade handles sit on a clip `widthPx` wide: each centred on its fade's end, inside the
35
+ * clip and left of its tools; two drawn handles that would overlap split at the midpoint.
36
+ */
37
+ export function fadeHandleBoxes(input: {
38
+ widthPx: number;
39
+ inPx: number;
40
+ outPx: number;
41
+ clipBox: FadeHandleClipBox;
42
+ drawn: Record<FadeEdge, boolean>;
43
+ }): Record<FadeEdge, FadeHandleBox> {
44
+ const { widthPx, inPx, outPx, clipBox, drawn } = input;
45
+ const clipWidth = clipBox.toolsLeft === null ? widthPx : clipBox.width;
46
+ const edgeInset = clipBox.toolsLeft === null ? 0 : CLIP_TRIM_HIT_PX;
47
+ // Reserve FX horizontally so fades keep their top-edge lane above compact keyframe centres.
48
+ const rightEdge =
49
+ clipBox.toolsLeft === null ? clipWidth : Math.min(clipWidth - edgeInset, clipBox.toolsLeft);
50
+ const hitWidth = Math.min(FADE_HANDLE_HIT, (rightEdge - edgeInset) / 2);
51
+ const tabX = (edge: FadeEdge) => {
52
+ const knee = edge === "in" ? inPx : clipWidth - outPx;
53
+ const tabInset = edgeInset === 0 ? TAB_INSET : FADE_TAB_WIDTH;
54
+ const inset = Math.min(tabInset, (rightEdge - edgeInset) / 2);
55
+ return Math.min(rightEdge - inset, Math.max(edgeInset + inset, knee));
56
+ };
57
+ const boxLeft = (x: number) =>
58
+ Math.min(rightEdge - hitWidth, Math.max(edgeInset, x - hitWidth / 2));
59
+ const [inX, outX] = [tabX("in"), tabX("out")];
60
+ const mid = (inX + outX) / 2;
61
+ const overlap = drawn.in && drawn.out && boxLeft(inX) + hitWidth > boxLeft(outX);
62
+ const box = (edge: FadeEdge): FadeHandleBox => {
63
+ const x = edge === "in" ? inX : outX;
64
+ const [left, right] = !overlap
65
+ ? [boxLeft(x), boxLeft(x) + hitWidth]
66
+ : edge === "in"
67
+ ? [Math.max(edgeInset, mid - hitWidth), mid]
68
+ : [mid, Math.min(rightEdge, mid + hitWidth)];
69
+ const edgeY = topEdgeY(x, clipWidth, clipBox.height, clipBox.radius);
70
+ const top = edgeY + 1 - FADE_TAB_CENTER_IN_HIT;
71
+ return { left, width: right - left, height: FADE_HANDLE_HIT, tabLeft: x - left, top };
72
+ };
73
+ return { in: box("in"), out: box("out") };
74
+ }
@@ -1,5 +1,17 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
 
3
+ /** Audio member tracks mark the audio zone, including members hidden by a group header. */
4
+ export function timelineAudioRow(
5
+ order: number[],
6
+ audioTracks: ReadonlySet<number>,
7
+ groupTracks?: ReadonlyMap<number, readonly number[]>,
8
+ ): number {
9
+ return order.findIndex(
10
+ (track) =>
11
+ audioTracks.has(track) || groupTracks?.get(track)?.some((member) => audioTracks.has(member)),
12
+ );
13
+ }
14
+
3
15
  /**
4
16
  * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
5
17
  * the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
@@ -31,7 +43,10 @@ export function isInsertAllowedForZone(
31
43
  insertRow: number,
32
44
  audioRow: number,
33
45
  isAudio: boolean,
46
+ allowedRows?: ReadonlySet<number>,
34
47
  ): boolean {
48
+ if (allowedRows && !allowedRows.has(insertRow)) return false;
49
+ while (audioRow > 0 && allowedRows && !allowedRows.has(audioRow)) audioRow--;
35
50
  if (audioRow < 0) return true;
36
51
  return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
37
52
  }
@@ -52,9 +67,8 @@ function outOfRangeZoneInsertRow(
52
67
  audioRow: number,
53
68
  desired: number,
54
69
  ): number {
55
- // No lane of this kind yet: fall to the split (audioRow) or the very top.
56
- // A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
57
- if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
70
+ // An empty audio zone opens below visual rows; an empty visual zone opens above audio.
71
+ if (zoneTracks.length === 0) return audioRow < 0 ? order.length : audioRow;
58
72
  // zoneTracks preserves `order` sequence, so its ends map to the zone boundary
59
73
  // rows: above the zone's min lane → its top boundary, else its bottom.
60
74
  const zoneTop = order.indexOf(zoneTracks[0]);
@@ -104,14 +118,31 @@ export function resolveNearestFreeStart(
104
118
  return clear ? origin : best;
105
119
  }
106
120
 
107
- // Where a dragged clip lands: on the aimed row of its kind, at the nearest free time there. Only an aim outside all
108
- // rows, or into a kind with no row yet, opens a track (`insertRow`).
121
+ function groupHeaderAim(
122
+ input: Parameters<typeof resolveZoneDropPlacement>[0],
123
+ ):
124
+ | { kind: "ordinary" | "member"; track: number }
125
+ | { kind: "origin"; track: number; start: number } {
126
+ const members = input.groupTracks?.get(input.desiredTrack);
127
+ if (!members) return { kind: "ordinary", track: input.desiredTrack };
128
+ const memberTracks = new Set(members);
129
+ const member = input.order.find(
130
+ (track) => memberTracks.has(track) && input.audioTracks.has(track) === input.isAudio,
131
+ );
132
+ if (member !== undefined) return { kind: "member", track: member };
133
+ if (!input.origin) throw new Error("Group-header drop requires the clip origin");
134
+ return { kind: "origin", ...input.origin };
135
+ }
136
+
137
+ // A deliberate seam opens a track; other aims land at the nearest free time on a row of the clip's kind.
109
138
  export function resolveZoneDropPlacement(input: {
110
139
  order: number[];
111
140
  audioTracks: ReadonlySet<number>;
112
141
  elements: TimelineElement[];
113
142
  desiredTrack: number;
114
143
  deliberateInsertRow: number | null;
144
+ allowedInsertRows?: ReadonlySet<number>;
145
+ groupTracks?: ReadonlyMap<number, readonly number[]>;
115
146
  start: number;
116
147
  duration: number;
117
148
  dragKey: string;
@@ -123,24 +154,33 @@ export function resolveZoneDropPlacement(input: {
123
154
  }): { track: number; insertRow: number | null; start: number } {
124
155
  const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
125
156
  const { start, duration, dragKey, isAudio, minStart } = input;
126
- const audioRow = order.findIndex((t) => audioTracks.has(t));
157
+ const audioRow = timelineAudioRow(order, audioTracks, input.groupTracks);
127
158
 
128
159
  if (
129
160
  deliberateInsertRow !== null &&
130
- isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
161
+ isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio, input.allowedInsertRows)
131
162
  ) {
132
163
  return { track: desiredTrack, insertRow: deliberateInsertRow, start };
133
164
  }
134
165
 
135
- const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
136
- const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
166
+ const aim = groupHeaderAim(input);
167
+ if (aim.kind === "origin") return { track: aim.track, start: aim.start, insertRow: null };
168
+ const firstVisibleAudioRow = order.findIndex((track) => audioTracks.has(track));
169
+ const zoneBoundary = isAudio ? firstVisibleAudioRow : audioRow;
170
+ const desired =
171
+ aim.kind === "member" ? aim.track : clampTrackToZone(aim.track, order, zoneBoundary, isAudio);
172
+ const zoneTracks = order.filter(
173
+ (t) => !input.groupTracks?.has(t) && audioTracks.has(t) === isAudio,
174
+ );
137
175
  // Only when the aim is outside the rows, or the clip's zone has no row yet.
138
176
  if (!zoneTracks.includes(desired)) {
139
177
  const desiredRow = order.indexOf(desired);
140
- const insertRow =
141
- desiredRow < 0
178
+ let insertRow =
179
+ desiredRow < 0 || zoneTracks.length === 0
142
180
  ? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
143
181
  : desiredRow + 1;
182
+ while (insertRow > 0 && input.allowedInsertRows && !input.allowedInsertRows.has(insertRow))
183
+ insertRow--;
144
184
  return { track: desired, insertRow, start };
145
185
  }
146
186
  const origin = input.origin?.track === desired ? input.origin.start : null;
@@ -24,7 +24,7 @@ export function layoutAfterTrackInsert(
24
24
  keys: ReadonlySet<string>;
25
25
  movedStart: (e: TimelineElement) => number;
26
26
  } | null,
27
- deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
27
+ deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "trackInsertLayout">,
28
28
  ): {
29
29
  normalized: TimelineElement[];
30
30
  targetTrack: number;
@@ -37,18 +37,32 @@ export function layoutAfterTrackInsert(
37
37
  const writableZone = classifyZone(element);
38
38
  const writable = (src: TimelineElement): boolean =>
39
39
  sameSourceFile(src, element) && classifyZone(src) === writableZone;
40
- const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
40
+ let topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
41
41
  (a, b) => a - b,
42
42
  );
43
- const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
44
- const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
43
+ let topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
44
+ const grouped = deps.trackInsertLayout;
45
+ let ranks: Map<number, number> | undefined;
46
+ if (grouped) {
47
+ const writableTracks = new Set(topologyOrder);
48
+ topologyOrder = grouped.trackOrder.filter((track) => writableTracks.has(track));
49
+ topologyInsertRow = grouped.trackOrder
50
+ .slice(0, grouped.topologyRows[insertRow])
51
+ .filter((track) => writableTracks.has(track)).length;
52
+ ranks = new Map(topologyOrder.map((track, index) => [track, index]));
53
+ }
54
+ const topologyTargetTrack = ranks
55
+ ? topologyInsertRow - 0.5
56
+ : insertTrackValue(topologyOrder, topologyInsertRow);
45
57
  const normalized = normalizeToZones(
46
58
  elements.filter(writable).map((e) => {
47
59
  if (keyOf(e) === editKey) {
48
60
  return { ...e, start: previewStart, track: topologyTargetTrack };
49
61
  }
50
- if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
51
- return e;
62
+ const track = ranks ? ranks.get(e.track) : e.track;
63
+ if (track === undefined) throw new Error("Writable track missing from insertion topology");
64
+ const start = multi?.keys.has(keyOf(e)) ? multi.movedStart(e) : e.start;
65
+ return track === e.track && start === e.start ? e : { ...e, start, track };
52
66
  }),
53
67
  );
54
68
  return { normalized, targetTrack, writable };
@@ -1,4 +1,3 @@
1
- import { type ReactNode } from "react";
2
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
2
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
4
3
  import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
@@ -13,7 +12,7 @@ import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
13
12
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
14
13
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
15
14
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
16
- import type { TimelineClipRenderContext } from "./TimelineTypes";
15
+ import type { TimelineProps } from "./TimelineTypes";
17
16
  import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
18
17
 
19
18
  /**
@@ -52,12 +51,8 @@ export interface TimelineLaneBaseProps {
52
51
  blockedClipRef: React.RefObject<BlockedClipState | null>;
53
52
  suppressClickRef: React.RefObject<boolean>;
54
53
  scrollRef: React.RefObject<HTMLDivElement | null>;
55
- renderClipContent?: (
56
- element: TimelineElement,
57
- style: { clip: string; label: string },
58
- context: TimelineClipRenderContext,
59
- ) => ReactNode;
60
- renderClipOverlay?: (element: TimelineElement) => ReactNode;
54
+ renderClipContent?: TimelineProps["renderClipContent"];
55
+ renderClipOverlay?: TimelineProps["renderClipOverlay"];
61
56
  onDrillDown?: (element: TimelineElement) => void;
62
57
  onSelectElement?: (element: TimelineElement | null) => void;
63
58
  setHoveredClip: (key: string | null) => void;
@@ -60,7 +60,7 @@ describe("timeline motion styles", () => {
60
60
  const timelineClipMotionRule = expectRule(motionMediaCss, ".timeline-clip");
61
61
 
62
62
  expect(expectDeclaration(timelineClipMotionRule, "transition")).toBe(
63
- "background-color 120ms ease-out",
63
+ "background-color var(--timeline-clip-hover-duration) ease-out",
64
64
  );
65
65
  });
66
66
 
@@ -72,10 +72,9 @@ describe("timeline motion styles", () => {
72
72
  const audioClipHoverRule = expectRule(studioCss, ".timeline-clip.is-audio.is-hovered");
73
73
  const audioClipDraggingRule = expectRule(studioCss, ".timeline-clip.is-audio.is-dragging");
74
74
  const activeTimelineClipRule = expectRule(studioCss, ".timeline-clip[data-active]");
75
- const selectedTimelineClipRule = expectRule(studioCss, ".timeline-clip.is-selected");
76
- const activeSelectedTimelineClipRule = expectRule(
75
+ const selectedTimelineClipRule = expectRule(
77
76
  studioCss,
78
- ".timeline-clip[data-active].is-selected",
77
+ ".timeline-clip.is-selected,\n.timeline-clip[data-active].is-selected",
79
78
  );
80
79
  const bloomOverlayRule = expectRule(studioCss, ".timeline-clip::before");
81
80
  const activeBloomOverlayRule = expectRule(studioCss, ".timeline-clip[data-active]::before");
@@ -92,7 +91,7 @@ describe("timeline motion styles", () => {
92
91
  expect(timelineClipTimecodeRule).toContain("color: var(--timeline-tick-text)");
93
92
  expect(themeCss).toContain("--timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85)");
94
93
  expect(themeCss).toContain("--timeline-clip-selection-highlight: rgb(255 255 255 / 0.55)");
95
- expect(themeCss).toContain("--timeline-clip-radius: 8px");
94
+ expect(themeCss).toContain("--timeline-clip-radius: 12px");
96
95
  expect(themeCss).toContain("--timeline-clip-audio-radius: 21px");
97
96
  expect(themeCss).toContain("--timeline-clip-highlight: rgb(255 255 255 / 0.08)");
98
97
  expect(themeCss).toContain("--timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3)");
@@ -104,14 +103,8 @@ describe("timeline motion styles", () => {
104
103
  expect(audioClipRule).toContain("border-color: var(--timeline-clip-audio-border)");
105
104
  expect(audioClipHoverRule).toContain("background-color: var(--timeline-clip-audio-bg-hover)");
106
105
  expect(audioClipDraggingRule).toContain("var(--timeline-clip-audio-bg-dragging)");
107
- expect(selectedTimelineClipRule).toContain(
108
- "outline: 1.5px solid var(--timeline-clip-selection)",
109
- );
110
- expect(selectedTimelineClipRule).toContain("outline-offset: -1.5px");
111
- expect(activeSelectedTimelineClipRule).toContain(
112
- "outline: 1.5px solid var(--timeline-clip-selection)",
113
- );
114
- expect(activeSelectedTimelineClipRule).toContain("outline-offset: -1.5px");
106
+ expect(selectedTimelineClipRule).toContain("outline: 1px solid var(--timeline-clip-selection)");
107
+ expect(selectedTimelineClipRule).toContain("outline-offset: 0");
115
108
  expect(activeTimelineClipRule).not.toContain("background: linear-gradient");
116
109
  expect(activeTimelineClipRule).toContain("border-color: var(--clip-border-active)");
117
110
  expect(activeTimelineClipRule).not.toContain("box-shadow");
@@ -0,0 +1,65 @@
1
+ import React, { act } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { Timeline } from "./Timeline";
4
+ import type { TimelineProps } from "./TimelineTypes";
5
+ import { usePlayerStore } from "../store/playerStore";
6
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
7
+
8
+ export async function mountThreeTrackTimeline(props: TimelineProps = {}) {
9
+ usePlayerStore.setState({
10
+ duration: 10,
11
+ currentTime: 0,
12
+ timelineReady: true,
13
+ gsapAnimations: new Map(),
14
+ selectedElementId: null,
15
+ selectedElementIds: new Set(),
16
+ elements: [0, 1, 2].map((track) => ({
17
+ id: `c${track}`,
18
+ key: `c${track}`,
19
+ domId: `c${track}`,
20
+ tag: "div",
21
+ start: 0,
22
+ duration: 2,
23
+ track,
24
+ })),
25
+ });
26
+ const host = document.createElement("div");
27
+ document.body.append(host);
28
+ const root = createRoot(host);
29
+ await act(async () => root.render(<Timeline {...props} />));
30
+ const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
31
+ const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
32
+ return { host, root, viewport, ghost };
33
+ }
34
+
35
+ export function dragTimelineFixtureAsset(target: EventTarget, type: string, clientY: number): void {
36
+ const event = new Event(type, { bubbles: true, cancelable: true });
37
+ Object.defineProperties(event, {
38
+ clientX: { value: 400 },
39
+ clientY: { value: clientY },
40
+ dataTransfer: {
41
+ value: {
42
+ types: [TIMELINE_ASSET_MIME],
43
+ files: [],
44
+ dropEffect: "none",
45
+ getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
46
+ },
47
+ },
48
+ });
49
+ act(() => void target.dispatchEvent(event));
50
+ }
51
+
52
+ export function pointerTimelineFixture(target: EventTarget, type: string, clientY: number): void {
53
+ act(
54
+ () =>
55
+ void target.dispatchEvent(
56
+ new PointerEvent(type, {
57
+ bubbles: true,
58
+ button: 0,
59
+ pointerId: 1,
60
+ clientX: 400,
61
+ clientY,
62
+ }),
63
+ ),
64
+ );
65
+ }
@@ -1,16 +1,23 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
 
3
3
  type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
4
- const revisionsByUpdater = new WeakMap<UpdateElement, Map<string, number>>();
4
+ type RevisionScope = "timing" | "membership";
5
+ const revisionsByUpdater = new WeakMap<UpdateElement, Map<RevisionScope, Map<string, number>>>();
5
6
 
6
7
  export function beginTimelineOptimisticGesture(
7
8
  updateElement: UpdateElement,
8
9
  keys: readonly string[],
10
+ scope: RevisionScope = "timing",
9
11
  ): Map<string, number> {
10
- let revisions = revisionsByUpdater.get(updateElement);
12
+ let scopes = revisionsByUpdater.get(updateElement);
13
+ if (!scopes) {
14
+ scopes = new Map();
15
+ revisionsByUpdater.set(updateElement, scopes);
16
+ }
17
+ let revisions = scopes.get(scope);
11
18
  if (!revisions) {
12
19
  revisions = new Map();
13
- revisionsByUpdater.set(updateElement, revisions);
20
+ scopes.set(scope, revisions);
14
21
  }
15
22
  const gesture = new Map<string, number>();
16
23
  for (const key of keys) {
@@ -25,8 +32,12 @@ export function isLatestTimelineOptimisticGesture(
25
32
  updateElement: UpdateElement,
26
33
  gesture: ReadonlyMap<string, number>,
27
34
  key: string,
35
+ scope: RevisionScope = "timing",
28
36
  ): boolean {
29
- return revisionsByUpdater.get(updateElement)?.get(key) === gesture.get(key);
37
+ return (
38
+ gesture.has(key) &&
39
+ revisionsByUpdater.get(updateElement)?.get(scope)?.get(key) === gesture.get(key)
40
+ );
30
41
  }
31
42
 
32
43
  export function rollbackLatestTimelineOptimisticGesture(
@@ -1,5 +1,7 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
 
3
+ export const CLIP_TRIM_HIT_PX = 14;
4
+
3
5
  export interface TimelineTrackStyle {
4
6
  clip: string;
5
7
  accent: string;
@@ -6,7 +6,7 @@
6
6
  * routes through here; the raw key stays in callbacks and lookups only.
7
7
  */
8
8
 
9
- /** Ascending distinct track keys, the row order the timeline renders in. */
9
+ /** Ascending distinct track keys; the drawn rows also group audio (`displayTrackOrder`). */
10
10
  export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
11
11
  return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
12
12
  }