@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,380 @@
1
+ /**
2
+ * SP4 T2 — byte loader for the playback engine's demuxer.
3
+ *
4
+ * The spike's `media-loader.ts` could hardcode its transport (an Electron
5
+ * `window.montajSpike.readFile` bridge, else `fetch('/media/' + rel)`)
6
+ * because it owned its whole world. `@bycrux/editor` is a library and owns
7
+ * none of it: a host path like `/Users/…/orig_proxy_hable1.mp4` means
8
+ * nothing to a browser, and each host maps it differently (montaj's ui →
9
+ * `/api/files?path=…`; Hub → a presigned R2 URL). The editor already has
10
+ * exactly one primitive for that — `EditorAdapter.fileUrl(path)` — and the
11
+ * legacy player already routes proxy sources through it unconditionally
12
+ * (`useVideoPlayback.ts`: `el.src = fileUrlRef.current(playbackSrcFor(clip))`).
13
+ * So `loadBytes` takes the same function by injection and applies it the same
14
+ * way; T6 threads `adapter.fileUrl` straight through, no adapter change and
15
+ * no new host contract.
16
+ *
17
+ * ─────────────────────────────────────────────────────────────────────────
18
+ * RANGED LOADING — why this module returns a byte SOURCE, not bytes
19
+ * ─────────────────────────────────────────────────────────────────────────
20
+ * T2 shipped `loadBytes`: one `fetch(url).then(r => r.arrayBuffer())` for the
21
+ * whole proxy before a single frame could be decoded. That was correct for the
22
+ * proxies SP3 sized (tens of MB) and it is the engine's clearest scaling limit
23
+ * once they are not — time-to-first-frame grew with FILE SIZE, because nothing
24
+ * decodes until the last byte lands, and every open clip held its full proxy
25
+ * resident. A 2:19 source encoded all-intra is ~400 MB, so "Preparing preview…"
26
+ * sat on screen for as long as that download took, and a silence-trimmed B-roll
27
+ * timeline — fifty clips off one proxy — paid it while needing only a small
28
+ * byte-window each.
29
+ *
30
+ * All-intra is NOT the thing to fix (it is what makes a scrub decode exactly
31
+ * one frame; a GOP was measured ~50% slower per seek and rejected). The fix is
32
+ * to stop reading the file end-to-end: `openByteSource` returns a handle that
33
+ * knows the file's LENGTH and can serve arbitrary byte ranges, so `demux.ts`
34
+ * fetches `moov` (a couple hundred KB, sized by DURATION not bitrate), builds
35
+ * the sample index from it, and then pulls only the `mdat` spans the playhead
36
+ * actually touches. Time-to-first-frame becomes a function of the moov plus one
37
+ * frame's bytes — independent of how big the proxy is.
38
+ *
39
+ * Two properties this deliberately keeps:
40
+ *
41
+ * - **A whole-file fallback.** A host whose `fileUrl` target ignores `Range`
42
+ * answers the probe with `200` and the entire body; that is not an error,
43
+ * it is the old behavior, so the source reports `ranged: false` and serves
44
+ * every read out of the buffer it already has. montaj's `/api/files` serves
45
+ * 206/416 via Starlette's `FileResponse` and Hub's presigned R2 URLs do it
46
+ * natively, so this is the rare path, not the common one.
47
+ * - **Block-aligned caching.** Reads are rounded out to {@link BLOCK_BYTES}
48
+ * and cached, because the audio and video tracks of one proxy are
49
+ * INTERLEAVED: the audio packets for a second of media are scattered through
50
+ * the same megabytes as that second's video frames. Without alignment the
51
+ * audio clock would re-fetch, byte for byte, spans the frame server had just
52
+ * pulled. With it they share, and the cache is what bounds resident memory
53
+ * ({@link MAX_CACHED_BYTES}) instead of the file size doing it.
54
+ */
55
+
56
+ /**
57
+ * A host path → fetchable URL mapper. Structurally identical to
58
+ * `EditorAdapter['fileUrl']`, declared locally so the engine layer does not
59
+ * take a dependency on the whole adapter surface just to read bytes.
60
+ */
61
+ export type FileUrlResolver = (path: string) => string
62
+
63
+ export interface LoadBytesOptions {
64
+ /**
65
+ * Cancels an in-flight load. T5 terminates a per-source decode session on
66
+ * every clip-boundary swap; a load still running for the source it just
67
+ * left should not keep occupying a connection (or resolve into a session
68
+ * that no longer exists).
69
+ */
70
+ signal?: AbortSignal
71
+ }
72
+
73
+ /**
74
+ * Fetch a media source's complete bytes.
75
+ *
76
+ * No longer the demuxer's path — {@link openByteSource} is (see the module
77
+ * doc). Kept as the package's one-call "give me this file" primitive, and as
78
+ * the thing the whole-file fallback is defined against.
79
+ *
80
+ * `src` is a host path exactly as it appears on a project item (`proxySrc`,
81
+ * `normalizedSrc`, `src`) — it is passed to `fileUrl` unconditionally,
82
+ * matching `useVideoPlayback.ts`'s handling. Hosts whose `fileUrl` is
83
+ * identity for already-absolute URLs get that behavior for free; the engine
84
+ * does not second-guess the mapping.
85
+ *
86
+ * Throws (never returns a partial buffer) on a non-2xx response, so the
87
+ * caller's failure path is a single `catch` — T5 routes a failed proxy load
88
+ * to the same "Preparing preview…" clip state as a proxy that has not
89
+ * arrived yet, rather than reverting the project to the legacy player.
90
+ */
91
+ export async function loadBytes(
92
+ src: string,
93
+ fileUrl: FileUrlResolver,
94
+ options: LoadBytesOptions = {},
95
+ ): Promise<ArrayBuffer> {
96
+ if (!src) throw new Error('loadBytes: empty src')
97
+
98
+ const url = fileUrl(src)
99
+ const res = await fetch(url, { signal: options.signal })
100
+ if (!res.ok) {
101
+ throw new Error(`loadBytes: ${src} → ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`)
102
+ }
103
+ return res.arrayBuffer()
104
+ }
105
+
106
+ // ── Ranged byte source ──────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * How much of the file the opening probe asks for.
110
+ *
111
+ * Doubles as the range-support test (a host that honours it answers `206` and
112
+ * a `Content-Range` carrying the total length; one that does not answers `200`
113
+ * and the whole file) and as the first read, so a `+faststart` proxy — which
114
+ * ffmpeg writes with `ftyp` and `moov` at the FRONT, see `lib/proxy.py` — is
115
+ * usually parseable after exactly one round trip. Deliberately equal to
116
+ * {@link BLOCK_BYTES} so the probe's bytes land as a normal cache block and the
117
+ * demuxer's first `read` is free rather than a second request for the same
118
+ * megabyte.
119
+ */
120
+ export const PROBE_BYTES = 1024 * 1024
121
+
122
+ /**
123
+ * Read granularity. Every range request is rounded out to a multiple of this,
124
+ * so two tracks reading the same region of an interleaved file hit one cached
125
+ * block instead of issuing two overlapping fetches (see the module doc).
126
+ */
127
+ export const BLOCK_BYTES = 1024 * 1024
128
+
129
+ /**
130
+ * Ceiling on cached file bytes per source. The point of ranged loading is that
131
+ * memory stops tracking file size, so this has to be a hard cap and not a hint:
132
+ * once it is exceeded the least-recently-used spans are dropped, and a source
133
+ * that is scrubbed end to end costs this much, not its own size.
134
+ */
135
+ export const MAX_CACHED_BYTES = 8 * 1024 * 1024
136
+
137
+ /**
138
+ * A random-access handle on one media file.
139
+ *
140
+ * `size` is known before any media data is read — that is the whole point, it
141
+ * is what lets `demux.ts` walk top-level boxes and skip `mdat` instead of
142
+ * downloading it.
143
+ */
144
+ export interface MediaByteSource {
145
+ /** The `src` this reads, for error messages. */
146
+ readonly src: string
147
+ /** Total length of the file in bytes. */
148
+ readonly size: number
149
+ /**
150
+ * `true` when the host answered the opening probe with `206 Partial Content`
151
+ * — i.e. reads really are ranged. `false` means the host ignored `Range` and
152
+ * handed over the whole file, which is the T2 behavior preserved as a
153
+ * fallback; {@link whole} is then non-null and no further network I/O happens.
154
+ */
155
+ readonly ranged: boolean
156
+ /** The complete file, on the non-ranged fallback path only; `null` when ranged. */
157
+ readonly whole: ArrayBuffer | null
158
+ /**
159
+ * Bytes for `[start, end)`. Clamped to the file. The returned array may be a
160
+ * view into a cached block, so callers that keep the bytes must copy.
161
+ */
162
+ read(start: number, end: number): Promise<Uint8Array>
163
+ /** Drop every cached byte and abort reads still in flight. Idempotent. */
164
+ close(): void
165
+ }
166
+
167
+ /** One cached, block-aligned span of the file. */
168
+ interface CachedSpan {
169
+ start: number
170
+ end: number
171
+ bytes: Uint8Array
172
+ }
173
+
174
+ function alignDown(n: number): number {
175
+ return Math.floor(n / BLOCK_BYTES) * BLOCK_BYTES
176
+ }
177
+
178
+ function alignUp(n: number): number {
179
+ return Math.ceil(n / BLOCK_BYTES) * BLOCK_BYTES
180
+ }
181
+
182
+ /**
183
+ * Total file length out of a `Content-Range: bytes 0-1048575/402653184` header.
184
+ *
185
+ * Returns `null` for a header that is missing, unparseable, or reports the
186
+ * total as `*` (length unknown). The caller treats every one of those the same
187
+ * way — as "this host is not usable for ranged reads" — and falls back to the
188
+ * whole-file load rather than guessing a length it would then walk boxes
189
+ * against.
190
+ */
191
+ export function totalFromContentRange(header: string | null | undefined): number | null {
192
+ if (!header) return null
193
+ const m = /^\s*bytes\s+(\d+)\s*-\s*(\d+)\s*\/\s*(\d+)\s*$/i.exec(header)
194
+ if (!m) return null
195
+ const total = Number(m[3])
196
+ return Number.isFinite(total) && total > 0 ? total : null
197
+ }
198
+
199
+ class RangedByteSource implements MediaByteSource {
200
+ readonly whole: ArrayBuffer | null = null
201
+ /** LRU, oldest first — Map insertion order, re-inserted on hit. */
202
+ private readonly spans = new Map<string, CachedSpan>()
203
+ private readonly inflight = new Map<string, Promise<CachedSpan>>()
204
+ private cachedBytes = 0
205
+ private closed = false
206
+ private readonly controller = new AbortController()
207
+
208
+ constructor(
209
+ readonly src: string,
210
+ readonly size: number,
211
+ private readonly url: string,
212
+ head: Uint8Array,
213
+ headStart: number,
214
+ ) {
215
+ if (head.length > 0) {
216
+ this.store({ start: headStart, end: headStart + head.length, bytes: head })
217
+ }
218
+ }
219
+
220
+ readonly ranged = true
221
+
222
+ async read(start: number, end: number): Promise<Uint8Array> {
223
+ const lo = Math.max(0, Math.min(start, this.size))
224
+ const hi = Math.max(lo, Math.min(end, this.size))
225
+ if (hi === lo) return new Uint8Array(0)
226
+ if (this.closed) throw new Error(`read: ${this.src} — byte source closed`)
227
+
228
+ const hit = this.find(lo, hi)
229
+ if (hit) return hit.bytes.subarray(lo - hit.start, hi - hit.start)
230
+
231
+ const spanStart = alignDown(lo)
232
+ const spanEnd = Math.min(alignUp(hi), this.size)
233
+ const key = `${spanStart}:${spanEnd}`
234
+ let pending = this.inflight.get(key)
235
+ if (!pending) {
236
+ pending = this.fetchSpan(spanStart, spanEnd).finally(() => {
237
+ this.inflight.delete(key)
238
+ })
239
+ this.inflight.set(key, pending)
240
+ }
241
+ const span = await pending
242
+ return span.bytes.subarray(lo - span.start, hi - span.start)
243
+ }
244
+
245
+ close(): void {
246
+ if (this.closed) return
247
+ this.closed = true
248
+ this.spans.clear()
249
+ this.cachedBytes = 0
250
+ this.controller.abort()
251
+ }
252
+
253
+ private find(start: number, end: number): CachedSpan | null {
254
+ for (const [key, span] of this.spans) {
255
+ if (span.start > start || span.end < end) continue
256
+ // Touch: re-insert so Map iteration order stays oldest-first.
257
+ this.spans.delete(key)
258
+ this.spans.set(key, span)
259
+ return span
260
+ }
261
+ return null
262
+ }
263
+
264
+ private async fetchSpan(start: number, end: number): Promise<CachedSpan> {
265
+ const res = await fetch(this.url, {
266
+ headers: { Range: `bytes=${start}-${end - 1}` },
267
+ signal: this.controller.signal,
268
+ })
269
+ if (!res.ok) {
270
+ throw new Error(
271
+ `read: ${this.src} [${start}-${end - 1}] → ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`,
272
+ )
273
+ }
274
+ const bytes = new Uint8Array(await res.arrayBuffer())
275
+ // A host that answers `200` to a ranged read handed back the WHOLE file
276
+ // (some proxies drop the header on a retry). Honour what actually arrived
277
+ // rather than the offsets we asked for, so the bytes are still indexed
278
+ // correctly; the span simply covers more of the file than requested.
279
+ const span: CachedSpan =
280
+ res.status === 206
281
+ ? { start, end: start + bytes.length, bytes }
282
+ : { start: 0, end: bytes.length, bytes }
283
+ this.store(span)
284
+ return span
285
+ }
286
+
287
+ private store(span: CachedSpan): void {
288
+ if (this.closed || span.bytes.length === 0) return
289
+ const key = `${span.start}:${span.end}`
290
+ const existing = this.spans.get(key)
291
+ if (existing) {
292
+ this.spans.delete(key)
293
+ this.cachedBytes -= existing.bytes.length
294
+ }
295
+ this.spans.set(key, span)
296
+ this.cachedBytes += span.bytes.length
297
+ // Evict oldest-first, but never the span just stored — a read is about to
298
+ // slice out of it.
299
+ while (this.cachedBytes > MAX_CACHED_BYTES && this.spans.size > 1) {
300
+ const oldest = this.spans.keys().next()
301
+ if (oldest.done) break
302
+ const victim = this.spans.get(oldest.value)
303
+ this.spans.delete(oldest.value)
304
+ if (victim) this.cachedBytes -= victim.bytes.length
305
+ }
306
+ }
307
+ }
308
+
309
+ /** The whole-file fallback: a byte source over bytes that are already resident. */
310
+ class WholeFileByteSource implements MediaByteSource {
311
+ readonly ranged = false
312
+ readonly size: number
313
+ private view: Uint8Array | null
314
+
315
+ constructor(
316
+ readonly src: string,
317
+ readonly whole: ArrayBuffer,
318
+ ) {
319
+ this.size = whole.byteLength
320
+ this.view = new Uint8Array(whole)
321
+ }
322
+
323
+ async read(start: number, end: number): Promise<Uint8Array> {
324
+ if (!this.view) throw new Error(`read: ${this.src} — byte source closed`)
325
+ const lo = Math.max(0, Math.min(start, this.size))
326
+ const hi = Math.max(lo, Math.min(end, this.size))
327
+ return this.view.subarray(lo, hi)
328
+ }
329
+
330
+ close(): void {
331
+ this.view = null
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Open a media source for random access.
337
+ *
338
+ * One request goes out: a `Range` read of the first {@link PROBE_BYTES}. It
339
+ * answers three questions at once — does this host do ranged reads, how long is
340
+ * the file, and what is in its first megabyte (which for a `+faststart` proxy is
341
+ * `ftyp` + `moov`, i.e. everything the demuxer needs to build a sample index).
342
+ *
343
+ * A `200` answer means the host ignored `Range` and sent the whole file; that
344
+ * body becomes a {@link WholeFileByteSource} and the caller demuxes it exactly
345
+ * as it did before ranged loading existed. Same for a `206` whose
346
+ * `Content-Range` does not carry a usable total length: without a length there
347
+ * is no box walk to do, so falling back is more honest than guessing.
348
+ *
349
+ * Throws on a non-2xx response, matching {@link loadBytes} so callers keep a
350
+ * single `catch`.
351
+ */
352
+ export async function openByteSource(
353
+ src: string,
354
+ fileUrl: FileUrlResolver,
355
+ options: LoadBytesOptions = {},
356
+ ): Promise<MediaByteSource> {
357
+ if (!src) throw new Error('openByteSource: empty src')
358
+
359
+ const url = fileUrl(src)
360
+ const res = await fetch(url, {
361
+ headers: { Range: `bytes=0-${PROBE_BYTES - 1}` },
362
+ signal: options.signal,
363
+ })
364
+ if (!res.ok) {
365
+ throw new Error(
366
+ `openByteSource: ${src} → ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`,
367
+ )
368
+ }
369
+
370
+ const body = await res.arrayBuffer()
371
+ if (res.status !== 206) return new WholeFileByteSource(src, body)
372
+
373
+ const total = totalFromContentRange(res.headers?.get?.('Content-Range'))
374
+ if (total === null) return new WholeFileByteSource(src, body)
375
+ // A 206 that already covers the file is the whole file — skip the range
376
+ // machinery for a proxy small enough to arrive in the probe.
377
+ if (body.byteLength >= total) return new WholeFileByteSource(src, body)
378
+
379
+ return new RangedByteSource(src, total, url, new Uint8Array(body), 0)
380
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * SP4 T2 — hand-written type declarations for `mp4box` (the 0.5.x line).
3
+ *
4
+ * mp4box.js ships no types of its own: its `package.json` has no `types`
5
+ * field, `main` points at a plain-JS UMD bundle (`dist/mp4box.all.js`), and
6
+ * there is no `@types/mp4box` for the 0.5 line. Without this file every
7
+ * `import * as MP4Box from 'mp4box'` is a TS7016 error under `strict`.
8
+ *
9
+ * The spike (`spikes/playback-engine/src/mp4box.d.ts`) got away with the
10
+ * one-liner `declare module 'mp4box';`, which types the whole module as
11
+ * `any`. That is fine for a throwaway harness and wrong for a published
12
+ * package: `@bycrux/editor` ships `src/` and hosts typecheck it, so an `any`
13
+ * module would silently erase every mistake in `demux.ts` — including the
14
+ * exact class of mistake (reading `cts` where `dts` was meant) that SP1 bug
15
+ * §7.1 was. These declarations therefore cover *only what `demux.ts` calls*,
16
+ * typed honestly, and stop there. Everything mp4box can do beyond muxing our
17
+ * proxies is deliberately absent rather than stubbed.
18
+ *
19
+ * This file lives under `src/` on purpose: `package.json`'s `files` list
20
+ * publishes `src`, and `demux.ts` pulls it into any host's program with an
21
+ * explicit `/// <reference path>` (a bare ambient `.d.ts` sitting in a
22
+ * package directory is NOT auto-included by a consumer's `tsc`, only by this
23
+ * package's own `include: ["src"]`).
24
+ *
25
+ * Field names are mp4box's own (snake_case, `is_sync`, `cts`/`dts`) — kept
26
+ * verbatim so this file reads as a description of the library rather than a
27
+ * second naming convention to keep in sync.
28
+ */
29
+ declare module 'mp4box' {
30
+ /**
31
+ * mp4box's byte reader/writer. Only used here to re-serialize a codec
32
+ * configuration box (avcC/hvcC/vpcC/av1C) back into the raw bytes
33
+ * `VideoDecoderConfig.description` wants.
34
+ *
35
+ * Note the inverted-looking constants: mp4box models endianness as a
36
+ * boolean where `LITTLE_ENDIAN === true` and `BIG_ENDIAN === false`. They
37
+ * are typed as `boolean` rather than a literal union precisely so nobody
38
+ * is tempted to write `true`/`false` at a call site instead of naming the
39
+ * constant.
40
+ */
41
+ export class DataStream {
42
+ static BIG_ENDIAN: boolean
43
+ static LITTLE_ENDIAN: boolean
44
+ constructor(arrayBuffer?: ArrayBuffer, byteOffset?: number, endianness?: boolean)
45
+ /** The bytes written so far, trimmed to `position`. */
46
+ buffer: ArrayBuffer
47
+ position: number
48
+ /**
49
+ * Declared for `demux.test.ts`'s stand-in box, which writes a real
50
+ * 8-byte box header + payload so the header-strip in
51
+ * `descriptionForEntries` is asserted against mp4box's actual
52
+ * `DataStream` rather than a hand-rolled imitation of it.
53
+ */
54
+ writeUint8Array(arr: Uint8Array): void
55
+ }
56
+
57
+ /**
58
+ * A parsed ISOBMFF box. `write` is the only member `demux.ts` touches: it
59
+ * re-emits the box (8-byte header + payload) into a `DataStream`.
60
+ */
61
+ export interface MP4BoxBox {
62
+ write(stream: DataStream): void
63
+ }
64
+
65
+ /**
66
+ * One node of the MPEG-4 descriptor tree mp4box builds while parsing an
67
+ * `esds` box. Unlike the boxes above this is NOT round-trippable with
68
+ * `write()` — it is a nested descriptor tree, which is why extracting an
69
+ * AudioSpecificConfig needs its own walk (see `demux.ts`).
70
+ */
71
+ export interface MP4BoxDescriptor {
72
+ tag: number
73
+ descs?: MP4BoxDescriptor[]
74
+ /** Present on DecoderSpecificInfo (tag 0x05): the raw AudioSpecificConfig bytes. */
75
+ data?: ArrayLike<number>
76
+ }
77
+
78
+ /**
79
+ * One entry of a track's sample-description box (`stsd`). Which codec-config
80
+ * child is present depends on the track's codec; all four are optional and
81
+ * `demux.ts` takes the first one it finds.
82
+ */
83
+ export interface MP4BoxSampleEntry {
84
+ avcC?: MP4BoxBox
85
+ hvcC?: MP4BoxBox
86
+ vpcC?: MP4BoxBox
87
+ av1C?: MP4BoxBox
88
+ esds?: { esd?: MP4BoxDescriptor }
89
+ }
90
+
91
+ /**
92
+ * The slice of a `trak` box `demux.ts` walks: the `stsd` entries, and the
93
+ * sample list mp4box builds from the sample tables.
94
+ *
95
+ * `samples` is populated by `buildSampleLists()` the moment `moov` finishes
96
+ * parsing — BEFORE any `mdat` byte has been seen — which is what makes ranged
97
+ * loading possible: every sample's `offset`/`size`/`cts`/`dts`/`is_sync` is
98
+ * already known, and only `data` is missing. `demux.ts`'s ranged path reads
99
+ * this array directly instead of going through `setExtractionOptions` +
100
+ * `onSamples`, because the extraction API's whole job is to hand back sample
101
+ * DATA, which is precisely the thing we are trying not to download.
102
+ */
103
+ export interface MP4BoxTrak {
104
+ mdia: { minf: { stbl: { stsd: { entries: MP4BoxSampleEntry[] } } } }
105
+ samples: MP4Sample[]
106
+ }
107
+
108
+ /**
109
+ * A track as reported by `onReady`'s info object.
110
+ *
111
+ * `duration` is in this track's own `timescale` (mdhd), not the movie
112
+ * timescale — `movie_duration`/`movie_timescale` carry that pair separately.
113
+ *
114
+ * `track_width`/`track_height` come from tkhd and describe the *display*
115
+ * size after the track's rotation matrix; `video.width`/`video.height` come
116
+ * from the visual sample entry and are the CODED size the decoder needs.
117
+ * On portrait phone footage these differ.
118
+ */
119
+ export interface MP4TrackInfo {
120
+ id: number
121
+ codec: string
122
+ timescale: number
123
+ duration: number
124
+ nb_samples: number
125
+ track_width: number
126
+ track_height: number
127
+ /** Present on video tracks. */
128
+ video?: { width: number; height: number }
129
+ /** Present on audio tracks. */
130
+ audio?: { sample_rate: number; channel_count: number; sample_size: number }
131
+ }
132
+
133
+ /** The info object handed to `onReady` once the moov box has been parsed. */
134
+ export interface MP4Info {
135
+ duration: number
136
+ timescale: number
137
+ isFragmented: boolean
138
+ tracks: MP4TrackInfo[]
139
+ videoTracks: MP4TrackInfo[]
140
+ audioTracks: MP4TrackInfo[]
141
+ }
142
+
143
+ /**
144
+ * One extracted sample, as delivered to `onSamples` — **in decode order**,
145
+ * exactly as the container stores it. `cts` is presentation time and `dts`
146
+ * decode time, both in the track's timescale; they differ whenever the
147
+ * track carries B-frames.
148
+ */
149
+ export interface MP4Sample {
150
+ number: number
151
+ track_id: number
152
+ is_sync: boolean
153
+ timescale: number
154
+ dts: number
155
+ cts: number
156
+ duration: number
157
+ size: number
158
+ /**
159
+ * Byte offset of this sample's data in the FILE, resolved from
160
+ * `stco`/`co64` + `stsc` while the sample list is built. Known from the
161
+ * moov alone, which is what the ranged loader ranges on.
162
+ */
163
+ offset: number
164
+ /**
165
+ * The sample's encoded bytes. Typed as always-present because it is, on
166
+ * every sample delivered through `onSamples` — mp4box only emits a sample
167
+ * once it has read the data. A sample taken straight off
168
+ * `MP4BoxTrak.samples` is a different animal: mp4box has not filled this in
169
+ * yet, which is exactly why the ranged path reads `offset`/`size` and
170
+ * fetches the bytes itself rather than touching this field.
171
+ */
172
+ data: Uint8Array
173
+ }
174
+
175
+ /**
176
+ * An `ArrayBuffer` tagged with its offset in the source file. mp4box reads
177
+ * `fileStart` off every buffer handed to `appendBuffer` to know where the
178
+ * chunk belongs; with a whole-file load it is always 0.
179
+ */
180
+ export interface MP4ArrayBuffer extends ArrayBuffer {
181
+ fileStart: number
182
+ }
183
+
184
+ /** The parser handle returned by `createFile()`. */
185
+ export interface MP4File {
186
+ /** Fires (synchronously, inside `appendBuffer`) once `moov` is parsed. */
187
+ onReady?: (info: MP4Info) => void
188
+ /** Fires with a human-readable message; mp4box does not throw. */
189
+ onError?: (e: string) => void
190
+ /** Fires with a batch of samples in DECODE order for one track. */
191
+ onSamples?: (trackId: number, user: unknown, samples: MP4Sample[]) => void
192
+ /** Returns the next expected `fileStart`. */
193
+ appendBuffer(data: MP4ArrayBuffer): number
194
+ /** Begin emitting samples for tracks registered via `setExtractionOptions`. */
195
+ start(): void
196
+ stop(): void
197
+ /** Synchronously drains any samples not yet emitted. */
198
+ flush(): void
199
+ setExtractionOptions(
200
+ trackId: number,
201
+ user?: unknown,
202
+ options?: { nbSamples?: number; rapAlignement?: boolean },
203
+ ): void
204
+ getTrackById(trackId: number): MP4BoxTrak | undefined
205
+ releaseUsedSamples(trackId: number, sampleNumber: number): void
206
+ }
207
+
208
+ /**
209
+ * @param keepMdatData when false, mdat bytes are dropped after parsing.
210
+ * `demux.ts` leaves it at the default (true) because it extracts every
211
+ * sample's `data` up front.
212
+ */
213
+ export function createFile(keepMdatData?: boolean): MP4File
214
+ }