@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
@@ -1,19 +1,26 @@
1
1
  import { createContext, useContext } from 'react'
2
2
  import type { PlaybackClock } from '../playback-clock'
3
3
 
4
+ /**
5
+ * What Timeline shares with the chrome it renders around the canvas surface.
6
+ *
7
+ * That is now one component — `Scrubber`, the time-readout strip — so this
8
+ * carries exactly the two things it reads and nothing else. It used to also
9
+ * carry the canvas viewport, the DOM scroll container's zoom/scrollRef, the
10
+ * scrubber bar's rect ref and the overlay-drag flag, for the rows that stayed
11
+ * in the DOM while the tracks moved to canvas. There are no such rows left:
12
+ * clips, audio lanes and captions are all painted by `TimelineCanvas`, which
13
+ * reads the viewport from its own external store (`canvas/viewport.ts`) rather
14
+ * than through React, precisely so a zoom gesture doesn't re-render anything.
15
+ *
16
+ * Positions therefore never travel through this context — only times do. That
17
+ * is the property worth keeping: two surfaces that agreed about the clock but
18
+ * computed x independently is what made the old DOM playhead drift out of
19
+ * alignment with the clips above it the moment you zoomed.
20
+ */
4
21
  export interface TimelineContextValue {
5
- totalDuration: number
6
22
  contentDuration: number
7
- snapBoundaries: number[]
8
- zoom: number
9
- zoomRef: React.RefObject<number>
10
- scrollRef: React.RefObject<HTMLDivElement | null>
11
- scrubberRef: React.RefObject<HTMLDivElement | null>
12
- overlayDraggedRef: React.MutableRefObject<boolean>
13
23
  clock: PlaybackClock
14
- markers: [number | null, number | null]
15
- setMarkers: (m: [number | null, number | null] | ((prev: [number | null, number | null]) => [number | null, number | null])) => void
16
- selection: { start: number; end: number } | null
17
24
  }
18
25
 
19
26
  export const TimelineContext = createContext<TimelineContextValue | null>(null)
