@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
@@ -1,12 +1,42 @@
1
- // The audio lane: one coloured band per clip at its MASTER position.
1
+ // The audio lanes: one coloured band per clip, in whichever of the two homes
2
+ // holds it.
2
3
  //
3
- // ── WHY IT IS ONE LANE AND NOT A TRACK PER CLIP ─────────────────────────
4
+ // ── TWO HOMES, TWO PLACES ON SCREEN ─────────────────────────────────────
4
5
  //
5
- // `doc.audio` is a flat record of master-timed clips with no lane field, and
6
- // the reference editor draws one music bed under the whole video. A lane-packer
7
- // would be inventing a document concept the schema deliberately does not have,
8
- // and the first thing it would need is somewhere to persist which lane a clip
9
- // landed in. Clips overlap by drawing over each other, in document order.
6
+ // `doc.audio` is the BED — master-timed, spanning the cuts — and it draws here,
7
+ // at the bottom of the dock, where every NLE puts the music. `Scene3D.audio`
8
+ // (schema v13) is the sound a SHOT owns, and it draws inside that shot's group
9
+ // (`SceneAudioRow`), because that is what owning it means: it moves, duplicates
10
+ // and dies with the shot, and it is cut at the shot's end.
11
+ //
12
+ // Both use `AudioClipBar`, which is base-agnostic — it takes `offsetFrames`
13
+ // (the shot's master start, or 0 for the bed) exactly as `BlockRow` does, and
14
+ // every gesture below it is a signed DELTA, which means the same thing in
15
+ // either frame base. The one thing that is not base-agnostic is the snap
16
+ // anchor, which is always master, and that is what `offsetFrames` is added into.
17
+ //
18
+ // ── A THIRD SOUND EXISTS, AND IT IS DRAWN SOMEWHERE ELSE ────────────────
19
+ //
20
+ // A video layer's `video.sound` reaches the mix through `masterAudioClips` as a
21
+ // VIRTUAL clip whose id is the layer id and whose `MasterAudioClip.layerId` is
22
+ // set. It is deliberately absent from both lanes here: it is drawn in the video
23
+ // row itself (`LifespanRow`), under that recording's filmstrip, because a
24
+ // soundtrack that appears in two places is one an author can trim in the wrong
25
+ // one. Both lanes read their own record (`doc.audio`, `Scene3D.audio`), so this
26
+ // is a property of WHERE THEY READ rather than a filter they apply, and there
27
+ // is no `layerId` on an `AudioClipRef` for a filter to test.
28
+ //
29
+ // The one place the flatten IS read here is `duckCandidates`, which must offer
30
+ // the virtual clips: ducking a bed under a recording's dialogue is the case the
31
+ // feature exists for, and naming a source is not drawing a bar.
32
+ //
33
+ // ── WHY EACH IS ONE LANE AND NOT A TRACK PER CLIP ───────────────────────
34
+ //
35
+ // Neither record has a lane field, and the reference editor draws one music bed
36
+ // under the whole video. A lane-packer would be inventing a document concept
37
+ // the schema deliberately does not have, and the first thing it would need is
38
+ // somewhere to persist which lane a clip landed in. Clips overlap by drawing
39
+ // over each other, in document order.
10
40
  //
11
41
  // ── THE GESTURES ────────────────────────────────────────────────────────
12
42
  //
@@ -15,41 +45,126 @@
15
45
  // IN / OUT trim; the head trim moves `offsetFrames` with `frame` so the
16
46
  // audio under the remaining bar does not slide.
17
47
  // FADES the two corner handles, dragged along the bar.
18
- // GAIN the horizontal rubber band, dragged vertically.
48
+ // GAIN the horizontal rubber band, dragged vertically — and once the
49
+ // clip has a `gainTrack`, the band edits the KEY at the playhead
50
+ // and the flat line becomes the sampled envelope.
51
+ //
52
+ // GAIN is Sound mode's only (`mix`), with the duck, the gain key and the clip
53
+ // mute. Mixing lives in the Sound tab, so these rows stay slim: the waveform,
54
+ // move, trim, fades and the home switch.
19
55
  //
20
56
  // All of them go through `beginBlockDrag`/`endBlockDrag`, which is the dock's
21
57
  // ONE history scrub: each gesture is one undo entry and Escape restores the
22
58
  // base by reference. All of them snap through the shared `snap.ts` — there is
23
59
  // no second snapper in this lane.
24
60
  import { useEffect, useRef, useState } from "react";
25
- import type { PointerEvent as ReactPointerEvent } from "react";
61
+ import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
26
62
  import { drawClipWaveform, frameToPx, loadPeaks, startDrag } from "@dailies-studio/timeline";
27
63
  import type { PeakData } from "@dailies-studio/timeline";
64
+ import { IconVolume, IconVolumeOff } from "@dailies-studio/ui";
28
65
  import { framesToSeconds } from "@dailies-studio/engine";
29
66
  import type { Rational } from "@dailies-studio/engine";
30
- import { MAX_AUDIO_GAIN } from "@dailies-studio/shot/doc";
31
- import type { AudioClipRef, ShotAsset } from "@dailies-studio/shot/doc";
67
+ import {
68
+ DUCK_DEFAULT_STRENGTH,
69
+ DUCK_MAX_DEPTH_DB,
70
+ MAX_AUDIO_GAIN,
71
+ audioGainSampler,
72
+ masterAudioClips,
73
+ } from "@dailies-studio/shot/doc";
74
+ import type { AudioClipRef, ShotAsset, ShotTrack } from "@dailies-studio/shot/doc";
75
+ import { useStudioTheme } from "@dailies-studio/ui/theme";
32
76
  import { useShotEditor, useStore } from "../../state/context.js";
33
- import { audioClipView, frameDelta } from "./model.js";
77
+ import { ACCEPTED_AUDIO_EXTENSIONS } from "../storyboard/storyboardDrop.js";
78
+ import { AUDIO_ROW_H, audioClipView, frameDelta } from "./model.js";
34
79
  import type { AudioDrag } from "./model.js";
