@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,645 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { useState, type ReactNode } from 'react'
3
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
4
+ import type { AudioTrack, VisualItem } from '../../../schema'
5
+ import ClipPropertiesPanel, { type ClipSelection } from '../ClipPropertiesPanel'
6
+
7
+ // The panel now persists its active clip tab to localStorage (see
8
+ // ClipPropertiesPanel's CLIP_PANEL_TAB_STORAGE_KEY) — clear it between tests
9
+ // so one test's tab choice can't leak into the next, same pattern as
10
+ // LeftPanelTabs' own test file.
11
+ beforeEach(() => window.localStorage.clear())
12
+ afterEach(cleanup)
13
+
14
+ /** Clicks a clip-panel tab button by its accessible name (e.g. "Volume",
15
+ * "Speed", "Crop", "Generate", "Transform"). */
16
+ function openClipTab(name: string) {
17
+ fireEvent.click(screen.getByRole('button', { name }))
18
+ }
19
+
20
+ function clipItem(over: Partial<VisualItem> = {}): VisualItem {
21
+ return {
22
+ id: 'c1',
23
+ type: 'video',
24
+ src: 'clip.mp4',
25
+ start: 0,
26
+ end: 10,
27
+ inPoint: 100,
28
+ outPoint: 110,
29
+ ...over,
30
+ }
31
+ }
32
+
33
+ function audioTrack(over: Partial<AudioTrack> = {}): AudioTrack {
34
+ return {
35
+ id: 'a1',
36
+ src: '/media/voiceover-final.mp3',
37
+ start: 0,
38
+ end: 10,
39
+ ...over,
40
+ }
41
+ }
42
+
43
+ function makeCallbacks() {
44
+ return {
45
+ onPreviewClip: vi.fn(),
46
+ onCommitClip: vi.fn(),
47
+ onChangeClip: vi.fn(),
48
+ onPreviewAudio: vi.fn(),
49
+ onCommitAudio: vi.fn(),
50
+ onChangeAudio: vi.fn(),
51
+ }
52
+ }
53
+
54
+ type Callbacks = ReturnType<typeof makeCallbacks>
55
+
56
+ type ClipTabExtras = { transformSlot?: ReactNode; onOpenCrop?: () => void; generationSlot?: ReactNode }
57
+
58
+ function makeElement(selection: ClipSelection, cbs: Callbacks, extra: ClipTabExtras = {}) {
59
+ return (
60
+ <ClipPropertiesPanel
61
+ selection={selection}
62
+ onPreviewClip={cbs.onPreviewClip}
63
+ onCommitClip={cbs.onCommitClip}
64
+ onChangeClip={cbs.onChangeClip}
65
+ onPreviewAudio={cbs.onPreviewAudio}
66
+ onCommitAudio={cbs.onCommitAudio}
67
+ onChangeAudio={cbs.onChangeAudio}
68
+ {...extra}
69
+ />
70
+ )
71
+ }
72
+
73
+ function renderPanel(selection: ClipSelection, extra: ClipTabExtras = {}) {
74
+ const cbs = makeCallbacks()
75
+ const utils = render(makeElement(selection, cbs, extra))
76
+ return {
77
+ ...utils,
78
+ ...cbs,
79
+ /** Re-render the SAME mounted panel with a new selection, keeping the
80
+ * same callback spies. Used to simulate the host re-rendering from
81
+ * outside (e.g. an unrelated field committing elsewhere) while the
82
+ * operator is mid-keystroke on a field this panel owns. */
83
+ rerenderWith: (nextSelection: ClipSelection) => utils.rerender(makeElement(nextSelection, cbs, extra)),
84
+ }
85
+ }
86
+
87
+ /** Opens a nested sub-group (Fades / Ducking) by clicking its collapsible
88
+ * header. Both start collapsed. */
89
+ function openSection(name: string) {
90
+ fireEvent.click(screen.getByRole('button', { name: new RegExp(name) }))
91
+ }
92
+
93
+ /**
94
+ * A real host feeds a previewed item straight back into the tree (its own
95
+ * state update re-renders the panel with the new `item` before the operator
96
+ * releases the pointer) — that's what keeps a controlled `<input>` showing
97
+ * what was just dragged to. A bare mock spy does not, so `SpeedControl`'s
98
+ * `onPointerUp` (which reads the slider's OWN current DOM value) would see
99
+ * React's controlled-input revert instead of the dragged-to value. This tiny
100
+ * stateful wrapper stands in for that host feedback loop for the tests that
101
+ * exercise the speed gesture end-to-end.
102
+ */
103
+ function ClipHarness({ initialItem, spies }: { initialItem: VisualItem; spies: Callbacks }) {
104
+ const [item, setItem] = useState(initialItem)
105
+ return (
106
+ <ClipPropertiesPanel
107
+ selection={{ kind: 'clip', item }}
108
+ onPreviewClip={next => { spies.onPreviewClip(next); setItem(next) }}
109
+ onCommitClip={spies.onCommitClip}
110
+ onChangeClip={next => { spies.onChangeClip(next); setItem(next) }}
111
+ onPreviewAudio={spies.onPreviewAudio}
112
+ onCommitAudio={spies.onCommitAudio}
113
+ onChangeAudio={spies.onChangeAudio}
114
+ />
115
+ )
116
+ }
117
+
118
+ describe('ClipPropertiesPanel — empty state', () => {
119
+ it('shows the empty-state message when nothing is selected', () => {
120
+ renderPanel(null)
121
+ expect(screen.getByText('Select a clip to edit its properties.')).toBeTruthy()
122
+ })
123
+ })
124
+
125
+ describe('ClipPropertiesPanel — clip selection', () => {
126
+ it('renders the current volume, mute, and speed values', () => {
127
+ renderPanel({ kind: 'clip', item: clipItem({ volume: 1.5, muted: true, speed: 2 }) })
128
+
129
+ // No transformSlot offered by this fixture, so Speed — the first tab
130
+ // this video selection actually offers — is the default active tab.
131
+ expect(screen.getByRole('slider', { name: 'Speed' })).toHaveValue('2')
132
+
133
+ openClipTab('Volume')
134
+ expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('1.5')
135
+ expect(screen.getByRole('switch', { name: 'Mute clip' })).toHaveAttribute('aria-checked', 'true')
136
+ })
137
+
138
+ it('defaults volume/muted/speed the same way the modal does (1 / false / 1) when unset', () => {
139
+ renderPanel({ kind: 'clip', item: clipItem() })
140
+
141
+ expect(screen.getByRole('slider', { name: 'Speed' })).toHaveValue('1')
142
+
143
+ openClipTab('Volume')
144
+ expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('1')
145
+ expect(screen.getByRole('switch', { name: 'Mute clip' })).toHaveAttribute('aria-checked', 'false')
146
+ })
147
+
148
+ it('volume: previews on every drag step and commits once, writing item.volume', () => {
149
+ const { onPreviewClip, onCommitClip } = renderPanel({ kind: 'clip', item: clipItem({ volume: 1 }) })
150
+ openClipTab('Volume')
151
+ const slider = screen.getByRole('slider', { name: 'Volume' })
152
+
153
+ fireEvent.change(slider, { target: { value: '1.5' } })
154
+ expect(onPreviewClip).toHaveBeenCalledTimes(1)
155
+ expect(onPreviewClip.mock.calls[0][0]).toMatchObject({ id: 'c1', volume: 1.5 })
156
+ expect(onCommitClip).not.toHaveBeenCalled()
157
+
158
+ fireEvent.pointerUp(slider)
159
+ expect(onCommitClip).toHaveBeenCalledTimes(1)
160
+ })
161
+
162
+ it('mute: fires exactly one discrete onChangeClip, no preview/commit', () => {
163
+ const { onPreviewClip, onCommitClip, onChangeClip } = renderPanel({ kind: 'clip', item: clipItem({ muted: false }) })
164
+ openClipTab('Volume')
165
+
166
+ fireEvent.click(screen.getByRole('switch', { name: 'Mute clip' }))
167
+
168
+ expect(onChangeClip).toHaveBeenCalledTimes(1)
169
+ expect(onChangeClip.mock.calls[0][0]).toMatchObject({ id: 'c1', muted: true })
170
+ expect(onPreviewClip).not.toHaveBeenCalled()
171
+ expect(onCommitClip).not.toHaveBeenCalled()
172
+ })
173
+
174
+ it('speed: dragging previews only the number (duration untouched); commit resolves the real duration change through setClipSpeed', () => {
175
+ // Same fixture cuts.test.ts's own `setClipSpeed` suite uses, so the
176
+ // expected `end` below is directly comparable to that suite's numbers.
177
+ const item = clipItem({ start: 0, end: 10, inPoint: 100, outPoint: 110, speed: 1 })
178
+ const spies = makeCallbacks()
179
+ render(<ClipHarness initialItem={item} spies={spies} />)
180
+ const slider = screen.getByRole('slider', { name: 'Speed' })
181
+
182
+ fireEvent.change(slider, { target: { value: '2' } })
183
+ expect(spies.onPreviewClip).toHaveBeenCalledTimes(1)
184
+ // Mid-drag: the field shows 2x, but nothing has resized yet.
185
+ expect(spies.onPreviewClip.mock.calls[0][0]).toMatchObject({ speed: 2, end: 10 })
186
+ expect(spies.onCommitClip).not.toHaveBeenCalled()
187
+
188
+ fireEvent.pointerUp(slider)
189
+ expect(spies.onCommitClip).toHaveBeenCalledTimes(1)
190
+
191
+ // The commit path previews the setClipSpeed-resolved item (correct end)
192
+ // immediately before signaling commit — this IS "goes through setClipSpeed".
193
+ const resolved = spies.onPreviewClip.mock.calls[spies.onPreviewClip.mock.calls.length - 1][0]
194
+ expect(resolved).toMatchObject({ speed: 2, end: 5, inPoint: 100, outPoint: 110 })
195
+ })
196
+
197
+ it('speed: a preset chip previews then immediately commits the resolved duration', () => {
198
+ const item = clipItem({ start: 0, end: 10, inPoint: 100, outPoint: 110, speed: 1 })
199
+ const spies = makeCallbacks()
200
+ render(<ClipHarness initialItem={item} spies={spies} />)
201
+
202
+ fireEvent.click(screen.getByRole('button', { name: '4×' }))
203
+
204
+ expect(spies.onCommitClip).toHaveBeenCalledTimes(1)
205
+ const resolved = spies.onPreviewClip.mock.calls[spies.onPreviewClip.mock.calls.length - 1][0]
206
+ expect(resolved).toMatchObject({ speed: 4, end: 2.5 }) // 10 / 4
207
+ })
208
+ })
209
+
210
+ describe('ClipPropertiesPanel — generation slot', () => {
211
+ it('renders the host-injected slot in the Generate tab when provided', () => {
212
+ renderPanel({ kind: 'clip', item: clipItem() }, { generationSlot: <div data-testid="gen-slot">Regenerate</div> })
213
+ openClipTab('Generate')
214
+ expect(screen.getByTestId('gen-slot')).toBeTruthy()
215
+ })
216
+
217
+ it('offers no Generate tab, and renders nothing in its place, when absent', () => {
218
+ renderPanel({ kind: 'clip', item: clipItem() })
219
+ expect(screen.queryByRole('button', { name: 'Generate' })).toBeNull()
220
+ expect(screen.queryByTestId('gen-slot')).toBeNull()
221
+ })
222
+ })
223
+
224
+ describe('ClipPropertiesPanel — audio selection', () => {
225
+ it('renders every field with its current value, including the nested Fades and Ducking groups once opened', () => {
226
+ renderPanel({
227
+ kind: 'audio',
228
+ track: audioTrack({
229
+ label: 'VO',
230
+ volume: 1.4,
231
+ muted: true,
232
+ fadeIn: 0.5,
233
+ fadeOut: 0.8,
234
+ ducking: { enabled: true, depth: -9, attack: 0.2, release: 0.6 },
235
+ inPoint: 2,
236
+ outPoint: 8,
237
+ }),
238
+ })
239
+
240
+ expect(screen.getByLabelText('Track label')).toHaveValue('VO')
241
+ expect(screen.getByRole('slider', { name: 'Volume' })).toHaveValue('1.4')
242
+ expect(screen.getByRole('switch', { name: 'Mute track' })).toHaveAttribute('aria-checked', 'true')
243
+ expect(screen.getByLabelText('In point')).toHaveValue(2)
244
+ expect(screen.getByLabelText('Out point')).toHaveValue(8)
245
+
246
+ openSection('Fades')
247
+ expect(screen.getByLabelText('Fade in')).toHaveValue(0.5)
248
+ expect(screen.getByLabelText('Fade out')).toHaveValue(0.8)
249
+
250
+ openSection('Ducking')
251
+ expect(screen.getByRole('switch', { name: 'Enable ducking' })).toHaveAttribute('aria-checked', 'true')
252
+ expect(screen.getByLabelText('Depth')).toHaveValue(-9)
253
+ expect(screen.getByLabelText('Attack')).toHaveValue(0.2)
254
+ expect(screen.getByLabelText('Release')).toHaveValue(0.6)
255
+ })
256
+
257
+ it('label: previews on every keystroke and commits on blur, writing track.label', () => {
258
+ const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ label: 'Old' }) })
259
+ const input = screen.getByLabelText('Track label')
260
+
261
+ fireEvent.change(input, { target: { value: 'New name' } })
262
+ expect(onPreviewAudio).toHaveBeenCalledWith(expect.objectContaining({ label: 'New name' }))
263
+ expect(onCommitAudio).not.toHaveBeenCalled()
264
+
265
+ fireEvent.blur(input)
266
+ expect(onCommitAudio).toHaveBeenCalledTimes(1)
267
+ })
268
+
269
+ it('label falls back to the source basename when unset', () => {
270
+ renderPanel({ kind: 'audio', track: audioTrack({ label: undefined, src: '/media/voice/final-track.mp3' }) })
271
+ expect(screen.getByLabelText('Track label')).toHaveValue('final-track.mp3')
272
+ })
273
+
274
+ it('label falls back to the source basename on commit if cleared to blank', () => {
275
+ const { onPreviewAudio, onCommitAudio } = renderPanel({
276
+ kind: 'audio',
277
+ track: audioTrack({ label: 'Old', src: '/x/y/clip.wav' }),
278
+ })
279
+ const input = screen.getByLabelText('Track label')
280
+
281
+ fireEvent.change(input, { target: { value: ' ' } })
282
+ fireEvent.blur(input)
283
+
284
+ expect(onCommitAudio).toHaveBeenCalledTimes(1)
285
+ const resolved = onPreviewAudio.mock.calls[onPreviewAudio.mock.calls.length - 1][0]
286
+ expect(resolved.label).toBe('clip.wav')
287
+ })
288
+
289
+ it('volume: previews on every drag step and commits once, writing track.volume', () => {
290
+ const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ volume: 1 }) })
291
+ const slider = screen.getByRole('slider', { name: 'Volume' })
292
+
293
+ fireEvent.change(slider, { target: { value: '0.5' } })
294
+ expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({ id: 'a1', volume: 0.5 })
295
+ expect(onCommitAudio).not.toHaveBeenCalled()
296
+
297
+ fireEvent.pointerUp(slider)
298
+ expect(onCommitAudio).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ it('mute: fires exactly one discrete onChangeAudio', () => {
302
+ const { onPreviewAudio, onCommitAudio, onChangeAudio } = renderPanel({ kind: 'audio', track: audioTrack({ muted: false }) })
303
+
304
+ fireEvent.click(screen.getByRole('switch', { name: 'Mute track' }))
305
+
306
+ expect(onChangeAudio).toHaveBeenCalledTimes(1)
307
+ expect(onChangeAudio.mock.calls[0][0]).toMatchObject({ id: 'a1', muted: true })
308
+ expect(onPreviewAudio).not.toHaveBeenCalled()
309
+ expect(onCommitAudio).not.toHaveBeenCalled()
310
+ })
311
+
312
+ it('fades: fade in and fade out each write their own field, preview then commit on blur', () => {
313
+ const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ fadeIn: 0, fadeOut: 0 }) })
314
+ openSection('Fades')
315
+
316
+ const fadeIn = screen.getByLabelText('Fade in')
317
+ fireEvent.change(fadeIn, { target: { value: '1.25' } })
318
+ expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({ fadeIn: 1.25, fadeOut: 0 })
319
+ fireEvent.blur(fadeIn)
320
+ expect(onCommitAudio).toHaveBeenCalledTimes(1)
321
+
322
+ const fadeOut = screen.getByLabelText('Fade out')
323
+ fireEvent.change(fadeOut, { target: { value: '2' } })
324
+ const lastPreview = onPreviewAudio.mock.calls[onPreviewAudio.mock.calls.length - 1][0]
325
+ expect(lastPreview).toMatchObject({ fadeOut: 2 })
326
+ fireEvent.blur(fadeOut)
327
+ expect(onCommitAudio).toHaveBeenCalledTimes(2)
328
+ })
329
+
330
+ it('fade in stepper: fires exactly one discrete onChangeAudio through clampAudioPatch, no preview/commit', () => {
331
+ // The largest new adoption site for the stepper contract, and the only
332
+ // one routing through `clampAudioPatch` (see AudioSection's comment on
333
+ // why the fade rows' own `Math.max(0, v)` is redundant with it). A
334
+ // stepper click is a DISCRETE edit — one final change, one undo entry —
335
+ // never a preview/commit pair.
336
+ const { onPreviewAudio, onCommitAudio, onChangeAudio } = renderPanel({ kind: 'audio', track: audioTrack({ fadeIn: 0 }) })
337
+ openSection('Fades')
338
+
339
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Fade in' }))
340
+
341
+ expect(onChangeAudio).toHaveBeenCalledTimes(1)
342
+ expect(onChangeAudio.mock.calls[0][0]).toMatchObject({ fadeIn: 0.1 })
343
+ expect(onPreviewAudio).not.toHaveBeenCalled()
344
+ expect(onCommitAudio).not.toHaveBeenCalled()
345
+ })
346
+
347
+ it('trim: in point and out point each write their own field', () => {
348
+ const { onPreviewAudio, onCommitAudio } = renderPanel({ kind: 'audio', track: audioTrack({ inPoint: 0, outPoint: 10 }) })
349
+
350
+ const inPoint = screen.getByLabelText('In point')
351
+ fireEvent.change(inPoint, { target: { value: '3' } })
352
+ expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({ inPoint: 3, outPoint: 10 })
353
+ fireEvent.blur(inPoint)
354
+
355
+ const outPoint = screen.getByLabelText('Out point')
356
+ fireEvent.change(outPoint, { target: { value: '9' } })
357
+ const lastPreview = onPreviewAudio.mock.calls[onPreviewAudio.mock.calls.length - 1][0]
358
+ expect(lastPreview).toMatchObject({ outPoint: 9 })
359
+ fireEvent.blur(outPoint)
360
+
361
+ expect(onCommitAudio).toHaveBeenCalledTimes(2)
362
+ })
363
+
364
+ describe('ducking', () => {
365
+ it('numeric fields are disabled until ducking is enabled', () => {
366
+ renderPanel({ kind: 'audio', track: audioTrack({ ducking: undefined }) })
367
+ openSection('Ducking')
368
+
369
+ expect(screen.getByLabelText('Depth')).toBeDisabled()
370
+ expect(screen.getByLabelText('Attack')).toBeDisabled()
371
+ expect(screen.getByLabelText('Release')).toBeDisabled()
372
+ })
373
+
374
+ it('enabling seeds the documented defaults (-12 / 0.3 / 0.5) rather than a half-empty object', () => {
375
+ const { onChangeAudio } = renderPanel({ kind: 'audio', track: audioTrack({ ducking: undefined }) })
376
+ openSection('Ducking')
377
+
378
+ fireEvent.click(screen.getByRole('switch', { name: 'Enable ducking' }))
379
+
380
+ expect(onChangeAudio).toHaveBeenCalledTimes(1)
381
+ expect(onChangeAudio.mock.calls[0][0]).toMatchObject({
382
+ ducking: { enabled: true, depth: -12, attack: 0.3, release: 0.5 },
383
+ })
384
+ })
385
+
386
+ it('disabling keeps the tuned depth/attack/release so re-enabling restores them', () => {
387
+ const { onChangeAudio } = renderPanel({
388
+ kind: 'audio',
389
+ track: audioTrack({ ducking: { enabled: true, depth: -20, attack: 0.9, release: 1.2 } }),
390
+ })
391
+ openSection('Ducking')
392
+
393
+ fireEvent.click(screen.getByRole('switch', { name: 'Enable ducking' }))
394
+
395
+ expect(onChangeAudio.mock.calls[0][0]).toMatchObject({
396
+ ducking: { enabled: false, depth: -20, attack: 0.9, release: 1.2 },
397
+ })
398
+ })
399
+
400
+ it('depth/attack/release write the nested track.ducking object, preview then commit on blur', () => {
401
+ const { onPreviewAudio, onCommitAudio } = renderPanel({
402
+ kind: 'audio',
403
+ track: audioTrack({ ducking: { enabled: true, depth: -12, attack: 0.3, release: 0.5 } }),
404
+ })
405
+ openSection('Ducking')
406
+
407
+ const depth = screen.getByLabelText('Depth')
408
+ fireEvent.change(depth, { target: { value: '-6' } })
409
+ expect(onPreviewAudio.mock.calls[0][0]).toMatchObject({
410
+ ducking: { enabled: true, depth: -6, attack: 0.3, release: 0.5 },
411
+ })
412
+ fireEvent.blur(depth)
413
+ expect(onCommitAudio).toHaveBeenCalledTimes(1)
414
+ })
415
+ })
416
+
417
+ it('has no delete control anywhere in the panel (deliberate — see the AudioSection doc comment)', () => {
418
+ renderPanel({ kind: 'audio', track: audioTrack() })
419
+ openSection('Fades')
420
+ openSection('Ducking')
421
+
422
+ expect(screen.queryByRole('button', { name: /delete/i })).toBeNull()
423
+ expect(screen.queryByText(/delete/i)).toBeNull()
424
+ })
425
+ })
426
+
427
+ describe('ClipPropertiesPanel — mid-typed value survives an unrelated re-render', () => {
428
+ it('a text field (Track label) keeps its draft when the panel re-renders with an unrelated change', () => {
429
+ const track = audioTrack({ label: 'Original' })
430
+ const { rerenderWith } = renderPanel({ kind: 'audio', track })
431
+ const input = screen.getByLabelText('Track label')
432
+
433
+ fireEvent.change(input, { target: { value: 'Ne' } }) // mid-typing "Ne" of "New name"
434
+ expect(input).toHaveValue('Ne')
435
+
436
+ // The host re-renders the panel — e.g. an unrelated volume preview
437
+ // committed elsewhere — while the operator is still mid-edit.
438
+ rerenderWith({ kind: 'audio', track: { ...track, volume: 1.9 } })
439
+ expect(input).toHaveValue('Ne')
440
+
441
+ fireEvent.change(input, { target: { value: 'New name' } })
442
+ expect(input).toHaveValue('New name')
443
+ })
444
+
445
+ it('a number field (Fade in) keeps its draft when the panel re-renders with an unrelated change', () => {
446
+ const track = audioTrack({ fadeIn: 0 })
447
+ const { rerenderWith } = renderPanel({ kind: 'audio', track })
448
+ openSection('Fades')
449
+ const input = screen.getByLabelText('Fade in')
450
+
451
+ fireEvent.change(input, { target: { value: '1' } }) // mid-typing "1" of "1.5"
452
+ expect(input).toHaveValue(1)
453
+
454
+ rerenderWith({ kind: 'audio', track: { ...track, volume: 1.9 } })
455
+ expect(input).toHaveValue(1)
456
+
457
+ fireEvent.change(input, { target: { value: '1.5' } })
458
+ expect(input).toHaveValue(1.5)
459
+ })
460
+ })
461
+
462
+ describe('ClipPropertiesPanel — speed is video-only', () => {
463
+ it('hides the Speed control for a non-video clip', () => {
464
+ // `setClipSpeed` early-returns for a non-video item, so a Speed slider on
465
+ // an image would be a control that silently does nothing. The modal this
466
+ // panel replaces gated the same way.
467
+ const image = { id: 'i1', type: 'image', src: 'p.png', start: 0, end: 4 } as VisualItem
468
+ render(
469
+ <ClipPropertiesPanel
470
+ selection={{ kind: 'clip', item: image }}
471
+ onPreviewClip={vi.fn()}
472
+ onCommitClip={vi.fn()}
473
+ onChangeClip={vi.fn()}
474
+ onPreviewAudio={vi.fn()}
475
+ onCommitAudio={vi.fn()}
476
+ onChangeAudio={vi.fn()}
477
+ />,
478
+ )
479
+
480
+ expect(screen.queryByRole('button', { name: 'Speed' })).toBeNull()
481
+ expect(screen.queryByLabelText('Speed')).toBeNull()
482
+ // The video-agnostic controls are still there, on the Volume tab (the
483
+ // only tab this image selection offers besides Transform, so it's also
484
+ // the default active tab here).
485
+ openClipTab('Volume')
486
+ expect(screen.getByLabelText('Mute clip')).toBeTruthy()
487
+ })
488
+ })
489
+
490
+ describe('ClipPropertiesPanel — clip tabs', () => {
491
+ it('defaults to the Transform tab, showing transformSlot content on first render', () => {
492
+ renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div data-testid="transform-slot">Transform body</div> })
493
+
494
+ expect(screen.getByTestId('transform-slot')).toBeTruthy()
495
+ expect(screen.getByRole('button', { name: 'Transform' })).toHaveAttribute('aria-pressed', 'true')
496
+ })
497
+
498
+ it('an image clip offers no Speed, Crop, or Generate tabs', () => {
499
+ // Crop/Generate are gated on the CALLER supplying onOpenCrop/
500
+ // generationSlot at all (the host encodes the "images don't generate or
501
+ // crop" rule) — this fixture mirrors a host that, for an image, simply
502
+ // doesn't offer those slots.
503
+ renderPanel({ kind: 'clip', item: clipItem({ type: 'image' }) }, { transformSlot: <div>T</div> })
504
+
505
+ expect(screen.getByRole('button', { name: 'Transform' })).toBeTruthy()
506
+ expect(screen.getByRole('button', { name: 'Volume' })).toBeTruthy()
507
+ expect(screen.queryByRole('button', { name: 'Speed' })).toBeNull()
508
+ expect(screen.queryByRole('button', { name: 'Crop' })).toBeNull()
509
+ expect(screen.queryByRole('button', { name: 'Generate' })).toBeNull()
510
+ })
511
+
512
+ it('a main-track video with both slots offers all five tabs, in order', () => {
513
+ renderPanel(
514
+ { kind: 'clip', item: clipItem({ type: 'video' }) },
515
+ { transformSlot: <div>T</div>, onOpenCrop: vi.fn(), generationSlot: <div>G</div> },
516
+ )
517
+
518
+ const names = screen.getAllByRole('button').map(b => b.textContent)
519
+ expect(names).toEqual(['Transform', 'Speed', 'Volume', 'Crop', 'Generate'])
520
+ })
521
+
522
+ it('renders no Crop tab when onOpenCrop is omitted', () => {
523
+ renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div>T</div> })
524
+ expect(screen.queryByRole('button', { name: 'Crop' })).toBeNull()
525
+ })
526
+
527
+ it("clicking the Crop tab's button calls onOpenCrop", () => {
528
+ const onOpenCrop = vi.fn()
529
+ renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div>T</div>, onOpenCrop })
530
+
531
+ openClipTab('Crop')
532
+ fireEvent.click(screen.getByRole('button', { name: 'Open crop tool' }))
533
+ expect(onOpenCrop).toHaveBeenCalledTimes(1)
534
+ })
535
+
536
+ function StatefulSlot() {
537
+ const [value, setValue] = useState('')
538
+ return <input aria-label="Gen prompt" value={value} onChange={e => setValue(e.target.value)} />
539
+ }
540
+
541
+ it("switching tabs away from Generate and back preserves the generation slot's internal state", () => {
542
+ renderPanel(
543
+ { kind: 'clip', item: clipItem() },
544
+ { transformSlot: <div>T</div>, generationSlot: <StatefulSlot /> },
545
+ )
546
+
547
+ openClipTab('Generate')
548
+ fireEvent.change(screen.getByLabelText('Gen prompt'), { target: { value: 'a sunset over water' } })
549
+ expect(screen.getByLabelText('Gen prompt')).toHaveValue('a sunset over water')
550
+
551
+ // Switch away, then back. If the Generate tab body were unmounted on
552
+ // switch-away (a plain conditional render instead of lazy-mount-then-
553
+ // keep-mounted), StatefulSlot's `useState('')` initializer would re-run
554
+ // on remount and this value would be lost.
555
+ openClipTab('Volume')
556
+ openClipTab('Generate')
557
+ expect(screen.getByLabelText('Gen prompt')).toHaveValue('a sunset over water')
558
+ })
559
+
560
+ it('falls back to Transform when the persisted tab id is not in the current tab set', () => {
561
+ // Simulates the operator having 'generate' active on a different clip,
562
+ // then selecting one whose host doesn't offer a generationSlot.
563
+ window.localStorage.setItem('montaj.editor.clipPanelTab', JSON.stringify('generate'))
564
+ renderPanel({ kind: 'clip', item: clipItem() }, { transformSlot: <div data-testid="transform-slot">T</div> })
565
+
566
+ expect(screen.getByTestId('transform-slot')).toBeTruthy()
567
+ expect(screen.getByRole('button', { name: 'Transform' })).toHaveAttribute('aria-pressed', 'true')
568
+ })
569
+ })
570
+
571
+ describe('ClipPropertiesPanel — audio timeline position (modal parity)', () => {
572
+ it('renders Start and End and writes them as plain values', () => {
573
+ const track = { id: 'a1', src: '/m/bed.mp3', start: 2, end: 30, sourceDuration: 30 } as AudioTrack
574
+ const onPreviewAudio = vi.fn()
575
+ render(
576
+ <ClipPropertiesPanel
577
+ selection={{ kind: 'audio', track }}
578
+ onPreviewClip={vi.fn()}
579
+ onCommitClip={vi.fn()}
580
+ onChangeClip={vi.fn()}
581
+ onPreviewAudio={onPreviewAudio}
582
+ onCommitAudio={vi.fn()}
583
+ onChangeAudio={vi.fn()}
584
+ />,
585
+ )
586
+
587
+ expect(screen.getByLabelText('Start')).toHaveValue(2)
588
+ expect(screen.getByLabelText('End')).toHaveValue(30)
589
+
590
+ fireEvent.change(screen.getByLabelText('Start'), { target: { value: '5' } })
591
+ expect(onPreviewAudio).toHaveBeenLastCalledWith(expect.objectContaining({ start: 5 }))
592
+
593
+ fireEvent.change(screen.getByLabelText('End'), { target: { value: '25' } })
594
+ expect(onPreviewAudio).toHaveBeenLastCalledWith(expect.objectContaining({ end: 25 }))
595
+ })
596
+ })
597
+
598
+ describe('ClipPropertiesPanel — audio validation (parity with the retired modal)', () => {
599
+ function audioTrack(over: Partial<AudioTrack> = {}): AudioTrack {
600
+ return { id: 'a1', src: '/m/bed.mp3', start: 2, end: 30, sourceDuration: 40, ...over } as AudioTrack
601
+ }
602
+ function renderAudio(track: AudioTrack) {
603
+ const onPreviewAudio = vi.fn()
604
+ render(
605
+ <ClipPropertiesPanel
606
+ selection={{ kind: 'audio', track }}
607
+ onPreviewClip={vi.fn()} onCommitClip={vi.fn()} onChangeClip={vi.fn()}
608
+ onPreviewAudio={onPreviewAudio} onCommitAudio={vi.fn()} onChangeAudio={vi.fn()}
609
+ />,
610
+ )
611
+ return { onPreviewAudio }
612
+ }
613
+ const last = (fn: ReturnType<typeof vi.fn>) => fn.mock.calls[fn.mock.calls.length - 1][0] as AudioTrack
614
+
615
+ it('never lets End land at or before Start (a negative-duration track)', () => {
616
+ const { onPreviewAudio } = renderAudio(audioTrack())
617
+ fireEvent.change(screen.getByLabelText('End'), { target: { value: '1' } }) // before start=2
618
+ expect(last(onPreviewAudio).end).toBeGreaterThan(2)
619
+ })
620
+
621
+ it('never lets Start land at or after End', () => {
622
+ const { onPreviewAudio } = renderAudio(audioTrack())
623
+ fireEvent.change(screen.getByLabelText('Start'), { target: { value: '99' } })
624
+ expect(last(onPreviewAudio).start).toBeLessThan(30)
625
+ })
626
+
627
+ it('clamps Out point to the source duration', () => {
628
+ const { onPreviewAudio } = renderAudio(audioTrack())
629
+ fireEvent.change(screen.getByLabelText('Out point'), { target: { value: '999' } })
630
+ expect(last(onPreviewAudio).outPoint).toBe(40)
631
+ })
632
+
633
+ it('never lets In point cross Out point', () => {
634
+ const { onPreviewAudio } = renderAudio(audioTrack({ outPoint: 10 }))
635
+ fireEvent.change(screen.getByLabelText('In point'), { target: { value: '25' } })
636
+ expect(last(onPreviewAudio).inPoint).toBeLessThanOrEqual(10)
637
+ })
638
+
639
+ it('caps a fade at the modal\'s 5s ceiling rather than leaving it unbounded', () => {
640
+ const { onPreviewAudio } = renderAudio(audioTrack())
641
+ fireEvent.click(screen.getByRole('button', { name: 'Fades' })) // open the group
642
+ fireEvent.change(screen.getByLabelText('Fade in'), { target: { value: '999' } })
643
+ expect(last(onPreviewAudio).fadeIn).toBe(5)
644
+ })
645
+ })