@@ -0,0 +1,560 @@
1
+ import { useRef, useState, type RefObject } from 'react'
2
+ import { AudioLines, Captions, Eye, EyeOff, Film, Layers, Magnet, Settings2, Volume2, VolumeX } from 'lucide-react'
3
+ import { Tooltip } from '../../ui/Tooltip'
4
+ import type { Project } from '../../types'
5
+ import type { VisualItem } from '../../schema'
6
+ import { computeTimelineLayout, timelinePalette, type TimelineLayout, type TimelineMode, type TrackPalette, type VisualRowLayout, type AudioLaneLayout } from './canvas/draw'
7
+ import { AUDIO_LANE_HEIGHT_PX, normalizeTracks, trackItems } from './timeline-model'
8
+ import TrackSettingsPopover from './TrackSettingsPopover'
9
+
10
+ /**
11
+ * The left rail naming each track, aligned row-for-row with the canvas beside
12
+ * it — a real column rather than a badge floating over the clips, so it never
13
+ * covers the first frames of whatever sits at t=0.
14
+ *
15
+ * It carries what every clip used to repeat: the type. A track of twelve video
16
+ * clips said "video" twelve times, which cost a label slot on every block and
17
+ * still didn't tell you what the TRACK was. Saying it once here frees the
18
+ * clip's own label for what's actually particular to it — an overlay's
19
+ * component and copy — and lets a video clip carry no label at all, since its
20
+ * filmstrip identifies it better than a word could.
21
+ *
22
+ * The rail sits OUTSIDE the canvas, so the canvas keeps x=0 ≡ the viewport's
23
+ * scroll position and no coordinate math changes. It shrinks the canvas'
24
+ * measured width instead, which the viewport picks up on its own.
25
+ */
26
+
27
+ // Icon plus one control, with room to breathe. A written label wide enough not
28
+ // to truncate ("Captions", "Overlay") cost ~88px of timeline for a word you
29
+ // only need once; the icon carries it and the word is a hover away. But 34px —
30
+ // icon alone, hard against both edges — was too tight to read and left nowhere
31
+ // for the skip control to live.
32
+ // Type glyph plus ONE column of stacked controls. Side-by-side controls forced
33
+ // the rail to 84px and still crowded; stacking them uses the height these rows
34
+ // already have — the base track is 120px tall and was showing a single row of
35
+ // icons in the top 16px of it.
36
+ const GUTTER_WIDTH_PX = 46
37
+
38
+ /**
39
+ * What a visual track is *for*, from what it holds. Tracks aren't typed in the
40
+ * schema — a track is just an array — so this reads the items: the type shared
41
+ * by all of them, or the first item's type when a track mixes (rare; an overlay
42
+ * track that also holds a still, say). An empty track has no answer, and says
43
+ * so rather than guessing "video".
44
+ */
45
+ export function visualTrackKind(items: VisualItem[]): 'video' | 'overlay' | 'image' | null {
46
+ if (items.length === 0) return null
47
+ // The first item's type, whether or not the track is homogeneous — a mixed
48
+ // track (an overlay track that also holds a still, say) is named for what it
49
+ // mostly is rather than refusing to answer.
50
+ return items[0].type
51
+ }
52
+
53
+ const KIND_LABEL: Record<string, string> = {
54
+ video: 'Video',
55
+ overlay: 'Overlay',
56
+ image: 'Image',
57
+ }
58
+
59
+ /**
60
+ * The track's shared clip speed, for the settings popover's display value —
61
+ * same "read the items, not track-level settings" approach `visualTrackKind`
62
+ * above uses, since speed lives on each video `VisualItem`, not the track.
63
+ * Returns 1 when the track holds no video clips or its clips disagree, same
64
+ * "representative value, actual apply always writes the real thing"
65
+ * convention `AudioLaneRailRow` already uses for a lane's volume.
66
+ */
67
+ function commonTrackSpeed(items: VisualItem[]): number {
68
+ const speeds = items.filter(i => i.type === 'video').map(i => i.speed ?? 1)
69
+ if (speeds.length === 0) return 1
70
+ return speeds.every(s => s === speeds[0]) ? speeds[0] : 1
71
+ }
72
+
73
+ function KindIcon({ kind, className }: { kind: string | null; className?: string }) {
74
+ if (kind === 'overlay' || kind === 'image') return <Layers size={15} className={className} />
75
+ return <Film size={15} className={className} />
76
+ }
77
+
78
+ interface RailCellProps {
79
+ height: number
80
+ /** Left edge accent, matching the hue the clips on this row are drawn in. */
81
+ accent?: string
82
+ icon: React.ReactNode
83
+ label: string
84
+ /** Optional control rendered beside the icon — the skip toggle today. */
85
+ action?: React.ReactNode
86
+ /** Dim the cell to match a skipped track's dimmed clips. */
87
+ dimmed?: boolean
88
+ /**
89
+ * Makes the icon itself a button that opens the track-settings popover
90
+ * (TrackSettingsPopover). Absent → the icon stays the plain, non-interactive
91
+ * label it always was — the Captions row, or a visual/audio row when no
92
+ * settings callback at all is wired (same "no dead button" rule the skip
93
+ * toggle already follows).
94
+ */
95
+ settingsButton?: {
96
+ onClick: () => void
97
+ open: boolean
98
+ buttonRef: RefObject<HTMLButtonElement | null>
99
+ /** Names the button for screen readers — the gear alone says nothing. */
100
+ label: string
101
+ }
102
+ }
103
+
104
+ /**
105
+ * One row of the rail. Content sits at the TOP rather than centred: rows vary
106
+ * from 40px to 120px, and a centred icon on the tall base track floats in the
107
+ * middle of nothing while the short rows look fine. Top-aligned, every cell
108
+ * reads as the same control at the same place.
109
+ */
110
+ function RailCell({ height, accent, icon, label, action, dimmed, settingsButton }: RailCellProps) {
111
+ return (
112
+ <div
113
+ className={`flex items-start gap-1 rounded overflow-hidden bg-[var(--editor-surface)] border border-[var(--editor-border)] px-1 py-0.5 select-none transition-opacity ${dimmed ? 'opacity-40' : ''}`}
114
+ style={{ height, borderLeft: accent ? `2px solid ${accent}` : undefined }}
115
+ >
116
+ {/* The type icon identifies the row and is NOT interactive. It used to
117
+ double as the settings trigger, which made it impossible to tell which
118
+ icons could be clicked — a decorative-looking glyph that happened to
119
+ open a dialog. Settings moved to the gear beside it. */}
120
+ <Tooltip label={label}>
121
+ <span aria-label={label} className="flex h-4 w-4 items-center justify-center text-[var(--editor-text)]/50">
122
+ {icon}
123
+ </span>
124
+ </Tooltip>
125
+ {/* One COLUMN of controls, not a row: the rows are 40-120px tall and were
126
+ wasting all of that height on a single line of icons, which forced the
127
+ rail wide enough to crowd the timeline.
128
+
129
+ The gap is a single constant across every row height, so the audio
130
+ lane's controls and the base track's controls read as the same
131
+ spacing rather than "cramped" vs. "spread". Content stays top-aligned,
132
+ so the tall base track just has unused space below the stack —
133
+ harmless — instead of gap scaling to fill it. */}
134
+ <div className="flex min-w-0 flex-col gap-1.5">
135
+ {action}
136
+ {settingsButton && (
137
+ <Tooltip label={settingsButton.label}>
138
+ <button
139
+ ref={settingsButton.buttonRef}
140
+ type="button"
141
+ aria-label={settingsButton.label}
142
+ aria-haspopup="dialog"
143
+ aria-expanded={settingsButton.open}
144
+ onClick={settingsButton.onClick}
145
+ className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
146
+ settingsButton.open
147
+ ? 'text-[var(--editor-text)]/90 bg-[var(--editor-text)]/10'
148
+ : 'text-[var(--editor-text)]/40 hover:text-[var(--editor-text)]/90 hover:bg-[var(--editor-text)]/10'
149
+ }`}
150
+ >
151
+ <Settings2 size={13} />
152
+ </button>
153
+ </Tooltip>
154
+ )}
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ /**
161
+ * The per-track mute switch, inline in the rail beside skip.
162
+ *
163
+ * Mute lives here rather than behind the settings gear because it is a state
164
+ * you flip constantly while cutting and need to SEE at a glance — the icon is
165
+ * both the control and the indicator. The gear is for the settings you set once
166
+ * and leave (volume today, speed later).
167
+ *
168
+ * Only rendered for rows that can actually carry audio: an overlay-only track
169
+ * has no audio to mute, so offering the control there is a lie.
170
+ */
171
+ function MuteToggle({ muted, onToggle, trackLabel, mode }: { muted: boolean; onToggle: () => void; trackLabel: string; mode: TimelineMode }) {
172
+ return (
173
+ <Tooltip label={muted ? `Unmute ${trackLabel}` : `Mute ${trackLabel}`}>
174
+ <button
175
+ type="button"
176
+ aria-label={`Mute ${trackLabel}`}
177
+ aria-pressed={muted}
178
+ onClick={onToggle}
179
+ className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
180
+ muted
181
+ ? (mode === 'light' ? 'text-amber-600 hover:text-amber-700' : 'text-amber-400/90 hover:text-amber-300')
182
+ : 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
183
+ }`}
184
+ >
185
+ {muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
186
+ </button>
187
+ </Tooltip>
188
+ )
189
+ }
190
+
191
+ /**
192
+ * The per-lane magnet switch — gapless mode. Modeled directly on `MuteToggle`
193
+ * beside it: same size, same lit-vs-dim shape, the icon doubling as both
194
+ * control and indicator so the row's state reads at a glance without opening
195
+ * the settings gear. Audio-lane only today (see `AudioLaneRailRow`); no
196
+ * visual-track equivalent exists yet.
197
+ */
198
+ function MagnetToggle({ magnetic, onToggle, trackLabel, mode }: { magnetic: boolean; onToggle: () => void; trackLabel: string; mode: TimelineMode }) {
199
+ return (
200
+ <Tooltip label="Magnetic track — keep gapless">
201
+ <button
202
+ type="button"
203
+ aria-label={`Magnetic ${trackLabel}`}
204
+ aria-pressed={magnetic}
205
+ onClick={onToggle}
206
+ className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
207
+ magnetic
208
+ ? (mode === 'light' ? 'text-emerald-600 hover:text-emerald-700' : 'text-emerald-400/90 hover:text-emerald-300')
209
+ : 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
210
+ }`}
211
+ >
212
+ <Magnet size={13} />
213
+ </button>
214
+ </Tooltip>
215
+ )
216
+ }
217
+
218
+ /** The per-track skip switch. Eye/EyeOff rather than a checkbox: it reads at
219
+ * 14px and says "shown / not shown" without a word. */
220
+ function SkipToggle({ enabled, onToggle, trackLabel, mode }: { enabled: boolean; onToggle: () => void; trackLabel: string; mode: TimelineMode }) {
221
+ return (
222
+ <Tooltip label={enabled ? `Skip ${trackLabel} track` : `Un-skip ${trackLabel} track`}>
223
+ <button
224
+ type="button"
225
+ aria-label={`Skip ${trackLabel} track`}
226
+ aria-pressed={!enabled}
227
+ onClick={onToggle}
228
+ className={`flex h-3.5 w-3.5 items-center justify-center rounded transition-colors ${
229
+ enabled
230
+ ? 'text-[var(--editor-text)]/35 hover:text-[var(--editor-text)]/80'
231
+ : (mode === 'light' ? 'text-amber-600/90 hover:text-amber-700' : 'text-amber-400/80 hover:text-amber-300')
232
+ }`}
233
+ >
234
+ {enabled ? <Eye size={13} /> : <EyeOff size={13} />}
235
+ </button>
236
+ </Tooltip>
237
+ )
238
+ }
239
+
240
+ /**
241
+ * One visual-track row: the rail cell plus, on demand, its settings popover.
242
+ * Pulled out of the row `.map()` in `TrackGutter` because it needs its own
243
+ * `useState`/`useRef` for the popover's open state and anchor — hooks can't
244
+ * live inside a bare map callback.
245
+ */
246
+ function VisualTrackRailRow({
247
+ row,
248
+ palette,
249
+ mode,
250
+ label,
251
+ kind,
252
+ enabled,
253
+ volume,
254
+ muted,
255
+ speed,
256
+ onToggleTrackEnabled,
257
+ onSetTrackVolume,
258
+ onSetTrackMuted,
259
+ onApplySpeed,
260
+ }: {
261
+ row: VisualRowLayout
262
+ palette: TrackPalette
263
+ mode: TimelineMode
264
+ label: string
265
+ kind: string | null
266
+ enabled: boolean
267
+ volume: number
268
+ muted: boolean
269
+ speed: number
270
+ onToggleTrackEnabled?: (trackIdx: number, enabled: boolean) => void
271
+ onSetTrackVolume?: (trackIdx: number, volume: number, commit: boolean) => void
272
+ onSetTrackMuted?: (trackIdx: number, muted: boolean) => void
273
+ onApplySpeed?: (trackIdx: number, speed: number) => void
274
+ }) {
275
+ const [open, setOpen] = useState(false)
276
+ const buttonRef = useRef<HTMLButtonElement>(null)
277
+
278
+ // Audio controls only where there IS audio. An overlay- or image-only track
279
+ // carries none — `VisualItem.volume`/`muted` are documented "video type only" —
280
+ // so a volume slider and a mute button on such a row are controls that cannot
281
+ // do anything. Read the items rather than the track's nominal kind, so a
282
+ // mixed track (an overlay track that also holds a clip) still gets them.
283
+ // Speed is the same video-only concept, so it shares this gate.
284
+ const hasAudio = row.items.some(i => i.type === 'video')
285
+
286
+ const hasVolumeControl = hasAudio && !!onSetTrackVolume
287
+ const hasSpeedControl = hasAudio && !!onApplySpeed
288
+
289
+ // A button that opens an empty popover is worse than no button.
290
+ const hasSettings = hasVolumeControl || hasSpeedControl
291
+ const trackLabel = label.toLowerCase()
292
+
293
+ return (
294
+ <div className="absolute inset-x-0" style={{ top: row.y, height: row.height }}>
295
+ <RailCell
296
+ height={row.height}
297
+ accent={palette.border}
298
+ icon={<KindIcon kind={kind} />}
299
+ label={label}
300
+ dimmed={!enabled}
301
+ settingsButton={hasSettings ? { onClick: () => setOpen(o => !o), open, buttonRef, label: `${label} track settings` } : undefined}
302
+ action={
303
+ <>
304
+ {/* Visibility on top — it is the control you reach for most, and the
305
+ one whose state you scan a whole column of rows for. Mute sits
306
+ under it so the two state toggles stay together, with the
307
+ settings gear last. */}
308
+ {onToggleTrackEnabled && (
309
+ <SkipToggle
310
+ enabled={enabled}
311
+ mode={mode}
312
+ trackLabel={trackLabel}
313
+ onToggle={() => onToggleTrackEnabled(row.trackIdx, !enabled)}
314
+ />
315
+ )}
316
+ {hasAudio && onSetTrackMuted && (
317
+ <MuteToggle
318
+ muted={muted}
319
+ mode={mode}
320
+ trackLabel={`${trackLabel} track`}
321
+ onToggle={() => onSetTrackMuted(row.trackIdx, !muted)}
322
+ />
323
+ )}
324
+ </>
325
+ }
326
+ />
327
+ {open && hasSettings && (
328
+ <TrackSettingsPopover
329
+ anchorRef={buttonRef}
330
+ onClose={() => setOpen(false)}
331
+ title={`${label} track`}
332
+ volume={hasVolumeControl ? volume : undefined}
333
+ onVolumeChange={hasVolumeControl ? (v, commit) => onSetTrackVolume!(row.trackIdx, v, commit) : undefined}
334
+ volumeAriaLabel={`${label} volume`}
335
+ speed={hasSpeedControl ? speed : undefined}
336
+ onApplySpeed={hasSpeedControl ? s => onApplySpeed!(row.trackIdx, s) : undefined}
337
+ mode={mode}
338
+ />
339
+ )}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ /**
345
+ * One audio-lane row — same shape as `VisualTrackRailRow` minus skip (audio
346
+ * lanes have no enabled/disabled concept; mute IS their skip). A lane can hold
347
+ * several `AudioTrack`s (see `groupAudioLanes` in timeline-model.ts), so the
348
+ * popover's controls apply to every track sharing the lane — Timeline.tsx's
349
+ * `handleSetLaneVolume`/`handleSetLaneMuted` do the fan-out via the ids this
350
+ * component passes up.
351
+ */
352
+ function AudioLaneRailRow({
353
+ lane,
354
+ accent,
355
+ mode,
356
+ onSetLaneVolume,
357
+ onSetLaneMuted,
358
+ onSetLaneMagnet,
359
+ }: {
360
+ lane: AudioLaneLayout
361
+ /** The emerald edge, handed down rather than hardcoded here: it is the audio
362
+ * BAR's own border colour, and on a light ground that is a different
363
+ * emerald. A literal here would have gone on saying the dark one. */
364
+ accent: string
365
+ mode: TimelineMode
366
+ onSetLaneVolume?: (trackIds: string[], volume: number, commit: boolean) => void
367
+ onSetLaneMuted?: (trackIds: string[], muted: boolean) => void
368
+ onSetLaneMagnet?: (trackIds: string[], magnetic: boolean) => void
369
+ }) {
370
+ const [open, setOpen] = useState(false)
371
+ const buttonRef = useRef<HTMLButtonElement>(null)
372
+ // The gear holds volume only now that mute is inline in the rail.
373
+ const hasSettings = !!onSetLaneVolume
374
+
375
+ // Representative displayed value when the lane's tracks disagree: the
376
+ // FIRST track's volume, and "every track muted" for the mute switch's
377
+ // checked state — a half-muted lane reads as unmuted rather than silently
378
+ // implying full mute. This only affects what's shown before you touch it;
379
+ // committing the control always applies the new value to EVERY track in
380
+ // the lane, per the task's own rule, regardless of what was shown.
381
+ const volume = lane.tracks[0]?.volume ?? 1
382
+ const muted = lane.tracks.length > 0 && lane.tracks.every(t => t.muted)
383
+ const magnetic = lane.tracks.length > 0 && lane.tracks.every(t => t.magnetic)
384
+ const trackIds = lane.tracks.map(t => t.id)
385
+
386
+ return (
387
+ <div className="absolute inset-x-0" style={{ top: lane.y, height: lane.height }}>
388
+ <RailCell
389
+ height={AUDIO_LANE_HEIGHT_PX}
390
+ accent={accent}
391
+ icon={<AudioLines size={15} />}
392
+ label="Audio"
393
+ settingsButton={hasSettings ? { onClick: () => setOpen(o => !o), open, buttonRef, label: 'Audio lane settings' } : undefined}
394
+ action={
395
+ <>
396
+ {onSetLaneMuted && (
397
+ <MuteToggle
398
+ muted={muted}
399
+ mode={mode}
400
+ trackLabel="audio lane"
401
+ onToggle={() => onSetLaneMuted(trackIds, !muted)}
402
+ />
403
+ )}
404
+ {onSetLaneMagnet && (
405
+ <MagnetToggle
406
+ magnetic={magnetic}
407
+ mode={mode}
408
+ trackLabel="audio lane"
409
+ onToggle={() => onSetLaneMagnet(trackIds, !magnetic)}
410
+ />
411
+ )}
412
+ </>
413
+ }
414
+ />
415
+ {open && hasSettings && (
416
+ <TrackSettingsPopover
417
+ anchorRef={buttonRef}
418
+ onClose={() => setOpen(false)}
419
+ title="Audio"
420
+ volume={volume}
421
+ onVolumeChange={(v, commit) => onSetLaneVolume!(trackIds, v, commit)}
422
+ volumeAriaLabel="Audio lane volume"
423
+ mode={mode}
424
+ />
425
+ )}
426
+ </div>
427
+ )
428
+ }
429
+
430
+ export interface TrackGutterProps {
431
+ project: Project
432
+ /** Gates the caption rail cells, aligned with the canvas' own caption bands
433
+ * (`layout.captions`). Has no effect when the layout carries no bands at all
434
+ * (a project with no caption segments) — there is nothing to align a cell
435
+ * with. Defaults true so a host that hasn't wired the flag still sees the
436
+ * cells whenever the layout has any. */
437
+ showCaptionRow?: boolean
438
+ /** Layout to align against. Defaults to computing it from `project` — pass the
439
+ * caller's own so the two can't drift. */
440
+ layout?: TimelineLayout
441
+ /** Toggle a visual track's `enabled`. Absent → no skip controls (a host that
442
+ * hasn't wired the edit channel shows the rail read-only rather than
443
+ * offering a button that does nothing). */
444
+ onToggleTrackEnabled?: (trackIdx: number, enabled: boolean) => void
445
+ /** Visual-track volume, from the settings popover. `commit: false` on every
446
+ * drag tick (live preview only), `commit: true` once on release/keyup —
447
+ * mirrors `onToggleTrackEnabled`'s "absent ⇒ no control" convention. */
448
+ onSetTrackVolume?: (trackIdx: number, volume: number, commit: boolean) => void
449
+ /** Visual-track mute, from the settings popover. One call per click — mute
450
+ * is a discrete toggle, not a continuous gesture. */
451
+ onSetTrackMuted?: (trackIdx: number, muted: boolean) => void
452
+ /** Applies a speed to EVERY video clip on the track, from the settings
453
+ * popover's Speed control — committed on slider release / chip click (no
454
+ * button), a bulk edit with no `commit` flag (mirrors `onSetTrackMuted`).
455
+ * The DISPLAYED value isn't a prop — it's read off the track's own clips,
456
+ * same as `kind` above (see `commonTrackSpeed`). */
457
+ onApplySpeed?: (trackIdx: number, speed: number) => void
458
+ /** Audio-lane volume, from the settings popover — applies to every
459
+ * `AudioTrack` sharing the lane, hence the id array rather than one id. */
460
+ onSetLaneVolume?: (trackIds: string[], volume: number, commit: boolean) => void
461
+ /** Audio-lane mute, from the settings popover — same fan-out as volume. */
462
+ onSetLaneMuted?: (trackIds: string[], muted: boolean) => void
463
+ /** Audio-lane magnet (gapless mode), from the rail's inline toggle — same
464
+ * fan-out as mute/volume, and the same "absent ⇒ no control" convention. */
465
+ onSetLaneMagnet?: (trackIds: string[], magnetic: boolean) => void
466
+ /** Which ground the rail's non-token colours are picked for. The cell chrome
467
+ * itself is `--editor-*` driven and flips on its own; the LEFT-EDGE ACCENTS
468
+ * are not — they are the canvas' own track/caption/audio hues, and they have
469
+ * to be resolved from the same palette the canvas paints with or the rail
470
+ * and the clips beside it stop matching. Threaded from Timeline (never
471
+ * looked up here) for exactly that reason. Defaults to `'dark'`. */
472
+ mode?: TimelineMode
473
+ }
474
+
475
+ export default function TrackGutter({
476
+ project,
477
+ showCaptionRow = true,
478
+ layout,
479
+ mode = 'dark',
480
+ onToggleTrackEnabled,
481
+ onSetTrackVolume,
482
+ onSetTrackMuted,
483
+ onApplySpeed,
484
+ onSetLaneVolume,
485
+ onSetLaneMuted,
486
+ onSetLaneMagnet,
487
+ }: TrackGutterProps) {
488
+ const resolved = layout ?? computeTimelineLayout(project)
489
+ const palette = timelinePalette(mode)
490
+ const tracks = trackItems(project)
491
+ // Read `enabled`/`volume`/`muted` off the normalized tracks — `trackItems`
492
+ // gives items only, and a legacy-shaped project has no settings at all
493
+ // (every track enabled, unity volume, unmuted).
494
+ const trackSettings = normalizeTracks(project).tracks ?? []
495
+
496
+ return (
497
+ <div className="flex shrink-0 flex-col" style={{ width: GUTTER_WIDTH_PX }}>
498
+ {/* Absolute positioning mirrors the canvas' own layout pass, so a row's
499
+ label sits at exactly the y its clips do — including the base track's
500
+ extra height. */}
501
+ <div className="relative" style={{ height: resolved.height }}>
502
+ {resolved.rows.map(row => {
503
+ const kind = visualTrackKind(tracks[row.trackIdx] ?? [])
504
+ const trackPalette = palette.tracks[row.trackIdx % palette.tracks.length]
505
+ const label = kind ? KIND_LABEL[kind] ?? kind : 'Track'
506
+ const settings = trackSettings[row.trackIdx]
507
+ const enabled = settings?.enabled !== false
508
+ return (
509
+ <VisualTrackRailRow
510
+ key={`row-${row.trackIdx}`}
511
+ row={row}
512
+ palette={trackPalette}
513
+ mode={mode}
514
+ label={label}
515
+ kind={kind}
516
+ enabled={enabled}
517
+ volume={settings?.volume ?? 1}
518
+ muted={settings?.muted ?? false}
519
+ speed={commonTrackSpeed(tracks[row.trackIdx] ?? [])}
520
+ onToggleTrackEnabled={onToggleTrackEnabled}
521
+ onSetTrackVolume={onSetTrackVolume}
522
+ onSetTrackMuted={onSetTrackMuted}
523
+ onApplySpeed={onApplySpeed}
524
+ />
525
+ )
526
+ })}
527
+ {resolved.lanes.map(lane => (
528
+ <AudioLaneRailRow
529
+ key={`lane-${lane.laneIndex}`}
530
+ lane={lane}
531
+ accent={palette.colors.audioBorder}
532
+ mode={mode}
533
+ onSetLaneVolume={onSetLaneVolume}
534
+ onSetLaneMuted={onSetLaneMuted}
535
+ onSetLaneMagnet={onSetLaneMagnet}
536
+ />
537
+ ))}
538
+ {/* Same absolute-positioned wrapper every other row uses, keyed off
539
+ each band's own `y` — gutter and canvas read the identical
540
+ rectangles and can never drift apart. Gated on `resolved.captions`
541
+ FIRST: a caption-less project's layout carries no bands at all (see
542
+ TimelineLayout.captions), so `showCaptionRow` defaulting true must
543
+ not conjure cells with nothing to align to.
544
+ One band ⇒ the bare "Captions" label, unchanged from before N
545
+ lanes existed. Several bands ⇒ "Captions 1"…"Captions N" (N =
546
+ lane + 1) so each cell reads which lane it aligns to. */}
547
+ {showCaptionRow && resolved.captions?.map(band => (
548
+ <div key={`caption-${band.lane}`} className="absolute inset-x-0" style={{ top: band.y, height: band.height }}>
549
+ <RailCell
550
+ height={band.height}
551
+ accent={palette.captionRailAccent}
552
+ icon={<Captions size={15} />}
553
+ label={resolved.captions!.length > 1 ? `Captions ${band.lane + 1}` : 'Captions'}
554
+ />
555
+ </div>
556
+ ))}
557
+ </div>
558
+ </div>
559
+ )
560
+ }