@hyperframes/studio 0.4.12 → 0.4.13-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
  2. package/dist/assets/index-BKkR67xb.css +1 -0
  3. package/dist/assets/index-rN5doSq1.js +93 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +289 -11
  7. package/src/components/nle/NLELayout.tsx +24 -7
  8. package/src/components/nle/NLEPreview.test.ts +32 -0
  9. package/src/components/nle/NLEPreview.tsx +12 -1
  10. package/src/player/components/CompositionThumbnail.tsx +94 -17
  11. package/src/player/components/EditModal.tsx +48 -29
  12. package/src/player/components/Player.tsx +5 -2
  13. package/src/player/components/PlayerControls.test.ts +20 -0
  14. package/src/player/components/PlayerControls.tsx +12 -1
  15. package/src/player/components/Timeline.test.ts +44 -1
  16. package/src/player/components/Timeline.tsx +686 -169
  17. package/src/player/components/TimelineClip.tsx +112 -16
  18. package/src/player/components/timelineEditing.test.ts +310 -0
  19. package/src/player/components/timelineEditing.ts +213 -0
  20. package/src/player/components/timelineTheme.test.ts +56 -0
  21. package/src/player/components/timelineTheme.ts +141 -0
  22. package/src/player/components/timelineZoom.test.ts +62 -0
  23. package/src/player/components/timelineZoom.ts +38 -0
  24. package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
  25. package/src/player/hooks/useTimelinePlayer.ts +313 -59
  26. package/src/player/store/playerStore.test.ts +30 -12
  27. package/src/player/store/playerStore.ts +23 -9
  28. package/src/types/hyperframes-player.d.ts +1 -0
  29. package/src/utils/sourcePatcher.test.ts +84 -0
  30. package/src/utils/sourcePatcher.ts +143 -0
  31. package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
  32. package/dist/assets/index-CVDXfFQ6.js +0 -93
  33. package/dist/assets/index-jmDaI2F7.css +0 -1
@@ -1,9 +1,27 @@
1
- import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
2
- import { usePlayerStore, liveTime } from "../store/playerStore";
1
+ import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode } from "react";
2
+ import {
3
+ usePlayerStore,
4
+ liveTime,
5
+ type TimelineElement,
6
+ type ZoomMode,
7
+ } from "../store/playerStore";
3
8
  import { useMountEffect } from "../../hooks/useMountEffect";
4
9
  import { formatTime } from "../lib/time";
5
10
  import { TimelineClip } from "./TimelineClip";
6
11
  import { EditPopover } from "./EditModal";
12
+ import {
13
+ resolveTimelineAutoScroll,
14
+ resolveTimelineMove,
15
+ resolveTimelineResize,
16
+ } from "./timelineEditing";
17
+ import {
18
+ defaultTimelineTheme,
19
+ getRenderedTimelineElement,
20
+ getTimelineTrackStyle,
21
+ type TimelineTrackStyle,
22
+ type TimelineTheme,
23
+ } from "./timelineTheme";
24
+ import { getTimelinePixelsPerSecond } from "./timelineZoom";
7
25
 
8
26
  /* ── Layout ─────────────────────────────────────────────────────── */
9
27
  const GUTTER = 32;
@@ -11,17 +29,7 @@ const TRACK_H = 72;
11
29
  const RULER_H = 24;
12
30
  const CLIP_Y = 3; // vertical inset inside track
13
31
 
14
- /* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
15
- interface TrackStyle {
16
- /** Clip solid background */
17
- clip: string;
18
- /** Dark text color for label on clip */
19
- label: string;
20
- /** Track row tint (very subtle) */
21
- row: string;
22
- /** Gutter icon circle background */
23
- gutter: string;
24
- /** SVG icon paths (viewBox 0 0 24 24) */
32
+ interface TrackVisualStyle extends TimelineTrackStyle {
25
33
  icon: ReactNode;
26
34
  }
27
35
 
@@ -46,84 +54,29 @@ const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
46
54
  const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
47
55
  const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
48
56
 
