@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
@@ -0,0 +1,77 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { openComposition } from "@hyperframes/sdk";
6
+
7
+ /**
8
+ * linkedom's HTMLCanvasElement constructor calls `createCanvas(300, 150)` from
9
+ * the Node-only `canvas` package. linkedom guards that import in a try/catch —
10
+ * try the native package, fall back to its own bundled shim — but a bundler
11
+ * resolves the require statically, so the catch never fires in the browser
12
+ * build and `createCanvas` lands undefined. Parsing any `<canvas>` tag then
13
+ * throws, `openComposition` rejects, and Studio silently loses its SDK session
14
+ * for the whole composition (no cutover, and the resolver shadow does not run
15
+ * either).
16
+ *
17
+ * Production telemetry, 72h after the v0.8.47 flip: 1065 of 1544
18
+ * `studio:sdk_session_unavailable` events were this crash, spread across a
19
+ * large share of 197 users. Canvas is common in compositions — Three.js,
20
+ * shaders, particle effects, charts.
21
+ *
22
+ * The fix is a `resolve.alias` in the studio's vite config pointing `canvas` at
23
+ * linkedom's own shim. This test pins the behaviour the alias has to preserve.
24
+ */
25
+ const CANVAS_COMPOSITION = `<!doctype html>
26
+ <html>
27
+ <body data-composition-id="main" data-width="1920" data-height="1080">
28
+ <div class="clip" data-start="0" data-duration="3">
29
+ <canvas id="scene" width="800" height="600"></canvas>
30
+ </div>
31
+ </body>
32
+ </html>`;
33
+
34
+ describe("openComposition with a <canvas> element", () => {
35
+ it("opens a session instead of throwing on createCanvas", async () => {
36
+ const session = await openComposition(CANVAS_COMPOSITION, { history: false });
37
+ expect(session).toBeTruthy();
38
+ session.dispose();
39
+ });
40
+
41
+ it("models the canvas element so edits can target it", async () => {
42
+ const session = await openComposition(CANVAS_COMPOSITION, { history: false });
43
+ // A composition whose elements cannot be modelled is a session the cutover
44
+ // path declines wholesale (sessionEmpty), which is the same silent bypass by
45
+ // a different route.
46
+ expect(session.getElements().length).toBeGreaterThan(0);
47
+ session.dispose();
48
+ });
49
+ });
50
+
51
+ describe("canvas browser-stub alias", () => {
52
+ // The two tests above pass with OR without the fix: vitest resolves
53
+ // `require('canvas')` at runtime, so linkedom's own try/catch fallback works
54
+ // here. Only the bundled build breaks. Nothing else in this suite can observe
55
+ // the bundler, so the alias itself is what has to be pinned — without this,
56
+ // deleting the alias reintroduces a crash that is invisible until it reaches
57
+ // production telemetry.
58
+ it("is configured, so the browser build never resolves the Node canvas package", () => {
59
+ const config = readFileSync(resolve(import.meta.dirname, "../../vite.config.ts"), "utf8");
60
+ expect(config).toMatch(
61
+ /\bcanvas:\s*resolve\(__dirname,\s*"src\/shims\/canvasBrowserStub\.js"\)/,
62
+ );
63
+ });
64
+
65
+ it("exports the createCanvas shape linkedom destructures", async () => {
66
+ const stub = await import("../shims/canvasBrowserStub.js");
67
+ // linkedom takes the DEFAULT import and destructures createCanvas off it
68
+ // (`import Canvas from '...'; const {createCanvas} = Canvas`), so the
69
+ // default export is the one that actually has to carry the function.
70
+ expect(typeof stub.default.createCanvas).toBe("function");
71
+ expect(typeof stub.createCanvas).toBe("function");
72
+ const canvas = stub.default.createCanvas(300, 150);
73
+ expect(canvas.width).toBe(300);
74
+ expect(canvas.height).toBe(150);
75
+ expect(canvas.getContext()).toBeNull();
76
+ });
77
+ });
@@ -37,6 +37,21 @@ describe("studio UI preferences", () => {
37
37
  });
