@dailies-studio/shot-editor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (432) hide show
  1. package/dist/.vite/manifest.json +382 -0
  2. package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
  3. package/dist/assets/AgentPanel-DJEphah0.css +1 -0
  4. package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
  5. package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
  6. package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
  7. package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
  8. package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
  9. package/dist/assets/CurvePane-BjFVabKX.css +1 -0
  10. package/dist/assets/CurvePane-R22WzllJ.js +1 -0
  11. package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
  12. package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
  13. package/dist/assets/FontPicker-v0IOIArM.css +1 -0
  14. package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
  15. package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
  16. package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
  17. package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
  18. package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
  19. package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
  20. package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
  21. package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
  22. package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
  23. package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
  24. package/dist/assets/SoundMode-BmcZru3i.js +3 -0
  25. package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
  26. package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
  27. package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
  28. package/dist/assets/board-B88EPuLO.css +1 -0
  29. package/dist/assets/board-CL-he7SL.js +3 -0
  30. package/dist/assets/components-0oOSPN0l.js +1 -0
  31. package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
  32. package/dist/assets/googleFonts-BNon6M7X.js +1 -0
  33. package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
  34. package/dist/assets/index-4F5NQmI8.css +1 -0
  35. package/dist/assets/index-BGHQGV5z.js +117 -0
  36. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  37. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  38. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  39. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  40. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  41. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  42. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  43. package/dist/assets/libraryModel-CpIwfFha.js +1 -0
  44. package/dist/assets/lucide-6iK64tTB.js +9319 -0
  45. package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
  46. package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
  47. package/dist/assets/webmcp-BsB9gxWC.js +2 -0
  48. package/dist/index.html +34 -3
  49. package/package.json +12 -9
  50. package/src/addFamilies.ts +66 -0
  51. package/src/api.ts +1631 -55
  52. package/src/app/App.tsx +977 -198
  53. package/src/app/lazyPanel.tsx +137 -0
  54. package/src/app/lazySurfaces.tsx +203 -0
  55. package/src/audio/index.tsx +41 -10
  56. package/src/audio/meter.ts +485 -0
  57. package/src/audio/meterMath.ts +273 -0
  58. package/src/audio/meterStore.ts +155 -0
  59. package/src/audio/offlineMix.ts +289 -0
  60. package/src/audio/preview.ts +928 -118
  61. package/src/bridge.ts +15 -5
  62. package/src/bridgeTypes.ts +1611 -36
  63. package/src/channels.ts +17 -32
  64. package/src/fonts/googleFonts.ts +4 -0
  65. package/src/hostApi.ts +174 -0
  66. package/src/icons.tsx +770 -0
  67. package/src/layerWords.ts +61 -0
  68. package/src/main.tsx +56 -4
  69. package/src/panels/ResizeHandle.tsx +33 -43
  70. package/src/panels/agent/AgentDots.tsx +36 -0
  71. package/src/panels/agent/AgentPanel.tsx +119 -43
  72. package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
  73. package/src/panels/agent/agent.css +113 -97
  74. package/src/panels/agent/agentGate.ts +35 -0
  75. package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
  76. package/src/panels/code/CodeStudio.tsx +373 -0
  77. package/src/panels/code/FileTree.tsx +265 -0
  78. package/src/panels/code/codeBufferGuard.ts +52 -0
  79. package/src/panels/code/codeParts.tsx +321 -0
  80. package/src/panels/comments/CommentComposer.tsx +239 -0
  81. package/src/panels/comments/CommentThread.tsx +346 -0
  82. package/src/panels/comments/CommentsPanel.tsx +336 -0
  83. package/src/panels/comments/InboxButton.tsx +131 -0
  84. package/src/panels/comments/MentionMenu.tsx +98 -0
  85. package/src/panels/comments/parts.tsx +171 -0
  86. package/src/panels/export/ExportDialog.tsx +919 -96
  87. package/src/panels/export/ExportScrim.tsx +57 -0
  88. package/src/panels/files/AssetsTab.tsx +656 -0
  89. package/src/panels/files/FilePanel.tsx +1466 -216
  90. package/src/panels/files/LayerMarks.tsx +40 -0
  91. package/src/panels/files/LeftColumn.tsx +37 -0
  92. package/src/panels/files/McpSection.tsx +690 -47
  93. package/src/panels/files/MissingAssetsNotice.tsx +60 -0
  94. package/src/panels/files/assetDrag.ts +79 -0
  95. package/src/panels/files/resourceParts.tsx +255 -0
  96. package/src/panels/fonts/FontPicker.tsx +762 -0
  97. package/src/panels/fonts/faceCache.ts +207 -0
  98. package/src/panels/fonts/fontPicker.css +271 -0
  99. package/src/panels/fonts/pickerModel.ts +265 -0
  100. package/src/panels/fonts/pickerRequest.ts +46 -0
  101. package/src/panels/header/AccountAvatar.tsx +76 -0
  102. package/src/panels/header/AgentChip.tsx +77 -0
  103. package/src/panels/header/AppMenu.tsx +213 -0
  104. package/src/panels/header/Header.tsx +564 -84
  105. package/src/panels/header/HistoryControls.tsx +31 -5
  106. package/src/panels/header/PresenceStack.tsx +329 -0
  107. package/src/panels/header/ProjectTitle.tsx +232 -0
  108. package/src/panels/header/ShareButton.tsx +59 -0
  109. package/src/panels/header/StatusBadges.tsx +358 -27
  110. package/src/panels/header/SyncDeletedDialog.tsx +88 -0
  111. package/src/panels/header/ZoomControls.tsx +46 -15
  112. package/src/panels/header/agentHandoff.ts +52 -0
  113. package/src/panels/header/leave.ts +26 -0
  114. package/src/panels/help/HelpSection.tsx +189 -163
  115. package/src/panels/insert/InsertDrawer.tsx +723 -0
  116. package/src/panels/insert/iconSet.ts +109 -0
  117. package/src/panels/inspector/ExposeDot.tsx +117 -0
  118. package/src/panels/inspector/FramingRows.tsx +314 -44
  119. package/src/panels/inspector/InspectorFooter.tsx +91 -0
  120. package/src/panels/inspector/InspectorHead.tsx +425 -0
  121. package/src/panels/inspector/InspectorPanel.tsx +408 -124
  122. package/src/panels/inspector/KeyframeDot.tsx +373 -0
  123. package/src/panels/inspector/KeyframeSection.tsx +257 -0
  124. package/src/panels/inspector/MarkerSection.tsx +55 -11
  125. package/src/panels/inspector/ParamRow.tsx +84 -53
  126. package/src/panels/inspector/RightColumn.tsx +152 -0
  127. package/src/panels/inspector/TimeSection.tsx +866 -0
  128. package/src/panels/inspector/animate/EaseControl.tsx +244 -0
  129. package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
  130. package/src/panels/inspector/animate/ShotSection.tsx +562 -48
  131. package/src/panels/inspector/design/AlignRow.tsx +95 -31
  132. package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
  133. package/src/panels/inspector/design/ClonerSection.tsx +323 -62
  134. package/src/panels/inspector/design/ComponentSection.tsx +50 -43
  135. package/src/panels/inspector/design/CornerSection.tsx +45 -14
  136. package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
  137. package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
  138. package/src/panels/inspector/design/DeviceSection.tsx +210 -0
  139. package/src/panels/inspector/design/FillSection.tsx +83 -51
  140. package/src/panels/inspector/design/FontField.tsx +196 -0
  141. package/src/panels/inspector/design/HtmlSection.tsx +23 -12
  142. package/src/panels/inspector/design/InstanceSection.tsx +373 -154
  143. package/src/panels/inspector/design/MaskSection.tsx +84 -0
  144. package/src/panels/inspector/design/MaterialSection.tsx +177 -0
  145. package/src/panels/inspector/design/MediaSection.tsx +273 -120
  146. package/src/panels/inspector/design/MeshSection.tsx +910 -82
  147. package/src/panels/inspector/design/PaintRows.tsx +550 -0
  148. package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
  149. package/src/panels/inspector/design/ShadowSection.tsx +221 -48
  150. package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
  151. package/src/panels/inspector/design/StrokeSection.tsx +228 -43
  152. package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
  153. package/src/panels/inspector/design/TransformSection.tsx +244 -50
  154. package/src/panels/inspector/design/TypographySection.tsx +679 -110
  155. package/src/panels/inspector/layout.ts +295 -0
  156. package/src/panels/inspector/propValue.ts +199 -0
  157. package/src/panels/inspector/scene/CameraSection.tsx +232 -22
  158. package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
  159. package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
  160. package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
  161. package/src/panels/inspector/scene/SceneSection.tsx +219 -44
  162. package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
  163. package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
  164. package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
  165. package/src/panels/inspector/timeModel.ts +190 -0
  166. package/src/panels/inspector/useDocWrite.ts +438 -146
  167. package/src/panels/library/LibraryBrowser.tsx +435 -0
  168. package/src/panels/library/LibraryGrid.tsx +190 -0
  169. package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
  170. package/src/panels/library/LibraryRail.tsx +84 -0
  171. package/src/panels/library/LibrarySoundPanel.tsx +293 -0
  172. package/src/panels/library/SoundHistory.tsx +245 -0
  173. package/src/panels/library/StockSounds.tsx +390 -0
  174. package/src/panels/library/libraryModel.ts +147 -0
  175. package/src/panels/library/stockSearch.ts +77 -0
  176. package/src/panels/maskMarks.tsx +117 -0
  177. package/src/panels/material/MaterialCommentNode.tsx +54 -0
  178. package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
  179. package/src/panels/material/MaterialGraphMenu.tsx +45 -0
  180. package/src/panels/material/MaterialGroupNode.tsx +56 -0
  181. package/src/panels/material/MaterialLibrary.tsx +253 -0
  182. package/src/panels/material/MaterialNode.tsx +453 -0
  183. package/src/panels/material/MaterialNodePalette.tsx +349 -0
  184. package/src/panels/material/MaterialParameterControls.tsx +525 -0
  185. package/src/panels/material/MaterialRerouteNode.tsx +42 -0
  186. package/src/panels/material/MaterialWorkspace.tsx +501 -0
  187. package/src/panels/material/NodePreview.tsx +70 -0
  188. package/src/panels/material/flowModel.ts +441 -0
  189. package/src/panels/material/parameterValues.ts +231 -0
  190. package/src/panels/material/previewScheduler.ts +235 -0
  191. package/src/panels/material/reactFlow.css +6 -0
  192. package/src/panels/material/socketGlyph.ts +85 -0
  193. package/src/panels/material/viewportMemory.ts +58 -0
  194. package/src/panels/preferences/PreferencesDialog.tsx +215 -0
  195. package/src/panels/share/ShareComponentDialog.tsx +247 -0
  196. package/src/panels/stage/StageMenu.tsx +35 -5
  197. package/src/panels/stage/StagePanel.tsx +338 -23
  198. package/src/panels/stage/cellSlots.tsx +70 -0
  199. package/src/panels/storyboard/BoardCanvas.tsx +739 -0
  200. package/src/panels/storyboard/BoardCursors.tsx +158 -0
  201. package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
  202. package/src/panels/storyboard/BoardPlan.tsx +42 -0
  203. package/src/panels/storyboard/ComponentSheet.tsx +187 -0
  204. package/src/panels/storyboard/ComponentStage.tsx +235 -0
  205. package/src/panels/storyboard/MiniSketch.tsx +173 -0
  206. package/src/panels/storyboard/PartPicker.tsx +64 -0
  207. package/src/panels/storyboard/PlanPanel.tsx +617 -0
  208. package/src/panels/storyboard/ReferencePreview.tsx +217 -0
  209. package/src/panels/storyboard/ShotBrief.tsx +360 -0
  210. package/src/panels/storyboard/ShotCard.tsx +251 -0
  211. package/src/panels/storyboard/StagePreview.tsx +348 -0
  212. package/src/panels/storyboard/StillThumbs.tsx +162 -0
  213. package/src/panels/storyboard/StoryboardView.tsx +79 -0
  214. package/src/panels/storyboard/board.css +1065 -0
  215. package/src/panels/storyboard/board.ts +12 -0
  216. package/src/panels/storyboard/boardClipboard.ts +111 -0
  217. package/src/panels/storyboard/boardOrder.ts +68 -0
  218. package/src/panels/storyboard/boardRows.tsx +425 -0
  219. package/src/panels/storyboard/liveCap.ts +90 -0
  220. package/src/panels/storyboard/plan.css +295 -0
  221. package/src/panels/storyboard/posterCache.ts +133 -0
  222. package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
  223. package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
  224. package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
  225. package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
  226. package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
  227. package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
  228. package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
  229. package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
  230. package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
  231. package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
  232. package/src/panels/storyboard/shotboard/sketch.css +1392 -0
  233. package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
  234. package/src/panels/storyboard/storyboardDrop.ts +164 -0
  235. package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
  236. package/src/panels/studio/ComponentSidePanel.tsx +355 -0
  237. package/src/panels/studio/ComponentStudio.tsx +243 -0
  238. package/src/panels/studio/ComponentsHome.tsx +318 -0
  239. package/src/panels/studio/ComponentsPreview.tsx +179 -0
  240. package/src/panels/studio/EveryUse.tsx +244 -0
  241. package/src/panels/studio/ThisUsePanel.tsx +679 -0
  242. package/src/panels/studio/componentParts.tsx +341 -0
  243. package/src/panels/studio/components.ts +6 -0
  244. package/src/panels/studio/newCodeComponent.ts +66 -0
  245. package/src/panels/timeline/AudioFxButton.tsx +290 -0
  246. package/src/panels/timeline/AudioLane.tsx +674 -85
  247. package/src/panels/timeline/BlockRow.tsx +154 -85
  248. package/src/panels/timeline/ChannelRow.tsx +378 -129
  249. package/src/panels/timeline/CurvePane.tsx +508 -0
  250. package/src/panels/timeline/Filmstrip.tsx +361 -0
  251. package/src/panels/timeline/GenerateDrawer.tsx +407 -0
  252. package/src/panels/timeline/KeyStretch.tsx +115 -0
  253. package/src/panels/timeline/LifespanRow.tsx +730 -47
  254. package/src/panels/timeline/MarkerLane.tsx +46 -8
  255. package/src/panels/timeline/PlayButton.tsx +91 -0
  256. package/src/panels/timeline/PropertyPicker.tsx +254 -0
  257. package/src/panels/timeline/ShotRow.tsx +400 -0
  258. package/src/panels/timeline/SoundMode.tsx +1406 -0
  259. package/src/panels/timeline/TimelineDock.tsx +2338 -272
  260. package/src/panels/timeline/TransportVolume.tsx +163 -0
  261. package/src/panels/timeline/beatRuler.ts +74 -0
  262. package/src/panels/timeline/curveModel.ts +500 -0
  263. package/src/panels/timeline/duckUnderVoice.ts +68 -0
  264. package/src/panels/timeline/keyframeMenu.ts +138 -0
  265. package/src/panels/timeline/labelWidth.ts +49 -0
  266. package/src/panels/timeline/marquee.ts +155 -0
  267. package/src/panels/timeline/model.ts +1860 -199
  268. package/src/panels/timeline/snap.ts +125 -43
  269. package/src/panels/timeline/staggerPreview.tsx +92 -0
  270. package/src/panels/timeline/takeMeasure.ts +72 -0
  271. package/src/panels/timeline/useLive.ts +48 -0
  272. package/src/panels/timeline/viewport.ts +234 -0
  273. package/src/panels/toolbar/BackChip.tsx +116 -0
  274. package/src/panels/toolbar/CameraViewBar.tsx +118 -49
  275. package/src/panels/toolbar/CanvasBar.tsx +117 -0
  276. package/src/panels/toolbar/InsertButton.tsx +38 -0
  277. package/src/panels/toolbar/IsolationTrail.tsx +156 -0
  278. package/src/panels/toolbar/PanelToggles.tsx +86 -0
  279. package/src/panels/toolbar/ShadingBar.tsx +119 -26
  280. package/src/panels/toolbar/ToolDockBar.tsx +329 -80
  281. package/src/panels/toolbar/ToolFooter.tsx +102 -0
  282. package/src/panels/toolbar/ViewModeBar.tsx +247 -38
  283. package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
  284. package/src/panels/toolbar/useAddRows.tsx +326 -0
  285. package/src/panels/toolbar/useDismiss.ts +122 -0
  286. package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
  287. package/src/panels/versions/versionModel.ts +103 -0
  288. package/src/panels/virtualWindow.ts +271 -0
  289. package/src/shotTokens.css +151 -60
  290. package/src/stage/FrustumGizmo.tsx +504 -84
  291. package/src/stage/GridPlane.tsx +225 -16
  292. package/src/stage/NodePreviews.tsx +628 -0
  293. package/src/stage/PerfHud.tsx +437 -47
  294. package/src/stage/SplitRenderer.tsx +816 -169
  295. package/src/stage/StageApp.tsx +4274 -486
  296. package/src/stage/StageRootBoundary.tsx +114 -0
  297. package/src/stage/StageTools.tsx +651 -151
  298. package/src/stage/TransformGizmo.tsx +696 -0
  299. package/src/stage/adaptivePresentation.ts +145 -0
  300. package/src/stage/bridgeClient.ts +2522 -41
  301. package/src/stage/exportWorker.ts +9 -0
  302. package/src/stage/feed.ts +305 -14
  303. package/src/stage/fontPreview.ts +176 -0
  304. package/src/stage/frustumGeometry.ts +319 -1
  305. package/src/stage/furnitureTokens.ts +33 -0
  306. package/src/stage/gizmoMath.ts +508 -16
  307. package/src/stage/gpuTimer.ts +276 -0
  308. package/src/stage/instanceModules.ts +194 -33
  309. package/src/stage/interactions/move.ts +24 -5
  310. package/src/stage/isolation.ts +291 -33
  311. package/src/stage/jsSelfProfiler.ts +325 -0
  312. package/src/stage/main.tsx +116 -16
  313. package/src/stage/modalState.ts +222 -23
  314. package/src/stage/moduleEpoch.ts +60 -0
  315. package/src/stage/motionPathStage.ts +279 -0
  316. package/src/stage/motionPreview.ts +74 -0
  317. package/src/stage/overlays/Overlays.tsx +1112 -159
  318. package/src/stage/overlays/Rulers.tsx +495 -0
  319. package/src/stage/panes.ts +159 -10
  320. package/src/stage/project.ts +68 -3
  321. package/src/stage/renderScaleController.ts +730 -0
  322. package/src/stage/rulers.ts +129 -0
  323. package/src/stage/sceneExtent.ts +75 -0
  324. package/src/stage/shading.ts +20 -0
  325. package/src/stage/splineStage.ts +632 -0
  326. package/src/stage/stagePerformanceTelemetry.ts +717 -0
  327. package/src/stage/stageTheme.ts +36 -0
  328. package/src/stage/stageWarmup.ts +11 -0
  329. package/src/stage/textAutoSize.ts +124 -0
  330. package/src/stage/tier1Export.ts +641 -0
  331. package/src/stage/tokenColor.ts +14 -9
  332. package/src/stage/view.ts +56 -9
  333. package/src/stage/workingView.ts +277 -33
  334. package/src/state/accountPrefs.ts +261 -0
  335. package/src/state/agentActions.ts +173 -15
  336. package/src/state/agentActivity.ts +198 -0
  337. package/src/state/agentQuestion.ts +239 -0
  338. package/src/state/bootOpen.ts +42 -0
  339. package/src/state/cameraVerbs.ts +139 -0
  340. package/src/state/clientId.ts +6 -1
  341. package/src/state/comments.ts +1099 -0
  342. package/src/state/context.tsx +19 -1
  343. package/src/state/editorPrefs.ts +220 -0
  344. package/src/state/errorReports.ts +121 -0
  345. package/src/state/fieldWrite.ts +161 -0
  346. package/src/state/fontRecents.ts +70 -0
  347. package/src/state/fonts.ts +656 -0
  348. package/src/state/graphClipboard.ts +414 -0
  349. package/src/state/imageThumbs.ts +216 -0
  350. package/src/state/insert.ts +444 -67
  351. package/src/state/isolationTrail.ts +92 -0
  352. package/src/state/keyMoves.ts +134 -0
  353. package/src/state/keyframeClipboard.ts +177 -0
  354. package/src/state/keymap.ts +1153 -117
  355. package/src/state/layoutPrefs.ts +272 -0
  356. package/src/state/layouts.ts +205 -0
  357. package/src/state/measureMedia.ts +167 -0
  358. package/src/state/menu.ts +2341 -28
  359. package/src/state/motionStamp.ts +7 -0
  360. package/src/state/panelSize.ts +375 -5
  361. package/src/state/pendingChanges.ts +119 -0
  362. package/src/state/playHold.ts +78 -0
  363. package/src/state/playbackPreparation.ts +562 -0
  364. package/src/state/posterCapture.ts +242 -0
  365. package/src/state/presence.ts +254 -0
  366. package/src/state/projectComponents.ts +160 -0
  367. package/src/state/renderEvents.ts +298 -18
  368. package/src/state/runtimeErrors.ts +46 -0
  369. package/src/state/selection.ts +12 -0
  370. package/src/state/shortcuts.ts +1606 -0
  371. package/src/state/sketchGuide.ts +51 -0
  372. package/src/state/splineRefs.ts +87 -0
  373. package/src/state/stageHealth.ts +259 -0
  374. package/src/state/stageWatchdog.ts +47 -0
  375. package/src/state/stillStage.ts +330 -0
  376. package/src/state/stillThumbs.ts +474 -0
  377. package/src/state/store.ts +23591 -2982
  378. package/src/state/storyboardModel.ts +528 -0
  379. package/src/state/sync.ts +1257 -0
  380. package/src/state/syncCache.ts +159 -0
  381. package/src/state/triangles.ts +13 -0
  382. package/src/state/types.ts +1698 -55
  383. package/src/state/unsavedGuard.ts +49 -0
  384. package/src/state/viewMode.ts +200 -0
  385. package/src/state/webmcp.ts +1128 -0
  386. package/src/state/workspace.ts +1147 -0
  387. package/src/styles/assets-tab.css +304 -0
  388. package/src/styles/canvas.css +545 -0
  389. package/src/styles/comments.css +1435 -0
  390. package/src/styles/controls.css +162 -0
  391. package/src/styles/dialogs.css +390 -0
  392. package/src/styles/insert-drawer.css +586 -0
  393. package/src/styles/inspector-board.css +207 -0
  394. package/src/styles/inspector-object.css +486 -0
  395. package/src/styles/inspector-shot.css +252 -0
  396. package/src/styles/inspector.css +360 -0
  397. package/src/styles/left-column.css +1628 -0
  398. package/src/styles/library-preview.css +575 -0
  399. package/src/styles/library.css +512 -0
  400. package/src/styles/modes/code.css +569 -0
  401. package/src/styles/modes/component.css +1561 -0
  402. package/src/styles/modes/material.css +1201 -0
  403. package/src/styles/modes/storyboard.css +518 -0
  404. package/src/styles/share-component.css +49 -0
  405. package/src/styles/shell.css +653 -0
  406. package/src/styles/shot-brief.css +360 -0
  407. package/src/styles/states.css +76 -0
  408. package/src/styles/sync.css +380 -0
  409. package/src/styles/timeline-graph.css +384 -0
  410. package/src/styles/timeline-meters.css +89 -0
  411. package/src/styles/timeline-rows.css +1959 -0
  412. package/src/styles/timeline-sound.css +1003 -0
  413. package/src/styles/timeline.css +754 -0
  414. package/src/styles/tool-footer.css +525 -0
  415. package/src/styles/topbar.css +886 -0
  416. package/src/styles/versions.css +262 -0
  417. package/src/styles.css +72 -2921
  418. package/src/tools.ts +108 -23
  419. package/dist/assets/index-B7notge7.css +0 -1
  420. package/dist/assets/index-BBE6Ta9L.js +0 -52
  421. package/src/panels/files/icons.tsx +0 -118
  422. package/src/panels/header/DocSwitcher.tsx +0 -85
  423. package/src/panels/header/TransportBar.tsx +0 -86
  424. package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
  425. package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
  426. package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
  427. package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
  428. package/src/panels/inspector/animate/useBlocks.ts +0 -43
  429. package/src/panels/material/MaterialPicker.tsx +0 -172
  430. package/src/panels/timeline/SceneStrip.tsx +0 -145
  431. package/src/panels/toolbar/AddMenu.tsx +0 -230
  432. package/src/stage/MoveGizmo.tsx +0 -411
