@hyperframes/studio 0.8.48 → 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 (124) hide show
  1. package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
  2. package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +16 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6209 -5924
  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/useBlockHandlers.ts +7 -2
  35. package/src/hooks/useClipboard.test.ts +234 -0
  36. package/src/hooks/useClipboard.ts +264 -70
  37. package/src/hooks/useGroupCommits.test.ts +0 -1
  38. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  39. package/src/hooks/useRazorSplit.test.ts +0 -2
  40. package/src/hooks/useRazorSplit.test.tsx +0 -1
  41. package/src/hooks/useStudioContextValue.ts +5 -19
  42. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  43. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  44. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  45. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  46. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  47. package/src/player/components/ClipContextMenu.tsx +87 -7
  48. package/src/player/components/Timeline.tsx +14 -14
  49. package/src/player/components/TimelineCanvas.tsx +30 -0
  50. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  51. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  52. package/src/player/components/TimelineLanes.test.tsx +22 -1
  53. package/src/player/components/TimelineOverlays.tsx +12 -0
  54. package/src/player/components/TimelineTypes.ts +4 -0
  55. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  56. package/src/player/components/timelineClipChildren.test.ts +2 -2
  57. package/src/player/components/timelineClipChildren.tsx +2 -1
  58. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  59. package/src/player/components/timelineClipDragCommit.ts +20 -51
  60. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  61. package/src/player/components/timelineClipDragPreview.ts +8 -1
  62. package/src/player/components/timelineCollision.test.ts +23 -0
  63. package/src/player/components/timelineCollision.ts +27 -0
  64. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  65. package/src/player/components/timelineDragDrop.ts +30 -23
  66. package/src/player/components/timelineDragLanding.ts +108 -0
  67. package/src/player/components/timelineDropReveal.test.ts +34 -0
  68. package/src/player/components/timelineDropReveal.ts +12 -0
  69. package/src/player/components/timelineEditCapabilities.ts +1 -2
  70. package/src/player/components/timelineEditing.test.ts +0 -16
  71. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  72. package/src/player/components/timelineGroupEditing.ts +0 -1
  73. package/src/player/components/timelineTrackDisplay.ts +1 -1
  74. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  75. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  76. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  77. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  78. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  79. package/src/player/components/useTimelineGeometry.ts +7 -2
  80. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  81. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  82. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  83. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  84. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  85. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  86. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  87. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  88. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  89. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  90. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  91. package/src/player/lib/describeClips.test.ts +95 -0
  92. package/src/player/lib/describeClips.ts +103 -0
  93. package/src/player/lib/timelineDOM.test.ts +0 -80
  94. package/src/player/lib/timelineDOM.ts +1 -61
  95. package/src/player/lib/timelineElementHelpers.ts +10 -53
  96. package/src/player/store/playerStore.ts +5 -0
  97. package/src/player/store/readinessSlice.ts +4 -3
  98. package/src/player/store/timelineElement.ts +1 -3
  99. package/src/shims/canvasBrowserStub.js +50 -0
  100. package/src/utils/blockInstaller.test.ts +4 -0
  101. package/src/utils/blockInstaller.ts +9 -3
  102. package/src/utils/clipboardPayload.test.ts +42 -2
  103. package/src/utils/clipboardPayload.ts +57 -32
  104. package/src/utils/previewSafeMargins.test.ts +34 -0
  105. package/src/utils/previewSafeMargins.ts +10 -0
  106. package/src/utils/razorSplitTransaction.test.ts +0 -1
  107. package/src/utils/retryBudget.ts +20 -0
  108. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  109. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  110. package/src/utils/sdkCutover.ts +7 -4
  111. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  112. package/src/utils/sdkCutoverEligibility.ts +34 -11
  113. package/src/utils/studioUiPreferences.test.ts +43 -0
  114. package/src/utils/studioUiPreferences.ts +27 -6
  115. package/src/utils/timelineCompositionInsert.ts +2 -1
  116. package/src/utils/timelineElementSplit.test.ts +1 -2
  117. package/src/utils/timelineElementSplit.ts +0 -1
  118. package/src/webmcp/tools/lookTools.test.ts +40 -0
  119. package/src/webmcp/tools/lookTools.ts +32 -1
  120. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
  121. package/dist/assets/index-B9LfWNyM.css +0 -1
  122. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  123. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  124. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -0,0 +1,52 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { PreviewPane } from "./PreviewPane";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ vi.mock("../editor/PreviewGuides", () => ({ PreviewGuides: () => <i data-testid="guides" /> }));
