@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,1310 @@
1
+ /**
2
+ * SP4 T5 — every transport transition, over fakes.
3
+ *
4
+ * The scheduler is a synchronous state machine over three injected surfaces
5
+ * (`SourceHost`, `Painter`, `MasterClock`), so all of it is reachable in jsdom
6
+ * with no WebCodecs, no `Worker` and no canvas. What these tests CAN prove is
7
+ * every transition the plan names — play into a gap, scrub into a gap, scrub
8
+ * past the end, loop wrap, boundary swap, opaque toggle mid-play, decode
9
+ * failure mid-clip — plus the `VideoFrame` ownership contract on each path.
10
+ * What they cannot prove (that a real decoder returns real frames) is what T9's
11
+ * operator parity checklist is for; there are no pretend-integration tests
12
+ * against a mock of the whole browser here.
13
+ *
14
+ * The fakes are deliberately dumb: the clock is a settable number, the frame
15
+ * server records calls, the host is a map. Every test drives time explicitly
16
+ * (`host.setTime(t); scheduler.tick()`) so nothing depends on rAF or timers.
17
+ */
18
+ import { describe, expect, it, vi } from 'vitest'
19
+ import type { EditorProject as Project, VisualItem } from '../../schema'
20
+ import type { MasterClock } from '../audio-clock'
21
+ import type { ChunkSource } from '../demux'
22
+ import type { FrameServer, FrameServerStats } from '../frame-server'
23
+ import {
24
+ createScheduler,
25
+ drawPlanFor,
26
+ endsOnLoopBoundary,
27
+ engineSrcFor,
28
+ placeInSource,
29
+ planTick,
30
+ track0VideoItems,
31
+ transportEndFor,
32
+ type ClipSource,
33
+ type DrawPlan,
34
+ type EngineStatus,
35
+ type Painter,
36
+ type Scheduler,
37
+ type SourceHost,
38
+ type SourceRequest,
39
+ type SourceState,
40
+ } from '../scheduler'
41
+
42
+ /** `Array.prototype.at` is ES2022; this package targets ES2020. */
43
+ const last = <T>(items: readonly T[]): T | undefined => items[items.length - 1]
44
+
45
+ // ── Fakes ───────────────────────────────────────────────────────────────────
46
+
47
+ class FakeClock implements MasterClock {
48
+ playing = false
49
+ disposed = false
50
+ t: number
51
+ readonly seeks: number[] = []
52
+ /** Second arg of every `seek` call, aligned index-for-index with `seeks` — `undefined` when omitted. */
53
+ readonly seekMediaS: Array<number | undefined> = []
54
+ /** Every `setTransportRate` call, in order — the propagation test reads this. */
55
+ readonly rates: number[] = []
56
+ constructor(
57
+ readonly kind: 'audio' | 'fallback',
58
+ start: number,
59
+ ) {
60
+ this.t = start
61
+ }
62
+ now() {
63
+ return this.t
64
+ }
65
+ play() {
66
+ this.playing = true
67
+ }
68
+ pause() {
69
+ this.playing = false
70
+ }
71
+ seek(projectS: number, mediaS?: number) {
72
+ this.seeks.push(projectS)
73
+ this.seekMediaS.push(mediaS)
74
+ this.t = projectS
75
+ }
76
+ setVolume() {}
77
+ setTransportRate(rate: number) {
78
+ this.rates.push(rate)
79
+ }
80
+ stats() {
81
+ return {
82
+ kind: this.kind,
83
+ playing: this.playing,
84
+ samplesConsumed: 0,
85
+ underrunFrames: 0,
86
+ queuedFrames: 0,
87
+ queuedSeconds: 0,
88
+ }
89
+ }
90
+ dispose() {
91
+ this.disposed = true
92
+ this.playing = false
93
+ }
94
+ }
95
+
96
+ interface FakeFrame {
97
+ timestamp: number
98
+ displayWidth: number
99
+ displayHeight: number
100
+ closed: boolean
101
+ close(): void
102
+ }
103
+
104
+ function fakeFrame(timestamp: number, w = 1280, h = 720): FakeFrame {
105
+ return {
106
+ timestamp,
107
+ displayWidth: w,
108
+ displayHeight: h,
109
+ closed: false,
110
+ close() {
111
+ this.closed = true
112
+ },
113
+ }
114
+ }
115
+
116
+ const asFrame = (f: FakeFrame) => f as unknown as VideoFrame
117
+
118
+ function chunkSource(firstPresentationTsUs = 0): ChunkSource {
119
+ return {
120
+ kind: 'video',
121
+ codec: 'av01.0.05M.08',
122
+ fps: 30,
123
+ durationS: 10,
124
+ coded: { width: 1280, height: 720 },
125
+ samples: [],
126
+ presIndex: [],
127
+ firstPresentationTsUs,
128
+ }
129
+ }
130
+
131
+ class FakeFrameServer implements FrameServer {
132
+ decodeAheadFrames = 8
133
+ readonly video: ChunkSource
134
+ readonly starts: number[] = []
135
+ readonly seekTargets: number[] = []
136
+ readonly pulls: number[] = []
137
+ stops = 0
138
+ disposed = false
139
+ /** What `nextFrameFor` hands back, if anything. */
140
+ supply: ((clockUs: number) => FakeFrame | null) | null = null
141
+ /** Resolvers for outstanding `seek()` promises, newest last. */
142
+ readonly pendingSeeks: Array<(f: VideoFrame | null) => void> = []
143
+
144
+ constructor(
145
+ readonly src: string,
146
+ firstPresentationTsUs = 0,
147
+ ) {
148
+ this.video = chunkSource(firstPresentationTsUs)
149
+ }
150
+
151
+ seek(targetTsUs: number) {
152
+ this.seekTargets.push(targetTsUs)
153
+ let resolve: (f: VideoFrame | null) => void = () => {}
154
+ const frame = new Promise<VideoFrame | null>((r) => {
155
+ resolve = r
156
+ })
157
+ this.pendingSeeks.push(resolve)
158
+ return { reqId: this.seekTargets.length, frame }
159
+ }
160
+ startStream(targetTsUs: number) {
161
+ this.starts.push(targetTsUs)
162
+ return this.starts.length
163
+ }
164
+ stopStream() {
165
+ this.stops++
166
+ }
167
+ nextFrameFor(clockUs: number) {
168
+ this.pulls.push(clockUs)
169
+ const f = this.supply?.(clockUs) ?? null
170
+ return { frame: f ? asFrame(f) : null, dropped: 0 }
171
+ }
172
+ stats(): FrameServerStats {
173
+ return {
174
+ buffered: 0,
175
+ inFlightFrames: 0,
176
+ inFlightBatches: 0,
177
+ received: 0,
178
+ dropped: 0,
179
+ atEndOfSource: false,
180
+ drained: false,
181
+ lastError: null,
182
+ }
183
+ }
184
+ dispose() {
185
+ this.disposed = true
186
+ }
187
+ }
188
+
189
+ interface FakeSession {
190
+ src: string
191
+ item: VisualItem
192
+ server: FakeFrameServer
193
+ clock: FakeClock
194
+ ready: boolean
195
+ failed?: string
196
+ /**
197
+ * Cached, exactly as the real host caches it on its `Session`. Handing back a
198
+ * fresh object each call would read as "the session was rebuilt" and make the
199
+ * scheduler respawn the decoder on every tick.
200
+ */
201
+ source?: ClipSource
202
+ }
203
+
204
+ class FakeHost implements SourceHost {
205
+ readonly sessions = new Map<string, FakeSession>()
206
+ readonly retainLog: SourceRequest[][] = []
207
+ readonly droppedClips: string[] = []
208
+ readonly fallbacks: FakeClock[] = []
209
+ /** When false, a retained clip stays `loading` until `ready()` is called. */
210
+ autoReady = true
211
+ scheduler: Scheduler | null = null
212
+ private time = 0
213
+ private readonly clocks: FakeClock[] = []
214
+
215
+ retain(requests: readonly SourceRequest[]): void {
216
+ this.retainLog.push([...requests])
217
+ for (const [clipId, session] of [...this.sessions]) {
218
+ if (requests.some((r) => r.clipId === clipId && r.src === session.src)) continue
219
+ this.sessions.delete(clipId)
220
+ this.droppedClips.push(clipId)
221
+ session.server.dispose()
222
+ session.clock.dispose()
223
+ }
224
+ for (const request of requests) {
225
+ if (this.sessions.has(request.clipId)) continue
226
+ const clock = new FakeClock('audio', request.anchorProjectS)
227
+ clock.t = this.time
228
+ this.clocks.push(clock)
229
+ this.sessions.set(request.clipId, {
230
+ src: request.src,
231
+ item: request.item,
232
+ server: new FakeFrameServer(request.src),
233
+ clock,
234
+ ready: this.autoReady,
235
+ })
236
+ }
237
+ }
238
+
239
+ state(clipId: string): SourceState {
240
+ const session = this.sessions.get(clipId)
241
+ if (!session) return { status: 'idle' }
242
+ if (session.failed) return { status: 'failed', reason: session.failed }
243
+ if (!session.ready) return { status: 'loading' }
244
+ session.source ??= {
245
+ clipId,
246
+ src: session.src,
247
+ frameServer: session.server,
248
+ clock: session.clock,
249
+ timebase: {
250
+ start: session.item.start,
251
+ inPoint: session.item.inPoint ?? 0,
252
+ // The AUDIO origin. The scheduler must read the VIDEO one off the frame
253
+ // server; a non-zero value here proves it does not use this.
254
+ firstPresentationTsUs: 999_000,
255
+ },
256
+ }
257
+ return { status: 'ready', source: session.source }
258
+ }
259
+
260
+ fallbackClock(startProjectS: number): MasterClock {
261
+ const clock = new FakeClock('fallback', startProjectS)
262
+ clock.t = this.time
263
+ this.clocks.push(clock)
264
+ this.fallbacks.push(clock)
265
+ return clock
266
+ }
267
+
268
+ // ── test controls ───────────────────────────────────────────────────────
269
+
270
+ /** Move every clock the scheduler could be holding to `t`. */
271
+ setTime(t: number): void {
272
+ this.time = t
273
+ for (const clock of this.clocks) clock.t = t
274
+ }
275
+ ready(clipId: string): void {
276
+ const session = this.sessions.get(clipId)
277
+ if (!session) throw new Error(`no session for ${clipId}`)
278
+ session.ready = true
279
+ session.failed = undefined
280
+ this.scheduler?.sourceChanged(clipId)
281
+ }
282
+ fail(clipId: string, reason: string): void {
283
+ const session = this.sessions.get(clipId)
284
+ if (!session) throw new Error(`no session for ${clipId}`)
285
+ session.failed = reason
286
+ session.source = undefined
287
+ session.clock.dispose()
288
+ this.scheduler?.sourceChanged(clipId)
289
+ }
290
+ server(clipId: string): FakeFrameServer {
291
+ const session = this.sessions.get(clipId)
292
+ if (!session) throw new Error(`no session for ${clipId}`)
293
+ return session.server
294
+ }
295
+ lastRetain(): SourceRequest[] {
296
+ return this.retainLog[this.retainLog.length - 1] ?? []
297
+ }
298
+ }
299
+
300
+ class FakePainter implements Painter {
301
+ readonly paints: Array<{ frame: FakeFrame; plan: DrawPlan }> = []
302
+ clears = 0
303
+ constructor(
304
+ private readonly width = 1080,
305
+ private readonly height = 1920,
306
+ ) {}
307
+ size() {
308
+ return { width: this.width, height: this.height }
309
+ }
310
+ paint(frame: VideoFrame, plan: DrawPlan) {
311
+ this.paints.push({ frame: frame as unknown as FakeFrame, plan })
312
+ }
313
+ clear() {
314
+ this.clears++
315
+ }
316
+ }
317
+
318
+ // ── Project builders ────────────────────────────────────────────────────────
319
+
320
+ function clip(id: string, start: number, end: number, extra: Partial<VisualItem> = {}): VisualItem {
321
+ return {
322
+ id,
323
+ type: 'video',
324
+ src: `/media/${id}.mov`,
325
+ proxySrc: `/proxies/${id}_proxy.mp4`,
326
+ start,
327
+ end,
328
+ inPoint: 0,
329
+ outPoint: end - start,
330
+ ...extra,
331
+ }
332
+ }
333
+
334
+ function overlay(id: string, start: number, end: number, opaque = false): VisualItem {
335
+ return { id, type: 'overlay', src: `/overlays/${id}.jsx`, start, end, opaque }
336
+ }
337
+
338
+ function project(
339
+ track0: VisualItem[],
340
+ overlays: VisualItem[] = [],
341
+ extra: Partial<Project> = {},
342
+ ): Project {
343
+ return {
344
+ id: 'p1',
345
+ status: 'draft',
346
+ settings: { resolution: [1080, 1920], fps: 30 },
347
+ tracks: overlays.length > 0 ? [track0, overlays] : [track0],
348
+ ...extra,
349
+ } as Project
350
+ }
351
+
352
+ interface Harness {
353
+ scheduler: Scheduler
354
+ host: FakeHost
355
+ painter: FakePainter
356
+ times: number[]
357
+ statuses: EngineStatus[]
358
+ }
359
+
360
+ function harness(p: Project, opts: { autoReady?: boolean; painter?: FakePainter } = {}): Harness {
361
+ const host = new FakeHost()
362
+ host.autoReady = opts.autoReady ?? true
363
+ const painter = opts.painter ?? new FakePainter()
364
+ const times: number[] = []
365
+ const statuses: EngineStatus[] = []
366
+ const scheduler = createScheduler({
367
+ project: p,
368
+ host,
369
+ painter,
370
+ onTime: (t) => times.push(t),
371
+ onStatusChange: (s) => statuses.push(s),
372
+ })
373
+ host.scheduler = scheduler
374
+ return { scheduler, host, painter, times, statuses }
375
+ }
376
+
377
+ /** Move the clocks and run one tick. */
378
+ function step(h: Harness, t: number): void {
379
+ h.host.setTime(t)
380
+ h.scheduler.tick()
381
+ }
382
+
383
+ // ── Pure helpers ────────────────────────────────────────────────────────────
384
+
385
+ describe('track0VideoItems', () => {
386
+ it('keeps only track-0 video items, sorted by start (the legacy `clips` memo)', () => {
387
+ const p = project(
388
+ [
389
+ { id: 'img', type: 'image', src: '/a.png', start: 0, end: 1 },
390
+ clip('b', 5, 7),
391
+ clip('a', 0, 2),
392
+ ],
393
+ [overlay('o', 0, 9)],
394
+ )
395
+ expect(track0VideoItems(p).map((c) => c.id)).toEqual(['a', 'b'])
396
+ })
397
+ })
398
+
399
+ describe('transportEndFor', () => {
400
+ it('uses projectEnd for a video project — audio tail included', () => {
401
+ const p = project([clip('a', 0, 4)], [overlay('o', 0, 5)], {
402
+ audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 8 }] },
403
+ })
404
+ expect(transportEndFor(p)).toBe(8)
405
+ })
406
+
407
+ it('uses max(overlayEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
408
+ // The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
409
+ // leave audio out. Faithful, not unified.
410
+ const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
411
+ overlay('o', 0, 4),
412
+ ], {
413
+ audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 30 }] },
414
+ captions: { style: 'pop', segments: [{ text: 'hi', start: 0, end: 6 }] },
415
+ })
416
+ expect(transportEndFor(p)).toBe(6)
417
+ })
418
+ })
419
+
420
+ describe('engineSrcFor — proxy-only playback is structural', () => {
421
+ it('accepts the proxy when the resolver chose it', () => {
422
+ const c = clip('a', 0, 2)
423
+ expect(engineSrcFor(c, { src: c.proxySrc })).toEqual({ src: c.proxySrc })
424
+ })
425
+
426
+ it('blocks a clip with no proxy yet — never opens the master', () => {
427
+ const c = clip('a', 0, 2, { proxySrc: undefined })
428
+ const result = engineSrcFor(c, { src: c.src })
429
+ expect(result.src).toBe('')
430
+ expect(result.blocked).toMatch(/proxy/i)
431
+ })
432
+
433
+ it('blocks a clip whose nobg_preview_src outranks the proxy', () => {
434
+ const c = clip('a', 0, 2, { nobg_preview_src: '/nobg/a.webm' })
435
+ const result = engineSrcFor(c, { src: '/nobg/a.webm' })
436
+ expect(result.src).toBe('')
437
+ expect(result.blocked).toMatch(/decode/i)
438
+ })
439
+ })
440
+
441
+ describe('placeInSource — the loopOffset reimplementation', () => {
442
+ const window = { inPoint: 0, outPoint: 2 }
443
+
444
+ it('maps project time linearly for a non-looping clip', () => {
445
+ const c = clip('a', 10, 14)
446
+ expect(placeInSource(c, { inPoint: 1.5, outPoint: 5.5 }, 12)).toEqual({
447
+ mediaS: 3.5,
448
+ wraps: 0,
449
+ loopOffset: 0,
450
+ looping: false,
451
+ })
452
+ })
453
+
454
+ it('wraps a looping clip and derives loopOffset from t', () => {
455
+ const c = clip('a', 0, 10, { loop: true })
456
+ expect(placeInSource(c, window, 0.5)).toMatchObject({ mediaS: 0.5, wraps: 0, loopOffset: 0 })
457
+ expect(placeInSource(c, window, 2.5)).toMatchObject({ mediaS: 0.5, wraps: 1, loopOffset: 2 })
458
+ expect(placeInSource(c, window, 4.5)).toMatchObject({ mediaS: 0.5, wraps: 2, loopOffset: 4 })
459
+ })
460
+
461
+ it('scrubbing into a looped region lands where a wrap would have', () => {
462
+ // The property the legacy hook's two writers of `loopOffsetRef` were trying
463
+ // to keep by hand; here it holds by construction.
464
+ const c = clip('a', 0, 10, { loop: true })
465
+ expect(placeInSource(c, window, 6.75)).toEqual(placeInSource(c, window, 6.75))
466
+ expect(placeInSource(c, window, 6.75).mediaS).toBeCloseTo(0.75, 9)
467
+ })
468
+
469
+ it('falls back to the non-loop branch when there is no outPoint', () => {
470
+ const c = clip('a', 0, 10, { loop: true, outPoint: undefined })
471
+ expect(placeInSource(c, { inPoint: 0, outPoint: undefined }, 5)).toMatchObject({
472
+ mediaS: 5,
473
+ looping: false,
474
+ })
475
+ })
476
+
477
+ it('falls back to the non-loop branch on a degenerate window', () => {
478
+ const c = clip('a', 0, 10, { loop: true })
479
+ expect(placeInSource(c, { inPoint: 3, outPoint: 3 }, 5)).toMatchObject({
480
+ mediaS: 8,
481
+ looping: false,
482
+ })
483
+ })
484
+
485
+ it('clamps to the in-point before the clip starts', () => {
486
+ const c = clip('a', 5, 9)
487
+ expect(placeInSource(c, { inPoint: 2, outPoint: 6 }, 1).mediaS).toBe(2)
488
+ })
489
+
490
+ it('scales the source traversal by per-clip speed (non-looping)', () => {
491
+ // speed 2: 2.5s into the clip consumes 5s of source; speed 0.5 consumes 1.25s.
492
+ const fast = clip('a', 10, 14, { speed: 2 })
493
+ expect(placeInSource(fast, { inPoint: 1, outPoint: 9 }, 12.5)).toMatchObject({
494
+ mediaS: 1 + 5,
495
+ wraps: 0,
496
+ looping: false,
497
+ })
498
+ const slow = clip('a', 10, 14, { speed: 0.5 })
499
+ expect(placeInSource(slow, { inPoint: 1, outPoint: 9 }, 12.5).mediaS).toBeCloseTo(1 + 1.25, 9)
500
+ })
501
+
502
+ it('speed 1 (and absent speed) is unchanged', () => {
503
+ const plain = clip('a', 10, 14)
504
+ const one = clip('a', 10, 14, { speed: 1 })
505
+ for (const t of [10, 11.3, 13.9]) {
506
+ expect(placeInSource(one, { inPoint: 1.5, outPoint: 5.5 }, t)).toEqual(
507
+ placeInSource(plain, { inPoint: 1.5, outPoint: 5.5 }, t),
508
+ )
509
+ }
510
+ })
511
+
512
+ it('wraps a sped-up loop in source terms (loopDur unaffected, traversed faster)', () => {
513
+ // window is a 2s source loop; at speed 2 a loop iteration is exhausted in 1s
514
+ // of project time. loopDur stays 2 (source); wraps/loopOffset stay in source.
515
+ const c = clip('a', 0, 10, { loop: true, speed: 2 })
516
+ const window = { inPoint: 0, outPoint: 2 }
517
+ // 0.25s project → 0.5s source → mediaS 0.5, no wrap yet.
518
+ expect(placeInSource(c, window, 0.25)).toMatchObject({ mediaS: 0.5, wraps: 0, loopOffset: 0 })
519
+ // 1.25s project → 2.5s source → one full loop (2s) + 0.5s → mediaS 0.5, wraps 1.
520
+ expect(placeInSource(c, window, 1.25)).toMatchObject({ mediaS: 0.5, wraps: 1, loopOffset: 2 })
521
+ })
522
+ })
523
+
524
+ describe('endsOnLoopBoundary', () => {
525
+ it('is true when the project span is a whole number of loops', () => {
526
+ expect(endsOnLoopBoundary(clip('a', 0, 6, { loop: true }), { inPoint: 0, outPoint: 2 })).toBe(
527
+ true,
528
+ )
529
+ })
530
+ it('is false mid-loop — the legacy hard-stop case', () => {
531
+ expect(endsOnLoopBoundary(clip('a', 0, 5, { loop: true }), { inPoint: 0, outPoint: 2 })).toBe(
532
+ false,
533
+ )
534
+ })
535
+ it('is false for a non-looping clip', () => {
536
+ expect(endsOnLoopBoundary(clip('a', 0, 6), { inPoint: 0, outPoint: 2 })).toBe(false)
537
+ })
538
+ })
539
+
540
+ describe('planTick', () => {
541
+ it('resolves the active track-0 clip, the opaque flag and the next clip', () => {
542
+ const p = project([clip('a', 0, 2), clip('b', 3, 5)], [overlay('o', 1, 4, true)])
543
+ const clips = track0VideoItems(p)
544
+ const plan = planTick(p, 1.5, clips)
545
+ expect(plan.active?.clipId).toBe('a')
546
+ expect(plan.opaque).toBe(true)
547
+ expect(plan.next).toMatchObject({ clipId: 'b', start: 3 })
548
+ expect(plan.canvas).toBe(false)
549
+ })
550
+
551
+ it('returns no active clip inside a gap (resolveAt has no last-clip fallback)', () => {
552
+ const p = project([clip('a', 0, 2), clip('b', 3, 5)])
553
+ const plan = planTick(p, 2.5, track0VideoItems(p))
554
+ expect(plan.active).toBeNull()
555
+ expect(plan.next?.clipId).toBe('b')
556
+ })
557
+
558
+ it('picks the earliest-start clip when two track-0 videos overlap', () => {
559
+ // Document order puts 'late' first; the legacy hook start-sorts.
560
+ const p = project([clip('late', 1, 4), clip('early', 0, 4)])
561
+ expect(planTick(p, 2, track0VideoItems(p)).active?.clipId).toBe('early')
562
+ })
563
+
564
+ it('flags a canvas project', () => {
565
+ const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 4 }])
566
+ const plan = planTick(p, 1, track0VideoItems(p))
567
+ expect(plan.canvas).toBe(true)
568
+ expect(plan.active).toBeNull()
569
+ })
570
+ })
571
+
572
+ describe('track audio folds into the source requests', () => {
573
+ // `project()` above builds the legacy array-of-arrays shape, which has
574
+ // nowhere to put track settings — these cases need the object shape.
575
+ function tracked(items: VisualItem[], track: { volume?: number; muted?: boolean } = {}): Project {
576
+ return {
577
+ id: 'p1',
578
+ status: 'draft',
579
+ settings: { resolution: [1080, 1920], fps: 30 },
580
+ tracks: [{ id: 'trk-0', items, ...track }],
581
+ } as Project
582
+ }
583
+
584
+ /** The retained request for `clipId` on the last tick. */
585
+ function requested(h: Harness, clipId: string): SourceRequest {
586
+ const found = h.host.lastRetain().find((r) => r.clipId === clipId)
587
+ if (!found) throw new Error(`no request for ${clipId}`)
588
+ return found
589
+ }
590
+
591
+ it('multiplies the track volume into the clip volume, keeping the mix between clips', () => {
592
+ // a and b are a 2:1 mix apart. Pulling the track down must move both by the
593
+ // same factor — replacing rather than multiplying would flatten them onto
594
+ // one level and throw away the per-clip work.
595
+ const h = harness(tracked([clip('a', 0, 2, { volume: 0.8 }), clip('b', 2, 4, { volume: 0.4 })], { volume: 0.5 }))
596
+ h.scheduler.play()
597
+ step(h, 1.5) // inside the prewarm lead, so both clips are retained at once
598
+
599
+ expect(requested(h, 'a').item.volume).toBeCloseTo(0.4, 10)
600
+ expect(requested(h, 'b').item.volume).toBeCloseTo(0.2, 10)
601
+ })
602
+
603
+ it('mutes every clip on a muted track, whatever their own volume', () => {
604
+ const h = harness(tracked([clip('a', 0, 2, { volume: 2 })], { muted: true }))
605
+ h.scheduler.play()
606
+ step(h, 1)
607
+ expect(requested(h, 'a').item.muted).toBe(true)
608
+ })
609
+
610
+ it('keeps a muted clip muted on an unmuted track', () => {
611
+ const h = harness(tracked([clip('a', 0, 2, { muted: true })], { volume: 1 }))
612
+ h.scheduler.play()
613
+ step(h, 1)
614
+ expect(requested(h, 'a').item.muted).toBe(true)
615
+ })
616
+
617
+ it('passes the clip through untouched when the track carries no settings', () => {
618
+ const h = harness(tracked([clip('a', 0, 2, { volume: 0.8, muted: false })]))
619
+ h.scheduler.play()
620
+ step(h, 1)
621
+ expect(requested(h, 'a').item.volume).toBeCloseTo(0.8, 10)
622
+ expect(requested(h, 'a').item.muted).toBe(false)
623
+ })
624
+
625
+ it('defaults to unity for a clip and track that set nothing', () => {
626
+ const h = harness(tracked([clip('a', 0, 2)]))
627
+ h.scheduler.play()
628
+ step(h, 1)
629
+ expect(requested(h, 'a').item.volume).toBe(1)
630
+ expect(requested(h, 'a').item.muted).toBe(false)
631
+ })
632
+
633
+ it('DERIVES the request item — the project\'s own clip is never written to', () => {
634
+ // The accessors share item objects by reference all the way from
635
+ // project.json; `resolveProjectPaths` (renderer) and `_apply_project_edits`
636
+ // (server) both mutate them in place. Folding by assignment would corrupt
637
+ // the stored project with every tick and break nothing visible until export.
638
+ const item = clip('a', 0, 2, { volume: 0.8 })
639
+ const p = tracked([item], { volume: 0.5, muted: true })
640
+ const h = harness(p)
641
+ h.scheduler.play()
642
+ step(h, 1)
643
+
644
+ expect(requested(h, 'a').item).not.toBe(item)
645
+ expect(item.volume).toBe(0.8)
646
+ expect(item.muted).toBeUndefined()
647
+ // Everything the fold does not own rides along, or the host would rebuild
648
+ // the session against a clip with no trim and no src.
649
+ expect(requested(h, 'a').item).toMatchObject({ id: 'a', start: 0, inPoint: 0, outPoint: 2 })
650
+ })
651
+
652
+ it('re-folds after setProject, so a track edit reaches the next retain', () => {
653
+ const items = [clip('a', 0, 2, { volume: 0.8 })]
654
+ const h = harness(tracked(items, { volume: 0.5 }))
655
+ h.scheduler.play()
656
+ step(h, 1)
657
+ expect(requested(h, 'a').item.volume).toBeCloseTo(0.4, 10)
658
+
659
+ // Same clip object, new track settings — the only thing that changed is the
660
+ // track, so nothing but the fold can carry it through.
661
+ h.scheduler.setProject(tracked(items, { volume: 0.25, muted: true }))
662
+ expect(requested(h, 'a').item.volume).toBeCloseTo(0.2, 10)
663
+ expect(requested(h, 'a').item.muted).toBe(true)
664
+ })
665
+ })
666
+
667
+ // ── Transitions ─────────────────────────────────────────────────────────────
668
+
669
+ describe('transition: play into a gap and out the other side', () => {
670
+ const p = project([clip('a', 0, 2), clip('b', 3, 5)])
671
+
672
+ it('hides the picture, keeps time advancing, and picks the next clip up', () => {
673
+ const h = harness(p)
674
+ h.scheduler.play()
675
+ step(h, 1)
676
+ expect(h.scheduler.status()).toMatchObject({
677
+ transport: 'playing',
678
+ picture: 'video',
679
+ clipId: 'a',
680
+ clock: 'audio',
681
+ })
682
+
683
+ // Into the gap: black, but still PLAYING and still emitting time — the
684
+ // legacy gap clock's whole job.
685
+ step(h, 2.4)
686
+ expect(h.scheduler.status()).toMatchObject({
687
+ transport: 'playing',
688
+ picture: 'black',
689
+ clipId: null,
690
+ clock: 'fallback',
691
+ })
692
+ step(h, 2.8)
693
+ expect(last(h.times)).toBe(2.8)
694
+ expect(h.scheduler.status().transport).toBe('playing')
695
+
696
+ // Out the other side.
697
+ step(h, 3.05)
698
+ expect(h.scheduler.status()).toMatchObject({
699
+ transport: 'playing',
700
+ picture: 'video',
701
+ clipId: 'b',
702
+ clock: 'audio',
703
+ })
704
+ expect(h.times).toEqual([...h.times].sort((x, y) => x - y))
705
+ })
706
+
707
+ it('prewarms the next clip at boundary−1s and not before', () => {
708
+ const h = harness(p)
709
+ h.scheduler.play()
710
+ step(h, 1.5) // 1.5s to b.start — outside the lead
711
+ expect(h.host.lastRetain().map((r) => r.clipId)).toEqual(['a'])
712
+
713
+ step(h, 2.4) // 0.6s to b.start — inside the lead
714
+ expect(h.host.lastRetain().map((r) => r.clipId)).toContain('b')
715
+ expect(h.host.sessions.has('b')).toBe(true)
716
+ })
717
+
718
+ it('keeps the clip it just left retained, and drops it once it is no longer behind', () => {
719
+ // Reverses the original terminate-on-boundary rule. Disposing the outgoing
720
+ // clip made nudging the playhead back over a cut — the commonest gesture in
721
+ // an editor — rebuild it from scratch every single time. It is retained
722
+ // instead: its stream is stopped and its clock paused (asserted below), but
723
+ // the session survives so crossing back is free. Affordable only because
724
+ // ranged loading made a retained source cost a sample index rather than a
725
+ // whole proxy.
726
+ const three = project([clip('a', 0, 2), clip('b', 3, 5), clip('c', 5, 7)])
727
+ const h = harness(three)
728
+ h.scheduler.play()
729
+ step(h, 1)
730
+ const serverA = h.host.server('a')
731
+ const sessionA = h.host.sessions.get('a')!
732
+
733
+ step(h, 3.05)
734
+ expect(serverA.stops).toBeGreaterThan(0)
735
+ expect(serverA.disposed).toBe(false)
736
+ expect(sessionA.clock.playing).toBe(false)
737
+ expect(h.host.lastRetain().map((r) => r.clipId)).toContain('a')
738
+ expect(h.host.droppedClips).not.toContain('a')
739
+
740
+ // Two cuts later 'a' is neither active nor the clip behind the playhead, so
741
+ // the set difference finally takes it.
742
+ step(h, 5.05)
743
+ expect(h.host.lastRetain().map((r) => r.clipId)).not.toContain('a')
744
+ expect(h.host.droppedClips).toContain('a')
745
+ expect(serverA.disposed).toBe(true)
746
+ })
747
+ })
748
+
749
+ describe('transition: scrub into a gap', () => {
750
+ const p = project([clip('a', 0, 2), clip('b', 3, 5)])
751
+
752
+ it('hides the picture while paused and clears the canvas', () => {
753
+ const h = harness(p)
754
+ h.scheduler.seek(1)
755
+ expect(h.scheduler.status()).toMatchObject({ transport: 'paused', picture: 'video' })
756
+ h.scheduler.seek(2.5)
757
+ expect(h.scheduler.status()).toMatchObject({
758
+ transport: 'paused',
759
+ picture: 'black',
760
+ clipId: null,
761
+ })
762
+ expect(h.painter.clears).toBeGreaterThan(0)
763
+ })
764
+
765
+ it('keeps playing when scrubbed into a gap mid-playback', () => {
766
+ const h = harness(p)
767
+ h.scheduler.play()
768
+ step(h, 1)
769
+ h.scheduler.seek(2.5)
770
+ expect(h.scheduler.status()).toMatchObject({ transport: 'playing', picture: 'black' })
771
+ // The wall clock it swapped onto really is running.
772
+ expect(last(h.host.fallbacks)?.playing).toBe(true)
773
+ })
774
+ })
775
+
776
+ describe('transition: scrub past the end', () => {
777
+ const p = project([clip('a', 0, 2)], [], {
778
+ audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 6 }] },
779
+ })
780
+
781
+ it('stops playback outright rather than leaving audio running under a dark frame', () => {
782
+ const h = harness(p)
783
+ h.scheduler.play()
784
+ step(h, 1)
785
+ h.scheduler.seek(9)
786
+ expect(h.scheduler.status()).toMatchObject({ transport: 'ended', picture: 'black' })
787
+ })
788
+
789
+ it('re-arms on a scrub back inside the timeline', () => {
790
+ const h = harness(p)
791
+ h.scheduler.seek(9)
792
+ expect(h.scheduler.status().transport).toBe('ended')
793
+ h.scheduler.seek(1)
794
+ expect(h.scheduler.status()).toMatchObject({ transport: 'paused', picture: 'video' })
795
+ })
796
+
797
+ it('refuses to start playing from the end (the legacy togglePlay no-op)', () => {
798
+ const h = harness(p)
799
+ h.scheduler.seek(9)
800
+ h.scheduler.play()
801
+ expect(h.scheduler.status().transport).toBe('ended')
802
+ })
803
+ })
804
+
805
+ describe('transition: end of project', () => {
806
+ it('keeps running past the last clip to projectEnd, then stops exactly there', () => {
807
+ // The trailing-tail case: audio to 6s under a last clip that ends at 2s.
808
+ const p = project([clip('a', 0, 2)], [overlay('o', 2, 4)], {
809
+ audio: { tracks: [{ id: 'm', src: '/m.mp3', start: 0, end: 6 }] },
810
+ })
811
+ const h = harness(p)
812
+ h.scheduler.play()
813
+ step(h, 1)
814
+ step(h, 3) // past the last clip, inside the tail
815
+ expect(h.scheduler.status()).toMatchObject({ transport: 'playing', picture: 'black' })
816
+ step(h, 5.5)
817
+ expect(h.scheduler.status().transport).toBe('playing')
818
+ step(h, 6.2)
819
+ expect(h.scheduler.status().transport).toBe('ended')
820
+ // Clamped to the ceiling exactly, never past it.
821
+ expect(last(h.times)).toBe(6)
822
+ })
823
+
824
+ it('runs a canvas project on the wall clock and stops at its overlay/caption ceiling', () => {
825
+ const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
826
+ overlay('o', 0, 4),
827
+ ])
828
+ const h = harness(p)
829
+ h.scheduler.play()
830
+ step(h, 1)
831
+ expect(h.scheduler.status()).toMatchObject({
832
+ transport: 'playing',
833
+ picture: 'black',
834
+ clock: 'fallback',
835
+ })
836
+ expect(h.host.retainLog.every((r) => r.length === 0)).toBe(true)
837
+ step(h, 4.1)
838
+ expect(h.scheduler.status().transport).toBe('ended')
839
+ expect(last(h.times)).toBe(4)
840
+ })
841
+ })
842
+
843
+ describe('transition: loop wrap', () => {
844
+ it('restarts the decode session at the window start on every wrap', () => {
845
+ const p = project([clip('a', 0, 6, { loop: true, outPoint: 2 })])
846
+ const h = harness(p)
847
+ h.scheduler.play() // opens the session at t=0
848
+ const clock = h.host.sessions.get('a')!.clock
849
+ step(h, 0.5)
850
+ const server = h.host.server('a')
851
+ expect(server.starts).toEqual([0])
852
+ expect(last(server.pulls)).toBe(500_000)
853
+
854
+ step(h, 1.5) // same loop — no restart, just a pull
855
+ expect(server.starts).toEqual([0])
856
+ expect(last(server.pulls)).toBe(1_500_000)
857
+
858
+ step(h, 2.25) // wrapped
859
+ expect(server.starts).toEqual([0, 250_000])
860
+ expect(last(server.pulls)).toBe(250_000)
861
+ // The audio clock's ring must restart at the SAME wrapped media position
862
+ // as the picture, not at the pre-wrap mapping of project time `t` through
863
+ // the clip's timebase — that would leave audio a whole loop window ahead.
864
+ expect(last(clock.seeks)).toBeCloseTo(2.25, 6)
865
+ expect(last(clock.seekMediaS)).toBeCloseTo(0.25, 6)
866
+
867
+ step(h, 4.1) // wrapped again
868
+ expect(last(server.starts)).toBeCloseTo(100_000, 0)
869
+ expect(last(clock.seeks)).toBeCloseTo(4.1, 6)
870
+ expect(last(clock.seekMediaS)).toBeCloseTo(0.1, 6)
871
+ })
872
+
873
+ it('does not re-seek the audio clock on a same-loop pull (no wrap, no discontinuity)', () => {
874
+ const p = project([clip('a', 0, 6, { loop: true, outPoint: 2 })])
875
+ const h = harness(p)
876
+ h.scheduler.play()
877
+ const clock = h.host.sessions.get('a')!.clock
878
+ const seeksAfterOpen = clock.seeks.length
879
+ step(h, 0.5)
880
+ step(h, 1.5)
881
+ expect(clock.seeks).toHaveLength(seeksAfterOpen)
882
+ })
883
+
884
+ it('advances normally when the clip span ends on a wrap', () => {
885
+ const p = project([clip('a', 0, 6, { loop: true, outPoint: 2 }), clip('b', 6, 8)])
886
+ const h = harness(p)
887
+ h.scheduler.play()
888
+ step(h, 1)
889
+ step(h, 6.05)
890
+ expect(h.scheduler.status()).toMatchObject({ transport: 'playing', clipId: 'b' })
891
+ })
892
+
893
+ it('stops at clip.end when the span ends mid-loop (legacy behavior, warts included)', () => {
894
+ const p = project([clip('a', 0, 5, { loop: true, outPoint: 2 }), clip('b', 5, 8)])
895
+ const h = harness(p)
896
+ h.scheduler.play()
897
+ step(h, 1)
898
+ step(h, 5.05)
899
+ expect(h.scheduler.status().transport).toBe('paused')
900
+ expect(last(h.times)).toBe(5)
901
+ })
902
+
903
+ it('does not fire the loop stop on a scrub across the clip end', () => {
904
+ const p = project([clip('a', 0, 5, { loop: true, outPoint: 2 }), clip('b', 5, 8)])
905
+ const h = harness(p)
906
+ h.scheduler.play()
907
+ step(h, 1)
908
+ h.scheduler.seek(6)
909
+ expect(h.scheduler.status()).toMatchObject({ transport: 'playing', clipId: 'b' })
910
+ })
911
+ })
912
+
913
+ describe('transition: boundary swap', () => {
914
+ const p = project([clip('a', 0, 2), clip('b', 2, 4)])
915
+
916
+ it('stops the outgoing stream, starts the incoming one, and swaps the clock', () => {
917
+ const h = harness(p)
918
+ h.scheduler.play()
919
+ step(h, 1)
920
+ const serverA = h.host.server('a')
921
+ const clockA = h.host.sessions.get('a')!.clock
922
+ expect(serverA.starts).toEqual([0])
923
+ expect(last(serverA.pulls)).toBe(1_000_000)
924
+
925
+ step(h, 2.05)
926
+ const serverB = h.host.server('b')
927
+ // The outgoing session is STOPPED, not destroyed: nothing decodes and
928
+ // nothing plays out of it, but it stays retained as the clip behind the
929
+ // playhead so a scrub back over the cut does not rebuild it. What the swap
930
+ // rule was really protecting — no live session is ever reconfigured — is
931
+ // asserted by the next test.
932
+ expect(serverA.stops).toBeGreaterThan(0)
933
+ expect(serverA.disposed).toBe(false)
934
+ expect(clockA.disposed).toBe(false)
935
+ expect(clockA.playing).toBe(false)
936
+ // Anchored at the tick's time, not snapped back to the cut.
937
+ expect(serverB.starts).toHaveLength(1)
938
+ expect(serverB.starts[0]).toBeCloseTo(50_000, 3)
939
+ expect(h.host.sessions.get('b')!.clock.playing).toBe(true)
940
+ })
941
+
942
+ it('never reconfigures a live session — a new src means a new server', () => {
943
+ const h = harness(p)
944
+ h.scheduler.play()
945
+ step(h, 1)
946
+ const serverA = h.host.server('a')
947
+ step(h, 2.05)
948
+ expect(h.host.server('b')).not.toBe(serverA)
949
+ })
950
+
951
+ it('maps project time through the video track origin, not the audio one', () => {
952
+ const h = harness(project([clip('a', 0, 4, { inPoint: 1.5 })]))
953
+ h.scheduler.play()
954
+ step(h, 0.5)
955
+ const server = h.host.server('a')
956
+ // The clock's timebase carries a 999_000µs AUDIO origin; the video track's
957
+ // is 0, so nothing here may show a 999ms offset.
958
+ // inPoint 1.5 + elapsed 0.5 = 2.0s into the file.
959
+ expect(last(server.pulls)).toBe(2_000_000)
960
+ // …and the VIDEO origin, when it is non-zero, is added exactly once.
961
+ server.video.firstPresentationTsUs = 7_000
962
+ step(h, 1.5)
963
+ expect(last(server.pulls)).toBe(3_007_000)
964
+ })
965
+ })
966
+
967
+ describe('setRate — the live transport rate', () => {
968
+ const p = project([clip('a', 0, 2), clip('b', 2, 4)])
969
+
970
+ it('pushes the rate to the active clock and carries it onto clocks built afterward', () => {
971
+ const h = harness(p)
972
+ h.scheduler.play()
973
+ step(h, 0.5)
974
+ const clockA = h.host.sessions.get('a')!.clock
975
+ expect(clockA.kind).toBe('audio')
976
+
977
+ h.scheduler.setRate(2)
978
+ expect(clockA.rates).toEqual([2])
979
+
980
+ // Cross into clip b: the newly adopted clock inherits the live rate.
981
+ step(h, 2.1)
982
+ const clockB = h.host.sessions.get('b')!.clock
983
+ expect(clockB.rates).toContain(2)
984
+ })
985
+
986
+ it('leaves clocks untouched while R stays 1 (strict no-op default path)', () => {
987
+ const h = harness(p)
988
+ h.scheduler.play()
989
+ step(h, 0.5)
990
+ const clockA = h.host.sessions.get('a')!.clock
991
+ step(h, 2.1) // crosses the boundary; a's session is dropped afterward
992
+ const clockB = h.host.sessions.get('b')!.clock
993
+ // Never told a rate — the ownership swap must not re-anchor at the default.
994
+ expect(clockA.rates).toEqual([])
995
+ expect(clockB.rates).toEqual([])
996
+ })
997
+
998
+ it('is idempotent — an unchanged rate never touches the active clock', () => {
999
+ const h = harness(p)
1000
+ h.scheduler.play()
1001
+ step(h, 0.5)
1002
+ const clockA = h.host.sessions.get('a')!.clock
1003
+ h.scheduler.setRate(1) // already 1× — the shuttle's stop/reverse call
1004
+ expect(clockA.rates).toEqual([])
1005
+ h.scheduler.setRate(2) // a real change lands
1006
+ expect(clockA.rates).toEqual([2])
1007
+ h.scheduler.setRate(2) // same again — no-op, no re-anchor
1008
+ expect(clockA.rates).toEqual([2])
1009
+ })
1010
+ })
1011
+
1012
+ describe('transition: opaque toggle mid-play', () => {
1013
+ const p = project([clip('a', 0, 4)], [overlay('o', 1, 2, true)])
1014
+
1015
+ it('skips the paint under an opaque overlay but keeps the pipeline moving', () => {
1016
+ const h = harness(p)
1017
+ const frames: FakeFrame[] = []
1018
+ h.scheduler.play()
1019
+ step(h, 0.5)
1020
+ h.host.server('a').supply = (us) => {
1021
+ const f = fakeFrame(us)
1022
+ frames.push(f)
1023
+ return f
1024
+ }
1025
+
1026
+ step(h, 0.75)
1027
+ expect(h.painter.paints).toHaveLength(1)
1028
+ expect(h.scheduler.status().picture).toBe('video')
1029
+
1030
+ // Opaque window: the frame is still PULLED (so the decode-ahead buffer
1031
+ // keeps draining) and immediately closed unpainted.
1032
+ step(h, 1.25)
1033
+ expect(h.scheduler.status().picture).toBe('opaque')
1034
+ expect(h.painter.paints).toHaveLength(1)
1035
+ expect(last(h.host.server('a').pulls)).toBe(1_250_000)
1036
+ expect(last(frames)!.closed).toBe(true)
1037
+
1038
+ // Back to video, no session churn.
1039
+ step(h, 2.25)
1040
+ expect(h.scheduler.status().picture).toBe('video')
1041
+ expect(h.painter.paints).toHaveLength(2)
1042
+ expect(h.host.server('a').starts).toHaveLength(1)
1043
+ })
1044
+
1045
+ it('keeps the clip clock running under the overlay — opaque replaces picture, not audio', () => {
1046
+ const h = harness(p)
1047
+ h.scheduler.play()
1048
+ step(h, 1.25)
1049
+ expect(h.scheduler.status().clock).toBe('audio')
1050
+ expect(h.host.sessions.get('a')!.clock.playing).toBe(true)
1051
+ })
1052
+ })
1053
+
1054
+ describe('transition: decode failure mid-clip', () => {
1055
+ const p = project([clip('a', 0, 4), clip('b', 4, 6)])
1056
+
1057
+ it('routes to `preparing` for that clip only and never stops the project', () => {
1058
+ const h = harness(p)
1059
+ h.scheduler.play()
1060
+ step(h, 1)
1061
+ expect(h.scheduler.status().picture).toBe('video')
1062
+
1063
+ h.host.fail('a', 'decoder: VideoDecoder error')
1064
+ expect(h.scheduler.status()).toMatchObject({
1065
+ transport: 'playing',
1066
+ picture: 'preparing',
1067
+ clipId: 'a',
1068
+ reason: 'decoder: VideoDecoder error',
1069
+ clock: 'fallback',
1070
+ })
1071
+
1072
+ // Gap semantics: time keeps advancing through the failed clip's range.
1073
+ step(h, 2)
1074
+ expect(last(h.times)).toBe(2)
1075
+ expect(h.scheduler.status().transport).toBe('playing')
1076
+
1077
+ // …and the NEXT clip plays normally. No whole-project fallback.
1078
+ step(h, 4.05)
1079
+ expect(h.scheduler.status()).toMatchObject({ picture: 'video', clipId: 'b' })
1080
+ })
1081
+
1082
+ it('reaches the same state when the proxy has not arrived yet', () => {
1083
+ const h = harness(project([clip('a', 0, 4, { proxySrc: undefined })]), { autoReady: true })
1084
+ h.scheduler.play()
1085
+ step(h, 1)
1086
+ expect(h.scheduler.status()).toMatchObject({ picture: 'preparing', clipId: 'a' })
1087
+ expect(h.scheduler.status().reason).toMatch(/proxy/i)
1088
+ // Nothing was ever requested — the engine never opens a master.
1089
+ expect(h.host.retainLog.every((r) => r.length === 0)).toBe(true)
1090
+ })
1091
+
1092
+ it('reaches the same state while a proxy is still loading', () => {
1093
+ const h = harness(p, { autoReady: false })
1094
+ h.scheduler.play()
1095
+ step(h, 1)
1096
+ expect(h.scheduler.status().picture).toBe('preparing')
1097
+ h.host.ready('a')
1098
+ expect(h.scheduler.status()).toMatchObject({ picture: 'video', clipId: 'a' })
1099
+ })
1100
+
1101
+ it('resolves when a project update delivers the proxy', () => {
1102
+ const h = harness(project([clip('a', 0, 4, { proxySrc: undefined })]))
1103
+ h.scheduler.seek(1)
1104
+ expect(h.scheduler.status().picture).toBe('preparing')
1105
+
1106
+ h.scheduler.setProject(project([clip('a', 0, 4)]))
1107
+ expect(h.scheduler.status()).toMatchObject({ picture: 'video', clipId: 'a' })
1108
+ })
1109
+
1110
+ it('rebuilds the session when a clip swaps to a different proxy', () => {
1111
+ const h = harness(project([clip('a', 0, 4)]))
1112
+ h.scheduler.seek(1)
1113
+ const first = h.host.server('a')
1114
+ h.scheduler.setProject(project([clip('a', 0, 4, { proxySrc: '/proxies/a_proxy_v2.mp4' })]))
1115
+ expect(first.disposed).toBe(true)
1116
+ expect(h.host.server('a')).not.toBe(first)
1117
+ expect(h.host.server('a').src).toBe('/proxies/a_proxy_v2.mp4')
1118
+ })
1119
+ })
1120
+
1121
+ // ── Painting & frame ownership ──────────────────────────────────────────────
1122
+
1123
+ describe('painting', () => {
1124
+ it('paints with the crop plan the item asks for and closes every frame', () => {
1125
+ const item = clip('a', 0, 4, {
1126
+ sourceCrop: { x: 0.25, y: 0, w: 0.5, h: 1 },
1127
+ sourceWidth: 1920,
1128
+ sourceHeight: 1080,
1129
+ })
1130
+ const h = harness(project([item]))
1131
+ h.scheduler.play()
1132
+ step(h, 0.5)
1133
+ const frame = fakeFrame(500_000)
1134
+ h.host.server('a').supply = () => frame
1135
+ step(h, 1)
1136
+
1137
+ expect(h.painter.paints).toHaveLength(1)
1138
+ expect(h.painter.paints[0].plan).toEqual(drawPlanFor(item, 1280, 720, 1080, 1920))
1139
+ expect(frame.closed).toBe(true)
1140
+ })
1141
+
1142
+ it('closes a frame it cannot paint (no canvas attached)', () => {
1143
+ const h = harness(project([clip('a', 0, 4)]))
1144
+ h.scheduler.attach(null)
1145
+ h.scheduler.play()
1146
+ const frame = fakeFrame(0)
1147
+ step(h, 0.5)
1148
+ h.host.server('a').supply = () => frame
1149
+ step(h, 1)
1150
+ expect(frame.closed).toBe(true)
1151
+ })
1152
+
1153
+ it('paints a paused scrub from the seek path, once it lands', async () => {
1154
+ const h = harness(project([clip('a', 0, 4)]))
1155
+ h.scheduler.seek(1)
1156
+ const server = h.host.server('a')
1157
+ expect(server.seekTargets).toEqual([1_000_000])
1158
+ expect(h.scheduler.status().seeking).toBe(true)
1159
+
1160
+ const frame = fakeFrame(1_000_000)
1161
+ server.pendingSeeks[0](asFrame(frame))
1162
+ await Promise.resolve()
1163
+ expect(h.painter.paints).toHaveLength(1)
1164
+ expect(frame.closed).toBe(true)
1165
+ expect(h.scheduler.status().seeking).toBe(false)
1166
+ })
1167
+
1168
+ it('discards a superseded scrub frame instead of painting it', async () => {
1169
+ const h = harness(project([clip('a', 0, 4)]))
1170
+ h.scheduler.seek(1)
1171
+ h.scheduler.seek(2)
1172
+ const server = h.host.server('a')
1173
+ const stale = fakeFrame(1_000_000)
1174
+ const fresh = fakeFrame(2_000_000)
1175
+ server.pendingSeeks[0](asFrame(stale))
1176
+ server.pendingSeeks[1](asFrame(fresh))
1177
+ await Promise.resolve()
1178
+ expect(stale.closed).toBe(true)
1179
+ expect(h.painter.paints.map((p) => p.frame)).toEqual([fresh])
1180
+ })
1181
+
1182
+ it('does not re-seek a paused picture that has not moved', () => {
1183
+ // An overlay drag spreads the project on every pointer event; a decoder
1184
+ // seek per pointermove would be both wasteful and visibly unstable.
1185
+ const h = harness(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
1186
+ h.scheduler.seek(1)
1187
+ const server = h.host.server('a')
1188
+ expect(server.seekTargets).toHaveLength(1)
1189
+ h.scheduler.setProject(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
1190
+ h.scheduler.setProject(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
1191
+ expect(server.seekTargets).toHaveLength(1)
1192
+ // …but a real move does.
1193
+ h.scheduler.seek(1.5)
1194
+ expect(server.seekTargets).toHaveLength(2)
1195
+ })
1196
+
1197
+ it('repaints when a fresh canvas is attached', () => {
1198
+ const h = harness(project([clip('a', 0, 4)]))
1199
+ h.scheduler.seek(1)
1200
+ const server = h.host.server('a')
1201
+ expect(server.seekTargets).toHaveLength(1)
1202
+ h.scheduler.attach(new FakePainter())
1203
+ expect(server.seekTargets).toHaveLength(2)
1204
+ })
1205
+
1206
+ it('retries after a seek that produced no frame', async () => {
1207
+ const h = harness(project([clip('a', 0, 4)]))
1208
+ h.scheduler.seek(1)
1209
+ const server = h.host.server('a')
1210
+ server.pendingSeeks[0](null)
1211
+ await Promise.resolve()
1212
+ expect(h.painter.paints).toHaveLength(0)
1213
+ h.scheduler.setProject(project([clip('a', 0, 4)]))
1214
+ expect(server.seekTargets).toHaveLength(2)
1215
+ })
1216
+
1217
+ it('does not restart a live stream just because the project object changed', () => {
1218
+ // An overlay drag spreads the project every frame; that must not stutter
1219
+ // the picture.
1220
+ const p = project([clip('a', 0, 4)], [overlay('o', 0, 4)])
1221
+ const h = harness(p)
1222
+ h.scheduler.play()
1223
+ step(h, 1)
1224
+ const server = h.host.server('a')
1225
+ expect(server.starts).toHaveLength(1)
1226
+ h.scheduler.setProject(project([clip('a', 0, 4)], [overlay('o', 0, 4)]))
1227
+ expect(server.starts).toHaveLength(1)
1228
+ expect(server.disposed).toBe(false)
1229
+ })
1230
+ })
1231
+
1232
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
1233
+
1234
+ describe('lifecycle', () => {
1235
+ it('emits status only when it changes', () => {
1236
+ const h = harness(project([clip('a', 0, 4)]))
1237
+ h.scheduler.play()
1238
+ step(h, 0.5)
1239
+ const before = h.statuses.length
1240
+ step(h, 0.6)
1241
+ step(h, 0.7)
1242
+ expect(h.statuses.length).toBe(before)
1243
+ })
1244
+
1245
+ it('emits the playhead every tick', () => {
1246
+ const h = harness(project([clip('a', 0, 4)]))
1247
+ h.scheduler.play()
1248
+ step(h, 0.5)
1249
+ step(h, 0.6)
1250
+ expect(h.times.slice(-2)).toEqual([0.5, 0.6])
1251
+ })
1252
+
1253
+ it('pause stops the stream and freezes the clock', () => {
1254
+ const h = harness(project([clip('a', 0, 4)]))
1255
+ h.scheduler.play()
1256
+ step(h, 1)
1257
+ const server = h.host.server('a')
1258
+ h.scheduler.pause()
1259
+ expect(h.scheduler.status().transport).toBe('paused')
1260
+ expect(server.stops).toBeGreaterThan(0)
1261
+ expect(h.host.sessions.get('a')!.clock.playing).toBe(false)
1262
+ })
1263
+
1264
+ it('tick is inert before anything establishes a position', () => {
1265
+ const h = harness(project([clip('a', 0, 4)]))
1266
+ h.scheduler.tick()
1267
+ expect(h.times).toEqual([])
1268
+ expect(h.scheduler.status().transport).toBe('idle')
1269
+ })
1270
+
1271
+ it('dispose releases every session and stops painting', () => {
1272
+ const h = harness(project([clip('a', 0, 4)]))
1273
+ h.scheduler.play()
1274
+ step(h, 1)
1275
+ const server = h.host.server('a')
1276
+ h.scheduler.dispose()
1277
+ expect(server.disposed).toBe(true)
1278
+ expect(h.host.sessions.size).toBe(0)
1279
+ const painted = h.painter.paints.length
1280
+ step(h, 2)
1281
+ expect(h.painter.paints.length).toBe(painted)
1282
+ })
1283
+
1284
+ it('surfaces a painter throw without stopping the transport', () => {
1285
+ const painter = new FakePainter()
1286
+ const errors: string[] = []
1287
+ const host = new FakeHost()
1288
+ const scheduler = createScheduler({
1289
+ project: project([clip('a', 0, 4)]),
1290
+ host,
1291
+ painter,
1292
+ onError: (m) => errors.push(m),
1293
+ })
1294
+ host.scheduler = scheduler
1295
+ const boom = vi.spyOn(painter, 'paint').mockImplementation(() => {
1296
+ throw new Error('context lost')
1297
+ })
1298
+ scheduler.play()
1299
+ host.setTime(0.5)
1300
+ scheduler.tick()
1301
+ const frame = fakeFrame(500_000)
1302
+ host.server('a').supply = () => frame
1303
+ host.setTime(1)
1304
+ scheduler.tick()
1305
+ expect(errors.some((e) => e.includes('context lost'))).toBe(true)
1306
+ expect(frame.closed).toBe(true)
1307
+ expect(scheduler.status().transport).toBe('playing')
1308
+ boom.mockRestore()
1309
+ })
1310
+ })