@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,1324 @@
1
+ /**
2
+ * SP4 T5 — the scheduler: ONE tick, and every transport semantic the editor has.
3
+ *
4
+ * The legacy player (`../video/preview/useVideoPlayback.ts`) runs three clocks —
5
+ * a canvas rAF, a gap rAF, and a per-frame `handleTimeUpdate` pump over a
6
+ * `<video>` element's `currentTime` — plus a double-buffered pair of `<video>`
7
+ * slots and a `loopOffsetRef` threaded through three call sites. All of it
8
+ * exists to answer one question sixty times a second: *what should be on screen
9
+ * and where is the playhead*. This module answers that question once, from a
10
+ * single master clock, and the legacy hook is its specification: every branch
11
+ * below cites the site it reproduces.
12
+ *
13
+ * ─────────────────────────────────────────────────────────────────────────
14
+ * WHY THIS IS A SYNCHRONOUS STATE MACHINE
15
+ * ─────────────────────────────────────────────────────────────────────────
16
+ * Everything genuinely asynchronous lives BEHIND an injected interface:
17
+ *
18
+ * - `SourceHost` — fetch + demux + `createFrameServer` + `createMasterClock`
19
+ * for one clip. The scheduler never awaits it; it declares which clips it
20
+ * needs (`retain`) and reads a status (`state`) that is `'loading'` until
21
+ * the host says otherwise. The host pokes the scheduler with
22
+ * `sourceChanged(clipId)` when a load lands or fails.
23
+ * - `Painter` — `drawImage` onto a canvas.
24
+ * - `MasterClock` — the T4 clock (audio-derived or wall-clock fallback).
25
+ *
26
+ * The only `await` in the whole module is the frame promise a paused seek
27
+ * returns, and it is guarded by a generation counter. Everything else is
28
+ * straight-line synchronous logic over the injected surfaces — which is exactly
29
+ * what makes "play into a gap", "scrub past the end", "loop wrap", "boundary
30
+ * swap", "opaque toggle mid-play" and "decode failure mid-clip" unit-testable
31
+ * with fakes in jsdom, where there is no WebCodecs, no `Worker` and no canvas.
32
+ *
33
+ * ─────────────────────────────────────────────────────────────────────────
34
+ * THE TWO STATE AXES (and why they are not one enum)
35
+ * ─────────────────────────────────────────────────────────────────────────
36
+ * `transport` (idle → paused ↔ playing → ended) and `picture` (video / black /
37
+ * opaque / preparing) are ORTHOGONAL. You can be paused on a gap, playing under
38
+ * an opaque overlay, or playing through a clip whose proxy is still encoding.
39
+ * Collapsing them into one enum would make the transition table a product
40
+ * rather than a sum, and would hide the single most important invariant this
41
+ * task exists to establish: **the picture state never stops the clock.** A gap,
42
+ * an opaque overlay and a missing/failed proxy all hide the picture while
43
+ * project time keeps advancing — the legacy gap clock's behavior
44
+ * (`useVideoPlayback.ts`'s `tickGap`), generalized.
45
+ *
46
+ * ─────────────────────────────────────────────────────────────────────────
47
+ * MAPPING FROM THE LEGACY HOOK (the spec, by content not by line number)
48
+ * ─────────────────────────────────────────────────────────────────────────
49
+ * gap `tickGap` + the `idx === -1` branch of the scrub effect + the
50
+ * `next.start > cur.end + 0.02` branch of `handleTimeUpdate`.
51
+ * Here: `plan.active === null` ⇒ picture `'black'`, the clock is
52
+ * a wall-clock fallback, time advances, and the boundary is
53
+ * simply the tick at which `resolveAt` starts returning the next
54
+ * clip. No gap-specific clock, no `gapTargetRef`.
55
+ * loop the three `loopOffsetRef` sites (scrub-into-loop, wrap-at-
56
+ * outPoint, stop-at-clip.end). Here: `placeInSource` is the
57
+ * scrub site's arithmetic made total, and `loopOffset` is
58
+ * derived (`wraps * loopDur`) rather than accumulated — a
59
+ * derived offset cannot drift, and a scrub lands identically to
60
+ * a wrap by construction.
61
+ * end `handleTimeUpdate`'s last-clip branch (which hands off to the
62
+ * gap clock with `gapTargetRef = projectEnd` so trailing
63
+ * overlays/audio keep playing) + `tickGap`'s no-next-clip tail
64
+ * (`setIsPlaying(false)`). Here: `transportEndFor` and the
65
+ * `t >= end` clamp.
66
+ * opaque NOT a legacy behavior — the legacy `<video>` path keeps showing
67
+ * the video under an opaque overlay while render replaces the
68
+ * picture. The registry disposition (KNOWN-DIVERGENCES, opaque)
69
+ * is to unify on RENDER semantics now that there is a
70
+ * compositing stage: skip the paint, keep the audio.
71
+ * sourceCrop `../video/preview/sourceCropStyle.ts`, re-expressed as a
72
+ * `drawImage` source+destination rect. See `sourceCropDrawPlan`.
73
+ * canvas `isCanvasProject` — a project with no track-0 video items runs
74
+ * the whole timeline on the fallback clock with picture
75
+ * `'black'`; the overlay/caption DOM layers do the drawing.
76
+ */
77
+ import {
78
+ projectEnd as timelineProjectEnd,
79
+ resolveAt,
80
+ sourceWindow,
81
+ } from '@bycrux/timeline-core'
82
+ import type { Scene, SourceWindow } from '@bycrux/timeline-core'
83
+ import type { EditorProject as Project, VisualItem, VisualTrack } from '../schema'
84
+ import type { ClipTimebase, MasterClock } from './audio-clock'
85
+ import type { FrameServer } from './frame-server'
86
+ import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
87
+
88
+ // ── Tuning constants ────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * How far ahead of a clip boundary the NEXT clip's decode session is built.
92
+ *
93
+ * The plan names 1s ("prewarm next item at boundary−1s (the preload analog)").
94
+ * Worth knowing while tuning it: the legacy hook deliberately abandoned a ~1s
95
+ * lead and preloads the next `<video>` slot for the WHOLE of the current clip
96
+ * instead — its comment records that 1s was "far too short" and stalled
97
+ * playback at cross-source cuts. That lesson was measured against 4K 10-bit
98
+ * HEVC masters with the moov atom at the END of the file (a slow tail range
99
+ * fetch before anything can be indexed). The engine only ever opens SP3's
100
+ * faststart H.264 proxies, which is a different order of magnitude — hence the
101
+ * plan's 1s. It is a dep (`prewarmLeadS`) precisely so the parity checklist can
102
+ * move it without a code change if a real project says otherwise.
103
+ */
104
+ export const PREWARM_LEAD_S = 1
105
+
106
+ /**
107
+ * Tolerance for "the loop window was exhausted at the same instant the clip's
108
+ * project window ended" — see `endsOnLoopBoundary`. Authors set loop spans
109
+ * deliberately (a 2s loop across a 6s clip), so the coincidence is exact
110
+ * arithmetic in practice and this only absorbs float error.
111
+ */
112
+ export const LOOP_BOUNDARY_EPS_S = 1e-3
113
+
114
+ // ── Painting ────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * One `drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh)` call, split out so the
118
+ * arithmetic is pure and testable without a canvas.
119
+ *
120
+ * Source coordinates are in the decoded frame's own pixels; destination
121
+ * coordinates are in the canvas backing store's pixels. Neither carries the
122
+ * item's `scale`/`offsetX`/`offsetY`: those stay on the CSS transform container
123
+ * that wraps the canvas, untouched, exactly as they wrap the `<video>` slots
124
+ * today (`PreviewPlayer.tsx`'s `transformContainerStyle`).
125
+ */
126
+ export interface DrawPlan {
127
+ sx: number
128
+ sy: number
129
+ sw: number
130
+ sh: number
131
+ dx: number
132
+ dy: number
133
+ dw: number
134
+ dh: number
135
+ }
136
+
137
+ /** The canvas surface, injected so the scheduler can be tested without one. */
138
+ export interface Painter {
139
+ /** Backing-store size in pixels. Re-read every paint — the host may resize. */
140
+ size(): { width: number; height: number }
141
+ /** Draw one frame. The scheduler closes the frame immediately after this returns. */
142
+ paint(frame: VideoFrame, plan: DrawPlan): void
143
+ /** Fill the whole surface with black (gap / opaque / preparing). */
144
+ clear(): void
145
+ }
146
+
147
+ /**
148
+ * `object-fit: contain` as a `drawImage` rect — the no-crop default, matching
149
+ * `PreviewPlayer.tsx`'s `baseVideoStyle` (`objectFit: 'contain'`, inset 0).
150
+ */
151
+ export function containFitPlan(
152
+ codedWidth: number,
153
+ codedHeight: number,
154
+ frameWidth: number,
155
+ frameHeight: number,
156
+ ): DrawPlan {
157
+ if (!codedWidth || !codedHeight || !frameWidth || !frameHeight) {
158
+ return { sx: 0, sy: 0, sw: 0, sh: 0, dx: 0, dy: 0, dw: 0, dh: 0 }
159
+ }
160
+ const scale = Math.min(frameWidth / codedWidth, frameHeight / codedHeight)
161
+ const dw = codedWidth * scale
162
+ const dh = codedHeight * scale
163
+ return {
164
+ sx: 0,
165
+ sy: 0,
166
+ sw: codedWidth,
167
+ sh: codedHeight,
168
+ dx: (frameWidth - dw) / 2,
169
+ dy: (frameHeight - dh) / 2,
170
+ dw,
171
+ dh,
172
+ }
173
+ }
174
+
175
+ export interface SourceCropDrawInput {
176
+ crop: { x: number; y: number; w: number; h: number } | undefined
177
+ /** The ITEM's intrinsic source dims. Absent ⇒ no crop; see the guard note below. */
178
+ sourceWidth: number | undefined
179
+ sourceHeight: number | undefined
180
+ /** The DECODED frame's dims — what the source rect is expressed in. */
181
+ codedWidth: number
182
+ codedHeight: number
183
+ frameWidth: number
184
+ frameHeight: number
185
+ }
186
+
187
+ /**
188
+ * `sourceCropStyle.ts`'s algebra, re-expressed as a `drawImage` rect.
189
+ *
190
+ * The CSS version sizes the `<video>` LARGER than its frame box and translates
191
+ * it so only the crop sub-rect is visible; the canvas version draws only the
192
+ * crop sub-rect, into the box that sub-rect would have occupied. They are the
193
+ * same statement:
194
+ *
195
+ * CSS: video box = (videoWRatio, videoHRatio) at (leftRatio, topRatio),
196
+ * with videoWRatio = cropWRatio / crop.w and
197
+ * leftRatio = (1 - cropWRatio)/2 - crop.x * videoWRatio.
198
+ * The crop sub-rect therefore lands at
199
+ * leftRatio + crop.x * videoWRatio = (1 - cropWRatio)/2
200
+ * and spans crop.w * videoWRatio = cropWRatio.
201
+ *
202
+ * …which is exactly `dx`/`dw` below. `__tests__/scheduler-crop.test.ts` asserts
203
+ * that equality against `sourceCropVideoStyle`'s own test vectors rather than
204
+ * trusting this comment.
205
+ *
206
+ * ── The no-dims guard (a parity decision, not an oversight) ──
207
+ * Returns `null` — i.e. NO crop — when the ITEM carries no
208
+ * `sourceWidth`/`sourceHeight`. `sourceCropStyle.ts:32` makes the same call,
209
+ * and so does render (`encode-segment.js`'s `if (sc && item.sourceWidth &&
210
+ * item.sourceHeight)` gate, which drops the `crop=` filter step entirely) —
211
+ * KNOWN-DIVERGENCES entry 8, `sourcecrop-missing-dims-silent-drop`.
212
+ *
213
+ * What is deliberately NOT reproduced is `PreviewPlayer.tsx`'s call-site
214
+ * fallback `activeClip?.sourceWidth ?? videoDims?.w`, which substitutes the
215
+ * loaded `<video>`'s intrinsic dims when the item has none. The decoded frame's
216
+ * `coded` dims are the engine's equivalent of `videoDims` and were available
217
+ * here, so this is a choice: that fallback makes the PREVIEW crop an item render
218
+ * would not crop, which is the divergence entry 8 documents. The plan's
219
+ * disposition is the "parity-safe" guard, so the engine gates on the item.
220
+ */
221
+ export function sourceCropDrawPlan(input: SourceCropDrawInput): DrawPlan | null {
222
+ const { crop, sourceWidth, sourceHeight, codedWidth, codedHeight, frameWidth, frameHeight } =
223
+ input
224
+ // sourceCropStyle.ts:32 — all four dims required.
225
+ if (!sourceWidth || !sourceHeight || !frameWidth || !frameHeight) return null
226
+ if (!codedWidth || !codedHeight) return null
227
+ // sourceCropStyle.ts:33 — an inverted or empty crop is not a crop.
228
+ if (!crop || crop.w <= 0 || crop.h <= 0) return null
229
+ // sourceCropStyle.ts:35 — a full-frame crop is the default and needs nothing.
230
+ if (crop.x === 0 && crop.y === 0 && crop.w === 1 && crop.h === 1) return null
231
+
232
+ const frameAspect = frameWidth / frameHeight
233
+ const cropAspect = (sourceWidth * crop.w) / (sourceHeight * crop.h)
234
+
235
+ // Contain-fit the crop region into the frame → its displayed size as a ratio
236
+ // of the frame's own dimensions. Verbatim from sourceCropStyle.ts:44-50.
237
+ let cropWRatio: number
238
+ let cropHRatio: number
239
+ if (cropAspect >= frameAspect) {
240
+ cropWRatio = 1
241
+ cropHRatio = frameAspect / cropAspect
242
+ } else {
243
+ cropHRatio = 1
244
+ cropWRatio = cropAspect / frameAspect
245
+ }
246
+
247
+ return {
248
+ // The crop sub-rect inside the DECODED frame. Expressed against the coded
249
+ // dims rather than the item's `sourceWidth`/`sourceHeight` because the
250
+ // proxy is a re-encode at a different resolution — `sourceCrop` is stored
251
+ // as RATIOS of the source precisely so it survives that.
252
+ sx: crop.x * codedWidth,
253
+ sy: crop.y * codedHeight,
254
+ sw: crop.w * codedWidth,
255
+ sh: crop.h * codedHeight,
256
+ dx: ((1 - cropWRatio) / 2) * frameWidth,
257
+ dy: ((1 - cropHRatio) / 2) * frameHeight,
258
+ dw: cropWRatio * frameWidth,
259
+ dh: cropHRatio * frameHeight,
260
+ }
261
+ }
262
+
263
+ /** The crop plan when the item has one, the contain-fit default otherwise. */
264
+ export function drawPlanFor(
265
+ item: Pick<VisualItem, 'sourceCrop' | 'sourceWidth' | 'sourceHeight'>,
266
+ codedWidth: number,
267
+ codedHeight: number,
268
+ frameWidth: number,
269
+ frameHeight: number,
270
+ ): DrawPlan {
271
+ const cropped = sourceCropDrawPlan({
272
+ crop: item.sourceCrop,
273
+ sourceWidth: item.sourceWidth,
274
+ sourceHeight: item.sourceHeight,
275
+ codedWidth,
276
+ codedHeight,
277
+ frameWidth,
278
+ frameHeight,
279
+ })
280
+ return cropped ?? containFitPlan(codedWidth, codedHeight, frameWidth, frameHeight)
281
+ }
282
+
283
+ // ── Source placement (the loop reimplementation) ─────────────────────────────
284
+
285
+ export interface SourcePlacement {
286
+ /** Position inside the LOADED src, seconds — the `<video>.currentTime` analog. */
287
+ mediaS: number
288
+ /** Whole loop windows elapsed. 0 for a non-looping clip. */
289
+ wraps: number
290
+ /** `wraps * loopDur` — the legacy `loopOffsetRef.current`, derived rather than accumulated. */
291
+ loopOffset: number
292
+ /** Whether loop arithmetic actually applied (`item.loop` AND a usable window). */
293
+ looping: boolean
294
+ }
295
+
296
+ /**
297
+ * Where inside the loaded source project time `t` lands, loop included.
298
+ *
299
+ * This is the legacy scrub site's arithmetic, made total:
300
+ *
301
+ * if (clip.loop && clipOutPoint != null) {
302
+ * const loopDur = clipOutPoint - inPoint
303
+ * const elapsed = currentTime - clip.start
304
+ * const loops = Math.floor(elapsed / loopDur)
305
+ * loopOffsetRef.current = loops * loopDur
306
+ * video.currentTime = inPoint + (elapsed % loopDur)
307
+ * } else {
308
+ * loopOffsetRef.current = 0
309
+ * video.currentTime = Math.max(inPoint, inPoint + (currentTime - clip.start))
310
+ * }
311
+ *
312
+ * **`loopOffset` is derived, not accumulated.** The legacy hook keeps a mutable
313
+ * `loopOffsetRef` that the wrap site increments (`loopOffsetRef.current +=
314
+ * loopDur`) and the scrub site recomputes — two writers, one of which can drift
315
+ * if a wrap is missed (a dropped frame, a backgrounded tab). Deriving it from
316
+ * `t` makes a wrap during playback and a scrub into the middle of the same loop
317
+ * land on identical media positions by construction, which is the property the
318
+ * three legacy sites were trying to maintain by hand.
319
+ *
320
+ * `outPoint == null` falls to the non-loop branch exactly as the legacy site's
321
+ * `clipOutPoint != null` guard does; a non-positive `loopDur` does too (the
322
+ * legacy expression would produce `Infinity`/`NaN` there).
323
+ */
324
+ export function placeInSource(
325
+ item: Pick<VisualItem, 'start' | 'loop' | 'speed'>,
326
+ window: Pick<SourceWindow, 'inPoint' | 'outPoint'>,
327
+ t: number,
328
+ ): SourcePlacement {
329
+ const inPoint = window.inPoint
330
+ const speed = item.speed ?? 1
331
+ // Per-clip speed S scales the SOURCE traversal, not project time: the elapsed
332
+ // project-seconds inside the clip are converted to source-seconds at S× before
333
+ // any placement (`mediaS = inPoint + S·(t − start)`), so the clip shows S× its
334
+ // content per project-second. S=1 is the legacy `inPoint + elapsed`.
335
+ const sourceElapsed = speed * Math.max(0, t - (item.start ?? 0))
336
+ const outPoint = window.outPoint
337
+ const loopDur = outPoint == null ? 0 : outPoint - inPoint
338
+ if (!item.loop || outPoint == null || loopDur <= 0) {
339
+ return { mediaS: inPoint + sourceElapsed, wraps: 0, loopOffset: 0, looping: false }
340
+ }
341
+ // `loopDur` is a SOURCE span (outPoint − inPoint) and speed does not touch it —
342
+ // only how fast we traverse it. One loop iteration therefore occupies
343
+ // `loopDur/speed` PROJECT-seconds, but once `elapsed` is in source-seconds the
344
+ // wrap arithmetic below is identical to the speed-1 case. `wraps`/`loopOffset`
345
+ // stay in source terms, consistent with a non-sped loop.
346
+ const wraps = Math.floor(sourceElapsed / loopDur)
347
+ return {
348
+ mediaS: inPoint + (sourceElapsed - wraps * loopDur),
349
+ wraps,
350
+ loopOffset: wraps * loopDur,
351
+ looping: true,
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Does a looping clip's project window end exactly ON a loop wrap?
357
+ *
358
+ * This decides which of the legacy hook's two loop-end behaviors applies when
359
+ * project time reaches `clip.end`:
360
+ *
361
+ * - **On a wrap** — `handleTimeUpdate`'s `video.currentTime >= outPoint`
362
+ * branch fires first, sees `projectT >= clip.end`, and falls THROUGH to the
363
+ * ordinary next-clip / end-of-project logic.
364
+ * - **Mid-loop** (the common case, since `end - start` is rarely an exact
365
+ * multiple of the loop) — the separate `if (clip.loop && t >= clip.end)`
366
+ * site fires instead: `video.pause()`, playhead parked at `clip.end`, every
367
+ * audio element paused, `setIsPlaying(false)`. Playback STOPS; it does not
368
+ * continue into the next clip.
369
+ *
370
+ * The second behavior looks like a bug and is reproduced anyway: it is what
371
+ * ships, the plan's acceptance asks for loop transport "indistinguishable from
372
+ * legacy", and quietly changing it would be a behavior change smuggled in under
373
+ * a refactor. `KNOWN-DIVERGENCES` is where a decision to drop it belongs.
374
+ */
375
+ export function endsOnLoopBoundary(
376
+ item: Pick<VisualItem, 'start' | 'end' | 'loop'>,
377
+ window: Pick<SourceWindow, 'inPoint' | 'outPoint'>,
378
+ ): boolean {
379
+ if (!item.loop || window.outPoint == null) return false
380
+ const loopDur = window.outPoint - window.inPoint
381
+ if (loopDur <= 0) return false
382
+ const span = (item.end ?? 0) - (item.start ?? 0)
383
+ if (span <= 0) return true
384
+ const remainder = span % loopDur
385
+ return remainder < LOOP_BOUNDARY_EPS_S || loopDur - remainder < LOOP_BOUNDARY_EPS_S
386
+ }
387
+
388
+ // ── Per-tick plan ────────────────────────────────────────────────────────────
389
+
390
+ /** The track-0 video item on screen at `t`, with everything the tick needs. */
391
+ export interface ActiveClip {
392
+ item: VisualItem
393
+ clipId: string
394
+ /** The editing proxy the engine may open, or `''` when this clip is not playable — see {@link engineSrcFor}. */
395
+ src: string
396
+ /** Why `src` is empty. Surfaces as the Preparing placeholder's reason. */
397
+ blocked?: string
398
+ window: SourceWindow
399
+ placement: SourcePlacement
400
+ }
401
+
402
+ /** Everything one tick needs to know about the timeline at `t`. */
403
+ export interface TickPlan {
404
+ t: number
405
+ /** The active track-0 video item, or `null` for a gap / canvas project / past the last clip. */
406
+ active: ActiveClip | null
407
+ /** An `opaque` overlay is active — picture suppressed, audio kept (render semantics). */
408
+ opaque: boolean
409
+ /** The next track-0 video item starting after `t` — the prewarm target. */
410
+ next: { item: VisualItem; clipId: string; start: number } | null
411
+ /**
412
+ * The track-0 video item immediately BEFORE the active one — the retain
413
+ * target behind the playhead.
414
+ *
415
+ * Prewarming only forward was right when a cached source pinned a whole
416
+ * proxy, but it made the commonest editing gesture the expensive one: nudge
417
+ * the playhead back over a cut and the clip you just left had already been
418
+ * disposed, so it rebuilt from scratch — new decode worker, new demux — every
419
+ * time you crossed the seam. Naming it in the plan lets `retainFor` keep it
420
+ * alive instead, which is only affordable now that ranged loading made a
421
+ * retained source cost a sample index rather than a file.
422
+ */
423
+ prev: { item: VisualItem; clipId: string; start: number } | null
424
+ /** No track-0 video items at all — the legacy `isCanvasProject`. */
425
+ canvas: boolean
426
+ }
427
+
428
+ /**
429
+ * Track-0 video items, filtered and start-sorted — the legacy hook's `clips`
430
+ * memo verbatim (`(trackItems(project)[0] ?? []).filter(c => c.type === 'video')
431
+ * .sort((a, b) => a.start - b.start)`).
432
+ */
433
+ export function track0VideoItems(project: Project): VisualItem[] {
434
+ // Enabled tracks only — this feeds playback, so a skipped base track yields
435
+ // no clips and the preview falls through to its no-primary-video path.
436
+ return (enabledTrackItems(project)[0] ?? [])
437
+ .filter((c) => c.type === 'video')
438
+ .slice()
439
+ .sort((a, b) => a.start - b.start)
440
+ }
441
+
442
+ /**
443
+ * The track `track0VideoItems` drew its clips from — the object that carries
444
+ * the `volume`/`muted` those clips inherit. `undefined` when the project has no
445
+ * enabled tracks at all, which folds to the clip's own settings.
446
+ *
447
+ * `enabledTracks` applies `enabledTrackItems`' same skip test and preserves
448
+ * the same positions, so `[0]` here is the track `[0]` there came out of.
449
+ */
450
+ export function track0Track(project: Project): VisualTrack | undefined {
451
+ return enabledTracks(project)[0]
452
+ }
453
+
454
+ /**
455
+ * One clip as the HOST should see it: its own audio settings with its track's
456
+ * already folded in (`effectiveItemAudio` — volume multiplies, mute is
457
+ * either/or).
458
+ *
459
+ * A DERIVED object, never a write to the item. `SourceRequest` is built fresh
460
+ * per source every tick and nothing downstream reads `item` by identity (the
461
+ * host compares `start`, the source window's `inPoint`, `muted` and `volume`
462
+ * by value), so a spread is safe here — whereas assigning onto the item would
463
+ * hit the project's own object, which the renderer's `resolveProjectPaths` and
464
+ * the server's `_apply_project_edits` both mutate in place.
465
+ *
466
+ * Folding HERE, before the request exists, is what makes a TRACK mute behave
467
+ * exactly like a clip mute downstream: `retain`'s drop test reads
468
+ * `want.item.muted`, so the session respawns onto a wall clock the same way,
469
+ * and a track VOLUME change goes down the same live `setVolume` path a clip
470
+ * volume change does. Neither branch had to learn about tracks.
471
+ */
472
+ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): VisualItem {
473
+ const { volume, muted } = effectiveItemAudio(track, item)
474
+ return { ...item, volume, muted }
475
+ }
476
+
477
+ /**
478
+ * The transport's last instant.
479
+ *
480
+ * Two formulas, because the legacy hook has two and they legitimately differ:
481
+ *
482
+ * - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
483
+ * whose ceiling is `canvasMaxEndRef` = `max(overlayEnd, captionEnd)`. Note
484
+ * what is NOT in it: audio. A canvas project whose music outlasts its
485
+ * overlays stops at the overlays today.
486
+ * - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
487
+ * audioEnd)` — captions excluded, audio included — which timeline-core
488
+ * already ports verbatim (including its two documented faithfulness warts:
489
+ * last-clip-by-start rather than max, and track-0-video-only).
490
+ *
491
+ * Unifying them would be a behavior change in one mode or the other, so both
492
+ * are kept and the divergence is named here rather than smoothed over.
493
+ */
494
+ export function transportEndFor(project: Project): number {
495
+ const clips = track0VideoItems(project)
496
+ if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
497
+ const overlayEnd = enabledTrackItems(project).slice(1)
498
+ .flat()
499
+ .reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
500
+ const captionEnd = (project.captions?.segments ?? []).reduce(
501
+ (m: number, s) => Math.max(m, s.end ?? 0),
502
+ 0,
503
+ )
504
+ return Math.max(overlayEnd, captionEnd)
505
+ }
506
+
507
+ /**
508
+ * The one source the engine is allowed to open for a clip — or why it can't.
509
+ *
510
+ * **Proxy-only playback is structural, not a preference** (SP1 requirement 4).
511
+ * `playbackSrcFor(item, 'preview')` answers a broader question than the engine
512
+ * can act on: its chain is `nobg_preview_src > proxySrc > normalizedSrc > src`,
513
+ * and three of those four are things this engine must never hand to its
514
+ * demuxer — the masters are 4K 10-bit HEVC and `nobg_preview_src` is VP9 WebM,
515
+ * while the demuxer is MP4-only in v1 and the decoder is configured for H.264.
516
+ *
517
+ * `eligibility.ts` keeps whole projects containing those out of engine mode in
518
+ * the first place, but eligibility is evaluated once per project-LOAD (plan
519
+ * decision 2, no mid-session mode-flapping), so a clip added or edited
520
+ * afterwards can still present one. That clip alone goes to `preparing`; the
521
+ * project does not fall back. Comparing against the resolved src rather than
522
+ * just reading `item.proxySrc` is what catches the second case: a
523
+ * `nobg_preview_src` appearing mid-session outranks the proxy in the chain, and
524
+ * silently decoding the proxy instead would show a preview with the background
525
+ * the user just removed still in it.
526
+ */
527
+ export function engineSrcFor(
528
+ item: Pick<VisualItem, 'proxySrc'>,
529
+ window: Pick<SourceWindow, 'src'>,
530
+ ): { src: string; blocked?: string } {
531
+ if (!item.proxySrc) return { src: '', blocked: 'no editing proxy yet' }
532
+ if (window.src !== item.proxySrc) {
533
+ return { src: '', blocked: 'a higher-precedence preview source the engine cannot decode' }
534
+ }
535
+ return { src: item.proxySrc }
536
+ }
537
+
538
+ /** Injected resolver — `resolveAt(project, t, {variant:'preview'})` in production. */
539
+ export type SceneResolver = (project: Project, t: number) => Scene
540
+
541
+ /** The production resolver: timeline-core's preview variant, no wrapper semantics. */
542
+ export const previewResolver: SceneResolver = (project, t) =>
543
+ resolveAt(withEnabledItemTracks(project), t, { variant: 'preview' })
544
+
545
+ /**
546
+ * What the timeline says at `t`. Pure: no clock, no host, no painter.
547
+ *
548
+ * Activation comes from `resolveAt`, so the engine inherits the resolver's
549
+ * half-open `start <= t < end` predicate and its src precedence — the same
550
+ * answers `sample_frame` and (post-adoption) render give. Two details are
551
+ * resolved here rather than in the resolver:
552
+ *
553
+ * - **Which track-0 video item wins** when two overlap. `resolveAt` returns
554
+ * them in document order; the legacy hook picks the first in START order.
555
+ * The earliest-start rule is reproduced so an overlapping pair resolves the
556
+ * same way it does today.
557
+ * - **`opaque`** is read off any active OVERLAY item on any track, matching
558
+ * render's `overlays.some(o => o.opaque)` (`segment-plan.js`). Track-0
559
+ * videos and images never carry it.
560
+ */
561
+ export function planTick(
562
+ project: Project,
563
+ t: number,
564
+ clips: readonly VisualItem[],
565
+ resolver: SceneResolver = previewResolver,
566
+ ): TickPlan {
567
+ const scene = resolver(project, t)
568
+
569
+ let active: ActiveClip | null = null
570
+ let opaque = false
571
+ for (const resolved of scene.items) {
572
+ if (resolved.kind === 'overlay' && resolved.item.opaque === true) opaque = true
573
+ if (resolved.trackIdx !== 0 || resolved.kind !== 'video' || !resolved.window) continue
574
+ // `ResolvedItem.item` is the project's own object by reference (documented
575
+ // in timeline-core's `ResolvedItem`), so this recovers the editor-side
576
+ // fields (`loop`, `volume`, `muted`) the resolver's structural view omits.
577
+ const item = resolved.item as unknown as VisualItem
578
+ if (active && (active.item.start ?? 0) <= (item.start ?? 0)) continue
579
+ const usable = engineSrcFor(item, resolved.window)
580
+ active = {
581
+ item,
582
+ clipId: item.id,
583
+ src: usable.src,
584
+ blocked: usable.blocked,
585
+ window: resolved.window,
586
+ placement: placeInSource(item, resolved.window, t),
587
+ }
588
+ }
589
+
590
+ let next: TickPlan['next'] = null
591
+ for (const clip of clips) {
592
+ if (clip.start > t) {
593
+ next = { item: clip, clipId: clip.id, start: clip.start }
594
+ break
595
+ }
596
+ }
597
+
598
+ // `clips` is start-sorted, so the LAST one starting before `t` that is not
599
+ // the active clip is the one immediately behind it. Excluding the active clip
600
+ // by id matters when clips overlap: `active` resolves to the latest start
601
+ // among the overlapping set, and without the check `prev` would name that
602
+ // same clip and retain nothing extra.
603
+ let prev: TickPlan['prev'] = null
604
+ for (const clip of clips) {
605
+ if (clip.start >= t) break
606
+ if (active && clip.id === active.clipId) continue
607
+ prev = { item: clip, clipId: clip.id, start: clip.start }
608
+ }
609
+
610
+ return { t, active, opaque, next, prev, canvas: clips.length === 0 }
611
+ }
612
+
613
+ // ── Injected async surfaces ─────────────────────────────────────────────────
614
+
615
+ /** One clip's live decode session: a frame server for its src plus its own clock. */
616
+ export interface ClipSource {
617
+ clipId: string
618
+ src: string
619
+ frameServer: FrameServer
620
+ clock: MasterClock
621
+ timebase: ClipTimebase
622
+ }
623
+
624
+ export type SourceState =
625
+ | { status: 'idle' }
626
+ | { status: 'loading' }
627
+ | { status: 'ready'; source: ClipSource }
628
+ | { status: 'failed'; reason: string }
629
+
630
+ /** What the scheduler asks the host to have ready. */
631
+ export interface SourceRequest {
632
+ clipId: string
633
+ /**
634
+ * The clip, with its track's volume/mute already folded into its own
635
+ * (`withTrackAudio`) — a derived object, not the project's item. The host
636
+ * reads `volume`/`muted` off it as the EFFECTIVE values and never needs to
637
+ * know a track was involved.
638
+ */
639
+ item: VisualItem
640
+ /** The resolved preview src — the host does not re-derive it. */
641
+ src: string
642
+ /** Project time this clip's clock should be anchored at when it is built. */
643
+ anchorProjectS: number
644
+ }
645
+
646
+ /**
647
+ * The async half of the engine, injected.
648
+ *
649
+ * `retain` is the ONLY lifecycle call, deliberately: the scheduler declares the
650
+ * exact set of clips whose decode sessions must exist right now (the active one
651
+ * plus, inside the prewarm lead, the next one) and the host reconciles. Anything
652
+ * not in the set is disposed — that is the spike's `load` rule
653
+ * (`spikes/playback-engine/src/player.ts`: terminate the worker and spawn a new
654
+ * one, never `reset()` a live decoder and never `configure()` it again) stated
655
+ * as a set difference rather than as a sequence of imperative teardown calls
656
+ * the scheduler could get out of order.
657
+ */
658
+ export interface SourceHost {
659
+ /**
660
+ * Reconcile live sessions to exactly `requests`. Idempotent; safe every tick.
661
+ *
662
+ * CONTRACT: never call `Scheduler.sourceChanged` synchronously from inside
663
+ * this method. A session's readiness is an async fact by nature (a fetch, a
664
+ * demux, an `AudioWorklet` module load), and the scheduler reads `state()`
665
+ * immediately after this call within the same tick — a re-entrant notify
666
+ * would land mid-decision. `index.ts`'s host satisfies this by construction:
667
+ * every notify sits behind at least one `await`.
668
+ */
669
+ retain(requests: readonly SourceRequest[]): void
670
+ state(clipId: string): SourceState
671
+ /**
672
+ * A wall-clock `MasterClock` anchored at `startProjectS`, for every stretch
673
+ * with no clip audio to derive time from: gaps, canvas projects, and clips
674
+ * stuck in `preparing`. The scheduler disposes what it is handed.
675
+ */
676
+ fallbackClock(startProjectS: number): MasterClock
677
+ }
678
+
679
+ // ── Status ──────────────────────────────────────────────────────────────────
680
+
681
+ export type Transport = 'idle' | 'paused' | 'playing' | 'ended'
682
+
683
+ /**
684
+ * What the canvas shows.
685
+ * - `video` — painting decoded frames.
686
+ * - `black` — nothing on track 0 (gap, canvas project, past the last clip).
687
+ * The legacy `showVideo === false`.
688
+ * - `opaque` — an opaque overlay replaces the picture; audio keeps running.
689
+ * - `preparing` — the active clip has no usable media (proxy still encoding,
690
+ * load failed, decode failed). T7 draws "Preparing preview…".
691
+ */
692
+ export type Picture = 'video' | 'black' | 'opaque' | 'preparing'
693
+
694
+ export interface EngineStatus {
695
+ transport: Transport
696
+ picture: Picture
697
+ /** The track-0 clip that owns the picture at the current instant, or `null`. */
698
+ clipId: string | null
699
+ /** Set when `picture === 'preparing'`: why. */
700
+ reason?: string
701
+ /** True between a paused seek and the frame it asked for landing on the canvas. */
702
+ seeking: boolean
703
+ /** Which clock is driving time — for T7's HUD. */
704
+ clock: 'audio' | 'fallback'
705
+ }
706
+
707
+ export interface SchedulerDeps {
708
+ project: Project
709
+ host: SourceHost
710
+ painter?: Painter | null
711
+ resolver?: SceneResolver
712
+ /** Playhead, every tick. T6 bridges this to the editor's `clock.set`. */
713
+ onTime?: (projectS: number) => void
714
+ /** Called only when the status actually changes, never per tick. */
715
+ onStatusChange?: (status: EngineStatus) => void
716
+ onError?: (message: string) => void
717
+ /** Boundary−N seconds. Defaults to {@link PREWARM_LEAD_S}. */
718
+ prewarmLeadS?: number
719
+ /** Where playback starts. Defaults to 0. */
720
+ startProjectS?: number
721
+ }
722
+
723
+ export interface Scheduler {
724
+ /** Bind (or unbind) the canvas. Re-paints the current frame when one is due. */
725
+ attach(painter: Painter | null): void
726
+ play(): void
727
+ pause(): void
728
+ /** External scrub. Continues playing if it was playing (the legacy scrub effect's contract). */
729
+ seek(projectS: number): void
730
+ /**
731
+ * Set the live transport rate R (default 1). Applied to the active clock now
732
+ * and to every session built afterward; per-clip `speed` is a rebuild edit
733
+ * (`setProject`), not this. End-of-project and seek read time off the clock,
734
+ * so they inherit R automatically.
735
+ */
736
+ setRate(rate: number): void
737
+ /** One rAF step. No-op while `idle`; the facade pumps this only while `playing`. */
738
+ tick(): void
739
+ /** Project edits. Does NOT re-evaluate engine eligibility — that policy is T6's. */
740
+ setProject(project: Project): void
741
+ /** The host calls this when a clip's load resolves or fails. */
742
+ sourceChanged(clipId: string): void
743
+ status(): EngineStatus
744
+ /** Current playhead in project seconds. */
745
+ now(): number
746
+ dispose(): void
747
+ }
748
+
749
+ // ── The machine ─────────────────────────────────────────────────────────────
750
+
751
+ /** What the previous `apply` settled on — the change detector for the next one. */
752
+ interface AppliedSnapshot {
753
+ t: number
754
+ clipId: string | null
755
+ status: SourceState['status']
756
+ picture: Picture
757
+ wraps: number
758
+ }
759
+
760
+ class SchedulerImpl implements Scheduler {
761
+ private project: Project
762
+ private readonly host: SourceHost
763
+ private readonly resolver: SceneResolver
764
+ private readonly onTime?: (projectS: number) => void
765
+ private readonly onStatusChange?: (status: EngineStatus) => void
766
+ private readonly onError?: (message: string) => void
767
+ private readonly prewarmLeadS: number
768
+
769
+ private painter: Painter | null
770
+
771
+ /**
772
+ * Always non-null. Ours to dispose when `clockOwner === null` (a wall-clock
773
+ * fallback we asked the host for); the host's otherwise (it lives and dies
774
+ * with the clip's decode session).
775
+ */
776
+ private clock: MasterClock
777
+ private clockOwner: string | null = null
778
+ /** Identity of the session the clock came from — catches a rebuild under the same clip id. */
779
+ private clockSource: ClipSource | null = null
780
+ /**
781
+ * Live transport rate R (default 1). Pushed onto the active clock by `setRate`
782
+ * and re-applied to every clock the ownership swap adopts, so a rate set
783
+ * mid-clip survives boundaries, gaps and rebuilds.
784
+ */
785
+ private transportRate = 1
786
+
787
+ private transport: Transport = 'idle'
788
+ private picture: Picture = 'black'
789
+ private pictureReason: string | undefined
790
+ private clipId: string | null = null
791
+ /** The session with a streaming decode-ahead session open, if any. */
792
+ private streamingSource: ClipSource | null = null
793
+ /** Bumped on every seek / boundary / dispose; a resolved seek frame paints only if it still matches. */
794
+ private seekGen = 0
795
+ private pendingSeeks = 0
796
+ /**
797
+ * `clipId@mediaUs` of the frame the canvas holds (or has been asked for)
798
+ * while PAUSED. The dedupe key that keeps a project edit from re-seeking the
799
+ * decoder for a picture that has not moved. `null` whenever the canvas
800
+ * content is unknown — after a clear, during playback, or on a fresh attach.
801
+ */
802
+ private paintedKey: string | null = null
803
+ /** The looping clip the last applied tick was inside — the loop-end stop needs it after it goes inactive. */
804
+ private lastLoopClip: ActiveClip | null = null
805
+
806
+ private clips: VisualItem[]
807
+ /** The track `clips` came from — its `volume`/`muted` fold into each request. */
808
+ private clipsTrack: VisualTrack | undefined
809
+ private transportEnd: number
810
+ private applied: AppliedSnapshot | null = null
811
+ private lastStatusKey = ''
812
+ private disposed = false
813
+
814
+ constructor(deps: SchedulerDeps) {
815
+ this.project = deps.project
816
+ this.host = deps.host
817
+ this.resolver = deps.resolver ?? previewResolver
818
+ this.onTime = deps.onTime
819
+ this.onStatusChange = deps.onStatusChange
820
+ this.onError = deps.onError
821
+ this.prewarmLeadS = deps.prewarmLeadS ?? PREWARM_LEAD_S
822
+ this.painter = deps.painter ?? null
823
+ this.clips = track0VideoItems(deps.project)
824
+ this.clipsTrack = track0Track(deps.project)
825
+ this.transportEnd = transportEndFor(deps.project)
826
+ this.clock = this.host.fallbackClock(deps.startProjectS ?? 0)
827
+ }
828
+
829
+ // ── public surface ────────────────────────────────────────────────────────
830
+
831
+ attach(painter: Painter | null): void {
832
+ this.painter = painter
833
+ // A canvas that just appeared is blank, whatever was on the last one.
834
+ this.paintedKey = null
835
+ if (this.disposed) return
836
+ // Re-establish the current frame. While playing the next tick does it for free.
837
+ if (painter && this.transport !== 'playing') this.apply(this.clock.now(), { force: true })
838
+ }
839
+
840
+ play(): void {
841
+ if (this.disposed) return
842
+ const t = this.clock.now()
843
+ // Legacy `togglePlay`, parked at or past the end: the gap branch finds no
844
+ // next clip, falls through its `if (t < projectEnd)` guard and RETURNS —
845
+ // pressing play at the end of a project does nothing at all. Reproduced so
846
+ // the button can't fake a running transport with nowhere to run.
847
+ if (t >= this.transportEnd) {
848
+ this.transport = 'ended'
849
+ this.publish()
850
+ return
851
+ }
852
+ this.transport = 'playing'
853
+ this.clock.play()
854
+ this.apply(t, { force: true })
855
+ }
856
+
857
+ pause(): void {
858
+ if (this.disposed) return
859
+ if (this.transport === 'playing') this.transport = 'paused'
860
+ this.clock.pause()
861
+ this.stopStream()
862
+ this.publish()
863
+ }
864
+
865
+ seek(projectS: number): void {
866
+ if (this.disposed) return
867
+ const t = Math.max(0, projectS)
868
+ if (t >= this.transportEnd) {
869
+ // Scrubbed past the end. Legacy: the scrub effect's no-next-clip branch
870
+ // pauses the active video and calls `setIsPlaying(false)` — "the picture
871
+ // goes dark but its audio keeps going" is the bug that comment describes,
872
+ // and stopping outright is its fix.
873
+ this.clock.pause()
874
+ this.stopStream()
875
+ this.transport = 'ended'
876
+ } else if (this.transport === 'ended') {
877
+ this.transport = 'paused'
878
+ }
879
+ this.apply(t, { force: true, seeked: true })
880
+ }
881
+
882
+ setRate(rate: number): void {
883
+ if (this.disposed) return
884
+ // Idempotent: the shuttle calls setRate(1) on stop/reverse, and re-anchoring
885
+ // (worse, flushing) the active clock for an unchanged rate is pure churn.
886
+ if (rate === this.transportRate) return
887
+ this.transportRate = rate
888
+ // The active clock takes it immediately (re-anchor + ring flush — no jump);
889
+ // clocks built afterward pick it up in `apply`'s ownership swap.
890
+ this.clock.setTransportRate(rate)
891
+ }
892
+
893
+ tick(): void {
894
+ if (this.disposed || this.transport === 'idle') return
895
+ const t = this.clock.now()
896
+
897
+ if (this.transport === 'playing' && t >= this.transportEnd) {
898
+ // End of project. Legacy arrives here two ways and both land on the same
899
+ // number: the gap clock's `t >= gapTargetRef` tail with `gapTargetRef =
900
+ // projectEnd` (trailing overlays and audio having played out — the
901
+ // "continue advancing time via the gap clock" branch of the last-clip
902
+ // case), and the canvas rAF's `next >= maxEnd` clamp. Both emit the
903
+ // ceiling exactly, then stop.
904
+ this.clock.pause()
905
+ this.clock.seek(this.transportEnd)
906
+ this.transport = 'ended'
907
+ this.apply(this.transportEnd, { force: true })
908
+ return
909
+ }
910
+
911
+ this.apply(t, {})
912
+ }
913
+
914
+ setProject(project: Project): void {
915
+ if (this.disposed) return
916
+ this.project = project
917
+ this.clips = track0VideoItems(project)
918
+ this.clipsTrack = track0Track(project)
919
+ this.transportEnd = transportEndFor(project)
920
+ // This is how a `preparing` clip resolves: the SSE that delivered
921
+ // `proxySrc` produced a new project object, `retain` reports a different
922
+ // `src` for the same clip id, and the host rebuilds that ONE session. No
923
+ // mode change and no reload — the engine never reverts a running project to
924
+ // the legacy player because one clip's media was late.
925
+ //
926
+ // Engine ELIGIBILITY is deliberately not re-evaluated here (plan decision
927
+ // 2: evaluated once per project-load, no mid-session mode-flapping). That
928
+ // policy, including "initially-ineligible stays legacy for the session",
929
+ // belongs to T6.
930
+ this.apply(this.clock.now(), { force: true })
931
+ }
932
+
933
+ sourceChanged(clipId: string): void {
934
+ if (this.disposed) return
935
+ // Only the clip currently on screen can change what is painted; a prewarmed
936
+ // clip landing early changes nothing until its boundary arrives.
937
+ if (clipId !== this.clipId) return
938
+ this.apply(this.clock.now(), { force: true })
939
+ }
940
+
941
+ status(): EngineStatus {
942
+ return {
943
+ transport: this.transport,
944
+ picture: this.picture,
945
+ clipId: this.clipId,
946
+ reason: this.pictureReason,
947
+ seeking: this.pendingSeeks > 0,
948
+ clock: this.clock.kind,
949
+ }
950
+ }
951
+
952
+ now(): number {
953
+ return this.clock.now()
954
+ }
955
+
956
+ dispose(): void {
957
+ if (this.disposed) return
958
+ this.disposed = true
959
+ this.seekGen++
960
+ this.stopStream()
961
+ if (this.clockOwner === null) this.clock.dispose()
962
+ this.clockOwner = null
963
+ this.clockSource = null
964
+ this.host.retain([])
965
+ this.painter = null
966
+ }
967
+
968
+ // ── the tick ──────────────────────────────────────────────────────────────
969
+
970
+ /**
971
+ * The single decision point. Everything above funnels here with a project
972
+ * time and a reason; nothing below it reads a clock.
973
+ *
974
+ * `force` re-runs the side effects (repaint, clear) even when nothing the
975
+ * snapshot tracks moved — used by the four events whose entire point is that
976
+ * something OUTSIDE the snapshot did: `seek`, `setProject`, `attach`,
977
+ * `sourceChanged`. It deliberately does NOT restart a live decode stream;
978
+ * only a genuine discontinuity (`seeked`, a boundary, a loop wrap, a rebuilt
979
+ * session) does that, so a project edit during playback — an overlay drag
980
+ * spreads the project object every frame — cannot stutter the picture.
981
+ */
982
+ private apply(t: number, opts: { force?: boolean; seeked?: boolean }): void {
983
+ if (this.disposed) return
984
+ if (this.transport === 'idle') this.transport = 'paused'
985
+
986
+ const plan = planTick(this.project, t, this.clips, this.resolver)
987
+ const nextClipId = plan.active?.clipId ?? null
988
+
989
+ // ── 1. Loop-end stop — the legacy hook's THIRD loopOffset site ──────────
990
+ // A looping clip whose project window ends mid-loop stops the transport at
991
+ // `clip.end` rather than advancing to whatever comes next. Detected on the
992
+ // tick that leaves the clip, since `resolveAt` is half-open and simply
993
+ // stops returning it at `end`. See `endsOnLoopBoundary` for the other case.
994
+ const leaving = this.lastLoopClip
995
+ if (
996
+ leaving &&
997
+ this.transport === 'playing' &&
998
+ !opts.seeked &&
999
+ nextClipId !== leaving.clipId &&
1000
+ t >= (leaving.item.end ?? 0) &&
1001
+ !endsOnLoopBoundary(leaving.item, leaving.window)
1002
+ ) {
1003
+ const stopAt = leaving.item.end ?? 0
1004
+ this.lastLoopClip = null
1005
+ this.clock.pause()
1006
+ this.clock.seek(stopAt)
1007
+ this.transport = 'paused'
1008
+ this.apply(stopAt, { force: true })
1009
+ return
1010
+ }
1011
+
1012
+ // ── 2. Release the outgoing session BEFORE the host can dispose it ──────
1013
+ // `retain` below drops everything not in the new set, and a dropped session
1014
+ // takes its clock and its worker with it. Anything still pointing at the
1015
+ // outgoing session has to let go here, while it is definitely alive.
1016
+ if (nextClipId !== this.clipId) {
1017
+ this.stopStream()
1018
+ if (this.clockOwner !== null) this.clock.pause()
1019
+ }
1020
+
1021
+ // ── 3. Declare the live-session set (prewarm lives here) ───────────────
1022
+ this.retainFor(plan, t)
1023
+
1024
+ const state: SourceState = plan.active
1025
+ ? this.host.state(plan.active.clipId)
1026
+ : { status: 'idle' }
1027
+ const source = state.status === 'ready' ? state.source : null
1028
+
1029
+ // ── 4. Picture ─────────────────────────────────────────────────────────
1030
+ let picture: Picture
1031
+ let reason: string | undefined
1032
+ if (!plan.active) {
1033
+ picture = 'black'
1034
+ } else if (!source) {
1035
+ // ONE state for three causes — proxy not yet encoded, proxy failed to
1036
+ // load, proxy failed to DECODE mid-session. The plan is explicit that
1037
+ // they route identically: this clip's range shows the Preparing
1038
+ // placeholder while project time keeps advancing (gap semantics), and the
1039
+ // engine never hands the whole project back to the legacy player.
1040
+ picture = 'preparing'
1041
+ reason =
1042
+ state.status === 'failed'
1043
+ ? state.reason
1044
+ : (plan.active.blocked ?? 'preparing preview')
1045
+ } else if (plan.opaque) {
1046
+ // Registry disposition: `opaque` unifies onto RENDER semantics now that
1047
+ // there is a compositing stage. The overlay replaces the picture; the
1048
+ // clip's audio — and therefore the clock derived from it — keeps running.
1049
+ picture = 'opaque'
1050
+ } else {
1051
+ picture = 'video'
1052
+ }
1053
+
1054
+ // ── 5. Clock ownership ─────────────────────────────────────────────────
1055
+ const owner = source ? plan.active!.clipId : null
1056
+ const ownerChanged = owner !== this.clockOwner || source !== this.clockSource
1057
+ if (ownerChanged) {
1058
+ if (this.clockOwner === null) this.clock.dispose()
1059
+ const wasPlaying = this.transport === 'playing'
1060
+ this.clock = source ? source.clock : this.host.fallbackClock(t)
1061
+ this.clockOwner = owner
1062
+ this.clockSource = source
1063
+ // Anchored at `t`, NOT at the incoming clip's `start`.
1064
+ //
1065
+ // Legacy snaps (`lastTimeRef.current = next.start; onTimeUpdate(next.start)`)
1066
+ // because on that path project time is DERIVED from the freshly loaded
1067
+ // <video>'s currentTime, so the playhead had to be told where the cut
1068
+ // was. Here project time is primary and media position is derived from
1069
+ // it, so there is nothing to snap — and anchoring at `t` removes the
1070
+ // ≤1-frame backwards step the snap introduces at every cut.
1071
+ this.clock.seek(t)
1072
+ if (wasPlaying) this.clock.play()
1073
+ // A freshly adopted clock defaults to 1×; carry the live transport rate
1074
+ // onto it so a rate set mid-clip survives the boundary/gap swap. Guarded
1075
+ // so R===1 stays a strict no-op (no spurious re-anchor on the default
1076
+ // path). Applied AFTER seek/play, whose `restartAt` re-bases the ramp.
1077
+ if (this.transportRate !== 1) this.clock.setTransportRate(this.transportRate)
1078
+ this.seekGen++
1079
+ } else if (opts.seeked) {
1080
+ this.clock.seek(t)
1081
+ this.seekGen++
1082
+ }
1083
+
1084
+ const clipChanged = nextClipId !== this.clipId
1085
+ this.clipId = nextClipId
1086
+ const pictureChanged = picture !== this.picture
1087
+ this.picture = picture
1088
+ this.pictureReason = reason
1089
+
1090
+ // ── 6. Media session ───────────────────────────────────────────────────
1091
+ if (source && plan.active) {
1092
+ const mediaUs = containerTsUsFor(
1093
+ source.frameServer.video.firstPresentationTsUs,
1094
+ plan.active.placement.mediaS,
1095
+ )
1096
+ const wrapped = (this.applied?.wraps ?? 0) !== plan.active.placement.wraps
1097
+ // A discontinuity in MEDIA time — the only thing a decode session cares
1098
+ // about. `force` is absent on purpose (see the method doc).
1099
+ const discontinuity = ownerChanged || clipChanged || wrapped || opts.seeked === true
1100
+
1101
+ if (this.transport === 'playing') {
1102
+ if (discontinuity || this.streamingSource !== source) {
1103
+ // Loop wrap: the media pointer jumps back to the window start while
1104
+ // project time keeps advancing — the legacy wrap site's
1105
+ // `video.currentTime = clipInPoint`, expressed as a fresh decode
1106
+ // session rather than a seek on a live one (the spike's rule).
1107
+ this.stopStream()
1108
+ source.frameServer.startStream(mediaUs)
1109
+ this.streamingSource = source
1110
+ // A wrap resets the PICTURE's media position to the loop window's
1111
+ // start while project time keeps climbing; the audio clock's ring
1112
+ // must restart at that same media position too, or it keeps
1113
+ // decoding from the pre-wrap mapping (`MasterClock.seek`'s `mediaS`
1114
+ // param exists exactly for this).
1115
+ if (wrapped) source.clock.seek(t, plan.active.placement.mediaS)
1116
+ }
1117
+ // The stream owns the canvas while playing; whatever a paused seek had
1118
+ // put there is long gone.
1119
+ this.paintedKey = null
1120
+ this.pullFrame(source, plan.active, mediaUs)
1121
+ } else {
1122
+ this.stopStream()
1123
+ // Repaint only when the canvas does not already hold this exact frame.
1124
+ // Without this guard every project spread — an overlay drag emits one
1125
+ // per pointer event — would fire a fresh decoder seek for a picture
1126
+ // that has not moved.
1127
+ const key = `${plan.active.clipId}@${Math.round(mediaUs)}`
1128
+ if (picture === 'video' && key !== this.paintedKey) {
1129
+ this.paintedKey = key
1130
+ this.paintFromSeek(source, plan.active, mediaUs)
1131
+ }
1132
+ }
1133
+ } else {
1134
+ this.stopStream()
1135
+ }
1136
+
1137
+ // ── 7. Surface ─────────────────────────────────────────────────────────
1138
+ if (picture !== 'video' && (pictureChanged || opts.force)) {
1139
+ this.painter?.clear()
1140
+ this.paintedKey = null
1141
+ }
1142
+
1143
+ this.lastLoopClip = plan.active?.placement.looping ? plan.active : null
1144
+ this.applied = {
1145
+ t,
1146
+ clipId: nextClipId,
1147
+ status: state.status,
1148
+ picture,
1149
+ wraps: plan.active?.placement.wraps ?? 0,
1150
+ }
1151
+ this.onTime?.(t)
1152
+ this.publish()
1153
+ }
1154
+
1155
+ /**
1156
+ * Declare the live-session set: the active clip, the next one once its
1157
+ * boundary is inside the prewarm lead, and the previous one. Everything else
1158
+ * the host disposes — terminate-and-respawn stated as a set difference.
1159
+ *
1160
+ * The window is deliberately asymmetric. FORWARD retention is conditional on
1161
+ * `prewarmLeadS` because playback reaches the next clip on a schedule, so
1162
+ * there is a right moment to start; BACKWARD retention is unconditional
1163
+ * because a scrub back across the cut has no schedule at all — by the time
1164
+ * you could predict it, it has already happened, and predicting it wrongly is
1165
+ * exactly the rebuild this exists to avoid. Both extra sessions usually cost
1166
+ * nothing beyond a clock: `FrameServer` is refcounted by SRC, and on a
1167
+ * silence-trimmed timeline prev/active/next are three windows into ONE proxy.
1168
+ *
1169
+ * Every request's `item` carries its TRACK's volume/mute already folded in
1170
+ * (`withTrackAudio`). The plan's own items are untouched — only what crosses
1171
+ * into the host is derived — so the picture side keeps reading the project's
1172
+ * real objects.
1173
+ */
1174
+ private retainFor(plan: TickPlan, t: number): void {
1175
+ const requests: SourceRequest[] = []
1176
+ if (plan.active && plan.active.src) {
1177
+ requests.push({
1178
+ clipId: plan.active.clipId,
1179
+ item: withTrackAudio(this.clipsTrack, plan.active.item),
1180
+ src: plan.active.src,
1181
+ anchorProjectS: t,
1182
+ })
1183
+ }
1184
+ if (plan.next && plan.next.start - t <= this.prewarmLeadS) {
1185
+ this.pushRetain(requests, plan.next)
1186
+ }
1187
+ if (plan.prev) {
1188
+ this.pushRetain(requests, plan.prev)
1189
+ }
1190
+ this.host.retain(requests)
1191
+ }
1192
+
1193
+ /**
1194
+ * Add a non-active clip to the retained set, if it has an engine-usable src.
1195
+ *
1196
+ * The same proxy-only gate the active clip goes through, over the same
1197
+ * timeline-core window the resolver itself computes — never a second,
1198
+ * drifting copy of the precedence chain.
1199
+ */
1200
+ private pushRetain(
1201
+ requests: SourceRequest[],
1202
+ clip: { item: VisualItem; clipId: string; start: number },
1203
+ ): void {
1204
+ const { src } = engineSrcFor(clip.item, sourceWindow(clip.item, 'preview'))
1205
+ if (!src) return
1206
+ requests.push({
1207
+ clipId: clip.clipId,
1208
+ item: withTrackAudio(this.clipsTrack, clip.item),
1209
+ src,
1210
+ anchorProjectS: clip.start,
1211
+ })
1212
+ }
1213
+
1214
+ /** Playback path: paint whatever frame is due at the media clock. */
1215
+ private pullFrame(source: ClipSource, active: ActiveClip, mediaUs: number): void {
1216
+ const { frame } = source.frameServer.nextFrameFor(mediaUs)
1217
+ if (!frame) return
1218
+ // Under an opaque overlay the frame is still PULLED, then closed unpainted.
1219
+ // Not painting is the point; not pulling would let the decode-ahead buffer
1220
+ // fill, stall the pipeline behind it, and leave a stale frame to slam onto
1221
+ // the canvas the instant the overlay ends.
1222
+ if (this.picture !== 'video' || !this.painter) {
1223
+ frame.close()
1224
+ return
1225
+ }
1226
+ this.paintFrame(frame, source, active)
1227
+ }
1228
+
1229
+ /** Paused path: one frame-accurate seek, painted when it lands. */
1230
+ private paintFromSeek(source: ClipSource, active: ActiveClip, mediaUs: number): void {
1231
+ if (!this.painter) return
1232
+ const gen = this.seekGen
1233
+ this.pendingSeeks++
1234
+ const { frame } = source.frameServer.seek(mediaUs)
1235
+ void frame.then((f) => {
1236
+ this.pendingSeeks--
1237
+ if (!f) {
1238
+ // Nothing landed on the canvas, so the dedupe key must not claim it did
1239
+ // — otherwise a retry (the source rebuilt, the clip re-entered) would be
1240
+ // suppressed forever.
1241
+ if (gen === this.seekGen) this.paintedKey = null
1242
+ this.publish()
1243
+ return
1244
+ }
1245
+ // Superseded by a newer scrub, a boundary or a dispose: the frame is ours
1246
+ // to close and must not reach the canvas.
1247
+ if (this.disposed || gen !== this.seekGen || !this.painter || this.picture !== 'video') {
1248
+ f.close()
1249
+ this.publish()
1250
+ return
1251
+ }
1252
+ this.paintFrame(f, source, active)
1253
+ this.publish()
1254
+ })
1255
+ }
1256
+
1257
+ private paintFrame(frame: VideoFrame, source: ClipSource, active: ActiveClip): void {
1258
+ const painter = this.painter
1259
+ if (!painter) {
1260
+ frame.close()
1261
+ return
1262
+ }
1263
+ try {
1264
+ const size = painter.size()
1265
+ // `drawImage` reads a VideoFrame in its DISPLAY coordinates (pixel aspect
1266
+ // applied); the track's `coded` dims are the fallback for a frame that
1267
+ // does not report them.
1268
+ const w = frame.displayWidth || source.frameServer.video.coded.width
1269
+ const h = frame.displayHeight || source.frameServer.video.coded.height
1270
+ painter.paint(frame, drawPlanFor(active.item, w, h, size.width, size.height))
1271
+ } catch (err) {
1272
+ this.onError?.(`paint: ${err instanceof Error ? err.message : String(err)}`)
1273
+ } finally {
1274
+ // The frame-server contract: whoever receives a frame closes it. Holding
1275
+ // one past the paint exhausts the decoder's surface pool.
1276
+ frame.close()
1277
+ }
1278
+ }
1279
+
1280
+ private stopStream(): void {
1281
+ const source = this.streamingSource
1282
+ if (!source) return
1283
+ this.streamingSource = null
1284
+ source.frameServer.stopStream()
1285
+ }
1286
+
1287
+ private publish(): void {
1288
+ const status = this.status()
1289
+ const key = [
1290
+ status.transport,
1291
+ status.picture,
1292
+ status.clipId,
1293
+ status.reason ?? '',
1294
+ status.seeking,
1295
+ status.clock,
1296
+ ].join('|')
1297
+ if (key === this.lastStatusKey) return
1298
+ this.lastStatusKey = key
1299
+ this.onStatusChange?.(status)
1300
+ }
1301
+ }
1302
+
1303
+ /**
1304
+ * Media position (seconds inside the loaded src) → container µs.
1305
+ *
1306
+ * The loop-aware counterpart of `audio-clock.ts`'s `mediaTsUsForProjectTime`,
1307
+ * which folds the project→media mapping and the track origin together and is
1308
+ * therefore only linear for a non-looping clip. `placeInSource` has already
1309
+ * done the (possibly wrapped) project→media step, so all that is left here is
1310
+ * the track's own t=0 origin.
1311
+ *
1312
+ * The origin passed in is the VIDEO track's (`frameServer.video
1313
+ * .firstPresentationTsUs`), never the clock's — `ClipTimebase
1314
+ * .firstPresentationTsUs` is the AUDIO track's origin, and demux is explicit
1315
+ * that the two tracks of one file need not share one. Crossing them would
1316
+ * offset every painted frame by their difference.
1317
+ */
1318
+ function containerTsUsFor(videoFirstPresentationTsUs: number, mediaS: number): number {
1319
+ return videoFirstPresentationTsUs + mediaS * 1_000_000
1320
+ }
1321
+
1322
+ export function createScheduler(deps: SchedulerDeps): Scheduler {
1323
+ return new SchedulerImpl(deps)
1324
+ }