@hyperframes/studio 0.5.5 → 0.6.0-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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-UWFaHilT.css +1 -0
  3. package/dist/assets/index-cPJbxeAk.js +107 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +129 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -28,6 +28,11 @@ import {
28
28
  } from "./timelineTheme";
29
29
  import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
30
30
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
31
+ import {
32
+ canInspectTimelineElement,
33
+ getTimelineElementKey,
34
+ isAudioTimelineElement,
35
+ } from "../../utils/timelineInspector";
31
36
 
32
37
  /* ── Layout ─────────────────────────────────────────────────────── */
33
38
  const GUTTER = 32;
@@ -35,7 +40,7 @@ const TRACK_H = 72;
35
40
  const RULER_H = 24;
36
41
  const CLIP_Y = 3; // vertical inset inside track
37
42
  const CLIP_HANDLE_W = 18;
38
- const TIMELINE_SCROLL_BUFFER = 24;
43
+ const TIMELINE_SCROLL_BUFFER = 20;
39
44
 
40
45
  interface TrackVisualStyle extends TimelineTrackStyle {
41
46
  icon: ReactNode;
@@ -216,6 +221,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
216
221
  return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
217
222
  }
218
223
 
224
+ export function shouldShowTimelineShortcutHint(
225
+ scrollHeight: number,
226
+ clientHeight: number,
227
+ ): boolean {
228
+ if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
229
+ return scrollHeight - clientHeight <= 1;
230
+ }
231
+
219
232
  export function shouldHandleTimelineDeleteKey(input: {
220
233
  key: string;
221
234
  metaKey?: boolean;
@@ -279,7 +292,6 @@ export function resolveTimelineAssetDrop(
279
292
  track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
280
293
  };
281
294
  }
282
-
283
295
  /* ── Component ──────────────────────────────────────────────────── */
284
296
  interface TimelineProps {
285
297
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -322,6 +334,12 @@ interface TimelineProps {
322
334
  element: import("../store/playerStore").TimelineElement,
323
335
  intent: BlockedTimelineEditIntent,
324
336
  ) => void;
337
+ onSelectElement?: (element: import("../store/playerStore").TimelineElement | null) => void;
338
+ onInspectElement?: (element: import("../store/playerStore").TimelineElement) => void;
339
+ inspectedElementId?: string | null;
340
+ layerChildCounts?: ReadonlyMap<string, number>;
341
+ thumbnailedElementIds?: ReadonlySet<string>;
342
+ onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void;
325
343
  theme?: Partial<TimelineTheme>;
326
344
  }
327
345
 
@@ -369,6 +387,12 @@ export const Timeline = memo(function Timeline({
369
387
  onMoveElement,
370
388
  onResizeElement,
371
389
  onBlockedEditAttempt,
390
+ onSelectElement,
391
+ onInspectElement,
392
+ inspectedElementId,
393
+ layerChildCounts,
394
+ thumbnailedElementIds,
395
+ onToggleElementThumbnail,
372
396
  theme: themeOverrides,
373
397
  }: TimelineProps = {}) {
374
398
  const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
@@ -427,30 +451,51 @@ export const Timeline = memo(function Timeline({
427
451
  onDeleteElementRef.current = onDeleteElement;
428
452
  const suppressClickRef = useRef(false);
429
453
  const [showPopover, setShowPopover] = useState(false);
454
+ const [showShortcutHint, setShowShortcutHint] = useState(true);
430
455
  const [viewportWidth, setViewportWidth] = useState(0);
431
456
  const roRef = useRef<ResizeObserver | null>(null);
457
+ const shortcutHintRafRef = useRef(0);
458
+ const syncShortcutHintVisibility = useCallback(() => {
459
+ const scroll = scrollRef.current;
460
+ setShowShortcutHint(
461
+ scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
462
+ );
463
+ }, []);
464
+ const scheduleShortcutHintVisibilitySync = useCallback(() => {
465
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
466
+ shortcutHintRafRef.current = requestAnimationFrame(() => {
467
+ shortcutHintRafRef.current = 0;
468
+ syncShortcutHintVisibility();
469
+ });
470
+ }, [syncShortcutHintVisibility]);
432
471
 
433
472
  // Callback ref: sets up ResizeObserver when the DOM element actually mounts.
434
473
  // useMountEffect can't work here because the component returns null on first
435
474
  // render (timelineReady=false), so containerRef.current is null when the
436
475
  // effect fires and the ResizeObserver is never created.
437
- const setContainerRef = useCallback((el: HTMLDivElement | null) => {
438
- if (roRef.current) {
439
- roRef.current.disconnect();
440
- roRef.current = null;
441
- }
442
- containerRef.current = el;
443
- if (!el) return;
444
- setViewportWidth(el.clientWidth);
445
- roRef.current = new ResizeObserver(([entry]) => {
446
- setViewportWidth(entry.contentRect.width);
447
- });
448
- roRef.current.observe(el);
449
- }, []);
476
+ const setContainerRef = useCallback(
477
+ (el: HTMLDivElement | null) => {
478
+ if (roRef.current) {
479
+ roRef.current.disconnect();
480
+ roRef.current = null;
481
+ }
482
+ containerRef.current = el;
483
+ if (!el) return;
484
+ setViewportWidth(el.clientWidth);
485
+ scheduleShortcutHintVisibilitySync();
486
+ roRef.current = new ResizeObserver(([entry]) => {
487
+ setViewportWidth(entry.contentRect.width);
488
+ scheduleShortcutHintVisibilitySync();
489
+ });
490
+ roRef.current.observe(el);
491
+ },
492
+ [scheduleShortcutHintVisibilitySync],
493
+ );
450
494
 
451
495
  // Clean up ResizeObserver on unmount
452
496
  useMountEffect(() => () => {
453
497
  roRef.current?.disconnect();
498
+ if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
454
499
  });
455
500
 
456
501
  // Effective duration: max of store duration and the furthest element end.
@@ -495,6 +540,7 @@ export const Timeline = memo(function Timeline({
495
540
  }
496
541
  return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
497
542
  }, [draggedClip, trackOrder]);
543
+ const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
498
544
  const selectedElement = useMemo(
499
545
  () => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
500
546
  [elements, selectedElementId],
@@ -544,7 +590,6 @@ export const Timeline = memo(function Timeline({
544
590
  );
545
591
  previousZoomModeRef.current = zoomMode;
546
592
  }, [zoomMode]);
547
-
548
593
  useMountEffect(() => {
549
594
  const unsub = liveTime.subscribe((t) => {
550
595
  const dur = durationRef.current;
@@ -1012,6 +1057,10 @@ export const Timeline = memo(function Timeline({
1012
1057
  );
1013
1058
  const majorTickInterval =
1014
1059
  major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
1060
+ useEffect(() => {
1061
+ syncShortcutHintVisibility();
1062
+ }, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
1063
+
1015
1064
  const getPreviewElement = useCallback(
1016
1065
  (element: TimelineElement): TimelineElement => {
1017
1066
  if (
@@ -1239,7 +1288,6 @@ export const Timeline = memo(function Timeline({
1239
1288
  );
1240
1289
  }
1241
1290
 
1242
- const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
1243
1291
  const draggedElement = draggedClip?.element ?? null;
1244
1292
  const activeDraggedElement =
1245
1293
  draggedClip?.started === true && draggedElement
@@ -1313,7 +1361,7 @@ export const Timeline = memo(function Timeline({
1313
1361
  <div
1314
1362
  ref={setContainerRef}
1315
1363
  aria-label="Timeline"
1316
- className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1364
+ className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
1317
1365
  style={{
1318
1366
  touchAction: "pan-x pan-y",
1319
1367
  background: theme.shellBackground,
@@ -1451,6 +1499,14 @@ export const Timeline = memo(function Timeline({
1451
1499
  const elementKey = el.key ?? el.id;
1452
1500
  const capabilities = getTimelineEditCapabilities(el);
1453
1501
  const isSelected = selectedElementId === elementKey;
1502
+ const canInspectClip = canInspectTimelineElement(el);
1503
+ const isInspectorActive =
1504
+ canInspectClip && inspectedElementId === getTimelineElementKey(el);
1505
+ const childCount = canInspectClip
1506
+ ? (layerChildCounts?.get(elementKey) ?? 0)
1507
+ : 0;
1508
+ const isThumbnailActive = thumbnailedElementIds?.has(elementKey) ?? false;
1509
+ const thumbnailLabel = isAudioTimelineElement(el) ? "waveform" : "thumbnail";
1454
1510
  const isComposition = !!el.compositionSrc;
1455
1511
  const clipKey = `${elementKey}-${i}`;
1456
1512
  const isHovered = hoveredClip === clipKey;
@@ -1474,8 +1530,32 @@ export const Timeline = memo(function Timeline({
1474
1530
  theme={theme}
1475
1531
  trackStyle={clipStyle}
1476
1532
  isComposition={isComposition}
1533
+ isInspectorActive={isInspectorActive}
1534
+ isThumbnailActive={isThumbnailActive}
1535
+ thumbnailLabel={thumbnailLabel}
1536
+ childCount={childCount}
1477
1537
  onHoverStart={() => setHoveredClip(clipKey)}
1478
1538
  onHoverEnd={() => setHoveredClip(null)}
1539
+ onInspectorClick={
1540
+ canInspectClip && onInspectElement
1541
+ ? (e) => {
1542
+ e.stopPropagation();
1543
+ if (suppressClickRef.current) return;
1544
+ setSelectedElementId(elementKey);
1545
+ onSelectElement?.(el);
1546
+ onInspectElement(el);
1547
+ }
1548
+ : undefined
1549
+ }
1550
+ onThumbnailClick={
1551
+ onToggleElementThumbnail && canInspectClip
1552
+ ? (e) => {
1553
+ e.stopPropagation();
1554
+ if (suppressClickRef.current) return;
1555
+ onToggleElementThumbnail(el);
1556
+ }
1557
+ : undefined
1558
+ }
1479
1559
  onResizeStart={(edge, e) => {
1480
1560
  if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
1481
1561
  if (edge === "start" && !capabilities.canTrimStart) return;
@@ -1548,7 +1628,9 @@ export const Timeline = memo(function Timeline({
1548
1628
  onClick={(e) => {
1549
1629
  e.stopPropagation();
1550
1630
  if (suppressClickRef.current) return;
1551
- setSelectedElementId(isSelected ? null : elementKey);
1631
+ const nextElement = isSelected ? null : el;
1632
+ setSelectedElementId(nextElement ? elementKey : null);
1633
+ onSelectElement?.(nextElement);
1552
1634
  }}
1553
1635
  onDoubleClick={(e) => {
1554
1636
  e.stopPropagation();
@@ -1652,8 +1734,8 @@ export const Timeline = memo(function Timeline({
1652
1734
  </div>
1653
1735
  </div>
1654
1736
 
1655
- {/* Keyboard shortcut hint — always visible */}
1656
- {!showPopover && !rangeSelection && (
1737
+ {/* Keyboard shortcut hint */}
1738
+ {showShortcutHint && !showPopover && !rangeSelection && (
1657
1739
  <div className="absolute bottom-2 right-3 pointer-events-none z-20">
1658
1740
  <div
1659
1741
  className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getTimelineClipControlPresentation } from "./TimelineClip";
3
+
4
+ describe("getTimelineClipControlPresentation", () => {
5
+ it("collapses persistent controls for compact clips until the clip is interactive", () => {
6
+ expect(
7
+ getTimelineClipControlPresentation({
8
+ widthPx: 42,
9
+ isHovered: false,
10
+ isSelected: false,
11
+ isInspectorActive: false,
12
+ isThumbnailActive: false,
13
+ isDragging: false,
14
+ }),
15
+ ).toMatchObject({
16
+ compact: true,
17
+ showControls: false,
18
+ });
19
+ });
20
+
21
+ it("shows compact controls when the clip is hovered, selected, or active", () => {
22
+ expect(
23
+ getTimelineClipControlPresentation({
24
+ widthPx: 42,
25
+ isHovered: true,
26
+ isSelected: false,
27
+ isInspectorActive: false,
28
+ isThumbnailActive: false,
29
+ isDragging: false,
30
+ }),
31
+ ).toMatchObject({
32
+ compact: true,
33
+ showControls: true,
34
+ });
35
+
36
+ expect(
37
+ getTimelineClipControlPresentation({
38
+ widthPx: 42,
39
+ isHovered: false,
40
+ isSelected: false,
41
+ isInspectorActive: true,
42
+ isThumbnailActive: false,
43
+ isDragging: false,
44
+ }).showControls,
45
+ ).toBe(true);
46
+ });
47
+
48
+ it("keeps controls visible on wide clips", () => {
49
+ expect(
50
+ getTimelineClipControlPresentation({
51
+ widthPx: 120,
52
+ isHovered: false,
53
+ isSelected: false,
54
+ isInspectorActive: false,
55
+ isThumbnailActive: false,
56
+ isDragging: false,
57
+ }),
58
+ ).toMatchObject({
59
+ compact: false,
60
+ showControls: true,
61
+ });
62
+ });
63
+
64
+ it("treats medium-width clips as compact so dense tracks do not turn into icon grids", () => {
65
+ expect(
66
+ getTimelineClipControlPresentation({
67
+ widthPx: 96,
68
+ isHovered: false,
69
+ isSelected: false,
70
+ isInspectorActive: false,
71
+ isThumbnailActive: false,
72
+ isDragging: false,
73
+ }),
74
+ ).toMatchObject({
75
+ compact: true,
76
+ showControls: false,
77
+ });
78
+ });
79
+
80
+ it("hides controls while dragging", () => {
81
+ expect(
82
+ getTimelineClipControlPresentation({
83
+ widthPx: 120,
84
+ isHovered: true,
85
+ isSelected: true,
86
+ isInspectorActive: true,
87
+ isThumbnailActive: true,
88
+ isDragging: true,
89
+ }).showControls,
90
+ ).toBe(false);
91
+ });
92
+ });
@@ -17,15 +17,67 @@ interface TimelineClipProps {
17
17
  theme?: TimelineTheme;
18
18
  trackStyle: TimelineTrackStyle;
19
19
  isComposition: boolean;
20
+ isInspectorActive?: boolean;
21
+ isThumbnailActive?: boolean;
22
+ thumbnailLabel?: string;
23
+ childCount?: number;
20
24
  onHoverStart: () => void;
21
25
  onHoverEnd: () => void;
22
26
  onPointerDown?: (e: React.PointerEvent) => void;
23
27
  onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
28
+ onInspectorClick?: (e: React.MouseEvent) => void;
29
+ onThumbnailClick?: (e: React.MouseEvent) => void;
24
30
  onClick: (e: React.MouseEvent) => void;
25
31
  onDoubleClick: (e: React.MouseEvent) => void;
26
32
  children?: ReactNode;
27
33
  }
28
34
 
35
+ export const TIMELINE_CLIP_CONTROL_Z_INDEX = 20;
36
+
37
+ const COMPACT_CLIP_CONTROL_WIDTH = 112;
38
+
39
+ interface TimelineClipControlPresentationInput {
40
+ widthPx: number;
41
+ isSelected: boolean;
42
+ isHovered: boolean;
43
+ isInspectorActive: boolean;
44
+ isThumbnailActive: boolean;
45
+ isDragging: boolean;
46
+ }
47
+
48
+ export interface TimelineClipControlPresentation {
49
+ compact: boolean;
50
+ showControls: boolean;
51
+ containerClassName: string;
52
+ buttonClassName: string;
53
+ iconSize: number;
54
+ }
55
+
56
+ export function getTimelineClipControlPresentation({
57
+ widthPx,
58
+ isSelected,
59
+ isHovered,
60
+ isInspectorActive,
61
+ isThumbnailActive,
62
+ isDragging,
63
+ }: TimelineClipControlPresentationInput): TimelineClipControlPresentation {
64
+ const compact = widthPx < COMPACT_CLIP_CONTROL_WIDTH;
65
+ const isInteractive = isHovered || isSelected || isInspectorActive || isThumbnailActive;
66
+ const showControls = !isDragging && (!compact || isInteractive);
67
+
68
+ return {
69
+ compact,
70
+ showControls,
71
+ containerClassName: compact
72
+ ? "absolute right-1 top-1 flex items-center gap-1"
73
+ : "absolute right-2 top-2 flex items-center gap-1",
74
+ buttonClassName: compact
75
+ ? "flex h-5 w-5 items-center justify-center rounded-[7px]"
76
+ : "flex h-6 w-6 items-center justify-center rounded-md",
77
+ iconSize: compact ? 12 : 14,
78
+ };
79
+ }
80
+
29
81
  export const TimelineClip = memo(function TimelineClip({
30
82
  el,
31
83
  pps,
@@ -37,10 +89,16 @@ export const TimelineClip = memo(function TimelineClip({
37
89
  theme = defaultTimelineTheme,
38
90
  trackStyle,
39
91
  isComposition,
92
+ isInspectorActive = false,
93
+ isThumbnailActive = false,
94
+ thumbnailLabel = "thumbnail",
95
+ childCount = 0,
40
96
  onHoverStart,
41
97
  onHoverEnd,
42
98
  onPointerDown,
43
99
  onResizeStart,
100
+ onInspectorClick,
101
+ onThumbnailClick,
44
102
  onClick,
45
103
  onDoubleClick,
46
104
  children,
@@ -62,7 +120,38 @@ export const TimelineClip = memo(function TimelineClip({
62
120
  : theme.clipShadow;
63
121
  const capabilities = getTimelineEditCapabilities(el);
64
122
  const displayLabel = el.label || el.id || el.tag;
123
+ const inspectorLabel =
124
+ childCount > 0
125
+ ? `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`
126
+ : "Inspect clip layer";
65
127
  const showHandles = handleOpacity > 0.01;
128
+ const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
129
+ const controlPresentation = getTimelineClipControlPresentation({
130
+ widthPx,
131
+ isSelected,
132
+ isHovered,
133
+ isInspectorActive,
134
+ isThumbnailActive,
135
+ isDragging,
136
+ });
137
+ const glossBackgroundImage = isSelected
138
+ ? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
139
+ : "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
140
+ const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
141
+ isSelected ? "22" : "1e"
142
+ }, transparent 28%)`;
143
+ const compositionStripeBackgroundImage =
144
+ isComposition && !hasCustomContent
145
+ ? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
146
+ : undefined;
147
+ const clipBackgroundImage = [
148
+ compositionStripeBackgroundImage,
149
+ glossBackgroundImage,
150
+ accentBackgroundImage,
151
+ baseBackgroundImage,
152
+ ]
153
+ .filter(Boolean)
154
+ .join(", ");
66
155
 
67
156
  return (
68
157
  <div
@@ -76,13 +165,7 @@ export const TimelineClip = memo(function TimelineClip({
76
165
  top: clipY,
77
166
  bottom: clipY,
78
167
  borderRadius: theme.clipRadius,
79
- background: isSelected
80
- ? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
81
- : `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
82
- backgroundImage:
83
- isComposition && !hasCustomContent
84
- ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
85
- : undefined,
168
+ backgroundImage: clipBackgroundImage,
86
169
  border: `1px solid ${borderColor}`,
87
170
  boxShadow,
88
171
  transition:
@@ -102,6 +185,157 @@ export const TimelineClip = memo(function TimelineClip({
102
185
  onClick={onClick}
103
186
  onDoubleClick={onDoubleClick}
104
187
  >
188
+ {childCount > 0 && controlPresentation.showControls && (
189
+ <button
190
+ type="button"
191
+ className={`absolute flex items-center gap-1 rounded-md border border-studio-accent/30 bg-neutral-950/75 text-[10px] font-semibold tabular-nums text-studio-accent shadow-lg shadow-black/25 backdrop-blur transition-colors hover:border-studio-accent/60 hover:bg-studio-accent/15 ${
192
+ controlPresentation.compact ? "left-1 top-1 h-5 px-1" : "left-2 top-2 h-6 px-1.5"
193
+ }`}
194
+ style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
195
+ title={inspectorLabel}
196
+ aria-label={inspectorLabel}
197
+ onPointerDown={(event) => {
198
+ event.stopPropagation();
199
+ }}
200
+ onClick={(event) => {
201
+ event.stopPropagation();
202
+ onInspectorClick?.(event);
203
+ }}
204
+ >
205
+ <svg
206
+ width={controlPresentation.compact ? "11" : "13"}
207
+ height={controlPresentation.compact ? "11" : "13"}
208
+ viewBox="0 0 24 24"
209
+ fill="none"
210
+ stroke="currentColor"
211
+ strokeWidth="1.8"
212
+ strokeLinecap="round"
213
+ strokeLinejoin="round"
214
+ aria-hidden="true"
215
+ >
216
+ <rect x="4" y="4" width="6" height="6" rx="1" />
217
+ <rect x="14" y="4" width="6" height="6" rx="1" />
218
+ <rect x="4" y="14" width="6" height="6" rx="1" />
219
+ <path d="M14 17h6" />
220
+ </svg>
221
+ {childCount}
222
+ </button>
223
+ )}
224
+ {onInspectorClick &&
225
+ controlPresentation.compact &&
226
+ !controlPresentation.showControls &&
227
+ !isDragging && (
228
+ <button
229
+ type="button"
230
+ className="group/clip-inspect absolute right-1 top-1/2 flex h-7 w-2 -translate-y-1/2 items-center justify-center rounded-full border border-white/15 bg-neutral-950/70 text-neutral-300 shadow-lg shadow-black/25 backdrop-blur transition-all hover:w-5 hover:border-white/30 hover:bg-neutral-950/90 focus:w-5 focus:border-studio-accent/60 focus:bg-studio-accent/15 focus:outline-none"
231
+ style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
232
+ title={inspectorLabel}
233
+ aria-label={inspectorLabel}
234
+ onPointerDown={(event) => {
235
+ event.stopPropagation();
236
+ }}
237
+ onClick={(event) => {
238
+ event.stopPropagation();
239
+ onInspectorClick(event);
240
+ }}
241
+ >
242
+ <svg
243
+ className="opacity-0 transition-opacity group-hover/clip-inspect:opacity-100 group-focus/clip-inspect:opacity-100"
244
+ width="12"
245
+ height="12"
246
+ viewBox="0 0 24 24"
247
+ fill="none"
248
+ stroke="currentColor"
249
+ strokeWidth="1.8"
250
+ strokeLinecap="round"
251
+ strokeLinejoin="round"
252
+ aria-hidden="true"
253
+ >
254
+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
255
+ <circle cx="12" cy="12" r="3" />
256
+ </svg>
257
+ </button>
258
+ )}
259
+ {(onThumbnailClick || onInspectorClick) && controlPresentation.showControls && (
260
+ <div
261
+ className={controlPresentation.containerClassName}
262
+ style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
263
+ >
264
+ {onThumbnailClick && (
265
+ <button
266
+ type="button"
267
+ className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
268
+ isThumbnailActive
269
+ ? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
270
+ : "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
271
+ }`}
272
+ title={
273
+ isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
274
+ }
275
+ aria-label={
276
+ isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
277
+ }
278
+ onPointerDown={(event) => {
279
+ event.stopPropagation();
280
+ }}
281
+ onClick={(event) => {
282
+ event.stopPropagation();
283
+ onThumbnailClick(event);
284
+ }}
285
+ >
286
+ <svg
287
+ width={controlPresentation.iconSize}
288
+ height={controlPresentation.iconSize}
289
+ viewBox="0 0 24 24"
290
+ fill="none"
291
+ stroke="currentColor"
292
+ strokeWidth="1.8"
293
+ strokeLinecap="round"
294
+ strokeLinejoin="round"
295
+ aria-hidden="true"
296
+ >
297
+ <rect x="3" y="5" width="18" height="14" rx="2" />
298
+ <circle cx="8" cy="10" r="1.5" />
299
+ <path d="m4 17 5-5 4 4 2-2 5 5" />
300
+ </svg>
301
+ </button>
302
+ )}
303
+ {onInspectorClick && (
304
+ <button
305
+ type="button"
306
+ className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
307
+ isInspectorActive
308
+ ? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
309
+ : "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
310
+ }`}
311
+ title={inspectorLabel}
312
+ aria-label={inspectorLabel}
313
+ onPointerDown={(event) => {
314
+ event.stopPropagation();
315
+ }}
316
+ onClick={(event) => {
317
+ event.stopPropagation();
318
+ onInspectorClick(event);
319
+ }}
320
+ >
321
+ <svg
322
+ width={controlPresentation.iconSize}
323
+ height={controlPresentation.iconSize}
324
+ viewBox="0 0 24 24"
325
+ fill="none"
326
+ stroke="currentColor"
327
+ strokeWidth="1.8"
328
+ strokeLinecap="round"
329
+ strokeLinejoin="round"
330
+ aria-hidden="true"
331
+ >
332
+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
333
+ <circle cx="12" cy="12" r="3" />
334
+ </svg>
335
+ </button>
336
+ )}
337
+ </div>
338
+ )}
105
339
  <div
106
340
  aria-hidden="true"
107
341
  role="presentation"
@@ -248,13 +248,28 @@ describe("getTimelineEditCapabilities", () => {
248
248
  });
249
249
  });
250
250
 
251
- it("disables move and trims for generic motion clips even when patchable", () => {
251
+ it("allows moving generic motion clips while keeping trims blocked", () => {
252
252
  expect(
253
253
  getTimelineEditCapabilities({
254
254
  tag: "section",
255
255
  duration: 2,
256
256
  selector: ".feature-card",
257
257
  }),
258
+ ).toEqual({
259
+ canMove: true,
260
+ canTrimStart: false,
261
+ canTrimEnd: false,
262
+ });
263
+ });
264
+
265
+ it("keeps implicit layout layers selectable but not timeline-editable", () => {
266
+ expect(
267
+ getTimelineEditCapabilities({
268
+ duration: 8,
269
+ selector: ".scene-shell",
270
+ tag: "div",
271
+ timingSource: "implicit",
272
+ }),
258
273
  ).toEqual({
259
274
  canMove: false,
260
275
  canTrimStart: false,
@@ -428,7 +443,6 @@ describe("buildClipRangeSelection", () => {
428
443
  });
429
444
  });
430
445
  });
431
-
432
446
  describe("resolveTimelineAutoScroll", () => {
433
447
  it("does not scroll when the pointer stays away from the edges", () => {
434
448
  expect(
@@ -512,7 +526,6 @@ describe("buildTimelineElementAgentPrompt", () => {
512
526
  ).toContain("If this clip is animated with GSAP");
513
527
  });
514
528
  });
515
-
516
529
  describe("resolveTimelineResize", () => {
517
530
  it("shrinks clip duration from the right edge", () => {
518
531
  expect(