@hyperframes/studio 0.4.12 → 0.4.13-alpha.2

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 (33) hide show
  1. package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
  2. package/dist/assets/index-BKkR67xb.css +1 -0
  3. package/dist/assets/index-rN5doSq1.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +289 -11
  7. package/src/components/nle/NLELayout.tsx +24 -7
  8. package/src/components/nle/NLEPreview.test.ts +32 -0
  9. package/src/components/nle/NLEPreview.tsx +12 -1
  10. package/src/player/components/CompositionThumbnail.tsx +94 -17
  11. package/src/player/components/EditModal.tsx +48 -29
  12. package/src/player/components/Player.tsx +5 -2
  13. package/src/player/components/PlayerControls.test.ts +20 -0
  14. package/src/player/components/PlayerControls.tsx +12 -1
  15. package/src/player/components/Timeline.test.ts +44 -1
  16. package/src/player/components/Timeline.tsx +686 -169
  17. package/src/player/components/TimelineClip.tsx +112 -16
  18. package/src/player/components/timelineEditing.test.ts +310 -0
  19. package/src/player/components/timelineEditing.ts +213 -0
  20. package/src/player/components/timelineTheme.test.ts +56 -0
  21. package/src/player/components/timelineTheme.ts +141 -0
  22. package/src/player/components/timelineZoom.test.ts +62 -0
  23. package/src/player/components/timelineZoom.ts +38 -0
  24. package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
  25. package/src/player/hooks/useTimelinePlayer.ts +313 -59
  26. package/src/player/store/playerStore.test.ts +30 -12
  27. package/src/player/store/playerStore.ts +23 -9
  28. package/src/types/hyperframes-player.d.ts +1 -0
  29. package/src/utils/sourcePatcher.test.ts +84 -0
  30. package/src/utils/sourcePatcher.ts +143 -0
  31. package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
  32. package/dist/assets/index-CVDXfFQ6.js +0 -93
  33. package/dist/assets/index-jmDaI2F7.css +0 -1
@@ -1,7 +1,9 @@
1
+ import type { TimelineTrackStyle } from "./timelineTheme";
1
2
  // TimelineClip — Visual clip component for the NLE timeline.
2
3
 
3
4
  import { memo, type ReactNode } from "react";
4
5
  import type { TimelineElement } from "../store/playerStore";
6
+ import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
5
7
 
