@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,900 @@
1
+ /**
2
+ * SP4 T2 — demux layer: parse an MP4 container with mp4box.js and expose a
3
+ * flat, codec-agnostic sample index the decode layer (T3) can drive directly
4
+ * with WebCodecs (`EncodedVideoChunk` / `EncodedAudioChunk`).
5
+ *
6
+ * Ported from `spikes/playback-engine/src/demux.ts`. Three patterns come
7
+ * across verbatim in *behavior* because each one is the fix for a measured
8
+ * SP1 defect, not a stylistic choice (`spikes/playback-engine/RESULTS.md`
9
+ * §7):
10
+ *
11
+ * 1. **Decode-order `samples` + a separate `presIndex`** (§7.1, the spike's
12
+ * most severe bug). The spike originally sorted samples by CTS, which is
13
+ * presentation order. WebCodecs requires chunks in DECODE order; the
14
+ * source had B-frames (87 of the first 117 frames), so PTS ≠ DTS and
15
+ * `VideoDecoder` threw `Decoding error` immediately after the first
16
+ * keyframe and stayed dead until the worker was respawned. The bug was
17
+ * invisible on all-intra proxies (decode order == presentation order) —
18
+ * i.e. it could only ever manifest on the thing being compared against,
19
+ * which is exactly why it survived to the measurement stage. `samples`
20
+ * therefore stays in container order forever, and every *time*-based
21
+ * question bisects `presIndex` instead.
22
+ * 2. **The `esds` AudioSpecificConfig walk.** avcC/hvcC/vpcC/av1C are boxes
23
+ * that serialize straight back into the bytes WebCodecs wants; `esds` is
24
+ * a nested descriptor tree that does not, so AAC needs its own walk.
25
+ * Kept general even though SP3's proxies are avc1+opus (neither of which
26
+ * needs it): the engine also has to be able to look at whatever else a
27
+ * host hands it, and the generality costs ~15 lines that are already
28
+ * written and already tested.
29
+ * 3. **`firstPresentationTsUs` as the t=0 origin contract.** Media
30
+ * timestamps do not necessarily start at 0, and in decode order
31
+ * `samples[0]` need not carry the lowest CTS. Every consumer maps
32
+ * through this one number; T4's clock math is built on it.
33
+ *
34
+ * Deliberate deviations from the spike, each for a stated reason — see the
35
+ * comments at `demuxBytes`, `ChunkSource.audio`, and `fps`.
36
+ */
37
+ // mp4box ships no types; `./mp4box.d.ts` supplies them. The explicit
38
+ // reference is load-bearing for CONSUMERS of this package: an ambient
39
+ // `.d.ts` inside a dependency's `src/` is not auto-included by a host's
40
+ // `tsc` (only by this package's own `include: ["src"]`), so without this
41
+ // line montaj's ui build fails with TS2307 the moment it pulls in this file.
42
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
43
+ /// <reference path="./mp4box.d.ts" />
44
+ import * as MP4Box from 'mp4box'
45
+ import type {
46
+ MP4ArrayBuffer,
47
+ MP4BoxDescriptor,
48
+ MP4BoxSampleEntry,
49
+ MP4BoxTrak,
50
+ MP4File,
51
+ MP4Info,
52
+ MP4Sample,
53
+ MP4TrackInfo,
54
+ } from 'mp4box'
55
+ import {
56
+ openByteSource,
57
+ type FileUrlResolver,
58
+ type LoadBytesOptions,
59
+ type MediaByteSource,
60
+ } from './media-loader'
61
+
62
+ /** One encoded sample (video frame / audio packet), ready to become an `Encoded*Chunk`. */
63
+ export interface SampleRef {
64
+ /**
65
+ * Composition/presentation time (cts) in microseconds.
66
+ *
67
+ * A FLOAT, deliberately: it is `cts / timescale * 1e6` and is left
68
+ * un-rounded so no precision is discarded here. SP1 §7.2 is the reason
69
+ * this is called out — `EncodedVideoChunk.timestamp` is a `long long`, so
70
+ * a chunk fed at `33333.333` comes back off the decoder stamped `33333`,
71
+ * and a naive `frame.timestamp < targetTsUs` pre-roll test rejects the
72
+ * very frame that was requested (~2 of 3 seeks painted nothing). The cure
73
+ * is a 1µs epsilon at the comparison site, which belongs to T3, not here.
74
+ * Rounding at this layer instead would push the same error into the
75
+ * duration/origin arithmetic.
76
+ */
77
+ tsUs: number
78
+ /** Decode time (dts) in microseconds — differs from `tsUs` whenever B-frames are present. */
79
+ dtsUs: number
80
+ /** Sample duration in microseconds. */
81
+ durUs: number
82
+ /** Sync sample (keyframe): decoding may start here. */
83
+ isKey: boolean
84
+ /**
85
+ * The encoded bytes.
86
+ *
87
+ * On a RANGED source (the default — see `demuxRanged`) this starts as the
88
+ * shared empty array and is filled in by `ChunkSource.ensure`, then emptied
89
+ * again when the loader evicts the span. It is therefore only meaningful
90
+ * inside the window a caller has just ensured; anywhere else, an empty
91
+ * `data` means "not resident", not "an empty sample". Both readers —
92
+ * `frame-server.ts`'s `postBatch` and `audio-clock.ts`'s `feedMore` — go
93
+ * through `ensure` first for exactly this reason.
94
+ */
95
+ data: Uint8Array
96
+ /**
97
+ * Byte offset of this sample in the file. Ranged sources only; `undefined`
98
+ * on the whole-file path, where the bytes are already in hand and there is
99
+ * nothing to range on.
100
+ */
101
+ offset?: number
102
+ /** Encoded length in bytes. Ranged sources only, same as {@link offset}. */
103
+ size?: number
104
+ }
105
+
106
+ /**
107
+ * The "not resident" sample payload. One shared instance rather than a fresh
108
+ * `new Uint8Array(0)` per sample: a 2-minute all-intra proxy has ~4000 video
109
+ * samples and ~7000 audio packets, and allocating 11000 empty typed arrays to
110
+ * represent "nothing here yet" is pure waste.
111
+ */
112
+ const EMPTY_SAMPLE_DATA = new Uint8Array(0)
113
+
114
+ /** A demuxed track: its decoder config plus its full sample index. */
115
+ export interface ChunkSource {
116
+ kind: 'video' | 'audio'
117
+ /**
118
+ * The container's codec string, e.g. `av01.0.05M.08`, `hvc1.2.4.L153.B0`,
119
+ * `Opus`, `mp4a.40.2`. Reported verbatim — normalization is the decode
120
+ * layer's job, and it is not cosmetic: mp4box reports `'Opus'` while
121
+ * WebCodecs requires `'opus'`, and `'Opus'.startsWith('opus')` is false,
122
+ * so an un-normalized comparison silently drops every proxy onto the
123
+ * wall-clock fallback (SP1 §7.5). T4 owns `normalizeAudioCodec`.
124
+ */
125
+ codec: string
126
+ /** avcC/hvcC/vpcC/av1C payload, or the esds AudioSpecificConfig, for `*DecoderConfig.description`. */
127
+ description?: Uint8Array
128
+ /**
129
+ * Video: frames per second, derived as `sampleCount / durationS`. Audio:
130
+ * packets per second (kept for symmetry with the spike's shape; it is not
131
+ * a meaningful rate for audio — use `audio.sampleRate`). 0 when the track
132
+ * reports no duration, rather than the spike's `Infinity`/`NaN`.
133
+ */
134
+ fps: number
135
+ durationS: number
136
+ /**
137
+ * Video: the CODED dimensions from the visual sample entry (stsd) — what
138
+ * the decoder emits, which is what the painter draws. Distinct from tkhd's
139
+ * `track_width`/`track_height`, the *display* size after the rotation
140
+ * matrix; on portrait phone footage the two differ. Only `coded` is
141
+ * carried because SP3's proxies are re-encoded by ffmpeg with rotation
142
+ * already baked in, so display == coded for everything the engine opens.
143
+ * `{0, 0}` for audio.
144
+ */
145
+ coded: { width: number; height: number }
146
+ /**
147
+ * Audio tracks only: the real rate/channel count from the container's
148
+ * audio sample entry.
149
+ *
150
+ * Deviation from the spike, deliberate: the spike's `ChunkSource` carried
151
+ * neither, so `audio.ts` hardcoded 48000/2 and flagged it as "load-bearing,
152
+ * not decorative" — `AudioDecoderConfig` requires both fields. mp4box hands
153
+ * them over for free, so T4 gets to read the container instead of assuming
154
+ * it (with 48000/2 still available as the fallback when a container omits
155
+ * them, which is what Opus-in-MP4 conventionally reports anyway).
156
+ */
157
+ audio?: { sampleRate: number; channelCount: number }
158
+ /**
159
+ * The samples in **DECODE order, exactly as the container stores them** —
160
+ * NOT sorted by `tsUs`. This is a contract, not an implementation detail:
161
+ * feeding `VideoDecoder` in presentation order throws on the first
162
+ * non-intra frame (SP1 §7.1). Slice decode ranges out of this array.
163
+ */
164
+ samples: SampleRef[]
165
+ /**
166
+ * Indices into `samples`, ascending by `tsUs` (presentation order).
167
+ * Seeking asks a question about presentation time, so every time→sample
168
+ * lookup bisects this rather than `samples` directly. For all-intra
169
+ * sources (SP3's proxies) it is just `[0, 1, 2, …]`, since cts order ==
170
+ * dts order — which is precisely why a bug here stays invisible until a
171
+ * B-frame source shows up.
172
+ */
173
+ presIndex: number[]
174
+ /**
175
+ * The t=0 origin: presentation time of the earliest frame in this track.
176
+ * Media timestamps need not start at 0, so every conversion between
177
+ * project time and media time subtracts this. Resolved once here so no
178
+ * consumer re-derives it (and so nobody re-derives it as `samples[0].tsUs`,
179
+ * which is the first *decoded* frame and need not carry the lowest cts).
180
+ */
181
+ firstPresentationTsUs: number
182
+ /**
183
+ * Make `samples[startIdx…endIdx)` readable: fetch whatever part of that
184
+ * decode range is not resident and populate each sample's `data`.
185
+ *
186
+ * **Returns `null` when the bytes are already there**, which is the whole
187
+ * point of the shape: the whole-file path never defines `ensure` at all, and
188
+ * a ranged source returns `null` for a range it has already pulled, so the
189
+ * hot path (`frame-server.ts` posting the next batch of a stream that is
190
+ * already fed) stays synchronous. Only a genuine miss costs a promise — and
191
+ * a caller that gets one MUST await it before reading `data`.
192
+ *
193
+ * Rejects if the range cannot be fetched. Callers turn that into the same
194
+ * failure they already have for a proxy that will not load.
195
+ */
196
+ ensure?: (startIdx: number, endIdx: number) => Promise<void> | null
197
+ }
198
+
199
+ /** What `demux` returns for one media file: a video track and, if present, an audio track. */
200
+ export interface DemuxedSource {
201
+ /** The `src` this was demuxed from — carried for logging and per-source session bookkeeping. */
202
+ src: string
203
+ video: ChunkSource
204
+ audio: ChunkSource | null
205
+ /**
206
+ * Release the underlying byte source: drop its cached spans and abort reads
207
+ * still in flight. Ranged sources only; absent on the whole-file path, which
208
+ * holds nothing but the buffer the caller can already drop.
209
+ *
210
+ * Called by `index.ts` when a source leaves the demux LRU or the engine is
211
+ * torn down. A disposed source cannot be read again — evicting one that a
212
+ * live decode session is still using would strand it, which is why
213
+ * `evictDemux` skips any src with a frame server on it.
214
+ */
215
+ dispose?: () => void
216
+ }
217
+
218
+ /**
219
+ * The subset of `ChunkSource` the pure lookups below need. Declared so tests
220
+ * (and callers holding only a sample table) can use them without fabricating
221
+ * a codec string, dimensions and a duration.
222
+ */
223
+ export type SampleTable = Pick<ChunkSource, 'samples' | 'presIndex'>
224
+
225
+ // ── Pure sample-table logic ──────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Build the presentation-order index over a decode-ordered sample array:
229
+ * `presIndex[k]` is the index in `samples` of the k-th frame by presentation
230
+ * time.
231
+ *
232
+ * `Array.prototype.sort` is stable (spec-guaranteed since ES2019), so samples
233
+ * sharing a `tsUs` keep their relative decode order — the tie-break that
234
+ * matters if a container ever emits duplicate composition times.
235
+ */
236
+ export function buildPresIndex(samples: readonly SampleRef[]): number[] {
237
+ return samples.map((_, i) => i).sort((x, y) => samples[x].tsUs - samples[y].tsUs)
238
+ }
239
+
240
+ /**
241
+ * Presentation time of the earliest frame — the natural zero of this track's
242
+ * timeline. Cannot be `samples[0]`: in decode order that is the first
243
+ * *decoded* frame, which with B-frames need not carry the lowest cts.
244
+ * Returns 0 for an empty track.
245
+ */
246
+ export function firstPresentationTsUs(
247
+ samples: readonly SampleRef[],
248
+ presIndex: readonly number[],
249
+ ): number {
250
+ if (samples.length === 0 || presIndex.length === 0) return 0
251
+ return samples[presIndex[0]].tsUs
252
+ }
253
+
254
+ /**
255
+ * DECODE index of the sample whose presentation time is the latest `<= tUs`.
256
+ * Bisects `presIndex` (presentation order) and returns the value it points
257
+ * at (a decode index), which is what a decode range has to be sliced
258
+ * against. For `tUs` before the first frame, returns the earliest frame by
259
+ * presentation time rather than failing.
260
+ */
261
+ export function sampleAtOrBefore(src: SampleTable, tUs: number): number {
262
+ const { samples, presIndex } = src
263
+ if (samples.length === 0 || presIndex.length === 0) return 0
264
+
265
+ let lo = 0
266
+ let hi = presIndex.length - 1
267
+ let at = presIndex[0]
268
+ while (lo <= hi) {
269
+ const mid = (lo + hi) >> 1
270
+ if (samples[presIndex[mid]].tsUs <= tUs) {
271
+ at = presIndex[mid]
272
+ lo = mid + 1
273
+ } else {
274
+ hi = mid - 1
275
+ }
276
+ }
277
+ return at
278
+ }
279
+
280
+ /**
281
+ * DECODE index of the keyframe to start decoding from in order to display the
282
+ * frame at `tUs`.
283
+ *
284
+ * Walks back through **decode** order, not presentation order. Decoding is a
285
+ * contiguous run of the decode-ordered array starting at a keyframe, and with
286
+ * B-frames the frames needed to reconstruct the target can sit at
287
+ * presentation times *after* it while preceding it in decode order — so a
288
+ * walk that skipped samples with `tsUs > tUs` would step over the very
289
+ * keyframe the run must start at. Returns 0 when the track has no sync sample
290
+ * at or before the target (decode from the top).
291
+ */
292
+ export function keyframeAtOrBefore(src: SampleTable, tUs: number): number {
293
+ const samples = src.samples
294
+ if (samples.length === 0) return 0
295
+
296
+ const targetIdx = sampleAtOrBefore(src, tUs)
297
+ for (let i = targetIdx; i >= 0; i--) {
298
+ if (samples[i].isKey) return i
299
+ }
300
+ return 0
301
+ }
302
+
303
+ // ── Codec-configuration extraction ───────────────────────────────────────────
304
+
305
+ /**
306
+ * Tags in the MPEG-4 descriptor tree an `esds` box carries:
307
+ * ES_Descriptor → DecoderConfigDescriptor (0x04) → DecoderSpecificInfo (0x05).
308
+ */
309
+ const DECODER_CONFIG_DESCR_TAG = 0x04
310
+ const DEC_SPECIFIC_INFO_TAG = 0x05
311
+
312
+ function findDescriptor(desc: MP4BoxDescriptor | undefined, tag: number): MP4BoxDescriptor | undefined {
313
+ if (!desc?.descs) return undefined
314
+ for (const child of desc.descs) {
315
+ if (child.tag === tag) return child
316
+ }
317
+ return undefined
318
+ }
319
+
320
+ /**
321
+ * Walk the `esds` descriptor tree to the raw AudioSpecificConfig bytes
322
+ * `AudioDecoderConfig.description` wants for AAC. A different code path from
323
+ * the box dump below on purpose: avcC/hvcC/vpcC/av1C are boxes that
324
+ * round-trip through `box.write()`, whereas `esds` is a nested descriptor
325
+ * tree with no such serialization.
326
+ *
327
+ * Returns `undefined` when the tree is absent or truncated — the caller
328
+ * treats a missing description as "cannot configure a decoder for this
329
+ * track", which is the honest outcome (SP1 §6/§7.1 hit exactly this on
330
+ * QuickTime-v1 AAC, where mp4box cannot produce the config at all).
331
+ */
332
+ export function audioSpecificConfigFor(entry: MP4BoxSampleEntry): Uint8Array | undefined {
333
+ const esd = entry.esds?.esd
334
+ if (!esd) return undefined
335
+ const dcd = findDescriptor(esd, DECODER_CONFIG_DESCR_TAG)
336
+ const dsi = findDescriptor(dcd, DEC_SPECIFIC_INFO_TAG)
337
+ if (dsi?.data) return new Uint8Array(dsi.data)
338
+ return undefined
339
+ }
340
+
341
+ /**
342
+ * Extract the decoder `description` payload from a track's sample-description
343
+ * entries.
344
+ *
345
+ * The canonical W3C-samples pattern for avcC/hvcC/vpcC/av1C: those
346
+ * sample-entry child boxes serialize straight back into the bytes
347
+ * `VideoDecoderConfig.description` wants, so the box is re-written and its
348
+ * 8-byte header (4-byte size + 4-byte fourcc) stripped. Audio falls through
349
+ * to the `esds` walk above.
350
+ *
351
+ * `undefined` is a legitimate result, not an error: AV1 in some muxes and
352
+ * Opus in MP4 both configure fine without a description.
353
+ */
354
+ export function descriptionForEntries(entries: MP4BoxSampleEntry[]): Uint8Array | undefined {
355
+ for (const entry of entries) {
356
+ const box = entry.avcC ?? entry.hvcC ?? entry.vpcC ?? entry.av1C
357
+ if (box) {
358
+ const stream = new MP4Box.DataStream(undefined, 0, MP4Box.DataStream.BIG_ENDIAN)
359
+ box.write(stream)
360
+ return new Uint8Array(stream.buffer, 8) // strip box header
361
+ }
362
+ const audioDesc = audioSpecificConfigFor(entry)
363
+ if (audioDesc) return audioDesc
364
+ }
365
+ return undefined
366
+ }
367
+
368
+ // ── Demux ────────────────────────────────────────────────────────────────────
369
+
370
+ /** Convert one mp4box sample into a `SampleRef`, rebasing its ticks onto microseconds. */
371
+ function toSampleRef(s: MP4Sample, timescale: number): SampleRef {
372
+ return {
373
+ tsUs: (s.cts / timescale) * 1e6,
374
+ dtsUs: (s.dts / timescale) * 1e6,
375
+ durUs: (s.duration / timescale) * 1e6,
376
+ isKey: !!s.is_sync,
377
+ data: s.data,
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Demux a complete MP4 byte buffer into its video (+ optional audio)
383
+ * `ChunkSource`.
384
+ *
385
+ * **Synchronous, unlike the spike's Promise-wrapped version — deliberately.**
386
+ * The spike wrapped this in `new Promise` because mp4box's API is
387
+ * callback-shaped, then documented in the same file that the callbacks are
388
+ * not actually asynchronous: `appendBuffer()` parses synchronously when
389
+ * handed the whole file in one call, `onReady`/`onSamples` fire inside that
390
+ * call, and `flush()` synchronously drains the remainder — the spike's
391
+ * `resolve()` ran on the same tick regardless. Keeping the Promise would
392
+ * therefore have bought nothing and cost T3 the ability to call this straight
393
+ * from the decode worker, and cost these tests an `await` on every case.
394
+ * Errors surface as a thrown `Error` where the spike rejected; the timing is
395
+ * identical either way.
396
+ *
397
+ * @param label used only in error messages (the `src` that produced `bytes`).
398
+ */
399
+ export function demuxBytes(bytes: ArrayBuffer, label = 'media'): DemuxedSource {
400
+ const file = MP4Box.createFile()
401
+
402
+ let videoTrackId: number | null = null
403
+ let audioTrackId: number | null = null
404
+ let videoCoded = { width: 0, height: 0 }
405
+ let videoCodec = ''
406
+ let audioCodec = ''
407
+ let audioParams: { sampleRate: number; channelCount: number } | undefined
408
+ let videoTimescale = 1
409
+ let audioTimescale = 1
410
+ let videoDurationTicks = 0
411
+ let audioDurationTicks = 0
412
+
413
+ const videoSamples: SampleRef[] = []
414
+ const audioSamples: SampleRef[] = []
415
+ // mp4box reports parse failures through `onError` rather than throwing, so
416
+ // they are collected and re-thrown below. Collected into an array (not a
417
+ // single nullable) so nothing depends on TypeScript's narrowing of a `let`
418
+ // that is only ever written from inside a callback.
419
+ const errors: string[] = []
420
+
421
+ file.onError = (e) => {
422
+ errors.push(String(e))
423
+ }
424
+
425
+ file.onReady = (info) => {
426
+ const vTrack = info.videoTracks?.[0]
427
+ const aTrack = info.audioTracks?.[0]
428
+
429
+ if (vTrack) {
430
+ videoTrackId = vTrack.id
431
+ videoCodec = vTrack.codec
432
+ videoTimescale = vTrack.timescale || 1
433
+ videoDurationTicks = vTrack.duration
434
+ // CODED dimensions (stsd), not tkhd's display size — see
435
+ // `ChunkSource.coded`. `track_width/height` is the fallback only
436
+ // because a 0x0 coded size would break decoder configuration outright.
437
+ videoCoded = {
438
+ width: vTrack.video?.width ?? vTrack.track_width,
439
+ height: vTrack.video?.height ?? vTrack.track_height,
440
+ }
441
+ file.setExtractionOptions(vTrack.id, null, { nbSamples: Infinity })
442
+ }
443
+
444
+ if (aTrack) {
445
+ audioTrackId = aTrack.id
446
+ audioCodec = aTrack.codec
447
+ audioTimescale = aTrack.timescale || 1
448
+ audioDurationTicks = aTrack.duration
449
+ if (aTrack.audio) {
450
+ audioParams = {
451
+ sampleRate: aTrack.audio.sample_rate,
452
+ channelCount: aTrack.audio.channel_count,
453
+ }
454
+ }
455
+ file.setExtractionOptions(aTrack.id, null, { nbSamples: Infinity })
456
+ }
457
+
458
+ file.start()
459
+ }
460
+
461
+ file.onSamples = (trackId, _user, samples) => {
462
+ const target =
463
+ trackId === videoTrackId ? videoSamples : trackId === audioTrackId ? audioSamples : null
464
+ if (!target) return
465
+ const timescale = trackId === videoTrackId ? videoTimescale : audioTimescale
466
+ for (const s of samples) target.push(toSampleRef(s, timescale))
467
+ }
468
+
469
+ const buffer = bytes as MP4ArrayBuffer
470
+ buffer.fileStart = 0
471
+ file.appendBuffer(buffer)
472
+ file.flush()
473
+
474
+ if (errors.length > 0) {
475
+ throw new Error(`demux: ${label} — mp4box parse error: ${errors.join('; ')}`)
476
+ }
477
+ if (videoTrackId === null) {
478
+ throw new Error(`demux: no video track in ${label}`)
479
+ }
480
+ // A described track that hands out no samples means the moov parsed but the
481
+ // media data is not there — a truncated file, or one whose `mdat` header
482
+ // declares bytes the file does not contain. mp4box reports no error for
483
+ // this: `onReady` fires off the sample table in the moov, and `onSamples`
484
+ // simply never comes.
485
+ //
486
+ // Failing here is load-bearing. An empty table is not a degraded source, it
487
+ // is an unplayable one, and every downstream helper tolerates it silently
488
+ // (`sampleAtOrBefore`/`keyframeAtOrBefore`/`firstPresentationTsUs` all
489
+ // return 0 for an empty table). Left unchecked it built a "valid" source,
490
+ // `EngineSourceHost` marked the session `ready`, and the scheduler's
491
+ // `!source` branch never fired — so the clip's range stayed on
492
+ // `picture: 'video'` and playback froze holding the PREVIOUS clip's last
493
+ // frame while project time advanced. Showing the wrong clip's picture with
494
+ // no error surfaced is the outcome the SP4 checklist §A.14 rules out.
495
+ // Throwing routes the clip into the existing failure path, which already
496
+ // does the right thing: Preparing placeholder, scoped to that clip, with a
497
+ // reason attached.
498
+ if (videoSamples.length === 0) {
499
+ throw new Error(
500
+ `demux: ${label} — video track has no sample data (truncated or missing mdat)`,
501
+ )
502
+ }
503
+
504
+ const buildTrack = (
505
+ kind: 'video' | 'audio',
506
+ trackId: number,
507
+ codec: string,
508
+ samples: SampleRef[],
509
+ durationTicks: number,
510
+ timescale: number,
511
+ ): ChunkSource => {
512
+ const presIndex = buildPresIndex(samples)
513
+ const durationS = durationTicks / timescale
514
+ const trak = file.getTrackById(trackId)
515
+ return {
516
+ kind,
517
+ codec,
518
+ description: trak ? descriptionForEntries(trak.mdia.minf.stbl.stsd.entries) : undefined,
519
+ // Guarded against the spike's unguarded divide: a track reporting zero
520
+ // duration produced Infinity (or NaN for an empty track), which then
521
+ // propagated into every downstream frame-budget calculation.
522
+ fps: durationS > 0 ? samples.length / durationS : 0,
523
+ durationS,
524
+ coded: kind === 'video' ? videoCoded : { width: 0, height: 0 },
525
+ ...(kind === 'audio' && audioParams ? { audio: audioParams } : {}),
526
+ samples,
527
+ presIndex,
528
+ firstPresentationTsUs: firstPresentationTsUs(samples, presIndex),
529
+ }
530
+ }
531
+
532
+ return {
533
+ src: label,
534
+ video: buildTrack('video', videoTrackId, videoCodec, videoSamples, videoDurationTicks, videoTimescale),
535
+ audio:
536
+ audioTrackId === null
537
+ ? null
538
+ : buildTrack('audio', audioTrackId, audioCodec, audioSamples, audioDurationTicks, audioTimescale),
539
+ }
540
+ }
541
+
542
+ // ── Ranged demux ─────────────────────────────────────────────────────────────
543
+
544
+ /**
545
+ * How much of the file each header-walk step asks for while hunting `moov`.
546
+ *
547
+ * Equal to `media-loader.ts`'s `BLOCK_BYTES`/`PROBE_BYTES` so the first step is
548
+ * served straight out of the bytes the opening probe already fetched — for a
549
+ * `+faststart` proxy (which is what `lib/proxy.py` writes) that means the whole
550
+ * sample index arrives in the SAME request that measured the file, and the walk
551
+ * ends after one iteration with zero extra round trips.
552
+ */
553
+ const HEADER_CHUNK_BYTES = 1024 * 1024
554
+
555
+ /**
556
+ * Hard stop on the header walk. `moov` is either at the front (faststart) or
557
+ * one hop past `mdat` (mp4box reports where to jump), so a real file finishes
558
+ * in one or two steps; this only exists so a malformed container cannot spin
559
+ * the loop forever.
560
+ */
561
+ const MAX_HEADER_CHUNKS = 64
562
+
563
+ /**
564
+ * Resident-sample-data budget per track.
565
+ *
566
+ * This is the number that replaces "the whole proxy" as the engine's memory
567
+ * cost. Video gets the larger share because its samples are three orders of
568
+ * magnitude bigger than Opus packets; both are small next to a 400 MB file,
569
+ * which is the point.
570
+ */
571
+ export const MAX_RESIDENT_VIDEO_BYTES = 16 * 1024 * 1024
572
+ export const MAX_RESIDENT_AUDIO_BYTES = 2 * 1024 * 1024
573
+
574
+ /**
575
+ * How many of the most recently loaded ranges are exempt from eviction.
576
+ *
577
+ * Load-bearing, not a tuning knob. A caller's sequence is `await ensure(a, b)`
578
+ * and then a SYNCHRONOUS read of `samples[a…b)`, and another track's `ensure`
579
+ * can resolve — and evict — in the microtask gap between the two. Anything
580
+ * inside the last few ranges must therefore still be there when the awaiting
581
+ * caller wakes up. Four covers `MAX_IN_FLIGHT_BATCHES` (2) with room to spare.
582
+ */
583
+ const PROTECTED_RANGES = 4
584
+
585
+ /** Copy bytes into a standalone `ArrayBuffer` tagged with its file offset for mp4box. */
586
+ function toMp4Buffer(bytes: Uint8Array, fileStart: number): MP4ArrayBuffer {
587
+ const copy = new ArrayBuffer(bytes.length)
588
+ new Uint8Array(copy).set(bytes)
589
+ const buffer = copy as MP4ArrayBuffer
590
+ buffer.fileStart = fileStart
591
+ return buffer
592
+ }
593
+
594
+ /**
595
+ * Feed mp4box top-level boxes until it has parsed `moov`, skipping `mdat`.
596
+ *
597
+ * `appendBuffer` returns the file position mp4box wants next — that is the
598
+ * whole streaming contract, and it is what lets this jump over an `mdat` that
599
+ * may be hundreds of megabytes without reading a byte of it. Two cases come
600
+ * back and they need different handling:
601
+ *
602
+ * - `next > end` (past what we just fed): mp4box finished a box and is telling
603
+ * us to skip ahead. Follow it.
604
+ * - `next <= end`: mp4box needs MORE of the box it is in the middle of.
605
+ * Continue sequentially from where we stopped — following `next` here would
606
+ * re-read the same bytes forever.
607
+ *
608
+ * Once `onReady` fires, mp4box has already run `buildSampleLists()`, so every
609
+ * track's `samples` array is fully populated with offsets and sizes and no
610
+ * data. That is the sample index; nothing else needs to be downloaded to build
611
+ * it.
612
+ */
613
+ async function readMoov(
614
+ source: MediaByteSource,
615
+ label: string,
616
+ ): Promise<{ file: MP4File; info: MP4Info }> {
617
+ const file = MP4Box.createFile()
618
+ const errors: string[] = []
619
+ let info: MP4Info | null = null
620
+
621
+ file.onError = (e) => {
622
+ errors.push(String(e))
623
+ }
624
+ file.onReady = (parsed) => {
625
+ info = parsed
626
+ }
627
+
628
+ let next = 0
629
+ for (let step = 0; step < MAX_HEADER_CHUNKS; step++) {
630
+ if (next >= source.size) break
631
+ const end = Math.min(next + HEADER_CHUNK_BYTES, source.size)
632
+ const chunk = await source.read(next, end)
633
+ if (chunk.length === 0) break
634
+ const returned = file.appendBuffer(toMp4Buffer(chunk, next))
635
+ if (errors.length > 0) {
636
+ throw new Error(`demux: ${label} — mp4box parse error: ${errors.join('; ')}`)
637
+ }
638
+ if (info) return { file, info }
639
+ next = typeof returned === 'number' && returned > end ? returned : end
640
+ }
641
+
642
+ throw new Error(`demux: ${label} — no moov box found in ${source.size} bytes`)
643
+ }
644
+
645
+ /** One loaded decode range, for eviction bookkeeping. */
646
+ interface LoadedRange {
647
+ seq: number
648
+ startIdx: number
649
+ endIdx: number
650
+ }
651
+
652
+ /**
653
+ * Build a track's `ensure`: the on-demand loader that turns "samples `a…b`"
654
+ * into "one byte range" and fills in their `data`.
655
+ *
656
+ * The byte range is the union of the samples' extents, which for an interleaved
657
+ * MP4 means it also spans the OTHER track's bytes sitting between them. That is
658
+ * fine and in fact desirable — `media-loader.ts` caches block-aligned, so the
659
+ * audio clock's read of the same region is a cache hit rather than a second
660
+ * fetch of the same megabytes.
661
+ *
662
+ * Ownership, not chunk membership, drives eviction: when a range overlaps one
663
+ * already loaded, the NEWER range takes ownership of the shared samples, so
664
+ * retiring the older one cannot pull bytes out from under a caller that just
665
+ * awaited the newer one.
666
+ */
667
+ function createRangeLoader(
668
+ source: MediaByteSource,
669
+ samples: SampleRef[],
670
+ budgetBytes: number,
671
+ ): ((startIdx: number, endIdx: number) => Promise<void> | null) | undefined {
672
+ if (samples.length === 0) return undefined
673
+
674
+ /** `owner[i]` is the seq of the range that materialized sample i, or -1 for "not resident". */
675
+ const owner = new Int32Array(samples.length).fill(-1)
676
+ const ranges: LoadedRange[] = []
677
+ const inflight = new Map<string, Promise<void>>()
678
+ let residentBytes = 0
679
+ let nextSeq = 0
680
+
681
+ function evict(): void {
682
+ while (residentBytes > budgetBytes && ranges.length > PROTECTED_RANGES) {
683
+ const victim = ranges.shift()
684
+ if (!victim) return
685
+ for (let i = victim.startIdx; i < victim.endIdx; i++) {
686
+ if (owner[i] !== victim.seq) continue
687
+ residentBytes -= samples[i].data.length
688
+ samples[i].data = EMPTY_SAMPLE_DATA
689
+ owner[i] = -1
690
+ }
691
+ }
692
+ }
693
+
694
+ async function load(startIdx: number, endIdx: number): Promise<void> {
695
+ let lo = Number.POSITIVE_INFINITY
696
+ let hi = 0
697
+ for (let i = startIdx; i < endIdx; i++) {
698
+ const offset = samples[i].offset ?? 0
699
+ const size = samples[i].size ?? 0
700
+ if (offset < lo) lo = offset
701
+ if (offset + size > hi) hi = offset + size
702
+ }
703
+ if (!(hi > lo)) return
704
+
705
+ const seq = nextSeq++
706
+ const bytes = await source.read(lo, hi)
707
+ for (let i = startIdx; i < endIdx; i++) {
708
+ if (owner[i] < 0) {
709
+ const sample = samples[i]
710
+ const at = (sample.offset ?? 0) - lo
711
+ const size = sample.size ?? 0
712
+ if (at < 0 || at + size > bytes.length) {
713
+ throw new Error(
714
+ `demux: ${source.src} — sample ${i} falls outside its own byte range (truncated file?)`,
715
+ )
716
+ }
717
+ // A COPY, not a subarray view: a view would pin the whole fetched span
718
+ // for as long as any single sample outlives it, which is the memory
719
+ // problem this module exists to remove. `frame-server.ts` structured-
720
+ // clones these to the worker, so it copies regardless.
721
+ sample.data = bytes.slice(at, at + size)
722
+ residentBytes += sample.data.length
723
+ }
724
+ owner[i] = seq
725
+ }
726
+ ranges.push({ seq, startIdx, endIdx })
727
+ evict()
728
+ }
729
+
730
+ return (startIdx: number, endIdx: number): Promise<void> | null => {
731
+ const from = Math.max(0, Math.min(startIdx, samples.length))
732
+ const to = Math.max(from, Math.min(endIdx, samples.length))
733
+ if (to === from) return null
734
+ let missing = false
735
+ for (let i = from; i < to; i++) {
736
+ if (owner[i] < 0) {
737
+ missing = true
738
+ break
739
+ }
740
+ }
741
+ if (!missing) return null
742
+
743
+ const key = `${from}:${to}`
744
+ const existing = inflight.get(key)
745
+ if (existing) return existing
746
+ const pending = load(from, to).finally(() => {
747
+ inflight.delete(key)
748
+ })
749
+ inflight.set(key, pending)
750
+ return pending
751
+ }
752
+ }
753
+
754
+ /** Sample index for one track, straight off the moov — offsets and sizes, no data. */
755
+ function refsFromTrak(trak: MP4BoxTrak, timescale: number): SampleRef[] {
756
+ const out: SampleRef[] = []
757
+ for (const s of trak.samples ?? []) {
758
+ out.push({
759
+ tsUs: (s.cts / timescale) * 1e6,
760
+ dtsUs: (s.dts / timescale) * 1e6,
761
+ durUs: (s.duration / timescale) * 1e6,
762
+ isKey: !!s.is_sync,
763
+ data: EMPTY_SAMPLE_DATA,
764
+ offset: s.offset,
765
+ size: s.size,
766
+ })
767
+ }
768
+ return out
769
+ }
770
+
771
+ /** The byte the track's last sample ends at — 0 for an empty track. */
772
+ function trackEndOffset(samples: readonly SampleRef[]): number {
773
+ let end = 0
774
+ for (const s of samples) {
775
+ const at = (s.offset ?? 0) + (s.size ?? 0)
776
+ if (at > end) end = at
777
+ }
778
+ return end
779
+ }
780
+
781
+ /**
782
+ * Build a lazily-loaded `DemuxedSource` over an already-open byte source.
783
+ *
784
+ * Everything except sample DATA comes from the moov, so this returns as soon as
785
+ * the header is parsed — a couple of hundred KB regardless of whether the file
786
+ * behind it is 40 MB or 400 MB, which is the acceptance criterion this whole
787
+ * change exists to meet.
788
+ */
789
+ async function buildRangedSource(
790
+ source: MediaByteSource,
791
+ label: string,
792
+ ): Promise<DemuxedSource> {
793
+ const { file, info } = await readMoov(source, label)
794
+
795
+ const vTrack = info.videoTracks?.[0]
796
+ const aTrack = info.audioTracks?.[0]
797
+ if (!vTrack) throw new Error(`demux: no video track in ${label}`)
798
+
799
+ const buildTrack = (kind: 'video' | 'audio', track: MP4TrackInfo): ChunkSource => {
800
+ const timescale = track.timescale || 1
801
+ const trak = file.getTrackById(track.id)
802
+ const samples = trak ? refsFromTrak(trak, timescale) : []
803
+ const presIndex = buildPresIndex(samples)
804
+ const durationS = track.duration / timescale
805
+ return {
806
+ kind,
807
+ codec: track.codec,
808
+ description: trak ? descriptionForEntries(trak.mdia.minf.stbl.stsd.entries) : undefined,
809
+ fps: durationS > 0 ? samples.length / durationS : 0,
810
+ durationS,
811
+ coded:
812
+ kind === 'video'
813
+ ? {
814
+ // CODED dimensions (stsd), not tkhd's display size — see
815
+ // `ChunkSource.coded`.
816
+ width: track.video?.width ?? track.track_width,
817
+ height: track.video?.height ?? track.track_height,
818
+ }
819
+ : { width: 0, height: 0 },
820
+ ...(kind === 'audio' && track.audio
821
+ ? {
822
+ audio: {
823
+ sampleRate: track.audio.sample_rate,
824
+ channelCount: track.audio.channel_count,
825
+ },
826
+ }
827
+ : {}),
828
+ samples,
829
+ presIndex,
830
+ firstPresentationTsUs: firstPresentationTsUs(samples, presIndex),
831
+ ensure: createRangeLoader(
832
+ source,
833
+ samples,
834
+ kind === 'video' ? MAX_RESIDENT_VIDEO_BYTES : MAX_RESIDENT_AUDIO_BYTES,
835
+ ),
836
+ }
837
+ }
838
+
839
+ const video = buildTrack('video', vTrack)
840
+ // Same §A.14 guard as the whole-file path, moved forward to where a ranged
841
+ // load can actually see it: a moov that describes samples the file is too
842
+ // short to contain is a truncated proxy, and every downstream helper
843
+ // tolerates an unplayable source silently. Failing here routes the clip to
844
+ // the Preparing placeholder with a reason instead of freezing on the
845
+ // PREVIOUS clip's last frame.
846
+ if (video.samples.length === 0 || trackEndOffset(video.samples) > source.size) {
847
+ throw new Error(
848
+ `demux: ${label} — video track has no sample data (truncated or missing mdat)`,
849
+ )
850
+ }
851
+
852
+ return {
853
+ src: label,
854
+ video,
855
+ audio: aTrack ? buildTrack('audio', aTrack) : null,
856
+ dispose: () => source.close(),
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Load and demux one media source.
862
+ *
863
+ * Ranged by default: `openByteSource` measures the file and fetches only its
864
+ * header, the sample index is built from that, and `mdat` bytes are pulled
865
+ * through `ChunkSource.ensure` as the playhead reaches them. A host that does
866
+ * not honour `Range` transparently falls back to the original whole-file load
867
+ * (`demuxBytes` over the body the probe already returned), so nothing about
868
+ * this is conditional on the caller.
869
+ *
870
+ * `fileUrl` is injected rather than assumed — see `media-loader.ts` for why.
871
+ */
872
+ export async function demux(
873
+ src: string,
874
+ fileUrl: FileUrlResolver,
875
+ options: LoadBytesOptions = {},
876
+ ): Promise<DemuxedSource> {
877
+ const source = await openByteSource(src, fileUrl, options)
878
+
879
+ if (!source.ranged) {
880
+ const whole = source.whole
881
+ source.close()
882
+ if (!whole) throw new Error(`demux: ${src} — no bytes returned`)
883
+ return demuxBytes(whole, src)
884
+ }
885
+
886
+ // The build's abort signal has to reach the reads that happen DURING the
887
+ // build (header walk); `openByteSource` only carried it as far as the probe.
888
+ // Closing on abort is the right response either way — a session dropped
889
+ // mid-build is never resumed, it is rebuilt from scratch.
890
+ const onAbort = () => source.close()
891
+ options.signal?.addEventListener('abort', onAbort, { once: true })
892
+ try {
893
+ return await buildRangedSource(source, src)
894
+ } catch (err) {
895
+ source.close()
896
+ throw err
897
+ } finally {
898
+ options.signal?.removeEventListener('abort', onAbort)
899
+ }
900
+ }