49
- const STYLES: Record<string, TrackStyle> = {
50
- video: {
51
- clip: "#1F6AFF",
52
- label: "#DBEAFE",
53
- row: "rgba(31,106,255,0.04)",
54
- gutter: "#1F6AFF",
55
- icon: IconImage,
56
- },
57
- audio: {
58
- clip: "#00C4FF",
59
- label: "#013A4B",
60
- row: "rgba(0,196,255,0.04)",
61
- gutter: "#00C4FF",
62
- icon: IconMusic,
63
- },
64
- img: {
65
- clip: "#8B5CF6",
66
- label: "#EDE9FE",
67
- row: "rgba(139,92,246,0.04)",
68
- gutter: "#8B5CF6",
69
- icon: IconImage,
70
- },
71
- div: {
72
- clip: "#68B200",
73
- label: "#1A2B03",
74
- row: "rgba(104,178,0,0.04)",
75
- gutter: "#68B200",
76
- icon: IconComposition,
77
- },
78
- span: {
79
- clip: "#F3A6FF",
80
- label: "#8D00A3",
81
- row: "rgba(243,166,255,0.04)",
82
- gutter: "#F3A6FF",
83
- icon: IconCaptions,
84
- },
85
- p: {
86
- clip: "#35C838",
87
- label: "#024A03",
88
- row: "rgba(53,200,56,0.04)",
89
- gutter: "#35C838",
90
- icon: IconText,
91
- },
92
- h1: {
93
- clip: "#35C838",
94
- label: "#024A03",
95
- row: "rgba(53,200,56,0.04)",
96
- gutter: "#35C838",
97
- icon: IconText,
98
- },
99
- section: {
100
- clip: "#68B200",
101
- label: "#1A2B03",
102
- row: "rgba(104,178,0,0.04)",
103
- gutter: "#68B200",
104
- icon: IconComposition,
105
- },
106
- sfx: {
107
- clip: "#FF8C42",
108
- label: "#512000",
109
- row: "rgba(255,140,66,0.04)",
110
- gutter: "#FF8C42",
111
- icon: IconAudio,
112
- },
57
+ const ICONS: Record<string, ReactNode> = {
58
+ video: IconImage,
59
+ audio: IconMusic,
60
+ img: IconImage,
61
+ div: IconComposition,
62
+ span: IconCaptions,
63
+ p: IconText,
64
+ h1: IconText,
65
+ section: IconComposition,
66
+ sfx: IconAudio,
113
67
  };
114
68
 
115
- const DEFAULT: TrackStyle = {
116
- clip: "#6B7280",
117
- label: "#F3F4F6",
118
- row: "rgba(107,114,128,0.03)",
119
- gutter: "#6B7280",
120
- icon: IconComposition,
121
- };
122
-
123
- function getStyle(tag: string): TrackStyle {
124
- const t = tag.toLowerCase();
125
- if (t.startsWith("h") && t.length === 2 && "123456".includes(t[1])) return STYLES.h1;
126
- return STYLES[t] ?? DEFAULT;
69
+ function getStyle(tag: string): TrackVisualStyle {
70
+ const trackStyle = getTimelineTrackStyle(tag);
71
+ const normalized = tag.toLowerCase();
72
+ const icon =
73
+ normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
74
+ ? ICONS.h1
75
+ : (ICONS[normalized] ?? IconComposition);
76
+ return {
77
+ ...trackStyle,
78
+ icon,
79
+ };
127
80
  }
128
81
 
129
82
  /* ── Tick Generation ────────────────────────────────────────────── */
@@ -152,6 +105,30 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
152
105
  return { major, minor };
153
106
  }
154
107
 
108
+ export function shouldAutoScrollTimeline(
109
+ zoomMode: ZoomMode,
110
+ scrollWidth: number,
111
+ clientWidth: number,
112
+ ): boolean {
113
+ if (zoomMode === "fit") return false;
114
+ if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false;
115
+ return scrollWidth - clientWidth > 1;
116
+ }
117
+
118
+ export function getTimelineScrollLeftForZoomTransition(
119
+ previousZoomMode: ZoomMode | null,
120
+ nextZoomMode: ZoomMode,
121
+ currentScrollLeft: number,
122
+ ): number {
123
+ if (previousZoomMode === "manual" && nextZoomMode === "fit") return 0;
124
+ return currentScrollLeft;
125
+ }
126
+
127
+ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
128
+ if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
129
+ return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
130
+ }
131
+
155
132
  /* ── Component ──────────────────────────────────────────────────── */
