@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,159 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest'
2
+ import { render, waitFor, act } from '@testing-library/react'
3
+ import type { EditorAdapter, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import type { ImageElement } from '../../types'
5
+ import VideoEditor from '../VideoEditor'
6
+ import { installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
7
+
8
+ // ── FIX 3 regression ──────────────────────────────────────────────────────────
9
+ // Shift+Delete (VideoEditor.tsx's `handleRippleDelete`) reaches cuts.ts's
10
+ // `rippleDelete`, which reaches `applyCutToCaptions` — the function that DROPS
11
+ // a caption segment outright when it falls entirely inside the removed span,
12
+ // rather than shifting it like `collapseGaps` does. Dropping the only caption
13
+ // in a row leaves a hole lane.
14
+ //
15
+ // IMPORTANT: VideoEditor's own general-purpose lane-normalization effect
16
+ // (mount/SSE/regen/etc, keyed on `sync.project.captions`) ALSO catches and
17
+ // closes that hole shortly afterward — via `sync.applyExternal`, since a
18
+ // ripple-delete isn't a caption drag gesture. So asserting on the LAST
19
+ // `onProjectChange` call (client-side state) passes with or without this fix
20
+ // and proves nothing — `applyExternal` never calls `adapter.saveProject`, only
21
+ // `sync.mutate`/`commit`/`undo`/`redo` do. The actual bug is that the SAVED
22
+ // project — what a reload or another client would see — is whatever
23
+ // `handleRippleDelete`'s own `sync.mutate` call handed to the adapter, which
24
+ // without this fix is the sparse, hole-lane version. So this test asserts on
25
+ // the adapter's `saveProject` payload, not on local `onProjectChange` state.
26
+ // (Verified against a scratchpad revert: without the fix this fails with
27
+ // cap-2 stuck at lane 2 instead of renumbering to lane 1.)
28
+
29
+ function makeVideoProject(overrides: Partial<Project> = {}): Project {
30
+ return {
31
+ id: 'vid-1',
32
+ name: 'Test Video',
33
+ status: 'draft',
34
+ editingPrompt: '',
35
+ projectType: 'video',
36
+ settings: { resolution: [1080, 1920], fps: 30 },
37
+ tracks: [
38
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
39
+ ],
40
+ audio: { tracks: [] },
41
+ assets: [],
42
+ ...overrides,
43
+ } as Project
44
+ }
45
+
46
+ interface FakeAdapter extends EditorAdapter<Project> {
47
+ saveCalls: Array<{ id: string; project: Project }>
48
+ }
49
+
50
+ function makeFakeAdapter(): FakeAdapter {
51
+ let subscribers: Array<(project: Project) => void> = []
52
+ const saveCalls: Array<{ id: string; project: Project }> = []
53
+ return {
54
+ loadProject: vi.fn(async () => makeVideoProject()),
55
+ saveProject: vi.fn(async (id: string, project: Project) => { saveCalls.push({ id, project }) }),
56
+ subscribe: (_id: string, onFrame: (project: Project) => void) => {
57
+ subscribers.push(onFrame)
58
+ return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
59
+ },
60
+ render: async function* (): AsyncIterable<RenderEvent> {
61
+ yield { type: 'done', outputPath: '/out.mp4' }
62
+ },
63
+ resolveImageSrc: (el: ImageElement) => el.src,
64
+ compileOverlay: vi.fn(async () => () => null),
65
+ listGlobalOverlays: vi.fn(async () => []),
66
+ listSystemOverlays: vi.fn(async () => []),
67
+ uploadFile: vi.fn(async () => '/path'),
68
+ fileUrl: (path: string) => path,
69
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
70
+ restoreVersion: vi.fn(async () => makeVideoProject()),
71
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
72
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
73
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
74
+ saveCalls,
75
+ } as unknown as FakeAdapter
76
+ }
77
+
78
+ beforeEach(() => {
79
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
80
+ vi.spyOn(console, 'error').mockImplementation(() => {})
81
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
82
+ observe() {}
83
+ unobserve() {}
84
+ disconnect() {}
85
+ }
86
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
87
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
88
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
89
+ state = 'running'
90
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
91
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
92
+ get destination() { return {} }
93
+ close() {}
94
+ }
95
+ })
96
+ afterEach(() => vi.restoreAllMocks())
97
+
98
+ describe('VideoEditor — ripple-delete densifies caption lanes (FIX 3)', () => {
99
+ it('Shift+Delete on a clip whose span contains the only caption in its row PERSISTS a collapsed, dense row in the same commit', async () => {
100
+ const adapter = makeFakeAdapter()
101
+ const initial = makeVideoProject({
102
+ tracks: [{
103
+ id: 'trk-0',
104
+ items: [
105
+ { id: 'clip-A', type: 'video', src: 'a.mp4', start: 0, end: 2, inPoint: 0, outPoint: 2 },
106
+ { id: 'clip-B', type: 'video', src: 'a.mp4', start: 2, end: 4, inPoint: 2, outPoint: 4 },
107
+ ],
108
+ }],
109
+ captions: {
110
+ style: 'clean',
111
+ segments: [
112
+ // Outside clip-A's [0,2) span — survives, shifts left with clip-B.
113
+ { id: 'cap-0', text: 'ground', start: 2.5, end: 3, words: [{ word: 'ground', start: 2.5, end: 3 }] },
114
+ // Entirely inside clip-A's [0,2) span, alone in row 1 — dropped by
115
+ // applyCutToCaptions when clip-A is ripple-deleted.
116
+ { id: 'cap-1', text: 'middle', start: 0.5, end: 1, lane: 1, words: [{ word: 'middle', start: 0.5, end: 1 }] },
117
+ // Outside clip-A's span, row 2 — survives, shifts left, and should
118
+ // renumber into the row-1 hole cap-1 left behind.
119
+ { id: 'cap-2', text: 'top', start: 3, end: 3.5, lane: 2, words: [{ word: 'top', start: 3, end: 3.5 }] },
120
+ ],
121
+ },
122
+ } as Partial<Project>)
123
+
124
+ onTestFinished(installCanvasHarness())
125
+ const { container } = render(
126
+ <VideoEditor
127
+ project={initial}
128
+ adapter={adapter}
129
+ onProjectChange={vi.fn()}
130
+ slots={{ exportActions: <div /> }}
131
+ />,
132
+ )
133
+
134
+ // Additive (metaKey) click, matching the DOM version's modifier.
135
+ // `{ type: 'video' }` resolves the FIRST video item in draw order —
136
+ // clip-A — same as the DOM version's `getAllByText('▪ video')[0]`.
137
+ selectCanvasItem(container, initial, { type: 'video' }, { metaKey: true })
138
+
139
+ await act(async () => {
140
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
141
+ })
142
+
143
+ await waitFor(() => expect(adapter.saveCalls.length).toBeGreaterThan(0))
144
+ // Give any redundant client-side patch-up effect a chance to also fire a
145
+ // second save — there shouldn't be one; applyExternal never saves.
146
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
147
+
148
+ const saved = adapter.saveCalls[0].project
149
+ const segs = saved.captions?.segments ?? []
150
+ expect(segs.map((s) => s.id)).toEqual(['cap-0', 'cap-2'])
151
+ // Row 1 (cap-1) is gone; row 2 (cap-2) drops into the vacated row — this
152
+ // is the assertion that fails without the fix (cap-2 stays at lane 2).
153
+ expect(segs.map((s) => s.lane ?? 0)).toEqual([0, 1])
154
+ expect(segs.map((s) => s.start)).toEqual([0.5, 1])
155
+ // Exactly one save for the whole gesture — densifying didn't cost a
156
+ // second commit.
157
+ expect(adapter.saveCalls.length).toBe(1)
158
+ })
159
+ })
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, waitFor, act } from '@testing-library/react'
3
+ import type { EditorAdapter, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import type { ImageElement } from '../../types'
5
+ import VideoEditor from '../VideoEditor'
6
+ import { createSourcePreviewStore } from '../source-preview'
7
+
8
+ // ── Source-preview overlay (opt-in footage-bin → main-preview scrub) ─────────
9
+ // When a host wires the `sourcePreview` store and sets a value, VideoEditor
10
+ // mounts a paused <video> overlay (its src = the value's url) above PreviewPlayer
11
+ // in the preview stage; clearing the store unmounts it. Absent store → no
12
+ // overlay, and the classic preview is unchanged. Setup mirrors
13
+ // VideoEditor.layout.test.tsx.
14
+
15
+ function makeVideoProject(overrides: Partial<Project> = {}): Project {
16
+ return {
17
+ id: 'vid-1',
18
+ name: 'Test Video',
19
+ status: 'draft',
20
+ editingPrompt: '',
21
+ projectType: 'video',
22
+ settings: { resolution: [1080, 1920], fps: 30 },
23
+ tracks: [
24
+ [
25
+ { id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 },
26
+ ],
27
+ ],
28
+ audio: { tracks: [] },
29
+ assets: [],
30
+ ...overrides,
31
+ } as Project
32
+ }
33
+
34
+ function makeFakeAdapter(): EditorAdapter<Project> {
35
+ let subscribers: Array<(project: Project) => void> = []
36
+ return {
37
+ loadProject: vi.fn(async () => makeVideoProject()),
38
+ saveProject: vi.fn(async () => {}),
39
+ subscribe: (_id: string, onFrame: (project: Project) => void) => {
40
+ subscribers.push(onFrame)
41
+ return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
42
+ },
43
+ render: async function* (): AsyncIterable<RenderEvent> {
44
+ yield { type: 'done', outputPath: '/out.mp4' }
45
+ },
46
+ resolveImageSrc: (el: ImageElement) => el.src,
47
+ compileOverlay: vi.fn(async () => () => null),
48
+ listGlobalOverlays: vi.fn(async () => []),
49
+ listSystemOverlays: vi.fn(async () => []),
50
+ uploadFile: vi.fn(async () => '/path'),
51
+ fileUrl: (path: string) => path,
52
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
53
+ restoreVersion: vi.fn(async () => makeVideoProject()),
54
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
55
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
56
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
57
+ } as unknown as EditorAdapter<Project>
58
+ }
59
+
60
+ const SCRUB_URL = 'proxy-scrub.mp4'
61
+
62
+ beforeEach(() => {
63
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
64
+ vi.spyOn(console, 'error').mockImplementation(() => {})
65
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
66
+ observe() {}
67
+ unobserve() {}
68
+ disconnect() {}
69
+ }
70
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
71
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
72
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
73
+ state = 'running'
74
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
75
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
76
+ get destination() { return {} }
77
+ close() {}
78
+ }
79
+ })
80
+ afterEach(() => vi.restoreAllMocks())
81
+
82
+ describe('VideoEditor — source-preview overlay', () => {
83
+ it('mounts a <video> overlay with the store value url, and unmounts on clear', async () => {
84
+ const adapter = makeFakeAdapter()
85
+ const store = createSourcePreviewStore()
86
+ const { container, getByLabelText } = render(
87
+ <VideoEditor
88
+ project={makeVideoProject()}
89
+ adapter={adapter}
90
+ onProjectChange={vi.fn()}
91
+ sourcePreview={store}
92
+ />,
93
+ )
94
+
95
+ await waitFor(() => getByLabelText('Editor controls & shortcuts'))
96
+ // No value yet → no scrub overlay.
97
+ expect(container.querySelector(`video[src="${SCRUB_URL}"]`)).toBeNull()
98
+
99
+ // Host hovers a bin card → the overlay appears with that src.
100
+ act(() => { store.set({ url: SCRUB_URL, fraction: 0.5 }) })
101
+ await waitFor(() => expect(container.querySelector(`video[src="${SCRUB_URL}"]`)).toBeTruthy())
102
+
103
+ // Clearing (pointer leaves the card) unmounts the overlay.
104
+ act(() => { store.set(null) })
105
+ await waitFor(() => expect(container.querySelector(`video[src="${SCRUB_URL}"]`)).toBeNull())
106
+ })
107
+
108
+ it('is inert when no sourcePreview store is supplied (classic path)', async () => {
109
+ const adapter = makeFakeAdapter()
110
+ const { container, getByLabelText } = render(
111
+ <VideoEditor
112
+ project={makeVideoProject()}
113
+ adapter={adapter}
114
+ onProjectChange={vi.fn()}
115
+ />,
116
+ )
117
+
118
+ await waitFor(() => getByLabelText('Editor controls & shortcuts'))
119
+ // Only PreviewPlayer's own slots exist; none carry the scrub url.
120
+ expect(container.querySelector(`video[src="${SCRUB_URL}"]`)).toBeNull()
121
+ })
122
+ })