@hyperframes/studio 0.1.10 → 0.1.12

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 (38) hide show
  1. package/dist/assets/index-BEwJNmPo.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +137 -0
  11. package/src/components/renders/useRenderQueue.ts +193 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
@@ -1,12 +1,15 @@
1
1
  import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
2
2
  import { usePlayerStore, liveTime } from "../store/playerStore";
3
- import { useMountEffect } from "../lib/useMountEffect";
3
+ import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { formatTime } from "../lib/time";
5
+ import { TimelineClip } from "./TimelineClip";
6
+ import { EditPopover } from "./EditModal";
4
7
 
5
8
  /* ── Layout ─────────────────────────────────────────────────────── */
6
9
  const GUTTER = 32;
7
- const TRACK_H = 28;
10
+ const TRACK_H = 72;
8
11
  const RULER_H = 24;
9
- const CLIP_Y = 2; // vertical inset inside track
12
+ const CLIP_Y = 3; // vertical inset inside track
10
13
 
11
14
  /* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
12
15
  interface TrackStyle {
@@ -22,7 +25,7 @@ interface TrackStyle {
22
25
  icon: ReactNode;
23
26
  }
24
27
 
25
- /* ── Icons from Figma HyperFrames design system ── */
28
+ /* ── Icons from Figma Motion Cut design system ── */
26
29
  const ICON_BASE = "/icons/timeline";
