@bycrux/editor 0.12.0 → 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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,164 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The accessor split that IS the skip feature: editing surfaces see every
4
+ * track, playback and export see only the enabled ones. If these two ever
5
+ * return the same thing, either skip does nothing or a skipped track becomes
6
+ * uneditable — both are silent failures on screen.
7
+ */
8
+ import { describe, it, expect } from 'vitest'
9
+ import {
10
+ enabledTrackItems,
11
+ enabledTracks,
12
+ trackItems,
13
+ withEnabledItemTracks,
14
+ withItemTracks,
15
+ } from '../timeline-model'
16
+ import type { VisualItem } from '../../../schema'
17
+
18
+ const item = (id: string): VisualItem =>
19
+ ({ id, type: 'video', src: `${id}.mp4`, start: 0, end: 1 }) as VisualItem
20
+
21
+ const project = {
22
+ id: 'p',
23
+ tracks: [
24
+ { id: 'trk-0', items: [item('a')] },
25
+ { id: 'trk-1', items: [item('b')], enabled: false },
26
+ { id: 'trk-2', items: [item('c')], enabled: true },
27
+ ],
28
+ } as never
29
+
30
+ describe('enabledTrackItems', () => {
31
+ it('empties tracks marked enabled:false, in place', () => {
32
+ // A map, not a filter: the skipped track KEEPS its slot (index 1) so every
33
+ // positional consumer downstream ([0] = base track, .slice(1) = overlay
34
+ // tracks) still lines up.
35
+ expect(enabledTrackItems(project).map(t => t.map(i => i.id))).toEqual([['a'], [], ['c']])
36
+ })
37
+
38
+ it('does not renumber later tracks when an earlier one is skipped', () => {
39
+ // Reproduction of the bug this shape guards against: with track 0 skipped
40
+ // and two overlay tracks, a FILTER would promote trk-1 into slot 0 and
41
+ // drop trk-2 out of the overlay set (`.slice(1)`) entirely.
42
+ const p = {
43
+ tracks: [
44
+ { id: 'trk-0', items: [item('base')], enabled: false },
45
+ { id: 'trk-1', items: [item('o1')] },
46
+ { id: 'trk-2', items: [item('o2')] },
47
+ ],
48
+ } as never
49
+ const out = enabledTrackItems(p)
50
+ expect(out.map(t => t.map(i => i.id))).toEqual([[], ['o1'], ['o2']])
51
+ expect(out.slice(1).map(t => t.map(i => i.id))).toEqual([['o1'], ['o2']])
52
+ })
53
+
54
+ it('keeps every track when none is disabled', () => {
55
+ const all = { id: 'p', tracks: [{ id: 't0', items: [item('a')] }, { id: 't1', items: [item('b')] }] } as never
56
+ expect(enabledTrackItems(all)).toHaveLength(2)
57
+ })
58
+
59
+ it('treats an absent `enabled` as enabled — untouched projects are unaffected', () => {
60
+ // The default matters: nothing writes `enabled` in, so every project that
61
+ // predates this feature has it missing on every track.
62
+ const legacyish = { id: 'p', tracks: [{ id: 't0', items: [item('a')] }] } as never
63
+ expect(enabledTrackItems(legacyish)).toEqual([[item('a')]])
64
+ })
65
+
66
+ it('accepts the legacy array-of-arrays shape, with every track enabled', () => {
67
+ expect(enabledTrackItems({ tracks: [[item('a')], [item('b')]] } as never)).toHaveLength(2)
68
+ })
69
+
70
+ it('returns one slot per track, same as the editing accessor — only a skipped slot is emptied', () => {
71
+ // The other half of the invariant — a skipped track must stay visible and
72
+ // editable on the timeline, and its slot must not disappear from either view.
73
+ expect(trackItems(project)).toHaveLength(3)
74
+ expect(enabledTrackItems(project)).toHaveLength(3)
75
+ expect(enabledTrackItems(project)[1]).toEqual([])
76
+ })
77
+
78
+ it('shares item objects by reference, never copies', () => {
79
+ // Load-bearing: the renderer's `resolveProjectPaths` rewrites `item.src` in
80
+ // place through this view. A defensive copy here breaks path resolution in
81
+ // the renderer with nothing failing.
82
+ const src = item('a')
83
+ const p = { tracks: [{ id: 't0', items: [src] }] } as never
84
+ expect(enabledTrackItems(p)[0][0]).toBe(src)
85
+ })
86
+
87
+ it('survives absent, empty and malformed tracks instead of throwing', () => {
88
+ expect(enabledTrackItems(undefined)).toEqual([])
89
+ expect(enabledTrackItems(null)).toEqual([])
90
+ expect(enabledTrackItems({} as never)).toEqual([])
91
+ expect(enabledTrackItems({ tracks: 'nonsense' } as never)).toEqual([])
92
+ })
93
+ })
94
+
95
+ describe('enabledTracks', () => {
96
+ // The object-shape sibling. Preview needs it because a track's `volume`/
97
+ // `muted` fold into every clip on it, and the item-array view has nowhere to
98
+ // carry them.
99
+ it('returns the track OBJECTS, so their own settings survive the read', () => {
100
+ const p = { tracks: [{ id: 't0', items: [item('a')], volume: 0.5, muted: true }] } as never
101
+ expect(enabledTracks(p)[0]).toMatchObject({ id: 't0', volume: 0.5, muted: true })
102
+ })
103
+
104
+ it('applies the same skip test and order as enabledTrackItems — index i is index i', () => {
105
+ // The whole point of having two accessors with one skip test: if they ever
106
+ // disagree about which track is at [n], the preview folds the WRONG
107
+ // track's fader into its clips and nothing throws.
108
+ expect(enabledTracks(project).map(t => t.id)).toEqual(['trk-0', 'trk-1', 'trk-2'])
109
+ expect(enabledTracks(project).map(t => t.items)).toEqual(enabledTrackItems(project))
110
+ expect(enabledTracks(project)[1].items).toEqual([])
111
+ })
112
+
113
+ it('treats an absent `enabled` as enabled', () => {
114
+ expect(enabledTracks({ tracks: [{ id: 't0', items: [] }] } as never)).toHaveLength(1)
115
+ })
116
+
117
+ it('normalizes the legacy array-of-arrays shape into settingless tracks', () => {
118
+ const out = enabledTracks({ tracks: [[item('a')], [item('b')]] } as never)
119
+ expect(out).toHaveLength(2)
120
+ // No settings to fold — a legacy project sounds exactly as it always did.
121
+ expect(out[0].volume).toBeUndefined()
122
+ expect(out[0].muted).toBeUndefined()
123
+ })
124
+
125
+ it('shares item objects by reference, never copies', () => {
126
+ const src = item('a')
127
+ const p = { tracks: [{ id: 't0', items: [src] }] } as never
128
+ expect(enabledTracks(p)[0].items[0]).toBe(src)
129
+ })
130
+
131
+ it('survives absent, empty and malformed tracks instead of throwing', () => {
132
+ expect(enabledTracks(undefined)).toEqual([])
133
+ expect(enabledTracks(null)).toEqual([])
134
+ expect(enabledTracks({} as never)).toEqual([])
135
+ expect(enabledTracks({ tracks: 'nonsense' } as never)).toEqual([])
136
+ })
137
+ })
138
+
139
+ describe('withEnabledItemTracks', () => {
140
+ it('hands timeline-core bare item arrays, not track objects', () => {
141
+ // `@bycrux/timeline-core` reads `tracks` as an array of item arrays and
142
+ // throws `TypeError: object is not iterable` on the object shape. The
143
+ // unwrap is why this shim exists at all.
144
+ const out = withEnabledItemTracks(project)
145
+ expect(Array.isArray(out.tracks[0])).toBe(true)
146
+ expect(out.tracks.map(t => t.map(i => i.id))).toEqual([['a'], [], ['c']])
147
+ })
148
+
149
+ it('differs from withItemTracks by exactly the disabled tracks\' content, not the slot count', () => {
150
+ // Filtering without the unwrap re-breaks timeline-core; unwrapping without
151
+ // the filter ignores skip. Both halves, or neither works. Both adapters
152
+ // keep one slot per track — timeline-core resolves `trackIdx` positionally
153
+ // — so the difference shows up as an EMPTIED slot, not a shorter array.
154
+ expect(withItemTracks(project).tracks).toHaveLength(3)
155
+ expect(withEnabledItemTracks(project).tracks).toHaveLength(3)
156
+ expect(withItemTracks(project).tracks[1]).toEqual([item('b')])
157
+ expect(withEnabledItemTracks(project).tracks[1]).toEqual([])
158
+ })
159
+
160
+ it('passes every other project field through untouched', () => {
161
+ const out = withEnabledItemTracks({ ...(project as object), name: 'Keep me' } as { tracks?: unknown; name: string })
162
+ expect(out.name).toBe('Keep me')
163
+ })
164
+ })
@@ -0,0 +1,363 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T5 — the cross-track placement search, extracted out of VisualTrackRow so
4
+ * the canvas pointer machine lands a dragged clip on the same track the DOM
5
+ * rows would have. VisualTrackRow and its DOM control arm are retired, so this
6
+ * file is now the only coverage for this placement search.
7
+ *
8
+ * One vertical track step is 24px (VISUAL_ROW_HEIGHT_PX) and DOWNWARD travel
9
+ * LOWERS the index, because track 0 is the base video track at the bottom of
10
+ * the stack.
11
+ *
12
+ * Tracks are `VisualTrack` OBJECTS. Most tests here only care about which items
13
+ * end up on which track, so they build fixtures through `stack()` and read
14
+ * results through `ids()`; the settings-preservation suite at the bottom is the
15
+ * one that looks at the track objects themselves.
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import type { VisualItem, VisualTrack } from '../../../schema'
19
+ import { moveItemAcrossTracks } from '../timeline-model'
20
+
21
+ const clip = (id: string, start: number, end: number): VisualItem =>
22
+ ({ id, type: 'video', src: `${id}.mp4`, start, end }) as VisualItem
23
+
24
+ /** An overlay-kind item — everything below cares only about `type`, which is
25
+ * the whole axis the kind-lock tests are about. */
26
+ const overlay = (id: string, start: number, end: number): VisualItem =>
27
+ ({ id, type: 'overlay', start, end }) as VisualItem
28
+
29
+ const dragged = clip('d', 0, 10)
30
+
31
+ /** Tracks from bare item arrays, ids matching what the normalizer would give. */
32
+ function stack(...items: VisualItem[][]): VisualTrack[] {
33
+ return items.map((its, i) => ({ id: `trk-${i}`, items: its }))
34
+ }
35
+
36
+ function ids(tracks: VisualTrack[]): string[][] {
37
+ return tracks.map(t => t.items.map(i => i.id))
38
+ }
39
+
40
+ describe('moveItemAcrossTracks — vertical mapping', () => {
41
+ it('keeps the item on its own track when the drag is horizontal', () => {
42
+ const moved = moveItemAcrossTracks({ tracks: stack([dragged], []), item: dragged, start: 2, end: 12, sourceTrackIdx: 0, dy: 0 })
43
+ expect(ids(moved)).toEqual([['d']])
44
+ expect(moved[0].items[0]).toMatchObject({ start: 2, end: 12 })
45
+ })
46
+
47
+ it('sends the item UP a track for upward travel', () => {
48
+ const moved = moveItemAcrossTracks({ tracks: stack([dragged], []), item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
49
+ expect(ids(moved)).toEqual([['d']]) // track 0 emptied and pruned
50
+ })
51
+
52
+ it('needs half a step (12px) of travel to change track', () => {
53
+ // Rounding means 11px is still the same track, 13px is the next one. The
54
+ // second track is occupied by a clip far enough away not to block.
55
+ const stacked = () => stack([dragged], [clip('x', 50, 60)])
56
+ expect(ids(moveItemAcrossTracks({ tracks: stacked(), item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -11 })))
57
+ .toEqual([['d'], ['x']])
58
+ expect(ids(moveItemAcrossTracks({ tracks: stacked(), item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -13 })))
59
+ .toEqual([['x', 'd']])
60
+ })
61
+
62
+ it('never goes below track 0', () => {
63
+ const moved = moveItemAcrossTracks({ tracks: stack([clip('a', 20, 30)], [dragged]), item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 500 })
64
+ expect(ids(moved)).toEqual([['a', 'd']])
65
+ })
66
+ })
67
+
68
+ describe('moveItemAcrossTracks — collision avoidance', () => {
69
+ it('rejects a target track where it would overlap by more than 30% of itself', () => {
70
+ // The dragged clip is 10s, so more than 3s of overlap is a collision.
71
+ const tracks = stack([clip('blocker', 5, 20)], [dragged])
72
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24 })
73
+ expect(ids(moved)).toEqual([['blocker'], ['d']])
74
+ })
75
+
76
+ it('accepts a brush past a neighbour', () => {
77
+ // Only 2s of overlap — inside the 3s tolerance.
78
+ const tracks = stack([clip('blocker', 8, 20)], [dragged])
79
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24 })
80
+ expect(ids(moved)).toEqual([['blocker', 'd']])
81
+ })
82
+
83
+ it('searches outward from the target, taking the first free track', () => {
84
+ // Target is track 1; it collides, so the search tries 0 then 2.
85
+ const tracks = stack([clip('lo', 0, 10)], [clip('mid', 0, 10)], [], [dragged])
86
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 3, dy: 48 })
87
+ expect(ids(moved)).toEqual([['lo'], ['mid'], ['d']])
88
+ })
89
+
90
+ it('creates a new track above the stack when the drag points past the top', () => {
91
+ const tracks = stack([dragged, clip('stay', 50, 60)])
92
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
93
+ expect(ids(moved)).toEqual([['stay'], ['d']])
94
+ })
95
+
96
+ it('ignores the dragged item when testing its own track for collisions', () => {
97
+ const tracks = stack([dragged, clip('far', 50, 60)])
98
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 1, end: 11, sourceTrackIdx: 0, dy: 0 })
99
+ expect(ids(moved)).toEqual([['far', 'd']])
100
+ })
101
+ })
102
+
103
+ // ── Kind lock: a video item and an overlay/image item are different worlds —
104
+ // an overlay composites on top of the video underneath it, it doesn't splice
105
+ // into that track's timeline. Neither kind may cross onto the other's track,
106
+ // even when there'd be no time-overlap. (This is only the "can't cross
107
+ // kinds" half; the fuller "video tracks form their own block below
108
+ // overlays" reorganization is separate follow-up work.)
109
+
110
+ describe('moveItemAcrossTracks — kind lock (video vs overlay)', () => {
111
+ it('does not let a video item land on an overlay track — skips it for a same-kind track', () => {
112
+ const videoDragged = clip('d', 0, 10)
113
+ const tracks: VisualTrack[] = [
114
+ { id: 'trk-0', items: [videoDragged, clip('stay', 50, 60)] },
115
+ { id: 'trk-1', items: [overlay('ov', 50, 60)] },
116
+ ]
117
+ // One step up from track 0 targets track 1 — the overlay track. There is
118
+ // no time-overlap with `ov`, so the OLD (kind-blind) search would have
119
+ // placed the video item there; it must instead fall back to its own
120
+ // (video) track.
121
+ const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
122
+ expect(ids(moved)).toEqual([['stay', 'd'], ['ov']])
123
+ })
124
+
125
+ it('does not let an overlay item land on a video track — skips it for a same-kind track', () => {
126
+ const ovDragged = overlay('d', 0, 10)
127
+ const tracks: VisualTrack[] = [
128
+ { id: 'trk-0', items: [clip('vid', 50, 60)] },
129
+ { id: 'trk-1', items: [ovDragged, overlay('stay', 50, 60)] },
130
+ ]
131
+ // One step down from track 1 targets track 0 — the video track. Again no
132
+ // time-overlap, so only the kind gate keeps the overlay item off it.
133
+ const moved = moveItemAcrossTracks({ tracks, item: ovDragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24 })
134
+ expect(ids(moved)).toEqual([['vid'], ['stay', 'd']])
135
+ })
136
+
137
+ it('still mints a new track past the top when every same-kind track is blocked', () => {
138
+ // Track 1 is overlay-kind (wrong kind, even though there's no
139
+ // time-overlap with `ov`); track 0 is video-kind but occupied by an
140
+ // overlapping clip (wrong reason, but still blocked). Neither works, so
141
+ // the search must mint a new (video) track above the stack. The result
142
+ // is then re-grouped into the canonical video-block/overlay-block stack
143
+ // (see `normalizeTrackOrder`), so the minted video track lands ahead of
144
+ // the overlay track in the final order, not merely "above" it positionally.
145
+ const videoDragged = clip('d', 0, 10)
146
+ const tracks: VisualTrack[] = [
147
+ { id: 'trk-0', items: [videoDragged, clip('blocker', 2, 8)] },
148
+ { id: 'trk-1', items: [overlay('ov', 50, 60)] },
149
+ ]
150
+ const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -24 })
151
+ expect(ids(moved)).toEqual([['blocker'], ['d'], ['ov']])
152
+ })
153
+ })
154
+
155
+ // ── Ripple-insert (`makeSpace`, magnet/ripple mode ON) ──────────────────
156
+ //
157
+ // Everything above exercises the DEFAULT (`makeSpace` omitted, i.e. magnet
158
+ // off) path and must keep passing byte-for-byte — that is the whole contract
159
+ // of the flag being optional. These tests are the ON path: a colliding
160
+ // target track stops being disqualifying and instead gets pushed open to fit
161
+ // the dropped item, CapCut-style.
162
+
163
+ describe('moveItemAcrossTracks — ripple-insert (makeSpace: true)', () => {
164
+ it('ripple-inserts on the target track instead of fanning out when it collides', () => {
165
+ // Same fixture as "rejects a target track where it would overlap by more
166
+ // than 30% of itself" above (which asserts the magnet-OFF fallback lands
167
+ // `d` back on its own track). With makeSpace on, the target (track 0, the
168
+ // base track) is no longer disqualified by the collision — `blocker`
169
+ // shifts right by `d`'s own duration (10s) instead.
170
+ const tracks = stack([clip('blocker', 5, 20)], [dragged])
171
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24, makeSpace: true })
172
+ expect(ids(moved)).toEqual([['blocker', 'd']])
173
+ expect(moved[0].items[0]).toMatchObject({ id: 'blocker', start: 15, end: 30 })
174
+ expect(moved[0].items[1]).toMatchObject({ id: 'd', start: 0, end: 10 })
175
+ })
176
+
177
+ it('only pushes items starting at or after the drop point, leaving earlier items alone', () => {
178
+ const tracks = stack([clip('early', 0, 3), clip('blocker', 5, 20)], [dragged])
179
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 4, end: 14, sourceTrackIdx: 1, dy: 24, makeSpace: true })
180
+ expect(ids(moved)).toEqual([['early', 'blocker', 'd']])
181
+ expect(moved[0].items[0]).toMatchObject({ id: 'early', start: 0, end: 3 }) // untouched — starts before the drop point
182
+ expect(moved[0].items[1]).toMatchObject({ id: 'blocker', start: 15, end: 30 }) // pushed right by 10s
183
+ expect(moved[0].items[2]).toMatchObject({ id: 'd', start: 4, end: 14 })
184
+ })
185
+
186
+ it('drops into a genuine gap unchanged — nothing to push', () => {
187
+ const tracks = stack([clip('far', 50, 60)], [dragged])
188
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24, makeSpace: true })
189
+ expect(ids(moved)).toEqual([['far', 'd']])
190
+ expect(moved[0].items[0]).toMatchObject({ id: 'far', start: 50, end: 60 })
191
+ })
192
+
193
+ it('does not push the dragged item against itself when it collides on its own track', () => {
194
+ // A same-track re-drop (dy: 0) that overlaps a later neighbour on the
195
+ // SAME track it started on — the dragged item is excluded from its own
196
+ // track before the push, so it can never end up shifting itself.
197
+ const tracks = stack([dragged, clip('x', 12, 20)])
198
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 10, end: 20, sourceTrackIdx: 0, dy: 0, makeSpace: true })
199
+ expect(ids(moved)).toEqual([['x', 'd']])
200
+ expect(moved[0].items[0]).toMatchObject({ id: 'x', start: 22, end: 30 })
201
+ expect(moved[0].items[1]).toMatchObject({ id: 'd', start: 10, end: 20 })
202
+ })
203
+
204
+ it('keeps the kind-lock even with makeSpace on — a video item never ripple-inserts onto an overlay track', () => {
205
+ const videoDragged = clip('d', 0, 10)
206
+ const tracks: VisualTrack[] = [
207
+ { id: 'trk-0', items: [overlay('ov', 0, 20)] },
208
+ { id: 'trk-1', items: [videoDragged] },
209
+ ]
210
+ // One step down targets track 0 — overlay-kind, so the kind gate refuses
211
+ // it regardless of `makeSpace`; the item falls back to its own (now
212
+ // empty) track rather than ripple-inserting into the overlay. The result
213
+ // is then re-grouped into the canonical video-block/overlay-block stack
214
+ // (`normalizeTrackOrder`), which is why the video track leads even though
215
+ // it was positionally second.
216
+ const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 24, makeSpace: true })
217
+ expect(ids(moved)).toEqual([['d'], ['ov']])
218
+ expect(moved[1].items[0]).toMatchObject({ id: 'ov', start: 0, end: 20 }) // untouched — no ripple happened
219
+ })
220
+
221
+ it('still mints a new track when every existing track is kind-blocked, even with makeSpace on', () => {
222
+ // Both existing tracks are overlay-kind from the dragged item's point of
223
+ // view (track 1 mixes the dragged video item with an overlay — an
224
+ // unusual shape, but one the kind gate has to cope with either way), so
225
+ // there is no kind-ok candidate anywhere in the array — only past its
226
+ // end, same as the magnet-off "mint" case.
227
+ const videoDragged = clip('d', 0, 10)
228
+ const tracks: VisualTrack[] = [
229
+ { id: 'trk-0', items: [overlay('ov0', 0, 5)] },
230
+ { id: 'trk-1', items: [videoDragged, overlay('ov1', 50, 60)] },
231
+ ]
232
+ const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 1, dy: 0, makeSpace: true })
233
+ expect(ids(moved)).toEqual([['d'], ['ov0'], ['ov1']])
234
+ })
235
+ })
236
+
237
+ // ── Track grouping (Part B): the result is ALWAYS canonical ─────────────
238
+ //
239
+ // `moveItemAcrossTracks` re-groups its own output into the video-block/
240
+ // overlay-block stack (`normalizeTrackOrder`) before returning, so every
241
+ // mid-drag frame is already canonical rather than relying on a later pass to
242
+ // fix it up.
243
+
244
+ const trackKind = (t: VisualTrack): 'video' | 'overlay' =>
245
+ t.items.some(i => i.type === 'video') ? 'video' : 'overlay'
246
+
247
+ describe('moveItemAcrossTracks — result is canonically grouped', () => {
248
+ it('a newly-minted video track (past the top of the stack, over an overlay track) lands in the video block, not stranded above the overlay', () => {
249
+ // trk-0 is fully occupied by an overlapping blocker (can't share), and
250
+ // trk-1 is overlay-kind (kind-lock rules it out too) — the search mints
251
+ // a new track two steps up. Before Part B that new track would simply
252
+ // sit "on top" (index 2, above the overlay track); the re-group now
253
+ // moves it back down into the video block, ahead of the overlay track.
254
+ const videoDragged = clip('d', 0, 10)
255
+ const tracks: VisualTrack[] = [
256
+ { id: 'trk-0', items: [videoDragged, clip('blocker', 0, 10)] },
257
+ { id: 'trk-1', items: [overlay('ov', 50, 60)] },
258
+ ]
259
+ const moved = moveItemAcrossTracks({ tracks, item: videoDragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -48 })
260
+ expect(ids(moved)).toEqual([['blocker'], ['d'], ['ov']])
261
+ expect(moved.map(trackKind)).toEqual(['video', 'video', 'overlay'])
262
+ })
263
+
264
+ it('the result is always canonical — matches its own re-grouping — for an ordinary same-kind move too', () => {
265
+ const tracks = stack([dragged], [clip('x', 50, 60)])
266
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -13 })
267
+ const videoGroup = moved.filter(t => trackKind(t) === 'video')
268
+ const overlayGroup = moved.filter(t => trackKind(t) === 'overlay')
269
+ expect(moved).toEqual([...videoGroup, ...overlayGroup])
270
+ })
271
+ })
272
+
273
+ describe('moveItemAcrossTracks — bookkeeping', () => {
274
+ it('carries the item\'s other props through untouched', () => {
275
+ const rich = { ...dragged, inPoint: 3, outPoint: 13, muted: true } as VisualItem
276
+ const moved = moveItemAcrossTracks({ tracks: stack([rich]), item: rich, start: 5, end: 15, sourceTrackIdx: 0, dy: 0 })
277
+ expect(moved[0].items[0]).toMatchObject({ inPoint: 3, outPoint: 13, muted: true, start: 5, end: 15 })
278
+ })
279
+
280
+ it('prunes tracks the move left empty', () => {
281
+ const tracks = stack([clip('a', 0, 10)], [dragged])
282
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 20, end: 30, sourceTrackIdx: 1, dy: 24 })
283
+ expect(ids(moved)).toEqual([['a', 'd']])
284
+ })
285
+
286
+ it('does not mutate the tracks it was given', () => {
287
+ const tracks = stack([clip('a', 0, 10)], [dragged])
288
+ const before = JSON.stringify(tracks)
289
+ moveItemAcrossTracks({ tracks, item: dragged, start: 20, end: 30, sourceTrackIdx: 1, dy: 24 })
290
+ expect(JSON.stringify(tracks)).toBe(before)
291
+ })
292
+
293
+ it('gives a newly minted top track an id that collides with nothing', () => {
294
+ // `trk-2` is deliberately squatting on the id the index rule would hand out
295
+ // for the new track, so the normalizer's dedupe suffix has to kick in.
296
+ const tracks: VisualTrack[] = [
297
+ { id: 'trk-0', items: [dragged, clip('stay', 50, 60)] },
298
+ { id: 'trk-2', items: [clip('b', 50, 60)] },
299
+ ]
300
+ const moved = moveItemAcrossTracks({ tracks, item: dragged, start: 0, end: 10, sourceTrackIdx: 0, dy: -240 })
301
+ expect(moved.map(t => t.id)).toEqual(['trk-0', 'trk-2', 'trk-2-2'])
302
+ expect(ids(moved)).toEqual([['stay'], ['b'], ['d']])
303
+ })
304
+ })
305
+
306
+ // ── The reason tracks became objects ─────────────────────────────────────────
307
+ //
308
+ // This is the bug the whole shape change exists to make impossible. Track
309
+ // settings used to have nowhere to live but a second array indexed alongside
310
+ // `tracks`. The prune below deletes a track from the MIDDLE of the stack, so
311
+ // every index above it shifts down by one — and a parallel array, which has no
312
+ // idea a prune happened, keeps handing out settings by the OLD index. The
313
+ // result is silent: no error, no crash, the timeline just quietly plays a
314
+ // different track at the wrong volume.
315
+ //
316
+ // Settings that live ON the track object travel with the track through the
317
+ // filter, so there is no index to get out of step. Both tests below would fail
318
+ // against a parallel-array implementation.
319
+
320
+ describe('moveItemAcrossTracks — settings survive the prune', () => {
321
+ /** Three tracks, each with settings only IT should ever carry. */
322
+ function settled(soloItem: VisualItem): VisualTrack[] {
323
+ return [
324
+ { id: 'trk-a', items: [clip('a', 0, 10)], volume: 0.2 },
325
+ { id: 'trk-b', items: [soloItem], volume: 0.5 },
326
+ { id: 'trk-c', items: [clip('c', 0, 10)], muted: true },
327
+ ]
328
+ }
329
+
330
+ it('does not shift a pruned middle track\'s settings onto the track above it', () => {
331
+ const solo = clip('solo', 40, 50)
332
+ // Downward travel lowers the index, so trk-b's ONLY item lands on trk-a and
333
+ // trk-b is pruned. trk-c moves from index 2 to index 1.
334
+ const moved = moveItemAcrossTracks({
335
+ tracks: settled(solo), item: solo, start: 40, end: 50, sourceTrackIdx: 1, dy: 24,
336
+ })
337
+
338
+ expect(ids(moved)).toEqual([['a', 'solo'], ['c']])
339
+ expect(moved.map(t => t.id)).toEqual(['trk-a', 'trk-c'])
340
+ // Each survivor still carries ITS OWN settings, not the ones its new index
341
+ // used to point at.
342
+ expect(moved[0]).toMatchObject({ id: 'trk-a', volume: 0.2 })
343
+ expect(moved[0].muted).toBeUndefined()
344
+ expect(moved[1]).toMatchObject({ id: 'trk-c', muted: true })
345
+ expect(moved[1].volume).toBeUndefined() // trk-b's 0.5 did not follow the index down
346
+ })
347
+
348
+ it('keeps settings intact when the prune also mints a new top track', () => {
349
+ const solo = clip('solo', 40, 50)
350
+ // Two steps up from track 1 is index 3, one past the end — a new track.
351
+ const moved = moveItemAcrossTracks({
352
+ tracks: settled(solo), item: solo, start: 40, end: 50, sourceTrackIdx: 1, dy: -48,
353
+ })
354
+
355
+ expect(ids(moved)).toEqual([['a'], ['c'], ['solo']])
356
+ expect(moved.map(t => t.id)).toEqual(['trk-a', 'trk-c', 'trk-3'])
357
+ expect(moved.map(t => [t.volume, t.muted])).toEqual([
358
+ [0.2, undefined],
359
+ [undefined, true],
360
+ [undefined, undefined], // a fresh track carries no settings at all
361
+ ])
362
+ })
363
+ })