35
80
  import { isSnapSuppressed } from "./snap.js";
81
+ import { AudioFxButton } from "./AudioFxButton.js";
36
82
  import type { SnapPress } from "./snap.js";
83
+ import { formatCombo } from "../../state/shortcuts.js";
84
+
85
+ const WAVE_COLOR_FALLBACK = "rgba(150, 205, 255, 0.7)";
86
+ /**
87
+ * Mirrors of timeline-rows.css, pinned to it by audioLane.test: `.audio-lane`'s
88
+ * height (with its 1px top seam), `.scene-audio-row`'s height, and
89
+ * `.audio-clip`'s inset top and bottom.
90
+ *
91
+ * ONE AUDIO ROW HEIGHT. A shot's own row is the boards' 32px lane too: inside
92
+ * a shot it is the only audio row on screen (see `AudioLane`'s shot-context
93
+ * early return), and two heights for one medium read as two features.
94
+ * `groupHeights` counts it through this constant.
95
+ */
96
+ export const AUDIO_LANE_H = AUDIO_ROW_H;
97
+ export const SHOT_AUDIO_ROW_H = AUDIO_LANE_H;
98
+ export const AUDIO_CLIP_INSET = 4;
99
+ /**
100
+ * A clip bar's inner height per row kind, in px: what the waveform canvas, the
101
+ * fade ramp and the gain envelope are drawn into. The row less the bar's two
102
+ * insets and its 1px border; the master lane also pays its top seam.
103
+ */
104
+ const MASTER_WAVE_H = AUDIO_LANE_H - 1 - 2 * AUDIO_CLIP_INSET - 2;
105
+ const SHOT_WAVE_H = SHOT_AUDIO_ROW_H - 2 * AUDIO_CLIP_INSET - 2;
37
106
 
38
- const WAVE_COLOR = "rgba(150, 205, 255, 0.7)";
39
- /** Canvas px. Matches the bar's inner height in styles.css. */
40
- const WAVE_H = 26;
107
+ /**
108
+ * What the Sound tab's file input offers and what the drop box says, from ONE
109
+ * list: the extensions the import accepts (storyboardDrop's mirror of the
110
+ * CLI's audio kinds). Extensions only, no MIME types: `audio/ogg` would also offer an
111
+ * `.opus` the import then refuses, and the picker should not offer a file the
112
+ * drop would turn away.
113
+ */
114
+ export const AUDIO_FILE_ACCEPT = ACCEPTED_AUDIO_EXTENSIONS.join(",");
115
+ export const AUDIO_FORMAT_HINT = ACCEPTED_AUDIO_EXTENSIONS.map((ext) => ext.slice(1).toUpperCase()).join(" / ");
41
116
 
