@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,338 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest'
2
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
3
+ import type { CaptionEvent, EditorAdapter, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import type { ImageElement } from '../../types'
5
+ import VideoEditor from '../VideoEditor'
6
+ import { installCanvasHarness, selectCanvasItem, type CanvasItemSelector } from '../timeline/__tests__/_canvasSelect'
7
+
8
+ // ── Layout gating: classic vs. CapCut media-panel branch ─────────────────────
9
+ // ReviewSurface renders the classic layout (preview above a timeline pane, plus
10
+ // the version rail) UNLESS the host supplies `slots.mediaPanel`, which switches
11
+ // it to the CapCut layout: [media | preview | rail] across the top with a
12
+ // full-width timeline strip below. These tests assert the gate both directions —
13
+ // the media panel appears/disappears, and (in CapCut) the timeline strip is a
14
+ // sibling below the top row rather than nested inside the preview column.
15
+
16
+ function makeVideoProject(overrides: Partial<Project> = {}): Project {
17
+ return {
18
+ id: 'vid-1',
19
+ name: 'Test Video',
20
+ status: 'draft',
21
+ editingPrompt: '',
22
+ projectType: 'video',
23
+ settings: { resolution: [1080, 1920], fps: 30 },
24
+ tracks: [
25
+ [
26
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 },
27
+ ],
28
+ ],
29
+ audio: { tracks: [] },
30
+ assets: [],
31
+ ...overrides,
32
+ } as Project
33
+ }
34
+
35
+ function makeFakeAdapter(): EditorAdapter<Project> {
36
+ let subscribers: Array<(project: Project) => void> = []
37
+ return {
38
+ loadProject: vi.fn(async () => makeVideoProject()),
39
+ saveProject: vi.fn(async () => {}),
40
+ subscribe: (_id: string, onFrame: (project: Project) => void) => {
41
+ subscribers.push(onFrame)
42
+ return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
43
+ },
44
+ render: async function* (): AsyncIterable<RenderEvent> {
45
+ yield { type: 'done', outputPath: '/out.mp4' }
46
+ },
47
+ resolveImageSrc: (el: ImageElement) => el.src,
48
+ compileOverlay: vi.fn(async () => () => null),
49
+ listGlobalOverlays: vi.fn(async () => []),
50
+ listSystemOverlays: vi.fn(async () => []),
51
+ uploadFile: vi.fn(async () => '/path'),
52
+ fileUrl: (path: string) => path,
53
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
54
+ restoreVersion: vi.fn(async () => makeVideoProject()),
55
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
56
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
57
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
58
+ } as unknown as EditorAdapter<Project>
59
+ }
60
+
61
+ beforeEach(() => {
62
+ // The caption panel now splits into Style / Captions sub-tabs and defaults
63
+ // to Style; these tests select captions from the transcript list, so pin the
64
+ // sub-tab to 'captions' (usePersistentState reads this at mount).
65
+ window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
66
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
67
+ vi.spyOn(console, 'error').mockImplementation(() => {})
68
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
69
+ observe() {}
70
+ unobserve() {}
71
+ disconnect() {}
72
+ }
73
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
74
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
75
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
76
+ state = 'running'
77
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
78
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
79
+ get destination() { return {} }
80
+ close() {}
81
+ }
82
+ })
83
+ afterEach(() => vi.restoreAllMocks())
84
+
85
+ describe('VideoEditor — layout gating (classic vs. CapCut media panel)', () => {
86
+ it('renders the CapCut layout when slots.mediaPanel is provided', async () => {
87
+ const adapter = makeFakeAdapter()
88
+ const { getByTestId, getByLabelText, getByRole } = render(
89
+ <VideoEditor
90
+ project={makeVideoProject()}
91
+ adapter={adapter}
92
+ onProjectChange={vi.fn()}
93
+ slots={{ mediaPanel: <div data-testid="media-panel">Footage bin</div> }}
94
+ />,
95
+ )
96
+
97
+ // The host's media panel is rendered (its content is present). It sits in a
98
+ // Media TAB of the left panel now, and the tabs mount LAZILY, so this test
99
+ // clicks that tab before asserting rather than relying on it happening to
100
+ // be open. It would otherwise pass only incidentally: this fixture has no
101
+ // captions and no `generateCaptions`, so there is no Captions tab for
102
+ // `defaultTabId="captions"` to open and Media is tabs[0] by fallback — and
103
+ // giving this adapter caption support later would break the assertion for
104
+ // reasons that have nothing to do with layout. See
105
+ // VideoEditor.propertiesPanel.test.tsx for the tab behaviour itself.
106
+ fireEvent.click(await waitFor(() => getByRole('tab', { name: 'Media' })))
107
+ const media = await waitFor(() => getByTestId('media-panel'))
108
+ // The CapCut-only media-column resize divider exists.
109
+ expect(getByLabelText('Resize media panel')).toBeTruthy()
110
+
111
+ // Structural: the timeline strip (its Controls button lives in the timeline
112
+ // pane) is a SIBLING below the top row, not nested inside it or the preview.
113
+ const controls = getByLabelText('Editor controls & shortcuts')
114
+ expect(media.contains(controls)).toBe(false)
115
+ expect(controls.contains(media)).toBe(false)
116
+ // Top row (media | preview | rail) comes before the timeline strip in the DOM.
117
+ expect(
118
+ media.compareDocumentPosition(controls) & Node.DOCUMENT_POSITION_FOLLOWING,
119
+ ).toBeTruthy()
120
+ })
121
+
122
+ it('renders the classic layout (no media panel) when slots.mediaPanel is absent', async () => {
123
+ const adapter = makeFakeAdapter()
124
+ const { queryByTestId, queryByLabelText, getByLabelText } = render(
125
+ <VideoEditor
126
+ project={makeVideoProject()}
127
+ adapter={adapter}
128
+ onProjectChange={vi.fn()}
129
+ />,
130
+ )
131
+
132
+ // The classic timeline pane still renders (its Controls button is present)…
133
+ await waitFor(() => getByLabelText('Editor controls & shortcuts'))
134
+ // …and the CapCut media panel is entirely inert: no media-panel content and
135
+ // no media-column resize divider.
136
+ expect(queryByTestId('media-panel')).toBeNull()
137
+ expect(queryByLabelText('Resize media panel')).toBeNull()
138
+ })
139
+ })
140
+
141
+ // ── Right-rail visibility gate (SP5-captions Phase 5) ─────────────────────────
142
+ // The rail (version history / run history / sidebar assets / CaptionListPanel)
143
+ // only mounts when it has something to show. A project with NO captions and NO
144
+ // other rail content, on a host that DOES support `generateCaptions`, must
145
+ // still get a rail — otherwise "Generate captions" (CaptionListPanel's only way to
146
+ // create captions from scratch) is unreachable. The retired bottom
147
+ // TranscriptPanel offered Regenerate unconditionally whenever the host
148
+ // supported it; the rail gate must preserve that reachability now that the
149
+ // control lives in the sidebar instead.
150
+ describe('VideoEditor — right-rail gate includes the regenerate capability', () => {
151
+ it('renders the rail (and a working Regenerate) with no captions and no version/run/assets content, purely because the adapter supports generateCaptions', async () => {
152
+ const project = makeVideoProject() // no `captions` field at all
153
+ const adapter = {
154
+ loadProject: vi.fn(async () => project),
155
+ saveProject: vi.fn(async () => {}),
156
+ subscribe: () => () => {},
157
+ render: async function* (): AsyncIterable<RenderEvent> {
158
+ yield { type: 'done', outputPath: '/out.mp4' }
159
+ },
160
+ resolveImageSrc: (el: ImageElement) => el.src,
161
+ compileOverlay: vi.fn(async () => () => null),
162
+ listGlobalOverlays: vi.fn(async () => []),
163
+ listSystemOverlays: vi.fn(async () => []),
164
+ uploadFile: vi.fn(async () => '/path'),
165
+ fileUrl: (path: string) => path,
166
+ // Deliberately NO listVersionHistory, and no slots below — the rail has
167
+ // nothing else to justify its presence except the regenerate capability.
168
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
169
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
170
+ generateCaptions: async function* (): AsyncIterable<CaptionEvent> {
171
+ yield { type: 'log', message: 'transcribing audio…' }
172
+ },
173
+ } as unknown as EditorAdapter<Project>
174
+
175
+ render(
176
+ <VideoEditor
177
+ project={project}
178
+ adapter={adapter}
179
+ onProjectChange={vi.fn()}
180
+ />,
181
+ )
182
+
183
+ // The rail mounted at all — its divider is the structural tell.
184
+ const divider = await waitFor(() => screen.getByLabelText('Resize sidebar'))
185
+ expect(divider).toBeTruthy()
186
+
187
+ // And the trigger is not just present but actually reachable: clicking it
188
+ // opens CaptionRegenModal (asserted via its immediate "Starting
189
+ // transcription…" line, present before any stream event arrives).
190
+ fireEvent.click(screen.getByText('Generate captions'))
191
+ expect(await screen.findByText(/starting transcription/i)).toBeTruthy()
192
+ })
193
+ })
194
+
195
+ // ── primarySelectedId must skip caption ids (FIX 1) ─────────────────────────
196
+ //
197
+ // Under D1, a caption id shares `selectedIds` with clips and audio — selected
198
+ // on the canvas timeline exactly like either, or (as exercised here, since
199
+ // captions have no DOM-mode timeline row of their own any more — see the
200
+ // retired CaptionTrackRow) via a plain click on its row in the sidebar
201
+ // CaptionListPanel. `primarySelectedId` used to be `selectedIds[0]` verbatim,
202
+ // so a caption id at index 0 blanked the selected clip's crop/overlay-edit
203
+ // affordances and scoped Split to an id no track item matches. These are full
204
+ // <VideoEditor> DOM-mode renders, not source-level assertions: selecting a
205
+ // caption via its sidebar row, then additively (metaKey) selecting a clip or
206
+ // overlay block via the ordinary DOM-mode timeline click, is a real user
207
+ // gesture and needs no canvas/engine internals to drive.
208
+ describe('VideoEditor — a caption id ahead of a clip id in selectedIds (D1)', () => {
209
+ function makeMixedProject(): Project {
210
+ return makeVideoProject({
211
+ tracks: [
212
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
213
+ [{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' } }],
214
+ ],
215
+ captions: { style: 'pop', segments: [{ id: 'cap-0', text: 'caption one', start: 0, end: 1 }] },
216
+ } as unknown as Partial<Project>)
217
+ }
218
+
219
+ /** Click the caption's sidebar row (plain, single-select — replaces
220
+ * `selectedIds`), then additively (metaKey) select the given canvas
221
+ * item — `toggleSelection` appends, so the caption id lands FIRST. */
222
+ async function selectCaptionThenAdditively(container: HTMLElement, project: Project, selector: CanvasItemSelector) {
223
+ fireEvent.click(await screen.findByText('caption one'))
224
+ selectCanvasItem(container, project, selector, { metaKey: true })
225
+ }
226
+
227
+ async function seekTo(seconds: string) {
228
+ fireEvent.click(await screen.findByLabelText('Go to time'))
229
+ const input = await screen.findByPlaceholderText(/mm:ss/)
230
+ fireEvent.change(input, { target: { value: seconds } })
231
+ fireEvent.keyDown(input, { key: 'Enter' })
232
+ }
233
+
234
+ it('resolves cropTarget to the clip, not null, and S still splits it', async () => {
235
+ onTestFinished(installCanvasHarness())
236
+ const adapter = makeFakeAdapter()
237
+ const onProjectChange = vi.fn()
238
+ const project = makeMixedProject()
239
+ const { container } = render(
240
+ <VideoEditor
241
+ project={project}
242
+ adapter={adapter}
243
+ onProjectChange={onProjectChange}
244
+ />,
245
+ )
246
+ await selectCaptionThenAdditively(container, project, { type: 'video' })
247
+
248
+ // cropTarget resolved past the caption to the clip: the button is
249
+ // enabled, not stuck on "Select a clip to crop".
250
+ expect(screen.getByLabelText('Crop source').hasAttribute('disabled')).toBe(false)
251
+ // selectedOverlayItem correctly stayed null — the resolved item is the
252
+ // video clip, not the overlay, so the right panel's Content/Transform tab
253
+ // strip (`role="group" aria-label="Overlay panel view"`, only drawn when
254
+ // `overlayPropertiesPanel` has a truthy `selectedOverlayItem`) is absent.
255
+ expect(screen.queryByRole('group', { name: 'Overlay panel view' })).toBeNull()
256
+
257
+ // Seek inside the clip (splitting exactly at a clip's start/end is a
258
+ // no-op — see splitAtTime), then Split. Pre-fix, primarySelectedId was
259
+ // 'cap-0': no track item has that id, splitAtTime returned the same
260
+ // project reference, and handleSplit's `if (updated === base) return`
261
+ // swallowed the keypress with no onProjectChange call at all.
262
+ await seekTo('2')
263
+ await act(async () => {
264
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
265
+ })
266
+
267
+ await waitFor(() => {
268
+ const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
269
+ // `mapTrackItems` normalizes to `VisualTrack[]` ({ id, items }) on the
270
+ // way out, whatever shape the fixture went in with.
271
+ expect(last.tracks?.[0]?.items).toHaveLength(2) // clip-0 split into two
272
+ expect(last.tracks?.[1]?.items).toHaveLength(1) // overlay untouched — Split scoped to the clip only
273
+ })
274
+ })
275
+
276
+ it('resolves selectedOverlayItem to the overlay, not null, when it (not a clip) is the non-caption member', async () => {
277
+ onTestFinished(installCanvasHarness())
278
+ const adapter = makeFakeAdapter()
279
+ const project = makeMixedProject()
280
+ const { container } = render(
281
+ <VideoEditor
282
+ project={project}
283
+ adapter={adapter}
284
+ onProjectChange={vi.fn()}
285
+ />,
286
+ )
287
+ await selectCaptionThenAdditively(container, project, { type: 'overlay' })
288
+
289
+ // selectedOverlayItem resolved to the overlay (not null): `overlayPropertiesPanel`
290
+ // (VideoEditor.tsx, shared by both layouts' right rail) only draws its
291
+ // Content/Transform tab strip — `role="group" aria-label="Overlay panel
292
+ // view"` — when `selectedOverlayItem` is truthy; with nothing (or a
293
+ // non-overlay) selected it falls through to OverlayInspector's bare empty
294
+ // state instead. The floating "Edit overlay" Pencil button this test used
295
+ // to probe with is gone (folded into this same panel), but the tab strip
296
+ // proves the same resolution this test is actually about.
297
+ expect(screen.getByRole('group', { name: 'Overlay panel view' })).toBeTruthy()
298
+ // The overlay is not croppable — cropTarget correctly stayed null.
299
+ expect(screen.getByLabelText('Crop source').hasAttribute('disabled')).toBe(true)
300
+ })
301
+
302
+ it('a caption-only selection leaves primarySelectedId null — Split then scopes to the MAIN video track only, not every clip under the playhead', async () => {
303
+ onTestFinished(installCanvasHarness())
304
+ const adapter = makeFakeAdapter()
305
+ const onProjectChange = vi.fn()
306
+ render(
307
+ <VideoEditor
308
+ project={makeMixedProject()}
309
+ adapter={adapter}
310
+ onProjectChange={onProjectChange}
311
+ />,
312
+ )
313
+ fireEvent.click(await screen.findByText('caption one'))
314
+
315
+ expect(screen.getByLabelText('Crop source').hasAttribute('disabled')).toBe(true)
316
+ // primarySelectedId is null for a caption-only selection, so
317
+ // selectedOverlayItem is null too — the Content/Transform tab strip
318
+ // (`role="group" aria-label="Overlay panel view"`) doesn't render.
319
+ expect(screen.queryByRole('group', { name: 'Overlay panel view' })).toBeNull()
320
+
321
+ // primarySelectedId is null (caption-only selection). Split with nothing
322
+ // selected resolves the MAIN video track's clip under the playhead
323
+ // (`trackItems(base)[0]`) and scopes the split to its id — it does NOT
324
+ // razor every track. Passing null straight to `splitAtTime` used to cut
325
+ // the overlay under the playhead too, which was wrong (Sam): nothing
326
+ // selected means the main track only.
327
+ await seekTo('2')
328
+ await act(async () => {
329
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
330
+ })
331
+
332
+ await waitFor(() => {
333
+ const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
334
+ expect(last.tracks?.[0]?.items).toHaveLength(2) // main video clip split
335
+ expect(last.tracks?.[1]?.items).toHaveLength(1) // overlay untouched — split scoped to the main track
336
+ })
337
+ })
338
+ })