@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.
Files changed (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. 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
+ }