@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,261 @@
1
+ /**
2
+ * The frame server against a RANGED source: what happens when a decode range's
3
+ * bytes are not resident yet.
4
+ *
5
+ * Four properties, each of which fails loudly if it is wrong:
6
+ *
7
+ * 1. **The resident case stays synchronous.** `ensure` returns `null` when the
8
+ * bytes are already there, and the post must go out in the same tick — a
9
+ * seek that hopped a microtask would paint late, and the whole reason
10
+ * all-intra was kept is that a seek decodes exactly one frame instantly.
11
+ * 2. **Nothing overtakes a batch that is still fetching.** `VideoDecoder`
12
+ * throws on the first out-of-order chunk and stays dead until the worker is
13
+ * respawned (SP1 §7.1), so posts are serialized behind a pending fetch.
14
+ * 3. **…but a SUPERSEDED batch is not something to wait for.** A scrub landing
15
+ * mid-fetch retires the queue and posts immediately; queueing behind the
16
+ * request it just made pointless would give back the latency this whole
17
+ * change exists to remove.
18
+ * 4. **A failed or dropped fetch settles, never hangs.** `seek().frame` is
19
+ * contractually total; a range that will not load — or one abandoned
20
+ * because the caller asked for somewhere else — has to resolve `null` and
21
+ * report, not leave the scrubber waiting forever.
22
+ */
23
+ import { describe, it, expect, vi } from 'vitest'
24
+ import { buildPresIndex, type ChunkSource, type DemuxedSource, type SampleRef } from '../demux'
25
+ import { createFrameServer, type DecodeCmd, type DecodeEvt, type DecodeWorkerPort } from '../frame-server'
26
+
27
+ interface FakePort extends DecodeWorkerPort {
28
+ sent: DecodeCmd[]
29
+ terminated: boolean
30
+ deliver: (evt: DecodeEvt) => void
31
+ }
32
+
33
+ function fakePort(): FakePort {
34
+ const port: FakePort = {
35
+ sent: [],
36
+ terminated: false,
37
+ onmessage: null,
38
+ postMessage(msg) {
39
+ port.sent.push(msg)
40
+ },
41
+ terminate() {
42
+ port.terminated = true
43
+ },
44
+ deliver(evt) {
45
+ port.onmessage?.({ data: evt })
46
+ },
47
+ }
48
+ return port
49
+ }
50
+
51
+ /** Every `decodeRange` posted so far, oldest first. */
52
+ function batches(port: FakePort): { startTs: number; count: number; reqId: number }[] {
53
+ return port.sent
54
+ .filter((m): m is Extract<DecodeCmd, { t: 'decodeRange' }> => m.t === 'decodeRange')
55
+ .map((m) => ({ startTs: m.samples[0]?.tsUs ?? -1, count: m.samples.length, reqId: m.reqId }))
56
+ }
57
+
58
+ /**
59
+ * An all-intra ranged source: the index is complete, the bytes arrive through
60
+ * `ensure`. `gate` decides what each call returns — `null` for resident.
61
+ */
62
+ function rangedSource(
63
+ gate: (startIdx: number, endIdx: number) => Promise<void> | null,
64
+ n = 120,
65
+ ): DemuxedSource {
66
+ const samples: SampleRef[] = Array.from({ length: n }, (_, i) => ({
67
+ tsUs: i * 33333,
68
+ dtsUs: i * 33333,
69
+ durUs: 33333,
70
+ isKey: true,
71
+ data: new Uint8Array(0),
72
+ offset: i * 1000,
73
+ size: 1000,
74
+ }))
75
+ const video: ChunkSource = {
76
+ kind: 'video',
77
+ codec: 'avc1.640028',
78
+ description: undefined,
79
+ fps: 30,
80
+ durationS: n / 30,
81
+ coded: { width: 1280, height: 720 },
82
+ samples,
83
+ presIndex: buildPresIndex(samples),
84
+ firstPresentationTsUs: 0,
85
+ ensure: gate,
86
+ }
87
+ return { src: '/proxies/ranged_proxy.mp4', video, audio: null }
88
+ }
89
+
90
+ /** A promise plus its settle handles, for driving a fetch by hand. */
91
+ function deferred(): { promise: Promise<void>; resolve: () => void; reject: (e: Error) => void } {
92
+ let resolve: () => void = () => {}
93
+ let reject: (e: Error) => void = () => {}
94
+ const promise = new Promise<void>((res, rej) => {
95
+ resolve = () => res()
96
+ reject = rej
97
+ })
98
+ return { promise, resolve, reject }
99
+ }
100
+
101
+ /** Let every already-settled promise in the post chain run. */
102
+ const settle = () => new Promise<void>((r) => setTimeout(r, 0))
103
+
104
+ describe('frame server — ranged source', () => {
105
+ it('posts in the same tick when the range is already resident', () => {
106
+ const ensure = vi.fn(() => null)
107
+ const port = fakePort()
108
+ const server = createFrameServer({ source: rangedSource(ensure), spawnWorker: () => port })
109
+
110
+ server.seek(30 * 33333)
111
+
112
+ // Synchronously — no await anywhere in this test.
113
+ expect(batches(port)).toHaveLength(1)
114
+ expect(batches(port)[0]).toMatchObject({ startTs: 30 * 33333, count: 1 })
115
+ expect(ensure).toHaveBeenCalledWith(30, 31)
116
+ server.dispose()
117
+ })
118
+
119
+ it('holds the post until the bytes land, then sends it', async () => {
120
+ const gate = deferred()
121
+ let calls = 0
122
+ const port = fakePort()
123
+ const server = createFrameServer({
124
+ source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
125
+ spawnWorker: () => port,
126
+ })
127
+
128
+ server.seek(50 * 33333)
129
+ expect(batches(port)).toHaveLength(0)
130
+
131
+ gate.resolve()
132
+ await settle()
133
+
134
+ expect(batches(port)).toHaveLength(1)
135
+ expect(batches(port)[0].startTs).toBe(50 * 33333)
136
+ server.dispose()
137
+ })
138
+
139
+ it('posts nothing further while a range is still fetching, then resumes in order', async () => {
140
+ // `planNextBatches` hands out one batch per pump (the first takes the whole
141
+ // decode-ahead capacity), so this is what "in order" looks like from the
142
+ // outside: while batch A's bytes are in flight nothing else reaches the
143
+ // worker, no matter how many ticks come through. That ordering matters
144
+ // because `VideoDecoder` throws on the first out-of-order chunk and stays
145
+ // dead until the worker is respawned (SP1 §7.1).
146
+ const gate = deferred()
147
+ let calls = 0
148
+ const port = fakePort()
149
+ const server = createFrameServer({
150
+ source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
151
+ decodeAheadFrames: 8,
152
+ spawnWorker: () => port,
153
+ })
154
+
155
+ server.startStream(0)
156
+ server.nextFrameFor(0)
157
+ server.nextFrameFor(33333)
158
+ expect(batches(port)).toHaveLength(0)
159
+
160
+ gate.resolve()
161
+ await settle()
162
+
163
+ const posted = batches(port)
164
+ expect(posted).toHaveLength(1)
165
+ expect(posted[0]).toMatchObject({ startTs: 0, count: 8 })
166
+ server.dispose()
167
+ })
168
+
169
+ it('drops a deferred batch whose request was superseded while fetching', async () => {
170
+ const gate = deferred()
171
+ let calls = 0
172
+ const port = fakePort()
173
+ const server = createFrameServer({
174
+ source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
175
+ spawnWorker: () => port,
176
+ })
177
+
178
+ const first = server.seek(10 * 33333)
179
+ const second = server.seek(80 * 33333)
180
+ gate.resolve()
181
+ await settle()
182
+
183
+ // The superseded seek settles with null (that is `claimReqId`'s job) and
184
+ // its batch never reaches the worker — decoding it would only produce
185
+ // frames `onFrameEvt` closes as stale.
186
+ await expect(first.frame).resolves.toBeNull()
187
+ const posted = batches(port)
188
+ expect(posted.every((b) => b.reqId !== first.reqId)).toBe(true)
189
+ expect(posted.some((b) => b.reqId === second.reqId)).toBe(true)
190
+ server.dispose()
191
+ })
192
+
193
+ it('settles a seek with null and reports when its bytes cannot be fetched', async () => {
194
+ const gate = deferred()
195
+ const onError = vi.fn()
196
+ const port = fakePort()
197
+ const server = createFrameServer({
198
+ source: rangedSource(() => gate.promise),
199
+ spawnWorker: () => port,
200
+ onError,
201
+ })
202
+
203
+ const { frame } = server.seek(60 * 33333)
204
+ gate.reject(new Error('read: /proxies/ranged_proxy.mp4 [0-1048575] → 503 Service Unavailable'))
205
+
206
+ await expect(frame).resolves.toBeNull()
207
+ expect(onError).toHaveBeenCalledWith(expect.stringContaining('503 Service Unavailable'))
208
+ expect(server.stats().lastError).toMatch(/503 Service Unavailable/)
209
+ expect(batches(port)).toHaveLength(0)
210
+ server.dispose()
211
+ })
212
+
213
+ it('does not make a new seek wait behind the fetch it superseded', async () => {
214
+ // The latency trap in a serialized post queue: a scrub lands while the
215
+ // stream's bytes are still in flight, and if the new request queued behind
216
+ // the abandoned one it would paint no sooner than the frame nobody wants.
217
+ // `claimReqId` retires the chain instead, so a resident seek posts in the
218
+ // same tick — exactly as it does with no fetch pending at all.
219
+ const gate = deferred()
220
+ let calls = 0
221
+ const port = fakePort()
222
+ const server = createFrameServer({
223
+ source: rangedSource(() => (calls++ === 0 ? gate.promise : null)),
224
+ decodeAheadFrames: 8,
225
+ spawnWorker: () => port,
226
+ })
227
+
228
+ server.startStream(0)
229
+ expect(batches(port)).toHaveLength(0)
230
+
231
+ server.seek(90 * 33333)
232
+
233
+ // Synchronously, with the stream's fetch still unresolved.
234
+ expect(batches(port)).toHaveLength(1)
235
+ expect(batches(port)[0].startTs).toBe(90 * 33333)
236
+
237
+ gate.resolve()
238
+ await settle()
239
+
240
+ // And the abandoned batch never shows up late.
241
+ expect(batches(port)).toHaveLength(1)
242
+ server.dispose()
243
+ })
244
+
245
+ it('does not post a deferred batch after dispose', async () => {
246
+ const gate = deferred()
247
+ const port = fakePort()
248
+ const server = createFrameServer({
249
+ source: rangedSource(() => gate.promise),
250
+ spawnWorker: () => port,
251
+ })
252
+
253
+ server.seek(40 * 33333)
254
+ server.dispose()
255
+ gate.resolve()
256
+ await settle()
257
+
258
+ expect(batches(port)).toHaveLength(0)
259
+ expect(port.terminated).toBe(true)
260
+ })
261
+ })
@@ -0,0 +1,326 @@
1
+ /**
2
+ * SP4 T3 — the frame server's main-side half: the worker protocol, the frame
3
+ * budget, `batchExpected` reconciliation, and the `VideoFrame` leak contract.
4
+ *
5
+ * The worker port is injected, so none of this needs `Worker`,
6
+ * `URL.createObjectURL` or WebCodecs — jsdom has none of the three. What the
7
+ * fakes cannot prove (that a real decoder returns frames at all) is what T9's
8
+ * operator parity checklist is for; what they CAN prove is every place a
9
+ * `VideoFrame` could leak or a promise could hang, which is where SP1's bugs
10
+ * actually were.
11
+ */
12
+ import { describe, it, expect, vi } from 'vitest'
13
+ import { buildPresIndex, type ChunkSource, type DemuxedSource, type SampleRef } from '../demux'
14
+ import { DEFAULT_DECODE_AHEAD_FRAMES, minBatchFrames } from '../batch-planner'
15
+ import {
16
+ createFrameServer,
17
+ type DecodeCmd,
18
+ type DecodeEvt,
19
+ type DecodeWorkerPort,
20
+ } from '../frame-server'
21
+
22
+ interface FakeFrame {
23
+ timestamp: number
24
+ closed: boolean
25
+ close: () => void
26
+ }
27
+
28
+ function frame(timestamp: number): FakeFrame {
29
+ return {
30
+ timestamp,
31
+ closed: false,
32
+ close() {
33
+ this.closed = true
34
+ },
35
+ }
36
+ }
37
+
38
+ /** Hand a fake frame to the server the way the worker would. */
39
+ function asVideoFrame(f: FakeFrame): VideoFrame {
40
+ return f as unknown as VideoFrame
41
+ }
42
+
43
+ interface FakePort extends DecodeWorkerPort {
44
+ sent: DecodeCmd[]
45
+ terminated: boolean
46
+ deliver: (evt: DecodeEvt) => void
47
+ }
48
+
49
+ function fakePort(): FakePort {
50
+ const port: FakePort = {
51
+ sent: [],
52
+ terminated: false,
53
+ onmessage: null,
54
+ postMessage(msg) {
55
+ port.sent.push(msg)
56
+ },
57
+ terminate() {
58
+ port.terminated = true
59
+ },
60
+ deliver(evt) {
61
+ port.onmessage?.({ data: evt })
62
+ },
63
+ }
64
+ return port
65
+ }
66
+
67
+ /** All-intra source at 30fps, the shape of an SP3 proxy. */
68
+ function proxySource(n = 100, src = '/proxies/clip_proxy.mp4'): DemuxedSource {
69
+ const samples: SampleRef[] = Array.from({ length: n }, (_, i) => ({
70
+ tsUs: i * 33333,
71
+ dtsUs: i * 33333,
72
+ durUs: 33333,
73
+ isKey: true,
74
+ data: new Uint8Array([i & 0xff]),
75
+ }))
76
+ const video: ChunkSource = {
77
+ kind: 'video',
78
+ codec: 'av01.0.05M.08',
79
+ description: undefined,
80
+ fps: 30,
81
+ durationS: n / 30,
82
+ coded: { width: 540, height: 960 },
83
+ samples,
84
+ presIndex: buildPresIndex(samples),
85
+ firstPresentationTsUs: 0,
86
+ }
87
+ return { src, video, audio: null }
88
+ }
89
+
90
+ /** A reordered GOP: decode order I0 P3 B1 B2, so a seek run emits frames after the target too. */
91
+ function bFrameSource(): DemuxedSource {
92
+ const mk = (cts: number, dts: number, isKey = false): SampleRef => ({
93
+ tsUs: cts,
94
+ dtsUs: dts,
95
+ durUs: 1000,
96
+ isKey,
97
+ data: new Uint8Array([dts / 1000]),
98
+ })
99
+ const samples = [mk(0, 0, true), mk(3000, 1000), mk(1000, 2000), mk(2000, 3000)]
100
+ const video: ChunkSource = {
101
+ kind: 'video',
102
+ codec: 'hvc1.2.4.L153.B0',
103
+ fps: 1000,
104
+ durationS: 0.004,
105
+ coded: { width: 100, height: 100 },
106
+ samples,
107
+ presIndex: buildPresIndex(samples),
108
+ firstPresentationTsUs: 0,
109
+ }
110
+ return { src: '/proxies/b.mp4', video, audio: null }
111
+ }
112
+
113
+ function decodeRanges(port: FakePort): Extract<DecodeCmd, { t: 'decodeRange' }>[] {
114
+ return port.sent.flatMap((cmd) => (cmd.t === 'decodeRange' ? [cmd] : []))
115
+ }
116
+
117
+ describe('frame server', () => {
118
+ it('decodes only from the single injected proxy source', () => {
119
+ // SP1 requirement 4 (proxy-only playback) is structural here: the server
120
+ // is handed one demuxed source and has no other input, no `fileUrl`, and
121
+ // no fallback branch. What is left to assert is that the bytes it posts
122
+ // really are that source's samples — sliced in decode order, by reference.
123
+ const port = fakePort()
124
+ const source = proxySource(40)
125
+ const server = createFrameServer({ source, spawnWorker: () => port })
126
+
127
+ expect(port.sent[0]).toEqual({
128
+ t: 'init',
129
+ codec: 'av01.0.05M.08',
130
+ description: undefined,
131
+ hardwareAcceleration: 'no-preference',
132
+ preRollEpsilonUs: 1,
133
+ })
134
+
135
+ server.startStream(0)
136
+ const [range] = decodeRanges(port)
137
+ expect(range.samples).toHaveLength(DEFAULT_DECODE_AHEAD_FRAMES)
138
+ range.samples.forEach((s, i) => {
139
+ // Identity, not equality: the sample bytes are the demuxed table's own
140
+ // buffers, handed over by structured clone (never transferred — a
141
+ // transfer would detach them from `source` and corrupt every later
142
+ // request overlapping the same GOP).
143
+ expect(s.data).toBe(source.video.samples[i].data)
144
+ expect(s.tsUs).toBe(source.video.samples[i].tsUs)
145
+ })
146
+ expect(server.src).toBe('/proxies/clip_proxy.mp4')
147
+ server.dispose()
148
+ })
149
+
150
+ it('holds buffered + in-flight frames to the decode-ahead budget', () => {
151
+ const port = fakePort()
152
+ const server = createFrameServer({ source: proxySource(200), spawnWorker: () => port })
153
+ const reqId = server.startStream(0)
154
+
155
+ const first = decodeRanges(port)[0]
156
+ expect(first.samples).toHaveLength(30)
157
+ expect(server.stats()).toMatchObject({ buffered: 0, inFlightFrames: 30, inFlightBatches: 1 })
158
+
159
+ for (let i = 0; i < 30; i++) port.deliver({ t: 'frame', frame: asVideoFrame(frame(i * 33333)), reqId })
160
+ port.deliver({ t: 'done', reqId, decoded: 30, dropped: 0 })
161
+ // Budget full: the `done` pumped, and the pump found no capacity.
162
+ expect(server.stats()).toMatchObject({ buffered: 30, inFlightFrames: 0, inFlightBatches: 0 })
163
+ expect(decodeRanges(port)).toHaveLength(1)
164
+
165
+ // Painting 10 frames frees exactly 10 frames of budget, which clears the
166
+ // batch floor and releases the next request.
167
+ const taken = server.nextFrameFor(9 * 33333)
168
+ expect(taken.frame?.timestamp).toBe(9 * 33333)
169
+ expect(taken.dropped).toBe(9)
170
+ expect(decodeRanges(port)).toHaveLength(2)
171
+ expect(decodeRanges(port)[1].samples).toHaveLength(10)
172
+ expect(minBatchFrames(DEFAULT_DECODE_AHEAD_FRAMES)).toBeLessThanOrEqual(10)
173
+ expect(server.stats().buffered + server.stats().inFlightFrames).toBe(30)
174
+
175
+ server.dispose()
176
+ })
177
+
178
+ it('reconciles the budget when the worker drops frames as pre-roll', () => {
179
+ const port = fakePort()
180
+ const server = createFrameServer({ source: proxySource(200), spawnWorker: () => port })
181
+ const reqId = server.startStream(0)
182
+
183
+ // The batch asked for 30 but 4 were pre-roll and never arrive. Without
184
+ // reconciliation `inFlightFrames` would sit at 4 forever and that much
185
+ // decode-ahead capacity would be gone for the rest of the session.
186
+ for (let i = 4; i < 30; i++) port.deliver({ t: 'frame', frame: asVideoFrame(frame(i * 33333)), reqId })
187
+ port.deliver({ t: 'done', reqId, decoded: 26, dropped: 4 })
188
+
189
+ expect(server.stats()).toMatchObject({ buffered: 26, inFlightFrames: 0, inFlightBatches: 0 })
190
+ // 4 frames of budget are free, but that is below the batch floor, so
191
+ // nothing is posted yet — the flush amortization rule still holds.
192
+ expect(decodeRanges(port)).toHaveLength(1)
193
+
194
+ // Consume 7 frames (frames 4..10 by presentation time). Budget now: 11
195
+ // free = 7 painted + the 4 the reconciliation recovered. WITHOUT the
196
+ // reconciliation this next batch would be 7 — that difference is the
197
+ // capacity that would have been lost permanently.
198
+ expect(server.nextFrameFor(10 * 33333).frame?.timestamp).toBe(10 * 33333)
199
+ expect(decodeRanges(port)[1].samples).toHaveLength(11)
200
+ server.dispose()
201
+ })
202
+
203
+ it('closes frames from a superseded request instead of buffering them', () => {
204
+ const port = fakePort()
205
+ const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
206
+ const streamReq = server.startStream(0)
207
+ const buffered = frame(0)
208
+ port.deliver({ t: 'frame', frame: asVideoFrame(buffered), reqId: streamReq })
209
+ expect(server.stats().buffered).toBe(1)
210
+
211
+ // A seek supersedes the stream: the frame already buffered for it is
212
+ // closed, and a straggler still in flight for it is closed on arrival.
213
+ server.seek(33333)
214
+ expect(buffered.closed).toBe(true)
215
+ expect(server.stats().buffered).toBe(0)
216
+
217
+ const straggler = frame(33333)
218
+ port.deliver({ t: 'frame', frame: asVideoFrame(straggler), reqId: streamReq })
219
+ expect(straggler.closed).toBe(true)
220
+ expect(server.stats().buffered).toBe(0)
221
+ server.dispose()
222
+ })
223
+
224
+ it('seek resolves the requested frame and closes every other frame in the run', async () => {
225
+ const port = fakePort()
226
+ const server = createFrameServer({ source: bFrameSource(), spawnWorker: () => port })
227
+ const { reqId, frame: pending } = server.seek(2000)
228
+
229
+ const range = decodeRanges(port)[0]
230
+ // The whole GOP up to the target, in decode order.
231
+ expect(range.samples.map((s) => s.tsUs)).toEqual([0, 3000, 1000, 2000])
232
+ expect(range.targetTsUs).toBe(2000)
233
+
234
+ // The worker drops 0 and 1000 as pre-roll; 2000 (the target) and 3000 (a
235
+ // frame that precedes the target in DECODE order) both arrive.
236
+ const target = frame(2000)
237
+ const later = frame(3000)
238
+ port.deliver({ t: 'frame', frame: asVideoFrame(target), reqId })
239
+ port.deliver({ t: 'frame', frame: asVideoFrame(later), reqId })
240
+ port.deliver({ t: 'done', reqId, decoded: 2, dropped: 2 })
241
+
242
+ // The requested frame, not "whatever arrived last" — on a reordered source
243
+ // those are different frames.
244
+ expect(await pending).toBe(asVideoFrame(target))
245
+ expect(target.closed).toBe(false) // handed to the caller, who closes it after painting
246
+ expect(later.closed).toBe(true)
247
+ server.dispose()
248
+ })
249
+
250
+ it('settles every seek, including superseded ones (total done contract)', async () => {
251
+ const port = fakePort()
252
+ const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
253
+
254
+ const first = server.seek(33333)
255
+ const early = frame(33333)
256
+ port.deliver({ t: 'frame', frame: asVideoFrame(early), reqId: first.reqId })
257
+
258
+ // A second seek arrives before the first's `done`. The worker owes a
259
+ // `done` for the superseded request regardless (SP1 §7.4) — and the frame
260
+ // already held for it must not leak.
261
+ const second = server.seek(66666)
262
+ expect(early.closed).toBe(true)
263
+
264
+ port.deliver({ t: 'done', reqId: first.reqId, decoded: 1, dropped: 1 })
265
+ expect(await first.frame).toBeNull()
266
+
267
+ const wanted = frame(66666)
268
+ port.deliver({ t: 'frame', frame: asVideoFrame(wanted), reqId: second.reqId })
269
+ port.deliver({ t: 'done', reqId: second.reqId, decoded: 1, dropped: 1 })
270
+ expect(await second.frame).toBe(asVideoFrame(wanted))
271
+ server.dispose()
272
+ })
273
+
274
+ it('holds the newest due frame and closes the ones it overtook', () => {
275
+ const port = fakePort()
276
+ const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
277
+ const reqId = server.startStream(0)
278
+ const frames = [frame(0), frame(33333), frame(66666), frame(99999)]
279
+ for (const f of frames) port.deliver({ t: 'frame', frame: asVideoFrame(f), reqId })
280
+
281
+ // Clock sits between frames 2 and 3: frame 2 is due, 0 and 1 became due
282
+ // and were overtaken, 3 is not due yet.
283
+ const { frame: due, dropped } = server.nextFrameFor(70000)
284
+ expect(due?.timestamp).toBe(66666)
285
+ expect(dropped).toBe(2)
286
+ expect(frames.map((f) => f.closed)).toEqual([true, true, false, false])
287
+ expect(server.stats().buffered).toBe(1)
288
+
289
+ // Nothing due yet → nothing painted, nothing closed.
290
+ const none = server.nextFrameFor(80000)
291
+ expect(none.frame).toBeNull()
292
+ expect(frames[3].closed).toBe(false)
293
+ server.dispose()
294
+ })
295
+
296
+ it('closes everything still held on dispose and terminates the worker', async () => {
297
+ const port = fakePort()
298
+ const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port })
299
+ const reqId = server.startStream(0)
300
+ const held = [frame(0), frame(33333)]
301
+ for (const f of held) port.deliver({ t: 'frame', frame: asVideoFrame(f), reqId })
302
+
303
+ server.dispose()
304
+ expect(held.every((f) => f.closed)).toBe(true)
305
+ expect(port.terminated).toBe(true)
306
+ expect(port.onmessage).toBeNull()
307
+
308
+ // A frame that raced the teardown is closed rather than leaked, and a
309
+ // disposed server hands nothing further to the worker.
310
+ const sentBefore = port.sent.length
311
+ server.startStream(0)
312
+ expect(port.sent).toHaveLength(sentBefore)
313
+ })
314
+
315
+ it('reports decoder errors to the host rather than throwing', () => {
316
+ const onError = vi.fn()
317
+ const port = fakePort()
318
+ const server = createFrameServer({ source: proxySource(60), spawnWorker: () => port, onError })
319
+ server.startStream(0)
320
+ port.deliver({ t: 'error', message: 'decoder: Decoding error' })
321
+
322
+ expect(onError).toHaveBeenCalledWith('decoder: Decoding error')
323
+ expect(server.stats().lastError).toBe('decoder: Decoding error')
324
+ server.dispose()
325
+ })
326
+ })