@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
@@ -12,17 +12,48 @@
12
12
  // the fade maths is exactly how preview stops matching export, and the F3-A
13
13
  // review caught that divergence in the wild once already.
14
14
  //
15
- // There are no gain TRACKS here. Doc3D has blocks, not tracks, and no block
16
- // writes a clip's gain — so `sampledGain` is never supplied and the envelope
17
- // falls back to the clip's static gain, which is what the shot exporter mixes.
15
+ // That includes the DE-CLICK: the engine ramps every clip edge the author did
16
+ // not fade, so nothing here opens or closes a voice by hand. A voice scheduled
17
+ // from the middle of a clip gets the same ramp for the same reason — it is a
18
+ // transport artefact, and the engine is where that is decided.
18
19
  //
19
20
  // ── TIME MODEL ──────────────────────────────────────────────────────────
20
21
  //
21
22
  // The chrome's rAF transport is the authority; this anchors frame→audio-time as
22
- // t(f) = anchorTime + framesToSeconds(f - anchor, fps) per (re)schedule. Both
23
- // clocks are wall-clock and drift between them is NOT corrected: a reschedule
24
- // (scrub, doc edit, unmute) re-anchors. Only edges, discontinuities and doc
25
- // identity changes do work; the per-frame path just records the snapshot.
23
+ // t(f) = anchorTime + framesToSeconds(f - anchor, fps), ONCE per session. The
24
+ // anchor pair is STORED, so everything that joins the session later — a late
25
+ // decode, a clip the author just added — lands on the same timeline as the
26
+ // voices already playing rather than on a fresh reading of two clocks taken a
27
+ // moment apart.
28
+ //
29
+ // The play edge's session anchors on the TRANSPORT'S ORIGIN (`playOrigin`,
30
+ // bridge v51): the frame the run started on, at the audio time of the wall
31
+ // instant it started. The picture clock (Header.tsx) counts from that same
32
+ // instant, so picture and sound start together rather than a React commit
33
+ // apart. A later session of the same run (an unmute, a re-anchor) takes its
34
+ // own reading, as the picture does after a rate or window change.
35
+ //
36
+ // Drift between the rAF clock and the AudioContext clock is CHECKED rather than
37
+ // assumed away: when the picture's frame and the audio clock disagree by more
38
+ // than a frame, the session re-anchors. A scrub and a loop wrap are that
39
+ // disagreement in the large. A main-thread stall is NOT: the transport derives
40
+ // its frame from absolute wall time (Header.tsx), so after a stall the picture
41
+ // catches up to where the audio already is and the two still agree. It is
42
+ // checked only when the FRAME moved: a notification that moved anything else
43
+ // says nothing about the picture's clock, and the audio has run on since the
44
+ // last frame by up to a frame of its own. The audio may also be one refresh
45
+ // further AHEAD (`DRIFT_REFRESH_S`): the transport picks a frame for the start
46
+ // of the refresh it draws on (rAF's timestamp), and this reads its clock later
47
+ // in that same tick.
48
+ //
49
+ // ── DECODED AHEAD ───────────────────────────────────────────────────────
50
+ //
51
+ // Once the stage has presented the open document whole (its ready edge), the
52
+ // heard clips decode one at a time, nearest the playhead first, at low fetch
53
+ // priority, into the cache the session schedules from. A decode needs no
54
+ // realtime context (`AudioPreviewDeps.decode`), so none exists before the
55
+ // first press: a queued Play's ARM creates or resumes it inside the press,
56
+ // and the transport starts later by itself.
26
57
  //
27
58
  // ── AND IT MOUNTS FROM THE CHROME ───────────────────────────────────────
28
59
  //
@@ -33,10 +64,35 @@
33
64
  //
34
65
  // No DOM lib types: every Web Audio surface is a minimal structural *Like type
35
66
  // and every effect is injected, so the jsdom tests drive a hand-rolled fake.
36
- import { audioClipWindow, audioEnvelopePoints, audioSourceTimeAt, framesToSeconds } from "@dailies-studio/engine";
37
- import type { AudioClip, Rational } from "@dailies-studio/engine";
38
- import { docDurationFrames } from "@dailies-studio/shot/doc";
39
- import type { AudioClipRef, Doc3D } from "@dailies-studio/shot/doc";
67
+ import {
68
+ AUDIO_EDGE_RAMP_SAMPLES,
69
+ MIX_SAMPLE_RATE,
70
+ audioClipWindow,
71
+ audioEnvelopePoints,
72
+ audioSourceTimeAt,
73
+ framesToSeconds,
74
+ panGains,
75
+ wirePan,
76
+ } from "@dailies-studio/engine";
77
+ import type {
78
+ PanContextLike as EnginePanContextLike,
79
+ PanStage as EnginePanStage,
80
+ Rational,
81
+ } from "@dailies-studio/engine";
82
+ import {
83
+ asEngineClip,
84
+ audioAnalysisFileName,
85
+ audioGainSampler,
86
+ audioPlayPath,
87
+ audioTracksOf,
88
+ derivedGainTracks,
89
+ docDurationFrames,
90
+ duckSourceAssets,
91
+ isAudioFxFileName,
92
+ masterAudioClips,
93
+ sceneWindows,
94
+ } from "@dailies-studio/shot/doc";
95
+ import type { AudioAnalysis, AudioClipRef, Doc3D, MasterAudioClip, ShotTrack } from "@dailies-studio/shot/doc";
40
96
 
41
97
  // --- minimal structural Web Audio surface (fakes stay trivial) ---------------
42
98
 
@@ -44,12 +100,30 @@ export interface AudioParamLike {
44
100
  value: number;
45
101
  setValueAtTime(value: number, time: number): unknown;
46
102
  linearRampToValueAtTime(value: number, time: number): unknown;
103
+ /** Needed to re-automate a voice that is already sounding: an edit has to
104
+ * drop the future the old envelope scheduled before it writes its own. */
105
+ cancelScheduledValues(time: number): unknown;
47
106
  }
48
107
 
49
108
  export interface GainNodeLike {
50
109
  readonly gain: AudioParamLike;
51
- connect(destination: unknown): unknown;
52
- disconnect(): void;
110
+ /** `output`/`input` pick a channel on a splitter or merger (the pan stage). */
111
+ connect(destination: unknown, output?: number, input?: number): unknown;
112
+ /** Written only by the pan stage, which needs a mono source UPMIXED before
113
+ * its splitter. Optional so the fakes that never pan stay as they are. */
114
+ channelCount?: number;
115
+ channelCountMode?: string;
116
+ channelInterpretation?: string;
117
+ /**
118
+ * The SELECTIVE form is optional here and required by the meter.
119
+ *
120
+ * A tap hangs off the master node and must unwire only itself
121
+ * (`meter.ts`'s `release`): calling the no-argument form on the monitored
122
+ * node would drop its real output as well, which is the whole mix. Declared
123
+ * as an optional parameter rather than a second method so every existing
124
+ * fake — each of which spells `disconnect(): void` — still satisfies it.
125
+ */
126
+ disconnect(destination?: unknown): void;
53
127
  }
54
128
 
