@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
@@ -6,76 +6,269 @@
6
6
  // `iframe.contentWindow.__shotStudio` on load and must never observe a
7
7
  // rendered-but-bridgeless stage; if the handle lived in a component it could
8
8
  // only appear after the first commit, and the poll would race it.
9
- import { clearRasterCache } from "@dailies-studio/shot";
10
- import { markPerf } from "./PerfHud.js";
11
- import type { Doc3D, Scene3D } from "@dailies-studio/shot/doc";
9
+ import { clearLayerMaterialPool, clearPlaneCache, clearRasterCache, resetRasterPressure } from "@dailies-studio/shot";
10
+ import type { CoverageProbe, ShotPresentedFrame } from "@dailies-studio/shot";
11
+ import { createShotClock, setStagePhase } from "@dailies-studio/shot/doc";
12
+ import type { ShotClock } from "@dailies-studio/shot/doc";
13
+ import {
14
+ describePieceFailure,
15
+ docDurationFrames,
16
+ instanceComponentIdsOf,
17
+ instanceModuleOf,
18
+ masterToSceneCached,
19
+ pieceFailuresAt,
20
+ pieceLayerLabel,
21
+ posterFrame,
22
+ recordStageError,
23
+ sceneWindows,
24
+ sliceShotToScene,
25
+ } from "@dailies-studio/shot/doc";
26
+ import type { StageErrorSource, StageHealthSnapshot, StageRendered, StageRuntimeError } from "@dailies-studio/shot/doc";
27
+ import { stageReadiness } from "@dailies-studio/runtime";
28
+ import { PERF_HUD_ENABLED, markPerf } from "./PerfHud.js";
29
+ import { stageDensity } from "./adaptivePresentation.js";
30
+ import { RenderScaleController } from "./renderScaleController.js";
31
+ import { StagePerformanceTelemetry } from "./stagePerformanceTelemetry.js";
32
+ import { createShotStageExporter } from "./tier1Export.js";
33
+ import type { ShotStageExporter } from "./tier1Export.js";
34
+ import type { CameraGate, Doc3D, Scene3D } from "@dailies-studio/shot/doc";
12
35
  import type {
36
+ EditorChrome,
37
+ MaterialPreviewTarget,
38
+ NodePreviewBatch,
39
+ NodePreviewRequest,
13
40
  ShadingMode,
41
+ ShotBoardPin,
14
42
  ShotBridgeHandlers,
43
+ ShotComponentContext,
44
+ ShotPeerSelection,
45
+ ShotPreparePace,
46
+ ShotPreparePlaybackResult,
47
+ ShotPreparePlaybackYield,
48
+ ShotPreparedOutcome,
15
49
  ShotScreenQuad,
50
+ ShotSketchGuide,
51
+ ShotMotionPreview,
52
+ ShotSketchGuideArrow,
53
+ ShotSketchGuideRect,
54
+ ShotStudioComments,
55
+ ShotStudioPin,
16
56
  ShotStageBridge,
17
57
  ShotTool,
18
58
  ShotView,
59
+ SplineBasis,
60
+ SplineEditContext,
19
61
  SnapGuide,
20
62
  StageInset,
21
63
  StageLayout,
22
64
  StagePane,
65
+ StagePieceFailure,
23
66
  WorkingView,
67
+ WorkPlaneTarget,
68
+ } from "../bridgeTypes.js";
69
+ import {
70
+ MAX_LOOP_FRAMES,
71
+ MAX_NODE_PREVIEWS,
72
+ MAX_SKETCH_GUIDE_SHAPES,
73
+ SHOT_BRIDGE_VERSION,
74
+ componentContextSubject,
75
+ isPlaybackResolution,
76
+ validPreviewProps,
24
77
  } from "../bridgeTypes.js";
25
- import { SHOT_BRIDGE_VERSION } from "../bridgeTypes.js";
26
78
  import { clampSplitRatio } from "./panes.js";
27
79
  import { clampShading } from "./shading.js";
80
+ import { TOOL_SPECS } from "../tools.js";
28
81
  import { INITIAL_WORKING_VIEW } from "./workingView.js";
82
+ import { createFontPreviewFaces, fontPreviewDoc, sanitizeFontPreview } from "./fontPreview.js";
83
+ import { motionPreviewDoc, sanitizeMotionPreview } from "./motionPreview.js";
84
+ import type { StageFontPreview } from "./fontPreview.js";
85
+ import type { FontPlatform } from "@dailies-studio/shot";
29
86
 
30
87
  export interface StageClientState {
31
88
  /** A chrome has registered its handlers. Nothing renders before this. */
32
89
  connected: boolean;
33
90
  doc: Doc3D | null;
34
- /** MASTER frame. */
91
+ /** The saved revision `doc` is (v34 `loadDoc`), or null for an unsaved edit
92
+ * or a host that did not say. Stamped on a piece failure first seen while
93
+ * this document is on stage; never read by anything that draws. */
94
+ docRev: string | null;
95
+ /**
96
+ * MASTER frame, for the one mode that renders it through React: a Tier-1
97
+ * export, whose commit stamps the marker the capture requires, written by
98
+ * the capture loop (`exportHost.seek`). Isolation and a component context
99
+ * are not that mode: isolation's head-on frame is held still for the
100
+ * subtree's whole life (`isolationAnchor`), and a component context's
101
+ * def-local frames ride the clock like the scene's. Everywhere else the
102
+ * transport publishes into `StageClient.clock` with its presentation token,
103
+ * and this field holds the frame of the last export commit: read
104
+ * `clock.get().frame` for the playhead.
105
+ */
35
106
  frame: number;
107
+ /** The scene under the playhead, or null off the timeline, with no
108
+ * document, or under a component context. The coarse half of the playhead:
109
+ * it moves at cuts, so the stage re-renders at cuts and not per frame. */
110
+ sceneId: string | null;
111
+ /** The chrome's transport is running. SCHEDULING ONLY — see the bridge's
112
+ * `setPlaying`. It must never reach a cache key or a sampled value. */
113
+ playing: boolean;
36
114
  selection: readonly string[];
115
+ /** Other people's selections (bridge v43), sanitized on the way in. */
116
+ peerSelections: readonly ShotPeerSelection[];
37
117
  view: ShotView;
38
118
  /** Live only while a gesture is snapping. */
39
119
  guides: readonly SnapGuide[];
40
120
  tool: ShotTool;
121
+ /** Editor-only construction plane and spline point-mode subject. */
122
+ workPlane: WorkPlaneTarget;
123
+ splineBasis: SplineBasis;
124
+ splineEdit: SplineEditContext | null;
41
125
  /** How many panes are on screen. Pushed by the chrome; never derived here,
42
126
  * so there is one owner of "what is the author looking at". */
43
127
  layout: StageLayout;
128
+ /** THE AUTHOR IS WATCHING: the picture, and nothing drawn over it. Pushed by
129
+ * the chrome like the layout, and orthogonal to it: it does not replace
130
+ * `layout`, it hides it (see `setVideo` in bridgeTypes and `watching` in
131
+ * StageApp). */
132
+ video: boolean;
133
+ /** VIEW ONLY (bridge v41): the person may look but not edit, so the stage
134
+ * draws no handles and starts no edit gesture. See `setReadOnly` in
135
+ * bridgeTypes and `readOnly` in StageApp. */
136
+ readOnly: boolean;
137
+ /** THE BOARD IS WATCHING (bridge v30): the same no-furniture picture as
138
+ * `video`, plus a derived fit into the chrome's hole. Separate from
139
+ * `video` because the two are different author intents that happen to want
140
+ * one picture; see `setStoryboardPreview` in bridgeTypes. */
141
+ storyboardPreview: boolean;
142
+ /** THE STUDIO'S COMMENT LAYER (bridge v45): every Studio pin, drawn on its
143
+ * anchor's frame, and whether the tool is armed. Null when the chrome has
144
+ * none. Sanitized on the way in; see `setStudioComments` in bridgeTypes. */
145
+ studioComments: ShotStudioComments | null;
146
+ /** THE SKETCH GUIDE (bridge v47): one storyboard sketch as plain shapes in
147
+ * design px, or null. Sanitized on the way in; see `setSketchGuide`. */
148
+ sketchGuide: ShotSketchGuide | null;
149
+ /** THE FONT PREVIEW (bridge v50), sanitized, with the private family its
150
+ * faces landed under (`face`), or null. Only the runtime's picture reads
151
+ * it (`fontPreviewDoc` in StageApp); see `setFontPreview`. */
152
+ fontPreview: StageFontPreview | null;
153
+ /** THE MOTION PREVIEW (bridge v53), sanitized, or null. Only the runtime's
154
+ * picture reads it (`motionPreviewDoc` in StageApp); see `setMotionPreview`. */
155
+ motionPreview: ShotMotionPreview | null;
156
+ /** The text layer the inline editor is open on (bridge v38), or null. */
157
+ textEdit: string | null;
44
158
  workingView: WorkingView;
45
- /** "Camera follows this view" is armed. The stage brackets its orbit with the
46
- * framing gesture lane while it is, and draws the badge that says so. */
47
- cameraFollowsView: boolean;
159
+ /** PILOTING (bridge v29): the Camera pane's navigation writes the shot
160
+ * camera through the framing gesture lane, and the pane wears the badge
161
+ * that says so. `pilotText` is the badge's words, composed by the chrome
162
+ * (it knows what holds the camera); null means the plain badge. */
163
+ pilot: boolean;
164
+ pilotText: string | null;
165
+ /** What a camera write would do at the playhead (bridge v29), so the gizmo
166
+ * can dim a held camera and the pointer lane can say why BEFORE a drag.
167
+ * Null when no document is open. */
168
+ cameraGate: CameraGate | null;
48
169
  /** The isolated subtree's root layer id, or null for the whole scene. The
49
170
  * stage derives the members, the camera and the dimming from the document
50
171
  * it already has — see `setIsolation`. */
51
172
  isolation: string | null;
173
+ /** Mesh SURFACE whose material graph owns the working-pane interaction
174
+ * surface. The layer still renders; only scene-edit gestures are suppressed.
175
+ * `slot` names which of a model glTF materials the workspace is editing, or
176
+ * null for the layer-wide binding. */
177
+ materialPreview: MaterialPreviewTarget | null;
52
178
  /** Inside isolation, has the author taken the working camera? False means the
53
179
  * derived orthographic head-on rig draws the pane; true means the ordinary
54
180
  * orbit rig does. Meaningless while `isolation` is null. */
55
181
  isolationOrbit: boolean;
182
+ /**
183
+ * The master frame the isolated subtree is framed from: where the playhead
184
+ * stood when the author entered it, or pressed Home inside it. A new object
185
+ * on each of those and on nothing else, so it is the one key the stage's
186
+ * frozen head-on frame is derived on. Null while `isolation` is.
187
+ */
188
+ isolationAnchor: { frame: number } | null;
189
+ /** The component context (v14): render ONE definition standalone, def-local
190
+ * clock, whole viewport. Null = the ordinary scene render. Mutually
191
+ * exclusive with isolation — the chrome owns the mode and clears isolation
192
+ * first. `zoom` scales the derived head-on frame; absent is 1. */
193
+ componentContext: ShotComponentContext | null;
56
194
  /** The working pane's shading. Workspace state, mirrored from the chrome. */
57
195
  shading: ShadingMode;
58
- /** How far the chrome's floating panels reach into the stage, per side. Paint
59
- * only: the overlay lays its furniture inside it and `paneRects` ignores it
60
- * entirely. Zero until the chrome says otherwise. */
196
+ /** The working pane's grid floor (bridge v22). Workspace state too: how the
197
+ * author looks at the scene, never something about the shot. */
198
+ grid: boolean;
199
+ /** Rulers and smart guides (bridge v44), workspace state on `grid`'s rule.
200
+ * The playback resolution the same push carries (v52) goes to
201
+ * `StageClient.renderScale` and not here: it moves nothing drawn paused,
202
+ * so it re-renders nothing and ends no preparing pass. */
203
+ editorChrome: Pick<EditorChrome, "rulers" | "smartGuides">;
204
+ /** Motion paths for the selection (bridge v44), workspace state too. */
205
+ motionPaths: boolean;
206
+ /** How far the chrome's docked panels reach into the stage, per side. It
207
+ * places what has to be SEEN — the overlay's furniture, the derived head-on
208
+ * framing, the camera pane's fit, and where the split seam falls — and moves
209
+ * neither the scissor nor the hit test: `paneRects` takes it for the seam
210
+ * alone and still returns rects that partition the whole viewport. See
211
+ * `setStageInset`. Zero until the chrome says otherwise. */
61
212
  inset: StageInset;
213
+ /** A Tier-1 export is running: StageApp renders the export framing (design
214
+ * viewport, no view offset, dpr 1, no overlays) and the capture loop owns
215
+ * `frame`. Chrome `seek`/`loadDoc` pushes are DEFERRED until this clears —
216
+ * a mid-capture re-render would let the encoder stamp one frame's
217
+ * timestamp on another frame's pixels. Null = normal editing. */
218
+ exporting: { scene?: string } | null;
219
+ /** THE FROZEN PICTURE (v51): the frame a preparing pass froze on screen,
220
+ * from the freeze until it lifts, or null. While it stands, StageApp keeps
221
+ * the overlays it drew for that frame and starts no transform on the
222
+ * scene graph under it, which holds another frame. */
223
+ held: number | null;
224
+ /** A preparing pass is drawing its own frames under the freeze (v51). Set
225
+ * by its first publication and cleared by an author seek, the restore, the
226
+ * hand-over and a cancel. StageTools projects nothing while it is set, so
227
+ * the furniture keeps the frozen frame's outlines. */
228
+ passDrawing: boolean;
62
229
  }
63
230
 
64
231
  const INITIAL: StageClientState = {
65
232
  connected: false,
66
233
  doc: null,
234
+ docRev: null,
67
235
  frame: 0,
236
+ sceneId: null,
237
+ playing: false,
68
238
  selection: [],
239
+ peerSelections: [],
69
240
  view: { zoom: 1, panX: 0, panY: 0 },
70
241
  guides: [],
71
242
  tool: "select",
243
+ workPlane: { kind: "xy" },
244
+ splineBasis: "bezier",
245
+ splineEdit: null,
72
246
  layout: { mode: "camera", splitRatio: 0.5 },
247
+ video: false,
248
+ readOnly: false,
249
+ storyboardPreview: false,
250
+ studioComments: null,
251
+ sketchGuide: null,
252
+ fontPreview: null,
253
+ motionPreview: null,
254
+ textEdit: null,
73
255
  workingView: INITIAL_WORKING_VIEW,
74
- cameraFollowsView: false,
256
+ pilot: false,
257
+ pilotText: null,
258
+ cameraGate: null,
75
259
  isolation: null,
260
+ materialPreview: null,
76
261
  isolationOrbit: false,
262
+ isolationAnchor: null,
263
+ componentContext: null,
77
264
  shading: "solid",
265
+ grid: true,
266
+ editorChrome: { rulers: false, smartGuides: true },
267
+ motionPaths: true,
78
268
  inset: { top: 0, right: 0, bottom: 0, left: 0 },
269
+ exporting: null,
270
+ held: null,
271
+ passDrawing: false,
79
272
  };
80
273
 
