@dailies-studio/shot-editor 0.1.0 → 0.2.0

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 (432) hide show
  1. package/dist/.vite/manifest.json +382 -0
  2. package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
  3. package/dist/assets/AgentPanel-DJEphah0.css +1 -0
  4. package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
  5. package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
  6. package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
  7. package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
  8. package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
  9. package/dist/assets/CurvePane-BjFVabKX.css +1 -0
  10. package/dist/assets/CurvePane-R22WzllJ.js +1 -0
  11. package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
  12. package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
  13. package/dist/assets/FontPicker-v0IOIArM.css +1 -0
  14. package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
  15. package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
  16. package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
  17. package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
  18. package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
  19. package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
  20. package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
  21. package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
  22. package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
  23. package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
  24. package/dist/assets/SoundMode-BmcZru3i.js +3 -0
  25. package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
  26. package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
  27. package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
  28. package/dist/assets/board-B88EPuLO.css +1 -0
  29. package/dist/assets/board-CL-he7SL.js +3 -0
  30. package/dist/assets/components-0oOSPN0l.js +1 -0
  31. package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
  32. package/dist/assets/googleFonts-BNon6M7X.js +1 -0
  33. package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
  34. package/dist/assets/index-4F5NQmI8.css +1 -0
  35. package/dist/assets/index-BGHQGV5z.js +117 -0
  36. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  37. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  38. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  39. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  40. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  41. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  42. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  43. package/dist/assets/libraryModel-CpIwfFha.js +1 -0
  44. package/dist/assets/lucide-6iK64tTB.js +9319 -0
  45. package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
  46. package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
  47. package/dist/assets/webmcp-BsB9gxWC.js +2 -0
  48. package/dist/index.html +34 -3
  49. package/package.json +12 -9
  50. package/src/addFamilies.ts +66 -0
  51. package/src/api.ts +1631 -55
  52. package/src/app/App.tsx +977 -198
  53. package/src/app/lazyPanel.tsx +137 -0
  54. package/src/app/lazySurfaces.tsx +203 -0
  55. package/src/audio/index.tsx +41 -10
  56. package/src/audio/meter.ts +485 -0
  57. package/src/audio/meterMath.ts +273 -0
  58. package/src/audio/meterStore.ts +155 -0
  59. package/src/audio/offlineMix.ts +289 -0
  60. package/src/audio/preview.ts +928 -118
  61. package/src/bridge.ts +15 -5
  62. package/src/bridgeTypes.ts +1611 -36
  63. package/src/channels.ts +17 -32
  64. package/src/fonts/googleFonts.ts +4 -0
  65. package/src/hostApi.ts +174 -0
  66. package/src/icons.tsx +770 -0
  67. package/src/layerWords.ts +61 -0
  68. package/src/main.tsx +56 -4
  69. package/src/panels/ResizeHandle.tsx +33 -43
  70. package/src/panels/agent/AgentDots.tsx +36 -0
  71. package/src/panels/agent/AgentPanel.tsx +119 -43
  72. package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
  73. package/src/panels/agent/agent.css +113 -97
  74. package/src/panels/agent/agentGate.ts +35 -0
  75. package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
  76. package/src/panels/code/CodeStudio.tsx +373 -0
  77. package/src/panels/code/FileTree.tsx +265 -0
  78. package/src/panels/code/codeBufferGuard.ts +52 -0
  79. package/src/panels/code/codeParts.tsx +321 -0
  80. package/src/panels/comments/CommentComposer.tsx +239 -0
  81. package/src/panels/comments/CommentThread.tsx +346 -0
  82. package/src/panels/comments/CommentsPanel.tsx +336 -0
  83. package/src/panels/comments/InboxButton.tsx +131 -0
  84. package/src/panels/comments/MentionMenu.tsx +98 -0
  85. package/src/panels/comments/parts.tsx +171 -0
  86. package/src/panels/export/ExportDialog.tsx +919 -96
  87. package/src/panels/export/ExportScrim.tsx +57 -0
  88. package/src/panels/files/AssetsTab.tsx +656 -0
  89. package/src/panels/files/FilePanel.tsx +1466 -216
  90. package/src/panels/files/LayerMarks.tsx +40 -0
  91. package/src/panels/files/LeftColumn.tsx +37 -0
  92. package/src/panels/files/McpSection.tsx +690 -47
  93. package/src/panels/files/MissingAssetsNotice.tsx +60 -0
  94. package/src/panels/files/assetDrag.ts +79 -0
  95. package/src/panels/files/resourceParts.tsx +255 -0
  96. package/src/panels/fonts/FontPicker.tsx +762 -0
  97. package/src/panels/fonts/faceCache.ts +207 -0
  98. package/src/panels/fonts/fontPicker.css +271 -0
  99. package/src/panels/fonts/pickerModel.ts +265 -0
  100. package/src/panels/fonts/pickerRequest.ts +46 -0
  101. package/src/panels/header/AccountAvatar.tsx +76 -0
  102. package/src/panels/header/AgentChip.tsx +77 -0
  103. package/src/panels/header/AppMenu.tsx +213 -0
  104. package/src/panels/header/Header.tsx +564 -84
  105. package/src/panels/header/HistoryControls.tsx +31 -5
  106. package/src/panels/header/PresenceStack.tsx +329 -0
  107. package/src/panels/header/ProjectTitle.tsx +232 -0
  108. package/src/panels/header/ShareButton.tsx +59 -0
  109. package/src/panels/header/StatusBadges.tsx +358 -27
  110. package/src/panels/header/SyncDeletedDialog.tsx +88 -0
  111. package/src/panels/header/ZoomControls.tsx +46 -15
  112. package/src/panels/header/agentHandoff.ts +52 -0
  113. package/src/panels/header/leave.ts +26 -0
  114. package/src/panels/help/HelpSection.tsx +189 -163
  115. package/src/panels/insert/InsertDrawer.tsx +723 -0
  116. package/src/panels/insert/iconSet.ts +109 -0
  117. package/src/panels/inspector/ExposeDot.tsx +117 -0
  118. package/src/panels/inspector/FramingRows.tsx +314 -44
  119. package/src/panels/inspector/InspectorFooter.tsx +91 -0
  120. package/src/panels/inspector/InspectorHead.tsx +425 -0
  121. package/src/panels/inspector/InspectorPanel.tsx +408 -124
  122. package/src/panels/inspector/KeyframeDot.tsx +373 -0
  123. package/src/panels/inspector/KeyframeSection.tsx +257 -0
  124. package/src/panels/inspector/MarkerSection.tsx +55 -11
  125. package/src/panels/inspector/ParamRow.tsx +84 -53
  126. package/src/panels/inspector/RightColumn.tsx +152 -0
  127. package/src/panels/inspector/TimeSection.tsx +866 -0
  128. package/src/panels/inspector/animate/EaseControl.tsx +244 -0
  129. package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
  130. package/src/panels/inspector/animate/ShotSection.tsx +562 -48
  131. package/src/panels/inspector/design/AlignRow.tsx +95 -31
  132. package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
  133. package/src/panels/inspector/design/ClonerSection.tsx +323 -62
  134. package/src/panels/inspector/design/ComponentSection.tsx +50 -43
  135. package/src/panels/inspector/design/CornerSection.tsx +45 -14
  136. package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
  137. package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
  138. package/src/panels/inspector/design/DeviceSection.tsx +210 -0
  139. package/src/panels/inspector/design/FillSection.tsx +83 -51
  140. package/src/panels/inspector/design/FontField.tsx +196 -0
  141. package/src/panels/inspector/design/HtmlSection.tsx +23 -12
  142. package/src/panels/inspector/design/InstanceSection.tsx +373 -154
  143. package/src/panels/inspector/design/MaskSection.tsx +84 -0
  144. package/src/panels/inspector/design/MaterialSection.tsx +177 -0
  145. package/src/panels/inspector/design/MediaSection.tsx +273 -120
  146. package/src/panels/inspector/design/MeshSection.tsx +910 -82
  147. package/src/panels/inspector/design/PaintRows.tsx +550 -0
  148. package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
  149. package/src/panels/inspector/design/ShadowSection.tsx +221 -48
  150. package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
  151. package/src/panels/inspector/design/StrokeSection.tsx +228 -43
  152. package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
  153. package/src/panels/inspector/design/TransformSection.tsx +244 -50
  154. package/src/panels/inspector/design/TypographySection.tsx +679 -110
  155. package/src/panels/inspector/layout.ts +295 -0
  156. package/src/panels/inspector/propValue.ts +199 -0
  157. package/src/panels/inspector/scene/CameraSection.tsx +232 -22
  158. package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
  159. package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
  160. package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
  161. package/src/panels/inspector/scene/SceneSection.tsx +219 -44
  162. package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
  163. package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
  164. package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
  165. package/src/panels/inspector/timeModel.ts +190 -0
  166. package/src/panels/inspector/useDocWrite.ts +438 -146
  167. package/src/panels/library/LibraryBrowser.tsx +435 -0
  168. package/src/panels/library/LibraryGrid.tsx +190 -0
  169. package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
  170. package/src/panels/library/LibraryRail.tsx +84 -0
  171. package/src/panels/library/LibrarySoundPanel.tsx +293 -0
  172. package/src/panels/library/SoundHistory.tsx +245 -0
  173. package/src/panels/library/StockSounds.tsx +390 -0
  174. package/src/panels/library/libraryModel.ts +147 -0
  175. package/src/panels/library/stockSearch.ts +77 -0
  176. package/src/panels/maskMarks.tsx +117 -0
  177. package/src/panels/material/MaterialCommentNode.tsx +54 -0
  178. package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
  179. package/src/panels/material/MaterialGraphMenu.tsx +45 -0
  180. package/src/panels/material/MaterialGroupNode.tsx +56 -0
  181. package/src/panels/material/MaterialLibrary.tsx +253 -0
  182. package/src/panels/material/MaterialNode.tsx +453 -0
  183. package/src/panels/material/MaterialNodePalette.tsx +349 -0
  184. package/src/panels/material/MaterialParameterControls.tsx +525 -0
  185. package/src/panels/material/MaterialRerouteNode.tsx +42 -0
  186. package/src/panels/material/MaterialWorkspace.tsx +501 -0
  187. package/src/panels/material/NodePreview.tsx +70 -0
  188. package/src/panels/material/flowModel.ts +441 -0
  189. package/src/panels/material/parameterValues.ts +231 -0
  190. package/src/panels/material/previewScheduler.ts +235 -0
  191. package/src/panels/material/reactFlow.css +6 -0
  192. package/src/panels/material/socketGlyph.ts +85 -0
  193. package/src/panels/material/viewportMemory.ts +58 -0
  194. package/src/panels/preferences/PreferencesDialog.tsx +215 -0
  195. package/src/panels/share/ShareComponentDialog.tsx +247 -0
  196. package/src/panels/stage/StageMenu.tsx +35 -5
  197. package/src/panels/stage/StagePanel.tsx +338 -23
  198. package/src/panels/stage/cellSlots.tsx +70 -0
  199. package/src/panels/storyboard/BoardCanvas.tsx +739 -0
  200. package/src/panels/storyboard/BoardCursors.tsx +158 -0
  201. package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
  202. package/src/panels/storyboard/BoardPlan.tsx +42 -0
  203. package/src/panels/storyboard/ComponentSheet.tsx +187 -0
  204. package/src/panels/storyboard/ComponentStage.tsx +235 -0
  205. package/src/panels/storyboard/MiniSketch.tsx +173 -0
  206. package/src/panels/storyboard/PartPicker.tsx +64 -0
  207. package/src/panels/storyboard/PlanPanel.tsx +617 -0
  208. package/src/panels/storyboard/ReferencePreview.tsx +217 -0
  209. package/src/panels/storyboard/ShotBrief.tsx +360 -0
  210. package/src/panels/storyboard/ShotCard.tsx +251 -0
  211. package/src/panels/storyboard/StagePreview.tsx +348 -0
  212. package/src/panels/storyboard/StillThumbs.tsx +162 -0
  213. package/src/panels/storyboard/StoryboardView.tsx +79 -0
  214. package/src/panels/storyboard/board.css +1065 -0
  215. package/src/panels/storyboard/board.ts +12 -0
  216. package/src/panels/storyboard/boardClipboard.ts +111 -0
  217. package/src/panels/storyboard/boardOrder.ts +68 -0
  218. package/src/panels/storyboard/boardRows.tsx +425 -0
  219. package/src/panels/storyboard/liveCap.ts +90 -0
  220. package/src/panels/storyboard/plan.css +295 -0
  221. package/src/panels/storyboard/posterCache.ts +133 -0
  222. package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
  223. package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
  224. package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
  225. package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
  226. package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
  227. package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
  228. package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
  229. package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
  230. package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
  231. package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
  232. package/src/panels/storyboard/shotboard/sketch.css +1392 -0
  233. package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
  234. package/src/panels/storyboard/storyboardDrop.ts +164 -0
  235. package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
  236. package/src/panels/studio/ComponentSidePanel.tsx +355 -0
  237. package/src/panels/studio/ComponentStudio.tsx +243 -0
  238. package/src/panels/studio/ComponentsHome.tsx +318 -0
  239. package/src/panels/studio/ComponentsPreview.tsx +179 -0
  240. package/src/panels/studio/EveryUse.tsx +244 -0
  241. package/src/panels/studio/ThisUsePanel.tsx +679 -0
  242. package/src/panels/studio/componentParts.tsx +341 -0
  243. package/src/panels/studio/components.ts +6 -0
  244. package/src/panels/studio/newCodeComponent.ts +66 -0
  245. package/src/panels/timeline/AudioFxButton.tsx +290 -0
  246. package/src/panels/timeline/AudioLane.tsx +674 -85
  247. package/src/panels/timeline/BlockRow.tsx +154 -85
  248. package/src/panels/timeline/ChannelRow.tsx +378 -129
  249. package/src/panels/timeline/CurvePane.tsx +508 -0
  250. package/src/panels/timeline/Filmstrip.tsx +361 -0
  251. package/src/panels/timeline/GenerateDrawer.tsx +407 -0
  252. package/src/panels/timeline/KeyStretch.tsx +115 -0
  253. package/src/panels/timeline/LifespanRow.tsx +730 -47
  254. package/src/panels/timeline/MarkerLane.tsx +46 -8
  255. package/src/panels/timeline/PlayButton.tsx +91 -0
  256. package/src/panels/timeline/PropertyPicker.tsx +254 -0
  257. package/src/panels/timeline/ShotRow.tsx +400 -0
  258. package/src/panels/timeline/SoundMode.tsx +1406 -0
  259. package/src/panels/timeline/TimelineDock.tsx +2338 -272
  260. package/src/panels/timeline/TransportVolume.tsx +163 -0
  261. package/src/panels/timeline/beatRuler.ts +74 -0
  262. package/src/panels/timeline/curveModel.ts +500 -0
  263. package/src/panels/timeline/duckUnderVoice.ts +68 -0
  264. package/src/panels/timeline/keyframeMenu.ts +138 -0
  265. package/src/panels/timeline/labelWidth.ts +49 -0
  266. package/src/panels/timeline/marquee.ts +155 -0
  267. package/src/panels/timeline/model.ts +1860 -199
  268. package/src/panels/timeline/snap.ts +125 -43
  269. package/src/panels/timeline/staggerPreview.tsx +92 -0
  270. package/src/panels/timeline/takeMeasure.ts +72 -0
  271. package/src/panels/timeline/useLive.ts +48 -0
  272. package/src/panels/timeline/viewport.ts +234 -0
  273. package/src/panels/toolbar/BackChip.tsx +116 -0
  274. package/src/panels/toolbar/CameraViewBar.tsx +118 -49
  275. package/src/panels/toolbar/CanvasBar.tsx +117 -0
  276. package/src/panels/toolbar/InsertButton.tsx +38 -0
  277. package/src/panels/toolbar/IsolationTrail.tsx +156 -0
  278. package/src/panels/toolbar/PanelToggles.tsx +86 -0
  279. package/src/panels/toolbar/ShadingBar.tsx +119 -26
  280. package/src/panels/toolbar/ToolDockBar.tsx +329 -80
  281. package/src/panels/toolbar/ToolFooter.tsx +102 -0
  282. package/src/panels/toolbar/ViewModeBar.tsx +247 -38
  283. package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
  284. package/src/panels/toolbar/useAddRows.tsx +326 -0
  285. package/src/panels/toolbar/useDismiss.ts +122 -0
  286. package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
  287. package/src/panels/versions/versionModel.ts +103 -0
  288. package/src/panels/virtualWindow.ts +271 -0
  289. package/src/shotTokens.css +151 -60
  290. package/src/stage/FrustumGizmo.tsx +504 -84
  291. package/src/stage/GridPlane.tsx +225 -16
  292. package/src/stage/NodePreviews.tsx +628 -0
  293. package/src/stage/PerfHud.tsx +437 -47
  294. package/src/stage/SplitRenderer.tsx +816 -169
  295. package/src/stage/StageApp.tsx +4274 -486
  296. package/src/stage/StageRootBoundary.tsx +114 -0
  297. package/src/stage/StageTools.tsx +651 -151
  298. package/src/stage/TransformGizmo.tsx +696 -0
  299. package/src/stage/adaptivePresentation.ts +145 -0
  300. package/src/stage/bridgeClient.ts +2522 -41
  301. package/src/stage/exportWorker.ts +9 -0
  302. package/src/stage/feed.ts +305 -14
  303. package/src/stage/fontPreview.ts +176 -0
  304. package/src/stage/frustumGeometry.ts +319 -1
  305. package/src/stage/furnitureTokens.ts +33 -0
  306. package/src/stage/gizmoMath.ts +508 -16
  307. package/src/stage/gpuTimer.ts +276 -0
  308. package/src/stage/instanceModules.ts +194 -33
  309. package/src/stage/interactions/move.ts +24 -5
  310. package/src/stage/isolation.ts +291 -33
  311. package/src/stage/jsSelfProfiler.ts +325 -0
  312. package/src/stage/main.tsx +116 -16
  313. package/src/stage/modalState.ts +222 -23
  314. package/src/stage/moduleEpoch.ts +60 -0
  315. package/src/stage/motionPathStage.ts +279 -0
  316. package/src/stage/motionPreview.ts +74 -0
  317. package/src/stage/overlays/Overlays.tsx +1112 -159
  318. package/src/stage/overlays/Rulers.tsx +495 -0
  319. package/src/stage/panes.ts +159 -10
  320. package/src/stage/project.ts +68 -3
  321. package/src/stage/renderScaleController.ts +730 -0
  322. package/src/stage/rulers.ts +129 -0
  323. package/src/stage/sceneExtent.ts +75 -0
  324. package/src/stage/shading.ts +20 -0
  325. package/src/stage/splineStage.ts +632 -0
  326. package/src/stage/stagePerformanceTelemetry.ts +717 -0
  327. package/src/stage/stageTheme.ts +36 -0
  328. package/src/stage/stageWarmup.ts +11 -0
  329. package/src/stage/textAutoSize.ts +124 -0
  330. package/src/stage/tier1Export.ts +641 -0
  331. package/src/stage/tokenColor.ts +14 -9
  332. package/src/stage/view.ts +56 -9
  333. package/src/stage/workingView.ts +277 -33
  334. package/src/state/accountPrefs.ts +261 -0
  335. package/src/state/agentActions.ts +173 -15
  336. package/src/state/agentActivity.ts +198 -0
  337. package/src/state/agentQuestion.ts +239 -0
  338. package/src/state/bootOpen.ts +42 -0
  339. package/src/state/cameraVerbs.ts +139 -0
  340. package/src/state/clientId.ts +6 -1
  341. package/src/state/comments.ts +1099 -0
  342. package/src/state/context.tsx +19 -1
  343. package/src/state/editorPrefs.ts +220 -0
  344. package/src/state/errorReports.ts +121 -0
  345. package/src/state/fieldWrite.ts +161 -0
  346. package/src/state/fontRecents.ts +70 -0
  347. package/src/state/fonts.ts +656 -0
  348. package/src/state/graphClipboard.ts +414 -0
  349. package/src/state/imageThumbs.ts +216 -0
  350. package/src/state/insert.ts +444 -67
  351. package/src/state/isolationTrail.ts +92 -0
  352. package/src/state/keyMoves.ts +134 -0
  353. package/src/state/keyframeClipboard.ts +177 -0
  354. package/src/state/keymap.ts +1153 -117
  355. package/src/state/layoutPrefs.ts +272 -0
  356. package/src/state/layouts.ts +205 -0
  357. package/src/state/measureMedia.ts +167 -0
  358. package/src/state/menu.ts +2341 -28
  359. package/src/state/motionStamp.ts +7 -0
  360. package/src/state/panelSize.ts +375 -5
  361. package/src/state/pendingChanges.ts +119 -0
  362. package/src/state/playHold.ts +78 -0
  363. package/src/state/playbackPreparation.ts +562 -0
  364. package/src/state/posterCapture.ts +242 -0
  365. package/src/state/presence.ts +254 -0
  366. package/src/state/projectComponents.ts +160 -0
  367. package/src/state/renderEvents.ts +298 -18
  368. package/src/state/runtimeErrors.ts +46 -0
  369. package/src/state/selection.ts +12 -0
  370. package/src/state/shortcuts.ts +1606 -0
  371. package/src/state/sketchGuide.ts +51 -0
  372. package/src/state/splineRefs.ts +87 -0
  373. package/src/state/stageHealth.ts +259 -0
  374. package/src/state/stageWatchdog.ts +47 -0
  375. package/src/state/stillStage.ts +330 -0
  376. package/src/state/stillThumbs.ts +474 -0
  377. package/src/state/store.ts +23591 -2982
  378. package/src/state/storyboardModel.ts +528 -0
  379. package/src/state/sync.ts +1257 -0
  380. package/src/state/syncCache.ts +159 -0
  381. package/src/state/triangles.ts +13 -0
  382. package/src/state/types.ts +1698 -55
  383. package/src/state/unsavedGuard.ts +49 -0
  384. package/src/state/viewMode.ts +200 -0
  385. package/src/state/webmcp.ts +1128 -0
  386. package/src/state/workspace.ts +1147 -0
  387. package/src/styles/assets-tab.css +304 -0
  388. package/src/styles/canvas.css +545 -0
  389. package/src/styles/comments.css +1435 -0
  390. package/src/styles/controls.css +162 -0
  391. package/src/styles/dialogs.css +390 -0
  392. package/src/styles/insert-drawer.css +586 -0
  393. package/src/styles/inspector-board.css +207 -0
  394. package/src/styles/inspector-object.css +486 -0
  395. package/src/styles/inspector-shot.css +252 -0
  396. package/src/styles/inspector.css +360 -0
  397. package/src/styles/left-column.css +1628 -0
  398. package/src/styles/library-preview.css +575 -0
  399. package/src/styles/library.css +512 -0
  400. package/src/styles/modes/code.css +569 -0
  401. package/src/styles/modes/component.css +1561 -0
  402. package/src/styles/modes/material.css +1201 -0
  403. package/src/styles/modes/storyboard.css +518 -0
  404. package/src/styles/share-component.css +49 -0
  405. package/src/styles/shell.css +653 -0
  406. package/src/styles/shot-brief.css +360 -0
  407. package/src/styles/states.css +76 -0
  408. package/src/styles/sync.css +380 -0
  409. package/src/styles/timeline-graph.css +384 -0
  410. package/src/styles/timeline-meters.css +89 -0
  411. package/src/styles/timeline-rows.css +1959 -0
  412. package/src/styles/timeline-sound.css +1003 -0
  413. package/src/styles/timeline.css +754 -0
  414. package/src/styles/tool-footer.css +525 -0
  415. package/src/styles/topbar.css +886 -0
  416. package/src/styles/versions.css +262 -0
  417. package/src/styles.css +72 -2921
  418. package/src/tools.ts +108 -23
  419. package/dist/assets/index-B7notge7.css +0 -1
  420. package/dist/assets/index-BBE6Ta9L.js +0 -52
  421. package/src/panels/files/icons.tsx +0 -118
  422. package/src/panels/header/DocSwitcher.tsx +0 -85
  423. package/src/panels/header/TransportBar.tsx +0 -86
  424. package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
  425. package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
  426. package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
  427. package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
  428. package/src/panels/inspector/animate/useBlocks.ts +0 -43
  429. package/src/panels/material/MaterialPicker.tsx +0 -172
  430. package/src/panels/timeline/SceneStrip.tsx +0 -145
  431. package/src/panels/toolbar/AddMenu.tsx +0 -230
  432. package/src/stage/MoveGizmo.tsx +0 -411