38
38
  });
39
39
 
40
+ it("remembers the ruler and safe-margin toggles and drops non-boolean values", () => {
41
+ const storage = createStorage();
42
+ writeStudioUiPreferences({ rulerVisible: true, safeMarginsVisible: false }, storage);
43
+ expect(readStudioUiPreferences(storage)).toEqual({
44
+ rulerVisible: true,
45
+ safeMarginsVisible: false,
46
+ });
47
+
48
+ storage.setItem(
49
+ "hf-studio-ui-preferences",
50
+ JSON.stringify({ rulerVisible: "yes", safeMarginsVisible: 1 }),
51
+ );
52
+ expect(readStudioUiPreferences(storage)).toEqual({});
53
+ });
54
+
40
55
  it("ignores malformed stored values", () => {
41
56
  const storage = createStorage();
42
57
  storage.setItem(
@@ -101,6 +116,34 @@ describe("thumbnailMode preference", () => {
101
116
  });
102
117
  });
103
118
 
119
+ describe("per-project scoping", () => {
120
+ it("keeps two projects' preferences independent", () => {
121
+ const storage = createStorage();
122
+ writeStudioUiPreferences({ leftWidth: 300 }, storage, "alpha");
123
+ writeStudioUiPreferences({ leftWidth: 500 }, storage, "beta");
124
+
125
+ expect(readStudioUiPreferences(storage, "alpha").leftWidth).toBe(300);
126
+ expect(readStudioUiPreferences(storage, "beta").leftWidth).toBe(500);
127
+ });
128
+
129
+ it("a project with nothing saved yet inherits the pre-scoping global entry once", () => {
130
+ const storage = createStorage();
131
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
132
+
133
+ expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(600);
134
+ });
135
+
136
+ it("stops inheriting the global entry once the project has its own write", () => {
137
+ const storage = createStorage();
138
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
139
+
140
+ writeStudioUiPreferences({ leftWidth: 250 }, storage, "gamma");
141
+
142
+ expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(250);
143
+ expect(readStudioUiPreferences(storage, null).leftWidth).toBe(600);
144
+ });
145
+ });
146
+
104
147
  describe("timeline zoom pin persistence", () => {
105
148
  it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
106
149
  const storage = createStorage();
@@ -1,3 +1,6 @@
1
+ import type { SerializedDockview } from "dockview-react";
2
+ import { parseDockLayout } from "../components/dock/dockLayoutSchema";
3
+
1
4
  export interface StoredPreviewZoomState {
2
5
  zoomPercent: number;
3
6
  panX: number;
@@ -20,6 +23,8 @@ export interface StudioUiPreferences {
20
23
  recentBlocks?: string[];
21
24
  snapEnabled?: boolean;
22
25
  gridVisible?: boolean;
26
+ rulerVisible?: boolean;
27
+ safeMarginsVisible?: boolean;
23
28
  gridSpacing?: number;
24
29
  snapToGrid?: boolean;
25
30
  /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
@@ -45,6 +50,8 @@ export interface StudioUiPreferences {
45
50
  * intentionally scoped to one mount.
46
51
  */
47
52
  agentToolsEnabled?: boolean;
53
+ /** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
54
+ dockLayout?: SerializedDockview;
48
55
  }
49
56
 
50
57
  const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -62,11 +69,15 @@ function getBrowserStorage(): Storage | null {
62
69
  }
63
70
  }
64
71
 
72
+ function storageKeyFor(projectId: string | null): string {
73
+ return projectId ? `${STUDIO_UI_PREFERENCES_KEY}:${projectId}` : STUDIO_UI_PREFERENCES_KEY;
74
+ }
75
+
65
76
  // fallow-ignore-next-line complexity
66
- function readStorage(storage: Storage | null): StudioUiPreferences {
77
+ function readStorage(storage: Storage | null, key: string): StudioUiPreferences {
67
78
  if (!storage) return {};
68
79
  try {
69
- const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
80
+ const raw = storage.getItem(key);
70
81
  if (!raw) return {};
71
82
  const parsed: unknown = JSON.parse(raw);
72
83
  if (!isRecord(parsed)) return {};
@@ -130,6 +141,12 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
130
141
  if (typeof parsed.gridVisible === "boolean") {
131
142
  preferences.gridVisible = parsed.gridVisible;
132
143
  }
144
+ if (typeof parsed.rulerVisible === "boolean") {
145
+ preferences.rulerVisible = parsed.rulerVisible;
146
+ }
147
+ if (typeof parsed.safeMarginsVisible === "boolean") {
148
+ preferences.safeMarginsVisible = parsed.safeMarginsVisible;
149
+ }
133
150
  if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
134
151
  preferences.gridSpacing = parsed.gridSpacing;
135
152
  }
@@ -157,27 +174,38 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
157
174
  if (typeof parsed.agentToolsEnabled === "boolean") {
158
175
  preferences.agentToolsEnabled = parsed.agentToolsEnabled;
159
176
  }
177
+ const dockLayout = parseDockLayout(parsed.dockLayout);
178
+ if (dockLayout) preferences.dockLayout = dockLayout;
160
179
  return preferences;
161
180
  } catch {
162
181
  return {};
163
182
  }
164
183
  }
165
184
 
166
- export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
167
- return readStorage(storage);
185
+ /** `projectId` opts a caller into a per-project entry (falls back once to the
186
+ * shared entry so a project's first read isn't blank). Defaults to `null`:
187
+ * most callers read once at mount, never on a live project switch. */
188
+ export function readStudioUiPreferences(
189
+ storage: Storage | null = getBrowserStorage(),
190
+ projectId: string | null = null,
191
+ ): StudioUiPreferences {
192
+ const scoped = readStorage(storage, storageKeyFor(projectId));
193
+ if (!projectId || Object.keys(scoped).length > 0) return scoped;
194
+ return readStorage(storage, STUDIO_UI_PREFERENCES_KEY);
168
195
  }
169
196
 
170
197
  export function writeStudioUiPreferences(
171
198
  patch: StudioUiPreferences,
172
199
  storage: Storage | null = getBrowserStorage(),
200
+ projectId: string | null = null,
173
201
  ) {
174
202
  if (!storage) return;
175
203
  try {
176
204
  const next = {
177
- ...readStorage(storage),
205
+ ...readStudioUiPreferences(storage, projectId),
178
206
  ...patch,
179
207
  };
180
- storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
208
+ storage.setItem(storageKeyFor(projectId), JSON.stringify(next));
181
209
  } catch {
182
210
  /* localStorage may be unavailable or full */
183
211
  }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../player";
3
+ import {
4
+ applyTrackRenumbers,
5
+ planDropTrackInsert,
6
+ resolveDropTrack,
7
+ } from "./timelineDropTrackInsert";
8
+
9
+ function clip(id: string, track: number, start: number): TimelineElement {
10
+ return {
11
+ id,
12
+ key: id,
13
+ tag: "div",
14
+ start,
15
+ duration: 2,
16
+ track,
17
+ authoredTrack: track,
18
+ hfId: `hf-${id}`,
19
+ domId: id,
20
+ // Main-document elements carry no sourceFile, as in production.
21
+ };
22
+ }
23
+
24
+ const trackOrder = [0, 1, 2];
25
+ const dropped = { id: "new", tag: "img", start: 5, duration: 3 };
26
+ const elements = [clip("a", 0, 0), clip("b", 1, 0), clip("c", 2, 0)];
27
+ const source = elements
28
+ .map(
29
+ (e) =>
30
+ `<div data-hf-id="${e.hfId}" id="${e.id}" data-start="0" data-track-index="${e.track}"></div>`,
31
+ )
32
+ .join("\n");
33
+
34
+ describe("planDropTrackInsert", () => {
35
+ it("opens a lane between two rows and pushes the lanes below down", () => {
36
+ const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 1, dropped });
37
+ expect(plan?.track).toBe(1);
38
+ expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([
39
+ ["b", 2],
40
+ ["c", 3],
41
+ ]);
42
+ });
43
+
44
+ it("opens a lane above the first row", () => {
45
+ const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 0, dropped });
46
+ expect(plan?.track).toBe(0);
47
+ expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([
48
+ ["a", 1],
49
+ ["b", 2],
50
+ ["c", 3],
51
+ ]);
52
+ });
53
+
54
+ it("still renumbers when the peers' own sourceFile is a real path, not undefined", () => {
55
+ const subComp = elements.map((e) => ({ ...e, sourceFile: "sub.html" }));
56
+ const plan = planDropTrackInsert({ elements: subComp, trackOrder, insertRow: 1, dropped });
57
+ expect(plan?.track).toBe(1);
58
+ expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([
59
+ ["b", 2],
60
+ ["c", 3],
61
+ ]);
62
+ });
63
+ });
64
+
65
+ describe("planDropTrackInsert against the display row order", () => {
66
+ it("counts an audio-group anchor row the way a clip drag does", () => {
67
+ // Rows shown: group anchor (-0.5), track 0, track 1. Row boundary 2 is between 0 and 1.
68
+ const grouped = elements.slice(0, 2).map((e) => (e.id === "a" ? { ...e, audioGroup: "g" } : e));
69
+ const plan = planDropTrackInsert({
70
+ elements: grouped,
71
+ trackOrder: [-0.5, 0, 1],
72
+ insertRow: 2,
73
+ dropped,
74
+ });
75
+ expect(plan?.track).toBe(1);
76
+ expect(plan?.renumbers.map((r) => [r.element.id, r.track])).toEqual([["b", 2]]);
77
+ });
78
+ });
79
+
80
+ describe("applyTrackRenumbers", () => {
81
+ it("rewrites only the renumbered clips' data-track-index", () => {
82
+ const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 1, dropped });
83
+ const out = applyTrackRenumbers(source, plan!);
84
+ const tracks = [...out.matchAll(/id="(\w)"[^>]*data-track-index="(\d+)"/g)].map((m) => [
85
+ m[1],
86
+ +m[2],
87
+ ]);
88
+ expect(tracks).toEqual([
89
+ ["a", 0],
90
+ ["b", 2],
91
+ ["c", 3],
92
+ ]);
93
+ });
94
+
95
+ it("throws when a clip's opening tag is missing", () => {
96
+ const plan = planDropTrackInsert({ elements, trackOrder, insertRow: 1, dropped });
97
+ expect(() => applyTrackRenumbers("<div></div>", plan!)).toThrow(/Cannot renumber/);
98
+ });
99
+ });
100
+
101
+ describe("resolveDropTrack", () => {
102
+ it("keeps the aimed lane and the source when no insert is asked for", () => {
103
+ const out = resolveDropTrack({
104
+ source,
105
+ elements,
106
+ placement: { track: 2 },
107
+ dropped,
108
+ });
109
+ expect(out).toEqual({ source, track: 2 });
110
+ });
111
+
112
+ it("returns the new lane and the source with the lanes below pushed down", () => {
113
+ const out = resolveDropTrack({
114
+ source,
115
+ elements,
116
+ placement: { track: 1, insertRow: 1, trackOrder },
117
+ dropped,
118
+ });
119
+ expect(out.track).toBe(1);
120
+ expect(out.source).toContain('id="b" data-start="0" data-track-index="2"');
121
+ });
122
+
123
+ it("refuses to insert when a locked clip would have to move", () => {
124
+ const locked = elements.map((e) => (e.id === "b" ? { ...e, timelineLocked: true } : e));
125
+ expect(() =>
126
+ resolveDropTrack({
127
+ source,
128
+ elements: locked,
129
+ placement: { track: 1, insertRow: 1, trackOrder },
130
+ dropped,
131
+ }),
132
+ ).toThrow(/locked/);
133
+ });
134
+ });
@@ -0,0 +1,83 @@
1
+ import type { TimelineElement } from "../player";
2
+ import { layoutAfterTrackInsert } from "../player/components/timelineDragLanding";
3
+ import { canMoveTimelineElement } from "../player/components/timelineAuthoredMoveTarget";
4
+ import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
5
+ import { applyPatchByTarget, readAttributeByTarget } from "./sourcePatcher";
6
+ import { buildPatchTarget } from "../hooks/timelineEditingHelpers";
7
+ import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
8
+
9
+ export interface DropTrackInsertPlan {
10
+ /** Lane the dropped clip is written on. */
11
+ track: number;
12
+ /** Existing clips whose lane changes to make room. */
13
+ renumbers: Array<{ element: TimelineElement; track: number }>;
14
+ }
15
+
16
+ /** Plan a new lane at boundary `insertRow` with the renumber a clip drag into a gutter uses.
17
+ * Null when a locked clip would have to move. */
18
+ export function planDropTrackInsert(input: {
19
+ elements: TimelineElement[];
20
+ /** The row order the timeline shows (anchor rows included), which `insertRow` indexes. */
21
+ trackOrder: readonly number[];
22
+ insertRow: number;
23
+ dropped: Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
24
+ }): DropTrackInsertPlan | null {
25
+ const { elements, trackOrder, insertRow, dropped } = input;
26
+ const newElement: TimelineElement = {
27
+ ...dropped,
28
+ key: dropped.id,
29
+ // Parked on an existing lane so it adds no lane of its own to the topology.
30
+ track: elements[0]?.track ?? 0,
31
+ // sameSourceFile compares this raw field: borrow the peers' value, not the resolved path.
32
+ sourceFile: elements[0]?.sourceFile,
33
+ };
34
+ const layout = layoutAfterTrackInsert(newElement, dropped.start, insertRow, null, {
35
+ elements: [...elements, newElement],
36
+ trackOrder: [...trackOrder],
37
+ });
38
+ if (!layout) return null;
39
+ const byKey = new Map(elements.map((e) => [e.key ?? e.id, e]));
40
+ const renumbers: DropTrackInsertPlan["renumbers"] = [];
41
+ for (const norm of layout.normalized) {
42
+ const key = norm.key ?? norm.id;
43
+ if (key === dropped.id) continue;
44
+ const src = byKey.get(key);
45
+ if (!src || norm.track === (src.authoredTrack ?? src.track)) continue;
46
+ if (!canMoveTimelineElement(src)) return null;
47
+ renumbers.push({ element: src, track: norm.track });
48
+ }
49
+ const track = layout.normalized.find((n) => (n.key ?? n.id) === dropped.id)?.track;
50
+ return track == null ? null : { track, renumbers };
51
+ }
52
+
53
+ /** Rewrite `data-track-index` on each renumbered clip's opening tag, via the shared source patcher. */
54
+ export function applyTrackRenumbers(source: string, plan: DropTrackInsertPlan): string {
55
+ let out = source;
56
+ for (const { element, track } of plan.renumbers) {
57
+ const target = buildPatchTarget(element);
58
+ if (!target || readAttributeByTarget(out, target, "track-index") === undefined) {
59
+ throw new Error(`Cannot renumber the track of "${element.id}" in the source`);
60
+ }
61
+ out = applyPatchByTarget(out, target, {
62
+ type: "attribute",
63
+ property: "track-index",
64
+ value: formatTimelineAttributeNumber(track),
65
+ });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** The lane a dropped clip is written on and the source with any lanes pushed down to make room. */
71
+ export function resolveDropTrack(input: {
72
+ source: string;
73
+ elements: TimelineElement[];
74
+ placement: TimelineDropPlacement;
75
+ dropped: Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
76
+ }): { source: string; track: number } {
77
+ const { source, elements, placement, dropped } = input;
78
+ if (placement.insertRow == null) return { source, track: placement.track };
79
+ const { insertRow, trackOrder } = placement;
80
+ const plan = planDropTrackInsert({ elements, trackOrder, insertRow, dropped });
81
+ if (!plan) throw new Error("Cannot open a new track here: a locked clip would have to move.");
82
+ return { source: applyTrackRenumbers(source, plan), track: plan.track };
83
+ }
@@ -78,9 +78,8 @@ describe("canSplitElementAt", () => {
78
78
  ).toBe(false);
79
79
  });
80
80
 
81
- it("rejects locked and implicit elements while allowing identified compositions", () => {
81
+ it("rejects locked elements while allowing identified compositions", () => {
82
82
  expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
83
- expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
84
83
  expect(
85
84
  canSplitElementAt(
86
85
  element({ kind: "composition", compositionSrc: "child.html", playbackRate: 2 }),
@@ -27,7 +27,6 @@ export function canSplitElement(el: TimelineElement): boolean {
27
27
  el.playbackRate == null || (Number.isFinite(el.playbackRate) && el.playbackRate > 0);
28
28
  return (
29
29
  !el.timelineLocked &&
30
- el.timingSource !== "implicit" &&
31
30
  hasStableIdentity &&
32
31
  hasValidRate &&
33
32
  !!el.duration &&
@@ -370,3 +370,43 @@ function sceneDoc(): Document {
370
370
  </main>`;
371
371
  return doc;
372
372
  }
373
+
374
+ describe("buildStudioLook clips", () => {
375
+ const clip = (overrides: Partial<TimelineElement>) =>
376
+ element({ tag: "audio", kind: "audio", domId: "sfx", start: 2, duration: 0.5, ...overrides });
377
+
378
+ it("returns timeline clips that are not scene layers, audio included", () => {
379
+ const result = buildStudioLook(
380
+ snapshot({
381
+ elements: [clip({ domId: "late", start: 9 }), clip({ domId: "early", volume: 0.85 })],
382
+ }),
383
+ );
384
+ if (!result.ok) throw new Error("expected ok");
385
+ expect(result.clipCount).toBe(2);
386
+ expect(result.clips.map((c) => c.id)).toEqual(["early", "late"]);
387
+ expect(result.clips[0]).toMatchObject({ kind: "audio", start: 2, volume: 0.85 });
388
+ expect(result.elements).toEqual([]);
389
+ });
390
+
391
+ it("filters clips by id, kind or src", () => {
392
+ const result = buildStudioLook(
393
+ snapshot({
394
+ elements: [
395
+ clip({ domId: "click", src: "click.mp3" }),
396
+ clip({ domId: "vo", src: "voice.mp3" }),
397
+ ],
398
+ }),
399
+ { filter: "voice" },
400
+ );
401
+ if (!result.ok) throw new Error("expected ok");
402
+ expect(result.clips.map((c) => c.id)).toEqual(["vo"]);
403
+ });
404
+
405
+ it("caps clips at the limit and reports the full count and truncation", () => {
406
+ const elements = Array.from({ length: 5 }, (_, i) => clip({ domId: `c${i}`, start: i }));
407
+ const result = buildStudioLook(snapshot({ elements }), { limit: 2 });
408
+ if (!result.ok) throw new Error("expected ok");
409
+ expect(result.clips.map((c) => c.id)).toEqual(["c0", "c1"]);
410
+ expect([result.clipCount, result.clipsTruncated]).toEqual([5, true]);
411
+ });
412
+ });
@@ -11,6 +11,7 @@
11
11
  import { collectDomEditLayerItems } from "../../components/editor/domEditingLayers";
12
12
  import type { DomEditLayerItem, DomEditSelection } from "../../components/editor/domEditingTypes";
13
13
  import type { TimelineElement } from "../../player/store/timelineElement";
14
+ import { byStart, describeClip, type ClipFact } from "../../player/lib/describeClips";
14
15
  import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
15
16
  import { toolOk, type ToolResult } from "../toolResult";
16
17
 
@@ -107,8 +108,15 @@ export interface StudioLook {
107
108
  elementCount: number;
108
109
  truncated: boolean;
109
110
  elements: LookElement[];
111
+ /** Every timeline clip, audio included, whether or not it is a visual layer. */
112
+ clipCount: number;
113
+ clipsTruncated: boolean;
114
+ clips: LookClip[];
110
115
  }
111
116
 
117
+ /** A timeline clip; `handle` is null when the clip has no addressable source element. */
118
+ type LookClip = ClipFact & { handle: string | null };
119
+
112
120
  export interface StudioLookInput {
113
121
  /** Case-insensitive substring match against label, tag, and handle. */
114
122
  filter?: string;
@@ -154,6 +162,22 @@ function describeSelection(
154
162
  };
155
163
  }
156
164
 
165
+ function matchesClipFilter(clip: LookClip, needle: string): boolean {
166
+ return [clip.id, clip.label, clip.kind, clip.src].some((field) =>
167
+ field?.toLowerCase().includes(needle),
168
+ );
169
+ }
170
+
171
+ function describeClipsWithHandles(snapshot: StudioLookSnapshot): LookClip[] {
172
+ const activeCompositionPath = snapshot.compositionPath ?? "index.html";
173
+ return snapshot.elements.map((element) => ({
174
+ ...describeClip(element),
175
+ handle: mintElementHandle(
176
+ timelineElementAddress(element, activeCompositionPath, snapshot.projectId),
177
+ ),
178
+ }));
179
+ }
180
+
157
181
  function matchesFilter(element: LookElement, needle: string): boolean {
158
182
  return (
159
183
  element.label.toLowerCase().includes(needle) ||
@@ -258,6 +282,8 @@ export function buildStudioLook(
258
282
  const requested =
259
283
  Number.isInteger(input.limit) && input.limit! > 0 ? input.limit! : DEFAULT_LIMIT;
260
284
  const limit = Math.min(requested, DEFAULT_LIMIT);
285
+ const clips = describeClipsWithHandles(snapshot).sort(byStart);
286
+ const matchedClips = needle ? clips.filter((clip) => matchesClipFilter(clip, needle)) : clips;
261
287
 
262
288
  return toolOk<StudioLook>({
263
289
  projectId: snapshot.projectId,
@@ -281,6 +307,9 @@ export function buildStudioLook(
281
307
  elementCount: matched.length,
282
308
  truncated: matched.length > limit,
283
309
  elements: matched.slice(0, limit),
310
+ clipCount: matchedClips.length,
311
+ clipsTruncated: matchedClips.length > limit,
312
+ clips: matchedClips.slice(0, limit),
284
313
  });
285
314
  }
286
315
 
@@ -296,7 +325,7 @@ export const STUDIO_LOOK_INPUT_SCHEMA = {
296
325
  type: "integer",
297
326
  minimum: 1,
298
327
  maximum: DEFAULT_LIMIT,
299
- description: `Cap the returned elements (default and max ${DEFAULT_LIMIT}). elementCount always reports the full match count.`,
328
+ description: `Cap the returned elements and clips (default and max ${DEFAULT_LIMIT} each). elementCount and clipCount always report the full match count.`,
300
329
  },
301
330
  },
302
331
  additionalProperties: false,
@@ -307,6 +336,8 @@ export const STUDIO_LOOK_DESCRIPTION = [
307
336
  "the playhead and duration, what the human currently has selected (including what that",
308
337
  "element will and will not accept), and the live nested scene in DOM preorder.",
309
338
  "Each scene element includes source ownership, hierarchy, and optional timeline timing.",
339
+ "`clips` lists every timeline clip, audio included: kind, start, duration, track, volume,",
340
+ "volume/effect automation points, playback rate, audio group and role.",
310
341
  "Pass a handle back to any tool that edits an element.",
311
342
  "Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
312
343
  "`history.undoLabel` is worth checkpointing before a batch: if it later names something",