@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,8 +6,8 @@
6
6
  // `rows[].keyframes`, `toggleKeyframe(rowId, frame)`, `setEase(refs:
7
7
  // KeyframeAddr[])`, `focusedCurve`. Every member is about INSTANTS.
8
8
  //
9
- // A block lane has INTERVALS, and its verbs are trim-in, trim-out, split,
10
- // reorder and retime. Forcing one through the other gives either a
9
+ // A lifespan, a clip and a camera move are INTERVALS, and their verbs are
10
+ // trim-in, trim-out, split, reorder and retime. Forcing one through the other gives either a
11
11
  // discriminated union half the shared components must switch on, or a
12
12
  // `TimelineModel` whose `keyframes` array is permanently empty — and the second
13
13
  // is worse, because it looks like it works.
@@ -18,22 +18,61 @@
18
18
  // The one geometry helper this lane does NOT reuse is `pxToFrame` — see
19
19
  // `frameDelta` below for why a signed delta must not go through it.
20
20
  //
21
- // ponytail: BlockRow and SceneStrip are local. Promote them into
22
- // @dailies-studio/timeline when a SECOND chrome needs a block lane — the motion
23
- // editor already has overlapping `Transition`s, so it plausibly will.
21
+ // ponytail: BlockRow and ShotRow are local. Promote them into
22
+ // @dailies-studio/timeline when a SECOND chrome needs an interval lane — the
23
+ // motion editor already has overlapping `Transition`s, so it plausibly will.
24
24
  import {
25
- deltaFor,
25
+ defDurationFrames,
26
26
  docDurationFrames,
27
- isSpringBlock,
27
+ audioHomeOf,
28
+ firstKeyframeFrame,
29
+ homeOf,
30
+ isDefId,
31
+ isOpenEndedOut,
28
32
  layerChildren,
29
- motionPresets,
30
- resolveLayerAt,
33
+ masterAudioClips,
34
+ framingsChain,
35
+ isZoomRigBreak,
36
+ patchHome,
37
+ sceneStart,
31
38
  sceneWindows,
39
+ sceneTrackablePath,
40
+ layerLifespan,
41
+ layerTrackablePath,
42
+ updateAudioClip,
43
+ // ── the clip ops, which are what a VIDEO row's gestures write through ──
44
+ //
45
+ // Every one of them refuses by returning the document it was handed, which
46
+ // is the property `writeLifespan` below is built on: a gesture that cannot
47
+ // land writes nothing and the scrub's base survives untouched.
48
+ rollLayers,
49
+ rollSeam,
50
+ shiftLayer,
51
+ shiftTracks,
52
+ slipLayer,
53
+ tracksPatch,
54
+ trimLayer,
55
+ videoClipOf,
56
+ videoSoundClip,
57
+ } from "@dailies-studio/shot/doc";
58
+ import type {
59
+ AudioClipRef,
60
+ ComponentDef3D,
61
+ Doc3D,
62
+ LayerHome,
63
+ Layer3D,
64
+ PathShot,
65
+ Scene3D,
66
+ Shot,
67
+ ShotAsset,
68
+ ShotTrack,
69
+ VideoClipInfo,
70
+ VideoLayer3D,
32
71
  } from "@dailies-studio/shot/doc";
33
- import type { AnimBlock, AudioClipRef, Doc3D, Layer3D, Scene3D } from "@dailies-studio/shot/doc";
34
- import type { Rational } from "@dailies-studio/engine";
35
- import { CHANNELS, CHANNEL_BY_KEY } from "../../channels.js";
36
- import type { Channel } from "../../channels.js";
72
+ import { formatValue } from "@dailies-studio/ui";
73
+ import { clipProgress01 } from "@dailies-studio/engine";
74
+ import type { Ease, Rational, TrackKind, Value } from "@dailies-studio/engine";
75
+ import { TRANSFORM_CHANNELS } from "../../channels.js";
37
76
 
38
77
  /** One scene's bar on the SCENE STRIP. */
