@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Ranged demux: build the sample index from `moov`, fetch `mdat` on demand.
3
+ *
4
+ * The acceptance criterion this change was written against is that
5
+ * time-to-first-frame is INDEPENDENT of proxy size — not merely smaller. So
6
+ * what these tests measure is bytes on the wire, not milliseconds: a source
7
+ * whose moov is 200 KB and whose mdat is hundreds of megabytes must become a
8
+ * fully-indexed `DemuxedSource` having moved only the header, and a decode
9
+ * range must cost only the samples in it.
10
+ *
11
+ * mp4box is mocked, for the same reason `demux-truncated.test.ts` mocks it:
12
+ * vitest/jsdom has no MP4 fixture, and container parsing is mp4box's job, not
13
+ * ours. What IS ours is the streaming loop that hunts `moov` while skipping
14
+ * `mdat`, the sample index built off `trak.samples`, and the on-demand loader —
15
+ * all three of which the fake exercises exactly as the real parser would drive
16
+ * them (`appendBuffer` returning the next wanted file position is mp4box's own
17
+ * streaming contract, see `isofile.js`).
18
+ */
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
20
+ import { PROBE_BYTES, BLOCK_BYTES } from '../media-loader'
21
+ import { MAX_RESIDENT_VIDEO_BYTES } from '../demux'
22
+
23
+ // ── the mp4box fake ─────────────────────────────────────────────────────────
24
+
25
+ interface FakeSample {
26
+ number: number
27
+ track_id: number
28
+ is_sync: boolean
29
+ timescale: number
30
+ dts: number
31
+ cts: number
32
+ duration: number
33
+ size: number
34
+ offset: number
35
+ }
36
+
37
+ interface FakeTrack {
38
+ id: number
39
+ codec: string
40
+ timescale: number
41
+ duration: number
42
+ video?: { width: number; height: number }
43
+ audio?: { sample_rate: number; channel_count: number }
44
+ track_width: number
45
+ track_height: number
46
+ }
47
+
48
+ const script: {
49
+ /**
50
+ * File offset at which the moov becomes parseable. `appendBuffer` fires
51
+ * `onReady` once a fed chunk reaches it, and until then reports that offset
52
+ * as the next wanted position — which is how the real parser skips an mdat
53
+ * that sits in front of the moov.
54
+ */
55
+ moovAt: number
56
+ videoTracks: FakeTrack[]
57
+ audioTracks: FakeTrack[]
58
+ samplesByTrack: Map<number, FakeSample[]>
59
+ /** Samples delivered through `onSamples` on the whole-file path only. */
60
+ deliverOnFlush: boolean
61
+ errors: string[]
62
+ /** Every (fileStart, length) `appendBuffer` was given, in order. */
63
+ appends: { fileStart: number; length: number }[]
64
+ } = {
65
+ moovAt: 0,
66
+ videoTracks: [],
67
+ audioTracks: [],
68
+ samplesByTrack: new Map(),
69
+ deliverOnFlush: false,
70
+ errors: [],
71
+ appends: [],
72
+ }
73
+
74
+ vi.mock('mp4box', () => ({
75
+ createFile: () => {
76
+ const file: Record<string, unknown> = {
77
+ onError: undefined,
78
+ onReady: undefined,
79
+ onSamples: undefined,
80
+ setExtractionOptions: () => {},
81
+ start: () => {},
82
+ appendBuffer: (buf: ArrayBuffer & { fileStart: number }) => {
83
+ script.appends.push({ fileStart: buf.fileStart, length: buf.byteLength })
84
+ for (const e of script.errors) (file.onError as (x: string) => void)?.(e)
85
+ if (script.errors.length > 0) return buf.fileStart + buf.byteLength
86
+ const reached =
87
+ buf.fileStart <= script.moovAt && script.moovAt < buf.fileStart + buf.byteLength
88
+ if (reached) {
89
+ ;(file.onReady as (i: unknown) => void)?.({
90
+ videoTracks: script.videoTracks,
91
+ audioTracks: script.audioTracks,
92
+ })
93
+ return buf.fileStart + buf.byteLength
94
+ }
95
+ // Not there yet: tell the caller where the next box actually starts.
96
+ // This is the mdat skip — the whole reason the loop follows the return
97
+ // value instead of reading sequentially.
98
+ return script.moovAt
99
+ },
100
+ flush: () => {
101
+ if (!script.deliverOnFlush) return
102
+ for (const [trackId, samples] of script.samplesByTrack) {
103
+ if (samples.length === 0) continue
104
+ ;(file.onSamples as (t: number, u: unknown, s: unknown[]) => void)?.(
105
+ trackId,
106
+ null,
107
+ samples.map((s) => ({ ...s, data: new Uint8Array(s.size) })),
108
+ )
109
+ }
110
+ },
111
+ getTrackById: (id: number) => ({
112
+ mdia: { minf: { stbl: { stsd: { entries: [] } } } },
113
+ samples: script.samplesByTrack.get(id) ?? [],
114
+ }),
115
+ }
116
+ return file
117
+ },
118
+ DataStream: class {
119
+ static BIG_ENDIAN = false
120
+ static LITTLE_ENDIAN = true
121
+ },
122
+ }))
123
+
124
+ // ── the fake host ───────────────────────────────────────────────────────────
125
+
126
+ interface FakeHost {
127
+ fetch: ReturnType<typeof vi.fn>
128
+ ranges: (string | null)[]
129
+ served: number
130
+ }
131
+
132
+ function syntheticFile(size: number): Uint8Array {
133
+ const bytes = new Uint8Array(size)
134
+ for (let i = 0; i < size; i++) bytes[i] = i % 251
135
+ return bytes
136
+ }
137
+
138
+ function rangeHost(file: Uint8Array, { ignoreRange = false } = {}): FakeHost {
139
+ const host: FakeHost = { fetch: vi.fn(), ranges: [], served: 0 }
140
+ host.fetch.mockImplementation(async (_url: string, init?: RequestInit) => {
141
+ const header = (init?.headers as Record<string, string> | undefined)?.Range ?? null
142
+ host.ranges.push(header)
143
+ const m = header ? /^bytes=(\d+)-(\d+)$/.exec(header) : null
144
+ const [start, end] = !m || ignoreRange ? [0, file.length] : [Number(m[1]), Math.min(Number(m[2]) + 1, file.length)]
145
+ const body = file.subarray(start, end)
146
+ host.served += body.length
147
+ const copy = new ArrayBuffer(body.length)
148
+ new Uint8Array(copy).set(body)
149
+ return {
150
+ ok: true,
151
+ status: !m || ignoreRange ? 200 : 206,
152
+ statusText: 'OK',
153
+ headers: {
154
+ get: (name: string) =>
155
+ name.toLowerCase() === 'content-range' && m && !ignoreRange
156
+ ? `bytes ${start}-${end - 1}/${file.length}`
157
+ : null,
158
+ },
159
+ arrayBuffer: async () => copy,
160
+ } as unknown as Response
161
+ })
162
+ vi.stubGlobal('fetch', host.fetch)
163
+ return host
164
+ }
165
+
166
+ // ── fixtures ────────────────────────────────────────────────────────────────
167
+
168
+ function videoTrack(): FakeTrack {
169
+ return {
170
+ id: 1,
171
+ codec: 'avc1.640028',
172
+ timescale: 30000,
173
+ duration: 30000 * 10,
174
+ video: { width: 1280, height: 720 },
175
+ track_width: 1280,
176
+ track_height: 720,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * An all-intra sample table laid out contiguously from `dataStart` — the shape
182
+ * `lib/proxy.py` produces (`-g 1`, every frame a sync sample).
183
+ */
184
+ function allIntraSamples(count: number, sampleSize: number, dataStart: number): FakeSample[] {
185
+ return Array.from({ length: count }, (_, i) => ({
186
+ number: i,
187
+ track_id: 1,
188
+ is_sync: true,
189
+ timescale: 30000,
190
+ dts: i * 1000,
191
+ cts: i * 1000,
192
+ duration: 1000,
193
+ size: sampleSize,
194
+ offset: dataStart + i * sampleSize,
195
+ }))
196
+ }
197
+
198
+ beforeEach(() => {
199
+ script.moovAt = 0
200
+ script.videoTracks = [videoTrack()]
201
+ script.audioTracks = []
202
+ script.samplesByTrack = new Map()
203
+ script.deliverOnFlush = false
204
+ script.errors = []
205
+ script.appends = []
206
+ })
207
+
208
+ afterEach(() => {
209
+ vi.unstubAllGlobals()
210
+ })
211
+
212
+ describe('demux — ranged', () => {
213
+ it('indexes a huge proxy having fetched only its header', async () => {
214
+ // 64 MB of media behind a faststart moov. Nothing but the probe should move.
215
+ const { demux } = await import('../demux')
216
+ const fileSize = BLOCK_BYTES * 64
217
+ script.samplesByTrack.set(1, allIntraSamples(600, 100_000, PROBE_BYTES))
218
+ const host = rangeHost(syntheticFile(fileSize))
219
+
220
+ const source = await demux('/proxies/big_proxy.mp4', (p) => p)
221
+
222
+ expect(source.video.samples).toHaveLength(600)
223
+ expect(source.video.codec).toBe('avc1.640028')
224
+ expect(source.video.coded).toEqual({ width: 1280, height: 720 })
225
+ expect(host.fetch).toHaveBeenCalledTimes(1)
226
+ expect(host.served).toBe(PROBE_BYTES)
227
+ // The index is complete and every frame is a keyframe — all-intra survives
228
+ // ranged loading untouched, which is the constraint the whole design is
229
+ // built around.
230
+ expect(source.video.samples.every((s) => s.isKey)).toBe(true)
231
+ expect(source.video.presIndex).toHaveLength(600)
232
+ expect(source.video.firstPresentationTsUs).toBe(0)
233
+ })
234
+
235
+ it('costs the same to open whether the proxy is 4 MB or 400 MB', async () => {
236
+ const { demux } = await import('../demux')
237
+ // Sized to fit inside the small file; the big one has 100x the room and
238
+ // must still cost exactly the same to open.
239
+ const samples = allIntraSamples(30, 100_000, PROBE_BYTES)
240
+
241
+ script.samplesByTrack.set(1, samples)
242
+ const small = rangeHost(syntheticFile(BLOCK_BYTES * 4))
243
+ await demux('/small.mp4', (p) => p)
244
+ const smallServed = small.served
245
+
246
+ script.appends = []
247
+ script.samplesByTrack.set(1, samples)
248
+ const big = rangeHost(syntheticFile(BLOCK_BYTES * 400))
249
+ await demux('/big.mp4', (p) => p)
250
+
251
+ expect(big.served).toBe(smallServed)
252
+ })
253
+
254
+ it('skips mdat when the moov is at the END of the file', async () => {
255
+ // Not the faststart layout, but a proxy that arrived from somewhere else
256
+ // could have it. mp4box reports where the next box starts; following that
257
+ // is what turns "download 400 MB to reach the moov" into one extra request.
258
+ const { demux } = await import('../demux')
259
+ const fileSize = BLOCK_BYTES * 40
260
+ const moovAt = BLOCK_BYTES * 39
261
+ script.moovAt = moovAt
262
+ script.samplesByTrack.set(1, allIntraSamples(100, 50_000, PROBE_BYTES))
263
+ const host = rangeHost(syntheticFile(fileSize))
264
+
265
+ await demux('/tail_moov.mp4', (p) => p)
266
+
267
+ expect(script.appends[0].fileStart).toBe(0)
268
+ // The second append starts where mp4box pointed, not one chunk after the
269
+ // first — the 38 MB of mdat in between is never requested.
270
+ expect(script.appends[1].fileStart).toBe(moovAt)
271
+ expect(host.served).toBe(PROBE_BYTES + BLOCK_BYTES)
272
+ })
273
+
274
+ it('gives up rather than walking a file with no moov in it', async () => {
275
+ const { demux } = await import('../demux')
276
+ script.moovAt = Number.MAX_SAFE_INTEGER
277
+ rangeHost(syntheticFile(BLOCK_BYTES * 3))
278
+
279
+ await expect(demux('/headless.mp4', (p) => p)).rejects.toThrow(/no moov box found/)
280
+ })
281
+
282
+ it('surfaces an mp4box parse error with the src attached', async () => {
283
+ const { demux } = await import('../demux')
284
+ script.errors = ['bad box']
285
+ rangeHost(syntheticFile(BLOCK_BYTES * 2))
286
+
287
+ await expect(demux('/broken.mp4', (p) => p)).rejects.toThrow(
288
+ /\/broken\.mp4 — mp4box parse error: bad box/,
289
+ )
290
+ })
291
+
292
+ it('fails a proxy whose moov describes samples the file is too short to hold', async () => {
293
+ // The §A.14 guard, moved to where a ranged load can see it: with no mdat
294
+ // read there is no "onSamples never fired" signal, so truncation shows up
295
+ // as a sample table that runs past EOF. Failing here routes the clip to the
296
+ // Preparing placeholder instead of freezing on the previous clip's frame.
297
+ const { demux } = await import('../demux')
298
+ const fileSize = BLOCK_BYTES * 4
299
+ script.samplesByTrack.set(1, allIntraSamples(200, 100_000, fileSize - 500_000))
300
+ rangeHost(syntheticFile(fileSize))
301
+
302
+ await expect(demux('/truncated.mp4', (p) => p)).rejects.toThrow(
303
+ /truncated or missing mdat/,
304
+ )
305
+ })
306
+
307
+ it('fails a moov that describes a video track with no samples at all', async () => {
308
+ const { demux } = await import('../demux')
309
+ script.samplesByTrack.set(1, [])
310
+ rangeHost(syntheticFile(BLOCK_BYTES * 2))
311
+
312
+ await expect(demux('/empty.mp4', (p) => p)).rejects.toThrow(/truncated or missing mdat/)
313
+ })
314
+
315
+ it('fails when the moov has no video track', async () => {
316
+ const { demux } = await import('../demux')
317
+ script.videoTracks = []
318
+ rangeHost(syntheticFile(BLOCK_BYTES * 2))
319
+
320
+ await expect(demux('/audio_only.mp4', (p) => p)).rejects.toThrow(/no video track/)
321
+ })
322
+ })
323
+
324
+ describe('ChunkSource.ensure', () => {
325
+ it('fetches only the byte window a decode range spans', async () => {
326
+ const { demux } = await import('../demux')
327
+ const fileSize = BLOCK_BYTES * 64
328
+ const sampleSize = 100_000
329
+ const dataStart = BLOCK_BYTES * 8
330
+ script.samplesByTrack.set(1, allIntraSamples(500, sampleSize, dataStart))
331
+ const file = syntheticFile(fileSize)
332
+ const host = rangeHost(file)
333
+
334
+ const source = await demux('/big.mp4', (p) => p)
335
+ const servedAfterOpen = host.served
336
+
337
+ // A seek on an all-intra source plans exactly one sample (`planSeek`:
338
+ // keyframe == target), so this is the real time-to-first-frame cost.
339
+ await source.video.ensure?.(300, 301)
340
+
341
+ expect(host.served - servedAfterOpen).toBe(BLOCK_BYTES)
342
+ const at = dataStart + 300 * sampleSize
343
+ expect(source.video.samples[300].data).toEqual(file.subarray(at, at + sampleSize))
344
+ // Only that sample was materialized — neighbours are still index-only.
345
+ expect(source.video.samples[299].data).toHaveLength(0)
346
+ expect(source.video.samples[301].data).toHaveLength(0)
347
+ })
348
+
349
+ it('returns null once the range is resident, so the hot path stays synchronous', async () => {
350
+ const { demux } = await import('../demux')
351
+ script.samplesByTrack.set(1, allIntraSamples(500, 100_000, BLOCK_BYTES * 8))
352
+ const host = rangeHost(syntheticFile(BLOCK_BYTES * 64))
353
+ const source = await demux('/big.mp4', (p) => p)
354
+
355
+ expect(source.video.ensure?.(10, 20)).toBeInstanceOf(Promise)
356
+ await source.video.ensure?.(10, 20)
357
+ const fetches = host.fetch.mock.calls.length
358
+
359
+ expect(source.video.ensure?.(10, 20)).toBeNull()
360
+ expect(source.video.ensure?.(12, 18)).toBeNull()
361
+ expect(host.fetch.mock.calls.length).toBe(fetches)
362
+ })
363
+
364
+ it('coalesces two concurrent ensures of the same range into one load', async () => {
365
+ const { demux } = await import('../demux')
366
+ script.samplesByTrack.set(1, allIntraSamples(500, 100_000, BLOCK_BYTES * 8))
367
+ const host = rangeHost(syntheticFile(BLOCK_BYTES * 64))
368
+ const source = await demux('/big.mp4', (p) => p)
369
+ const before = host.fetch.mock.calls.length
370
+
371
+ await Promise.all([source.video.ensure?.(40, 50), source.video.ensure?.(40, 50)])
372
+
373
+ expect(host.fetch.mock.calls.length).toBe(before + 1)
374
+ })
375
+
376
+ it('clamps out-of-range indices instead of reading past the table', async () => {
377
+ const { demux } = await import('../demux')
378
+ script.samplesByTrack.set(1, allIntraSamples(20, 1000, PROBE_BYTES / 2))
379
+ rangeHost(syntheticFile(BLOCK_BYTES * 4))
380
+ const source = await demux('/p.mp4', (p) => p)
381
+
382
+ expect(source.video.ensure?.(-5, 0)).toBeNull()
383
+ expect(source.video.ensure?.(100, 200)).toBeNull()
384
+ await source.video.ensure?.(18, 999)
385
+ expect(source.video.samples[19].data).toHaveLength(1000)
386
+ })
387
+
388
+ it('releases old sample bytes once the resident budget is exceeded', async () => {
389
+ // The memory half of the acceptance criterion: scrubbing the whole timeline
390
+ // must not end with the whole proxy in memory.
391
+ const { demux } = await import('../demux')
392
+ const sampleSize = BLOCK_BYTES
393
+ const count = Math.floor(MAX_RESIDENT_VIDEO_BYTES / sampleSize) + 8
394
+ script.samplesByTrack.set(1, allIntraSamples(count, sampleSize, 0))
395
+ rangeHost(syntheticFile(sampleSize * count))
396
+ const source = await demux('/long.mp4', (p) => p)
397
+
398
+ for (let i = 0; i < count; i++) await source.video.ensure?.(i, i + 1)
399
+
400
+ const resident = source.video.samples.reduce((n, s) => n + s.data.length, 0)
401
+ expect(resident).toBeLessThanOrEqual(MAX_RESIDENT_VIDEO_BYTES)
402
+ // The earliest samples were dropped...
403
+ expect(source.video.samples[0].data).toHaveLength(0)
404
+ // ...and the most recent ones are still there, because a caller that just
405
+ // awaited `ensure` reads them synchronously right after.
406
+ expect(source.video.samples[count - 1].data).toHaveLength(sampleSize)
407
+ // Dropped, not lost: asking again reloads.
408
+ expect(source.video.ensure?.(0, 1)).toBeInstanceOf(Promise)
409
+ })
410
+
411
+ it('indexes and loads an audio track alongside the video one', async () => {
412
+ const { demux } = await import('../demux')
413
+ script.audioTracks = [
414
+ {
415
+ id: 2,
416
+ codec: 'Opus',
417
+ timescale: 48000,
418
+ duration: 48000 * 10,
419
+ audio: { sample_rate: 48000, channel_count: 2 },
420
+ track_width: 0,
421
+ track_height: 0,
422
+ },
423
+ ]
424
+ // Interleaved with the video: audio packets sit between the frames, which
425
+ // is why the block cache is what stops the two tracks re-fetching each
426
+ // other's bytes.
427
+ script.samplesByTrack.set(1, allIntraSamples(300, 100_000, BLOCK_BYTES * 4))
428
+ script.samplesByTrack.set(
429
+ 2,
430
+ Array.from({ length: 300 }, (_, i) => ({
431
+ number: i,
432
+ track_id: 2,
433
+ is_sync: true,
434
+ timescale: 48000,
435
+ dts: i * 960,
436
+ cts: i * 960,
437
+ duration: 960,
438
+ size: 400,
439
+ offset: BLOCK_BYTES * 4 + i * 100_000 + 99_000,
440
+ })),
441
+ )
442
+ const host = rangeHost(syntheticFile(BLOCK_BYTES * 64))
443
+
444
+ const source = await demux('/av.mp4', (p) => p)
445
+ await source.video.ensure?.(0, 10)
446
+ const afterVideo = host.fetch.mock.calls.length
447
+ await source.audio?.ensure?.(0, 10)
448
+
449
+ expect(source.audio?.kind).toBe('audio')
450
+ expect(source.audio?.audio).toEqual({ sampleRate: 48000, channelCount: 2 })
451
+ expect(source.audio?.samples[3].data).toHaveLength(400)
452
+ // The audio packets lived inside blocks the video pull already cached.
453
+ expect(host.fetch.mock.calls.length).toBe(afterVideo)
454
+ })
455
+
456
+ it('closes the byte source on dispose', async () => {
457
+ const { demux } = await import('../demux')
458
+ script.samplesByTrack.set(1, allIntraSamples(50, 1000, PROBE_BYTES / 2))
459
+ rangeHost(syntheticFile(BLOCK_BYTES * 8))
460
+ const source = await demux('/p.mp4', (p) => p)
461
+
462
+ source.dispose?.()
463
+
464
+ await expect(source.video.ensure?.(40, 45)).rejects.toThrow(/byte source closed/)
465
+ })
466
+
467
+ it('closes the byte source when the build is aborted', async () => {
468
+ const { demux } = await import('../demux')
469
+ script.moovAt = Number.MAX_SAFE_INTEGER
470
+ rangeHost(syntheticFile(BLOCK_BYTES * 3))
471
+ const controller = new AbortController()
472
+ const pending = demux('/p.mp4', (p) => p, { signal: controller.signal })
473
+ controller.abort()
474
+
475
+ await expect(pending).rejects.toThrow()
476
+ })
477
+ })
478
+
479
+ describe('demux — whole-file fallback', () => {
480
+ it('demuxes through the T2 path when the host ignores Range', async () => {
481
+ const { demux } = await import('../demux')
482
+ script.deliverOnFlush = true
483
+ script.samplesByTrack.set(1, allIntraSamples(40, 1000, 0))
484
+ const host = rangeHost(syntheticFile(BLOCK_BYTES * 2), { ignoreRange: true })
485
+
486
+ const source = await demux('/no_range_host.mp4', (p) => p)
487
+
488
+ expect(source.video.samples).toHaveLength(40)
489
+ // No `ensure`: the bytes are already in hand, so every reader stays
490
+ // synchronous exactly as it was before ranged loading existed.
491
+ expect(source.video.ensure).toBeUndefined()
492
+ expect(source.dispose).toBeUndefined()
493
+ expect(host.fetch).toHaveBeenCalledTimes(1)
494
+ })
495
+ })
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A truncated proxy — container header intact, media data missing — must fail
3
+ * the demux loudly instead of yielding an empty-but-"valid" sample table.
4
+ *
5
+ * This is the SP4 parity pass's one outright FAIL (`docs/qa/reference/
6
+ * SP4-PARITY-RESULTS.md` §A.14). A file whose `ftyp` + `moov` parse but whose
7
+ * `mdat` payload is absent makes mp4box fire `onReady` (the track and its
8
+ * sample *table* are described in the moov) and then never fire `onSamples`
9
+ * (there are no bytes to hand out). `demuxBytes` checked for parse errors and
10
+ * for a missing video track, but not for a video track that produced zero
11
+ * samples — so the source was built, `EngineSourceHost` marked the session
12
+ * `ready`, and `scheduler.ts`'s `!source` branch never fired. The surface then
13
+ * stayed on `picture: 'video'` for the clip's whole range: black on seek, and
14
+ * on playback a freeze holding the *previous* clip's last frame while project
15
+ * time advanced. Silently showing the wrong clip's picture is worse than
16
+ * showing black, and §A.14 explicitly rules that outcome out.
17
+ *
18
+ * Failing here routes the clip into the existing failure path, which is
19
+ * already correct: `state.status === 'failed'` → the scheduler's `!source`
20
+ * branch → the Preparing placeholder scoped to that clip's range, with a
21
+ * reason, and the rest of the project unaffected.
22
+ *
23
+ * mp4box is mocked because vitest/jsdom has no MP4 fixture — the same reason
24
+ * `demux.test.ts` restricts itself to the pure sample-table helpers. What is
25
+ * asserted here is our own guard, not mp4box's parsing.
26
+ */
27
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
28
+
29
+ interface FakeTrack {
30
+ id: number
31
+ codec: string
32
+ timescale: number
33
+ duration: number
34
+ video?: { width: number; height: number }
35
+ track_width?: number
36
+ track_height?: number
37
+ }
38
+
39
+ /** What the fake mp4box should do when `demuxBytes` drives it. */
40
+ const script: {
41
+ videoTracks: FakeTrack[]
42
+ /** Samples delivered per track id. Absent id → `onSamples` never fires for it. */
43
+ samplesByTrack: Map<number, unknown[]>
44
+ errors: string[]
45
+ } = { videoTracks: [], samplesByTrack: new Map(), errors: [] }
46
+
47
+ vi.mock('mp4box', () => {
48
+ return {
49
+ createFile: () => {
50
+ const file: Record<string, unknown> = {
51
+ onError: undefined,
52
+ onReady: undefined,
53
+ onSamples: undefined,
54
+ setExtractionOptions: () => {},
55
+ start: () => {},
56
+ // `demuxBytes` appends then flushes. Fire onReady on append (the moov
57
+ // is parsed) and deliver samples on flush — mirroring the real
58
+ // ordering, so a track with no sample data simply never gets a call.
59
+ appendBuffer: () => {
60
+ for (const e of script.errors) (file.onError as (x: string) => void)?.(e)
61
+ ;(file.onReady as (i: unknown) => void)?.({
62
+ videoTracks: script.videoTracks,
63
+ audioTracks: [],
64
+ })
65
+ },
66
+ flush: () => {
67
+ for (const [trackId, samples] of script.samplesByTrack) {
68
+ if (samples.length === 0) continue
69
+ ;(file.onSamples as (t: number, u: unknown, s: unknown[]) => void)?.(
70
+ trackId,
71
+ null,
72
+ samples,
73
+ )
74
+ }
75
+ },
76
+ getTrackById: () => ({
77
+ mdia: { minf: { stbl: { stsd: { entries: [] } } } },
78
+ }),
79
+ }
80
+ return file
81
+ },
82
+ }
83
+ })
84
+
85
+ const { demuxBytes } = await import('../demux')
86
+
87
+ const videoTrack: FakeTrack = {
88
+ id: 1,
89
+ codec: 'av01.0.05M.08',
90
+ timescale: 30000,
91
+ duration: 300000, // 10s
92
+ video: { width: 1280, height: 720 },
93
+ }
94
+
95
+ /** One mp4box-shaped sample. Ticks, not µs — `toSampleRef` rebases them. */
96
+ function rawSample(i: number, timescale = 30000) {
97
+ const dur = timescale / 30
98
+ return {
99
+ cts: i * dur,
100
+ dts: i * dur,
101
+ duration: dur,
102
+ is_sync: true,
103
+ data: new Uint8Array(4),
104
+ }
105
+ }
106
+
107
+ beforeEach(() => {
108
+ script.videoTracks = []
109
+ script.samplesByTrack = new Map()
110
+ script.errors = []
111
+ })
112
+
113
+ describe('demuxBytes — truncated media', () => {
114
+ it('throws when the video track yields no samples (moov intact, mdat missing)', () => {
115
+ script.videoTracks = [videoTrack]
116
+ script.samplesByTrack.set(1, []) // onSamples never fires
117
+
118
+ expect(() => demuxBytes(new ArrayBuffer(8), 'truncated.mp4')).toThrow(/no sample data/i)
119
+ })
120
+
121
+ it('names the source in the error so the failure is attributable', () => {
122
+ script.videoTracks = [videoTrack]
123
+ script.samplesByTrack.set(1, [])
124
+
125
+ expect(() => demuxBytes(new ArrayBuffer(8), 'clip-7-proxy.mp4')).toThrow(/clip-7-proxy\.mp4/)
126
+ })
127
+
128
+ it('does NOT throw when samples are present (the guard is not over-broad)', () => {
129
+ script.videoTracks = [videoTrack]
130
+ script.samplesByTrack.set(1, [rawSample(0), rawSample(1), rawSample(2)])
131
+
132
+ const out = demuxBytes(new ArrayBuffer(8), 'good.mp4')
133
+ expect(out.video.samples).toHaveLength(3)
134
+ expect(out.video.durationS).toBeCloseTo(10, 5)
135
+ })
136
+ })