@bycrux/editor 0.12.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +161 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react'
3
+ import type { CaptionEvent, EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import VideoEditor from '../VideoEditor'
5
+
6
+ // ── T1 — host-driven caption trigger seam ────────────────────────────────────
7
+ //
8
+ // `onRegenerateCaptions` / `captionsGenerating` (VideoEditorProps) let a host
9
+ // run caption generation as its own background job instead of the package's
10
+ // built-in blocking `CaptionRegenModal`. Purely additive: mounts the REAL
11
+ // VideoEditor (classic layout — no `slots.mediaPanel`, so the caption panel
12
+ // renders directly in the right rail, no tab navigation needed) and drives
13
+ // the actual "Regenerate captions" footer button, mirroring
14
+ // VideoEditor.captionDelete.test.tsx's mounting pattern.
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
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
26
+ ],
27
+ audio: { tracks: [] },
28
+ assets: [],
29
+ // Non-empty caption track: the "Regenerate captions" footer button (the
30
+ // one under test) only renders once there is at least one segment — an
31
+ // empty track shows a different "Generate captions" empty-state button.
32
+ captions: {
33
+ style: 'clean',
34
+ segments: [{ id: 'cap-0', text: 'hello', start: 0, end: 1, words: [{ word: 'hello', start: 0, end: 1 }] }],
35
+ },
36
+ ...overrides,
37
+ } as Project
38
+ }
39
+
40
+ function makeFakeAdapter(): EditorAdapter<Project> {
41
+ return {
42
+ loadProject: vi.fn(async () => makeVideoProject()),
43
+ saveProject: vi.fn(async () => {}),
44
+ subscribe: () => () => {},
45
+ render: async function* (): AsyncIterable<RenderEvent> {
46
+ yield { type: 'done', outputPath: '/out.mp4' }
47
+ },
48
+ resolveImageSrc: (el: ImageElement) => el.src,
49
+ compileOverlay: vi.fn(async () => () => null),
50
+ listGlobalOverlays: vi.fn(async () => []),
51
+ listSystemOverlays: vi.fn(async () => []),
52
+ uploadFile: vi.fn(async () => '/path'),
53
+ fileUrl: (path: string) => path,
54
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
55
+ restoreVersion: vi.fn(async () => makeVideoProject()),
56
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
57
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
58
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
59
+ // Present in BOTH tests below, deliberately: the host-prop test proves
60
+ // `onRegenerateCaptions` wins even though `adapter.generateCaptions` is
61
+ // also available — a real host that passes the prop still implements
62
+ // this to power the background job it owns (see types.ts's doc comment
63
+ // on `onRegenerateCaptions`).
64
+ generateCaptions: vi.fn(async function* (): AsyncIterable<CaptionEvent> {
65
+ yield { type: 'log', message: 'transcribing…' }
66
+ }),
67
+ } as unknown as EditorAdapter<Project>
68
+ }
69
+
70
+ beforeEach(() => {
71
+ // The caption panel now splits into Style / Captions sub-tabs and defaults
72
+ // to Style; these tests want the transcript + Regenerate trigger visible, so
73
+ // pin the sub-tab to 'captions' (usePersistentState reads this at mount).
74
+ window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
75
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
76
+ vi.spyOn(console, 'error').mockImplementation(() => {})
77
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
78
+ observe() {}
79
+ unobserve() {}
80
+ disconnect() {}
81
+ }
82
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
83
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
84
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
85
+ state = 'running'
86
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
87
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
88
+ get destination() { return {} }
89
+ close() {}
90
+ }
91
+ })
92
+ afterEach(() => vi.restoreAllMocks())
93
+
94
+ describe('VideoEditor — host-driven caption trigger seam', () => {
95
+ it('clicking the trigger calls the host onRegenerateCaptions and never mounts the built-in modal', async () => {
96
+ // Proves a host that owns the caption job (Mel's Hub running it as a
97
+ // background task) gets its callback invoked instead of the package
98
+ // racing its own blocking modal against it.
99
+ const adapter = makeFakeAdapter()
100
+ const onRegenerateCaptions = vi.fn()
101
+ render(
102
+ <VideoEditor
103
+ project={makeVideoProject()}
104
+ adapter={adapter}
105
+ onProjectChange={vi.fn()}
106
+ onRegenerateCaptions={onRegenerateCaptions}
107
+ />,
108
+ )
109
+
110
+ const trigger = await screen.findByText('Regenerate captions')
111
+ fireEvent.click(trigger)
112
+
113
+ expect(onRegenerateCaptions).toHaveBeenCalledTimes(1)
114
+ expect(screen.queryByText('Regenerating captions…')).toBeNull()
115
+ })
116
+
117
+ it('clicking the trigger still opens the built-in modal when the host prop is absent', async () => {
118
+ // Hub and Los Parceros never pass `onRegenerateCaptions` — this pins the
119
+ // pre-existing blocking-modal path so it can never silently regress.
120
+ const adapter = makeFakeAdapter()
121
+ render(
122
+ <VideoEditor
123
+ project={makeVideoProject()}
124
+ adapter={adapter}
125
+ onProjectChange={vi.fn()}
126
+ />,
127
+ )
128
+
129
+ const trigger = await screen.findByText('Regenerate captions')
130
+ fireEvent.click(trigger)
131
+
132
+ await waitFor(() => expect(screen.getByText('Regenerating captions…')).toBeTruthy())
133
+ })
134
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { geometryAt, geometryFor } from '@bycrux/timeline-core'
3
+
4
+ /**
5
+ * SP9d — clips animate their geometry in the preview, and their opacity does not.
6
+ *
7
+ * OverlayItemsLayer resolves an item's live transform through the same two
8
+ * timeline-core calls asserted here; this pins the RULE those calls encode, which
9
+ * is the part that must not drift from the renderer.
10
+ */
11
+ describe('SP9d — preview geometry for clips', () => {
12
+ const clip = {
13
+ id: 'v1', type: 'video' as const, src: '/tmp/a.mp4', start: 0, end: 3,
14
+ scale: 0.75, offsetX: 0, offsetY: 0, opacity: 0.4,
15
+ keyframes: [
16
+ { prop: 'offsetX' as const, points: [{ t: 0, value: -20 }, { t: 3, value: 20 }] },
17
+ { prop: 'scale' as const, points: [{ t: 0, value: 0.5 }, { t: 3, value: 1 }] },
18
+ { prop: 'rotation' as const, points: [{ t: 0, value: 0 }, { t: 3, value: 30 }] },
19
+ { prop: 'opacity' as const, points: [{ t: 0, value: 0 }, { t: 3, value: 1 }] },
20
+ ],
21
+ }
22
+
23
+ // Exactly what OverlayItemsLayer computes for a non-overlay item.
24
+ const previewGeometry = (t: number) => ({
25
+ ...geometryAt(clip, 'video', t - clip.start),
26
+ opacity: geometryFor(clip, 'video').opacity,
27
+ })
28
+
29
+ it('position, scale and rotation all advance with the playhead', () => {
30
+ const a = previewGeometry(0)
31
+ const b = previewGeometry(1.5)
32
+ const c = previewGeometry(3)
33
+ expect(a.offsetX).toBeLessThan(b.offsetX)
34
+ expect(b.offsetX).toBeLessThan(c.offsetX)
35
+ expect(a.scale).toBeLessThan(c.scale)
36
+ expect(a.rotation).toBeLessThan(c.rotation)
37
+ })
38
+
39
+ it('opacity does NOT animate — ffmpeg cannot express it, so neither may the preview', () => {
40
+ // The whole point: an opacity curve exists on this clip and is ignored.
41
+ expect(previewGeometry(0).opacity).toBe(0.4)
42
+ expect(previewGeometry(1.5).opacity).toBe(0.4)
43
+ expect(previewGeometry(3).opacity).toBe(0.4)
44
+ // ...while the curve itself would very much have moved.
45
+ expect(geometryAt(clip, 'video', 1.5).opacity).not.toBe(0.4)
46
+ })
47
+
48
+ it('an overlay still animates opacity, because its render path can', () => {
49
+ const ov = { ...clip, type: 'overlay' as const }
50
+ expect(geometryAt(ov, 'overlay', 0).opacity).toBe(0)
51
+ expect(geometryAt(ov, 'overlay', 3).opacity).toBe(1)
52
+ })
53
+
54
+ it('a clip with no keyframes resolves exactly as it always did', () => {
55
+ const plain = { id: 'v2', type: 'video' as const, src: '/tmp/b.mp4', start: 0, end: 3, scale: 0.6, offsetX: 4, offsetY: -2, opacity: 0.9 }
56
+ const g = { ...geometryAt(plain, 'video', 1), opacity: geometryFor(plain, 'video').opacity }
57
+ expect(g).toMatchObject({ scale: 0.6, offsetX: 4, offsetY: -2, opacity: 0.9 })
58
+ })
59
+ })
@@ -0,0 +1,44 @@
1
+ /// <reference types="vite/client" />
2
+ import { describe, it, expect } from 'vitest'
3
+ import src from '../VideoEditor.tsx?raw'
4
+
5
+ /**
6
+ * A source-level assertion rather than a render test: VideoEditor mounts the
7
+ * whole editor (engine, canvas timeline, workers) and is not cheaply
8
+ * renderable in jsdom. What matters here is only that the reporter is wired
9
+ * to the real clock and the real selection state — which the source shows
10
+ * directly, and which is exactly what a future refactor could silently break.
11
+ */
12
+ describe('VideoEditor context reporting', () => {
13
+ it('calls useReportContext', () => {
14
+ expect(src).toMatch(/useReportContext\(/)
15
+ })
16
+
17
+ it('passes the real clock and both selection states', () => {
18
+ const call = src.match(/useReportContext\(\{[\s\S]*?\}\)/)?.[0] ?? ''
19
+ expect(call).toMatch(/clock/)
20
+ expect(call).toMatch(/selectedIds/)
21
+ expect(call).toMatch(/selectedCaptionId/)
22
+ expect(call).toMatch(/adapter/)
23
+ })
24
+
25
+ // D1: captions share `selectedIds` with clips/audio now, so `selectedCaptionId`
26
+ // must be DERIVED from that array rather than tracked as its own `useState` —
27
+ // a second selection model is exactly the bug a future refactor could
28
+ // reintroduce silently, since both compile and both satisfy the assertion
29
+ // above.
30
+ it('derives selectedCaptionId from selectedIds instead of its own useState', () => {
31
+ expect(src).not.toMatch(/\[selectedCaptionId, setSelectedCaptionId\]/)
32
+ const decl = src.match(/const selectedCaptionId = [^\n]+/)?.[0] ?? ''
33
+ expect(decl).toMatch(/selectedIds\.find/)
34
+ })
35
+
36
+ // handleSelectCaption used to keep two selection models mutually exclusive
37
+ // (set the caption id AND clear selectedIds). Under D1 there is one model,
38
+ // so selecting a caption is just replacing selectedIds.
39
+ it('handleSelectCaption sets selectedIds directly, not a separate caption state', () => {
40
+ const fn = src.match(/const handleSelectCaption = useCallback\(\([\s\S]*?\}, \[\]\)/)?.[0] ?? ''
41
+ expect(fn).toMatch(/setSelectedIds/)
42
+ expect(fn).not.toMatch(/setSelectedCaptionId/)
43
+ })
44
+ })
@@ -0,0 +1,55 @@
1
+ /// <reference types="vite/client" />
2
+ import { describe, it, expect } from 'vitest'
3
+ import src from '../VideoEditor.tsx?raw'
4
+
5
+ /**
6
+ * Source-level, same rationale as VideoEditor.context.test.tsx: VideoEditor
7
+ * mounts the whole editor (engine, canvas timeline, workers) and is not
8
+ * cheaply renderable in jsdom, so the closure logic here can't be exercised
9
+ * through a real double-click. The three things that DO need a live DOM or a
10
+ * plain function call — "a canvas double-click calls onEditCaption(id)", "a
11
+ * changed editFocusId nonce scrolls/focuses the sidebar row", and "the nonce
12
+ * actually climbs off the previous request, not a fixed value" — are already
13
+ * covered where they live: TimelineCanvas.pointer.test.tsx, CaptionListPanel's
14
+ * own `editFocusId` tests, and CaptionListPanel.test.tsx's `nextEditFocus`
15
+ * suite (the increment is a pure exported function specifically so that last
16
+ * one can be proven behaviourally instead of by matching source text, which
17
+ * only pins formatting and would miss a wrong-operator bug). What's unique to
18
+ * VideoEditor, and only lives here, is the bridge between them: that
19
+ * `handleEditCaption` actually calls both `handleSelectCaption` and
20
+ * `nextEditFocus` rather than reimplementing either inline.
21
+ */
22
+ describe('VideoEditor caption edit-focus bridge (Phase 6)', () => {
23
+ it('imports nextEditFocus from CaptionListPanel — the same shared helper the composition check below points at', () => {
24
+ expect(src).toMatch(/import CaptionListPanel, \{[^}]*nextEditFocus[^}]*\} from '\.\/CaptionListPanel'/)
25
+ })
26
+
27
+ it('holds editFocusId as its own state, shaped { id, nonce } | null', () => {
28
+ expect(src).toMatch(/const \[editFocusId, setEditFocusId\] = useState<CaptionEditFocusRequest \| null>\(null\)/)
29
+ })
30
+
31
+ it('handleEditCaption both selects the caption and requests a focus', () => {
32
+ const fn = src.match(/const handleEditCaption = useCallback\(\([\s\S]*?\}, \[handleSelectCaption\]\)/)?.[0] ?? ''
33
+ expect(fn).not.toBe('')
34
+ expect(fn).toMatch(/handleSelectCaption\(id\)/)
35
+ expect(fn).toMatch(/setEditFocusId/)
36
+ })
37
+
38
+ // A composition check, not a match on the increment's arithmetic — that
39
+ // arithmetic is unit-tested behaviourally in CaptionListPanel.test.tsx's
40
+ // `nextEditFocus` suite (`?? 0` vs `?? 1`, `+ 1` present or dropped, etc).
41
+ // What this can honestly prove from source text alone is that
42
+ // `handleEditCaption` actually CALLS the shared helper rather than
43
+ // reimplementing the increment inline — a reformat of `nextEditFocus`'s
44
+ // body won't break this the way matching its expression verbatim would.
45
+ it('derives the next focus request via the shared nextEditFocus helper, not an inline reimplementation', () => {
46
+ const fn = src.match(/const handleEditCaption = useCallback\(\([\s\S]*?\}, \[handleSelectCaption\]\)/)?.[0] ?? ''
47
+ expect(fn).toMatch(/setEditFocusId\(prev => nextEditFocus\(prev, id\)\)/)
48
+ expect(fn).not.toMatch(/prev\?\.nonce/)
49
+ })
50
+
51
+ it('threads onEditCaption into Timeline and editFocusId into the sidebar CaptionListPanel', () => {
52
+ expect(src).toMatch(/onEditCaption=\{handleEditCaption\}/)
53
+ expect(src).toMatch(/editFocusId=\{editFocusId\}/)
54
+ })
55
+ })