55
129
  export interface AudioBufferLike {
@@ -62,11 +136,37 @@ export interface AudioBufferSourceNodeLike {
62
136
  start(when?: number, offset?: number, duration?: number): void;
63
137
  stop(when?: number): void;
64
138
  disconnect(): void;
139
+ /** Set so a voice released with a ramp disconnects ITSELF once the stop it
140
+ * scheduled has actually happened. Disconnecting earlier would cut the ramp
141
+ * off, which is the click the ramp exists to remove. */
142
+ onended: (() => void) | null;
143
+ }
144
+
145
+ /** A ChannelSplitter or ChannelMerger, as far as the pan stage wires one. */
146
+ export interface ChannelNodeLike {
147
+ connect(destination: unknown, output?: number, input?: number): unknown;
148
+ disconnect(): void;
65
149
  }
66
150
 
67
- export interface AudioContextLike {
151
+ /**
152
+ * The part of a context the pan stage (engine's `wirePan`) needs. The splitter
153
+ * pair is OPTIONAL so a jsdom fake that never pans stays trivial; the offline
154
+ * mix refuses a panned clip without it rather than mixing it centred.
155
+ */
156
+ export type PanContextLike = EnginePanContextLike<GainNodeLike, ChannelNodeLike>;
157
+
158
+ /** One voice's pan stage, as this lane's node types. */
159
+ export type PanStage = EnginePanStage<GainNodeLike, ChannelNodeLike>;
160
+
161
+ export interface AudioContextLike extends PanContextLike {
68
162
  readonly currentTime: number;
69
163
  readonly destination: unknown;
164
+ /** The device's rate. Nothing in the scheduler reads it — the envelope is
165
+ * the engine's and works in seconds — but the meter's worklet derives its
166
+ * own `dt` from it, and `MeterContextLike` (meter.ts) is satisfied by
167
+ * exactly this field. Declaring it here is what lets the monitor hand a
168
+ * real context straight to `attachMeter` with no cast. */
169
+ readonly sampleRate: number;
70
170
  createGain(): GainNodeLike;
71
171
  createBufferSource(): AudioBufferSourceNodeLike;
72
172
  resume?(): unknown;
@@ -74,13 +174,25 @@ export interface AudioContextLike {
74
174
  }
75
175
 
76
176
  export interface AudioPreviewDeps {
77
- /** Called lazily on the first audible play edge — a user gesture, so the
78
- * real context is born unblocked by autoplay policy. */
177
+ /** Called lazily inside the first press that could sound: a queued Play's
178
+ * arm, or a play edge that starts at once. Both run in the press's own call
179
+ * stack, so the real context is born unblocked by autoplay policy. */
79
180
  createContext(): AudioContextLike;
80
- fetchBytes(url: string): Promise<ArrayBuffer>;
81
- decode(ctx: AudioContextLike, bytes: ArrayBuffer): Promise<AudioBufferLike>;
181
+ /** `"low"` for a decode ahead, which must not compete with the stage's own
182
+ * loads; absent for a play edge's, which the author is waiting on. */
183
+ fetchBytes(url: string, priority?: "low"): Promise<ArrayBuffer>;
184
+ /**
185
+ * Decode with NO realtime context, so a clip can decode before the first
186
+ * press. The browser's is one `OfflineAudioContext(2, 1, MIX_SAMPLE_RATE)`,
187
+ * the export mix's rate (audio/index.tsx); a buffer plays on any context,
188
+ * resampled to its rate.
189
+ */
190
+ decode(bytes: ArrayBuffer): Promise<AudioBufferLike>;
82
191
  /** Audio-clock override for tests; defaults to ctx.currentTime. */
83
192
  now?(): number;
193
+ /** Wall-clock override for tests; defaults to `performance.now()`, the clock
194
+ * `TransportSnapshot.origin.at` is read on. */
195
+ wallNow?(): number;
84
196
  }
85
197
 
86
198
  /** The transport truth the host reads off the store on every change. */
@@ -88,6 +200,66 @@ export interface TransportSnapshot {
88
200
  playing: boolean;
89
201
  frame: number;
90
202
  doc: Doc3D | null;
203
+ /**
204
+ * Whether the MASTER timeline is what is playing, AT ITS OWN SPEED.
205
+ *
206
+ * Two things make it false, and they are one thing twice: a second clock the
207
+ * picture's cannot agree with. `transportAudible` in state/types.ts is the
208
+ * one place both are decided, and `audio/index.tsx` is its only caller — this
209
+ * scheduler is handed the answer rather than forming a second opinion.
210
+ *
211
+ * A COMPONENT CONTEXT drives a definition's own clock and `frame` does not
212
+ * move at all. Without this the preview heard a play edge, started the whole
213
+ * document's soundtrack from a static master frame, and played it over a
214
+ * definition looping on its own — with nothing to re-anchor it, because the
215
+ * frame it was watching never changed.
216
+ *
217
+ * ANY SHUTTLE RATE BUT 1 IS SILENT, and that is a refusal rather than a
218
+ * missing feature. Reverse needs reversed buffers and a pitch-preserved 2x
219
+ * needs a time stretcher; each is a second scheduler running its own
220
+ * arithmetic beside the picture's, and the first time the two round
221
+ * differently the author hears one frame while watching another. Playing the
222
+ * buffers at `playbackRate` instead would be worse still: it is a pitch shift
223
+ * the export will never make, so the preview would be wrong about what the
224
+ * film sounds like — the one thing this lane refuses above all.
225
+ */
226
+ audible: boolean;
227
+ /** The shot the author has ENTERED, whose window the transport loops inside;
228
+ * null on the master timeline. A voice must not outlive that window. */
229
+ scene: string | null;
230
+ /**
231
+ * The tracks this tab SOLOS, or absent/empty for the whole mix.
232
+ *
233
+ * Monitoring, not document state: it lives in the editor beside
234
+ * `previewMuted`, only `reconcile` reads it, and neither export mix ever
235
+ * sees it. A soloed tab hears a partial mix, which the mixer shows by dimming
236
+ * every track that is not soloed.
237
+ */
238
+ solo?: readonly string[];
239
+ /**
240
+ * A QUEUED PLAY waits (`playIntent`, bridge v51): the press has happened and
241
+ * the transport starts later, by itself, outside it. So the ARM is where the
242
+ * realtime context is created, or resumed after the page suspended it.
243
+ */
244
+ armed?: boolean;
245
+ /**
246
+ * WHERE AND WHEN THE TRANSPORT STARTED (`playOrigin`): its frame, and the
247
+ * `performance.now()` of the start. The play edge that brings a new one
248
+ * anchors its session on it, as the picture clock does.
249
+ */
250
+ origin?: TransportOrigin | null;
251
+ /**
252
+ * The stage has PRESENTED the open subject whole: one is attached and owes
253
+ * no presentation (`stageBusy` false). Its rising edge is the ready edge the
254
+ * decode ahead waits for.
255
+ */
256
+ presented?: boolean;
257
+ }
258
+
259
+ /** `ShotEditorState.playOrigin`, as the scheduler reads it. */
260
+ export interface TransportOrigin {
261
+ readonly frame: number;
262
+ readonly at: number;
91
263
  }
92
264
 
93
265
  export interface AudioPreview {
@@ -107,47 +279,245 @@ export interface AudioPreview {
107
279
  dispose(): void;
108
280
  }
109
281
 
110
- /** A frame jump beyond this (or any decrease) while playing is a scrub. */
111
- export const SCRUB_JUMP_FRAMES = 10;
282
+ // ── THE MONITOR TAP: WHERE A METER FACE FINDS THE GRAPH ────────────────────
283
+ //
284
+ // `audio/meter.ts` can measure any gain node, and there was no way for a
285
+ // component to GET one. The preview owns the only audio graph this chrome
286
+ // builds, it builds it lazily inside `ensureContext` on the first press (an
287
+ // arm or a play edge), and the preview object itself lives inside
288
+ // `AudioPreviewBridge`'s effect, which renders null and is reachable from
289
+ // nothing. So the one node worth metering — the master gain every voice is
290
+ // summed into, which is exactly what the author is hearing — is published
291
+ // here and subscribed to by the face.
292
+ //
293
+ // A MODULE-LEVEL SLOT AND NOT THE SHOT STORE, for `meterStore.ts`'s reason one
294
+ // level up: this is a live Web Audio node, it is not document state, it must
295
+ // never enter the undo history, and a face holding it through the store would
296
+ // pin the graph across every notification. It is also not the meter store,
297
+ // which carries READINGS (three floats at ~60 Hz) and knows nothing about
298
+ // nodes.
299
+ //
300
+ // IDENTITY IS THE CONTRACT, the same one `meterStore.readMeter` keeps:
301
+ // `readMonitor` is a `useSyncExternalStore` getter called during render, so it
302
+ // returns the SAME object until the graph actually changes and allocates
303
+ // nothing. Null until the first press, which is why `useMeterTap` takes a
304
+ // null source and starts measuring when one arrives.
305
+ //
306
+ // ponytail: ONE slot for the page, not one per preview. Exactly one
307
+ // `AudioPreviewBridge` mounts in this chrome and it owns the page's only
308
+ // AudioContext; a second preview would overwrite the first's publication.
309
+ // `dispose` therefore clears only its OWN graph, so the surviving one keeps
310
+ // its meter rather than both going dark. Key the slot by preview instance if a
311
+ // second bridge is ever mounted.
312
+
313
+ /** The channel a master meter reads, spelled once so the tap and the face
314
+ * cannot disagree about which name the readings arrive under. */
315
+ export const MASTER_METER_KEY = "master";
316
+
317
+ /** What a face needs to hang a tap: the context to compile the worklet into,
318
+ * and the node to measure. */
319
+ export interface MonitorGraph {
320
+ ctx: AudioContextLike;
321
+ node: GainNodeLike;
322
+ /**
323
+ * One unity BUS per audio track, keyed by track id, every voice on that
324
+ * track summed into it before the master. A tap on it reads that track
325
+ * alone (Sound mode's channel strips). Unity gain and a plain connect, so
326
+ * the master sum is the same with or without the buses. A NEW map whenever
327
+ * a bus is added, so a face's `useSyncExternalStore` sees the change.
328
+ */
329
+ tracks: ReadonlyMap<string, GainNodeLike>;
330
+ }
331
+
332
+ let monitor: MonitorGraph | null = null;
333
+ const monitorListeners = new Set<() => void>();
334
+
335
+ /** The live monitor graph, or null before the first press. */
336
+ export function readMonitor(): MonitorGraph | null {
337
+ return monitor;
338
+ }
339
+
340
+ /** Subscribe to the graph appearing or going away. */
341
+ export function subscribeMonitor(listener: () => void): () => void {
342
+ monitorListeners.add(listener);
343
+ return () => {
344
+ monitorListeners.delete(listener);
345
+ };
346
+ }
347
+
348
+ /** A listener that THROWS must not stop the others hearing about it — the
349
+ * posture `publishMeter` already takes, and for the same reason: a meter face
350
+ * is decoration and may not take the preview down with it. */
351
+ function publishMonitor(next: MonitorGraph | null): void {
352
+ if (next === monitor) return;
353
+ monitor = next;
354
+ for (const listener of [...monitorListeners]) {
355
+ try {
356
+ listener();
357
+ } catch {
358
+ // See above.
359
+ }
360
+ }
361
+ }
362
+
363
+ /** Test hook, and the one way to empty the slot without a real dispose. */
364
+ export function resetMonitor(): void {
365
+ publishMonitor(null);
366
+ }
367
+
368
+ /**
369
+ * How far the picture and the audio clock may disagree before the session
370
+ * re-anchors, in frames.
371
+ *
372
+ * ponytail: one frame, the smallest disagreement that is not rounding. Raise it
373
+ * if a loaded machine is measured re-anchoring on its own.
374
+ */
375
+ export const DRIFT_FRAMES = 1;
376
+
377
+ /**
378
+ * How much further the audio clock may run AHEAD of the picture than behind
379
+ * it before the session re-anchors, in seconds: one refresh.
380
+ *
381
+ * The transport picks each frame for the start of the refresh it draws on
382
+ * (rAF's timestamp, Header.tsx), its boundaries placed up to half a refresh
383
+ * off the origin, and this reads the audio clock later in that same tick. So
384
+ * a picture that agrees with the audio begins up to a refresh before the
385
+ * reading, besides the frame it has run into. With a frame alone allowed, a
386
+ * 60 fps run whose base sat late would re-anchor at nearly every frame.
387
+ *
388
+ * ponytail: a 60 Hz refresh assumed, as renderScaleController.ts does; a
389
+ * slower display's late tick reads as drift. Hand the measured refresh over
390
+ * if one ever must not.
391
+ */
392
+ export const DRIFT_REFRESH_S = 1 / 60;
393
+
394
+ /** The engine's de-click, in seconds — the release a voice fades over when the
395
+ * TRANSPORT stops it, which is not something the envelope can express. */
396
+ const RAMP_SECONDS = AUDIO_EDGE_RAMP_SAMPLES / MIX_SAMPLE_RATE;
112
397
 
113
398
  type CacheEntry =
114
399
  | { status: "pending" }
115
400
  | { status: "ready"; buffer: AudioBufferLike }
116
401
  | { status: "failed" };
117
402
 
403
+ /** The same three states for an analysis sidecar. */
404
+ type AnalysisEntry =
405
+ | { status: "pending" }
406
+ | { status: "ready"; analysis: AudioAnalysis }
407
+ | { status: "failed" };
408
+
118
409
  interface Voice {
119
410
  source: AudioBufferSourceNodeLike;
120
411
  gain: GainNodeLike;
412
+ /** The pan stage, or null on a context with no splitter pair. */
413
+ pan: PanStage | null;
414
+ /** What would make this voice the WRONG SOURCE WINDOW — a move, a trim, a
415
+ * slip, a different asset. Changing it means a new node. */
416
+ timing: string;
417
+ /** The file it plays (`audioPlayPath`): the asset, or its processed file. */
418
+ path: string;
419
+ /** What is only a LEVEL — gain, fades, the automation track. Changing it is
420
+ * re-automated on the node that is already sounding. */
421
+ shape: string;
121
422
  }
122
423
 
123
- /** `AudioClipRef` as engine's `AudioClip`: the same fields under the one name
124
- * that differs, with this lane's absent-means-default filled in. */
125
- function asEngineClip(clip: AudioClipRef): AudioClip {
126
- return {
127
- id: clip.id,
128
- name: clip.id,
129
- asset: clip.asset,
130
- at: clip.frame,
131
- offsetFrames: clip.offsetFrames ?? 0,
132
- durationFrames: clip.durationFrames,
133
- gain: clip.gain ?? 1,
134
- fadeInFrames: clip.fadeInFrames ?? 0,
135
- fadeOutFrames: clip.fadeOutFrames ?? 0,
136
- ...(clip.muted === undefined ? {} : { muted: clip.muted }),
137
- };
424
+ /** One audible session: the anchor pair every voice in it shares, the window
425
+ * its voices end at, and the voices themselves. */
426
+ interface Session {
427
+ anchorFrame: number;
428
+ anchorTime: number;
429
+ endFrame: number;
430
+ voices: Map<string, Voice>;
431
+ }
432
+
433
+ /** Re-exported from the data plane, where `dailies check` also needs it. One
434
+ * widening, not two, and now not three. */
435
+ export { asEngineClip };
436
+
437
+ /** The frame a voice may sound until: the entered shot's window end, or the
438
+ * whole document's. A voice scheduled to the document end while the transport
439
+ * loops inside one shot would keep playing past the loop point. */
440
+ function endFrameOf(doc: Doc3D, scene: string | null): number {
441
+ if (scene === null) return docDurationFrames(doc);
442
+ return sceneWindows(doc).find((w) => w.id === scene)?.end ?? docDurationFrames(doc);
443
+ }
444
+
445
+ /** What makes a voice the wrong SOURCE WINDOW. Asset PATH, not id: an id is
446
+ * only unique within its own document. */
447
+ /** The file a `timingOf` key names. */
448
+ const playPathOf = (timing: string): string => (JSON.parse(timing) as unknown[])[2] as string;
449
+
450
+ function timingOf(clip: AudioClipRef, assetPath: string | undefined, trackId: string): string {
451
+ return JSON.stringify([
452
+ // The TRACK too: a voice is wired to its track's bus, and a clip moved to
453
+ // another track has to sum into the other bus or that strip's meter lies.
454
+ trackId,
455
+ clip.frame,
456
+ assetPath,
457
+ clip.offsetFrames ?? 0,
458
+ clip.durationFrames,
459
+ clip.muted === true,
460
+ ]);
461
+ }
462
+
463
+ /**
464
+ * What is only a level, and can be re-automated in place.
465
+ *
466
+ * Takes the RESOLVED track rather than reading `clip.gainTrack`, because a
467
+ * ducked bed's level changes when nothing on the bed does: the voice above it
468
+ * moved, or its analysis just landed. Hashing the authored field would leave
469
+ * the old envelope sounding with no edit to notice.
470
+ */
471
+ function shapeOf(clip: AudioClipRef, track: ShotTrack | undefined, pan: number): string {
472
+ return JSON.stringify([
473
+ clip.gain ?? 1,
474
+ clip.fadeInFrames ?? 0,
475
+ clip.fadeOutFrames ?? 0,
476
+ track ?? null,
477
+ // PAN IS A LEVEL, so a pan-knob drag re-automates the two side gains of
478
+ // the voice already sounding instead of restarting it.
479
+ pan,
480
+ ]);
481
+ }
482
+
483
+ /** Whether a flattened clip is heard in this tab: not muted (the track's mute
484
+ * is already folded in), and on a soloed track when any track is soloed. */
485
+ function heard(m: MasterAudioClip, solo: readonly string[] | undefined): boolean {
486
+ if (m.clip.muted === true) return false;
487
+ return solo === undefined || solo.length === 0 || solo.includes(m.trackId);
488
+ }
489
+
490
+ function sameSolo(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
491
+ const x = a ?? [];
492
+ const y = b ?? [];
493
+ return x.length === y.length && x.every((id, i) => id === y[i]);
138
494
  }
139
495
 
140
496
  export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
141
497
  let ctx: AudioContextLike | null = null;
142
498
  let ctxFailed = false;
143
499
  let master: GainNodeLike | null = null;
500
+ /** Per-track unity buses, created as tracks first get a voice; see
501
+ * `MonitorGraph.tracks`. They live as long as the context does. */
502
+ let buses: Map<string, GainNodeLike> = new Map();
144
503
  let masterMuted = false;
145
504
  let masterVolume = 1;
146
505
  let disposed = false;
147
- let last: TransportSnapshot = { playing: false, frame: 0, doc: null };
506
+ let last: TransportSnapshot = {
507
+ playing: false,
508
+ frame: 0,
509
+ doc: null,
510
+ audible: true,
511
+ scene: null,
512
+ };
148
513
 
149
514
  /**
150
- * AudioBuffer cache by asset PATH. "failed" is terminal: one error, no retries.
515
+ * AudioBuffer cache by asset PATH, filled by the decode ahead and by a play
516
+ * edge alike. A "failed" entry is retried on the next PLAY EDGE, never inside
517
+ * a session and never by the decode ahead, so one dead URL cannot turn the
518
+ * transport into a fetch loop and one transient 5xx cannot silence a clip for
519
+ * the rest of the page's life — which used to make the preview and the export
520
+ * disagree about what the document sounds like.
151
521
  *
152
522
  * NOT by asset id. `ShotAsset.id` is a slug of the filename uniquified only
153
523
  * against the ids already in ITS OWN document (`freshId(usedIdsOf(doc), …)`),
@@ -156,19 +526,90 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
156
526
  * that owns this preview never remounts — so an id key serves one shot's
157
527
  * audio to another shot with the same filename. `asset.path` is the
158
528
  * content-addressed `<hash8>-<name>` the asset store writes, which is unique
159
- * across every document and identical for identical bytes: keying on it also
160
- * keeps the cache hit for a genuine re-import of the same file.
529
+ * across every document and identical for identical bytes.
530
+ *
531
+ * A clip's PROCESSED file (schema v49) leaves once no clip of the document
532
+ * plays it (`reconcile`): each setting is a whole decoded sound, and a knob
533
+ * turned during playback would otherwise keep every one of them.
161
534
  */
162
535
  const buffers = new Map<string, CacheEntry>();
163
536
 
164
- /** clipId -> live voice. Non-null exactly while an audible session runs —
165
- * late decodes join it; mute/pause null it. */
166
- let session: Map<string, Voice> | null = null;
537
+ /**
538
+ * Analysis sidecars by asset PATH, on the same terms as the buffers above:
539
+ * content-addressed key, one fetch per asset, a failure retried on the next
540
+ * play edge rather than latched for the life of the page.
541
+ *
542
+ * A missing analysis is not an error here. `duckedGainTrack` returns
543
+ * undefined without one, so the bed simply plays at its authored level until
544
+ * the sidecar arrives and `reconcile` re-automates it. The EXPORT is the
545
+ * strict one: it refuses rather than shipping the level the author has
546
+ * stopped hearing.
547
+ */
548
+ const analyses = new Map<string, AnalysisEntry>();
549
+ /** Bumped whenever an analysis lands, so the derived-track memo below knows
550
+ * the answer may have changed even though the document did not. */
551
+ let analysisGeneration = 0;
552
+ let derivedFor: { doc: Doc3D; generation: number; tracks: Map<string, ShotTrack> } | null = null;
553
+
554
+ /** Every ducked clip's derived level, recomputed only when the document
555
+ * object or the set of landed analyses actually changes. A gain drag mints
556
+ * a new document per pointermove, so this runs at gesture rate. */
557
+ function derivedTracks(doc: Doc3D): Map<string, ShotTrack> {
558
+ if (derivedFor !== null && derivedFor.doc === doc && derivedFor.generation === analysisGeneration) {
559
+ return derivedFor.tracks;
560
+ }
561
+ const tracks = derivedGainTracks(doc, (asset) => {
562
+ const entry = analyses.get(asset.path);
563
+ return entry?.status === "ready" ? entry.analysis : undefined;
564
+ });
565
+ derivedFor = { doc, generation: analysisGeneration, tracks };
566
+ return tracks;
567
+ }
568
+
569
+ /** The live session, or null while paused, muted or inaudible. */
570
+ let session: Session | null = null;
571
+ /** The document id the stage last presented whole (its ready edge): the
572
+ * one the decode ahead may fetch for. */
573
+ let readyDoc: string | null = null;
574
+ /** A decode ahead in flight: one at a time, so the nearest lands first. */
575
+ let ahead = false;
167
576
 
168
577
  function now(): number {
169
578
  return deps.now ? deps.now() : ctx!.currentTime;
170
579
  }
171
580
 
581
+ function wallNow(): number {
582
+ return deps.wallNow ? deps.wallNow() : performance.now();
583
+ }
584
+
585
+ function fpsOf(): Rational {
586
+ return last.doc!.fps;
587
+ }
588
+
589
+ /** Audio time of a picture frame, on the CURRENT session's anchor. */
590
+ function t(frame: number): number {
591
+ const s = session!;
592
+ return s.anchorTime + framesToSeconds(frame - s.anchorFrame, fpsOf());
593
+ }
594
+
595
+ /**
596
+ * The frame the audio clock has reached, rounded UP so anything scheduled
597
+ * from it is scheduled in the future.
598
+ *
599
+ * EPSILON before the ceil, because the common case is an exact boundary: the
600
+ * transport ticks frame by frame and the elapsed time is n/fps, which in
601
+ * doubles lands a few ULPs either side of the integer. Ceiling that lands on
602
+ * n + 1 half the time, and a reconcile or a late decode scheduled from one
603
+ * frame later than the audio actually is drops a frame of it.
604
+ */
605
+ function frameNow(): number {
606
+ const s = session!;
607
+ const fps = fpsOf();
608
+ const elapsed = now() - s.anchorTime;
609
+ const frames = (elapsed * fps.num) / fps.den;
610
+ return s.anchorFrame + Math.max(0, Math.ceil(frames - 1e-9));
611
+ }
612
+
172
613
  function ensureContext(): boolean {
173
614
  if (ctxFailed) return false;
174
615
  if (!ctx) {
@@ -184,126 +625,445 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
184
625
  master = ctx.createGain();
185
626
  master.gain.value = masterVolume;
186
627
  master.connect(ctx.destination);
628
+ // THE GRAPH NOW EXISTS, so anything waiting to meter it can start. After
629
+ // the connect, so a tap can never be attached to a node that is not yet
630
+ // in the graph.
631
+ // The document's buses go up WITH the master, so the first press
632
+ // publishes once and a face re-renders once.
633
+ buses = new Map();
634
+ if (last.doc !== null) addBuses(last.doc);
635
+ publishMonitor({ ctx, node: master, tracks: buses });
187
636
  }
188
637
  try {
189
- void ctx.resume?.(); // autoplay policy: nudge on every play gesture
638
+ void ctx.resume?.(); // autoplay policy: nudge on every press
190
639
  } catch {
191
640
  // resume is best-effort; a suspended context just stays silent
192
641
  }
193
642
  return true;
194
643
  }
195
644
 
196
- /** Schedule one clip's voice, anchored at (anchorFrame ↔ anchorTime). */
645
+ /** Create or resume the realtime context, for a transport that can sound. */
646
+ function wake(): boolean {
647
+ return last.doc !== null && !masterMuted && last.audible && ensureContext();
648
+ }
649
+
650
+ /**
651
+ * Stop a voice the way the TRANSPORT stops it: a short release, then the
652
+ * stop, then teardown once that stop has happened.
653
+ *
654
+ * The envelope's own ramps cannot cover this. They describe the CLIP, and
655
+ * pausing at frame 200 is not something the clip has an opinion about — the
656
+ * gain is whatever it is when the author lets go, and cutting a source off
657
+ * there is a step discontinuity in the output buffer. Which is a click, on
658
+ * every pause, every scrub and every loop wrap.
659
+ */
660
+ /**
661
+ * Give every track of `doc` its bus, into a NEW map when any is missing.
662
+ * True when it added one, so the caller publishes.
663
+ *
664
+ * ponytail: a deleted track's bus stays wired, idle, until the context
665
+ * closes. Drop it here if a session ever churns through many tracks.
666
+ */
667
+ function addBuses(doc: Doc3D): boolean {
668
+ const missing = audioTracksOf(doc).filter((t) => !buses.has(t.id));
669
+ if (missing.length === 0) return false;
670
+ const next = new Map(buses);
671
+ for (const track of missing) {
672
+ const bus = ctx!.createGain();
673
+ bus.gain.value = 1;
674
+ bus.connect(master!);
675
+ next.set(track.id, bus);
676
+ }
677
+ buses = next;
678
+ return true;
679
+ }
680
+
681
+ /** The bus a track's voices sum into. `reconcile` has already made one for
682
+ * every track of the document, so this only builds on a stale snapshot. */
683
+ function busFor(trackId: string): GainNodeLike {
684
+ const existing = buses.get(trackId);
685
+ if (existing !== undefined) return existing;
686
+ const bus = ctx!.createGain();
687
+ bus.gain.value = 1;
688
+ bus.connect(master!);
689
+ buses = new Map(buses).set(trackId, bus);
690
+ publishMonitor({ ctx: ctx!, node: master!, tracks: buses });
691
+ return bus;
692
+ }
693
+
694
+ function release(voice: Voice): void {
695
+ const t0 = now();
696
+ const param = voice.gain.gain;
697
+ const drop = (): void => {
698
+ voice.source.disconnect();
699
+ voice.gain.disconnect();
700
+ if (voice.pan !== null) {
701
+ voice.pan.split.disconnect();
702
+ voice.pan.l.disconnect();
703
+ voice.pan.r.disconnect();
704
+ voice.pan.merge.disconnect();
705
+ }
706
+ };
707
+ try {
708
+ param.cancelScheduledValues(t0);
709
+ param.setValueAtTime(param.value, t0);
710
+ param.linearRampToValueAtTime(0, t0 + RAMP_SECONDS);
711
+ } catch {
712
+ // A param that refuses a schedule (a context already closed) still has to
713
+ // reach the stop below.
714
+ }
715
+ voice.source.onended = drop;
716
+ try {
717
+ voice.source.stop(t0 + RAMP_SECONDS);
718
+ } catch {
719
+ drop(); // already ended — its nodes are inert, and onended will not fire
720
+ }
721
+ }
722
+
723
+ /** Schedule one clip's voice into the CURRENT session. */
197
724
  function scheduleClip(
198
- ref: AudioClipRef,
725
+ m: MasterAudioClip,
199
726
  buffer: AudioBufferLike,
200
- endFrameExclusive: number,
201
- fps: Rational,
202
- anchorFrame: number,
203
- anchorTime: number,
727
+ fromFrame: number,
728
+ timing: string,
729
+ shape: string,
730
+ track: ShotTrack | undefined,
204
731
  ): void {
732
+ const ref = m.clip;
733
+ const s = session!;
734
+ const fps = fpsOf();
205
735
  const clip = asEngineClip(ref);
206
736
  const { start, end } = audioClipWindow(clip);
207
- const schedEnd = Math.min(end, endFrameExclusive);
208
- const first = Math.max(start, anchorFrame); // mid-window start advances the offset
737
+ const schedEnd = Math.min(end, s.endFrame);
738
+ const first = Math.max(start, fromFrame); // mid-window start advances the offset
209
739
  if (first >= schedEnd) return;
210
740
  const offset = audioSourceTimeAt(clip, first, fps);
211
741
  if (offset >= buffer.duration) return; // trimmed past the source: silence is legal
212
- const t = (f: number): number => anchorTime + framesToSeconds(f - anchorFrame, fps);
213
742
  const c = ctx!;
214
743
  const source = c.createBufferSource();
215
744
  source.buffer = buffer;
216
745
  const gain = c.createGain();
217
746
  source.connect(gain);
218
- gain.connect(master!);
747
+ // ALWAYS through the pan stage when the context has one, even at centre,
748
+ // so a later pan drag is a parameter write and never a reschedule.
749
+ const bus = busFor(m.trackId);
750
+ const pan = wirePan(c, gain, bus, m.pan ?? 0);
751
+ if (pan === null) gain.connect(bus);
219
752
  // The preview===export envelope: the engine's ONE point list, rendered as
220
- // set-then-ramps; the source stops at the last point's frame.
221
- const points = audioEnvelopePoints(clip, first, endFrameExclusive);
753
+ // set-then-ramps; the source stops at the last point's frame. `ref.frame`
754
+ // is the MASTER start the flatten put this clip at, and `gainTrack`'s
755
+ // frames are local to exactly that. The de-click at either edge is already
756
+ // in that list, including the one a mid-window `first` needs.
757
+ const points = audioEnvelopePoints(
758
+ clip,
759
+ first,
760
+ s.endFrame,
761
+ fps,
762
+ audioGainSampler(track, ref.frame, fps),
763
+ );
222
764
  if (points.length === 0) return;
223
- gain.gain.setValueAtTime(points[0]!.gain, t(points[0]!.frame));
765
+ // A START ALREADY BEHIND THE AUDIO CLOCK. The session anchors on the
766
+ // transport's origin, and a context resumed at the play edge reads only a
767
+ // few ms by then, so the anchor, and a voice that starts on it, can lie
768
+ // before zero (Web Audio refuses a negative time: the throw ended the
769
+ // hand-over, and the transport never started) or merely in the past. The
770
+ // voice is already sounding there: it starts now, that much further into
771
+ // its source (source time runs with the timeline's), and no automation
772
+ // point is set before now.
773
+ const current = now();
774
+ const when = t(first);
775
+ const late = Math.max(0, current - when);
776
+ const length = framesToSeconds(points.at(-1)!.frame - first, fps) - late;
777
+ if (length <= 0) {
778
+ gain.disconnect();
779
+ return;
780
+ }
781
+ const at = (frame: number): number => Math.max(current, t(frame));
782
+ gain.gain.setValueAtTime(points[0]!.gain, at(points[0]!.frame));
224
783
  for (const p of points.slice(1)) {
225
- gain.gain.linearRampToValueAtTime(p.gain, t(p.frame));
784
+ gain.gain.linearRampToValueAtTime(p.gain, at(p.frame));
226
785
  }
227
- source.start(t(first), offset, framesToSeconds(points.at(-1)!.frame - first, fps));
228
- session!.set(clip.id, { source, gain });
786
+ source.start(when + late, offset + late, length);
787
+ s.voices.set(clip.id, { source, gain, pan, timing, shape, path: playPathOf(timing) });
229
788
  }
230
789
 
231
- /** Begin an audible session from `fromFrame`: schedule every decoded clip
232
- * whose window intersects [fromFrame, docDuration); kick decodes for the
233
- * rest (their resolve handler joins the then-current session). */
234
- function startSession(fromFrame: number): void {
790
+ /**
791
+ * Follow an edited LEVEL on the node that is already sounding.
792
+ *
793
+ * The whole point of separating `shape` from `timing`. A gain-band drag mints
794
+ * a new document object per pointermove (the history scrub re-derives from
795
+ * the base), so treating any document change as a reschedule stopped and
796
+ * restarted every voice sixty times a second — and that is precisely the
797
+ * gesture an author performs WHILE LISTENING.
798
+ *
799
+ * The glide is the envelope's OWN next point rather than a ramp of our
800
+ * choosing: hold what is sounding, then ramp to where the new envelope says
801
+ * to be at the next point. At most one frame, and click-free by construction.
802
+ */
803
+ function reautomate(
804
+ voice: Voice,
805
+ ref: AudioClipRef,
806
+ shape: string,
807
+ track: ShotTrack | undefined,
808
+ pan: number,
809
+ ): void {
810
+ const s = session!;
811
+ const fps = fpsOf();
812
+ const clip = asEngineClip(ref);
813
+ const points = audioEnvelopePoints(
814
+ clip,
815
+ audioClipWindow(clip).start,
816
+ s.endFrame,
817
+ fps,
818
+ audioGainSampler(track, ref.frame, fps),
819
+ );
820
+ const t0 = now();
821
+ const param = voice.gain.gain;
822
+ param.cancelScheduledValues(t0);
823
+ param.setValueAtTime(param.value, t0);
824
+ for (const p of points) {
825
+ if (t(p.frame) > t0) param.linearRampToValueAtTime(p.gain, t(p.frame));
826
+ }
827
+ if (voice.pan !== null) {
828
+ // The side gains glide over the engine's de-click, so a knob drag never
829
+ // steps the waveform.
830
+ const g = panGains(pan);
831
+ for (const [side, value] of [
832
+ [voice.pan.l, g.l],
833
+ [voice.pan.r, g.r],
834
+ ] as const) {
835
+ side.gain.cancelScheduledValues(t0);
836
+ side.gain.setValueAtTime(side.gain.value, t0);
837
+ side.gain.linearRampToValueAtTime(value, t0 + RAMP_SECONDS);
838
+ }
839
+ }
840
+ voice.shape = shape;
841
+ }
842
+
843
+ /**
844
+ * Begin an audible session from `fromFrame`: on the transport's `origin`
845
+ * when the play edge brought a new one (its frame, at the audio time of its
846
+ * wall instant), otherwise on a reading of the clock now.
847
+ */
848
+ function startSession(fromFrame: number, origin: TransportOrigin | null = null): void {
235
849
  const doc = last.doc;
236
- if (doc === null || masterMuted) return;
237
- if (!ensureContext()) return;
238
- session = new Map();
239
- const anchorTime = now();
240
- const endFrame = docDurationFrames(doc);
241
- for (const clip of Object.values(doc.audio)) {
242
- if (clip.muted === true) continue; // gain would be 0 — skip the node entirely
243
- const window = audioClipWindow(asEngineClip(clip));
244
- if (Math.min(window.end, endFrame) <= Math.max(window.start, fromFrame)) continue;
850
+ if (doc === null || !wake()) return;
851
+ session = {
852
+ anchorFrame: origin?.frame ?? fromFrame,
853
+ anchorTime: origin === null ? now() : now() - (wallNow() - origin.at) / 1000,
854
+ endFrame: endFrameOf(doc, last.scene),
855
+ voices: new Map(),
856
+ };
857
+ reconcile(fromFrame);
858
+ }
859
+
860
+ /**
861
+ * Bring the session's voices in line with the document, from `fromFrame`.
862
+ *
863
+ * Starting a session and absorbing an edit are the same question asked at
864
+ * different moments: which clips should be sounding, and are the nodes we
865
+ * hold the right ones for them.
866
+ *
867
+ * THROUGH THE FLATTEN, never `doc.audio`: since schema v13 that record is
868
+ * the bed only, and a shot's own clips are scene-local — so scheduling from
869
+ * it would play them at the wrong master time, or (for a shot that is not
870
+ * first) not at all. `masterAudioClips` is also what the export mixes from,
871
+ * and preview === export is the rule this lane is built on.
872
+ */
873
+ function reconcile(fromFrame: number): void {
874
+ const s = session;
875
+ const doc = last.doc;
876
+ if (s === null || doc === null) return;
877
+ const wanted = new Set<string>();
878
+ // Ask for anything a duck needs and does not have yet. Cheap: the list is
879
+ // empty for a document with no duck, and each path is fetched once.
880
+ for (const asset of duckSourceAssets(doc)) {
881
+ const entry = analyses.get(asset.path);
882
+ if (entry === undefined || entry.status === "failed") requestAnalysis(asset.path);
883
+ }
884
+ const derived = derivedTracks(doc);
885
+ /** Every file a clip of this document plays, heard now or not. */
886
+ const plays = new Set<string>();
887
+ // A track added mid-session gets its bus (and a strip its tap) here.
888
+ if (addBuses(doc)) publishMonitor({ ctx: ctx!, node: master!, tracks: buses });
889
+ for (const m of masterAudioClips(doc)) {
890
+ const clip = m.clip;
245
891
  const asset = doc.assets[clip.asset];
246
- if (asset === undefined) continue; // validation requires it; a hole stays silent
247
- const entry = buffers.get(asset.path);
892
+ if (asset !== undefined) plays.add(audioPlayPath(clip, asset));
893
+ // Muted (gain would be 0) or not soloed: skip the node entirely.
894
+ if (!heard(m, last.solo)) continue;
895
+ if (asset === undefined) {
896
+ // Both mixes THROW on this document and validateShotDoc refuses it, so
897
+ // silence with no explanation would be the preview quietly disagreeing
898
+ // with a file that cannot be produced at all.
899
+ console.error(
900
+ `[audio] clip ${clip.id} references unknown asset ${clip.asset}; ` +
901
+ `an export of this document will refuse it`,
902
+ );
903
+ continue;
904
+ }
905
+ wanted.add(clip.id);
906
+ const path = audioPlayPath(clip, asset);
907
+ const timing = timingOf(clip, path, m.trackId);
908
+ const track = derived.get(clip.id) ?? clip.gainTrack;
909
+ const shape = shapeOf(clip, track, m.pan ?? 0);
910
+ const voice = s.voices.get(clip.id);
911
+ const entry = buffers.get(path);
912
+ if (voice !== undefined) {
913
+ if (voice.timing === timing) {
914
+ if (voice.shape !== shape) reautomate(voice, clip, shape, track, m.pan ?? 0);
915
+ continue;
916
+ }
917
+ // NEW EFFECTS, SAME WINDOW: the processed file is being made. The old
918
+ // voice keeps sounding until it decodes (`onBufferReady` swaps it), so
919
+ // a knob turned during playback never drops the clip out.
920
+ if (entry?.status !== "ready" && voice.timing === timingOf(clip, voice.path, m.trackId)) {
921
+ if (entry === undefined || entry.status === "failed") requestDecode(path);
922
+ continue;
923
+ }
924
+ release(voice); // the source window moved: this node is the wrong one
925
+ s.voices.delete(clip.id);
926
+ }
248
927
  if (entry?.status === "ready") {
249
- scheduleClip(clip, entry.buffer, endFrame, doc.fps, fromFrame, anchorTime);
250
- } else if (entry === undefined) {
251
- requestDecode(asset.path);
928
+ scheduleClip(m, entry.buffer, fromFrame, timing, shape, track);
929
+ } else if (entry === undefined || entry.status === "failed") {
930
+ void requestDecode(path);
252
931
  }
253
- // pending: scheduled on resolve if still playing; failed: already errored
932
+ // pending: scheduled by onBufferReady when it lands
933
+ }
934
+ for (const [id, voice] of [...s.voices]) {
935
+ if (wanted.has(id)) continue;
936
+ release(voice); // deleted, muted, or moved out of this shot
937
+ s.voices.delete(id);
938
+ }
939
+ // Processed files the document no longer plays. A voice still sounding
940
+ // one holds its own buffer; one still decoding lands and goes next time.
941
+ for (const [path, entry] of buffers) {
942
+ if (entry.status !== "pending" && !plays.has(path) && isAudioFxFileName(path)) buffers.delete(path);
254
943
  }
255
944
  }
256
945
 
257
- function requestDecode(path: string): void {
258
- const c = ctx!; // non-null: only called under an ensured context; captured
259
- // so a dispose() while the fetch is in flight can't null it away.
946
+ /** Fetch and decode one asset into the cache. Settles when it has landed or
947
+ * failed, and never rejects. */
948
+ function requestDecode(path: string, priority?: "low"): Promise<void> {
260
949
  buffers.set(path, { status: "pending" });
261
- void deps
262
- .fetchBytes(`/studio/assets/${encodeURIComponent(path)}`)
263
- .then((bytes) => deps.decode(c, bytes))
950
+ return deps
951
+ .fetchBytes(`/studio/assets/${encodeURIComponent(path)}`, priority)
952
+ .then((bytes) => deps.decode(bytes))
264
953
  .then(
265
954
  (buffer) => {
266
955
  buffers.set(path, { status: "ready", buffer });
267
956
  onBufferReady(path, buffer);
268
957
  },
269
958
  (e: unknown) => {
270
- buffers.set(path, { status: "failed" }); // terminal — never retried
271
- console.error(`[audio] asset ${path} failed to load; its clips stay silent`, e);
959
+ // Not terminal: the next PLAY EDGE tries again. A terminal failure
960
+ // made one transient error silence a clip for the page's life while
961
+ // the export — which fetches independently — still had it.
962
+ buffers.set(path, { status: "failed" });
963
+ console.error(`[audio] asset ${path} failed to load; retrying on the next play`, e);
272
964
  },
273
965
  );
274
966
  }
275
967
 
276
- /** A late decode joins the CURRENT session from the then-current store frame
277
- * — never the (stale) frame play started at. No session = arrived after
278
- * pause/mute: schedule nothing.
968
+ /**
969
+ * DECODE AHEAD: the heard clip nearest the playhead whose asset nothing has
970
+ * asked for yet, then the next once that one lands.
279
971
  *
280
- * Matched through the then-current document's own asset record, so a decode
281
- * started in one shot cannot claim the same-named asset of the shot that
282
- * replaced it while the fetch was in flight. */
972
+ * Only while the stage has presented THIS document whole, and at low fetch
973
+ * priority, because these are canonical WAVs, tens of megabytes, and the
974
+ * stage's own loads come first. A failure waits for the next play edge, as
975
+ * any other does.
976
+ */
977
+ function decodeAhead(): void {
978
+ const doc = last.doc;
979
+ if (disposed || ahead || doc === null || last.presented !== true || doc.id !== readyDoc) return;
980
+ const frame = last.frame;
981
+ let nearest: { path: string; distance: number } | null = null;
982
+ for (const m of masterAudioClips(doc)) {
983
+ // The file playback asks for (`audioPlayPath`): a clip with effects
984
+ // plays its processed file, so warming the raw asset would be a large
985
+ // fetch nobody uses while the processed one still decodes cold on Play.
986
+ const asset = doc.assets[m.clip.asset];
987
+ const path = asset === undefined ? undefined : audioPlayPath(m.clip, asset);
988
+ if (path === undefined || buffers.has(path) || !heard(m, last.solo)) continue;
989
+ const start = m.clip.frame;
990
+ const end = start + m.clip.durationFrames;
991
+ const distance = frame < start ? start - frame : frame < end ? 0 : frame - end + 1;
992
+ if (nearest === null || distance < nearest.distance) nearest = { path, distance };
993
+ }
994
+ if (nearest === null) return;
995
+ ahead = true;
996
+ void requestDecode(nearest.path, "low").then(() => {
997
+ ahead = false;
998
+ decodeAhead();
999
+ });
1000
+ }
1001
+
1002
+ /**
1003
+ * Fetch one analysis sidecar. The document's own duck is what asks for it,
1004
+ * so a project with no ducking never touches this path.
1005
+ *
1006
+ * On landing it RECONCILES rather than scheduling directly: the clip whose
1007
+ * level changed is the bed, not the voice the sidecar describes, and
1008
+ * reconcile is the one place that knows which is which.
1009
+ */
1010
+ function requestAnalysis(path: string): void {
1011
+ analyses.set(path, { status: "pending" });
1012
+ void deps
1013
+ .fetchBytes(`/studio/assets/${encodeURIComponent(audioAnalysisFileName(path))}`)
1014
+ .then((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as AudioAnalysis)
1015
+ .then(
1016
+ (analysis) => {
1017
+ analyses.set(path, { status: "ready", analysis });
1018
+ analysisGeneration++;
1019
+ if (!disposed && session !== null && last.playing) reconcile(frameNow());
1020
+ },
1021
+ (e: unknown) => {
1022
+ analyses.set(path, { status: "failed" });
1023
+ console.error(`[audio] analysis for ${path} failed to load; a duck on it stays flat`, e);
1024
+ },
1025
+ );
1026
+ }
1027
+
1028
+ /**
1029
+ * A late decode joins the CURRENT session on ITS OWN ANCHOR.
1030
+ *
1031
+ * Not on a fresh reading of the clock: the pair (anchorTime, anchorFrame) has
1032
+ * to be sampled at one instant, or the voice lands offset from the ones
1033
+ * already playing. This used to read `now()` here and pair it with the frame
1034
+ * recorded at the last store notification — two different moments, up to a
1035
+ * whole main-thread stall apart — which is a flam on a hit and phasing on two
1036
+ * beds. A first Play takes this path for every clip the decode ahead has not
1037
+ * reached yet.
1038
+ */
283
1039
  function onBufferReady(path: string, buffer: AudioBufferLike): void {
284
1040
  if (disposed || session === null || !last.playing) return;
285
1041
  const doc = last.doc;
286
1042
  if (doc === null) return;
287
- const anchorTime = now();
288
- const endFrame = docDurationFrames(doc);
289
- for (const clip of Object.values(doc.audio)) {
290
- if (doc.assets[clip.asset]?.path !== path) continue;
291
- if (clip.muted === true || session.has(clip.id)) continue;
292
- scheduleClip(clip, buffer, endFrame, doc.fps, last.frame, anchorTime);
1043
+ const from = frameNow();
1044
+ const derived = derivedTracks(doc);
1045
+ for (const m of masterAudioClips(doc)) {
1046
+ const clip = m.clip;
1047
+ const asset = doc.assets[clip.asset];
1048
+ if (asset === undefined || audioPlayPath(clip, asset) !== path) continue;
1049
+ if (!heard(m, last.solo)) continue;
1050
+ const timing = timingOf(clip, path, m.trackId);
1051
+ // A voice still playing this clip's previous file (its effects changed
1052
+ // while the new one was made) is swapped here; one already on it stays.
1053
+ const held = session.voices.get(clip.id);
1054
+ if (held !== undefined) {
1055
+ if (held.timing === timing) continue;
1056
+ release(held);
1057
+ session.voices.delete(clip.id);
1058
+ }
1059
+ const track = derived.get(clip.id) ?? clip.gainTrack;
1060
+ scheduleClip(m, buffer, from, timing, shapeOf(clip, track, m.pan ?? 0), track);
293
1061
  }
294
1062
  }
295
1063
 
296
1064
  function teardown(): void {
297
1065
  if (session === null) return;
298
- for (const { source, gain } of session.values()) {
299
- try {
300
- source.stop();
301
- } catch {
302
- // already ended — stopping is best-effort
303
- }
304
- source.disconnect();
305
- gain.disconnect();
306
- }
1066
+ for (const voice of session.voices.values()) release(voice);
307
1067
  session = null;
308
1068
  }
309
1069
 
@@ -312,30 +1072,72 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
312
1072
  if (disposed) return;
313
1073
  const prev = last;
314
1074
  last = snap;
315
- if (!snap.playing) {
316
- if (prev.playing) teardown();
1075
+ // THE ARM. A queued Play is the press, and the start it waits for is not
1076
+ // one, so the realtime context is created or resumed here: the store
1077
+ // notifies inside the `set` that arms, which runs in the press itself.
1078
+ if (snap.armed === true && prev.armed !== true) wake();
1079
+ // THE READY EDGE, keyed to its document. A Project switch reaches this
1080
+ // before the stage has said it owes the new one a picture, and the
1081
+ // decode ahead must not start under a scene that is still loading.
1082
+ const presented = snap.presented === true;
1083
+ if (presented && prev.presented !== true) readyDoc = snap.doc?.id ?? null;
1084
+ // Only on what can change its answer, never on a tick. After this push,
1085
+ // so a play edge's own requests go first, at full priority.
1086
+ if (presented && (prev.presented !== true || snap.doc !== prev.doc || !sameSolo(prev.solo, snap.solo))) {
1087
+ queueMicrotask(decodeAhead);
1088
+ }
1089
+ const running = snap.playing && snap.audible;
1090
+ if (!running) {
1091
+ if (session !== null) teardown();
317
1092
  return;
318
1093
  }
319
- if (!prev.playing) {
320
- // Play edge — the user-gesture moment; the context is created here.
321
- startSession(snap.frame);
1094
+ if (session === null) {
1095
+ // Play edge. A new origin is this edge's alone: its session anchors on
1096
+ // it, and any later one in the same run takes its own reading.
1097
+ const origin = snap.origin ?? null;
1098
+ startSession(snap.frame, origin === (prev.origin ?? null) ? null : origin);
322
1099
  return;
323
1100
  }
324
- // While playing: reschedule on document identity change (a gain or fade
325
- // edit audibly applies) or a scrub-jump; the monotone +small frame path
326
- // (the rAF clock ticking) does nothing but the snapshot record above.
327
- const scrubbed = snap.frame < prev.frame || snap.frame - prev.frame > SCRUB_JUMP_FRAMES;
328
- if (snap.doc !== prev.doc || scrubbed) {
1101
+ const doc = snap.doc;
1102
+ if (doc === null) {
1103
+ teardown();
1104
+ return;
1105
+ }
1106
+ // The WINDOW moved under the transport (the entered shot was trimmed, or
1107
+ // the author entered one): every voice's stop time is wrong, so this is a
1108
+ // restart and not a reconcile.
1109
+ if (endFrameOf(doc, snap.scene) !== session.endFrame) {
329
1110
  teardown();
330
1111
  startSession(snap.frame);
1112
+ return;
1113
+ }
1114
+ // THE DRIFT CHECK, which replaced a jump threshold. A scrub or a loop
1115
+ // wrap moves the picture without moving the audio clock, so the two
1116
+ // disagree by the whole distance; a monotone tick does not, and neither
1117
+ // does a stall — the transport's clock is absolute, so the picture
1118
+ // catches up to where the audio already is. On a FRAME CHANGE only: any
1119
+ // other notification carries the last tick's frame, which the audio has
1120
+ // run past by up to a frame of its own. The audio runs ahead of the
1121
+ // picture's frame by up to a refresh more (`DRIFT_REFRESH_S`).
1122
+ if (snap.frame !== prev.frame) {
1123
+ const ahead = now() - t(snap.frame);
1124
+ const allowed = framesToSeconds(DRIFT_FRAMES, doc.fps);
1125
+ if (ahead < -allowed || ahead > allowed + DRIFT_REFRESH_S) {
1126
+ teardown();
1127
+ startSession(snap.frame);
1128
+ return;
1129
+ }
331
1130
  }
1131
+ // A DOCUMENT EDIT, absorbed per clip rather than as a restart.
1132
+ // A solo change is the same question: which voices should be sounding.
1133
+ if (doc !== prev.doc || !sameSolo(prev.solo, snap.solo)) reconcile(frameNow());
332
1134
  },
333
1135
 
334
1136
  setMasterMute(muted) {
335
1137
  if (disposed || muted === masterMuted) return;
336
1138
  masterMuted = muted;
337
1139
  if (muted) teardown();
338
- else if (last.playing) startSession(last.frame);
1140
+ else if (last.playing && last.audible) startSession(last.frame);
339
1141
  },
340
1142
 
341
1143
  setMasterVolume(volume) {
@@ -351,6 +1153,14 @@ export function createAudioPreview(deps: AudioPreviewDeps): AudioPreview {
351
1153
  if (disposed) return;
352
1154
  disposed = true;
353
1155
  teardown();
1156
+ // ITS OWN PUBLICATION AND NOT WHATEVER IS THERE. See the ceiling on the
1157
+ // slot: one bridge owns it in practice, and a second preview disposing
1158
+ // must not blank the surviving one's meter. A face's detach lands on the
1159
+ // next commit, after this node is already unwired, which `release`
1160
+ // already tolerates — its `disconnect` is wrapped for exactly that.
1161
+ if (monitor !== null && monitor.node === master) publishMonitor(null);
1162
+ for (const bus of buses.values()) bus.disconnect();
1163
+ buses = new Map();
354
1164
  master?.disconnect();
355
1165
  master = null;
356
1166
  try {