@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
@@ -20,39 +20,191 @@
20
20
  // GIF gets NO size figure at all — its size is dominated by palette and by how
21
21
  // much of the frame changes, neither of which the bits-per-pixel model knows.
22
22
  // It shows the frame count instead, which is true.
23
- import { useEffect, useState } from "react";
23
+ //
24
+ // ── WHEN AN AGENT ASKED FOR IT ──────────────────────────────────────────
25
+ //
26
+ // An export question's yes (state/agentQuestion.ts, `exportAsk`) opens this
27
+ // dialog with the agent's format, quality and Shot, and hosted with Save to
28
+ // Videos on (Only me, unless the person's remembered choice already saves).
29
+ // What the export then saves is reported back to the question: the Videos id
30
+ // and page, else the downloaded file's name. A studio server render is
31
+ // reported by the question dialog when its job ends (AgentQuestionDialog).
32
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
33
+ import type { ReactNode } from "react";
24
34
  import {
25
35
  EXPORT_QUALITIES,
26
36
  docDurationFrames,
27
37
  estimateMp4Size,
38
+ exportFileBase,
28
39
  exportQuality,
29
40
  formatSizeEstimate,
30
41
  formatTimeEstimate,
42
+ masterAudioClips,
31
43
  predecessorTransitionOmitted,
32
44
  sceneWindows,
45
+ creditsText,
46
+ creditsWithin,
47
+ sliceShotToScene,
48
+ soundCredits,
33
49
  } from "@dailies-studio/shot/doc";
34
50
  import type { ExportFormat, ExportQualityId, RenderStats } from "@dailies-studio/shot/doc";
51
+ import { audioPeakWarning } from "@dailies-studio/engine";
35
52
  import { Dialog, SegmentedControl } from "@dailies-studio/ui";
36
- import { ApiError, cancelRender, renderState, startRender } from "../../api.js";
53
+ import { CAPABILITY_PROBE_TIMED_OUT, gifFrameStride } from "@dailies-studio/shot/tier1";
54
+ import type { Tier1Capability } from "@dailies-studio/shot/tier1";
55
+ import { ApiError, renderState, saveVideo, startRender } from "../../api.js";
56
+ import type { AgentQuestionVideo, VideoVisibility } from "../../api.js";
57
+ import type { AgentExportAsk } from "../../state/agentQuestion.js";
58
+ import { dailiesHost } from "../../hostApi.js";
59
+ import { reconcileRender } from "../../state/renderEvents.js";
37
60
  import type { RenderJob } from "../../api.js";
61
+ import { mixShotAudio } from "../../audio/offlineMix.js";
38
62
  import { useShotEditor, useStore } from "../../state/context.js";
63
+ import { defClockFrames, playbackWindow } from "../../state/types.js";
64
+ import type { ShotEditorState } from "../../state/types.js";
65
+ import { exportBlocker } from "../../state/workspace.js";
66
+ import { raceStage } from "../../state/stageWatchdog.js";
67
+ import { reportError } from "../../state/errorReports.js";
68
+ import { exportLockCount } from "./ExportScrim.js";
39
69
 
40
- /** "the whole timeline", as a select value. Not a scene id, and cannot be one. */
70
+ /** "the whole Project", as a select value. Not a scene id, and cannot be one. */
41
71
  const WHOLE = "";
72
+ /** "the work area", as a select value. Scene ids are `scn_` ids, never this. */
73
+ const WORK_AREA = "work-area";
74
+
75
+ /** The Project context's work area as the transport plays it (clamped by
76
+ * `playbackWindow`, never the raw field), or null when there is none, when
77
+ * a definition or an entered Shot owns the clock, or when it spans the film. */
78
+ function projectWorkArea(s: ShotEditorState): { start: number; frames: number } | null {
79
+ if (s.doc === null || s.workArea === null || defClockFrames(s) !== null || s.enteredScene !== null) return null;
80
+ const area = playbackWindow(s);
81
+ return area.frames < docDurationFrames(s.doc) ? area : null;
82
+ }
83
+
84
+ /** How many times the dialog asks the stage for its encoders when a probe
85
+ * runs past the stage's own deadline. A late probe is not a "no": under load
86
+ * (the stage capturing a poster on a software GL) the first one has run out
87
+ * of time where the next answered, and hosted has no server to fall back to. */
88
+ const PROBE_ATTEMPTS = 3;
89
+ /** Why a probe's await ended when the stage it asked was replaced: its answer
90
+ * died with the old stage's realm, so the new stage is asked. */
91
+ const PROBE_STAGE_RELOADED = "the stage reloaded while its encoders were checked";
92
+
93
+ /** The export refusals by name, for the hosted error report: the stage's
94
+ * (bridgeClient.ts `exportRefusal`, in its order) and this dialog's own. A
95
+ * refusal carries only its sentence, so the name is read off it, and one no
96
+ * row matches reports with `refusal: null`. Names that must never drift
97
+ * would need a `refusal` field on the stage's Error. */
98
+ const REFUSALS: ReadonlyArray<readonly [string, RegExp]> = [
99
+ ["stage_crashed", /stage crashed/],
100
+ ["piece_failed", /piece\(s\)/],
101
+ ["context_lost", /context (is|was) lost/],
102
+ ["render_failed", /render failure/],
103
+ ["module_failed", /module\(s\) failed/],
104
+ ["component_crashed", /crashed while rendering/],
105
+ ["dom_output", /DOM output/],
106
+ ["project_changed", /project changed/],
107
+ ];
108
+
109
+ /** How an in-browser export ENDED. Its progress is not here: that lives in the
110
+ * store's export lock, so the scrim over the stage, the header badge and this
111
+ * row all read one number and closing the dialog loses none of it. */
112
+ type BrowserJob = { phase: "done"; filename: string } | { phase: "cancelled" };
113
+
114
+ /** Hosted only: whether a finished export is also saved to the workspace's
115
+ * Videos, and who its page opens for (worker videos.ts). */
116
+ type ShareChoice = "off" | VideoVisibility;
117
+ const SHARE_CHOICES: readonly ShareChoice[] = ["off", "private", "workspace", "link"];
118
+ /** The last choice, per browser. A preference, so storage that throws (a
119
+ * private window, blocked site data) only means the default. */
120
+ const SHARE_KEY = "dailies.export.share";
121
+ function readShare(): ShareChoice {
122
+ try {
123
+ const saved = localStorage.getItem(SHARE_KEY);
124
+ return SHARE_CHOICES.find((c) => c === saved) ?? "off";
125
+ } catch {
126
+ return "off";
127
+ }
128
+ }
129
+ function writeShare(choice: ShareChoice): void {
130
+ try {
131
+ localStorage.setItem(SHARE_KEY, choice);
132
+ } catch {
133
+ // Remembering is a convenience; the export itself is unaffected.
134
+ }
135
+ }
136
+ const SHARE_HINTS: Record<ShareChoice, string> = {
137
+ off: "The file goes to your downloads only.",
138
+ private: "Also saved to Videos. Only you can open its page. You can change this later in Videos.",
139
+ workspace: "Also saved to Videos. Members of this workspace can open its page. You can change this later in Videos.",
140
+ link: "Also saved to Videos. Anyone with the link can watch it. You can change this later in Videos.",
141
+ };
142
+
143
+ /** The save to Videos that follows a hosted download. Apart from the job:
144
+ * the file is already in the downloads, so a failed save is not a failed
145
+ * export. */
146
+ type Shared = { phase: "saving" } | { phase: "saved"; url: string } | { phase: "failed"; message: string };
42
147
 
