@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,725 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, waitFor, act, fireEvent, screen } from '@testing-library/react'
3
+ import type { EditorAdapter, ImageElement, Project, RenderEvent, VersionEntry, WaveformChunk } from '../../types'
4
+ import VideoEditor from '../VideoEditor'
5
+ import { trackItems } from '../timeline/timeline-model'
6
+ import { installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
7
+
8
+ // ── T9 integration tests — the ReviewSurface keymap, the command palette,
9
+ // and the scrubber's "go to time" affordance, driven through a mounted
10
+ // <VideoEditor>. A minimal fake adapter is duplicated from VideoEditor.test.tsx
11
+ // (that file isn't touched — "existing tests unmodified") rather than shared.
12
+
13
+ function makeVideoProject(overrides: Partial<Project> = {}): Project {
14
+ return {
15
+ id: 'vid-1',
16
+ name: 'Test Video',
17
+ status: 'draft',
18
+ editingPrompt: '',
19
+ projectType: 'video',
20
+ settings: { resolution: [1080, 1920], fps: 30 },
21
+ tracks: [
22
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
23
+ [{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' } }],
24
+ ],
25
+ audio: { tracks: [] },
26
+ assets: [],
27
+ ...overrides,
28
+ } as Project
29
+ }
30
+
31
+ // T2 clipboard fixture: identical to `makeVideoProject`'s default tracks
32
+ // except `overlay-1` carries a distinguishing `opacity`, so a paste-attributes
33
+ // test can prove the TARGET actually picked up the SOURCE's look field
34
+ // (rather than merely asserting no error was thrown). Built the same way
35
+ // `makeVideoProject` builds its own default tracks (a whole-object `as
36
+ // Project` cast) rather than routed through `makeVideoProject`'s `overrides:
37
+ // Partial<Project>` parameter — the legacy array-of-arrays tracks shape isn't
38
+ // structurally assignable to `Partial<Project>`'s `VisualTrack[]`, even
39
+ // though `trackItems`/`normalizeTracks` accept both shapes at runtime.
40
+ function makeClipboardProject(): Project {
41
+ return {
42
+ ...makeVideoProject(),
43
+ tracks: [
44
+ [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
45
+ [{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Hello' }, opacity: 0.4 }],
46
+ ],
47
+ // `as unknown as Project` (TS's own suggestion here): the legacy
48
+ // array-of-arrays tracks shape isn't structurally assignable to `Project`'s
49
+ // `VisualTrack[]`, even though `trackItems`/`normalizeTracks` accept both
50
+ // shapes at runtime — same reason `makeVideoProject`'s own default tracks
51
+ // only compile because its `...overrides: Partial<Project>` spread widens
52
+ // the inferred literal type enough for a plain `as Project` to pass.
53
+ } as unknown as Project
54
+ }
55
+
56
+ function makeFakeAdapter(): EditorAdapter<Project> {
57
+ return {
58
+ loadProject: vi.fn(async () => makeVideoProject()),
59
+ saveProject: vi.fn(async () => {}),
60
+ subscribe: () => () => {},
61
+ render: async function* (): AsyncIterable<RenderEvent> { yield { type: 'done', outputPath: '/out.mp4' } },
62
+ resolveImageSrc: (el: ImageElement) => el.src,
63
+ compileOverlay: vi.fn(async () => () => null),
64
+ listGlobalOverlays: vi.fn(async () => []),
65
+ listSystemOverlays: vi.fn(async () => []),
66
+ uploadFile: vi.fn(async () => '/path'),
67
+ fileUrl: (path: string) => path,
68
+ listVersionHistory: vi.fn(async (): Promise<VersionEntry[]> => []),
69
+ restoreVersion: vi.fn(async () => makeVideoProject()),
70
+ getWaveformChunks: vi.fn(async (): Promise<WaveformChunk[]> => []),
71
+ resolveCaptionTemplate: (style: string) => `/caption/${style}`,
72
+ getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
73
+ }
74
+ }
75
+
76
+ // Stashed by `renderEditor` so `selectOverlay`/`selectVideoClip` can address a
77
+ // clip by canvas geometry without every call site threading `container` and
78
+ // `project` through by hand. Reset in `afterEach` below.
79
+ let selCtx: { container: HTMLElement; project: Project } | null = null
80
+
81
+ /** Canvas-mode replacement for a bare `render(<VideoEditor ... />)` at every
82
+ * call site in this file that later selects a clip — mounts as usual, then
83
+ * stashes `{ container, project }` for `selectOverlay`/`selectVideoClip`. */
84
+ function renderEditor(ui: Parameters<typeof render>[0], project: Project): ReturnType<typeof render> {
85
+ const result = render(ui)
86
+ selCtx = { container: result.container, project }
87
+ return result
88
+ }
89
+
90
+ let uninstallCanvasHarness: () => void
91
+
92
+ beforeEach(() => {
93
+ uninstallCanvasHarness = installCanvasHarness()
94
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
95
+ vi.spyOn(console, 'error').mockImplementation(() => {})
96
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
97
+ observe() {}
98
+ unobserve() {}
99
+ disconnect() {}
100
+ }
101
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
102
+ ;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
103
+ ;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
104
+ state = 'running'
105
+ createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
106
+ createMediaElementSource() { return { connect() {}, disconnect() {} } }
107
+ get destination() { return {} }
108
+ close() {}
109
+ }
110
+ // T5 fullscreen — jsdom has no real Fullscreen API. Stubbed so
111
+ // `requestFullscreen`/`exitFullscreen` do the one thing VideoEditor relies
112
+ // on (real browsers do this natively): flip `document.fullscreenElement`
113
+ // and fire `fullscreenchange`, synchronously, so the effect that reads it
114
+ // settles inside the same `act()` as the call that triggered it.
115
+ ;(HTMLElement.prototype as unknown as { requestFullscreen: () => Promise<void> }).requestFullscreen =
116
+ vi.fn(function (this: HTMLElement) {
117
+ Object.defineProperty(document, 'fullscreenElement', { value: this, configurable: true })
118
+ document.dispatchEvent(new Event('fullscreenchange'))
119
+ return Promise.resolve()
120
+ })
121
+ document.exitFullscreen = vi.fn(() => {
122
+ Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true })
123
+ document.dispatchEvent(new Event('fullscreenchange'))
124
+ return Promise.resolve()
125
+ })
126
+ })
127
+ afterEach(() => {
128
+ vi.restoreAllMocks()
129
+ Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true })
130
+ uninstallCanvasHarness()
131
+ selCtx = null
132
+ })
133
+
134
+ function requireSelCtx(caller: string): { container: HTMLElement; project: Project } {
135
+ if (!selCtx) throw new Error(`[VideoEditor.keymap.test] ${caller}() called before renderEditor()`)
136
+ return selCtx
137
+ }
138
+
139
+ // Canvas-native replacement for `fireEvent.click(await screen.findByText('▪
140
+ // overlay'), { metaKey: true })` — additive (extends rather than replaces the
141
+ // selection), matching the pointer machine's own `isAdditive`.
142
+ async function selectOverlay() {
143
+ const { container, project } = requireSelCtx('selectOverlay')
144
+ selectCanvasItem(container, project, { type: 'overlay' }, { metaKey: true })
145
+ }
146
+
147
+ // A PLAIN click (no meta/shift) replaces the selection outright — see
148
+ // `toggleSelection` (multiSelectOps.ts): additive-false always resolves to
149
+ // `[id]`. Used by the T2 tests below to move the selection off the clipboard
150
+ // source and onto a distinct paste-attributes TARGET.
151
+ async function selectVideoClip() {
152
+ const { container, project } = requireSelCtx('selectVideoClip')
153
+ selectCanvasItem(container, project, { type: 'video' })
154
+ }
155
+
156
+ /** Timeline's own Delete/Enter bindings are focus-scoped to its root (the
157
+ * `tabIndex={0}` container) — see Timeline.tsx. Selecting via the preview
158
+ * (as `selectOverlay` does above) never touches that container, so a plain
159
+ * Delete needs focus established explicitly here, mirroring how a real
160
+ * operator would have clicked a timeline row first. It's the only
161
+ * `tabIndex={0}` element this tree renders (TimelineCanvas's own container
162
+ * uses `tabIndex={-1}`, and isn't mounted in DOM mode anyway). */
163
+ function focusTimelineRoot() {
164
+ const root = document.querySelector('[tabindex="0"]') as HTMLElement | null
165
+ root?.focus()
166
+ }
167
+
168
+ describe('VideoEditor — T9 ripple-delete binding', () => {
169
+ it('Shift+Delete ripple-deletes the selected item and commits (persists) exactly once', async () => {
170
+ const adapter = makeFakeAdapter()
171
+ const onProjectChange = vi.fn()
172
+ const project = makeVideoProject()
173
+ renderEditor(
174
+ <VideoEditor
175
+ project={project}
176
+ adapter={adapter}
177
+ onProjectChange={onProjectChange}
178
+ slots={{ exportActions: <div /> }}
179
+ />,
180
+ project,
181
+ )
182
+ await selectOverlay()
183
+
184
+ await act(async () => {
185
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
186
+ })
187
+
188
+ await waitFor(() => {
189
+ const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
190
+ expect(last.tracks?.flat().find((i) => i.id === 'overlay-1')).toBeUndefined()
191
+ })
192
+ // Exactly one commit for the gesture — one save call.
193
+ await waitFor(() => expect((adapter.saveProject as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1))
194
+ })
195
+
196
+ it('Shift+Delete with no selection is a no-op', async () => {
197
+ const adapter = makeFakeAdapter()
198
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
199
+ await screen.findByLabelText('Preview axis')
200
+
201
+ await act(async () => {
202
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: true }))
203
+ })
204
+ expect(adapter.saveProject).not.toHaveBeenCalled()
205
+ })
206
+
207
+ it('plain Delete (no Shift) still runs the ORIGINAL two-step delete, not ripple-delete', async () => {
208
+ // Regression guard for the Shift-exclusion added to Timeline's delete
209
+ // matcher (see keymap.ts's matchesDelete) — without it, plain Delete
210
+ // would ALSO match a binding meant only for Shift+Delete, or vice versa.
211
+ const adapter = makeFakeAdapter()
212
+ const onProjectChange = vi.fn()
213
+ const project = makeVideoProject()
214
+ renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />, project)
215
+ await selectOverlay()
216
+ focusTimelineRoot()
217
+
218
+ await act(async () => {
219
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', shiftKey: false }))
220
+ })
221
+ await waitFor(() => {
222
+ const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
223
+ expect(last.tracks?.flat().find((i) => i.id === 'overlay-1')).toBeUndefined()
224
+ })
225
+ })
226
+ })
227
+
228
+ describe('VideoEditor — T9 command palette', () => {
229
+ it('Cmd+K opens the palette listing commands', async () => {
230
+ const adapter = makeFakeAdapter()
231
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
232
+ await screen.findByLabelText('Preview axis')
233
+
234
+ await act(async () => {
235
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
236
+ })
237
+ expect(await screen.findByText('Split at playhead')).toBeTruthy()
238
+ })
239
+
240
+ it('typing a filter letter into the palette does not also split the timeline', async () => {
241
+ const adapter = makeFakeAdapter()
242
+ const onProjectChange = vi.fn()
243
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
244
+ await screen.findByLabelText('Preview axis')
245
+
246
+ await act(async () => {
247
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
248
+ })
249
+ const input = await screen.findByPlaceholderText('Type a command…')
250
+ onProjectChange.mockClear()
251
+ fireEvent.keyDown(input, { key: 's' })
252
+ fireEvent.change(input, { target: { value: 's' } })
253
+ // 's' typed into the palette's OWN input must filter, not split — split
254
+ // would show up as an onProjectChange carrying a new track split at 0.
255
+ expect(onProjectChange).not.toHaveBeenCalled()
256
+ })
257
+
258
+ it('Escape closes the palette', async () => {
259
+ const adapter = makeFakeAdapter()
260
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
261
+ await screen.findByLabelText('Preview axis')
262
+
263
+ await act(async () => {
264
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
265
+ })
266
+ const input = await screen.findByPlaceholderText('Type a command…')
267
+ fireEvent.keyDown(input, { key: 'Escape' })
268
+ await waitFor(() => expect(screen.queryByPlaceholderText('Type a command…')).toBeNull())
269
+ })
270
+
271
+ it('clicking the scrubber time readout opens the palette directly in "go to time" mode', async () => {
272
+ const adapter = makeFakeAdapter()
273
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
274
+ await screen.findByLabelText('Preview axis')
275
+
276
+ const readout = await screen.findByLabelText('Go to time')
277
+ fireEvent.click(readout)
278
+ expect(await screen.findByPlaceholderText(/mm:ss/)).toBeTruthy()
279
+ // The list view's search box must NOT also be present — it opened
280
+ // straight into goto mode, not the filtered list.
281
+ expect(screen.queryByPlaceholderText('Type a command…')).toBeNull()
282
+ })
283
+ })
284
+
285
+ describe('VideoEditor — T9 keymap does not race Space', () => {
286
+ it('Space is never observed by the keymap (no palette, no split, no undo/redo side effect)', async () => {
287
+ const adapter = makeFakeAdapter()
288
+ const onProjectChange = vi.fn()
289
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={onProjectChange} slots={{ exportActions: <div /> }} />)
290
+ await screen.findByLabelText('Preview axis')
291
+ onProjectChange.mockClear()
292
+
293
+ await act(async () => {
294
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' }))
295
+ })
296
+ // No palette opened, no split committed as a side effect of Space.
297
+ expect(screen.queryByPlaceholderText('Type a command…')).toBeNull()
298
+ expect(onProjectChange).not.toHaveBeenCalled()
299
+ })
300
+ })
301
+
302
+ // ── The preview-axis toggle ──────────────────────────────────────────────
303
+ //
304
+ // The gesture RULES are covered as pure data in `pointer-machine.test.ts`.
305
+ // What's only observable at this level is the chrome: the toggle exists, it
306
+ // starts off, and the toolbar button / Cmd+S / the palette all drive the same
307
+ // piece of state.
308
+
309
+ describe('VideoEditor — preview axis toggle', () => {
310
+ it('starts OFF, so clicking the timeline does not scrub by default', async () => {
311
+ const adapter = makeFakeAdapter()
312
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
313
+ await screen.findByLabelText('Preview axis')
314
+
315
+ expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('false')
316
+ })
317
+
318
+ it('the toolbar button toggles it', async () => {
319
+ const adapter = makeFakeAdapter()
320
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
321
+ await screen.findByLabelText('Preview axis')
322
+
323
+ const button = screen.getByLabelText('Preview axis')
324
+ fireEvent.click(button)
325
+ expect(button.getAttribute('aria-pressed')).toBe('true')
326
+ fireEvent.click(button)
327
+ expect(button.getAttribute('aria-pressed')).toBe('false')
328
+ })
329
+
330
+ it('Cmd+A and Ctrl+A both toggle it — A for Axis', async () => {
331
+ const adapter = makeFakeAdapter()
332
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
333
+ await screen.findByLabelText('Preview axis')
334
+
335
+ const button = screen.getByLabelText('Preview axis')
336
+ await act(async () => {
337
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true }))
338
+ })
339
+ expect(button.getAttribute('aria-pressed')).toBe('true')
340
+
341
+ await act(async () => {
342
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true }))
343
+ })
344
+ expect(button.getAttribute('aria-pressed')).toBe('false')
345
+ })
346
+
347
+ it('leaves Cmd+A alone inside a typing surface, so Select All still works there', async () => {
348
+ // The chord shadows the browser's Select All everywhere EXCEPT text entry.
349
+ // `isTypingTarget` is what draws that line, and a caption row is a real
350
+ // contentEditable in this surface — regressing the guard would make it
351
+ // impossible to select the text of a caption you are editing.
352
+ const adapter = makeFakeAdapter()
353
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
354
+ await screen.findByLabelText('Preview axis')
355
+
356
+ const input = document.createElement('input')
357
+ document.body.appendChild(input)
358
+ await act(async () => {
359
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true, bubbles: true }))
360
+ })
361
+ expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('false')
362
+ input.remove()
363
+ })
364
+
365
+ it('bare A does nothing — the toggle is the chord, not the letter', async () => {
366
+ const adapter = makeFakeAdapter()
367
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
368
+ await screen.findByLabelText('Preview axis')
369
+
370
+ await act(async () => {
371
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
372
+ })
373
+ expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('false')
374
+ })
375
+
376
+ it('offers the toggle in the command palette, labelled by what it will do', async () => {
377
+ const adapter = makeFakeAdapter()
378
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
379
+ await screen.findByLabelText('Preview axis')
380
+
381
+ await act(async () => {
382
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
383
+ })
384
+ fireEvent.click(await screen.findByText('Preview axis: turn on'))
385
+ expect(screen.getByLabelText('Preview axis').getAttribute('aria-pressed')).toBe('true')
386
+ })
387
+ })
388
+
389
+ // ── T2 — copy / paste / duplicate / paste-attributes ────────────────────
390
+ //
391
+ // Persistence assertions read `adapter.saveProject`'s SECOND argument (the
392
+ // project actually being saved), not `onProjectChange` — an optimistic
393
+ // `onProjectChange` fire can't tell a real commit apart from a client-side
394
+ // normalizer patch that never saves (see the load-time id-backfill/caption-
395
+ // repair effects elsewhere in VideoEditor.tsx).
396
+
397
+ describe('VideoEditor — T2 copy/paste/duplicate', () => {
398
+ it('Cmd+C never persists on its own — copying is local clipboard state only', async () => {
399
+ const adapter = makeFakeAdapter()
400
+ const project = makeVideoProject()
401
+ renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
402
+ await selectVideoClip()
403
+
404
+ await act(async () => {
405
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
406
+ })
407
+ expect(adapter.saveProject).not.toHaveBeenCalled()
408
+ })
409
+
410
+ it('Cmd+V with an empty clipboard is a no-op', async () => {
411
+ const adapter = makeFakeAdapter()
412
+ const project = makeVideoProject()
413
+ renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
414
+ await selectVideoClip()
415
+
416
+ await act(async () => {
417
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
418
+ })
419
+ expect(adapter.saveProject).not.toHaveBeenCalled()
420
+ })
421
+
422
+ it('Cmd+C then Cmd+V pastes a copy at the playhead and commits it exactly once', async () => {
423
+ const adapter = makeFakeAdapter()
424
+ const project = makeVideoProject()
425
+ renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
426
+ await selectVideoClip()
427
+
428
+ await act(async () => {
429
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
430
+ })
431
+ await act(async () => {
432
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
433
+ })
434
+
435
+ await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
436
+ const saved = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls[0][1] as Project
437
+ const videoItems = trackItems(saved).flat().filter((i) => i.type === 'video')
438
+ // The original plus one pasted copy — a fresh id, not a re-used one.
439
+ expect(videoItems).toHaveLength(2)
440
+ expect(videoItems.map((i) => i.id)).toContain('clip-0')
441
+ expect(videoItems.filter((i) => i.id !== 'clip-0')).toHaveLength(1)
442
+ })
443
+
444
+ it('Cmd+D with no selection is a no-op', async () => {
445
+ const adapter = makeFakeAdapter()
446
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
447
+ await screen.findByLabelText('Preview axis')
448
+
449
+ await act(async () => {
450
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', metaKey: true }))
451
+ })
452
+ expect(adapter.saveProject).not.toHaveBeenCalled()
453
+ })
454
+
455
+ // Every test in this file is driven through the CANVAS timeline rather
456
+ // than the DOM rows — see `renderEditor`/`selectOverlay`/`selectVideoClip`
457
+ // near the top of the file, which route through `selectCanvasItem` (see
458
+ // `timeline/__tests__/_canvasSelect.ts`). It computes a clip's page
459
+ // coordinates from the same layout + viewport math the surface paints
460
+ // through, so selecting `clip-0` here lands exactly where clicking
461
+ // "▪ video" used to — the assertions below are untouched.
462
+ //
463
+ // Canvas is simply what a timeline is now — there is no mode to opt into.
464
+ it('Cmd+D duplicates the selection in place and commits it exactly once', async () => {
465
+ const adapter = makeFakeAdapter()
466
+ const project = makeVideoProject()
467
+ renderEditor(
468
+ <VideoEditor
469
+ project={project}
470
+ adapter={adapter}
471
+ onProjectChange={vi.fn()}
472
+ slots={{ exportActions: <div /> }}
473
+ />,
474
+ project,
475
+ )
476
+ await selectVideoClip()
477
+
478
+ await act(async () => {
479
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', metaKey: true }))
480
+ })
481
+
482
+ await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
483
+ const saved = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls[0][1] as Project
484
+ const videoItems = trackItems(saved).flat().filter((i) => i.type === 'video')
485
+ expect(videoItems).toHaveLength(2)
486
+ expect(videoItems.filter((i) => i.id !== 'clip-0')).toHaveLength(1)
487
+ })
488
+
489
+ it('Cmd+Opt+V with neither a clipboard nor a selection is a no-op', async () => {
490
+ const adapter = makeFakeAdapter()
491
+ render(<VideoEditor project={makeClipboardProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
492
+ await screen.findByLabelText('Preview axis')
493
+
494
+ await act(async () => {
495
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true, altKey: true }))
496
+ })
497
+ expect(adapter.saveProject).not.toHaveBeenCalled()
498
+ })
499
+
500
+ it('Cmd+Opt+V pastes ATTRIBUTES onto the selected target and does NOT fall through to a plain paste', async () => {
501
+ // Regression guard for the binding-ORDER requirement in keymap.ts's
502
+ // `matchesModAltKey` doc comment: `matchesModKey('v')` (plain paste)
503
+ // doesn't exclude `altKey`, so Cmd+Opt+V matches BOTH bindings — only
504
+ // registering paste-attributes FIRST in the `useKeymap` array makes it
505
+ // win. Proven two ways at once: the target's `opacity` picks up the
506
+ // clipboard source's value, AND no third item is created (a plain paste
507
+ // would have added one).
508
+ const adapter = makeFakeAdapter()
509
+ const project = makeClipboardProject()
510
+ renderEditor(<VideoEditor project={project} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />, project)
511
+ await selectOverlay() // selects overlay-1 (opacity 0.4) — the copy SOURCE
512
+ await act(async () => {
513
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', metaKey: true }))
514
+ })
515
+ await selectVideoClip() // plain click replaces the selection with clip-0 — the TARGET
516
+
517
+ await act(async () => {
518
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', metaKey: true, altKey: true }))
519
+ })
520
+
521
+ await waitFor(() => expect(adapter.saveProject).toHaveBeenCalledTimes(1))
522
+ const saved = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls[0][1] as Project
523
+ const allItems = trackItems(saved).flat()
524
+ expect(allItems).toHaveLength(2) // unchanged — proves this was NOT a paste
525
+ expect(allItems.find((i) => i.id === 'clip-0')?.opacity).toBe(0.4)
526
+ })
527
+ })
528
+
529
+ // ── T5 — fullscreen preview ──────────────────────────────────────────────
530
+
531
+ describe('VideoEditor — T5 fullscreen preview', () => {
532
+ it('starts out of fullscreen', async () => {
533
+ const adapter = makeFakeAdapter()
534
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
535
+ await screen.findByLabelText('Preview axis')
536
+
537
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
538
+ })
539
+
540
+ it('F requests fullscreen on the preview, and F again exits it via document.exitFullscreen', async () => {
541
+ const adapter = makeFakeAdapter()
542
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
543
+ await screen.findByLabelText('Preview axis')
544
+
545
+ await act(async () => {
546
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f' }))
547
+ })
548
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('true')
549
+
550
+ await act(async () => {
551
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f' }))
552
+ })
553
+ expect(document.exitFullscreen).toHaveBeenCalledTimes(1)
554
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
555
+ })
556
+
557
+ it('Cmd+F is left alone — F only fires unmodified, so native browser find still works', async () => {
558
+ const adapter = makeFakeAdapter()
559
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
560
+ await screen.findByLabelText('Preview axis')
561
+
562
+ await act(async () => {
563
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', metaKey: true }))
564
+ })
565
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
566
+ })
567
+
568
+ it('the fullscreen button in the preview controls row also toggles it', async () => {
569
+ const adapter = makeFakeAdapter()
570
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
571
+ await screen.findByLabelText('Preview axis')
572
+
573
+ const button = screen.getByLabelText('Toggle fullscreen')
574
+ fireEvent.click(button)
575
+ expect(button.getAttribute('aria-pressed')).toBe('true')
576
+ })
577
+
578
+ it('a browser-driven fullscreenchange (Escape, tab switch) updates state truthfully — no Escape handling of our own', async () => {
579
+ const adapter = makeFakeAdapter()
580
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
581
+ await screen.findByLabelText('Preview axis')
582
+
583
+ await act(async () => {
584
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'f' }))
585
+ })
586
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('true')
587
+
588
+ // The browser exits fullscreen entirely on its own here — VideoEditor's
589
+ // own `exitFullscreen` mock is never called, only the native event fires.
590
+ await act(async () => {
591
+ Object.defineProperty(document, 'fullscreenElement', { value: null, configurable: true })
592
+ document.dispatchEvent(new Event('fullscreenchange'))
593
+ })
594
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('false')
595
+ })
596
+
597
+ it('offers the toggle in the command palette, labelled by what it will do', async () => {
598
+ const adapter = makeFakeAdapter()
599
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
600
+ await screen.findByLabelText('Preview axis')
601
+
602
+ await act(async () => {
603
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
604
+ })
605
+ fireEvent.click(await screen.findByText('Enter fullscreen'))
606
+ expect(screen.getByLabelText('Toggle fullscreen').getAttribute('aria-pressed')).toBe('true')
607
+
608
+ await act(async () => {
609
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))
610
+ })
611
+ expect(await screen.findByText('Exit fullscreen')).toBeTruthy()
612
+ })
613
+ })
614
+
615
+ // ── Preview controls row — timecode, zoom-to-fit, safe-zone toggle ────────
616
+
617
+ describe('VideoEditor — preview controls row', () => {
618
+ it('renders a current / total timecode readout that updates as the playhead moves', async () => {
619
+ const adapter = makeFakeAdapter()
620
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
621
+ await screen.findByLabelText('Preview axis')
622
+
623
+ // makeVideoProject's clips run 0-4, so content duration is 4s.
624
+ expect(screen.getByTestId('preview-timecode').textContent).toBe('0:00.0 / 0:04.0')
625
+
626
+ // Seek via the command palette's "go to time" entry (clock.set under the
627
+ // hood) — the readout's `usePlaybackTime(clock)` subscription should
628
+ // pick the new position up without any other interaction.
629
+ fireEvent.click(await screen.findByLabelText('Go to time'))
630
+ const input = await screen.findByPlaceholderText(/mm:ss/)
631
+ fireEvent.change(input, { target: { value: '2' } })
632
+ fireEvent.keyDown(input, { key: 'Enter' })
633
+
634
+ await waitFor(() => expect(screen.getByTestId('preview-timecode').textContent).toBe('0:02.0 / 0:04.0'))
635
+ })
636
+
637
+ it('clamps the DISPLAYED current time to the total, never showing more than it, while parked in the timeline canvas headroom', async () => {
638
+ const adapter = makeFakeAdapter()
639
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
640
+ await screen.findByLabelText('Preview axis')
641
+
642
+ // makeVideoProject's clips run 0-4 (content duration 4s), but the go-to-time
643
+ // clamp uses `getTotalDuration()` — content duration plus drag headroom for
644
+ // the timeline canvas (max(5, contentDuration * 0.2), so 9s here) — not the
645
+ // 4s shown as the readout's total. Seeking to 6 lands the playhead PAST the
646
+ // readout's total, entirely inside that headroom.
647
+ fireEvent.click(await screen.findByLabelText('Go to time'))
648
+ const input = await screen.findByPlaceholderText(/mm:ss/)
649
+ fireEvent.change(input, { target: { value: '6' } })
650
+ fireEvent.keyDown(input, { key: 'Enter' })
651
+
652
+ // The readout must never show a current time past its own total — clamped
653
+ // display only; the clock and total are untouched (proven by the sibling
654
+ // test above landing exactly on an UN-clamped in-range seek).
655
+ await waitFor(() => expect(screen.getByTestId('preview-timecode').textContent).toBe('0:04.0 / 0:04.0'))
656
+ })
657
+
658
+ it('the social-preview picker shows and hides the chrome overlay over the video, off ("None") by default', async () => {
659
+ const adapter = makeFakeAdapter()
660
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
661
+ await screen.findByLabelText('Preview axis')
662
+
663
+ expect(screen.queryByTestId('social-safe-zone-overlay')).toBeNull()
664
+ const trigger = screen.getByLabelText('Preview for social media')
665
+ expect(trigger.getAttribute('aria-pressed')).toBe('false')
666
+
667
+ // Open the picker and choose TikTok — the chrome overlay mounts and the
668
+ // trigger reflects an active selection.
669
+ fireEvent.click(trigger)
670
+ fireEvent.click(screen.getByLabelText('TikTok'))
671
+ expect(trigger.getAttribute('aria-pressed')).toBe('true')
672
+ expect(screen.getByTestId('social-safe-zone-overlay')).toBeTruthy()
673
+
674
+ // Reopen and choose None — the overlay unmounts and the trigger returns
675
+ // to its inactive state.
676
+ fireEvent.click(trigger)
677
+ fireEvent.click(screen.getByLabelText('None'))
678
+ expect(trigger.getAttribute('aria-pressed')).toBe('false')
679
+ expect(screen.queryByTestId('social-safe-zone-overlay')).toBeNull()
680
+ })
681
+
682
+ it('the social-preview pick persists into project settings (mirrors handleImageToneChange)', async () => {
683
+ const adapter = makeFakeAdapter()
684
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
685
+ await screen.findByLabelText('Preview axis')
686
+
687
+ fireEvent.click(screen.getByLabelText('Preview for social media'))
688
+ fireEvent.click(screen.getByLabelText('Instagram Reels'))
689
+
690
+ await waitFor(() => expect(adapter.saveProject).toHaveBeenCalled())
691
+ // saveProject(projectId, project) — the project itself is the SECOND arg.
692
+ const calls = (adapter.saveProject as ReturnType<typeof vi.fn>).mock.calls
693
+ const saved = calls[calls.length - 1]?.[1]
694
+ expect(saved.settings.socialPreview).toBe('instagram')
695
+ })
696
+
697
+ it("the timeline chrome's 'fit' button resets the zoom — the single consolidated zoom-to-fit control (the duplicate preview-row icon was removed)", async () => {
698
+ // Canvas zoom chrome, which is now the only zoom chrome. The literals
699
+ // below are NOT the DOM path's: that badge printed an integer and stepped
700
+ // by +1 (1× → 2×), whereas canvas prints one decimal
701
+ // (`formatZoomMultiple`, canvas/viewport.ts:209-213) and steps by a
702
+ // multiplicative `ZOOM_BUTTON_FACTOR = 1.5` (canvas/viewport.ts:60) —
703
+ // hence 1.0× → 1.5×, not 1× → 2×. Not a user-visible change: canvas has
704
+ // been the shipping path all along, so this is the formatting the app has
705
+ // always shown. Only this test still encoded the dead DOM path's chrome.
706
+ // (The canvas harness itself comes from the file-level `beforeEach` above
707
+ // — every test in this file gets it, not just this one.)
708
+ const adapter = makeFakeAdapter()
709
+ render(<VideoEditor project={makeVideoProject()} adapter={adapter} onProjectChange={vi.fn()} slots={{ exportActions: <div /> }} />)
710
+ await screen.findByLabelText('Preview axis')
711
+
712
+ expect(screen.getByLabelText('Zoom in').closest('div')?.textContent).toContain('1.0×')
713
+ fireEvent.click(screen.getByLabelText('Zoom in'))
714
+ expect(screen.getByLabelText('Zoom in').closest('div')?.textContent).toContain('1.5×')
715
+
716
+ // "fit" (aria-label "Fit to view") lives next to the +/- zoom buttons in
717
+ // the timeline chrome; the preview controls row no longer carries a
718
+ // duplicate zoom-to-fit icon. Canvas sets `showFit: true` unconditionally
719
+ // (TimelineCanvas.tsx:1150-1152) — deliberately, because canvas zoom can
720
+ // sit BELOW 1×, so "am I off fit?" is not simply "is zoom > 1" as it was
721
+ // on the DOM path.
722
+ fireEvent.click(screen.getByLabelText('Fit to view'))
723
+ expect(screen.getByLabelText('Zoom in').closest('div')?.textContent).toContain('1.0×')
724
+ })
725
+ })