39
78
  export interface SceneBar {
@@ -44,12 +83,12 @@ export interface SceneBar {
44
83
  end: number;
45
84
  }
46
85
 
47
- /** One block's bar on a MICRO track. */
86
+ /** One camera move's bar on the camera row. */
48
87
  export interface BlockBar {
88
+ /** The move's own id (`shot.id`). */
49
89
  id: string;
50
- layerId: string;
51
- slot: AnimBlock["slot"];
52
- preset: string;
90
+ /** The move's kind (`dolly`, `orbit`), the bar's name. */
91
+ label: string;
53
92
  /** SCENE-LOCAL frames, half-open. */
54
93
  frame: number;
55
94
  durationFrames: number;
@@ -63,11 +102,6 @@ export interface TrackRowModel {
63
102
  layerId: string;
64
103
  label: string;
65
104
  depth: number;
66
- blocks: BlockBar[];
67
- /** A layer with no blocks still gets a row: it is where the Animate tool
68
- * will put the first one, and an empty lane the author cannot see is a
69
- * feature they cannot find. */
70
- empty: boolean;
71
105
  }
72
106
 
73
107
  export function sceneBars(doc: Doc3D): SceneBar[] {
@@ -84,24 +118,42 @@ export function masterDuration(doc: Doc3D): number {
84
118
  }
85
119
 
86
120
  /**
87
- * One scene's rows, in OUTLINE order and nested.
121
+ * A sibling list in DISPLAY order: front first, each id with its index in the
122
+ * STORED list.
88
123
  *
89
- * Outline order and not paint order, because the dock sits under a layers panel
90
- * that is itself the outline — two lists of the same layers in different orders
91
- * is a UI nobody can follow.
124
+ * Every layer list is stored back to front (index 0 paints first, schema v35,
125
+ * Figma's `children`), and Figma's layers panel shows it reversed, the front
126
+ * layer as the top row. A walker that emits a parent before it recurses then
127
+ * puts the parent row above its children, which is the other half of that
128
+ * reading. The outline and the dock both walk through this one function, so
129
+ * they never disagree about which row is on top, and a row keeps its ARRAY
130
+ * index, which is what a drop and `aria-posinset` are computed from.
92
131
  */
93
- export function trackRows(scene: Scene3D, isolation: string | null = null): TrackRowModel[] {
132
+ export function frontFirst(ids: readonly string[]): { id: string; index: number }[] {
133
+ const out: { id: string; index: number }[] = [];
134
+ for (let index = ids.length - 1; index >= 0; index -= 1) out.push({ id: ids[index]!, index });
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * One scene's rows, nested, FRONT FIRST (`frontFirst`), exactly as the layers
140
+ * panel lists them: the dock sits under that panel, and two lists of the same
141
+ * layers in different orders is a UI nobody can follow. Upper rows cover lower
142
+ * ones, which is also an NLE's track order.
143
+ */
144
+ export function trackRows(
145
+ scene: LayerHome,
146
+ isolation: string | null = null,
147
+ ): TrackRowModel[] {
94
148
  const out: TrackRowModel[] = [];
95
149
  const walk = (ids: readonly string[], depth: number): void => {
96
- for (const id of ids) {
150
+ for (const { id } of frontFirst(ids)) {
97
151
  const layer = scene.layersById[id];
98
152
  if (layer === undefined) continue;
99
153
  out.push({
100
154
  layerId: id,
101
155
  label: layer.label,
102
156
  depth,
103
- blocks: layer.blocks.map((b) => barOf(b, id)),
104
- empty: layer.blocks.length === 0,
105
157
  });
106
158
  walk(layerChildren(layer), depth + 1);
107
159
  }
@@ -110,11 +162,11 @@ export function trackRows(scene: Scene3D, isolation: string | null = null): Trac
110
162
  //
111
163
  // Going inside a card dims the rest of the scene, makes it unpickable and
112
164
  // narrows the outline to the subtree — and the dock kept listing every layer
113
- // in the scene, so the one panel an author uses to find a block still showed
165
+ // in the scene, so the one panel an author uses to retime a layer still showed
114
166
  // dozens of rows for layers they could not click.
115
167
  //
116
168
  // The subtree's ROOT is included and its ancestors are not, so the card's own
117
- // blocks stay reachable: entering a card to retime it and then not finding it
169
+ // keys stay reachable: entering a card to retime it and then not finding it
118
170
  // is the reason to enter at all.
119
171
  const root = isolation === null ? null : scene.layersById[isolation];
120
172
  if (root !== undefined && root !== null) walk([root.id], 0);
@@ -122,114 +174,126 @@ export function trackRows(scene: Scene3D, isolation: string | null = null): Trac
122
174
  return out;
123
175
  }
124
176
 
125
- function barOf(block: AnimBlock, layerId: string): BlockBar {
126
- return {
127
- id: block.id,
128
- layerId,
129
- slot: block.slot,
130
- preset: block.preset,
131
- frame: block.frame,
132
- durationFrames: Math.max(1, Math.trunc(block.durationFrames)),
133
- spring: isSpringBlock(block),
134
- };
135
- }
136
-
137
- // ── CHANNEL ROWS: THE LOTTIE READ OF A BLOCK LANE ───────────────────────────
138
- //
139
- // Lottielab's timeline expands a layer into one row per ANIMATED PROPERTY —
140
- // Position, Scale, Opacity — with keyframe diamonds on each. This lane
141
- // animates with blocks, not stored keyframe tracks, and that does not change
142
- // here: a channel row is a DERIVED VIEW over the blocks. `deltaFor` already
143
- // says which channels a block writes, so the rows come straight out of the
144
- // document with nothing new stored in it.
145
- //
146
- // The span's endpoint values come from `resolveLayerAt` — the same fold the
147
- // stage draws and the renderer samples — at the block's two edge frames. A
148
- // second fold here would be a second answer to a question with exactly one.
149
- //
150
- // Dragging anything on a channel row drags the BLOCK. The block is still the
151
- // atom: "move the opacity keyframes" moves the Fade everywhere it writes,
152
- // which is what keeps a preset one legible thing instead of six anonymous
153
- // tracks.
154
-
155
- /** One block's footprint on one channel row. Frames are SCENE-LOCAL, like
156
- * every bar in this file. */
157
- export interface ChannelSpan {
158
- blockId: string;
159
- slot: AnimBlock["slot"];
160
- /** The preset's display label, for the span's tooltip. */
161
- preset: string;
177
+ /** One keyframe on a track. SCENE-LOCAL frames, like every bar in this file. */
178
+ export interface TrackKey {
179
+ /** The keyframe's own id. Carried for React's key and for nothing else — the
180
+ * ops address a keyframe by (track, frame), see `selectedKeyframe`. */
181
+ id: string;
162
182
  frame: number;
163
- durationFrames: number;
164
- spring: boolean;
165
- /** The channel's resolved value at the span's two edges, formatted. */
166
- from: string;
167
- to: string;
183
+ /** The ease LEAVING this key — `easeOut`, which is why the menu that sets it
184
+ * hangs off the key rather than off the segment between two. The whole object
185
+ * rather than its `type`, so the row can name the PRESET ("Ease Out") through
186
+ * the shared `matchPreset` instead of the shape ("Bezier"). */
187
+ ease: Ease;
188
+ /** The value, formatted for the title. */
189
+ value: string;
190
+ /** The OWNING TRACK's kind. Carried so the menu that sets `ease` can offer
191
+ * only what the engine will sample: a spring on a non-springable kind
192
+ * throws inside `evaluateTrack`, and the discrete kinds hold structurally
193
+ * so every tween on them is a no-op dressed as a choice. */
194
+ kind: TrackKind;
195
+ /** True for the track's final keyframe, whose `easeOut` governs no segment.
196
+ * The menu disables its ease entries rather than writing a value that is
197
+ * never read — this lane refuses rather than going silently inert. */
198
+ last: boolean;
199
+ /** The track curves its path (`spatialInterp`): no spring ease, the
200
+ * engine's rule, so the menu leaves the spring presets out. */
201
+ curved: boolean;
168
202
  }
169
203
 
170
204
  export interface ChannelRowModel {
171
- key: Channel;
205
+ /** The tracked path this row's diamonds edit, and the row's identity. */
206
+ path: string;
172
207
  label: string;
173
- spans: ChannelSpan[];
174
- }
175
-
176
- /** The parts of a bar that fall outside the layer's lifespan, in SCENE frames.
177
- * Both ends, because a lifespan can be trimmed from either side and a short
178
- * enough one clips a block at both. Shared by the camera's block bars and the
179
- * channel spans, so "drawn dead" means one thing. */
180
- export function deadSpans(
181
- bar: { frame: number; durationFrames: number },
182
- lifespan: Lifespan | undefined,
183
- ): { from: number; to: number }[] {
184
- if (lifespan === undefined) return [];
185
- const end = bar.frame + bar.durationFrames;
186
- const out: { from: number; to: number }[] = [];
187
- if (bar.frame < lifespan.in) out.push({ from: bar.frame, to: Math.min(end, lifespan.in) });
188
- if (end > lifespan.out) out.push({ from: Math.max(bar.frame, lifespan.out), to: end });
189
- return out;
208
+ keys: TrackKey[];
190
209
  }
191
210
 
192
211
  /**
193
- * One row per channel that at least one block on this layer writes, in
194
- * CHANNELS order (which is resolve.ts's), each carrying the spans of the
195
- * blocks that write it. A channel nothing writes gets no row — Lottie lists
196
- * animated properties, not every property.
197
- *
198
- * Which channels a block writes is DECLARED, not sampled: `deltaFor`'s keys at
199
- * any progress. Probed at 0.5 rather than an endpoint only because a fade's
200
- * delta at p=0 is still a declaration and the keys do not depend on p.
201
- */
202
- export function channelRows(layer: Layer3D, fps: Rational): ChannelRowModel[] {
203
- const spansByKey = new Map<Channel, ChannelSpan[]>();
204
- for (const block of layer.blocks) {
205
- const keys = Object.keys(deltaFor(block.preset, block.slot, block.params, 0.5)) as Channel[];
206
- if (keys.length === 0) continue;
207
- const frame = Math.trunc(block.frame);
208
- const duration = Math.max(1, Math.trunc(block.durationFrames));
209
- const a = resolveLayerAt(layer, frame, fps);
210
- const b = resolveLayerAt(layer, frame + duration, fps);
211
- for (const key of keys) {
212
- const spec = CHANNEL_BY_KEY.get(key);
213
- if (spec === undefined) continue;
214
- const span: ChannelSpan = {
215
- blockId: block.id,
216
- slot: block.slot,
217
- preset: motionPresets[block.preset]?.label ?? block.preset,
218
- frame,
219
- durationFrames: duration,
220
- spring: isSpringBlock(block),
221
- from: spec.read(a),
222
- to: spec.read(b),
223
- };
224
- const at = spansByKey.get(key);
225
- if (at === undefined) spansByKey.set(key, [span]);
226
- else at.push(span);
227
- }
212
+ * `channelRows`, replayed per layer object.
213
+ *
214
+ * Layers are immutable on edit, so identity is value here. The dock computed
215
+ * the rows of every visible expanded layer on every document change just to
216
+ * read their `.length` for a row height, while `ChannelRows` computed the same
217
+ * rows again to draw them. One entry per layer.
218
+ */
219
+ const CHANNEL_ROWS = new WeakMap<Layer3D, ChannelRowModel[]>();
220
+ export function channelRowsCached(layer: Layer3D): ChannelRowModel[] {
221
+ const cached = CHANNEL_ROWS.get(layer);
222
+ if (cached !== undefined) return cached;
223
+ const rows = channelRows(layer);
224
+ CHANNEL_ROWS.set(layer, rows);
225
+ return rows;
226
+ }
227
+
228
+ /**
229
+ * One row per tracked path on this layer. A path nothing animates gets no row:
230
+ * Lottie lists animated properties, not every property, and a property lane
231
+ * appears when it carries animation and not before.
232
+ *
233
+ * The transform paths lead, in `TRANSFORM_CHANNELS`' order (which is
234
+ * resolve.ts's), then every other tracked path sorted, so two layers with the
235
+ * same tracks list them the same way; a `Record`'s own order is not one.
236
+ */
237
+ export function channelRows(layer: Layer3D): ChannelRowModel[] {
238
+ const tracks = layer.tracks ?? {};
239
+ const out: ChannelRowModel[] = [];
240
+ for (const [path, spec] of TRANSFORM_CHANNELS) {
241
+ const track = tracks[path];
242
+ if (track !== undefined) out.push({ path, label: spec.label, keys: trackKeys(track) });
228
243
  }
229
- return CHANNELS.flatMap((c) => {
230
- const spans = spansByKey.get(c.key);
231
- return spans === undefined ? [] : [{ key: c.key, label: c.label, spans }];
232
- });
244
+ for (const path of Object.keys(tracks).sort()) {
245
+ if (TRANSFORM_CHANNELS.has(path)) continue;
246
+ out.push({
247
+ path,
248
+ // The LAYER'S lookup, so a tracked `instance.props.<key>` row reads the
249
+ // contract's label ("Time") instead of its path. Falls back to the path
250
+ // when the layer carries no snapshot and the registry is not in reach
251
+ // here — a dock row is a readout, and this module takes no store.
252
+ label: layerTrackablePath(layer, path)?.label ?? path,
253
+ keys: trackKeys(tracks[path]!),
254
+ });
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * The SHOT's own tracked properties as channel rows (P5): its background colour
261
+ * and its post stack.
262
+ *
263
+ * Sorted by path for `channelRows`' reason: a `Record`'s key order is not one.
264
+ */
265
+ export function sceneChannelRows(scene: Scene3D): ChannelRowModel[] {
266
+ const tracks = scene.tracks ?? {};
267
+ return Object.keys(tracks)
268
+ .sort()
269
+ .map((path) => ({
270
+ path,
271
+ label: sceneTrackablePath(path)?.label ?? path,
272
+ keys: trackKeys(tracks[path]!),
273
+ }));
274
+ }
275
+
276
+ function trackKeys(track: ShotTrack): TrackKey[] {
277
+ const lastIndex = track.keyframes.length - 1;
278
+ return track.keyframes.map((k, i) => ({
279
+ id: k.id,
280
+ frame: k.frame,
281
+ ease: k.easeOut,
282
+ value: formatTrackValue(k.value),
283
+ kind: track.kind,
284
+ last: i === lastIndex,
285
+ curved: track.spatialInterp !== undefined,
286
+ }));
287
+ }
288
+
289
+ /** A keyframe's value as one short string, for the diamond's title. Not a
290
+ * channel formatter: a track's value is the AUTHORED type at its path, which is
291
+ * a number, a vector or a hex colour, and `TRANSFORM_CHANNELS`' readers all
292
+ * take a resolved `LayerState`. */
293
+ function formatTrackValue(value: Value): string {
294
+ if (typeof value === "number") return formatValue(value, 0.01);
295
+ if (Array.isArray(value)) return value.map((v) => formatValue(v, 0.1)).join(", ");
296
+ return String(value);
233
297
  }
234
298
 
235
299
  /** The camera's own track: one bar per shot. It is a row like any other so the
@@ -238,15 +302,160 @@ export function channelRows(layer: Layer3D, fps: Rational): ChannelRowModel[] {
238
302
  export function cameraBars(scene: Scene3D): BlockBar[] {
239
303
  return scene.shots.map((shot) => ({
240
304
  id: shot.id,
241
- layerId: CAMERA_ROW_ID,
242
- slot: "during" as const,
243
- preset: shot.kind,
305
+ label: shot.kind,
244
306
  frame: shot.frame,
245
307
  durationFrames: Math.max(1, Math.trunc(shot.durationFrames)),
246
308
  spring: shot.ease?.type === "spring",
247
309
  }));
248
310
  }
249
311
 
312
+ // ── CAMERA KEYFRAMES: PRESENTATION, ZERO SCHEMA (P4) ────────────────────────
313
+ //
314
+ // The camera move system already IS a keyframe track — `cameraAt` walks
315
+ // non-overlapping windows with hold-forever semantics, which is
316
+ // last-writer-by-time. What it lacked was a picture of its own poses.
317
+ //
318
+ // ── AND WHERE THE PICTURE STOPS ─────────────────────────────────────────
319
+ //
320
+ // A camera endpoint is NOT a property-lane keyframe, so it does not get that
321
+ // diamond. A move carries ONE ease for its whole window and the gap after it is
322
+ // a HOLD rather than an interpolation, so drawing them alike would teach an
323
+ // author that every diamond behaves the same — and the first time they
324
+ // right-clicked a camera mark expecting an ease menu they would be right to be
325
+ // annoyed. Camera marks are HOLLOW and read-only; ease stays per-move in the
326
+ // inspector, where a move's ease belongs.
327
+
328
+ export type CameraMarkKind = "pose" | "chained" | "break" | "waypoint";
329
+
330
+ /** One camera pose on the camera row. SCENE-LOCAL frames, like every bar. */
331
+ export interface CameraMark {
332
+ /** Stable across renders: the move it belongs to plus which end. */
333
+ id: string;
334
+ frame: number;
335
+ kind: CameraMarkKind;
336
+ title: string;
337
+ }
338
+
339
+ /**
340
+ * Every camera pose in a scene, in frame order.
341
+ *
342
+ * Three rules, and each is a claim the drawing then makes:
343
+ *
344
+ * CHAINED — a move that opens on exactly the pose the previous one closed on
345
+ * is ONE keyframe, not two stacked on a pixel. Compared through
346
+ * `framingsChain`, which walks `shotWaypoints` because a `PathShot` has no
347
+ * `from`/`to` at all. A GAP between the two is still a chain: the camera
348
+ * holds that pose across it, so it really is continuous.
349
+ * BREAK — the end of a zoom move that changes the zoom. `cameraRigAt` pins
350
+ * the distance only INSIDE that window, so the rig jumps one frame later
351
+ * and every layer at a non-zero depth pops. Drawn as a split mark rather
352
+ * than as an unbroken diamond, because an unbroken diamond would promise a
353
+ * continuity that is not there.
354
+ * WAYPOINT — a path move's interior poses, READ-ONLY. The chrome refuses to
355
+ * half-edit a path today (`setShotFraming` no-ops on one and the inspector
356
+ * says so), and the only waypoint writer in the repo replaces the whole
357
+ * array, so a draggable mark here would be a control with no op behind it.
358
+ */
359
+ export function cameraMarks(scene: Scene3D, fps: Rational): CameraMark[] {
360
+ const out: CameraMark[] = [];
361
+ const shots = [...scene.shots].sort((a, b) => a.frame - b.frame);
362
+
363
+ for (const [index, shot] of shots.entries()) {
364
+ const duration = Math.max(1, Math.trunc(shot.durationFrames));
365
+ const end = shot.frame + duration;
366
+ const previous = shots[index - 1];
367
+ const chained = previous !== undefined && framingsChain(previous, shot);
368
+
369
+ // The OPENING pose. Suppressed when it chains, because the previous move's
370
+ // closing mark already stands for it — two marks for one pose is the thing
371
+ // the chain predicate exists to prevent.
372
+ if (!chained) {
373
+ out.push({
374
+ id: `${shot.id}:in`,
375
+ frame: shot.frame,
376
+ kind: "pose",
377
+ title: `${shot.label} starts here`,
378
+ });
379
+ }
380
+
381
+ // A path's interior poses, read-only. Endpoints are excluded: index 0 is
382
+ // the opening mark above and the last is the closing mark below.
383
+ if (shot.kind === "path") {
384
+ for (const [at, frame] of waypointFrames(shot, fps).entries()) {
385
+ if (at === 0 || at === shot.waypoints.length - 1) continue;
386
+ out.push({
387
+ id: `${shot.id}:wp${at}`,
388
+ frame,
389
+ kind: "waypoint",
390
+ // READ-ONLY IN TIME, editable in POSE. A mark sits at the frame the
391
+ // eased progress reaches this waypoint, which is derived — there is
392
+ // nothing under it for a drag to write to. The pose itself is edited
393
+ // in the inspector's waypoint rows.
394
+ title:
395
+ `${shot.label} waypoint ${at + 1} of ${shot.waypoints.length}: its time is derived ` +
396
+ `from the move's ease; edit its pose in the inspector`,
397
+ });
398
+ }
399
+ }
400
+
401
+ // The CLOSING pose, which on a LEGACY scene is also where a zoom's rig pin
402
+ // ends. v18 removed the pin, so a modern scene draws a plain pose here and
403
+ // the split mark survives only for documents still on the old rig.
404
+ const broken = scene.legacyZoomRig === true && isZoomRigBreak(shot);
405
+ out.push({
406
+ id: `${shot.id}:out`,
407
+ frame: end,
408
+ kind: broken ? "break" : chainsForward(shots, index) ? "chained" : "pose",
409
+ title: broken
410
+ ? `${shot.label} ends here, and the camera RIG jumps: on this scene's old rig a zoom move ` +
411
+ `pins the distance only inside its own window, so every layer at a non-zero depth pops ` +
412
+ `one frame later. Upgrade the camera in the inspector to remove the step.`
413
+ : `${shot.label} ends here`,
414
+ });
415
+ }
416
+ return out.sort((a, b) => a.frame - b.frame);
417
+ }
418
+
419
+ /** Whether the NEXT move opens on this one's closing pose. */
420
+ function chainsForward(shots: readonly Shot[], index: number): boolean {
421
+ const here = shots[index];
422
+ const next = shots[index + 1];
423
+ return here !== undefined && next !== undefined && framingsChain(here, next);
424
+ }
425
+
426
+ /**
427
+ * The frame each waypoint of a path move is first REACHED at, on the integer
428
+ * grid the whole document is built on.
429
+ *
430
+ * ── SCANNED, NOT SOLVED ─────────────────────────────────────────────────
431
+ *
432
+ * A waypoint sits at `i / (n - 1)` in the spline's own parameter, and the ease
433
+ * maps time onto that parameter — so placing a mark needs the ease INVERTED. A
434
+ * bezier ease has no closed-form inverse in the direction wanted, and a hold
435
+ * ease has no inverse at all (its progress is a step).
436
+ *
437
+ * So this walks the move's frames and takes the first one whose eased progress
438
+ * has reached the waypoint. That is exact ON THE GRID, which is the only
439
+ * resolution the dock can draw at anyway, and it is total for every ease
440
+ * including hold. The cost is one `clipProgress01` per frame of one move, once
441
+ * per render of the camera row.
442
+ */
443
+ function waypointFrames(shot: PathShot, fps: Rational): number[] {
444
+ const duration = Math.max(1, Math.trunc(shot.durationFrames));
445
+ const count = shot.waypoints.length;
446
+ if (count < 2) return shot.waypoints.map(() => shot.frame);
447
+ const out: number[] = [];
448
+ let at = 0;
449
+ for (let i = 0; i < count; i += 1) {
450
+ const target = i / (count - 1);
451
+ while (at < duration && clipProgress01({ ...shot, durationFrames: duration }, shot.frame + at, fps) < target) {
452
+ at += 1;
453
+ }
454
+ out.push(shot.frame + at);
455
+ }
456
+ return out;
457
+ }
458
+
250
459
  /**
251
460
  * A DELTA in px as a delta in frames.
252
461
  *
@@ -256,33 +465,493 @@ export function cameraBars(scene: Scene3D): BlockBar[] {
256
465
  * A drag delta is signed, so running it through the clamp silently drops every
257
466
  * leftward drag — a left-trim that never moves, which looks like a dead handle.
258
467
  */
468
+ // ── AND THE SAME RULE FOR THE POSES ───────────────────────────────────────
469
+ //
470
+ // A camera mark is an 8px square turned 45 degrees, so it occupies about 11.3px
471
+ // of the lane whatever the zoom, while the distance between two of them is the
472
+ // distance between two moves. On a row of one-frame moves that distance is one
473
+ // frame, and at the zoom where a 71-frame shot fills the dock that is 9.9px:
474
+ // the marks overlapped each other by more than a pixel and eight pairs of them
475
+ // fused into a chain of lozenges. A mark that cannot be told from its neighbour
476
+ // has stopped being a mark and become a texture.
477
+ //
478
+ // So the ones that will not fit are not drawn, EXCEPT a break. That exception
479
+ // is the same one the drawing itself makes: a pose, a chain and a waypoint each
480
+ // restate something the bars beside them already say, which is why they are
481
+ // hidden from assistive technology, while a break is a rig discontinuity that
482
+ // exists nowhere else on the row. Dropping the redundant ones costs a reader
483
+ // nothing; dropping a break would remove the only place that fact appears. Two
484
+ // breaks close enough to touch will therefore still touch, and that is the
485
+ // right trade: rare, and information over tidiness.
486
+ //
487
+ // 13 and not 12, because a break is drawn a pixel larger than the rest (a 9px
488
+ // square, so 12.73px across its diagonal) and 12 left it fusing with the pose
489
+ // beside it. The gap has to clear the WIDEST mark, not the common one.
490
+ export const CAMERA_MARK_MIN_GAP_PX = 13;
491
+
492
+ /**
493
+ * The marks that can be drawn without fusing into their neighbours, in frame
494
+ * order. `ppf` is the lane's current zoom, so this thins as the author zooms
495
+ * out and restores every mark as they zoom back in.
496
+ */
497
+ /**
498
+ * Whether a mark carries something no other part of the row does.
499
+ *
500
+ * ONE definition, because two things read it and they must agree: this file
501
+ * decides whether a crowded mark may be dropped, and BlockRow decides whether
502
+ * it is announced to assistive technology. Those were the same condition
503
+ * written out twice, so a second load-bearing kind would have been exposed in
504
+ * the markup and silently thinned away out of the picture.
505
+ */
506
+ export function isLoadBearingMark(mark: CameraMark): boolean {
507
+ return mark.kind === "break";
508
+ }
509
+
510
+ export function thinCameraMarks(marks: readonly CameraMark[], ppf: number): CameraMark[] {
511
+ const kept: CameraMark[] = [];
512
+ let lastFrame: number | null = null;
513
+ for (const mark of marks) {
514
+ const room = lastFrame === null || (mark.frame - lastFrame) * ppf >= CAMERA_MARK_MIN_GAP_PX;
515
+ if (!room && !isLoadBearingMark(mark)) continue;
516
+ kept.push(mark);
517
+ lastFrame = mark.frame;
518
+ }
519
+ return kept;
520
+ }
521
+
259
522
  export function frameDelta(px: number, ppf: number): number {
260
523
  return Math.round(px / Math.max(1e-6, ppf));
261
524
  }
262
525
 
526
+ // ── THE ZOOM, AS A SLIDER POSITION ────────────────────────────────────────
527
+ //
528
+ // `dockPpf` runs 0.2 to 60, which is a 300x range, and a linear slider over it
529
+ // is unusable: every zoom an author actually works at lives in the first 3% of
530
+ // the track, and the remaining 97% is the difference between "very close" and
531
+ // "closer". Half a track of nothing is not a control.
532
+ //
533
+ // LOGARITHMIC, so equal travel is equal RATIO. A slider that doubles the zoom
534
+ // for every fifth of the track behaves the way the wheel already does
535
+ // (`useAnchoredZoom` multiplies by `exp(-deltaY * 0.002)`, which is the same
536
+ // geometric family), so the two gestures agree about what "a bit more" means
537
+ // and the thumb tracks a wheel zoom at a constant rate.
538
+ //
539
+ // The pair is exact in both directions (`sliderToPpf(ppfToSlider(x)) === x`
540
+ // within float error), which is what lets the slider be a readout of the wheel
541
+ // and the wheel keep working while the slider is on screen.
542
+
543
+ /** Where `ppf` sits on a 0..1 track. Clamped, so a value the store has not seen
544
+ * yet (or a NaN out of a broken caller) still paints somewhere real. */
545
+ export function ppfToSlider(ppf: number, min: number, max: number): number {
546
+ if (!Number.isFinite(ppf)) return 0;
547
+ const clamped = Math.min(max, Math.max(min, ppf));
548
+ return Math.log(clamped / min) / Math.log(max / min);
549
+ }
550
+
551
+ /** The zoom a 0..1 track position means. */
552
+ export function sliderToPpf(t: number, min: number, max: number): number {
553
+ if (!Number.isFinite(t)) return min;
554
+ const clamped = Math.min(1, Math.max(0, t));
555
+ return min * Math.pow(max / min, clamped);
556
+ }
557
+
558
+ // ── WHETHER A BAR CAN CARRY ITS OWN NAME ──────────────────────────────────
559
+ //
560
+ // A bar is as wide as its window is long, so on a dense row most bars are
561
+ // narrower than the word inside them. They were drawn anyway: the label kept
562
+ // its `text-overflow: ellipsis` and the browser gave it whatever was left after
563
+ // the inset, which on a one-frame move was between zero and three pixels. A row
564
+ // of twenty-two camera moves therefore read as "d d d dolly d d dolly", where
565
+ // every "d" is a name that has been compressed past the point of being one.
566
+ //
567
+ // The rule is the inspector's, one lane over: a label that cannot be read in
568
+ // full is worse than no label, because the reader cannot tell a truncation from
569
+ // a short word. So the name is drawn only when the whole name fits, and the
570
+ // bar's `title` carries the name and the frame range for every bar either
571
+ // way, so nothing becomes unreachable.
572
+ //
573
+ // ── WHY AN ESTIMATE AND NOT A MEASUREMENT ─────────────────────────────────
574
+ //
575
+ // The honest measurement is the text's own advance width, which needs a layout
576
+ // pass or a canvas `measureText` PER BAR, on a row that re-renders on every
577
+ // frame of a zoom drag. This is one multiply against a per-character average,
578
+ // and the average is measured rather than guessed: against the face the label
579
+ // actually resolves to (`500 12px "Inter Variable"`), "in" runs 5.12px per
580
+ // character, "orbit" 5.32, "dolly" 5.54 and "crossfade" 6.35. 6.4 sits just
581
+ // above the widest of them, so every real move name is biased toward hiding:
582
+ // erring that way costs a name that would have just fitted, while erring the
583
+ // other way puts back the sliver this exists to remove.
584
+ //
585
+ // A label of all-wide glyphs would beat it (a lone "w" is 9.95px), which is
586
+ // why `.block-label` keeps `text-overflow: ellipsis` underneath as a backstop
587
+ // rather than relying on this being exact.
588
+ export const BLOCK_LABEL_INSET_PX = 18;
589
+
590
+ /**
591
+ * About how many pixels `label` needs at the dock's own face.
592
+ *
593
+ * Three buckets, because a name is NOT always a lowercase English keyword.
594
+ * The camera row's is (`dolly`, `orbit`), but a clip's (`clipNamePlace`) is a
595
+ * file or layer name, so this sees whatever the author typed. Measured against `500 12px "Inter Variable"`, the face the label
596
+ * actually inherits: lowercase runs 5.1 to 6.4 per character across `in`,
597
+ * `orbit`, `dolly` and `crossfade`, capitals about 8, and a full-width glyph
598
+ * takes the whole 12px em. One flat average tuned on lowercase would pass an
599
+ * all-caps or CJK name and then ellipsise it, which is the exact sliver this
600
+ * exists to remove.
601
+ *
602
+ * Iterated with `for...of` so a surrogate pair counts once: `label.length` is
603
+ * UTF-16 code units and would score an emoji as two narrow characters.
604
+ */
605
+ export function estimateLabelPx(label: string): number {
606
+ let px = 0;
607
+ for (const ch of label) {
608
+ const code = ch.codePointAt(0) ?? 0;
609
+ // Everything from CJK radicals up is full-width in practice, as are the
610
+ // fullwidth forms and emoji above the BMP.
611
+ if (code >= 0x2e80) px += 12;
612
+ // A cheap test for "this glyph is a capital": a character that changes
613
+ // under toLowerCase. It catches Latin, Greek and Cyrillic capitals without
614
+ // a table, and scores digits and punctuation as lowercase, which is right.
615
+ else if (ch !== ch.toLowerCase()) px += 8;
616
+ else px += 6.4;
617
+ }
618
+ return px;
619
+ }
620
+
621
+ /**
622
+ * Does `label` fit inside a bar `widthPx` wide, inset and all?
623
+ *
624
+ * `widthPx` is the bar's BORDER-box width, which is what BlockBarView computes
625
+ * and what CSS consumes, so the inset above is the label's 16px of padding plus
626
+ * the bar's own 1px border on each side. Getting that wrong by two pixels would
627
+ * bias the answer toward showing a label that then clips, which is the one
628
+ * direction this must not err in.
629
+ */
630
+ export function fitsBlockLabel(widthPx: number, label: string): boolean {
631
+ return widthPx >= BLOCK_LABEL_INSET_PX + estimateLabelPx(label);
632
+ }
633
+
634
+ /** The gap between a clip and a name drawn beside it, the boards' 6px. */
635
+ export const CLIP_NAME_GAP_PX = 6;
636
+
637
+ /**
638
+ * WHERE A CLIP'S NAME GOES. A name is never cut short: inside the clip when the
639
+ * whole of it fits, else just AFTER the clip, else (at the track's end, where
640
+ * after would run off the lane) just BEFORE it, right-aligned. `laneW` is the
641
+ * lane's drawable width in px, measured from the lane's own 0 like `leftPx`.
642
+ */
643
+ export function clipNamePlace(
644
+ leftPx: number,
645
+ widthPx: number,
646
+ label: string,
647
+ laneW: number,
648
+ ): "inside" | "after" | "before" {
649
+ const text = estimateLabelPx(label);
650
+ if (fitsBlockLabel(widthPx, label)) return "inside";
651
+ return leftPx + widthPx + CLIP_NAME_GAP_PX + text <= laneW || leftPx - CLIP_NAME_GAP_PX - text < 0
652
+ ? "after"
653
+ : "before";
654
+ }
655
+
263
656
  /** The camera row's stand-in layer id. Not a real layer, and deliberately not a
264
657
  * valid `l3_` id, so a code path that treated it as one fails loudly. */
265
658
  export const CAMERA_ROW_ID = "camera";
266
659
 
267
- // ── THE MASTER STRIP ────────────────────────────────────────────────────────
660
+ // ── THE PLAYHEAD IN LANE COORDINATES, AND THE LANE THAT FOLLOWS IT ─────────
661
+
662
+ /**
663
+ * WHERE THE PLAYHEAD IS, in the frames this lane is drawn in. ONE function,
664
+ * because two things read it and they must never disagree: the line that draws
665
+ * it, and the scroller that follows it.
666
+ *
667
+ * The fork is unify P2's one-time-base rule. A DEFINITION runs on `defFrame`, a
668
+ * clock of its own with no master position at all. Everything else runs on
669
+ * master time, re-zeroed by `origin` — which is DRAW-ONLY (see this file's
670
+ * master-strip header): the shot context subtracts the shot's start here and
671
+ * `seek` adds it straight back, so what reaches the store is the master frame
672
+ * it has always been.
673
+ *
674
+ * Split out of `DockPlayhead` when the lane learned to follow. Left inline it
675
+ * would have been copied into the follow effect, and a lane that scrolled to a
676
+ * different frame than it drew is the exact shape of bug this codebase has paid
677
+ * for before — two spellings of one mapping, agreeing on every input anyone
678
+ * happened to try.
679
+ */
680
+ export function laneFrame(
681
+ state: { frame: number; defFrame: number; editingDef: string | null },
682
+ origin: number,
683
+ ): number {
684
+ return state.editingDef === null ? state.frame - origin : state.defFrame;
685
+ }
686
+
687
+ /**
688
+ * How much of the visible lane the playhead may cross before it drags the view
689
+ * along. Outside this band the scroll moves; inside it nothing happens.
690
+ *
691
+ * A DEADZONE and not a centred playhead. Centring writes a scroll on every
692
+ * frame, so the whole lane slides under the pointer at all times and there is
693
+ * never a still moment in which to click a bar. With a band, a scrub inside the
694
+ * visible window costs nothing at all, and only playback that runs off the edge
695
+ * pays.
696
+ *
697
+ * The forward number is the interesting one. Once playback pushes past it, each
698
+ * later frame scrolls by exactly one frame's width, so the playhead stays PINNED
699
+ * at 0.75 and the content slides under it — which is the "shots passing by" read
700
+ * this was asked for, and it falls out of the clamp rather than needing its own
701
+ * mode. 0.75 rather than the far edge leaves a quarter of a lane of lookahead:
702
+ * an author watching a cut approach can see the next shot's bars before they
703
+ * arrive.
704
+ */
705
+ export const FOLLOW_BAND = { back: 0.25, forward: 0.75 } as const;
706
+
707
+ /**
708
+ * The `scrollLeft` that brings the playhead back inside the band, or NULL when
709
+ * it is already inside and nothing should move.
710
+ *
711
+ * Null rather than "the current value" on purpose: the caller writes
712
+ * `el.scrollLeft` only when this answers, so the common case (a playhead in
713
+ * view) touches no DOM at all — and a write of the same number would still
714
+ * fire a scroll event and still fight a momentum scroll mid-flight.
715
+ *
716
+ * Pure, and that is not tidiness either: jsdom reports every `clientWidth` as 0,
717
+ * so a lane's geometry can only be exercised from a test by stubbing it. The
718
+ * arithmetic is the part worth pinning, and it is testable here with no layout
719
+ * at all.
720
+ */
721
+ export function followScrollLeft(view: {
722
+ /** The playhead's x in CONTENT pixels — label column included, because the
723
+ * label cell is part of the scrolled content and `left:` is measured from
724
+ * the same origin. */
725
+ playheadPx: number;
726
+ scrollLeft: number;
727
+ /** The scroll container's `clientWidth`. */
728
+ laneW: number;
729
+ /** `scrollWidth`, so the answer is clamped to what the browser would clamp it
730
+ * to anyway. Without this the caller writes a number, the browser stores a
731
+ * different one, and every later comparison is against a value that was
732
+ * never there. */
733
+ contentW: number;
734
+ }): number | null {
735
+ const { playheadPx, scrollLeft, laneW, contentW } = view;
736
+ if (!(laneW > 0) || !Number.isFinite(playheadPx)) return null;
737
+ const max = Math.max(0, contentW - laneW);
738
+ const back = scrollLeft + laneW * FOLLOW_BAND.back;
739
+ const forward = scrollLeft + laneW * FOLLOW_BAND.forward;
740
+ let next = scrollLeft;
741
+ if (playheadPx > forward) next = playheadPx - laneW * FOLLOW_BAND.forward;
742
+ else if (playheadPx < back) next = playheadPx - laneW * FOLLOW_BAND.back;
743
+ else return null;
744
+ next = Math.max(0, Math.min(max, next));
745
+ // The clamp can land back on where we already are — a playhead past the end
746
+ // of a document already scrolled to its limit is the ordinary case, and it
747
+ // must not write a scroll on every frame of the tail.
748
+ return next === scrollLeft ? null : next;
749
+ }
750
+
751
+ /** `.dock-ruler`'s height, and the sticky strip the rows scroll under. Paired
752
+ * with the CSS rule of the same name: the ruler is the one piece of the
753
+ * scroller that does not move vertically, so it is also the top of the usable
754
+ * viewport for anything being revealed below it. 28 in every context, the
755
+ * boards' ruler: the playhead's 18px pill hangs 5px down with the ticks
756
+ * standing up off the floor beside it. */
757
+ export const DOCK_RULER_H = 28;
758
+
759
+ /** Breathing room around a revealed row, so it lands just inside the edge
760
+ * rather than flush against it (or half under the sticky ruler). */
761
+ export const FOLLOW_MARGIN = 8;
762
+
763
+ /**
764
+ * The `scrollTop` that brings a shot's row into view, or NULL when it already
765
+ * is and nothing should move.
766
+ *
767
+ * NEAREST, not a band, and that is the difference from `followScrollLeft`
768
+ * above. The horizontal follow tracks a playhead that moves every frame, so it
769
+ * pins the head at 0.75 of the lane and lets the content slide under it. This
770
+ * one is driven by `sceneId`, which moves about once per SHOT — roughly seven
771
+ * times in a minute of film. Recentring the lane on each of those would be the
772
+ * panel jumping under an author who is reading it, for no lookahead worth
773
+ * having. So it moves the minimum distance, and only when the row is actually
774
+ * outside: it is `scrollIntoView({ block: "nearest" })` written as arithmetic.
775
+ *
776
+ * Arithmetic and not the DOM call for two reasons. Past 40 shots the dock
777
+ * virtualizes and the row may not be MOUNTED, so there is nothing to call the
778
+ * method on, while the prefix sum over `groupHeights` that positions the
779
+ * spacers still knows exactly where it would be. And jsdom lays nothing out,
780
+ * so a DOM-measured follow could not be tested at all.
781
+ */
782
+ export function followScrollTop(view: {
783
+ /** The row's y in CONTENT pixels, ruler included: it is inside the scroller
784
+ * and above every group, so it is part of the same origin. */
785
+ rowTop: number;
786
+ rowH: number;
787
+ scrollTop: number;
788
+ /** The scroll container's `clientHeight`. */
789
+ viewH: number;
790
+ /** `scrollHeight`, so the answer is clamped where the browser would clamp
791
+ * it, for `followScrollLeft`'s reason. */
792
+ contentH: number;
793
+ /** The sticky ruler's height: content under it is covered, not visible. */
794
+ stickyH: number;
795
+ }): number | null {
796
+ const { rowTop, rowH, scrollTop, viewH, contentH, stickyH } = view;
797
+ if (!(viewH > 0) || !Number.isFinite(rowTop)) return null;
798
+ const max = Math.max(0, contentH - viewH);
799
+ // The visible band, in content pixels: under the ruler, above the bottom.
800
+ const top = scrollTop + stickyH + FOLLOW_MARGIN;
801
+ const bottom = scrollTop + viewH - FOLLOW_MARGIN;
802
+ let next = scrollTop;
803
+ if (rowTop < top) next = rowTop - stickyH - FOLLOW_MARGIN;
804
+ else if (rowTop + rowH > bottom) next = rowTop + rowH - viewH + FOLLOW_MARGIN;
805
+ else return null;
806
+ next = Math.max(0, Math.min(max, next));
807
+ return next === scrollTop ? null : next;
808
+ }
809
+
810
+ /**
811
+ * Where a layer's row sits in the dock's scrolled content, ruler included (the
812
+ * origin `followScrollTop` takes), or NULL when no shown group lists it.
813
+ *
814
+ * From the same heights the virtual windows place their spacers by, not from
815
+ * the DOM, for `followScrollTop`'s reason: past the window thresholds the row
816
+ * being revealed is exactly the one that may not be mounted.
817
+ *
818
+ * `leads[i]` is the height of group i above its first layer row: the separator,
819
+ * the shot row, and the camera rows of a shown shot. `height` is the row with
820
+ * its open lanes, the entry the layer window holds for it.
821
+ *
822
+ * A layer id names a row of one container, but the scene context lists every
823
+ * shot, so `prefer` (the shot the stage shows) wins a tie before document order.
824
+ */
825
+ export function rowOffsetOf(
826
+ groups: readonly { id: string; rows: readonly { layerId: string }[] }[],
827
+ groupHeights: readonly number[],
828
+ leads: readonly number[],
829
+ layerHeights: ReadonlyMap<string, readonly number[]>,
830
+ layerId: string,
831
+ prefer: string | null = null,
832
+ ): { groupId: string; top: number; height: number } | null {
833
+ const holds = (index: number): number => {
834
+ const heights = layerHeights.get(groups[index]!.id) ?? [];
835
+ const row = groups[index]!.rows.findIndex((r) => r.layerId === layerId);
836
+ // A closed group lists no heights: its rows are not drawn.
837
+ return row < heights.length ? row : -1;
838
+ };
839
+ let index = groups.findIndex((g) => g.id === prefer);
840
+ if (index < 0 || holds(index) < 0) index = groups.findIndex((_, i) => holds(i) >= 0);
841
+ if (index < 0) return null;
842
+ const heights = layerHeights.get(groups[index]!.id)!;
843
+ const row = holds(index);
844
+ let top = DOCK_RULER_H + (leads[index] ?? 0);
845
+ for (let i = 0; i < index; i += 1) top += groupHeights[i] ?? 0;
846
+ for (let i = 0; i < row; i += 1) top += heights[i] ?? 0;
847
+ return { groupId: groups[index]!.id, top, height: heights[row] ?? 0 };
848
+ }
849
+
850
+ /**
851
+ * A layer row as the in-container follow reads it: its span in LANE frames,
852
+ * where it sits in the dock's content (ruler included, `rowOffsetOf`'s
853
+ * origin) with its open lanes, its depth in the pre-order walk, and whether
854
+ * its eye is off.
855
+ */
856
+ export interface FollowRow {
857
+ in: number;
858
+ out: number;
859
+ top: number;
860
+ height: number;
861
+ depth: number;
862
+ hidden: boolean;
863
+ }
864
+
865
+ /**
866
+ * Which rows are ON SCREEN at a lane frame, by the runtime's rule and not the
867
+ * bar's (`useLive` lights a bar by its span alone). `layerAliveAt` drops a
868
+ * hidden layer, and the render walk stops at a layer that is not alive, so a
869
+ * child inside its own span is still not drawn while its parent is not. A
870
+ * row's parent is the nearest row above it one level up: `trackRows` walks
871
+ * pre-order and `masterRows`' time order keeps children under their parent.
872
+ */
873
+ export function rowsOnScreenAt(rows: readonly FollowRow[], frame: number): boolean[] {
874
+ const out: boolean[] = [];
875
+ // The latest row seen at each depth: the open ancestor of the next one.
876
+ const chain: boolean[] = [];
877
+ for (const row of rows) {
878
+ // An isolated subtree can start below depth 0: its root has no parent row.
879
+ const parentOn = row.depth === 0 ? true : (chain[row.depth - 1] ?? true);
880
+ const on = parentOn && !row.hidden && row.in <= frame && frame < row.out;
881
+ chain[row.depth] = on;
882
+ chain.length = row.depth + 1;
883
+ out.push(on);
884
+ }
885
+ return out;
886
+ }
887
+
888
+ /**
889
+ * THE ROWS THAT CAME ON SCREEN as the playhead moved from `from` to `to`, as
890
+ * one band from the topmost to the lowest of them, or NULL when none did.
891
+ *
892
+ * A difference of two sets, not a crossing of edges, so one answer covers a
893
+ * playback tick that skipped frames, the reverse shuttle (a row comes on as
894
+ * the playhead backs into its end), a scrub, and a loop's wrap to the start
895
+ * (the rows the shot opens with come on again). A row on screen for the whole
896
+ * run never appears, so a full-length background never pins the view.
897
+ *
898
+ * What the band is for is `followScrollTop`: the shot reveal's "nearest", one
899
+ * scroll per appearance and only when it is out of view, never a scroll per
900
+ * frame. Rows run in time order, so playback walks the dock down the list the
901
+ * way the Project's reveal walks it down the shots.
902
+ */
903
+ export function appearedBand(
904
+ rows: readonly FollowRow[],
905
+ from: number,
906
+ to: number,
907
+ ): { top: number; height: number } | null {
908
+ if (from === to || rows.length === 0) return null;
909
+ const before = rowsOnScreenAt(rows, from);
910
+ const after = rowsOnScreenAt(rows, to);
911
+ let top = Infinity;
912
+ let bottom = -Infinity;
913
+ for (let i = 0; i < rows.length; i += 1) {
914
+ if (!after[i] || before[i]) continue;
915
+ const row = rows[i]!;
916
+ top = Math.min(top, row.top);
917
+ bottom = Math.max(bottom, row.top + row.height);
918
+ }
919
+ return top === Infinity ? null : { top, height: bottom - top };
920
+ }
921
+
922
+ // ── THE MASTER STRIP, AND THE TWO CONTEXTS OVER IT ──────────────────────────
923
+ //
924
+ // The dock was two levels once, keyed on `state.sceneId` — which follows the
925
+ // PLAYHEAD, so the lower half changed under the author every time they scrubbed
926
+ // across a cut. That is the thing that was deleted, and it stays deleted.
268
927
  //
269
- // The dock used to be two levels: a scene strip on top and the tracks of
270
- // WHICHEVER SCENE THE PLAYHEAD WAS IN underneath. One bit, `state.sceneId`,
271
- // decided what the lower half showed.
928
+ // What replaced it is a real context bit, `state.enteredScene`, written only by
929
+ // the enter/leave verbs:
272
930
  //
273
- // That bit is gone from the geometry. Every scene's rows are on screen at once,
274
- // at their MASTER positions, grouped in master order — one continuous strip, the
275
- // way the reference editor and every NLE draw a sequence. `state.sceneId` is
276
- // still the inspector's subject and the URL's, but nothing about the timeline's
277
- // layout reads it any more, which is what makes "drag a layer in scene 3 while
278
- // the playhead sits in scene 1" a gesture rather than a mode change.
931
+ // SCENE context — every shot's rows at their MASTER positions, in master
932
+ // order, one continuous strip: the sequence read, which is what answers
933
+ // "what happens between the logo landing and the price appearing" when
934
+ // those two things are in different shots. The per-element rows are opt-in
935
+ // here (the summary toggle) because the subject is the arrangement.
936
+ // SHOT context — one group, re-zeroed on the shot the author OPENED, plus
937
+ // the master-timed marker and audio lanes clipped to its window. Not the
938
+ // old scene-follows-playhead mode: nothing but the enter verb can change
939
+ // what is on screen, so a scrub cannot move the author.
279
940
  //
280
941
  // BARS STAY SCENE-LOCAL and the GROUP carries the offset. The alternative —
281
942
  // baking `start` into every bar's `frame` — turns each bar into a master frame
282
943
  // that every gesture has to convert back before it can call a document op, and
283
- // one missed conversion moves a block a whole scene. A drag DELTA is
944
+ // one missed conversion moves a bar a whole scene. A drag DELTA is
284
945
  // offset-invariant, so keeping the offset out of the bar makes the conversion a
285
946
  // rendering detail and never a document one.
947
+ //
948
+ // The shot context's re-zero rides that same split, and this is the sharp edge
949
+ // in it: `offsetFrames` STAYS the scene's true master start, because SNAPPING
950
+ // converts through it (`beginSnap` freezes MASTER targets) and `ChannelRow`
951
+ // round-trips the master playhead through it. The re-zero is a second,
952
+ // DRAW-ONLY number — `originFrames` — subtracted in the `left` expressions and
953
+ // nowhere else. Folding the two into one is how a snap in the shot context
954
+ // would land on a frame the same drag reaches in the scene context.
286
955
 
287
956
  /** A layer's half-open SCENE-LOCAL lifespan, with the default filled in. */
288
957
  export interface Lifespan {
@@ -291,7 +960,8 @@ export interface Lifespan {
291
960
  }
292
961
 
293
962
  /**
294
- * The layer's bar, with an ABSENT `lifetime` resolved to the whole scene.
963
+ * The layer's bar, with an ABSENT `lifetime` resolved to the whole scene, and
964
+ * an OPEN `out` resolved to the container's end.
295
965
  *
296
966
  * The document's field is optional and absent means "always" (`layerAliveAt`).
297
967
  * A bar needs two ends, so the default is materialised HERE, in one place,
@@ -299,19 +969,323 @@ export interface Lifespan {
299
969
  * their own. Nothing is written by reading it: the first edge drag is what puts
300
970
  * a `lifetime` in the document.
301
971
  *
302
- * A stored lifespan is returned AS IT IS, even when it runs past the end of the
303
- * scene. Clamping it to the scene here would silently rewrite a document the
304
- * first time the dock drew it, and the part past the end is exactly the thing
305
- * the author needs to see in order to pull it back.
972
+ * An open end (`isOpenEndedOut`: a component placed by an agent with only a
973
+ * start frame) is the same default at one end: "until the container ends". It
974
+ * resolves here for the same reason, and every reader downstream gets it for
975
+ * free. Read raw it was a bar nine quadrillion frames long: the content extent
976
+ * followed it, and the ruler pushed a tick every few frames of that until V8
977
+ * refused the array and the dock died with the tab frozen.
978
+ *
979
+ * A stored lifespan is otherwise returned AS IT IS, even when it runs past the
980
+ * end of the scene. Clamping it to the scene here would silently rewrite a
981
+ * document the first time the dock drew it, and the part past the end is
982
+ * exactly the thing the author needs to see in order to pull it back. The open
983
+ * end is not that clamp: the marker has no position to show or pull back, and
984
+ * nothing is written by resolving it either. A drag that moves the out edge
985
+ * starts from the resolved end and writes a finite one, as it does for an
986
+ * absent lifetime; one that moves only the in edge keeps the marker
987
+ * (`writeLifespan`).
988
+ *
989
+ * NEVER BEFORE `in + MIN_LIFESPAN_FRAMES`. A Shot shortened under a placement
990
+ * (a later `update_scene`, a razor cut before its entrance) leaves `in` at or
991
+ * past the end, and the end alone is an inverted bar: a negative duration in
992
+ * the inspector, an extent that stops short of the bar, and an in edge that
993
+ * jumps back to `out - 1` on the first nudge. A finite window there keeps its
994
+ * real `out` and the extent follows it; the open one is drawn as its shortest
995
+ * bar, at its start, and the extent follows that.
996
+ */
997
+ export function lifespanOf(layer: Layer3D, containerDurationFrames: number): Lifespan {
998
+ // The rule itself lives in shot (`layerLifespan`), shared with the clip ops
999
+ // and the motion presets, so the dock cannot draw a window they disagree on.
1000
+ return layerLifespan(layer, containerDurationFrames);
1001
+ }
1002
+
1003
+ // ── HOW TALL A LIFESPAN ROW IS, AND WHY THE NUMBER LIVES HERE ───────────────
1004
+ //
1005
+ // `TimelineDock`'s virtual window builds PREFIX SUMS out of `expandedLayerHeight`
1006
+ // and positions spacers with them, so a row whose CSS height disagrees with the
1007
+ // number the sum used scrolls to the wrong place and the mismatch grows one row
1008
+ // at a time. The heights are therefore constants both sides read, not a literal
1009
+ // in the sheet and a literal in the sum.
1010
+ //
1011
+ // A VIDEO row is a CLIP row: a filmstrip over the recording's own waveform. It
1012
+ // is 56 tall against an ordinary row's 28, and that budget is spent exactly:
1013
+ //
1014
+ // 32 picture
1015
+ // 1 EDGE between the two, and an edge is what reads. In the light theme
1016
+ // `--panel-raised` IS `--panel`, so a "surface step up" between the strip
1017
+ // and the wave would be invisible; a line is not.
1018
+ // 22 waveform
1019
+ // ──
1020
+ // 55 the bar, inside the row's own 1px bottom border (`box-sizing` is
1021
+ // border-box globally, so 55 + 1 = `VIDEO_ROW_H`).
1022
+
1023
+ /** An ordinary lifespan row, paired with `.lifespan-row`'s CSS height, and the
1024
+ * left column's outline row (`.layer-row`, var(--row-h)): one row height. */
1025
+ export const LAYER_ROW_H = 28;
1026
+ /** One `ShotRow`, paired with `.shot-row`'s CSS height. The vertical follow
1027
+ * reveals exactly this much: a shot's own row, not its expanded contents,
1028
+ * which can be taller than the dock. 28, the row ruling: the 20px clip sits
1029
+ * 4px under the row's top inside the 1px bottom border. */
1030
+ export const SHOT_ROW_H = 28;
1031
+ /** A video row: the two strips plus their edge, plus the row's own border. */
1032
+ export const VIDEO_ROW_H = 56;
1033
+ export const VIDEO_PICTURE_H = 32;
1034
+ export const VIDEO_EDGE_H = 1;
1035
+ export const VIDEO_WAVE_H = 22;
1036
+ /** One `.channel-row`: an animated property, the "No animation" hint, or the
1037
+ * "+ property" row that ends every expansion. Paired with the CSS height, and
1038
+ * the unit `expandedLayerHeight` and `groupHeights` count channel rows in.
1039
+ * 28, the row ruling: a channel row is a row like any other, not a sub-row. */
1040
+ export const CHANNEL_ROW_H = 28;
1041
+ /** One `.block-row`: the shot's camera. Paired with the CSS height, and
1042
+ * counted by the same two sums. */
1043
+ export const BLOCK_ROW_H = 28;
1044
+
1045
+ /**
1046
+ * How wide a bar's trim handles are, in px, measured INSIDE its two edges.
1047
+ *
1048
+ * A fixed 8px reads fine on a two-second bar and is absurd on a six-frame one:
1049
+ * at 16px wide the two handles ARE the bar, and the body gesture — the one an
1050
+ * author reaches for first — has nowhere left to be pressed. So: a third of the
1051
+ * bar, never below 3px (smaller is not a pointer target), never above half the
1052
+ * bar (the body has to survive), and never above 10px.
306
1053
  */
307
- export function lifespanOf(layer: Layer3D, scene: Scene3D): Lifespan {
308
- const life = layer.lifetime;
309
- if (life === undefined) return { in: 0, out: Math.max(1, Math.trunc(scene.durationFrames)) };
310
- return { in: Math.trunc(life.in), out: Math.trunc(life.out) };
1054
+ export function trimHandlePx(widthPx: number): number {
1055
+ const w = Number.isFinite(widthPx) ? Math.max(0, widthPx) : 0;
1056
+ return Math.min(Math.max(w / 3, 3), w / 2, 10);
1057
+ }
1058
+
1059
+ /** A stretch of SCENE-LOCAL frames, half-open, where the picture is frozen. */
1060
+ export interface HeldSpan {
1061
+ from: number;
1062
+ to: number;
1063
+ }
1064
+
1065
+ /**
1066
+ * The clip facts a VIDEO row draws with, derived ONCE per document beside the
1067
+ * row rather than per render inside it.
1068
+ *
1069
+ * `masterRows` is memoised on the document's identity and the dock re-renders
1070
+ * for every zoom tick, so a row that re-derived its own clip arithmetic would
1071
+ * re-run `videoClipOf` and re-resolve two assets sixty times a second during a
1072
+ * zoom drag. It also keeps `LifespanRow` free of `doc`: the row reads its own
1073
+ * model and the selection, which is the rule the rest of this lane follows.
1074
+ */
1075
+ export interface VideoRow {
1076
+ /** The PICTURE's asset row, which is what `Filmstrip` keys its sheet on (its
1077
+ * content-addressed `path`). Undefined when the document carries no row for
1078
+ * `video.asset`, which draws a bar with no strip rather than refusing. */
1079
+ pictureAsset: ShotAsset | undefined;
1080
+ /** The inline soundtrack's asset, handed to the waveform as it is. The OBJECT
1081
+ * and not its path, because `AudioWaveform` fetches on the identity of this
1082
+ * prop and a fresh `{ path }` per render would refetch forever. */
1083
+ soundAsset: ShotAsset | undefined;
1084
+ /**
1085
+ * The inline sound's slice of its source, or null when this recording
1086
+ * contributes none to the mix (`videoSoundClip`'s three gates: hidden,
1087
+ * retimed or looping, or a window that never opens).
1088
+ *
1089
+ * Only the two fields the waveform cuts on, with `offsetFrames`' absent-is-0
1090
+ * already resolved: the bar's placement is the LAYER's window, which the row
1091
+ * is drawing anyway, so carrying a whole `AudioClipRef` here would be a second
1092
+ * copy of the frames the lifespan already holds.
1093
+ */
1094
+ sound: { offsetFrames: number; durationFrames: number } | null;
1095
+ /** `video.sound.muted`. Read off the LAYER, not off `sound`, because the
1096
+ * button must still say what it would toggle when the clip above is null. */
1097
+ muted: boolean;
1098
+ /**
1099
+ * `video.offsetFrames`, absent resolved to 0: the SCENE frame the source's
1100
+ * t=0 lands on, and the thing a SLIP moves.
1101
+ *
1102
+ * Carried because it is a slip's snap anchor. A slip leaves the window where
1103
+ * it is, so snapping the window's `in` would snap a number that is not
1104
+ * moving: the delta would land the FOOTAGE wherever the constant gap between
1105
+ * `in` and the offset happened to put it. `clip.leadFrom` is the same
1106
+ * crossing but is null whenever the window does not sit on the frozen side of
1107
+ * it, which is most of the time and exactly when a slip is being dragged.
1108
+ */
1109
+ offsetFrames: number;
1110
+ /** Present only when the layer declares a soundtrack at all. Nothing to mute
1111
+ * is not a muted thing, and a dead button is worse than no button. */
1112
+ hasSound: boolean;
1113
+ clip: VideoClipInfo;
1114
+ /** Where the FIRST picture is frozen because the footage has not started,
1115
+ * clipped to the window. Null when no frame of the window is. */
1116
+ lead: HeldSpan | null;
1117
+ /** Where the LAST picture is frozen because the footage ran out. */
1118
+ hold: HeldSpan | null;
1119
+ /** The layer whose window BEGINS where this one's ends, so the pair has a
1120
+ * seam a roll can move. `rollSeam` is the authority on whether it does. */
1121
+ rollPartner: string | null;
311
1122
  }
312
1123
 
313
1124
  export interface MasterRow extends TrackRowModel {
314
1125
  lifespan: Lifespan;
1126
+ /** Set for a `kind: "video"` layer in a SCENE. Absent inside a component
1127
+ * definition, where a video has no sound and no seam to roll: its frames are
1128
+ * def-local and the placing instance decides where they land. */
1129
+ video?: VideoRow;
1130
+ }
1131
+
1132
+ /**
1133
+ * The two frozen stretches of a clip's window, on the scene clock.
1134
+ *
1135
+ * `videoClipOf` reports the two CROSSINGS and leaves the rounding here. What
1136
+ * this function wants out of a crossing is ONE number either way: the boundary
1137
+ * frame between the live stretch and the frozen one, which both halves of a
1138
+ * half-open span are then written from. That number falls out of the clamp in
1139
+ * `sourceTimeAt`, not out of a rounding preference.
1140
+ *
1141
+ * ── THE ROUNDING IS THE RATE'S SIGN, NOT THE SIDE ────────────────────────
1142
+ *
1143
+ * Source time at a scene frame is `(f - offsetFrames) * timeScale`, read at the
1144
+ * frame's LEFT EDGE, which is `VideoClipInfo`'s stated convention. Work out
1145
+ * which frames the clamp is holding, on each end and each sign:
1146
+ *
1147
+ * FORWARD the lead clamps below t=0, so a frame is frozen while `f < cross`,
1148
+ * and the run-out clamps above the duration, so a frame is frozen once
1149
+ * `f >= cross`. Both boundaries are the smallest integer at or past the
1150
+ * crossing, which is `ceil(cross)`.
1151
+ *
1152
+ * BACKWARD source time runs DOWN as the scene runs on, so both inequalities
1153
+ * flip: the lead freezes while `f > cross` and the run-out while `f <= cross`.
1154
+ * Both boundaries are now the smallest integer STRICTLY past the crossing,
1155
+ * which is `floor(cross) + 1`.
1156
+ *
1157
+ * The two agree on a fractional crossing and differ by exactly one on a whole
1158
+ * one, which is the case that actually ships: `leadFrom` IS `offsetFrames`, a
1159
+ * whole frame in every document that validates. Ceiling both signs therefore
1160
+ * shaded the last LIVE frame of every reversed clip as frozen, on every one of
1161
+ * them, and the fractional rows that would have caught it agree by luck.
1162
+ *
1163
+ * WHICH END IS WHICH is the sign too, and `VideoClipInfo`'s header states it:
1164
+ * the frozen first picture is the window's HEAD forward and its TAIL backward,
1165
+ * and the run-out is whichever one is left. Derived here beside the rounding
1166
+ * rather than at the call site, so the one branch the sign owns is one branch.
1167
+ */
1168
+ function heldSpan(
1169
+ win: Lifespan,
1170
+ cross: number | null,
1171
+ end: "lead" | "hold",
1172
+ forward: boolean,
1173
+ ): HeldSpan | null {
1174
+ if (cross === null || !Number.isFinite(cross)) return null;
1175
+ const at = forward ? Math.ceil(cross) : Math.floor(cross) + 1;
1176
+ const head = end === "lead" ? forward : !forward;
1177
+ if (head) {
1178
+ const to = Math.min(at, win.out);
1179
+ return to > win.in ? { from: win.in, to } : null;
1180
+ }
1181
+ const from = Math.max(at, win.in);
1182
+ return from < win.out ? { from, to: win.out } : null;
1183
+ }
1184
+
1185
+ /**
1186
+ * One video layer's row model.
1187
+ *
1188
+ * `byIn` maps a scene-local `in` frame to the CLIP that starts there, built
1189
+ * once per scene so finding an abutting neighbour is a lookup rather than a
1190
+ * scan per row; `null` there is a frame two clips contest, which names no
1191
+ * partner at all (see `clipsByIn`). A named one is still only a CANDIDATE:
1192
+ * `rollSeam` decides whether the pair actually has a seam, because that verdict
1193
+ * has exactly one home (a collapsed `legalRollRange` reads the same as a
1194
+ * genuine one-frame range, which is the confusion `rollSeam` exists to end).
1195
+ */
1196
+ function videoRowOf(
1197
+ doc: Doc3D,
1198
+ scene: Scene3D,
1199
+ layer: VideoLayer3D,
1200
+ byIn: ReadonlyMap<number, string | null>,
1201
+ ): VideoRow {
1202
+ const clip = videoClipOf(layer, scene.durationFrames, doc.fps, doc.assets[layer.video.asset]);
1203
+ const sound = videoSoundClip(layer, scene.durationFrames);
1204
+ const forward = (layer.video.timeScale ?? 1) > 0;
1205
+ const win: Lifespan = { in: clip.in, out: clip.out };
1206
+ const candidate = byIn.get(clip.out);
1207
+ return {
1208
+ pictureAsset: doc.assets[layer.video.asset],
1209
+ soundAsset: sound === null ? undefined : doc.assets[sound.asset],
1210
+ sound: sound === null ? null : { offsetFrames: sound.offsetFrames ?? 0, durationFrames: sound.durationFrames },
1211
+ muted: layer.video.sound?.muted === true,
1212
+ hasSound: layer.video.sound !== undefined,
1213
+ offsetFrames: layer.video.offsetFrames ?? 0,
1214
+ clip,
1215
+ lead: heldSpan(win, clip.leadFrom, "lead", forward),
1216
+ hold: heldSpan(win, clip.holdFrom, "hold", forward),
1217
+ // `typeof` rather than a nullish test, because the map has THREE answers
1218
+ // and two of them mean "no partner": absent (nothing starts there) and null
1219
+ // (two clips do, so which one is not the index's to pick).
1220
+ rollPartner:
1221
+ typeof candidate !== "string" || rollSeam(doc, scene.id, layer.id, candidate) === null ? null : candidate,
1222
+ };
1223
+ }
1224
+
1225
+ /**
1226
+ * IS THIS A CLIP ROW? The answer is the row's clip facts, or nothing.
1227
+ *
1228
+ * ONE PREDICATE, AND IT HAS TWO READERS, because those two are a pair:
1229
+ * `LifespanRow` draws a clip bar from it, and `expandedLayerHeight` in
1230
+ * TimelineDock sizes the row from it. The dock's virtual window builds PREFIX
1231
+ * SUMS out of that height and positions every spacer beneath this row with
1232
+ * them, so the two answers are one fact or the list shears.
1233
+ *
1234
+ * THEY DISAGREED INSIDE A COMPONENT DEFINITION, which is the case that needs
1235
+ * both halves and not just the layer's kind. `defGroup` carries no `video` on
1236
+ * its rows on purpose: a definition's frames are def-local, so its video has no
1237
+ * sound and no seam, and the placing instance decides where they land. The
1238
+ * height keyed on `layer.kind === "video"` alone, so the dock reserved 56px,
1239
+ * the row drew 28, and every row below it sat 28px out.
1240
+ *
1241
+ * It also answers the stale-row case the render has always needed: a row whose
1242
+ * layer has just been deleted under a render still carries `row.video`, and a
1243
+ * 56px bar drawn where the prefix sum said 28 shears the same list the same
1244
+ * way.
1245
+ *
1246
+ * It lives HERE, beside `videoRowOf` which mints the thing it reads, and not in
1247
+ * the component that draws it: a predicate two panels answer with is a model
1248
+ * fact, and importing one leaf component from another to get at it is the
1249
+ * shape that made the two disagree in the first place.
1250
+ */
1251
+ export function clipRowOf(
1252
+ layer: Layer3D | undefined,
1253
+ row: { video?: VideoRow | undefined },
1254
+ ): VideoRow | undefined {
1255
+ return layer?.kind === "video" ? row.video : undefined;
1256
+ }
1257
+
1258
+ /**
1259
+ * Scene-local `in` frame -> the CLIP that starts there, or null when more than
1260
+ * one does.
1261
+ *
1262
+ * ── A ROLL IS A CLIP GESTURE, AND THIS INDEX IS WHERE THAT IS TRUE ──────
1263
+ *
1264
+ * The header's own sentence for a roll is "this clip's tail and the next clip's
1265
+ * head trade frames", the handle renders on clip rows alone, and the range the
1266
+ * seam is bounded by (`legalRollRange`) is built out of both partners' FOOTAGE
1267
+ * bounds. Indexing every layer kind made a liar of all three: a rect that
1268
+ * happened to abut a clip became its partner, and the drag then moved a seam
1269
+ * between a recording and a rectangle. So only `kind: "video"` layers are
1270
+ * candidates, and a rect abutting a clip simply is not one.
1271
+ *
1272
+ * ── AND NULL IS A REFUSAL, NOT A MISS ───────────────────────────────────
1273
+ *
1274
+ * This used to be first-writer-wins in `layersById` order, which is an order no
1275
+ * author can see and no document promises: two clips starting on the same frame
1276
+ * meant the partner you rolled against depended on key order, and the same
1277
+ * gesture on the same picture could write to either one. Refusal over
1278
+ * corruption: a contested frame has no unambiguous "next clip", so it names
1279
+ * none, the handle does not render, and the author trims an edge instead.
1280
+ */
1281
+ function clipsByIn(scene: Scene3D): Map<number, string | null> {
1282
+ const out = new Map<number, string | null>();
1283
+ for (const layer of Object.values(scene.layersById)) {
1284
+ if (layer.kind !== "video") continue;
1285
+ const at = lifespanOf(layer, scene.durationFrames).in;
1286
+ out.set(at, out.has(at) ? null : layer.id);
1287
+ }
1288
+ return out;
315
1289
  }
316
1290
 
317
1291
  /** One scene's whole block of rows, placed on the master timeline. */
@@ -323,15 +1297,54 @@ export interface MasterSceneGroup {
323
1297
  end: number;
324
1298
  /** The camera's shots. SCENE-LOCAL, like every other bar here. */
325
1299
  camera: BlockBar[];
1300
+ /** The camera's POSES (P4), derived once with the group rather than per
1301
+ * render of the row: `cameraMarks` scans a path move's frames to invert its
1302
+ * ease, and the dock re-renders on every ppf change — so a zoom drag would
1303
+ * otherwise re-run that scan sixty times a second. */
1304
+ cameraKeys: CameraMark[];
326
1305
  rows: MasterRow[];
327
1306
  }
328
1307
 
1308
+ /**
1309
+ * THE DOCK READS IN TIME ORDER: siblings sorted by where their lifespan starts,
1310
+ * earliest on top. `rows` is a pre-order walk (`trackRows`), front first, so a
1311
+ * tie keeps the stacking order. Children stay under their parent and are sorted
1312
+ * among themselves the same way. The Layers panel keeps the stacking order;
1313
+ * this is the dock's reading only, since a timeline is read left to right and
1314
+ * top to bottom.
1315
+ */
1316
+ export function timeOrder<R extends { depth: number; lifespan: Lifespan }>(rows: readonly R[]): R[] {
1317
+ type Node = { row: R; kids: Node[] };
1318
+ const roots: Node[] = [];
1319
+ const path: Node[] = [];
1320
+ for (const row of rows) {
1321
+ const node: Node = { row, kids: [] };
1322
+ path.length = Math.min(path.length, row.depth);
1323
+ (path[path.length - 1]?.kids ?? roots).push(node);
1324
+ path.push(node);
1325
+ }
1326
+ const out: R[] = [];
1327
+ const emit = (nodes: Node[]): void => {
1328
+ // `sort` is stable, so equal starts keep the walk's front-first order.
1329
+ for (const n of [...nodes].sort((a, b) => a.row.lifespan.in - b.row.lifespan.in)) {
1330
+ out.push(n.row);
1331
+ emit(n.kids);
1332
+ }
1333
+ };
1334
+ emit(roots);
1335
+ return out;
1336
+ }
1337
+
329
1338
  /** Every scene's rows in MASTER order, each group carrying the offset its bars
330
1339
  * are drawn at. */
331
1340
  export function masterRows(doc: Doc3D, isolation: string | null = null): MasterSceneGroup[] {
332
1341
  return sceneWindows(doc).flatMap((w) => {
333
1342
  const scene = doc.scenesById[w.id];
334
1343
  if (scene === undefined) return [];
1344
+ // Built from the WHOLE scene and not from the rows: an isolation narrows
1345
+ // what is drawn, and a clip still abuts the neighbour it abuts whether or
1346
+ // not that neighbour has a row on screen.
1347
+ const byIn = clipsByIn(scene);
335
1348
  return [
336
1349
  {
337
1350
  id: w.id,
@@ -339,13 +1352,22 @@ export function masterRows(doc: Doc3D, isolation: string | null = null): MasterS
339
1352
  start: w.start,
340
1353
  end: w.end,
341
1354
  camera: cameraBars(scene),
1355
+ cameraKeys: cameraMarks(scene, doc.fps),
342
1356
  // The isolation only narrows the scene it LIVES IN. A master timeline
343
1357
  // shows every scene, and the subtree exists in exactly one of them —
344
1358
  // scoping the others by an id they do not contain would empty them.
345
- rows: trackRows(scene, scene.layersById[isolation ?? ""] === undefined ? null : isolation).map((row) => ({
346
- ...row,
347
- lifespan: lifespanOf(scene.layersById[row.layerId]!, scene),
348
- })),
1359
+ rows: timeOrder(
1360
+ trackRows(scene, scene.layersById[isolation ?? ""] === undefined ? null : isolation).map((row) => {
1361
+ const layer = scene.layersById[row.layerId]!;
1362
+ const lifespan = lifespanOf(layer, scene.durationFrames);
1363
+ // Spread-or-omit rather than `video: undefined`, because
1364
+ // `exactOptionalPropertyTypes` is on and an explicitly undefined
1365
+ // optional is not the same thing as an absent one.
1366
+ return layer.kind === "video"
1367
+ ? { ...row, lifespan, video: videoRowOf(doc, scene, layer, byIn) }
1368
+ : { ...row, lifespan };
1369
+ }),
1370
+ ),
349
1371
  },
350
1372
  ];
351
1373
  });
@@ -354,7 +1376,7 @@ export function masterRows(doc: Doc3D, isolation: string | null = null): MasterS
354
1376
  /**
355
1377
  * How wide the time area has to be, in MASTER frames.
356
1378
  *
357
- * Not `docDurationFrames`: a lifespan or a block may sit past the end of its
1379
+ * Not `docDurationFrames`: a lifespan or a camera move may sit past the end of its
358
1380
  * scene (nothing forbids it, and the runtime simply never reaches it), and a
359
1381
  * bar the author cannot scroll to is a bar they cannot drag back.
360
1382
  */
@@ -363,13 +1385,97 @@ export function masterContentFrames(doc: Doc3D, groups: readonly MasterSceneGrou
363
1385
  for (const group of groups) {
364
1386
  for (const row of group.rows) {
365
1387
  end = Math.max(end, group.start + row.lifespan.out);
366
- for (const bar of row.blocks) end = Math.max(end, group.start + bar.frame + bar.durationFrames);
367
1388
  }
368
1389
  for (const bar of group.camera) end = Math.max(end, group.start + bar.frame + bar.durationFrames);
369
1390
  }
1391
+ // The two MASTER-timed lanes, on the same argument the rows make: a clip
1392
+ // dragged past the last shot has to stay scrollable-to, or it cannot be
1393
+ // dragged back. They were left out while the lanes drew unclipped and it did
1394
+ // not show; the shot context clips them to its window, so an extent that
1395
+ // stopped short would now hide the tail of a bed rather than let it overhang.
1396
+ for (const { clip } of masterAudioClips(doc)) {
1397
+ end = Math.max(end, Math.trunc(clip.frame) + Math.max(1, Math.trunc(clip.durationFrames)));
1398
+ }
1399
+ for (const marker of doc.markers ?? []) end = Math.max(end, Math.trunc(marker.frame) + 1);
370
1400
  return end;
371
1401
  }
372
1402
 
1403
+ /**
1404
+ * ONE SHOT's content extent, in SHOT-LOCAL frames — `masterContentFrames`' twin
1405
+ * for the shot context, the way `defContentFrames` is for the component one.
1406
+ *
1407
+ * Never `masterContentFrames` with one group: that floors at the whole
1408
+ * document's duration, so an eight-frame shot inside a fifty-second video would
1409
+ * open with a ruler fifty seconds wide and its own bars in the first pixel.
1410
+ */
1411
+ export function shotContentFrames(group: MasterSceneGroup): number {
1412
+ let end = group.end - group.start;
1413
+ for (const row of group.rows) {
1414
+ end = Math.max(end, row.lifespan.out);
1415
+ }
1416
+ for (const bar of group.camera) end = Math.max(end, bar.frame + bar.durationFrames);
1417
+ return end;
1418
+ }
1419
+
1420
+ /**
1421
+ * THE ISOLATED SUBTREE'S LIFESPAN UNION, in SCENE-LOCAL frames: the earliest
1422
+ * `in` and the latest `out` over the root and every layer under it, the same
1423
+ * rows `trackRows` lists for that isolation. Null when the scene does not hold
1424
+ * the id, or the union is empty.
1425
+ *
1426
+ * What the dock fits on entering isolation, and the span its follow holds to,
1427
+ * so an object that lives for one second of a ten-second shot is not drawn as
1428
+ * a sliver nor scrolled away from while the rest of the shot plays.
1429
+ */
1430
+ export function isolationSpan(scene: Scene3D, isolation: string): Lifespan | null {
1431
+ if (scene.layersById[isolation] === undefined) return null;
1432
+ let from = Infinity;
1433
+ let to = -Infinity;
1434
+ for (const row of trackRows(scene, isolation)) {
1435
+ const layer = scene.layersById[row.layerId];
1436
+ if (layer === undefined) continue;
1437
+ const life = lifespanOf(layer, scene.durationFrames);
1438
+ from = Math.min(from, life.in);
1439
+ to = Math.max(to, life.out);
1440
+ }
1441
+ return Number.isFinite(from) && to > from ? { in: from, out: to } : null;
1442
+ }
1443
+
1444
+ /** One audio row, the master bed's lane and a shot's own Sound row alike.
1445
+ * Paired with `.audio-lane` and `.scene-audio-row`'s CSS height. */
1446
+ export const AUDIO_ROW_H = 32;
1447
+ /** `.marker-lane`'s CSS height, its 1px top border included. */
1448
+ export const MARKER_LANE_H = 20;
1449
+ /** `.dock-scroll`'s padding-bottom (`--space-3`). */
1450
+ const DOCK_SCROLL_PAD_H = 6;
1451
+
1452
+ /**
1453
+ * THE DOCK BODY'S HEIGHT WITH EVERY SHOT FOLDED, in px: the ruler plus the rows
1454
+ * a first look shows. `fitShell` sizes an untouched dock to this, so the stage
1455
+ * gets every pixel the timeline does not draw.
1456
+ *
1457
+ * Inside a shot: Shot, Camera and Sound, plus the master bed when one overlaps
1458
+ * the shot (`AudioLane`'s own early return). Over the Project: one row per shot
1459
+ * and the separators between them, plus the master bed, which is always drawn
1460
+ * there. The marker lane in both, once the document has a marker.
1461
+ */
1462
+ export function foldedDockContentH(doc: Doc3D, enteredScene: string | null): number {
1463
+ const windows = sceneWindows(doc);
1464
+ const shot = enteredScene === null ? undefined : windows.find((w) => w.id === enteredScene);
1465
+ let rows: number;
1466
+ if (shot !== undefined) {
1467
+ const bed = Object.values(doc.audio).some((clip) => {
1468
+ const start = Math.trunc(clip.frame);
1469
+ return start + Math.max(1, Math.trunc(clip.durationFrames)) > shot.start && start < shot.end;
1470
+ });
1471
+ rows = SHOT_ROW_H + BLOCK_ROW_H + AUDIO_ROW_H + (bed ? AUDIO_ROW_H : 0);
1472
+ } else {
1473
+ rows = windows.length * SHOT_ROW_H + Math.max(0, windows.length - 1) + AUDIO_ROW_H;
1474
+ }
1475
+ const markers = (doc.markers ?? []).length > 0 ? MARKER_LANE_H : 0;
1476
+ return DOCK_RULER_H + rows + markers + DOCK_SCROLL_PAD_H;
1477
+ }
1478
+
373
1479
  /**
374
1480
  * The dock's one time string.
375
1481
  *
@@ -395,11 +1501,202 @@ export function timecode(frame: number, fps: Rational): string {
395
1501
  return minutes === 0 ? fixed : `${minutes}:${fixed.padStart(5, "0")}`;
396
1502
  }
397
1503
 
398
- // ── THE LIFESPAN'S THREE GESTURES ───────────────────────────────────────────
1504
+ /**
1505
+ * THE CHROME'S ONE DURATION STRING: seconds, two decimals, no space ("18.00s",
1506
+ * "2.50s", "125.50s"). Paper's format, and the founder's ruling: a LENGTH
1507
+ * reads this way everywhere it is printed (a shot's row, the Tool footer, the
1508
+ * inspector head, the storyboard, a sound's trailing value), so one shot is
1509
+ * one string wherever the eye meets it. POSITIONS keep the clock (`timecode`,
1510
+ * `clock`, the rulers) and frame counts keep "60f": a clock says
1511
+ * where, this says how long, and the two never share a form.
1512
+ *
1513
+ * Never a minute prefix: a length is compared against other lengths, and
1514
+ * "1:05" beside "58.00s" would make the eye convert. A length is never
1515
+ * negative and never NaN, so either reads as nothing at all.
1516
+ */
1517
+ export function formatDuration(seconds: number): string {
1518
+ return `${(Number.isFinite(seconds) && seconds > 0 ? seconds : 0).toFixed(2)}s`;
1519
+ }
1520
+
1521
+ const pad2 = (n: number): string => String(n).padStart(2, "0");
1522
+
1523
+ /**
1524
+ * THE TRANSPORT CHIP'S CLOCK: always "MM:SS.hh" ("00:02.20").
1525
+ *
1526
+ * Padded, unlike `timecode`, because the chip is a fixed slot in the head: a
1527
+ * readout whose width changes at every whole second shoves the suffix beside
1528
+ * it sideways. Rounded to the hundredth in INTEGER hundredths, so 59.996s
1529
+ * carries into "01:00.00" rather than printing "00:60.00". Minutes keep
1530
+ * counting past an hour ("75:00.00").
1531
+ */
1532
+ export function clock(frame: number, fps: Rational): string {
1533
+ const h = Math.round((frame * fps.den * 100) / fps.num);
1534
+ const abs = Math.abs(h);
1535
+ return `${h < 0 ? "-" : ""}${pad2(Math.floor(abs / 6000))}:${pad2(Math.floor(abs / 100) % 60)}.${pad2(abs % 100)}`;
1536
+ }
1537
+
1538
+ /**
1539
+ * THE PLAYHEAD PILL'S READING: decimal seconds, always two places ("2.20",
1540
+ * "8.00"), and a minute prefix only past a minute ("1:07.50"). Unlike
1541
+ * `timecode` a whole second keeps its decimals, so the pill holds one width
1542
+ * while it moves. Rounded in integer hundredths, like `clock`.
1543
+ */
1544
+ export function playheadLabel(frame: number, fps: Rational): string {
1545
+ const h = Math.round((frame * fps.den * 100) / fps.num);
1546
+ const abs = Math.abs(h);
1547
+ const minutes = Math.floor(abs / 6000);
1548
+ const rest = `${Math.floor(abs / 100) % 60}.${pad2(abs % 100)}`;
1549
+ return `${h < 0 ? "-" : ""}${minutes === 0 ? rest : `${minutes}:${rest.padStart(5, "0")}`}`;
1550
+ }
1551
+
1552
+ // ── THE DOCK RULER'S TICKS: THREE TIERS ON A SECONDS LADDER ────────────────
399
1553
  //
400
- // BODY moves the bar AND every block on the layer by the same delta.
401
- // SLIP moves every block and leaves the bar where it is.
402
- // IN / OUT move one end and touch no block at all.
1554
+ // The package `Ruler` steps one tier of labelled ticks over a FRAME ladder,
1555
+ // which is what a keyframe lane wants. The dock spans a whole video, so it
1556
+ // counts SECONDS, and three tiers (labelled majors, mids, minors) say where in
1557
+ // a second the playhead is without a label on every tick.
1558
+ //
1559
+ // Each row is [major, mid, minor] in seconds. Every ratio inside a row is an
1560
+ // integer, so all three tiers are read off ONE index over the finest step and
1561
+ // a tick is never drawn twice.
1562
+
1563
+ /** [major, mid, minor] seconds; `null` is no minor tier at this zoom. */
1564
+ const RULER_LADDER: readonly (readonly [number, number, number | null])[] = [
1565
+ [0.1, 0.05, null],
1566
+ [0.2, 0.1, null],
1567
+ [0.5, 0.25, 0.05],
1568
+ [1, 0.5, 0.25],
1569
+ [2, 1, 0.25],
1570
+ [5, 1, 0.5],
1571
+ [10, 5, 1],
1572
+ [30, 10, 5],
1573
+ [60, 30, 10],
1574
+ [120, 60, 10],
1575
+ [300, 60, 30],
1576
+ [600, 300, 60],
1577
+ [1800, 600, 300],
1578
+ [3600, 1800, 600],
1579
+ ];
1580
+
1581
+ /** The narrowest spacing a labelled major may have: room for "1:30" and air. */
1582
+ export const RULER_LABEL_PX = 64;
1583
+ /** A tier closer than this is noise, not a scale, and is not drawn. */
1584
+ const RULER_TIER_MIN_PX = 6;
1585
+ /** The same ceiling `rulerTicks` holds, for the same reason: a stored extent
1586
+ * far past its Shot must not become millions of spans. */
1587
+ const RULER_MAX_TICKS = 20000;
1588
+
1589
+ export interface DockRulerTick {
1590
+ /** Lane frame (fractional: a quarter second at 30 fps is 7.5 frames). */
1591
+ frame: number;
1592
+ /** Exact seconds, for the label. */
1593
+ seconds: number;
1594
+ /** 0 major (labelled), 1 mid, 2 minor. */
1595
+ tier: 0 | 1 | 2;
1596
+ }
1597
+
1598
+ /**
1599
+ * The ticks from 0 to `extentF` lane frames at `ppf` pixels per frame: the
1600
+ * first ladder row whose major spacing clears `RULER_LABEL_PX`, less any tier
1601
+ * under `RULER_TIER_MIN_PX`, and the row coarsened until the count is bounded.
1602
+ */
1603
+ export function dockRulerTicks(extentF: number, ppf: number, fps: Rational): DockRulerTick[] {
1604
+ const pxPerSecond = (ppf * fps.num) / fps.den;
1605
+ const endS = Number.isFinite(extentF) ? Math.max(0, (extentF * fps.den) / fps.num) : 0;
1606
+ if (!(pxPerSecond > 0)) return [{ frame: 0, seconds: 0, tier: 0 }];
1607
+ let row = RULER_LADDER.findIndex(([major]) => major * pxPerSecond >= RULER_LABEL_PX);
1608
+ if (row === -1) row = RULER_LADDER.length - 1;
1609
+ for (;;) {
1610
+ const [major, mid, minor] = RULER_LADDER[row]!;
1611
+ const steps = [major, mid, minor].filter(
1612
+ (s): s is number => s !== null && s * pxPerSecond >= RULER_TIER_MIN_PX,
1613
+ );
1614
+ const finest = steps[steps.length - 1]!;
1615
+ if (endS / finest <= RULER_MAX_TICKS || row === RULER_LADDER.length - 1) {
1616
+ const majorEvery = Math.round(major / finest);
1617
+ const midEvery = Math.round(mid / finest);
1618
+ const count = Math.min(Math.floor(endS / finest + 1e-9), RULER_MAX_TICKS);
1619
+ const ticks: DockRulerTick[] = [];
1620
+ for (let i = 0; i <= count; i++) {
1621
+ // Rounded to the millisecond so 0.05 * 3 labels "0.15", not 0.15000000000000002.
1622
+ const seconds = Math.round(i * finest * 1000) / 1000;
1623
+ ticks.push({
1624
+ frame: (seconds * fps.num) / fps.den,
1625
+ seconds,
1626
+ tier: i % majorEvery === 0 ? 0 : i % midEvery === 0 ? 1 : 2,
1627
+ });
1628
+ }
1629
+ return ticks;
1630
+ }
1631
+ row++;
1632
+ }
1633
+ }
1634
+
1635
+ /** A ruler label's width per character at `.dock-tick-label`'s 12px numeric
1636
+ * face. An estimate on purpose: reading layout for it would force a reflow per
1637
+ * label on every zoom step, and the face's digits are tabular, so a count of
1638
+ * characters is close to the drawn width. Slightly generous, so an estimate
1639
+ * that is off errs toward hiding a label rather than overlapping two. */
1640
+ const RULER_LABEL_CHAR_PX = 7.4;
1641
+ /** `.dock-tick-label`'s inset from its tick, on either side (var(--space-2)). */
1642
+ const RULER_LABEL_INSET_PX = 4;
1643
+ /** The least air between two labels' boxes. */
1644
+ const RULER_LABEL_GAP_PX = 4;
1645
+
1646
+ export type RulerLabelPlace = "start" | "end" | "hidden";
1647
+
1648
+ /**
1649
+ * WHERE EACH MAJOR'S LABEL GOES: right of its tick ("start"), left of it
1650
+ * ("end", `.is-end`) when a start label would run off the lane, or nowhere.
1651
+ *
1652
+ * The end label reads back INTO the lane, which puts it on top of the previous
1653
+ * major's label whenever the two ticks are closer than both labels' widths:
1654
+ * `dockRulerTicks` only promises `RULER_LABEL_PX` between majors, and "1:30"
1655
+ * beside "1:40" needs more than that once one of them is flipped. So, walking
1656
+ * from the end, a label whose box would reach the next shown one is dropped.
1657
+ * The END label wins, because it is the one that says how long the lane is.
1658
+ */
1659
+ export function rulerLabelPlaces(
1660
+ majors: readonly { x: number; text: string }[],
1661
+ timeW: number,
1662
+ ): RulerLabelPlace[] {
1663
+ const width = (text: string): number => text.length * RULER_LABEL_CHAR_PX;
1664
+ const places = majors.map(({ x, text }): RulerLabelPlace =>
1665
+ x > 0 && x + RULER_LABEL_INSET_PX + width(text) > timeW ? "end" : "start",
1666
+ );
1667
+ let limit = Infinity; // the left edge of the next label that is shown
1668
+ for (let i = majors.length - 1; i >= 0; i--) {
1669
+ const { x, text } = majors[i]!;
1670
+ const left = places[i] === "end" ? x - RULER_LABEL_INSET_PX - width(text) : x + RULER_LABEL_INSET_PX;
1671
+ if (left + width(text) + RULER_LABEL_GAP_PX > limit) {
1672
+ places[i] = "hidden";
1673
+ continue;
1674
+ }
1675
+ limit = left;
1676
+ }
1677
+ return places;
1678
+ }
1679
+
1680
+ /** Seconds rounded to the hundredth, as a number, so `${s}` prints "0.5". */
1681
+ const centi = (s: number): number => Math.round(s * 100) / 100;
1682
+
1683
+ /** THE RULER'S LABEL, in every context: a clock, "0:00", "0:02", "1:15", and
1684
+ * "0:00.5" between seconds. The same position form as `timecode`, so the
1685
+ * ruler, the playhead chip and the marker tooltips read one time. */
1686
+ export function rulerLabel(seconds: number): string {
1687
+ const s = centi(seconds);
1688
+ const m = Math.floor(s / 60);
1689
+ const rest = centi(s - m * 60);
1690
+ const whole = Math.floor(rest);
1691
+ const frac = String(centi(rest - whole)).slice(1); // "" or ".5" / ".25"
1692
+ return `${m}:${pad2(whole)}${frac}`;
1693
+ }
1694
+
1695
+ // ── THE LIFESPAN'S GESTURES: THREE ON ANY BAR ───────────────────────────────
1696
+ //
1697
+ // BODY moves the bar AND every keyframe on the layer by the same delta.
1698
+ // SLIP moves every keyframe and leaves the bar where it is.
1699
+ // IN / OUT move one end and touch no keyframe at all.
403
1700
  //
404
1701
  // Body-versus-edge is After Effects verbatim, and it is not configurable in any
405
1702
  // tool that ships it: dragging a layer's duration bar carries its keyframes,
@@ -407,13 +1704,38 @@ export function timecode(frame: number, fps: Rational): string {
407
1704
  // Premiere's slip tool, Cavalry's offset command — and a timeline that ships
408
1705
  // only the first two gets filed against for it.
409
1706
  //
410
- // EVERY MODE CLAMPS ON THE LEFT ONLY. `block.frame` is validated non-negative
1707
+ // EVERY MODE CLAMPS ON THE LEFT ONLY. A keyframe's frame is validated non-negative
411
1708
  // and a lifespan starting before its scene is meaningless, so zero is a real
412
1709
  // floor; the right has no floor, because a bar hanging past the end of its scene
413
1710
  // is a legible state the author can drag back and a clamp there would freeze the
414
1711
  // default lifespan (which is exactly the whole scene) in place.
415
1712
 
416
- export type LifespanDrag = "body" | "in" | "out" | "slip";
1713
+ // ── AND THE TWO A VIDEO CLIP ADDS ───────────────────────────────────────────
1714
+ //
1715
+ // A word names a gesture on ONE bar, which is all four above ever needed. Two
1716
+ // of the clip gestures need an ID the word cannot hold — which OTHER bars a
1717
+ // body drag is carrying, and which neighbour a roll is rolling against — so
1718
+ // those two are objects.
1719
+ //
1720
+ // THEY ARE OBJECTS RATHER THAN EXTRA ARGUMENTS on purpose: `dragLifespan` on
1721
+ // the store takes `(sceneId, layerId, mode, delta)` and hands `mode` straight
1722
+ // to this function, so a gesture that carries its own payload in the mode needs
1723
+ // no change to the store's signature and cannot be called with the payload
1724
+ // missing.
1725
+
1726
+ /** A body drag carrying every bar in the list, not just the pressed one. */
1727
+ export interface LifespanMove {
1728
+ move: readonly string[];
1729
+ }
1730
+ /** A roll of the seam this bar's `out` shares with `roll`'s `in`. */
1731
+ export interface LifespanRoll {
1732
+ roll: string;
1733
+ }
1734
+ /** A body drag that FANS the bars out: rank i of `staggerOrder` moves i steps. */
1735
+ export interface LifespanStagger {
1736
+ stagger: readonly string[];
1737
+ }
1738
+ export type LifespanDrag = "body" | "in" | "out" | "slip" | "shift" | LifespanMove | LifespanRoll | LifespanStagger;
417
1739
 
418
1740
  /** The shortest lifespan an edge drag may leave behind. `validate.ts` demands
419
1741
  * `out > in`; this is that rule with the integer grid applied. */
@@ -431,34 +1753,91 @@ export function writeLifespan(
431
1753
  mode: LifespanDrag,
432
1754
  delta: number,
433
1755
  ): Doc3D {
434
- const scene = doc.scenesById[sceneId];
435
- const layer = scene?.layersById[layerId];
436
- if (scene === undefined || layer === undefined) return doc;
437
- const life = lifespanOf(layer, scene);
1756
+ // Any CONTAINER (unify P2): the def-local dock drags an interior layer's
1757
+ // lifespan with the definition's id in the sceneId slot.
1758
+ const home = homeOf(doc, sceneId);
1759
+ const layer = home?.layersById[layerId];
1760
+ if (home === null || layer === undefined) return doc;
1761
+ const containerDuration = isDefId(sceneId)
1762
+ ? defDurationFrames(doc.components![sceneId]!)
1763
+ : Math.trunc(doc.scenesById[sceneId]!.durationFrames);
1764
+ const life = lifespanOf(layer, containerDuration);
438
1765
  const d = Math.round(delta);
1766
+ if (!Number.isFinite(d)) return doc;
1767
+
1768
+ // ── THE TWO GESTURES THAT NAME A SECOND LAYER ─────────────────────────
1769
+ //
1770
+ // Both are pair or group edits, so neither can be expressed as a patch of the
1771
+ // pressed layer the way the four word gestures below are, and both go
1772
+ // straight to the op that owns the arithmetic. A roll asks `rollSeam` first
1773
+ // because that is the ONE place a pair's touching is decided: `rollLayers`
1774
+ // would refuse a pair with no seam anyway, and asking here is what turns the
1775
+ // delta into the absolute frame the op wants.
1776
+ if (typeof mode !== "string") {
1777
+ if ("roll" in mode) {
1778
+ const seam = rollSeam(doc, sceneId, layerId, mode.roll);
1779
+ return seam === null ? doc : rollLayers(doc, sceneId, layerId, mode.roll, seam + d);
1780
+ }
1781
+ if ("stagger" in mode) return staggerLayers(doc, sceneId, staggerOrder(home, mode.stagger, containerDuration), d);
1782
+ return moveLayers(doc, sceneId, mode.move, d);
1783
+ }
1784
+
1785
+ // The one-bar slide. `shiftLayer` carries the window, the tracks
1786
+ // and the content clock together, so the picture at the new `in` is the
1787
+ // picture that was at the old one — and it REFUSES rather than clamps, which
1788
+ // is why it is not folded into the clamped `body` case below.
1789
+ if (mode === "shift") return shiftLayer(doc, sceneId, layerId, d);
1790
+
1791
+ // ── THE VIDEO FORK ────────────────────────────────────────────────────
1792
+ //
1793
+ // A clip's edges are bounded by its FOOTAGE as well as by its scene, and its
1794
+ // body carries the recording under it. Writing `lifetime` here would produce
1795
+ // a window over frames the recording does not have (an edge) or slide the
1796
+ // footage under a bar that only moved (a body) — the two states the clip ops
1797
+ // were written to refuse. So a video layer in a SCENE writes through them.
1798
+ //
1799
+ // NOT INSIDE A DEFINITION. The clip ops resolve their layer through
1800
+ // `doc.scenesById`, and a `cmp_` container is not there: they would refuse
1801
+ // every gesture silently. A definition's video has no sound and no seam
1802
+ // either (its frames are def-local; the placing instance decides where they
1803
+ // land), so the ordinary body below is the honest behaviour there.
1804
+ if (layer.kind === "video" && !isDefId(sceneId)) {
1805
+ switch (mode) {
1806
+ case "in":
1807
+ return trimLayer(doc, sceneId, layerId, "in", life.in + d);
1808
+ case "out":
1809
+ return trimLayer(doc, sceneId, layerId, "out", life.out + d);
1810
+ case "slip":
1811
+ return slipLayer(doc, sceneId, layerId, d);
1812
+ case "body":
1813
+ return moveLayers(doc, sceneId, [layerId], d);
1814
+ }
1815
+ }
1816
+
439
1817
  let next: Layer3D;
440
1818
 
441
1819
  switch (mode) {
442
1820
  case "body": {
443
- // ONE clamped delta for the bar and the blocks together. Clamping them
1821
+ // ONE clamped delta for the bar and the keys together. Clamping them
444
1822
  // apart is the bug this shape exists to prevent: the bar would stop at
445
- // zero while its blocks kept sliding, and the author would have retimed
446
- // the animation by dragging the layer.
447
- const moved = Math.max(d, -Math.min(life.in, earliestBlock(layer)));
1823
+ // zero while its keys kept sliding, and the author would have retimed
1824
+ // the animation by dragging the layer. `firstKeyframeFrame` is Infinity
1825
+ // with no keys, so the `Math.min` against it is a no-op.
1826
+ const moved = Math.max(d, -Math.min(life.in, firstKeyframeFrame(layer.tracks)));
448
1827
  if (moved === 0) return doc;
449
1828
  next = {
450
1829
  ...layer,
451
1830
  lifetime: { in: life.in + moved, out: life.out + moved },
452
- blocks: shiftBlocks(layer.blocks, moved),
453
- };
1831
+ ...shiftedKeys(layer, moved),
1832
+ } as Layer3D;
454
1833
  break;
455
1834
  }
456
1835
  case "slip": {
457
- const earliest = earliestBlock(layer);
1836
+ const earliest = firstKeyframeFrame(layer.tracks);
458
1837
  if (!Number.isFinite(earliest)) return doc;
459
1838
  const moved = Math.max(d, -earliest);
460
1839
  if (moved === 0) return doc;
461
- next = { ...layer, blocks: shiftBlocks(layer.blocks, moved) };
1840
+ next = { ...layer, ...shiftedKeys(layer, moved) } as Layer3D;
462
1841
  break;
463
1842
  }
464
1843
  // An edge that lands where it already was returns the document BY
@@ -468,7 +1847,11 @@ export function writeLifespan(
468
1847
  case "in": {
469
1848
  const at = Math.max(0, Math.min(life.out - MIN_LIFESPAN_FRAMES, life.in + d));
470
1849
  if (at === life.in) return doc;
471
- next = { ...layer, lifetime: { in: at, out: life.out } };
1850
+ // An OPEN end stays open: this gesture moved the head, not the tail, and
1851
+ // closing it would end the placement short of a Shot lengthened later.
1852
+ // `splitLayer` and `trimLayer` keep it for the same reason.
1853
+ const out = layer.lifetime !== undefined && isOpenEndedOut(layer.lifetime.out) ? layer.lifetime.out : life.out;
1854
+ next = { ...layer, lifetime: { in: at, out } };
472
1855
  break;
473
1856
  }
474
1857
  case "out": {
@@ -479,19 +1862,156 @@ export function writeLifespan(
479
1862
  }
480
1863
  }
481
1864
 
1865
+ return patchHome(doc, sceneId, {
1866
+ layers: home.layers,
1867
+ layersById: { ...home.layersById, [layerId]: next },
1868
+ });
1869
+ }
1870
+
1871
+ /**
1872
+ * Slide these layers whole, ALL OR NOTHING.
1873
+ *
1874
+ * ── WHY THE WHOLE SELECTION AND NOT THE PRESSED BAR ─────────────────────
1875
+ *
1876
+ * Selecting three clips and dragging one of them moves all three in every NLE,
1877
+ * and the dock already selects the pressed bar before the drag starts. Moving
1878
+ * only the one the pointer is on would silently break the arrangement the
1879
+ * author selected in order to preserve.
1880
+ *
1881
+ * ── WHY IT REFUSES INSTEAD OF MOVING WHAT IT CAN ────────────────────────
1882
+ *
1883
+ * `shiftLayer` refuses — by returning the document it was handed — when a
1884
+ * window would leave the scene or a keyframe would pass zero. Applying the rest
1885
+ * anyway would move part of a selection and leave the rest, which is a
1886
+ * rearrangement the author did not ask for, cannot see the shape of, and undoes
1887
+ * as one entry. So the first refusal refuses the gesture: nothing moves, the
1888
+ * bars stay where they were, and the next pixel of travel is tried against the
1889
+ * same base.
1890
+ *
1891
+ * IDS THAT ARE NOT IN THIS CONTAINER refuse for the same reason, because
1892
+ * `shiftLayer` answers an unknown layer with the same unchanged document. A
1893
+ * selection that spans two scenes therefore does not move, which is honest: a
1894
+ * signed delta means the same thing in both clocks, but the two windows are
1895
+ * bounded by different scenes and half of them landing is the state above.
1896
+ */
1897
+ export function moveLayers(
1898
+ doc: Doc3D,
1899
+ sceneId: string,
1900
+ layerIds: readonly string[],
1901
+ byFrames: number,
1902
+ ): Doc3D {
1903
+ const by = Math.round(byFrames);
1904
+ if (by === 0 || !Number.isFinite(by) || layerIds.length === 0) return doc;
1905
+ let next = doc;
1906
+ for (const id of layerIds) {
1907
+ const moved = shiftLayer(next, sceneId, id, by);
1908
+ if (moved === next) return doc;
1909
+ next = moved;
1910
+ }
1911
+ return next;
1912
+ }
1913
+
1914
+ /**
1915
+ * The order a stagger fans `ids` out in: by each bar's `in`, earliest first,
1916
+ * and a tie by OUTLINE order (the dock's rows, top to bottom), so two bars that
1917
+ * start together fan out the way the author reads them. Ids not in `home` are
1918
+ * kept at the end, so `staggerLayers` can refuse them rather than drop them.
1919
+ */
1920
+ export function staggerOrder(
1921
+ home: LayerHome,
1922
+ ids: readonly string[],
1923
+ containerDurationFrames: number,
1924
+ ): string[] {
1925
+ const outline = new Map(trackRows(home).map((r, i) => [r.layerId, i] as const));
1926
+ const start = (id: string): number => {
1927
+ const layer = home.layersById[id];
1928
+ return layer === undefined ? Infinity : lifespanOf(layer, containerDurationFrames).in;
1929
+ };
1930
+ const rank = (id: string): number => outline.get(id) ?? Infinity;
1931
+ return [...ids].sort((a, b) => start(a) - start(b) || rank(a) - rank(b));
1932
+ }
1933
+
1934
+ /**
1935
+ * Move `ordered[i]` by `i * step`: the first bar stays, each next one a step
1936
+ * further. ALL OR NOTHING, `moveLayers`' rule and for its reason: a fan with one
1937
+ * bar refused is a rearrangement nobody asked for.
1938
+ */
1939
+ export function staggerLayers(doc: Doc3D, sceneId: string, ordered: readonly string[], step: number): Doc3D {
1940
+ const by = Math.round(step);
1941
+ if (by === 0 || !Number.isFinite(by) || ordered.length < 2) return doc;
1942
+ let next = doc;
1943
+ for (const [i, id] of ordered.entries()) {
1944
+ if (i === 0) {
1945
+ // Rank 0 does not move, but an id outside this container still refuses.
1946
+ if (homeOf(doc, sceneId)?.layersById[id] === undefined) return doc;
1947
+ continue;
1948
+ }
1949
+ const moved = shiftLayer(next, sceneId, id, i * by);
1950
+ if (moved === next) return doc;
1951
+ next = moved;
1952
+ }
1953
+ return next;
1954
+ }
1955
+
1956
+ /** Every keyframe on `layer` moved by `delta`, as a patch. No per-key clamp,
1957
+ * deliberately: the caller has already clamped the DELTA against the earliest
1958
+ * key, so none can go negative, and a floor here would break the one thing a
1959
+ * body drag and a slip promise, that every key moved by the same amount.
1960
+ * `shiftTracks` subtracts, so a move LATER passes a negative. */
1961
+ function shiftedKeys(layer: Layer3D, delta: number): ReturnType<typeof tracksPatch> {
1962
+ return tracksPatch(shiftTracks(layer.tracks, -delta));
1963
+ }
1964
+
1965
+ // ── THE DEF-LOCAL STRIP (the component context's dock, unify P2) ────────────
1966
+ //
1967
+ // A definition's timeline is ONE group shaped exactly like a scene's — same
1968
+ // MasterRow, same gestures — with no camera row (a definition
1969
+ // has no camera), no scene strip and no master offsets (start = 0). Reusing
1970
+ // MasterSceneGroup rather than minting a sibling type is what lets the dock
1971
+ // render both through one path.
1972
+
1973
+ /** The open definition's rows as one master-shaped group at offset 0. */
1974
+ export function defGroup(def: ComponentDef3D): MasterSceneGroup {
1975
+ const duration = defDurationFrames(def);
1976
+ const home: Scene3D = defAsScene(def, duration);
1977
+ return {
1978
+ id: def.id,
1979
+ name: def.name,
1980
+ start: 0,
1981
+ end: duration,
1982
+ // A DEFINITION HAS NO CAMERA, so it has no poses either. The dock skips the
1983
+ // camera row entirely inside one; this keeps the shape total anyway.
1984
+ camera: [],
1985
+ cameraKeys: [],
1986
+ rows: trackRows(home, null).map((row) => ({
1987
+ ...row,
1988
+ lifespan: lifespanOf(home.layersById[row.layerId]!, duration),
1989
+ })),
1990
+ };
1991
+ }
1992
+
1993
+ /** A definition wearing a Scene3D's shape, for the row derivations that take
1994
+ * one. Only `layers`/`layersById`/`durationFrames` are ever read by them. */
1995
+ function defAsScene(def: ComponentDef3D, duration: number): Scene3D {
482
1996
  return {
483
- ...doc,
484
- scenesById: {
485
- ...doc.scenesById,
486
- [sceneId]: { ...scene, layersById: { ...scene.layersById, [layerId]: next } },
487
- },
1997
+ id: def.id,
1998
+ name: def.name,
1999
+ durationFrames: duration,
2000
+ background: { type: "solid", color: "#000000" },
2001
+ fovDeg: 35,
2002
+ camera: { x: 0, y: 0, zoom: 1, rollDeg: 0, focusZ: 0 },
2003
+ shots: [],
2004
+ layers: def.roots,
2005
+ layersById: def.layersById,
2006
+ effects: {},
488
2007
  };
489
2008
  }
490
2009
 
491
- /** `Infinity` for a layer with no blocks, so the caller's `Math.min` against it
492
- * is a no-op rather than a special case. */
493
- function earliestBlock(layer: Layer3D): number {
494
- return layer.blocks.reduce((min, b) => Math.min(min, b.frame), Infinity);
2010
+ /** The def strip's content extent, `masterContentFrames`' twin. */
2011
+ export function defContentFrames(group: MasterSceneGroup): number {
2012
+ let end = group.end;
2013
+ for (const row of group.rows) end = Math.max(end, row.lifespan.out);
2014
+ return end;
495
2015
  }
496
2016
 
497
2017
  // ── THE AUDIO CLIP'S GESTURES ───────────────────────────────────────────────
@@ -555,10 +2075,26 @@ export function audioClipView(clip: AudioClipRef): AudioClipView {
555
2075
  * a live drag can never build a document the save would refuse: `frame` and
556
2076
  * `offsetFrames` stay non-negative integers, `durationFrames` stays positive,
557
2077
  * and `fadeIn + fadeOut` never exceeds the duration.
2078
+ *
2079
+ * ── THE GESTURE IS BASE-AGNOSTIC, THE MARKERS ARE NOT ──────────────────
2080
+ *
2081
+ * Every clamp below works on the clip's OWN frames, which since schema v13 are
2082
+ * master frames for the bed and scene-local ones for a clip its shot owns. That
2083
+ * needs no branch: a delta is a delta in either base, and "not before zero" is
2084
+ * "not before the video" for one and "not before this shot" for the other,
2085
+ * which is exactly what each schema requires.
2086
+ *
2087
+ * The MARKER sweep is the one thing that cannot be base-agnostic, because
2088
+ * markers are master-timed whatever the clip is. `home` supplies the shift, and
2089
+ * getting it wrong would drag a beat mark off a bed in a document where the
2090
+ * clip and the marks are numbered in two different bases.
558
2091
  */
559
2092
  export function writeAudioDrag(doc: Doc3D, clipId: string, mode: AudioDrag, delta: number): Doc3D {
560
- const clip = doc.audio[clipId];
2093
+ const home = audioHomeOf(doc, clipId);
2094
+ if (home === null) return doc;
2095
+ const clip = home.sceneId === null ? doc.audio[clipId] : doc.scenesById[home.sceneId]?.audio?.[clipId];
561
2096
  if (clip === undefined) return doc;
2097
+ const base = home.sceneId === null ? 0 : (sceneStart(doc, home.sceneId) ?? 0);
562
2098
  const c = audioClipView(clip);
563
2099
  const d = Math.round(delta);
564
2100
  /** The shortest a trim may leave the clip: one frame, and never shorter than
@@ -620,22 +2156,147 @@ export function writeAudioDrag(doc: Doc3D, clipId: string, mode: AudioDrag, delt
620
2156
  }
621
2157
  }
622
2158
 
623
- const next: Doc3D = { ...doc, audio: { ...doc.audio, [clipId]: { ...clip, ...patch } } };
2159
+ const next = updateAudioClip(doc, clipId, patch);
624
2160
  if (markerDelta === 0) return next;
625
- const end = c.frame + c.durationFrames;
2161
+ // MASTER frames, so the clip's own window is shifted into that base first.
2162
+ const from = base + c.frame;
2163
+ const end = from + c.durationFrames;
626
2164
  const markers = (doc.markers ?? []).map((m) =>
627
- m.frame >= c.frame && m.frame < end ? { ...m, frame: Math.max(0, m.frame + markerDelta) } : m,
2165
+ m.frame >= from && m.frame < end ? { ...m, frame: Math.max(0, m.frame + markerDelta) } : m,
628
2166
  );
629
2167
  // Re-sorted because a moved subset can cross a marker that did not move, and
630
2168
  // `validate.ts` enforces the sort the strip draws in.
631
2169
  return { ...next, markers: [...markers].sort((a, b) => a.frame - b.frame) };
632
2170
  }
633
2171
 
634
- function shiftBlocks(blocks: readonly AnimBlock[], delta: number): AnimBlock[] {
635
- // No per-block clamp, deliberately: the caller has already clamped the DELTA
636
- // against the earliest block, so none of these can go negative. A floor here
637
- // would break the one thing a body drag and a slip promise — that every block
638
- // moved by the same amount — in exactly the case it was meant to protect.
639
- // No re-sort either: a uniform shift preserves whatever order they were in.
640
- return blocks.map((b) => ({ ...b, frame: b.frame + delta }));
2172
+ // ── THE FILMSTRIP'S COLUMNS ───────────────────────────────────────────────
2173
+ //
2174
+ // Where a video row's thumbnails go, and it is the only part of the strip that
2175
+ // is arithmetic rather than pixels, which is why it lives here beside the rest
2176
+ // of the dock's geometry and not in `Filmstrip.tsx`.
2177
+ //
2178
+ // TILES ARE PLACED IN SOURCE TIME. That is the whole design, and everything
2179
+ // below is a consequence of it. A column is not "the nth slot of this bar", it
2180
+ // is "the picture at source frame s", and s is derived from the same three
2181
+ // fields the decode clock reads (`lifetime.in`, `video.offsetFrames`,
2182
+ // `video.timeScale`) through the same arithmetic `videoClipOf` states:
2183
+ //
2184
+ // sourceFrame(sceneFrame) = (sceneFrame - offsetFrames) * timeScale
2185
+ //
2186
+ // ── WHY THAT MATTERS: A TRIM MUST NOT RE-CUT THE STRIP ────────────────────
2187
+ //
2188
+ // `trimLayer` writes `lifetime` and NOTHING ELSE (`video.offsetFrames` is
2189
+ // untouched), so a head trim of d frames adds d to `lifetime.in`, and the
2190
+ // derived `sourceInFrames` moves by d * timeScale on its own. The bar's left
2191
+ // edge therefore moves right by d * ppf while `x0` (below) moves left by
2192
+ // exactly the same d * ppf, and every column's ABSOLUTE position on the lane
2193
+ // is unchanged. The footage slides under the window instead of being re-cut,
2194
+ // which is what a trim looks like in every NLE and what an author is checking
2195
+ // when they drag the edge: the frame they were aiming at must not move.
2196
+ //
2197
+ // A strip laid out from the BAR's left edge would do the opposite. Each trim
2198
+ // step would re-phase every thumbnail, the row would shimmer through the whole
2199
+ // gesture, and the one thing the author is reading off the strip, which
2200
+ // moment of the take this edge lands on, would be the thing that moves.
2201
+ //
2202
+ // ── THE COLUMN PITCH IS EXACTLY `tileWidth`, AND THAT IS NOT A ROUNDING ───
2203
+ //
2204
+ // Column i sits at `x0 + (i * intervalFrames / rate) * ppf`, and
2205
+ // `intervalFrames / rate` is `tileWidth / ppf` by construction, so the whole
2206
+ // expression collapses to `x0 + i * tileWidth`. Screen pitch is constant at
2207
+ // every rate and every zoom; the rate only changes how much SOURCE each column
2208
+ // covers, and the sign of the rate only changes which direction the source
2209
+ // runs as the columns march right. `intervalFrames` is therefore signed and
2210
+ // never rounded: rounding it would put a drift of up to half a tile into every
2211
+ // column index, growing along the bar.
2212
+
2213
+ /** How wide one thumbnail is drawn, in CSS pixels. Wide enough that a column
2214
+ * is a picture rather than a smear, narrow enough that an ordinary clip bar
2215
+ * carries several. */
2216
+ export const FILMSTRIP_TILE_PX = 48;
2217
+
2218
+ /**
2219
+ * A hard ceiling on columns per plan, so a degenerate input cannot turn a
2220
+ * decoration into a hang.
2221
+ *
2222
+ * Not a budget the caller is expected to hit: `Filmstrip.tsx` asks only for the
2223
+ * visible slice plus the viewport's margin, which is a few thousand pixels and
2224
+ * so a few dozen columns. This catches the case where a caller asks for a whole
2225
+ * bar at a deep zoom.
2226
+ */
2227
+ export const FILMSTRIP_MAX_COLUMNS = 512;
2228
+
2229
+ /** One thumbnail's placement. */
2230
+ export interface FilmstripColumn {
2231
+ /** The column's LEFT edge in BAR-LOCAL pixels. May be negative, or run past
2232
+ * the bar's right edge: a column that straddles an edge is still drawn, and
2233
+ * the bar's own `overflow: hidden` is what cuts it. */
2234
+ x: number;
2235
+ /**
2236
+ * The source frame this column shows, at the DOCUMENT's rate.
2237
+ *
2238
+ * Fractional and signed on purpose. Negative means the window opens before
2239
+ * the recording's t=0, which is the lead `videoClipOf.leadFrom` names, and
2240
+ * the loader clamps it to the first tile, so the frozen first picture draws
2241
+ * itself with no branch here. The run-out end clamps the same way.
2242
+ */
2243
+ sourceFrame: number;
2244
+ }
2245
+
2246
+ export interface FilmstripPlan {
2247
+ /** Source frames one column covers. SIGNED (a negative `timeScale` runs the
2248
+ * source down as the scene runs on) and never rounded. */
2249
+ intervalFrames: number;
2250
+ /** Bar-local pixels where source frame 0 sits. */
2251
+ x0: number;
2252
+ columns: FilmstripColumn[];
2253
+ }
2254
+
2255
+ /**
2256
+ * The columns covering `[fromPx, toPx)` of a video clip's bar.
2257
+ *
2258
+ * PURE, and the whole reason the maths is here rather than inside the canvas
2259
+ * effect: the invariant above is a property of these numbers, so a test can pin
2260
+ * it without a DOM, a sheet, or a device pixel ratio.
2261
+ *
2262
+ * `fromPx`/`toPx` are BAR-LOCAL and are a clip, not a clamp: they decide which
2263
+ * columns are worth producing, never where a column lands.
2264
+ */
2265
+ export function filmstripLayout(input: {
2266
+ /** `lifetime.in`, scene-local. */
2267
+ lifetimeIn: number;
2268
+ /** `video.offsetFrames`. */
2269
+ offsetFrames: number;
2270
+ /** `video.timeScale`. A zero or non-finite rate reads as 1, matching
2271
+ * `videoClipOf`'s own defaulting, so the strip and the decode clock cannot
2272
+ * disagree about what a broken field meant. */
2273
+ timeScale: number;
2274
+ ppf: number;
2275
+ tileWidth: number;
2276
+ fromPx: number;
2277
+ toPx: number;
2278
+ }): FilmstripPlan {
2279
+ const { lifetimeIn, offsetFrames, timeScale, ppf, tileWidth, fromPx, toPx } = input;
2280
+ const rate = Number.isFinite(timeScale) && timeScale !== 0 ? timeScale : 1;
2281
+ const empty: FilmstripPlan = { intervalFrames: 0, x0: 0, columns: [] };
2282
+ if (!(ppf > 0) || !(tileWidth > 0)) return empty;
2283
+ if (!Number.isFinite(lifetimeIn) || !Number.isFinite(offsetFrames)) return empty;
2284
+ if (!Number.isFinite(fromPx) || !Number.isFinite(toPx) || toPx <= fromPx) return empty;
2285
+
2286
+ const intervalFrames = (tileWidth / ppf) * rate;
2287
+ const x0 = (offsetFrames - lifetimeIn) * ppf;
2288
+
2289
+ // The pitch identity, spelled as `i * tileWidth` because that is what it is.
2290
+ // Indices may be negative, and must be: a bar whose window opens before the
2291
+ // source's t=0 has its first visible column at a negative source frame.
2292
+ const first = Math.floor((fromPx - x0) / tileWidth);
2293
+ const last = Math.ceil((toPx - x0) / tileWidth);
2294
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return empty;
2295
+
2296
+ const columns: FilmstripColumn[] = [];
2297
+ const stop = Math.min(last, first + FILMSTRIP_MAX_COLUMNS);
2298
+ for (let i = first; i < stop; i++) {
2299
+ columns.push({ x: x0 + i * tileWidth, sourceFrame: i * intervalFrames });
2300
+ }
2301
+ return { intervalFrames, x0, columns };
641
2302
  }