@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -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,
@@ -187,7 +189,7 @@ export function useAppHotkeys({
187
189
  // earlier file edit (which would ALSO leave the caption change intact).
188
190
  const captionState = useCaptionStore.getState();
189
191
  // Only when the caption preview is actually visible: isEditMode stays
190
- // true while the preview is hidden (storyboard view), and eating ⌘Z
192
+ // true while the preview is hidden, and eating ⌘Z
191
193
  // there would pop invisible caption edits instead of file history.
192
194
  if (captionState.isEditMode && isCaptionPreviewVisible()) {
193
195
  const restored = direction === "undo" ? captionState.undo() : captionState.redo();
@@ -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,
@@ -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
+ });