@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,228 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Switch } from '../../ui/switch'
4
+ import SpeedControl from './SpeedControl'
5
+ import VolumeControl from './VolumeControl'
6
+
7
+ /**
8
+ * Track-wide settings, opened from the rail's icon button (see TrackGutter.tsx).
9
+ *
10
+ * Portaled to `document.body`, exactly like `Tooltip.tsx` and for the same
11
+ * reason: the rail sits inside the timeline's `overflow-x-auto` scroll
12
+ * container, so a popover positioned `absolute` inside its own row would be
13
+ * clipped the moment it grew past the row's own bounds. Positioning is a
14
+ * two-pass "render once off-screen, measure, place" — same shape Tooltip uses
15
+ * for its horizontal clamp, extended here to both axes since this panel is
16
+ * tall enough to run past the bottom of the viewport on a row near the end of
17
+ * a long timeline.
18
+ *
19
+ * Each section is independently optional and renders only when ITS OWN
20
+ * value+callback pair is given — the same "no dead control" rule the rail's
21
+ * skip toggle already follows (a control wired to nothing is worse than no
22
+ * control). In practice `Timeline.tsx` wires all of them together, but the
23
+ * component itself doesn't assume that.
24
+ */
25
+
26
+ /** Gap in px between the trigger's edge and the popover. */
27
+ const OFFSET_PX = 6
28
+ /** Keeps the popover from hanging off any edge of the window. */
29
+ const VIEWPORT_MARGIN_PX = 8
30
+
31
+ interface Position {
32
+ left: number
33
+ top: number
34
+ /** True when there wasn't room to open downward, so the popover is anchored
35
+ * from its own BOTTOM edge (opens upward) instead. */
36
+ flipped: boolean
37
+ }
38
+
39
+ export interface TrackSettingsPopoverProps {
40
+ /** The rail button this popover is anchored to and positioned against. */
41
+ anchorRef: RefObject<HTMLButtonElement | null>
42
+ onClose: () => void
43
+ /** Names WHICH row this is, e.g. "Video track" / "Overlay track" / "Audio" —
44
+ * shown as the popover's own heading and its `aria-label`. */
45
+ title: string
46
+ /** Volume section — give both `volume` and `onVolumeChange` to show it. */
47
+ volume?: number
48
+ /** `commit: false` on every drag tick (live preview only, mirrors
49
+ * CaptionListPanel's fontsize slider); `commit: true` once, on
50
+ * release/keyup — the same preview-then-commit split every continuous
51
+ * timeline control uses, so a drag never produces one undo entry per
52
+ * pixel. */
53
+ onVolumeChange?: (volume: number, commit: boolean) => void
54
+ /** Distinct wording from `title` on purpose — "Mute audio lane" needs to
55
+ * read differently from AudioTrackRow's own per-clip "Mute"/"Unmute"
56
+ * button, which already exists and is a different control. */
57
+ volumeAriaLabel?: string
58
+ /** Mute section — give both `muted` and `onMutedChange` to show it. A mute
59
+ * click is a single, already-complete gesture — no preview/commit split
60
+ * needed, same as the rail's existing skip toggle. */
61
+ muted?: boolean
62
+ onMutedChange?: (muted: boolean) => void
63
+ muteAriaLabel?: string
64
+ /** Present only for visual tracks — audio lanes have no skip concept. When
65
+ * given, `onToggle` is the SAME callback the rail's inline eye icon calls;
66
+ * this is a second surface for it, not a second mutation path. */
67
+ skip?: { skipped: boolean; onToggle: () => void; ariaLabel: string }
68
+ /**
69
+ * Speed section — give both `speed` and `onApplySpeed` to show it. Present
70
+ * only for visual (video) tracks; speed is a video-only concept, same as
71
+ * volume/mute above.
72
+ *
73
+ * Applies directly, no button: it is a track control, so touching it is the
74
+ * intent to set the whole track. It previews into local state during a slider
75
+ * drag and commits `onApplySpeed` on release (and immediately on a chip
76
+ * click) — the same live-preview-then-commit split the volume fader uses,
77
+ * just committing to every clip instead of a track-level value.
78
+ */
79
+ speed?: number
80
+ /** Applies `speed` to EVERY clip on the track. Fired on slider release and on
81
+ * a preset-chip click (via SpeedControl's `onCommit`), not per drag tick. */
82
+ onApplySpeed?: (speed: number) => void
83
+ /** Editor theme mode — light/dark. Only affects the Mute/Skip switches'
84
+ * unchecked track colour. Absent -> dark, matching every existing caller. */
85
+ mode?: 'light' | 'dark'
86
+ }
87
+
88
+ export default function TrackSettingsPopover({
89
+ anchorRef,
90
+ onClose,
91
+ title,
92
+ volume,
93
+ onVolumeChange,
94
+ volumeAriaLabel,
95
+ muted,
96
+ onMutedChange,
97
+ muteAriaLabel,
98
+ skip,
99
+ speed,
100
+ onApplySpeed,
101
+ mode = 'dark',
102
+ }: TrackSettingsPopoverProps) {
103
+ const popoverRef = useRef<HTMLDivElement>(null)
104
+ const [position, setPosition] = useState<Position | null>(null)
105
+
106
+ const showVolume = volume !== undefined && !!onVolumeChange
107
+ const showMute = muted !== undefined && !!onMutedChange
108
+ const showSpeed = speed !== undefined && !!onApplySpeed
109
+
110
+ // Local slider value during a drag, seeded from the committed `speed` and
111
+ // reset whenever it changes under us — same shape as `liveVolume` below. The
112
+ // bulk apply to every clip runs on commit (slider release / chip click) via
113
+ // SpeedControl's `onCommit`, not on each drag tick.
114
+ const [localSpeed, setLocalSpeed] = useState(speed ?? 1)
115
+ useEffect(() => {
116
+ if (speed !== undefined) setLocalSpeed(speed)
117
+ }, [speed])
118
+
119
+ // Live slider value during a drag — local state so a mid-drag frame doesn't
120
+ // stutter waiting on a round trip through the project. Reset whenever the
121
+ // committed value changes under us (e.g. undo while the popover is open).
122
+ // Exactly CaptionListPanel's `fontsize` pattern.
123
+ const [liveVolume, setLiveVolume] = useState(volume ?? 1)
124
+ useEffect(() => {
125
+ if (volume !== undefined) setLiveVolume(volume)
126
+ }, [volume])
127
+
128
+ // Measure once the popover has a real size, then place it. Anchored from
129
+ // the trigger's top-left by default (NOT centered — the rail sits at the
130
+ // left edge of the timeline pane, so centering a wider panel under a 16px
131
+ // icon would push its left edge off-screen as often as not). Flips to open
132
+ // upward when there isn't room below before the viewport's bottom edge;
133
+ // only ever needs a LEFTWARD horizontal clamp, since the rail is the
134
+ // leftmost UI element and the trigger's own left edge is always ≥ 0.
135
+ useLayoutEffect(() => {
136
+ const anchorRect = anchorRef.current?.getBoundingClientRect()
137
+ const popover = popoverRef.current
138
+ if (!anchorRect || !popover) return
139
+ const { width, height } = popover.getBoundingClientRect()
140
+
141
+ let left = anchorRect.left
142
+ if (left + width > window.innerWidth - VIEWPORT_MARGIN_PX) {
143
+ left = window.innerWidth - VIEWPORT_MARGIN_PX - width
144
+ }
145
+
146
+ const fitsBelow = anchorRect.bottom + OFFSET_PX + height <= window.innerHeight - VIEWPORT_MARGIN_PX
147
+ const top = fitsBelow ? anchorRect.bottom + OFFSET_PX : anchorRect.top - OFFSET_PX
148
+ setPosition({ left, top, flipped: !fitsBelow })
149
+ }, [anchorRef])
150
+
151
+ // Close on outside click / Escape — same shape as ImageToneMenu.tsx's.
152
+ // "Outside" excludes both the trigger (a click on it is the button's own
153
+ // toggle, handled by TrackGutter) and the popover's own portaled content.
154
+ useEffect(() => {
155
+ const onDown = (e: MouseEvent) => {
156
+ const target = e.target as Node
157
+ if (anchorRef.current?.contains(target)) return
158
+ if (popoverRef.current?.contains(target)) return
159
+ onClose()
160
+ }
161
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
162
+ document.addEventListener('mousedown', onDown)
163
+ document.addEventListener('keydown', onKey)
164
+ return () => {
165
+ document.removeEventListener('mousedown', onDown)
166
+ document.removeEventListener('keydown', onKey)
167
+ }
168
+ }, [anchorRef, onClose])
169
+
170
+ if (typeof document === 'undefined') return null
171
+
172
+ return createPortal(
173
+ <div
174
+ ref={popoverRef}
175
+ role="dialog"
176
+ aria-label={title}
177
+ className="fixed z-[100] w-64 rounded-lg border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-3 flex flex-col gap-3"
178
+ style={{
179
+ left: position?.left ?? -9999,
180
+ top: position?.top ?? -9999,
181
+ // Invisible until positioned: the first render has nothing measured
182
+ // to position against yet, so it paints once off-screen for the
183
+ // effect above to measure, then becomes visible in its real place —
184
+ // never a visible jump.
185
+ visibility: position ? 'visible' : 'hidden',
186
+ transform: position?.flipped ? 'translateY(-100%)' : undefined,
187
+ }}
188
+ >
189
+ <p className="text-xs font-semibold text-[var(--editor-text)]/90">{title}</p>
190
+
191
+ {showVolume && (
192
+ <VolumeControl
193
+ value={liveVolume}
194
+ onChange={v => { setLiveVolume(v); onVolumeChange!(v, false) }}
195
+ onCommit={v => onVolumeChange!(v, true)}
196
+ label="Volume"
197
+ ariaLabel={volumeAriaLabel}
198
+ idBase="track-volume"
199
+ />
200
+ )}
201
+
202
+ {showSpeed && (
203
+ <SpeedControl
204
+ value={localSpeed}
205
+ onChange={setLocalSpeed}
206
+ onCommit={onApplySpeed}
207
+ label="Speed"
208
+ idBase="track-speed"
209
+ />
210
+ )}
211
+
212
+ {showMute && (
213
+ <div className="flex items-center justify-between">
214
+ <span className="text-[11px] text-[var(--editor-text)]/70">Mute</span>
215
+ <Switch checked={!!muted} onCheckedChange={onMutedChange!} aria-label={muteAriaLabel} mode={mode} />
216
+ </div>
217
+ )}
218
+
219
+ {skip && (
220
+ <div className="flex items-center justify-between">
221
+ <span className="text-[11px] text-[var(--editor-text)]/70">Skip</span>
222
+ <Switch checked={skip.skipped} onCheckedChange={() => skip.onToggle()} aria-label={skip.ariaLabel} mode={mode} />
223
+ </div>
224
+ )}
225
+ </div>,
226
+ document.body,
227
+ )
228
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A controlled volume picker: a slider plus a row of clickable preset chips
3
+ * snapping it to a common level. The audio twin of `SpeedControl` — same shape,
4
+ * same look, so the two controls read as siblings wherever they sit together
5
+ * (the track-wide settings popover and the per-clip inspect modal).
6
+ *
7
+ * Controlled, not stateful: the caller owns `value` and reacts to `onChange`,
8
+ * exactly like the range inputs elsewhere in this rail. The two mount points
9
+ * wire it up differently — the track popover previews into local state and
10
+ * commits directly via `onCommit` (slider release and chip click); the clip
11
+ * modal previews live and commits on an explicit Save button (so it omits
12
+ * `onCommit` and just reads `onChange`).
13
+ *
14
+ * The slider itself is the shared `<Slider>` (ui/Slider.tsx): its own
15
+ * `onCommit` already fires on gesture-end carrying the value, so this is a
16
+ * direct rewiring rather than a reshaping. One behaviour change comes along
17
+ * for free — `Slider` only commits when the gesture actually moved the value,
18
+ * where the old bare `<input>` committed on every pointerup/keyup regardless.
19
+ * That is strictly better (no more no-op undo entries) and no test here
20
+ * depended on the old wart.
21
+ */
22
+ import { Slider } from '../../ui'
23
+
24
+ /** `20·log10(v)` dB, matching the readout the volume faders used before this
25
+ * control existed. Escapes for U+2212 (minus) and U+221E (infinity) so the
26
+ * glyphs survive whatever transform sits between here and the DOM. */
27
+ function volumeToDb(v: number): string {
28
+ if (v === 0) return '−∞ dB'
29
+ return `${(20 * Math.log10(v)).toFixed(1)} dB`
30
+ }
31
+
32
+ export interface VolumeControlProps {
33
+ value: number
34
+ onChange: (v: number) => void
35
+ /** Optional commit signal, fired on slider release (pointer/keyboard) and on
36
+ * a chip click, carrying the value to commit. Callers that commit on their
37
+ * own control (e.g. a Save button) omit it and just read `onChange`. */
38
+ onCommit?: (v: number) => void
39
+ min?: number
40
+ max?: number
41
+ step?: number
42
+ /** Levels the chips snap to, as raw multipliers (1 = unity / 0 dB). Rendered
43
+ * as whole-percent labels. */
44
+ presets?: number[]
45
+ label?: string
46
+ /** Overrides the slider's `aria-label` — used where the accessible name must
47
+ * differ from the visible `label` (e.g. "Mute audio lane" vs "Volume"). */
48
+ ariaLabel?: string
49
+ /** Base id for the slider input, so a caller can wire a `<label htmlFor>`
50
+ * to it. Optional — the control is already self-labelled via `aria-label`. */
51
+ idBase?: string
52
+ }
53
+
54
+ const DEFAULT_PRESETS = [0, 0.5, 1, 1.5, 2]
55
+
56
+ export default function VolumeControl({
57
+ value,
58
+ onChange,
59
+ onCommit,
60
+ min = 0,
61
+ max = 2,
62
+ step = 0.01,
63
+ presets = DEFAULT_PRESETS,
64
+ label,
65
+ ariaLabel,
66
+ idBase,
67
+ }: VolumeControlProps) {
68
+ const sliderId = idBase ? `${idBase}-volume-slider` : undefined
69
+
70
+ return (
71
+ <div className="flex flex-col gap-2">
72
+ <div className={`flex items-center text-[11px] text-[var(--editor-text)]/70 ${label ? 'justify-between' : 'justify-end'}`}>
73
+ {label && <span>{label}</span>}
74
+ <span className="font-mono text-[10px] text-[var(--editor-text)]/50">
75
+ {value.toFixed(2)} ({volumeToDb(value)})
76
+ </span>
77
+ </div>
78
+ <Slider
79
+ id={sliderId}
80
+ min={min}
81
+ max={max}
82
+ step={step}
83
+ value={value}
84
+ aria-label={ariaLabel ?? label ?? 'Volume'}
85
+ onChange={onChange}
86
+ onCommit={onCommit}
87
+ />
88
+ {/* Quick-set chips under the slider — a click snaps straight to that
89
+ level rather than dragging for it. The active one is marked with
90
+ `aria-pressed` and the accent fill, matching SpeedControl's chips. */}
91
+ <div role="group" aria-label={label ? `${label} presets` : 'Volume presets'} className="flex flex-wrap gap-1">
92
+ {presets.map(p => {
93
+ const active = Math.abs(value - p) < 1e-6
94
+ return (
95
+ <button
96
+ key={p}
97
+ type="button"
98
+ aria-pressed={active}
99
+ onClick={() => { onChange(p); onCommit?.(p) }}
100
+ className={`rounded-md border px-2 py-0.5 text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] ${
101
+ active
102
+ ? 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
103
+ : 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
104
+ }`}
105
+ >
106
+ {Math.round(p * 100)}%
107
+ </button>
108
+ )
109
+ })}
110
+ </div>
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,110 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * Clicks that land on the timeline COLUMN but on no row: the readout strip
4
+ * above the tracks, the gaps between rows, the space under the last one.
5
+ *
6
+ * These went dead in canvas mode and nothing noticed. `handleContainerClick`
7
+ * mapped x→time through the scrubber bar's rect, and canvas mode doesn't
8
+ * render that bar, so `scrubberRef.current` was null and every such click fell
9
+ * out of an early return. The visible symptom was a ~40px strip under the
10
+ * toolbar that looked like timeline and ignored you — including for the thing
11
+ * people most expect from a background click, dropping the selection.
12
+ *
13
+ * There was no failing assertion to catch that: "looks live, does nothing" is
14
+ * invisible to a test that doesn't ask. So these ask.
15
+ *
16
+ * jsdom lays everything out at 0×0, so the canvas surface's rect is stubbed to
17
+ * a real one via `installCanvasHarness` — without it every click reads as x=0
18
+ * in a 0-wide surface and the seek half of this can't be observed at all.
19
+ */
20
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
21
+ import { render, cleanup, fireEvent } from '@testing-library/react'
22
+ import type { Project } from '../../../types'
23
+ import { createPlaybackClock } from '../../playback-clock'
24
+ import Timeline from '../Timeline'
25
+ import { installCanvasHarness, SURFACE_LEFT } from './_canvasSelect'
26
+
27
+ let uninstall: () => void
28
+
29
+ beforeEach(() => {
30
+ uninstall = installCanvasHarness()
31
+ })
32
+
33
+ afterEach(() => {
34
+ cleanup()
35
+ uninstall()
36
+ })
37
+
38
+ /** 10s of content. NOT a round 100px/second on the fitted surface — "fit" is
39
+ * computed against `computeDerivedTiming().totalDuration`, which is content
40
+ * duration PLUS drag headroom (`+ max(5, content * 0.2)`), so 10s of content
41
+ * fits as 15s of fitted timeline (≈66.7px/s here), not 100px/s. Nothing below
42
+ * asserts the scale, so the exact value was never load-bearing — this note is
43
+ * just so a reader doesn't assume a round number computing their own point. */
44
+ function makeProject(): Project {
45
+ return {
46
+ id: 'p1',
47
+ status: 'draft',
48
+ settings: { resolution: [1080, 1920], fps: 30 },
49
+ tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
50
+ } as unknown as Project
51
+ }
52
+
53
+ function mount(clockAt = 0) {
54
+ const clock = createPlaybackClock(clockAt)
55
+ const onSelectIds = vi.fn()
56
+ const utils = render(
57
+ <Timeline
58
+ project={makeProject()}
59
+ clock={clock}
60
+ selectedIds={['clip-0']}
61
+ onSelectIds={onSelectIds}
62
+ />,
63
+ )
64
+ // The strip is the column's own background: the row area is a child, so
65
+ // clicking the root element itself is exactly a click that hit no row.
66
+ const root = utils.container.firstElementChild as HTMLElement
67
+ return { clock, onSelectIds, root, ...utils }
68
+ }
69
+
70
+ describe('Timeline — background clicks in canvas mode', () => {
71
+ it('clears the selection', () => {
72
+ const { root, onSelectIds } = mount()
73
+ fireEvent.click(root, { clientX: 600, clientY: 10 })
74
+ expect(onSelectIds).toHaveBeenCalledWith([])
75
+ })
76
+
77
+ it('seeks to the clicked time on the canvas axis', () => {
78
+ const { root, clock } = mount()
79
+ // 600 - SURFACE_LEFT = 500px into a 1000px surface showing 10s of content.
80
+ fireEvent.click(root, { clientX: 600, clientY: 10 })
81
+ expect(clock.get()).toBeGreaterThan(0)
82
+ expect(clock.get()).toBeLessThan(10)
83
+ })
84
+
85
+ it('does not seek from over the track rail, but still clears the selection', () => {
86
+ const { root, clock, onSelectIds } = mount(4)
87
+ fireEvent.click(root, { clientX: SURFACE_LEFT - 20, clientY: 10 })
88
+ expect(onSelectIds).toHaveBeenCalledWith([])
89
+ expect(clock.get()).toBe(4) // no time axis over the rail
90
+ })
91
+
92
+ it('leaves a click on a button alone — the zoom and readout controls are chrome', () => {
93
+ const { root, clock, onSelectIds } = mount(4)
94
+ const button = document.createElement('button')
95
+ root.appendChild(button)
96
+ fireEvent.click(button, { clientX: 600, clientY: 10, bubbles: true })
97
+ expect(onSelectIds).not.toHaveBeenCalled()
98
+ expect(clock.get()).toBe(4)
99
+ })
100
+
101
+ it('leaves a click on a [data-timeline-chrome] readout alone', () => {
102
+ const { root, clock, onSelectIds } = mount(4)
103
+ const readout = document.createElement('span')
104
+ readout.setAttribute('data-timeline-chrome', '')
105
+ root.appendChild(readout)
106
+ fireEvent.click(readout, { clientX: 600, clientY: 10, bubbles: true })
107
+ expect(onSelectIds).not.toHaveBeenCalled()
108
+ expect(clock.get()).toBe(4)
109
+ })
110
+ })
@@ -0,0 +1,104 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The auto-crossfade pass must land as ONE commit, on a delay — not one per
4
+ * change. Audio timing moves on every mousemove of a drag, and the effect used
5
+ * to `onOverlayEdit` (commit) synchronously on each, which recorded dozens of
6
+ * undo entries for a single audio move. It is now debounced; a drag's own
7
+ * commit folds the fade into its single undo step, and this pass is the
8
+ * catch-all for audio-timing changes that arrive outside a gesture.
9
+ *
10
+ * jsdom lays out at 0×0 and has no 2D canvas, so both are stubbed via
11
+ * `installCanvasHarness` — the canvas-mode timeline mounts a real <canvas>
12
+ * surface underneath Timeline. This file dispatches no pointer event and
13
+ * `computeAutoCrossfade` reads only audio-track start/end/lane data, so the
14
+ * harness's surface-inset geometry (vs. this file's old blanket one-rect-fits-
15
+ * all stub) changes nothing this file asserts.
16
+ */
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
18
+ import { render, cleanup, act } from '@testing-library/react'
19
+ import type { Project } from '../../../types'
20
+ import { createPlaybackClock } from '../../playback-clock'
21
+ import Timeline, { CROSSFADE_COMMIT_DELAY_MS } from '../Timeline'
22
+ import { installCanvasHarness } from './_canvasSelect'
23
+
24
+ let uninstall: () => void
25
+
26
+ beforeEach(() => {
27
+ vi.useFakeTimers()
28
+ uninstall = installCanvasHarness()
29
+ })
30
+
31
+ afterEach(() => {
32
+ cleanup()
33
+ vi.useRealTimers()
34
+ uninstall()
35
+ })
36
+
37
+ /** Two audio tracks that overlap by 1s and carry NO fades — so
38
+ * `computeAutoCrossfade` has a real change to make (fadeOut/fadeIn = 1). */
39
+ function projectWithOverlap(): Project {
40
+ return {
41
+ id: 'p1',
42
+ status: 'draft',
43
+ settings: { resolution: [1080, 1920], fps: 30 },
44
+ tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
45
+ audio: { tracks: [
46
+ { id: 'a0', src: 'v.mp3', start: 0, end: 5, lane: 0 },
47
+ { id: 'a1', src: 'w.mp3', start: 4, end: 9, lane: 0 },
48
+ ] },
49
+ } as unknown as Project
50
+ }
51
+
52
+ /** Same shape, but the fades are already correct — the pass must find nothing. */
53
+ function projectAlreadyFaded(): Project {
54
+ return {
55
+ id: 'p1',
56
+ status: 'draft',
57
+ settings: { resolution: [1080, 1920], fps: 30 },
58
+ tracks: [[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10 }]],
59
+ audio: { tracks: [
60
+ { id: 'a0', src: 'v.mp3', start: 0, end: 5, lane: 0, fadeOut: 1 },
61
+ { id: 'a1', src: 'w.mp3', start: 4, end: 9, lane: 0, fadeIn: 1 },
62
+ ] },
63
+ } as unknown as Project
64
+ }
65
+
66
+ function mount(project: Project) {
67
+ const onOverlayEdit = vi.fn()
68
+ const onProjectChange = vi.fn()
69
+ render(
70
+ <Timeline
71
+ project={project}
72
+ clock={createPlaybackClock(0)}
73
+ selectedIds={[]}
74
+ onSelectIds={vi.fn()}
75
+ onProjectChange={onProjectChange}
76
+ onOverlayEdit={onOverlayEdit}
77
+ />,
78
+ )
79
+ return { onOverlayEdit, onProjectChange }
80
+ }
81
+
82
+ describe('Timeline — auto-crossfade commit is debounced', () => {
83
+ it('does not commit synchronously, then commits once after the delay', () => {
84
+ const { onOverlayEdit } = mount(projectWithOverlap())
85
+ // The per-change commit is exactly the audio-move-undo bug — it must not
86
+ // fire on mount / on the audio-timing change itself.
87
+ expect(onOverlayEdit).not.toHaveBeenCalled()
88
+
89
+ act(() => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS) })
90
+
91
+ expect(onOverlayEdit).toHaveBeenCalledTimes(1)
92
+ const committed = onOverlayEdit.mock.calls[0][0] as Project
93
+ const a0 = committed.audio!.tracks.find(t => t.id === 'a0')!
94
+ const a1 = committed.audio!.tracks.find(t => t.id === 'a1')!
95
+ expect(a0.fadeOut).toBe(1)
96
+ expect(a1.fadeIn).toBe(1)
97
+ })
98
+
99
+ it('never commits when the crossfade is already applied (idempotent)', () => {
100
+ const { onOverlayEdit } = mount(projectAlreadyFaded())
101
+ act(() => { vi.advanceTimersByTime(CROSSFADE_COMMIT_DELAY_MS * 4) })
102
+ expect(onOverlayEdit).not.toHaveBeenCalled()
103
+ })
104
+ })