43
- export function ExportDialog(props: { onClose(): void }) {
148
+ /** An anchor download, not showSaveFilePicker: no permission prompt, works in
149
+ * every browser, and the object URL is revoked after the click has consumed
150
+ * it (the same helper the motion editor ships). */
151
+ function download(blob: Blob, filename: string): void {
152
+ const url = URL.createObjectURL(blob);
153
+ const a = document.createElement("a");
154
+ a.href = url;
155
+ a.download = filename;
156
+ document.body.appendChild(a);
157
+ a.click();
158
+ a.remove();
159
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
160
+ }
161
+
162
+ export function ExportDialog(props: {
163
+ onClose(): void;
164
+ /** Test seam for holding the asynchronous mix across a document edit. */
165
+ mixAudio?: typeof mixShotAudio;
166
+ }) {
44
167
  const store = useStore();
45
168
  const doc = useShotEditor((s) => s.doc);
46
169
  const name = useShotEditor((s) => s.name);
47
170
  const currentScene = useShotEditor((s) => s.sceneId);
48
171
  const job = useShotEditor((s) => s.renderJob);
172
+ // ONE answer to "is an export running", whichever path started it.
173
+ const exportLock = useShotEditor((s) => s.exportLock);
174
+ // The agent's export ask for THIS Project, if the person said yes to one.
175
+ const exportAsk = useSyncExternalStore(store.agentQuestion.subscribe, () => store.agentQuestion.getState().exportAsk);
176
+ const asked = exportAsk !== null && exportAsk.projectId === doc?.id ? exportAsk : null;
177
+ // Its choices, read once as the dialog opens: after that they are the
178
+ // person's to change.
179
+ const [askedAtOpen] = useState(() => asked?.export ?? null);
180
+ const hosted = dailiesHost() !== undefined;
49
181
 
50
- const [format, setFormat] = useState<ExportFormat>("mp4");
51
- const [quality, setQuality] = useState<ExportQualityId>("social");
52
- // Defaults to the WHOLE timeline even though a scene is open, because
182
+ const [format, setFormat] = useState<ExportFormat>(askedAtOpen?.format ?? "mp4");
183
+ const [quality, setQuality] = useState<ExportQualityId>(askedAtOpen?.quality ?? "social");
184
+ // Defaults to the WHOLE Project even though a scene is open, because
53
185
  // "export" without qualification means the deliverable. Choosing one scene is
54
- // deliberate, and the select says which one.
55
- const [scope, setScope] = useState<string>(WHOLE);
186
+ // deliberate, and the select says which one. A work area the author drew is
187
+ // that deliberate choice already made, so it is the default when there is one.
188
+ // An agent's ask is the choice made for them: its Shot, else the whole
189
+ // Project it promised the agent, never a work area drawn earlier.
190
+ const [scopeChoice, setScope] = useState<string>(() => {
191
+ if (askedAtOpen !== null) {
192
+ const scene = askedAtOpen.scene;
193
+ return scene !== undefined && store.getState().doc?.scenesById[scene] !== undefined ? scene : WHOLE;
194
+ }
195
+ return projectWorkArea(store.getState()) === null ? WHOLE : WORK_AREA;
196
+ });
197
+ // Primitive selectors: a fresh object per read would never settle.
198
+ const areaStart = useShotEditor((s) => projectWorkArea(s)?.start ?? null);
199
+ const areaFrames = useShotEditor((s) => projectWorkArea(s)?.frames ?? null);
200
+ const area = areaStart === null || areaFrames === null ? null : { start: areaStart, frames: areaFrames };
201
+ // A work area cleared while the dialog is open falls back to the whole Project.
202
+ const scope = scopeChoice === WORK_AREA && area === null ? WHOLE : scopeChoice;
203
+ const sceneId = scope === WHOLE || scope === WORK_AREA ? undefined : scope;
204
+ const range = scope === WORK_AREA ? area : null;
205
+ /** Why no export can start here (a component, material or code workspace
206
+ * owns the stage), or null. A run already going is not refused by it. */
207
+ const blocked = useShotEditor(exportBlocker);
56
208
  const [stats, setStats] = useState<RenderStats>({ runs: [] });
57
209
  const [error, setError] = useState<string | null>(null);
58
210
  // ON by default: the document's clips are part of the deliverable, and an
@@ -60,18 +212,149 @@ export function ExportDialog(props: { onClose(): void }) {
60
212
  // wrong artifact this lane refuses to produce. Unticking it is deliberate,
61
213
  // and the row says how many clips it is dropping.
62
214
  const [withAudio, setWithAudio] = useState(true);
215
+ // BROWSER by default, MP4 and GIF alike: the author's own machine renders
216
+ // near-instantly and costs the studio nothing (the Earth Studio model).
217
+ // Locally the server stays the answer for a browser that cannot encode the
218
+ // format, said in place. Hosted there is no server to offer.
219
+ const [dest, setDest] = useState<"browser" | "server">("browser");
220
+ // The hosted editor has no Tier-2 renderer, so the server is never offered
221
+ // (`hosted`, above).
222
+ /** What THIS browser can encode, probed through the stage. Null while the
223
+ * probe runs (the "checking" note); "unavailable" when the stage is not
224
+ * connected; "unanswered" when every probe ran out of time. */
225
+ const [cap, setCap] = useState<Tier1Capability | "unavailable" | "unanswered" | null>(null);
226
+ /** Which probe is running, from 1, for the checking note. */
227
+ const [probeAttempt, setProbeAttempt] = useState(1);
228
+ /** Bumped by "Check again", which runs the probe anew. */
229
+ const [probeRun, setProbeRun] = useState(0);
230
+ const [browserJob, setBrowserJob] = useState<BrowserJob | null>(null);
231
+ // An agent's ask saves to Videos: the remembered choice when it saves, else
232
+ // Only me. The remembered choice itself is left as it was.
233
+ const [share, setShare] = useState<ShareChoice>(() => {
234
+ const saved = readShare();
235
+ return askedAtOpen !== null && hosted && saved === "off" ? "private" : saved;
236
+ });
237
+ const [shared, setShared] = useState<Shared | null>(null);
238
+ const [audioNotice, setAudioNotice] = useState<string | null>(null);
239
+ /** The credits' Copy said so. */
240
+ const [copied, setCopied] = useState(false);
241
+ /** Set when the last MP4 came from a software encoder at a bitrate, whose
242
+ * opening frames start soft. Only the finished export knows which encoder
243
+ * ran (the stage decides it when the encode starts), so it is said after. */
244
+ const [encoderNotice, setEncoderNotice] = useState<string | null>(null);
245
+ /** Ties the Audio checkbox to the hint that says why it is ticked or not. */
246
+ const audioHintId = useId();
247
+ // An export outlives this dialog, so a failure that lands after Close has to
248
+ // reach the shell's error strip instead of a setState nobody is mounted for.
249
+ const mounted = useRef(true);
250
+ useEffect(() => () => { mounted.current = false; }, []);
251
+
252
+ // ── FOCUS FOLLOWS THE ONE ACTION THAT IS LIVE ──────────────────────────
253
+ // Export sits in the footer and Cancel export sits in the Rendering card,
254
+ // so starting or ending a run UNMOUNTS the focused button and drops focus
255
+ // on <body>. The Dialog's Escape and Tab trap listen on the dialog itself,
256
+ // so from <body> the keyboard would have no way back in. When focus has
257
+ // fallen out like that, it moves to the action that replaced the one that
258
+ // went, or to the dialog itself when that action is disabled. A focus
259
+ // anywhere else (the author tabbed to Quality while a run from another tab
260
+ // arrived) is left alone: that is not ours to steal.
261
+ const running = exportLock !== null;
262
+ const exportButton = useRef<HTMLButtonElement>(null);
263
+ const cancelButton = useRef<HTMLButtonElement>(null);
264
+ const body = useRef<HTMLDivElement>(null);
265
+ useEffect(() => {
266
+ const at = document.activeElement;
267
+ if (at !== null && at !== document.body) return;
268
+ const next = running ? cancelButton.current : exportButton.current;
269
+ (next !== null && !next.disabled ? next : body.current?.closest<HTMLElement>('[role="dialog"]'))?.focus();
270
+ }, [running]);
271
+
272
+ // ── A SERVER RUN RETIRES THE LAST FAILURE ──────────────────────────────
273
+ // The outcome slot ranks a failure above the last job, so a 409 (or any
274
+ // refused POST) left its Failed card standing after a server run that
275
+ // started elsewhere had gone on to finish: the dialog said "Failed" over a
276
+ // render that had just written its file. A server lock is a NEW run, so
277
+ // whatever went wrong before it is history. Keyed on `where`, not on the
278
+ // lock object, which is fresh on every progress tick. A browser lock does
279
+ // not clear it: runBrowser clears its own error before it takes the lock,
280
+ // and a browser failure lands WHILE that lock is still held.
281
+ const lockWhere = exportLock?.where;
282
+ useEffect(() => {
283
+ if (lockWhere === "server") setError(null);
284
+ }, [lockWhere]);
285
+
286
+ // Capability probe on open: a dead stage degrades to the server path with a
287
+ // reason rather than a dead button. A probe the stage timed out is asked
288
+ // again, up to PROBE_ATTEMPTS, and each await races the stage watchdog: a
289
+ // stage that reloads mid-probe takes its answer with it, so the new stage
290
+ // is asked rather than the dialog waiting forever.
291
+ useEffect(() => {
292
+ if (store.stage() === null) {
293
+ setCap("unavailable");
294
+ return;
295
+ }
296
+ const closed = new AbortController();
297
+ void (async () => {
298
+ for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt++) {
299
+ const bridge = store.stage();
300
+ if (bridge === null) break;
301
+ setProbeAttempt(attempt);
302
+ let answer: Tier1Capability;
303
+ try {
304
+ answer = await raceStage(bridge.exportCapability(), {
305
+ stage: () => store.stage(),
306
+ bridgeAtStart: bridge,
307
+ signal: closed.signal,
308
+ reloaded: PROBE_STAGE_RELOADED,
309
+ });
310
+ } catch (e) {
311
+ if (closed.signal.aborted) return;
312
+ if (e instanceof Error && e.message === PROBE_STAGE_RELOADED) continue;
313
+ setCap("unavailable");
314
+ return;
315
+ }
316
+ if (closed.signal.aborted) return;
317
+ if (!(answer.mp4.ok === false && answer.mp4.reason === CAPABILITY_PROBE_TIMED_OUT)) {
318
+ setCap(answer);
319
+ return;
320
+ }
321
+ }
322
+ if (!closed.signal.aborted) setCap(store.stage() === null ? "unavailable" : "unanswered");
323
+ })();
324
+ return () => closed.abort();
325
+ }, [store, probeRun]);
326
+ const checkAgain = (): void => {
327
+ setCap(null);
328
+ setProbeAttempt(1);
329
+ setProbeRun((run) => run + 1);
330
+ };
331
+
332
+ // The browser path needs a connected stage and an encoder for the format:
333
+ // H.264 for an MP4, the GIF kind for a GIF (absent on a stage older than
334
+ // it). When the probe says no, the choice STEPS ASIDE to the server rather
335
+ // than holding a dead Export button — the note beside the row says why, and
336
+ // re-choosing "This browser" snaps back with the same note still on screen.
337
+ // Hosted there is nowhere to step to: the note says why and Export waits.
338
+ const encoder = cap === null || typeof cap === "string" ? undefined : format === "gif" ? cap.gif : cap.mp4;
339
+ const browserReady = encoder?.ok === true;
340
+ useEffect(() => {
341
+ if (!hosted && dest === "browser" && cap !== null && !browserReady) setDest("server");
342
+ }, [hosted, dest, cap, browserReady]);
63
343
 
64
344
  // The dialog can open MID-RENDER (the author closed it and came back), so it
65
345
  // asks rather than assuming idle — and picks up this project's measured speed
66
- // in the same round trip.
346
+ // in the same round trip. Hosted has no server render to ask about.
67
347
  useEffect(() => {
68
- if (name === null) return;
348
+ if (name === null || hosted) return;
69
349
  let live = true;
350
+ const asked = store.getState().renderJob;
70
351
  void renderState(name)
71
352
  .then((state) => {
72
353
  if (!live) return;
73
354
  setStats(state.stats);
74
- if (state.job !== null) store.setRenderJob(state.job);
355
+ // The live job, or the last result after a reload (the route reports
356
+ // this shot's renders only), or a render the server has lost.
357
+ reconcileRender(store, name, state, asked);
75
358
  })
76
359
  .catch(() => {
77
360
  // An estimate is a nicety. A server without the render lane still lets
@@ -80,20 +363,56 @@ export function ExportDialog(props: { onClose(): void }) {
80
363
  return () => {
81
364
  live = false;
82
365
  };
83
- }, [name, store, job?.phase]);
366
+ }, [name, store, job?.phase, hosted]);
84
367
 
85
368
  if (doc === null || name === null) return null;
86
369
 
87
370
  const windows = sceneWindows(doc);
88
371
  const frames =
89
- scope === WHOLE
90
- ? docDurationFrames(doc)
91
- : (windows.find((w) => w.id === scope)?.end ?? 0) - (windows.find((w) => w.id === scope)?.start ?? 0);
372
+ range !== null
373
+ ? range.frames
374
+ : sceneId === undefined
375
+ ? docDurationFrames(doc)
376
+ : (windows.find((w) => w.id === sceneId)?.end ?? 0) - (windows.find((w) => w.id === sceneId)?.start ?? 0);
377
+ /** Whether master frames [from, from + length) reach into the range. */
378
+ const inRange = (from: number, length: number): boolean =>
379
+ range === null || (from < range.start + range.frames && from + length > range.start);
92
380
  const megapixels = (doc.size.width * doc.size.height) / 1_000_000;
93
381
  // GIF has no audio stream at all, so the checkbox is not a choice there and
94
382
  // the size model must not budget for one either.
95
- const clipCount = Object.keys(doc.audio).length;
96
- const audible = format === "mp4" && withAudio && clipCount > 0;
383
+ // BOTH HOMES. `doc.audio` is only the bed since schema v13, so counting it
384
+ // alone would budget no audio bitrate — and, worse, drop `audible` to false
385
+ // on a document whose whole soundtrack is shot-owned, silently exporting a
386
+ // video with no sound and a checkbox that looked ticked.
387
+ // THE SCOPE'S clips, not the document's. Counting the whole timeline while
388
+ // the range is one scene promised sound for a scene that owns none: the mix
389
+ // then returned null, no track was added, and nothing said why.
390
+ // Only the UNMUTED clips, as the mixers count them: a muted clip adds no
391
+ // sound, and counting it promised a track the file would not carry.
392
+ const scoped = sceneId === undefined ? doc : (sliceShotToScene(doc, sceneId) ?? doc);
393
+ const rangeClips = masterAudioClips(scoped).filter((m) => inRange(m.clip.frame, m.clip.durationFrames));
394
+ const clipCount = rangeClips.filter((m) => m.clip.muted !== true).length;
395
+ const effectiveDest = hosted ? "browser" : dest;
396
+ // A browser whose encoder cannot take audio ships the video silent, so the
397
+ // row says so before the export rather than after the download.
398
+ const browserAudioReason =
399
+ effectiveDest === "browser" && cap !== null && typeof cap !== "string" && !cap.audio.ok
400
+ ? cap.audio.reason
401
+ : undefined;
402
+ const audible = format === "mp4" && withAudio && clipCount > 0 && browserAudioReason === undefined;
403
+ // THE CREDITS THIS FILE OWES (v49): the Openverse sounds it plays, in the
404
+ // export's own scope. Only when the file carries sound: a video exported
405
+ // silent plays no sound and owes no credit.
406
+ const credits = audible ? soundCredits(doc, range ?? sceneId) : { required: [], courtesy: [] };
407
+ const creditBlock = creditsText(credits);
408
+ /** "Project" or "Shot", whichever the range exports from. */
409
+ const unit = range !== null ? "work area" : sceneId === undefined ? "Project" : "Shot";
410
+ // The same "off" rule the server render reads (`shotMotionBlurAt`).
411
+ const blurred = scoped.scenes.some((id) => {
412
+ const mb = scoped.scenesById[id]?.effects?.motionBlur;
413
+ const w = windows.find((x) => x.id === id);
414
+ return mb !== undefined && mb.samples > 1 && (w === undefined || inRange(w.start, w.end - w.start));
415
+ });
97
416
  const size = estimateMp4Size({
98
417
  width: doc.size.width,
99
418
  height: doc.size.height,
@@ -102,59 +421,265 @@ export function ExportDialog(props: { onClose(): void }) {
102
421
  quality: exportQuality(quality),
103
422
  audio: audible,
104
423
  });
105
- const running = job?.phase === "running";
106
424
 
107
- const submit = (): void => {
425
+ /** Tell the agent's question where the export went. A refused report says
426
+ * so in the shell's strip: the file itself is fine. */
427
+ const report = (ask: AgentExportAsk | null, video: AgentQuestionVideo, what: string): void => {
428
+ if (ask === null) return;
429
+ store.agentQuestion.reportVideo(ask, name, video).catch((e: unknown) => {
430
+ store.reportError(`${what} was exported, but telling the agent failed: ${e instanceof Error ? e.message : String(e)}`);
431
+ });
432
+ };
433
+
434
+ const runServer = async (): Promise<void> => {
435
+ // Export is hidden while the lock is held; this is the refusal behind it,
436
+ // for a keyboard submit that raced the re-render.
437
+ if (exportLock !== null) return;
438
+ const reportTo = asked;
108
439
  setError(null);
109
- void startRender(name, {
110
- format,
111
- quality,
112
- ...(scope === WHOLE ? {} : { sceneId: scope }),
113
- // Only ever sent as `false`. Absent means the server's default, which is
114
- // to mux whatever `doc.audio` carries, and saying so explicitly would
115
- // make this chrome the thing that decides it.
116
- ...(withAudio ? {} : { audio: false }),
117
- })
118
- .then((started) => store.setRenderJob(started))
119
- .catch((e: unknown) => {
120
- // A 409 carries the running job, which is more useful than the message.
121
- if (e instanceof ApiError && e.conflict) setError(e.message);
122
- else setError(e instanceof Error ? e.message : String(e));
440
+ try {
441
+ // The server renders the saved document, so what is on screen is saved
442
+ // first: an edit inside the autosave debounce, or behind a failed save,
443
+ // would otherwise export an older revision. `flush` throws on the latter.
444
+ await store.flush();
445
+ // A browser export may have taken the lock while the save ran.
446
+ if (store.getState().exportLock !== null) return;
447
+ const started = await startRender(name, {
448
+ format,
449
+ quality,
450
+ ...(sceneId === undefined ? {} : { sceneId }),
451
+ ...(range === null ? {} : { frameRange: { start: range.start, end: range.start + range.frames } }),
452
+ // Only ever sent as `false`. Absent means the server's default, which is
453
+ // to mux whatever `doc.audio` carries, and saying so explicitly would
454
+ // make this chrome the thing that decides it.
455
+ ...(withAudio ? {} : { audio: false }),
123
456
  });
457
+ store.setRenderJob(started);
458
+ // Reported when the job ends, by the question dialog, which outlives
459
+ // this one.
460
+ if (reportTo !== null) store.agentQuestion.exportOnServer(started.id);
461
+ } catch (e) {
462
+ // A 409 means a server run is already going. When the bus has already
463
+ // delivered it, the Rendering card says so and outlives this refusal;
464
+ // writing the error then would resurface it the moment that run ends.
465
+ if (e instanceof ApiError && e.conflict) {
466
+ if (store.getState().exportLock?.where !== "server") setError(e.message);
467
+ } else setError(e instanceof Error ? e.message : String(e));
468
+ }
469
+ };
470
+
471
+ const runBrowser = async (): Promise<void> => {
472
+ const reportTo = asked;
473
+ const bridgeAtStart = store.stage();
474
+ if (bridgeAtStart === null) {
475
+ setError("the stage is not connected: reload and try again");
476
+ return;
477
+ }
478
+ // THE LOCK, which stops the transport, refuses Space, veils the stage and
479
+ // survives this dialog unmounting. It throws when one is already running,
480
+ // which is the same refusal hiding the Export button expresses. Said by
481
+ // nothing more: the lock it lost to already shows as the Rendering card,
482
+ // Cancel included, and a refusal kept in `error` would outlive that run
483
+ // and paint a Failed card over the run's real outcome.
484
+ let signal: AbortSignal;
485
+ try {
486
+ signal = store.beginBrowserExport();
487
+ } catch {
488
+ return;
489
+ }
490
+ setError(null);
491
+ setAudioNotice(null);
492
+ setEncoderNotice(null);
493
+ setBrowserJob(null);
494
+ setShared(null);
495
+ // The export promise lives in the IFRAME's realm, and dies unsettled with
496
+ // it if the stage reloads mid-export, so every await races the bridge's
497
+ // replacement and a local abort (`raceStage`).
498
+ const watch = { stage: () => store.stage(), bridgeAtStart, signal, reloaded: "the stage reloaded: export aborted" };
499
+ try {
500
+ // The mix renders BEFORE the capture starts — a missing or undecodable
501
+ // audio asset must reject the export up front, not after two minutes
502
+ // of frames (the motion lane's posture, refusal over corruption).
503
+ const audio = audible
504
+ ? await raceStage((props.mixAudio ?? mixShotAudio)(doc, range ?? sceneId), watch)
505
+ : null;
506
+ // Audio mixing is asynchronous, while document edits can still arrive
507
+ // from another tab or an agent behind the export scrim. The stage does
508
+ // not freeze its document until exportMovie enters `begin`, below. Refuse
509
+ // if the immutable document object changed during the mix, otherwise the
510
+ // accepted file could pair audio from the old revision with video from
511
+ // the new one. The check and exportMovie call are in the same task, and
512
+ // exportMovie enters stage export framing synchronously before its first
513
+ // await, so no document push can interleave between them.
514
+ if (store.getState().doc !== doc) {
515
+ throw new Error(
516
+ "the project changed while its audio was prepared; export refused so sound and video cannot come from different revisions",
517
+ );
518
+ }
519
+ // The peak is measured on the FLOAT mix, which is the last moment it has
520
+ // its true amplitude — the encoder flattens everything past full scale.
521
+ // Same sentence the server render prints, from the same engine advisory,
522
+ // and on screen while the frames render rather than after the download.
523
+ if (audio !== null) setAudioNotice(audioPeakWarning(audio.peak));
524
+ const q = exportQuality(quality);
525
+ const result = await raceStage(
526
+ bridgeAtStart.exportMovie({
527
+ format,
528
+ ...(sceneId === undefined ? {} : { sceneId }),
529
+ ...(range === null ? {} : { window: range }),
530
+ // The same quality table the server path reads, expressed the way
531
+ // WebCodecs takes it: the CRF as the fixed QP the stage encodes at
532
+ // where a hardware encoder honours one (bridge v49), and bits/s from
533
+ // bits-per-pixel at this document's dimensions and rate for every
534
+ // other encoder. A GIF's size is its palette's, as on the server,
535
+ // which gives it no quality number either.
536
+ ...(format === "gif"
537
+ ? {}
538
+ : {
539
+ quantizer: q.crf,
540
+ videoBitrate: Math.round(
541
+ q.bitsPerPixel * doc.size.width * doc.size.height * (doc.fps.num / doc.fps.den),
542
+ ),
543
+ }),
544
+ ...(audio !== null ? { audio } : {}),
545
+ signal,
546
+ onProgress: (done, total) => store.exportProgress(done, total),
547
+ }),
548
+ watch,
549
+ );
550
+ // Named by the server render's rule, so both backends write one name.
551
+ const filename = `${exportFileBase(doc, sceneId, range !== null)}${result.extension}`;
552
+ download(result.blob, filename);
553
+ setBrowserJob({ phase: "done", filename });
554
+ if (hosted && share !== "off") {
555
+ setShared({ phase: "saving" });
556
+ // Not awaited: the lock (and the stage's veil) ends with the download,
557
+ // and the upload needs neither.
558
+ saveVideo(result.blob, {
559
+ project: name,
560
+ title: doc.title ?? name,
561
+ filename,
562
+ visibility: share,
563
+ // Within what a saved video keeps: the dialog below still shows and
564
+ // copies the whole block.
565
+ ...(creditBlock === "" ? {} : { credits: creditsWithin(credits) }),
566
+ durationMs: Math.round((frames * 1000 * doc.fps.den) / doc.fps.num),
567
+ width: doc.size.width,
568
+ height: doc.size.height,
569
+ }).then(
570
+ (saved) => {
571
+ if (mounted.current) setShared({ phase: "saved", url: saved.url });
572
+ report(reportTo, { where: "videos", id: saved.id, url: saved.url }, filename);
573
+ },
574
+ (e: unknown) => {
575
+ const message = e instanceof Error ? e.message : String(e);
576
+ if (mounted.current) setShared({ phase: "failed", message });
577
+ else store.reportError(`${filename} downloaded, but saving it to Videos failed: ${message}`);
578
+ // Only the person's downloads hold it: the agent hears that.
579
+ report(reportTo, { where: "downloads", filename }, filename);
580
+ },
581
+ );
582
+ } else report(reportTo, { where: "downloads", filename }, filename);
583
+ // A software encoder at a bitrate (OpenH264 in Chrome) starts every film
584
+ // at a coarse QP and walks it down, so fine grain and a faint tint arrive
585
+ // late (docs/exporting.md, "Shot Tier 1"). Said beside the download
586
+ // rather than refused, because it is still the best encode this browser
587
+ // can make.
588
+ if (result.encoding?.rateControl === "bitrate" && result.encoding.encoder === "software") {
589
+ setEncoderNotice(
590
+ "This browser encoded the video in software, so its first second may look softer than the rest." +
591
+ (hosted ? "" : " The studio server render does not have this problem."),
592
+ );
593
+ }
594
+ if (result.audioSkippedReason !== undefined) {
595
+ // The movie downloaded but shipped silent — say why instead of
596
+ // letting the author discover it on playback. Overwrites the peak
597
+ // advisory, which has nothing to warn about in a file with no sound.
598
+ setAudioNotice(
599
+ `The video saved without sound: ${result.audioSkippedReason}.` +
600
+ (hosted ? "" : " The studio server render can include it."),
601
+ );
602
+ }
603
+ } catch (e) {
604
+ const message = e instanceof Error ? e.message : String(e);
605
+ if (message === "export cancelled") {
606
+ // The author pressed Cancel; it must not read as a failure.
607
+ setBrowserJob({ phase: "cancelled" });
608
+ return;
609
+ }
610
+ // Hosted, the admin panel hears of it too. A piece's refusal names the
611
+ // layer by its label, which is the author's words, so it goes without
612
+ // its sentence: the piece's own report (`stage.piece_failure`) carries
613
+ // its id and what it threw.
614
+ const refusal = /export refused/.test(message) ? (REFUSALS.find(([, said]) => said.test(message))?.[0] ?? null) : undefined;
615
+ reportError({
616
+ code: refusal === undefined ? "export.failed" : "export.refused",
617
+ ...(refusal === "piece_failed" ? {} : { message }),
618
+ projectId: doc.id,
619
+ ...(sceneId === undefined ? {} : { shot: sceneId }),
620
+ detail: { format, ...(refusal === undefined ? {} : { refusal }) },
621
+ });
622
+ if (mounted.current) {
623
+ setBrowserJob(null);
624
+ setError(message);
625
+ } else {
626
+ // Closed mid-export and it then failed: the shell's strip is the only
627
+ // surface left, and losing the reason entirely is the alternative.
628
+ store.reportError(message);
629
+ }
630
+ } finally {
631
+ store.endBrowserExport();
632
+ }
633
+ };
634
+
635
+ const submit = (): void => {
636
+ if (blocked !== null) return;
637
+ if (effectiveDest === "browser") void runBrowser();
638
+ else void runServer();
124
639
  };
125
640
 
126
641
  return (
127
642
  <Dialog
128
643
  open
129
644
  title="Export"
130
- // The shared dialog is a hard 320px, which is right for a confirm and
131
- // too narrow for three labelled rows plus a size RANGE — the estimate
132
- // was overflowing and clipping mid-range, and "911 KB–2" reads as a
133
- // finished number rather than a truncated one.
134
- className="shot-export-dialog"
645
+ // Paper's 648px form dialog. The shared 320px card was too narrow for a
646
+ // label column plus a size RANGE: the estimate clipped mid-range, and
647
+ // "911 KB–2" reads as a finished number rather than a truncated one.
648
+ size="wide"
135
649
  onClose={props.onClose}
136
- actions={
137
- <>
138
- {running ? (
139
- <button type="button" className="btn" onClick={() => void cancelRender(name)}>
140
- Cancel render
141
- </button>
142
- ) : (
143
- <button type="button" className="btn is-primary" onClick={submit}>
144
- Export
145
- </button>
146
- )}
147
- <button type="button" className="btn" onClick={props.onClose}>
148
- Close
149
- </button>
150
- </>
151
- }
650
+ // No Export while one runs, and no second Cancel here: the Rendering
651
+ // card below carries the one Cancel export, beside the count it stops.
652
+ // The title bar's X is the one way to close, so a run leaves no footer.
653
+ {...(running
654
+ ? {}
655
+ : {
656
+ actions: (
657
+ <button
658
+ ref={exportButton}
659
+ type="button"
660
+ className="btn is-primary"
661
+ // Held while the probe is still running on the browser path.
662
+ // Locally a known-bad capability never reaches here: the
663
+ // snap-back effect has already moved the destination to the
664
+ // server. Hosted it holds, with the note saying why. Held too
665
+ // while the stage shows one component or material, not the film.
666
+ disabled={blocked !== null || (effectiveDest === "browser" && (cap === null || !browserReady))}
667
+ onClick={submit}
668
+ >
669
+ Export
670
+ </button>
671
+ ),
672
+ })}
152
673
  >
153
- <div className="export-dialog" data-testid="export-dialog">
154
- <label className="export-row">
155
- <span>Format</span>
674
+ <div ref={body} className="export-dialog" data-testid="export-dialog">
675
+ {/* A <div>, not a <label>, around a segmented control: a label
676
+ forwards a click on its text to its FIRST labelable descendant, so
677
+ clicking "Format" chose MP4. The control names itself. */}
678
+ <div className="export-row">
679
+ <span className="export-label">Format</span>
156
680
  <SegmentedControl
157
681
  ariaLabel="Export format"
682
+ className="is-accent is-lg export-switch"
158
683
  value={format}
159
684
  options={[
160
685
  { value: "mp4", label: "MP4" },
@@ -162,12 +687,101 @@ export function ExportDialog(props: { onClose(): void }) {
162
687
  ]}
163
688
  onChange={(next) => setFormat(next as ExportFormat)}
164
689
  />
165
- </label>
690
+ </div>
691
+
692
+ {/* Hosted has no server render at all, so there is no choice to show. */}
693
+ {hosted ? null : (
694
+ <div className="export-row">
695
+ <span className="export-label">Render on</span>
696
+ <SegmentedControl
697
+ ariaLabel="Render destination"
698
+ className="is-accent is-lg export-switch is-destination"
699
+ value={dest}
700
+ options={[
701
+ { value: "browser", label: "This browser" },
702
+ { value: "server", label: "Studio server" },
703
+ ]}
704
+ onChange={(next) => setDest(next as "browser" | "server")}
705
+ />
706
+ </div>
707
+ )}
708
+
709
+ {cap === null ? (
710
+ <p className="export-note" data-testid="export-capability-checking">
711
+ {probeAttempt === 1
712
+ ? "Checking this browser's encoder…"
713
+ : `The encoder check ran out of time, so it is checking again (${probeAttempt} of ${PROBE_ATTEMPTS})…`}
714
+ </p>
715
+ ) : null}
716
+ {cap === "unavailable" ? (
717
+ <p className="export-note" data-testid="export-capability-note">
718
+ The browser export renders through the stage, which is not
719
+ connected.{" "}
720
+ {hosted ? "Reload the editor to reconnect it." : "The studio server render still works."}
721
+ </p>
722
+ ) : null}
723
+ {cap === "unanswered" ? (
724
+ <p className="export-note" data-testid="export-capability-note">
725
+ This browser&apos;s encoder did not answer in {PROBE_ATTEMPTS} checks.{" "}
726
+ <button type="button" className="link-button" onClick={checkAgain}>
727
+ Check again
728
+ </button>
729
+ {hosted ? "" : " The studio server render still works."}
730
+ </p>
731
+ ) : null}
732
+ {cap !== null && typeof cap !== "string" && !browserReady ? (
733
+ <p className="export-note" data-testid="export-capability-note">
734
+ This browser can&apos;t encode the {format === "gif" ? "GIF" : "MP4"}:{" "}
735
+ {encoder?.reason ?? "this stage predates the in-browser GIF encoder."}
736
+ {hosted ? "" : " The studio server render still works."}
737
+ </p>
738
+ ) : null}
739
+ {/* Why Export is held: the dialog stays open over a running export
740
+ in any workspace, so a run that ends here must not offer a start
741
+ that would capture the wrong stage. */}
742
+ {blocked !== null && !running ? (
743
+ <p className="export-note" data-testid="export-blocked-note">
744
+ {blocked}
745
+ </p>
746
+ ) : null}
747
+
748
+ {asked !== null ? (
749
+ <p className="export-note" data-testid="export-agent-note">
750
+ The agent asked for this export, and is told where the video is saved.
751
+ </p>
752
+ ) : null}
753
+
754
+ {/* Hosted only: the local studio has no workspace to share into. */}
755
+ {hosted ? (
756
+ <>
757
+ <div className="export-row">
758
+ <span className="export-label">Share</span>
759
+ <SegmentedControl
760
+ ariaLabel="Save to Videos"
761
+ className="is-accent is-lg export-switch is-share"
762
+ value={share}
763
+ options={[
764
+ { value: "off", label: "Don't save" },
765
+ { value: "private", label: "Only me" },
766
+ { value: "workspace", label: "Workspace" },
767
+ { value: "link", label: "Anyone with the link" },
768
+ ]}
769
+ onChange={(next) => {
770
+ setShare(next as ShareChoice);
771
+ writeShare(next as ShareChoice);
772
+ }}
773
+ />
774
+ </div>
775
+ <p className="export-note" data-testid="export-share-note">
776
+ {SHARE_HINTS[share]}
777
+ </p>
778
+ </>
779
+ ) : null}
166
780
 
167
781
  <label className="export-row">
168
- <span>Quality</span>
782
+ <span className="export-label">Quality</span>
169
783
  <select
170
- className="select"
784
+ className="select is-field export-select"
171
785
  aria-label="Export quality"
172
786
  value={quality}
173
787
  onChange={(e) => setQuality(e.target.value as ExportQualityId)}
@@ -181,14 +795,15 @@ export function ExportDialog(props: { onClose(): void }) {
181
795
  </label>
182
796
 
183
797
  <label className="export-row">
184
- <span>Range</span>
798
+ <span className="export-label">Range</span>
185
799
  <select
186
- className="select"
800
+ className="select is-field export-select"
187
801
  aria-label="Export range"
188
802
  value={scope}
189
803
  onChange={(e) => setScope(e.target.value)}
190
804
  >
191
- <option value={WHOLE}>Whole timeline</option>
805
+ <option value={WHOLE}>Whole Project</option>
806
+ {area === null ? null : <option value={WORK_AREA}>Work area</option>}
192
807
  {doc.scenes.map((id) => (
193
808
  <option key={id} value={id}>
194
809
  {doc.scenesById[id]?.name ?? id}
@@ -202,83 +817,291 @@ export function ExportDialog(props: { onClose(): void }) {
202
817
  render does not carry it. Warned HERE, client-side off (doc, scope),
203
818
  because the dialog already holds both and this costs the author
204
819
  nothing — the alternative is finding out after the render. */}
205
- {scope !== WHOLE && predecessorTransitionOmitted(doc, scope) ? (
820
+ {sceneId !== undefined && predecessorTransitionOmitted(doc, sceneId) ? (
206
821
  <p className="export-note" data-testid="export-transition-note">
207
822
  The transition into this shot plays from the shot before it, so a
208
- single-shot render starts on a cut. Render the whole timeline to
823
+ single-shot render starts on a cut. Render the whole Project to
209
824
  include it.
210
825
  </p>
211
826
  ) : null}
827
+ {/* The stage previews without blur, and so does its export. Said, not
828
+ switched: the author may be fine dropping it. */}
829
+ {blurred && effectiveDest === "browser" ? (
830
+ <p className="export-note" data-testid="export-blur-note">
831
+ Motion blur is not rendered in a browser export.
832
+ {hosted ? "" : " The studio server render includes it."}
833
+ </p>
834
+ ) : null}
212
835
 
213
836
  <label className="export-row">
214
- <span>Audio</span>
837
+ <span className="export-label">Audio</span>
215
838
  <span className="export-audio" data-testid="export-audio">
839
+ {/* `checked` is what the FILE will carry, not the preference:
840
+ with no unmuted clips, as a GIF, or from a browser that cannot
841
+ encode audio, there is no audio track, and a ticked box over
842
+ "no audio clips in this Shot" claimed one. The preference
843
+ (`withAudio`) is kept, so picking MP4 back or a scope with
844
+ clips restores the author's choice. */}
216
845
  <input
846
+ className="checkbox"
217
847
  type="checkbox"
218
848
  aria-label="Include audio"
219
- checked={withAudio}
220
- disabled={format === "gif" || clipCount === 0}
849
+ aria-describedby={audioHintId}
850
+ checked={audible}
851
+ disabled={format === "gif" || clipCount === 0 || browserAudioReason !== undefined}
221
852
  onChange={(e) => setWithAudio(e.currentTarget.checked)}
222
853
  />
223
- <span>
854
+ <span className="export-audio-hint" id={audioHintId}>
224
855
  {clipCount === 0
225
- ? "no clips in this scene"
856
+ ? rangeClips.length === 0
857
+ ? `no audio clips in this ${unit}`
858
+ : `every audio clip in this ${unit} is muted`
226
859
  : format === "gif"
227
- ? `${clipCount} clip${clipCount === 1 ? "" : "s"} — GIF has no audio track`
228
- : withAudio
229
- ? `${clipCount} clip${clipCount === 1 ? "" : "s"} included`
230
- : `${clipCount} clip${clipCount === 1 ? "" : "s"} will be dropped`}
860
+ ? `${clipCount} clip${clipCount === 1 ? "" : "s"}, but GIF has no audio track`
861
+ : browserAudioReason !== undefined
862
+ ? `this browser cannot encode audio: ${browserAudioReason}.${hosted ? "" : " The studio server render includes it."}`
863
+ : withAudio
864
+ ? `${clipCount} clip${clipCount === 1 ? "" : "s"} included`
865
+ : `${clipCount} clip${clipCount === 1 ? "" : "s"} will be dropped`}
231
866
  </span>
232
867
  </span>
233
868
  </label>
234
869
 
870
+ {creditBlock === "" ? null : (
871
+ <div className="export-credits" data-testid="export-credits">
872
+ <div className="export-row">
873
+ <span className="export-label">Credits</span>
874
+ <button
875
+ type="button"
876
+ className="btn is-outline"
877
+ data-testid="export-credits-copy"
878
+ onClick={() => {
879
+ void navigator.clipboard?.writeText(creditBlock).then(
880
+ () => {
881
+ if (mounted.current) setCopied(true);
882
+ },
883
+ () => {},
884
+ );
885
+ }}
886
+ >
887
+ {copied ? "Copied" : "Copy credits"}
888
+ </button>
889
+ </div>
890
+ <p className="export-note" data-testid="export-credits-note">
891
+ {credits.required.length > 0
892
+ ? "Required by CC BY: put these lines in the video's description or end card."
893
+ : "No credit is required. These lines thank the people whose sounds you used."}
894
+ </p>
895
+ <pre className="export-credits-text" data-testid="export-credits-text">
896
+ {creditBlock}
897
+ </pre>
898
+ </div>
899
+ )}
900
+
235
901
  <dl className="export-estimate" data-testid="export-estimate">
236
902
  <dt>Frames</dt>
237
- <dd>{frames}</dd>
903
+ {/* The file's own count: a GIF over 50 fps keeps every k-th frame
904
+ (`gifFrameStride`), because browsers slow a delay under 2 cs. */}
905
+ <dd>{format === "gif" ? Math.ceil(frames / gifFrameStride(doc.fps)) : frames}</dd>
238
906
  <dt>Size</dt>
239
907
  {/* See the header: a GIF's size is a palette question, and the
240
908
  bits-per-pixel model does not know the answer. */}
241
909
  <dd>{format === "gif" ? "varies with palette" : formatSizeEstimate(size)}</dd>
242
- <dt>Time</dt>
243
- <dd>{formatTimeEstimate(stats, megapixels, frames)}</dd>
910
+ {/* The time figure is a median of the SERVER's own past renders —
911
+ it knows nothing about this browser's machine, so the browser
912
+ path shows no number rather than a wrong one. */}
913
+ {effectiveDest === "server" ? (
914
+ <>
915
+ <dt>Time</dt>
916
+ <dd>{formatTimeEstimate(stats, megapixels, frames)}</dd>
917
+ </>
918
+ ) : null}
244
919
  </dl>
245
920
 
246
- {job !== null && <JobRow job={job} />}
247
- {error !== null && <p className="export-error">{error}</p>}
921
+ {/* ONE outcome card at a time, newest first: a run in progress, then
922
+ a failure this dialog saw, then how the last run ended.
923
+ The slot is a polite live region, so a screen reader hears
924
+ "Rendering", "Saved", "Failed" as the card changes; it stays in the
925
+ DOM while empty because a region inserted with its content is not
926
+ reliably announced. The per-frame count inside Rendering opts out
927
+ (aria-live="off") or every progress tick would be read aloud. */}
928
+ <div className="export-outcome-slot" role="status">
929
+ {exportLock !== null ? (
930
+ <Outcome
931
+ kind="rendering"
932
+ title="Rendering"
933
+ action={
934
+ // The run's one Cancel, for either path: one lock is behind it.
935
+ // A `.btn` at the card's 28px (`export-outcome-cancel`).
936
+ <button
937
+ ref={cancelButton}
938
+ type="button"
939
+ className="btn export-outcome-cancel"
940
+ onClick={() => store.cancelExport()}
941
+ >
942
+ Cancel export
943
+ </button>
944
+ }
945
+ >
946
+ <p className="export-outcome-copy" data-testid="export-progress" aria-live="off">
947
+ {exportLockCount(exportLock)}
948
+ {exportLock.where === "browser" ? " in this browser" : ""}
949
+ </p>
950
+ </Outcome>
951
+ ) : error !== null ? (
952
+ <Outcome kind="failed" title="Failed">
953
+ <p className="export-outcome-copy" data-testid="export-error">
954
+ {error}
955
+ </p>
956
+ </Outcome>
957
+ ) : browserJob !== null ? (
958
+ <BrowserJobRow job={browserJob} shared={shared} />
959
+ ) : job !== null ? (
960
+ <JobRow job={job} />
961
+ ) : null}
962
+ </div>
963
+ {/* What the render had to say about the document, beside whatever card
964
+ is showing: an overloaded mix, a material that stalls under software
965
+ raster, a pool smaller than asked for. The server used to write
966
+ these to a log callback the job never passed. */}
967
+ {(job?.warnings ?? []).map((w) => (
968
+ <p className="export-note" data-testid="export-warning" key={w}>
969
+ {w}
970
+ </p>
971
+ ))}
972
+ {audioNotice !== null && (
973
+ <p className="export-note" data-testid="export-audio-notice">
974
+ {audioNotice}
975
+ </p>
976
+ )}
977
+ {encoderNotice !== null && (
978
+ <p className="export-note" data-testid="export-encoder-notice">
979
+ {encoderNotice}
980
+ </p>
981
+ )}
248
982
  </div>
249
983
  </Dialog>
250
984
  );
251
985
  }
252
986
 
987
+ type OutcomeKind = "rendering" | "done" | "cancelled" | "failed";
988
+
989
+ /** Paper's outcome card: a sunken tile with a title row (and the Rendering
990
+ * card's Cancel on it) over one line of copy. Only Rendering takes the accent
991
+ * ink: blue marks the state that is live, and a finished run is not. A
992
+ * failure is ink too, with its reason in the copy, so the card states what
993
+ * happened rather than shouting it. `data-outcome` is the state's hook. */
994
+ function Outcome(props: { kind: OutcomeKind; title: string; action?: ReactNode; children: ReactNode }) {
995
+ return (
996
+ <section className={`export-outcome is-${props.kind}`} data-outcome={props.kind}>
997
+ <div className="export-outcome-head">
998
+ <span className="export-outcome-title">{props.title}</span>
999
+ {props.action}
1000
+ </div>
1001
+ {props.children}
1002
+ </section>
1003
+ );
1004
+ }
1005
+
1006
+ /** How an in-browser export ENDED — answering the same question the server's
1007
+ * JobRow does ("where did it go") for a file that went to the DOWNLOADS
1008
+ * folder rather than the project. Progress is the lock's, above. */
1009
+ function BrowserJobRow(props: { job: BrowserJob; shared: Shared | null }) {
1010
+ const { job, shared } = props;
1011
+ const [copied, setCopied] = useState(false);
1012
+ if (job.phase === "done") {
1013
+ return (
1014
+ <Outcome kind="done" title="Saved">
1015
+ <p className="export-outcome-copy" data-testid="export-progress">
1016
+ {/* Plain copy, as Paper sets it: the name is part of the sentence. */}
1017
+ Saved {job.filename} to your downloads.
1018
+ </p>
1019
+ {shared === null ? null : shared.phase === "saving" ? (
1020
+ <p className="export-outcome-copy" data-testid="export-share">
1021
+ Saving to Videos…
1022
+ </p>
1023
+ ) : shared.phase === "failed" ? (
1024
+ <p className="export-outcome-copy" data-testid="export-share">
1025
+ Saving to Videos failed: {shared.message}. The download still stands.
1026
+ </p>
1027
+ ) : (
1028
+ <p className="export-outcome-copy export-share-links" data-testid="export-share">
1029
+ <button
1030
+ type="button"
1031
+ className="link-button"
1032
+ onClick={() => {
1033
+ void navigator.clipboard?.writeText(shared.url).then(() => setCopied(true), () => {});
1034
+ }}
1035
+ >
1036
+ {copied ? "Link copied" : "Copy link"}
1037
+ </button>
1038
+ <a className="link-button" href={shared.url} target="_blank" rel="noopener">
1039
+ Open page ↗
1040
+ </a>
1041
+ </p>
1042
+ )}
1043
+ </Outcome>
1044
+ );
1045
+ }
1046
+ // Not an error, and it must not look like one: the author pressed Cancel.
1047
+ return (
1048
+ <Outcome kind="cancelled" title="Cancelled">
1049
+ <p className="export-outcome-copy" data-testid="export-progress">
1050
+ Export cancelled. Nothing was saved.
1051
+ </p>
1052
+ </Outcome>
1053
+ );
1054
+ }
1055
+
253
1056
  /** The live job, or the last one this session. Shown after a "done" too: the
254
1057
  * author's next question is always "where did it go". */
255
1058
  function JobRow(props: { job: RenderJob }) {
256
1059
  const { job } = props;
257
- if (job.phase === "running") {
258
- return (
259
- <p className="export-progress" data-testid="export-progress">
260
- Rendering frame {job.done}/{job.total || "?"}
261
- </p>
262
- );
263
- }
264
1060
  if (job.phase === "done") {
265
1061
  return (
266
- <p className="export-progress is-done" data-testid="export-progress">
267
- Written to <code>{job.out}</code>
268
- </p>
1062
+ <Outcome kind="done" title="Saved">
1063
+ <p className="export-outcome-copy" data-testid="export-progress">
1064
+ Written to {job.out}
1065
+ </p>
1066
+ </Outcome>
269
1067
  );
270
1068
  }
271
1069
  if (job.phase === "cancelled") {
272
1070
  // Not an error, and it must not look like one: the author pressed Cancel.
273
1071
  return (
274
- <p className="export-progress" data-testid="export-progress">
275
- Render cancelled — nothing was written.
276
- </p>
1072
+ <Outcome kind="cancelled" title="Cancelled">
1073
+ <p className="export-outcome-copy" data-testid="export-progress">
1074
+ Render cancelled. Nothing was written.
1075
+ </p>
1076
+ </Outcome>
277
1077
  );
278
1078
  }
279
1079
  return (
280
- <p className="export-error" data-testid="export-progress">
281
- {job.error ?? "The render failed."}
282
- </p>
1080
+ <Outcome kind="failed" title="Failed">
1081
+ <p className="export-outcome-copy" data-testid="export-progress">
1082
+ {job.error ?? "The render failed."}
1083
+ </p>
1084
+ {/* Where the failing frame's forensics went, and the render's last
1085
+ output: the page's own errors used to stop at a server log. */}
1086
+ {job.diagnostics !== undefined && (
1087
+ <p className="export-outcome-copy" data-testid="export-diagnostics">
1088
+ Diagnostics for the failing frame: <code>{job.diagnostics}</code>
1089
+ </p>
1090
+ )}
1091
+ {job.log !== undefined && job.log.length > 0 && (
1092
+ <details className="export-log" data-testid="export-log">
1093
+ <summary>
1094
+ {/* Paper's 12px chevron in place of the UA's marker triangle,
1095
+ which takes neither a token colour nor this size. It turns
1096
+ over when the log is open (dialogs.css). */}
1097
+ <svg className="export-log-chevron" viewBox="0 0 12 12" aria-hidden="true">
1098
+ <path d="m3 4.5 3 3 3-3" />
1099
+ </svg>
1100
+ Render output
1101
+ </summary>
1102
+ <pre>{job.log.join("\n")}</pre>
1103
+ </details>
1104
+ )}
1105
+ </Outcome>
283
1106
  );
284
1107
  }