156
133
  interface TimelineProps {
157
134
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -167,6 +144,44 @@ interface TimelineProps {
167
144
  renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
168
145
  /** Called when files are dropped onto the empty timeline */
169
146
  onFileDrop?: (files: File[]) => void;
147
+ /** Persist a clip move back into source HTML */
148
+ onMoveElement?: (
149
+ element: import("../store/playerStore").TimelineElement,
150
+ updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
151
+ ) => Promise<void> | void;
152
+ onResizeElement?: (
153
+ element: import("../store/playerStore").TimelineElement,
154
+ updates: Pick<
155
+ import("../store/playerStore").TimelineElement,
156
+ "start" | "duration" | "playbackStart"
157
+ >,
158
+ ) => Promise<void> | void;
159
+ theme?: Partial<TimelineTheme>;
160
+ }
161
+
162
+ interface DraggedClipState {
163
+ element: TimelineElement;
164
+ originClientX: number;
165
+ originClientY: number;
166
+ originScrollLeft: number;
167
+ originScrollTop: number;
168
+ pointerClientX: number;
169
+ pointerClientY: number;
170
+ pointerOffsetX: number;
171
+ pointerOffsetY: number;
172
+ previewStart: number;
173
+ previewTrack: number;
174
+ started: boolean;
175
+ }
176
+
177
+ interface ResizingClipState {
178
+ element: TimelineElement;
179
+ edge: "start" | "end";
180
+ originClientX: number;
181
+ previewStart: number;
182
+ previewDuration: number;
183
+ previewPlaybackStart?: number;
184
+ started: boolean;
170
185
  }
171
186
 
172
187
  export const Timeline = memo(function Timeline({
@@ -175,14 +190,20 @@ export const Timeline = memo(function Timeline({
175
190
  renderClipContent,
176
191
  renderClipOverlay,
177
192
  onFileDrop,
193
+ onMoveElement,
194
+ onResizeElement,
195
+ theme: themeOverrides,
178
196
  }: TimelineProps = {}) {
197
+ const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
179
198
  const elements = usePlayerStore((s) => s.elements);
180
199
  const duration = usePlayerStore((s) => s.duration);
181
200
  const timelineReady = usePlayerStore((s) => s.timelineReady);
182
201
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
183
202
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
203
+ const updateElement = usePlayerStore((s) => s.updateElement);
204
+ const currentTime = usePlayerStore((s) => s.currentTime);
184
205
  const zoomMode = usePlayerStore((s) => s.zoomMode);
185
- const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
206
+ const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
186
207
  const playheadRef = useRef<HTMLDivElement>(null);
187
208
  const containerRef = useRef<HTMLDivElement>(null);
188
209
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -211,6 +232,17 @@ export const Timeline = memo(function Timeline({
211
232
  anchorX: number;
212
233
  anchorY: number;
213
234
  } | null>(null);
235
+ const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
236
+ const draggedClipRef = useRef<DraggedClipState | null>(null);
237
+ draggedClipRef.current = draggedClip;
238
+ const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
239
+ const resizingClipRef = useRef<ResizingClipState | null>(null);
240
+ resizingClipRef.current = resizingClip;
241
+ const onMoveElementRef = useRef(onMoveElement);
242
+ onMoveElementRef.current = onMoveElement;
243
+ const onResizeElementRef = useRef(onResizeElement);
244
+ onResizeElementRef.current = onResizeElement;
245
+ const suppressClickRef = useRef(false);
214
246
  const [showPopover, setShowPopover] = useState(false);
215
247
  const [viewportWidth, setViewportWidth] = useState(0);
216
248
  const roRef = useRef<ResizeObserver | null>(null);
@@ -249,30 +281,91 @@ export const Timeline = memo(function Timeline({
249
281
  return Number.isFinite(result) ? result : safeDur;
250
282
  }, [elements, duration]);
251
283
 
284
+ const tracks = useMemo(() => {
285
+ const map = new Map<number, typeof elements>();
286
+ for (const el of elements) {
287
+ const list = map.get(el.track) ?? [];
288
+ list.push(el);
289
+ map.set(el.track, list);
290
+ }
291
+ return Array.from(map.entries()).sort(([a], [b]) => a - b);
292
+ }, [elements]);
293
+
294
+ const trackStyles = useMemo(() => {
295
+ const map = new Map<number, TrackVisualStyle>();
296
+ for (const [trackNum, els] of tracks) {
297
+ map.set(trackNum, getStyle(els[0]?.tag ?? ""));
298
+ }
299
+ return map;
300
+ }, [tracks]);
301
+
302
+ const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
303
+ const trackOrderRef = useRef(trackOrder);
304
+ trackOrderRef.current = trackOrder;
305
+ const displayTrackOrder = useMemo(() => {
306
+ if (
307
+ !draggedClip?.started ||
308
+ trackOrder.length === 0 ||
309
+ trackOrder.includes(draggedClip.previewTrack)
310
+ ) {
311
+ return trackOrder;
312
+ }
313
+ return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
314
+ }, [draggedClip, trackOrder]);
315
+
252
316
  // Calculate effective pixels per second
253
317
  // In fit mode, use clientWidth (excludes scrollbar) with a small padding
254
318
  const fitPps =
255
319
  viewportWidth > GUTTER && effectiveDuration > 0
256
320
  ? (viewportWidth - GUTTER - 2) / effectiveDuration
257
321
  : 100;
258
- const pps = zoomMode === "fit" ? fitPps : manualPps;
322
+ const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
259
323
  const trackContentWidth = Math.max(0, effectiveDuration * pps);
324
+ const zoomModeRef = useRef(zoomMode);
325
+ zoomModeRef.current = zoomMode;
326
+ const previousZoomModeRef = useRef<ZoomMode | null>(zoomMode);
260
327
 
261
328
  const durationRef = useRef(effectiveDuration);
262
329
  durationRef.current = effectiveDuration;
263
330
  const ppsRef = useRef(pps);
264
331
  ppsRef.current = pps;
332
+ const syncPlayheadPosition = useCallback((time: number) => {
333
+ if (!playheadRef.current || durationRef.current <= 0) return;
334
+ playheadRef.current.style.left = `${getTimelinePlayheadLeft(time, ppsRef.current)}px`;
335
+ }, []);
336
+
337
+ useEffect(() => {
338
+ syncPlayheadPosition(currentTime);
339
+ }, [currentTime, pps, syncPlayheadPosition]);
340
+
341
+ useEffect(() => {
342
+ const scroll = scrollRef.current;
343
+ if (!scroll) {
344
+ previousZoomModeRef.current = zoomMode;
345
+ return;
346
+ }
347
+ scroll.scrollLeft = getTimelineScrollLeftForZoomTransition(
348
+ previousZoomModeRef.current,
349
+ zoomMode,
350
+ scroll.scrollLeft,
351
+ );
352
+ previousZoomModeRef.current = zoomMode;
353
+ }, [zoomMode]);
354
+
265
355
  useMountEffect(() => {
266
356
  const unsub = liveTime.subscribe((t) => {
267
357
  const dur = durationRef.current;
268
358
  if (!playheadRef.current || dur <= 0) return;
269
- const px = t * ppsRef.current;
270
- playheadRef.current.style.left = `${GUTTER + px}px`;
359
+ const playheadX = getTimelinePlayheadLeft(t, ppsRef.current);
360
+ playheadRef.current.style.left = `${playheadX}px`;
271
361
 
272
362
  // Auto-scroll to follow playhead during playback or seeking
273
363
  const scroll = scrollRef.current;
274
- if (scroll && !isDragging.current) {
275
- const playheadX = GUTTER + px;
364
+ if (
365
+ scroll &&
366
+ !isDragging.current &&
367
+ shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth)
368
+ ) {
276
369
  const visibleRight = scroll.scrollLeft + scroll.clientWidth;
277
370
  const visibleLeft = scroll.scrollLeft;
278
371
  const edgeMargin = scroll.clientWidth * 0.12;
@@ -290,6 +383,106 @@ export const Timeline = memo(function Timeline({
290
383
  });
291
384
 
292
385
  const dragScrollRaf = useRef(0);
386
+ const clipDragScrollRaf = useRef(0);
387
+ const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
388
+
389
+ const updateDraggedClipPreview = useCallback(
390
+ (drag: DraggedClipState, clientX: number, clientY: number) => {
391
+ const scroll = scrollRef.current;
392
+ const nextMove = resolveTimelineMove(
393
+ {
394
+ start: drag.element.start,
395
+ track: drag.element.track,
396
+ duration: drag.element.duration,
397
+ originClientX: drag.originClientX,
398
+ originClientY: drag.originClientY,
399
+ originScrollLeft: drag.originScrollLeft,
400
+ originScrollTop: drag.originScrollTop,
401
+ currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
402
+ currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
403
+ pixelsPerSecond: ppsRef.current,
404
+ trackHeight: TRACK_H,
405
+ maxStart: Math.max(0, durationRef.current - drag.element.duration),
406
+ trackOrder: trackOrderRef.current,
407
+ },
408
+ clientX,
409
+ clientY,
410
+ );
411
+
412
+ return {
413
+ ...drag,
414
+ started: true,
415
+ pointerClientX: clientX,
416
+ pointerClientY: clientY,
417
+ previewStart: nextMove.start,
418
+ previewTrack: nextMove.track,
419
+ };
420
+ },
421
+ [],
422
+ );
423
+
424
+ const stopClipDragAutoScroll = useCallback(() => {
425
+ clipDragPointerRef.current = null;
426
+ if (clipDragScrollRaf.current) {
427
+ cancelAnimationFrame(clipDragScrollRaf.current);
428
+ clipDragScrollRaf.current = 0;
429
+ }
430
+ }, []);
431
+
432
+ const stepClipDragAutoScroll = useCallback(() => {
433
+ clipDragScrollRaf.current = 0;
434
+ const drag = draggedClipRef.current;
435
+ const pointer = clipDragPointerRef.current;
436
+ const scroll = scrollRef.current;
437
+ if (!drag || !pointer || !scroll) return;
438
+
439
+ const rect = scroll.getBoundingClientRect();
440
+ const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
441
+ if (delta.x === 0 && delta.y === 0) return;
442
+
443
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
444
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
445
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
446
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
447
+ const didScroll = nextScrollLeft !== scroll.scrollLeft || nextScrollTop !== scroll.scrollTop;
448
+
449
+ if (!didScroll) return;
450
+
451
+ scroll.scrollLeft = nextScrollLeft;
452
+ scroll.scrollTop = nextScrollTop;
453
+ setDraggedClip((prev) =>
454
+ prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
455
+ );
456
+
457
+ clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
458
+ }, [updateDraggedClipPreview]);
459
+
460
+ const syncClipDragAutoScroll = useCallback(
461
+ (clientX: number, clientY: number) => {
462
+ clipDragPointerRef.current = { clientX, clientY };
463
+ const scroll = scrollRef.current;
464
+ if (!scroll) return;
465
+ const rect = scroll.getBoundingClientRect();
466
+ const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
467
+ if (delta.x === 0 && delta.y === 0) {
468
+ if (clipDragScrollRaf.current) {
469
+ cancelAnimationFrame(clipDragScrollRaf.current);
470
+ clipDragScrollRaf.current = 0;
471
+ }
472
+ return;
473
+ }
474
+ if (!clipDragScrollRaf.current) {
475
+ clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
476
+ }
477
+ },
478
+ [stepClipDragAutoScroll],
479
+ );
480
+ const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
481
+ updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
482
+ const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
483
+ syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
484
+ const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
485
+ stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
293
486
 
294
487
  const seekFromX = useCallback(
295
488
  (clientX: number) => {
@@ -311,7 +504,13 @@ export const Timeline = memo(function Timeline({
311
504
  (clientX: number) => {
312
505
  cancelAnimationFrame(dragScrollRaf.current);
313
506
  const el = scrollRef.current;
314
- if (!el || !isDragging.current) return;
507
+ if (
508
+ !el ||
509
+ !isDragging.current ||
510
+ !shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth)
511
+ ) {
512
+ return;
513
+ }
315
514
  const rect = el.getBoundingClientRect();
316
515
  const edgeZone = 40;
317
516
  const maxSpeed = 12;
@@ -336,6 +535,158 @@ export const Timeline = memo(function Timeline({
336
535
  [seekFromX],
337
536
  );
338
537
 
538
+ useMountEffect(() => {
539
+ const clearSuppressedClick = () => {
540
+ requestAnimationFrame(() => {
541
+ suppressClickRef.current = false;
542
+ });
543
+ };
544
+
545
+ const handleWindowPointerMove = (e: PointerEvent) => {
546
+ const drag = draggedClipRef.current;
547
+ const resize = resizingClipRef.current;
548
+ if (resize) {
549
+ const distance = Math.abs(e.clientX - resize.originClientX);
550
+ if (!resize.started && distance < 2) return;
551
+
552
+ setShowPopover(false);
553
+ setRangeSelection(null);
554
+
555
+ const sourceRemaining =
556
+ resize.element.sourceDuration != null
557
+ ? Math.max(
558
+ 0,
559
+ (resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
560
+ Math.max(resize.element.playbackRate ?? 1, 0.1),
561
+ )
562
+ : Number.POSITIVE_INFINITY;
563
+ const nextResize = resolveTimelineResize(
564
+ {
565
+ start: resize.element.start,
566
+ duration: resize.element.duration,
567
+ originClientX: resize.originClientX,
568
+ pixelsPerSecond: ppsRef.current,
569
+ minStart: 0,
570
+ maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
571
+ playbackStart: resize.element.playbackStart,
572
+ playbackRate: resize.element.playbackRate,
573
+ },
574
+ resize.edge,
575
+ e.clientX,
576
+ );
577
+
578
+ setResizingClip((prev) =>
579
+ prev
580
+ ? {
581
+ ...prev,
582
+ started: true,
583
+ previewStart: nextResize.start,
584
+ previewDuration: nextResize.duration,
585
+ previewPlaybackStart: nextResize.playbackStart,
586
+ }
587
+ : prev,
588
+ );
589
+ return;
590
+ }
591
+ if (!drag) return;
592
+
593
+ const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
594
+ if (!drag.started && distance < 4) return;
595
+
596
+ setShowPopover(false);
597
+ setRangeSelection(null);
598
+
599
+ setDraggedClip((prev) =>
600
+ prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
601
+ );
602
+ syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
603
+ };
604
+
605
+ const handleWindowPointerUp = () => {
606
+ stopClipDragAutoScrollRef.current();
607
+ const resize = resizingClipRef.current;
608
+ if (resize) {
609
+ resizingClipRef.current = null;
610
+ setResizingClip(null);
611
+
612
+ if (!resize.started) return;
613
+
614
+ suppressClickRef.current = true;
615
+ clearSuppressedClick();
616
+
617
+ const hasChanged =
618
+ resize.previewStart !== resize.element.start ||
619
+ resize.previewDuration !== resize.element.duration ||
620
+ resize.previewPlaybackStart !== resize.element.playbackStart;
621
+ if (!hasChanged) return;
622
+
623
+ updateElement(resize.element.key ?? resize.element.id, {
624
+ start: resize.previewStart,
625
+ duration: resize.previewDuration,
626
+ playbackStart: resize.previewPlaybackStart,
627
+ });
628
+
629
+ Promise.resolve(
630
+ onResizeElementRef.current?.(resize.element, {
631
+ start: resize.previewStart,
632
+ duration: resize.previewDuration,
633
+ playbackStart: resize.previewPlaybackStart,
634
+ }),
635
+ ).catch((error) => {
636
+ updateElement(resize.element.key ?? resize.element.id, {
637
+ start: resize.element.start,
638
+ duration: resize.element.duration,
639
+ playbackStart: resize.element.playbackStart,
640
+ });
641
+ console.error("[Timeline] Failed to persist clip resize", error);
642
+ });
643
+ return;
644
+ }
645
+
646
+ const drag = draggedClipRef.current;
647
+ if (!drag) return;
648
+ draggedClipRef.current = null;
649
+ setDraggedClip(null);
650
+
651
+ if (!drag.started) return;
652
+
653
+ suppressClickRef.current = true;
654
+ clearSuppressedClick();
655
+
656
+ const hasChanged =
657
+ drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
658
+ if (!hasChanged) return;
659
+
660
+ updateElement(drag.element.key ?? drag.element.id, {
661
+ start: drag.previewStart,
662
+ track: drag.previewTrack,
663
+ });
664
+
665
+ Promise.resolve(
666
+ onMoveElementRef.current?.(drag.element, {
667
+ start: drag.previewStart,
668
+ track: drag.previewTrack,
669
+ }),
670
+ ).catch((error) => {
671
+ updateElement(drag.element.key ?? drag.element.id, {
672
+ start: drag.element.start,
673
+ track: drag.element.track,
674
+ });
675
+ console.error("[Timeline] Failed to persist clip move", error);
676
+ });
677
+ };
678
+
679
+ window.addEventListener("pointermove", handleWindowPointerMove);
680
+ window.addEventListener("pointerup", handleWindowPointerUp);
681
+ window.addEventListener("pointercancel", handleWindowPointerUp);
682
+ return () => {
683
+ stopClipDragAutoScrollRef.current();
684
+ window.removeEventListener("pointermove", handleWindowPointerMove);
685
+ window.removeEventListener("pointerup", handleWindowPointerUp);
686
+ window.removeEventListener("pointercancel", handleWindowPointerUp);
687
+ };
688
+ });
689
+
339
690
  const handlePointerDown = useCallback(
340
691
  (e: React.PointerEvent) => {
341
692
  if (e.button !== 0) return;
@@ -402,26 +753,21 @@ export const Timeline = memo(function Timeline({
402
753
  cancelAnimationFrame(dragScrollRaf.current);
403
754
  }, []);
404
755
 
405
- const tracks = useMemo(() => {
406
- const map = new Map<number, typeof elements>();
407
- for (const el of elements) {
408
- const list = map.get(el.track) ?? [];
409
- list.push(el);
410
- map.set(el.track, list);
411
- }
412
- return Array.from(map.entries()).sort(([a], [b]) => a - b);
413
- }, [elements]);
414
-
415
- // Determine dominant style per track (from first element)
416
- const trackStyles = useMemo(() => {
417
- const map = new Map<number, TrackStyle>();
418
- for (const [trackNum, els] of tracks) {
419
- map.set(trackNum, getStyle(els[0]?.tag ?? ""));
420
- }
421
- return map;
422
- }, [tracks]);
423
-
424
756
  const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
757
+ const getPreviewElement = useCallback(
758
+ (element: TimelineElement): TimelineElement => {
759
+ if (resizingClip?.element.id === element.id) {
760
+ return {
761
+ ...element,
762
+ start: resizingClip.previewStart,
763
+ duration: resizingClip.previewDuration,
764
+ playbackStart: resizingClip.previewPlaybackStart,
765
+ };
766
+ }
767
+ return element;
768
+ },
769
+ [resizingClip],
770
+ );
425
771
 
426
772
  const [isDragOver, setIsDragOver] = useState(false);
427
773
 
@@ -522,14 +868,92 @@ export const Timeline = memo(function Timeline({
522
868
  );
523
869
  }
524
870
 
525
- const totalH = RULER_H + tracks.length * TRACK_H;
871
+ const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
872
+ const draggedElement = draggedClip?.element ?? null;
873
+ const activeDraggedElement =
874
+ draggedClip?.started === true && draggedElement
875
+ ? getRenderedTimelineElement({
876
+ element: draggedElement,
877
+ draggedElementId: draggedElement.id,
878
+ previewStart: draggedClip.previewStart,
879
+ previewTrack: draggedClip.previewTrack,
880
+ })
881
+ : null;
882
+ const activeDraggedPosition =
883
+ draggedClip?.started === true && activeDraggedElement && scrollRef.current
884
+ ? {
885
+ left:
886
+ draggedClip.pointerClientX -
887
+ scrollRef.current.getBoundingClientRect().left +
888
+ scrollRef.current.scrollLeft -
889
+ draggedClip.pointerOffsetX,
890
+ top:
891
+ draggedClip.pointerClientY -
892
+ scrollRef.current.getBoundingClientRect().top +
893
+ scrollRef.current.scrollTop -
894
+ draggedClip.pointerOffsetY,
895
+ }
896
+ : null;
897
+ const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => {
898
+ return (
899
+ <>
900
+ {renderClipOverlay?.(element)}
901
+ <div
902
+ className={
903
+ renderClipContent
904
+ ? "absolute inset-0 overflow-hidden"
905
+ : "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
906
+ }
907
+ >
908
+ {renderClipContent?.(element, clipStyle) ?? (
909
+ <div className="flex h-full min-h-0 flex-col justify-between py-3">
910
+ <div className="flex items-start">
911
+ <span
912
+ className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
913
+ style={{
914
+ color: clipStyle.label,
915
+ background: `${clipStyle.accent}26`,
916
+ boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
917
+ }}
918
+ >
919
+ {element.tag}
920
+ </span>
921
+ </div>
922
+ <span
923
+ className="text-[14px] font-semibold truncate leading-none tracking-[-0.02em]"
924
+ style={{ color: theme.textPrimary }}
925
+ >
926
+ {element.id || element.tag}
927
+ </span>
928
+ <div className="flex items-center">
929
+ <span
930
+ className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
931
+ style={{
932
+ color: theme.textSecondary,
933
+ background: "rgba(255,255,255,0.04)",
934
+ }}
935
+ >
936
+ {formatTime(element.start)} {"\u2192"}{" "}
937
+ {formatTime(element.start + element.duration)}
938
+ </span>
939
+ </div>
940
+ </div>
941
+ )}
942
+ </div>
943
+ </>
944
+ );
945
+ };
526
946
 
527
947
  return (
528
948
  <div
529
949
  ref={setContainerRef}
530
950
  aria-label="Timeline"
531
- className={`border-t border-neutral-800/50 bg-[#0a0a0b] select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
532
- style={{ touchAction: "pan-x pan-y" }}
951
+ className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
952
+ style={{
953
+ touchAction: "pan-x pan-y",
954
+ background: theme.shellBackground,
955
+ borderColor: theme.shellBorder,
956
+ }}
533
957
  >
534
958
  <div
535
959
  ref={scrollRef}
@@ -555,7 +979,7 @@ export const Timeline = memo(function Timeline({
555
979
  y1={RULER_H}
556
980
  x2={x}
557
981
  y2={totalH}
558
- stroke="rgba(255,255,255,0.035)"
982
+ stroke={theme.tickMinor}
559
983
  strokeWidth="1"
560
984
  />
561
985
  );
@@ -564,20 +988,20 @@ export const Timeline = memo(function Timeline({
564
988
 
565
989
  {/* Ruler */}
566
990
  <div
567
- className="relative border-b border-neutral-800/40 overflow-hidden"
991
+ className="relative overflow-hidden"
568
992
  style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
569
993
  >
570
994
  {/* Shift hint */}
571
995
  {shiftHeld && !rangeSelection && (
572
996
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
573
- <span className="text-[9px] text-studio-accent/60 font-medium">
997
+ <span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
574
998
  Drag to select range
575
999
  </span>
576
1000
  </div>
577
1001
  )}
578
1002
  {minor.map((t) => (
579
1003
  <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
580
- <div className="w-px h-[3px] bg-neutral-700/40" />
1004
+ <div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
581
1005
  </div>
582
1006
  ))}
583
1007
  {major.map((t) => (
@@ -586,36 +1010,49 @@ export const Timeline = memo(function Timeline({
586
1010
  className="absolute bottom-0 flex flex-col items-center"
587
1011
  style={{ left: t * pps }}
588
1012
  >
589
- <span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
1013
+ <span
1014
+ className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
1015
+ style={{ color: theme.tickText }}
1016
+ >
590
1017
  {formatTime(t)}
591
1018
  </span>
592
- <div className="w-px h-[5px] bg-neutral-600/60" />
1019
+ <div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
593
1020
  </div>
594
1021
  ))}
595
1022
  </div>
596
1023
 
597
1024
  {/* Tracks */}
598
- {tracks.map(([trackNum, els]) => {
599
- const ts = trackStyles.get(trackNum) ?? DEFAULT;
1025
+ {displayTrackOrder.map((trackNum) => {
1026
+ const els = tracks.find(([currentTrack]) => currentTrack === trackNum)?.[1] ?? [];
1027
+ const ts = trackStyles.get(trackNum) ?? getStyle("");
1028
+ const isPendingTrack =
1029
+ draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
600
1030
  return (
601
1031
  <div
602
1032
  key={trackNum}
603
1033
  className="relative flex"
604
- style={{ height: TRACK_H, backgroundColor: ts.row }}
1034
+ style={{
1035
+ height: TRACK_H,
1036
+ background: theme.rowBackground,
1037
+ borderBottom: `1px solid ${theme.rowBorder}`,
1038
+ }}
605
1039
  >
606
- {/* Gutter: colored icon badge (Figma Motion Cut style) */}
607
1040
  <div
608
1041
  className="flex-shrink-0 flex items-center justify-center"
609
- style={{ width: GUTTER }}
1042
+ style={{
1043
+ width: GUTTER,
1044
+ background: theme.gutterBackground,
1045
+ borderRight: `1px solid ${theme.gutterBorder}`,
1046
+ }}
610
1047
  >
611
1048
  <div
612
1049
  className="flex items-center justify-center"
613
1050
  style={{
614
- width: 20,
615
- height: 20,
1051
+ width: 18,
1052
+ height: 18,
616
1053
  borderRadius: 6,
617
- backgroundColor: ts.gutter,
618
- border: "1px solid rgba(255,255,255,0.35)",
1054
+ backgroundColor: ts.iconBackground,
1055
+ border: `1px solid ${theme.gutterBorder}`,
619
1056
  color: "#fff",
620
1057
  }}
621
1058
  >
@@ -625,64 +1062,98 @@ export const Timeline = memo(function Timeline({
625
1062
 
626
1063
  {/* Clips */}
627
1064
  <div style={{ width: trackContentWidth }} className="relative">
1065
+ {isPendingTrack && (
1066
+ <div
1067
+ className="absolute inset-0 flex items-center"
1068
+ style={{
1069
+ paddingLeft: 16,
1070
+ color: ts.label,
1071
+ fontSize: 11,
1072
+ letterSpacing: "0.08em",
1073
+ textTransform: "uppercase",
1074
+ background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
1075
+ boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
1076
+ }}
1077
+ >
1078
+ New track
1079
+ </div>
1080
+ )}
628
1081
  {els.map((el, i) => {
629
1082
  const clipStyle = getStyle(el.tag);
630
- const isSelected = selectedElementId === el.id;
1083
+ const elementKey = el.key ?? el.id;
1084
+ const isSelected = selectedElementId === elementKey;
631
1085
  const isComposition = !!el.compositionSrc;
632
- const clipKey = `${el.id}-${i}`;
1086
+ const clipKey = `${elementKey}-${i}`;
633
1087
  const isHovered = hoveredClip === clipKey;
634
1088
  const hasCustomContent = !!renderClipContent;
635
- const clipWidthPx = Math.max(el.duration * pps, 4);
1089
+ const isDragging =
1090
+ draggedClip?.started === true &&
1091
+ (draggedElement?.key ?? draggedElement?.id) === elementKey;
1092
+ if (isDragging) return null;
1093
+ const previewElement = getPreviewElement(el);
636
1094
 
637
1095
  return (
638
1096
  <TimelineClip
639
1097
  key={clipKey}
640
- el={el}
1098
+ el={previewElement}
641
1099
  pps={pps}
642
1100
  clipY={CLIP_Y}
643
1101
  isSelected={isSelected}
644
1102
  isHovered={isHovered}
1103
+ isDragging={false}
645
1104
  hasCustomContent={hasCustomContent}
646
- style={clipStyle}
1105
+ theme={theme}
1106
+ trackStyle={clipStyle}
647
1107
  isComposition={isComposition}
648
1108
  onHoverStart={() => setHoveredClip(clipKey)}
649
1109
  onHoverEnd={() => setHoveredClip(null)}
1110
+ onResizeStart={(edge, e) => {
1111
+ if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
1112
+ e.stopPropagation();
1113
+ setShowPopover(false);
1114
+ setRangeSelection(null);
1115
+ setResizingClip({
1116
+ element: el,
1117
+ edge,
1118
+ originClientX: e.clientX,
1119
+ previewStart: el.start,
1120
+ previewDuration: el.duration,
1121
+ previewPlaybackStart: el.playbackStart,
1122
+ started: false,
1123
+ });
1124
+ }}
1125
+ onPointerDown={(e) => {
1126
+ if (e.button !== 0 || e.shiftKey || !onMoveElement) return;
1127
+ setShowPopover(false);
1128
+ setRangeSelection(null);
1129
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1130
+ setDraggedClip({
1131
+ element: el,
1132
+ originClientX: e.clientX,
1133
+ originClientY: e.clientY,
1134
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
1135
+ originScrollTop: scrollRef.current?.scrollTop ?? 0,
1136
+ pointerClientX: e.clientX,
1137
+ pointerClientY: e.clientY,
1138
+ pointerOffsetX: e.clientX - rect.left,
1139
+ pointerOffsetY: e.clientY - rect.top,
1140
+ previewStart: el.start,
1141
+ previewTrack: el.track,
1142
+ started: false,
1143
+ });
1144
+ }}
650
1145
  onClick={(e) => {
651
1146
  e.stopPropagation();
652
- setSelectedElementId(isSelected ? null : el.id);
1147
+ if (suppressClickRef.current) return;
1148
+ setSelectedElementId(isSelected ? null : elementKey);
653
1149
  }}
654
1150
  onDoubleClick={(e) => {
655
1151
  e.stopPropagation();
1152
+ if (suppressClickRef.current) return;
656
1153
  if (isComposition && onDrillDown) onDrillDown(el);
657
1154
  }}
658
1155
  >
659
- {renderClipOverlay?.(el)}
660
- <div
661
- className={
662
- renderClipContent
663
- ? "absolute inset-0 overflow-hidden rounded-[4px]"
664
- : "flex items-center overflow-hidden flex-1 min-w-0"
665
- }
666
- >
667
- {renderClipContent?.(el, clipStyle) ?? (
668
- <>
669
- <span
670
- className="text-[10px] font-semibold truncate px-1.5 leading-none"
671
- style={{ color: clipStyle.label }}
672
- >
673
- {el.id || el.tag}
674
- </span>
675
- {clipWidthPx > 60 && (
676
- <span
677
- className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
678
- style={{ color: clipStyle.label }}
679
- >
680
- {el.duration.toFixed(1)}s
681
- </span>
682
- )}
683
- </>
684
- )}
685
- </div>
1156
+ {renderClipChildren(previewElement, clipStyle)}
686
1157
  </TimelineClip>
687
1158
  );
688
1159
  })}
@@ -691,6 +1162,41 @@ export const Timeline = memo(function Timeline({
691
1162
  );
692
1163
  })}
693
1164
 
1165
+ {activeDraggedElement && activeDraggedPosition && (
1166
+ <div
1167
+ className="absolute pointer-events-none"
1168
+ style={{
1169
+ top: activeDraggedPosition.top,
1170
+ left: activeDraggedPosition.left,
1171
+ width: Math.max(activeDraggedElement.duration * pps, 4),
1172
+ height: TRACK_H - CLIP_Y * 2,
1173
+ zIndex: 40,
1174
+ }}
1175
+ >
1176
+ <TimelineClip
1177
+ el={{ ...activeDraggedElement, start: 0 }}
1178
+ pps={pps}
1179
+ clipY={0}
1180
+ isSelected={
1181
+ selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
1182
+ }
1183
+ isHovered={false}
1184
+ isDragging={true}
1185
+ hasCustomContent={!!renderClipContent}
1186
+ theme={theme}
1187
+ trackStyle={getStyle(activeDraggedElement.tag)}
1188
+ isComposition={!!activeDraggedElement.compositionSrc}
1189
+ onHoverStart={() => {}}
1190
+ onHoverEnd={() => {}}
1191
+ onResizeStart={() => {}}
1192
+ onClick={() => {}}
1193
+ onDoubleClick={() => {}}
1194
+ >
1195
+ {renderClipChildren(activeDraggedElement, getStyle(activeDraggedElement.tag))}
1196
+ </TimelineClip>
1197
+ </div>
1198
+ )}
1199
+
694
1200
  {/* Range selection highlight */}
695
1201
  {rangeSelection && (
696
1202
  <div
@@ -746,11 +1252,22 @@ export const Timeline = memo(function Timeline({
746
1252
  {/* Keyboard shortcut hint — always visible */}
747
1253
  {!showPopover && !rangeSelection && (
748
1254
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
749
- <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
750
- <kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
1255
+ <div
1256
+ className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
1257
+ style={{
1258
+ background: "rgba(17,23,35,0.84)",
1259
+ borderColor: theme.gutterBorder,
1260
+ }}
1261
+ >
1262
+ <kbd
1263
+ className="text-[9px] font-mono px-1 py-0.5 rounded"
1264
+ style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
1265
+ >
751
1266
  Shift
752
1267
  </kbd>
753
- <span className="text-[9px] text-neutral-600">+ drag to edit range</span>
1268
+ <span className="text-[9px]" style={{ color: theme.textSecondary }}>
1269
+ + drag to edit range
1270
+ </span>
754
1271
  </div>
755
1272
  </div>
756
1273
  )}