@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,263 @@
1
+ /**
2
+ * Phase 2 of the preview-performance work: the two ways a clip used to get
3
+ * stuck on "Preparing preview…" forever, and the retain window that stops a
4
+ * scrub across a cut rebuilding what it just left.
5
+ *
6
+ * Both stuck states came from the same design gap. `SourceState` reports
7
+ * `loading` and `failed` into the SAME placeholder as a proxy that has not been
8
+ * encoded yet — deliberately, because from the user's side they are the same
9
+ * sentence — but that meant nothing distinguished "still coming" from "never
10
+ * coming":
11
+ *
12
+ * 1. A fetch that never settles left the session `loading` for the lifetime of
13
+ * the tab. `BUILD_TIMEOUT_MS` turns it into a `failed` session with a real
14
+ * reason.
15
+ * 2. A `failed` session was kept in the map precisely so `retain` would not
16
+ * rebuild it, which made every failure permanent — including the transient
17
+ * ones. `MAX_SESSION_RETRIES` with a doubling backoff rebuilds it a bounded
18
+ * number of times.
19
+ *
20
+ * `demux` is mocked because the point here is session bookkeeping, not
21
+ * container parsing, and because these tests need to hold a build open
22
+ * indefinitely — which no real fetch will do on request.
23
+ */
24
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
25
+ import type { EditorProject as Project, VisualItem } from '../../schema'
26
+
27
+ const demuxMock = vi.fn()
28
+ vi.mock('../demux', async (importOriginal) => {
29
+ const actual = await importOriginal<typeof import('../demux')>()
30
+ return { ...actual, demux: (...args: unknown[]) => demuxMock(...args) }
31
+ })
32
+
33
+ // The frame server and master clock both want WebCodecs/WebAudio, neither of
34
+ // which jsdom has. Stubbed to the smallest thing `build()` can finish against.
35
+ vi.mock('../frame-server', () => ({
36
+ createFrameServer: vi.fn(() => ({
37
+ src: 'stub',
38
+ video: { samples: [], presIndex: [], firstPresentationTsUs: 0 },
39
+ decodeAheadFrames: 30,
40
+ seek: () => ({ reqId: 0, frame: Promise.resolve(null) }),
41
+ startStream: () => 0,
42
+ stopStream: () => {},
43
+ nextFrameFor: () => ({ frame: null, dropped: 0 }),
44
+ stats: () => ({
45
+ buffered: 0,
46
+ inFlightFrames: 0,
47
+ inFlightBatches: 0,
48
+ received: 0,
49
+ dropped: 0,
50
+ atEndOfSource: false,
51
+ drained: false,
52
+ lastError: null,
53
+ }),
54
+ dispose: () => {},
55
+ })),
56
+ }))
57
+
58
+ function clip(id: string, start: number, end: number, extra: Partial<VisualItem> = {}): VisualItem {
59
+ return {
60
+ id,
61
+ type: 'video',
62
+ src: `/media/${id}.mov`,
63
+ proxySrc: `/proxies/${id}_proxy.mp4`,
64
+ start,
65
+ end,
66
+ inPoint: 0,
67
+ outPoint: end - start,
68
+ ...extra,
69
+ }
70
+ }
71
+
72
+ function project(items: VisualItem[]): Project {
73
+ return {
74
+ id: 'p1',
75
+ status: 'draft',
76
+ settings: { resolution: [1080, 1920], fps: 30 },
77
+ tracks: [{ id: 'trk-0', items }],
78
+ } as unknown as Project
79
+ }
80
+
81
+ /** A demuxed source shaped just enough for `build()` to reach `ready`. */
82
+ function fakeDemuxed(src: string) {
83
+ return {
84
+ src,
85
+ video: { kind: 'video', samples: [{ tsUs: 0 }], presIndex: [0], firstPresentationTsUs: 0 },
86
+ audio: null,
87
+ }
88
+ }
89
+
90
+ /** Drain the microtask queue so an in-flight `build()` settles. */
91
+ const settle = () => new Promise<void>((r) => setTimeout(r, 0))
92
+
93
+ let clock = 0
94
+ const nowMs = () => clock
95
+
96
+ beforeEach(() => {
97
+ clock = 0
98
+ demuxMock.mockReset()
99
+ vi.useRealTimers()
100
+ })
101
+
102
+ afterEach(() => {
103
+ vi.useRealTimers()
104
+ })
105
+
106
+ async function engineFor(items: VisualItem[]) {
107
+ const { createEngine } = await import('../index')
108
+ const errors: string[] = []
109
+ const engine = createEngine(project(items), {
110
+ fileUrl: (p) => p,
111
+ nowMs,
112
+ onError: (m) => errors.push(m),
113
+ requestFrame: () => 1,
114
+ cancelFrame: () => {},
115
+ })
116
+ return { engine, errors }
117
+ }
118
+
119
+ describe('build timeout', () => {
120
+ it('aborts a build that never settles and reports why', async () => {
121
+ vi.useFakeTimers()
122
+ const { BUILD_TIMEOUT_MS } = await import('../index')
123
+ // A fetch that hangs: the abort signal is the ONLY thing that can end it,
124
+ // which is why the timeout aborts rather than racing a timer against the
125
+ // promise (a race would resolve the build while the request stayed open).
126
+ demuxMock.mockImplementation(
127
+ (_src: string, _url: unknown, options: { signal?: AbortSignal }) =>
128
+ new Promise((_resolve, reject) => {
129
+ options.signal?.addEventListener('abort', () =>
130
+ reject(new DOMException('The operation was aborted.', 'AbortError')),
131
+ )
132
+ }),
133
+ )
134
+ const { engine } = await engineFor([clip('a', 0, 4)])
135
+
136
+ engine.seek(1)
137
+ await vi.advanceTimersByTimeAsync(1)
138
+ expect(engine.status()).toMatchObject({ picture: 'preparing' })
139
+
140
+ await vi.advanceTimersByTimeAsync(BUILD_TIMEOUT_MS + 10)
141
+
142
+ expect(engine.status().reason).toMatch(/timed out after 20000 ms/)
143
+ engine.dispose()
144
+ })
145
+
146
+ it('does not fire for a build that finishes normally', async () => {
147
+ vi.useFakeTimers()
148
+ const { BUILD_TIMEOUT_MS } = await import('../index')
149
+ demuxMock.mockImplementation(async (src: string) => fakeDemuxed(src))
150
+ const { engine, errors } = await engineFor([clip('a', 0, 4)])
151
+
152
+ engine.seek(1)
153
+ await vi.advanceTimersByTimeAsync(1)
154
+ // Past the deadline: a timer left armed would abort a source that is
155
+ // already in use.
156
+ await vi.advanceTimersByTimeAsync(BUILD_TIMEOUT_MS * 2)
157
+
158
+ expect(errors).toEqual([])
159
+ expect(engine.status().reason).toBeUndefined()
160
+ engine.dispose()
161
+ })
162
+ })
163
+
164
+ describe('failed-session retry', () => {
165
+ it('rebuilds a failed clip after the backoff, and succeeds when the cause clears', async () => {
166
+ demuxMock
167
+ .mockRejectedValueOnce(new Error('read: /proxies/a_proxy.mp4 → 503 Service Unavailable'))
168
+ .mockImplementation(async (src: string) => fakeDemuxed(src))
169
+ const { engine } = await engineFor([clip('a', 0, 4)])
170
+
171
+ engine.seek(1)
172
+ await settle()
173
+ expect(engine.status()).toMatchObject({ picture: 'preparing' })
174
+ expect(engine.status().reason).toMatch(/503 Service Unavailable/)
175
+ expect(demuxMock).toHaveBeenCalledTimes(1)
176
+
177
+ // Inside the backoff: a tick must NOT retry, or the three attempts would be
178
+ // spent inside a few frames of the rAF loop.
179
+ engine.seek(1.1)
180
+ await settle()
181
+ expect(demuxMock).toHaveBeenCalledTimes(1)
182
+
183
+ clock = 1000
184
+ engine.seek(1.2)
185
+ await settle()
186
+
187
+ expect(demuxMock).toHaveBeenCalledTimes(2)
188
+ expect(engine.status()).toMatchObject({ picture: 'video' })
189
+ engine.dispose()
190
+ })
191
+
192
+ it('gives up after MAX_SESSION_RETRIES rather than rebuilding forever', async () => {
193
+ const { MAX_SESSION_RETRIES } = await import('../index')
194
+ demuxMock.mockRejectedValue(new Error('corrupt proxy'))
195
+ const { engine } = await engineFor([clip('a', 0, 4)])
196
+
197
+ engine.seek(1)
198
+ await settle()
199
+
200
+ // Walk the clock far past every backoff and keep ticking.
201
+ for (let i = 0; i < MAX_SESSION_RETRIES + 5; i++) {
202
+ clock += 60_000
203
+ engine.seek(1 + i * 0.01)
204
+ await settle()
205
+ }
206
+
207
+ // One initial build plus exactly MAX_SESSION_RETRIES rebuilds.
208
+ expect(demuxMock).toHaveBeenCalledTimes(MAX_SESSION_RETRIES + 1)
209
+ expect(engine.status()).toMatchObject({ picture: 'preparing' })
210
+ expect(engine.status().reason).toMatch(/corrupt proxy/)
211
+ engine.dispose()
212
+ })
213
+
214
+ it('a src change resets the retry budget — a new proxy is a new problem', async () => {
215
+ // A proxy arriving over SSE replaces `proxySrc`; `retain`'s drop loop tears
216
+ // the session down for the src change, so the new one starts fresh rather
217
+ // than inheriting an exhausted budget from the file it replaced.
218
+ const { MAX_SESSION_RETRIES } = await import('../index')
219
+ demuxMock.mockRejectedValue(new Error('corrupt proxy'))
220
+ const { engine } = await engineFor([clip('a', 0, 4)])
221
+
222
+ engine.seek(1)
223
+ await settle()
224
+ for (let i = 0; i < MAX_SESSION_RETRIES + 2; i++) {
225
+ clock += 60_000
226
+ engine.seek(1 + i * 0.01)
227
+ await settle()
228
+ }
229
+ const exhausted = demuxMock.mock.calls.length
230
+
231
+ demuxMock.mockImplementation(async (src: string) => fakeDemuxed(src))
232
+ engine.updateProject(project([clip('a', 0, 4, { proxySrc: '/proxies/a_proxy_v2.mp4' })]))
233
+ engine.seek(1.5)
234
+ await settle()
235
+
236
+ expect(demuxMock.mock.calls.length).toBe(exhausted + 1)
237
+ expect(engine.status()).toMatchObject({ picture: 'video' })
238
+ engine.dispose()
239
+ })
240
+ })
241
+
242
+ describe('retain window', () => {
243
+ it('holds the previous clip so a scrub back over a cut does not rebuild it', async () => {
244
+ demuxMock.mockImplementation(async (src: string) => fakeDemuxed(src))
245
+ const { engine } = await engineFor([clip('a', 0, 2), clip('b', 2, 4), clip('c', 4, 6)])
246
+
247
+ engine.seek(1)
248
+ await settle()
249
+ engine.seek(3)
250
+ await settle()
251
+ const afterForward = demuxMock.mock.calls.length
252
+
253
+ // Back over the cut. 'a' was retained as the clip behind the playhead, so
254
+ // there is nothing to rebuild — no new demux, and the picture is live
255
+ // immediately rather than passing through `preparing`.
256
+ engine.seek(1)
257
+ await settle()
258
+
259
+ expect(demuxMock.mock.calls.length).toBe(afterForward)
260
+ expect(engine.status()).toMatchObject({ picture: 'video', clipId: 'a' })
261
+ engine.dispose()
262
+ })
263
+ })
@@ -0,0 +1,326 @@
1
+ /**
2
+ * SP4 T5 — the facade: the rAF loop, the canvas painter, and the surface T6
3
+ * builds on.
4
+ *
5
+ * The transport itself is covered by `scheduler.test.ts` over fakes; what is
6
+ * left here is the wiring only `index.ts` owns. A CANVAS project is the vehicle
7
+ * for the loop tests on purpose: it has no track-0 video, so nothing ever
8
+ * reaches `loadBytes`/`demux`/WebCodecs and the whole thing runs in jsdom with
9
+ * a real `EngineSourceHost` rather than a stand-in for one.
10
+ */
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import type { EditorProject as Project } from '../../schema'
13
+ import { createCanvasPainter, createEngine, fpsFromPaintTimes, pruneOlderThan } from '../index'
14
+ import type { DrawPlan } from '../scheduler'
15
+
16
+ /** `Array.prototype.at` is ES2022; this package targets ES2020. */
17
+ const last = <T>(items: readonly T[]): T | undefined => items[items.length - 1]
18
+
19
+ function canvasProject(overlayEnd = 4): Project {
20
+ return {
21
+ id: 'p1',
22
+ status: 'draft',
23
+ settings: { resolution: [1080, 1920], fps: 30 },
24
+ tracks: [
25
+ { id: 'trk-0', items: [{ id: 'img', type: 'image', src: '/bg.png', start: 0, end: overlayEnd }] },
26
+ { id: 'trk-1', items: [{ id: 'o', type: 'overlay', src: '/o.jsx', start: 0, end: overlayEnd }] },
27
+ ],
28
+ } as Project
29
+ }
30
+
31
+ /** A hand-pumped rAF, so no test depends on a real frame callback firing. */
32
+ function fakeRaf() {
33
+ const queue = new Map<number, () => void>()
34
+ let nextHandle = 1
35
+ const cancelFrame = vi.fn((handle: number) => {
36
+ queue.delete(handle)
37
+ })
38
+ return {
39
+ requestFrame: (cb: () => void) => {
40
+ const handle = nextHandle++
41
+ queue.set(handle, cb)
42
+ return handle
43
+ },
44
+ cancelFrame,
45
+ /** Run every callback queued so far (each may enqueue the next). */
46
+ flush(times = 1) {
47
+ for (let i = 0; i < times; i++) {
48
+ const pending = [...queue.values()]
49
+ queue.clear()
50
+ for (const cb of pending) cb()
51
+ }
52
+ },
53
+ get pending() {
54
+ return queue.size
55
+ },
56
+ }
57
+ }
58
+
59
+ describe('createEngine — transport over the rAF loop', () => {
60
+ it('runs the loop only while playing and stops at the project end', () => {
61
+ const raf = fakeRaf()
62
+ let now = 0
63
+ const times: number[] = []
64
+ const engine = createEngine(canvasProject(4), {
65
+ fileUrl: (p) => p,
66
+ onTime: (t) => times.push(t),
67
+ requestFrame: raf.requestFrame,
68
+ cancelFrame: raf.cancelFrame,
69
+ nowMs: () => now,
70
+ })
71
+
72
+ // Nothing runs until asked to.
73
+ expect(raf.pending).toBe(0)
74
+ engine.play()
75
+ expect(raf.pending).toBe(1)
76
+
77
+ now = 1000
78
+ raf.flush()
79
+ expect(last(times)).toBeCloseTo(1, 6)
80
+ expect(engine.status().transport).toBe('playing')
81
+ expect(raf.pending).toBe(1)
82
+
83
+ // Past the ceiling: clamped to it exactly, transport ends, loop stops.
84
+ now = 5000
85
+ raf.flush()
86
+ expect(last(times)).toBe(4)
87
+ expect(engine.status().transport).toBe('ended')
88
+ expect(raf.pending).toBe(0)
89
+
90
+ engine.dispose()
91
+ })
92
+
93
+ it('pause stops the loop; play restarts it', () => {
94
+ const raf = fakeRaf()
95
+ let now = 0
96
+ const engine = createEngine(canvasProject(10), {
97
+ fileUrl: (p) => p,
98
+ requestFrame: raf.requestFrame,
99
+ cancelFrame: raf.cancelFrame,
100
+ nowMs: () => now,
101
+ })
102
+ engine.play()
103
+ now = 500
104
+ raf.flush()
105
+ engine.pause()
106
+ expect(raf.cancelFrame).toHaveBeenCalled()
107
+ expect(engine.status().transport).toBe('paused')
108
+
109
+ // The playhead froze where it was, and does not drift while paused.
110
+ const frozen = engine.clock.now()
111
+ now = 4000
112
+ expect(engine.clock.now()).toBe(frozen)
113
+
114
+ engine.play()
115
+ expect(raf.pending).toBe(1)
116
+ engine.dispose()
117
+ })
118
+
119
+ it('a canvas project runs on the wall clock', () => {
120
+ const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
121
+ engine.seek(1)
122
+ expect(engine.clock.kind).toBe('fallback')
123
+ expect(engine.clock.now()).toBe(1)
124
+ expect(engine.status()).toMatchObject({ picture: 'black', clipId: null })
125
+ engine.dispose()
126
+ })
127
+
128
+ it('seek past the end stops the loop', () => {
129
+ const raf = fakeRaf()
130
+ const engine = createEngine(canvasProject(4), {
131
+ fileUrl: (p) => p,
132
+ requestFrame: raf.requestFrame,
133
+ cancelFrame: raf.cancelFrame,
134
+ nowMs: () => 0,
135
+ })
136
+ engine.play()
137
+ engine.seek(9)
138
+ expect(engine.status().transport).toBe('ended')
139
+ expect(raf.cancelFrame).toHaveBeenCalled()
140
+ engine.dispose()
141
+ })
142
+
143
+ it('dispose is idempotent and stops everything', () => {
144
+ const raf = fakeRaf()
145
+ const engine = createEngine(canvasProject(4), {
146
+ fileUrl: (p) => p,
147
+ requestFrame: raf.requestFrame,
148
+ cancelFrame: raf.cancelFrame,
149
+ nowMs: () => 0,
150
+ })
151
+ engine.play()
152
+ engine.dispose()
153
+ engine.dispose()
154
+ expect(raf.pending).toBe(0)
155
+ })
156
+ })
157
+
158
+ describe('createEngine — canvas attachment', () => {
159
+ it('sizes the backing store to the project design canvas', () => {
160
+ const canvas = document.createElement('canvas')
161
+ vi.spyOn(canvas, 'getContext').mockReturnValue(null)
162
+ const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
163
+ engine.attach(canvas)
164
+ expect([canvas.width, canvas.height]).toEqual([1080, 1920])
165
+ engine.dispose()
166
+ })
167
+
168
+ it('re-sizes when the project resolution changes', () => {
169
+ const canvas = document.createElement('canvas')
170
+ vi.spyOn(canvas, 'getContext').mockReturnValue(null)
171
+ const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
172
+ engine.attach(canvas)
173
+ const landscape = { ...canvasProject(4), settings: { resolution: [1920, 1080] as [number, number] } }
174
+ engine.updateProject(landscape as Project)
175
+ expect([canvas.width, canvas.height]).toEqual([1920, 1080])
176
+ engine.dispose()
177
+ })
178
+
179
+ it('unbinds cleanly on attach(null)', () => {
180
+ const canvas = document.createElement('canvas')
181
+ vi.spyOn(canvas, 'getContext').mockReturnValue(null)
182
+ const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
183
+ engine.attach(canvas)
184
+ expect(() => engine.attach(null)).not.toThrow()
185
+ engine.dispose()
186
+ })
187
+ })
188
+
189
+ describe('createEngine — stats()', () => {
190
+ it('reports zeroed defaults with no active clip and no paints', () => {
191
+ const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
192
+ // A canvas project's picture is always 'black' (no track-0 video), so
193
+ // there is never a clip clock to derive from — `stats().clock` matches
194
+ // `engine.clock.kind`.
195
+ expect(engine.stats()).toEqual({ fps: 0, dropped: 0, buffered: 0, clock: 'fallback' })
196
+ expect(engine.clock.kind).toBe('fallback')
197
+ engine.dispose()
198
+ })
199
+
200
+ it('attach(null) leaves stats() safe to call — no dangling painter reference', () => {
201
+ const canvas = document.createElement('canvas')
202
+ vi.spyOn(canvas, 'getContext').mockReturnValue(null)
203
+ const engine = createEngine(canvasProject(4), { fileUrl: (p) => p, nowMs: () => 0 })
204
+ engine.attach(canvas)
205
+ engine.attach(null)
206
+ expect(() => engine.stats()).not.toThrow()
207
+ engine.dispose()
208
+ })
209
+ })
210
+
211
+ // `stats().fps`'s rolling-window math is exercised directly below
212
+ // (`pruneOlderThan`/`fpsFromPaintTimes`) rather than through a canvas and an
213
+ // attached painter: a canvas PROJECT (this file's fixture) never reaches
214
+ // `picture === 'video'`, so nothing here ever calls the wrapped painter's
215
+ // `paint` — that would need a real track-0 clip behind a live decode session,
216
+ // which is `scheduler.test.ts`/`frame-server.test.ts` territory, not this
217
+ // file's.
218
+
219
+ describe('pruneOlderThan', () => {
220
+ it('drops everything at or before the cutoff, keeps the rest, in place', () => {
221
+ const times = [0, 500, 1000, 1500, 1900]
222
+ pruneOlderThan(times, 2000, 1000) // cutoff = 1000: drop < 1000
223
+ expect(times).toEqual([1000, 1500, 1900])
224
+ })
225
+
226
+ it('is a no-op when nothing is old enough to drop', () => {
227
+ const times = [1900, 1950]
228
+ pruneOlderThan(times, 2000, 1000)
229
+ expect(times).toEqual([1900, 1950])
230
+ })
231
+
232
+ it('empties the array when everything is older than the window', () => {
233
+ const times = [0, 100, 200]
234
+ pruneOlderThan(times, 5000, 1000)
235
+ expect(times).toEqual([])
236
+ })
237
+ })
238
+
239
+ describe('fpsFromPaintTimes', () => {
240
+ it('is 0 for an empty list', () => {
241
+ expect(fpsFromPaintTimes([], 1000, 2000)).toBe(0)
242
+ })
243
+
244
+ it('reads 30fps from paints spaced exactly 1000/30 ms apart', () => {
245
+ // 30 paints over one second (0, 33.3, 66.6, … 966.7), read at t=1000 with
246
+ // a 2000ms window — the window covers the whole run, so this is exactly
247
+ // "how many paints in the last second", i.e. fps.
248
+ const times: number[] = []
249
+ for (let i = 0; i < 30; i++) times.push((i * 1000) / 30)
250
+ expect(fpsFromPaintTimes(times, 1000, 2000)).toBeCloseTo(30, 0)
251
+ })
252
+
253
+ it('clamps the span to the window even when the buffer spans longer', () => {
254
+ // First paint 5s ago, but the window is only 2s — span must clamp to
255
+ // 2000ms, not the full 5000ms, or a long-lived low-rate buffer would
256
+ // under-report fps.
257
+ const times = [0, 4000, 4500, 5000]
258
+ expect(fpsFromPaintTimes(times, 5000, 2000)).toBeCloseTo((4 / 2000) * 1000, 6)
259
+ })
260
+ })
261
+
262
+ describe('createCanvasPainter', () => {
263
+ interface Recorded {
264
+ fills: Array<[number, number, number, number]>
265
+ draws: unknown[][]
266
+ }
267
+
268
+ function stubCanvas(width = 1080, height = 1920): { canvas: HTMLCanvasElement; rec: Recorded } {
269
+ const canvas = document.createElement('canvas')
270
+ canvas.width = width
271
+ canvas.height = height
272
+ const rec: Recorded = { fills: [], draws: [] }
273
+ const ctx = {
274
+ fillStyle: '',
275
+ fillRect: (...args: [number, number, number, number]) => rec.fills.push(args),
276
+ drawImage: (...args: unknown[]) => rec.draws.push(args),
277
+ }
278
+ vi.spyOn(canvas, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
279
+ return { canvas, rec }
280
+ }
281
+
282
+ const plan: DrawPlan = { sx: 10, sy: 20, sw: 30, sh: 40, dx: 1, dy: 2, dw: 3, dh: 4 }
283
+ const frame = { close() {} } as unknown as VideoFrame
284
+
285
+ it('fills black before every paint so letterbox bars never keep a stale edge', () => {
286
+ const { canvas, rec } = stubCanvas()
287
+ const painter = createCanvasPainter(canvas)
288
+ painter.paint(frame, plan)
289
+ expect(rec.fills).toEqual([[0, 0, 1080, 1920]])
290
+ expect(rec.draws).toEqual([[frame, 10, 20, 30, 40, 1, 2, 3, 4]])
291
+ })
292
+
293
+ it('clears to black without drawing', () => {
294
+ const { canvas, rec } = stubCanvas()
295
+ createCanvasPainter(canvas).clear()
296
+ expect(rec.fills).toEqual([[0, 0, 1080, 1920]])
297
+ expect(rec.draws).toEqual([])
298
+ })
299
+
300
+ it('skips a degenerate rect rather than throwing at drawImage', () => {
301
+ const { canvas, rec } = stubCanvas()
302
+ createCanvasPainter(canvas).paint(frame, { ...plan, sw: 0 })
303
+ expect(rec.draws).toEqual([])
304
+ })
305
+
306
+ it('re-reads the backing-store size on every paint, so a host resize is picked up', () => {
307
+ const { canvas, rec } = stubCanvas()
308
+ const painter = createCanvasPainter(canvas)
309
+ expect(painter.size()).toEqual({ width: 1080, height: 1920 })
310
+ canvas.width = 540
311
+ canvas.height = 960
312
+ expect(painter.size()).toEqual({ width: 540, height: 960 })
313
+ painter.clear()
314
+ expect(last(rec.fills)).toEqual([0, 0, 540, 960])
315
+ })
316
+
317
+ it('is inert when the canvas gives no 2D context', () => {
318
+ const canvas = document.createElement('canvas')
319
+ vi.spyOn(canvas, 'getContext').mockReturnValue(null)
320
+ const painter = createCanvasPainter(canvas)
321
+ expect(() => {
322
+ painter.clear()
323
+ painter.paint(frame, plan)
324
+ }).not.toThrow()
325
+ })
326
+ })