@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,530 @@
1
+ /**
2
+ * SP4 T6 — the clock bridge, the gesture anchors and the audio-lane slaving.
3
+ *
4
+ * The engine itself is mocked: everything under `src/engine/` is decode,
5
+ * WebCodecs and rAF, none of which exist in jsdom, and none of which this file
6
+ * is about. What IS under test is the seam — who seeks whom, when, and exactly
7
+ * once — so the fake engine records calls and lets each test drive `onTime` /
8
+ * `onStatusChange` by hand at the moment the real engine would.
9
+ *
10
+ * `currentTime` is the prop `PreviewPlayer` feeds from `usePlaybackTime(clock)`;
11
+ * a `rerender` with a new value is exactly what a Scrubber/Timeline/track-row
12
+ * `clock.set(t)` looks like from inside this hook.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
15
+ import { renderHook, act } from '@testing-library/react'
16
+ // jsdom implements neither `play()` nor `pause()`. Installed once, at module
17
+ // scope, rather than as a per-test spy: Testing Library's own `afterEach`
18
+ // cleanup unmounts the hook — and therefore runs the audio-lane teardown, which
19
+ // pauses every element — AFTER a `restoreAllMocks()` in this file would have
20
+ // put the unimplemented originals back.
21
+ Object.defineProperty(HTMLMediaElement.prototype, 'paused', {
22
+ configurable: true,
23
+ get(this: HTMLMediaElement & { __paused?: boolean }) { return this.__paused !== false },
24
+ })
25
+ HTMLMediaElement.prototype.play = function (this: HTMLMediaElement & { __paused?: boolean }) {
26
+ this.__paused = false
27
+ return Promise.resolve()
28
+ }
29
+ HTMLMediaElement.prototype.pause = function (this: HTMLMediaElement & { __paused?: boolean }) {
30
+ this.__paused = true
31
+ }
32
+ // jsdom has no `AudioContext` at all. Fix 5 makes `togglePlay` call
33
+ // `getSharedAudioContext()` unconditionally (every real browser has one), so
34
+ // without a stub ANY `togglePlay()`/Space call in this file would throw a
35
+ // `ReferenceError` the moment it tries `new AudioContext()` — including tests
36
+ // that have nothing to do with audio and never call `stubSuspendedContext()`.
37
+ // A minimal, harmless default at module scope; tests that care about
38
+ // suspend/resume behavior still control `state`/`resume` themselves (see
39
+ // `stubSuspendedContext` and the 'audio lanes' describe block's own richer
40
+ // stub, both of which override this per-test).
41
+ ;(window as unknown as { AudioContext: unknown }).AudioContext = class {
42
+ state = 'running'
43
+ destination = {}
44
+ resume() {
45
+ return Promise.resolve()
46
+ }
47
+ createMediaElementSource() {
48
+ return { connect() {} }
49
+ }
50
+ createGain() {
51
+ return { gain: { value: 1 }, connect() {} }
52
+ }
53
+ }
54
+ import type { EditorProject as Project } from '../../../schema'
55
+ import type { EngineStats, EngineStatus } from '../../../engine'
56
+
57
+ interface FakeEngine {
58
+ deps: {
59
+ onTime?: (t: number) => void
60
+ onStatusChange?: (s: EngineStatus) => void
61
+ onError?: (m: string) => void
62
+ startProjectS?: number
63
+ fileUrl: (p: string) => string
64
+ }
65
+ project: Project
66
+ transport: EngineStatus['transport']
67
+ now: number
68
+ seeks: number[]
69
+ rates: number[]
70
+ plays: number
71
+ pauses: number
72
+ disposes: number
73
+ updates: Project[]
74
+ attaches: Array<HTMLCanvasElement | null>
75
+ statsCalls: number
76
+ /** Emit a playhead the way a real tick would. */
77
+ emit(t: number): void
78
+ /** Move the transport and publish, the way `play()`/`pause()` do. */
79
+ setTransport(t: EngineStatus['transport']): void
80
+ }
81
+
82
+ const engines = vi.hoisted(() => [] as unknown[])
83
+
84
+ vi.mock('../../../engine', async (importOriginal) => {
85
+ const actual = await importOriginal<typeof import('../../../engine')>()
86
+ return {
87
+ ...actual,
88
+ createEngine: (project: Project, deps: FakeEngine['deps']) => {
89
+ const status = (): EngineStatus => ({
90
+ transport: engine.transport,
91
+ picture: 'video',
92
+ clipId: null,
93
+ seeking: false,
94
+ clock: 'fallback',
95
+ })
96
+ const engine: FakeEngine & {
97
+ attach: (c: HTMLCanvasElement | null) => void
98
+ play: () => void
99
+ pause: () => void
100
+ seek: (t: number) => void
101
+ setRate: (r: number) => void
102
+ updateProject: (p: Project) => void
103
+ status: () => EngineStatus
104
+ clock: { now: () => number; playing: boolean; kind: 'audio' | 'fallback' }
105
+ stats: () => EngineStats
106
+ dispose: () => void
107
+ } = {
108
+ deps,
109
+ project,
110
+ transport: 'paused',
111
+ now: deps.startProjectS ?? 0,
112
+ seeks: [],
113
+ rates: [],
114
+ plays: 0,
115
+ pauses: 0,
116
+ disposes: 0,
117
+ updates: [],
118
+ attaches: [],
119
+ statsCalls: 0,
120
+ emit(t: number) {
121
+ engine.now = t
122
+ deps.onTime?.(t)
123
+ },
124
+ setTransport(t) {
125
+ engine.transport = t
126
+ deps.onStatusChange?.(status())
127
+ },
128
+ attach: (c) => { engine.attaches.push(c) },
129
+ play: () => { engine.plays++; engine.transport = 'playing'; deps.onStatusChange?.(status()) },
130
+ pause: () => { engine.pauses++; engine.transport = 'paused'; deps.onStatusChange?.(status()) },
131
+ seek: (t: number) => { engine.seeks.push(t); engine.now = t },
132
+ setRate: (r: number) => { engine.rates.push(r) },
133
+ updateProject: (p: Project) => { engine.updates.push(p) },
134
+ status,
135
+ clock: {
136
+ get now() { return () => engine.now },
137
+ get playing() { return engine.transport === 'playing' },
138
+ kind: 'fallback' as const,
139
+ } as unknown as { now: () => number; playing: boolean; kind: 'audio' | 'fallback' },
140
+ stats: () => {
141
+ engine.statsCalls++
142
+ return { fps: 24, dropped: 1, buffered: 2, clock: 'fallback' }
143
+ },
144
+ dispose: () => { engine.disposes++ },
145
+ }
146
+ engines.push(engine)
147
+ return engine
148
+ },
149
+ }
150
+ })
151
+
152
+ // Spy-wrapped, not replaced: `togglePlay` must reach the REAL
153
+ // `resumeAudioContextFromGesture` (its own suspended-context behavior is
154
+ // exercised via `window.__montajSharedCtx` below, unchanged), but Fix 5
155
+ // needs to assert `getSharedAudioContext` is actually called — it is the
156
+ // call that CREATES the context, which `resumeAudioContextFromGesture`
157
+ // deliberately never does (see that function's own doc).
158
+ vi.mock('../audio-context', async (importOriginal) => {
159
+ const actual = await importOriginal<typeof import('../audio-context')>()
160
+ return {
161
+ ...actual,
162
+ getSharedAudioContext: vi.fn(actual.getSharedAudioContext),
163
+ resumeAudioContextFromGesture: vi.fn(actual.resumeAudioContextFromGesture),
164
+ }
165
+ })
166
+
167
+ // Imported AFTER the mock declaration for readability only — vi.mock is hoisted.
168
+ import { useEnginePlayback, SCRUB_DEAD_ZONE_S } from '../useEnginePlayback'
169
+ import { getSharedAudioContext, resumeAudioContextFromGesture } from '../audio-context'
170
+
171
+ function makeProject(overrides: Partial<Project> = {}): Project {
172
+ return {
173
+ id: 'engine-project',
174
+ status: 'draft',
175
+ settings: { resolution: [1080, 1920], fps: 30 },
176
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', proxySrc: 'a_proxy.mp4', start: 0, end: 10 }] }],
177
+ ...overrides,
178
+ } as Project
179
+ }
180
+
181
+ function setup(project: Project = makeProject(), initialTime = 0) {
182
+ const onTimeUpdate = vi.fn<(t: number) => void>()
183
+ const view = renderHook(
184
+ ({ t }: { t: number }) => useEnginePlayback(project, t, onTimeUpdate, (p) => p),
185
+ { initialProps: { t: initialTime } },
186
+ )
187
+ const engine = engines[engines.length - 1] as FakeEngine
188
+ return { view, engine, onTimeUpdate }
189
+ }
190
+
191
+ beforeEach(() => {
192
+ engines.length = 0
193
+ vi.mocked(getSharedAudioContext).mockClear()
194
+ vi.mocked(resumeAudioContextFromGesture).mockClear()
195
+ })
196
+
197
+ // ── The clock bridge ─────────────────────────────────────────────────────────
198
+
199
+ describe('useEnginePlayback clock bridge', () => {
200
+ it('engine echo → no seek', () => {
201
+ const { view, engine, onTimeUpdate } = setup()
202
+
203
+ act(() => { engine.emit(1.234) })
204
+ // Mirror-then-forward: the editor clock got the value…
205
+ expect(onTimeUpdate).toHaveBeenCalledWith(1.234)
206
+
207
+ // …and the store coming back around as a new `currentTime` is recognized
208
+ // as this hook's own echo, not as somebody asking to go to 1.234.
209
+ act(() => { view.rerender({ t: 1.234 }) })
210
+ expect(engine.seeks).toEqual([])
211
+ })
212
+
213
+ it('engine echo → no seek even when React commits a STALE snapshot', () => {
214
+ // The dead zone alone cannot cover this: by the time the render carrying
215
+ // 1.0 commits, the engine has ticked on to 1.30, which is 0.30s away — six
216
+ // times the dead zone. Answering it with a seek would rewind the picture to
217
+ // a frame it had already left (and restart the decode stream doing it).
218
+ const { view, engine } = setup()
219
+ act(() => { engine.setTransport('playing') })
220
+ act(() => { for (const t of [1.0, 1.1, 1.2, 1.3]) engine.emit(t) })
221
+
222
+ act(() => { view.rerender({ t: 1.0 }) })
223
+ expect(engine.seeks).toEqual([])
224
+ })
225
+
226
+ it('external set → exactly one seek', () => {
227
+ const { view, engine } = setup()
228
+ act(() => { engine.emit(0.5) })
229
+
230
+ act(() => { view.rerender({ t: 4 }) })
231
+ expect(engine.seeks).toEqual([4])
232
+
233
+ // The engine answers a seek by emitting the position it landed on. That
234
+ // echo must not produce a SECOND seek for the same gesture.
235
+ act(() => { engine.emit(4) })
236
+ act(() => { view.rerender({ t: 4 }) })
237
+ expect(engine.seeks).toEqual([4])
238
+ })
239
+
240
+ it('ignores a store nudge inside the legacy dead zone', () => {
241
+ const { view, engine } = setup()
242
+ act(() => { engine.emit(2) })
243
+ act(() => { view.rerender({ t: 2 + SCRUB_DEAD_ZONE_S / 2 }) })
244
+ expect(engine.seeks).toEqual([])
245
+ })
246
+
247
+ it('scrub during playback → seek + playback continues from the target', () => {
248
+ const { view, engine } = setup()
249
+ act(() => { engine.setTransport('playing') })
250
+ act(() => { for (const t of [0.1, 0.2, 0.3]) engine.emit(t) })
251
+ expect(engine.transport).toBe('playing')
252
+
253
+ act(() => { view.rerender({ t: 7 }) })
254
+
255
+ expect(engine.seeks).toEqual([7])
256
+ // The bridge never pauses to scrub — `engine.seek` keeps the transport, and
257
+ // the very next tick reports project time from the new position.
258
+ expect(engine.pauses).toBe(0)
259
+ expect(engine.transport).toBe('playing')
260
+
261
+ act(() => { engine.emit(7.05) })
262
+ expect(view.result.current.isPlaying).toBe(true)
263
+ act(() => { view.rerender({ t: 7.05 }) })
264
+ expect(engine.seeks).toEqual([7])
265
+ })
266
+
267
+ it('never seeks a disposed engine', () => {
268
+ // Between project identities (and on unmount) the engine is torn down. The
269
+ // bridge holds the store as its mirror while there is nothing to drive, so
270
+ // a scrub arriving in that window is never replayed as a stale seek.
271
+ const { view, engine } = setup()
272
+ act(() => { view.unmount() })
273
+ expect(engine.disposes).toBe(1)
274
+ expect(engine.seeks).toEqual([])
275
+ })
276
+ })
277
+
278
+ // ── Engine lifecycle ─────────────────────────────────────────────────────────
279
+
280
+ describe('useEnginePlayback engine lifecycle', () => {
281
+ it('builds one engine per project identity and forwards edits, not rebuilds', () => {
282
+ const project = makeProject()
283
+ const onTimeUpdate = vi.fn()
284
+ const view = renderHook(
285
+ ({ p }: { p: Project }) => useEnginePlayback(p, 0, onTimeUpdate, (x) => x),
286
+ { initialProps: { p: project } },
287
+ )
288
+ const engine = engines[0] as FakeEngine
289
+ expect(engines).toHaveLength(1)
290
+ // The project the engine was constructed with is already applied.
291
+ expect(engine.updates).toEqual([])
292
+
293
+ const edited = { ...project, tracks: [{ id: 'trk-0', items: [...project.tracks![0].items, { id: 'c1', type: 'video', src: 'b.mp4', start: 10, end: 12 }] }] } as Project
294
+ act(() => { view.rerender({ p: edited }) })
295
+ expect(engines).toHaveLength(1)
296
+ expect(engine.updates).toEqual([edited])
297
+
298
+ // A different project id IS a teardown.
299
+ act(() => { view.rerender({ p: makeProject({ id: 'other' }) }) })
300
+ expect(engines).toHaveLength(2)
301
+ expect(engine.disposes).toBe(1)
302
+ })
303
+
304
+ it('exposes the legacy-shaped derived collections', () => {
305
+ const project = makeProject({
306
+ tracks: [
307
+ {
308
+ id: 'trk-0',
309
+ items: [
310
+ { id: 'later', type: 'video', src: 'b.mp4', start: 5, end: 8 },
311
+ { id: 'first', type: 'video', src: 'a.mp4', start: 0, end: 5 },
312
+ { id: 'img', type: 'image', src: 'i.png', start: 0, end: 2 },
313
+ ],
314
+ },
315
+ { id: 'trk-1', items: [{ id: 'ov', type: 'overlay', src: 'o.jsx', start: 0, end: 3 }] },
316
+ ],
317
+ } as Partial<Project>)
318
+ const { view } = setup(project)
319
+ expect(view.result.current.clips.map((c) => c.id)).toEqual(['first', 'later'])
320
+ expect(view.result.current.tracks0NonVideo.map((c) => c.id)).toEqual(['img'])
321
+ expect(view.result.current.overlayTracks.map((tr) => tr.map((i) => i.id))).toEqual([['ov']])
322
+ expect(view.result.current.isCanvasProject).toBe(false)
323
+ })
324
+
325
+ it('reports a canvas project the way the legacy hook does', () => {
326
+ const { view } = setup(makeProject({ tracks: [{ id: 'trk-0', items: [{ id: 'img', type: 'image', src: 'i.png', start: 0, end: 2 }] }] } as Partial<Project>))
327
+ expect(view.result.current.isCanvasProject).toBe(true)
328
+ })
329
+
330
+ it('getStats reads through to the live engine, and is null once there is none', () => {
331
+ const { view, engine } = setup()
332
+ expect(engine.statsCalls).toBe(0)
333
+
334
+ expect(view.result.current.getStats()).toEqual({ fps: 24, dropped: 1, buffered: 2, clock: 'fallback' })
335
+ expect(engine.statsCalls).toBe(1)
336
+
337
+ act(() => { view.unmount() })
338
+ expect(view.result.current.getStats()).toBeNull()
339
+ })
340
+
341
+ it('mirrors the picture state into the showVideo analog', () => {
342
+ const { view, engine } = setup()
343
+ expect(view.result.current.showVideo).toBe(false)
344
+ act(() => {
345
+ engine.deps.onStatusChange?.({ transport: 'paused', picture: 'video', clipId: 'c0', seeking: false, clock: 'audio' })
346
+ })
347
+ expect(view.result.current.showVideo).toBe(true)
348
+ act(() => {
349
+ engine.deps.onStatusChange?.({ transport: 'paused', picture: 'preparing', clipId: 'c0', reason: 'no editing proxy yet', seeking: false, clock: 'fallback' })
350
+ })
351
+ expect(view.result.current.showVideo).toBe(false)
352
+ expect(view.result.current.status.reason).toBe('no editing proxy yet')
353
+ })
354
+ })
355
+
356
+ // ── Gesture ownership ────────────────────────────────────────────────────────
357
+
358
+ describe('useEnginePlayback gesture anchors', () => {
359
+ function stubSuspendedContext() {
360
+ const resume = vi.fn(() => Promise.resolve())
361
+ ;(window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx = { state: 'suspended', resume }
362
+ return resume
363
+ }
364
+
365
+ afterEach(() => {
366
+ delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
367
+ })
368
+
369
+ it('togglePlay resumes the shared AudioContext from the gesture, then plays', () => {
370
+ const resume = stubSuspendedContext()
371
+ const { view, engine } = setup()
372
+ act(() => { view.result.current.togglePlay() })
373
+ expect(resume).toHaveBeenCalledTimes(1)
374
+ expect(engine.plays).toBe(1)
375
+ })
376
+
377
+ it('togglePlay creates the shared AudioContext BEFORE resuming it from the gesture', () => {
378
+ // `resumeAudioContextFromGesture` deliberately never creates the context
379
+ // (see its own doc) — on a project with no audio tracks and a video clip
380
+ // whose context is built inside `createMasterClock`, off the gesture
381
+ // stack entirely, nothing upstream of `togglePlay` has created it yet.
382
+ // Without `getSharedAudioContext()` running first, inside the gesture,
383
+ // the very first play on such a project would resume nothing.
384
+ stubSuspendedContext()
385
+ const { view } = setup()
386
+ act(() => { view.result.current.togglePlay() })
387
+ expect(getSharedAudioContext).toHaveBeenCalledTimes(1)
388
+ expect(resumeAudioContextFromGesture).toHaveBeenCalledTimes(1)
389
+ const getOrder = vi.mocked(getSharedAudioContext).mock.invocationCallOrder[0]
390
+ const resumeOrder = vi.mocked(resumeAudioContextFromGesture).mock.invocationCallOrder[0]
391
+ expect(getOrder).toBeLessThan(resumeOrder)
392
+ })
393
+
394
+ it('togglePlay pauses a playing transport and pushes the stop position to the clock', () => {
395
+ const { view, engine, onTimeUpdate } = setup()
396
+ act(() => { engine.setTransport('playing') })
397
+ act(() => { engine.emit(3.5) })
398
+ onTimeUpdate.mockClear()
399
+
400
+ act(() => { view.result.current.togglePlay() })
401
+ expect(engine.pauses).toBe(1)
402
+ // `pause()` never ticks, so nothing but this would settle the playhead.
403
+ expect(onTimeUpdate).toHaveBeenCalledWith(3.5)
404
+ })
405
+
406
+ it('Space toggles playback, with the legacy typing-surface guards', () => {
407
+ const resume = stubSuspendedContext()
408
+ setup()
409
+ const engine = engines[0] as FakeEngine
410
+
411
+ act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
412
+ expect(engine.plays).toBe(1)
413
+ expect(resume).toHaveBeenCalledTimes(1)
414
+
415
+ act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
416
+ expect(engine.pauses).toBe(1)
417
+
418
+ // Typing a space in a text field must not toggle playback. (The guard's
419
+ // third arm, `isContentEditable`, is not testable here — jsdom does not
420
+ // implement the property and reports `false` for every element.)
421
+ const input = document.createElement('input')
422
+ const textarea = document.createElement('textarea')
423
+ document.body.append(input, textarea)
424
+ act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
425
+ act(() => { textarea.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
426
+ expect(engine.plays).toBe(1)
427
+
428
+ // Any other key is none of this hook's business.
429
+ act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true })) })
430
+ expect(engine.plays).toBe(1)
431
+ expect(engine.pauses).toBe(1)
432
+
433
+ input.remove()
434
+ textarea.remove()
435
+ })
436
+
437
+ it('drops the keydown listener on unmount', () => {
438
+ const { view } = setup()
439
+ const engine = engines[0] as FakeEngine
440
+ act(() => { view.unmount() })
441
+ act(() => { document.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', bubbles: true })) })
442
+ expect(engine.plays).toBe(0)
443
+ })
444
+ })
445
+
446
+ // ── Audio lanes over timeline-core's audioWindow ─────────────────────────────
447
+
448
+ describe('useEnginePlayback audio lanes', () => {
449
+ const gains: Array<{ gain: { value: number } }> = []
450
+ // Lane elements are created programmatically (`new Audio()`, never mounted in
451
+ // the tree), so wrap the constructor to capture them for the rate assertions.
452
+ const audioEls: HTMLAudioElement[] = []
453
+ let RealAudio: typeof Audio
454
+
455
+ beforeEach(() => {
456
+ gains.length = 0
457
+ audioEls.length = 0
458
+ RealAudio = window.Audio
459
+ ;(window as unknown as { Audio: unknown }).Audio = class extends RealAudio {
460
+ constructor(src?: string) { super(src); audioEls.push(this as unknown as HTMLAudioElement) }
461
+ }
462
+ ;(window as unknown as { AudioContext: unknown }).AudioContext = class {
463
+ destination = {}
464
+ createMediaElementSource() { return { connect() {} } }
465
+ createGain() { const g = { gain: { value: 1 }, connect() {} }; gains.push(g); return g }
466
+ }
467
+ })
468
+
469
+ afterEach(() => {
470
+ ;(window as unknown as { Audio: unknown }).Audio = RealAudio
471
+ delete (window as unknown as { __montajSharedCtx?: unknown }).__montajSharedCtx
472
+ })
473
+
474
+ const withAudio = () =>
475
+ makeProject({
476
+ audio: {
477
+ tracks: [
478
+ { id: 'music', src: 'music.mp3', start: 2, end: 8, inPoint: 1, volume: 0.5, fadeIn: 2 } as never,
479
+ ],
480
+ },
481
+ } as Partial<Project>)
482
+
483
+ it('places, gains and starts a lane from the engine tick', () => {
484
+ const { view, engine } = setup(withAudio())
485
+ const el = document.querySelector('audio') ?? null
486
+ // The lane element is created programmatically (never mounted in the tree),
487
+ // so reach it through the effect's own behavior instead: the gain node it
488
+ // wired is the observable.
489
+ expect(gains).toHaveLength(1)
490
+ expect(el).toBeNull()
491
+
492
+ act(() => { engine.setTransport('playing') })
493
+ // t = 3 → 1s into the track, halfway through a 2s fade-in, at volume 0.5.
494
+ act(() => { engine.emit(3) })
495
+ expect(gains[0].gain.value).toBeCloseTo(0.25, 6)
496
+ expect(view.result.current.isPlaying).toBe(true)
497
+ })
498
+
499
+ it('setRate speeds every lane up, pitch-corrected, and drives the engine rate', () => {
500
+ const { view, engine } = setup(withAudio())
501
+ expect(audioEls).toHaveLength(1)
502
+ const el = audioEls[0]
503
+
504
+ act(() => { view.result.current.setRate(2) })
505
+ expect(engine.rates).toContain(2)
506
+ expect(el.playbackRate).toBe(2)
507
+ expect(el.preservesPitch).toBe(true)
508
+ })
509
+
510
+ it('a lane re-synced from the engine tick inherits the current transport rate', () => {
511
+ const { view, engine } = setup(withAudio())
512
+ const el = audioEls[0]
513
+ act(() => { view.result.current.setRate(4) })
514
+ act(() => { engine.setTransport('playing') })
515
+ // t = 3 is inside the track window, so the tick's syncAudioTracks re-applies
516
+ // the stored rate to the (active) lane.
517
+ act(() => { engine.emit(3) })
518
+ expect(el.playbackRate).toBe(4)
519
+ })
520
+
521
+ it('leaves the gain alone outside the track window', () => {
522
+ const { engine } = setup(withAudio())
523
+ act(() => { engine.emit(3) })
524
+ const inside = gains[0].gain.value
525
+ act(() => { engine.emit(9) })
526
+ // Outside the window the lane is paused and the envelope is not applied —
527
+ // exactly the legacy `continue`.
528
+ expect(gains[0].gain.value).toBe(inside)
529
+ })
530
+ })