@@ -0,0 +1,1406 @@
1
+ // SOUND MODE: the dock as a small DAW (gap 8).
2
+ //
3
+ // ── ONE ROW PER TRACK, AND THE TRACK IS THE MIXER ───────────────────────
4
+ //
5
+ // Rows mode draws audio by HOME: the bed at the bottom, a shot's own clips
6
+ // inside its group. Sound mode draws it by TRACK (`doc.audioTracks`, schema
7
+ // v39), one 72px row each, because the question here is "what plays with
8
+ // what", and a track is the answer the mixer gives. Both homes still exist and
9
+ // both draw on their track's row: a shot's clip wears a "Shot 02" badge and is
10
+ // placed from its shot's start, exactly as `SceneAudioRow` places it.
11
+ //
12
+ // A recording's `video.sound` is drawn on its track too, READ-ONLY: its
13
+ // fields live on the layer, and Rows mode's video row is where it is trimmed.
14
+ // It can still change track, because a track is all Sound mode owns.
15
+ //
16
+ // ── WHAT IS SAVED AND WHAT IS NOT ──────────────────────────────────────
17
+ //
18
+ // Name, level, pan and mute are the document's (`AudioTrack3D`), so an export
19
+ // hears them. SOLO is this tab's (`soloTracks`): a listening aid, never a mix
20
+ // decision, and a solo saved by accident would silence a render. The master
21
+ // strip is a meter and nothing else: the document has no master gain, and the
22
+ // preview volume (this tab's, like solo) is the transport's speaker, so the
23
+ // mixer never shows a second level an export ignores.
24
+ //
25
+ // ── ONE HOME FOR EACH CONTROL ──────────────────────────────────────────
26
+ //
27
+ // Level and pan live on the mixer's strips only. The track row's strip keeps
28
+ // the name and M/S, the two things an author reaches for while looking at the
29
+ // waveforms. Two faders for one gain were two places to wonder which one won.
30
+ //
31
+ // ── EVERY STRIP DRAG IS ONE UNDO ───────────────────────────────────────
32
+ //
33
+ // Fader and pan drags go through the dock's one history scrub
34
+ // (`beginBlockDrag` / `dragAudioTrack` / `endBlockDrag`), like a clip drag:
35
+ // one undo entry per gesture, and Escape puts the base back by reference.
36
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
37
+ import type {
38
+ KeyboardEvent as ReactKeyboardEvent,
39
+ MouseEvent as ReactMouseEvent,
40
+ PointerEvent as ReactPointerEvent,
41
+ ReactNode,
42
+ } from "react";
43
+ import { frameToPx, startDrag } from "@dailies-studio/timeline";
44
+ import { Popover, Toggle, useConfirm } from "@dailies-studio/ui";
45
+ import type { MenuEntry, UseContextMenuResult } from "@dailies-studio/ui";
46
+ import {
47
+ MAX_AUDIO_GAIN,
48
+ TEMPO_BPM_MAX,
49
+ TEMPO_BPM_MIN,
50
+ addAudioTrack,
51
+ audioAnalysisFileName,
52
+ audioTrackOf,
53
+ audioTracksOf,
54
+ authoredAudioClips,
55
+ sceneWindows,
56
+ setAudioClipTrack,
57
+ } from "@dailies-studio/shot/doc";
58
+ import type { AudioClipRef, AudioTrack3D, Doc3D, Tempo3D } from "@dailies-studio/shot/doc";
59
+ import { fetchAssetBytes, generationStatus } from "../../api.js";
60
+ import { meterChannelKey, useMeterReading, useMeterTap } from "../../audio/meter.js";
61
+ import { METER_FLOOR_DB } from "../../audio/meterMath.js";
62
+ import { MASTER_METER_KEY, readMonitor, subscribeMonitor } from "../../audio/preview.js";
63
+ import { ADD_ICON, CLOSE_ICON, LIBRARY_ICON, LOCK_ICON, METRONOME_ICON, SPARKLES_ICON } from "../../icons.js";
64
+ import { useShotEditor, useShotEditorHot, useStore } from "../../state/context.js";
65
+ import { gateForViewer } from "../../state/menu.js";
66
+ import { formatCombo, shortcutFor } from "../../state/shortcuts.js";
67
+ import { shotNumberOf } from "../../state/storyboardModel.js";
68
+ import { AUDIO_FILE_ACCEPT, AudioClipBar, AudioWaveform, clipLabel, importEach } from "./AudioLane.js";
69
+ import { voiceDuckState } from "./duckUnderVoice.js";
70
+ import type { BeatTick } from "./beatRuler.js";
71
+ import type { SnapPress } from "./snap.js";
72
+
73
+ type Store = ReturnType<typeof useStore>;
74
+ type OpenMenu = UseContextMenuResult["open"];
75
+
76
+ /** Mirrors of timeline-sound.css. */
77
+ export const SOUND_ROW_H = 72;
78
+ export const SOUND_SHOTS_ROW_H = 24;
79
+ /** The clip bar's inner height in a track row: the row less its 1px seam, the
80
+ * bar's 8px insets and its 1px border. */
81
+ const SOUND_WAVE_H = SOUND_ROW_H - 1 - 2 * 8 - 2;
82
+ /** A press that travels less than this is a click, not a rubber band. */
83
+ const MARQUEE_MIN_PX = 3;
84
+
85
+ const MINUS = "−";
86
+
87
+ // ── THE FADER LAW ────────────────────────────────────────────────────────
88
+ //
89
+ // Unity sits at three quarters of the travel, as on a console, so the useful
90
+ // range (cuts of a few dB) gets most of the throw and +6 dB gets the last
91
+ // quarter. `pos = (gain / 2) ^ log2(4/3)` puts gain 1 at exactly 0.75.
92
+ const FADER_EXP = Math.log2(4 / 3);
93
+ /** Within this much travel of unity, a drag lands ON unity. */
94
+ const UNITY_DETENT = 0.012;
95
+
96
+ /** A track gain (0..2) as fader travel (0..1). */
97
+ export function faderPos(gain: number): number {
98
+ if (!(gain > 0)) return 0;
99
+ return Math.min(1, (gain / MAX_AUDIO_GAIN) ** FADER_EXP);
100
+ }
101
+
102
+ /** Fader travel (0..1) as a gain (0..2), with the unity detent. Rounded to
103
+ * three places so a drag writes numbers a person could have typed. */
104
+ export function faderGain(pos: number): number {
105
+ const p = Math.min(1, Math.max(0, pos));
106
+ if (p <= 0) return 0;
107
+ if (Math.abs(p - faderPos(1)) < UNITY_DETENT) return 1;
108
+ return Math.round(MAX_AUDIO_GAIN * p ** (1 / FADER_EXP) * 1000) / 1000;
109
+ }
110
+
111
+ /** A gain as dB, one decimal: "0.0 dB", "−3.0 dB", "+1.5 dB", "−∞ dB". */
112
+ export function dbLabel(gain: number, unit = true): string {
113
+ const tail = unit ? " dB" : "";
114
+ if (!(gain > 0)) return `${MINUS}∞${tail}`;
115
+ let text = (20 * Math.log10(gain)).toFixed(1);
116
+ if (text === "-0.0") text = "0.0";
117
+ if (text.startsWith("-")) text = `${MINUS}${text.slice(1)}`;
118
+ else if (text !== "0.0") text = `+${text}`;
119
+ return `${text}${tail}`;
120
+ }
121
+
122
+ /** The BPM chip's text: "120 BPM · 4/4". */
123
+ export function tempoLabel(tempo: Tempo3D): string {
124
+ const bpm = Math.round(tempo.bpm * 100) / 100;
125
+ return `${bpm} BPM · ${Math.max(1, Math.trunc(tempo.beatsPerBar ?? 4))}/4`;
126
+ }
127
+
128
+ // ── THE ROWS ─────────────────────────────────────────────────────────────
129
+
130
+ /** One clip on a row, in either home, or a recording's read-only sound. */
131
+ interface RowClip {
132
+ clip: AudioClipRef;
133
+ /** Master frame the clip's own frame base starts at. */
134
+ offsetFrames: number;
135
+ sceneId: string | null;
136
+ /** Set for a recording's sound (read-only). */
137
+ layerId?: string;
138
+ masterStart: number;
139
+ masterEnd: number;
140
+ }
141
+
142
+ /** Every clip by track id, in both homes plus the recordings' sounds. */
143
+ function clipsByTrack(doc: Doc3D): Map<string, RowClip[]> {
144
+ const out = new Map<string, RowClip[]>();
145
+ const push = (trackId: string, row: RowClip): void => {
146
+ const list = out.get(trackId);
147
+ if (list === undefined) out.set(trackId, [row]);
148
+ else list.push(row);
149
+ };
150
+ const span = (clip: AudioClipRef, offset: number) => {
151
+ const start = offset + Math.trunc(clip.frame);
152
+ return { masterStart: start, masterEnd: start + Math.max(1, Math.trunc(clip.durationFrames)) };
153
+ };
154
+ for (const clip of Object.values(doc.audio)) {
155
+ push(audioTrackOf(doc, clip.track).id, { clip, offsetFrames: 0, sceneId: null, ...span(clip, 0) });
156
+ }
157
+ for (const w of sceneWindows(doc)) {
158
+ for (const clip of Object.values(doc.scenesById[w.id]?.audio ?? {})) {
159
+ push(audioTrackOf(doc, clip.track).id, { clip, offsetFrames: w.start, sceneId: w.id, ...span(clip, w.start) });
160
+ }
161
+ }
162
+ // The recordings' sounds: the flatten is the one reader of `video.sound`,
163
+ // already on master frames and cut to the shot.
164
+ for (const m of authoredAudioClips(doc)) {
165
+ if (m.layerId === undefined) continue;
166
+ push(m.trackId, {
167
+ clip: m.clip,
168
+ offsetFrames: 0,
169
+ sceneId: m.sceneId,
170
+ layerId: m.layerId,
171
+ ...span(m.clip, 0),
172
+ });
173
+ }
174
+ return out;
175
+ }
176
+
177
+ export function SoundLanes(props: {
178
+ ppf: number;
179
+ labelWidth: number;
180
+ timeW: number;
181
+ /** The shot context's re-zero, 0 over the Project. */
182
+ origin: number;
183
+ /** The shot context's window, absent over the Project. */
184
+ window?: { origin: number; len: number } | undefined;
185
+ beginSnap: SnapPress;
186
+ /** The beat grid (`beatRulerTicks`), or empty with no tempo. */
187
+ grid: readonly BeatTick[];
188
+ /** The snap guide and the playhead, drawn through every row. */
189
+ overlay: ReactNode;
190
+ openMenu: OpenMenu;
191
+ onGenerate: () => void;
192
+ }) {
193
+ const { ppf, labelWidth, timeW, origin, window: shotWindow, beginSnap, grid, overlay, openMenu, onGenerate } = props;
194
+ const store = useStore();
195
+ const doc = useShotEditor((s) => s.doc);
196
+ const editingDef = useShotEditor((s) => s.editingDef);
197
+ const solo = useShotEditor((s) => s.soloTracks);
198
+ const notice = useShotEditor((s) => s.dockNotice);
199
+ const reveal = useShotEditor((s) => s.dockReveal);
200
+ // Hot: the cold lane sleeps through a cut the transport plays (store
201
+ // `set`), and the Shot pills mark what is playing. A string, so this
202
+ // renders at a cut and not per frame.
203
+ const currentShot = useShotEditorHot((s) => s.sceneId);
204
+ const rootRef = useRef<HTMLDivElement | null>(null);
205
+ const [marquee, setMarquee] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
206
+ const [flashed, setFlashed] = useState<string | null>(null);
207
+ const { confirm, dialog } = useConfirm();
208
+
209
+ const byTrack = useMemo(() => (doc === null ? new Map<string, RowClip[]>() : clipsByTrack(doc)), [doc]);
210
+
211
+ // THE REVEAL, for the clips an agent or the library just placed. The dock's
212
+ // own reveal effect consumes the request after this one runs (a child's
213
+ // effects run first), and only scrolls layer rows.
214
+ useEffect(() => {
215
+ const ids = reveal?.audioIds;
216
+ if (ids === undefined || ids.length === 0) return;
217
+ const root = rootRef.current;
218
+ const el = root?.querySelector<HTMLElement>(`[data-audio-clip="${CSS.escape(ids[0]!)}"]`);
219
+ if (el === null || el === undefined) return;
220
+ el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
221
+ if (reveal?.flash) setFlashed(el.closest<HTMLElement>("[data-sound-row]")?.dataset["soundRow"] ?? null);
222
+ }, [reveal]);
223
+
224
+ if (doc === null) return null;
225
+ if (editingDef !== null) {
226
+ return (
227
+ <div className="sound-lanes" style={{ minWidth: labelWidth + timeW }}>
228
+ <p className="sound-note">A component has no master time, so it has no sound. Leave it to mix the film.</p>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ const tracks = audioTracksOf(doc);
234
+ const inWindow = (r: RowClip): boolean =>
235
+ shotWindow === undefined ||
236
+ (r.masterEnd > shotWindow.origin && r.masterStart < shotWindow.origin + shotWindow.len);
237
+ const total = [...byTrack.values()].reduce((n, list) => n + list.length, 0);
238
+ const windows = sceneWindows(doc).filter(
239
+ (w) => shotWindow === undefined || (w.end > shotWindow.origin && w.start < shotWindow.origin + shotWindow.len),
240
+ );
241
+
242
+ /** The track row under a screen y, read live: rows can scroll mid-drag. */
243
+ const rowAt = (clientY: number): string | undefined => {
244
+ for (const row of rootRef.current?.querySelectorAll<HTMLElement>("[data-sound-row]") ?? []) {
245
+ const r = row.getBoundingClientRect();
246
+ if (clientY >= r.top && clientY < r.bottom) return row.dataset["soundRow"];
247
+ }
248
+ return undefined;
249
+ };
250
+
251
+ const clipMenu = (row: RowClip) => (e: ReactMouseEvent<HTMLElement>): void => {
252
+ e.preventDefault();
253
+ e.stopPropagation();
254
+ const s = store.getState();
255
+ if (!s.selectedAudio.includes(row.clip.id)) store.selectAudio([row.clip.id]);
256
+ openMenu({ x: e.clientX, y: e.clientY }, gateForViewer(store.getState(), clipMenuEntries(store, doc, row)));
257
+ };
258
+
259
+ const trackMenu = (track: AudioTrack3D, index: number, onRename: () => void) =>
260
+ (e: ReactMouseEvent<HTMLElement>): void => {
261
+ e.preventDefault();
262
+ e.stopPropagation();
263
+ const count = byTrack.get(track.id)?.filter((r) => r.layerId === undefined).length ?? 0;
264
+ const entries: MenuEntry[] = [
265
+ { kind: "action", id: "track-add-above", label: "Add track above", onSelect: () => store.addAudioTrackAt(index) },
266
+ { kind: "action", id: "track-add-below", label: "Add track below", onSelect: () => store.addAudioTrackAt(index + 1) },
267
+ { kind: "action", id: "track-rename", label: "Rename", onSelect: onRename },
268
+ {
269
+ kind: "action",
270
+ id: "track-up",
271
+ label: "Move up",
272
+ disabled: index === 0,
273
+ onSelect: () => store.reorderAudioTrack(track.id, index - 1),
274
+ },
275
+ {
276
+ kind: "action",
277
+ id: "track-down",
278
+ label: "Move down",
279
+ disabled: index === tracks.length - 1,
280
+ onSelect: () => store.reorderAudioTrack(track.id, index + 1),
281
+ },
282
+ { kind: "separator" },
283
+ {
284
+ kind: "action",
285
+ id: "track-delete",
286
+ label: "Delete track",
287
+ danger: true,
288
+ disabled: tracks.length <= 1,
289
+ title: tracks.length <= 1 ? "A film keeps at least one track." : undefined,
290
+ onSelect: () => {
291
+ if (count === 0) {
292
+ store.removeAudioTrack(track.id);
293
+ return;
294
+ }
295
+ void confirm({
296
+ title: `Delete ${track.name}?`,
297
+ message: `Its ${count} clip${count === 1 ? "" : "s"} go with it. Undo brings them back.`,
298
+ confirmLabel: "Delete track",
299
+ danger: true,
300
+ }).then((ok) => {
301
+ if (ok) store.removeAudioTrack(track.id);
302
+ });
303
+ },
304
+ },
305
+ ];
306
+ openMenu({ x: e.clientX, y: e.clientY }, gateForViewer(store.getState(), entries));
307
+ };
308
+
309
+ /**
310
+ * THE RUBBER BAND over clip bars. Membership is decided per move from rects
311
+ * frozen at the press; shift adds to the pick as it stood; Escape puts that
312
+ * pick back.
313
+ */
314
+ const beginMarquee = (e: ReactPointerEvent<HTMLDivElement>): void => {
315
+ if (e.button !== 0) return;
316
+ const target = e.target as HTMLElement;
317
+ if (!target.classList.contains("sound-lane")) return;
318
+ e.preventDefault();
319
+ const root = e.currentTarget;
320
+ const at = root.getBoundingClientRect();
321
+ const from = { x: e.clientX - at.left, y: e.clientY - at.top };
322
+ const bars = [...root.querySelectorAll<HTMLElement>("[data-audio-clip]:not([data-readonly])")].map((el) => {
323
+ const r = el.getBoundingClientRect();
324
+ return {
325
+ id: el.dataset["audioClip"]!,
326
+ left: r.left - at.left,
327
+ right: r.right - at.left,
328
+ top: r.top - at.top,
329
+ bottom: r.bottom - at.top,
330
+ };
331
+ });
332
+ const base = store.getState().selectedAudio;
333
+ const add = e.shiftKey ? base : [];
334
+ let painting = false;
335
+ let written: string | null = null;
336
+ startDrag(e, {
337
+ onMove(ev) {
338
+ const box = root.getBoundingClientRect();
339
+ const to = { x: ev.clientX - box.left, y: ev.clientY - box.top };
340
+ if (!painting && Math.abs(to.x - from.x) < MARQUEE_MIN_PX && Math.abs(to.y - from.y) < MARQUEE_MIN_PX) return;
341
+ painting = true;
342
+ const rect = {
343
+ left: Math.min(from.x, to.x),
344
+ right: Math.max(from.x, to.x),
345
+ top: Math.min(from.y, to.y),
346
+ bottom: Math.max(from.y, to.y),
347
+ };
348
+ setMarquee(rect);
349
+ const hits = [...add];
350
+ for (const b of bars) {
351
+ if (b.right > rect.left && b.left < rect.right && b.bottom > rect.top && b.top < rect.bottom) {
352
+ if (!hits.includes(b.id)) hits.push(b.id);
353
+ }
354
+ }
355
+ const key = hits.join("\n");
356
+ if (key === written) return;
357
+ written = key;
358
+ store.selectAudio(hits);
359
+ },
360
+ onEnd(commit) {
361
+ setMarquee(null);
362
+ if (!painting) {
363
+ // A plain click on empty lane drops the pick, as it does over rows.
364
+ if (commit && !e.shiftKey) store.selectAudio([]);
365
+ return;
366
+ }
367
+ if (!commit) store.selectAudio(base);
368
+ },
369
+ });
370
+ };
371
+
372
+ const split = shortcutFor("edit.split");
373
+
374
+ return (
375
+ <div
376
+ ref={rootRef}
377
+ className="sound-lanes"
378
+ data-testid="sound-lanes"
379
+ style={{ minWidth: labelWidth + timeW }}
380
+ onPointerDown={beginMarquee}
381
+ onAnimationEnd={(e) => {
382
+ if (e.animationName === "row-revealed") setFlashed(null);
383
+ }}
384
+ >
385
+ {dialog}
386
+ <div className="dock-overlay sound-overlay" style={{ left: labelWidth }} aria-hidden="true">
387
+ {grid.map((t) =>
388
+ t.tier === 2 ? (
389
+ <span key={t.frame} className="sound-beat" style={{ left: frameToPx(t.frame, ppf) }} />
390
+ ) : (
391
+ <span key={t.frame} className="sound-bar" style={{ left: frameToPx(t.frame, ppf) }} />
392
+ ),
393
+ )}
394
+ {overlay}
395
+ </div>
396
+
397
+ {notice === null ? null : (
398
+ <div className="sound-notice" role="status" data-testid="sound-notice" style={{ left: labelWidth + 8 }}>
399
+ {split === undefined ? null : <kbd className="sound-notice-key">{formatCombo(split)}</kbd>}
400
+ <span>{notice.text}</span>
401
+ <button
402
+ type="button"
403
+ className="btn is-icon is-ghost sound-notice-close"
404
+ aria-label="Dismiss"
405
+ onClick={() => store.dismissDockNotice()}
406
+ >
407
+ {CLOSE_ICON}
408
+ </button>
409
+ </div>
410
+ )}
411
+
412
+ <div className="sound-row sound-shots-row">
413
+ <div className="sound-strip-cell sound-shots-label" style={{ width: labelWidth }}>
414
+ Shots
415
+ </div>
416
+ <div className="sound-shots">
417
+ {windows.map((w) => (
418
+ <button
419
+ key={w.id}
420
+ type="button"
421
+ className={`sound-shot-pill${w.id === currentShot ? " is-current" : ""}`}
422
+ aria-current={w.id === currentShot ? "true" : undefined}
423
+ style={{ left: frameToPx(w.start - origin, ppf), width: Math.max(4, frameToPx(w.end - w.start, ppf) - 2) }}
424
+ title={doc.scenesById[w.id]?.name ?? ""}
425
+ onPointerDown={(e) => e.stopPropagation()}
426
+ onClick={() => {
427
+ store.pauseForScrub();
428
+ store.seek(w.start);
429
+ }}
430
+ >
431
+ Shot {shotNumberOf(doc, w.id) ?? String(doc.scenes.indexOf(w.id) + 1).padStart(2, "0")}
432
+ </button>
433
+ ))}
434
+ </div>
435
+ </div>
436
+
437
+ {tracks.map((track, index) => {
438
+ const rows = (byTrack.get(track.id) ?? []).filter(inWindow);
439
+ const dimmed = solo.length > 0 && !solo.includes(track.id);
440
+ return (
441
+ <TrackRow
442
+ key={track.id}
443
+ track={track}
444
+ index={index}
445
+ rows={rows}
446
+ doc={doc}
447
+ ppf={ppf}
448
+ labelWidth={labelWidth}
449
+ origin={origin}
450
+ beginSnap={beginSnap}
451
+ rowAt={rowAt}
452
+ dimmed={dimmed}
453
+ soloed={solo.includes(track.id)}
454
+ flashed={flashed === track.id}
455
+ empty={index === 0 && total === 0}
456
+ onClipMenu={clipMenu}
457
+ onTrackMenu={trackMenu}
458
+ onGenerate={onGenerate}
459
+ />
460
+ );
461
+ })}
462
+
463
+ <div className="sound-row sound-add-row">
464
+ <div className="sound-strip-cell" style={{ width: labelWidth }}>
465
+ <button
466
+ type="button"
467
+ className="btn is-ghost sound-add-track"
468
+ data-testid="sound-add-track"
469
+ onClick={() => store.addAudioTrackAt()}
470
+ >
471
+ {ADD_ICON}
472
+ Add track
473
+ </button>
474
+ </div>
475
+ </div>
476
+
477
+ {marquee === null ? null : (
478
+ <span
479
+ className="dock-marquee"
480
+ aria-hidden="true"
481
+ style={{
482
+ left: marquee.left,
483
+ top: marquee.top,
484
+ width: marquee.right - marquee.left,
485
+ height: marquee.bottom - marquee.top,
486
+ }}
487
+ />
488
+ )}
489
+ </div>
490
+ );
491
+ }
492
+
493
+ /** The right-click menu on a clip bar. A recording's sound offers the track
494
+ * move alone: its other fields live on the layer. */
495
+ function clipMenuEntries(store: Store, doc: Doc3D, row: RowClip): MenuEntry[] {
496
+ const s = store.getState();
497
+ const picked = s.selectedAudio.includes(row.clip.id) ? s.selectedAudio : [row.clip.id];
498
+ const current = audioTrackOf(doc, row.clip.track).id;
499
+ const moveTo = (trackId: string): void => {
500
+ store.edit((base) => picked.reduce((d, id) => setAudioClipTrack(d, id, trackId), base));
501
+ };
502
+ const trackItems: MenuEntry[] = [
503
+ ...audioTracksOf(doc).map(
504
+ (t): MenuEntry => ({
505
+ kind: "action",
506
+ id: `clip-track-${t.id}`,
507
+ label: t.name,
508
+ checked: row.layerId === undefined ? t.id === current : t.id === audioTrackOf(doc, row.clip.track).id,
509
+ onSelect: () => moveTo(t.id),
510
+ }),
511
+ ),
512
+ { kind: "separator" },
513
+ {
514
+ kind: "action",
515
+ id: "clip-track-new",
516
+ label: "New track",
517
+ onSelect: () =>
518
+ store.edit((base) => {
519
+ const made = addAudioTrack(base, {});
520
+ return picked.reduce((d, id) => setAudioClipTrack(d, id, made.id), made.doc);
521
+ }),
522
+ },
523
+ ];
524
+ const move: MenuEntry = { kind: "submenu", label: "Move to track", items: trackItems };
525
+ if (row.layerId !== undefined) return [move];
526
+
527
+ const f = s.frame;
528
+ const inside = f > row.masterStart && f < row.masterEnd;
529
+ const shotNo = row.sceneId === null ? null : (shotNumberOf(doc, row.sceneId) ?? null);
530
+ return [
531
+ {
532
+ kind: "action",
533
+ id: "clip-split",
534
+ label: "Split at playhead",
535
+ shortcut: shortcutFor("edit.split"),
536
+ disabled: !inside,
537
+ title: inside ? undefined : "Move the playhead inside the clip to split it.",
538
+ onSelect: () => store.splitAtPlayhead(),
539
+ },
540
+ move,
541
+ {
542
+ kind: "action",
543
+ id: "clip-duplicate",
544
+ label: "Duplicate",
545
+ shortcut: shortcutFor("edit.duplicate"),
546
+ onSelect: () => store.duplicateSelection(),
547
+ },
548
+ {
549
+ kind: "action",
550
+ id: "clip-home",
551
+ label: row.sceneId === null ? "Attach to shot" : `Detach from Shot ${shotNo ?? ""}`.trim(),
552
+ title:
553
+ row.sceneId === null
554
+ ? "Attach to the shot it starts in: it moves, duplicates and ends with that shot."
555
+ : "Move to the bed: it spans the cuts and stops following this shot.",
556
+ onSelect: () => store.moveAudioHome(row.clip.id),
557
+ },
558
+ { kind: "separator" },
559
+ {
560
+ kind: "action",
561
+ id: "clip-delete",
562
+ label: "Delete",
563
+ shortcut: shortcutFor("edit.delete"),
564
+ danger: true,
565
+ onSelect: () => store.deleteSelection(),
566
+ },
567
+ ];
568
+ }
569
+
570
+ function TrackRow(props: {
571
+ track: AudioTrack3D;
572
+ index: number;
573
+ rows: readonly RowClip[];
574
+ doc: Doc3D;
575
+ ppf: number;
576
+ labelWidth: number;
577
+ origin: number;
578
+ beginSnap: SnapPress;
579
+ rowAt: (clientY: number) => string | undefined;
580
+ dimmed: boolean;
581
+ soloed: boolean;
582
+ flashed: boolean;
583
+ empty: boolean;
584
+ onClipMenu: (row: RowClip) => (e: ReactMouseEvent<HTMLElement>) => void;
585
+ onTrackMenu: (track: AudioTrack3D, index: number, onRename: () => void) => (e: ReactMouseEvent<HTMLElement>) => void;
586
+ onGenerate: () => void;
587
+ }) {
588
+ const { track, index, rows, doc, ppf, labelWidth, origin, beginSnap, rowAt } = props;
589
+ const store = useStore();
590
+ const [over, setOver] = useState(false);
591
+ const [importing, setImporting] = useState(false);
592
+ const [error, setError] = useState<string | null>(null);
593
+ const [renaming, setRenaming] = useState(false);
594
+ const fileRef = useRef<HTMLInputElement | null>(null);
595
+ const muted = track.muted === true;
596
+
597
+ return (
598
+ <div
599
+ className={`sound-row sound-track-row${muted ? " is-muted" : ""}${props.dimmed ? " is-unsoloed" : ""}${
600
+ over ? " is-over" : ""
601
+ }${props.flashed ? " is-revealed" : ""}`}
602
+ data-sound-row={track.id}
603
+ data-testid={`sound-row-${track.id}`}
604
+ onDragOver={(e) => {
605
+ if (!e.dataTransfer.types.includes("Files")) return;
606
+ e.preventDefault();
607
+ setOver(true);
608
+ }}
609
+ onDragLeave={() => setOver(false)}
610
+ onDrop={(e) => {
611
+ if (!e.dataTransfer.types.includes("Files")) return;
612
+ e.preventDefault();
613
+ setOver(false);
614
+ importEach(store, e.dataTransfer.files, undefined, setImporting, setError, track.id);
615
+ }}
616
+ >
617
+ <TrackStrip
618
+ track={track}
619
+ width={labelWidth}
620
+ soloed={props.soloed}
621
+ renaming={renaming}
622
+ setRenaming={setRenaming}
623
+ onContextMenu={props.onTrackMenu(track, index, () => setRenaming(true))}
624
+ />
625
+ <div className="sound-lane">
626
+ {props.empty && !importing ? (
627
+ <span className="sound-empty" data-testid="sound-empty">
628
+ <span>Drop audio here, or</span>
629
+ <button
630
+ type="button"
631
+ className="btn is-ghost sound-empty-btn"
632
+ onPointerDown={(e) => e.stopPropagation()}
633
+ onClick={props.onGenerate}
634
+ >
635
+ + Generate
636
+ </button>
637
+ <button
638
+ type="button"
639
+ className="btn is-ghost sound-empty-btn"
640
+ onPointerDown={(e) => e.stopPropagation()}
641
+ onClick={() => store.setLibraryBrowserOpen(true, "sounds")}
642
+ >
643
+ Library
644
+ </button>
645
+ <button
646
+ type="button"
647
+ className="btn is-ghost sound-empty-btn"
648
+ onPointerDown={(e) => e.stopPropagation()}
649
+ onClick={() => fileRef.current?.click()}
650
+ >
651
+ Import
652
+ </button>
653
+ <input
654
+ ref={fileRef}
655
+ type="file"
656
+ accept={AUDIO_FILE_ACCEPT}
657
+ multiple
658
+ hidden
659
+ data-testid="sound-file-input"
660
+ onChange={(e) => {
661
+ importEach(store, e.currentTarget.files, undefined, setImporting, setError, track.id);
662
+ e.currentTarget.value = "";
663
+ }}
664
+ />
665
+ </span>
666
+ ) : null}
667
+ {rows.map((row) =>
668
+ row.layerId !== undefined ? (
669
+ <VideoSoundBar
670
+ key={`v:${row.layerId}:${row.sceneId ?? ""}`}
671
+ row={row}
672
+ doc={doc}
673
+ ppf={ppf}
674
+ origin={origin}
675
+ onContextMenu={props.onClipMenu(row)}
676
+ />
677
+ ) : (
678
+ <AudioClipBar
679
+ key={row.clip.id}
680
+ clip={row.clip}
681
+ asset={doc.assets[row.clip.asset]}
682
+ fps={doc.fps}
683
+ ppf={ppf}
684
+ offsetFrames={row.offsetFrames}
685
+ originFrames={origin}
686
+ beginSnap={beginSnap}
687
+ waveH={SOUND_WAVE_H}
688
+ mix
689
+ rowAt={rowAt}
690
+ onContextMenu={props.onClipMenu(row)}
691
+ {...(row.sceneId === null
692
+ ? {}
693
+ : {
694
+ ownedBy: row.sceneId,
695
+ limitFrames: Math.max(0, Math.trunc(doc.scenesById[row.sceneId]?.durationFrames ?? 0)),
696
+ badge: `Shot ${shotNumberOf(doc, row.sceneId) ?? ""}`.trim(),
697
+ })}
698
+ />
699
+ ),
700
+ )}
701
+ {error === null ? null : (
702
+ <p className="audio-error" role="status">
703
+ {error}
704
+ </p>
705
+ )}
706
+ </div>
707
+ </div>
708
+ );
709
+ }
710
+
711
+ /** A recording's sound on its track. Drawn, never dragged: its in, out and
712
+ * level are the video layer's, trimmed in Rows mode's video row. */
713
+ function VideoSoundBar(props: {
714
+ row: RowClip;
715
+ doc: Doc3D;
716
+ ppf: number;
717
+ origin: number;
718
+ onContextMenu: (e: ReactMouseEvent<HTMLElement>) => void;
719
+ }) {
720
+ const { row, doc, ppf, origin } = props;
721
+ const left = frameToPx(row.masterStart - origin, ppf);
722
+ const width = Math.max(8, frameToPx(row.masterEnd - row.masterStart, ppf));
723
+ const layer = row.sceneId === null ? undefined : doc.scenesById[row.sceneId]?.layersById[row.layerId!];
724
+ const name = layer?.label ?? "Video";
725
+ return (
726
+ <div
727
+ className={`audio-clip is-video${row.clip.muted === true ? " is-muted" : ""}`}
728
+ data-audio-clip={row.clip.id}
729
+ data-readonly=""
730
+ style={{ left, width }}
731
+ title={`${name}: the recording's own sound. Trim it on the video's row in Rows.`}
732
+ onPointerDown={(e) => e.stopPropagation()}
733
+ onContextMenu={props.onContextMenu}
734
+ >
735
+ <AudioWaveform
736
+ clip={{ offsetFrames: row.clip.offsetFrames ?? 0, durationFrames: row.clip.durationFrames }}
737
+ asset={doc.assets[row.clip.asset]}
738
+ fps={doc.fps}
739
+ widthPx={width}
740
+ heightPx={SOUND_WAVE_H}
741
+ />
742
+ <span className="audio-clip-name">
743
+ <span className="audio-clip-lock" aria-hidden="true">
744
+ {LOCK_ICON}
745
+ </span>
746
+ {name} · video sound
747
+ </span>
748
+ </div>
749
+ );
750
+ }
751
+
752
+ // ── THE CHANNEL STRIP ────────────────────────────────────────────────────
753
+
754
+ /**
755
+ * Begin a one-undo level drag on a track. `read` turns the pointer into the
756
+ * patch; it is called for the press too, so a press on the rail jumps there.
757
+ */
758
+ function trackDrag(
759
+ store: Store,
760
+ trackId: string,
761
+ e: ReactPointerEvent<HTMLElement>,
762
+ read: (clientX: number, clientY: number) => Partial<Omit<AudioTrack3D, "id">>,
763
+ jump: boolean,
764
+ ): void {
765
+ if (e.button !== 0) return;
766
+ e.preventDefault();
767
+ e.stopPropagation();
768
+ if (!store.beginBlockDrag()) return;
769
+ if (jump) store.dragAudioTrack(trackId, read(e.clientX, e.clientY));
770
+ startDrag(e, {
771
+ onMove: (ev) => store.dragAudioTrack(trackId, read(ev.clientX, ev.clientY)),
772
+ onEnd: (commit) => store.endBlockDrag(commit),
773
+ });
774
+ }
775
+
776
+ /** Arrow keys on a level control: small steps, Shift for big ones. Stopped,
777
+ * so the timeline's own arrows (edit points, nudges) do not fire too. */
778
+ function stepKey(e: ReactKeyboardEvent<HTMLElement>, step: (by: number) => void): void {
779
+ const by =
780
+ e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
781
+ if (by === 0) return;
782
+ e.preventDefault();
783
+ e.stopPropagation();
784
+ step(by * (e.shiftKey ? 5 : 1));
785
+ }
786
+
787
+ function TrackStrip(props: {
788
+ track: AudioTrack3D;
789
+ width: number;
790
+ soloed: boolean;
791
+ renaming: boolean;
792
+ setRenaming: (on: boolean) => void;
793
+ onContextMenu: (e: ReactMouseEvent<HTMLElement>) => void;
794
+ }) {
795
+ const { track, width, soloed, renaming, setRenaming } = props;
796
+ const store = useStore();
797
+ const muted = track.muted === true;
798
+ return (
799
+ <div
800
+ className="sound-strip-cell sound-strip"
801
+ style={{ width }}
802
+ data-testid={`sound-strip-${track.id}`}
803
+ onPointerDown={(e) => e.stopPropagation()}
804
+ onContextMenu={props.onContextMenu}
805
+ >
806
+ <div className="sound-strip-top">
807
+ <span className="sound-strip-dot" aria-hidden="true" />
808
+ {renaming ? (
809
+ <input
810
+ className="sound-strip-rename"
811
+ defaultValue={track.name}
812
+ aria-label="Track name"
813
+ autoFocus
814
+ onFocus={(e) => e.currentTarget.select()}
815
+ onKeyDown={(e) => {
816
+ if (e.key === "Enter") e.currentTarget.blur();
817
+ if (e.key === "Escape") {
818
+ e.currentTarget.value = track.name;
819
+ e.currentTarget.blur();
820
+ }
821
+ e.stopPropagation();
822
+ }}
823
+ onBlur={(e) => {
824
+ const name = e.currentTarget.value.trim();
825
+ if (name !== "" && name !== track.name) store.updateAudioTrack(track.id, { name });
826
+ setRenaming(false);
827
+ }}
828
+ />
829
+ ) : (
830
+ <span className="sound-strip-name" title={`${track.name}: double-click to rename`} onDoubleClick={() => setRenaming(true)}>
831
+ {track.name}
832
+ </span>
833
+ )}
834
+ <button
835
+ type="button"
836
+ className={`sound-ms is-mute${muted ? " is-on" : ""}`}
837
+ aria-pressed={muted}
838
+ aria-label={`Mute ${track.name}`}
839
+ title={muted ? "Muted: saved with the film, so an export is silent here too" : "Mute this track"}
840
+ data-testid={`sound-mute-${track.id}`}
841
+ onClick={() => store.updateAudioTrack(track.id, { muted: !muted })}
842
+ >
843
+ M
844
+ </button>
845
+ <button
846
+ type="button"
847
+ className={`sound-ms is-solo${soloed ? " is-on" : ""}`}
848
+ aria-pressed={soloed}
849
+ aria-label={`Solo ${track.name}`}
850
+ title={soloed ? "Soloed in this tab only" : "Solo: hear this track alone, in this tab only"}
851
+ data-testid={`sound-solo-${track.id}`}
852
+ onClick={() => store.toggleSoloTrack(track.id)}
853
+ >
854
+ S
855
+ </button>
856
+ </div>
857
+ </div>
858
+ );
859
+ }
860
+
861
+ function panLabel(pan: number): string {
862
+ if (Math.abs(pan) < 0.005) return "Centre";
863
+ return `${Math.round(Math.abs(pan) * 100)}% ${pan < 0 ? "left" : "right"}`;
864
+ }
865
+
866
+ /** Pan, -1..1. A vertical drag (up is right, 100px is full travel), as a
867
+ * knob is turned on every console; double-click centres it. */
868
+ function PanKnob({ track }: { track: AudioTrack3D }) {
869
+ const store = useStore();
870
+ const pan = track.pan ?? 0;
871
+ const angle = (pan * 135 * Math.PI) / 180;
872
+ const set = (p: number): number => {
873
+ const clamped = Math.max(-1, Math.min(1, p));
874
+ return Math.abs(clamped) < 0.02 ? 0 : Math.round(clamped * 100) / 100;
875
+ };
876
+ return (
877
+ <span
878
+ className="sound-pan"
879
+ role="slider"
880
+ tabIndex={0}
881
+ aria-label={`Pan ${track.name}`}
882
+ aria-valuemin={-1}
883
+ aria-valuemax={1}
884
+ aria-valuenow={pan}
885
+ aria-valuetext={panLabel(pan)}
886
+ title={`Pan: ${panLabel(pan)}. Drag up or down, double-click to centre.`}
887
+ data-testid={`sound-pan-${track.id}`}
888
+ onPointerDown={(e) => {
889
+ const y0 = e.clientY;
890
+ trackDrag(store, track.id, e, (_x, y) => ({ pan: set(pan - (y - y0) / 100) }), false);
891
+ }}
892
+ onDoubleClick={() => store.updateAudioTrack(track.id, { pan: 0 })}
893
+ onKeyDown={(e) => stepKey(e, (by) => store.updateAudioTrack(track.id, { pan: set(pan + by * 0.05) }))}
894
+ >
895
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
896
+ <circle cx="9" cy="9" r="7.5" />
897
+ <path d={`M9 9 L${(9 + 6 * Math.sin(angle)).toFixed(2)} ${(9 - 6 * Math.cos(angle)).toFixed(2)}`} />
898
+ </svg>
899
+ </span>
900
+ );
901
+ }
902
+
903
+ // ── THE MIXER COLUMN ─────────────────────────────────────────────────────
904
+
905
+ /** Sound mode's right column: the peak and the one-press duck on top, then a
906
+ * vertical strip per track (pan, level, its own meter), then the master's
907
+ * meter. The dock's only meters: Rows and Graph show none. */
908
+ export function SoundMixer() {
909
+ const store = useStore();
910
+ const doc = useShotEditor((s) => s.doc);
911
+ const solo = useShotEditor((s) => s.soloTracks);
912
+ const muted = useShotEditor((s) => s.previewMuted);
913
+ const graph = useSyncExternalStore(subscribeMonitor, readMonitor, readMonitor);
914
+ useMeterTap(MASTER_METER_KEY, graph?.ctx ?? null, graph?.node ?? null);
915
+ if (doc === null) return null;
916
+ const tracks = audioTracksOf(doc);
917
+ const duck = voiceDuckState(doc);
918
+ return (
919
+ <div className="sound-mixer" data-testid="sound-mixer">
920
+ <div className="sound-mixer-head">
921
+ <span className="sound-mixer-title">Mixer</span>
922
+ {solo.length > 0 ? (
923
+ <button
924
+ type="button"
925
+ className="sound-solo-chip"
926
+ title="Solo is this tab's alone. Exports hear every track. Press to hear every track again."
927
+ onClick={() => {
928
+ for (const id of solo) store.toggleSoloTrack(id);
929
+ }}
930
+ >
931
+ Solo · clear
932
+ </button>
933
+ ) : (
934
+ <MixerPeak muted={muted} />
935
+ )}
936
+ <button
937
+ type="button"
938
+ className="sound-duck"
939
+ data-testid="sound-duck"
940
+ aria-pressed={duck.active}
941
+ disabled={!duck.available}
942
+ title={
943
+ duck.available
944
+ ? duck.active
945
+ ? "The other tracks dip while the voice speaks. Press to stop."
946
+ : "Dip the other tracks while the voice speaks"
947
+ : "Name a track Voice, with a clip on it, to duck the others under it"
948
+ }
949
+ onClick={() => store.setDuckUnderVoice(!duck.active)}
950
+ >
951
+ Duck under voice
952
+ </button>
953
+ </div>
954
+ <div className="sound-mixer-strips">
955
+ {tracks.map((t) => (
956
+ <MixerStrip
957
+ key={t.id}
958
+ track={t}
959
+ graph={graph}
960
+ dimmed={solo.length > 0 && !solo.includes(t.id)}
961
+ soloed={solo.includes(t.id)}
962
+ />
963
+ ))}
964
+ <div className="sound-mixer-strip is-master" data-testid="mixer-strip-master">
965
+ {/* The pan knob's slot, so the master's meters start level with
966
+ the tracks'. */}
967
+ <span className="sound-pan-slot" aria-hidden="true" />
968
+ <MasterHold />
969
+ <div className="sound-mixer-body">
970
+ <MeterPair meterKey={MASTER_METER_KEY} />
971
+ </div>
972
+ <span className="sound-mixer-name" title="Master: what the mix sends out, before the preview volume">
973
+ Master
974
+ </span>
975
+ </div>
976
+ </div>
977
+ </div>
978
+ );
979
+ }
980
+
981
+ /** The master strip's number: its held peak, the louder channel. A leaf, so
982
+ * a reading re-renders this span and not the strips. */
983
+ function MasterHold() {
984
+ const left = useMeterReading(meterChannelKey(MASTER_METER_KEY, "L"));
985
+ const right = useMeterReading(meterChannelKey(MASTER_METER_KEY, "R"));
986
+ return (
987
+ <span className="sound-mixer-db" data-testid="mixer-master-hold">
988
+ {dbLabel(Math.max(left.hold, right.hold), false)}
989
+ </span>
990
+ );
991
+ }
992
+
993
+ /** "−6.2 dB peak": the master's held peak, the louder channel. */
994
+ function MixerPeak({ muted }: { muted: boolean }) {
995
+ const left = useMeterReading(meterChannelKey(MASTER_METER_KEY, "L"));
996
+ const right = useMeterReading(meterChannelKey(MASTER_METER_KEY, "R"));
997
+ return (
998
+ <span className="sound-mixer-peak" data-testid="sound-mixer-peak">
999
+ {muted ? "Preview muted" : `${dbLabel(Math.max(left.hold, right.hold))} peak`}
1000
+ </span>
1001
+ );
1002
+ }
1003
+
1004
+ function MixerStrip(props: {
1005
+ track: AudioTrack3D;
1006
+ graph: ReturnType<typeof readMonitor>;
1007
+ dimmed: boolean;
1008
+ soloed: boolean;
1009
+ }) {
1010
+ const { track, graph } = props;
1011
+ const store = useStore();
1012
+ const key = `track:${track.id}`;
1013
+ useMeterTap(key, graph?.ctx ?? null, graph?.tracks.get(track.id) ?? null);
1014
+ const gain = track.gain ?? 1;
1015
+ const muted = track.muted === true;
1016
+ return (
1017
+ <div
1018
+ className={`sound-mixer-strip${muted ? " is-muted" : ""}${props.dimmed ? " is-unsoloed" : ""}`}
1019
+ data-testid={`mixer-strip-${track.id}`}
1020
+ >
1021
+ <PanKnob track={track} />
1022
+ <span className="sound-mixer-db">{dbLabel(gain, false)}</span>
1023
+ <div className="sound-mixer-body">
1024
+ <MeterPair meterKey={key} />
1025
+ <VFader
1026
+ label={`${track.name} level`}
1027
+ gain={gain}
1028
+ onBegin={() => store.beginBlockDrag()}
1029
+ onDrag={(g) => store.dragAudioTrack(track.id, { gain: g })}
1030
+ onEnd={(commit) => store.endBlockDrag(commit)}
1031
+ onReset={() => store.updateAudioTrack(track.id, { gain: 1 })}
1032
+ onStep={(g) => store.updateAudioTrack(track.id, { gain: g })}
1033
+ />
1034
+ </div>
1035
+ <span className="sound-mixer-ms">
1036
+ <button
1037
+ type="button"
1038
+ className={`sound-ms is-mute${muted ? " is-on" : ""}`}
1039
+ aria-pressed={muted}
1040
+ aria-label={`Mute ${track.name}`}
1041
+ onClick={() => store.updateAudioTrack(track.id, { muted: !muted })}
1042
+ >
1043
+ M
1044
+ </button>
1045
+ <button
1046
+ type="button"
1047
+ className={`sound-ms is-solo${props.soloed ? " is-on" : ""}`}
1048
+ aria-pressed={props.soloed}
1049
+ aria-label={`Solo ${track.name}`}
1050
+ onClick={() => store.toggleSoloTrack(track.id)}
1051
+ >
1052
+ S
1053
+ </button>
1054
+ </span>
1055
+ <span className="sound-mixer-name" title={track.name}>
1056
+ {track.name}
1057
+ </span>
1058
+ </div>
1059
+ );
1060
+ }
1061
+
1062
+ /** Linear amplitude to a percentage of the meter. Logarithmic, because a
1063
+ * linear meter spends four fifths of its length on the top 6 dB and shows
1064
+ * nothing at all for dialogue sitting at -18.
1065
+ *
1066
+ * `METER_FLOOR_DB` is `meterMath.ts`'s because the publisher's gate is
1067
+ * defined against this scale: two levels the floor flattens into the same 0%
1068
+ * are the same picture. Below the floor the meter is empty rather than
1069
+ * negative, here as there. */
1070
+ export function meterPercent(amplitude: number): number {
1071
+ if (!Number.isFinite(amplitude) || amplitude <= 0) return 0;
1072
+ const db = 20 * Math.log10(amplitude);
1073
+ if (db <= METER_FLOOR_DB) return 0;
1074
+ return Math.min(100, (1 - db / METER_FLOOR_DB) * 100);
1075
+ }
1076
+
1077
+ /** Two vertical bars, L and R, each with its held peak. */
1078
+ function MeterPair({ meterKey }: { meterKey: string }) {
1079
+ return (
1080
+ <span className="sound-meter-pair" aria-hidden="true">
1081
+ <MeterBar meterKey={meterKey} channel="L" />
1082
+ <MeterBar meterKey={meterKey} channel="R" />
1083
+ </span>
1084
+ );
1085
+ }
1086
+
1087
+ function MeterBar({ meterKey, channel }: { meterKey: string; channel: "L" | "R" }) {
1088
+ const level = useMeterReading(meterChannelKey(meterKey, channel));
1089
+ const lit = meterPercent(level.rms);
1090
+ const held = meterPercent(level.hold);
1091
+ return (
1092
+ <span className="sound-meter">
1093
+ <span className={`sound-meter-lit${lit > 95 ? " is-hot" : ""}`} style={{ height: `${lit}%` }} />
1094
+ {held > 0 ? <span className="sound-meter-hold" style={{ bottom: `${held}%` }} /> : null}
1095
+ </span>
1096
+ );
1097
+ }
1098
+
1099
+ /** A track's level: a vertical fader on the fader law. A press on the thumb
1100
+ * drags from where it is; a press on the rail jumps there first. */
1101
+ function VFader(props: {
1102
+ label: string;
1103
+ gain: number;
1104
+ onBegin?: () => boolean;
1105
+ onDrag: (gain: number) => void;
1106
+ onEnd?: (commit: boolean) => void;
1107
+ onReset: () => void;
1108
+ onStep?: (gain: number) => void;
1109
+ }) {
1110
+ const pos = faderPos(props.gain);
1111
+ const write = (p: number): number => faderGain(p);
1112
+ return (
1113
+ <span
1114
+ className="sound-vfader"
1115
+ role="slider"
1116
+ tabIndex={0}
1117
+ aria-orientation="vertical"
1118
+ aria-label={props.label}
1119
+ aria-valuemin={0}
1120
+ aria-valuemax={MAX_AUDIO_GAIN}
1121
+ aria-valuenow={props.gain}
1122
+ aria-valuetext={dbLabel(props.gain)}
1123
+ title={`${dbLabel(props.gain)}. Drag to set, double-click for 0 dB.`}
1124
+ onPointerDown={(e) => {
1125
+ if (e.button !== 0) return;
1126
+ e.preventDefault();
1127
+ e.stopPropagation();
1128
+ if (props.onBegin !== undefined && !props.onBegin()) return;
1129
+ const rail = e.currentTarget.getBoundingClientRect();
1130
+ const onThumb = (e.target as HTMLElement).classList.contains("sound-vfader-thumb");
1131
+ const y0 = e.clientY;
1132
+ const start = onThumb ? pos : (rail.bottom - e.clientY) / Math.max(1, rail.height);
1133
+ if (!onThumb) props.onDrag(write(start));
1134
+ startDrag(e, {
1135
+ onMove: (ev) => props.onDrag(write(start - (ev.clientY - y0) / Math.max(1, rail.height))),
1136
+ onEnd: (commit) => props.onEnd?.(commit),
1137
+ });
1138
+ }}
1139
+ onDoubleClick={props.onReset}
1140
+ onKeyDown={(e) => stepKey(e, (by) => (props.onStep ?? props.onDrag)(write(pos + by * 0.01)))}
1141
+ >
1142
+ <span className="sound-vfader-thumb" style={{ bottom: `${pos * 100}%` }} />
1143
+ </span>
1144
+ );
1145
+ }
1146
+
1147
+ // ── TEMPO ────────────────────────────────────────────────────────────────
1148
+
1149
+ /** The head's BPM chip and its popover. */
1150
+ export function TempoChip() {
1151
+ const tempo = useShotEditor((s) => s.doc?.tempo);
1152
+ const [open, setOpen] = useState(false);
1153
+ const anchor = useRef<HTMLButtonElement | null>(null);
1154
+ return (
1155
+ <>
1156
+ <button
1157
+ ref={anchor}
1158
+ type="button"
1159
+ className={`sound-bpm-chip${tempo === undefined ? " is-unset" : ""}`}
1160
+ data-testid="sound-bpm-chip"
1161
+ aria-expanded={open}
1162
+ title="The film's tempo: bar lines, beat snap and the bar readout"
1163
+ onClick={() => setOpen((v) => !v)}
1164
+ >
1165
+ {METRONOME_ICON}
1166
+ <span>{tempo === undefined ? "Set tempo" : tempoLabel(tempo)}</span>
1167
+ </button>
1168
+ <Popover open={open} anchor={anchor} onClose={() => setOpen(false)} className="sound-tempo-pop">
1169
+ {open ? <TempoPopover /> : null}
1170
+ </Popover>
1171
+ </>
1172
+ );
1173
+ }
1174
+
1175
+ const BEATS_PER_BAR = [3, 4, 6, 7] as const;
1176
+ /** Taps further apart than this start a new count. */
1177
+ const TAP_RESET_MS = 2000;
1178
+ /** The tap tempo commits once the author stops tapping for this long. */
1179
+ const TAP_COMMIT_MS = 1500;
1180
+
1181
+ export function TempoPopover() {
1182
+ const store = useStore();
1183
+ const doc = useShotEditor((s) => s.doc);
1184
+ const snap = useShotEditor((s) => s.snapBeats);
1185
+ const tempo = doc?.tempo;
1186
+ const [draft, setDraft] = useState<string>(tempo === undefined ? "" : String(Math.round(tempo.bpm * 100) / 100));
1187
+ const [warning, setWarning] = useState<string | null>(null);
1188
+ const [detecting, setDetecting] = useState(false);
1189
+ const taps = useRef<number[]>([]);
1190
+ const pending = useRef<{ timer: ReturnType<typeof setTimeout>; bpm: number } | null>(null);
1191
+
1192
+ useEffect(() => {
1193
+ if (tempo !== undefined) setDraft(String(Math.round(tempo.bpm * 100) / 100));
1194
+ }, [tempo?.bpm]); // eslint-disable-line react-hooks/exhaustive-deps
1195
+
1196
+ const write = (patch: Partial<Tempo3D>): void => {
1197
+ const s = store.getState().doc?.tempo;
1198
+ const bpm = patch.bpm ?? s?.bpm ?? 120;
1199
+ store.setTempo({ ...(s ?? {}), ...patch, bpm });
1200
+ };
1201
+ const flush = (): void => {
1202
+ const p = pending.current;
1203
+ if (p === null) return;
1204
+ clearTimeout(p.timer);
1205
+ pending.current = null;
1206
+ write({ bpm: p.bpm });
1207
+ };
1208
+ // A pending tap count is not lost when the popover closes.
1209
+ useEffect(() => flush, []); // eslint-disable-line react-hooks/exhaustive-deps
1210
+
1211
+ const commitDraft = (): void => {
1212
+ const bpm = Number(draft);
1213
+ if (draft.trim() === "" || !Number.isFinite(bpm)) return;
1214
+ const clamped = Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, Math.round(bpm * 100) / 100));
1215
+ setDraft(String(clamped));
1216
+ if (clamped !== tempo?.bpm) write({ bpm: clamped });
1217
+ };
1218
+
1219
+ const tap = (): void => {
1220
+ const now = performance.now();
1221
+ const last = taps.current[taps.current.length - 1];
1222
+ taps.current = last !== undefined && now - last > TAP_RESET_MS ? [now] : [...taps.current, now].slice(-8);
1223
+ const t = taps.current;
1224
+ if (t.length < 2) return;
1225
+ const bpm = Math.round((60000 * (t.length - 1)) / (t[t.length - 1]! - t[0]!));
1226
+ const clamped = Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, bpm));
1227
+ setDraft(String(clamped));
1228
+ if (pending.current !== null) clearTimeout(pending.current.timer);
1229
+ pending.current = { bpm: clamped, timer: setTimeout(flush, TAP_COMMIT_MS) };
1230
+ };
1231
+
1232
+ const detect = async (): Promise<void> => {
1233
+ if (doc === null) return;
1234
+ const s = store.getState();
1235
+ const all = authoredAudioClips(doc).filter((m) => m.layerId === undefined);
1236
+ const source =
1237
+ all.find((m) => s.selectedAudio.includes(m.clip.id)) ??
1238
+ all.find((m) => s.frame >= m.clip.frame && s.frame < m.clip.frame + m.clip.durationFrames) ??
1239
+ all[0];
1240
+ if (source === undefined) {
1241
+ setWarning("Add a music clip first: the tempo is read from its beat analysis.");
1242
+ return;
1243
+ }
1244
+ const asset = doc.assets[source.clip.asset];
1245
+ const name = clipLabel(source.clip, asset);
1246
+ if (asset === undefined) return;
1247
+ setDetecting(true);
1248
+ setWarning(null);
1249
+ try {
1250
+ const bytes = await fetchAssetBytes(audioAnalysisFileName(asset.path));
1251
+ const analysis = JSON.parse(new TextDecoder().decode(bytes)) as {
1252
+ beats?: { bpm: number | null; reliable: boolean; gridPhaseSeconds: number | null };
1253
+ };
1254
+ const beats = analysis.beats;
1255
+ if (beats === undefined || beats.bpm === null || !(beats.bpm > 0)) {
1256
+ setWarning(`${name} has no steady beat to read. Set the tempo by hand or tap it.`);
1257
+ return;
1258
+ }
1259
+ const bpm = Math.min(TEMPO_BPM_MAX, Math.max(TEMPO_BPM_MIN, Math.round(beats.bpm * 100) / 100));
1260
+ const perBar = Math.max(1, Math.trunc(store.getState().doc?.tempo?.beatsPerBar ?? 4));
1261
+ const fps = doc.fps.num / doc.fps.den;
1262
+ // The file's first grid line, on the master timeline: where the source's
1263
+ // second 0 sits, plus the phase. Folded back by whole bars so the grid
1264
+ // starts at or after frame 0.
1265
+ const raw = source.clip.frame - (source.clip.offsetFrames ?? 0) + (beats.gridPhaseSeconds ?? 0) * fps;
1266
+ const bar = ((60 * fps) / bpm) * perBar;
1267
+ const startFrame = Math.round(((raw % bar) + bar) % bar);
1268
+ write({ bpm, startFrame });
1269
+ setDraft(String(bpm));
1270
+ if (!beats.reliable) {
1271
+ setWarning(`${name} reads as ${bpm} BPM, but its beat grid is not reliable. Check it by ear before you snap.`);
1272
+ }
1273
+ } catch {
1274
+ setWarning(`${name} has no beat analysis yet. Re-import it, or set the tempo by hand.`);
1275
+ } finally {
1276
+ setDetecting(false);
1277
+ }
1278
+ };
1279
+
1280
+ const perBar = Math.max(1, Math.trunc(tempo?.beatsPerBar ?? 4));
1281
+ return (
1282
+ <div className="sound-tempo" data-testid="sound-tempo">
1283
+ <div className="sound-tempo-head">
1284
+ <span className="sound-tempo-title">Tempo</span>
1285
+ <span className="sound-tempo-scope">this doc</span>
1286
+ </div>
1287
+ <div className="sound-tempo-bpm">
1288
+ <input
1289
+ className="text-input sound-tempo-input"
1290
+ inputMode="decimal"
1291
+ aria-label="Beats per minute"
1292
+ data-testid="sound-tempo-bpm"
1293
+ value={draft}
1294
+ placeholder="120"
1295
+ onChange={(e) => setDraft(e.currentTarget.value)}
1296
+ onBlur={commitDraft}
1297
+ onKeyDown={(e) => {
1298
+ if (e.key === "Enter") commitDraft();
1299
+ }}
1300
+ />
1301
+ <span className="sound-tempo-unit">BPM</span>
1302
+ <button type="button" className="btn is-outline sound-tempo-tap" data-testid="sound-tempo-tap" onClick={tap}>
1303
+ Tap
1304
+ </button>
1305
+ </div>
1306
+ <div className="sound-tempo-field">
1307
+ <span className="sound-tempo-label">Beats per bar</span>
1308
+ <span className="sound-tempo-seg" role="radiogroup" aria-label="Beats per bar">
1309
+ {BEATS_PER_BAR.map((n) => (
1310
+ <button
1311
+ key={n}
1312
+ type="button"
1313
+ role="radio"
1314
+ aria-checked={perBar === n}
1315
+ className={`sound-tempo-seg-btn${perBar === n ? " is-on" : ""}`}
1316
+ onClick={() => write({ beatsPerBar: n })}
1317
+ >
1318
+ {n}
1319
+ </button>
1320
+ ))}
1321
+ </span>
1322
+ </div>
1323
+ <div className="sound-tempo-field">
1324
+ <span className="sound-tempo-label">
1325
+ Snap to beats
1326
+ <span className="sound-tempo-hint">This tab only</span>
1327
+ </span>
1328
+ <Toggle checked={snap} onChange={(on) => store.setSnapBeats(on)} ariaLabel="Snap to beats" />
1329
+ </div>
1330
+ <span className="sound-tempo-rule" aria-hidden="true" />
1331
+ <div className="sound-tempo-actions">
1332
+ <button
1333
+ type="button"
1334
+ className="btn is-outline sound-tempo-detect"
1335
+ data-testid="sound-tempo-detect"
1336
+ disabled={detecting}
1337
+ onClick={() => void detect()}
1338
+ >
1339
+ {detecting ? "Reading…" : "Detect from clip"}
1340
+ </button>
1341
+ {tempo === undefined ? null : (
1342
+ <button type="button" className="btn is-ghost" onClick={() => store.setTempo(null)}>
1343
+ Remove
1344
+ </button>
1345
+ )}
1346
+ </div>
1347
+ {warning === null ? null : (
1348
+ <p className="sound-tempo-warn" role="status" data-testid="sound-tempo-warn">
1349
+ {warning}
1350
+ </p>
1351
+ )}
1352
+ </div>
1353
+ );
1354
+ }
1355
+
1356
+ /** The head's two sound verbs: the library's sounds and the Generate drawer.
1357
+ * Generate shows once the host says some kind is available, or, hosted, that
1358
+ * only the workspace's own ElevenLabs key is missing (`needsKey`): the drawer
1359
+ * then says who adds it. A local studio without the key has no dead button. */
1360
+ export function SoundHeadTools(props: { generateOpen: boolean; onGenerate: () => void }) {
1361
+ const store = useStore();
1362
+ const project = useShotEditor((s) => s.name);
1363
+ const [canGenerate, setCanGenerate] = useState(false);
1364
+ useEffect(() => {
1365
+ if (project === null) return;
1366
+ let live = true;
1367
+ generationStatus(project).then(
1368
+ (s) => {
1369
+ if (live) setCanGenerate(s.needsKey !== undefined || Object.values(s.kinds).some((k) => k.ok));
1370
+ },
1371
+ () => {
1372
+ if (live) setCanGenerate(false);
1373
+ },
1374
+ );
1375
+ return () => {
1376
+ live = false;
1377
+ };
1378
+ }, [project]);
1379
+ return (
1380
+ <>
1381
+ <button
1382
+ type="button"
1383
+ className="btn is-outline sound-head-btn"
1384
+ data-testid="sound-library"
1385
+ title="Browse the library's sounds"
1386
+ onClick={() => store.setLibraryBrowserOpen(true, "sounds")}
1387
+ >
1388
+ {LIBRARY_ICON}
1389
+ Library
1390
+ </button>
1391
+ {canGenerate || props.generateOpen ? (
1392
+ <button
1393
+ type="button"
1394
+ className="btn sound-head-btn is-accent"
1395
+ data-testid="sound-generate"
1396
+ aria-pressed={props.generateOpen}
1397
+ title="Generate a sound effect, music or a voice"
1398
+ onClick={props.onGenerate}
1399
+ >
1400
+ {SPARKLES_ICON}
1401
+ Generate
1402
+ </button>
1403
+ ) : null}
1404
+ </>
1405
+ );
1406
+ }