@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,919 @@
1
+ // Pure timeline math shared by the DOM track-row area (today) and the canvas
2
+ // track-row area (T4 — see Timeline.tsx's `timeline` flag). Anything lifted
3
+ // here is behavior BOTH surfaces must reproduce identically, so it lives
4
+ // outside either render path.
5
+
6
+ import type { AudioTrack, VisualItem, VisualTrack } from '../../schema'
7
+ import type { Project } from '../../types'
8
+
9
+ // ── Row geometry ─────────────────────────────────────────────────────────
10
+ // Named constants for the row-height magic numbers used in cross-row drag
11
+ // math (the canvas pointer machine, Timeline.tsx). Centralized so the canvas
12
+ // painter (T4) draws rows at these heights consistently.
13
+
14
+ /** Vertical travel, in px, that a cross-track drag must cover to move an item
15
+ * one visual track. NOT the rendered row height (see
16
+ * VISUAL_ROW_RENDER_HEIGHT_PX) — it is deliberately shorter than the row so a
17
+ * drag reaches the neighbouring track before the cursor fully leaves the
18
+ * current one. Lifted verbatim from VisualTrackRow's drag math. */
19
+ export const VISUAL_ROW_HEIGHT_PX = 24
20
+
21
+ /** Audio-lane row height in px. Sized to fit the rail's stacked controls: mute,
22
+ * magnet, and the volume gear share one vertical column (TrackGutter's
23
+ * RailCell), and three 14px buttons plus their gaps need ~58px — at the old
24
+ * 40px the third control (the volume gear) was clipped below the fold once the
25
+ * magnet was added. Doubles as the lane-index drag divisor, so drag travel and
26
+ * rendered height stay coincident for audio lanes. */
27
+ export const AUDIO_LANE_HEIGHT_PX = 64
28
+
29
+ /** Rendered height of a non-base visual track row — 40px. Held at that height
30
+ * on purpose: these rows carry overlays, which have no waveform and no
31
+ * filmstrip to show, so the extra height the base track needs would just be
32
+ * empty space here. */
33
+ export const VISUAL_ROW_RENDER_HEIGHT_PX = 40
34
+
35
+ /** Rendered height of the BASE visual track (index 0). No longer the DOM
36
+ * `h-14` its name came from: a video clip splits its height between a
37
+ * filmstrip and a waveform (see `canvas/clip-bands.ts`), and 56px left ~27px
38
+ * for each — too short to read either. */
39
+ export const BASE_VISUAL_ROW_RENDER_HEIGHT_PX = 120
40
+
41
+
42
+ /** Vertical gap between rows — the `gap-1` (0.25rem = 4px) on the DOM track
43
+ * list's flex column. */
44
+ export const ROW_GAP_PX = 4
45
+
46
+ /** Caption row height — matches the `h-10` (2.5rem = 40px) Tailwind class the
47
+ * retired DOM caption row (CaptionTrackRow.tsx) used to draw. The only
48
+ * direct reader is `computeTimelineLayout` (canvas/draw.ts), which seeds
49
+ * each `layout.captions`/`resolved.captions` band with it; TrackGutter's
50
+ * rail cells and the hit-tester both take the rectangles from THAT output
51
+ * rather than reading this constant themselves, so every reader agrees on
52
+ * the same rectangles by construction. */
53
+ export const CAPTION_ROW_HEIGHT_PX = 40
54
+
55
+ // ── Audio lane grouping ──────────────────────────────────────────────────
56
+
57
+ export interface AudioLane {
58
+ laneIndex: number
59
+ tracks: AudioTrack[]
60
+ }
61
+
62
+ /** Span given to an audio track that declares no `end` and whose source length
63
+ * the project does not record: enough bar to see and grab, nothing more. */
64
+ export const AUDIO_FALLBACK_SPAN_SECONDS = 5
65
+
66
+ /** The value when it is a real, finite number; `null` otherwise. */
67
+ function finiteNumber(value: unknown): number | null {
68
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
69
+ }
70
+
71
+ /**
72
+ * The timeline window an audio track occupies.
73
+ *
74
+ * NOT to be confused with `@bycrux/timeline-core`'s `audioWindow(track, t)`,
75
+ * which takes a PLAYHEAD and answers "is this track audible right now, and at
76
+ * what gain". This one takes the project's content duration and answers "where
77
+ * does this bar sit on the timeline". Both are imported into this package, so
78
+ * the names are kept distinct on purpose.
79
+ *
80
+ * `start` and `end` are OPTIONAL on an audio track. `docs/schemas/project.md`
81
+ * marks only `src` required, and the renderer agrees: `render/mix-audio.js`
82
+ * delays by `start ?? 0` and never trims on `end` (it reads `end` only to
83
+ * place a fade-out), so the source window is `inPoint`/`outPoint` alone and a
84
+ * music bed with neither field plays its natural length. The `AudioTrack`
85
+ * type here declares both required — a convenience for the many call sites
86
+ * that do arithmetic on them, not a claim about what is on disk.
87
+ *
88
+ * That gap produced a real defect: a track written without `start`/`end`
89
+ * computed `NaN` for its left and width, so the DOM lane drew an invisible
90
+ * bar and the canvas painter culled it entirely, while the export was
91
+ * correct. Resolving the window here — and only here, at the one funnel every
92
+ * audio surface reads lanes from — is what keeps painter, hit-test and the
93
+ * pointer machine agreeing on where a bar is.
94
+ */
95
+ export function resolveAudioWindow(
96
+ track: AudioTrack,
97
+ contentDuration = 0,
98
+ ): { start: number; end: number } {
99
+ const start = finiteNumber(track.start) ?? 0
100
+ // `> start`, not merely present: a declared window of zero or negative width
101
+ // is the sibling of the missing-window bug and fails the same way. `start: 0,
102
+ // end: 0` is the exact shape `skills/lyrics-video` told agents to write until
103
+ // recently, so projects carrying it exist; `engine/validate.py` rejects it now,
104
+ // but validation does not run on project open, so the editor still has to cope.
105
+ // Treat it as undeclared and fall through to the natural-length chain below.
106
+ const declaredEnd = finiteNumber(track.end)
107
+ if (declaredEnd !== null && declaredEnd > start) return { start, end: declaredEnd }
108
+
109
+ // No `end`: the track plays its natural length. Prefer the source's own
110
+ // length when the project records it, then the rest of the project's
111
+ // content, then a fixed span — so the bar is never zero-width.
112
+ // `outPoint` wins over `sourceDuration` only when it is actually past the
113
+ // in-point. A stale `outPoint: 0` is finite, so a bare `??` would let it beat
114
+ // a perfectly good `sourceDuration` and collapse `natural` to zero.
115
+ const inPt = finiteNumber(track.inPoint) ?? 0
116
+ const outPt = finiteNumber(track.outPoint)
117
+ const sourceEnd = outPt !== null && outPt > inPt ? outPt : finiteNumber(track.sourceDuration)
118
+ const natural = sourceEnd === null ? null : sourceEnd - inPt
119
+ if (natural !== null && natural > 0) return { start, end: start + natural }
120
+ // Guard the horizon too. `contentDuration` comes from `computeDerivedTiming`,
121
+ // which reduces with `Math.max(m, i.end ?? 0)` — so ONE item anywhere in the
122
+ // project with a non-numeric `end` makes it `NaN`, and an unguarded `Math.max`
123
+ // would propagate that straight back into the invisible bar this function
124
+ // exists to prevent. Falling back to 0 yields the fixed span instead.
125
+ const horizon = finiteNumber(contentDuration) ?? 0
126
+ return { start, end: Math.max(horizon, start + AUDIO_FALLBACK_SPAN_SECONDS) }
127
+ }
128
+
129
+ /**
130
+ * Group audio tracks into rendered lanes, in ascending lane order. Tracks
131
+ * carrying an explicit `lane` keep it; the rest are auto-assigned lanes above
132
+ * the highest explicit one, in array order. Lifted out of Timeline's inline
133
+ * IIFE so the DOM rows and the canvas painter can't drift on which track lands
134
+ * in which row.
135
+ *
136
+ * Also resolves each track's timeline window (see `resolveAudioWindow`). A track
137
+ * whose `start` and `end` are both already finite is returned as the SAME
138
+ * object — no copy, no change — so nothing about a well-formed project is
139
+ * different from before this existed. Only a track missing one of them gets a
140
+ * resolved copy, and edits are applied by `id` (`updateAudioTrack`) rather
141
+ * than by object identity, so a drag or trim on that copy commits back to the
142
+ * right track and writes it a concrete window in passing.
143
+ */
144
+ export function groupAudioLanes(tracks: AudioTrack[], contentDuration = 0): AudioLane[] {
145
+ const laneMap = new Map<number, AudioTrack[]>()
146
+ let nextAutoLane = 0
147
+ for (const t of tracks) {
148
+ if (t.lane != null && t.lane >= nextAutoLane) nextAutoLane = t.lane + 1
149
+ }
150
+ for (const t of tracks) {
151
+ const lane = t.lane ?? nextAutoLane++
152
+ if (!laneMap.has(lane)) laneMap.set(lane, [])
153
+ const { start, end } = resolveAudioWindow(t, contentDuration)
154
+ laneMap.get(lane)!.push(start === t.start && end === t.end ? t : { ...t, start, end })
155
+ }
156
+ return [...laneMap.entries()]
157
+ .sort((a, b) => a[0] - b[0])
158
+ .map(([laneIndex, laneTracks]) => ({ laneIndex, tracks: laneTracks }))
159
+ }
160
+
161
+ // ── Item labels ──────────────────────────────────────────────────────────
162
+
163
+ /** Longest run of an overlay's own text kept in its label; past this it stops
164
+ * being scannable and starts being a smear. */
165
+ const LABEL_TEXT_MAX = 28
166
+
167
+ /** Basename without extension, for either separator. */
168
+ function stem(path: string): string {
169
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
170
+ const name = cut === -1 ? path : path.slice(cut + 1)
171
+ const dot = name.lastIndexOf('.')
172
+ return dot > 0 ? name.slice(0, dot) : name
173
+ }
174
+
175
+ /**
176
+ * The first human-readable string an overlay's props carry, if any. Overlay
177
+ * components are free-form, so this checks the handful of prop names the shipped
178
+ * ones actually use for their visible copy, in the order a reader would care
179
+ * about: the text of the first line, then a caption, then generic single-string
180
+ * fields.
181
+ */
182
+ function overlayText(props: Record<string, unknown> | undefined): string | null {
183
+ if (!props) return null
184
+ const lines = props.lines
185
+ if (Array.isArray(lines) && lines.length > 0) {
186
+ const first = lines[0]
187
+ if (first && typeof first === 'object' && typeof (first as { text?: unknown }).text === 'string') {
188
+ const text = (first as { text: string }).text.trim()
189
+ if (text) return text
190
+ }
191
+ }
192
+ for (const key of ['caption', 'text', 'title', 'label', 'headline'] as const) {
193
+ const value = props[key]
194
+ if (typeof value === 'string' && value.trim()) return value.trim()
195
+ }
196
+ return null
197
+ }
198
+
199
+ /**
200
+ * What a timeline block says it is.
201
+ *
202
+ * Every overlay used to read `▪ overlay`, which on a project carrying twenty of
203
+ * them identified nothing — you had to click each one to find out which was
204
+ * which. An overlay's component file names its KIND (`text_line`, `photo_hero`)
205
+ * and its props usually carry the copy actually on screen, so the two together
206
+ * say what the block is at a glance:
207
+ *
208
+ * `text_line · we still need them`
209
+ * `photo_hero · ex-googler`
210
+ * `cold_open` (no text of its own)
211
+ *
212
+ * Video clips get NO label: the track rail already says the row is video, and
213
+ * the filmstrip inside the clip identifies which shot it is far better than a
214
+ * word would. Images fall back to their own filename.
215
+ */
216
+ export function visualItemLabel(item: VisualItem): string {
217
+ if (item.type === 'video') return ''
218
+ if (item.type === 'image') return item.src ? stem(item.src) : 'image'
219
+
220
+ const kind = item.src ? stem(item.src) : 'overlay'
221
+ const text = overlayText(item.props)
222
+ if (!text) return kind
223
+ const trimmed = text.length > LABEL_TEXT_MAX ? `${text.slice(0, LABEL_TEXT_MAX - 1)}…` : text
224
+ return `${kind} · ${trimmed}`
225
+ }
226
+
227
+ // ── Cross-track move ─────────────────────────────────────────────────────
228
+
229
+ export interface CrossTrackMoveArgs {
230
+ /** The project's visual tracks as they stand mid-drag. */
231
+ tracks: VisualTrack[]
232
+ /** The item being dragged, at its ORIGINAL props — only start/end change. */
233
+ item: VisualItem
234
+ /** The dragged item's new timeline window. */
235
+ start: number
236
+ end: number
237
+ /** Which track the drag began on. */
238
+ sourceTrackIdx: number
239
+ /** Vertical travel of the drag in raw pixels (positive = downward). */
240
+ dy: number
241
+ /** Magnet/ripple mode (`ctx.rippleMode`). When true, a colliding target
242
+ * track is no longer disqualifying — the drop RIPPLE-INSERTS on it instead
243
+ * of fanning out to a different track. See the "ripple-insert" section of
244
+ * this function's own doc comment. Defaults to false, which reproduces the
245
+ * function's pre-existing (magnet-off) behaviour byte-for-byte. */
246
+ makeSpace?: boolean
247
+ }
248
+
249
+ export interface ResolveTargetTrackOptions {
250
+ /** Vet candidate tracks on KIND alone, ignoring collisions entirely.
251
+ *
252
+ * For the MOVE itself this is always off — a collision is what makes a drop
253
+ * fan out to a free row in the first place. It exists for callers that need
254
+ * a stable answer to "which row is this drag on" while the item is still
255
+ * sliding, i.e. the canvas timeline's snap tier.
256
+ *
257
+ * The two rejections in the search differ in kind, and only one of them is
258
+ * safe to consult mid-slide:
259
+ *
260
+ * - The kind gate is STRUCTURAL. An overlay can never land on the video
261
+ * track under it, at any horizontal position, for the whole gesture.
262
+ * - A collision is TRANSIENT — it depends on where the item happens to be
263
+ * right now, and it is at its worst precisely where a magnet is trying to
264
+ * pull the item INTO alignment with the very neighbour it overlaps. A
265
+ * tier that consulted it would go dead exactly when it is wanted, and
266
+ * would re-rank (and visibly lurch) every time the clip slid across a
267
+ * neighbour. */
268
+ kindOnly?: boolean
269
+ }
270
+
271
+ /**
272
+ * WHICH track a cross-track move lands on — the outward search `moveItemAcross
273
+ * Tracks` performs, and nothing else. Extracted from it (it now calls this) so
274
+ * a caller can ask the question WITHOUT performing the move.
275
+ *
276
+ * That matters because the pointed-at index (`sourceTrackIdx - dy/24`) is NOT
277
+ * the landing track: the search rejects it for a kind mismatch or a collision
278
+ * and fans out to a neighbour. Anything that needs to reason about the row the
279
+ * dragged item is currently sitting ON — the canvas timeline's strong snap tier
280
+ * does — has to run this same search, or it ends up reasoning about a row the
281
+ * item was never placed on. See `applyMove` in `canvas/pointer-machine.ts`, and
282
+ * `kindOnly` above for the half of the search that caller deliberately skips.
283
+ *
284
+ * The answer may be past the end of `tracks`, which is the caller's signal that
285
+ * the drop would MINT a new track — a legal landing with, by construction, no
286
+ * neighbours on it.
287
+ */
288
+ export function resolveTargetTrackIdx(
289
+ { tracks, item, start, end, sourceTrackIdx, dy, makeSpace = false }: CrossTrackMoveArgs,
290
+ { kindOnly = false }: ResolveTargetTrackOptions = {},
291
+ ): number {
292
+ const trackDelta = Math.round(dy / VISUAL_ROW_HEIGHT_PX)
293
+ const targetIdx = Math.max(0, sourceTrackIdx - trackDelta)
294
+ const duration = end - start
295
+ const overlapMin = duration * 0.3
296
+
297
+ function hasOverlap(items: VisualItem[]): boolean {
298
+ return items.some(other => {
299
+ if (other.id === item.id) return false
300
+ return Math.min(end, other.end) - Math.max(start, other.start) > overlapMin
301
+ })
302
+ }
303
+
304
+ // Coarse kind gate: video and overlay/image tracks are different worlds
305
+ // (an overlay is composited on top of the video underneath it, not spliced
306
+ // into its timeline), so a candidate track is only valid if it's either
307
+ // empty or already carries the dragged item's own coarse kind. This is
308
+ // deliberately narrow — just "don't let a video item land on an overlay
309
+ // track or vice versa" — NOT the fuller "video tracks form their own block
310
+ // below overlays" reorganization, which is separate follow-up work.
311
+ const coarse = (type?: string) => (type === 'video' ? 'video' : 'overlay')
312
+ const itemKind = coarse(item.type)
313
+ const kindOk = (items: VisualItem[]) => {
314
+ const others = items.filter(o => o.id !== item.id)
315
+ // Every OTHER item, not just the first — a track is allowed to hold both
316
+ // kinds (see TrackGutter's own note on a track that "also holds a
317
+ // clip"), and sampling only `others[0]` would make the check depend on
318
+ // array order rather than actually vetting every item already there.
319
+ return others.length === 0 || others.every(o => coarse(o.type) === itemKind)
320
+ }
321
+
322
+ let bestIdx = targetIdx
323
+ outer: for (let delta = 0; delta <= tracks.length; delta++) {
324
+ for (const i of delta === 0 ? [targetIdx] : [targetIdx - delta, targetIdx + delta]) {
325
+ if (i < 0) continue
326
+ // Past the end of the array is a new track — it inherits the dragged
327
+ // item's own kind by construction, so `[]` always passes `kindOk`.
328
+ const candidateItems = i < tracks.length ? tracks[i].items : []
329
+ // In ripple mode a collision is no longer disqualifying (it becomes a
330
+ // ripple-insert in `moveItemAcrossTracks`), so the gate drops to
331
+ // kind-only — which is what keeps the search from fanning out past the
332
+ // pointed-at track just because something is sitting there. `kindOnly`
333
+ // drops it for a different reason; see the option's own doc.
334
+ const collisionOk = kindOnly || makeSpace || !hasOverlap(candidateItems)
335
+ if (collisionOk && kindOk(candidateItems)) { bestIdx = i; break outer }
336
+ }
337
+ }
338
+ return bestIdx
339
+ }
340
+
341
+ /**
342
+ * Place a dragged item on the visual track its vertical travel points at,
343
+ * searching outward for one where it does not collide.
344
+ *
345
+ * Extracted verbatim from VisualTrackRow's drag handler so the canvas pointer
346
+ * machine (SP5 T5) lands items in exactly the same track the DOM rows would.
347
+ * The rules it encodes, none of them obvious from the outside:
348
+ *
349
+ * - Vertical travel is divided by `VISUAL_ROW_HEIGHT_PX` (24), not the rendered
350
+ * row height, so a drag reaches the neighbouring track before the cursor has
351
+ * fully left the current one. Downward travel LOWERS the track index, because
352
+ * tracks are stacked with the highest index on top.
353
+ * - "Collision" means overlapping an existing item by more than 30% of the
354
+ * dragged item's duration; brushing past a neighbour is allowed.
355
+ * - When the target track is occupied the search fans out — one above, one
356
+ * below, then two, and so on — and one step past the end of the array is a
357
+ * legal answer, which is how a drag creates a new top track. That search now
358
+ * lives in `resolveTargetTrackIdx` above, which this calls: the snap tier
359
+ * needs the same answer without the move.
360
+ * - Tracks left empty by the move are pruned, so dragging the last item off a
361
+ * track collapses it. The surviving TRACK OBJECTS are carried through the
362
+ * prune, so each keeps its own id and its own volume/muted/enabled. (This is
363
+ * the whole reason tracks are objects: with settings held in a parallel array
364
+ * indexed alongside `tracks`, a prune shifts every index above it and hands
365
+ * the wrong settings to the wrong track.)
366
+ * - A drag past the top of the stack mints a new track, with a fresh id from
367
+ * the same rule the normalizer uses and deduped against the ids already in
368
+ * play, so it can never collide with a surviving track.
369
+ *
370
+ * ── Ripple-insert (`makeSpace`, magnet/ripple mode ON) ────────────────────
371
+ * Everything above is the magnet-OFF path and is completely unchanged by
372
+ * `makeSpace` being available — with it omitted or false this function is
373
+ * byte-identical to before. When `makeSpace` is true (the pointer machine
374
+ * passes `ctx.rippleMode`), a collision at the pointed-at track
375
+ * (`resolveTargetTrackIdx`'s `targetIdx`)
376
+ * stops being disqualifying, CapCut-style: instead of fanning out to another
377
+ * track, the drop lands exactly where the drag points and PUSHES every item
378
+ * on that track whose `start` is at/after the dropped item's own `start` to
379
+ * the right by the dropped item's duration, making room for it in place. The
380
+ * search still runs — so the kind-lock and the "one past the end mints a track"
381
+ * rule are unchanged — but with `makeSpace` on it only ever fans out
382
+ * for a KIND mismatch, never for a collision, since collision no longer
383
+ * disqualifies a candidate. Dropping into a genuine gap (no collision at that
384
+ * same pointed-at track) is identical in both modes — there is nothing to push.
385
+ */
386
+ export function moveItemAcrossTracks(args: CrossTrackMoveArgs): VisualTrack[] {
387
+ const { tracks, item, start, end, makeSpace = false } = args
388
+ const duration = end - start
389
+ const overlapMin = duration * 0.3
390
+ const bestIdx = resolveTargetTrackIdx(args)
391
+
392
+ function hasOverlap(items: VisualItem[]): boolean {
393
+ return items.some(other => {
394
+ if (other.id === item.id) return false
395
+ return Math.min(end, other.end) - Math.max(start, other.start) > overlapMin
396
+ })
397
+ }
398
+
399
+ const removed = tracks.map(t => ({ ...t, items: t.items.filter(other => other.id !== item.id) }))
400
+ const movedItem = { ...item, start, end }
401
+ const newTrack = (): VisualTrack => ({
402
+ id: assignTrackId(removed.length, new Set(removed.map(t => t.id))),
403
+ items: [movedItem],
404
+ })
405
+
406
+ let placed: VisualTrack[]
407
+ if (bestIdx >= removed.length) {
408
+ placed = [...removed, newTrack()]
409
+ } else if (makeSpace && hasOverlap(removed[bestIdx].items)) {
410
+ // Ripple-insert: push every item on the target track that starts at or
411
+ // after the drop point to the right by the dragged item's own duration —
412
+ // `removed[bestIdx].items` already excludes the dragged item itself, so
413
+ // this can't shift the very item being placed — then land the dragged
414
+ // item at its dropped window.
415
+ placed = removed.map((t, i) => {
416
+ if (i !== bestIdx) return t
417
+ const shifted = t.items.map(other =>
418
+ other.start >= start ? { ...other, start: other.start + duration, end: other.end + duration } : other,
419
+ )
420
+ return { ...t, items: [...shifted, movedItem] }
421
+ })
422
+ } else {
423
+ placed = removed.map((t, i) => i === bestIdx ? { ...t, items: [...t.items, movedItem] } : t)
424
+ }
425
+
426
+ // Re-group into the canonical video-block/overlay-block stack (see
427
+ // `normalizeTrackOrder`'s doc). This is what makes a freshly-minted video
428
+ // track — appended at the top by the mint above — land in the video block
429
+ // rather than staying stranded above the overlays, and makes every
430
+ // mid-drag transient frame already-canonical instead of relying on some
431
+ // later pass to re-normalize it.
432
+ return orderedTrackArray(placed.filter(t => t.items.length > 0))
433
+ }
434
+
435
+ // ── Audio track update ───────────────────────────────────────────────────
436
+
437
+ /** Patch one audio track by id, leaving the rest of the project alone. Shared
438
+ * by the canvas pointer machine and Timeline.tsx so audio edits take the
439
+ * same shape wherever they're made. */
440
+ export function updateAudioTrack(project: Project, trackId: string, changes: Partial<AudioTrack>): Project {
441
+ return {
442
+ ...project,
443
+ audio: {
444
+ ...project.audio,
445
+ tracks: (project.audio?.tracks ?? []).map(t =>
446
+ t.id === trackId ? { ...t, ...changes } : t,
447
+ ),
448
+ },
449
+ }
450
+ }
451
+
452
+ // ── Derived timing ───────────────────────────────────────────────────────
453
+
454
+ export interface DerivedTiming {
455
+ snapBoundaries: number[]
456
+ contentDuration: number
457
+ totalDuration: number
458
+ }
459
+
460
+ /** Snap boundaries, content duration, and the zoom/scroll-padded total
461
+ * duration for a project's tracks + audio. Lifted from the render-time memo
462
+ * that used to live inline in Timeline so the canvas surface (T4) computes
463
+ * timing identically to the DOM surface. */
464
+ export function computeDerivedTiming(project: Project): DerivedTiming {
465
+ const allTracks = trackItems(project)
466
+ const audioTracks = project.audio?.tracks ?? []
467
+ const snapBoundaries = [...new Set([
468
+ ...allTracks.flat().flatMap(c => [c.start, c.end]),
469
+ ...audioTracks.flatMap(t => [t.start, t.end]),
470
+ ])]
471
+ const contentDuration = Math.max(
472
+ allTracks.flat().reduce((m, i) => Math.max(m, i.end ?? 0), 0),
473
+ audioTracks.reduce((m, t) => Math.max(m, t.end ?? 0), 0),
474
+ )
475
+ // Add 20% padding beyond content so the rightmost item can always be
476
+ // dragged or resized further out. Minimum 5s headroom.
477
+ const totalDuration = contentDuration + Math.max(5, contentDuration * 0.2)
478
+ return { snapBoundaries, contentDuration, totalDuration }
479
+ }
480
+
481
+ // ── Auto-crossfade ───────────────────────────────────────────────────────
482
+
483
+ /**
484
+ * Auto-crossfade: when two audio tracks overlap, apply fade-out on the
485
+ * earlier track and fade-in on the later one, each equal to the overlap
486
+ * duration. Lifted out of Timeline's render-time effect — previously a
487
+ * hidden project mutation with no test coverage — so the canvas timeline
488
+ * (T4) can't silently drop the behavior.
489
+ *
490
+ * Returns `null` — the no-change signal — when no track's fade needs to
491
+ * change, so the caller's effect can skip calling `onProjectChange` and
492
+ * avoid re-triggering itself forever.
493
+ */
494
+ export function computeAutoCrossfade(project: Project): Project | null {
495
+ const audioTracks = project.audio?.tracks ?? []
496
+ if (!audioTracks.length) return null
497
+
498
+ const sorted = [...audioTracks].sort((a, b) => a.start - b.start)
499
+ let changed = false
500
+ const updated = sorted.map(t => ({ ...t }))
501
+
502
+ // We only auto-set fades where overlap exists
503
+ for (let i = 0; i < updated.length - 1; i++) {
504
+ const a = updated[i]
505
+ const b = updated[i + 1]
506
+ if (a.end > b.start && !a.muted && !b.muted) {
507
+ // Overlap detected. Round ONCE and compare against the rounded value —
508
+ // comparing against the raw `overlap` made this non-idempotent whenever
509
+ // the overlap wasn't already a multiple of 0.1s (e.g. 0.37): the stored
510
+ // fade (0.4) would never equal the raw overlap (0.37), so `changed` was
511
+ // permanently true and this function kept reporting a change on a
512
+ // project it had already converged. Since Timeline.tsx's effect commits
513
+ // on every non-null result, that meant merely opening such a project
514
+ // wrote to disk and pushed a no-op undo entry, re-firing on every
515
+ // unrelated edit too.
516
+ const overlap = Math.min(a.end - b.start, a.end - a.start, b.end - b.start)
517
+ const rounded = Math.round(overlap * 10) / 10 // round to 0.1s
518
+ if ((a.fadeOut ?? 0) !== rounded) {
519
+ a.fadeOut = rounded
520
+ changed = true
521
+ }
522
+ if ((b.fadeIn ?? 0) !== rounded) {
523
+ b.fadeIn = rounded
524
+ changed = true
525
+ }
526
+ }
527
+ }
528
+
529
+ if (!changed) return null
530
+
531
+ const trackMap = new Map(updated.map(t => [t.id, t]))
532
+ return {
533
+ ...project,
534
+ audio: {
535
+ ...project.audio,
536
+ tracks: audioTracks.map(t => trackMap.get(t.id) ?? t),
537
+ },
538
+ }
539
+ }
540
+
541
+ // ── Track shape (legacy VisualItem[][] ⟷ VisualTrack[]) ───────────────────
542
+
543
+ /**
544
+ * Both-shapes tolerance for `project.tracks`.
545
+ *
546
+ * A project's `tracks` is on disk in one of two shapes. The legacy shape is a
547
+ * bare array of arrays — `[[item, item], [item]]` — with nowhere to hang a
548
+ * property that belongs to the TRACK rather than to a clip. The object shape
549
+ * (`VisualTrack[]`, see schema.ts) gives each track that place:
550
+ *
551
+ * [{ id: 'trk-0', items: [...] },
552
+ * { id: 'trk-1', items: [...], volume: 0.8, muted: false }]
553
+ *
554
+ * `volume`/`muted`/`enabled` are optional and ABSENT by default, so a
555
+ * normalized project behaves identically to the legacy one it came from. Track
556
+ * order is unchanged and still meaningful (`tracks[0]` is the primary footage
557
+ * track; higher indices render on top).
558
+ *
559
+ * The house rule is read-tolerant everywhere, write-normalized on open: readers
560
+ * call `trackItems()` and never care which shape is on disk; whoever opens the
561
+ * project calls `normalizeTracks()` and persists the result.
562
+ *
563
+ * Mirrored, semantics for semantics, by `lib/project_tracks.py` and
564
+ * `montaj_assets/render/project-tracks.js`. Change one, change all three — a
565
+ * legacy project normalized by any of them must produce the same ids and the
566
+ * same structure.
567
+ */
568
+
569
+ /** Plain object (not an array, not null) — the shape a track object arrives as. */
570
+ function isTrackObject(v: unknown): v is Record<string, unknown> {
571
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
572
+ }
573
+
574
+ /** A usable track id: a non-empty string. */
575
+ function isTrackId(v: unknown): v is string {
576
+ return typeof v === 'string' && v.length > 0
577
+ }
578
+
579
+ /** True when `tracks` needs no work: every element is an object carrying a
580
+ * non-empty string `id` and an array `items`, and no two share an id. */
581
+ function isTrackObjectForm(tracks: unknown[]): boolean {
582
+ const seen = new Set<string>()
583
+ for (const track of tracks) {
584
+ if (!isTrackObject(track)) return false
585
+ if (!isTrackId(track.id)) return false
586
+ if (!Array.isArray(track.items)) return false
587
+ if (seen.has(track.id)) return false
588
+ seen.add(track.id)
589
+ }
590
+ return true
591
+ }
592
+
593
+ /** Generate an id for the track at `index`, avoiding every id in `taken`. The
594
+ * rule: `trk-<index>`; if that is already taken, append an incrementing
595
+ * counter starting at 2 — `trk-<index>-2`, `trk-<index>-3`, … — until one is
596
+ * free. Deterministic and collision-free. `taken` is updated in place with the
597
+ * id handed out. */
598
+ function assignTrackId(index: number, taken: Set<string>): string {
599
+ let candidate = `trk-${index}`
600
+ let suffix = 2
601
+ while (taken.has(candidate)) {
602
+ candidate = `trk-${index}-${suffix}`
603
+ suffix += 1
604
+ }
605
+ taken.add(candidate)
606
+ return candidate
607
+ }
608
+
609
+ /**
610
+ * A fresh, collision-free track id for a track about to be appended at the
611
+ * end of `tracks` — the public door onto `assignTrackId` above, for callers
612
+ * outside this module that mint a whole new track (today: `placement.ts`'s
613
+ * "no free video track, make one" case). Delegates to the same private rule
614
+ * `normalizeTracks`/`moveItemAcrossTracks` use (`trk-<index>`, then
615
+ * `trk-<index>-2`, `-3`, … until free) rather than re-deriving it, so there
616
+ * remains exactly ONE implementation of the id convention.
617
+ *
618
+ * `index` is `tracks.length` — the new track's own position once appended —
619
+ * and `taken` is every id already in `tracks`, collected fresh on each call
620
+ * so a caller never has to keep its own id set in sync.
621
+ */
622
+ export function nextVisualTrackId(tracks: readonly unknown[]): string {
623
+ const taken = new Set<string>()
624
+ for (const track of tracks) {
625
+ if (isTrackObject(track) && isTrackId(track.id)) taken.add(track.id)
626
+ }
627
+ return assignTrackId(tracks.length, taken)
628
+ }
629
+
630
+ // ── Track group ordering ─────────────────────────────────────────────────
631
+ // Sam's decision: the timeline ALWAYS stacks (top→bottom) overlay tracks,
632
+ // then the caption band(s), then every VIDEO track as one contiguous block
633
+ // (base video at the bottom), then audio lanes. Video and overlay tracks
634
+ // stay SEPARATE — the kind-lock in `moveItemAcrossTracks` already blocks new
635
+ // cross-kind mixing, so this only has to group what's already there, never
636
+ // merge or split a track.
637
+
638
+ /** A track's coarse kind for STACKING purposes: 'video' if it holds at least
639
+ * one video item, 'overlay' otherwise (overlay/image items, or none at
640
+ * all). Distinct from `moveItemAcrossTracks`'s own item-level `coarse` —
641
+ * that one classifies a single dragged ITEM to gate a move; this one
642
+ * classifies a whole TRACK by its content, to group tracks for display. A
643
+ * track holding even one video item groups as video, mixed or not. */
644
+ function trackGroupKind(track: VisualTrack): 'video' | 'overlay' {
645
+ return track.items.some(item => item.type === 'video') ? 'video' : 'overlay'
646
+ }
647
+
648
+ /**
649
+ * Stably partition `tracks` into the video group (in existing relative
650
+ * order) followed by the overlay group (in existing relative order).
651
+ * Returns the SAME array reference when the input is already in that order
652
+ * — the shared core both `normalizeTrackOrder` (below) and `normalizeTracks`
653
+ * call, so the identity contract lives in exactly one place.
654
+ */
655
+ function orderedTrackArray(tracks: VisualTrack[]): VisualTrack[] {
656
+ const video: VisualTrack[] = []
657
+ const overlay: VisualTrack[] = []
658
+ for (const track of tracks) (trackGroupKind(track) === 'video' ? video : overlay).push(track)
659
+ const reordered = [...video, ...overlay]
660
+ return reordered.every((track, i) => track === tracks[i]) ? tracks : reordered
661
+ }
662
+
663
+ /**
664
+ * Reorder `project.tracks` into the canonical video-block/overlay-block
665
+ * stack (see the section header above) — a STABLE partition, so it only
666
+ * moves the two groups past each other and never reorders within one. Base
667
+ * video (index 0 today) is always video-kind, so it always stays first.
668
+ *
669
+ * Only reorders `tracks` itself — never touches items, captions, or audio —
670
+ * and assumes `tracks` is already in `VisualTrack[]` object form; call
671
+ * `normalizeTracks` first for a project that might still be in the legacy
672
+ * array-of-arrays shape (its own hook below already applies this after).
673
+ *
674
+ * Identity-preserving: when `tracks` is ALREADY in canonical order, returns
675
+ * the exact SAME `project` object — no new array, no new project, not even
676
+ * a shallow copy. This is load-bearing, not a micro-optimisation: a
677
+ * normalizer that changes identity on already-canonical state defeats "same
678
+ * reference → no re-render, no new undo entry" for a project that hasn't
679
+ * actually changed, silently eating whatever gesture is mid-flight when it
680
+ * runs (see the `normalizeTrackOrder(canonical) === canonical` test).
681
+ */
682
+ export function normalizeTrackOrder<P extends Project>(project: P): P {
683
+ const tracks = project.tracks
684
+ if (!Array.isArray(tracks) || tracks.length === 0) return project
685
+ const reordered = orderedTrackArray(tracks)
686
+ return reordered === tracks ? project : { ...project, tracks: reordered }
687
+ }
688
+
689
+ /**
690
+ * Return `project` with `tracks` in object form AND in canonical stacking
691
+ * order (see `normalizeTrackOrder`). Accepts either shape.
692
+ *
693
+ * Pure — never mutates the input, at any depth. New track objects and new item
694
+ * ARRAYS are built; the item objects themselves are carried over by reference.
695
+ *
696
+ * Idempotent, and identity-preserving: when `tracks` is already in object form
697
+ * AND already in canonical order, the input object itself is returned, so
698
+ * `normalizeTracks(p) === p`. That identity is what the lazy on-open
699
+ * migration reads as "nothing to write" — a converged project must trigger
700
+ * no save. Same for a project with no `tracks`, a `tracks` of
701
+ * `null`/`undefined`, or a `tracks` that is not an array; validation is
702
+ * someone else's job and normalization never throws.
703
+ *
704
+ * Per element of `tracks`:
705
+ * - an array → `{ id: <generated>, items: <copy> }`
706
+ * - an object → preserved, with every other key (`volume`, `muted`,
707
+ * `enabled`, anything unknown) carried through untouched; `items` coerced
708
+ * to an array (missing / null / non-array → `[]`); `id` filled in when
709
+ * missing, not a string, empty, or a duplicate of an earlier track's id.
710
+ * - anything else (null, a string, a number) → `{ id: <generated>, items: [] }`
711
+ * Then the whole array is re-ordered per `normalizeTrackOrder` — so this is
712
+ * the ONE place that migrates a project to the current canonical shape,
713
+ * called on every open and after every track-affecting change (Sam's "always
714
+ * normalize" decision), rather than something callers opt into separately.
715
+ *
716
+ * The input side is deliberately structural and loose so this keeps compiling
717
+ * both while `Project.tracks` is `VisualItem[][]` and after it becomes
718
+ * `VisualTrack[]`.
719
+ */
720
+ export function normalizeTracks<T extends { tracks?: unknown }>(project: T): T {
721
+ if (project === null || typeof project !== 'object') return project
722
+ const tracks: unknown = project.tracks
723
+ if (!Array.isArray(tracks)) return project
724
+ if (isTrackObjectForm(tracks)) {
725
+ const reordered = orderedTrackArray(tracks as VisualTrack[])
726
+ return reordered === tracks ? project : ({ ...project, tracks: reordered } as T)
727
+ }
728
+
729
+ // Every explicit id in the project, collected up front so a generated
730
+ // `trk-<i>` can never land on an id a LATER track already claims.
731
+ const taken = new Set<string>()
732
+ for (const track of tracks) {
733
+ if (isTrackObject(track) && isTrackId(track.id)) taken.add(track.id)
734
+ }
735
+
736
+ const kept = new Set<string>() // ids handed out so far, so a duplicate loses to the first holder
737
+ const out = (tracks as unknown[]).map((track, index) => {
738
+ let normalized: Record<string, unknown>
739
+ if (Array.isArray(track)) {
740
+ normalized = { id: assignTrackId(index, taken), items: [...track] }
741
+ } else if (isTrackObject(track)) {
742
+ const items = Array.isArray(track.items) ? [...track.items] : []
743
+ const id = isTrackId(track.id) && !kept.has(track.id)
744
+ ? track.id
745
+ : assignTrackId(index, taken)
746
+ normalized = { ...track, id, items }
747
+ } else {
748
+ normalized = { id: assignTrackId(index, taken), items: [] }
749
+ }
750
+ kept.add(normalized.id as string)
751
+ return normalized
752
+ })
753
+
754
+ // The spread widens T to `T & { tracks: … }`; the cast restores the caller's
755
+ // own project type, which is what it was apart from the tracks rebuild.
756
+ // A brand-new `tracks` array either way, so no identity to preserve here —
757
+ // just order it before handing it back.
758
+ return { ...project, tracks: orderedTrackArray(out as unknown as VisualTrack[]) } as T
759
+ }
760
+
761
+ /**
762
+ * Just the items, in track order — for the many callers that only read.
763
+ * `[]` when the project has no tracks (or a `tracks` too malformed to
764
+ * normalize). The returned arrays are the normalized project's own item
765
+ * arrays; treat them as read-only.
766
+ */
767
+ export function trackItems(project: { tracks?: unknown } | null | undefined): VisualItem[][] {
768
+ if (project === null || project === undefined || typeof project !== 'object') return []
769
+ const tracks: unknown = normalizeTracks(project).tracks
770
+ if (!Array.isArray(tracks)) return []
771
+ return (tracks as Array<{ items: VisualItem[] }>).map(t => t.items)
772
+ }
773
+
774
+ /**
775
+ * The items of ENABLED tracks only, in track order — for the surfaces that
776
+ * PRODUCE picture and sound.
777
+ *
778
+ * The counterpart to `trackItems`, and the split between them is the whole of
779
+ * the skip feature: editing surfaces (timeline, hit-testing, drag, trim, split,
780
+ * selection) call `trackItems` and keep seeing a skipped track, because you have
781
+ * to be able to see it and turn it back on. Playback and export call this one.
782
+ * Routing by which accessor a call site uses keeps the rule in one reviewable
783
+ * place instead of scattering `track.enabled === false` through a dozen files.
784
+ *
785
+ * `enabled` is absent by default, so `!== false` is the test: an untouched
786
+ * project has every track enabled.
787
+ *
788
+ * A skipped track's slot is EMPTIED, not removed — this maps, it does not
789
+ * filter. Every consumer of this array indexes it positionally (`[0]` is "the
790
+ * base footage track", `.slice(1)` is "the overlay tracks"); filtering would
791
+ * shift every later track's index down and silently reassign it to the wrong
792
+ * role. Emptying preserves position while still contributing nothing to
793
+ * playback or export.
794
+ *
795
+ * Items are passed through BY REFERENCE, never copied — the renderer's
796
+ * `resolveProjectPaths` mutates `item.src` in place through this view, so a
797
+ * defensive copy here would break path resolution with nothing failing.
798
+ */
799
+ export function enabledTrackItems(project: { tracks?: unknown } | null | undefined): VisualItem[][] {
800
+ if (project === null || project === undefined || typeof project !== 'object') return []
801
+ const tracks: unknown = normalizeTracks(project).tracks
802
+ if (!Array.isArray(tracks)) return []
803
+ return (tracks as Array<{ items: VisualItem[]; enabled?: boolean }>)
804
+ .map(t => (t.enabled !== false ? t.items : []))
805
+ }
806
+
807
+ /**
808
+ * The ENABLED tracks themselves, in track order — the object-shape sibling of
809
+ * `enabledTrackItems`, for callers that need a track's own settings
810
+ * (`volume`, `muted`) alongside its items, not just the items. Both preview
811
+ * paths need it: they read their clips out of the first enabled track and then
812
+ * have to fold that track's audio settings into each one
813
+ * (`effectiveItemAudio`), which the flattened item-array view has nowhere to
814
+ * carry.
815
+ *
816
+ * Same treatment as `enabledTrackItems`: a skipped track's slot is emptied in
817
+ * place (`items: []`) rather than removed, so index i here is always index i
818
+ * there — the two accessors can never disagree about which tracks are "in",
819
+ * and neither shifts a later track into an earlier, positionally-meaningful
820
+ * slot.
821
+ *
822
+ * Tracks and items alike pass through BY REFERENCE; see `enabledTrackItems` on
823
+ * why copying items would break path resolution.
824
+ *
825
+ * Mirrors `enabledTracks` in `montaj_assets/render/project-tracks.js`.
826
+ */
827
+ export function enabledTracks(project: { tracks?: unknown } | null | undefined): VisualTrack[] {
828
+ if (project === null || project === undefined || typeof project !== 'object') return []
829
+ const tracks: unknown = normalizeTracks(project).tracks
830
+ if (!Array.isArray(tracks)) return []
831
+ return (tracks as VisualTrack[]).map(t => (t.enabled !== false ? t : { ...t, items: [] }))
832
+ }
833
+
834
+ // ── Effective per-item audio (track × item fold) ────────────────────────
835
+
836
+ /**
837
+ * Fold a track's volume/mute settings into one of its item's effective audio
838
+ * values. Volume **multiplies** — never replaces — so a clip an editor
839
+ * already turned down stays proportionally quieter under a track that's also
840
+ * pulled down; replacing would silently discard that per-clip work. Mute is
841
+ * **either/or** — either the track or the item being muted silences it.
842
+ *
843
+ * effectiveVolume = (track.volume ?? 1) * (item.volume ?? 1)
844
+ * effectiveMuted = track.muted === true || item.muted === true
845
+ *
846
+ * Pure, and tolerant of absent fields on either argument — `track`/`item` may
847
+ * each be `undefined`/`null`/`{}`. That is the common case: nothing writes
848
+ * `track.volume`/`track.muted` by default, so most calls see an absent track
849
+ * side and the result reduces to the item's own settings, unchanged.
850
+ *
851
+ * Mirrored by `effectiveItemAudio` in `montaj_assets/render/project-tracks.js`
852
+ * — the two must agree or preview and render will disagree on how loud a clip
853
+ * actually is.
854
+ */
855
+ export function effectiveItemAudio(
856
+ track: Pick<VisualTrack, 'volume' | 'muted'> | null | undefined,
857
+ item: Pick<VisualItem, 'volume' | 'muted'> | null | undefined,
858
+ ): { volume: number; muted: boolean } {
859
+ const volume = (track?.volume ?? 1) * (item?.volume ?? 1)
860
+ const muted = track?.muted === true || item?.muted === true
861
+ return { volume, muted }
862
+ }
863
+
864
+ /**
865
+ * `project` with its `tracks` swapped for the bare items view — the adapter for
866
+ * the `@bycrux/timeline-core` boundary.
867
+ *
868
+ * That package is plain JS and its contract (`ResolverProject`,
869
+ * `DurationProject`) still reads `tracks` as an array of item arrays. It takes
870
+ * the WHOLE project, not just the tracks, so a caller can't route it through
871
+ * `trackItems()` the way every in-package reader does. Everything but `tracks`
872
+ * passes through by reference.
873
+ */
874
+ export function withItemTracks<T extends { tracks?: unknown }>(
875
+ project: T,
876
+ ): Omit<T, 'tracks'> & { tracks: VisualItem[][] } {
877
+ return { ...project, tracks: trackItems(project) }
878
+ }
879
+
880
+ /**
881
+ * `withItemTracks`, but showing only the enabled tracks — the timeline-core
882
+ * adapter for playback and export.
883
+ *
884
+ * Both halves are required together. Filtering without the unwrap re-breaks
885
+ * `@bycrux/timeline-core` (it reads `tracks` as bare item arrays and throws
886
+ * `TypeError: object is not iterable` on the object shape); unwrapping without
887
+ * the filter silently ignores skip, so a skipped track keeps rendering. Use
888
+ * this at every project-level timeline-core call on a playback path.
889
+ */
890
+ export function withEnabledItemTracks<T extends { tracks?: unknown }>(
891
+ project: T,
892
+ ): Omit<T, 'tracks'> & { tracks: VisualItem[][] } {
893
+ return { ...project, tracks: enabledTrackItems(project) }
894
+ }
895
+
896
+ /**
897
+ * Rewrite every track's items, preserving each track's id and settings.
898
+ *
899
+ * The shape almost every edit takes: "map over the tracks, produce new items
900
+ * per track, keep everything else". Doing that by hand invites the bug this
901
+ * whole shape change exists to kill — rebuilding a track as a bare
902
+ * `{ id, items }` silently drops its `volume`/`muted`/`enabled`.
903
+ *
904
+ * Both-shapes tolerant on input (it normalizes first), always returns the
905
+ * object shape. Pure: new track objects, never a mutation of the input. `fn`
906
+ * receives the track's own item array — treat it as read-only — and the
907
+ * track's index, which is unchanged by this function (order is meaningful).
908
+ *
909
+ * Tracks left with no items are KEPT; pruning is `moveItemAcrossTracks`'
910
+ * and `deleteSelection`'s business and is spelled out explicitly there.
911
+ */
912
+ export function mapTrackItems(
913
+ project: { tracks?: unknown },
914
+ fn: (items: VisualItem[], trackIndex: number) => VisualItem[],
915
+ ): VisualTrack[] {
916
+ const tracks: unknown = normalizeTracks(project).tracks
917
+ if (!Array.isArray(tracks)) return []
918
+ return (tracks as VisualTrack[]).map((track, i) => ({ ...track, items: fn(track.items, i) }))
919
+ }