81
274
  /** Finite and non-negative on every side. A boundary, like `clampSplitRatio`:
@@ -86,8 +279,168 @@ function clampInset(inset: StageInset): StageInset {
86
279
  return { top: one(inset.top), right: one(inset.right), bottom: one(inset.bottom), left: one(inset.left) };
87
280
  }
88
281
 
282
+ function sanitizeWorkPlane(target: WorkPlaneTarget): WorkPlaneTarget {
283
+ if (target.kind === "selection") {
284
+ return target.layerId.length > 0 ? { kind: "selection", layerId: target.layerId } : { kind: "xy" };
285
+ }
286
+ return target.kind === "xy" || target.kind === "xz" || target.kind === "yz" || target.kind === "view"
287
+ ? { kind: target.kind }
288
+ : { kind: "xy" };
289
+ }
290
+
291
+ const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
292
+
293
+ /** At most this many peer outlines, and ids per peer: past that the outlines
294
+ * are noise, and the stage projects every id each frame. */
295
+ export const MAX_PEER_SELECTIONS = 16;
296
+ export const MAX_PEER_IDS = 50;
297
+
298
+ /** A boundary like `sanitizePins`: each name becomes a `<text>` and each
299
+ * colour a `stroke`. A peer with no ids or no plain hex colour is dropped;
300
+ * a name is cut to 60 characters. */
301
+ export function sanitizePeerSelections(peers: unknown): ShotPeerSelection[] {
302
+ if (!Array.isArray(peers)) return [];
303
+ const out: ShotPeerSelection[] = [];
304
+ for (const peer of peers as unknown[]) {
305
+ if (out.length >= MAX_PEER_SELECTIONS) break;
306
+ if (typeof peer !== "object" || peer === null) continue;
307
+ const { ids, name, color } = peer as Record<string, unknown>;
308
+ if (!Array.isArray(ids) || typeof color !== "string" || !HEX_COLOR_RE.test(color)) continue;
309
+ const kept = (ids as unknown[]).filter((id): id is string => typeof id === "string" && id.length > 0).slice(0, MAX_PEER_IDS);
310
+ if (kept.length === 0) continue;
311
+ out.push({ ids: kept, name: typeof name === "string" ? name.slice(0, 60) : "", color });
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /** Pins become positioned SVG with a label: a pin with no thread id or a
317
+ * point off the frame is dropped, and a colour that is not plain hex falls
318
+ * back to the accent rather than reaching a `fill` attribute. */
319
+ function sanitizePins(pins: unknown): ShotBoardPin[] {
320
+ if (!Array.isArray(pins)) return [];
321
+ const unit = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
322
+ return pins.flatMap((pin: Partial<ShotBoardPin> | null) => {
323
+ if (pin === null || typeof pin !== "object") return [];
324
+ if (typeof pin.threadId !== "string" || pin.threadId.length === 0 || !unit(pin.u) || !unit(pin.v)) return [];
325
+ return [
326
+ {
327
+ threadId: pin.threadId,
328
+ u: pin.u,
329
+ v: pin.v,
330
+ initials: typeof pin.initials === "string" ? pin.initials.slice(0, 2) : "",
331
+ color: typeof pin.color === "string" && HEX_COLOR_RE.test(pin.color) ? pin.color : "#0099FF",
332
+ active: pin.active === true,
333
+ label: typeof pin.label === "string" ? pin.label : "Comment",
334
+ },
335
+ ];
336
+ });
337
+ }
338
+
339
+ /** Studio pins (bridge v45) on `sanitizePins`'s rule, plus the anchor: a pin
340
+ * whose anchor names no Shot, a frame that is not a whole frame at or after
341
+ * the Shot's start, or a layer that is not an id or null, is dropped. */
342
+ export function sanitizeStudioComments(comments: ShotStudioComments | null): ShotStudioComments | null {
343
+ if (comments === null || typeof comments !== "object") return null;
344
+ const raw: unknown[] = Array.isArray(comments.pins) ? comments.pins : [];
345
+ const pins = raw.flatMap((pin): ShotStudioPin[] => {
346
+ if (pin === null || typeof pin !== "object") return [];
347
+ const { anchor, ...rest } = pin as Partial<ShotStudioPin>;
348
+ if (anchor === null || typeof anchor !== "object" || anchor.kind !== "studio") return [];
349
+ const { sceneId, frame, layerId } = anchor;
350
+ if (typeof sceneId !== "string" || sceneId.length === 0) return [];
351
+ if (!Number.isSafeInteger(frame) || frame < 0) return [];
352
+ if (layerId !== null && (typeof layerId !== "string" || layerId.length === 0)) return [];
353
+ const [board] = sanitizePins([{ ...rest, u: anchor.u, v: anchor.v }]);
354
+ if (board === undefined) return [];
355
+ const { u, v, ...face } = board;
356
+ return [{ ...face, anchor: { kind: "studio", sceneId, frame, layerId, u, v } }];
357
+ });
358
+ return { pins, armed: comments.armed === true };
359
+ }
360
+
361
+ /** A sketch guide (bridge v47) the overlay can draw: finite numbers, sides
362
+ * above zero, labels as short plain strings, at most
363
+ * `MAX_SKETCH_GUIDE_SHAPES` shapes. A shape that fails is dropped; a guide
364
+ * with nothing left is null. */
365
+ export function sanitizeSketchGuide(guide: ShotSketchGuide | null): ShotSketchGuide | null {
366
+ if (guide === null || typeof guide !== "object") return null;
367
+ const num = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n);
368
+ const pair = (p: unknown): p is [number, number] => Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]);
369
+ const label = (l: unknown): { label?: string } => (typeof l === "string" && l.trim() !== "" ? { label: l.trim().slice(0, 80) } : {});
370
+ const rects = (Array.isArray(guide.rects) ? guide.rects : []).flatMap((r: Partial<ShotSketchGuideRect> | null): ShotSketchGuideRect[] => {
371
+ if (r === null || typeof r !== "object" || !pair(r.at) || !pair(r.size) || r.size[0] <= 0 || r.size[1] <= 0) return [];
372
+ return [{ at: [r.at[0], r.at[1]], size: [r.size[0], r.size[1]], ...(num(r.radius) && r.radius > 0 ? { radius: r.radius } : {}), ...label(r.label) }];
373
+ });
374
+ const arrows = (Array.isArray(guide.arrows) ? guide.arrows : []).flatMap((a: Partial<ShotSketchGuideArrow> | null): ShotSketchGuideArrow[] => {
375
+ if (a === null || typeof a !== "object" || !pair(a.from) || !pair(a.to)) return [];
376
+ return [{ from: [a.from[0], a.from[1]], to: [a.to[0], a.to[1]], ...label(a.label) }];
377
+ });
378
+ const kept = rects.slice(0, MAX_SKETCH_GUIDE_SHAPES);
379
+ const shapes = { rects: kept, arrows: arrows.slice(0, MAX_SKETCH_GUIDE_SHAPES - kept.length) };
380
+ return shapes.rects.length + shapes.arrows.length === 0 ? null : shapes;
381
+ }
382
+
383
+ /**
384
+ * A DERIVED head-on rig is drawing the working pane: an isolated subtree, or an
385
+ * open component definition.
386
+ *
387
+ * The stage's copy of the chrome store's `headOnOwnsPane`. Two copies because
388
+ * the two sides hold different state shapes, and no more than two: the one
389
+ * thing a third head-on mode must not have to do is find six spellings of this.
390
+ *
391
+ * `componentContext` is validated against the document wherever it is written
392
+ * (`setComponentContext`, `loadDoc`), so a non-null one always names a
393
+ * definition that exists and is therefore always drawing.
394
+ */
395
+ export function headOnOwnsPane(state: StageClientState): boolean {
396
+ return (state.isolation !== null || state.componentContext !== null) && !state.isolationOrbit;
397
+ }
398
+
89
399
  export interface StageClient {
90
400
  readonly bridge: ShotStageBridge;
401
+ /** The playhead as a STORE (shot's frameStore.ts). `bridge.seek` publishes
402
+ * every accepted frame here; `ShotRuntime` subscribes to it and the stage's
403
+ * frame loops read it. The one live answer to "what frame is it". */
404
+ readonly clock: ShotClock;
405
+ /** Shared by the always-on playback quality policy and, under `?perf=1`,
406
+ * the opt-in diagnostics. */
407
+ readonly performanceTelemetry: StagePerformanceTelemetry;
408
+ /** The playback render scale (renderScaleController.ts): StageApp hands it
409
+ * to the runtime as `presentationScale` and feeds it the GPU timer's
410
+ * frames; the client feeds it the frames the transport presented (the
411
+ * telemetry's `subscribePresented`), tells it the transport, the
412
+ * document's rate, another Project and the Preferences mode, and reports
413
+ * what it holds (v52). */
414
+ readonly renderScale: RenderScaleController;
415
+ /** One runtime presentation reached the GL canvas. This is the single
416
+ * stage-local fan-out for telemetry and an armed Tier-1 framebuffer
417
+ * snapshot; callers must invoke it synchronously from onPresented.
418
+ * `drawnPlaying` is the `playing` the runtime was rendered with for this
419
+ * draw: a flag flip reaches the runtime a commit after it is set here.
420
+ * `drawnForExport` is whether that render was in export framing: only such
421
+ * a draw may resolve an armed capture (see `present` below). */
422
+ present(info: ShotPresentedFrame, document: Doc3D | null, drawnPlaying?: boolean, drawnForExport?: boolean): void;
423
+ /** A presentation of the WHOLE picture for `health()` (v35): StageApp calls
424
+ * it from every presentation once the placed components' modules are
425
+ * installed, `epoch` being the module epoch they were imported under
426
+ * (hosted), paused or playing, with no telemetry, capture or
427
+ * preparing-pass work. Ignored while other work above the runtime is
428
+ * still outstanding (the capture gate's own question), and never recorded
429
+ * for a preparing pass's own frames (v51), which are not the author's.
430
+ *
431
+ * It also closes the READINESS LATCH (v51): a whole presentation of the
432
+ * open subject (`drawn.subject`) means this renderer owes it nothing more
433
+ * (`onStageBusy`). And one the transport drew in the playing presentation
434
+ * (`drawn.playing`) joins `takePlayed()`. Without `drawn`, the subject is
435
+ * the open one whenever `document` is the open document, drawn paused. */
436
+ noteDrawn(info: ShotPresentedFrame, document: Doc3D | null, epoch?: number | null, drawn?: StageDrawn): void;
437
+ /** The project's modules hot-updated under this stage (Vite's
438
+ * `vite:afterUpdate`, locally): the code it runs is now that update's.
439
+ * `checked` is each placed component's module (`/src/X.tsx`) the update
440
+ * touched, with null when it replaced cleanly and the failure when it did
441
+ * not, which leaves the stage running the previous code. `health()` says
442
+ * so as a component failure, and its `loadedAt` moves to now. */
443
+ noteHotUpdate(checked: ReadonlyMap<string, string | null>): void;
91
444
  getState(): StageClientState;
92
445
  subscribe(listener: () => void): () => void;
93
446
  /** The chrome's handlers, or null before connect. The stage calls up through
@@ -98,64 +451,1444 @@ export interface StageClient {
98
451
  setQuadSource(source: ((pane: StagePane) => readonly ShotScreenQuad[] | null) | null): void;
99
452
  /** How the stage answers `fitView()`. Registered by the viewport. */
100
453
  setFitter(fit: (() => ShotView) | null): void;
454
+ /** How the stage answers `designPointAt()`. Registered by the viewport;
455
+ * until then the bridge answers null and a drop lands centred. */
456
+ setDesignPointer(source: ((clientX: number, clientY: number) => readonly [number, number] | null) | null): void;
457
+ /** What the last instance-module resolution could not load. Registered by
458
+ * StageApp; the Tier-1 export REFUSES rather than shipping the empty layers
459
+ * those errors describe. */
460
+ setInstanceErrors(errors: readonly string[]): void;
461
+ /** Layers whose component RESOLVED and then threw while rendering — the
462
+ * boundary contains the crash and the layer draws nothing, which an export
463
+ * cannot tell from a layer meant to be empty. Same refusal, other cause. */
464
+ setInstanceCrashes(errors: readonly string[]): void;
465
+ /** A crash above the instance boundaries makes the whole stage unusable.
466
+ * Latch it into the export refusal path and reject an armed capture now,
467
+ * instead of leaving the controller to wait for its presentation timeout. */
468
+ setFatalError(message: string): void;
469
+ /** Known render-input or renderer failures whose fallback pixels are valid
470
+ * for an interactive preview but not an exact export. Registered by the
471
+ * stage and cleared when every reported source has recovered.
472
+ * `compileBlocked` says one of them is a standing renderer compile failure
473
+ * that stopped the draw (the runtime's `blocking`, never a list of failed
474
+ * pipelines the rest draws around): the stage cannot present, so it owes
475
+ * no presentation (v51). */
476
+ setRenderErrors(errors: readonly string[], compileBlocked?: boolean): void;
477
+ /** The pieces the runtime isolated (`ShotRuntimeProps.onPieceFailures`),
478
+ * whole. Forwarded to the chrome (v34 `onPieceFailures`), and an exact
479
+ * capture of any frame a listed piece belongs to REFUSES by name, since
480
+ * that frame would be missing it: at arm, at the matching presentation,
481
+ * and at once for a capture already armed. */
482
+ setPieceFailures(failures: readonly StagePieceFailure[]): void;
483
+ /** The list as last set. */
484
+ pieceFailures(): readonly StagePieceFailure[];
485
+ /** An error outside the piece log: an uncaught throw or rejection in this
486
+ * document, the export worker's failure. Kept, deduplicated, for
487
+ * `health()` (v35) and announced to the chrome (`onStageHealth`). */
488
+ recordError(source: StageErrorSource, message: string): void;
489
+ /** How the stage answers `requestNodePreviews`. Registered by the node
490
+ * preview component once it mounts; until then the bridge answers null,
491
+ * which is the same posture `setQuadSource` takes for selection shapes. */
492
+ setNodePreviewSource(
493
+ source: ((request: NodePreviewRequest) => Promise<NodePreviewBatch | null>) | null,
494
+ ): void;
495
+ /** How the stage answers `coverage()` (v36): the runtime's check of the
496
+ * live Shot, handed over while one is mounted and null once none is. */
497
+ setCoverageProbe(probe: CoverageProbe | null): void;
101
498
  /** …and `fitWorkingView()`. A separate registration rather than one fitter
102
499
  * returning both, because the two are computed from different things: the
103
500
  * camera fit needs the PANE SIZE and the working fit needs the DOCUMENT. */
104
501
  setWorkingFitter(fit: (() => WorkingView) | null): void;
502
+ /** How `hoverLayer` reaches the overlay: an imperative sink registered by
503
+ * StageApp, so a chrome hover never routes through `set()` (a stage commit
504
+ * is a GL draw under frameloop="never"). */
505
+ setHoverSink(sink: ((layerId: string | null) => void) | null): void;
506
+ /** The stage area element, registered by StageApp — the Tier-1 exporter
507
+ * finds the GL canvas and the commit marker through it. Stage-internal,
508
+ * never on the bridge. */
509
+ setExportArea(el: HTMLElement | null): void;
510
+ /** The renderer-owned framebuffer canvas. Kept separate from the area marker
511
+ * so a component DOM portal cannot redirect capture by adding another canvas.
512
+ * The optional predicate is the renderer's already-created WebGL context,
513
+ * which closes the event-delivery gap around a lost context. */
514
+ setExportCanvas(
515
+ canvas: HTMLCanvasElement | null,
516
+ isContextLost?: () => boolean,
517
+ ): void;
105
518
  setDisposer(dispose: () => void): void;
519
+ /** A press or a wheel on the stage: aborts a dashboard poster capture that
520
+ * holds the stage in export framing, and answers true when there was one,
521
+ * so the input is swallowed rather than landing on a picture the author
522
+ * never framed. */
523
+ yieldPoster(): boolean;
524
+ /** `passDrawing`, as a stable synchronous getter for StageTools' frame
525
+ * loop, which runs inside a draw and cannot wait for a render (v51). */
526
+ passDrawing(): boolean;
527
+ /** Where the frozen picture and its shield mount (v51): StageApp's element
528
+ * between the runtime's canvas host and the overlays, OUTSIDE the canvas's
529
+ * parent, so `exportRefusal`'s portal scan never counts it. Stage-internal.
530
+ * Null (the stage unmounting it) ends a pass and drops a freeze. */
531
+ registerFreezeHost(el: HTMLElement | null): void;
532
+ /** Whether a stage interaction is live (v51): a G/R/S modal, a pen or
533
+ * spline draft, a marquee or a held pointer, a drag over the stage.
534
+ * Registered by StageApp, which owns them. A preparing pass neither starts
535
+ * nor goes on while one is (`yielded: "interaction"`); the inline text
536
+ * editor and a spline edit are the client's own state and count too. */
537
+ setInteractionProbe(probe: (() => boolean) | null): void;
538
+ /** One of those interactions just began: a running preparing pass ends now
539
+ * (`yielded: "interaction"`), not at its next hop. */
540
+ yieldToInteraction(): void;
541
+ /** The raster rung the stage draws at (the authored zoom, quantised:
542
+ * `editorRasterScale`), the last part of
543
+ * `preparationKey().base`. Registered by StageApp. */
544
+ setRasterScale(scale: number): void;
545
+ }
546
+
547
+ /** What one presentation drew, beside its document (v51 `noteDrawn`). */
548
+ export interface StageDrawn {
549
+ /** Its subject (`stageSubject` of the render that drew it). */
550
+ subject: string | null;
551
+ /** Drawn in the playing presentation: the `playing` it rendered with. */
552
+ playing: boolean;
553
+ }
554
+
555
+ /**
556
+ * WHAT THE STAGE SHOWS (v51), the subject the readiness latch is kept for:
557
+ * the Project, a component context on it, or a material preview of one of its
558
+ * meshes. The document's id leads every one, so another Project is another
559
+ * subject even under the same code context. Null with no document.
560
+ */
561
+ export function stageSubject(
562
+ doc: Doc3D | null,
563
+ context: ShotComponentContext | null,
564
+ preview: MaterialPreviewTarget | null,
565
+ ): string | null {
566
+ if (doc === null) return null;
567
+ if (preview !== null) return `${doc.id}\nmaterial:${preview.layerId}:${preview.slot}`;
568
+ return context === null ? doc.id : `${doc.id}\n${componentContextSubject(context)}`;
106
569
  }
107
570
 
