@bycrux/editor 0.12.0 → 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 (266) 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 +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  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 +586 -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 +6 -2
  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 +2202 -251
  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/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,316 @@
1
+ /**
2
+ * SP4 T4 — the ring processor's own rules, tested against the shipped string.
3
+ *
4
+ * The worklet ships as an inlined source string (plan decision 6), so it can
5
+ * neither be imported as a module nor type-checked. It can be *executed*
6
+ * though — `decode-worker-source.test.ts` is the precedent. Its only free
7
+ * identifiers are `AudioWorkletProcessor`, `registerProcessor`, `globalThis`
8
+ * and `currentTime`; the first three are shadowed by `new Function`
9
+ * parameters, and `currentTime` (which the report cadence reads dynamically)
10
+ * is driven through the real global object.
11
+ *
12
+ * Worth executing rather than re-implementing main-side, because the rules
13
+ * that live in this string live ONLY in this string: silence-through-
14
+ * underruns (the clock must advance through starvation or the painter
15
+ * freezes), the pause gate, and the epoch echo that lets main discard reports
16
+ * from a ring it has already reset.
17
+ */
18
+ import { describe, it, expect, afterEach } from 'vitest'
19
+ import { audioWorkletSource, RING_PROCESSOR_NAME } from '../audio-worklet-source'
20
+
21
+ const QUANTUM = 128
22
+
23
+ interface Report {
24
+ t: 'consumed'
25
+ epoch: number
26
+ samplesConsumed: number
27
+ underrunFrames: number
28
+ queuedFrames: number
29
+ }
30
+
31
+ interface RingProcessor {
32
+ process(inputs: unknown[], outputs: Float32Array[][]): boolean
33
+ }
34
+
35
+ interface Harness {
36
+ /** Name the string registered under, or null if the guard suppressed it. */
37
+ registeredAs: string | null
38
+ processor: RingProcessor
39
+ /** Everything the processor posted back to main, in order. */
40
+ reports: Report[]
41
+ /** main → worklet. */
42
+ send(msg: Record<string, unknown>): void
43
+ /** Run one render quantum and return the (fresh, zero-filled) output channels. */
44
+ render(channels?: number): Float32Array[]
45
+ }
46
+
47
+ function setCurrentTime(seconds: number): void {
48
+ ;(globalThis as unknown as { currentTime: number }).currentTime = seconds
49
+ }
50
+
51
+ afterEach(() => {
52
+ delete (globalThis as unknown as { currentTime?: number }).currentTime
53
+ })
54
+
55
+ function harness(processorOptions: Record<string, unknown> = {}, scope: Record<string, unknown> = {}): Harness {
56
+ setCurrentTime(0)
57
+ const reports: Report[] = []
58
+ let onmessage: ((ev: { data: unknown }) => void) | null = null
59
+
60
+ const port = {
61
+ set onmessage(fn: ((ev: { data: unknown }) => void) | null) {
62
+ onmessage = fn
63
+ },
64
+ get onmessage() {
65
+ return onmessage
66
+ },
67
+ postMessage(msg: Report) {
68
+ reports.push(msg)
69
+ },
70
+ }
71
+
72
+ class FakeAudioWorkletProcessor {
73
+ port = port
74
+ }
75
+
76
+ let registered: { name: string; cls: new (opts: unknown) => RingProcessor } | null = null
77
+ const registerProcessor = (name: string, cls: new (opts: unknown) => RingProcessor) => {
78
+ registered = { name, cls }
79
+ }
80
+
81
+ const load = new Function(
82
+ 'AudioWorkletProcessor',
83
+ 'registerProcessor',
84
+ 'globalThis',
85
+ audioWorkletSource,
86
+ ) as (base: unknown, register: unknown, scope: unknown) => void
87
+ load(FakeAudioWorkletProcessor, registerProcessor, scope)
88
+
89
+ const entry = registered as { name: string; cls: new (opts: unknown) => RingProcessor } | null
90
+ if (!entry) {
91
+ return {
92
+ registeredAs: null,
93
+ processor: { process: () => true },
94
+ reports,
95
+ send: () => {},
96
+ render: () => [],
97
+ }
98
+ }
99
+
100
+ const processor = new entry.cls({ processorOptions })
101
+ return {
102
+ registeredAs: entry.name,
103
+ processor,
104
+ reports,
105
+ send(msg) {
106
+ onmessage?.({ data: msg })
107
+ },
108
+ render(channels = 2) {
109
+ // The host hands `process` zero-filled output buffers every quantum;
110
+ // "silence" in this processor means "left untouched".
111
+ const out = Array.from({ length: channels }, () => new Float32Array(QUANTUM))
112
+ processor.process([], [out])
113
+ return out
114
+ },
115
+ }
116
+ }
117
+
118
+ /** Interleaved stereo PCM chunk message with `frames` frames of a constant value per channel. */
119
+ function chunk(frames: number, left: number, right: number) {
120
+ const pcm = new Float32Array(frames * 2)
121
+ for (let i = 0; i < frames; i++) {
122
+ pcm[i * 2] = left
123
+ pcm[i * 2 + 1] = right
124
+ }
125
+ return { t: 'chunk', pcm, channels: 2, frames }
126
+ }
127
+
128
+ describe('registration', () => {
129
+ it('registers the processor name audio-clock.ts constructs its node with', () => {
130
+ expect(harness().registeredAs).toBe(RING_PROCESSOR_NAME)
131
+ expect(RING_PROCESSOR_NAME).toBe('montaj-engine-pcm-ring')
132
+ })
133
+
134
+ it('does not re-register into a scope that already has it (shared context, second clock)', () => {
135
+ const scope: Record<string, unknown> = {}
136
+ expect(harness({}, scope).registeredAs).toBe(RING_PROCESSOR_NAME)
137
+ // A duplicate registerProcessor throws NotSupportedError and would reject
138
+ // addModule, dropping a healthy engine onto the wall clock.
139
+ expect(harness({}, scope).registeredAs).toBe(null)
140
+ })
141
+ })
142
+
143
+ describe('pause gate', () => {
144
+ it('renders silence and does not advance the clock before play', () => {
145
+ const h = harness()
146
+ h.send(chunk(QUANTUM, 0.5, -0.5))
147
+ const out = h.render()
148
+ expect(Array.from(out[0]).every((v) => v === 0)).toBe(true)
149
+ setCurrentTime(0.2)
150
+ h.render()
151
+ const last = h.reports[h.reports.length - 1]
152
+ expect(last.samplesConsumed).toBe(0)
153
+ // The chunk is still queued — pausing does not throw audio away.
154
+ expect(last.queuedFrames).toBe(QUANTUM)
155
+ })
156
+
157
+ it('stops advancing on pause and resumes from exactly where it stopped', () => {
158
+ const h = harness()
159
+ h.send({ t: 'play' })
160
+ h.send(chunk(QUANTUM * 3, 0.25, 0.25))
161
+ h.render()
162
+ h.send({ t: 'pause' })
163
+ const atPause = h.reports[h.reports.length - 1]
164
+ expect(atPause.samplesConsumed).toBe(QUANTUM)
165
+ expect(atPause.queuedFrames).toBe(QUANTUM * 2)
166
+
167
+ h.render() // paused: no drain, no advance
168
+ setCurrentTime(0.5)
169
+ h.render()
170
+ expect(h.reports[h.reports.length - 1].samplesConsumed).toBe(QUANTUM)
171
+
172
+ h.send({ t: 'play' })
173
+ const out = h.render()
174
+ expect(out[0][0]).toBeCloseTo(0.25, 6)
175
+ setCurrentTime(1)
176
+ h.render()
177
+ expect(h.reports[h.reports.length - 1].samplesConsumed).toBe(QUANTUM * 3)
178
+ })
179
+
180
+ it('reports immediately on pause rather than making main wait a full interval', () => {
181
+ const h = harness()
182
+ h.send({ t: 'play' })
183
+ h.render()
184
+ const before = h.reports.length
185
+ h.send({ t: 'pause' })
186
+ expect(h.reports.length).toBe(before + 1)
187
+ })
188
+ })
189
+
190
+ describe('ring drain', () => {
191
+ it('drains interleaved PCM into the output channels in order', () => {
192
+ const h = harness()
193
+ h.send({ t: 'play' })
194
+ h.send(chunk(QUANTUM, 0.5, -0.5))
195
+ const out = h.render()
196
+ expect(out[0][0]).toBeCloseTo(0.5, 6)
197
+ expect(out[1][0]).toBeCloseTo(-0.5, 6)
198
+ expect(out[0][QUANTUM - 1]).toBeCloseTo(0.5, 6)
199
+ })
200
+
201
+ it('spans chunk boundaries within one render quantum', () => {
202
+ const h = harness()
203
+ h.send({ t: 'play' })
204
+ h.send(chunk(QUANTUM / 2, 1, 1))
205
+ h.send(chunk(QUANTUM / 2, -1, -1))
206
+ const out = h.render()
207
+ expect(out[0][0]).toBeCloseTo(1, 6)
208
+ expect(out[0][QUANTUM / 2 - 1]).toBeCloseTo(1, 6)
209
+ expect(out[0][QUANTUM / 2]).toBeCloseTo(-1, 6)
210
+ expect(out[0][QUANTUM - 1]).toBeCloseTo(-1, 6)
211
+ })
212
+
213
+ it('duplicates the last source channel when a mono clip plays to a stereo output', () => {
214
+ const h = harness()
215
+ h.send({ t: 'play' })
216
+ h.send({ t: 'chunk', pcm: new Float32Array(QUANTUM).fill(0.75), channels: 1, frames: QUANTUM })
217
+ const out = h.render()
218
+ expect(out[0][0]).toBeCloseTo(0.75, 6)
219
+ expect(out[1][0]).toBeCloseTo(0.75, 6)
220
+ })
221
+ })
222
+
223
+ describe('silence through underruns', () => {
224
+ it('renders silence AND keeps the clock advancing when the ring is empty', () => {
225
+ const h = harness()
226
+ h.send({ t: 'play' })
227
+ const out = h.render()
228
+ expect(Array.from(out[0]).every((v) => v === 0)).toBe(true)
229
+
230
+ setCurrentTime(0.2)
231
+ h.render()
232
+ const report = h.reports[h.reports.length - 1]
233
+ // The load-bearing half: a starved ring must NOT stall the clock, or the
234
+ // canvas painter that follows it freezes on every slow decode tick.
235
+ expect(report.samplesConsumed).toBe(QUANTUM * 2)
236
+ expect(report.underrunFrames).toBe(QUANTUM * 2)
237
+ expect(report.queuedFrames).toBe(0)
238
+ })
239
+
240
+ it('counts only the starved frames, not the rendered ones', () => {
241
+ const h = harness()
242
+ h.send({ t: 'play' })
243
+ h.send(chunk(QUANTUM, 0.5, 0.5))
244
+ h.render() // fully fed
245
+ h.render() // starved
246
+ setCurrentTime(0.2)
247
+ h.render() // starved
248
+ const report = h.reports[h.reports.length - 1]
249
+ expect(report.samplesConsumed).toBe(QUANTUM * 3)
250
+ expect(report.underrunFrames).toBe(QUANTUM * 2)
251
+ // samplesConsumed - underrunFrames is what audio-clock.ts treats as the
252
+ // ring's drained frames; it must equal what was actually enqueued.
253
+ expect(report.samplesConsumed - report.underrunFrames).toBe(QUANTUM)
254
+ })
255
+ })
256
+
257
+ describe('report cadence and epoch', () => {
258
+ it('reports no more often than the injected interval', () => {
259
+ const h = harness({ reportIntervalS: 0.1 })
260
+ h.send({ t: 'play' })
261
+ h.render()
262
+ h.render()
263
+ expect(h.reports.length).toBe(0)
264
+ setCurrentTime(0.1)
265
+ h.render()
266
+ expect(h.reports.length).toBe(1)
267
+ setCurrentTime(0.15)
268
+ h.render()
269
+ expect(h.reports.length).toBe(1)
270
+ setCurrentTime(0.2)
271
+ h.render()
272
+ expect(h.reports.length).toBe(2)
273
+ })
274
+
275
+ it('honors an injected interval rather than a duplicated constant', () => {
276
+ const h = harness({ reportIntervalS: 0.5 })
277
+ h.send({ t: 'play' })
278
+ setCurrentTime(0.2)
279
+ h.render()
280
+ expect(h.reports.length).toBe(0)
281
+ setCurrentTime(0.5)
282
+ h.render()
283
+ expect(h.reports.length).toBe(1)
284
+ })
285
+
286
+ it('resets the ring, zeroes the counters and adopts the new epoch immediately', () => {
287
+ const h = harness()
288
+ h.send({ t: 'play' })
289
+ h.send(chunk(QUANTUM * 4, 0.5, 0.5))
290
+ h.render()
291
+ setCurrentTime(0.1)
292
+ h.render()
293
+ expect(h.reports[h.reports.length - 1].epoch).toBe(0)
294
+
295
+ h.send({ t: 'reset', epoch: 7 })
296
+ const afterReset = h.reports[h.reports.length - 1]
297
+ expect(afterReset.epoch).toBe(7)
298
+ expect(afterReset.samplesConsumed).toBe(0)
299
+ expect(afterReset.underrunFrames).toBe(0)
300
+ // Stale audio for the position we just left is discarded, not played.
301
+ expect(afterReset.queuedFrames).toBe(0)
302
+
303
+ const out = h.render()
304
+ expect(Array.from(out[0]).every((v) => v === 0)).toBe(true)
305
+ })
306
+
307
+ it('stamps every later report with the new epoch so main can discard stale ones', () => {
308
+ const h = harness()
309
+ h.send({ t: 'play' })
310
+ h.send({ t: 'reset', epoch: 3 })
311
+ setCurrentTime(0.2)
312
+ h.render()
313
+ expect(h.reports.every((r) => r.epoch === 0 || r.epoch === 3)).toBe(true)
314
+ expect(h.reports[h.reports.length - 1].epoch).toBe(3)
315
+ })
316
+ })
@@ -0,0 +1,298 @@
1
+ /**
2
+ * SP4 T3 — the decode planner, which is where three of SP1's five engine
3
+ * requirements are actually decided. The three tests named by the plan
4
+ * (`decode-order-feed`, `pipelined-batches`, `min-cts-target`) are `it()`
5
+ * blocks with exactly those names; the other two (`preroll-epsilon`, and
6
+ * supersession's `stale frames closed, done always posted`) live in
7
+ * `decode-worker-source.test.ts`, where they can be run against the shipped
8
+ * worker string itself.
9
+ *
10
+ * Every table here carries a B-frame pattern for the same reason T2's tests
11
+ * do: on an all-intra table (which is what SP3's proxies are) decode order ==
12
+ * presentation order, so a correct planner and the broken one that sorts by
13
+ * cts — or targets `batch[0].tsUs` — produce identical answers. That is
14
+ * precisely how both bugs survived to SP1's measurement stage
15
+ * (`spikes/playback-engine/RESULTS.md` §6.1, §7.1).
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import { buildPresIndex, type SampleRef, type SampleTable } from '../demux'
19
+ import {
20
+ DEFAULT_DECODE_AHEAD_FRAMES,
21
+ MAX_IN_FLIGHT_BATCHES,
22
+ minBatchFrames,
23
+ minCtsUs,
24
+ onFrameBuffered,
25
+ onFramesConsumed,
26
+ planNextBatches,
27
+ planSeek,
28
+ planStreamStart,
29
+ reconcileOnBatchDone,
30
+ type PipelineConfig,
31
+ type PipelineState,
32
+ } from '../batch-planner'
33
+
34
+ /** One synthetic sample. Times in µs; 1000µs "frames" keep the tables readable. */
35
+ function s(cts: number, dts: number, isKey = false): SampleRef {
36
+ return { tsUs: cts, dtsUs: dts, durUs: 1000, isKey, data: new Uint8Array(0) }
37
+ }
38
+
39
+ function table(samples: SampleRef[]): SampleTable {
40
+ return { samples, presIndex: buildPresIndex(samples) }
41
+ }
42
+
43
+ /**
44
+ * Reordered GOP, decode order: I0 P3 B1 B2 P6 B4 B5 I7 (cts in the comments).
45
+ * The same fixture shape `demux.test.ts` uses, so both layers are exercised
46
+ * against one reordering pattern.
47
+ */
48
+ function bFrameGop(): SampleRef[] {
49
+ return [
50
+ s(0, 0, true), // 0: I0
51
+ s(3000, 1000), // 1: P3
52
+ s(1000, 2000), // 2: B1
53
+ s(2000, 3000), // 3: B2
54
+ s(6000, 4000), // 4: P6
55
+ s(4000, 5000), // 5: B4
56
+ s(5000, 6000), // 6: B5
57
+ s(7000, 7000, true), // 7: I7
58
+ ]
59
+ }
60
+
61
+ /** A long all-intra table — the shape of a real SP3 proxy, for the pipeline simulations. */
62
+ function allIntra(n: number): SampleRef[] {
63
+ return Array.from({ length: n }, (_, i) => s(i * 33333.333, i * 33333.333, true))
64
+ }
65
+
66
+ const SHIPPED: PipelineConfig = {
67
+ decodeAheadFrames: DEFAULT_DECODE_AHEAD_FRAMES,
68
+ maxInFlightBatches: MAX_IN_FLIGHT_BATCHES,
69
+ }
70
+
71
+ /**
72
+ * SP1's PRE-fix planner, rebuilt out of the shipped one's config: exactly one
73
+ * request in flight, and each batch sized to the current shortfall with no
74
+ * floor. Nothing else differs, so what the `pipelined-batches` test observes
75
+ * is the scheduling rule and not a different implementation.
76
+ */
77
+ const DEGENERATE: PipelineConfig = {
78
+ decodeAheadFrames: DEFAULT_DECODE_AHEAD_FRAMES,
79
+ maxInFlightBatches: 1,
80
+ minBatchFrames: 1,
81
+ }
82
+
83
+ interface SimOptions {
84
+ ticks: number
85
+ /** Frames the painter consumes per tick. */
86
+ paintPerTick: number
87
+ /** Ticks between a batch being posted and the worker delivering it (decode + flush + round trip). */
88
+ latency: number
89
+ }
90
+
91
+ interface SimResult {
92
+ batchSizes: number[]
93
+ maxInFlightBatches: number
94
+ /** Peak `buffered + inFlightFrames` — the budget N is supposed to cap. */
95
+ maxAlive: number
96
+ painted: number
97
+ }
98
+
99
+ /**
100
+ * Drive the planner the way the real tick does — pump, paint, then absorb
101
+ * whatever the worker delivered — using the planner's own transition
102
+ * functions, so the simulation cannot drift from the shipped bookkeeping.
103
+ */
104
+ function simulate(src: SampleTable, config: PipelineConfig, opts: SimOptions): SimResult {
105
+ let state: PipelineState = planStreamStart(src, 0)
106
+ const inFlight: { expected: number; landsAt: number }[] = []
107
+ const batchSizes: number[] = []
108
+ let maxInFlightBatches = 0
109
+ let maxAlive = 0
110
+ let painted = 0
111
+
112
+ for (let t = 0; t < opts.ticks; t++) {
113
+ const planned = planNextBatches(src, state, config)
114
+ state = planned.state
115
+ for (const batch of planned.batches) {
116
+ batchSizes.push(batch.count)
117
+ inFlight.push({ expected: batch.count, landsAt: t + opts.latency })
118
+ }
119
+ maxInFlightBatches = Math.max(maxInFlightBatches, state.inFlightBatches)
120
+ maxAlive = Math.max(maxAlive, state.buffered + state.inFlightFrames)
121
+
122
+ for (let p = 0; p < opts.paintPerTick && state.buffered > 0; p++) {
123
+ state = onFramesConsumed(state, 1)
124
+ painted++
125
+ }
126
+
127
+ while (inFlight.length > 0 && inFlight[0].landsAt <= t) {
128
+ const batch = inFlight.shift()!
129
+ for (let k = 0; k < batch.expected; k++) state = onFrameBuffered(state)
130
+ state = reconcileOnBatchDone(state, batch.expected, batch.expected)
131
+ maxAlive = Math.max(maxAlive, state.buffered + state.inFlightFrames)
132
+ }
133
+ }
134
+
135
+ return { batchSizes, maxInFlightBatches, maxAlive, painted }
136
+ }
137
+
138
+ describe('batch planner — SP1 engine requirements', () => {
139
+ it('decode-order-feed', () => {
140
+ const src = table(bFrameGop())
141
+
142
+ // A seek slices DECODE indices: enclosing keyframe → the sample displayed
143
+ // at the target, found by bisecting presIndex (T2), not by scanning cts.
144
+ const seek = planSeek(src, 2000)!
145
+ expect(seek.startIdx).toBe(0) // I0, walking back through decode order
146
+ expect(seek.endIdx).toBe(4) // B2 (cts 2000) sits at decode index 3
147
+ expect(seek.targetTsUs).toBe(2000)
148
+
149
+ const fed = src.samples.slice(seek.startIdx, seek.endIdx)
150
+ // What actually goes to VideoDecoder: strictly ascending DTS…
151
+ expect(fed.map((x) => x.dtsUs)).toEqual([0, 1000, 2000, 3000])
152
+ // …and a cts sequence that is NOT ascending. If this were ever sorted into
153
+ // presentation order the decoder would throw on the first non-intra frame
154
+ // and stay dead until respawned (SP1 §7.1).
155
+ expect(fed.map((x) => x.tsUs)).toEqual([0, 3000, 1000, 2000])
156
+ expect(fed[0].isKey).toBe(true)
157
+
158
+ // Streaming batches tile `samples` contiguously in decode order: no gaps,
159
+ // no overlaps, no reordering, every sample requested exactly once.
160
+ const src2 = table(allIntra(97))
161
+ let state = planStreamStart(src2, 0)
162
+ const covered: number[] = []
163
+ for (let round = 0; round < 200 && state.nextSampleIdx < src2.samples.length; round++) {
164
+ const planned = planNextBatches(src2, state, SHIPPED)
165
+ for (const batch of planned.batches) {
166
+ for (let i = batch.startIdx; i < batch.endIdx; i++) covered.push(i)
167
+ }
168
+ // Pretend every requested frame arrived and was painted, so the next
169
+ // round has budget again.
170
+ state = { ...planned.state, buffered: 0, inFlightFrames: 0, inFlightBatches: 0 }
171
+ }
172
+ expect(covered).toEqual(Array.from({ length: 97 }, (_, i) => i))
173
+ })
174
+
175
+ it('min-cts-target', () => {
176
+ const src = table(bFrameGop())
177
+
178
+ // Mid-stream batch (no session pre-roll left): decode indices 1..4 —
179
+ // P3(3000) B1(1000) B2(2000) P6(6000).
180
+ const midStream: PipelineState = {
181
+ nextSampleIdx: 1,
182
+ buffered: 0,
183
+ inFlightFrames: 0,
184
+ inFlightBatches: 0,
185
+ firstBatchTargetTsUs: 0,
186
+ }
187
+ const { batches } = planNextBatches(src, midStream, {
188
+ decodeAheadFrames: 4,
189
+ maxInFlightBatches: 1,
190
+ })
191
+ expect(batches).toHaveLength(1)
192
+ const batch = batches[0]
193
+ expect([batch.startIdx, batch.endIdx]).toEqual([1, 5])
194
+
195
+ // The requirement: the target is the MINIMUM cts in the batch.
196
+ expect(batch.targetTsUs).toBe(1000)
197
+ expect(minCtsUs(src.samples, 1, 5)).toBe(1000)
198
+
199
+ // And the bug it replaces: `batch[0].tsUs` is 3000 in decode order, which
200
+ // would have made the worker discard two perfectly good frames as false
201
+ // pre-roll on every mid-stream batch (SP1 §6.1's second, latent bug).
202
+ const brokenTarget = src.samples[batch.startIdx].tsUs
203
+ expect(brokenTarget).toBe(3000)
204
+ const falselyDropped = src.samples
205
+ .slice(batch.startIdx, batch.endIdx)
206
+ .filter((x) => x.tsUs < brokenTarget)
207
+ expect(falselyDropped.map((x) => x.tsUs)).toEqual([1000, 2000])
208
+
209
+ // The one legitimate pre-roll — keyframe → the frame actually asked for —
210
+ // is expressed as the first batch's floor, not as a per-batch guess.
211
+ const started = planStreamStart(src, 2000)
212
+ expect(started.nextSampleIdx).toBe(0) // decode from I0
213
+ expect(started.firstBatchTargetTsUs).toBe(2000) // …but emit nothing before cts 2000
214
+ const first = planNextBatches(src, started, SHIPPED).batches[0]
215
+ expect(minCtsUs(src.samples, first.startIdx, first.endIdx)).toBe(0)
216
+ expect(first.targetTsUs).toBe(2000)
217
+ })
218
+
219
+ it('pipelined-batches', () => {
220
+ const src = table(allIntra(400))
221
+ const floor = minBatchFrames(DEFAULT_DECODE_AHEAD_FRAMES)
222
+ expect(floor).toBe(7) // max(4, N/4) at N=30
223
+
224
+ // (a) SP1's pre-fix configuration, reproduced against this same planner:
225
+ // one request in flight, batch sized to the shortfall. After the initial
226
+ // fill it asks for ONE frame per batch — and every batch ends in
227
+ // `decoder.flush()`, which drains the whole decoder pipeline. That is the
228
+ // measured 4.5fps supply limit (§6.1), not a hypothetical.
229
+ const degenerate = simulate(src, DEGENERATE, { ticks: 60, paintPerTick: 1, latency: 0 })
230
+ expect(degenerate.maxInFlightBatches).toBe(1)
231
+ expect(new Set(degenerate.batchSizes.slice(1))).toEqual(new Set([1]))
232
+
233
+ // (b) The shipped configuration on the identical workload: batches floored
234
+ // so flush cost amortizes over many frames instead of one.
235
+ const shipped = simulate(src, SHIPPED, { ticks: 60, paintPerTick: 1, latency: 0 })
236
+ expect(Math.min(...shipped.batchSizes)).toBeGreaterThanOrEqual(floor)
237
+ expect(shipped.painted).toBe(degenerate.painted) // same workload, so the comparison is like-for-like
238
+
239
+ // (c) …and when decode takes longer than one pump interval, the planner
240
+ // keeps a second batch queued behind the in-flight one rather than
241
+ // degenerating to a single request the worker has to wait on.
242
+ const pipelined = simulate(src, SHIPPED, { ticks: 60, paintPerTick: 4, latency: 2 })
243
+ expect(pipelined.maxInFlightBatches).toBe(MAX_IN_FLIGHT_BATCHES)
244
+ expect(Math.min(...pipelined.batchSizes)).toBeGreaterThanOrEqual(floor)
245
+
246
+ // (d) The budget invariant that must survive all of the above: N still
247
+ // means "at most N frames alive at once" (SP1 §8's memory sweep rests on
248
+ // it), in every configuration.
249
+ for (const run of [degenerate, shipped, pipelined]) {
250
+ expect(run.maxAlive).toBeLessThanOrEqual(DEFAULT_DECODE_AHEAD_FRAMES)
251
+ }
252
+ })
253
+ })
254
+
255
+ describe('batch planner — bookkeeping', () => {
256
+ it('stops at end of source and never plans past the last sample', () => {
257
+ const src = table(allIntra(5))
258
+ const { batches, state } = planNextBatches(src, planStreamStart(src, 0), SHIPPED)
259
+ expect(batches).toEqual([{ startIdx: 0, endIdx: 5, count: 5, targetTsUs: 0 }])
260
+ expect(planNextBatches(src, state, SHIPPED).batches).toEqual([])
261
+ })
262
+
263
+ it('lets a starving pipeline through the batch floor rather than stalling', () => {
264
+ const src = table(allIntra(100))
265
+ // Nothing buffered, nothing coming, but only 3 frames of budget: a batch
266
+ // below the floor still beats painting nothing.
267
+ const starving: PipelineState = {
268
+ nextSampleIdx: 10,
269
+ buffered: 0,
270
+ inFlightFrames: 0,
271
+ inFlightBatches: 0,
272
+ firstBatchTargetTsUs: 0,
273
+ }
274
+ const { batches } = planNextBatches(src, starving, { decodeAheadFrames: 3, maxInFlightBatches: 2 })
275
+ expect(batches.map((b) => b.count)).toEqual([3])
276
+ })
277
+
278
+ it('reconciles inFlightFrames against what a batch actually decoded', () => {
279
+ // A 10-frame batch whose first 4 were pre-roll: only 6 frames will ever
280
+ // arrive, so the per-frame decrement under-counts by 4. Without
281
+ // reconciliation that 4 is lost decode-ahead capacity, permanently.
282
+ let state: PipelineState = {
283
+ nextSampleIdx: 10,
284
+ buffered: 0,
285
+ inFlightFrames: 10,
286
+ inFlightBatches: 1,
287
+ firstBatchTargetTsUs: 0,
288
+ }
289
+ for (let i = 0; i < 6; i++) state = onFrameBuffered(state)
290
+ expect(state).toMatchObject({ buffered: 6, inFlightFrames: 4 })
291
+ state = reconcileOnBatchDone(state, 10, 6)
292
+ expect(state).toMatchObject({ buffered: 6, inFlightFrames: 0, inFlightBatches: 0 })
293
+ })
294
+
295
+ it('planSeek returns null for an empty table', () => {
296
+ expect(planSeek(table([]), 0)).toBeNull()
297
+ })
298
+ })