@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.
- package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
- package/dist/assets/index-BKkR67xb.css +1 -0
- package/dist/assets/index-rN5doSq1.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +289 -11
- package/src/components/nle/NLELayout.tsx +24 -7
- package/src/components/nle/NLEPreview.test.ts +32 -0
- package/src/components/nle/NLEPreview.tsx +12 -1
- package/src/player/components/CompositionThumbnail.tsx +94 -17
- package/src/player/components/EditModal.tsx +48 -29
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/PlayerControls.test.ts +20 -0
- package/src/player/components/PlayerControls.tsx +12 -1
- package/src/player/components/Timeline.test.ts +44 -1
- package/src/player/components/Timeline.tsx +686 -169
- package/src/player/components/TimelineClip.tsx +112 -16
- package/src/player/components/timelineEditing.test.ts +310 -0
- package/src/player/components/timelineEditing.ts +213 -0
- package/src/player/components/timelineTheme.test.ts +56 -0
- package/src/player/components/timelineTheme.ts +141 -0
- package/src/player/components/timelineZoom.test.ts +62 -0
- package/src/player/components/timelineZoom.ts +38 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
- package/src/player/hooks/useTimelinePlayer.ts +313 -59
- package/src/player/store/playerStore.test.ts +30 -12
- package/src/player/store/playerStore.ts +23 -9
- package/src/types/hyperframes-player.d.ts +1 -0
- package/src/utils/sourcePatcher.test.ts +84 -0
- package/src/utils/sourcePatcher.ts +143 -0
- package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
- package/dist/assets/index-CVDXfFQ6.js +0 -93
- 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
|
-
|
|
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
|
-
|
|
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={
|
|
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:
|
|
50
|
-
|
|
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.
|
|
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:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|