@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,765 @@
1
+ import { useState } from 'react'
2
+ import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest'
3
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
4
+ import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
5
+ import VideoEditor from '../VideoEditor'
6
+ import { installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
7
+
8
+ // ── The CapCut layout's right properties panel ───────────────────────────────
9
+ //
10
+ // The clip/audio inspector used to be a host-rendered MODAL, opened by a
11
+ // double-click on the timeline and wired through the `renderClipInspector`
12
+ // render-prop (now removed). It is a persistent column now: single-click
13
+ // selection populates it, and the column is ALWAYS mounted so the preview never
14
+ // resizes as the operator clicks from clip to clip.
15
+ //
16
+ // Two behaviours here can only be tested at THIS seam, not in
17
+ // ClipPropertiesPanel's own suite:
18
+ //
19
+ // 1. RIPPLE (Task G). A speed-up shrinks a clip and leaves a gap. The retired
20
+ // modal closed it via `collapseGaps` when the magnet was on; the panel
21
+ // cannot — it only ever sees the one item, and collapsing gaps moves that
22
+ // item's SIBLINGS. So VideoEditor's commit handler owns it.
23
+ // 2. The DRAG FEEDBACK LOOP (Task H). `SpeedControl` reads the slider's own
24
+ // DOM value at pointerup. If the previewed item is not fed back into the
25
+ // panel's `selection` prop DURING the drag, React reverts the controlled
26
+ // input and commit reads the pre-drag value.
27
+
28
+ const MEDIA_PANEL = <div data-testid="media-panel">Footage bin</div>
29
+
30
+ /** Two CONTIGUOUS video clips: clip-0 [0,10] holding a 10s source window at 1×,
31
+ * clip-1 [10,14] butted against it. No gap to start with — so turning the
32
+ * magnet on is a no-op (`handleRippleToggle` collapses on enable) and the only
33
+ * gap in the test is the one the speed change itself opens. */
34
+ function makeProject(overrides: Partial<Project> = {}): Project {
35
+ return {
36
+ id: 'vid-1',
37
+ name: 'Test Video',
38
+ status: 'draft',
39
+ editingPrompt: '',
40
+ projectType: 'video',
41
+ settings: { resolution: [1080, 1920], fps: 30 },
42
+ tracks: [
43
+ [
44
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 },
45
+ { id: 'clip-1', type: 'video', src: 'b.mp4', start: 10, end: 14, inPoint: 0, outPoint: 4 },
46
+ ],
47
+ ],
48
+ audio: { tracks: [] },
49
+ assets: [],
50
+ ...overrides,
51
+ } as Project
52
+ }
53
+
54
+ function makeFakeAdapter(project: Project): EditorAdapter<Project> {
55
+ return {
56
+ loadProject: vi.fn(async () => project),
57
+ saveProject: vi.fn(async () => {}),
58
+ subscribe: () => () => {},
59
+ render: async function* (): AsyncIterable<RenderEvent> {
60
+ yield { type: 'done', outputPath: '/out.mp4' }
61
+ },
62
+ resolveImageSrc: (el: ImageElement) => el.src,
63
+ compileOverlay: vi.fn(async () => () => null),
64
+ listGlobalOverlays: vi.fn(async () => []),
65
+ listSystemOverlays: vi.fn(async () => []),
66
+ uploadFile: vi.fn(async () => '/path'),
67
+ fileUrl: (path: string) => path,
68
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
69
+ restoreVersion: vi.fn(async () => project),
70
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
71
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
72
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
73
+ } as unknown as EditorAdapter<Project>
74
+ }
75
+
76
+ /** The project body of the LAST `saveProject` call — what a commit persisted. */
77
+ function lastSaved(adapter: EditorAdapter<Project>): Project {
78
+ const calls = (adapter.saveProject as unknown as { mock: { calls: unknown[][] } }).mock.calls
79
+ return calls[calls.length - 1][1] as Project
80
+ }
81
+
82
+ /**
83
+ * One of the CLIP panel's own tab buttons — Transform · Speed · Volume · Crop
84
+ * · Generate — resolved INSIDE that tab strip's `role="group"`, the same
85
+ * scoping `overlayTab` below uses for the overlay panel's Content/Transform
86
+ * pair, and for the same defensive reason: a tab's own body can grow a
87
+ * control whose accessible name would otherwise collide with a tab label.
88
+ */
89
+ async function clipTab(name: 'Transform' | 'Speed' | 'Volume' | 'Crop' | 'Generate'): Promise<HTMLButtonElement> {
90
+ const strip = await screen.findByRole('group', { name: 'Clip panel view' })
91
+ const button = Array.from(strip.querySelectorAll('button')).find(b => b.textContent === name)
92
+ if (!button) throw new Error(`no "${name}" tab in the clip panel strip`)
93
+ return button
94
+ }
95
+
96
+ /** The tab labels the clip panel currently offers, in TabNav's own render
97
+ * order — for asserting a WHOLE tab set (e.g. "just Transform and Volume for
98
+ * an image clip") in one line instead of one assertion per missing tab. */
99
+ function clipTabNames(strip: HTMLElement): string[] {
100
+ return Array.from(strip.querySelectorAll('button')).map(b => b.textContent ?? '')
101
+ }
102
+
103
+ /**
104
+ * The Crop tab's OWN body button ("Open crop tool", a plain-text button with
105
+ * no `aria-label`) — distinct from the crop TOOLBAR button in the controls
106
+ * bar, which is an icon-only button carrying `aria-label="Crop source"`. The
107
+ * two used to share the same visible/accessible text ("Crop source"); the
108
+ * body button's label was changed to resolve that collision, so scoping to
109
+ * the clip panel's own subtree (the tab strip's parent) is no longer needed
110
+ * to disambiguate — kept anyway as good practice for a panel-specific query.
111
+ */
112
+ async function cropTabBodyButton(): Promise<HTMLButtonElement> {
113
+ const strip = await screen.findByRole('group', { name: 'Clip panel view' })
114
+ const scope = strip.parentElement as HTMLElement
115
+ return waitFor(() => {
116
+ const found = Array.from(scope.querySelectorAll('button')).find(b => b.textContent === 'Open crop tool')
117
+ if (!found) throw new Error('no "Open crop tool" button in the clip panel body')
118
+ return found
119
+ })
120
+ }
121
+
122
+ function renderCapCut(
123
+ project: Project,
124
+ adapter: EditorAdapter<Project>,
125
+ { slots, ...props }: { slots?: Record<string, unknown> } & Record<string, unknown> = {},
126
+ ) {
127
+ return render(
128
+ <VideoEditor
129
+ project={project}
130
+ adapter={adapter}
131
+ onProjectChange={vi.fn()}
132
+ slots={{ mediaPanel: MEDIA_PANEL, ...slots }}
133
+ {...props}
134
+ />,
135
+ )
136
+ }
137
+
138
+ beforeEach(() => {
139
+ // The left panel persists its active tab; a leftover value from an earlier
140
+ // test would decide which tab this one opens on.
141
+ localStorage.clear()
142
+ // The caption panel now has Style / Captions sub-tabs defaulting to Style;
143
+ // the CapCut-left-panel test wants the transcript ('caption one') visible.
144
+ localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
145
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
146
+ vi.spyOn(console, 'error').mockImplementation(() => {})
147
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
148
+ observe() {}
149
+ unobserve() {}
150
+ disconnect() {}
151
+ }
152
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
153
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
154
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
155
+ state = 'running'
156
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
157
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
158
+ get destination() { return {} }
159
+ close() {}
160
+ }
161
+ })
162
+ afterEach(() => vi.restoreAllMocks())
163
+
164
+ describe('VideoEditor — CapCut right properties panel', () => {
165
+ it('always renders the column, showing the generic empty state when nothing is selected', async () => {
166
+ const project = makeProject()
167
+ renderCapCut(project, makeFakeAdapter(project))
168
+
169
+ // The column's divider is the structural tell that it mounted at all —
170
+ // with NOTHING selected, which is the whole point of "always visible".
171
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
172
+ expect(screen.getByText('Select an element')).toBeTruthy()
173
+ })
174
+
175
+ it('renders the host-supplied propertiesEmptyState slot instead of the default when nothing is selected', async () => {
176
+ const project = makeProject()
177
+ renderCapCut(project, makeFakeAdapter(project), {
178
+ slots: { propertiesEmptyState: <div data-testid="host-empty">host empty</div> },
179
+ })
180
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
181
+ expect(screen.getByTestId('host-empty')).toBeTruthy()
182
+ // The host node REPLACES the generic default, it is not stacked with it.
183
+ expect(screen.queryByText('Select an element')).toBeNull()
184
+ })
185
+
186
+ it('swaps in the clip properties when a video clip is selected, defaulting to Transform', async () => {
187
+ onTestFinished(installCanvasHarness())
188
+ const project = makeProject()
189
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
190
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
191
+
192
+ selectCanvasItem(container, project, { type: 'video' })
193
+
194
+ // Transform is the default tab, and its body is the shared OverlayInspector
195
+ // — the same control the overlay branch's Transform tab uses.
196
+ expect((await clipTab('Transform')).getAttribute('aria-pressed')).toBe('true')
197
+ expect(await screen.findByLabelText('Scale')).toBeTruthy()
198
+ // Exactly one branch renders — the empty state is gone, not stacked.
199
+ expect(screen.queryByText('Select an element')).toBeNull()
200
+ })
201
+
202
+ it('switching to the Speed tab reveals the speed control (video only)', async () => {
203
+ onTestFinished(installCanvasHarness())
204
+ const project = makeProject()
205
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
206
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
207
+ selectCanvasItem(container, project, { type: 'video' })
208
+ await screen.findByLabelText('Scale')
209
+
210
+ fireEvent.click(await clipTab('Speed'))
211
+
212
+ // Not asserting the Transform body is gone: ClipTabs keeps every opened
213
+ // tab MOUNTED (only hiding the inactive ones) once it has been shown, so
214
+ // the Transform inputs are still in the DOM here — just hidden, and that
215
+ // is by design (see ClipPropertiesPanel's "Lazy mount, then KEEP MOUNTED"
216
+ // comment), not a leak this test should chase.
217
+ expect(await screen.findByRole('slider', { name: 'Speed' })).toBeTruthy()
218
+ })
219
+
220
+ it('switching to the Volume tab reveals volume and mute controls', async () => {
221
+ onTestFinished(installCanvasHarness())
222
+ const project = makeProject()
223
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
224
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
225
+ selectCanvasItem(container, project, { type: 'video' })
226
+ await screen.findByLabelText('Scale')
227
+
228
+ fireEvent.click(await clipTab('Volume'))
229
+
230
+ expect(await screen.findByLabelText('Mute clip')).toBeTruthy()
231
+ })
232
+
233
+ it('swaps in the audio-track properties when an audio track is selected', async () => {
234
+ onTestFinished(installCanvasHarness())
235
+ const project = makeProject({
236
+ audio: { tracks: [{ id: 'a0', src: 'vo.wav', start: 0, end: 4 }] },
237
+ } as unknown as Partial<Project>)
238
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
239
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
240
+
241
+ selectCanvasItem(container, project, { id: 'a0' })
242
+
243
+ expect(await screen.findByLabelText('Track label')).toBeTruthy()
244
+ expect(screen.getByLabelText('Mute track')).toBeTruthy()
245
+ // Speed is video-only, so an audio selection must not carry a Speed slider.
246
+ expect(screen.queryByRole('slider', { name: 'Speed' })).toBeNull()
247
+ })
248
+
249
+ it('calls renderGenerationPanel with the SELECTED clip\'s id, and its node mounts in the Generate tab', async () => {
250
+ onTestFinished(installCanvasHarness())
251
+ // Both `regenEnabled` and the clip's own frozen `generation` provenance
252
+ // have to be present — the Generate tab is gated on the clip actually
253
+ // being a regenerable generation, not merely on being a video.
254
+ const project = makeProject({
255
+ tracks: [[
256
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110,
257
+ generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' } },
258
+ { id: 'clip-1', type: 'video', src: 'b.mp4', start: 10, end: 14, inPoint: 0, outPoint: 4 },
259
+ ]],
260
+ } as unknown as Partial<Project>)
261
+ const renderGenerationPanel = vi.fn(({ clipId }: { clipId: string }) => (
262
+ <div data-testid="generation-panel">Regenerate {clipId}</div>
263
+ ))
264
+ const { container } = renderCapCut(project, makeFakeAdapter(project), { renderGenerationPanel, regenEnabled: true })
265
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
266
+
267
+ // Not called with nothing selected — it hangs off the clip branch, and the
268
+ // seam is per-clip, so there is no clip id to call it with yet.
269
+ expect(screen.queryByTestId('generation-panel')).toBeNull()
270
+ expect(renderGenerationPanel).not.toHaveBeenCalled()
271
+
272
+ selectCanvasItem(container, project, { type: 'video' })
273
+
274
+ // The render-prop is called as soon as the clip is selected — it builds
275
+ // the slot VideoEditor hands to the tab shell, independent of which tab is
276
+ // showing. The whole point of the render-prop over a static node: the
277
+ // editor owns selection, so it tells the host WHICH clip to draw for.
278
+ await waitFor(() => expect(renderGenerationPanel).toHaveBeenCalledWith({ clipId: 'clip-0' }))
279
+ // But the Generate tab's body is lazy-mounted — it does not reach the DOM
280
+ // until the operator actually opens that tab.
281
+ expect(screen.queryByTestId('generation-panel')).toBeNull()
282
+
283
+ fireEvent.click(await clipTab('Generate'))
284
+
285
+ expect(await screen.findByTestId('generation-panel')).toBeTruthy()
286
+ })
287
+
288
+ it('remounts the Generate tab body on a clip-to-clip switch, rather than reconciling it in place with the OLD clip\'s content', async () => {
289
+ onTestFinished(installCanvasHarness())
290
+ // Two DIFFERENT generated clips, both eligible for the Generate tab.
291
+ const project = makeProject({
292
+ tracks: [[
293
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110,
294
+ generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' } },
295
+ { id: 'clip-1', type: 'video', src: 'b.mp4', start: 10, end: 20, inPoint: 0, outPoint: 10,
296
+ generation: { sceneId: 'sc-2', prompt: 'a close-up', provider: 'kling' } },
297
+ ]],
298
+ } as unknown as Partial<Project>)
299
+
300
+ // A stand-in for the host's real regen form. Like the real one, it seeds
301
+ // state from `clipId` in a `useState` INITIALIZER — which React only
302
+ // ever runs on a component instance's initial mount, never on a
303
+ // re-render with new props. If `generationSlot` were reconciled IN PLACE
304
+ // across the clip switch (missing or wrong `key`), this label would keep
305
+ // reading the FIRST clip it ever mounted for even though
306
+ // `renderGenerationPanel` had already been called with the new clip's
307
+ // id — the exact stale-form bug (wrong clip's prompt queued for
308
+ // regeneration, spending credits on the wrong content) the
309
+ // `key={clipSelection.item.id}` guard exists to prevent. Calling the
310
+ // render-prop with the right id is necessary but not sufficient to catch
311
+ // that bug; only the rendered content proves the subtree actually
312
+ // remounted.
313
+ function FakeRegenForm({ clipId }: { clipId: string }) {
314
+ const [seededFor] = useState(clipId)
315
+ return <div data-testid="generation-panel">seeded for {seededFor}</div>
316
+ }
317
+ const renderGenerationPanel = vi.fn(({ clipId }: { clipId: string }) => <FakeRegenForm clipId={clipId} />)
318
+ const { container } = renderCapCut(project, makeFakeAdapter(project), { renderGenerationPanel, regenEnabled: true })
319
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
320
+
321
+ selectCanvasItem(container, project, { id: 'clip-0' })
322
+ await waitFor(() => expect(renderGenerationPanel).toHaveBeenCalledWith({ clipId: 'clip-0' }))
323
+ fireEvent.click(await clipTab('Generate'))
324
+ expect(await screen.findByTestId('generation-panel')).toHaveTextContent('seeded for clip-0')
325
+
326
+ selectCanvasItem(container, project, { id: 'clip-1' })
327
+
328
+ // The render-prop is called with the NEW clip's id — this much would
329
+ // pass even with the key missing.
330
+ await waitFor(() => expect(renderGenerationPanel).toHaveBeenCalledWith({ clipId: 'clip-1' }))
331
+ // The OBSERVABLE remount: the seeded label must now read clip-1. Without
332
+ // the key, React would reconcile `FakeRegenForm` in place, its `useState`
333
+ // initializer would NOT re-run, and this would still read "seeded for
334
+ // clip-0" forever — the credit-spending bug the key guards against.
335
+ expect(await screen.findByTestId('generation-panel')).toHaveTextContent('seeded for clip-1')
336
+ })
337
+
338
+ it('does not call renderGenerationPanel for an audio track (generation is video-only)', async () => {
339
+ onTestFinished(installCanvasHarness())
340
+ const project = makeProject({
341
+ audio: { tracks: [{ id: 'a0', src: 'vo.wav', start: 0, end: 4 }] },
342
+ } as unknown as Partial<Project>)
343
+ const renderGenerationPanel = vi.fn(() => <div data-testid="generation-panel">Regenerate</div>)
344
+ const { container } = renderCapCut(project, makeFakeAdapter(project), { renderGenerationPanel })
345
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
346
+
347
+ selectCanvasItem(container, project, { id: 'a0' })
348
+
349
+ expect(await screen.findByLabelText('Track label')).toBeTruthy()
350
+ expect(screen.queryByTestId('generation-panel')).toBeNull()
351
+ expect(renderGenerationPanel).not.toHaveBeenCalled()
352
+ })
353
+
354
+ // ── Task H: the drag-preview feedback loop ─────────────────────────────────
355
+ it('a speed drag round-trips: the previewed value survives to pointerup and lands in the saved project', async () => {
356
+ onTestFinished(installCanvasHarness())
357
+ const project = makeProject()
358
+ const adapter = makeFakeAdapter(project)
359
+ const { container } = renderCapCut(project, adapter)
360
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
361
+ selectCanvasItem(container, project, { type: 'video' })
362
+ fireEvent.click(await clipTab('Speed'))
363
+
364
+ const slider = await screen.findByRole('slider', { name: 'Speed' })
365
+ fireEvent.change(slider, { target: { value: '2' } })
366
+
367
+ // The load-bearing assertion. The panel is controlled off `selection`,
368
+ // which is derived from the project — so the preview must have updated the
369
+ // project state, or React would have reverted the input to 1 by now and the
370
+ // commit below would read the pre-drag value off the DOM.
371
+ expect(slider).toHaveValue('2')
372
+
373
+ fireEvent.pointerUp(slider)
374
+
375
+ await waitFor(() => {
376
+ const items = lastSaved(adapter).tracks?.[0]?.items ?? []
377
+ // `end` re-fit through setClipSpeed: the same 10s source window at 2×.
378
+ expect(items[0]).toMatchObject({ id: 'clip-0', speed: 2, end: 5 })
379
+ })
380
+ })
381
+
382
+ // ── Task G: ripple after a committed speed change ──────────────────────────
383
+ it('with the magnet on, a committed speed change closes the gap it opened', async () => {
384
+ onTestFinished(installCanvasHarness())
385
+ const project = makeProject()
386
+ const adapter = makeFakeAdapter(project)
387
+ const { container } = renderCapCut(project, adapter)
388
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
389
+
390
+ // Ripple on FIRST — the fixture has no gaps, so enabling it is a no-op and
391
+ // the only gap in play is the one the speed change opens below.
392
+ fireEvent.click(screen.getByLabelText('Ripple mode'))
393
+ selectCanvasItem(container, project, { type: 'video' })
394
+ fireEvent.click(await clipTab('Speed'))
395
+
396
+ const slider = await screen.findByRole('slider', { name: 'Speed' })
397
+ fireEvent.change(slider, { target: { value: '2' } })
398
+ fireEvent.pointerUp(slider)
399
+
400
+ await waitFor(() => {
401
+ const items = lastSaved(adapter).tracks?.[0]?.items ?? []
402
+ expect(items[0]).toMatchObject({ id: 'clip-0', speed: 2, end: 5 })
403
+ // The sibling followed the shrink — collapseGaps ran on the same commit.
404
+ expect(items[1]).toMatchObject({ id: 'clip-1', start: 5, end: 9 })
405
+ })
406
+ })
407
+
408
+ it('with the magnet off, the same speed change leaves the gap open', async () => {
409
+ onTestFinished(installCanvasHarness())
410
+ const project = makeProject()
411
+ const adapter = makeFakeAdapter(project)
412
+ const { container } = renderCapCut(project, adapter)
413
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
414
+ selectCanvasItem(container, project, { type: 'video' })
415
+ fireEvent.click(await clipTab('Speed'))
416
+
417
+ const slider = await screen.findByRole('slider', { name: 'Speed' })
418
+ fireEvent.change(slider, { target: { value: '2' } })
419
+ fireEvent.pointerUp(slider)
420
+
421
+ await waitFor(() => {
422
+ const items = lastSaved(adapter).tracks?.[0]?.items ?? []
423
+ expect(items[0]).toMatchObject({ id: 'clip-0', speed: 2, end: 5 })
424
+ expect(items[1]).toMatchObject({ id: 'clip-1', start: 10, end: 14 })
425
+ })
426
+ })
427
+
428
+ // NOTE the name: this does NOT exercise the magnet, and cannot — see the
429
+ // comment below. It pins the OTHER half of the gate: that the ripple keys on
430
+ // a DURATION change and not on "any commit". The magnet-on/magnet-off pair
431
+ // above is what proves `rippleMode` itself gates the collapse.
432
+ it('a mute toggle does not ripple, because the ripple keys on a duration change', async () => {
433
+ onTestFinished(installCanvasHarness())
434
+ const project = makeProject({
435
+ // A REAL gap this time, so a stray collapseGaps would be unmissable.
436
+ tracks: [[
437
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 },
438
+ { id: 'clip-1', type: 'video', src: 'b.mp4', start: 20, end: 24, inPoint: 0, outPoint: 4 },
439
+ ]],
440
+ } as unknown as Partial<Project>)
441
+ const adapter = makeFakeAdapter(project)
442
+ const { container } = renderCapCut(project, adapter)
443
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
444
+ selectCanvasItem(container, project, { type: 'video' })
445
+ fireEvent.click(await clipTab('Volume'))
446
+
447
+ // Deliberately NOT touching the magnet: `handleRippleToggle` collapses on
448
+ // enable, which would close this gap before the mute even happened.
449
+ fireEvent.click(await screen.findByLabelText('Mute clip'))
450
+
451
+ await waitFor(() => {
452
+ const items = lastSaved(adapter).tracks?.[0]?.items ?? []
453
+ expect(items[0]).toMatchObject({ id: 'clip-0', muted: true })
454
+ expect(items[1]).toMatchObject({ id: 'clip-1', start: 20 })
455
+ })
456
+ })
457
+
458
+ // ── Tab set varies with the selection ───────────────────────────────────
459
+ it('offers only Transform and Volume for a selected image clip', async () => {
460
+ onTestFinished(installCanvasHarness())
461
+ const project = makeProject({
462
+ tracks: [[{ id: 'img-0', type: 'image', src: 'photo.jpg', start: 0, end: 4 }]],
463
+ } as unknown as Partial<Project>)
464
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
465
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
466
+
467
+ selectCanvasItem(container, project, { type: 'image' })
468
+
469
+ const strip = await screen.findByRole('group', { name: 'Clip panel view' })
470
+ // No Speed (image, not video), no Crop (not a tracks[0] video), no
471
+ // Generate (generation is video-only).
472
+ expect(clipTabNames(strip)).toEqual(['Transform', 'Volume'])
473
+ })
474
+
475
+ it('offers no Generate tab for an ordinary video clip with no generation provenance', async () => {
476
+ onTestFinished(installCanvasHarness())
477
+ // The default fixture: a plain video clip, no `generation` field, and
478
+ // `renderCapCut` here passes neither `regenEnabled` nor
479
+ // `renderGenerationPanel`. This is the shipping shape of every
480
+ // non-ai_video project — the Generate tab must not appear for it, or
481
+ // every ordinary video clip in every ordinary project grows a dead,
482
+ // empty tab.
483
+ const project = makeProject()
484
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
485
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
486
+
487
+ // clip-0 is the tracks[0] video with a src — also the crop target, so
488
+ // Crop is expected here alongside Transform/Speed/Volume.
489
+ selectCanvasItem(container, project, { id: 'clip-0' })
490
+
491
+ const strip = await screen.findByRole('group', { name: 'Clip panel view' })
492
+ expect(clipTabNames(strip)).toEqual(['Transform', 'Speed', 'Volume', 'Crop'])
493
+ })
494
+
495
+ it('offers no Crop tab for a video clip that is not the crop target', async () => {
496
+ onTestFinished(installCanvasHarness())
497
+ // clip-1 is a VIDEO, but on the second track — `cropTarget` only ever
498
+ // matches tracks[0], so it still gets Speed/Generate, just no Crop. It
499
+ // also carries `generation` provenance and regen is enabled, so Generate
500
+ // stays in the expected set here rather than being the thing under test.
501
+ const project = makeProject({
502
+ tracks: [
503
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 }],
504
+ [{ id: 'clip-1', type: 'video', src: 'b.mp4', start: 0, end: 6, inPoint: 0, outPoint: 6,
505
+ generation: { sceneId: 'sc-1', prompt: 'a wide shot', provider: 'kling' } }],
506
+ ],
507
+ } as unknown as Partial<Project>)
508
+ const renderGenerationPanel = vi.fn(() => <div data-testid="generation-panel">Regenerate</div>)
509
+ const { container } = renderCapCut(project, makeFakeAdapter(project), { regenEnabled: true, renderGenerationPanel })
510
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
511
+
512
+ selectCanvasItem(container, project, { id: 'clip-1' })
513
+
514
+ const strip = await screen.findByRole('group', { name: 'Clip panel view' })
515
+ expect(clipTabNames(strip)).toEqual(['Transform', 'Speed', 'Volume', 'Generate'])
516
+ })
517
+
518
+ it("clicking the Crop tab's button enters crop mode and stays entered on a second click (unlike the toolbar's toggle)", async () => {
519
+ onTestFinished(installCanvasHarness())
520
+ const project = makeProject()
521
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
522
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
523
+ // clip-0 is the tracks[0] video with a src — the crop target.
524
+ selectCanvasItem(container, project, { id: 'clip-0' })
525
+
526
+ fireEvent.click(await clipTab('Crop'))
527
+ fireEvent.click(await cropTabBodyButton())
528
+
529
+ // Same underlying `cropMode` state the toolbar's own "Crop source" button
530
+ // drives — its aria-pressed flips too, proving there is only one crop path.
531
+ expect(screen.getByLabelText('Crop source').getAttribute('aria-pressed')).toBe('true')
532
+
533
+ // A second click must NOT exit crop mode: the tab body button is a
534
+ // one-way enter, not a toggle like the toolbar button it mirrors.
535
+ fireEvent.click(await cropTabBodyButton())
536
+ expect(screen.getByLabelText('Crop source').getAttribute('aria-pressed')).toBe('true')
537
+ })
538
+ })
539
+
540
+ // ── The CapCut left panel ────────────────────────────────────────────────────
541
+ // Media, Captions and Versions now live behind an icon rail on the LEFT, where
542
+ // captions and version history used to stack into the right rail. Tabs are lazy:
543
+ // only the active one is mounted, so "the media panel is on screen" is now
544
+ // "the Media tab is selected", not "the editor is in the CapCut layout".
545
+ describe('VideoEditor — CapCut left panel tabs', () => {
546
+ function makeCaptionedProject(): Project {
547
+ return makeProject({
548
+ captions: { style: 'pop', segments: [{ id: 'cap-0', text: 'caption one', start: 0, end: 1 }] },
549
+ } as unknown as Partial<Project>)
550
+ }
551
+
552
+ it('opens on Captions and keeps Media behind its own tab', async () => {
553
+ const project = makeCaptionedProject()
554
+ renderCapCut(project, makeFakeAdapter(project))
555
+
556
+ const captionsTab = await screen.findByRole('tab', { name: 'Captions' })
557
+ expect(captionsTab.getAttribute('aria-selected')).toBe('true')
558
+ expect(await screen.findByText('caption one')).toBeTruthy()
559
+ // Lazy mount: the Media tab's content has never been rendered.
560
+ expect(screen.queryByTestId('media-panel')).toBeNull()
561
+
562
+ fireEvent.click(screen.getByRole('tab', { name: 'Media' }))
563
+ expect(await screen.findByTestId('media-panel')).toBeTruthy()
564
+ })
565
+
566
+ it('puts version history and the host runHistory slot together under Versions', async () => {
567
+ const project = makeCaptionedProject()
568
+ renderCapCut(project, makeFakeAdapter(project), {
569
+ slots: { runHistory: <div data-testid="run-history">Previous runs</div> },
570
+ })
571
+
572
+ fireEvent.click(await screen.findByRole('tab', { name: 'Versions' }))
573
+ expect(await screen.findByTestId('run-history')).toBeTruthy()
574
+ })
575
+
576
+ it('offers no Captions tab on a project with no captions and a host that cannot generate them', async () => {
577
+ const project = makeProject() // no `captions`, adapter has no generateCaptions
578
+ renderCapCut(project, makeFakeAdapter(project))
579
+
580
+ await waitFor(() => screen.getByRole('tab', { name: 'Media' }))
581
+ expect(screen.queryByRole('tab', { name: 'Captions' })).toBeNull()
582
+ // `defaultTabId="captions"` names a tab that isn't there, so LeftPanelTabs
583
+ // falls back to the first one — Media is open, not a blank pane.
584
+ expect(screen.getByTestId('media-panel')).toBeTruthy()
585
+ })
586
+ })
587
+
588
+ // ── The overlay Content / Transform tabs ─────────────────────────────────────
589
+ //
590
+ // A selected overlay's own props (its text, colors, numbers, toggles, images)
591
+ // used to live in `OverlayPropsModal` — a floating, draggable dialog with a Save
592
+ // button, opened by a preview double-click or a Pencil in the controls bar. That
593
+ // dialog is retired. Those fields are the **Content** tab of this same right
594
+ // column now, and the keyframe inspector is the **Transform** tab beside it.
595
+ // Content is the default: what an overlay SAYS is what you reach for first.
596
+ //
597
+ // Two consequences worth pinning here rather than in OverlayContentPanel's own
598
+ // suite, because they only exist at THIS seam:
599
+ //
600
+ // 1. There is no dialog left to open, so the double-click has nothing to do
601
+ // but leave the already-selected overlay where it is — and nothing may
602
+ // portal itself over the preview.
603
+ // 2. Edits ride VideoEditor's sync core (transient preview → commit on blur),
604
+ // so a typed change has to reach `saveProject` without a Save button
605
+ // anywhere in the loop.
606
+ describe('VideoEditor — overlay properties tabs', () => {
607
+ /** The clip fixture plus a JSX overlay on its own track. */
608
+ function makeOverlayProject(): Project {
609
+ return makeProject({
610
+ tracks: [
611
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 100, outPoint: 110 }],
612
+ [{
613
+ id: 'ov-0',
614
+ type: 'overlay',
615
+ src: 'scoreboard.jsx',
616
+ start: 0,
617
+ end: 10,
618
+ props: { text: 'Full time', accent: '#FCD116' },
619
+ }],
620
+ ],
621
+ } as unknown as Partial<Project>)
622
+ }
623
+
624
+ /**
625
+ * One of the two tab buttons, resolved INSIDE the tab strip.
626
+ *
627
+ * Scoping is defensive, not load-bearing: `OverlayInspector`'s own
628
+ * "Transform" section header is an inert `<span>`, not a button, so there
629
+ * is currently only one "Transform" button in the tree and a bare
630
+ * `getByRole('button', { name: 'Transform' })` would not throw today. But a
631
+ * tab body can grow a control whose accessible name collides with a tab
632
+ * label, so resolving inside the tab strip's `role="group"` keeps this
633
+ * query safe against that regardless.
634
+ */
635
+ async function overlayTab(name: 'Content' | 'Transform'): Promise<HTMLButtonElement> {
636
+ const strip = await screen.findByRole('group', { name: 'Overlay panel view' })
637
+ const button = Array.from(strip.querySelectorAll('button')).find(b => b.textContent === name)
638
+ if (!button) throw new Error(`no "${name}" tab in the overlay panel strip`)
639
+ return button
640
+ }
641
+
642
+ it('opens on the Content tab, showing the overlay\'s own props', async () => {
643
+ onTestFinished(installCanvasHarness())
644
+ const project = makeOverlayProject()
645
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
646
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
647
+
648
+ selectCanvasItem(container, project, { type: 'overlay' })
649
+
650
+ // Content is the default with nothing persisted.
651
+ expect((await overlayTab('Content')).getAttribute('aria-pressed')).toBe('true')
652
+ expect((await overlayTab('Transform')).getAttribute('aria-pressed')).toBe('false')
653
+ // The overlay's OWN props, inferred — a text field and a color swatch.
654
+ expect(screen.getByLabelText('text')).toBeTruthy()
655
+ expect((screen.getByLabelText('accent') as HTMLInputElement).type).toBe('color')
656
+ // Exactly one tab body renders: the Transform inspector is not stacked under it.
657
+ expect(screen.queryByLabelText('Scale')).toBeNull()
658
+ })
659
+
660
+ it('switching to Transform swaps in the keyframe inspector', async () => {
661
+ onTestFinished(installCanvasHarness())
662
+ const project = makeOverlayProject()
663
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
664
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
665
+ selectCanvasItem(container, project, { type: 'overlay' })
666
+
667
+ fireEvent.click(await overlayTab('Transform'))
668
+
669
+ expect(screen.getByLabelText('Scale')).toBeTruthy()
670
+ expect(screen.getByLabelText('Offset X')).toBeTruthy()
671
+ // ...and the Content body is gone, not hidden behind it.
672
+ expect(screen.queryByLabelText('text')).toBeNull()
673
+ // The choice is written through to storage, not just held in React state.
674
+ expect(localStorage.getItem('montaj.editor.overlayPanelTab')).toBe(JSON.stringify('transform'))
675
+ })
676
+
677
+ it('opens on the tab the operator last used', async () => {
678
+ onTestFinished(installCanvasHarness())
679
+ // The write half is asserted above; this is the READ half, and it needs a
680
+ // fresh mount to mean anything — usePersistentState consults storage once,
681
+ // in its lazy `useState` initializer, and never again.
682
+ localStorage.setItem('montaj.editor.overlayPanelTab', JSON.stringify('transform'))
683
+ const project = makeOverlayProject()
684
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
685
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
686
+ selectCanvasItem(container, project, { type: 'overlay' })
687
+
688
+ expect((await overlayTab('Transform')).getAttribute('aria-pressed')).toBe('true')
689
+ expect(screen.getByLabelText('Scale')).toBeTruthy()
690
+ expect(screen.queryByLabelText('text')).toBeNull()
691
+ })
692
+
693
+ it('falls back to Content when the stored tab name is one this build does not know', async () => {
694
+ onTestFinished(installCanvasHarness())
695
+ localStorage.setItem('montaj.editor.overlayPanelTab', JSON.stringify('effects'))
696
+ const project = makeOverlayProject()
697
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
698
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
699
+ selectCanvasItem(container, project, { type: 'overlay' })
700
+
701
+ // A stale key from an older build must not render a blank pane.
702
+ expect((await overlayTab('Content')).getAttribute('aria-pressed')).toBe('true')
703
+ expect(screen.getByLabelText('text')).toBeTruthy()
704
+ })
705
+
706
+ it('a Content edit previews live and lands in the saved project on blur — no Save button in the loop', async () => {
707
+ onTestFinished(installCanvasHarness())
708
+ const project = makeOverlayProject()
709
+ const adapter = makeFakeAdapter(project)
710
+ const { container } = renderCapCut(project, adapter)
711
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
712
+ selectCanvasItem(container, project, { type: 'overlay' })
713
+
714
+ const field = await screen.findByLabelText('text')
715
+ fireEvent.change(field, { target: { value: 'Half time' } })
716
+ // The field is controlled off the project, so it only reads back what was
717
+ // typed if the transient preview actually reached the sync core.
718
+ expect(field).toHaveValue('Half time')
719
+ // The dialog this replaces persisted on a Save click; a panel has no such
720
+ // moment, so nothing is saved until the typing gesture closes.
721
+ expect(screen.queryByRole('button', { name: /save/i })).toBeNull()
722
+
723
+ fireEvent.blur(field)
724
+
725
+ await waitFor(() => {
726
+ const overlay = lastSaved(adapter).tracks?.[1]?.items?.[0] as { props?: Record<string, unknown> }
727
+ // The untouched sibling prop rides through the write untouched.
728
+ expect(overlay.props).toEqual({ text: 'Half time', accent: '#FCD116' })
729
+ })
730
+ })
731
+
732
+ it('a preview double-click keeps the overlay on the Content tab and opens no dialog', async () => {
733
+ onTestFinished(installCanvasHarness())
734
+ const project = makeOverlayProject()
735
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
736
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
737
+ selectCanvasItem(container, project, { type: 'overlay' })
738
+ await screen.findByLabelText('text')
739
+
740
+ // The preview's move surface, inside the selected overlay's wrapper — the
741
+ // wrapper is what carries `onDoubleClick` (OverlayItemsLayer), and the
742
+ // handler is gated on the overlay ALREADY being selected, so this path can
743
+ // only ever re-select. It used to portal a dialog over the preview.
744
+ fireEvent.dblClick(container.querySelector('.cursor-grab') as HTMLElement)
745
+
746
+ expect(screen.getByLabelText('text')).toBeTruthy()
747
+ expect((await overlayTab('Content')).getAttribute('aria-pressed')).toBe('true')
748
+ // Nothing portalled itself over the preview: no dialog header, no Save.
749
+ expect(screen.queryByRole('heading', { name: 'Edit overlay' })).toBeNull()
750
+ expect(screen.queryByRole('button', { name: /save/i })).toBeNull()
751
+ })
752
+
753
+ it('no longer offers an "Edit overlay" button in the controls bar', async () => {
754
+ onTestFinished(installCanvasHarness())
755
+ const project = makeOverlayProject()
756
+ const { container } = renderCapCut(project, makeFakeAdapter(project))
757
+ await waitFor(() => screen.getByLabelText('Resize sidebar'))
758
+ selectCanvasItem(container, project, { type: 'overlay' })
759
+
760
+ // Selecting the overlay IS opening its properties now, so the button whose
761
+ // only job was to open them has nothing left to do.
762
+ await screen.findByLabelText('text')
763
+ expect(screen.queryByLabelText('Edit overlay')).toBeNull()
764
+ })
765
+ })