@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,950 @@
1
+ /**
2
+ * SP4 T5 — the engine facade.
3
+ *
4
+ * `scheduler.ts` decides; this module makes those decisions possible. It owns
5
+ * the three things a pure state machine cannot:
6
+ *
7
+ * 1. **Resources.** `demux → createFrameServer → createMasterClock` for one
8
+ * clip, behind the scheduler's `SourceHost` interface. The scheduler
9
+ * declares which clips it needs; this reconciles. `demux` is ranged — it
10
+ * returns once the header is read and pulls media bytes as the playhead
11
+ * reaches them — so a build finishing is no longer the same event as the
12
+ * whole proxy having arrived.
13
+ * 2. **The rAF loop.** Injected (`requestFrame`/`cancelFrame`) so tests drive
14
+ * it by hand, and running ONLY while the transport is playing — a paused
15
+ * editor burns no frames, exactly like the legacy hook's boundary rAF.
16
+ * 3. **The canvas.** `attach(canvas)` builds the `Painter` the scheduler
17
+ * paints through.
18
+ *
19
+ * ─────────────────────────────────────────────────────────────────────────
20
+ * WHAT IS SHARED, AND WHAT IS TERMINATED (the boundary-swap rule)
21
+ * ─────────────────────────────────────────────────────────────────────────
22
+ * The spike's rule is per-SOURCE: "switching source is terminate + respawn -
23
+ * never `decoder.reset()` on a live worker" (`player.ts`'s `load`). That is
24
+ * exactly what happens here, and it is deliberately NOT per-clip:
25
+ *
26
+ * - **`FrameServer` — one per `src`, refcounted by clip.** A silence-trimmed
27
+ * timeline is fifty clips off ONE proxy; giving each its own decoder would
28
+ * re-fetch and re-demux the same file fifty times. The legacy hook has the
29
+ * same shape for the same reason (its comment notes same-source cuts were
30
+ * the fast path because the moov was already cached). Two clips never
31
+ * stream from one server at once — the scheduler stops the outgoing
32
+ * session before starting the incoming one — and when the last clip
33
+ * referencing a src is dropped, the worker really is terminated.
34
+ * - **`MasterClock` — one per CLIP**, because it is anchored to that clip's
35
+ * `start`/`inPoint`/`volume`/`muted` and T4 is explicit that a live clock is
36
+ * never reconfigured.
37
+ * - **`DemuxedSource` — cached by `src` in a small LRU** beyond the live refs.
38
+ * Demuxing used to be the expensive half (a WHOLE-FILE fetch plus a
39
+ * sample-table walk) and scrubbing back and forth across a cut is the
40
+ * single most common thing anyone does in an editor. Ranged loading made
41
+ * the fetch half cheap, but the cache earns its place either way: it holds
42
+ * the parsed sample index and whatever bytes have already been pulled, so a
43
+ * re-entered source starts warm. What lingers is now bounded by
44
+ * `demux.ts`'s resident-byte budgets rather than by the file's size.
45
+ */
46
+ import { designCanvas, sourceWindow } from '@bycrux/timeline-core'
47
+ import type { EditorProject as Project, VisualItem } from '../schema'
48
+ import { createMasterClock, createWallClock, type ClipTimebase } from './audio-clock'
49
+ import { demux, type DemuxedSource } from './demux'
50
+ import { createFrameServer, type FrameServer, type HardwarePref } from './frame-server'
51
+ import type { FileUrlResolver } from './media-loader'
52
+ import {
53
+ createScheduler,
54
+ type ClipSource,
55
+ type EngineStatus,
56
+ type Painter,
57
+ type Scheduler,
58
+ type SourceHost,
59
+ type SourceRequest,
60
+ type SourceState,
61
+ } from './scheduler'
62
+
63
+ export * from './scheduler'
64
+
65
+ /**
66
+ * Parsed sources kept alive past their last reference.
67
+ *
68
+ * Was 3 — "the clip you are on, the one before it, the one after it" — because
69
+ * a cached source used to pin a WHOLE PROXY in memory and three of those was
70
+ * already hundreds of megabytes. Ranged loading changed what a cached source
71
+ * costs: it is now a sample index plus a bounded byte cache (`demux.ts`'s
72
+ * `MAX_RESIDENT_*_BYTES` and `media-loader.ts`'s `MAX_CACHED_BYTES`, ~26 MB
73
+ * between them at the ceiling), so the same memory buys more of them. Five
74
+ * covers the retain window plus the two clips either side of it, which is the
75
+ * span a fast back-and-forth scrub across two cuts touches.
76
+ */
77
+ export const DEMUX_CACHE_MAX = 5
78
+
79
+ /**
80
+ * How long a source build may run before it is aborted.
81
+ *
82
+ * The failure this exists for: a fetch that never settles left the session in
83
+ * `loading` forever, and `state()` reports `loading` as the same
84
+ * "Preparing preview…" a not-yet-encoded proxy produces — so a hung request was
85
+ * indistinguishable from a proxy that simply had not arrived, and stayed on
86
+ * screen until the tab was reloaded. Aborting turns it into a `failed` session
87
+ * with a reason, which the retry below can then act on.
88
+ *
89
+ * Generous on purpose. A ranged build is a header fetch and at most one or two
90
+ * follow-ups; twenty seconds is not a latency budget, it is the point past
91
+ * which the request is not coming back.
92
+ */
93
+ export const BUILD_TIMEOUT_MS = 20_000
94
+
95
+ /**
96
+ * How many times a failed session may be rebuilt before the clip is left alone.
97
+ *
98
+ * Bounded because the failure might be permanent (a corrupt proxy, a codec the
99
+ * browser will not configure) and `retain` runs every tick — an unbounded retry
100
+ * would be an infinite rebuild loop burning a decode worker per attempt.
101
+ */
102
+ export const MAX_SESSION_RETRIES = 3
103
+
104
+ /**
105
+ * Backoff before the first retry, doubling per attempt (≈0.75s, 1.5s, 3s).
106
+ *
107
+ * Without it the three attempts would all be spent inside a few frames of the
108
+ * rAF loop, which retries nothing useful: the transient cases worth retrying —
109
+ * a proxy still being written, a request that lost its connection — need wall
110
+ * time to resolve, not another immediate attempt.
111
+ */
112
+ export const SESSION_RETRY_BASE_MS = 750
113
+
114
+ // ── Public surface ──────────────────────────────────────────────────────────
115
+
116
+ export interface EngineDeps {
117
+ /** Host path → fetchable URL. `EditorAdapter.fileUrl`, threaded through unchanged. */
118
+ fileUrl: FileUrlResolver
119
+ /**
120
+ * The playhead, every tick.
121
+ *
122
+ * T6's bridge contract: mirror each emitted value into a `lastEmittedRef`
123
+ * BEFORE forwarding it to `clock.set`, and treat an incoming `currentTime`
124
+ * that differs from it by more than the legacy dead-zone as an external
125
+ * scrub. Pass a stable function that reads a ref — the engine captures this
126
+ * once at construction and is not rebuilt when a React callback identity
127
+ * changes.
128
+ */
129
+ onTime?: (projectS: number) => void
130
+ /** Fires only when the status actually changes, never per tick. Same stability note as `onTime`. */
131
+ onStatusChange?: (status: EngineStatus) => void
132
+ /** Decoder, loader and paint failures. Advisory: none of them stop the transport. */
133
+ onError?: (message: string) => void
134
+ hardwareAcceleration?: HardwarePref
135
+ decodeAheadFrames?: number
136
+ /** Boundary−N seconds for prewarm. Defaults to the scheduler's `PREWARM_LEAD_S`. */
137
+ prewarmLeadS?: number
138
+ startProjectS?: number
139
+ /** rAF seam. Defaults to `requestAnimationFrame`; tests pump by hand. */
140
+ requestFrame?: (cb: () => void) => number
141
+ cancelFrame?: (handle: number) => void
142
+ /** Wall-clock seam, forwarded to every fallback clock. Defaults to `performance.now`. */
143
+ nowMs?: () => number
144
+ }
145
+
146
+ /** A read-only view of whatever clock is currently driving the transport. */
147
+ export interface EngineClock {
148
+ /** Playhead in project seconds. */
149
+ now(): number
150
+ readonly playing: boolean
151
+ /** `'audio'` when the active clip's Opus is driving, `'fallback'` on the wall clock. */
152
+ readonly kind: 'audio' | 'fallback'
153
+ }
154
+
155
+ /**
156
+ * T7's debug HUD reads this. A deliberately small, read-only aggregate — NOT
157
+ * the frame server's full `FrameServerStats` (buffered/inFlightFrames/
158
+ * inFlightBatches/received/dropped/atEndOfSource/drained/lastError) and NOT
159
+ * the spike's per-(source, hardwareAcceleration) bucket matrix
160
+ * (`spikes/playback-engine/src/hud.ts`). The HUD needs four numbers — is
161
+ * playback keeping up right now — not the frame server's internals or a
162
+ * benchmarking history, so this is the smallest surface that answers that
163
+ * from React without reaching past `Engine` into `EngineSourceHost` /
164
+ * `FrameServer`.
165
+ */
166
+ export interface EngineStats {
167
+ /** Painted frames per second over a trailing {@link STATS_FPS_WINDOW_MS} window. 0 when nothing has painted recently. */
168
+ fps: number
169
+ /** Frames dropped by the active clip's frame server (superseded or overtaken), lifetime. 0 with no active session. */
170
+ dropped: number
171
+ /** Frames buffered ahead in the active clip's decode-ahead pipeline. 0 with no active session. */
172
+ buffered: number
173
+ /** Which clock is driving the transport right now — same value as `EngineStatus.clock`. */
174
+ clock: 'audio' | 'fallback'
175
+ }
176
+
177
+ /**
178
+ * A pinned handle to a shared demuxed source. The demux LRU treats a src as
179
+ * unevictable while at least one `AcquiredDemux` for it is unreleased — so a
180
+ * non-scheduler consumer (the drag-scrub audio source) can share the warm
181
+ * cache without racing the scheduler's own eviction. Release exactly once.
182
+ */
183
+ export interface AcquiredDemux {
184
+ source: DemuxedSource
185
+ release(): void
186
+ }
187
+
188
+ export interface Engine {
189
+ /**
190
+ * Bind the canvas the engine paints into, or `null` to unbind.
191
+ *
192
+ * Sizes the backing store to the project's design canvas as a default. The
193
+ * host may resize it at any time (a `ResizeObserver` on the frame box, say) —
194
+ * the painter re-reads `canvas.width`/`height` on every paint, so the crop
195
+ * and contain-fit math follows automatically.
196
+ */
197
+ attach(canvas: HTMLCanvasElement | null): void
198
+ play(): void
199
+ pause(): void
200
+ /** External scrub. Playback survives it; scrubbing past the end stops it. */
201
+ seek(projectS: number): void
202
+ /**
203
+ * Set the live transport rate R (default 1): project time advances R× wall
204
+ * time, applied to the active clock immediately and to every session built
205
+ * afterward. This is the J/K/L shuttle's knob. A per-clip `speed` change is
206
+ * NOT this — it arrives through {@link updateProject} as a session rebuild,
207
+ * like any other timeline edit.
208
+ */
209
+ setRate(rate: number): void
210
+ /**
211
+ * Adopt an edited project.
212
+ *
213
+ * Beyond the obvious (new clips, moved boundaries), this is the path a
214
+ * `preparing` clip resolves through: when SSE delivers a `proxySrc` the
215
+ * affected clip's session is rebuilt and that clip alone leaves the Preparing
216
+ * state. Engine ELIGIBILITY is NOT re-evaluated here — plan decision 2 pins it
217
+ * to project-load, and the "initially-ineligible stays legacy" policy is T6's.
218
+ */
219
+ updateProject(project: Project): void
220
+ status(): EngineStatus
221
+ readonly clock: EngineClock
222
+ /** T7's debug HUD aggregate. See {@link EngineStats}. Cheap — safe to call on a polling timer. */
223
+ stats(): EngineStats
224
+ /**
225
+ * Acquire a shared demuxed source for `src`, pinning it in the LRU until
226
+ * the returned handle is released. Used by the drag-scrub audio source so a
227
+ * scrub across a cut hits the same warm cache the scheduler built up
228
+ * instead of re-demuxing the proxy privately. Release exactly once.
229
+ */
230
+ acquireDemux(src: string): Promise<AcquiredDemux>
231
+ dispose(): void
232
+ }
233
+
234
+ // ── Painter ─────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Canvas 2D painter (plan decision 3: `drawImage(VideoFrame)`, not WebGL, for
238
+ * v1 — one video layer is all the engine composites, and `drawImage` of a
239
+ * `VideoFrame` is GPU-backed in Chromium).
240
+ *
241
+ * Every paint fills black first. Without it a letterboxed frame leaves the
242
+ * previous, differently-shaped frame's edges around its bars — the same reason
243
+ * the legacy surface sits on a `bg-black` container.
244
+ */
245
+ export function createCanvasPainter(canvas: HTMLCanvasElement): Painter {
246
+ const ctx = canvas.getContext('2d', { alpha: false })
247
+ const fill = () => {
248
+ if (!ctx) return
249
+ ctx.fillStyle = '#000'
250
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
251
+ }
252
+ return {
253
+ size: () => ({ width: canvas.width, height: canvas.height }),
254
+ paint(frame, plan) {
255
+ if (!ctx) return
256
+ fill()
257
+ if (plan.sw <= 0 || plan.sh <= 0 || plan.dw <= 0 || plan.dh <= 0) return
258
+ ctx.drawImage(
259
+ frame,
260
+ plan.sx,
261
+ plan.sy,
262
+ plan.sw,
263
+ plan.sh,
264
+ plan.dx,
265
+ plan.dy,
266
+ plan.dw,
267
+ plan.dh,
268
+ )
269
+ },
270
+ clear: fill,
271
+ }
272
+ }
273
+
274
+ // ── The source host ─────────────────────────────────────────────────────────
275
+
276
+ interface Session {
277
+ clipId: string
278
+ src: string
279
+ status: 'loading' | 'ready' | 'failed'
280
+ reason?: string
281
+ source?: ClipSource
282
+ /** Set when the session is dropped mid-build; every await point checks it. */
283
+ cancelled: boolean
284
+ /**
285
+ * `request.item.start` as of the last (re)build. `retain` compares this
286
+ * against the wanted request to detect a mid-session trim on the SAME src —
287
+ * `src` alone is not enough, since the master clock's timebase is captured
288
+ * at build time and a trim otherwise leaves audio on the pre-trim mapping.
289
+ */
290
+ start: number
291
+ /** `sourceWindow(item, 'preview').inPoint` as of the last (re)build. Same trim-detection role as `start`. */
292
+ inPoint: number
293
+ /**
294
+ * `!!item.muted` as of the last (re)build. A mute toggle respawns the
295
+ * session — the clock's KIND depends on it. EFFECTIVE mute: the scheduler
296
+ * folds the clip's track in before the request is built, so muting the
297
+ * TRACK respawns here exactly as muting the clip does.
298
+ */
299
+ muted: boolean
300
+ /**
301
+ * `item.volume ?? 1` as of the last (re)build — the effective volume, track
302
+ * gain already multiplied in (see `SourceRequest.item`). Unlike `muted`, a
303
+ * change here is pushed live via `setVolume`, not a respawn trigger.
304
+ */
305
+ volume: number
306
+ /**
307
+ * `item.speed ?? 1` as of the last (re)build. Speed re-maps source↔timeline
308
+ * and the master clock's timebase captures it at build time, so — like a trim
309
+ * or a mute toggle, and UNLIKE `volume` — a change respawns the session rather
310
+ * than pushing live. The transport rate R is the live axis; per-clip speed is
311
+ * not.
312
+ */
313
+ speed: number
314
+ /** Rebuild attempts already spent on this clip — see {@link MAX_SESSION_RETRIES}. */
315
+ retries: number
316
+ /**
317
+ * Wall-clock milliseconds before which a `failed` session must not be
318
+ * retried. 0 on a session that has not failed.
319
+ */
320
+ retryAfterMs: number
321
+ }
322
+
323
+ interface ServerEntry {
324
+ server: FrameServer
325
+ refs: Set<string>
326
+ }
327
+
328
+ interface DemuxEntry {
329
+ promise: Promise<DemuxedSource>
330
+ controller: AbortController
331
+ /** Clears the build timeout. Idempotent; called on settle and on abandon. */
332
+ clearTimeout: () => void
333
+ waiters: number
334
+ /** How many `abandonDemux` calls this entry has absorbed — see `abandonDemux`. */
335
+ abandoned: number
336
+ }
337
+
338
+ class EngineSourceHost implements SourceHost {
339
+ private readonly sessions = new Map<string, Session>()
340
+ private readonly servers = new Map<string, ServerEntry>()
341
+ /** Insertion-ordered, oldest first — the LRU. */
342
+ private readonly demuxCache = new Map<string, DemuxedSource>()
343
+ private readonly demuxing = new Map<string, DemuxEntry>()
344
+ /**
345
+ * Refcount of outstanding `AcquiredDemux` pins per src. A pinned src is
346
+ * skipped by `evictDemux` so a non-scheduler consumer (the drag-scrub audio
347
+ * source) can hold a warm demux across a cut without the scheduler evicting
348
+ * it out from under them. Counted (not a Set) so a reentrant acquire on the
349
+ * same src does not silently trip the "already pinned" check on release.
350
+ */
351
+ private readonly demuxPins = new Map<string, number>()
352
+ private scheduler: Scheduler | null = null
353
+ private disposed = false
354
+
355
+ constructor(private readonly deps: EngineDeps) {}
356
+
357
+ bind(scheduler: Scheduler): void {
358
+ this.scheduler = scheduler
359
+ }
360
+
361
+ retain(requests: readonly SourceRequest[]): void {
362
+ if (this.disposed) return
363
+ const wanted = new Map(requests.map((r) => [r.clipId, r]))
364
+ // Drop first: a clip whose src changed under it (a proxy arriving via SSE),
365
+ // or whose trim or mute state changed, must lose its old session before the
366
+ // new one is built, so the rebuild is a genuine respawn rather than two
367
+ // sessions on one clip.
368
+ for (const [clipId, session] of [...this.sessions]) {
369
+ const want = wanted.get(clipId)
370
+ const changed =
371
+ !want ||
372
+ want.src !== session.src ||
373
+ want.item.start !== session.start ||
374
+ sourceWindow(want.item, 'preview').inPoint !== session.inPoint ||
375
+ !!want.item.muted !== session.muted ||
376
+ (want.item.speed ?? 1) !== session.speed
377
+ if (changed) this.dropSession(clipId)
378
+ }
379
+ // A clip's volume can change without a rebuild: push it straight to the
380
+ // live clock (`MasterClock.setVolume`) rather than tearing the session
381
+ // down. Only sessions that survived the drop loop above and already have a
382
+ // built `source` are eligible — a still-loading session picks up the
383
+ // request's volume when `build()` finishes.
384
+ for (const request of requests) {
385
+ const session = this.sessions.get(request.clipId)
386
+ if (!session?.source) continue
387
+ const vol = request.item.volume ?? 1
388
+ if (vol !== session.volume) {
389
+ session.volume = vol
390
+ session.source.clock.setVolume(vol)
391
+ }
392
+ }
393
+ for (const request of requests) {
394
+ const existing = this.sessions.get(request.clipId)
395
+ if (existing) {
396
+ // A `failed` session is KEPT in the map so `state()` can answer
397
+ // `'failed'` and this loop does not immediately rebuild it. That was
398
+ // unconditional, which made every failure permanent for as long as the
399
+ // clip stayed retained — including the transient ones (a proxy still
400
+ // being written, a request that lost its connection, a build that hit
401
+ // the timeout above). Retry is bounded and backed off so the "leave it
402
+ // alone" property still holds for a genuinely broken clip.
403
+ if (existing.status !== 'failed') continue
404
+ if (existing.retries >= MAX_SESSION_RETRIES) continue
405
+ if (this.now() < existing.retryAfterMs) continue
406
+ const retries = existing.retries + 1
407
+ this.dropSession(request.clipId)
408
+ this.startSession(request, retries)
409
+ continue
410
+ }
411
+ this.startSession(request)
412
+ }
413
+ }
414
+
415
+ /** Wall clock, through the injected seam so tests drive the backoff by hand. */
416
+ private now(): number {
417
+ return this.deps.nowMs?.() ?? performance.now()
418
+ }
419
+
420
+ state(clipId: string): SourceState {
421
+ const session = this.sessions.get(clipId)
422
+ if (!session) return { status: 'idle' }
423
+ if (session.status === 'ready' && session.source) {
424
+ return { status: 'ready', source: session.source }
425
+ }
426
+ if (session.status === 'failed') {
427
+ return { status: 'failed', reason: session.reason ?? 'preview unavailable' }
428
+ }
429
+ return { status: 'loading' }
430
+ }
431
+
432
+ fallbackClock(startProjectS: number) {
433
+ return createWallClock(startProjectS, 'no clip audio', this.deps.nowMs)
434
+ }
435
+
436
+ disposeAll(): void {
437
+ this.disposed = true
438
+ for (const clipId of [...this.sessions.keys()]) this.dropSession(clipId)
439
+ for (const [, entry] of this.servers) entry.server.dispose()
440
+ this.servers.clear()
441
+ // Ranged sources hold cached file bytes and can have reads in flight;
442
+ // dropping the map reference alone would leave both to the collector.
443
+ // Only done here, never on LRU eviction: `evictDemux` skips any src with a
444
+ // live server, so an evicted source has nothing reading it and nothing to
445
+ // abort, whereas closing one out from under an in-flight `build()` would
446
+ // strand the clip it was building.
447
+ for (const [, source] of this.demuxCache) source.dispose?.()
448
+ this.demuxCache.clear()
449
+ this.demuxPins.clear()
450
+ for (const [, entry] of this.demuxing) {
451
+ entry.clearTimeout()
452
+ entry.controller.abort()
453
+ }
454
+ this.demuxing.clear()
455
+ this.scheduler = null
456
+ }
457
+
458
+ // ── session lifecycle ─────────────────────────────────────────────────────
459
+
460
+ private startSession(request: SourceRequest, retries = 0): void {
461
+ const session: Session = {
462
+ clipId: request.clipId,
463
+ src: request.src,
464
+ status: 'loading',
465
+ cancelled: false,
466
+ start: request.item.start,
467
+ inPoint: sourceWindow(request.item, 'preview').inPoint,
468
+ muted: !!request.item.muted,
469
+ volume: request.item.volume ?? 1,
470
+ speed: request.item.speed ?? 1,
471
+ retries,
472
+ retryAfterMs: 0,
473
+ }
474
+ this.sessions.set(request.clipId, session)
475
+ void this.build(session, request)
476
+ }
477
+
478
+ private async build(session: Session, request: SourceRequest): Promise<void> {
479
+ // Set once `acquireServer` actually adds a ref, so the `catch` below knows
480
+ // whether it has to release one — a throw between `acquireServer` and the
481
+ // `session.source` assignment would otherwise leak the ref forever.
482
+ let acquired = false
483
+ try {
484
+ const demuxed = await this.acquireDemux(session.src)
485
+ if (session.cancelled) return
486
+ const server = this.acquireServer(session.src, demuxed, session.clipId)
487
+ acquired = true
488
+
489
+ const window = sourceWindow(request.item, 'preview')
490
+ // Re-stamp the trim/mute/volume fields `retain`'s drop test reads, in
491
+ // case this build was triggered by something other than `startSession`
492
+ // (e.g. a respawn) and the session object predates this request.
493
+ session.start = request.item.start
494
+ session.inPoint = window.inPoint
495
+ session.muted = !!request.item.muted
496
+ session.volume = request.item.volume ?? 1
497
+ session.speed = request.item.speed ?? 1
498
+ const timebase: ClipTimebase = {
499
+ start: request.item.start,
500
+ // `sourceWindow(...).inPoint`, NOT the raw `item.inPoint` — for a
501
+ // `normalizedSrc` window cache the two differ by the cache origin and
502
+ // every seek would land in the wrong place. Same value the legacy hook
503
+ // feeds its <video> elements through `effectiveInPoint`.
504
+ inPoint: window.inPoint,
505
+ // The AUDIO track's origin, per `ClipTimebase`. The video track has its
506
+ // own and the scheduler reads it straight off the frame server.
507
+ firstPresentationTsUs: demuxed.audio?.firstPresentationTsUs ?? 0,
508
+ // Per-clip speed is captured at build time (the clock is never
509
+ // reconfigured live); a speed edit respawns the session — see `Session
510
+ // .speed`. Absent ⇒ 1, the strict no-op mapping.
511
+ speed: request.item.speed ?? 1,
512
+ }
513
+ // Never rejects: a muted clip, a track with no decodable audio, a browser
514
+ // without WebCodecs audio — all resolve to a wall clock with a reason.
515
+ const clock = await createMasterClock({
516
+ audio: demuxed.audio,
517
+ timebase,
518
+ startProjectS: request.anchorProjectS,
519
+ volume: request.item.volume,
520
+ muted: request.item.muted,
521
+ onError: this.deps.onError,
522
+ nowMs: this.deps.nowMs,
523
+ })
524
+ if (session.cancelled) {
525
+ clock.dispose()
526
+ this.releaseServer(session.src, session.clipId)
527
+ return
528
+ }
529
+ session.source = {
530
+ clipId: session.clipId,
531
+ src: session.src,
532
+ frameServer: server,
533
+ clock,
534
+ timebase,
535
+ }
536
+ session.status = 'ready'
537
+ } catch (err) {
538
+ if (acquired) this.releaseServer(session.src, session.clipId)
539
+ if (session.cancelled) return
540
+ const message = err instanceof Error ? err.message : String(err)
541
+ this.markFailed(session, message)
542
+ this.deps.onError?.(`engine: ${session.src} — ${message}`)
543
+ }
544
+ this.scheduler?.sourceChanged(session.clipId)
545
+ }
546
+
547
+ /**
548
+ * Move a session to `failed` and arm its retry window.
549
+ *
550
+ * The backoff doubles per attempt already spent, so the three attempts land
551
+ * roughly 0.75s, 1.5s and 3s after their respective failures rather than all
552
+ * inside the same handful of rAF ticks.
553
+ */
554
+ private markFailed(session: Session, reason: string): void {
555
+ session.status = 'failed'
556
+ session.reason = reason
557
+ session.retryAfterMs = this.now() + SESSION_RETRY_BASE_MS * 2 ** session.retries
558
+ }
559
+
560
+ private dropSession(clipId: string): void {
561
+ const session = this.sessions.get(clipId)
562
+ if (!session) return
563
+ this.sessions.delete(clipId)
564
+ session.cancelled = true
565
+ session.source?.clock.dispose()
566
+ if (session.source) this.releaseServer(session.src, clipId)
567
+ else this.abandonDemux(session.src)
568
+ }
569
+
570
+ /**
571
+ * A decoder error on a shared src fails every clip using it.
572
+ *
573
+ * `frame-server.ts` surfaces decode errors and deliberately does not
574
+ * self-heal, so the session really is dead: its clock is disposed and its
575
+ * worker released, and the scheduler puts those clips' ranges into the SAME
576
+ * `preparing` state a not-yet-encoded proxy produces. The failed session is
577
+ * KEPT in the map (rather than deleted) so `state()` answers `'failed'` and
578
+ * `retain` does not immediately loop into a rebuild; it clears when the clip
579
+ * leaves the retained set or its `src` changes.
580
+ */
581
+ private onDecodeError(src: string, message: string): void {
582
+ this.deps.onError?.(`engine: decode failed for ${src} — ${message}`)
583
+ for (const session of this.sessions.values()) {
584
+ if (session.src !== src || session.status === 'failed') continue
585
+ this.markFailed(session, message)
586
+ session.source?.clock.dispose()
587
+ if (session.source) {
588
+ session.source = undefined
589
+ this.releaseServer(src, session.clipId)
590
+ }
591
+ this.scheduler?.sourceChanged(session.clipId)
592
+ }
593
+ }
594
+
595
+ // ── per-src resources ─────────────────────────────────────────────────────
596
+
597
+ private acquireServer(src: string, source: DemuxedSource, clipId: string): FrameServer {
598
+ let entry = this.servers.get(src)
599
+ if (!entry) {
600
+ entry = {
601
+ server: createFrameServer({
602
+ source,
603
+ hardwareAcceleration: this.deps.hardwareAcceleration,
604
+ decodeAheadFrames: this.deps.decodeAheadFrames,
605
+ onError: (message) => this.onDecodeError(src, message),
606
+ }),
607
+ refs: new Set(),
608
+ }
609
+ this.servers.set(src, entry)
610
+ }
611
+ entry.refs.add(clipId)
612
+ return entry.server
613
+ }
614
+
615
+ /** Last clip off this src leaves ⇒ the worker is terminated. The spike's `load` rule. */
616
+ private releaseServer(src: string, clipId: string): void {
617
+ const entry = this.servers.get(src)
618
+ if (!entry) return
619
+ entry.refs.delete(clipId)
620
+ if (entry.refs.size > 0) return
621
+ entry.server.dispose()
622
+ this.servers.delete(src)
623
+ }
624
+
625
+ private async acquireDemux(src: string): Promise<DemuxedSource> {
626
+ const cached = this.demuxCache.get(src)
627
+ if (cached) {
628
+ // Touch: re-insert so Map iteration order stays oldest-first.
629
+ this.demuxCache.delete(src)
630
+ this.demuxCache.set(src, cached)
631
+ return cached
632
+ }
633
+ let entry = this.demuxing.get(src)
634
+ if (!entry) {
635
+ const controller = new AbortController()
636
+ // The AbortController was already here for `abandonDemux`; the timer just
637
+ // gives it a second trigger. Aborting is the only thing that CAN end a
638
+ // hung fetch — a `Promise.race` would resolve the build's promise while
639
+ // the request stayed open, which is how you leak a connection per stalled
640
+ // clip on a timeline that keeps scrubbing past them.
641
+ let timedOut = false
642
+ let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
643
+ timedOut = true
644
+ controller.abort()
645
+ }, BUILD_TIMEOUT_MS)
646
+ const clear = () => {
647
+ if (timer === undefined) return
648
+ clearTimeout(timer)
649
+ timer = undefined
650
+ }
651
+ const promise = demux(src, this.deps.fileUrl, { signal: controller.signal }).then(
652
+ (source) => {
653
+ clear()
654
+ return source
655
+ },
656
+ (err: unknown) => {
657
+ clear()
658
+ // The abort surfaces as a generic `AbortError`, which reads as "the
659
+ // caller cancelled" and would be shown to the user as such. Name the
660
+ // real cause instead: the session's `reason` is what reaches the
661
+ // Preparing placeholder.
662
+ if (timedOut) {
663
+ throw new Error(`preview build timed out after ${BUILD_TIMEOUT_MS} ms`)
664
+ }
665
+ throw err
666
+ },
667
+ )
668
+ entry = { promise, controller, clearTimeout: clear, waiters: 0, abandoned: 0 }
669
+ this.demuxing.set(src, entry)
670
+ }
671
+ entry.waiters++
672
+ try {
673
+ const source = await entry.promise
674
+ this.demuxCache.set(src, source)
675
+ this.evictDemux()
676
+ return source
677
+ } finally {
678
+ entry.waiters--
679
+ this.demuxing.delete(src)
680
+ }
681
+ }
682
+
683
+ /**
684
+ * A session dropped before its bytes landed; abort the fetch once EVERY
685
+ * waiter has abandoned it, not just the first.
686
+ *
687
+ * `waiters` never shrinks when a session is dropped (only when its
688
+ * `acquireDemux` await settles), so comparing against a fixed `waiters > 1`
689
+ * threshold only ever protects the FIRST drop on a shared src — a second
690
+ * clip dropped moments later would see the same stale `waiters` count and
691
+ * never abort, leaking the fetch for good. Counting how many times this has
692
+ * been called instead, and comparing against `waiters`, aborts exactly when
693
+ * every current waiter has actually abandoned it.
694
+ */
695
+ private abandonDemux(src: string): void {
696
+ const entry = this.demuxing.get(src)
697
+ if (!entry) return
698
+ entry.abandoned++
699
+ if (entry.abandoned < entry.waiters) return
700
+ entry.clearTimeout()
701
+ entry.controller.abort()
702
+ this.demuxing.delete(src)
703
+ }
704
+
705
+ private evictDemux(): void {
706
+ while (this.demuxCache.size > DEMUX_CACHE_MAX) {
707
+ let victim: string | null = null
708
+ for (const src of this.demuxCache.keys()) {
709
+ // Never evict a source a live decode session is reading from.
710
+ if (this.servers.has(src)) continue
711
+ // Or one an outside consumer (the drag-scrub source) is holding.
712
+ if (this.demuxPins.has(src)) continue
713
+ victim = src
714
+ break
715
+ }
716
+ if (victim === null) return
717
+ this.demuxCache.delete(victim)
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Shared with the drag-scrub audio source. `acquireDemux` already coalesces
723
+ * concurrent builds and populates the LRU; this wraps it in a per-src pin
724
+ * so the caller can hold the demux across a cut without the scheduler
725
+ * evicting it, and releases the pin exactly once.
726
+ *
727
+ * On a disposed host the pin is a no-op and release is silent — the
728
+ * scrubber can outlive us during teardown and its release must not throw.
729
+ */
730
+ async acquirePinnedDemux(src: string): Promise<AcquiredDemux> {
731
+ const source = await this.acquireDemux(src)
732
+ if (this.disposed) {
733
+ return { source, release: () => {} }
734
+ }
735
+ this.demuxPins.set(src, (this.demuxPins.get(src) ?? 0) + 1)
736
+ let released = false
737
+ return {
738
+ source,
739
+ release: () => {
740
+ if (released) return
741
+ released = true
742
+ if (this.disposed) return
743
+ const next = (this.demuxPins.get(src) ?? 0) - 1
744
+ if (next > 0) {
745
+ this.demuxPins.set(src, next)
746
+ return
747
+ }
748
+ this.demuxPins.delete(src)
749
+ // The pin may have held this src past the LRU cap; unpinned entries
750
+ // are eligible again so re-check now instead of waiting for the next
751
+ // `acquireDemux` to reconcile it.
752
+ this.evictDemux()
753
+ },
754
+ }
755
+ }
756
+ }
757
+
758
+ // ── createEngine ────────────────────────────────────────────────────────────
759
+
760
+ /**
761
+ * Build the playback engine for one project.
762
+ *
763
+ * Nothing happens until something drives it: `attach` a canvas to see the
764
+ * current frame, `seek` to move, `play` to run. The engine starts paused at
765
+ * `startProjectS` (default 0).
766
+ *
767
+ * ── Surface notes for T6 ──
768
+ * `attach/play/pause/seek/dispose/clock` are the plan's table. Three additions,
769
+ * each because the hook genuinely needs it:
770
+ * - `updateProject(project)` — the editor mutates the project constantly, and
771
+ * without it a proxy arriving mid-session could never resolve a Preparing
772
+ * clip.
773
+ * - `status()` + the `onStatusChange` dep — the hook has to render
774
+ * `isPlaying`, the Preparing placeholder and (T7) the HUD; polling a
775
+ * getter every render is the alternative and it is worse.
776
+ * - `onTime` as a dep rather than a `subscribe()` method — the clock bridge
777
+ * needs the value BEFORE it reaches React state (see the dep's own note),
778
+ * and one construction-time callback reading a ref is the stable-identity
779
+ * shape React wants.
780
+ */
781
+ /** Rolling window for `stats().fps` — matches the spike HUD's `FPS_WINDOW_MS`. */
782
+ const STATS_FPS_WINDOW_MS = 2000
783
+
784
+ /**
785
+ * Drop every timestamp in `timesMs` older than `windowMs` before `nowMs`
786
+ * (mutates in place; the array must be ascending, oldest first — the order
787
+ * paints naturally arrive in). Exported, and split from `fpsFromPaintTimes`
788
+ * below, so `stats()`'s rolling-window math is unit-testable without a
789
+ * canvas, an rAF loop or a live decode session — the same reason
790
+ * `scheduler.ts` splits `containFitPlan`/`sourceCropDrawPlan` out of the
791
+ * class that owns the mutable state they serve.
792
+ *
793
+ * Called on every recorded paint (not just on `stats()` reads): a session
794
+ * where `debugHud` is never turned on still paints for the whole session, and
795
+ * pruning only on read would otherwise grow this array without bound for as
796
+ * long as nobody asks for `stats()`.
797
+ */
798
+ export function pruneOlderThan(timesMs: number[], nowMs: number, windowMs: number): void {
799
+ const cutoff = nowMs - windowMs
800
+ let i = 0
801
+ while (i < timesMs.length && timesMs[i] < cutoff) i++
802
+ if (i > 0) timesMs.splice(0, i)
803
+ }
804
+
805
+ /** Instantaneous fps from an already-pruned, ascending list of paint timestamps. */
806
+ export function fpsFromPaintTimes(prunedTimesMs: readonly number[], nowMs: number, windowMs: number): number {
807
+ if (prunedTimesMs.length === 0) return 0
808
+ const span = Math.min(windowMs, nowMs - prunedTimesMs[0])
809
+ return span > 0 ? (prunedTimesMs.length / span) * 1000 : 0
810
+ }
811
+
812
+ export function createEngine(project: Project, deps: EngineDeps): Engine {
813
+ const host = new EngineSourceHost(deps)
814
+ const scheduler = createScheduler({
815
+ project,
816
+ host,
817
+ onTime: deps.onTime,
818
+ onStatusChange: deps.onStatusChange,
819
+ onError: deps.onError,
820
+ prewarmLeadS: deps.prewarmLeadS,
821
+ startProjectS: deps.startProjectS,
822
+ })
823
+ host.bind(scheduler)
824
+
825
+ const requestFrame = deps.requestFrame ?? ((cb: () => void) => requestAnimationFrame(cb))
826
+ const cancelFrame = deps.cancelFrame ?? ((handle: number) => cancelAnimationFrame(handle))
827
+
828
+ let raf: number | null = null
829
+ let currentProject = project
830
+ let canvas: HTMLCanvasElement | null = null
831
+ let disposed = false
832
+
833
+ const nowMs = deps.nowMs ?? (() => performance.now())
834
+ /** Painted-frame timestamps within the trailing window, oldest first. `stats()`'s fps source. */
835
+ let paintTimesMs: number[] = []
836
+ const recordPaint = () => {
837
+ const t = nowMs()
838
+ paintTimesMs.push(t)
839
+ pruneOlderThan(paintTimesMs, t, STATS_FPS_WINDOW_MS)
840
+ }
841
+
842
+ const pump = () => {
843
+ raf = null
844
+ if (disposed) return
845
+ scheduler.tick()
846
+ // The transport can end inside the tick (project end, or a looping clip's
847
+ // mid-loop stop); re-reading it here is what stops the loop without a
848
+ // separate "should I still be running" flag to keep in sync.
849
+ if (scheduler.status().transport === 'playing') raf = requestFrame(pump)
850
+ }
851
+
852
+ const start = () => {
853
+ if (raf !== null || disposed) return
854
+ if (scheduler.status().transport !== 'playing') return
855
+ raf = requestFrame(pump)
856
+ }
857
+
858
+ const stop = () => {
859
+ if (raf === null) return
860
+ cancelFrame(raf)
861
+ raf = null
862
+ }
863
+
864
+ const sizeCanvas = () => {
865
+ if (!canvas) return
866
+ const [w, h] = designCanvas(currentProject.settings?.resolution)
867
+ if (canvas.width !== w) canvas.width = w
868
+ if (canvas.height !== h) canvas.height = h
869
+ }
870
+
871
+ return {
872
+ attach(next: HTMLCanvasElement | null) {
873
+ canvas = next
874
+ if (!next) {
875
+ scheduler.attach(null)
876
+ return
877
+ }
878
+ sizeCanvas()
879
+ const painter = createCanvasPainter(next)
880
+ // Wrapped only to feed `stats().fps` — every other call is `painter`'s own.
881
+ scheduler.attach({
882
+ ...painter,
883
+ paint: (frame, plan) => {
884
+ recordPaint()
885
+ painter.paint(frame, plan)
886
+ },
887
+ })
888
+ },
889
+ play() {
890
+ scheduler.play()
891
+ start()
892
+ },
893
+ pause() {
894
+ scheduler.pause()
895
+ stop()
896
+ },
897
+ seek(projectS: number) {
898
+ scheduler.seek(projectS)
899
+ if (scheduler.status().transport !== 'playing') stop()
900
+ },
901
+ setRate(rate: number) {
902
+ scheduler.setRate(rate)
903
+ },
904
+ updateProject(next: Project) {
905
+ currentProject = next
906
+ sizeCanvas()
907
+ scheduler.setProject(next)
908
+ },
909
+ status: () => scheduler.status(),
910
+ stats(): EngineStats {
911
+ // Prune on read too — a paused engine (no rAF, no paints) would otherwise
912
+ // report a stale fps from before it stopped rather than decaying to 0.
913
+ const t = nowMs()
914
+ pruneOlderThan(paintTimesMs, t, STATS_FPS_WINDOW_MS)
915
+ const fps = fpsFromPaintTimes(paintTimesMs, t, STATS_FPS_WINDOW_MS)
916
+
917
+ const st = scheduler.status()
918
+ const state = st.clipId ? host.state(st.clipId) : { status: 'idle' as const }
919
+ const frameStats = state.status === 'ready' ? state.source.frameServer.stats() : null
920
+
921
+ return {
922
+ fps,
923
+ dropped: frameStats?.dropped ?? 0,
924
+ buffered: frameStats?.buffered ?? 0,
925
+ clock: st.clock,
926
+ }
927
+ },
928
+ clock: {
929
+ now: () => scheduler.now(),
930
+ get playing() {
931
+ return scheduler.status().transport === 'playing'
932
+ },
933
+ get kind() {
934
+ return scheduler.status().clock
935
+ },
936
+ },
937
+ acquireDemux: (src: string) => host.acquirePinnedDemux(src),
938
+ dispose() {
939
+ if (disposed) return
940
+ disposed = true
941
+ stop()
942
+ scheduler.dispose()
943
+ host.disposeAll()
944
+ paintTimesMs = []
945
+ },
946
+ }
947
+ }
948
+
949
+ /** Re-exported so T6 can type its own item lists without reaching into the schema. */
950
+ export type { VisualItem }