27
30
  function TimelineIcon({ src }: { src: string }) {
28
31
  return (
@@ -124,15 +127,21 @@ function getStyle(tag: string): TrackStyle {
124
127
  }
125
128
 
126
129
  /* ── Tick Generation ────────────────────────────────────────────── */
127
- function generateTicks(duration: number): { major: number[]; minor: number[] } {
128
- if (duration <= 0) return { major: [], minor: [] };
130
+ export function generateTicks(duration: number): { major: number[]; minor: number[] } {
131
+ if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
132
+ return { major: [], minor: [] };
129
133
  const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
130
134
  const target = duration / 6;
131
135
  const majorInterval = intervals.find((i) => i >= target) ?? 60;
132
- const minorInterval = majorInterval / 2;
136
+ const minorInterval = Math.max(0.25, majorInterval / 2);
133
137
  const major: number[] = [];
134
138
  const minor: number[] = [];
135
- for (let t = 0; t <= duration + 0.001; t += minorInterval) {
139
+ const maxTicks = 500; // Safety cap to prevent infinite loop
140
+ for (
141
+ let t = 0;
142
+ t <= duration + 0.001 && major.length + minor.length < maxTicks;
143
+ t += minorInterval
144
+ ) {
136
145
  const rounded = Math.round(t * 100) / 100;
137
146
  const isMajor =
138
147
  Math.abs(rounded % majorInterval) < 0.01 ||
@@ -143,11 +152,8 @@ function generateTicks(duration: number): { major: number[]; minor: number[] } {
143
152
  return { major, minor };
144
153
  }
145
154
 
146
- function formatTick(s: number): string {
147
- const m = Math.floor(s / 60);
148
- const sec = Math.floor(s % 60);
149
- return `${m}:${sec.toString().padStart(2, "0")}`;
150
- }
155
+ /** @deprecated Use formatTime from '../lib/time' instead */
156
+ export const formatTick = formatTime;
151
157
 
152
158
  /* ── Component ──────────────────────────────────────────────────── */
153
159
  interface TimelineProps {
@@ -155,67 +161,250 @@ interface TimelineProps {
155
161
  onSeek?: (time: number) => void;
156
162
  /** Called when user double-clicks a composition clip to drill into it */
157
163
  onDrillDown?: (element: import("../store/playerStore").TimelineElement) => void;
164
+ /** Optional custom content renderer for clips (thumbnails, waveforms, etc.) */
165
+ renderClipContent?: (
166
+ element: import("../store/playerStore").TimelineElement,
167
+ style: { clip: string; label: string },
168
+ ) => ReactNode;
169
+ /** Optional overlay renderer for clips (e.g. badges, cursors) */
170
+ renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
171
+ /** Called when files are dropped onto the empty timeline */
172
+ onFileDrop?: (files: File[]) => void;
173
+ /** Called when a clip is moved, resized, or changes track via drag */
174
+ onClipChange?: (
175
+ elementId: string,
176
+ updates: { start?: number; duration?: number; track?: number },
177
+ ) => void;
158
178
  }
159
179
 
160
- export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: TimelineProps = {}) {
180
+ export const Timeline = memo(function Timeline({
181
+ onSeek,
182
+ onDrillDown,
183
+ renderClipContent,
184
+ renderClipOverlay,
185
+ onFileDrop,
186
+ }: TimelineProps = {}) {
161
187
  const elements = usePlayerStore((s) => s.elements);
162
188
  const duration = usePlayerStore((s) => s.duration);
163
189
  const timelineReady = usePlayerStore((s) => s.timelineReady);
164
190
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
165
191
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
166
- const activeEdits = usePlayerStore((s) => s.activeEdits);
192
+ const zoomMode = usePlayerStore((s) => s.zoomMode);
193
+ const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
167
194
  const playheadRef = useRef<HTMLDivElement>(null);
168
195
  const containerRef = useRef<HTMLDivElement>(null);
196
+ const scrollRef = useRef<HTMLDivElement>(null);
169
197
  const [hoveredClip, setHoveredClip] = useState<string | null>(null);
170
198
  const isDragging = useRef(false);
199
+ // Range selection (Shift+drag)
200
+ const [shiftHeld, setShiftHeld] = useState(false);
201
+ useMountEffect(() => {
202
+ const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
203
+ const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
204
+ const blur = () => setShiftHeld(false);
205
+ window.addEventListener("keydown", down);
206
+ window.addEventListener("keyup", up);
207
+ window.addEventListener("blur", blur);
208
+ return () => {
209
+ window.removeEventListener("keydown", down);
210
+ window.removeEventListener("keyup", up);
211
+ window.removeEventListener("blur", blur);
212
+ };
213
+ });
214
+ const isRangeSelecting = useRef(false);
215
+ const rangeAnchorTime = useRef(0);
216
+ const [rangeSelection, setRangeSelection] = useState<{
217
+ start: number;
218
+ end: number;
219
+ anchorX: number;
220
+ anchorY: number;
221
+ } | null>(null);
222
+ const [showPopover, setShowPopover] = useState(false);
223
+ const [viewportWidth, setViewportWidth] = useState(0);
224
+ const roRef = useRef<ResizeObserver | null>(null);
225
+
226
+ // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
227
+ // useMountEffect can't work here because the component returns null on first
228
+ // render (timelineReady=false), so containerRef.current is null when the
229
+ // effect fires and the ResizeObserver is never created.
230
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
231
+ if (roRef.current) {
232
+ roRef.current.disconnect();
233
+ roRef.current = null;
234
+ }
235
+ containerRef.current = el;
236
+ if (!el) return;
237
+ setViewportWidth(el.clientWidth);
238
+ roRef.current = new ResizeObserver(([entry]) => {
239
+ setViewportWidth(entry.contentRect.width);
240
+ });
241
+ roRef.current.observe(el);
242
+ }, []);
243
+
244
+ // Clean up ResizeObserver on unmount
245
+ useMountEffect(() => () => {
246
+ roRef.current?.disconnect();
247
+ });
171
248
 
172
- const durationRef = useRef(duration);
173
- durationRef.current = duration;
249
+ // Effective duration: max of store duration and the furthest element end.
250
+ // processTimelineMessage updates elements but not duration, so elements can
251
+ // extend beyond the store's duration — this ensures fit mode shows everything.
252
+ const effectiveDuration = useMemo(() => {
253
+ const safeDur = Number.isFinite(duration) ? duration : 0;
254
+ if (elements.length === 0) return safeDur;
255
+ const maxEnd = Math.max(...elements.map((el) => el.start + el.duration));
256
+ const result = Math.max(safeDur, maxEnd);
257
+ return Number.isFinite(result) ? result : safeDur;
258
+ }, [elements, duration]);
259
+
260
+ // Calculate effective pixels per second
261
+ // In fit mode, use clientWidth (excludes scrollbar) with a small padding
262
+ const fitPps =
263
+ viewportWidth > GUTTER && effectiveDuration > 0
264
+ ? (viewportWidth - GUTTER - 2) / effectiveDuration
265
+ : 100;
266
+ const pps = zoomMode === "fit" ? fitPps : manualPps;
267
+ const trackContentWidth = Math.max(0, effectiveDuration * pps);
268
+
269
+ const durationRef = useRef(effectiveDuration);
270
+ durationRef.current = effectiveDuration;
271
+ const ppsRef = useRef(pps);
272
+ ppsRef.current = pps;
174
273
  useMountEffect(() => {
175
274
  const unsub = liveTime.subscribe((t) => {
176
275
  const dur = durationRef.current;
177
276
  if (!playheadRef.current || dur <= 0) return;
178
- const pct = (t / dur) * 100;
179
- playheadRef.current.style.left = `calc(${GUTTER}px + (100% - ${GUTTER}px) * ${pct / 100})`;
277
+ const px = t * ppsRef.current;
278
+ playheadRef.current.style.left = `${GUTTER + px}px`;
279
+
280
+ // Auto-scroll to follow playhead during playback or seeking
281
+ const scroll = scrollRef.current;
282
+ if (scroll && !isDragging.current) {
283
+ const playheadX = GUTTER + px;
284
+ const visibleRight = scroll.scrollLeft + scroll.clientWidth;
285
+ const visibleLeft = scroll.scrollLeft;
286
+ const edgeMargin = scroll.clientWidth * 0.12;
287
+
288
+ if (playheadX > visibleRight - edgeMargin) {
289
+ // Playhead near right edge — page forward
290
+ scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
291
+ } else if (playheadX < visibleLeft + GUTTER) {
292
+ // Playhead before visible area (e.g. loop) — jump back
293
+ scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
294
+ }
295
+ }
180
296
  });
181
297
  return unsub;
182
298
  });
183
299
 
300
+ const dragScrollRaf = useRef(0);
301
+
184
302
  const seekFromX = useCallback(
185
303
  (clientX: number) => {
186
- const el = containerRef.current;
187
- if (!el || duration <= 0) return;
304
+ const el = scrollRef.current;
305
+ if (!el || effectiveDuration <= 0) return;
188
306
  const rect = el.getBoundingClientRect();
189
- const start = rect.left + GUTTER;
190
- const w = rect.width - GUTTER;
191
- if (w <= 0) return;
192
- const pct = Math.max(0, Math.min(1, (clientX - start) / w));
193
- const time = pct * duration;
194
- // Notify liveTime for instant visual update (direct DOM, no re-render)
307
+ const scrollLeft = el.scrollLeft;
308
+ const x = clientX - rect.left + scrollLeft - GUTTER;
309
+ if (x < 0) return;
310
+ const time = Math.max(0, Math.min(effectiveDuration, x / pps));
195
311
  liveTime.notify(time);
196
- // Call parent's onSeek to actually seek the iframe/player
197
312
  onSeek?.(time);
198
313
  },
199
- [duration, onSeek],
314
+ [effectiveDuration, onSeek, pps],
315
+ );
316
+
317
+ // Auto-scroll the timeline when dragging the playhead near edges
318
+ const autoScrollDuringDrag = useCallback(
319
+ (clientX: number) => {
320
+ cancelAnimationFrame(dragScrollRaf.current);
321
+ const el = scrollRef.current;
322
+ if (!el || !isDragging.current) return;
323
+ const rect = el.getBoundingClientRect();
324
+ const edgeZone = 40;
325
+ const maxSpeed = 12;
326
+ let scrollDelta = 0;
327
+
328
+ if (clientX < rect.left + edgeZone) {
329
+ // Near left edge — scroll left
330
+ const proximity = Math.max(0, 1 - (clientX - rect.left) / edgeZone);
331
+ scrollDelta = -maxSpeed * proximity;
332
+ } else if (clientX > rect.right - edgeZone) {
333
+ // Near right edge — scroll right
334
+ const proximity = Math.max(0, 1 - (rect.right - clientX) / edgeZone);
335
+ scrollDelta = maxSpeed * proximity;
336
+ }
337
+
338
+ if (scrollDelta !== 0) {
339
+ el.scrollLeft += scrollDelta;
340
+ seekFromX(clientX);
341
+ dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
342
+ }
343
+ },
344
+ [seekFromX],
200
345
  );
201
346
 
202
347
  const handlePointerDown = useCallback(
203
348
  (e: React.PointerEvent) => {
204
349
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
205
- isDragging.current = true;
350
+ if (e.button !== 0) return;
206
351
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
352
+
353
+ // Shift+click starts range selection
354
+ if (e.shiftKey) {
355
+ isRangeSelecting.current = true;
356
+ setShowPopover(false);
357
+ const rect = scrollRef.current?.getBoundingClientRect();
358
+ if (rect) {
359
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
360
+ const time = Math.max(0, x / pps);
361
+ rangeAnchorTime.current = time;
362
+ setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
363
+ }
364
+ return;
365
+ }
366
+
367
+ isDragging.current = true;
368
+ setRangeSelection(null);
369
+ setShowPopover(false);
207
370
  seekFromX(e.clientX);
208
371
  },
209
- [seekFromX],
372
+ [seekFromX, pps],
210
373
  );
211
374
  const handlePointerMove = useCallback(
212
375
  (e: React.PointerEvent) => {
213
- if (isDragging.current) seekFromX(e.clientX);
376
+ if (isRangeSelecting.current) {
377
+ const rect = scrollRef.current?.getBoundingClientRect();
378
+ if (rect) {
379
+ const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
380
+ const time = Math.max(0, x / pps);
381
+ setRangeSelection((prev) =>
382
+ prev ? { ...prev, end: time, anchorX: e.clientX, anchorY: e.clientY } : null,
383
+ );
384
+ }
385
+ return;
386
+ }
387
+ if (!isDragging.current) return;
388
+ seekFromX(e.clientX);
389
+ autoScrollDuringDrag(e.clientX);
214
390
  },
215
- [seekFromX],
391
+ [seekFromX, autoScrollDuringDrag, pps],
216
392
  );
217
393
  const handlePointerUp = useCallback(() => {
394
+ if (isRangeSelecting.current) {
395
+ isRangeSelecting.current = false;
396
+ // Show popover if range is meaningful (> 0.2s)
397
+ setRangeSelection((prev) => {
398
+ if (prev && Math.abs(prev.end - prev.start) > 0.2) {
399
+ setShowPopover(true);
400
+ return prev;
401
+ }
402
+ return null;
403
+ });
404
+ return;
405
+ }
218
406
  isDragging.current = false;
407
+ cancelAnimationFrame(dragScrollRaf.current);
219
408
  }, []);
220
409
 
221
410
  const tracks = useMemo(() => {
@@ -237,13 +426,101 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
237
426
  return map;
238
427
  }, [tracks]);
239
428
 
240
- const { major, minor } = useMemo(() => generateTicks(duration), [duration]);
429
+ const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
430
+
431
+ const [isDragOver, setIsDragOver] = useState(false);
241
432
 
242
- if (!timelineReady) return null;
243
- if (elements.length === 0) {
433
+ if (!timelineReady || elements.length === 0) {
244
434
  return (
245
- <div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">
246
- No timeline elements
435
+ <div
436
+ className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
437
+ isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
438
+ }`}
439
+ onDragOver={(e) => {
440
+ e.preventDefault();
441
+ setIsDragOver(true);
442
+ }}
443
+ onDragLeave={() => setIsDragOver(false)}
444
+ onDrop={(e) => {
445
+ e.preventDefault();
446
+ setIsDragOver(false);
447
+ if (onFileDrop && e.dataTransfer.files.length > 0) {
448
+ onFileDrop(Array.from(e.dataTransfer.files));
449
+ }
450
+ }}
451
+ >
452
+ {/* Ruler */}
453
+ <div
454
+ className="flex-shrink-0 border-b border-neutral-800/40 flex items-end relative"
455
+ style={{ height: RULER_H, paddingLeft: GUTTER }}
456
+ >
457
+ {[0, 10, 20, 30, 40, 50].map((s) => (
458
+ <div
459
+ key={s}
460
+ className="flex flex-col items-center"
461
+ style={{ position: "absolute", left: GUTTER + s * 14 }}
462
+ >
463
+ <span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
464
+ {`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
465
+ </span>
466
+ <div className="w-px h-[5px] bg-neutral-700/40" />
467
+ </div>
468
+ ))}
469
+ </div>
470
+ {/* Empty drop zone */}
471
+ <div className="flex-1 flex items-center justify-center">
472
+ <div
473
+ className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
474
+ isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
475
+ }`}
476
+ >
477
+ {isDragOver ? (
478
+ <>
479
+ <svg
480
+ width="18"
481
+ height="18"
482
+ viewBox="0 0 24 24"
483
+ fill="none"
484
+ stroke="currentColor"
485
+ strokeWidth="1.5"
486
+ strokeLinecap="round"
487
+ strokeLinejoin="round"
488
+ className="text-blue-400 flex-shrink-0"
489
+ >
490
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
491
+ <polyline points="7 10 12 15 17 10" />
492
+ <line x1="12" y1="15" x2="12" y2="3" />
493
+ </svg>
494
+ <span className="text-[13px] text-blue-400">Drop media files to import</span>
495
+ </>
496
+ ) : (
497
+ <>
498
+ <svg
499
+ width="18"
500
+ height="18"
501
+ viewBox="0 0 24 24"
502
+ fill="none"
503
+ stroke="currentColor"
504
+ strokeWidth="1.5"
505
+ strokeLinecap="round"
506
+ strokeLinejoin="round"
507
+ className="text-neutral-600 flex-shrink-0"
508
+ >
509
+ <rect x="2" y="2" width="20" height="20" rx="2" />
510
+ <path d="M7 2v20" />
511
+ <path d="M17 2v20" />
512
+ <path d="M2 7h20" />
513
+ <path d="M2 17h20" />
514
+ </svg>
515
+ <span className="text-[13px] text-neutral-500">
516
+ {onFileDrop
517
+ ? "Drop media here or describe your video to start"
518
+ : "Describe your video to start creating"}
519
+ </span>
520
+ </>
521
+ )}
522
+ </div>
523
+ </div>
247
524
  </div>
248
525
  );
249
526
  }
@@ -252,249 +529,249 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
252
529
 
253
530
  return (
254
531
  <div
255
- ref={containerRef}
532
+ ref={setContainerRef}
256
533
  aria-label="Timeline"
257
- className="border-t border-neutral-800/50 bg-[#0a0a0b] select-none overflow-x-hidden cursor-crosshair"
258
- style={{ touchAction: "none" }}
259
- onPointerDown={handlePointerDown}
260
- onPointerMove={handlePointerMove}
261
- onPointerUp={handlePointerUp}
534
+ className={`border-t border-neutral-800/50 bg-[#0a0a0b] select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
535
+ style={{ touchAction: "pan-x pan-y" }}
262
536
  >
263
- <div className="relative" style={{ height: totalH }}>
264
- {/* Grid lines */}
265
- <svg
266
- className="absolute pointer-events-none"
267
- style={{ left: GUTTER }}
268
- width={`calc(100% - ${GUTTER}px)`}
269
- height={totalH}
270
- >
271
- {major.map((t) => (
272
- <line
273
- key={`g-${t}`}
274
- x1={`${(t / duration) * 100}%`}
275
- y1={RULER_H}
276
- x2={`${(t / duration) * 100}%`}
277
- y2={totalH}
278
- stroke="rgba(255,255,255,0.035)"
279
- strokeWidth="1"
280
- />
281
- ))}
282
- </svg>
537
+ <div
538
+ ref={scrollRef}
539
+ className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
540
+ onPointerDown={handlePointerDown}
541
+ onPointerMove={handlePointerMove}
542
+ onPointerUp={handlePointerUp}
543
+ onLostPointerCapture={handlePointerUp}
544
+ >
545
+ <div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
546
+ {/* Grid lines */}
547
+ <svg
548
+ className="absolute pointer-events-none"
549
+ style={{ left: GUTTER, width: trackContentWidth }}
550
+ height={totalH}
551
+ >
552
+ {major.map((t) => {
553
+ const x = t * pps;
554
+ return (
555
+ <line
556
+ key={`g-${t}`}
557
+ x1={x}
558
+ y1={RULER_H}
559
+ x2={x}
560
+ y2={totalH}
561
+ stroke="rgba(255,255,255,0.035)"
562
+ strokeWidth="1"
563
+ />
564
+ );
565
+ })}
566
+ </svg>
283
567
 
284
- {/* Ruler */}
285
- <div
286
- className="relative border-b border-neutral-800/40"
287
- style={{ height: RULER_H, marginLeft: GUTTER }}
288
- >
289
- {minor.map((t) => (
290
- <div
291
- key={`m-${t}`}
292
- className="absolute bottom-0"
293
- style={{ left: `${(t / duration) * 100}%` }}
294
- >
295
- <div className="w-px h-[3px] bg-neutral-700/40" />
296
- </div>
297
- ))}
298
- {major.map((t) => (
299
- <div
300
- key={`M-${t}`}
301
- className="absolute bottom-0 flex flex-col items-center"
302
- style={{ left: `${(t / duration) * 100}%` }}
303
- >
304
- <span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
305
- {formatTick(t)}
306
- </span>
307
- <div className="w-px h-[5px] bg-neutral-600/60" />
308
- </div>
309
- ))}
310
- </div>
568
+ {/* Ruler */}
569
+ <div
570
+ className="relative border-b border-neutral-800/40 overflow-hidden"
571
+ style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
572
+ >
573
+ {/* Shift hint */}
574
+ {shiftHeld && !rangeSelection && (
575
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
576
+ <span className="text-[9px] text-blue-400/60 font-medium">
577
+ Drag to select range
578
+ </span>
579
+ </div>
580
+ )}
581
+ {minor.map((t) => (
582
+ <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
583
+ <div className="w-px h-[3px] bg-neutral-700/40" />
584
+ </div>
585
+ ))}
586
+ {major.map((t) => (
587
+ <div
588
+ key={`M-${t}`}
589
+ className="absolute bottom-0 flex flex-col items-center"
590
+ style={{ left: t * pps }}
591
+ >
592
+ <span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
593
+ {formatTime(t)}
594
+ </span>
595
+ <div className="w-px h-[5px] bg-neutral-600/60" />
596
+ </div>
597
+ ))}
598
+ </div>
311
599
 
312
- {/* Tracks */}
313
- {tracks.map(([trackNum, els]) => {
314
- const ts = trackStyles.get(trackNum) ?? DEFAULT;
315
- return (
316
- <div
317
- key={trackNum}
318
- className="relative flex"
319
- style={{ height: TRACK_H, backgroundColor: ts.row }}
320
- >
321
- {/* Gutter: colored icon badge (Figma HyperFrames style) */}
600
+ {/* Tracks */}
601
+ {tracks.map(([trackNum, els]) => {
602
+ const ts = trackStyles.get(trackNum) ?? DEFAULT;
603
+ return (
322
604
  <div
323
- className="flex-shrink-0 flex items-center justify-center"
324
- style={{ width: GUTTER }}
605
+ key={trackNum}
606
+ className="relative flex"
607
+ style={{ height: TRACK_H, backgroundColor: ts.row }}
325
608
  >
609
+ {/* Gutter: colored icon badge (Figma Motion Cut style) */}
326
610
  <div
327
- className="flex items-center justify-center"
328
- style={{
329
- width: 20,
330
- height: 20,
331
- borderRadius: 6,
332
- backgroundColor: ts.gutter,
333
- border: "1px solid rgba(255,255,255,0.35)",
334
- color: "#fff",
335
- }}
611
+ className="flex-shrink-0 flex items-center justify-center"
612
+ style={{ width: GUTTER }}
336
613
  >
337
- {ts.icon}
614
+ <div
615
+ className="flex items-center justify-center"
616
+ style={{
617
+ width: 20,
618
+ height: 20,
619
+ borderRadius: 6,
620
+ backgroundColor: ts.gutter,
621
+ border: "1px solid rgba(255,255,255,0.35)",
622
+ color: "#fff",
623
+ }}
624
+ >
625
+ {ts.icon}
626
+ </div>
338
627
  </div>
339
- </div>
340
628
 
341
- {/* Clips */}
342
- <div className="flex-1 relative">
343
- {els.map((el, i) => {
344
- const leftPct = (el.start / duration) * 100;
345
- const widthPct = (el.duration / duration) * 100;
346
- const style = getStyle(el.tag);
347
- const isSelected = selectedElementId === el.id;
348
- const isComposition = !!el.compositionSrc;
349
- const clipKey = `${el.id}-${i}`;
350
- const isHovered = hoveredClip === clipKey;
351
- const activeEdit = activeEdits[el.id];
352
- const isBeingEdited = !!activeEdit;
353
-
354
- return (
355
- <div
356
- key={clipKey}
357
- data-clip="true"
358
- className="absolute flex items-center overflow-hidden"
359
- style={{
360
- left: `${leftPct}%`,
361
- width: `${Math.max(widthPct, 1)}%`,
362
- top: CLIP_Y,
363
- bottom: CLIP_Y,
364
- borderRadius: 5,
365
- backgroundColor: style.clip,
366
- backgroundImage: isComposition
367
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
368
- : undefined,
369
- border: isSelected
370
- ? `2px solid rgba(255,255,255,0.9)`
371
- : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
372
- boxShadow: isSelected
373
- ? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
374
- : isBeingEdited
375
- ? `0 0 0 1px ${activeEdit.agentColor}80, 0 0 8px ${activeEdit.agentColor}40`
376
- : isHovered
377
- ? "0 1px 4px rgba(0,0,0,0.3)"
378
- : "none",
379
- cursor: "pointer",
380
- transition: "border-color 120ms, box-shadow 120ms, transform 80ms",
381
- transform: isHovered && !isSelected ? "scaleY(1.04)" : "scaleY(1)",
382
- zIndex: isSelected ? 10 : isHovered ? 5 : 1,
383
- }}
384
- title={
385
- isComposition
386
- ? `${el.compositionSrc} \u2022 Double-click to open`
387
- : `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
388
- }
389
- onPointerEnter={() => setHoveredClip(clipKey)}
390
- onPointerLeave={() => setHoveredClip(null)}
391
- onPointerDown={(e) => e.stopPropagation()}
392
- onClick={(e) => {
393
- e.stopPropagation();
394
- setSelectedElementId(isSelected ? null : el.id);
395
- }}
396
- onDoubleClick={(e) => {
397
- e.stopPropagation();
398
- if (isComposition && onDrillDown) {
399
- onDrillDown(el);
400
- }
401
- }}
402
- >
403
- {/* Agent ownership dot */}
404
- {el.agentColor && (
405
- <div
406
- className="flex-shrink-0 w-1.5 h-1.5 rounded-full ml-1"
407
- style={{ backgroundColor: el.agentColor }}
408
- title={el.agentId ? `Agent: ${el.agentId}` : undefined}
409
- />
410
- )}
411
- {/* Editing glow pulse */}
412
- {/* Agent editing indicator — cursor on the clip */}
413
- {isBeingEdited && (
414
- <>
415
- <div
416
- className="absolute inset-0 rounded-[5px] animate-pulse pointer-events-none"
417
- style={{ boxShadow: `inset 0 0 0 1px ${activeEdit.agentColor}60` }}
418
- />
419
- {/* Agent name badge above clip */}
420
- <div
421
- className="absolute pointer-events-none flex items-center gap-1"
422
- style={{
423
- top: -16,
424
- left: 2,
425
- zIndex: 30,
426
- }}
427
- >
428
- {/* Mini cursor arrow */}
429
- <svg
430
- width="8"
431
- height="10"
432
- viewBox="0 0 12 16"
433
- fill="none"
434
- style={{ flexShrink: 0 }}
435
- >
436
- <path
437
- d="M1 1L11 7L6 8L4 14L1 1Z"
438
- fill={activeEdit.agentColor}
439
- stroke="white"
440
- strokeWidth="0.8"
441
- />
442
- </svg>
443
- <span
444
- className="text-[8px] font-semibold px-1 py-px rounded whitespace-nowrap"
445
- style={{
446
- backgroundColor: activeEdit.agentColor,
447
- color: "white",
448
- boxShadow: `0 1px 4px ${activeEdit.agentColor}40`,
449
- }}
450
- >
451
- {activeEdit.agentId}
452
- </span>
453
- </div>
454
- </>
455
- )}
456
- <span
457
- className="text-[10px] font-semibold truncate px-1.5 leading-none"
458
- style={{ color: style.label }}
629
+ {/* Clips */}
630
+ <div style={{ width: trackContentWidth }} className="relative">
631
+ {els.map((el, i) => {
632
+ const clipStyle = getStyle(el.tag);
633
+ const isSelected = selectedElementId === el.id;
634
+ const isComposition = !!el.compositionSrc;
635
+ const clipKey = `${el.id}-${i}`;
636
+ const isHovered = hoveredClip === clipKey;
637
+ const hasCustomContent = !!renderClipContent;
638
+ const clipWidthPx = Math.max(el.duration * pps, 4);
639
+
640
+ return (
641
+ <TimelineClip
642
+ key={clipKey}
643
+ el={el}
644
+ pps={pps}
645
+ trackH={TRACK_H}
646
+ clipY={CLIP_Y}
647
+ isSelected={isSelected}
648
+ isHovered={isHovered}
649
+ hasCustomContent={hasCustomContent}
650
+ style={clipStyle}
651
+ isComposition={isComposition}
652
+ onHoverStart={() => setHoveredClip(clipKey)}
653
+ onHoverEnd={() => setHoveredClip(null)}
654
+ onClick={(e) => {
655
+ e.stopPropagation();
656
+ setSelectedElementId(isSelected ? null : el.id);
657
+ }}
658
+ onDoubleClick={(e) => {
659
+ e.stopPropagation();
660
+ if (isComposition && onDrillDown) onDrillDown(el);
661
+ }}
459
662
  >
460
- {el.id || el.tag}
461
- </span>
462
- {widthPct > 10 && (
463
- <span
464
- className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
465
- style={{ color: style.label }}
663
+ {renderClipOverlay?.(el)}
664
+ <div
665
+ className={
666
+ renderClipContent
667
+ ? "absolute inset-0 overflow-hidden rounded-[4px]"
668
+ : "flex items-center overflow-hidden flex-1 min-w-0"
669
+ }
466
670
  >
467
- {el.duration.toFixed(1)}s
468
- </span>
469
- )}
470
- </div>
471
- );
472
- })}
671
+ {renderClipContent?.(el, clipStyle) ?? (
672
+ <>
673
+ <span
674
+ className="text-[10px] font-semibold truncate px-1.5 leading-none"
675
+ style={{ color: clipStyle.label }}
676
+ >
677
+ {el.id || el.tag}
678
+ </span>
679
+ {clipWidthPx > 60 && (
680
+ <span
681
+ className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
682
+ style={{ color: clipStyle.label }}
683
+ >
684
+ {el.duration.toFixed(1)}s
685
+ </span>
686
+ )}
687
+ </>
688
+ )}
689
+ </div>
690
+ </TimelineClip>
691
+ );
692
+ })}
693
+ </div>
473
694
  </div>
474
- </div>
475
- );
476
- })}
695
+ );
696
+ })}
477
697
 
478
- {/* Playhead */}
479
- <div
480
- ref={playheadRef}
481
- className="absolute top-0 bottom-0 z-20 pointer-events-none"
482
- style={{ left: `${GUTTER}px` }}
483
- >
484
- <div className="absolute top-0 bottom-0 left-1/2 -translate-x-1/2 w-px bg-white/90" />
485
- <div className="absolute left-1/2 -translate-x-1/2" style={{ top: 0 }}>
698
+ {/* Range selection highlight */}
699
+ {rangeSelection && (
700
+ <div
701
+ className="absolute pointer-events-none"
702
+ style={{
703
+ left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
704
+ width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
705
+ top: RULER_H,
706
+ bottom: 0,
707
+ backgroundColor: "rgba(59, 130, 246, 0.12)",
708
+ borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
709
+ borderRight: "1px solid rgba(59, 130, 246, 0.4)",
710
+ zIndex: 50,
711
+ }}
712
+ />
713
+ )}
714
+
715
+ {/* Playhead — z-[100] to stay above all clips (which use z-1 to z-10) */}
716
+ <div
717
+ ref={playheadRef}
718
+ className="absolute top-0 bottom-0 pointer-events-none"
719
+ style={{ left: `${GUTTER}px`, zIndex: 100 }}
720
+ >
486
721
  <div
722
+ className="absolute top-0 bottom-0"
487
723
  style={{
488
- width: 0,
489
- height: 0,
490
- borderLeft: "5px solid transparent",
491
- borderRight: "5px solid transparent",
492
- borderTop: "7px solid rgba(255,255,255,0.95)",
724
+ left: "50%",
725
+ width: 2,
726
+ marginLeft: -1,
727
+ background: "var(--hf-accent, #3CE6AC)",
728
+ boxShadow: "0 0 8px rgba(60,230,172,0.5)",
493
729
  }}
494
730
  />
731
+ <div
732
+ className="absolute"
733
+ style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
734
+ >
735
+ <div
736
+ style={{
737
+ width: 0,
738
+ height: 0,
739
+ borderLeft: "6px solid transparent",
740
+ borderRight: "6px solid transparent",
741
+ borderTop: "8px solid var(--hf-accent, #3CE6AC)",
742
+ filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
743
+ }}
744
+ />
745
+ </div>
495
746
  </div>
496
747
  </div>
497
748
  </div>
749
+
750
+ {/* Keyboard shortcut hint — always visible */}
751
+ {!showPopover && !rangeSelection && (
752
+ <div className="absolute bottom-2 right-3 pointer-events-none">
753
+ <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
754
+ <kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
755
+ Shift
756
+ </kbd>
757
+ <span className="text-[9px] text-neutral-600">+ drag to edit range</span>
758
+ </div>
759
+ </div>
760
+ )}
761
+
762
+ {/* Edit range popover */}
763
+ {showPopover && rangeSelection && (
764
+ <EditPopover
765
+ rangeStart={rangeSelection.start}
766
+ rangeEnd={rangeSelection.end}
767
+ anchorX={rangeSelection.anchorX}
768
+ anchorY={rangeSelection.anchorY}
769
+ onClose={() => {
770
+ setShowPopover(false);
771
+ setRangeSelection(null);
772
+ }}
773
+ />
774
+ )}
498
775
  </div>
499
776
  );
500
777
  });