@hyperframes/studio 0.8.49 → 0.8.50

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 (95) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
  2. package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +12 -4
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5899 -5754
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useClipboard.test.ts +234 -0
  35. package/src/hooks/useClipboard.ts +264 -70
  36. package/src/hooks/useGroupCommits.test.ts +0 -1
  37. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  38. package/src/hooks/useRazorSplit.test.ts +0 -2
  39. package/src/hooks/useRazorSplit.test.tsx +0 -1
  40. package/src/hooks/useStudioContextValue.ts +5 -19
  41. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  42. package/src/player/components/ClipContextMenu.tsx +87 -7
  43. package/src/player/components/Timeline.tsx +14 -14
  44. package/src/player/components/TimelineCanvas.tsx +30 -0
  45. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  46. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  47. package/src/player/components/TimelineLanes.test.tsx +22 -1
  48. package/src/player/components/TimelineOverlays.tsx +12 -0
  49. package/src/player/components/TimelineTypes.ts +4 -0
  50. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  51. package/src/player/components/timelineClipChildren.test.ts +2 -2
  52. package/src/player/components/timelineClipChildren.tsx +2 -1
  53. package/src/player/components/timelineClipDragCommit.ts +2 -2
  54. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  55. package/src/player/components/timelineDragDrop.ts +30 -23
  56. package/src/player/components/timelineEditCapabilities.ts +1 -2
  57. package/src/player/components/timelineEditing.test.ts +0 -16
  58. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  59. package/src/player/components/timelineGroupEditing.ts +0 -1
  60. package/src/player/components/timelineTrackDisplay.ts +1 -1
  61. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  62. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  63. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  64. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  65. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  66. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  67. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  68. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  69. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  70. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  71. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  72. package/src/player/lib/describeClips.test.ts +95 -0
  73. package/src/player/lib/describeClips.ts +103 -0
  74. package/src/player/lib/timelineDOM.test.ts +0 -80
  75. package/src/player/lib/timelineDOM.ts +1 -61
  76. package/src/player/lib/timelineElementHelpers.ts +2 -53
  77. package/src/player/store/playerStore.ts +5 -0
  78. package/src/player/store/timelineElement.ts +1 -3
  79. package/src/shims/canvasBrowserStub.js +50 -0
  80. package/src/utils/clipboardPayload.test.ts +42 -2
  81. package/src/utils/clipboardPayload.ts +57 -32
  82. package/src/utils/previewSafeMargins.test.ts +34 -0
  83. package/src/utils/previewSafeMargins.ts +10 -0
  84. package/src/utils/razorSplitTransaction.test.ts +0 -1
  85. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  86. package/src/utils/studioUiPreferences.test.ts +43 -0
  87. package/src/utils/studioUiPreferences.ts +27 -6
  88. package/src/utils/timelineElementSplit.test.ts +1 -2
  89. package/src/utils/timelineElementSplit.ts +0 -1
  90. package/src/webmcp/tools/lookTools.test.ts +40 -0
  91. package/src/webmcp/tools/lookTools.ts +32 -1
  92. package/dist/assets/index-B9LfWNyM.css +0 -1
  93. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  94. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  95. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -1,6 +1,6 @@