11
+ vi.mock("../../player", () => ({ PlayerControls: () => null }));
12
+ vi.mock("./NLEPreview", () => ({ NLEPreview: () => null }));
13
+ vi.mock("./CompositionBreadcrumb", () => ({ CompositionBreadcrumb: () => null }));
14
+ vi.mock("./AssetPreviewOverlay", () => ({ AssetPreviewOverlay: () => null }));
15
+ vi.mock("./usePreviewBlockDrop", () => ({
16
+ usePreviewBlockDrop: () => ({
17
+ isDragOver: false,
18
+ handleDragEnter: vi.fn(),
19
+ handleDragOver: vi.fn(),
20
+ handleDragLeave: vi.fn(),
21
+ handleDrop: vi.fn(),
22
+ }),
23
+ }));
24
+ vi.mock("./NLEContext", () => ({
25
+ useNLEContext: () => ({
26
+ projectId: "p",
27
+ iframeRef: { current: null },
28
+ togglePlay: vi.fn(),
29
+ seek: vi.fn(),
30
+ onIframeLoad: vi.fn(),
31
+ compositionStack: [{ previewUrl: undefined }],
32
+ handleNavigateComposition: vi.fn(),
33
+ setCompositionLoading: vi.fn(),
34
+ timelineDisabled: false,
35
+ hasLoadedOnceRef: { current: false },
36
+ previewCompositionSize: null,
37
+ setPreviewCompositionSize: vi.fn(),
38
+ }),
39
+ }));
40
+
41
+ afterEach(() => {
42
+ document.body.innerHTML = "";
43
+ });
44
+
45
+ describe("PreviewPane", () => {
46
+ it("draws the guides in the pane itself, not through the previewOverlay slot", () => {
47
+ const host = document.createElement("div");
48
+ document.body.append(host);
49
+ act(() => createRoot(host).render(<PreviewPane />));
50
+ expect(host.querySelector('[data-testid="guides"]')).not.toBeNull();
51
+ });
52
+ });
@@ -6,6 +6,7 @@ import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
6
6
  import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
7
7
  import { useNLEContext } from "./NLEContext";
8
8
  import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9
+ import { PreviewGuides } from "../editor/PreviewGuides";
9
10
 