42
- export function AudioLane(props: { ppf: number; labelWidth: number; beginSnap?: SnapPress }) {
43
- const { ppf, labelWidth, beginSnap } = props;
117
+ /** The empty row's drop box, shared by the master lane and a shot's own row so
118
+ * the two say the same sentence in the same shape. */
119
+ function AudioDropBox() {
120
+ return (
121
+ <span className="audio-empty">
122
+ <span className="audio-empty-copy">Drop an audio file, or press + to add sound</span>
123
+ <span className="audio-empty-formats">{AUDIO_FORMAT_HINT}</span>
124
+ </span>
125
+ );
126
+ }
127
+
128
+ export function AudioLane(props: {
129
+ ppf: number;
130
+ labelWidth: number;
131
+ beginSnap?: SnapPress;
132
+ /**
133
+ * The SHOT CONTEXT's window, or absent in the scene context. Clips are
134
+ * MASTER-timed and stay so; this re-zeros the lane on the shot and keeps
135
+ * the clips that OVERLAP it.
136
+ *
137
+ * Overlap and not containment, and the difference is the common case: a music
138
+ * bed spans the whole video, so a containment test would empty the lane in
139
+ * every shot — in a tool whose launch deliverable is a 52-second music ad.
140
+ * A bed that starts before the shot is drawn from a negative x and its head is
141
+ * clipped by the track box, which is the honest read: it continues off-screen
142
+ * to the left because it continues before this shot.
143
+ */
144
+ shotWindow?: { origin: number; len: number } | undefined;
145
+ }) {
146
+ const { ppf, labelWidth, beginSnap, shotWindow } = props;
147
+ const origin = shotWindow?.origin ?? 0;
44
148
  const store = useStore();
45
149
  const doc = useShotEditor((s) => s.doc);
46
150
  const [error, setError] = useState<string | null>(null);
47
151
  const [importing, setImporting] = useState(false);
48
152
  const [over, setOver] = useState(false);
49
- const fileRef = useRef<HTMLInputElement | null>(null);
50
153
 
51
154
  if (doc === null) return null;
52
- const clips = Object.values(doc.audio);
155
+ const clips = Object.values(doc.audio).filter((clip) => {
156
+ if (shotWindow === undefined) return true;
157
+ const start = Math.trunc(clip.frame);
158
+ const end = start + Math.max(1, Math.trunc(clip.durationFrames));
159
+ return end > shotWindow.origin && start < shotWindow.origin + shotWindow.len;
160
+ });
161
+ // INSIDE A SHOT WITH NO BED OVER IT, THE SHOT'S OWN ROW IS THE AUDIO ROW. That
162
+ // row already draws the drop box (`SceneAudioRow`'s `empty`), so an empty
163
+ // master lane under it was a second "Audio" row with the same box, one that
164
+ // drops into the bed instead of the shot. The Project keeps the master lane
165
+ // always: there it is the only way into the bed. Nothing sums this lane's
166
+ // height, so dropping it moves no spacer.
167
+ if (shotWindow !== undefined && clips.length === 0 && !importing) return null;
53
168
 
54
169
  /**
55
170
  * NEW CLIPS LAND AT THE PLAYHEAD, dropped ones included.
@@ -60,22 +175,16 @@ export function AudioLane(props: { ppf: number; labelWidth: number; beginSnap?:
60
175
  * one would make "where does the thing I make appear" a question with two
61
176
  * answers. The clip is draggable the moment it exists.
62
177
  */
63
- const importFiles = (files: FileList | null): void => {
64
- const file = files?.[0];
65
- if (file === undefined) return;
66
- setError(null);
67
- setImporting(true);
68
- void store
69
- .importAudio(file)
70
- .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
71
- .finally(() => setImporting(false));
72
- };
178
+ const importFiles = (files: FileList | null): void =>
179
+ importEach(store, files, undefined, setImporting, setError);
73
180
 
74
181
  return (
75
182
  <div
76
183
  className={`audio-lane${over ? " is-over" : ""}`}
77
184
  data-testid="audio-lane"
78
185
  onDragOver={(e) => {
186
+ // Files only: a shot reorder or an asset drag is not an import.
187
+ if (!e.dataTransfer.types.includes("Files")) return;
79
188
  e.preventDefault();
80
189
  setOver(true);
81
190
  }}
@@ -87,34 +196,13 @@ export function AudioLane(props: { ppf: number; labelWidth: number; beginSnap?:
87
196
  }}
88
197
  >
89
198
  <div className="audio-label-cell" style={{ width: labelWidth }}>
90
- <span className="audio-lane-name">Audio</span>
91
- <button
92
- type="button"
93
- className="audio-add"
94
- title="Import audio at the playhead"
95
- disabled={importing}
96
- onClick={() => fileRef.current?.click()}
97
- >
98
- {importing ? "…" : "+"}
99
- </button>
100
- <input
101
- ref={fileRef}
102
- className="audio-file-input"
103
- type="file"
104
- accept="audio/*"
105
- hidden
106
- data-testid="audio-file-input"
107
- onChange={(e) => {
108
- importFiles(e.currentTarget.files);
109
- // Cleared so re-picking the SAME file fires change again.
110
- e.currentTarget.value = "";
111
- }}
112
- />
199
+ {/* Sound's teal square, the swatch every track row leads with. */}
200
+ <span className="audio-swatch" aria-hidden="true" />
201
+ <span className="audio-lane-name">Sound</span>
202
+ <AddSoundButton importing={importing} />
113
203
  </div>
114
204
  <div className="audio-track">
115
- {clips.length === 0 && !importing ? (
116
- <span className="audio-empty">Drop an audio file here, or press +</span>
117
- ) : null}
205
+ {clips.length === 0 && !importing ? <AudioDropBox /> : null}
118
206
  {clips.map((clip) => (
119
207
  <AudioClipBar
120
208
  key={clip.id}
@@ -122,7 +210,10 @@ export function AudioLane(props: { ppf: number; labelWidth: number; beginSnap?:
122
210
  asset={doc.assets[clip.asset]}
123
211
  fps={doc.fps}
124
212
  ppf={ppf}
213
+ offsetFrames={0}
214
+ originFrames={origin}
125
215
  beginSnap={beginSnap}
216
+ waveH={MASTER_WAVE_H}
126
217
  />
127
218
  ))}
128
219
  </div>
@@ -135,17 +226,340 @@ export function AudioLane(props: { ppf: number; labelWidth: number; beginSnap?:
135
226
  );
136
227
  }
137
228
 
138
- function AudioClipBar(props: {
229
+ /**
230
+ * A Sound row's "+": the Sound tab, where adding is Import, Library and
231
+ * Generate on one row per track. These rows keep the drop (a file dragged onto
232
+ * the lane still lands at the playhead), not a second picker.
233
+ */
234
+ function AddSoundButton({ importing }: { importing: boolean }) {
235
+ const store = useStore();
236
+ return (
237
+ <button
238
+ type="button"
239
+ className="audio-add"
240
+ data-testid="audio-add"
241
+ aria-label="Add sound"
242
+ title="Add sound: opens the Sound tab"
243
+ disabled={importing}
244
+ onClick={() => store.setDockMode("sound")}
245
+ >
246
+ {importing ? "…" : "+"}
247
+ </button>
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Import every file, one after another, so a multi-file drop loses nothing.
253
+ * Each store error already names its file, so the failures are shown together.
254
+ */
255
+ export function importEach(
256
+ store: ReturnType<typeof useStore>,
257
+ files: FileList | null,
258
+ sceneId: string | undefined,
259
+ setImporting: (busy: boolean) => void,
260
+ setError: (message: string | null) => void,
261
+ trackId?: string,
262
+ ): void {
263
+ // Copied now: the picker clears its input right after this returns.
264
+ const list = Array.from(files ?? []);
265
+ if (list.length === 0) return;
266
+ setError(null);
267
+ setImporting(true);
268
+ void (async () => {
269
+ const failed: string[] = [];
270
+ for (const file of list) {
271
+ try {
272
+ await store.importAudio(file, undefined, sceneId, trackId);
273
+ } catch (e) {
274
+ failed.push(e instanceof Error ? e.message : String(e));
275
+ }
276
+ }
277
+ setError(failed.length === 0 ? null : failed.join(" "));
278
+ setImporting(false);
279
+ })();
280
+ }
281
+
282
+ /** A clip's name: its file name without the store's hash prefix. */
283
+ export function clipLabel(clip: AudioClipRef, asset: ShotAsset | undefined): string {
284
+ return asset?.path.replace(/^[0-9a-f]{8}-/, "") ?? clip.asset;
285
+ }
286
+
287
+ /** The duck popover's rows. Two clips cut from one file share a name, so a
288
+ * repeated name gets a count ("voiceover.wav · 2") to keep the rows apart. */
289
+ function duckRows(
290
+ clips: readonly AudioClipRef[],
291
+ assets: Readonly<Record<string, ShotAsset>>,
292
+ ): { id: string; label: string }[] {
293
+ const labels = clips.map((c) => clipLabel(c, assets[c.asset]));
294
+ const seen = new Map<string, number>();
295
+ return clips.map((c, i) => {
296
+ const label = labels[i]!;
297
+ const n = (seen.get(label) ?? 0) + 1;
298
+ seen.set(label, n);
299
+ return { id: c.id, label: labels.indexOf(label) === labels.lastIndexOf(label) ? label : `${label} · ${n}` };
300
+ });
301
+ }
302
+
303
+ /**
304
+ * DUCKING, from the bar.
305
+ *
306
+ * The control is a popover rather than a drag, because a duck is a
307
+ * RELATIONSHIP: it names other clips, and there is no gesture on one bar that
308
+ * expresses "under these". The button stays visible at rest whether or not the
309
+ * clip ducks, which is the design system's rule about a capability's only
310
+ * affordance; what changes is whether it reads as armed.
311
+ */
312
+ function DuckButton({
313
+ clip,
314
+ candidates,
315
+ store,
316
+ }: {
317
+ clip: AudioClipRef;
318
+ /** Every OTHER clip in the document, named. A duck may name a clip in either
319
+ * home, which is why this is the flatten and not the lane. */
320
+ candidates: readonly { id: string; label: string }[];
321
+ store: ReturnType<typeof useStore>;
322
+ }) {
323
+ const [open, setOpen] = useState(false);
324
+ const duck = clip.duck;
325
+ const sources = duck?.sources ?? [];
326
+ const strength = duck?.strength ?? DUCK_DEFAULT_STRENGTH;
327
+
328
+ const toggle = (id: string): void => {
329
+ const next = sources.includes(id) ? sources.filter((s) => s !== id) : [...sources, id];
330
+ store.setAudioDuck(clip.id, next, duck?.strength);
331
+ };
332
+
333
+ return (
334
+ <span className="audio-duck">
335
+ <button
336
+ type="button"
337
+ className="audio-clip-btn"
338
+ aria-label={duck === undefined ? "Duck under a voice" : "Ducking"}
339
+ aria-pressed={duck !== undefined}
340
+ aria-expanded={open}
341
+ data-testid={`audio-duck-${clip.id}`}
342
+ title={
343
+ duck === undefined
344
+ ? "Duck under a voice: hold this clip down while another one speaks"
345
+ : `Ducking under ${sources.length} clip(s) at ${Math.round(strength * 100)}%`
346
+ }
347
+ onPointerDown={(e) => e.stopPropagation()}
348
+ onClick={() => setOpen((v) => !v)}
349
+ >
350
+ ⌄
351
+ </button>
352
+ {open && (
353
+ <div className="audio-duck-pop" onPointerDown={(e) => e.stopPropagation()}>
354
+ {candidates.length === 0 ? (
355
+ <p className="audio-duck-empty">
356
+ Nothing to duck under yet. Add the narration, then come back.
357
+ </p>
358
+ ) : (
359
+ <>
360
+ <p className="audio-duck-title">Hold this down while these speak</p>
361
+ {candidates.map((other) => (
362
+ <label key={other.id} className="audio-duck-row">
363
+ <input
364
+ type="checkbox"
365
+ checked={sources.includes(other.id)}
366
+ onChange={() => toggle(other.id)}
367
+ />
368
+ <span>{other.label}</span>
369
+ </label>
370
+ ))}
371
+ <label className="audio-duck-row audio-duck-strength">
372
+ <span>Depth</span>
373
+ <input
374
+ type="range"
375
+ min={0.05}
376
+ max={1}
377
+ step={0.05}
378
+ value={strength}
379
+ disabled={sources.length === 0}
380
+ aria-label="Duck depth"
381
+ onChange={(e) => store.setAudioDuck(clip.id, sources, Number(e.currentTarget.value))}
382
+ />
383
+ {/* The dB, because that is the unit the decision is made in.
384
+ 0.25 is 6 dB, which is the default for a reason. */}
385
+ <span className="audio-duck-db">{Math.round(strength * DUCK_MAX_DEPTH_DB)} dB</span>
386
+ </label>
387
+ </>
388
+ )}
389
+ </div>
390
+ )}
391
+ </span>
392
+ );
393
+ }
394
+
395
+ /**
396
+ * A SHOT'S OWN AUDIO, drawn inside that shot's group.
397
+ *
398
+ * Last in the group on purpose: video above, audio below, the way the master
399
+ * bed sits under every scene group in the dock. The clips are scene-local and
400
+ * `offsetFrames` puts them back on the ruler's master base.
401
+ *
402
+ * DRAWN ONLY WHEN THE SHOT HAS SOME, unless the caller asks for the empty
403
+ * row (`empty`). Over the Project the master lane is always present because it
404
+ * teaches the import (see `TimelineDock`'s `laneShown`). The boards also draw a drop box in
405
+ * a shot's own audio row, and `empty` is that row: the same box as the master
406
+ * lane's, dropping into this shot. The dock passes it inside an entered shot,
407
+ * where it is the one audio row on screen. It is opt-in because the dock's
408
+ * group height sum (`groupHeights`) adds `SHOT_AUDIO_ROW_H` only when the shot
409
+ * has audio OR is the entered shot, the same predicate the dock passes `empty`
410
+ * on, and a row drawn without that count would push every group under it off
411
+ * its spacer. Over the Project, the way in is a drop on this row once a clip is
412
+ * here, or the clip's own ⇥ button, which is the shortest path from "I
413
+ * imported some music" to "this whoosh belongs to that cut". The `+` opens the
414
+ * Sound tab, the one place sound is added from a picker.
415
+ */
416
+ export function SceneAudioRow(props: {
417
+ sceneId: string;
418
+ ppf: number;
419
+ labelWidth: number;
420
+ /** The shot's start on the master timeline. */
421
+ offsetFrames: number;
422
+ /** The shot context's re-zero, 0 in the scene context. */
423
+ originFrames: number;
424
+ beginSnap?: SnapPress;
425
+ /** Draw the row and its drop box even with no clips. The caller must count
426
+ * the row's height whenever it passes this (see above). */
427
+ empty?: boolean;
428
+ }) {
429
+ const { sceneId, ppf, labelWidth, offsetFrames, originFrames, beginSnap, empty = false } = props;
430
+ const store = useStore();
431
+ const doc = useShotEditor((s) => s.doc);
432
+ const [over, setOver] = useState(false);
433
+ const [importing, setImporting] = useState(false);
434
+ const [error, setError] = useState<string | null>(null);
435
+
436
+ const scene = doc?.scenesById[sceneId];
437
+ const clips = Object.values(scene?.audio ?? {});
438
+ if (doc === null || scene === undefined || (clips.length === 0 && !importing && !empty)) return null;
439
+
440
+ // The SHOT is the home, and the playhead is converted into its base by the
441
+ // store — this row never does frame arithmetic of its own.
442
+ const importFiles = (files: FileList | null): void =>
443
+ importEach(store, files, sceneId, setImporting, setError);
444
+
445
+ return (
446
+ <div
447
+ className={`scene-audio-row${over ? " is-over" : ""}`}
448
+ data-testid={`scene-audio-${sceneId}`}
449
+ data-scene-audio={sceneId}
450
+ onDragOver={(e) => {
451
+ if (!e.dataTransfer.types.includes("Files")) return;
452
+ e.preventDefault();
453
+ setOver(true);
454
+ }}
455
+ onDragLeave={() => setOver(false)}
456
+ onDrop={(e) => {
457
+ e.preventDefault();
458
+ // STOPPED, so the master lane's own drop handler does not also fire and
459
+ // make a second clip in the other home from one file.
460
+ e.stopPropagation();
461
+ setOver(false);
462
+ importFiles(e.dataTransfer?.files ?? null);
463
+ }}
464
+ >
465
+ <div className="scene-audio-label" style={{ width: labelWidth }}>
466
+ <span className="audio-swatch" aria-hidden="true" />
467
+ <span className="scene-audio-name">Sound</span>
468
+ <AddSoundButton importing={importing} />
469
+ </div>
470
+ <div className="audio-track">
471
+ {clips.length === 0 && !importing ? <AudioDropBox /> : null}
472
+ {clips.map((clip) => (
473
+ <AudioClipBar
474
+ key={clip.id}
475
+ clip={clip}
476
+ asset={doc.assets[clip.asset]}
477
+ fps={doc.fps}
478
+ ppf={ppf}
479
+ offsetFrames={offsetFrames}
480
+ originFrames={originFrames}
481
+ beginSnap={beginSnap}
482
+ ownedBy={sceneId}
483
+ limitFrames={Math.max(0, Math.trunc(scene.durationFrames))}
484
+ waveH={SHOT_WAVE_H}
485
+ />
486
+ ))}
487
+ </div>
488
+ {error === null ? null : (
489
+ <p className="audio-error" role="status">
490
+ {error}
491
+ </p>
492
+ )}
493
+ </div>
494
+ );
495
+ }
496
+
497
+ export function AudioClipBar(props: {
139
498
  clip: AudioClipRef;
140
499
  asset: ShotAsset | undefined;
141
500
  fps: Rational;
142
501
  ppf: number;
502
+ /** Where this clip's frame base starts on the MASTER timeline: 0 for a bed,
503
+ * the shot's start for a clip its shot owns. `BlockRow`'s prop, for the same
504
+ * reason and with the same meaning. */
505
+ offsetFrames: number;
506
+ /** The shot context's re-zero. DRAWING ONLY: every gesture below is a signed
507
+ * delta, which means the same thing in either base. */
508
+ originFrames: number;
143
509
  beginSnap: SnapPress | undefined;
510
+ /** Absent in the master lane. Present for a clip a shot owns, so the home
511
+ * button can say which way it would move. */
512
+ ownedBy?: string;
513
+ /**
514
+ * The owning shot's length, so the bar can mark what will NOT be heard.
515
+ *
516
+ * `masterAudioClips` cuts an owned clip at its shot's end, and the document
517
+ * keeps the authored length so growing the shot brings the tail back. That
518
+ * means the bar and the mixer legitimately disagree, and a bar that drew the
519
+ * authored 60 frames with nothing to say about the last 10 would be the exact
520
+ * silent divergence this lane exists to prevent — you would see audio, hear
521
+ * none, and have nothing to look at.
522
+ */
523
+ limitFrames?: number;
524
+ /** The bar's inner height in this row (`MASTER_WAVE_H` or `SHOT_WAVE_H`):
525
+ * the waveform, the fade ramp and the envelope all draw into it. */
526
+ waveH: number;
527
+ /**
528
+ * Sound mode's rows: which track row is under this screen y, or undefined
529
+ * off every row. Present only there. A body drag then carries the clip
530
+ * across rows in the SAME scrub as its time move, so one undo puts both back.
531
+ */
532
+ rowAt?: (clientY: number) => string | undefined;
533
+ onContextMenu?: (e: ReactMouseEvent<HTMLElement>) => void;
534
+ /** A short tag drawn after the name (Sound mode's "Shot 02"). */
535
+ badge?: string;
536
+ /**
537
+ * Sound mode's bar: the mixing controls too (the gain band and its keys, the
538
+ * duck, the gain key, the clip mute). Absent in Rows mode, where the bar
539
+ * moves, trims, fades and changes home, and draws the envelope it plays
540
+ * without editing it.
541
+ */
542
+ mix?: boolean;
144
543
  }) {
145
- const { clip, asset, fps, ppf, beginSnap } = props;
544
+ const { clip, asset, fps, ppf, offsetFrames, originFrames, beginSnap, ownedBy, limitFrames, waveH, rowAt } = props;
545
+ const mix = props.mix === true;
146
546
  const store = useStore();
547
+ const doc = useShotEditor((s) => s.doc);
548
+ const selected = useShotEditor((s) => s.selectedAudio.includes(clip.id));
147
549
  const c = audioClipView(clip);
148
- const left = frameToPx(c.frame, ppf);
550
+ // THE FLATTEN, so a bed can duck under narration a SHOT owns. Reading the
551
+ // lane instead would offer only the clips drawn beside this one, which is
552
+ // the commonest case and not the only one.
553
+ const duckCandidates =
554
+ doc === null || !mix
555
+ ? []
556
+ : duckRows(
557
+ masterAudioClips(doc)
558
+ .map((m) => m.clip)
559
+ .filter((other) => other.id !== clip.id),
560
+ doc.assets,
561
+ );
562
+ const left = frameToPx(offsetFrames + c.frame - originFrames, ppf);
149
563
  const width = Math.max(8, frameToPx(c.durationFrames, ppf));
150
564
 
151
565
  /**
@@ -161,25 +575,44 @@ function AudioClipBar(props: {
161
575
  (e: ReactPointerEvent<HTMLElement>): void => {
162
576
  e.preventDefault();
163
577
  e.stopPropagation();
578
+ if (e.button !== 0) return;
579
+ // THE PICK. A press on a clip already in a multi-pick keeps the pick, so
580
+ // the ⌥ stagger below has every clip to rank; ⇧ toggles.
581
+ const state = store.getState();
582
+ if (e.shiftKey) store.selectAudio([clip.id], "toggle");
583
+ else if (!state.selectedAudio.includes(clip.id)) store.selectAudio([clip.id]);
584
+ const picked = store.getState().selectedAudio;
585
+ // ⌥ on a multi-pick in Sound mode STAGGERS the pick by rank; on one clip
586
+ // it slips the source, as it always has.
587
+ const stagger = mode === "body" && e.altKey && rowAt !== undefined && picked.length > 1;
164
588
  // Read AT THE PRESS: a gesture whose meaning changed under a key held
165
589
  // mid-drag would slip a clip the author was only moving.
166
590
  const effective: AudioDrag = mode === "body" && e.altKey ? "slip" : mode;
167
591
  const startX = e.clientX;
168
592
  const snap = beginSnap?.({ audioClipId: clip.id }, isSnapSuppressed(e));
593
+ // MASTER frames, because every target on the snapper's list is: a clip a
594
+ // shot owns is numbered from that shot, so its own base has to be added
595
+ // in or the whole gesture snaps to the wrong side of the video.
169
596
  const anchor =
170
- effective === "out"
597
+ offsetFrames +
598
+ (effective === "out"
171
599
  ? c.frame + c.durationFrames
172
600
  : effective === "fade-in"
173
601
  ? c.frame + c.fadeInFrames
174
602
  : effective === "fade-out"
175
603
  ? c.frame + c.durationFrames - c.fadeOutFrames
176
- : c.frame;
604
+ : c.frame);
177
605
  store.beginBlockDrag();
178
606
  startDrag(e, {
179
607
  onMove(ev) {
180
608
  const raw = frameDelta(ev.clientX - startX, ppf);
609
+ if (stagger) {
610
+ store.dragAudioStagger(picked, raw);
611
+ return;
612
+ }
181
613
  const delta = snap === undefined ? raw : snap(anchor + raw) - anchor;
182
- store.dragAudio(clip.id, effective, delta);
614
+ const row = effective === "body" ? rowAt?.(ev.clientY) : undefined;
615
+ store.dragAudio(clip.id, effective, delta, row);
183
616
  },
184
617
  onEnd: (commit) => store.endBlockDrag(commit),
185
618
  });
@@ -192,7 +625,7 @@ function AudioClipBar(props: {
192
625
  e.stopPropagation();
193
626
  const startY = e.clientY;
194
627
  const from = c.gain;
195
- const height = e.currentTarget.parentElement?.clientHeight ?? WAVE_H;
628
+ const height = e.currentTarget.parentElement?.clientHeight ?? waveH;
196
629
  store.beginBlockDrag();
197
630
  startDrag(e, {
198
631
  onMove(ev) {
@@ -205,38 +638,103 @@ function AudioClipBar(props: {
205
638
 
206
639
  const fadeInPx = frameToPx(c.fadeInFrames, ppf);
207
640
  const fadeOutPx = frameToPx(c.fadeOutFrames, ppf);
641
+ const track = clip.gainTrack;
642
+ /** Frames past the shot's end — audible in the document, silent in the mix. */
643
+ const overrun = limitFrames === undefined ? 0 : Math.max(0, c.frame + c.durationFrames - limitFrames);
208
644
 
209
645
  return (
210
646
  <div
211
- className={`audio-clip${c.muted ? " is-muted" : ""}`}
647
+ className={`audio-clip${c.muted ? " is-muted" : ""}${selected ? " is-selected" : ""}`}
212
648
  data-audio-clip={clip.id}
213
649
  style={{ left, width }}
214
650
  onPointerDown={drag("body")}
215
- title={`${asset?.path ?? clip.asset} · drag to move it and its markers, ⌥drag to slip the source, ⌘drag to ignore snapping`}
651
+ onContextMenu={props.onContextMenu}
652
+ title={formatCombo(`${asset?.path ?? clip.asset} · drag to move it and its markers, ⌥drag to slip the source, ⌘drag to ignore snapping`)}
216
653
  >
217
- <AudioWaveform clip={c} asset={asset} fps={fps} widthPx={width} />
654
+ <AudioWaveform clip={c} asset={asset} fps={fps} widthPx={width} heightPx={waveH} />
218
655
 
219
656
  {/* The fade ramps, drawn as ONE polyline over the whole bar so the two
220
657
  fades and the flat middle cannot disagree about the level between
221
658
  them. Inert: the corner handles below take the pointer. */}
222
659
  {c.fadeInFrames > 0 || c.fadeOutFrames > 0 ? (
223
- <svg className="audio-fade-curve" viewBox={`0 0 ${width} ${WAVE_H}`} preserveAspectRatio="none" aria-hidden="true">
660
+ <svg className="audio-fade-curve" viewBox={`0 0 ${width} ${waveH}`} preserveAspectRatio="none" aria-hidden="true">
224
661
  <polyline
225
- points={`0,${WAVE_H} ${fadeInPx},0 ${Math.max(fadeInPx, width - fadeOutPx)},0 ${width},${WAVE_H}`}
662
+ points={`0,${waveH} ${fadeInPx},0 ${Math.max(fadeInPx, width - fadeOutPx)},0 ${width},${waveH}`}
226
663
  fill="none"
227
664
  />
228
665
  </svg>
229
666
  ) : null}
230
667
 
231
- <span
232
- className="audio-gain-band"
233
- data-handle="audio-gain"
234
- style={{ bottom: `${(c.gain / MAX_AUDIO_GAIN) * 100}%` }}
235
- onPointerDown={dragGain}
236
- title={`Gain ${c.gain.toFixed(2)} — drag up or down`}
237
- />
668
+ {/* THE GAIN, one control in two shapes. A clip with no automation gets
669
+ the flat rubber band it always had. One with a `gainTrack` gets the
670
+ ENVELOPE the mixer will actually apply, sampled through the engine so
671
+ the line on screen and the samples in the file are the same function —
672
+ and the band then edits the key under the playhead rather than a
673
+ static number the engine would ignore. */}
674
+ {!mix ? (
675
+ track === undefined ? null : (
676
+ <svg
677
+ className="audio-gain-curve"
678
+ data-testid={`audio-envelope-${clip.id}`}
679
+ viewBox={`0 0 ${width} ${waveH}`}
680
+ preserveAspectRatio="none"
681
+ aria-hidden="true"
682
+ >
683
+ <polyline points={envelopePoints(track, c, fps, width, ppf, waveH)} fill="none" />
684
+ </svg>
685
+ )
686
+ ) : track === undefined ? (
687
+ <span
688
+ className="audio-gain-band"
689
+ data-handle="audio-gain"
690
+ style={{ bottom: `${(c.gain / MAX_AUDIO_GAIN) * 100}%` }}
691
+ onPointerDown={dragGain}
692
+ title={`Gain ${c.gain.toFixed(2)}: drag up or down`}
693
+ />
694
+ ) : (
695
+ <>
696
+ <svg
697
+ className="audio-gain-curve"
698
+ data-testid={`audio-envelope-${clip.id}`}
699
+ viewBox={`0 0 ${width} ${waveH}`}
700
+ preserveAspectRatio="none"
701
+ aria-hidden="true"
702
+ >
703
+ <polyline points={envelopePoints(track, c, fps, width, ppf, waveH)} fill="none" />
704
+ </svg>
705
+ <span className="audio-gain-band is-automated" data-handle="audio-gain" onPointerDown={dragGain} />
706
+ {track.keyframes.map((k) => (
707
+ <span
708
+ key={k.id}
709
+ className="audio-gain-key"
710
+ data-audio-gain-key={k.id}
711
+ style={{
712
+ left: frameToPx(k.frame, ppf),
713
+ bottom: `${(gainOf(k.value) / MAX_AUDIO_GAIN) * 100}%`,
714
+ }}
715
+ title={`Gain ${gainOf(k.value).toFixed(2)} at +${k.frame}f`}
716
+ />
717
+ ))}
718
+ </>
719
+ )}
238
720
 
239
- <span className="audio-clip-name">{asset?.path.replace(/^[0-9a-f]{8}-/, "") ?? clip.asset}</span>
721
+ {/* WHAT WILL NOT BE HEARD. Hatched rather than cut off, because the
722
+ document still holds these frames: lengthening the shot brings them
723
+ back, and a bar that simply ended at the boundary would give the
724
+ author nothing to drag. */}
725
+ {overrun > 0 ? (
726
+ <span
727
+ className="audio-overrun"
728
+ data-testid={`audio-overrun-${clip.id}`}
729
+ style={{ left: frameToPx(c.durationFrames - overrun, ppf) }}
730
+ title={`${overrun}f past the end of this shot: kept, but not heard. Lengthen the shot or trim the clip.`}
731
+ />
732
+ ) : null}
733
+
734
+ <span className="audio-clip-name">
735
+ {clipLabel(clip, asset)}
736
+ {props.badge === undefined ? null : <span className="audio-clip-badge">{props.badge}</span>}
737
+ </span>
240
738
 
241
739
  <span
242
740
  className="audio-handle audio-handle-in"
@@ -266,16 +764,53 @@ function AudioClipBar(props: {
266
764
  />
267
765
 
268
766
  <span className="audio-clip-buttons">
767
+ {mix ? <AudioFxButton clip={clip} asset={asset} store={store} /> : null}
768
+ {mix ? <DuckButton clip={clip} candidates={duckCandidates} store={store} /> : null}
769
+ {/* THE HOME SWITCH. One bit, so a target rather than a drop zone: a
770
+ cross-lane drag would have to hit-test another lane mid-capture to
771
+ carry the same information. Attaching picks the shot the clip
772
+ STARTS in, which is the only shot it can be said to belong to. */}
269
773
  <button
270
774
  type="button"
271
775
  className="audio-clip-btn"
272
- aria-label={c.muted ? "Unmute clip" : "Mute clip"}
273
- aria-pressed={c.muted}
776
+ aria-label={ownedBy === undefined ? "Attach to the shot under it" : "Move to the master bed"}
777
+ title={
778
+ ownedBy === undefined
779
+ ? "Attach to the shot it starts in: it will move, duplicate and end with that shot"
780
+ : "Move to the master bed: it will span the cuts and stop following this shot"
781
+ }
274
782
  onPointerDown={(e) => e.stopPropagation()}
275
- onClick={() => store.toggleAudioMute(clip.id)}
783
+ onClick={() => store.moveAudioHome(clip.id)}
276
784
  >
277
- {c.muted ? "🔇" : "🔊"}
785
+ {ownedBy === undefined ? "⇥" : "⇤"}
278
786
  </button>
787
+ {/* THE AUTOMATION KEY, at the playhead. A toggle, because the same
788
+ button is both states and a second control for "remove" would be a
789
+ control that is disabled almost always. */}
790
+ {mix ? (
791
+ <button
792
+ type="button"
793
+ className="audio-clip-btn"
794
+ aria-label="Keyframe the gain at the playhead"
795
+ title="Keyframe the gain at the playhead: the first key changes nothing, it just makes the level automatable"
796
+ onPointerDown={(e) => e.stopPropagation()}
797
+ onClick={() => store.toggleAudioGainKey(clip.id)}
798
+ >
799
+ ◆
800
+ </button>
801
+ ) : null}
802
+ {mix ? (
803
+ <button
804
+ type="button"
805
+ className="audio-clip-btn"
806
+ aria-label={c.muted ? "Unmute clip" : "Mute clip"}
807
+ aria-pressed={c.muted}
808
+ onPointerDown={(e) => e.stopPropagation()}
809
+ onClick={() => store.toggleAudioMute(clip.id)}
810
+ >
811
+ {c.muted ? <IconVolumeOff /> : <IconVolume />}
812
+ </button>
813
+ ) : null}
279
814
  <button
280
815
  type="button"
281
816
  className="audio-clip-btn"
@@ -298,13 +833,23 @@ function AudioClipBar(props: {
298
833
  * decimation out of it — nothing here refetches. (Not the asset id: that is a
299
834
  * filename slug this lane mints per document, so it repeats across shots.)
300
835
  */
301
- function AudioWaveform(props: {
836
+ export function AudioWaveform(props: {
302
837
  clip: { offsetFrames: number; durationFrames: number };
303
- asset: ShotAsset | undefined;
838
+ /**
839
+ * The asset, by IDENTITY. The fetch below keys its effect on this prop, so a
840
+ * caller must hand it the same object across renders — `{ path }` built in
841
+ * the render would refetch and re-`setData` forever. The row models carry the
842
+ * resolved `ShotAsset` for exactly that reason.
843
+ */
844
+ asset: { path: string } | undefined;
304
845
  fps: Rational;
305
846
  widthPx: number;
847
+ /** Canvas px: the inner height of the box it is drawn into, which differs
848
+ * per row (the master lane, a shot's audio row, a video row's sound strip). */
849
+ heightPx: number;
306
850
  }) {
307
- const { clip, asset, fps, widthPx } = props;
851
+ const { clip, asset, fps, widthPx, heightPx } = props;
852
+ const { resolved: resolvedTheme } = useStudioTheme();
308
853
  const canvasRef = useRef<HTMLCanvasElement>(null);
309
854
  const [data, setData] = useState<PeakData | null>(null);
310
855
 
@@ -326,15 +871,59 @@ function AudioWaveform(props: {
326
871
  useEffect(() => {
327
872
  const canvas = canvasRef.current;
328
873
  if (canvas === null || data === null || data.peaks.length === 0) return;
874
+ const waveColor = getComputedStyle(canvas).getPropertyValue("--audio-waveform").trim();
329
875
  drawClipWaveform(canvas, {
330
876
  data,
331
877
  fromSeconds: framesToSeconds(clip.offsetFrames, fps),
332
878
  toSeconds: framesToSeconds(clip.offsetFrames + clip.durationFrames, fps),
333
879
  widthPx,
334
- heightPx: WAVE_H,
335
- color: WAVE_COLOR,
880
+ heightPx,
881
+ color: waveColor || WAVE_COLOR_FALLBACK,
336
882
  });
337
- }, [data, widthPx, fps, clip.offsetFrames, clip.durationFrames]);
883
+ }, [data, widthPx, heightPx, fps, clip.offsetFrames, clip.durationFrames, resolvedTheme]);
338
884
 
339
885
  return <canvas className="audio-wave" ref={canvasRef} aria-hidden="true" />;
340
886
  }
887
+
888
+ /** A track value as a gain. Guarded rather than cast: the track shape allows a
889
+ * vector, and a validator problem must not become a NaN in a `points` list
890
+ * that then removes the whole polyline. */
891
+ function gainOf(value: unknown): number {
892
+ return typeof value === "number" && Number.isFinite(value) ? value : 1;
893
+ }
894
+
895
+ /**
896
+ * The envelope polyline, in the bar's own pixel space.
897
+ *
898
+ * SAMPLED THROUGH THE ENGINE, one point per drawn pixel rather than one per
899
+ * keyframe, because the segments between keys are eased (and may be springs)
900
+ * and a straight line between two keys would draw a curve the mixer is not
901
+ * going to apply. `audioGainSampler` is the same function the preview and both
902
+ * export mixes take their values from, so the line and the samples cannot
903
+ * disagree — which is the whole reason this is not a local lerp.
904
+ *
905
+ * The FADES are deliberately not folded in. They already have their own
906
+ * polyline over the bar, and multiplying the two would draw one line the author
907
+ * could not take either control back off.
908
+ */
909
+ function envelopePoints(
910
+ track: ShotTrack,
911
+ clip: { durationFrames: number },
912
+ fps: Rational,
913
+ widthPx: number,
914
+ ppf: number,
915
+ heightPx: number,
916
+ ): string {
917
+ const sample = audioGainSampler(track, 0, fps);
918
+ if (sample === undefined) return "";
919
+ // One sample per pixel, capped: a bar zoomed to thousands of pixels does not
920
+ // need thousands of points to read as a curve, and this runs per render.
921
+ const steps = Math.max(2, Math.min(240, Math.round(widthPx)));
922
+ const out: string[] = [];
923
+ for (let i = 0; i <= steps; i++) {
924
+ const frame = (i / steps) * clip.durationFrames;
925
+ const y = heightPx * (1 - gainOf(sample(frame) ?? 1) / MAX_AUDIO_GAIN);
926
+ out.push(`${frameToPx(frame, ppf)},${y}`);
927
+ }
928
+ return out.join(" ");
929
+ }