108
- export function createStageClient(): StageClient {
571
+ /**
572
+ * How long the preparing pass waits for one frame's presentation before it
573
+ * moves on. A cold frame can take over a second (a lit material's program is
574
+ * 100 to 300 ms on its own, and gemini's first cut measured 1.5 s); the
575
+ * deadline only has to stop one frame that never lands from holding Play.
576
+ */
577
+ export const PREPARE_FRAME_DEADLINE_MS = 5_000;
578
+
579
+ /** The longest an `"idle"` pace waits for the browser's idle time between two
580
+ * frames (`requestIdleCallback`'s timeout). */
581
+ export const IDLE_HOP_TIMEOUT_MS = 100;
582
+
583
+ /** How long a frozen picture still stands once the author scrubs under it: a
584
+ * scrub shows the frames it passes, a video frame still decoding or a Shot
585
+ * still loading included, so it never waits out the 5 s backstop. */
586
+ export const SCRUB_LIFT_MS = 150;
587
+
588
+ /**
589
+ * Lay `bitmap` over `canvas`, in `host`, with a SHIELD over every overlay.
590
+ *
591
+ * The host is StageApp's, after the runtime's canvas host and before the
592
+ * overlays (`registerFreezeHost`): the copy covers the canvas and sits under
593
+ * the overlays, and `exportRefusal`'s portal scan, which counts the canvas's
594
+ * siblings, never sees it. The shield is transparent and stacks over the
595
+ * overlays. A press or a wheel on it is swallowed and calls `onPress`: the
596
+ * scene graph under it holds another frame, so a pick there would act on a
597
+ * picture the author is not looking at. A right press opens no browser menu,
598
+ * a move, a click and a double click stop there, so the hover and the
599
+ * double-click lanes stay still. It sets no cursor, so the area's shows
600
+ * through. Removing both is the caller's.
601
+ */
602
+ function mountFreeze(
603
+ host: HTMLElement,
604
+ canvas: HTMLCanvasElement,
605
+ bitmap: ImageBitmap,
606
+ onPress: () => void,
607
+ ): { remove(): void } {
608
+ const frozen = document.createElement("canvas");
609
+ frozen.width = bitmap.width;
610
+ frozen.height = bitmap.height;
611
+ frozen.getContext("2d")?.drawImage(bitmap, 0, 0);
612
+ frozen.dataset["preparing"] = "frozen";
613
+ const box = canvas.getBoundingClientRect();
614
+ const origin = host.getBoundingClientRect();
615
+ Object.assign(frozen.style, {
616
+ position: "absolute",
617
+ left: `${box.left - origin.left}px`,
618
+ top: `${box.top - origin.top}px`,
619
+ width: `${box.width}px`,
620
+ height: `${box.height}px`,
621
+ pointerEvents: "none",
622
+ });
623
+ const shield = document.createElement("div");
624
+ shield.dataset["preparing"] = "shield";
625
+ Object.assign(shield.style, {
626
+ position: "absolute",
627
+ left: "0",
628
+ top: "0",
629
+ width: "100%",
630
+ height: "100%",
631
+ zIndex: "1",
632
+ pointerEvents: "auto",
633
+ });
634
+ const press = (event: Event): void => {
635
+ event.preventDefault();
636
+ event.stopPropagation();
637
+ onPress();
638
+ };
639
+ const stop = (event: Event): void => event.stopPropagation();
640
+ shield.addEventListener("pointerdown", press);
641
+ shield.addEventListener("wheel", press, { passive: false });
642
+ shield.addEventListener("contextmenu", (event) => event.preventDefault());
643
+ for (const type of ["pointermove", "click", "dblclick"]) shield.addEventListener(type, stop);
644
+ host.append(frozen, shield);
645
+ return {
646
+ remove() {
647
+ frozen.remove();
648
+ shield.remove();
649
+ },
650
+ };
651
+ }
652
+
653
+ /** The changes a preparing pass ends on (v51 `yielded: "push"`): a stage push
654
+ * that changes the picture or the overlays drawn over it. */
655
+ const PASS_ENDS_ON_PUSH: ReadonlySet<keyof StageClientState> = new Set<keyof StageClientState>([
656
+ "fontPreview",
657
+ "motionPreview",
658
+ "grid",
659
+ "editorChrome",
660
+ "motionPaths",
661
+ "peerSelections",
662
+ "studioComments",
663
+ "sketchGuide",
664
+ "readOnly",
665
+ "textEdit",
666
+ "isolation",
667
+ "materialPreview",
668
+ "componentContext",
669
+ "layout",
670
+ "video",
671
+ "view",
672
+ "inset",
673
+ ]);
674
+
675
+ export function createStageClient(
676
+ opts: {
677
+ /** Test seam: the Tier-1 exporter factory. The real one spawns a module
678
+ * Worker and reads a live GL canvas, neither of which exists in jsdom —
679
+ * a fake here is what lets the deferral rules be pinned. */
680
+ createExporter?: typeof createShotStageExporter;
681
+ /** Test seam for deterministic request-to-present timing. */
682
+ performanceTelemetry?: StagePerformanceTelemetry;
683
+ /** Test seam for the close/export ordering of the three runtime caches. */
684
+ clearCaches?: () => void;
685
+ /** Test seam: where the font preview's faces load (v50). jsdom has no
686
+ * CSS Font Loading API. */
687
+ fontPlatform?: () => FontPlatform | null;
688
+ } = {},
689
+ ): StageClient {
109
690
  let state: StageClientState = INITIAL;
691
+ const clock = createShotClock(0);
692
+ /** The last frame the transport accepted: the coalescing reference and
693
+ * what a mode that renders the frame through React starts from. */
694
+ let lastFrame = 0;
695
+ /** The preparing pass in flight (`preparePlayback`), or null. `end` stops
696
+ * it and gives the stage back (a reason, or null for a cancel); `pace` is
697
+ * the chrome's, asked again here; `startFrame` is the author's frame it
698
+ * gives back; `ended` settles once that frame has been published again. */
699
+ let preparing: {
700
+ end(reason: ShotPreparePlaybackYield | null): void;
701
+ pace(): ShotPreparePace;
702
+ startFrame: number;
703
+ ended: Promise<void>;
704
+ } | null = null;
705
+ /** Coverage checks running (v36): a pass under an `"idle"` pace would draw
706
+ * other frames under them, so none starts meanwhile. */
707
+ let checking = 0;
708
+ /** The frozen picture over the canvas (v51), or null. It belongs to the
709
+ * client and not to one pass: a pass that starts while it still stands
710
+ * adopts it. */
711
+ let freeze: { remove(): void } | null = null;
712
+ /** The element it mounts in (`registerFreezeHost`). */
713
+ let freezeHost: HTMLElement | null = null;
714
+ /** The freeze's pending lift, or null: on the first complete presentation
715
+ * of the frame the clock is on, from the publication `after` on (prepare
716
+ * tokens count down), paused, or in either presentation once the
717
+ * transport runs (`either`). `deadline` is the 5 s backstop, which an
718
+ * author's scrub has shortened once `scrubbed` (`SCRUB_LIFT_MS`). */
719
+ let lift: { after: number; either: boolean; scrubbed: boolean; deadline: ReturnType<typeof setTimeout> } | null = null;
720
+ /** StageApp's answer to "is a stage interaction live" (v51). */
721
+ let interactionProbe: (() => boolean) | null = null;
722
+ const interactionLive = (): boolean =>
723
+ state.textEdit !== null || state.splineEdit !== null || interactionProbe?.() === true;
724
+ /** The transport is running: the chrome's `setPlaying`, which `playing`
725
+ * is not while a pass holds the playing presentation. */
726
+ let transport = false;
727
+ /** What the transport drew whole in the playing presentation since it
728
+ * started (`takePlayed`), as frames per document object: a loop draws the
729
+ * same frames again, and a Set keeps them once. */
730
+ let played = new Map<Doc3D, Set<number>>();
731
+ /** What `preparationKey()` reads: the GPU epoch (a new renderer, the end of
732
+ * export framing, a cache clear, a close: `gpuStateReplaced`), the raster
733
+ * rung, and the module epoch and hot updates the placed code components
734
+ * were imported under. */
735
+ let gpuEpoch = 0;
736
+ let rasterScale = 1;
737
+ let moduleEpoch: number | null = null;
738
+ let hotUpdates = 0;
739
+ /** The one MessageChannel a `"play"` pace hops through, made on first
740
+ * use, and the steps waiting on it in order. */
741
+ let hopChannel: MessageChannel | null = null;
742
+ const hops: (() => void)[] = [];
743
+ /**
744
+ * The next frame, in a task of its own. Never `setTimeout(step, 0)`: a
745
+ * nested timer clamps to 4 ms. `"play"` hops through the message channel;
746
+ * `"idle"` waits for the browser's idle time, at most `IDLE_HOP_TIMEOUT_MS`
747
+ * (a browser without `requestIdleCallback` hops as `"play"` does).
748
+ */
749
+ const hop = (pace: ShotPreparePace, run: () => void): void => {
750
+ if (pace === "idle" && typeof requestIdleCallback === "function") {
751
+ requestIdleCallback(() => run(), { timeout: IDLE_HOP_TIMEOUT_MS });
752
+ return;
753
+ }
754
+ if (hopChannel === null) {
755
+ hopChannel = new MessageChannel();
756
+ hopChannel.port1.onmessage = () => hops.shift()?.();
757
+ }
758
+ hops.push(run);
759
+ hopChannel.port2.postMessage(null);
760
+ };
761
+ /** The runtime's coverage check of the live Shot, registered while one is
762
+ * mounted (`ShotRuntimeProps.onCoverageProbe`). */
763
+ let coverageProbe: CoverageProbe | null = null;
764
+ /** The dashboard poster in flight (`capturePoster`), or null. It is
765
+ * decoration: whatever the author starts on the stage aborts it, and
766
+ * `settled` is the moment the stage has left its export framing. */
767
+ let poster: { abort: AbortController; settled: Promise<void> } | null = null;
768
+ /** The document whose poster capture FAILED (not yielded). It answers null
769
+ * until the document changes, so the chrome may ask again freely: a
770
+ * retry costs a comparison, never another minute in export framing. */
771
+ let posterFailedFor: Doc3D | null = null;
772
+ /** Abort the poster when it holds the stage in export framing, resolving
773
+ * once the stage is back in the author's; null when there is none. */
774
+ const yieldPoster = (): Promise<void> | null => {
775
+ if (poster === null || state.exporting === null) return null;
776
+ poster.abort.abort();
777
+ return poster.settled;
778
+ };
779
+ /** Publications the preparing pass is waiting to see presented, by token. */
780
+ const presentWaiters = new Map<number, (drawnPlaying: boolean) => void>();
781
+ /** The preparing pass's own tokens: NEGATIVE, so they can never be one the
782
+ * telemetry minted (it counts up from 1). */
783
+ let prepareTokens = 0;
784
+ const prepareToken = (): number => {
785
+ prepareTokens -= 1;
786
+ return prepareTokens;
787
+ };
788
+ const performanceTelemetry =
789
+ opts.performanceTelemetry ?? new StagePerformanceTelemetry({ rich: PERF_HUD_ENABLED });
790
+ const renderScale = new RenderScaleController();
791
+ /** The scene under a master frame, for `StageClientState.sceneId`. */
792
+ const sceneIdAt = (doc: Doc3D | null, frame: number): string | null =>
793
+ doc === null || state.componentContext !== null ? null : (masterToSceneCached(doc, frame)?.sceneId ?? null);
110
794
  const listeners = new Set<() => void>();
111
795
  let handlers: ShotBridgeHandlers | null = null;
112
796
  let quadSource: ((pane: StagePane) => readonly ShotScreenQuad[] | null) | null = null;
113
797
  let fitter: (() => ShotView) | null = null;
798
+ let designPointer: ((clientX: number, clientY: number) => readonly [number, number] | null) | null = null;
799
+ let nodePreviewSource:
800
+ | ((request: NodePreviewRequest) => Promise<NodePreviewBatch | null>)
801
+ | null = null;
114
802
  let workingFitter: (() => WorkingView) | null = null;
803
+ /** The chrome-hover lane's imperative sink, registered by StageApp. Null
804
+ * before the stage mounts — a hover over a not-yet-connected stage is
805
+ * nothing to draw on. */
806
+ let hoverSink: ((layerId: string | null) => void) | null = null;
115
807
  let dispose: (() => void) | null = null;
808
+ let exportArea: HTMLElement | null = null;
809
+ let exportCanvas: HTMLCanvasElement | null = null;
810
+ let exportContextLost = false;
811
+ let exportContextIsLost: (() => boolean) | null = null;
812
+ let removeExportCanvasListeners: (() => void) | null = null;
813
+ let exporter: ShotStageExporter | null = null;
814
+ /** Created on first use: the real one spawns a Worker to probe. */
815
+ const exporterOf = (): ShotStageExporter =>
816
+ (exporter ??= (opts.createExporter ?? createShotStageExporter)(exportHost, {
817
+ onWorkerError: (message) => recordError("export-worker", message),
818
+ }));
819
+ let pendingCapture: {
820
+ frame: number;
821
+ width: number;
822
+ height: number;
823
+ snapshotting: boolean;
824
+ snapshot: (canvas: HTMLCanvasElement) => Promise<ImageBitmap>;
825
+ resolve: (bitmap: ImageBitmap) => void;
826
+ reject: (error: unknown) => void;
827
+ } | null = null;
828
+ /** What the last instance-module resolution could not load; the Tier-1
829
+ * export refuses while it is non-empty. */
830
+ let instanceErrors: string[] = [];
831
+ /** Contained render crashes, by the same rule. */
832
+ let instanceCrashes: string[] = [];
833
+ /** Placed components' modules whose last hot update failed, by module
834
+ * path (`/src/X.tsx`): the stage still runs their previous code. */
835
+ const hotErrors = new Map<string, string>();
836
+ /** When the stage last took a hot update of the project's code (wall ms),
837
+ * or null: `health().loadedAt` is the later of this and its load. */
838
+ let codeAt: number | null = null;
839
+ /** The hot-update failures of modules the document on stage places. */
840
+ const placedHotErrors = (): string[] => {
841
+ if (hotErrors.size === 0 || state.doc === null) return [];
842
+ const placed = new Set(instanceComponentIdsOf(state.doc).map((id) => `/${instanceModuleOf(id)}`));
843
+ return [...hotErrors].filter(([module]) => placed.has(module)).map(([, message]) => message);
844
+ };
845
+ /** Last-resort StageRootBoundary failure. The boundary cannot recover without
846
+ * reloading the stage realm, so this remains terminal for this client. */
847
+ let fatalError: string | null = null;
848
+ /** Recoverable failures outside instance resolution. A preview may show a
849
+ * fallback for these, but an accepted export must not silently bake it. */
850
+ let renderErrors: string[] = [];
851
+ /** The pieces the runtime isolated, as last reported. Scoped per FRAME at
852
+ * the gate (`piecesAt`): a Shot warming ahead of its cut in the editor is
853
+ * not in the frame being captured. */
854
+ let pieceFailures: readonly StagePieceFailure[] = [];
855
+ // ── HEALTH (v35) ─────────────────────────────────────────────────────
856
+ //
857
+ // What `health()` answers: everything above, the errors outside the piece
858
+ // log, and what was last PRESENTED (a complete picture, readiness clear),
859
+ // with the saved revision and module epoch it was drawn from. The chrome
860
+ // hears `onStageHealth` when any of it changes, never per frame: a
861
+ // presentation announces itself only when its document, revision or epoch
862
+ // differs from the last one's.
863
+ let stageErrors: StageRuntimeError[] = [];
864
+ let rendered: { -readonly [K in keyof StageRendered]: StageRendered[K] } | null = null;
865
+ /** The document object `rendered` was drawn from. */
866
+ let presentedDoc: Doc3D | null = null;
867
+ /** Each pushed document's saved revision, by object: a presentation may
868
+ * still draw the document a newer push replaced. */
869
+ const revOfDoc = new WeakMap<Doc3D, string | null>();
870
+ /** Editor telemetry only, never read by anything that draws. */
871
+ const wallNow = (): number => Date.now();
872
+ /** What `health()` says was last presented: announced only when the
873
+ * document, its revision or the module epoch moved, never per frame. */
874
+ function notePresented(info: ShotPresentedFrame, document: Doc3D | null, epoch: number | null): void {
875
+ if (document === null) return;
876
+ const rev = revOfDoc.get(document) ?? null;
877
+ const same = rendered !== null && presentedDoc === document && rendered.rev === rev && rendered.epoch === epoch;
878
+ presentedDoc = document;
879
+ if (same) {
880
+ rendered!.frame = info.frame;
881
+ rendered!.at = wallNow();
882
+ return;
883
+ }
884
+ rendered = { rev, docId: document.id, frame: info.frame, epoch, at: wallNow() };
885
+ notifyHealth();
886
+ }
887
+ function recordError(source: StageErrorSource, message: string): void {
888
+ stageErrors = recordStageError(stageErrors, source, message, state.docRev, wallNow());
889
+ notifyHealth();
890
+ }
891
+ function notifyHealth(): void {
892
+ try {
893
+ handlers?.onStageHealth?.();
894
+ } catch {
895
+ // A handler must never throw back into the stage (bridgeTypes).
896
+ }
897
+ }
898
+ /** The GPU state behind prepared frames was replaced (v51): a new
899
+ * renderer, the end of export framing, a cache clear, a close. The
900
+ * preparation base moves, and the chrome hears it (`onStageHealth`), so a
901
+ * scheduler that had nothing left to prepare looks again. */
902
+ function gpuStateReplaced(): void {
903
+ gpuEpoch += 1;
904
+ notifyHealth();
905
+ }
906
+ /** Chrome pushes that landed mid-export, applied when it ends. `frame` also
907
+ * remembers the pre-export playhead so a cancelled export puts the author
908
+ * back where they were. */
909
+ const deferred: {
910
+ doc: Doc3D | null;
911
+ /** `doc`'s revision, from the same push. */
912
+ rev: string | null;
913
+ frame: number | null;
914
+ componentContext: ShotComponentContext | null;
915
+ closed: boolean;
916
+ clearCaches: boolean;
917
+ /** The latest isolation push, boxed so "isolate nothing" (null) is not
918
+ * mistaken for "nothing was pushed". `orbit` is the camera mode the
919
+ * chrome re-asserts right after it (`setIsolationOrbit`). */
920
+ isolation: { layerId: string | null; orbit: boolean } | null;
921
+ } = {
922
+ doc: null,
923
+ rev: null,
924
+ frame: null,
925
+ componentContext: null,
926
+ closed: false,
927
+ clearCaches: false,
928
+ isolation: null,
929
+ };
116
930
 
117
931
  function set(patch: Partial<StageClientState>): void {
118
- let changed = false;
119
- for (const key of Object.keys(patch) as (keyof StageClientState)[]) {
120
- if (!Object.is(state[key], patch[key])) {
121
- changed = true;
122
- break;
932
+ const keys = Object.keys(patch) as (keyof StageClientState)[];
933
+ if (keys.every((key) => Object.is(state[key], patch[key]))) return;
934
+ const was = state;
935
+ state = { ...state, ...patch };
936
+ // THE SUBJECT, and the latch with it (v51): a new one is owed its first
937
+ // whole presentation on this renderer. An edit of the open Project is
938
+ // not a new subject: the latch is kept per subject, never per document
939
+ // object, or every keystroke would hold Play again.
940
+ if (state.doc !== was.doc || state.componentContext !== was.componentContext || state.materialPreview !== was.materialPreview) {
941
+ const next = stageSubject(state.doc, state.componentContext, state.materialPreview);
942
+ if (next !== subject) {
943
+ subject = next;
944
+ latch = null;
945
+ queueBusy();
946
+ // A FROZEN PICTURE OF ONE SUBJECT never stands for another, nor do
947
+ // the overlays held with it: the new subject's own progressive draw is
948
+ // the honest picture, and its first whole one, which the lift waits
949
+ // for, is exactly what a load withholds. In this patch, not a second.
950
+ if (freeze !== null) {
951
+ cancelLift();
952
+ freeze.remove();
953
+ freeze = null;
954
+ state = { ...state, held: null, passDrawing: false };
955
+ }
123
956
  }
124
957
  }
125
- if (!changed) return;
126
- state = { ...state, ...patch };
958
+ // THE PHASE THE RUNTIME READS (`stagePhase`), in this task: the hand-over
959
+ // clears `passDrawing` and publishes the start frame in one, and that
960
+ // draw is the transport's first picture, never a pass's.
961
+ setStagePhase(state.passDrawing ? "pass" : state.playing ? "transport" : "paused");
127
962
  for (const l of listeners) l();
963
+ // A PASS GIVES WAY (v51) to a stage interaction starting, and to a push
964
+ // that changes the picture or what is drawn over it. By value, because
965
+ // the chrome re-pushes some of these unchanged with every document push.
966
+ const pass = preparing;
967
+ if (pass === null) return;
968
+ const moved = (key: keyof StageClientState): boolean => JSON.stringify(was[key]) !== JSON.stringify(state[key]);
969
+ if (state.splineEdit !== null && moved("splineEdit")) pass.end("interaction");
970
+ else if (keys.some((key) => PASS_ENDS_ON_PUSH.has(key) && moved(key))) pass.end("push");
128
971
  }
129
972
 
973
+ /** The font preview's faces (v50): loaded outside every readiness claim and
974
+ * every error report, and handed to the state once they land. */
975
+ const previewFaces = createFontPreviewFaces((face) => {
976
+ if (state.fontPreview !== null) set({ fontPreview: { ...state.fontPreview, face } });
977
+ }, opts.fontPlatform);
978
+ /** Whether the font preview changes what the runtime draws: one naming no
979
+ * text layer of the open document changes nothing, so the coverage check
980
+ * and the poster refuse nothing for it. */
981
+ const previewingFont = (): boolean =>
982
+ state.doc !== null && state.fontPreview !== null && fontPreviewDoc(state.doc, state.fontPreview) !== state.doc;
983
+ /** The same question for the motion preview (v53): a preset that lands no
984
+ * key on the open document changes nothing. */
985
+ const previewingMotion = (): boolean =>
986
+ state.doc !== null && state.motionPreview !== null && motionPreviewDoc(state.doc, state.motionPreview) !== state.doc;
987
+
988
+ function validComponentContext(
989
+ ctx: ShotComponentContext | null,
990
+ doc: Doc3D | null,
991
+ ): ShotComponentContext | null {
992
+ if (ctx === null) return null;
993
+ const zoom = typeof ctx.zoom === "number" && ctx.zoom > 0 && Number.isFinite(ctx.zoom)
994
+ ? ctx.zoom
995
+ : 1;
996
+ // v45's preview fields, each omitted unless valid so a context without
997
+ // them stays the v44 object.
998
+ const look = {
999
+ ...(ctx.background === "dark" || ctx.background === "light" || ctx.background === "brand"
1000
+ ? { background: ctx.background }
1001
+ : {}),
1002
+ ...(Number.isSafeInteger(ctx.loopFrames) && ctx.loopFrames! > 0 && ctx.loopFrames! <= MAX_LOOP_FRAMES
1003
+ ? { loopFrames: ctx.loopFrames! }
1004
+ : {}),
1005
+ };
1006
+ if ("code" in ctx) {
1007
+ if (ctx.code.length === 0) return null;
1008
+ // Rebuilt, not passed through, so junk fields a future chrome sends
1009
+ // cannot ride into stage state; `props` survives only in validated form
1010
+ // (v27) — a malformed key is dropped rather than blanking the preview.
1011
+ const props = validPreviewProps(ctx.props);
1012
+ return { code: ctx.code, zoom, ...(props === undefined ? {} : { props }), ...look };
1013
+ }
1014
+ if (doc?.components?.[ctx.def] === undefined) return null;
1015
+ // Tried values (v46), validated like the code arm's. The Edit tab's bit
1016
+ // (v48) only ever crosses as `true`.
1017
+ const tried = validPreviewProps(ctx.props);
1018
+ return {
1019
+ def: ctx.def,
1020
+ zoom,
1021
+ ...(tried === undefined ? {} : { props: tried }),
1022
+ ...look,
1023
+ ...(ctx.edit === true ? { edit: true as const } : {}),
1024
+ };
1025
+ }
1026
+
1027
+ /** The listed pieces that belong to the picture at `frame`, in the frame
1028
+ * space the capture uses: a one-Shot export's frames are its slice's. */
1029
+ function piecesAt(frame: number): StagePieceFailure[] {
1030
+ if (pieceFailures.length === 0) return [];
1031
+ const doc = state.doc;
1032
+ if (doc === null) return [...pieceFailures];
1033
+ const scope = state.exporting?.scene;
1034
+ const drawn = scope === undefined ? doc : (sliceShotToScene(doc, scope) ?? doc);
1035
+ return pieceFailuresAt(pieceFailures, drawn, frame);
1036
+ }
1037
+
1038
+ /** The renderer's context is lost: its loss event fired, or the renderer
1039
+ * says so now. A renderer that cannot answer counts as lost: an exact
1040
+ * export cannot turn that uncertainty into accepted pixels. */
1041
+ function contextLost(): boolean {
1042
+ if (exportContextLost) return true;
1043
+ if (exportContextIsLost === null) return false;
1044
+ try {
1045
+ return exportContextIsLost();
1046
+ } catch {
1047
+ return true;
1048
+ }
1049
+ }
1050
+
1051
+ /** `frame` is the capture's own; without one (a begin) the frame-scoped
1052
+ * refusals wait for the frame. */
1053
+ function exportRefusal(frame?: number): Error | null {
1054
+ if (fatalError !== null) {
1055
+ return new Error(`export refused because the stage crashed: ${fatalError}`);
1056
+ }
1057
+ if (frame !== undefined) {
1058
+ const broken = piecesAt(frame);
1059
+ if (broken.length > 0) {
1060
+ const first = broken[0]!;
1061
+ return new Error(
1062
+ `export refused: ${broken.length} piece(s) of frame ${frame} failed to render, so the frame ` +
1063
+ `would be missing them: ${describePieceFailure(first, pieceLayerLabel(state.doc, first))}`,
1064
+ );
1065
+ }
1066
+ }
1067
+ if (contextLost()) {
1068
+ return new Error(
1069
+ "export refused because the ShotRuntime WebGL context is lost; " +
1070
+ "reload the stage before exporting",
1071
+ );
1072
+ }
1073
+ if (renderErrors.length > 0) {
1074
+ return new Error(
1075
+ `export refused: ${renderErrors.length} known render failure(s) would make ` +
1076
+ `the accepted export incomplete or wrong: ${renderErrors[0]!}`,
1077
+ );
1078
+ }
1079
+ const moduleErrors = [...instanceErrors, ...placedHotErrors()];
1080
+ if (moduleErrors.length > 0) {
1081
+ return new Error(
1082
+ `export refused: ${moduleErrors.length} component module(s) failed to load, ` +
1083
+ `so their layers would render empty or stale: ${moduleErrors[0]!}`,
1084
+ );
1085
+ }
1086
+ if (instanceCrashes.length > 0) {
1087
+ return new Error(
1088
+ `export refused: ${instanceCrashes.length} component(s) crashed while rendering, ` +
1089
+ `so their layers would render empty: ${instanceCrashes[0]!}`,
1090
+ );
1091
+ }
1092
+ const canvasParent = exportCanvas?.parentElement ?? null;
1093
+ const portal =
1094
+ canvasParent === null
1095
+ ? null
1096
+ : [...canvasParent.children].find((child) => child !== exportCanvas) ?? null;
1097
+ if (portal !== null) {
1098
+ return new Error(
1099
+ "export refused: component DOM output is mounted beside the ShotRuntime canvas, " +
1100
+ "but browser export captures only the renderer framebuffer; use the Studio server render",
1101
+ );
1102
+ }
1103
+ return null;
1104
+ }
1105
+
1106
+ /** A refusal that arrives while a capture is armed cancels it at once, for
1107
+ * the frame that capture is for. */
1108
+ function refuseArmedCapture(): void {
1109
+ if (state.exporting === null) return;
1110
+ const refusal = exportRefusal(pendingCapture?.frame);
1111
+ if (refusal !== null) cancelPendingCapture(refusal);
1112
+ }
1113
+
1114
+ function clearStageCaches(): void {
1115
+ // The GPU state prepared frames stood on goes with the caches (v51).
1116
+ gpuStateReplaced();
1117
+ if (opts.clearCaches !== undefined) {
1118
+ opts.clearCaches();
1119
+ return;
1120
+ }
1121
+ clearRasterCache();
1122
+ clearPlaneCache();
1123
+ clearLayerMaterialPool();
1124
+ }
1125
+
1126
+ function cancelPendingCapture(error: Error): void {
1127
+ const pending = pendingCapture;
1128
+ if (pending === null) return;
1129
+ pendingCapture = null;
1130
+ pending.reject(error);
1131
+ }
1132
+
1133
+ function registerExportCanvas(
1134
+ canvas: HTMLCanvasElement | null,
1135
+ isContextLost?: () => boolean,
1136
+ ): void {
1137
+ removeExportCanvasListeners?.();
1138
+ removeExportCanvasListeners = null;
1139
+ exportCanvas = canvas;
1140
+ exportContextLost = false;
1141
+ exportContextIsLost = isContextLost ?? null;
1142
+ if (canvas === null) return;
1143
+ // A NEW RENDERER (v51): it owes the subject its first whole presentation,
1144
+ // and nothing an older one prepared is resident on it.
1145
+ latch = null;
1146
+ gpuStateReplaced();
1147
+ queueBusy();
1148
+
1149
+ const lost = (event: Event): void => {
1150
+ // WebGL restores only when the loss event is cancelled, so preview may
1151
+ // recover. Exact export stays terminal until a fresh renderer canvas
1152
+ // mounts: ClockDriver's program watch belongs to the old context and a
1153
+ // restored event alone does not prove those programs were recompiled.
1154
+ event.preventDefault();
1155
+ exportContextLost = true;
1156
+ cancelPendingCapture(
1157
+ new Error(
1158
+ "export refused because the ShotRuntime WebGL context was lost during capture",
1159
+ ),
1160
+ );
1161
+ // A lost context presents nothing, so nothing may wait on it (v51):
1162
+ // not the hold, and not a pass, whose every frame would wait out its
1163
+ // deadline. The chrome reads the loss from `health()` as the pass ends.
1164
+ preparing?.end(null);
1165
+ queueBusy();
1166
+ };
1167
+ // On a WebGPU-backed canvas this event simply never fires; the listener is
1168
+ // the WebGL path's loss signal and is harmless to leave armed.
1169
+ canvas.addEventListener("webglcontextlost", lost);
1170
+ removeExportCanvasListeners = () => {
1171
+ canvas.removeEventListener("webglcontextlost", lost);
1172
+ };
1173
+ }
1174
+
1175
+ /**
1176
+ * THE CAPTURE GATE'S QUESTION: is any real work outstanding.
1177
+ *
1178
+ * `shot:draw` and `shot:repaint` are internal handoff claims that open and
1179
+ * close inside a single presentation, so waiting on them is waiting on
1180
+ * yourself. `shot:instances` is a real load (StageApp holds it across
1181
+ * `resolveInstanceModules`), but the capture gate needs no claim for it:
1182
+ * StageApp checks the resolved map's identity directly before it calls
1183
+ * `present`, which is the stricter test. A paused video seek (`shot:seek`)
1184
+ * holds every capture, because photographing a stale frame is the bug that
1185
+ * claim exists to prevent.
1186
+ */
1187
+ const HANDOFF = new Set(["shot:draw", "shot:repaint", "shot:instances"]);
1188
+ const externallyPending = (): boolean => stageReadiness.waitingOn().some((label) => !HANDOFF.has(label));
1189
+
1190
+ /**
1191
+ * THE PLAYBACK HOLD'S QUESTION (v51): does this renderer still owe the open
1192
+ * subject its first whole presentation.
1193
+ *
1194
+ * Not "is any load open", which the hold used to ask of the registry: then
1195
+ * a preparing pass's own loads greyed Play, and the button lit on renderer
1196
+ * creation, before any picture. A whole presentation (`noteDrawn`'s full
1197
+ * path) of the subject closes the latch once, and loads arriving after it
1198
+ * hold nothing. A paused video seek therefore holds the subject's FIRST
1199
+ * whole presentation and never a later one, and Pause never re-arms it. A
1200
+ * new subject, a new renderer or a new stage document owes one again.
1201
+ *
1202
+ * Released, so nothing waits for ever, while the stage cannot present at
1203
+ * all: a crash, a lost context, a standing renderer compile failure. A
1204
+ * standing fallback (a texture, an html, a font or a glyph failure) is not
1205
+ * that: the stage still presents, so the latch closes.
1206
+ */
1207
+ let subject: string | null = null;
1208
+ let latch: string | null = null;
1209
+ let compileBlocked = false;
1210
+ const presentationOwed = (): boolean =>
1211
+ state.doc !== null && latch !== subject && fatalError === null && !compileBlocked && !contextLost();
1212
+
1213
+ /** What the chrome was last TOLD. Null before `connect`: a value computed
1214
+ * while nobody was listening was never sent, and must not suppress the
1215
+ * first real push. */
1216
+ let sentBusy: boolean | null = null;
1217
+ let busyQueued = false;
1218
+ const publishBusy = (): void => {
1219
+ if (handlers === null) return;
1220
+ const busy = presentationOwed();
1221
+ if (busy === sentBusy) return;
1222
+ sentBusy = busy;
1223
+ handlers.onStageBusy(busy);
1224
+ };
1225
+ /** COALESCED TO A MICROTASK and then deduped by value: one commit can move
1226
+ * the subject, the renderer and the errors together, and the chrome only
1227
+ * needs to know where that run landed. Never from inside a presentation:
1228
+ * the chrome's store write would put React between two draws. */
1229
+ const queueBusy = (): void => {
1230
+ if (busyQueued) return;
1231
+ busyQueued = true;
1232
+ queueMicrotask(() => {
1233
+ busyQueued = false;
1234
+ publishBusy();
1235
+ });
1236
+ };
1237
+
1238
+ /**
1239
+ * ONE SECOND OF PRESENTATION, forwarded to the chrome.
1240
+ *
1241
+ * On a microtask for `publishBusy`'s reason: the window closes inside the
1242
+ * runtime's `onPresented` call stack, and a chrome store write from there
1243
+ * would put React rendering between a draw and the next one.
1244
+ */
1245
+ const unsubscribeWindow = performanceTelemetry.subscribeWindow((window) => {
1246
+ const perSecond = 1000 / window.durationMs;
1247
+ const report = {
1248
+ presentedFps: window.presentedFrames * perSecond,
1249
+ expectedFps: window.expectedFrames * perSecond,
1250
+ droppedPublications: window.droppedPublications,
1251
+ // The GPU's own time and the scale in force (v52): what the fps chip
1252
+ // says while the picture renders below the display's density.
1253
+ gpuMs: window.gpuP95Ms,
1254
+ renderScale: renderScale.inForce(),
1255
+ };
1256
+ queueMicrotask(() => handlers?.onPerformance(report));
1257
+ });
1258
+ /** THE PLAYBACK RENDER SCALE'S DEADLINES (renderScaleController.ts): every
1259
+ * frame of the transport that reached the canvas, with its document frame,
1260
+ * timed by the clock read its request took in `publishFrame`, before it
1261
+ * was drawn. Inside the runtime's `onPresented` call stack, and a push
1262
+ * only. */
1263
+ const unsubscribePresented = performanceTelemetry.subscribePresented((at, frame) => renderScale.presented(at, frame));
1264
+
1265
+ /**
1266
+ * The transport's seek: publish `frame` to the clock and answer the token
1267
+ * its presentation will echo, or null when nothing was published (an export
1268
+ * owns the clock, or the frame is already the one on screen). `seek` and the
1269
+ * preparing pass share it, so a prepared frame is drawn exactly as a played
1270
+ * one is.
1271
+ *
1272
+ * The token is the telemetry's, which it mints only while the transport
1273
+ * runs or the perf HUD is on. `tokenFor` supplies one otherwise, for a
1274
+ * caller that has to know when THIS publication is presented.
1275
+ */
1276
+ const publishFrame = (frame: number, tokenFor?: () => number): number | null => {
1277
+ // Mid-export the capture loop owns the clock — remember the ask and
1278
+ // land on it when the export ends.
1279
+ if (state.exporting !== null) {
1280
+ deferred.frame = Math.round(frame);
1281
+ return null;
1282
+ }
1283
+ // Integer frames over a rational fps is the engine's invariant, and this
1284
+ // is the boundary a float could sneak through: a chrome that computed a
1285
+ // frame from a pixel position would otherwise hand the studio clock a
1286
+ // fractional one and break byte-identity against the export.
1287
+ // THE PREVIEW LOOP (v45): inside a component context with a loop length,
1288
+ // every frame wraps into it, so a transport that runs past the loop
1289
+ // shows the loop again instead of a held last frame.
1290
+ const loop = state.componentContext?.loopFrames;
1291
+ const rounded = loop === undefined ? Math.round(frame) : ((Math.round(frame) % loop) + loop) % loop;
1292
+ // REQUESTED here, PUBLISHED only if it actually changes the stage: a
1293
+ // transport tick that resolves to the frame already on screen is a
1294
+ // coalesced request, not a dropped one, and counting it as loss would
1295
+ // degrade quality on any document slower than the display.
1296
+ const request = performanceTelemetry.request(rounded);
1297
+ if (lastFrame === rounded) {
1298
+ performanceTelemetry.coalesceRequest(request);
1299
+ return null;
1300
+ }
1301
+ lastFrame = rounded;
1302
+ const token = performanceTelemetry.publish(request) ?? tokenFor?.() ?? null;
1303
+ // ── THE FRAME IS A STORE WRITE, NOT A STATE WRITE ───────────────────
1304
+ //
1305
+ // The stage committed its whole tree per seek: react-dom's half of a
1306
+ // played frame, measured at 1.6 to 3 ms, for a tree whose structure
1307
+ // changes at cuts. Only the scene under the playhead is state now; the
1308
+ // frame and its token go to the clock, which `ShotRuntime`'s driver
1309
+ // subscribes to and the stage's frame loops read. A component context's
1310
+ // def-local frames too, so a played definition commits no stage render
1311
+ // per frame: the runtime's component mount reads them off the clock. The
1312
+ // one mode that renders the frame through React, an export, never gets
1313
+ // here: its capture loop writes the frame (`exportHost.seek`).
1314
+ const sceneId = sceneIdAt(state.doc, rounded);
1315
+ if (sceneId !== state.sceneId) set({ sceneId });
1316
+ clock.set(rounded, token);
1317
+ return token;
1318
+ };
1319
+
1320
+ /**
1321
+ * A preparing pass's publication: `frame` with the pass's own `token`,
1322
+ * minted before it so a draw inside the publication finds its waiter.
1323
+ * Never coalesced, so a frame already on screen is drawn again under the
1324
+ * new token, and never a telemetry token: a negative one tells a pass's
1325
+ * presentation from the transport's (`takePlayed`). A pass never runs in
1326
+ * a component context, and an export ends it.
1327
+ */
1328
+ const publishPrepared = (frame: number, token: number): void => {
1329
+ const rounded = Math.round(frame);
1330
+ if (state.exporting !== null) {
1331
+ deferred.frame = rounded;
1332
+ return;
1333
+ }
1334
+ lastFrame = rounded;
1335
+ const sceneId = sceneIdAt(state.doc, rounded);
1336
+ if (sceneId !== state.sceneId) set({ sceneId });
1337
+ clock.set(rounded, token);
1338
+ };
1339
+
1340
+ const cancelLift = (): void => {
1341
+ if (lift === null) return;
1342
+ clearTimeout(lift.deadline);
1343
+ lift = null;
1344
+ };
1345
+ /** The frozen picture comes down, now, and the furniture follows the
1346
+ * scene graph again. */
1347
+ const dropFreeze = (): void => {
1348
+ cancelLift();
1349
+ freeze?.remove();
1350
+ freeze = null;
1351
+ if (state.held !== null || state.passDrawing) set({ held: null, passDrawing: false });
1352
+ };
1353
+ /** A press or a wheel on the shield (`mountFreeze`). It ends the pass
1354
+ * drawing under it (`yielded: "press"`), or, once that pass has given the
1355
+ * stage back, takes the frozen picture down at once, so a press between
1356
+ * the restore and the lift is never one that does nothing. Swallowed
1357
+ * either way: until the restore is drawn the scene graph may still hold
1358
+ * the pass's frame. */
1359
+ const shieldPress = (): void => {
1360
+ if (preparing !== null) preparing.end("press");
1361
+ else dropFreeze();
1362
+ };
1363
+ /** A standing freeze waits for the author's frame from publication `after`
1364
+ * on (see `lift`), and comes down at the 5 s backstop at the latest. */
1365
+ const armLift = (after: number): void => {
1366
+ if (freeze === null) return;
1367
+ cancelLift();
1368
+ lift = { after, either: transport, scrubbed: false, deadline: setTimeout(dropFreeze, PREPARE_FRAME_DEADLINE_MS) };
1369
+ };
1370
+
130
1371
  const bridge: ShotStageBridge = {
131
1372
  version: SHOT_BRIDGE_VERSION,
132
1373
  connect(next) {
133
1374
  handlers = next;
134
- set({ connected: true });
1375
+ // THE HANDSHAKE, and unconditional: the chrome starts at `stageBusy:
1376
+ // false`, so a presentation owed before it was listening, on a cold
1377
+ // open, would otherwise never be reported at all.
1378
+ sentBusy = null;
1379
+ queueBusy();
1380
+ // THE FONT PREVIEW IS CLEARED ON EVERY ATTACH (v50), its faces with it:
1381
+ // it belongs to an open picker, and a chrome that has just attached has
1382
+ // none open and re-pushes nothing for it.
1383
+ previewFaces.show(null);
1384
+ set({ connected: true, fontPreview: null, motionPreview: null });
1385
+ },
1386
+ health(): StageHealthSnapshot {
1387
+ return {
1388
+ crashed: fatalError,
1389
+ contextLost: contextLost(),
1390
+ pieces: pieceFailures,
1391
+ renderErrors: [...renderErrors],
1392
+ componentErrors: [...instanceErrors, ...instanceCrashes, ...placedHotErrors()],
1393
+ errors: stageErrors,
1394
+ rendered: rendered === null ? null : { ...rendered },
1395
+ loadedAt: Math.max(performance.timeOrigin, codeAt ?? 0),
1396
+ };
1397
+ },
1398
+ async coverage(request) {
1399
+ // THE AUTHOR'S FRAME, AS THE SHOT DRAWS IT. A poster holding export
1400
+ // framing steps aside first, as it does for any capture. A preparing
1401
+ // pass under an `"idle"` pace draws other frames under a frozen
1402
+ // picture: it ends (`yielded: "coverage"`), and hands the author's frame
1403
+ // back, before the check reads the stage, and none starts while one
1404
+ // runs (`checking`). A queued Play's pass is the author's and is never
1405
+ // ended by a read: the check refuses while it runs, as it refuses a
1406
+ // playing stage.
1407
+ //
1408
+ // It moves no `preparationKey()` (v51): the check draws into targets
1409
+ // of its own and frees only those, so what prepared frames stand on is
1410
+ // untouched. An agent's every write is checked, and a base that moved
1411
+ // with each would throw every prepared frame away after it.
1412
+ const yielding = yieldPoster();
1413
+ if (yielding !== null) await yielding;
1414
+ checking += 1;
1415
+ try {
1416
+ const pass = preparing;
1417
+ if (pass !== null && pass.pace() === "idle") {
1418
+ pass.end("coverage");
1419
+ await pass.ended;
1420
+ }
1421
+ // What the check refuses, by what the stage shows: its picture must
1422
+ // be the Shot's, still, at one frame.
1423
+ const refusal =
1424
+ state.doc === null
1425
+ ? "no Project is on the stage"
1426
+ : state.exporting !== null
1427
+ ? "the stage is exporting"
1428
+ : preparing !== null
1429
+ ? "the stage is preparing to play, so its frame moves; the check measures a paused stage"
1430
+ : state.playing
1431
+ ? "the stage is playing, so its frame moves; the check measures a paused stage"
1432
+ : state.componentContext !== null
1433
+ ? "the stage shows a component on its own, not a Shot"
1434
+ : state.isolation !== null
1435
+ ? "the stage is isolating a layer, which fades the rest of the Shot"
1436
+ : state.materialPreview !== null
1437
+ ? "the stage is previewing a material, which hides the rest of the Shot"
1438
+ : previewingFont()
1439
+ ? "the stage is previewing a font on some text layers, so its picture is not the Shot's"
1440
+ : previewingMotion()
1441
+ ? "the stage is previewing a motion preset on some layers, so its picture is not the Shot's"
1442
+ : coverageProbe === null
1443
+ ? "no Shot is under the playhead"
1444
+ : null;
1445
+ if (refusal !== null) throw new Error(`the stage did not check which layers draw: ${refusal}`);
1446
+ return await coverageProbe!({ ...(request ?? {}), frame: lastFrame });
1447
+ } finally {
1448
+ checking -= 1;
1449
+ }
135
1450
  },
136
- loadDoc(doc) {
1451
+ loadDoc(doc, rev = null) {
1452
+ revOfDoc.set(doc, rev);
1453
+ // THE SAME DOCUMENT, NOW A SAVED REVISION (v35): the chrome's own save
1454
+ // landed, or a re-push of what is on stage. Nothing to re-render; the
1455
+ // revision is all that moved, and a picture already drawn from this
1456
+ // object IS that revision.
1457
+ if (doc === state.doc && doc === (deferred.doc ?? state.doc)) {
1458
+ if (rev !== state.docRev) {
1459
+ set({ docRev: rev });
1460
+ if (rendered !== null && presentedDoc === doc && rendered.rev !== rev) {
1461
+ rendered.rev = rev;
1462
+ notifyHealth();
1463
+ }
1464
+ }
1465
+ return;
1466
+ }
1467
+ // Mid-export: DEFERRED, never interleaved. A doc swap re-renders every
1468
+ // plane, and the capture loop would encode frame N's timestamp over
1469
+ // whatever the new document draws. The export finishes on the document
1470
+ // it started with; the push lands the moment it ends.
1471
+ if (state.exporting !== null) {
1472
+ // An edit, not an echo of the document the stage already has.
1473
+ if (doc !== (deferred.doc ?? state.doc)) yieldPoster();
1474
+ deferred.doc = doc;
1475
+ deferred.rev = rev;
1476
+ deferred.closed = false;
1477
+ return;
1478
+ }
1479
+ // The authored cadence is what every window is judged against, so it has
1480
+ // to land before the first bucket closes. During export this waits with
1481
+ // the document because the active capture still uses the old cadence.
1482
+ // It is the render scale's budget too.
1483
+ performanceTelemetry.setFrameRate(doc.fps);
1484
+ renderScale.setFrameRate(doc.fps);
137
1485
  // A DIFFERENT document opening, not an edit echo: the span from here to
138
1486
  // the next presented frame is the author's "how long until I see it".
139
1487
  // markPerf no-ops without ?perf=1. Set BEFORE the state write, because
140
1488
  // the commit this write triggers is the frame that settles it.
141
- if (state.doc?.id !== doc.id) markPerf("open→frame");
142
- set({ doc });
1489
+ if (state.doc?.id !== doc.id) {
1490
+ markPerf("open→frame");
1491
+ // A pass's frames and what the transport played were the other
1492
+ // Project's (v51). An edit of this one keeps both.
1493
+ preparing?.end(null);
1494
+ played = new Map();
1495
+ // So was the raster cache's pressure: left standing, it would cut
1496
+ // this Project's first rasters at a cheaper rung, then cut them again
1497
+ // a rung at a time as the relief came in. And so was the render scale
1498
+ // the last Project's GPU time settled on.
1499
+ resetRasterPressure();
1500
+ renderScale.reset();
1501
+ }
1502
+ // A doc swap can remove the definition the context renders; a context
1503
+ // pointing at nothing would leave the whole viewport blank with no way
1504
+ // out from the stage side. A CODE subject survives the swap: it is built
1505
+ // from a registry id, so no document can take it away.
1506
+ const ctx =
1507
+ state.componentContext !== null &&
1508
+ !("code" in state.componentContext) &&
1509
+ doc.components?.[state.componentContext.def] === undefined
1510
+ ? null
1511
+ : state.componentContext;
1512
+ const materialPreview = materialPreviewStillReal(doc, state.materialPreview?.layerId ?? null)
1513
+ ? state.materialPreview
1514
+ : null;
1515
+ // Every outstanding token belonged to the OLD document. Holding them
1516
+ // would let a stale publication match a presentation of the new one.
1517
+ performanceTelemetry.resetPublications();
1518
+ performanceTelemetry.setDebugDocument(doc);
1519
+ set({
1520
+ doc,
1521
+ docRev: rev,
1522
+ componentContext: ctx,
1523
+ splineEdit: null,
1524
+ materialPreview,
1525
+ sceneId: ctx === null ? (masterToSceneCached(doc, lastFrame)?.sceneId ?? null) : null,
1526
+ });
143
1527
  },
144
1528
  seek(frame) {
145
- // Integer frames over a rational fps is the engine's invariant, and this
146
- // is the boundary a float could sneak through: a chrome that computed a
147
- // frame from a pixel position would otherwise hand the studio clock a
148
- // fractional one and break byte-identity against the export.
149
- set({ frame: Math.round(frame) });
1529
+ // A scrub is the author's: a poster holding the stage steps aside, and
1530
+ // the frame lands when its framing ends. A re-push of the frame the
1531
+ // stage will return to anyway is not one.
1532
+ if (state.exporting !== null && Math.round(frame) !== deferred.frame) yieldPoster();
1533
+ // Nor is a re-push of the frame a running pass gives back (v51): the
1534
+ // chrome restates the playhead after every document push, an ambient
1535
+ // one included, and a queued Play's pass runs on across those.
1536
+ if (preparing !== null && Math.round(frame) === preparing.startFrame) return;
1537
+ // A seek ends a preparing pass's picture (v51): the pass gives the
1538
+ // stage back first, and a freeze still standing lifts on the frame this
1539
+ // lands on. A SCRUB NEVER LEAVES THE PICTURE FROZEN: a frame that moves
1540
+ // under a freeze waiting for its lift shortens its backstop, once.
1541
+ preparing?.end(null);
1542
+ if (state.passDrawing) set({ passDrawing: false });
1543
+ if (lift !== null && !lift.scrubbed && Math.round(frame) !== lastFrame) {
1544
+ clearTimeout(lift.deadline);
1545
+ lift = { ...lift, scrubbed: true, deadline: setTimeout(dropFreeze, SCRUB_LIFT_MS) };
1546
+ }
1547
+ publishFrame(frame);
1548
+ },
1549
+ preparePlayback(request) {
1550
+ const { frames, onProgress, signal, pace, deadlineMs } = request;
1551
+ preparing?.end(null);
1552
+ const answer = (result: ShotPreparePlaybackResult): Promise<ShotPreparePlaybackResult> => Promise.resolve(result);
1553
+ // UNDER EXPORT FRAMING a queued Play steps a poster aside and runs once
1554
+ // it has. Anything else holding the framing (an export, a still, a
1555
+ // capture), or a poster under an `"idle"` pace, is the chrome's to
1556
+ // wait out.
1557
+ if (state.exporting !== null) {
1558
+ const yielding = pace() === "play" ? yieldPoster() : null;
1559
+ return yielding === null
1560
+ ? answer({ completed: false, presented: 0, yielded: "export" })
1561
+ : yielding.then(() => bridge.preparePlayback(request));
1562
+ }
1563
+ const canvas = exportCanvas;
1564
+ const host = freezeHost;
1565
+ if (state.doc === null) return answer({ completed: false, presented: 0, refused: "no Project is on the stage" });
1566
+ if (state.componentContext !== null) {
1567
+ return answer({ completed: false, presented: 0, refused: "the stage shows a component on its own" });
1568
+ }
1569
+ if (canvas === null || host === null) {
1570
+ return answer({ completed: false, presented: 0, refused: "the stage has no picture to freeze" });
1571
+ }
1572
+ // What the stage is doing comes first: a live interaction (its pointer,
1573
+ // plane and picks read the scene graph a pass re-poses), a hidden page
1574
+ // (it presents nothing), a coverage check measuring the author's frame
1575
+ // (only an `"idle"` pace gives way to it), and a font preview, whose
1576
+ // frames are not the Project's.
1577
+ const early: ShotPreparePlaybackYield | null = interactionLive()
1578
+ ? "interaction"
1579
+ : typeof document !== "undefined" && document.visibilityState === "hidden"
1580
+ ? "hidden"
1581
+ : checking > 0 && pace() === "idle"
1582
+ ? "coverage"
1583
+ : previewingFont() || previewingMotion()
1584
+ ? "push"
1585
+ : null;
1586
+ if (early !== null) return answer({ completed: false, presented: 0, yielded: early });
1587
+ if (signal?.aborted === true) return answer({ completed: false, presented: 0 });
1588
+ return new Promise((resolve) => {
1589
+ const startFrame = lastFrame;
1590
+ const wasPlaying = state.playing;
1591
+ let presented = 0;
1592
+ let settled = false;
1593
+ /** Tears down what the pass is waiting on now: one publication's
1594
+ * presentation and its deadline. */
1595
+ let abandon: (() => void) | null = null;
1596
+ let ended: () => void = () => {};
1597
+ // The chrome aborts with `"input"` for author input under an idle
1598
+ // pace; any other abort is a cancel.
1599
+ const onAbort = (): void => restore(false, signal?.reason === "input" ? "input" : null);
1600
+ const onResize = (): void => restore(false, "resize");
1601
+ const onHidden = (): void => {
1602
+ if (document.visibilityState === "hidden") restore(false, "hidden");
1603
+ };
1604
+ const stop = (): void => {
1605
+ settled = true;
1606
+ abandon?.();
1607
+ abandon = null;
1608
+ signal?.removeEventListener("abort", onAbort);
1609
+ window.removeEventListener("resize", onResize);
1610
+ document.removeEventListener("visibilitychange", onHidden);
1611
+ if (preparing === self) preparing = null;
1612
+ // After the author's frame is published, in this task.
1613
+ queueMicrotask(ended);
1614
+ };
1615
+ /**
1616
+ * BACK TO THE AUTHOR'S STAGE: an `"idle"` pace at the end of the
1617
+ * list, a cancel, a yield, an export. The stage gets its own playing
1618
+ * flag back and the start frame under a fresh token, the furniture
1619
+ * follows the scene graph again, and a freeze lifts on the first
1620
+ * complete presentation of the frame the clock is on (`lift`): the
1621
+ * start frame, or the newer one a later seek lands on.
1622
+ *
1623
+ * PAUSED, NOT JUST PRESENTED. `playing` reaches the runtime a commit
1624
+ * after it is set here, so the restored frame's first draw is usually
1625
+ * still the playing one, and a playing draw does not wait on a video
1626
+ * seek: it showed the nearest picture the decoder held, and lifted
1627
+ * there the author saw a softer frame for about 150 ms, then the real
1628
+ * one. Mid-export the frame lands in `deferred.frame`, which the
1629
+ * export's end restores.
1630
+ */
1631
+ const restore = (completed: boolean, why: ShotPreparePlaybackYield | null): void => {
1632
+ if (settled) return;
1633
+ stop();
1634
+ set({ playing: wasPlaying, passDrawing: false });
1635
+ const token = prepareToken();
1636
+ armLift(token);
1637
+ publishPrepared(startFrame, token);
1638
+ resolve({ completed, presented, ...(why === null ? {} : { yielded: why }) });
1639
+ };
1640
+ /** Could not run at all: nothing of `frames` was drawn. */
1641
+ const refuse = (reason: string): void => {
1642
+ if (settled) return;
1643
+ stop();
1644
+ resolve({ completed: false, presented: 0, refused: reason });
1645
+ };
1646
+ const self = {
1647
+ end: (why: ShotPreparePlaybackYield | null): void => restore(false, why),
1648
+ pace,
1649
+ startFrame,
1650
+ ended: new Promise<void>((done) => {
1651
+ ended = done;
1652
+ }),
1653
+ };
1654
+ preparing = self;
1655
+ signal?.addEventListener("abort", onAbort, { once: true });
1656
+ window.addEventListener("resize", onResize);
1657
+ document.addEventListener("visibilitychange", onHidden);
1658
+ /** Wait for publication `token` to be presented IN THE PLAYING
1659
+ * PRESENTATION, or for `ms` to pass. A frame drawn paused prepared a
1660
+ * mount playback never uses (the warm-up's playing roles), and
1661
+ * `playing` reaches the runtime a commit after `begin` sets it, so
1662
+ * the pass's first frame is usually drawn paused first; that commit
1663
+ * draws it again, echoing the token.
1664
+ * At the deadline an external claim still open (a font, a texture,
1665
+ * a video seek) makes it `"timeout-pending"`. */
1666
+ const waitFor = (token: number, ms: number, done: (outcome: ShotPreparedOutcome) => void): void => {
1667
+ const settle = (outcome: ShotPreparedOutcome): void => {
1668
+ abandon?.();
1669
+ abandon = null;
1670
+ done(outcome);
1671
+ };
1672
+ const timer = setTimeout(() => settle(externallyPending() ? "timeout-pending" : "timeout"), ms);
1673
+ presentWaiters.set(token, (drawnPlaying) => {
1674
+ if (drawnPlaying) settle("presented");
1675
+ });
1676
+ abandon = () => {
1677
+ clearTimeout(timer);
1678
+ presentWaiters.delete(token);
1679
+ };
1680
+ };
1681
+ /** One frame: published under a fresh token (so a frame already on
1682
+ * screen is drawn again, in the playing presentation), waited for,
1683
+ * reported, then the next one after a hop. At the end of the list
1684
+ * the pace decides: hand over, or restore. */
1685
+ const step = (index: number): void => {
1686
+ if (settled) return;
1687
+ if (interactionLive()) {
1688
+ restore(false, "interaction");
1689
+ return;
1690
+ }
1691
+ if (index >= frames.length) {
1692
+ if (pace() === "play") handOver();
1693
+ else restore(true, null);
1694
+ return;
1695
+ }
1696
+ const token = prepareToken();
1697
+ const asked = deadlineMs?.(index);
1698
+ const ms =
1699
+ typeof asked === "number" && asked > 0 ? Math.min(asked, PREPARE_FRAME_DEADLINE_MS) : PREPARE_FRAME_DEADLINE_MS;
1700
+ waitFor(token, ms, (outcome) => {
1701
+ if (outcome === "presented") presented += 1;
1702
+ // NOT FROM INSIDE THE PRESENTATION: `present` runs in the
1703
+ // runtime's `onPresented` call stack, where neither the chrome's
1704
+ // store write nor the next publication may run. Reported even if
1705
+ // the pass ended meanwhile: drawn is drawn.
1706
+ queueMicrotask(() => {
1707
+ onProgress?.(index + 1, frames.length, outcome);
1708
+ if (!settled) hop(pace(), () => step(index + 1));
1709
+ });
1710
+ });
1711
+ publishPrepared(frames[index]!, token);
1712
+ };
1713
+ /**
1714
+ * THE HAND-OVER, at the end of a queued Play's list: the start frame
1715
+ * is presented again in the playing presentation, under a fresh token,
1716
+ * and the freeze lifts INSIDE that presentation, so the picture the
1717
+ * author sees is the one the transport starts on and never another
1718
+ * Shot's. The furniture follows the scene graph from this draw on, so
1719
+ * the lift shows outlines projected from it. At the 5 s backstop it
1720
+ * restores instead: no presentation of the start frame means no
1721
+ * hand-over, and the freeze waits for one by the lift's rules, which
1722
+ * the chrome's start of the transport opens to either presentation.
1723
+ */
1724
+ const handOver = (): void => {
1725
+ set({ passDrawing: false });
1726
+ const token = prepareToken();
1727
+ waitFor(token, PREPARE_FRAME_DEADLINE_MS, (outcome) => {
1728
+ if (outcome !== "presented") {
1729
+ restore(true, null);
1730
+ return;
1731
+ }
1732
+ stop();
1733
+ dropFreeze();
1734
+ resolve({ completed: true, presented, handedOver: true });
1735
+ });
1736
+ publishPrepared(startFrame, token);
1737
+ };
1738
+ /** THE PLAYING PRESENTATION, which is what playback draws with: the
1739
+ * warm-up's playing roles. The canvas density is the paused one
1740
+ * (`useStageDensity` holds it while `playing`), so the pass resizes
1741
+ * nothing. From the first publication on the furniture holds the
1742
+ * frozen frame. */
1743
+ const begin = (): void => {
1744
+ if (settled) return;
1745
+ if (interactionLive()) {
1746
+ restore(false, "interaction");
1747
+ return;
1748
+ }
1749
+ set({ playing: true, passDrawing: true });
1750
+ step(0);
1751
+ };
1752
+ // ── EVERY PASS DRAWS UNDER A FROZEN PICTURE ────────────────────────
1753
+ //
1754
+ // Nothing it draws may be seen: not the other frames, not the playing
1755
+ // presentation's own picture of this one (a video's nearest decoded
1756
+ // frame). A freeze an earlier pass left standing OF THIS FRAME is
1757
+ // adopted, its lift called off. Otherwise the frame on screen is
1758
+ // presented again and copied INSIDE that presentation
1759
+ // (the playback framebuffer is unpreserved, so a copy taken later is
1760
+ // blank), then laid over the canvas with the shield above every
1761
+ // overlay. A freeze of an older frame (a seek landed before its lift)
1762
+ // stands, lift and all, until the new copy replaces it.
1763
+ if (freeze !== null && state.held === startFrame) {
1764
+ cancelLift();
1765
+ begin();
1766
+ return;
1767
+ }
1768
+ const token = prepareToken();
1769
+ const timer = setTimeout(() => refuse("freeze timed out"), PREPARE_FRAME_DEADLINE_MS);
1770
+ abandon = () => {
1771
+ clearTimeout(timer);
1772
+ presentWaiters.delete(token);
1773
+ };
1774
+ presentWaiters.set(token, () => {
1775
+ abandon?.();
1776
+ abandon = null;
1777
+ let copy: Promise<ImageBitmap>;
1778
+ try {
1779
+ copy = createImageBitmap(canvas);
1780
+ } catch {
1781
+ refuse("the stage could not copy its picture");
1782
+ return;
1783
+ }
1784
+ void copy.then(
1785
+ (bitmap) => {
1786
+ if (settled) {
1787
+ bitmap.close();
1788
+ return;
1789
+ }
1790
+ if (interactionLive()) {
1791
+ bitmap.close();
1792
+ restore(false, "interaction");
1793
+ return;
1794
+ }
1795
+ const older = freeze;
1796
+ freeze = mountFreeze(host, canvas, bitmap, shieldPress);
1797
+ bitmap.close();
1798
+ older?.remove();
1799
+ cancelLift();
1800
+ set({ held: startFrame });
1801
+ begin();
1802
+ },
1803
+ () => refuse("the stage could not copy its picture"),
1804
+ );
1805
+ });
1806
+ publishPrepared(startFrame, token);
1807
+ });
1808
+ },
1809
+ preparationKey() {
1810
+ const canvas = exportCanvas;
1811
+ // The density the stage draws at, paused and playing (`stageDensity`,
1812
+ // StageApp's canvas `dpr`). A change while the transport runs or a
1813
+ // pass draws reads here at once, while the canvas waits for the Pause
1814
+ // edge: the base moves, so nothing drawn at the old density is credited
1815
+ // under the new one.
1816
+ const ratio = stageDensity();
1817
+ return {
1818
+ base: `${performance.timeOrigin}:${gpuEpoch}:${canvas?.clientWidth ?? 0}x${canvas?.clientHeight ?? 0}@${ratio}:${rasterScale}`,
1819
+ modules: `${moduleEpoch ?? ""}:${hotUpdates}`,
1820
+ };
1821
+ },
1822
+ takePlayed() {
1823
+ const out: { frame: number; document: Doc3D }[] = [];
1824
+ for (const [document, frames] of played) for (const frame of frames) out.push({ frame, document });
1825
+ played = new Map();
1826
+ return out;
1827
+ },
1828
+ setPlaying(playing) {
1829
+ // NOT deferred mid-export, unlike `seek` and `loadDoc`. Those two would
1830
+ // change what the capture loop draws; this changes only what the stage
1831
+ // does between draws, and an export cannot be running while the transport
1832
+ // is (`setPlaying` refuses on the chrome side). Writing it through keeps
1833
+ // the flag honest if the two ever overlap.
1834
+ const next = playing === true;
1835
+ // The transport never overlaps a preparing pass (v51): one the chrome
1836
+ // did not hand over gives the stage back first.
1837
+ preparing?.end(null);
1838
+ // A poster is not an export: the chrome takes no lock for it, so Play
1839
+ // can arrive mid-capture. Aborting it cancels the armed capture at once,
1840
+ // so no captured frame can be drawn under the playing flag.
1841
+ if (next) yieldPoster();
1842
+ // Opens or closes the sampling windows, and releases the rung on pause.
1843
+ // The render scale takes the transport's frames alone as samples and
1844
+ // deadlines: a preparing pass sets `playing` and never this.
1845
+ performanceTelemetry.setPlaying(next);
1846
+ renderScale.setPlaying(next);
1847
+ // RETRACTED ON STOP, and null rather than zero: no frames are expected of
1848
+ // a stopped stage, so the last window's rate is stale the moment playback
1849
+ // ends and 0 fps would read as a stall. Queued so it lands after any
1850
+ // window report already in flight, and outside the chrome's own set.
1851
+ if (!next) queueMicrotask(() => handlers?.onPerformance(null));
1852
+ // What the transport draws is counted from its start (`takePlayed`). A
1853
+ // freeze still standing may lift in either presentation from here, but
1854
+ // never at once: the canvas may still hold another Shot's picture.
1855
+ if (next && !transport) played = new Map();
1856
+ transport = next;
1857
+ if (next && lift !== null) lift.either = true;
1858
+ if (state.playing === next) return;
1859
+ set({ playing: next });
150
1860
  },
151
1861
  setSelection(ids) {
152
1862
  set({ selection: [...ids] });
153
1863
  },
1864
+ setPeerSelections(peers) {
1865
+ const next = sanitizePeerSelections(peers);
1866
+ // Identity-stable when both are empty: no peers is the common case.
1867
+ if (next.length === 0 && state.peerSelections.length === 0) return;
1868
+ set({ peerSelections: next });
1869
+ },
154
1870
  selectionQuads(pane = "camera") {
1871
+ // Under a frozen picture the feed holds that frame's quads (StageTools
1872
+ // projects nothing while a pass draws), so this answers for it.
155
1873
  return quadSource === null ? null : quadSource(pane);
156
1874
  },
157
1875
  setTool(tool) {
158
- set({ tool });
1876
+ // An unknown id (the retired "move", or any stale caller) arms Select.
1877
+ set({ tool: Object.hasOwn(TOOL_SPECS, tool) ? tool : "select" });
1878
+ },
1879
+ setWorkPlane(target) {
1880
+ set({ workPlane: sanitizeWorkPlane(target) });
1881
+ },
1882
+ setSplineBasis(splineBasis) {
1883
+ set({ splineBasis: splineBasis === "catmull-rom" ? "catmull-rom" : "bezier" });
1884
+ },
1885
+ setSplineEditContext(splineEdit) {
1886
+ set({
1887
+ splineEdit:
1888
+ splineEdit === null
1889
+ ? null
1890
+ : { layerId: splineEdit.layerId, selectedPointIds: [...splineEdit.selectedPointIds] },
1891
+ });
159
1892
  },
160
1893
  setSnapGuides(guides) {
161
1894
  // Identity-stable when both are empty, so the common case (no gesture
@@ -163,6 +1896,11 @@ export function createStageClient(): StageClient {
163
1896
  if (guides.length === 0 && state.guides.length === 0) return;
164
1897
  set({ guides: [...guides] });
165
1898
  },
1899
+ hoverLayer(layerId) {
1900
+ // NOT a `set()`: the whole point of the verb is that a chrome hover
1901
+ // costs one SVG leaf repaint, never a stage commit. See bridgeTypes v22.
1902
+ hoverSink?.(layerId);
1903
+ },
166
1904
  setView(view) {
167
1905
  set({ view });
168
1906
  },
@@ -172,6 +1910,9 @@ export function createStageClient(): StageClient {
172
1910
  set({ view });
173
1911
  return view;
174
1912
  },
1913
+ designPointAt(clientX, clientY) {
1914
+ return designPointer?.(clientX, clientY) ?? null;
1915
+ },
175
1916
  setStageLayout(layout) {
176
1917
  // Gaining a working pane is the compile-storm moment (SplitRenderer +
177
1918
  // clay mount); the span to the next presented frame measures it.
@@ -181,7 +1922,75 @@ export function createStageClient(): StageClient {
181
1922
  // anywhere — the stage does not get to assume its caller was careful.
182
1923
  set({ layout: { mode: layout.mode, splitRatio: clampSplitRatio(layout.splitRatio) } });
183
1924
  },
1925
+ setVideo(video) {
1926
+ // Gaining or losing the working pane is the compile-storm moment on this
1927
+ // side too: Video is drawn as the camera-only panes, so turning it OFF
1928
+ // over a split or a 3D-view layout mounts the same renderer the layout
1929
+ // push does. Marked with the same span for the same reason.
1930
+ if (!video && state.layout.mode !== "camera" && state.video) {
1931
+ markPerf("split→frame");
1932
+ }
1933
+ set({ video });
1934
+ },
1935
+ setReadOnly(on) {
1936
+ set({ readOnly: on === true });
1937
+ },
1938
+ setStoryboardPreview(on) {
1939
+ // No `split→frame` mark, unlike `setVideo` above: the board covers the
1940
+ // window, so gaining or losing the working pane behind it costs the
1941
+ // author nothing they are looking at.
1942
+ set({ storyboardPreview: on });
1943
+ },
1944
+ setStudioComments(comments) {
1945
+ const next = sanitizeStudioComments(comments);
1946
+ // Identity-stable when both are empty: no Studio comments is the common
1947
+ // case, and the chrome re-pushes on every attach.
1948
+ if (next === null && state.studioComments === null) return;
1949
+ set({ studioComments: next });
1950
+ },
1951
+ setSketchGuide(guide) {
1952
+ const next = sanitizeSketchGuide(guide);
1953
+ // Identity-stable when both are off: the chrome re-pushes on attach.
1954
+ if (next === null && state.sketchGuide === null) return;
1955
+ set({ sketchGuide: next });
1956
+ },
1957
+ setFontPreview(preview) {
1958
+ // NOT deferred mid-export: export framing draws the real document
1959
+ // whatever this holds (StageApp), and the preview shows again when the
1960
+ // export ends.
1961
+ const next = sanitizeFontPreview(preview);
1962
+ // Identity-stable when both are off: no preview is the common case.
1963
+ if (next === null && state.fontPreview === null) return;
1964
+ // A hover is the author's: a poster holding export framing steps aside,
1965
+ // and the preview shows once it has.
1966
+ if (next !== null) yieldPoster();
1967
+ const face = previewFaces.show(next);
1968
+ set({ fontPreview: next === null ? null : { ...next, face } });
1969
+ },
1970
+ setMotionPreview(preview) {
1971
+ // NOT deferred mid-export, the font preview's rule: export framing draws
1972
+ // the real document whatever this holds (StageApp).
1973
+ const next = sanitizeMotionPreview(preview);
1974
+ if (next === null && state.motionPreview === null) return;
1975
+ if (next !== null) yieldPoster();
1976
+ set({ motionPreview: next });
1977
+ },
1978
+ editText(layerId) {
1979
+ set({ textEdit: typeof layerId === "string" ? layerId : null });
1980
+ },
184
1981
  setIsolation(isolation) {
1982
+ // DEFERRED MID-EXPORT, like every other chrome push. The chrome sends
1983
+ // this after every document push, and it once wrote `frame`: landing it
1984
+ // during a capture moved the export framing off the armed frame, so that
1985
+ // frame never presented and the export timed out 20 s later. The anchor
1986
+ // below reads `lastFrame`, which the capture loop owns, so it still waits.
1987
+ if (state.exporting !== null) {
1988
+ // Rides every document push unchanged: only a real change is the
1989
+ // author's, and steps a poster aside.
1990
+ if (isolation !== (deferred.isolation?.layerId ?? state.isolation)) yieldPoster();
1991
+ deferred.isolation = { layerId: isolation, orbit: false };
1992
+ return;
1993
+ }
185
1994
  // Validated against the DOCUMENT here, not trusted: the chrome's id can
186
1995
  // be one edit behind the stage's doc, and an isolation naming a layer
187
1996
  // that no longer exists would leave the pane pointed at nothing with no
@@ -192,10 +2001,71 @@ export function createStageClient(): StageClient {
192
2001
  // perspective pose derived from somewhere else entirely, and the head-on
193
2002
  // entry view — the whole point of isolation — would only appear the first
194
2003
  // time.
195
- set({ isolation: isolation !== null && scene !== null ? isolation : null, isolationOrbit: false });
2004
+ const next = isolation !== null && scene !== null ? isolation : null;
2005
+ set({
2006
+ isolation: next,
2007
+ isolationOrbit: false,
2008
+ // A NEW ANCHOR ONLY FOR A NEW SUBTREE. This push rides every document
2009
+ // push unchanged, and re-anchoring on each one re-fitted the camera on
2010
+ // every edit: a drag that grew the subtree zoomed the pane out from
2011
+ // under the pointer that was dragging.
2012
+ ...(next === state.isolation ? {} : { isolationAnchor: next === null ? null : { frame: lastFrame } }),
2013
+ });
2014
+ },
2015
+ setMaterialPreview(target) {
2016
+ // Clamped AT THE BOUNDARY, the way `clampInset` is: a slot arrives as a
2017
+ // number from another document and a negative or fractional one would
2018
+ // otherwise reach the runtime as a key that matches nothing.
2019
+ const layerId = target === null ? null : target.layerId;
2020
+ const known = state.doc !== null && materialPreviewStillReal(state.doc, layerId);
2021
+ if (!known || target === null) {
2022
+ set({ materialPreview: null });
2023
+ return;
2024
+ }
2025
+ const slot =
2026
+ typeof target.slot === "number" && Number.isSafeInteger(target.slot) && target.slot >= 0
2027
+ ? target.slot
2028
+ : null;
2029
+ set({ materialPreview: { layerId: target.layerId, slot } });
196
2030
  },
197
2031
  setIsolationOrbit(taken) {
198
- set({ isolationOrbit: taken === true });
2032
+ // Riding a deferred isolation push, the orbit belongs to that push: it
2033
+ // is applied with it in `end()`, not to the isolation still on screen.
2034
+ if (state.exporting !== null && deferred.isolation !== null) {
2035
+ deferred.isolation.orbit = taken === true;
2036
+ return;
2037
+ }
2038
+ // FALSE IS HOME, and Home inside a subtree re-derives its frame from
2039
+ // where the playhead stands now: the frame is held still across edits,
2040
+ // so this is how the author re-fits after moving things out of it.
2041
+ set(
2042
+ taken === true || state.isolation === null
2043
+ ? { isolationOrbit: taken === true }
2044
+ : { isolationOrbit: false, isolationAnchor: { frame: lastFrame } },
2045
+ );
2046
+ },
2047
+ setComponentContext(ctx) {
2048
+ // THE ZOOM IS CLAMPED AT THE BOUNDARY, exactly as `setMaterialPreview`
2049
+ // clamps its slot and `clampInset` its sides: it divides the subject's
2050
+ // half-extents, so a zero, a negative or a NaN is an orthographic frustum
2051
+ // that projects the whole subject to nothing — with no error anywhere.
2052
+ // The chrome owns the RANGE; this owns only "a number the camera can use".
2053
+ const targetDoc = state.exporting !== null
2054
+ ? deferred.closed
2055
+ ? null
2056
+ : deferred.doc ?? state.doc
2057
+ : state.doc;
2058
+ const next = validComponentContext(ctx, targetDoc);
2059
+ if (state.exporting !== null) {
2060
+ // A poster never starts inside a context, so a context arriving is
2061
+ // the author entering one: the poster steps aside.
2062
+ if (next !== null) yieldPoster();
2063
+ // Export framing always renders scenes. Remember a chrome context push
2064
+ // for the single restore commit instead of interleaving it with capture.
2065
+ deferred.componentContext = next;
2066
+ return;
2067
+ }
2068
+ set({ componentContext: next });
199
2069
  },
200
2070
  setStageInset(inset) {
201
2071
  const next = clampInset(inset);
@@ -217,55 +2087,652 @@ export function createStageClient(): StageClient {
217
2087
  set({ workingView });
218
2088
  return workingView;
219
2089
  },
2090
+ setGridVisible(visible) {
2091
+ set({ grid: visible === true });
2092
+ },
2093
+ setEditorChrome(chrome) {
2094
+ // Clamped at the boundary like the shading: a mode this stage does not
2095
+ // know is the default.
2096
+ renderScale.setMode(isPlaybackResolution(chrome.playbackResolution) ? chrome.playbackResolution : "auto");
2097
+ const next = { rulers: chrome.rulers === true, smartGuides: chrome.smartGuides !== false };
2098
+ if (next.rulers === state.editorChrome.rulers && next.smartGuides === state.editorChrome.smartGuides) return;
2099
+ set({ editorChrome: next });
2100
+ },
2101
+ setMotionPaths(on) {
2102
+ if ((on === true) !== state.motionPaths) set({ motionPaths: on === true });
2103
+ },
220
2104
  setShadingMode(mode) {
221
2105
  // Clamped at the boundary like the split ratio: an unknown mode from a
222
2106
  // careless caller means the default clay, not a pane that draws nothing.
223
2107
  set({ shading: clampShading(mode) });
224
2108
  },
225
- setCameraFollowsView(on) {
226
- set({ cameraFollowsView: on === true });
2109
+ setPilot(on, text) {
2110
+ set({ pilot: on === true, pilotText: typeof text === "string" ? text : null });
2111
+ },
2112
+ setCameraGate(gate) {
2113
+ set({ cameraGate: gate ?? null });
227
2114
  },
228
2115
  closeDoc() {
229
- set({ doc: null, selection: [] });
2116
+ // Mid-export: deferred like loadDoc. Unmounting every plane under the
2117
+ // capture loop is the one thing worse than re-rendering under it.
2118
+ if (state.exporting !== null) {
2119
+ yieldPoster();
2120
+ deferred.closed = true;
2121
+ deferred.doc = null;
2122
+ deferred.componentContext = null;
2123
+ return;
2124
+ }
2125
+ // A pass draws the document that is leaving, and nothing prepared for
2126
+ // it stands for anything now (v51).
2127
+ preparing?.end(null);
2128
+ gpuStateReplaced();
2129
+ // The context names a definition in the doc that just left; a context
2130
+ // over no document would render nothing with no way out.
2131
+ performanceTelemetry.setDebugDocument(null);
2132
+ // A font preview belongs to the document's open picker, and leaves with it.
2133
+ previewFaces.show(null);
2134
+ set({
2135
+ doc: null,
2136
+ docRev: null,
2137
+ selection: [],
2138
+ componentContext: null,
2139
+ splineEdit: null,
2140
+ materialPreview: null,
2141
+ fontPreview: null,
2142
+ motionPreview: null,
2143
+ });
230
2144
  },
231
2145
  clearCaches() {
232
- // Called right after closeDoc. The chrome's two calls run synchronously
233
- // before the stage's doc-null commit can land, so the clear disposes
234
- // textures some planes still bind — safe, because no GL draw happens
235
- // between synchronous JS and that commit (draws ride commits), and the
236
- // unmount commit runs cleanups, never a draw of the old doc.
237
- clearRasterCache();
2146
+ // `closeShot` pairs this with `closeDoc`. During export that close is
2147
+ // deferred, so its cache clear must be deferred with it. Disposing live
2148
+ // raster or plane resources inside the capture loop can invalidate the
2149
+ // frame in flight and force replacement allocations on later frames.
2150
+ if (state.exporting !== null) {
2151
+ deferred.clearCaches = true;
2152
+ return;
2153
+ }
2154
+ clearStageCaches();
2155
+ },
2156
+ exportCapability() {
2157
+ return exporterOf().capability();
2158
+ },
2159
+ exportMovie(request) {
2160
+ // An export the author asked for waits only for a poster to step aside.
2161
+ // Otherwise it enters export framing in this task, before any await,
2162
+ // which the dialog's same-task document check relies on.
2163
+ const yielding = yieldPoster();
2164
+ return yielding === null
2165
+ ? exporterOf().exportMovie(request)
2166
+ : yielding.then(() => exporterOf().exportMovie(request));
2167
+ },
2168
+ requestNodePreviews(request) {
2169
+ // Never during an export, for `capturePoster`'s reason and one more: the
2170
+ // preview component is not even mounted in export framing, so this is a
2171
+ // belt on top of a structural guarantee rather than the only guard.
2172
+ // Never without a preview target either — a thumbnail needs the source
2173
+ // material channels of the surface being edited, and there is no such
2174
+ // surface when the workspace is closed.
2175
+ if (state.exporting !== null || state.materialPreview === null || nodePreviewSource === null) {
2176
+ return Promise.resolve(null);
2177
+ }
2178
+ // Clamped rather than refused. A request one node too long is a scroll
2179
+ // position, and a size outside the band is a device-pixel-ratio the
2180
+ // chrome could not have known about.
2181
+ const size = Math.max(32, Math.min(256, Math.round(request.size)));
2182
+ const nodeIds = [...new Set(request.nodeIds)].slice(0, MAX_NODE_PREVIEWS);
2183
+ if (nodeIds.length === 0) return Promise.resolve(null);
2184
+ return nodePreviewSource({ ...request, size, nodeIds });
2185
+ },
2186
+ async capturePoster(maxWidth) {
2187
+ // THE EXPORT'S OWN CAPTURE, never a read of the live canvas: the playback
2188
+ // framebuffer is unpreserved, and a late read can be valid-sized black.
2189
+ // The exporter flips into export framing, arms the in-draw snapshot at
2190
+ // the poster frame (the one the local CLI renders, `posterFrame`), and
2191
+ // restores the author's stage on every path.
2192
+ //
2193
+ // Only on an idle stage showing the film. A poster is decoration: it
2194
+ // never interrupts playback, an export, Play's preparing pass, a
2195
+ // component or material being edited, or a font being previewed (export
2196
+ // framing would replace that view with the film). And it never holds
2197
+ // the stage against the author: anything they start on it (Play, an
2198
+ // export or capture, a scrub, an edit, a press, a font preview) aborts it
2199
+ // (`yieldPoster`), and the chrome takes no lock for it. Null means no
2200
+ // new upload, so an existing poster remains and a project that never
2201
+ // had one keeps the gradient.
2202
+ const doc = state.doc;
2203
+ if (
2204
+ doc === null ||
2205
+ doc === posterFailedFor ||
2206
+ poster !== null ||
2207
+ state.exporting !== null ||
2208
+ state.playing ||
2209
+ preparing !== null ||
2210
+ state.componentContext !== null ||
2211
+ state.materialPreview !== null ||
2212
+ previewingFont() ||
2213
+ previewingMotion()
2214
+ ) {
2215
+ return null;
2216
+ }
2217
+ const width = Math.min(Math.max(1, Math.floor(maxWidth)), doc.size.width);
2218
+ const abort = new AbortController();
2219
+ const capture = exporterOf().captureStill({
2220
+ frame: posterFrame(docDurationFrames(doc)),
2221
+ width,
2222
+ height: Math.max(1, Math.round((doc.size.height * width) / doc.size.width)),
2223
+ type: "image/jpeg",
2224
+ quality: 0.85,
2225
+ signal: abort.signal,
2226
+ });
2227
+ const entry = { abort, settled: capture.then(() => undefined, () => undefined) };
2228
+ poster = entry;
2229
+ try {
2230
+ return await capture;
2231
+ } catch {
2232
+ // Yielded is not failed: the chrome asks again once the stage is idle.
2233
+ if (!abort.signal.aborted) posterFailedFor = doc;
2234
+ return null;
2235
+ } finally {
2236
+ if (poster === entry) poster = null;
2237
+ }
2238
+ },
2239
+ captureFrame({ format, ...still }) {
2240
+ const yielding = yieldPoster();
2241
+ return yielding === null
2242
+ ? exporterOf().captureStill({ ...still, type: `image/${format}` })
2243
+ : yielding.then(() => exporterOf().captureStill({ ...still, type: `image/${format}` }));
238
2244
  },
239
2245
  dispose() {
2246
+ preparing?.end(null);
2247
+ dropFreeze();
2248
+ hopChannel?.port1.close();
2249
+ hopChannel = null;
2250
+ hops.length = 0;
2251
+ previewFaces.show(null);
2252
+ cancelPendingCapture(new Error("the stage was disposed during capture"));
2253
+ registerExportCanvas(null);
2254
+ unsubscribeWindow();
2255
+ unsubscribePresented();
240
2256
  dispose?.();
241
2257
  dispose = null;
242
2258
  handlers = null;
2259
+ // A reminted client handshakes again in `connect`; a stale "they already
2260
+ // know it is true" would swallow that push.
2261
+ sentBusy = null;
2262
+ },
2263
+ };
2264
+
2265
+ /** The exporter's window onto this client: state for the numbers, the
2266
+ * registered area element for the canvas and the commit marker, and the
2267
+ * shared readiness latch for "the draw landed". `begin`/`end` are the only
2268
+ * writers of `exporting`, so the deferral in `loadDoc`/`seek` and this
2269
+ * block cannot disagree about when the capture loop owns the clock. */
2270
+ const exportHost: Parameters<typeof createShotStageExporter>[0] = {
2271
+ composition() {
2272
+ const doc = state.doc;
2273
+ return doc === null
2274
+ ? null
2275
+ : { width: doc.size.width, height: doc.size.height, fps: doc.fps };
2276
+ },
2277
+ scopeFrames(sceneId) {
2278
+ const doc = state.doc;
2279
+ if (doc === null) return null;
2280
+ if (sceneId === undefined) return docDurationFrames(doc);
2281
+ const slice = sliceShotToScene(doc, sceneId);
2282
+ // sliceShotToScene falls back to the whole document for an unknown id
2283
+ // (a component rendering nothing is worse than one rendering the shot);
2284
+ // an EXPORT of the wrong scope is the opposite trade, so refuse.
2285
+ if (slice === null || doc.scenesById[sceneId] === undefined) return null;
2286
+ return docDurationFrames(slice);
2287
+ },
2288
+ sceneStartFrames(sceneId) {
2289
+ const doc = state.doc;
2290
+ // A one-scene export starts on its only scene; the whole timeline's
2291
+ // master frames ARE its export frames.
2292
+ if (doc === null || sceneId !== undefined) return [0];
2293
+ return sceneWindows(doc).map((window) => window.start);
2294
+ },
2295
+ begin(sceneId) {
2296
+ if (state.exporting !== null) throw new Error("an export is already running");
2297
+ // A preparing pass ends FIRST, synchronously (`yielded: "export"`): it
2298
+ // restores the stage's playing flag and the author's frame while
2299
+ // nothing is exporting, so the frame recorded below is the author's and
2300
+ // not the pass's current one. A frozen picture comes down with it:
2301
+ // export framing redraws the canvas, and nothing may stand over it.
2302
+ preparing?.end("export");
2303
+ dropFreeze();
2304
+ // REFUSAL OVER CORRUPTION. A component whose module failed to load or
2305
+ // crashed draws nothing, and export cannot tell that apart from a layer
2306
+ // meant to be empty. The author has already seen the stage error; this is
2307
+ // the exporter declining to bake it into every frame.
2308
+ const refusal = exportRefusal();
2309
+ if (refusal !== null) throw refusal;
2310
+ deferred.frame = lastFrame;
2311
+ deferred.doc = null;
2312
+ deferred.componentContext = state.componentContext;
2313
+ deferred.closed = false;
2314
+ deferred.clearCaches = false;
2315
+ deferred.isolation = null;
2316
+ // An export always renders SCENES: a component context left set would
2317
+ // put the runtime's def mount and the export framing on the canvas at
2318
+ // once. Cleared rather than refused — the chrome's dialog is master-side
2319
+ // and the author asked for the shot.
2320
+ set({
2321
+ exporting: sceneId === undefined ? {} : { scene: sceneId },
2322
+ // Outside every valid timeline. The first capture arms BEFORE seek(0),
2323
+ // so frame 0 cannot be coalesced with begin's framing commit and miss
2324
+ // the only in-draw snapshot opportunity.
2325
+ frame: -1,
2326
+ // Belt and braces. The chrome already refuses to start an export while
2327
+ // the transport runs and refuses to start the transport mid-export, so
2328
+ // this should always be false already — but `playing` defers work a
2329
+ // capture must not have deferred, and the cost of asserting it here is
2330
+ // one boolean against a whole class of wrong exported frame.
2331
+ playing: false,
2332
+ componentContext: null,
2333
+ });
2334
+ },
2335
+ seek(frame) {
2336
+ // Through React: the commit stamps `data-export-frame`, which the
2337
+ // capture requires in the same task as the draw. The clock follows so
2338
+ // the stage's frame loops agree.
2339
+ set({ frame });
2340
+ clock.set(frame);
2341
+ },
2342
+ end() {
2343
+ cancelPendingCapture(new Error("the export ended before its frame was captured"));
2344
+ // Deferred chrome pushes land HERE, with the export flag and the frame
2345
+ // in one commit — the stage never paints an in-between state.
2346
+ const restoredFrame = deferred.frame ?? lastFrame;
2347
+ lastFrame = restoredFrame;
2348
+ const patch: Partial<StageClientState> = {
2349
+ exporting: null,
2350
+ frame: restoredFrame,
2351
+ };
2352
+ const clearCaches = deferred.clearCaches;
2353
+ try {
2354
+ const restoredDoc = deferred.closed ? null : deferred.doc ?? state.doc;
2355
+ patch.componentContext = validComponentContext(deferred.componentContext, restoredDoc);
2356
+ if (deferred.closed) {
2357
+ performanceTelemetry.setDebugDocument(null);
2358
+ patch.doc = null;
2359
+ patch.docRev = null;
2360
+ patch.selection = [];
2361
+ patch.splineEdit = null;
2362
+ patch.materialPreview = null;
2363
+ previewFaces.show(null);
2364
+ patch.fontPreview = null;
2365
+ patch.motionPreview = null;
2366
+ } else if (deferred.doc !== null) {
2367
+ // Another Project, as `loadDoc` resets for one.
2368
+ if (deferred.doc.id !== state.doc?.id) {
2369
+ resetRasterPressure();
2370
+ renderScale.reset();
2371
+ }
2372
+ performanceTelemetry.setFrameRate(deferred.doc.fps);
2373
+ renderScale.setFrameRate(deferred.doc.fps);
2374
+ performanceTelemetry.resetPublications();
2375
+ performanceTelemetry.setDebugDocument(deferred.doc);
2376
+ patch.doc = deferred.doc;
2377
+ patch.docRev = deferred.rev;
2378
+ patch.splineEdit = null;
2379
+ patch.materialPreview = materialPreviewStillReal(
2380
+ deferred.doc,
2381
+ state.materialPreview?.layerId ?? null,
2382
+ )
2383
+ ? state.materialPreview
2384
+ : null;
2385
+ }
2386
+ // The isolation push the export held back, validated against the
2387
+ // document that is about to be current, as `setIsolation` does.
2388
+ if (deferred.isolation !== null) {
2389
+ const layerId = deferred.isolation.layerId;
2390
+ patch.isolation =
2391
+ layerId !== null && restoredDoc !== null && sceneWithLayer(restoredDoc, layerId) !== null
2392
+ ? layerId
2393
+ : null;
2394
+ // What the live pair would have left: `setIsolation` resets the
2395
+ // orbit and `setIsolationOrbit` sets it, whatever is isolated. A
2396
+ // component context reads it too (`headOnOwnsPane`).
2397
+ patch.isolationOrbit = deferred.isolation.orbit;
2398
+ if (patch.isolation !== state.isolation) {
2399
+ patch.isolationAnchor = patch.isolation === null ? null : { frame: restoredFrame };
2400
+ }
2401
+ }
2402
+ patch.sceneId = sceneIdAt(restoredDoc, restoredFrame);
2403
+ } finally {
2404
+ // WHATEVER FAILED ABOVE, THE STAGE LEAVES EXPORT FRAMING. Stuck in it,
2405
+ // every chrome push stays deferred for the session and every later
2406
+ // export refuses with "an export is already running".
2407
+ deferred.doc = null;
2408
+ deferred.frame = null;
2409
+ deferred.componentContext = null;
2410
+ deferred.closed = false;
2411
+ deferred.clearCaches = false;
2412
+ deferred.isolation = null;
2413
+ set(patch);
2414
+ clock.set(restoredFrame);
2415
+ // Export framing dropped the resident scenes and remounted the placed
2416
+ // components: what prepared frames stood on is gone (v51). Said once
2417
+ // the stage is the author's again, a throw above included.
2418
+ gpuStateReplaced();
2419
+ }
2420
+ // With exporting cleared and the deferred document patch published, no
2421
+ // capture draw can race this release. The following React commit either
2422
+ // unmounts the old document or mounts the replacement from empty caches.
2423
+ if (clearCaches) clearStageCaches();
2424
+ },
2425
+ canvas() {
2426
+ return exportCanvas;
2427
+ },
2428
+ frameCommitted(frame) {
2429
+ return exportArea?.getAttribute("data-export-frame") === String(frame);
2430
+ },
2431
+ waitingOn() {
2432
+ return stageReadiness.describe();
2433
+ },
2434
+ armCapture(frame, size, snapshot) {
2435
+ if (state.exporting === null) {
2436
+ return Promise.reject(new Error("an exact framebuffer capture requires export framing"));
2437
+ }
2438
+ const refusal = exportRefusal(frame);
2439
+ if (refusal !== null) return Promise.reject(refusal);
2440
+ if (pendingCapture !== null) {
2441
+ return Promise.reject(new Error("another export frame capture is already armed"));
2442
+ }
2443
+ return new Promise<ImageBitmap>((resolve, reject) => {
2444
+ pendingCapture = {
2445
+ frame,
2446
+ width: size.width,
2447
+ height: size.height,
2448
+ snapshotting: false,
2449
+ snapshot,
2450
+ resolve,
2451
+ reject,
2452
+ };
2453
+ });
2454
+ },
2455
+ cancelCapture(error) {
2456
+ cancelPendingCapture(error);
243
2457
  },
244
2458
  };
245
2459
 
246
2460
  return {
247
2461
  bridge,
2462
+ clock,
2463
+ performanceTelemetry,
2464
+ renderScale,
2465
+ noteDrawn(info, document, epoch = null, drawn) {
2466
+ if (document === null) return;
2467
+ moduleEpoch = epoch;
2468
+ // A FRAME THE TRANSPORT DREW (`takePlayed`), in the playing
2469
+ // presentation: never a pass's (its tokens are negative), and none
2470
+ // while a component or a font preview stands in for the Project's
2471
+ // picture, which the chrome's keys do not describe.
2472
+ const token = info.presentationToken;
2473
+ const transported =
2474
+ transport &&
2475
+ drawn?.playing === true &&
2476
+ !(token !== undefined && token < 0) &&
2477
+ state.componentContext === null &&
2478
+ state.fontPreview === null &&
2479
+ state.motionPreview === null;
2480
+ // A PASS'S OWN FRAME is not the author's picture, so it is never what
2481
+ // `health()` says was drawn: an agent's check reads that as the
2482
+ // playhead's Shot drawn at that revision. The freeze, the restore and
2483
+ // the hand-over draw the author's frame with `passDrawing` clear, and
2484
+ // count; a late draw of a prepared frame is off the clock's frame.
2485
+ const passFrame = token !== undefined && token < 0 && (state.passDrawing || info.frame !== clock.get().frame);
2486
+ // Playing, the next frame of the picture already recorded moves only
2487
+ // the frame: no readiness query and no clock read per played frame,
2488
+ // once the latch is closed and while nothing counts what was played.
2489
+ if (
2490
+ state.playing &&
2491
+ !transported &&
2492
+ latch === subject &&
2493
+ rendered !== null &&
2494
+ presentedDoc === document &&
2495
+ rendered.epoch === epoch &&
2496
+ rendered.rev === (revOfDoc.get(document) ?? null)
2497
+ ) {
2498
+ if (!passFrame) rendered.frame = info.frame;
2499
+ return;
2500
+ }
2501
+ if (externallyPending()) return;
2502
+ // A MOTION PREVIEW'S FRAME is not the Project's picture either (v53): the
2503
+ // chrome is seeking through a preset's frames on a stamped copy, so
2504
+ // `health()` reads pending until it clears and the playhead is drawn.
2505
+ if (!passFrame && !previewingMotion()) notePresented(info, document, epoch);
2506
+ if (transported) played.set(document, (played.get(document) ?? new Set<number>()).add(info.frame));
2507
+ // THE LATCH (v51): this renderer presented the open subject whole.
2508
+ const drew = drawn === undefined ? (document === state.doc ? subject : null) : drawn.subject;
2509
+ if (latch !== subject && drew === subject) {
2510
+ latch = subject;
2511
+ queueBusy();
2512
+ }
2513
+ },
2514
+ noteHotUpdate(checked) {
2515
+ for (const [module, error] of checked) {
2516
+ if (error === null) hotErrors.delete(module);
2517
+ else hotErrors.set(module, error);
2518
+ }
2519
+ hotUpdates += 1;
2520
+ codeAt = wallNow();
2521
+ if (hotErrors.size > 0) refuseArmedCapture();
2522
+ notifyHealth();
2523
+ },
2524
+ present(info, document, drawnPlaying = false, drawnForExport = false) {
2525
+ // The preparing pass, first: it waits on exactly this publication.
2526
+ const waiter = info.presentationToken === undefined ? undefined : presentWaiters.get(info.presentationToken);
2527
+ waiter?.(drawnPlaying);
2528
+ // Then a frozen picture waiting for the author's frame (v51): the frame
2529
+ // the clock is on now, so a seek moves the lift to the newer frame;
2530
+ // drawn from the restore's publication on, not a late draw of a
2531
+ // prepared one; complete; and paused, or in either presentation once
2532
+ // the transport runs (see `restore` in `preparePlayback`).
2533
+ const waiting = lift;
2534
+ if (waiting !== null) {
2535
+ const token = info.presentationToken;
2536
+ const current = info.frame === clock.get().frame && !(token !== undefined && token < 0 && token > waiting.after);
2537
+ if (current && (!drawnPlaying || waiting.either) && !externallyPending()) dropFreeze();
2538
+ }
2539
+ const pending = pendingCapture;
2540
+ // The DOCUMENT drawn, too. The r3f root commits after the DOM one, so a
2541
+ // draw from the previous document can land at the armed frame with the
2542
+ // new frame marker already stamped: the still stage reuses one stage
2543
+ // for every Shot of a Project, and most thumbnails are frame 0. Export
2544
+ // framing defers `loadDoc`, so `state.doc` is the armed document.
2545
+ //
2546
+ // And the FRAMING drawn. Until the r3f root commits the export render,
2547
+ // the editor's tree is still mounted, and a frame it started behind a
2548
+ // compile can land at the armed frame with every live check above and
2549
+ // below already true. Its picture is the presentation lane's: raster
2550
+ // rungs, analytic shapes, the GPU Canvas2D store. So only a draw from
2551
+ // an export-framing render resolves a capture.
2552
+ if (
2553
+ pending === null ||
2554
+ state.exporting === null ||
2555
+ !drawnForExport ||
2556
+ info.frame !== pending.frame ||
2557
+ document !== state.doc
2558
+ ) {
2559
+ performanceTelemetry.present(info, document);
2560
+ return;
2561
+ }
2562
+ if (pending.snapshotting) {
2563
+ // The framebuffer has already been synchronously handed to the
2564
+ // snapshot implementation. Keep the capture record live until that
2565
+ // asynchronous snapshot settles so a context loss, fatal stage error,
2566
+ // or known render failure can still cancel the final export frame.
2567
+ performanceTelemetry.present(info, document);
2568
+ return;
2569
+ }
2570
+ const refusal = exportRefusal(pending.frame);
2571
+ if (refusal !== null) {
2572
+ pendingCapture = null;
2573
+ pending.reject(refusal);
2574
+ performanceTelemetry.present(info, document);
2575
+ return;
2576
+ }
2577
+ // ClockDriver already proved its local scope clear before publishing.
2578
+ // The module-global scope can also contain cooperative work owned above
2579
+ // ShotRuntime, so it is a second exact gate. The three labels
2580
+ // `externallyPending` excludes are internal handoff claims: draw/repaint
2581
+ // belong to the presentation now executing, while instance-map identity
2582
+ // is checked directly by StageApp before it calls `present` and releases
2583
+ // in the parent layout effect after this child draw.
2584
+ if (externallyPending()) {
2585
+ performanceTelemetry.present(info, document);
2586
+ return;
2587
+ }
2588
+ const canvas = exportCanvas;
2589
+ if (
2590
+ canvas === null ||
2591
+ canvas.width !== pending.width ||
2592
+ canvas.height !== pending.height ||
2593
+ exportArea?.getAttribute("data-export-frame") !== String(pending.frame)
2594
+ ) {
2595
+ // ResizeObserver and the r3f commit can land separately. Keep the arm
2596
+ // until a later matching presentation proves both framing and pixels.
2597
+ performanceTelemetry.present(info, document);
2598
+ return;
2599
+ }
2600
+ pending.snapshotting = true;
2601
+ try {
2602
+ // Invocation, not only resolution, stays in ShotRuntime's onPresented
2603
+ // call stack. createImageBitmap snapshots the unpreserved default
2604
+ // framebuffer before Chromium can present and retire it.
2605
+ void pending.snapshot(canvas).then(
2606
+ (bitmap) => {
2607
+ if (pendingCapture !== pending) {
2608
+ // Cancellation rejects the controller immediately, but the
2609
+ // browser may still finish a createImageBitmap already in
2610
+ // flight. It must not escape or remain live after refusal.
2611
+ bitmap.close();
2612
+ return;
2613
+ }
2614
+ pendingCapture = null;
2615
+ pending.resolve(bitmap);
2616
+ },
2617
+ (error) => {
2618
+ if (pendingCapture !== pending) return;
2619
+ pendingCapture = null;
2620
+ pending.reject(error);
2621
+ },
2622
+ );
2623
+ } catch (error) {
2624
+ if (pendingCapture === pending) {
2625
+ pendingCapture = null;
2626
+ pending.reject(error);
2627
+ }
2628
+ }
2629
+ // Snapshot INVOCATION comes first. Rich telemetry can fingerprint,
2630
+ // allocate a sample row, and notify diagnostic listeners; none of that
2631
+ // belongs between the GL draw and an unpreserved framebuffer read.
2632
+ performanceTelemetry.present(info, document);
2633
+ },
248
2634
  getState: () => state,
249
2635
  subscribe(listener) {
250
2636
  listeners.add(listener);
251
2637
  return () => listeners.delete(listener);
252
2638
  },
253
2639
  handlers: () => handlers,
2640
+ setInstanceErrors(errors) {
2641
+ instanceErrors = [...errors];
2642
+ refuseArmedCapture();
2643
+ notifyHealth();
2644
+ },
2645
+ setInstanceCrashes(errors) {
2646
+ instanceCrashes = [...errors];
2647
+ refuseArmedCapture();
2648
+ notifyHealth();
2649
+ },
2650
+ setFatalError(message) {
2651
+ fatalError = message.trim() === "" ? "unknown stage error" : message;
2652
+ refuseArmedCapture();
2653
+ notifyHealth();
2654
+ queueBusy();
2655
+ },
2656
+ setRenderErrors(errors, blocked = false) {
2657
+ renderErrors = [...errors];
2658
+ compileBlocked = blocked;
2659
+ refuseArmedCapture();
2660
+ notifyHealth();
2661
+ queueBusy();
2662
+ },
2663
+ setPieceFailures(failures) {
2664
+ pieceFailures = [...failures];
2665
+ // Before the chrome hears it: its handler can start other work.
2666
+ refuseArmedCapture();
2667
+ handlers?.onPieceFailures(pieceFailures);
2668
+ notifyHealth();
2669
+ },
2670
+ pieceFailures: () => pieceFailures,
2671
+ recordError,
2672
+ setHoverSink(sink) {
2673
+ hoverSink = sink;
2674
+ },
254
2675
  setQuadSource(source) {
255
2676
  quadSource = source;
256
2677
  },
2678
+ setNodePreviewSource(source) {
2679
+ nodePreviewSource = source;
2680
+ },
2681
+ setCoverageProbe(probe) {
2682
+ coverageProbe = probe;
2683
+ },
257
2684
  setFitter(fit) {
258
2685
  fitter = fit;
259
2686
  },
2687
+ setDesignPointer(source) {
2688
+ designPointer = source;
2689
+ },
260
2690
  setWorkingFitter(fit) {
261
2691
  workingFitter = fit;
262
2692
  },
2693
+ setExportArea(el) {
2694
+ exportArea = el;
2695
+ },
2696
+ setExportCanvas(canvas, isContextLost) {
2697
+ if (isContextLost === undefined) registerExportCanvas(canvas);
2698
+ else registerExportCanvas(canvas, isContextLost);
2699
+ },
263
2700
  setDisposer(fn) {
264
2701
  dispose = fn;
265
2702
  },
2703
+ yieldPoster() {
2704
+ return yieldPoster() !== null;
2705
+ },
2706
+ passDrawing: () => state.passDrawing,
2707
+ registerFreezeHost(el) {
2708
+ if (el === freezeHost) return;
2709
+ // The frozen picture lived in the old host and went with it.
2710
+ if (freezeHost !== null) {
2711
+ preparing?.end(null);
2712
+ dropFreeze();
2713
+ }
2714
+ freezeHost = el;
2715
+ },
2716
+ setInteractionProbe(probe) {
2717
+ interactionProbe = probe;
2718
+ },
2719
+ yieldToInteraction() {
2720
+ preparing?.end("interaction");
2721
+ },
2722
+ setRasterScale(scale) {
2723
+ rasterScale = scale;
2724
+ },
266
2725
  };
267
2726
  }
268
2727
 
2728
+ /** One fatal-stage path shared by the root boundary and its integration test.
2729
+ * The export refusal must be installed before chrome reporting, because the
2730
+ * handler can synchronously start other work in response to the error. */
2731
+ export function reportFatalStage(client: StageClient, message: string): void {
2732
+ client.setFatalError(message);
2733
+ client.handlers()?.onError(`stage crashed: ${message}`);
2734
+ }
2735
+
269
2736
  /** The scene holding a layer, or null. Exported for the isolation guard and for
270
2737
  * the stage's own scoping — "is this id still real" is the question both ask,
271
2738
  * and a second implementation of it is how the two drift. */
@@ -276,3 +2743,17 @@ export function sceneWithLayer(doc: Doc3D, layerId: string | null): Scene3D | nu
276
2743
  }
277
2744
  return null;
278
2745
  }
2746
+
2747
+ /** Material preview also supports a mesh inside an opened component
2748
+ * definition. Layer ids are document-global, so a linear container walk is a
2749
+ * complete lookup and avoids teaching the stage bridge about chrome context. */
2750
+ function materialPreviewStillReal(doc: Doc3D, layerId: string | null): layerId is string {
2751
+ if (layerId === null) return false;
2752
+ for (const scene of Object.values(doc.scenesById)) {
2753
+ if (scene.layersById[layerId]?.kind === "mesh") return true;
2754
+ }
2755
+ for (const def of Object.values(doc.components ?? {})) {
2756
+ if (def.layersById[layerId]?.kind === "mesh") return true;
2757
+ }
2758
+ return false;
2759
+ }