10
11
  function subscribeFullscreen(cb: () => void) {
11
12
  document.addEventListener("fullscreenchange", cb);
@@ -142,6 +143,7 @@ export function PreviewPane({
142
143
  )}
143
144
  <AssetPreviewOverlay />
144
145
  </div>
146
+ <PreviewGuides iframeRef={iframeRef} />
145
147
  {!isFullscreen && previewOverlay}
146
148
  </div>
147
149
  {/* Transport row: no own background or border — the controls sit flat on
@@ -102,6 +102,12 @@ export interface TimelinePaneProps {
102
102
  ) => Promise<void> | void;
103
103
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
104
104
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
105
+ /** Copy/paste/duplicate act on the store's own selection, not a passed
106
+ * element, so unlike onDeleteElement they need no composition-basis wrapper. */
107
+ onCopyClip?: () => boolean;
108
+ onPasteClip?: () => Promise<void>;
109
+ onDuplicateClip?: () => Promise<boolean>;
110
+ canPasteClip?: () => boolean;
105
111
  }
106
112
 
107
113
  // fallow-ignore-next-line complexity
@@ -116,6 +122,10 @@ export function TimelinePane({
116
122
  onCompositionDrop,
117
123
  onBlockedEditAttempt,
118
124
  onSelectTimelineElement,
125
+ onCopyClip,
126
+ onPasteClip,
127
+ onDuplicateClip,
128
+ canPasteClip,
119
129
  }: TimelinePaneProps) {
120
130
  const {
121
131
  seek,
@@ -288,6 +298,10 @@ export function TimelinePane({
288
298
  onBlockedEditAttempt={onBlockedEditAttempt}
289
299
  onSplitElement={handleSplitElement}
290
300
  onSelectElement={onSelectTimelineElement}
301
+ onCopyClip={onCopyClip}
302
+ onPasteClip={onPasteClip}
303
+ onDuplicateClip={onDuplicateClip}
304
+ canPasteClip={canPasteClip}
291
305
  />
292
306
  </div>
293
307
  {timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -24,7 +24,7 @@ import {
24
24
  afterEach(() => {
25
25
  document.body.innerHTML = "";
26
26
  // Wrapped in act: mounted harnesses subscribe to the store via
27
- // useExpandedTimelineElements, so the reset re-renders them.
27
+ // useTimelineRowElements, so the reset re-renders them.
28
28
  act(() => usePlayerStore.getState().setElements([]));
29
29
  });
30
30
 
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { usePlayerStore } from "../../player";
3
- import { useExpandedTimelineElements } from "../../player/hooks/useExpandedTimelineElements";
3
+ import { useTimelineRowElements } from "../../player/hooks/useTimelineRowElements";
4
4
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
5
5
  import {
6
6
  displayTrackOrder,
@@ -41,7 +41,7 @@ export interface MirrorZOrderInput {
41
41
  * commitZMirrorLaneMove) — the shared key is unique per gesture
42
42
  * (zReorderCoalesceKey's gesture seq), so the fold stays gesture-scoped.
43
43
  *
44
- * Element source: `useExpandedTimelineElements()` — the same expanded display
44
+ * Element source: `useTimelineRowElements()` — the same expanded display
45
45
  * set the Timeline renders and the resolver expects (post-normalizeToZones
46
46
  * lanes, expanded sub-comp children on their synthetic rows). No new expansion
47
47
  * is built here.
@@ -141,7 +141,7 @@ function useMirrorLaneMoveCommit(): (
141
141
  coalesceKey: string,
142
142
  resolveMove: (element: TimelineElement, elements: TimelineElement[]) => ZMirrorLaneMove,
143
143
  ) => Promise<boolean> {
144
- const elements = useExpandedTimelineElements();
144
+ const elements = useTimelineRowElements();
145
145
  const elementsRef = useRef(elements);
146
146
  elementsRef.current = elements;
147
147
  const { onMoveElements } = useTimelineEditContextOptional();
@@ -46,6 +46,7 @@ export interface HotkeyCallbacks {
46
46
  handleCopy: () => boolean;
47
47
  handlePaste: () => Promise<void>;
48
48
  handleCut: () => Promise<boolean>;
49
+ handleDuplicate: () => Promise<boolean>;
49
50
  onResetKeyframes: () => boolean;
50
51
  onDeleteSelectedKeyframes: () => void;
51
52
  onToggleRecording?: () => void;
@@ -127,6 +128,16 @@ export function dispatchModifierKey(
127
128
  }
128
129
  return true;
129
130
  }
131
+ if (key === "d") {
132
+ // Always own this key here, even with nothing selected — otherwise the
133
+ // browser's own Cmd+D (bookmark this page) fires over the editor.
134
+ event.preventDefault();
135
+ if (usePlayerStore.getState().selectedElementId) {
136
+ trackStudioEvent("keyboard_shortcut", { action: "duplicate" });
137
+ void cb.handleDuplicate();
138
+ }
139
+ return true;
140
+ }
130
141
  }
131
142
  return false;
132
143
  }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useCallback } from "react";
10
10
  import { usePlayerStore, type TimelineElement } from "../player";
11
- import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
11
+ import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
12
12
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
13
13
  import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
14
14
  import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
@@ -272,7 +272,7 @@ export function useAudioGroupCarveAssignment({
272
272
  groupId: string,
273
273
  groupLabel?: string,
274
274
  ) => Promise<void> {
275
- const expandedElements = useExpandedTimelineElements();
275
+ const expandedElements = useTimelineRowElements();
276
276
  return useCallback(
277
277
  async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
278
278
  if (isRecordingRef?.current) {
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../player";
3
3
  import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
4
- import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
4
+ import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
5
5
  import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
6
6
  import {
7
7
  timelineTrackOrder,
@@ -295,7 +295,7 @@ export function useTimelineTrackVisibilityEditing({
295
295
  // virtual sub-comp children carry their own (display.track + idx) track numbers,
296
296
  // so filtering the raw store list by a virtual track number would hide the wrong
297
297
  // outer-scene sibling sharing that index.
298
- const expandedElements = useExpandedTimelineElements();
298
+ const expandedElements = useTimelineRowElements();
299
299
  return useCallback(
300
300
  async (track: number, hidden: boolean, displayNumber?: number | null) => {
301
301
  if (isRecordingRef?.current) {
@@ -354,15 +354,7 @@ export function useTimelineElementVisibilityEditing({
354
354
  elementKey: string | readonly string[],
355
355
  hidden: boolean,
356
356
  ) => Promise<void> {
357
- // Resolve against the EXPANDED rows, not the raw store list — a nested
358
- // sub-composition child has no entry of its own in the raw list (only its
359
- // host does), so an elementKey for such a child (the
360
- // `sourceFile#domId`-shaped virtual key `resolveTimelineIdForSelection`
361
- // falls back to) would never match anything there and Hide All would
362
- // silently no-op for it. The expanded list synthesizes a real, patchable
363
- // TimelineElement (with matching key/domId/sourceFile) for each visible
364
- // child whenever its host is currently expanded.
365
- const expandedElements = useExpandedTimelineElements();
357
+ const expandedElements = useTimelineRowElements();
366
358
  return useCallback(
367
359
  async (elementKey: string | readonly string[], hidden: boolean) => {
368
360
  if (isRecordingRef?.current) {
@@ -371,6 +363,11 @@ export function useTimelineElementVisibilityEditing({
371
363
  }
372
364
  const pid = projectIdRef.current;
373
365
  if (!pid) return;
366
+ const keys = typeof elementKey === "string" ? [elementKey] : elementKey;
367
+ if (!expandedElements.some((item) => keys.includes(item.key ?? item.id))) {
368
+ showToast("This element is inside a sub-composition and has no timeline row to hide.");
369
+ return;
370
+ }
374
371
  try {
375
372
  await toggleTimelineElementHidden({
376
373
  projectId: pid,
@@ -93,6 +93,7 @@ function Harness() {
93
93
  handleCopy: vi.fn(() => false),
94
94
  handlePaste: vi.fn(async () => undefined),
95
95
  handleCut: vi.fn(async () => false),
96
+ handleDuplicate: vi.fn(async () => false),
96
97
  onResetKeyframes: vi.fn(() => false),
97
98
  onDeleteSelectedKeyframes: keyframeDelete,
98
99
  });
@@ -114,6 +114,7 @@ interface UseAppHotkeysParams {
114
114
  handleCopy: () => boolean;
115
115
  handlePaste: () => Promise<void>;
116
116
  handleCut: () => Promise<boolean>;
117
+ handleDuplicate: () => Promise<boolean>;
117
118
  onResetKeyframes: () => boolean;
118
119
  onDeleteSelectedKeyframes: () => void;
119
120
  onAfterUndoRedo?: () => void;
@@ -150,6 +151,7 @@ export function useAppHotkeys({
150
151
  handleCopy,
151
152
  handlePaste,
152
153
  handleCut,
154
+ handleDuplicate,
153
155
  onResetKeyframes,
154
156
  onDeleteSelectedKeyframes,
155
157
  onAfterUndoRedo,
@@ -255,6 +257,7 @@ export function useAppHotkeys({
255
257
  handleCopy,
256
258
  handlePaste,
257
259
  handleCut,
260
+ handleDuplicate,
258
261
  onResetKeyframes,
259
262
  onDeleteSelectedKeyframes,
260
263
  onToggleRecording,
@@ -1,3 +1,4 @@
1
+ import { formatTimelineBlock } from "../player/lib/describeClips";
1
2
  import { buildProjectApiPath } from "../utils/projectRouting";
2
3
  import { useState, useCallback, useRef, useEffect } from "react";
3
4
  import { copyTextToClipboard } from "../utils/clipboard";
@@ -92,6 +93,7 @@ export function useAskAgentModal({
92
93
  const prompt = buildElementAgentPrompt({
93
94
  selection: domEditSelection,
94
95
  currentTime: usePlayerStore.getState().currentTime,
96
+ timeline: formatTimelineBlock(usePlayerStore.getState().elements),
95
97
  tagSnippet,
96
98
  selectionContext: agentPromptSelectionContext,
97
99
  userInstruction,
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
8
  import { addBlockToProject } from "../utils/blockInstaller";
9
+ import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
9
10
  import type { BlockParam } from "@hyperframes/core/registry";
10
11
  import type { EditHistoryKind } from "../utils/editHistory";
11
12
  import type { RightPanelTab } from "../utils/studioHelpers";
@@ -148,7 +149,9 @@ export function useBlockHandlers({
148
149
  previewIframe: previewIframeRef.current,
149
150
  currentTime: usePlayerStore.getState().currentTime,
150
151
  }),
151
- );
152
+ ).then((result) => {
153
+ if (result) selectAndRevealTimelineElement(result.hostKey);
154
+ });
152
155
  },
153
156
  [projectId, blockCtx, previewIframeRef, runBlockInstall],
154
157
  );
@@ -184,7 +187,9 @@ export function useBlockHandlers({
184
187
  previewIframe: previewIframeRef.current,
185
188
  currentTime: usePlayerStore.getState().currentTime,
186
189
  }),
187
- );
190
+ ).then((result) => {
191
+ if (result) selectAndRevealTimelineElement(result.hostKey);
192
+ });
188
193
  },
189
194
  [projectId, blockCtx, previewIframeRef, runBlockInstall],
190
195
  );
@@ -0,0 +1,234 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { pasteTimelineClips, resolveFreeTrack, type PlacedClip } from "./useClipboard";
4
+ import { extendRootDurationInSource } from "../utils/rootDuration";
5
+ import type { TimelineClipboardClip } from "../utils/clipboardPayload";
6
+ import type { TimelineElement } from "../player";
7
+
8
+ const ROOT =
9
+ '<div id="claude-paper" data-composition-id="claude-paper" data-start="0" data-duration="10"></div>';
10
+
11
+ function clip(id: string, start: number, duration: number, track: number): TimelineClipboardClip {
12
+ return {
13
+ html: `<audio id="${id}" src="typenew.mp3" data-start="${start}" data-duration="${duration}" data-track-index="${track}"></audio>`,
14
+ start,
15
+ duration,
16
+ track,
17
+ };
18
+ }
19
+
20
+ function liveElement(track: number, start: number, duration: number): TimelineElement {
21
+ return { id: `x-${track}`, tag: "audio", start, duration, track, authoredTrack: track };
22
+ }
23
+
24
+ describe("resolveFreeTrack", () => {
25
+ it("keeps the preferred track when nothing occupies it", () => {
26
+ const track = resolveFreeTrack({ track: 5, start: 10, duration: 1 }, []);
27
+ expect(track).toBe(5);
28
+ });
29
+
30
+ it("bumps to a new track when the preferred one overlaps in time", () => {
31
+ const taken: PlacedClip[] = [{ track: 5, start: 10.2, duration: 0.5 }];
32
+ const track = resolveFreeTrack({ track: 5, start: 10, duration: 1 }, taken);
33
+ expect(track).toBe(6);
34
+ });
35
+
36
+ it("keeps the preferred track when an occupant on it does not overlap in time", () => {
37
+ const taken: PlacedClip[] = [{ track: 5, start: 50, duration: 1 }];
38
+ const track = resolveFreeTrack({ track: 5, start: 10, duration: 1 }, taken);
39
+ expect(track).toBe(5);
40
+ });
41
+
42
+ it("picks the next track above the highest taken one, not just +1 from preferred", () => {
43
+ const taken: PlacedClip[] = [
44
+ { track: 5, start: 10, duration: 1 },
45
+ { track: 9, start: 30, duration: 1 },
46
+ ];
47
+ const track = resolveFreeTrack({ track: 5, start: 10.1, duration: 1 }, taken);
48
+ expect(track).toBe(10);
49
+ });
50
+ });
51
+
52
+ describe("pasteTimelineClips", () => {
53
+ it("anchors a single clip's data-start at the anchor time and keeps its track", () => {
54
+ const { content, ids } = pasteTimelineClips(ROOT, [clip("sfx-23", 21.61, 0.57, 132)], 40, []);
55
+ expect(content).toContain('data-start="40"');
56
+ expect(content).toContain('data-track-index="132"');
57
+ expect(ids).toEqual(["sfx-23"]);
58
+ });
59
+
60
+ it("preserves each clip's relative offset from the group's earliest clip", () => {
61
+ const clips = [
62
+ clip("sfx-23", 21.61, 0.57, 132),
63
+ clip("sfx-24", 21.73, 0.57, 133),
64
+ clip("sfx-25", 21.84, 0.57, 134),
65
+ ];
66
+ const { content } = pasteTimelineClips(ROOT, clips, 40, []);
67
+ // Offsets from the earliest clip (21.61): 0, 0.12, 0.23 -> 40, 40.12, 40.23
68
+ expect(content).toContain('id="sfx-23" src="typenew.mp3" data-start="40"');
69
+ expect(content).toContain('id="sfx-24" src="typenew.mp3" data-start="40.12"');
70
+ expect(content).toContain('id="sfx-25" src="typenew.mp3" data-start="40.23"');
71
+ });
72
+
73
+ it("moves a clip to a new track when its original track is occupied at the target time", () => {
74
+ const live = [liveElement(132, 40, 0.57)];
75
+ const { content } = pasteTimelineClips(ROOT, [clip("sfx-23", 21.61, 0.57, 132)], 40, live);
76
+ expect(content).not.toContain('data-track-index="132"');
77
+ expect(content).toMatch(/data-track-index="133"/);
78
+ });
79
+
80
+ it("strips the copied data-hf-id so a fresh one mints on next save", () => {
81
+ const clipWithHfId: TimelineClipboardClip = {
82
+ html: '<audio data-hf-id="hf-75o7" id="sfx-type-0" src="typenew.mp3" data-start="7.8" data-duration="0.57" data-track-index="109"></audio>',
83
+ start: 7.8,
84
+ duration: 0.57,
85
+ track: 109,
86
+ };
87
+ const { content } = pasteTimelineClips(ROOT, [clipWithHfId], 40, []);
88
+ expect(content).not.toContain("data-hf-id");
89
+ });
90
+
91
+ it("strips a nested descendant's data-hf-id too, not just the root's", () => {
92
+ // A composition-instance clip's descendants carry their own hf-ids; a
93
+ // clone must remint all of them, not just the root, or the descendants
94
+ // collide with their originals the same way the root would.
95
+ const nested: TimelineClipboardClip = {
96
+ html: '<div data-hf-id="hf-root" id="comp-1" data-start="7.8" data-duration="0.57" data-track-index="109"><span data-hf-id="hf-child"></span></div>',
97
+ start: 7.8,
98
+ duration: 0.57,
99
+ track: 109,
100
+ };
101
+ const { content } = pasteTimelineClips(ROOT, [nested], 40, []);
102
+ expect(content).not.toContain("hf-root");
103
+ expect(content).not.toContain("hf-child");
104
+ expect(content).not.toContain("data-hf-id");
105
+ });
106
+
107
+ it("does not strip text content that happens to contain the data-hf-id string", () => {
108
+ const withText: TimelineClipboardClip = {
109
+ html: '<div id="cap-1" data-start="7.8" data-duration="0.57" data-track-index="109"><span>talking about data-hf-id="hf-xyz" in my video</span></div>',
110
+ start: 7.8,
111
+ duration: 0.57,
112
+ track: 109,
113
+ };
114
+ const { content } = pasteTimelineClips(ROOT, [withText], 40, []);
115
+ expect(content).toContain('talking about data-hf-id="hf-xyz" in my video');
116
+ });
117
+
118
+ it("bumps the second clip in a batch off the first clip's own new track when their offsets collide", () => {
119
+ // Both clips start life a track apart but paste to overlapping times on
120
+ // the SAME track, so the second must dodge the first's placement, not
121
+ // just the live elements it was checked against.
122
+ const clips = [clip("sfx-23", 21.61, 0.57, 132), clip("sfx-24", 21.7, 0.57, 132)];
123
+ const { content } = pasteTimelineClips(ROOT, clips, 40, []);
124
+ expect(content).toContain(
125
+ 'id="sfx-23" src="typenew.mp3" data-start="40" data-duration="0.57" data-track-index="132"',
126
+ );
127
+ expect(content).toContain(
128
+ 'id="sfx-24" src="typenew.mp3" data-start="40.09" data-duration="0.57" data-track-index="133"',
129
+ );
130
+ });
131
+
132
+ it("dedupes an id that already exists in the target file", () => {
133
+ const withExisting = ROOT.replace(
134
+ "</div>",
135
+ '<audio id="sfx-23" data-start="1" data-duration="1" data-track-index="0"></audio></div>',
136
+ );
137
+ const { content, ids } = pasteTimelineClips(
138
+ withExisting,
139
+ [clip("sfx-23", 21.61, 0.57, 132)],
140
+ 40,
141
+ [],
142
+ );
143
+ expect(ids[0]).not.toBe("sfx-23");
144
+ expect(ids[0]).toMatch(/^sfx-23-\d+$/);
145
+ expect(content).toContain(`id="${ids[0]}"`);
146
+ });
147
+
148
+ it("strips a single-quoted data-hf-id, not just a double-quoted one", () => {
149
+ const singleQuoted: TimelineClipboardClip = {
150
+ html: '<audio data-hf-id=\'hf-75o7\' id="sfx-type-0" src="typenew.mp3" data-start="7.8" data-duration="0.57" data-track-index="109"></audio>',
151
+ start: 7.8,
152
+ duration: 0.57,
153
+ track: 109,
154
+ };
155
+ const { content } = pasteTimelineClips(ROOT, [singleQuoted], 40, []);
156
+ expect(content).not.toContain("data-hf-id");
157
+ });
158
+
159
+ it("strips data-hf-id even when an earlier attribute value contains a literal >", () => {
160
+ // A `>` inside title's value would close a bracket-scoped tag regex
161
+ // early, leaving data-hf-id (which comes after it) outside the matched
162
+ // span and unstripped. A real parser doesn't have this failure mode.
163
+ const gtInAttr: TimelineClipboardClip = {
164
+ html: '<div title="a > b" id="cap-1" data-hf-id="hf-xyz" data-start="7.8" data-duration="0.57" data-track-index="109"></div>',
165
+ start: 7.8,
166
+ duration: 0.57,
167
+ track: 109,
168
+ };
169
+ const { content } = pasteTimelineClips(ROOT, [gtInAttr], 40, []);
170
+ expect(content).not.toContain("data-hf-id");
171
+ });
172
+
173
+ it("still strips data-hf-id from bare text with no element root", () => {
174
+ const noRoot: TimelineClipboardClip = {
175
+ html: 'text mentioning data-hf-id="hf-leak" with no tag at all',
176
+ start: 0,
177
+ duration: 1,
178
+ track: 0,
179
+ };
180
+ const { content } = pasteTimelineClips(ROOT, [noRoot], 0, []);
181
+ expect(content).not.toContain("data-hf-id");
182
+ });
183
+
184
+ it("still strips data-hf-id from a tag the HTML parser hoists out of body (e.g. title)", () => {
185
+ // A <title> never reaches DOMParser's body, so stripHfIds's DOM walk sees
186
+ // no root element here -- this exercises the regex fallback, not the walk.
187
+ const hoisted: TimelineClipboardClip = {
188
+ html: '<title data-hf-id="hf-leak">x</title>',
189
+ start: 0,
190
+ duration: 1,
191
+ track: 0,
192
+ };
193
+ const { content } = pasteTimelineClips(ROOT, [hoisted], 0, []);
194
+ expect(content).not.toContain("data-hf-id");
195
+ });
196
+
197
+ it("captures the root's own id, not a data-id that happens to precede it", () => {
198
+ const withDataId: TimelineClipboardClip = {
199
+ html: '<audio data-id="not-the-id" id="sfx-23" src="typenew.mp3" data-start="21.61" data-duration="0.57" data-track-index="132"></audio>',
200
+ start: 21.61,
201
+ duration: 0.57,
202
+ track: 132,
203
+ };
204
+ const { ids } = pasteTimelineClips(ROOT, [withDataId], 40, []);
205
+ expect(ids).toEqual(["sfx-23"]);
206
+ });
207
+
208
+ it("reports the furthest clip end across the whole batch as requiredEnd, not just the last clip's", () => {
209
+ // The batch is placed in order sfx-23, sfx-24, sfx-25, but sfx-24 ends up
210
+ // the furthest forward once offsets are applied; requiredEnd must track
211
+ // the max across all placed clips, not whichever one the loop saw last.
212
+ const clips = [
213
+ clip("sfx-23", 21.61, 0.57, 132),
214
+ clip("sfx-24", 21.9, 5, 133),
215
+ clip("sfx-25", 21.73, 0.1, 134),
216
+ ];
217
+ const { requiredEnd } = pasteTimelineClips(ROOT, clips, 40, []);
218
+ expect(requiredEnd).toBeCloseTo(40.29 + 5, 2);
219
+ });
220
+ });
221
+
222
+ describe("pasteTimelineClips + extendRootDurationInSource", () => {
223
+ it("grows the root composition's duration when a paste lands past its current end", () => {
224
+ const pasted = pasteTimelineClips(ROOT, [clip("sfx-23", 21.61, 0.57, 132)], 40, []);
225
+ const extended = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
226
+ expect(extended).toContain('data-duration="40.57"');
227
+ });
228
+
229
+ it("leaves the root composition's duration unchanged when the paste fits inside it", () => {
230
+ const pasted = pasteTimelineClips(ROOT, [clip("sfx-23", 1, 0.5, 132)], 2, []);
231
+ const extended = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
232
+ expect(extended).toContain('data-duration="10"');
233
+ });
234
+ });