@@ -0,0 +1,1099 @@
1
+ // COMMENTS (plan C5, Paper page 20; S5): threads pinned to a storyboard
2
+ // Frame, a board card or a spot in the Studio, their messages, the links
3
+ // between a board thread and a Studio thread, the draft, the panel's filter,
4
+ // and the inbox.
5
+ //
6
+ // TWO PLACES, ONE LIST EACH (S5). The board talks about the plan: Frame pins
7
+ // and card threads. The Studio talks about the picture at a moment: Studio
8
+ // threads. The panel never mixes the two (`boardThreads`, `studioThreads`).
9
+ //
10
+ // A SMALL STORE OF ITS OWN, beside the editor's. Comments are not document
11
+ // edits: they never enter undo, never save with the Project, and change under
12
+ // the author's feet (another reviewer, an agent). So they live here, created
13
+ // by and hung on the editor store as `store.comments`. The editor store reads
14
+ // them for the board frame's pins and wakes on every change of this one.
15
+ //
16
+ // THE SERVER IS THE TRUTH. Every write goes to `/api/comments` and the answer
17
+ // replaces the local row. The `comments-changed` and `inbox-changed` events
18
+ // (renderEvents.ts) are signals with no payload worth trusting, so they refetch.
19
+ //
20
+ // WHERE A THREAD IS NOW is read against the LIVE document (`orphanOf`), not the
21
+ // saved one the server judged: an undo that brings a Frame back brings its pin
22
+ // back on the same frame, before the save lands.
23
+ import { visibleStills } from "@dailies-studio/shot/doc";
24
+ import type { Doc3D } from "@dailies-studio/shot/doc";
25
+ import * as api from "../api.js";
26
+ import type {
27
+ CommentAnchor,
28
+ CommentCardAnchor,
29
+ CommentFrameAnchor,
30
+ CommentOrphan,
31
+ CommentStudioAnchor,
32
+ CommentThread,
33
+ InboxItem,
34
+ Mentionable,
35
+ } from "../api.js";
36
+ import type { ShotStudioPin } from "../bridgeTypes.js";
37
+ import { boardShots, shotNameOf, shotNumberOf, stillNameOf } from "./storyboardModel.js";
38
+
39
+ export type CommentsFilter = "open" | "resolved" | "mine" | "mentions";
40
+
41
+ /** The most characters one message may hold (packages/db COMMENT_BODY_MAX). */
42
+ export const COMMENT_BODY_MAX = 4000;
43
+
44
+ /** The composer key of the draft pin, beside the thread ids. */
45
+ export const NEW_THREAD = "new";
46
+
47
+ /** The largest picture a thread keeps (packages/store COMMENT_SNAPSHOT_MAX). */
48
+ const COMMENT_SNAPSHOT_MAX = 256 * 1024;
49
+
50
+ export interface CommentsState {
51
+ /** The Project the threads belong to; null before a load. */
52
+ projectId: string | null;
53
+ /** Every thread of the Project, open and resolved, newest activity first. */
54
+ threads: readonly CommentThread[];
55
+ loaded: boolean;
56
+ /** The last failed read, shown in the panel. */
57
+ error: string | null;
58
+ filter: CommentsFilter;
59
+ /** The ids the server's Mine or Mentions filter answered; null until it has. */
60
+ matched: readonly string[] | null;
61
+ /** The thread open in the popover. */
62
+ openId: string | null;
63
+ /** The open thread with its messages, once read. */
64
+ detail: CommentThread | null;
65
+ /** The thread being written, before its first post: a Frame pin, a card
66
+ * or a Studio spot. */
67
+ draft: { anchor: CommentAnchor } | null;
68
+ /** What the composer holds, per thread id and `NEW_THREAD`. */
69
+ texts: Readonly<Record<string, string>>;
70
+ /** Names for mention chips and the assignee menu, by user id. */
71
+ names: Readonly<Record<string, string>>;
72
+ inbox: { items: readonly InboxItem[]; unread: number; loaded: boolean };
73
+ }
74
+
75
+ /** The API calls this store makes: the real module, or a test's double. */
76
+ export type CommentsApi = Pick<
77
+ typeof api,
78
+ | "listThreads"
79
+ | "getThread"
80
+ | "createThread"
81
+ | "replyThread"
82
+ | "patchThread"
83
+ | "deleteThread"
84
+ | "editMessage"
85
+ | "deleteMessage"
86
+ | "markThreadRead"
87
+ | "mentionable"
88
+ | "inbox"
89
+ | "markInboxRead"
90
+ | "putSnapshot"
91
+ | "linkThread"
92
+ | "unlinkThread"
93
+ >;
94
+
95
+ export interface CommentsStore {
96
+ getState(): CommentsState;
97
+ subscribe(listener: () => void): () => void;
98
+ /** Read the Project's threads (and the people to mention). Again for the
99
+ * same Project, it refreshes. */
100
+ load(projectId: string): Promise<void>;
101
+ /** Forget the Project: another one is opening. */
102
+ reset(): void;
103
+ /** Re-read the threads, the open thread and the filter's matches. */
104
+ refresh(): Promise<void>;
105
+ setFilter(filter: CommentsFilter): Promise<void>;
106
+ open(threadId: string): Promise<void>;
107
+ close(): void;
108
+ startDraft(anchor: CommentAnchor): void;
109
+ cancelDraft(): void;
110
+ setText(key: string, text: string): void;
111
+ /** Post the composer's text for `key` (a thread id, or `NEW_THREAD` for the
112
+ * draft pin). Answers the refusal to show, or null once it landed. A new
113
+ * thread then keeps `picture()`, the Frame as the author saw it, best
114
+ * effort: a removed Frame's thread shows it. */
115
+ post(key: string, body: string, picture?: () => Promise<Blob | null>): Promise<string | null>;
116
+ setStatus(threadId: string, status: "open" | "resolved"): Promise<string | null>;
117
+ assign(threadId: string, userId: string | null): Promise<string | null>;
118
+ remove(threadId: string): Promise<string | null>;
119
+ /** Link a board thread and a Studio thread ("Link to Studio", "Link to
120
+ * board"), or undo it. Both rows learn the link. The refusal, or null. */
121
+ link(threadId: string, otherId: string): Promise<string | null>;
122
+ unlink(threadId: string, otherId: string): Promise<string | null>;
123
+ editMessage(threadId: string, messageId: string, body: string): Promise<string | null>;
124
+ deleteMessage(threadId: string, messageId: string): Promise<string | null>;
125
+ /** The people a mention may name, for the menu. */
126
+ mentionable(q: string): Promise<Mentionable>;
127
+ loadInbox(): Promise<void>;
128
+ markInboxRead(ids?: readonly number[]): Promise<void>;
129
+ /** A `comments-changed` event: refetch when it is about this Project. */
130
+ changed(event: { projectId?: unknown }): void;
131
+ }
132
+
133
+ const EMPTY: CommentsState = {
134
+ projectId: null,
135
+ threads: [],
136
+ loaded: false,
137
+ error: null,
138
+ filter: "open",
139
+ matched: null,
140
+ openId: null,
141
+ detail: null,
142
+ draft: null,
143
+ texts: {},
144
+ names: {},
145
+ inbox: { items: [], unread: 0, loaded: false },
146
+ };
147
+
148
+ /** The most pages one load reads. */
149
+ // ponytail: 10 pages of 50 threads. A Project past 500 threads pages the
150
+ // panel instead (`nextCursor` is there for it).
151
+ const MAX_PAGES = 10;
152
+
153
+ const messageOf = (e: unknown): string => (e instanceof Error ? e.message : String(e));
154
+
155
+ /** A list with `thread` in place of the row of the same id, or added first. */
156
+ export function upsertThread(threads: readonly CommentThread[], thread: CommentThread): CommentThread[] {
157
+ const { messages: _messages, ...row } = thread;
158
+ const at = threads.findIndex((t) => t.id === thread.id);
159
+ return at < 0 ? [row, ...threads] : threads.map((t, i) => (i === at ? row : t));
160
+ }
161
+
162
+ /** The names a list of threads teaches: authors, assignees. */
163
+ function namesFrom(threads: readonly CommentThread[]): Record<string, string> {
164
+ const names: Record<string, string> = {};
165
+ for (const t of threads) {
166
+ if (t.head !== null) names[t.head.authorId] = t.head.authorLabel;
167
+ if (t.assignee !== null) names[t.assignee.id] = personLabel(t.assignee);
168
+ for (const m of t.messages ?? []) names[m.authorId] = m.authorLabel;
169
+ }
170
+ return names;
171
+ }
172
+
173
+ export function createCommentsStore(deps: { api?: CommentsApi } = {}): CommentsStore {
174
+ const http: CommentsApi = deps.api ?? api;
175
+ let state: CommentsState = EMPTY;
176
+ const listeners = new Set<() => void>();
177
+ /** Bumped by `reset`, so an answer for the last Project lands nowhere. */
178
+ let generation = 0;
179
+
180
+ function set(patch: Partial<CommentsState>): void {
181
+ state = { ...state, ...patch };
182
+ for (const listener of listeners) listener();
183
+ }
184
+
185
+ const learn = (threads: readonly CommentThread[]) => ({ names: { ...state.names, ...namesFrom(threads) } });
186
+
187
+ async function readAll(projectId: string): Promise<CommentThread[]> {
188
+ const all: CommentThread[] = [];
189
+ let cursor: string | undefined;
190
+ for (let page = 0; page < MAX_PAGES; page++) {
191
+ const res = await http.listThreads(projectId, cursor === undefined ? {} : { cursor });
192
+ all.push(...res.threads);
193
+ cursor = res.nextCursor;
194
+ if (cursor === undefined) break;
195
+ }
196
+ return all;
197
+ }
198
+
199
+ async function readMatched(projectId: string, filter: CommentsFilter): Promise<readonly string[] | null> {
200
+ if (filter !== "mine" && filter !== "mentions") return null;
201
+ const res = await http.listThreads(projectId, { filter });
202
+ return res.threads.map((t) => t.id);
203
+ }
204
+
205
+ /** Runs a write, folding its thread into the list. The refusal's message,
206
+ * or null. */
207
+ async function write(run: () => Promise<CommentThread | null>): Promise<string | null> {
208
+ const gen = generation;
209
+ try {
210
+ const thread = await run();
211
+ if (gen !== generation || thread === null) return null;
212
+ set({
213
+ threads: upsertThread(state.threads, thread),
214
+ ...(state.openId === thread.id
215
+ ? { detail: thread.messages === undefined ? { ...thread, messages: state.detail?.messages ?? [] } : thread }
216
+ : {}),
217
+ ...learn([thread]),
218
+ });
219
+ return null;
220
+ } catch (e) {
221
+ return messageOf(e);
222
+ }
223
+ }
224
+
225
+ /** Upload a new thread's picture. Silent: the thread stands without it. */
226
+ async function keepPicture(threadId: string, picture: () => Promise<Blob | null>): Promise<void> {
227
+ try {
228
+ const blob = await picture();
229
+ if (blob !== null && blob.size > 0 && blob.size <= COMMENT_SNAPSHOT_MAX) await http.putSnapshot(threadId, blob);
230
+ } catch {
231
+ // A thread without its picture shows the live Frame, or a ground.
232
+ }
233
+ }
234
+
235
+ /** A link or unlink: the server answers `threadId`'s row, and the other
236
+ * row takes the same link from here, so neither waits for a refetch. */
237
+ async function relink(threadId: string, otherId: string, on: boolean): Promise<string | null> {
238
+ const said = await write(() => (on ? http.linkThread(threadId, otherId) : http.unlinkThread(threadId, otherId)));
239
+ if (said !== null) return said;
240
+ const self = state.threads.find((t) => t.id === threadId);
241
+ const other = state.threads.find((t) => t.id === otherId);
242
+ if (self === undefined || other === undefined) return null;
243
+ const links = other.links.filter((l) => l.threadId !== threadId);
244
+ const next = { ...other, links: on ? [...links, { threadId, kind: self.anchor.kind }] : links };
245
+ set({
246
+ threads: upsertThread(state.threads, next),
247
+ ...(state.detail?.id === otherId ? { detail: { ...state.detail, links: next.links } } : {}),
248
+ });
249
+ return null;
250
+ }
251
+
252
+ const store: CommentsStore = {
253
+ getState: () => state,
254
+ subscribe(listener) {
255
+ listeners.add(listener);
256
+ return () => listeners.delete(listener);
257
+ },
258
+
259
+ async load(projectId) {
260
+ if (state.projectId !== projectId) {
261
+ generation++;
262
+ set({ ...EMPTY, projectId, inbox: state.inbox });
263
+ }
264
+ const gen = generation;
265
+ try {
266
+ const [threads, people] = await Promise.all([
267
+ readAll(projectId),
268
+ http.mentionable(projectId, "").catch(() => null),
269
+ ]);
270
+ if (gen !== generation) return;
271
+ const names: Record<string, string> = { ...state.names, ...namesFrom(threads) };
272
+ for (const p of [...(people?.people ?? []), ...(people?.others ?? [])]) names[p.id] = personLabel(p);
273
+ set({ threads, loaded: true, error: null, names });
274
+ } catch (e) {
275
+ if (gen === generation) set({ loaded: true, error: messageOf(e) });
276
+ }
277
+ },
278
+
279
+ reset() {
280
+ generation++;
281
+ set({ ...EMPTY, inbox: state.inbox });
282
+ },
283
+
284
+ async refresh() {
285
+ const { projectId, openId, filter } = state;
286
+ if (projectId === null) return;
287
+ const gen = generation;
288
+ try {
289
+ const [threads, matched, detail] = await Promise.all([
290
+ readAll(projectId),
291
+ readMatched(projectId, filter),
292
+ openId === null ? null : http.getThread(openId).catch(() => null),
293
+ ]);
294
+ if (gen !== generation) return;
295
+ set({
296
+ threads,
297
+ matched,
298
+ error: null,
299
+ loaded: true,
300
+ // A thread deleted elsewhere closes here.
301
+ ...(openId !== null && state.openId === openId
302
+ ? detail === null
303
+ ? { openId: null, detail: null }
304
+ : { detail }
305
+ : {}),
306
+ ...learn(detail === null ? threads : [...threads, detail]),
307
+ });
308
+ } catch (e) {
309
+ if (gen === generation) set({ error: messageOf(e) });
310
+ }
311
+ },
312
+
313
+ async setFilter(filter) {
314
+ set({ filter, matched: null });
315
+ const { projectId } = state;
316
+ if (projectId === null) return;
317
+ const gen = generation;
318
+ try {
319
+ const matched = await readMatched(projectId, filter);
320
+ if (gen === generation && state.filter === filter) set({ matched });
321
+ } catch (e) {
322
+ if (gen === generation) set({ error: messageOf(e) });
323
+ }
324
+ },
325
+
326
+ async open(threadId) {
327
+ const known = state.threads.find((t) => t.id === threadId) ?? null;
328
+ set({ openId: threadId, draft: null, detail: known });
329
+ const gen = generation;
330
+ try {
331
+ const thread = await http.getThread(threadId);
332
+ if (gen !== generation || state.openId !== threadId) return;
333
+ const read = { ...thread, viewer: { ...thread.viewer, unread: false } };
334
+ set({ detail: read, threads: upsertThread(state.threads, read), ...learn([thread]) });
335
+ if (thread.viewer.unread) await http.markThreadRead(threadId).catch(() => undefined);
336
+ } catch (e) {
337
+ if (gen === generation && state.openId === threadId) set({ openId: null, detail: null, error: messageOf(e) });
338
+ }
339
+ },
340
+
341
+ close() {
342
+ if (state.openId !== null) set({ openId: null, detail: null });
343
+ },
344
+
345
+ startDraft(anchor) {
346
+ set({ draft: { anchor }, openId: null, detail: null });
347
+ },
348
+
349
+ cancelDraft() {
350
+ if (state.draft === null) return;
351
+ const { [NEW_THREAD]: _gone, ...texts } = state.texts;
352
+ set({ draft: null, texts });
353
+ },
354
+
355
+ setText(key, text) {
356
+ set({ texts: { ...state.texts, [key]: text } });
357
+ },
358
+
359
+ async post(key, body, picture) {
360
+ const { projectId, draft } = state;
361
+ const text = body.trim();
362
+ if (text === "") return "Write something first.";
363
+ if (text.length > COMMENT_BODY_MAX) return `A comment holds at most ${COMMENT_BODY_MAX} characters.`;
364
+ const gen = generation;
365
+ try {
366
+ if (key === NEW_THREAD) {
367
+ if (projectId === null || draft === null) return "Pick a spot on the Frame first.";
368
+ const thread = await http.createThread(projectId, draft.anchor, text);
369
+ if (gen !== generation) return null;
370
+ const { [NEW_THREAD]: _gone, ...texts } = state.texts;
371
+ set({
372
+ threads: upsertThread(state.threads, thread),
373
+ draft: null,
374
+ texts,
375
+ openId: thread.id,
376
+ detail: { ...thread, messages: thread.head === null ? [] : [thread.head] },
377
+ ...learn([thread]),
378
+ });
379
+ if (picture !== undefined && thread.can.snapshot) void keepPicture(thread.id, picture);
380
+ return null;
381
+ }
382
+ const res = await http.replyThread(key, text);
383
+ if (gen !== generation) return null;
384
+ const { [key]: _gone, ...texts } = state.texts;
385
+ const detail =
386
+ state.openId === key
387
+ ? { ...res.thread, messages: [...(state.detail?.messages ?? []), res.message] }
388
+ : state.detail;
389
+ set({ threads: upsertThread(state.threads, res.thread), detail, texts, ...learn([res.thread]) });
390
+ return null;
391
+ } catch (e) {
392
+ return messageOf(e);
393
+ }
394
+ },
395
+
396
+ setStatus: (threadId, status) => write(() => http.patchThread(threadId, { status })),
397
+ assign: (threadId, userId) => write(() => http.patchThread(threadId, { assigneeId: userId })),
398
+
399
+ async remove(threadId) {
400
+ try {
401
+ await http.deleteThread(threadId);
402
+ set({
403
+ threads: state.threads.filter((t) => t.id !== threadId),
404
+ ...(state.openId === threadId ? { openId: null, detail: null } : {}),
405
+ });
406
+ return null;
407
+ } catch (e) {
408
+ return messageOf(e);
409
+ }
410
+ },
411
+
412
+ link: (threadId, otherId) => relink(threadId, otherId, true),
413
+ unlink: (threadId, otherId) => relink(threadId, otherId, false),
414
+
415
+ async editMessage(threadId, messageId, body) {
416
+ const text = body.trim();
417
+ if (text === "") return "Write something first.";
418
+ try {
419
+ const message = await http.editMessage(threadId, messageId, text);
420
+ const detail = state.detail;
421
+ if (message !== null && detail?.id === threadId) {
422
+ set({ detail: { ...detail, messages: (detail.messages ?? []).map((m) => (m.id === messageId ? message : m)) } });
423
+ }
424
+ return null;
425
+ } catch (e) {
426
+ return messageOf(e);
427
+ }
428
+ },
429
+
430
+ async deleteMessage(threadId, messageId) {
431
+ try {
432
+ await http.deleteMessage(threadId, messageId);
433
+ const detail = state.detail;
434
+ if (detail?.id === threadId) {
435
+ set({ detail: { ...detail, messages: (detail.messages ?? []).filter((m) => m.id !== messageId) } });
436
+ }
437
+ void store.refresh();
438
+ return null;
439
+ } catch (e) {
440
+ return messageOf(e);
441
+ }
442
+ },
443
+
444
+ async mentionable(q) {
445
+ const { projectId } = state;
446
+ if (projectId === null) return { people: [], others: [], canShare: false };
447
+ const res = await http.mentionable(projectId, q);
448
+ const names: Record<string, string> = {};
449
+ for (const p of [...res.people, ...res.others]) names[p.id] = personLabel(p);
450
+ set({ names: { ...state.names, ...names } });
451
+ return res;
452
+ },
453
+
454
+ async loadInbox() {
455
+ try {
456
+ const res = await http.inbox(false);
457
+ set({ inbox: { items: res.items, unread: res.unread, loaded: true } });
458
+ } catch {
459
+ // The badge keeps what it had: an inbox that cannot be read (an older
460
+ // server, signed out) is no error the author can act on.
461
+ if (!state.inbox.loaded) set({ inbox: { ...state.inbox, loaded: true } });
462
+ }
463
+ },
464
+
465
+ async markInboxRead(ids) {
466
+ const now = Date.now();
467
+ let read = 0;
468
+ const items = state.inbox.items.map((item) => {
469
+ if (item.readAt !== null || (ids !== undefined && !ids.includes(item.id))) return item;
470
+ read++;
471
+ return { ...item, readAt: now };
472
+ });
473
+ const unread = ids === undefined ? 0 : Math.max(0, state.inbox.unread - read);
474
+ set({ inbox: { ...state.inbox, items, unread } });
475
+ await http.markInboxRead(ids).catch(() => undefined);
476
+ void store.loadInbox();
477
+ },
478
+
479
+ changed(event) {
480
+ if (state.projectId !== null && event.projectId === state.projectId) void store.refresh();
481
+ },
482
+ };
483
+ return store;
484
+ }
485
+
486
+ // ── pure reads ────────────────────────────────────────────────────────────
487
+
488
+ /** A person's name as the comments show it. */
489
+ export function personLabel(p: { displayName: string | null; login: string }): string {
490
+ return p.displayName?.trim() || p.login;
491
+ }
492
+
493
+ /** The person palette (page 20): each person keeps one colour everywhere. */
494
+ const PERSON_COLORS = ["#0099FF", "#8A6CFF", "#2EC4B6", "#C46CFF", "#5B8CFF"] as const;
495
+
496
+ /** A person's colour, stable for their id. */
497
+ export function personColor(userId: string): string {
498
+ let hash = 0;
499
+ for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
500
+ return PERSON_COLORS[Math.abs(hash) % PERSON_COLORS.length]!;
501
+ }
502
+
503
+ /** A thread on a storyboard Frame. */
504
+ export type FrameThread = CommentThread & { anchor: CommentFrameAnchor };
505
+ /** A thread on a board card. */
506
+ export type CardThread = CommentThread & { anchor: CommentCardAnchor };
507
+ /** A thread on a spot in the Studio. */
508
+ export type StudioThread = CommentThread & { anchor: CommentStudioAnchor };
509
+
510
+ export const isFrameThread = (t: CommentThread): t is FrameThread => t.anchor.kind === "frame";
511
+ export const isCardThread = (t: CommentThread): t is CardThread => t.anchor.kind === "card";
512
+ export const isStudioThread = (t: CommentThread): t is StudioThread => t.anchor.kind === "studio";
513
+
514
+ /** The board's own threads (S5): Frame pins and card threads. */
515
+ export const boardThreads = (threads: readonly CommentThread[]): CommentThread[] =>
516
+ threads.filter((t) => t.anchor.kind !== "studio");
517
+ /** The Studio's own threads (S5). */
518
+ export const studioThreads = (threads: readonly CommentThread[]): StudioThread[] => threads.filter(isStudioThread);
519
+
520
+ /** Which place a thread belongs to: the board or the Studio. */
521
+ export const placeOfKind = (kind: CommentAnchor["kind"]): "board" | "studio" => (kind === "studio" ? "studio" : "board");
522
+
523
+ /** A board card, by the ids a card thread names. */
524
+ export interface CardRef {
525
+ type: "shot" | "reference";
526
+ id: string;
527
+ label: string;
528
+ /** The Shot the card points at now: a planned shot's `scene`, a reference's
529
+ * part's `scene`. Null when there is none. */
530
+ sceneId: string | null;
531
+ }
532
+
533
+ /** The card a card thread sits on, in the live document, or null when it is
534
+ * gone. */
535
+ export function cardOf(doc: Doc3D, target: { partId: string | null; referenceId: string | null }): CardRef | null {
536
+ const parts = doc.storyboard?.parts ?? [];
537
+ if (target.partId !== null) {
538
+ const part = parts.find((p) => p.id === target.partId);
539
+ return part === undefined
540
+ ? null
541
+ : { type: "shot", id: part.id, label: part.label.trim() || "Untitled shot", sceneId: part.scene ?? null };
542
+ }
543
+ const ref = doc.resources?.references?.find((r) => r.id === target.referenceId);
544
+ if (ref === undefined) return null;
545
+ const part = ref.part === undefined ? undefined : parts.find((p) => p.id === ref.part);
546
+ return { type: "reference", id: ref.id, label: ref.label.trim() || "Card", sceneId: part?.scene ?? null };
547
+ }
548
+
549
+ /**
550
+ * Whether a thread's anchor is there in the live document. Null when it is.
551
+ * - A Frame pin: "frame-removed" when its Shot is there but not the Frame,
552
+ * "shot-removed" when the Shot is gone too. Still ids are document-global,
553
+ * so a Frame moved to another Shot keeps its threads.
554
+ * - A card thread: "card-removed" once its part or reference is gone.
555
+ * - A Studio thread: "shot-removed" without its Shot, "layer-removed" without
556
+ * its layer.
557
+ * The server's `orphanState` (packages/store/src/commentOrphans.ts), which
558
+ * this package cannot import.
559
+ */
560
+ export function orphanOf(doc: Doc3D, anchor: CommentAnchor): CommentOrphan {
561
+ const shotThere = (id: string): boolean => doc.scenesById[id] !== undefined && doc.scenes.includes(id);
562
+ if (anchor.kind === "card") return cardOf(doc, anchor) === null ? "card-removed" : null;
563
+ if (anchor.kind === "studio") {
564
+ if (!shotThere(anchor.sceneId)) return "shot-removed";
565
+ if (anchor.layerId === null) return null;
566
+ return doc.scenesById[anchor.sceneId]!.layersById[anchor.layerId] === undefined ? "layer-removed" : null;
567
+ }
568
+ return homeOf(doc, anchor.stillId) !== null ? null : shotThere(anchor.sceneId) ? "frame-removed" : "shot-removed";
569
+ }
570
+
571
+ /** The Shot that holds still `stillId` now, or null. */
572
+ export function homeOf(doc: Doc3D, stillId: string): string | null {
573
+ for (const id of doc.scenes) {
574
+ if (doc.scenesById[id]?.stills?.some((still) => still.id === stillId) === true) return id;
575
+ }
576
+ return null;
577
+ }
578
+
579
+ const isOpen = (t: CommentThread): boolean => t.status === "open";
580
+
581
+ /** Where a pin sits, in words: its Shot, its Frame ("Frame 3 of 5" and the
582
+ * Frame's own name) and the layer under it. A removed Frame keeps the label
583
+ * it was pinned with. */
584
+ export interface ThreadPlace {
585
+ shotNumber: string | null;
586
+ shotName: string;
587
+ frameLabel: string;
588
+ /** "Frame 3 of 5", or null for a removed Frame. */
589
+ ordinal: string | null;
590
+ layer: string | null;
591
+ }
592
+
593
+ export function placeOf(doc: Doc3D, anchor: CommentFrameAnchor): ThreadPlace {
594
+ const home = homeOf(doc, anchor.stillId);
595
+ const sceneId = home ?? anchor.sceneId;
596
+ const scene = doc.scenesById[sceneId];
597
+ const stills = home === null || scene === undefined ? [] : visibleStills(scene);
598
+ const at = stills.findIndex((s) => s.id === anchor.stillId);
599
+ const layer = anchor.layerId === null ? undefined : scene?.layersById[anchor.layerId];
600
+ return {
601
+ shotNumber: home === null ? null : shotNumberOf(doc, home),
602
+ shotName: scene === undefined ? "Removed Shot" : shotNameOf(doc, sceneId),
603
+ frameLabel: at < 0 ? (anchor.frameLabel ?? "Frame") : stillNameOf(stills, stills[at]!.frame),
604
+ ordinal: at < 0 ? null : `Frame ${at + 1} of ${stills.length}`,
605
+ layer: layer === undefined ? null : layer.label.trim() || null,
606
+ };
607
+ }
608
+
609
+ /** A Studio thread's moment as S5 writes it: "00:01.10", minutes, seconds
610
+ * and the frame inside that second, from a Shot-local frame. */
611
+ export function studioTime(frame: number, fps: { num: number; den: number }): string {
612
+ const perSecond = Math.max(1, Math.round(fps.num / fps.den));
613
+ const seconds = Math.floor(frame / perSecond);
614
+ const two = (n: number): string => String(n).padStart(2, "0");
615
+ return `${two(Math.floor(seconds / 60))}:${two(seconds % 60)}.${two(frame - seconds * perSecond)}`;
616
+ }
617
+
618
+ /** What a thread's head says about where it sits (S5): "Frame 3 of 5 · on
619
+ * Logo", "On card · 02 Cards gather", or the Studio's "00:01.10" and
620
+ * "Layer · Card back". A gone anchor says so. */
621
+ export interface ThreadWhere {
622
+ /** The Studio moment, drawn as a timecode; null off the Studio. */
623
+ time: string | null;
624
+ text: string;
625
+ }
626
+
627
+ export function threadWhere(doc: Doc3D, anchor: CommentAnchor): ThreadWhere {
628
+ const orphan = orphanOf(doc, anchor);
629
+ if (anchor.kind === "card") {
630
+ const card = cardOf(doc, anchor);
631
+ if (card === null) return { time: null, text: "Card removed" };
632
+ const number = card.type === "shot" && card.sceneId !== null ? shotNumberOf(doc, card.sceneId) : null;
633
+ return { time: null, text: `On card · ${number === null ? "" : `${number} `}${card.label}` };
634
+ }
635
+ if (anchor.kind === "studio") {
636
+ const time = studioTime(anchor.frame, doc.fps);
637
+ if (orphan === "shot-removed") return { time, text: "Shot removed" };
638
+ const layer = anchor.layerId === null ? undefined : doc.scenesById[anchor.sceneId]?.layersById[anchor.layerId];
639
+ if (orphan === "layer-removed") return { time, text: "Layer removed" };
640
+ return { time, text: layer === undefined ? shotNameOf(doc, anchor.sceneId) : `Layer · ${layer.label.trim() || "Layer"}` };
641
+ }
642
+ const place = placeOf(doc, anchor);
643
+ const where = orphan === null ? (place.ordinal ?? place.frameLabel) : orphan === "frame-removed" ? "Frame removed" : "Shot removed";
644
+ return { time: null, text: `${where}${place.layer === null ? "" : ` · on ${place.layer}`}` };
645
+ }
646
+
647
+ /**
648
+ * Every Studio pin (bridge v45): the open Studio threads, and the open one
649
+ * even once resolved, each numbered in its Shot by frame then age, the
650
+ * number the panel's rows show too. The stage draws a pin only while stopped
651
+ * on its Shot and frame, so these go once, not per frame. A thread whose
652
+ * Shot or layer is gone draws none.
653
+ */
654
+ export function studioPinsFor(doc: Doc3D, threads: readonly CommentThread[], openId: string | null): ShotStudioPin[] {
655
+ const shown = threads
656
+ .filter(isStudioThread)
657
+ .filter((t) => (isOpen(t) || t.id === openId) && orphanOf(doc, t.anchor) === null);
658
+ const numbers = studioNumbers(shown);
659
+ return shown.map((t) => {
660
+ const author = t.head?.authorLabel ?? "Someone";
661
+ const excerpt = plainText(t.head?.body ?? "", {}).slice(0, 80);
662
+ return {
663
+ threadId: t.id,
664
+ anchor: { ...t.anchor },
665
+ initials: String(numbers.get(t.id) ?? ""),
666
+ color: personColor(t.createdBy),
667
+ active: t.id === openId,
668
+ label: `Comment by ${author}${excerpt === "" ? "" : `: ${excerpt}`}`,
669
+ };
670
+ });
671
+ }
672
+
673
+ /** Studio threads numbered 1, 2, 3 per Shot, by frame then age. */
674
+ export function studioNumbers(threads: readonly StudioThread[]): Map<string, number> {
675
+ const out = new Map<string, number>();
676
+ const sorted = [...threads].sort((a, b) => a.anchor.frame - b.anchor.frame || a.createdAt - b.createdAt);
677
+ const next = new Map<string, number>();
678
+ for (const t of sorted) {
679
+ const n = (next.get(t.anchor.sceneId) ?? 0) + 1;
680
+ next.set(t.anchor.sceneId, n);
681
+ out.set(t.id, n);
682
+ }
683
+ return out;
684
+ }
685
+
686
+ /** The open threads a board card counts (S5: "1 board · 1 Studio"). */
687
+ export interface CardCommentCount {
688
+ /** Open threads on the card itself. */
689
+ board: number;
690
+ /** Open Studio threads about it: on its planned shot's Shot, and any Studio
691
+ * thread linked to one of its board threads. */
692
+ studio: number;
693
+ }
694
+
695
+ /**
696
+ * The counts on a card (S5 note 3: "each shot card shows how many Studio notes
697
+ * are open, so the plan view never hides work"). A planned shot counts the
698
+ * open Studio threads on its Shot; a reference has no Shot of its own, so it
699
+ * counts only the Studio threads linked to its threads. Each thread once.
700
+ */
701
+ export function cardCommentCounts(
702
+ doc: Doc3D,
703
+ threads: readonly CommentThread[],
704
+ target: { partId: string } | { referenceId: string },
705
+ ): CardCommentCount {
706
+ const card = "partId" in target ? { partId: target.partId, referenceId: null } : { partId: null, referenceId: target.referenceId };
707
+ return {
708
+ board: cardThreadsOf(threads, card).filter(isOpen).length,
709
+ studio: studioNotesFor(doc, threads, card).length,
710
+ };
711
+ }
712
+
713
+ /** The threads on one card. */
714
+ function cardThreadsOf(
715
+ threads: readonly CommentThread[],
716
+ card: { partId: string | null; referenceId: string | null },
717
+ ): CardThread[] {
718
+ return threads
719
+ .filter(isCardThread)
720
+ .filter((t) => (card.partId !== null ? t.anchor.partId === card.partId : t.anchor.referenceId === card.referenceId));
721
+ }
722
+
723
+ /** The open Studio threads about a card, by frame: on its planned shot's
724
+ * Shot, and linked to one of its threads. The board's Studio-note strips
725
+ * and the card's "Studio" count. */
726
+ export function studioNotesFor(
727
+ doc: Doc3D,
728
+ threads: readonly CommentThread[],
729
+ card: { partId: string | null; referenceId: string | null },
730
+ ): StudioThread[] {
731
+ const linked = new Set(
732
+ cardThreadsOf(threads, card).flatMap((t) => t.links.filter((l) => l.kind === "studio").map((l) => l.threadId)),
733
+ );
734
+ const sceneId = card.partId === null ? null : (cardOf(doc, { partId: card.partId, referenceId: null })?.sceneId ?? null);
735
+ return threads
736
+ .filter(isStudioThread)
737
+ .filter((t) => isOpen(t) && (linked.has(t.id) || (sceneId !== null && t.anchor.sceneId === sceneId)))
738
+ .sort((a, b) => a.anchor.frame - b.anchor.frame || a.createdAt - b.createdAt);
739
+ }
740
+
741
+ /**
742
+ * The other place's threads one thread shows as notes (S5's strip): every
743
+ * thread linked to it, and on a card, the open Studio threads about the card
744
+ * too. Each once, linked ones first.
745
+ */
746
+ export function notesAbout(doc: Doc3D, threads: readonly CommentThread[], thread: CommentThread): CommentThread[] {
747
+ const byId = new Map(threads.map((t) => [t.id, t]));
748
+ const out = new Map<string, CommentThread>();
749
+ for (const link of thread.links) {
750
+ const other = byId.get(link.threadId);
751
+ if (other !== undefined) out.set(other.id, other);
752
+ }
753
+ if (isCardThread(thread)) for (const t of studioNotesFor(doc, threads, thread.anchor)) out.set(t.id, t);
754
+ return [...out.values()];
755
+ }
756
+
757
+ /** "1 board · 1 Studio", the parts with a count, or "" with none. */
758
+ export function cardCountWords(count: CardCommentCount): string {
759
+ return [count.board > 0 ? `${count.board} board` : "", count.studio > 0 ? `${count.studio} Studio` : ""]
760
+ .filter((part) => part !== "")
761
+ .join(" · ");
762
+ }
763
+
764
+ export interface CommentCount {
765
+ open: number;
766
+ /** Open threads with something the viewer has not read. */
767
+ unread: number;
768
+ }
769
+
770
+ /** Open threads per still id: the strip's Frame badges and the scrubber's
771
+ * dots. */
772
+ export function countsByStill(threads: readonly CommentThread[]): Map<string, CommentCount> {
773
+ const counts = new Map<string, CommentCount>();
774
+ for (const t of threads) {
775
+ if (!isOpen(t) || !isFrameThread(t)) continue;
776
+ const c = counts.get(t.anchor.stillId) ?? { open: 0, unread: 0 };
777
+ counts.set(t.anchor.stillId, { open: c.open + 1, unread: c.unread + (t.viewer.unread ? 1 : 0) });
778
+ }
779
+ return counts;
780
+ }
781
+
782
+ /** Open Frame threads per Shot, orphans included (page 20: "The Shot title
783
+ * counts every open thread, even on removed Frames"), by the Shot that holds
784
+ * the Frame now, else the one it was pinned in. */
785
+ export function countsByShot(doc: Doc3D, threads: readonly CommentThread[]): Map<string, CommentCount> {
786
+ const counts = new Map<string, CommentCount>();
787
+ for (const t of threads) {
788
+ if (!isOpen(t) || !isFrameThread(t)) continue;
789
+ const id = homeOf(doc, t.anchor.stillId) ?? t.anchor.sceneId;
790
+ const c = counts.get(id) ?? { open: 0, unread: 0 };
791
+ counts.set(id, { open: c.open + 1, unread: c.unread + (t.viewer.unread ? 1 : 0) });
792
+ }
793
+ return counts;
794
+ }
795
+
796
+ /** The threads a filter shows. Open also keeps nothing resolved; the panel
797
+ * adds "Resolved today" under it. */
798
+ export function filterThreads(
799
+ threads: readonly CommentThread[],
800
+ filter: CommentsFilter,
801
+ matched: readonly string[] | null,
802
+ ): CommentThread[] {
803
+ if (filter === "open") return threads.filter(isOpen);
804
+ if (filter === "resolved") return threads.filter((t) => !isOpen(t));
805
+ const ids = new Set(matched ?? []);
806
+ return threads.filter((t) => ids.has(t.id));
807
+ }
808
+
809
+ /** Resolved threads whose resolve happened since local midnight of `now`. */
810
+ export function resolvedToday(threads: readonly CommentThread[], now: number): CommentThread[] {
811
+ const midnight = new Date(now);
812
+ midnight.setHours(0, 0, 0, 0);
813
+ return threads.filter((t) => !isOpen(t) && (t.resolvedAt ?? 0) >= midnight.getTime());
814
+ }
815
+
816
+ /** One Frame's rows in the panel. */
817
+ export interface FrameGroup {
818
+ stillId: string;
819
+ /** Shot-local frame. */
820
+ frame: number;
821
+ label: string;
822
+ orphan: CommentOrphan;
823
+ threads: CommentThread[];
824
+ }
825
+
826
+ /** One Shot's block in the panel. */
827
+ export interface ShotGroup {
828
+ sceneId: string;
829
+ /** "02", or null for a Shot that is gone. */
830
+ number: string | null;
831
+ name: string;
832
+ open: number;
833
+ frames: FrameGroup[];
834
+ }
835
+
836
+ /**
837
+ * The board list's Frame threads: by Shot in playing order, then by Frame in
838
+ * frame order, newest thread first inside one Frame. A removed Frame sorts
839
+ * after its Shot's live ones; a removed Shot's threads come last, by the name
840
+ * it had. Card and Studio threads are left out (`groupCardThreads`,
841
+ * `groupStudioThreads`).
842
+ */
843
+ export function groupThreads(doc: Doc3D, threads: readonly CommentThread[]): ShotGroup[] {
844
+ const shots = boardShots(doc);
845
+ const order = new Map(shots.map((s, i) => [s.sceneId, i]));
846
+ const groups = new Map<string, ShotGroup>();
847
+ for (const t of threads.filter(isFrameThread)) {
848
+ const home = homeOf(doc, t.anchor.stillId);
849
+ const sceneId = home ?? t.anchor.sceneId;
850
+ const orphan = home !== null ? null : orphanOf(doc, t.anchor);
851
+ const groupKey = orphan === "shot-removed" ? `gone:${sceneId}` : sceneId;
852
+ let group = groups.get(groupKey);
853
+ if (group === undefined) {
854
+ group = {
855
+ sceneId,
856
+ number: orphan === "shot-removed" ? null : shotNumberOf(doc, sceneId),
857
+ name: orphan === "shot-removed" ? "Removed Shot" : shotNameOf(doc, sceneId),
858
+ open: 0,
859
+ frames: [],
860
+ };
861
+ groups.set(groupKey, group);
862
+ }
863
+ if (isOpen(t)) group.open++;
864
+ let frame = group.frames.find((f) => f.stillId === t.anchor.stillId);
865
+ if (frame === undefined) {
866
+ const stills = home === null ? [] : (shots.find((s) => s.sceneId === home)?.stills ?? []);
867
+ const still = stills.find((s) => s.id === t.anchor.stillId);
868
+ frame = {
869
+ stillId: t.anchor.stillId,
870
+ frame: still?.frame ?? t.anchor.frame,
871
+ label: still === undefined ? (t.anchor.frameLabel ?? "Frame") : stillNameOf(stills, still.frame),
872
+ orphan,
873
+ threads: [],
874
+ };
875
+ group.frames.push(frame);
876
+ }
877
+ frame.threads.push(t);
878
+ }
879
+ const rank = (g: ShotGroup): number => (g.number === null ? Infinity : (order.get(g.sceneId) ?? Infinity));
880
+ const out = [...groups.values()].sort((a, b) => rank(a) - rank(b));
881
+ for (const g of out) {
882
+ g.frames.sort((a, b) => Number(a.orphan !== null) - Number(b.orphan !== null) || a.frame - b.frame);
883
+ for (const f of g.frames) f.threads.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
884
+ }
885
+ return out;
886
+ }
887
+
888
+ /** One card's rows in the board list. */
889
+ export interface CardGroup {
890
+ /** The part or reference id. */
891
+ id: string;
892
+ /** The card, as `cardCommentCounts` takes it. */
893
+ target: { partId: string } | { referenceId: string };
894
+ /** "02 Cards gather", or what a removed card was. */
895
+ label: string;
896
+ orphan: CommentOrphan;
897
+ open: number;
898
+ threads: CommentThread[];
899
+ }
900
+
901
+ /**
902
+ * The board list's card threads, one group per card: planned shots in plan
903
+ * order, then references in board order, then removed cards. Newest activity
904
+ * first inside a card.
905
+ */
906
+ export function groupCardThreads(doc: Doc3D, threads: readonly CommentThread[]): CardGroup[] {
907
+ const order = new Map<string, number>();
908
+ (doc.storyboard?.parts ?? []).forEach((p, i) => order.set(p.id, i));
909
+ (doc.resources?.references ?? []).forEach((r, i) => order.set(r.id, 1e6 + i));
910
+ const groups = new Map<string, CardGroup>();
911
+ for (const t of threads.filter(isCardThread)) {
912
+ const id = t.anchor.partId ?? t.anchor.referenceId ?? "";
913
+ let group = groups.get(id);
914
+ if (group === undefined) {
915
+ const where = threadWhere(doc, t.anchor);
916
+ group = {
917
+ id,
918
+ target: t.anchor.partId !== null ? { partId: t.anchor.partId } : { referenceId: id },
919
+ label: where.text.replace(/^On card · /, ""),
920
+ orphan: orphanOf(doc, t.anchor),
921
+ open: 0,
922
+ threads: [],
923
+ };
924
+ groups.set(id, group);
925
+ }
926
+ if (isOpen(t)) group.open++;
927
+ group.threads.push(t);
928
+ }
929
+ const rank = (g: CardGroup): number => order.get(g.id) ?? Infinity;
930
+ const out = [...groups.values()].sort((a, b) => rank(a) - rank(b));
931
+ for (const g of out) g.threads.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
932
+ return out;
933
+ }
934
+
935
+ /** One Shot's Studio rows. */
936
+ export interface StudioGroup {
937
+ sceneId: string;
938
+ /** "02", or null for a Shot that is gone. */
939
+ number: string | null;
940
+ name: string;
941
+ open: number;
942
+ /** By frame, then oldest first: the order the pins number in. */
943
+ threads: StudioThread[];
944
+ }
945
+
946
+ /** The Studio list: its threads by Shot in playing order, a removed Shot's
947
+ * last. */
948
+ export function groupStudioThreads(doc: Doc3D, threads: readonly CommentThread[]): StudioGroup[] {
949
+ const order = new Map(doc.scenes.map((id, i) => [id, i]));
950
+ const groups = new Map<string, StudioGroup>();
951
+ for (const t of threads.filter(isStudioThread)) {
952
+ const sceneId = t.anchor.sceneId;
953
+ let group = groups.get(sceneId);
954
+ if (group === undefined) {
955
+ const gone = orphanOf(doc, t.anchor) === "shot-removed";
956
+ group = {
957
+ sceneId,
958
+ number: gone ? null : shotNumberOf(doc, sceneId),
959
+ name: gone ? "Removed Shot" : shotNameOf(doc, sceneId),
960
+ open: 0,
961
+ threads: [],
962
+ };
963
+ groups.set(sceneId, group);
964
+ }
965
+ if (isOpen(t)) group.open++;
966
+ group.threads.push(t);
967
+ }
968
+ const rank = (g: StudioGroup): number => (g.number === null ? Infinity : (order.get(g.sceneId) ?? Infinity));
969
+ const out = [...groups.values()].sort((a, b) => rank(a) - rank(b));
970
+ for (const g of out) g.threads.sort((a, b) => a.anchor.frame - b.anchor.frame || a.createdAt - b.createdAt);
971
+ return out;
972
+ }
973
+
974
+ /**
975
+ * The open thread N (dir 1) or Shift+N (dir -1) goes to from `fromId`, in the
976
+ * board list's order: the cards' threads (planned shots in plan order, then
977
+ * the other cards, oldest first on each), then any older Frame threads (Shot,
978
+ * Frame, oldest first). A removed card or Frame is skipped. Wraps at either
979
+ * end; undefined with none.
980
+ */
981
+ export function nextOpenThread(
982
+ doc: Doc3D,
983
+ threads: readonly CommentThread[],
984
+ fromId: string | null,
985
+ dir: 1 | -1,
986
+ ): CommentThread | undefined {
987
+ const open = threads.filter(isOpen);
988
+ const cards = groupCardThreads(doc, open)
989
+ .filter((g) => g.orphan === null)
990
+ .flatMap((g) => [...g.threads].sort((a, b) => a.createdAt - b.createdAt));
991
+ const place = new Map<string, number>();
992
+ boardShots(doc).forEach((shot, s) => {
993
+ for (const still of shot.stills) if (still.id !== null) place.set(still.id, s * 1e7 + still.frame);
994
+ });
995
+ const frames = open
996
+ .filter(isFrameThread)
997
+ .filter((t) => place.has(t.anchor.stillId))
998
+ .sort((a, b) => place.get(a.anchor.stillId)! - place.get(b.anchor.stillId)! || a.createdAt - b.createdAt);
999
+ const ring = [...cards, ...frames];
1000
+ if (ring.length === 0) return undefined;
1001
+ const at = fromId === null ? -1 : ring.findIndex((t) => t.id === fromId);
1002
+ if (at < 0) return dir === 1 ? ring[0] : ring[ring.length - 1];
1003
+ return ring[(at + dir + ring.length) % ring.length];
1004
+ }
1005
+
1006
+ /**
1007
+ * "ASK AGENT TO FIX" (plan C5): fix what open comment threads ask for, by
1008
+ * their ids, through the comment tools (packages/store/src/mcpCommentTools.ts).
1009
+ * Prefilled, never sent, like `movePrompt`. Each thread says where it sits, by
1010
+ * kind; the agent reads the words with `get_comment`, so a body is never
1011
+ * pasted into the prompt.
1012
+ */
1013
+ export function commentPrompt(doc: Doc3D, threads: readonly Pick<CommentThread, "id" | "anchor">[]): string {
1014
+ const shot = (sceneId: string): string =>
1015
+ doc.scenesById[sceneId] === undefined ? "a removed Shot" : `Shot "${shotNameOf(doc, sceneId)}"`;
1016
+ const where = (t: (typeof threads)[number]): string => {
1017
+ const a = t.anchor;
1018
+ if (a.kind === "card") {
1019
+ const card = cardOf(doc, a);
1020
+ return `${t.id} (on the board, ${card === null ? "a removed card" : `card "${card.label}"`})`;
1021
+ }
1022
+ if (a.kind === "studio") return `${t.id} (in the Studio, ${shot(a.sceneId)} at ${studioTime(a.frame, doc.fps)})`;
1023
+ return `${t.id} (${a.frameLabel ?? "a Frame"} in ${shot(a.sceneId)})`;
1024
+ };
1025
+ const lines = [
1026
+ threads.length === 1
1027
+ ? "Fix what this comment thread asks for:"
1028
+ : `Fix what these ${threads.length} open comment threads ask for:`,
1029
+ ...threads.map((t) => `- ${where(t)}`),
1030
+ "",
1031
+ "For each thread:",
1032
+ "1. Read it with get_comment: the messages, where it sits, the layer and the snapshot of what the reviewer saw.",
1033
+ "2. Make the change in one shot_batch.",
1034
+ "3. Reply with reply_comment, saying in one or two sentences what you changed.",
1035
+ "4. Resolve it with resolve_comment. Leave it open and say why in the reply when it needs the author to decide.",
1036
+ ];
1037
+ return lines.join("\n");
1038
+ }
1039
+
1040
+ // ── mentions ──────────────────────────────────────────────────────────────
1041
+
1042
+ /** A mention as stored: `<@usr-...>`, as in packages/db comments.ts. */
1043
+ const MENTION_RE = /<@(usr-[A-Za-z0-9-]{1,64})>/g;
1044
+
1045
+ export type BodySegment = { kind: "text"; text: string } | { kind: "mention"; userId: string; label: string };
1046
+
1047
+ /** A stored body as text and mention chips. */
1048
+ export function bodySegments(body: string, names: Readonly<Record<string, string>>): BodySegment[] {
1049
+ const out: BodySegment[] = [];
1050
+ let last = 0;
1051
+ for (const match of body.matchAll(MENTION_RE)) {
1052
+ if (match.index > last) out.push({ kind: "text", text: body.slice(last, match.index) });
1053
+ const userId = match[1]!;
1054
+ out.push({ kind: "mention", userId, label: names[userId] ?? "someone" });
1055
+ last = match.index + match[0].length;
1056
+ }
1057
+ if (last < body.length) out.push({ kind: "text", text: body.slice(last) });
1058
+ return out;
1059
+ }
1060
+
1061
+ /** A stored body as one line of plain text, mentions as `@Name`. */
1062
+ export function plainText(body: string, names: Readonly<Record<string, string>>): string {
1063
+ return bodySegments(body, names)
1064
+ .map((s) => (s.kind === "text" ? s.text : `@${s.label}`))
1065
+ .join("")
1066
+ .replace(/\s+/g, " ")
1067
+ .trim();
1068
+ }
1069
+
1070
+ /** The `@word` being typed just before `caret`, or null. An `@` counts at
1071
+ * the start or after whitespace, so an email address opens no menu. */
1072
+ export function mentionQuery(text: string, caret: number): { start: number; query: string } | null {
1073
+ const match = /(^|\s)@([^\s@]{0,40})$/.exec(text.slice(0, caret));
1074
+ return match === null ? null : { start: caret - match[2]!.length - 1, query: match[2]! };
1075
+ }
1076
+
1077
+ /** `text` with the `@query` at `start..caret` replaced by `@Label `. */
1078
+ export function insertMention(
1079
+ text: string,
1080
+ start: number,
1081
+ caret: number,
1082
+ label: string,
1083
+ ): { text: string; caret: number } {
1084
+ const chip = `@${label} `;
1085
+ return { text: text.slice(0, start) + chip + text.slice(caret), caret: start + chip.length };
1086
+ }
1087
+
1088
+ /**
1089
+ * The composer's text as a stored body: each `@Label` of a person picked from
1090
+ * the menu becomes their `<@id>` token. Longest labels first, so "@Ann Lee"
1091
+ * is not eaten by a picked "@Ann".
1092
+ */
1093
+ export function encodeMentions(text: string, picked: readonly { id: string; label: string }[]): string {
1094
+ let out = text;
1095
+ for (const p of [...picked].sort((a, b) => b.label.length - a.label.length)) {
1096
+ out = out.split(`@${p.label}`).join(`<@${p.id}>`);
1097
+ }
1098
+ return out;
1099
+ }