1
1
  import { memo } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import type { TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
6
  import { useMenuKeyboardNav } from "./menuKeyboardNav";
@@ -13,8 +13,14 @@ interface ClipContextMenuProps {
13
13
  onClose: () => void;
14
14
  onSplit: (element: TimelineElement, splitTime: number) => void;
15
15
  onDelete: (element: TimelineElement) => void;
16
+ onCopy?: () => boolean;
17
+ onPaste?: () => Promise<void>;
18
+ onDuplicate?: () => Promise<boolean>;
19
+ canPaste?: boolean;
16
20
  }
17
21
 
22
+ // A menu with many independently gated items (Split/Delete/Copy/Paste/Duplicate).
23
+ // fallow-ignore-next-line complexity
18
24
  export const ClipContextMenu = memo(function ClipContextMenu({
19
25
  x,
20
26
  y,
@@ -23,15 +29,22 @@ export const ClipContextMenu = memo(function ClipContextMenu({
23
29
  onClose,
24
30
  onSplit,
25
31
  onDelete,
32
+ onCopy,
33
+ onPaste,
34
+ onDuplicate,
35
+ canPaste,
26
36
  }: ClipContextMenuProps) {
27
37
  const menuRef = useContextMenuDismiss(onClose);
28
38
  useMenuKeyboardNav(menuRef);
29
-
30
- const menuWidth = 200;
31
- const menuHeight = 80;
32
- const overflowY = y + menuHeight - window.innerHeight;
33
- const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
34
- const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
39
+ // The right-clicked clip's own id: a member of the live multi-selection
40
+ // means Copy/Duplicate act on the whole group, matching onContextMenuClip's
41
+ // selection-preserving behaviour for a right-click inside it.
42
+ const selectionSize = usePlayerStore((s) => {
43
+ const id = element.key ?? element.id;
44
+ return s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)
45
+ ? s.selectedElementIds.size
46
+ : 1;
47
+ });
35
48
 
36
49
  const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
37
50
  const canSplit =
@@ -43,6 +56,23 @@ export const ClipContextMenu = memo(function ClipContextMenu({
43
56
  ? `Split at ${currentTime.toFixed(2)}s`
44
57
  : "Split (move playhead inside clip)";
45
58
 
59
+ const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
60
+ const rowCount = (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
61
+ const dividerCount = (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
62
+ const menuWidth = 200;
63
+ const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
64
+ const overflowY = y + menuHeight - window.innerHeight;
65
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
66
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
67
+
68
+ // Same enabled/disabled menu-item pattern as the sibling TrackGapContextMenu.
69
+ const itemClass = (enabled: boolean) =>
70
+ `w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none${
71
+ enabled
72
+ ? " focus-visible:bg-neutral-800 text-neutral-300 hover:bg-neutral-800 cursor-pointer"
73
+ : " text-neutral-600 cursor-not-allowed"
74
+ }`;
75
+
46
76
  return createPortal(
47
77
  <div
48
78
  ref={menuRef}
@@ -76,6 +106,56 @@ export const ClipContextMenu = memo(function ClipContextMenu({
76
106
  </>
77
107
  )}
78
108
 
109
+ {(onCopy || onPaste || onDuplicate) && (
110
+ <>
111
+ {onCopy && (
112
+ <button
113
+ type="button"
114
+ role="menuitem"
115
+ className={itemClass(true)}
116
+ onClick={() => {
117
+ onCopy();
118
+ onClose();
119
+ }}
120
+ >
121
+ <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
122
+ <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
123
+ </button>
124
+ )}
125
+ {onPaste && (
126
+ <button
127
+ type="button"
128
+ role="menuitem"
129
+ className={itemClass(!!canPaste)}
130
+ disabled={!canPaste}
131
+ onClick={() => {
132
+ if (!canPaste) return;
133
+ void onPaste();
134
+ onClose();
135
+ }}
136
+ >
137
+ <span>Paste</span>
138
+ <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
139
+ </button>
140
+ )}
141
+ {onDuplicate && (
142
+ <button
143
+ type="button"
144
+ role="menuitem"
145
+ className={itemClass(true)}
146
+ onClick={() => {
147
+ void onDuplicate();
148
+ onClose();
149
+ }}
150
+ >
151
+ <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
152
+ <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
153
+ </button>
154
+ )}
155
+ <div className="my-1 border-t border-neutral-700/60" />
156
+ </>
157
+ )}
158
+
79
159
  <button
80
160
  type="button"
81
161
  role="menuitem"
@@ -1,7 +1,7 @@
1
1
  import { useRef, useMemo, useCallback, useState, memo } from "react";
2
2
  import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
3
3
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
- import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
4
+ import { useTimelineRowElements } from "../hooks/useTimelineRowElements";
5
5
  import { defaultTimelineTheme } from "./timelineTheme";
6
6
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
7
7
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
@@ -43,6 +43,7 @@ import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
43
43
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
44
44
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
45
45
  import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
46
+ import { useClipContextMenu } from "./useTimelineClipContextMenu";
46
47
 
47
48
  export {
48
49
  shouldAutoScrollTimeline,
@@ -79,6 +80,10 @@ export const Timeline = memo(function Timeline({
79
80
  onBlockedEditAttempt: onBlockedEditAttemptOverride,
80
81
  onSplitElement: onSplitElementOverride,
81
82
  onSelectElement,
83
+ onCopyClip,
84
+ onPasteClip,
85
+ onDuplicateClip,
86
+ canPasteClip,
82
87
  theme: themeOverrides,
83
88
  sessionEpoch = 0,
84
89
  }: TimelineProps = {}) {
@@ -106,7 +111,7 @@ export const Timeline = memo(function Timeline({
106
111
  const refreshAfterLaneMove = useTimelineLaneMoveRefresh();
107
112
  useMusicBeatAnalysis();
108
113
  const rawElements = usePlayerStore((s) => s.elements);
109
- const expandedElements = useExpandedTimelineElements();
114
+ const expandedElements = useTimelineRowElements();
110
115
  const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
111
116
  const duration = usePlayerStore((s) => s.duration);
112
117
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
@@ -208,6 +213,7 @@ export const Timeline = memo(function Timeline({
208
213
  onMoveElement: pinnedOnMoveElement,
209
214
  onMoveElements: pinnedOnMoveElements,
210
215
  });
216
+ const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu);
211
217
 
212
218
  const {
213
219
  draggedClip,
@@ -492,6 +498,7 @@ export const Timeline = memo(function Timeline({
492
498
  rangeSelection={rangeSelection}
493
499
  marqueeRect={marqueeRect}
494
500
  laneGapStrips={laneGapStrips}
501
+ dropPreview={assetDrop.dropPreview}
495
502
  theme={theme}
496
503
  displayTrackOrder={displayLayout.displayTrackOrder}
497
504
  rowHeights={displayLayout.displayRowHeights}
@@ -548,18 +555,7 @@ export const Timeline = memo(function Timeline({
548
555
  onShiftClickKeyframe={onShiftClickKeyframe}
549
556
  onMoveKeyframe={onMoveKeyframe}
550
557
  onContextMenuKeyframe={onContextMenuKeyframe}
551
- onContextMenuClip={(e, el) => {
552
- e.preventDefault();
553
- setSelectedElementId(el.key ?? el.id);
554
- onSelectElement?.(el);
555
- dismissGapMenu();
556
- setClipContextMenu({
557
- x: e.clientX,
558
- y: e.clientY,
559
- element: el,
560
- sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
561
- });
562
- }}
558
+ onContextMenuClip={onContextMenuClip}
563
559
  onContextMenuLane={(e, track, time) => {
564
560
  if (draggedClip?.started || resizingClip) return;
565
561
  setClipContextMenu(null);
@@ -588,6 +584,10 @@ export const Timeline = memo(function Timeline({
588
584
  onSplitElement={onSplitElement}
589
585
  pinZoomBeforeEdit={pinZoomBeforeEdit}
590
586
  onDeleteElement={_onDeleteElement}
587
+ onCopyClip={onCopyClip}
588
+ onPasteClip={onPasteClip}
589
+ onDuplicateClip={onDuplicateClip}
590
+ canPasteClip={canPasteClip}
591
591
  gapContextMenu={gapMenuModel}
592
592
  onDismissGapContextMenu={dismissGapMenu}
593
593
  onCloseTrackGap={closeTrackGap}
@@ -39,12 +39,22 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
39
39
  playheadRef: React.RefObject<HTMLDivElement | null>;
40
40
  /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
41
41
  laneGapStrips: TimelineLaneGapStrips[];
42
+ /** Landing spot of an outside drag in progress, or null. */
43
+ dropPreview: { start: number; track: number } | null;
42
44
  }
43
45
 
46
+ // A dropped clip's length is unknown until it lands; the preview shows a default.
47
+ const DROP_PREVIEW_SECONDS = 3;
48
+
44
49
  export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
45
50
  const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props;
46
51
  const draggedRowIndex =
47
52
  draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
53
+ const dropTrackIndex = props.dropPreview
54
+ ? displayTrackOrder.indexOf(props.dropPreview.track)
55
+ : -1;
56
+ // A track not in the order is a new track, drawn one row past the last lane.
57
+ const dropRowIndex = dropTrackIndex < 0 ? displayTrackOrder.length : dropTrackIndex;
48
58
  const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights);
49
59
  // A clip bar in an EXPANDED row still renders at TRACK_H (the property lanes
50
60
  // occupy the rest of the row — see TimelineLanes' clipHeight), so the drag
@@ -171,6 +181,26 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
171
181
  />
172
182
  )}
173
183
 
184
+ {/* Drop preview: where an asset or file dragged in from outside will land
185
+ (a row past the last lane means a new track). */}
186
+ {props.dropPreview && (
187
+ <div
188
+ aria-hidden="true"
189
+ data-testid="timeline-drop-preview"
190
+ className="absolute pointer-events-none"
191
+ style={{
192
+ top: getTimelineRowTop(dropRowIndex, props.rowHeights) + CLIP_Y,
193
+ left: props.contentOrigin + props.dropPreview.start * props.pps,
194
+ width: DROP_PREVIEW_SECONDS * props.pps,
195
+ height: TRACK_H - CLIP_Y * 2,
196
+ border: "1px solid rgba(60,230,172,0.55)",
197
+ background: "rgba(60,230,172,0.12)",
198
+ borderRadius: 4,
199
+ zIndex: 30,
200
+ }}
201
+ />
202
+ )}
203
+
174
204
  {/* Insertion line — a new track will be inserted at this boundary on drop.
175
205
  Shown while the pointer is near a lane boundary (insert mode). */}
176
206
  {draggedClip?.started && draggedClip.insertRow != null && (
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { defaultTimelineTheme } from "./timelineTheme";
7
7
  import { TimelineGestureOverlay } from "./TimelineGestureOverlay";
8
8
  import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -65,6 +65,40 @@ describe("TimelineGestureOverlay", () => {
65
65
  act(() => root.unmount());
66
66
  });
67
67
 
68
+ it("asks for the same thumbnail frames as the clip at rest", () => {
69
+ const host = document.createElement("div");
70
+ document.body.append(host);
71
+ const root = createRoot(host);
72
+ const renderClipContent = vi.fn(() => null);
73
+ act(() => {
74
+ root.render(
75
+ <TimelineGestureOverlay
76
+ drag={drag}
77
+ scrollRef={{
78
+ current: {
79
+ scrollLeft: 0,
80
+ scrollTop: 0,
81
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
82
+ } as HTMLDivElement,
83
+ }}
84
+ pixelsPerSecond={100}
85
+ rowHeight={42}
86
+ selectedElementId="hero"
87
+ currentTime={4}
88
+ theme={defaultTimelineTheme}
89
+ getTrackStyle={getTrackStyle}
90
+ renderClipContent={renderClipContent}
91
+ />,
92
+ );
93
+ });
94
+ expect(renderClipContent).toHaveBeenCalledWith(
95
+ expect.anything(),
96
+ expect.anything(),
97
+ expect.objectContaining({ rich: false }),
98
+ );
99
+ act(() => root.unmount());
100
+ });
101
+
68
102
  it("keeps the stable overlay host after terminal cleanup", () => {
69
103
  const host = document.createElement("div");
70
104
  document.body.append(host);
@@ -89,7 +89,7 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
89
89
  getTrackStyle(element.tag),
90
90
  renderClipContent,
91
91
  renderClipOverlay,
92
- { priority: "interaction", rich: true },
92
+ { priority: "interaction", rich: false },
93
93
  )}
94
94
  </TimelineClip>
95
95
  </div>
@@ -70,6 +70,8 @@ interface RenderLanesOptions {
70
70
  draggedClip?: DraggedClipState | null;
71
71
  onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
72
72
  onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
73
+ hoveredClip?: string | null;
74
+ renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
73
75
  }
74
76
 
75
77
  function renderLanes(options: RenderLanesOptions = {}): {
@@ -135,7 +137,8 @@ function renderLanes(options: RenderLanesOptions = {}): {
135
137
  laneCounts={laneCounts}
136
138
  selectedElementId={null}
137
139
  selectedElementIds={next.selectedElementIds ?? new Set()}
138
- hoveredClip={null}
140
+ hoveredClip={next.hoveredClip ?? null}
141
+ renderClipContent={next.renderClipContent}
139
142
  draggedClip={next.draggedClip ?? null}
140
143
  draggedElement={null}
141
144
  multiDragPreview={next.multiDragPreview ?? null}
@@ -395,3 +398,21 @@ describe("TimelineLanes selection", () => {
395
398
  act(() => view.root.unmount());
396
399
  });
397
400
  });
401
+
402
+ describe("TimelineLanes clip thumbnails", () => {
403
+ it("asks for the same frames at rest, hovered and selected", () => {
404
+ const rich: unknown[] = [];
405
+ const renderClipContent = vi.fn(
406
+ (_el: TimelineElement, _style: unknown, context: { rich: boolean }) => {
407
+ rich.push(context.rich);
408
+ return null;
409
+ },
410
+ );
411
+ const elements = [element("clip-a", TRACK_A)];
412
+ const view = renderLanes({ elements, renderClipContent });
413
+ view.rerender({ elements, renderClipContent, hoveredClip: "clip-a" });
414
+ view.rerender({ elements, renderClipContent, selectedElementIds: new Set(["clip-a"]) });
415
+ expect(new Set(rich)).toEqual(new Set([false]));
416
+ act(() => view.root.unmount());
417
+ });
418
+ });
@@ -52,6 +52,10 @@ interface TimelineOverlaysProps {
52
52
  onSplitElement: TimelineEditCallbacks["onSplitElement"];
53
53
  pinZoomBeforeEdit: () => void;
54
54
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
55
+ onCopyClip?: () => boolean;
56
+ onPasteClip?: () => Promise<void>;
57
+ onDuplicateClip?: () => Promise<boolean>;
58
+ canPasteClip?: () => boolean;
55
59
  gapContextMenu: TrackGapContextMenuState | null;
56
60
  onDismissGapContextMenu: () => void;
57
61
  onCloseTrackGap: () => void;
@@ -119,6 +123,10 @@ export function TimelineOverlays({
119
123
  onSplitElement,
120
124
  pinZoomBeforeEdit,
121
125
  onDeleteElement,
126
+ onCopyClip,
127
+ onPasteClip,
128
+ onDuplicateClip,
129
+ canPasteClip,
122
130
  gapContextMenu,
123
131
  onDismissGapContextMenu,
124
132
  onCloseTrackGap,
@@ -249,6 +257,10 @@ export function TimelineOverlays({
249
257
  pinZoomBeforeEdit();
250
258
  onDeleteElement?.(element);
251
259
  }}
260
+ onCopy={onCopyClip}
261
+ onPaste={onPasteClip}
262
+ onDuplicate={onDuplicateClip}
263
+ canPaste={canPasteClip?.() ?? false}
252
264
  />
253
265
  )}
254
266
 
@@ -22,5 +22,9 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
22
22
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
23
23
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
24
24
  onSelectElement?: (element: TimelineElement | null) => void;
25
+ onCopyClip?: () => boolean;
26
+ onPasteClip?: () => Promise<void>;
27
+ onDuplicateClip?: () => Promise<boolean>;
28
+ canPasteClip?: () => boolean;
25
29
  theme?: Partial<TimelineTheme>;
26
30
  }
@@ -3,7 +3,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
3
  import { getTimelineEditCapabilities } from "./timelineEditing";
4
4
  import type { DraggedClipState } from "./timelineClipDragTypes";
5
5
 
6
- /** Whether Studio may write timing to this clip (false for locked/implicit rows). */
6
+ /** Whether Studio may write timing to this clip (false for locked rows). */
7
7
  export function canMoveTimelineElement(element: TimelineElement): boolean {
8
8
  return getTimelineEditCapabilities({
9
9
  tag: element.tag,
@@ -16,7 +16,6 @@ export function canMoveTimelineElement(element: TimelineElement): boolean {
16
16
  playbackStart: element.playbackStart,
17
17
  playbackStartAttr: element.playbackStartAttr,
18
18
  sourceDuration: element.sourceDuration,
19
- timingSource: element.timingSource,
20
19
  timelineLocked: element.timelineLocked,
21
20
  }).canMove;
22
21
  }
@@ -11,10 +11,10 @@ const clip: TimelineElement = {
11
11
  };
12
12
 
13
13
  describe("resolveClipRenderContext", () => {
14
- it("prioritizes interactive clips and enables rich thumbnails", () => {
14
+ it("prioritizes interactive clips without changing their thumbnail frames", () => {
15
15
  expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
16
16
  priority: "interaction",
17
- rich: true,
17
+ rich: false,
18
18
  });
19
19
  });
20
20
 
@@ -9,7 +9,8 @@ export function resolveClipRenderContext(
9
9
  visibleTimeRange: TimelineTimeRange,
10
10
  interactive: boolean,
11
11
  ): TimelineClipRenderContext {
12
- if (interactive) return { priority: "interaction", rich: true };
12
+ // Interaction only reorders loading; `rich` would swap the frames under the pointer.
13
+ if (interactive) return { priority: "interaction", rich: false };
13
14
  const visible =
14
15
  element.start < visibleTimeRange.end &&
15
16
  element.start + element.duration > visibleTimeRange.start;
@@ -176,8 +176,8 @@ export function persistMoveEdits(
176
176
  /**
177
177
  * Build the time-shift resolver for a multi-selection drag: every member of the
178
178
  * selection moves by the dragged clip's delta (clamped ≥ 0); non-members are
179
- * untouched. Returns null when this is not a multi-selection drag. A locked /
180
- * implicit member is dropped from the moving set (a marquee can sweep one in).
179
+ * untouched. Returns null when this is not a multi-selection drag. A locked
180
+ * member is dropped from the moving set (a marquee can sweep one in).
181
181
  */
182
182
  function resolveMultiSelection(
183
183
  drag: DraggedClipState,
@@ -220,4 +220,35 @@ describe("useTimelineAssetDrop", () => {
220
220
  expect(view.api.isDragOver).toBe(false);
221
221
  act(() => view.root.unmount());
222
222
  });
223
+
224
+ it("previews the exact placement the drop commits, and clears it after", () => {
225
+ const onAssetDrop = vi.fn();
226
+ const view = renderHarness(onAssetDrop);
227
+ const payload = JSON.stringify({ path: "assets/a.png" });
228
+
229
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer(payload), 400, 100)));
230
+ const preview = view.api.dropPreview;
231
+ expect(preview).toEqual({ start: 10, track: 0 });
232
+
233
+ act(() => view.api.handleAssetDrop(dragEvent(assetTransfer(payload), 400, 100)));
234
+ expect(onAssetDrop).toHaveBeenCalledExactlyOnceWith("assets/a.png", preview);
235
+ expect(view.api.dropPreview).toBeNull();
236
+ act(() => view.root.unmount());
237
+ });
238
+
239
+ it("moves the preview with the pointer and drops it when the drag leaves", () => {
240
+ const view = renderHarness(vi.fn());
241
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 400, 100)));
242
+ act(() => view.api.handleAssetDragOver(dragEvent(assetTransfer("{}"), 800, 100)));
243
+ expect(view.api.dropPreview?.start).toBe(20);
244
+
245
+ act(() =>
246
+ view.api.handleAssetDragLeave({
247
+ relatedTarget: null,
248
+ currentTarget: document.body,
249
+ } as unknown as React.DragEvent),
250
+ );
251
+ expect(view.api.dropPreview).toBeNull();
252
+ act(() => view.root.unmount());
253
+ });
223
254
  });
@@ -98,6 +98,7 @@ export function useTimelineAssetDrop({
98
98
  sessionEpoch,
99
99
  }: UseTimelineAssetDropOptions) {
100
100
  const [isDragOver, setIsDragOver] = useState(false);
101
+ const [dropPreview, setDropPreview] = useState<TimelinePlacement | null>(null);
101
102
  const dragPointerRef = useRef<{ clientX: number; clientY: number; sessionEpoch: number } | null>(
102
103
  null,
103
104
  );
@@ -142,6 +143,28 @@ export function useTimelineAssetDrop({
142
143
  [scrollRef, sessionEpoch, stepAutoScroll],
143
144
  );
144
145
 
146
+ const resolveDropPlacement = useCallback(
147
+ (clientX: number, clientY: number): TimelinePlacement => {
148
+ const scroll = scrollRef.current;
149
+ const rect = scroll?.getBoundingClientRect();
150
+ return resolveTimelineAssetDrop(
151
+ {
152
+ rectLeft: rect?.left ?? 0,
153
+ rectTop: rect?.top ?? 0,
154
+ scrollLeft: scroll?.scrollLeft ?? 0,
155
+ scrollTop: scroll?.scrollTop ?? 0,
156
+ contentOrigin,
157
+ pixelsPerSecond: ppsRef.current,
158
+ rowHeights: rowGeometryRef.current.rowHeights,
159
+ trackOrder: trackOrderRef.current,
160
+ },
161
+ clientX,
162
+ clientY,
163
+ );
164
+ },
165
+ [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
166
+ );
167
+
145
168
  const handleAssetDragOver = useCallback(
146
169
  (e: React.DragEvent) => {
147
170
  const types = Array.from(e.dataTransfer.types);
@@ -154,15 +177,20 @@ export function useTimelineAssetDrop({
154
177
  e.dataTransfer.dropEffect = "copy";
155
178
  activeDropEpochRef.current = sessionEpoch;
156
179
  setIsDragOver(true);
180
+ const next = resolveDropPlacement(e.clientX, e.clientY);
181
+ setDropPreview((prev) =>
182
+ prev?.start === next.start && prev.track === next.track ? prev : next,
183
+ );
157
184
  syncAutoScroll(e.clientX, e.clientY);
158
185
  },
159
- [sessionEpoch, syncAutoScroll],
186
+ [resolveDropPlacement, sessionEpoch, syncAutoScroll],
160
187
  );
161
188
 
162
189
  const clearDropPreview = useCallback(() => {
163
190
  activeDropEpochRef.current = null;
164
191
  stopAutoScroll();
165
192
  setIsDragOver(false);
193
+ setDropPreview(null);
166
194
  }, [stopAutoScroll]);
167
195
 
168
196
  const handleAssetDragLeave = useCallback(
@@ -174,28 +202,6 @@ export function useTimelineAssetDrop({
174
202
  [clearDropPreview],
175
203
  );
176
204
 
177
- const resolveDropPlacement = useCallback(
178
- (clientX: number, clientY: number): TimelinePlacement => {
179
- const scroll = scrollRef.current;
180
- const rect = scroll?.getBoundingClientRect();
181
- return resolveTimelineAssetDrop(
182
- {
183
- rectLeft: rect?.left ?? 0,
184
- rectTop: rect?.top ?? 0,
185
- scrollLeft: scroll?.scrollLeft ?? 0,
186
- scrollTop: scroll?.scrollTop ?? 0,
187
- contentOrigin,
188
- pixelsPerSecond: ppsRef.current,
189
- rowHeights: rowGeometryRef.current.rowHeights,
190
- trackOrder: trackOrderRef.current,
191
- },
192
- clientX,
193
- clientY,
194
- );
195
- },
196
- [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
197
- );
198
-
199
205
  const handleAssetDrop = useCallback(
200
206
  (e: React.DragEvent) => {
201
207
  e.preventDefault();
@@ -240,6 +246,7 @@ export function useTimelineAssetDrop({
240
246
 
241
247
  return {
242
248
  isDragOver,
249
+ dropPreview,
243
250
  handleAssetDragOver,
244
251
  handleAssetDragLeave,
245
252
  handleAssetDrop,
@@ -42,10 +42,9 @@ export function getTimelineEditCapabilities(input: {
42
42
  playbackStart?: number;
43
43
  playbackStartAttr?: "media-start" | "playback-start";
44
44
  sourceDuration?: number;
45
- timingSource?: "authored" | "implicit";
46
45
  timelineLocked?: boolean;
47
46
  }): TimelineEditCapabilities {
48
- if (input.timingSource === "implicit" || input.timelineLocked) {
47
+ if (input.timelineLocked) {
49
48
  return { canMove: false, canTrimStart: false, canTrimEnd: false };
50
49
  }
51
50
 
@@ -412,21 +412,6 @@ describe("getTimelineEditCapabilities", () => {
412
412
  });
413
413
  });
414
414
 
415
- it("keeps implicit layout layers selectable but not timeline-editable", () => {
416
- expect(
417
- getTimelineEditCapabilities({
418
- duration: 8,
419
- selector: ".scene-shell",
420
- tag: "div",
421
- timingSource: "implicit",
422
- }),
423
- ).toEqual({
424
- canMove: false,
425
- canTrimStart: false,
426
- canTrimEnd: false,
427
- });
428
- });
429
-
430
415
  it("allows move and both trims for patchable media clips with offset support", () => {
431
416
  expect(
432
417
  getTimelineEditCapabilities({
@@ -496,7 +481,6 @@ describe("getTimelineEditCapabilities", () => {
496
481
  tag: "div",
497
482
  duration: 4,
498
483
  selector: "#hero-card",
499
- timingSource: "authored",
500
484
  }),
501
485
  ).toEqual({
502
486
  canMove: true,
@@ -34,11 +34,9 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
34
34
  expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
35
35
  });
36
36
 
37
- it("degrades when a member is implicitly timed or has no patch target", () => {
37
+ it("degrades when a member has no patch target", () => {
38
38
  const a = el("a");
39
- const implicit = el("b", { timingSource: "implicit" });
40
39
  const noTarget = el("c", { domId: undefined, selector: undefined });
41
- expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
42
40
  expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
43
41
  });
44
42
 
@@ -198,7 +198,6 @@ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): b
198
198
  playbackStart: element.playbackStart,
199
199
  playbackStartAttr: element.playbackStartAttr,
200
200
  sourceDuration: element.sourceDuration,
201
- timingSource: element.timingSource,
202
201
  timelineLocked: element.timelineLocked,
203
202
  });
204
203
  return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
5
5
  * sub-composition child gets a synthesized fractional key (`host.track + n /
6
- * (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
6
+ * (siblings + 2)`, see `useTimelineRowElements`), so putting it in a string
7
7
  * announces "Hide track 0.16666666666666666". Every user-visible track number,
8
8
  * whether it is rendered by a component or baked into an undo-history label,
9
9
  * routes through here; the raw key stays in callbacks and lookups only.