@hyperframes/studio 0.6.0 → 0.6.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-CzwFysqv.js +418 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -13
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/StudioPreviewArea.tsx +6 -2
- package/src/components/editor/DomEditOverlay.tsx +88 -1007
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1150
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEdits.ts +84 -1081
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +60 -144
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Timeline.tsx +189 -1418
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +69 -1372
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
- package/dist/assets/index-DUqUmaoH.js +0 -117
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { memo, type ReactNode } from "react";
|
|
2
|
+
import { TimelineClip } from "./TimelineClip";
|
|
3
|
+
import { TimelineRuler } from "./TimelineRuler";
|
|
4
|
+
import {
|
|
5
|
+
getTimelineEditCapabilities,
|
|
6
|
+
resolveBlockedTimelineEditIntent,
|
|
7
|
+
type TimelineRangeSelection,
|
|
8
|
+
} from "./timelineEditing";
|
|
9
|
+
import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
|
|
10
|
+
import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
|
|
11
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
+
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
13
|
+
import { formatTime } from "../lib/time";
|
|
14
|
+
import type { TrackVisualStyle } from "./timelineIcons";
|
|
15
|
+
|
|
16
|
+
interface TimelineCanvasProps {
|
|
17
|
+
major: number[];
|
|
18
|
+
minor: number[];
|
|
19
|
+
pps: number;
|
|
20
|
+
trackContentWidth: number;
|
|
21
|
+
totalH: number;
|
|
22
|
+
effectiveDuration: number;
|
|
23
|
+
majorTickInterval: number;
|
|
24
|
+
shiftHeld: boolean;
|
|
25
|
+
rangeSelection: TimelineRangeSelection | null;
|
|
26
|
+
theme: TimelineTheme;
|
|
27
|
+
displayTrackOrder: number[];
|
|
28
|
+
trackOrder: number[];
|
|
29
|
+
tracks: [number, TimelineElement[]][];
|
|
30
|
+
trackStyles: Map<number, TrackVisualStyle>;
|
|
31
|
+
selectedElementId: string | null;
|
|
32
|
+
hoveredClip: string | null;
|
|
33
|
+
draggedClip: DraggedClipState | null;
|
|
34
|
+
resizingClip: ResizingClipState | null;
|
|
35
|
+
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
36
|
+
suppressClickRef: React.RefObject<boolean>;
|
|
37
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
38
|
+
renderClipContent?: (
|
|
39
|
+
element: TimelineElement,
|
|
40
|
+
style: { clip: string; label: string },
|
|
41
|
+
) => ReactNode;
|
|
42
|
+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
43
|
+
playheadRef: React.RefObject<HTMLDivElement | null>;
|
|
44
|
+
onResizeElement?: unknown;
|
|
45
|
+
onMoveElement?: unknown;
|
|
46
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
47
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
48
|
+
setHoveredClip: (key: string | null) => void;
|
|
49
|
+
setShowPopover: (v: boolean) => void;
|
|
50
|
+
setRangeSelection: (v: null) => void;
|
|
51
|
+
setResizingClip: (v: ResizingClipState | null) => void;
|
|
52
|
+
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
53
|
+
setSelectedElementId: (id: string | null) => void;
|
|
54
|
+
syncClipDragAutoScroll: (x: number, y: number) => void;
|
|
55
|
+
shiftClickClipRef: React.RefObject<{
|
|
56
|
+
element: TimelineElement;
|
|
57
|
+
anchorX: number;
|
|
58
|
+
anchorY: number;
|
|
59
|
+
} | null>;
|
|
60
|
+
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
61
|
+
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const TimelineCanvas = memo(function TimelineCanvas({
|
|
65
|
+
major,
|
|
66
|
+
minor,
|
|
67
|
+
pps,
|
|
68
|
+
trackContentWidth,
|
|
69
|
+
totalH,
|
|
70
|
+
effectiveDuration,
|
|
71
|
+
majorTickInterval,
|
|
72
|
+
shiftHeld,
|
|
73
|
+
rangeSelection,
|
|
74
|
+
theme,
|
|
75
|
+
displayTrackOrder,
|
|
76
|
+
trackOrder,
|
|
77
|
+
tracks,
|
|
78
|
+
trackStyles,
|
|
79
|
+
selectedElementId,
|
|
80
|
+
hoveredClip,
|
|
81
|
+
draggedClip,
|
|
82
|
+
resizingClip: _resizingClip,
|
|
83
|
+
blockedClipRef,
|
|
84
|
+
suppressClickRef,
|
|
85
|
+
scrollRef,
|
|
86
|
+
renderClipContent,
|
|
87
|
+
renderClipOverlay,
|
|
88
|
+
playheadRef,
|
|
89
|
+
onResizeElement,
|
|
90
|
+
onMoveElement,
|
|
91
|
+
onDrillDown,
|
|
92
|
+
onSelectElement,
|
|
93
|
+
setHoveredClip,
|
|
94
|
+
setShowPopover,
|
|
95
|
+
setRangeSelection,
|
|
96
|
+
setResizingClip,
|
|
97
|
+
setDraggedClip,
|
|
98
|
+
setSelectedElementId,
|
|
99
|
+
syncClipDragAutoScroll,
|
|
100
|
+
shiftClickClipRef,
|
|
101
|
+
getPreviewElement,
|
|
102
|
+
getTrackStyle,
|
|
103
|
+
}: TimelineCanvasProps) {
|
|
104
|
+
const draggedElement = draggedClip?.element ?? null;
|
|
105
|
+
const activeDraggedElement =
|
|
106
|
+
draggedClip?.started === true && draggedElement
|
|
107
|
+
? getRenderedTimelineElement({
|
|
108
|
+
element: draggedElement,
|
|
109
|
+
draggedElementId: draggedElement.key ?? draggedElement.id,
|
|
110
|
+
previewStart: draggedClip.previewStart,
|
|
111
|
+
previewTrack: draggedClip.previewTrack,
|
|
112
|
+
})
|
|
113
|
+
: null;
|
|
114
|
+
const activeDraggedPosition =
|
|
115
|
+
draggedClip?.started === true && activeDraggedElement && scrollRef.current
|
|
116
|
+
? {
|
|
117
|
+
left:
|
|
118
|
+
draggedClip.pointerClientX -
|
|
119
|
+
scrollRef.current.getBoundingClientRect().left +
|
|
120
|
+
scrollRef.current.scrollLeft -
|
|
121
|
+
draggedClip.pointerOffsetX,
|
|
122
|
+
top:
|
|
123
|
+
draggedClip.pointerClientY -
|
|
124
|
+
scrollRef.current.getBoundingClientRect().top +
|
|
125
|
+
scrollRef.current.scrollTop -
|
|
126
|
+
draggedClip.pointerOffsetY,
|
|
127
|
+
}
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => (
|
|
131
|
+
<>
|
|
132
|
+
{renderClipOverlay?.(element)}
|
|
133
|
+
<div
|
|
134
|
+
className={
|
|
135
|
+
renderClipContent
|
|
136
|
+
? "absolute inset-0 overflow-hidden"
|
|
137
|
+
: "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
|
|
138
|
+
}
|
|
139
|
+
>
|
|
140
|
+
{renderClipContent?.(element, clipStyle) ?? (
|
|
141
|
+
<div className="flex h-full min-h-0 flex-col justify-between py-3">
|
|
142
|
+
<span
|
|
143
|
+
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
|
|
144
|
+
style={{
|
|
145
|
+
color: clipStyle.label,
|
|
146
|
+
background: `${clipStyle.accent}26`,
|
|
147
|
+
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{element.tag}
|
|
151
|
+
</span>
|
|
152
|
+
<span
|
|
153
|
+
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
|
|
154
|
+
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.04)" }}
|
|
155
|
+
>
|
|
156
|
+
{formatTime(element.start)} {"→"} {formatTime(element.start + element.duration)}
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
</>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
|
|
166
|
+
<TimelineRuler
|
|
167
|
+
major={major}
|
|
168
|
+
minor={minor}
|
|
169
|
+
pps={pps}
|
|
170
|
+
trackContentWidth={trackContentWidth}
|
|
171
|
+
totalH={totalH}
|
|
172
|
+
effectiveDuration={effectiveDuration}
|
|
173
|
+
majorTickInterval={majorTickInterval}
|
|
174
|
+
shiftHeld={shiftHeld}
|
|
175
|
+
rangeSelection={rangeSelection}
|
|
176
|
+
theme={theme}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
{displayTrackOrder.map((trackNum) => {
|
|
180
|
+
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
181
|
+
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
182
|
+
const isPendingTrack =
|
|
183
|
+
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
key={trackNum}
|
|
187
|
+
className="relative flex"
|
|
188
|
+
style={{
|
|
189
|
+
height: TRACK_H,
|
|
190
|
+
background: theme.rowBackground,
|
|
191
|
+
borderBottom: `1px solid ${theme.rowBorder}`,
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<div
|
|
195
|
+
className="flex-shrink-0 flex items-center justify-center"
|
|
196
|
+
style={{
|
|
197
|
+
width: GUTTER,
|
|
198
|
+
background: theme.gutterBackground,
|
|
199
|
+
borderRight: `1px solid ${theme.gutterBorder}`,
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<div
|
|
203
|
+
className="flex items-center justify-center"
|
|
204
|
+
style={{
|
|
205
|
+
width: 18,
|
|
206
|
+
height: 18,
|
|
207
|
+
borderRadius: 6,
|
|
208
|
+
backgroundColor: ts.iconBackground,
|
|
209
|
+
border: `1px solid ${theme.gutterBorder}`,
|
|
210
|
+
color: "#fff",
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
{ts.icon}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
<div style={{ width: trackContentWidth }} className="relative">
|
|
217
|
+
{isPendingTrack && (
|
|
218
|
+
<div
|
|
219
|
+
className="absolute inset-0 flex items-center"
|
|
220
|
+
style={{
|
|
221
|
+
paddingLeft: 16,
|
|
222
|
+
color: ts.label,
|
|
223
|
+
fontSize: 11,
|
|
224
|
+
letterSpacing: "0.08em",
|
|
225
|
+
textTransform: "uppercase",
|
|
226
|
+
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
|
|
227
|
+
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
New track
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
{els.map((el, i) => {
|
|
234
|
+
const clipStyle = getTrackStyle(el.tag);
|
|
235
|
+
const elementKey = el.key ?? el.id;
|
|
236
|
+
const capabilities = getTimelineEditCapabilities(el);
|
|
237
|
+
const isSelected = selectedElementId === elementKey;
|
|
238
|
+
const isComposition = !!el.compositionSrc;
|
|
239
|
+
const clipKey = `${elementKey}-${i}`;
|
|
240
|
+
const isDraggingClip =
|
|
241
|
+
draggedClip?.started === true &&
|
|
242
|
+
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
|
243
|
+
if (isDraggingClip) return null;
|
|
244
|
+
const previewElement = getPreviewElement(el);
|
|
245
|
+
return (
|
|
246
|
+
<TimelineClip
|
|
247
|
+
key={clipKey}
|
|
248
|
+
el={previewElement}
|
|
249
|
+
pps={pps}
|
|
250
|
+
clipY={CLIP_Y}
|
|
251
|
+
isSelected={isSelected}
|
|
252
|
+
isHovered={hoveredClip === clipKey}
|
|
253
|
+
isDragging={false}
|
|
254
|
+
hasCustomContent={!!renderClipContent}
|
|
255
|
+
theme={theme}
|
|
256
|
+
trackStyle={clipStyle}
|
|
257
|
+
isComposition={isComposition}
|
|
258
|
+
onHoverStart={() => setHoveredClip(clipKey)}
|
|
259
|
+
onHoverEnd={() => setHoveredClip(null)}
|
|
260
|
+
onResizeStart={(edge, e) => {
|
|
261
|
+
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
262
|
+
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
263
|
+
if (edge === "end" && !capabilities.canTrimEnd) return;
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
blockedClipRef.current = null;
|
|
266
|
+
setShowPopover(false);
|
|
267
|
+
setRangeSelection(null);
|
|
268
|
+
setResizingClip({
|
|
269
|
+
element: el,
|
|
270
|
+
edge,
|
|
271
|
+
originClientX: e.clientX,
|
|
272
|
+
previewStart: el.start,
|
|
273
|
+
previewDuration: el.duration,
|
|
274
|
+
previewPlaybackStart: el.playbackStart,
|
|
275
|
+
started: false,
|
|
276
|
+
});
|
|
277
|
+
}}
|
|
278
|
+
onPointerDown={(e) => {
|
|
279
|
+
if (e.button !== 0) return;
|
|
280
|
+
if (e.shiftKey) {
|
|
281
|
+
shiftClickClipRef.current = {
|
|
282
|
+
element: el,
|
|
283
|
+
anchorX: e.clientX,
|
|
284
|
+
anchorY: e.clientY,
|
|
285
|
+
};
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const target = e.currentTarget as HTMLElement;
|
|
289
|
+
const rect = target.getBoundingClientRect();
|
|
290
|
+
const blockedIntent = resolveBlockedTimelineEditIntent({
|
|
291
|
+
width: rect.width,
|
|
292
|
+
offsetX: e.clientX - rect.left,
|
|
293
|
+
handleWidth: CLIP_HANDLE_W,
|
|
294
|
+
capabilities,
|
|
295
|
+
});
|
|
296
|
+
if (
|
|
297
|
+
blockedIntent &&
|
|
298
|
+
((blockedIntent === "move" && onMoveElement) ||
|
|
299
|
+
(blockedIntent !== "move" && onResizeElement))
|
|
300
|
+
) {
|
|
301
|
+
blockedClipRef.current = {
|
|
302
|
+
element: el,
|
|
303
|
+
intent: blockedIntent,
|
|
304
|
+
originClientX: e.clientX,
|
|
305
|
+
originClientY: e.clientY,
|
|
306
|
+
started: false,
|
|
307
|
+
};
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!onMoveElement || !capabilities.canMove) return;
|
|
311
|
+
blockedClipRef.current = null;
|
|
312
|
+
setShowPopover(false);
|
|
313
|
+
setRangeSelection(null);
|
|
314
|
+
setDraggedClip({
|
|
315
|
+
element: el,
|
|
316
|
+
originClientX: e.clientX,
|
|
317
|
+
originClientY: e.clientY,
|
|
318
|
+
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
319
|
+
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
|
320
|
+
pointerClientX: e.clientX,
|
|
321
|
+
pointerClientY: e.clientY,
|
|
322
|
+
pointerOffsetX: e.clientX - rect.left,
|
|
323
|
+
pointerOffsetY: e.clientY - rect.top,
|
|
324
|
+
previewStart: el.start,
|
|
325
|
+
previewTrack: el.track,
|
|
326
|
+
started: false,
|
|
327
|
+
});
|
|
328
|
+
syncClipDragAutoScroll(e.clientX, e.clientY);
|
|
329
|
+
}}
|
|
330
|
+
onClick={(e) => {
|
|
331
|
+
e.stopPropagation();
|
|
332
|
+
if (suppressClickRef.current) return;
|
|
333
|
+
const nextElement = isSelected ? null : el;
|
|
334
|
+
setSelectedElementId(nextElement ? elementKey : null);
|
|
335
|
+
onSelectElement?.(nextElement);
|
|
336
|
+
}}
|
|
337
|
+
onDoubleClick={(e) => {
|
|
338
|
+
e.stopPropagation();
|
|
339
|
+
if (suppressClickRef.current) return;
|
|
340
|
+
if (isComposition && onDrillDown) onDrillDown(el);
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
{renderClipChildren(previewElement, clipStyle)}
|
|
344
|
+
</TimelineClip>
|
|
345
|
+
);
|
|
346
|
+
})}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
})}
|
|
351
|
+
|
|
352
|
+
{/* Drag ghost */}
|
|
353
|
+
{activeDraggedElement && activeDraggedPosition && (
|
|
354
|
+
<div
|
|
355
|
+
className="absolute pointer-events-none"
|
|
356
|
+
style={{
|
|
357
|
+
top: activeDraggedPosition.top,
|
|
358
|
+
left: activeDraggedPosition.left,
|
|
359
|
+
width: Math.max(activeDraggedElement.duration * pps, 4),
|
|
360
|
+
height: TRACK_H - CLIP_Y * 2,
|
|
361
|
+
zIndex: 40,
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
<TimelineClip
|
|
365
|
+
el={{ ...activeDraggedElement, start: 0 }}
|
|
366
|
+
pps={pps}
|
|
367
|
+
clipY={0}
|
|
368
|
+
isSelected={selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)}
|
|
369
|
+
isHovered={false}
|
|
370
|
+
isDragging={true}
|
|
371
|
+
hasCustomContent={!!renderClipContent}
|
|
372
|
+
theme={theme}
|
|
373
|
+
trackStyle={getTrackStyle(activeDraggedElement.tag)}
|
|
374
|
+
isComposition={!!activeDraggedElement.compositionSrc}
|
|
375
|
+
onHoverStart={() => {}}
|
|
376
|
+
onHoverEnd={() => {}}
|
|
377
|
+
onResizeStart={() => {}}
|
|
378
|
+
onClick={() => {}}
|
|
379
|
+
onDoubleClick={() => {}}
|
|
380
|
+
>
|
|
381
|
+
{renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
|
|
382
|
+
</TimelineClip>
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
{/* Range highlight */}
|
|
387
|
+
{rangeSelection && (
|
|
388
|
+
<div
|
|
389
|
+
className="absolute pointer-events-none"
|
|
390
|
+
style={{
|
|
391
|
+
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
|
|
392
|
+
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
|
|
393
|
+
top: RULER_H,
|
|
394
|
+
bottom: 0,
|
|
395
|
+
backgroundColor: "rgba(59, 130, 246, 0.12)",
|
|
396
|
+
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
|
|
397
|
+
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
|
|
398
|
+
zIndex: 50,
|
|
399
|
+
}}
|
|
400
|
+
/>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* Playhead */}
|
|
404
|
+
<div
|
|
405
|
+
ref={playheadRef}
|
|
406
|
+
className="absolute top-0 bottom-0 pointer-events-none"
|
|
407
|
+
style={{ left: `${GUTTER}px`, zIndex: 100 }}
|
|
408
|
+
>
|
|
409
|
+
<div
|
|
410
|
+
className="absolute top-0 bottom-0"
|
|
411
|
+
style={{
|
|
412
|
+
left: "50%",
|
|
413
|
+
width: 2,
|
|
414
|
+
marginLeft: -1,
|
|
415
|
+
background: "var(--hf-accent, #3CE6AC)",
|
|
416
|
+
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
417
|
+
}}
|
|
418
|
+
/>
|
|
419
|
+
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
|
|
420
|
+
<div
|
|
421
|
+
style={{
|
|
422
|
+
width: 0,
|
|
423
|
+
height: 0,
|
|
424
|
+
borderLeft: "6px solid transparent",
|
|
425
|
+
borderRight: "6px solid transparent",
|
|
426
|
+
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
427
|
+
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
428
|
+
}}
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { DragEventHandler } from "react";
|
|
2
|
+
import { GUTTER, RULER_H } from "./timelineLayout";
|
|
3
|
+
|
|
4
|
+
interface TimelineEmptyStateProps {
|
|
5
|
+
isDragOver: boolean;
|
|
6
|
+
onFileDrop?: boolean;
|
|
7
|
+
onDragOver: DragEventHandler<HTMLDivElement>;
|
|
8
|
+
onDragLeave: DragEventHandler<HTMLDivElement>;
|
|
9
|
+
onDrop: DragEventHandler<HTMLDivElement>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TimelineEmptyState({
|
|
13
|
+
isDragOver,
|
|
14
|
+
onFileDrop,
|
|
15
|
+
onDragOver,
|
|
16
|
+
onDragLeave,
|
|
17
|
+
onDrop,
|
|
18
|
+
}: TimelineEmptyStateProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
|
22
|
+
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
|
23
|
+
}`}
|
|
24
|
+
onDragOver={onDragOver}
|
|
25
|
+
onDragLeave={onDragLeave}
|
|
26
|
+
onDrop={onDrop}
|
|
27
|
+
>
|
|
28
|
+
{/* Ruler */}
|
|
29
|
+
<div
|
|
30
|
+
className="flex-shrink-0 border-b border-neutral-800/40 flex items-end relative"
|
|
31
|
+
style={{ height: RULER_H, paddingLeft: GUTTER }}
|
|
32
|
+
>
|
|
33
|
+
{[0, 10, 20, 30, 40, 50].map((s) => (
|
|
34
|
+
<div
|
|
35
|
+
key={s}
|
|
36
|
+
className="flex flex-col items-center"
|
|
37
|
+
style={{ position: "absolute", left: GUTTER + s * 14 }}
|
|
38
|
+
>
|
|
39
|
+
<span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
|
|
40
|
+
{`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
|
|
41
|
+
</span>
|
|
42
|
+
<div className="w-px h-[5px] bg-neutral-700/40" />
|
|
43
|
+
</div>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
{/* Empty drop zone */}
|
|
47
|
+
<div className="flex-1 flex items-center justify-center">
|
|
48
|
+
<div
|
|
49
|
+
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
|
|
50
|
+
isDragOver ? "border-studio-accent/60 bg-studio-accent/[0.06]" : "border-neutral-700/50"
|
|
51
|
+
}`}
|
|
52
|
+
>
|
|
53
|
+
{isDragOver ? (
|
|
54
|
+
<>
|
|
55
|
+
<svg
|
|
56
|
+
width="18"
|
|
57
|
+
height="18"
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
strokeWidth="1.5"
|
|
62
|
+
strokeLinecap="round"
|
|
63
|
+
strokeLinejoin="round"
|
|
64
|
+
className="text-studio-accent flex-shrink-0"
|
|
65
|
+
>
|
|
66
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
67
|
+
<polyline points="7 10 12 15 17 10" />
|
|
68
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
69
|
+
</svg>
|
|
70
|
+
<span className="text-[13px] text-studio-accent">Drop media files to import</span>
|
|
71
|
+
</>
|
|
72
|
+
) : (
|
|
73
|
+
<>
|
|
74
|
+
<svg
|
|
75
|
+
width="18"
|
|
76
|
+
height="18"
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
fill="none"
|
|
79
|
+
stroke="currentColor"
|
|
80
|
+
strokeWidth="1.5"
|
|
81
|
+
strokeLinecap="round"
|
|
82
|
+
strokeLinejoin="round"
|
|
83
|
+
className="text-neutral-600 flex-shrink-0"
|
|
84
|
+
>
|
|
85
|
+
<rect x="2" y="2" width="20" height="20" rx="2" />
|
|
86
|
+
<path d="M7 2v20" />
|
|
87
|
+
<path d="M17 2v20" />
|
|
88
|
+
<path d="M2 7h20" />
|
|
89
|
+
<path d="M2 17h20" />
|
|
90
|
+
</svg>
|
|
91
|
+
<span className="text-[13px] text-neutral-500">
|
|
92
|
+
{onFileDrop
|
|
93
|
+
? "Drop media here or describe your video to start"
|
|
94
|
+
: "Describe your video to start creating"}
|
|
95
|
+
</span>
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
3
|
+
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
4
|
+
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
|
|
5
|
+
|
|
6
|
+
interface TimelineRulerProps {
|
|
7
|
+
major: number[];
|
|
8
|
+
minor: number[];
|
|
9
|
+
pps: number;
|
|
10
|
+
trackContentWidth: number;
|
|
11
|
+
totalH: number;
|
|
12
|
+
effectiveDuration: number;
|
|
13
|
+
majorTickInterval: number;
|
|
14
|
+
shiftHeld: boolean;
|
|
15
|
+
rangeSelection: TimelineRangeSelection | null;
|
|
16
|
+
theme: TimelineTheme;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const TimelineRuler = memo(function TimelineRuler({
|
|
20
|
+
major,
|
|
21
|
+
minor,
|
|
22
|
+
pps,
|
|
23
|
+
trackContentWidth,
|
|
24
|
+
totalH,
|
|
25
|
+
effectiveDuration,
|
|
26
|
+
majorTickInterval,
|
|
27
|
+
shiftHeld,
|
|
28
|
+
rangeSelection,
|
|
29
|
+
theme,
|
|
30
|
+
}: TimelineRulerProps) {
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
{/* Grid lines */}
|
|
34
|
+
<svg
|
|
35
|
+
className="absolute pointer-events-none"
|
|
36
|
+
style={{ left: GUTTER, width: trackContentWidth }}
|
|
37
|
+
height={totalH}
|
|
38
|
+
>
|
|
39
|
+
{major.map((t) => {
|
|
40
|
+
const x = t * pps;
|
|
41
|
+
return (
|
|
42
|
+
<line
|
|
43
|
+
key={`g-${t}`}
|
|
44
|
+
x1={x}
|
|
45
|
+
y1={RULER_H}
|
|
46
|
+
x2={x}
|
|
47
|
+
y2={totalH}
|
|
48
|
+
stroke={theme.tickMinor}
|
|
49
|
+
strokeWidth="1"
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</svg>
|
|
54
|
+
|
|
55
|
+
{/* Ruler */}
|
|
56
|
+
<div
|
|
57
|
+
className="relative overflow-hidden"
|
|
58
|
+
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
|
|
59
|
+
>
|
|
60
|
+
{shiftHeld && !rangeSelection && (
|
|
61
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
|
62
|
+
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
|
63
|
+
Drag or click a clip to edit range
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
{minor.map((t) => (
|
|
68
|
+
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
|
69
|
+
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
{major.map((t) => (
|
|
73
|
+
<div
|
|
74
|
+
key={`M-${t}`}
|
|
75
|
+
className="absolute bottom-0 flex flex-col items-center"
|
|
76
|
+
style={{ left: t * pps }}
|
|
77
|
+
>
|
|
78
|
+
<span
|
|
79
|
+
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
|
80
|
+
style={{ color: theme.tickText }}
|
|
81
|
+
>
|
|
82
|
+
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
|
83
|
+
</span>
|
|
84
|
+
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { getTimelineTrackStyle, type TimelineTrackStyle } from "./timelineTheme";
|
|
3
|
+
|
|
4
|
+
export interface TrackVisualStyle extends TimelineTrackStyle {
|
|
5
|
+
icon: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const ICON_BASE = "/icons/timeline";
|
|
9
|
+
function TimelineIcon({ src }: { src: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<img
|
|
12
|
+
src={src}
|
|
13
|
+
alt=""
|
|
14
|
+
width={12}
|
|
15
|
+
height={12}
|
|
16
|
+
style={{ filter: "brightness(0) invert(1)" }}
|
|
17
|
+
draggable={false}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
|
|
23
|
+
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
|
|
24
|
+
const IconMusic = <TimelineIcon src={`${ICON_BASE}/music.svg`} />;
|
|
25
|
+
const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
|
26
|
+
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
|
27
|
+
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
|
28
|
+
|
|
29
|
+
const ICONS: Record<string, ReactNode> = {
|
|
30
|
+
video: IconImage,
|
|
31
|
+
audio: IconMusic,
|
|
32
|
+
img: IconImage,
|
|
33
|
+
div: IconComposition,
|
|
34
|
+
span: IconCaptions,
|
|
35
|
+
p: IconText,
|
|
36
|
+
h1: IconText,
|
|
37
|
+
section: IconComposition,
|
|
38
|
+
sfx: IconAudio,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function getTrackStyle(tag: string): TrackVisualStyle {
|
|
42
|
+
const trackStyle = getTimelineTrackStyle(tag);
|
|
43
|
+
const normalized = tag.toLowerCase();
|
|
44
|
+
const icon =
|
|
45
|
+
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
|
|
46
|
+
? ICONS.h1
|
|
47
|
+
: (ICONS[normalized] ?? IconComposition);
|
|
48
|
+
return { ...trackStyle, icon };
|
|
49
|
+
}
|