@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,249 @@
1
+ /**
2
+ * SP4 T3 — the decode worker's own two requirements, tested against the
3
+ * shipped string.
4
+ *
5
+ * The worker ships as an inlined source string (plan decision 6), so it can
6
+ * neither be imported as a module nor type-checked. It can, however, be
7
+ * *executed*: the string is plain JS whose only free identifiers are `self`,
8
+ * `VideoDecoder` and `EncodedVideoChunk`, so `new Function('self',
9
+ * 'VideoDecoder', 'EncodedVideoChunk', decodeWorkerSource)` runs it with all
10
+ * three shadowed by fakes. That is worth doing rather than testing a
11
+ * main-side re-implementation, because both requirements here are about what
12
+ * the *worker* does with frames it has been handed: closing them, and owing a
13
+ * `done` no matter what.
14
+ *
15
+ * The fakes model the two WebCodecs behaviors the requirements depend on:
16
+ * - `EncodedVideoChunk.timestamp` is a `long long`, so a float sample time
17
+ * is TRUNCATED at construction (SP1 §7.2's root cause);
18
+ * - a decoder emits nothing until `flush()` settles, and emission lands on a
19
+ * later microtask — which is exactly the window in which a newer request
20
+ * supersedes an older one.
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import { decodeWorkerSource } from '../decode-worker-source'
24
+ import { PRE_ROLL_EPSILON_US, shouldEmitFrame } from '../batch-planner'
25
+ import type { DecodeCmd, WorkerSample } from '../frame-server'
26
+
27
+ interface FakeFrame {
28
+ timestamp: number
29
+ closed: boolean
30
+ close: () => void
31
+ }
32
+
33
+ type Posted =
34
+ | { t: 'frame'; frame: FakeFrame; reqId: number }
35
+ | { t: 'done'; reqId: number; decoded: number; dropped: number }
36
+ | { t: 'error'; message: string; reqId?: number }
37
+
38
+ interface Harness {
39
+ send: (cmd: DecodeCmd) => void
40
+ posted: Posted[]
41
+ /** Every frame the fake decoder ever emitted, in emission order. */
42
+ frames: FakeFrame[]
43
+ /** Chunk timestamps in the order they were handed to `decode()`. */
44
+ fed: number[]
45
+ }
46
+
47
+ /** Let every pending microtask (and therefore every `flush()`) settle. */
48
+ function settle(): Promise<void> {
49
+ return new Promise((resolve) => setTimeout(resolve, 0))
50
+ }
51
+
52
+ function harness(opts: { decodeThrows?: boolean } = {}): Harness {
53
+ const posted: Posted[] = []
54
+ const frames: FakeFrame[] = []
55
+ const fed: number[] = []
56
+ let emit: (frame: FakeFrame) => void = () => {}
57
+ let queued: { timestamp: number }[] = []
58
+
59
+ class FakeEncodedVideoChunk {
60
+ type: string
61
+ timestamp: number
62
+ duration: number
63
+ data: Uint8Array
64
+ constructor(init: { type: string; timestamp: number; duration: number; data: Uint8Array }) {
65
+ this.type = init.type
66
+ // WebIDL `long long`: the float sample time loses its fraction here.
67
+ this.timestamp = Math.trunc(init.timestamp)
68
+ this.duration = Math.trunc(init.duration)
69
+ this.data = init.data
70
+ }
71
+ }
72
+
73
+ class FakeVideoDecoder {
74
+ constructor(init: { output: (frame: FakeFrame) => void; error: (e: Error) => void }) {
75
+ emit = init.output
76
+ }
77
+ configure(): void {}
78
+ decode(chunk: { timestamp: number }): void {
79
+ if (opts.decodeThrows) throw new Error('boom')
80
+ fed.push(chunk.timestamp)
81
+ queued.push(chunk)
82
+ }
83
+ flush(): Promise<void> {
84
+ return Promise.resolve().then(() => {
85
+ const batch = queued
86
+ queued = []
87
+ // Real decoders emit in PRESENTATION order regardless of feed order.
88
+ batch.sort((a, b) => a.timestamp - b.timestamp)
89
+ for (const chunk of batch) {
90
+ const frame: FakeFrame = {
91
+ timestamp: chunk.timestamp,
92
+ closed: false,
93
+ close() {
94
+ this.closed = true
95
+ },
96
+ }
97
+ frames.push(frame)
98
+ emit(frame)
99
+ }
100
+ })
101
+ }
102
+ }
103
+
104
+ const scope = {
105
+ onmessage: null as ((ev: { data: DecodeCmd }) => void) | null,
106
+ postMessage(msg: Posted): void {
107
+ posted.push(msg)
108
+ },
109
+ }
110
+
111
+ const factory = new Function('self', 'VideoDecoder', 'EncodedVideoChunk', decodeWorkerSource)
112
+ factory(scope, FakeVideoDecoder, FakeEncodedVideoChunk)
113
+
114
+ return {
115
+ posted,
116
+ frames,
117
+ fed,
118
+ send: (cmd) => scope.onmessage?.({ data: cmd }),
119
+ }
120
+ }
121
+
122
+ function init(preRollEpsilonUs = PRE_ROLL_EPSILON_US): DecodeCmd {
123
+ return { t: 'init', codec: 'av01.0.05M.08', hardwareAcceleration: 'no-preference', preRollEpsilonUs }
124
+ }
125
+
126
+ /** Samples at a real 29.97fps cadence, so the timestamps are the awkward floats §7.2 is about. */
127
+ function sample(index: number, isKey = index === 0): WorkerSample {
128
+ return { tsUs: index * 33333.333, durUs: 33333.333, isKey, data: new Uint8Array([index]) }
129
+ }
130
+
131
+ function dones(posted: Posted[], reqId: number): Posted[] {
132
+ return posted.filter((m) => m.t === 'done' && m.reqId === reqId)
133
+ }
134
+
135
+ function framesFor(posted: Posted[], reqId: number): number[] {
136
+ return posted.flatMap((m) => (m.t === 'frame' && m.reqId === reqId ? [m.frame.timestamp] : []))
137
+ }
138
+
139
+ describe('decode worker source', () => {
140
+ it('preroll-epsilon', async () => {
141
+ // Requested: the frame at cts 33333.333 (sample 1). Decoding starts at the
142
+ // keyframe, so sample 0 is genuine pre-roll and must be closed.
143
+ const target = 33333.333
144
+ const w = harness()
145
+ w.send(init())
146
+ w.send({ t: 'decodeRange', samples: [sample(0), sample(1), sample(2)], targetTsUs: target, reqId: 0 })
147
+ await settle()
148
+
149
+ // The chunk fed at 33333.333 comes back stamped 33333 — an INTEGER, one
150
+ // notch BELOW the target it was asked for.
151
+ expect(w.frames.map((f) => f.timestamp)).toEqual([0, 33333, 66666])
152
+ // With the 1µs epsilon it survives, along with everything after it.
153
+ expect(framesFor(w.posted, 0)).toEqual([33333, 66666])
154
+ // Pre-roll is closed, never transferred, and counted as dropped.
155
+ expect(w.frames[0].closed).toBe(true)
156
+ expect(w.frames.filter((f) => !f.closed).map((f) => f.timestamp)).toEqual([33333, 66666])
157
+ expect(dones(w.posted, 0)).toEqual([{ t: 'done', reqId: 0, decoded: 2, dropped: 1 }])
158
+
159
+ // The same predicate, main-side, so both sides of the string boundary
160
+ // agree on the boundary condition.
161
+ expect(shouldEmitFrame(33333, target)).toBe(true)
162
+ expect(shouldEmitFrame(16666, target)).toBe(false)
163
+
164
+ // And the regression this guards: without the epsilon (the state SP1
165
+ // measured), the requested frame is itself rejected as pre-roll — ~2 of 3
166
+ // seeks silently painted nothing, and those seeks were quietly excluded
167
+ // from the latency distribution rather than failing loudly (§7.2).
168
+ const broken = harness()
169
+ broken.send(init(0))
170
+ broken.send({ t: 'decodeRange', samples: [sample(0), sample(1), sample(2)], targetTsUs: target, reqId: 0 })
171
+ await settle()
172
+ expect(framesFor(broken.posted, 0)).toEqual([66666])
173
+ expect(broken.frames[1].closed).toBe(true)
174
+ })
175
+
176
+ it('stale frames closed, done always posted', async () => {
177
+ const w = harness()
178
+ w.send(init())
179
+
180
+ // req 0 is fed and awaiting flush; its frames have not been emitted yet.
181
+ w.send({ t: 'decodeRange', samples: [sample(0), sample(1)], targetTsUs: 0, reqId: 0 })
182
+ // Two more arrive before any of that lands: 1 is superseded while still
183
+ // queued, 2 is the one that should actually run.
184
+ w.send({ t: 'decodeRange', samples: [sample(2), sample(3)], targetTsUs: 66666.666, reqId: 1 })
185
+ w.send({ t: 'decodeRange', samples: [sample(4), sample(5)], targetTsUs: 133333.332, reqId: 2 })
186
+ await settle()
187
+
188
+ // req 0: superseded mid-flush. Its frames were produced, so they must be
189
+ // CLOSED — never transferred, never leaked.
190
+ expect(framesFor(w.posted, 0)).toEqual([])
191
+ expect(w.frames.slice(0, 2).every((f) => f.closed)).toBe(true)
192
+ // req 1: superseded before reaching the decoder, so nothing was decoded
193
+ // for it at all.
194
+ expect(framesFor(w.posted, 1)).toEqual([])
195
+ expect(w.fed).toEqual([0, 33333, 133333, 166666])
196
+ // req 2: the live one, served normally.
197
+ expect(framesFor(w.posted, 2)).toEqual([133333, 166666])
198
+
199
+ // The total `done` contract: EVERY request gets exactly one, whatever
200
+ // happened to it. SP1 §7.4 is what the alternative costs — an orphaned
201
+ // pending entry and a seek promise that never settles.
202
+ expect(dones(w.posted, 0)).toHaveLength(1)
203
+ expect(dones(w.posted, 1)).toEqual([{ t: 'done', reqId: 1, decoded: 0, dropped: 0 }])
204
+ expect(dones(w.posted, 2)).toHaveLength(1)
205
+ // No `reset()` was needed to achieve any of that — supersession is purely
206
+ // a bookkeeping question, and `reset()` would leave the decoder
207
+ // unconfigured so the next `decode()` throws InvalidStateError.
208
+ expect(decodeWorkerSource).not.toContain('reset()')
209
+ })
210
+
211
+ it('still posts done when the decoder throws', async () => {
212
+ const w = harness({ decodeThrows: true })
213
+ w.send(init())
214
+ w.send({ t: 'decodeRange', samples: [sample(0)], targetTsUs: 0, reqId: 0 })
215
+ await settle()
216
+
217
+ expect(w.posted.filter((m) => m.t === 'error')).toHaveLength(1)
218
+ expect(dones(w.posted, 0)).toEqual([{ t: 'done', reqId: 0, decoded: 0, dropped: 0 }])
219
+ })
220
+
221
+ it('still posts done when decodeRange arrives before init', async () => {
222
+ const w = harness()
223
+ w.send({ t: 'decodeRange', samples: [sample(0)], targetTsUs: 0, reqId: 7 })
224
+ await settle()
225
+
226
+ expect(w.posted.filter((m) => m.t === 'error')).toHaveLength(1)
227
+ expect(dones(w.posted, 7)).toHaveLength(1)
228
+ })
229
+
230
+ it('feeds chunks to the decoder in the exact order handed over, never sorted', async () => {
231
+ // A reordered GOP as the planner slices it: decode order I0 P3 B1 B2, cts
232
+ // zig-zagging. The worker must not "helpfully" sort — that is SP1 §7.1,
233
+ // which killed the decoder outright on the first non-intra frame.
234
+ const gop: WorkerSample[] = [
235
+ { tsUs: 0, durUs: 1000, isKey: true, data: new Uint8Array([0]) },
236
+ { tsUs: 3000, durUs: 1000, isKey: false, data: new Uint8Array([1]) },
237
+ { tsUs: 1000, durUs: 1000, isKey: false, data: new Uint8Array([2]) },
238
+ { tsUs: 2000, durUs: 1000, isKey: false, data: new Uint8Array([3]) },
239
+ ]
240
+ const w = harness()
241
+ w.send(init())
242
+ w.send({ t: 'decodeRange', samples: gop, targetTsUs: 0, reqId: 0 })
243
+ await settle()
244
+
245
+ expect(w.fed).toEqual([0, 3000, 1000, 2000])
246
+ // …while the frames come back in presentation order, as a real decoder emits them.
247
+ expect(framesFor(w.posted, 0)).toEqual([0, 1000, 2000, 3000])
248
+ })
249
+ })