6
8
  interface TimelineClipProps {
7
9
  el: TimelineElement;
@@ -9,11 +11,15 @@ interface TimelineClipProps {
9
11
  clipY: number;
10
12
  isSelected: boolean;
11
13
  isHovered: boolean;
14
+ isDragging?: boolean;
12
15
  hasCustomContent: boolean;
13
- style: { clip: string; label: string };
16
+ theme?: TimelineTheme;
17
+ trackStyle: TimelineTrackStyle;
14
18
  isComposition: boolean;
15
19
  onHoverStart: () => void;
16
20
  onHoverEnd: () => void;
21
+ onPointerDown?: (e: React.PointerEvent) => void;
22
+ onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
17
23
  onClick: (e: React.MouseEvent) => void;
18
24
  onDoubleClick: (e: React.MouseEvent) => void;
19
25
  children?: ReactNode;
@@ -25,43 +31,62 @@ export const TimelineClip = memo(function TimelineClip({
25
31
  clipY,
26
32
  isSelected,
27
33
  isHovered,
34
+ isDragging = false,
28
35
  hasCustomContent,
29
- style,
36
+ theme = defaultTimelineTheme,
37
+ trackStyle,
30
38
  isComposition,
31
39
  onHoverStart,
32
40
  onHoverEnd,
41
+ onPointerDown,
42
+ onResizeStart,
33
43
  onClick,
34
44
  onDoubleClick,
35
45
  children,
36
46
  }: TimelineClipProps) {
37
47
  const leftPx = el.start * pps;
38
48
  const widthPx = Math.max(el.duration * pps, 4);
49
+ const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
50
+ const borderColor = isSelected
51
+ ? theme.clipBorderActive
52
+ : isHovered
53
+ ? theme.clipBorderHover
54
+ : theme.clipBorder;
55
+ const boxShadow = isDragging
56
+ ? theme.clipShadowDragging
57
+ : isSelected
58
+ ? theme.clipShadowActive
59
+ : isHovered
60
+ ? theme.clipShadowHover
61
+ : theme.clipShadow;
62
+ const showHandles = handleOpacity > 0.01;
39
63
 
40
64
  return (
41
65
  <div
42
66
  data-clip="true"
43
- className={hasCustomContent ? "absolute" : "absolute flex items-center"}
67
+ className={
68
+ hasCustomContent ? "absolute overflow-hidden" : "absolute flex items-center overflow-hidden"
69
+ }
44
70
  style={{
45
71
  left: leftPx,
46
72
  width: widthPx,
47
73
  top: clipY,
48
74
  bottom: clipY,
49
- borderRadius: 5,
50
- backgroundColor: hasCustomContent ? (isComposition ? "#111" : style.clip) : style.clip,
75
+ borderRadius: theme.clipRadius,
76
+ background: isSelected
77
+ ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
78
+ : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
51
79
  backgroundImage:
52
80
  isComposition && !hasCustomContent
53
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
81
+ ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
54
82
  : undefined,
55
- border: isSelected
56
- ? `2px solid rgba(255,255,255,0.9)`
57
- : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
58
- boxShadow: isSelected
59
- ? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
60
- : isHovered
61
- ? "0 1px 4px rgba(0,0,0,0.3)"
62
- : "none",
63
- transition: "border-color 120ms, box-shadow 120ms",
64
- zIndex: isSelected ? 10 : isHovered ? 5 : 1,
83
+ border: `1px solid ${borderColor}`,
84
+ boxShadow,
85
+ transition:
86
+ "border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out",
87
+ zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
88
+ cursor: "grab",
89
+ transform: isDragging ? "translateY(-1px)" : undefined,
65
90
  }}
66
91
  title={
67
92
  isComposition
@@ -70,9 +95,80 @@ export const TimelineClip = memo(function TimelineClip({
70
95
  }
71
96
  onPointerEnter={onHoverStart}
72
97
  onPointerLeave={onHoverEnd}
98
+ onPointerDown={onPointerDown}
73
99
  onClick={onClick}
74
100
  onDoubleClick={onDoubleClick}
75
101
  >
102
+ <div
103
+ aria-hidden="true"
104
+ role="presentation"
105
+ onPointerDown={(e) => onResizeStart?.("start", e)}
106
+ style={{
107
+ position: "absolute",
108
+ left: 0,
109
+ top: 0,
110
+ bottom: 0,
111
+ width: 18,
112
+ opacity: showHandles ? 1 : 0,
113
+ pointerEvents: onResizeStart ? "auto" : "none",
114
+ zIndex: 4,
115
+ transition: "opacity 120ms ease-out",
116
+ cursor: "col-resize",
117
+ background: showHandles
118
+ ? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
119
+ : "transparent",
120
+ }}
121
+ >
122
+ <div
123
+ style={{
124
+ position: "absolute",
125
+ left: 6,
126
+ top: 7,
127
+ bottom: 7,
128
+ width: 3,
129
+ borderRadius: 999,
130
+ background: theme.handleColor,
131
+ boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
132
+ opacity: handleOpacity,
133
+ pointerEvents: "none",
134
+ }}
135
+ />
136
+ </div>
137
+ <div
138
+ aria-hidden="true"
139
+ role="presentation"
140
+ onPointerDown={(e) => onResizeStart?.("end", e)}
141
+ style={{
142
+ position: "absolute",
143
+ right: 0,
144
+ top: 0,
145
+ bottom: 0,
146
+ width: 18,
147
+ opacity: showHandles ? 1 : 0,
148
+ pointerEvents: onResizeStart ? "auto" : "none",
149
+ zIndex: 4,
150
+ transition: "opacity 120ms ease-out",
151
+ cursor: "col-resize",
152
+ background: showHandles
153
+ ? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
154
+ : "transparent",
155
+ }}
156
+ >
157
+ <div
158
+ style={{
159
+ position: "absolute",
160
+ right: 6,
161
+ top: 7,
162
+ bottom: 7,
163
+ width: 3,
164
+ borderRadius: 999,
165
+ background: theme.handleColor,
166
+ boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
167
+ opacity: handleOpacity,
168
+ pointerEvents: "none",
169
+ }}
170
+ />
171
+ </div>
76
172
  {children}
77
173
  </div>
78
174
  );
@@ -0,0 +1,310 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildTrackZIndexMap,
4
+ buildPromptCopyText,
5
+ buildTimelineAgentPrompt,
6
+ resolveTimelineAutoScroll,
7
+ resolveTimelineMove,
8
+ resolveTimelineResize,
9
+ type TimelinePromptElement,
10
+ } from "./timelineEditing";
11
+
12
+ describe("resolveTimelineMove", () => {
13
+ it("moves timing based on horizontal drag and snaps to centiseconds", () => {
14
+ expect(
15
+ resolveTimelineMove(
16
+ {
17
+ start: 1.25,
18
+ track: 2,
19
+ duration: 2,
20
+ originClientX: 100,
21
+ originClientY: 200,
22
+ pixelsPerSecond: 100,
23
+ trackHeight: 72,
24
+ maxStart: 8,
25
+ trackOrder: [0, 1, 2, 3, 4],
26
+ },
27
+ 245,
28
+ 200,
29
+ ),
30
+ ).toEqual({ start: 2.7, track: 2 });
31
+ });
32
+
33
+ it("moves layers based on vertical drag and clamps to the allowed range", () => {
34
+ expect(
35
+ resolveTimelineMove(
36
+ {
37
+ start: 2,
38
+ track: 1,
39
+ duration: 3,
40
+ originClientX: 200,
41
+ originClientY: 200,
42
+ pixelsPerSecond: 100,
43
+ trackHeight: 72,
44
+ maxStart: 10,
45
+ trackOrder: [0, 1, 5, 9],
46
+ },
47
+ 150,
48
+ 390,
49
+ ),
50
+ ).toEqual({ start: 1.5, track: 9 });
51
+ });
52
+
53
+ it("prevents moving before zero or past the last valid start", () => {
54
+ expect(
55
+ resolveTimelineMove(
56
+ {
57
+ start: 0.2,
58
+ track: 0,
59
+ duration: 4,
60
+ originClientX: 300,
61
+ originClientY: 200,
62
+ pixelsPerSecond: 100,
63
+ trackHeight: 72,
64
+ maxStart: 6,
65
+ trackOrder: [0, 10, 20],
66
+ },
67
+ -100,
68
+ -200,
69
+ ),
70
+ ).toEqual({ start: 0, track: -1 });
71
+
72
+ expect(
73
+ resolveTimelineMove(
74
+ {
75
+ start: 5.8,
76
+ track: 10,
77
+ duration: 4,
78
+ originClientX: 300,
79
+ originClientY: 200,
80
+ pixelsPerSecond: 100,
81
+ trackHeight: 72,
82
+ maxStart: 6,
83
+ trackOrder: [0, 10, 20],
84
+ },
85
+ 500,
86
+ 200,
87
+ ),
88
+ ).toEqual({ start: 6, track: 10 });
89
+ });
90
+
91
+ it("creates a new top track when dragged past the first row threshold", () => {
92
+ expect(
93
+ resolveTimelineMove(
94
+ {
95
+ start: 1,
96
+ track: 0,
97
+ duration: 2,
98
+ originClientX: 100,
99
+ originClientY: 200,
100
+ pixelsPerSecond: 100,
101
+ trackHeight: 72,
102
+ maxStart: 8,
103
+ trackOrder: [0, 10, 20],
104
+ },
105
+ 100,
106
+ 150,
107
+ ),
108
+ ).toEqual({ start: 1, track: -1 });
109
+ });
110
+
111
+ it("creates a new bottom track when dragged past the last row threshold", () => {
112
+ expect(
113
+ resolveTimelineMove(
114
+ {
115
+ start: 1,
116
+ track: 20,
117
+ duration: 2,
118
+ originClientX: 100,
119
+ originClientY: 200,
120
+ pixelsPerSecond: 100,
121
+ trackHeight: 72,
122
+ maxStart: 8,
123
+ trackOrder: [0, 10, 20],
124
+ },
125
+ 100,
126
+ 250,
127
+ ),
128
+ ).toEqual({ start: 1, track: 21 });
129
+ });
130
+
131
+ it("accounts for scroll displacement while dragging", () => {
132
+ expect(
133
+ resolveTimelineMove(
134
+ {
135
+ start: 1,
136
+ track: 0,
137
+ duration: 2,
138
+ originClientX: 100,
139
+ originClientY: 200,
140
+ originScrollLeft: 0,
141
+ originScrollTop: 0,
142
+ currentScrollLeft: 100,
143
+ currentScrollTop: 144,
144
+ pixelsPerSecond: 100,
145
+ trackHeight: 72,
146
+ maxStart: 8,
147
+ trackOrder: [0, 1, 2, 3],
148
+ },
149
+ 100,
150
+ 200,
151
+ ),
152
+ ).toEqual({ start: 2, track: 2 });
153
+ });
154
+ });
155
+
156
+ describe("buildTrackZIndexMap", () => {
157
+ it("maps sorted tracks onto stable positive z-index values", () => {
158
+ expect(buildTrackZIndexMap([-2, -1, 0, 3])).toEqual(
159
+ new Map([
160
+ [-2, 1],
161
+ [-1, 2],
162
+ [0, 3],
163
+ [3, 4],
164
+ ]),
165
+ );
166
+ });
167
+
168
+ it("deduplicates tracks before assigning z-index values", () => {
169
+ expect(buildTrackZIndexMap([-1, 0, -1, 3, 3])).toEqual(
170
+ new Map([
171
+ [-1, 1],
172
+ [0, 2],
173
+ [3, 3],
174
+ ]),
175
+ );
176
+ });
177
+ });
178
+
179
+ describe("resolveTimelineAutoScroll", () => {
180
+ it("does not scroll when the pointer stays away from the edges", () => {
181
+ expect(
182
+ resolveTimelineAutoScroll(
183
+ {
184
+ left: 100,
185
+ top: 100,
186
+ right: 500,
187
+ bottom: 400,
188
+ },
189
+ 300,
190
+ 250,
191
+ ),
192
+ ).toEqual({ x: 0, y: 0 });
193
+ });
194
+
195
+ it("scrolls upward and leftward near the top-left edge", () => {
196
+ expect(
197
+ resolveTimelineAutoScroll(
198
+ {
199
+ left: 100,
200
+ top: 100,
201
+ right: 500,
202
+ bottom: 400,
203
+ },
204
+ 110,
205
+ 120,
206
+ ),
207
+ ).toEqual({ x: -9, y: -6 });
208
+ });
209
+
210
+ it("scrolls downward and rightward near the bottom-right edge", () => {
211
+ expect(
212
+ resolveTimelineAutoScroll(
213
+ {
214
+ left: 100,
215
+ top: 100,
216
+ right: 500,
217
+ bottom: 400,
218
+ },
219
+ 490,
220
+ 380,
221
+ ),
222
+ ).toEqual({ x: 9, y: 6 });
223
+ });
224
+ });
225
+
226
+ describe("buildTimelineAgentPrompt", () => {
227
+ it("includes the selected range, elements, and user request", () => {
228
+ const elements: TimelinePromptElement[] = [
229
+ { id: "title", tag: "div", start: 1, duration: 3, track: 0 },
230
+ { id: "music", tag: "audio", start: 0, duration: 8, track: 2 },
231
+ ];
232
+
233
+ const text = buildTimelineAgentPrompt({
234
+ rangeStart: 1,
235
+ rangeEnd: 4,
236
+ elements,
237
+ prompt: "Move the title later and lower the music",
238
+ });
239
+
240
+ expect(text).toContain("Time range: 0:01 — 0:04");
241
+ expect(text).toContain("#title (div)");
242
+ expect(text).toContain("#music (audio)");
243
+ expect(text).toContain("Move the title later and lower the music");
244
+ });
245
+ });
246
+
247
+ describe("resolveTimelineResize", () => {
248
+ it("shrinks clip duration from the right edge", () => {
249
+ expect(
250
+ resolveTimelineResize(
251
+ {
252
+ start: 1,
253
+ duration: 3,
254
+ originClientX: 100,
255
+ pixelsPerSecond: 100,
256
+ minStart: 0,
257
+ maxEnd: 10,
258
+ },
259
+ "end",
260
+ 40,
261
+ ),
262
+ ).toEqual({ start: 1, duration: 2.4, playbackStart: undefined });
263
+ });
264
+
265
+ it("trims media from the left edge by advancing playback start and clip start", () => {
266
+ expect(
267
+ resolveTimelineResize(
268
+ {
269
+ start: 1,
270
+ duration: 3,
271
+ originClientX: 100,
272
+ pixelsPerSecond: 100,
273
+ minStart: 0,
274
+ maxEnd: 10,
275
+ playbackStart: 0.5,
276
+ playbackRate: 1,
277
+ },
278
+ "start",
279
+ 150,
280
+ ),
281
+ ).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
282
+ });
283
+
284
+ it("prevents extending media left past available source before media-start", () => {
285
+ expect(
286
+ resolveTimelineResize(
287
+ {
288
+ start: 1,
289
+ duration: 3,
290
+ originClientX: 100,
291
+ pixelsPerSecond: 100,
292
+ minStart: 0,
293
+ maxEnd: 10,
294
+ playbackStart: 0.2,
295
+ playbackRate: 1,
296
+ },
297
+ "start",
298
+ 0,
299
+ ),
300
+ ).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
301
+ });
302
+ });
303
+
304
+ describe("buildPromptCopyText", () => {
305
+ it("returns a trimmed prompt for the copy-prompt action", () => {
306
+ expect(buildPromptCopyText(" Tighten the headline timing ")).toBe(
307
+ "Tighten the headline timing",
308
+ );
309
+ });
310
+ });
@@ -0,0 +1,213 @@
1
+ import { formatTime } from "../lib/time";
2
+
3
+ const TIME_PRECISION = 100;
4
+
5
+ function roundToCentiseconds(value: number): number {
6
+ return Math.round(value * TIME_PRECISION) / TIME_PRECISION;
7
+ }
8
+
9
+ function clamp(value: number, min: number, max: number): number {
10
+ return Math.min(Math.max(value, min), max);
11
+ }
12
+
13
+ const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
14
+ const AUTO_SCROLL_EDGE_ZONE = 40;
15
+ const AUTO_SCROLL_MAX_SPEED = 12;
16
+
17
+ export interface TimelineMoveInput {
18
+ start: number;
19
+ track: number;
20
+ duration: number;
21
+ originClientX: number;
22
+ originClientY: number;
23
+ originScrollLeft?: number;
24
+ originScrollTop?: number;
25
+ currentScrollLeft?: number;
26
+ currentScrollTop?: number;
27
+ pixelsPerSecond: number;
28
+ trackHeight: number;
29
+ maxStart: number;
30
+ trackOrder: number[];
31
+ }
32
+
33
+ export interface TimelineResizeInput {
34
+ start: number;
35
+ duration: number;
36
+ originClientX: number;
37
+ pixelsPerSecond: number;
38
+ minStart: number;
39
+ maxEnd: number;
40
+ minDuration?: number;
41
+ playbackStart?: number;
42
+ playbackRate?: number;
43
+ }
44
+
45
+ export interface TimelineAutoScrollBounds {
46
+ left: number;
47
+ top: number;
48
+ right: number;
49
+ bottom: number;
50
+ }
51
+
52
+ export function resolveTimelineAutoScroll(
53
+ bounds: TimelineAutoScrollBounds,
54
+ clientX: number,
55
+ clientY: number,
56
+ ): { x: number; y: number } {
57
+ const getAxisDelta = (start: number, end: number, pointer: number) => {
58
+ if (pointer < start + AUTO_SCROLL_EDGE_ZONE) {
59
+ const proximity = Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE);
60
+ return -Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
61
+ }
62
+ if (pointer > end - AUTO_SCROLL_EDGE_ZONE) {
63
+ const proximity = Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE);
64
+ return Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
65
+ }
66
+ return 0;
67
+ };
68
+
69
+ return {
70
+ x: getAxisDelta(bounds.left, bounds.right, clientX),
71
+ y: getAxisDelta(bounds.top, bounds.bottom, clientY),
72
+ };
73
+ }
74
+
75
+ export function resolveTimelineMove(
76
+ input: TimelineMoveInput,
77
+ clientX: number,
78
+ clientY: number,
79
+ ): { start: number; track: number } {
80
+ const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
81
+ const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
82
+ const deltaTime =
83
+ (clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1);
84
+ const trackDeltaRaw =
85
+ (clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
86
+ const deltaTrack = Math.round(trackDeltaRaw);
87
+ const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
88
+ const desiredTrackIndex = currentTrackIndex + deltaTrack;
89
+ const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
90
+ const minTrack = Math.min(...input.trackOrder);
91
+ const maxTrack = Math.max(...input.trackOrder);
92
+ let nextTrack = input.trackOrder[nextTrackIndex] ?? input.track;
93
+
94
+ const startedOnFirstTrack = currentTrackIndex === 0;
95
+ const startedOnLastTrack = currentTrackIndex === input.trackOrder.length - 1;
96
+
97
+ if (
98
+ startedOnFirstTrack &&
99
+ desiredTrackIndex < 0 &&
100
+ currentTrackIndex + trackDeltaRaw <= -EDGE_TRACK_CREATE_THRESHOLD
101
+ ) {
102
+ nextTrack = minTrack - 1;
103
+ } else if (
104
+ startedOnLastTrack &&
105
+ desiredTrackIndex > input.trackOrder.length - 1 &&
106
+ currentTrackIndex + trackDeltaRaw >= input.trackOrder.length - 1 + EDGE_TRACK_CREATE_THRESHOLD
107
+ ) {
108
+ nextTrack = maxTrack + 1;
109
+ }
110
+
111
+ return {
112
+ start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
113
+ track: nextTrack,
114
+ };
115
+ }
116
+
117
+ export function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
118
+ const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
119
+ return new Map(uniqueTracks.map((track, index) => [track, index + 1]));
120
+ }
121
+
122
+ export function resolveTimelineResize(
123
+ input: TimelineResizeInput,
124
+ edge: "start" | "end",
125
+ clientX: number,
126
+ ): { start: number; duration: number; playbackStart?: number } {
127
+ const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
128
+ const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
129
+
130
+ if (edge === "end") {
131
+ const nextDuration = clamp(
132
+ roundToCentiseconds(input.duration + deltaTime),
133
+ minDuration,
134
+ Math.max(minDuration, input.maxEnd - input.start),
135
+ );
136
+ return {
137
+ start: input.start,
138
+ duration: nextDuration,
139
+ playbackStart: input.playbackStart,
140
+ };
141
+ }
142
+
143
+ const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
144
+ const maxLeftExtensionFromMedia =
145
+ input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
146
+ const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
147
+ const maxDelta = input.duration - minDuration;
148
+ const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
149
+ const nextStart = roundToCentiseconds(input.start + clampedDelta);
150
+ const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
151
+ const nextPlaybackStart =
152
+ input.playbackStart != null
153
+ ? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
154
+ : undefined;
155
+
156
+ return {
157
+ start: nextStart,
158
+ duration: nextDuration,
159
+ playbackStart: nextPlaybackStart,
160
+ };
161
+ }
162
+
163
+ export interface TimelinePromptElement {
164
+ id: string;
165
+ tag: string;
166
+ start: number;
167
+ duration: number;
168
+ track: number;
169
+ }
170
+
171
+ export function buildTimelineAgentPrompt({
172
+ rangeStart,
173
+ rangeEnd,
174
+ elements,
175
+ prompt,
176
+ }: {
177
+ rangeStart: number;
178
+ rangeEnd: number;
179
+ elements: TimelinePromptElement[];
180
+ prompt: string;
181
+ }): string {
182
+ const start = Math.min(rangeStart, rangeEnd);
183
+ const end = Math.max(rangeStart, rangeEnd);
184
+ const elementLines = elements
185
+ .map(
186
+ (el) =>
187
+ `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
188
+ )
189
+ .join("\n");
190
+
191
+ return `Edit the following HyperFrames composition:
192
+
193
+ Time range: ${formatTime(start)} — ${formatTime(end)}
194
+
195
+ Elements in range:
196
+ ${elementLines || "(none)"}
197
+
198
+ User request:
199
+ ${prompt.trim() || "(no prompt provided)"}
200
+
201
+ Instructions:
202
+ Modify only the elements listed above within the specified time range.
203
+ The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
204
+ Preserve all other elements and timing outside this range.`;
205
+ }
206
+
207
+ export function buildPromptCopyText(prompt: string): string {
208
+ return prompt.trim();
209
+ }
210
+
211
+ export function formatTimelineAttributeNumber(value: number): string {
212
+ return Number(roundToCentiseconds(value).toFixed(2)).toString();
213
+ }