@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,289 @@
1
+ /**
2
+ * `openByteSource`: the ranged half of the byte loader.
3
+ *
4
+ * The acceptance criterion for this whole change is that time-to-first-frame
5
+ * stops tracking FILE SIZE, and the only way that can be true is if the loader
6
+ * never reads bytes nobody asked for. So the assertions here are mostly about
7
+ * what is NOT fetched: how many requests went out, what `Range` headers they
8
+ * carried, and that a second read of the same region issues none at all.
9
+ *
10
+ * The fake host below honours `Range` the way `serve/routes/files.py`'s
11
+ * `FileResponse` does (206 + `Content-Range`), with switches for the two hosts
12
+ * that would not: one that ignores the header and returns 200 + the whole body,
13
+ * and one that returns 206 with an unknown total (`/*`). Both must degrade to
14
+ * the pre-ranged whole-file behavior rather than failing, because a host that
15
+ * cannot serve ranges is a slow preview, not a broken one.
16
+ */
17
+ import { describe, it, expect, vi, afterEach } from 'vitest'
18
+ import {
19
+ BLOCK_BYTES,
20
+ MAX_CACHED_BYTES,
21
+ PROBE_BYTES,
22
+ openByteSource,
23
+ totalFromContentRange,
24
+ } from '../media-loader'
25
+
26
+ afterEach(() => {
27
+ vi.unstubAllGlobals()
28
+ })
29
+
30
+ /** A file whose byte at index i is `i % 251`, so any slice self-identifies. */
31
+ function syntheticFile(size: number): Uint8Array {
32
+ const bytes = new Uint8Array(size)
33
+ for (let i = 0; i < size; i++) bytes[i] = i % 251
34
+ return bytes
35
+ }
36
+
37
+ interface HostOptions {
38
+ /** Ignore `Range` entirely and answer 200 with the whole body — the fallback host. */
39
+ ignoreRange?: boolean
40
+ /** Answer 206 but report the total length as `*`. */
41
+ unknownTotal?: boolean
42
+ }
43
+
44
+ interface FakeHost {
45
+ fetch: ReturnType<typeof vi.fn>
46
+ /** Every `Range` header seen, in order. `null` for a request that sent none. */
47
+ ranges: (string | null)[]
48
+ /** Total bytes actually put on the wire — the number this change exists to shrink. */
49
+ served: number
50
+ }
51
+
52
+ function rangeHost(file: Uint8Array, options: HostOptions = {}): FakeHost {
53
+ const host: FakeHost = { fetch: vi.fn(), ranges: [], served: 0 }
54
+ host.fetch.mockImplementation(async (_url: string, init?: RequestInit) => {
55
+ const header = (init?.headers as Record<string, string> | undefined)?.Range ?? null
56
+ host.ranges.push(header)
57
+ const m = header ? /^bytes=(\d+)-(\d+)$/.exec(header) : null
58
+ if (!m || options.ignoreRange) {
59
+ host.served += file.length
60
+ return response(200, file, null)
61
+ }
62
+ const start = Number(m[1])
63
+ const end = Math.min(Number(m[2]) + 1, file.length)
64
+ const body = file.subarray(start, end)
65
+ host.served += body.length
66
+ const total = options.unknownTotal ? '*' : String(file.length)
67
+ return response(206, body, `bytes ${start}-${end - 1}/${total}`)
68
+ })
69
+ vi.stubGlobal('fetch', host.fetch)
70
+ return host
71
+ }
72
+
73
+ function response(status: number, body: Uint8Array, contentRange: string | null): Response {
74
+ const copy = new ArrayBuffer(body.length)
75
+ new Uint8Array(copy).set(body)
76
+ return {
77
+ ok: status >= 200 && status < 300,
78
+ status,
79
+ statusText: status === 206 ? 'Partial Content' : 'OK',
80
+ headers: { get: (name: string) => (name.toLowerCase() === 'content-range' ? contentRange : null) },
81
+ arrayBuffer: async () => copy,
82
+ } as unknown as Response
83
+ }
84
+
85
+ describe('totalFromContentRange', () => {
86
+ it('reads the total out of a well-formed header', () => {
87
+ expect(totalFromContentRange('bytes 0-1048575/402653184')).toBe(402653184)
88
+ })
89
+
90
+ it('is null for an unknown total, a missing header, or garbage', () => {
91
+ // `*` means the host will not say how long the file is — there is no box
92
+ // walk to do without a length, so the caller falls back rather than guesses.
93
+ expect(totalFromContentRange('bytes 0-99/*')).toBeNull()
94
+ expect(totalFromContentRange(null)).toBeNull()
95
+ expect(totalFromContentRange('')).toBeNull()
96
+ expect(totalFromContentRange('items 0-99/100')).toBeNull()
97
+ })
98
+ })
99
+
100
+ describe('openByteSource — the ranged path', () => {
101
+ it('opens a large file with ONE probe request and knows its full length', async () => {
102
+ const file = syntheticFile(PROBE_BYTES * 6)
103
+ const host = rangeHost(file)
104
+
105
+ const source = await openByteSource('/proxies/big_proxy.mp4', (p) => `/api/files?path=${p}`)
106
+
107
+ expect(source.ranged).toBe(true)
108
+ expect(source.size).toBe(file.length)
109
+ expect(source.whole).toBeNull()
110
+ expect(host.fetch).toHaveBeenCalledTimes(1)
111
+ expect(host.ranges).toEqual([`bytes=0-${PROBE_BYTES - 1}`])
112
+ // The headline number: opening a 6 MB file moved 1 MB, and opening a 600 MB
113
+ // one would move the same 1 MB.
114
+ expect(host.served).toBe(PROBE_BYTES)
115
+ })
116
+
117
+ it('resolves the src through fileUrl, exactly like loadBytes', async () => {
118
+ const host = rangeHost(syntheticFile(PROBE_BYTES * 3))
119
+ const fileUrl = vi.fn(() => 'https://r2.example/presigned?sig=abc')
120
+
121
+ await openByteSource('media/abc123', fileUrl)
122
+
123
+ expect(fileUrl).toHaveBeenCalledWith('media/abc123')
124
+ expect(host.fetch.mock.calls[0][0]).toBe('https://r2.example/presigned?sig=abc')
125
+ })
126
+
127
+ it('serves reads inside the probe window without touching the network', async () => {
128
+ const file = syntheticFile(PROBE_BYTES * 4)
129
+ const host = rangeHost(file)
130
+ const source = await openByteSource('/p.mp4', (p) => p)
131
+
132
+ const head = await source.read(0, 4096)
133
+ const later = await source.read(200_000, 200_064)
134
+
135
+ expect(host.fetch).toHaveBeenCalledTimes(1)
136
+ expect(head).toEqual(file.subarray(0, 4096))
137
+ expect(later).toEqual(file.subarray(200_000, 200_064))
138
+ })
139
+
140
+ it('range-fetches a read past the probe, block-aligned, and returns the exact slice', async () => {
141
+ const file = syntheticFile(BLOCK_BYTES * 5)
142
+ const host = rangeHost(file)
143
+ const source = await openByteSource('/p.mp4', (p) => p)
144
+
145
+ const start = BLOCK_BYTES * 3 + 17
146
+ const bytes = await source.read(start, start + 100)
147
+
148
+ expect(bytes).toEqual(file.subarray(start, start + 100))
149
+ expect(host.fetch).toHaveBeenCalledTimes(2)
150
+ // Rounded out to the enclosing block, not the file: 100 bytes wanted, one
151
+ // block moved, four blocks never touched.
152
+ expect(host.ranges[1]).toBe(`bytes=${BLOCK_BYTES * 3}-${BLOCK_BYTES * 4 - 1}`)
153
+ expect(host.served).toBe(PROBE_BYTES + BLOCK_BYTES)
154
+ })
155
+
156
+ it('shares one fetched block between two reads of the same region', async () => {
157
+ // The interleaving case: the frame server pulls a video span and the audio
158
+ // clock then pulls packets that live between those same frames. Block
159
+ // alignment is what stops the second read re-fetching the first read's bytes.
160
+ const file = syntheticFile(BLOCK_BYTES * 4)
161
+ const host = rangeHost(file)
162
+ const source = await openByteSource('/p.mp4', (p) => p)
163
+
164
+ const video = await source.read(BLOCK_BYTES * 2, BLOCK_BYTES * 2 + 500_000)
165
+ const audio = await source.read(BLOCK_BYTES * 2 + 600_000, BLOCK_BYTES * 2 + 600_400)
166
+
167
+ expect(host.fetch).toHaveBeenCalledTimes(2)
168
+ expect(video.length).toBe(500_000)
169
+ expect(audio).toEqual(file.subarray(BLOCK_BYTES * 2 + 600_000, BLOCK_BYTES * 2 + 600_400))
170
+ })
171
+
172
+ it('coalesces two concurrent reads of the same span into one request', async () => {
173
+ const file = syntheticFile(BLOCK_BYTES * 4)
174
+ const host = rangeHost(file)
175
+ const source = await openByteSource('/p.mp4', (p) => p)
176
+
177
+ const [a, b] = await Promise.all([
178
+ source.read(BLOCK_BYTES * 3, BLOCK_BYTES * 3 + 10),
179
+ source.read(BLOCK_BYTES * 3, BLOCK_BYTES * 3 + 10),
180
+ ])
181
+
182
+ expect(host.fetch).toHaveBeenCalledTimes(2)
183
+ expect(a).toEqual(b)
184
+ })
185
+
186
+ it('clamps reads to the file and returns empty for a zero-width range', async () => {
187
+ const file = syntheticFile(BLOCK_BYTES * 2 + 10)
188
+ rangeHost(file)
189
+ const source = await openByteSource('/p.mp4', (p) => p)
190
+
191
+ expect(await source.read(file.length - 4, file.length + 9999)).toEqual(file.subarray(-4))
192
+ expect(await source.read(500, 500)).toEqual(new Uint8Array(0))
193
+ })
194
+
195
+ it('caps cached bytes so a full scrub costs a bounded amount, not the file size', async () => {
196
+ const blocks = Math.floor(MAX_CACHED_BYTES / BLOCK_BYTES) + 4
197
+ const file = syntheticFile(BLOCK_BYTES * blocks)
198
+ const host = rangeHost(file)
199
+ const source = await openByteSource('/p.mp4', (p) => p)
200
+
201
+ // Walk the file end to end, one block at a time...
202
+ for (let b = 0; b < blocks; b++) await source.read(b * BLOCK_BYTES, b * BLOCK_BYTES + 16)
203
+ const fetchesAfterWalk = host.fetch.mock.calls.length
204
+ // ...then come back to the very first block. If nothing had been evicted
205
+ // this would be a cache hit; the point is that it is NOT, because holding
206
+ // the whole file is exactly the behavior being removed.
207
+ await source.read(0, 16)
208
+
209
+ expect(host.fetch.mock.calls.length).toBe(fetchesAfterWalk + 1)
210
+ })
211
+
212
+ it('close() drops the cache and refuses further reads', async () => {
213
+ const file = syntheticFile(BLOCK_BYTES * 3)
214
+ rangeHost(file)
215
+ const source = await openByteSource('/p.mp4', (p) => p)
216
+
217
+ source.close()
218
+
219
+ await expect(source.read(0, 16)).rejects.toThrow(/byte source closed/)
220
+ })
221
+
222
+ it('forwards the open signal so a dropped session does not keep a connection', async () => {
223
+ const host = rangeHost(syntheticFile(BLOCK_BYTES * 3))
224
+ const controller = new AbortController()
225
+
226
+ await openByteSource('/p.mp4', (p) => p, { signal: controller.signal })
227
+
228
+ expect(host.fetch.mock.calls[0][1]).toMatchObject({ signal: controller.signal })
229
+ })
230
+
231
+ it('throws with the src and status on a non-ok response', async () => {
232
+ vi.stubGlobal(
233
+ 'fetch',
234
+ vi.fn(async () => ({ ok: false, status: 404, statusText: 'Not Found' }) as unknown as Response),
235
+ )
236
+
237
+ await expect(openByteSource('/a/missing_proxy.mp4', (p) => p)).rejects.toThrow(
238
+ /\/a\/missing_proxy\.mp4 → 404 Not Found/,
239
+ )
240
+ })
241
+
242
+ it('rejects an empty src before touching fetch', async () => {
243
+ const fetchMock = vi.fn()
244
+ vi.stubGlobal('fetch', fetchMock)
245
+
246
+ await expect(openByteSource('', (p) => p)).rejects.toThrow('openByteSource: empty src')
247
+ expect(fetchMock).not.toHaveBeenCalled()
248
+ })
249
+ })
250
+
251
+ describe('openByteSource — the whole-file fallback', () => {
252
+ it('falls back when the host ignores Range and answers 200', async () => {
253
+ const file = syntheticFile(BLOCK_BYTES * 3)
254
+ const host = rangeHost(file, { ignoreRange: true })
255
+
256
+ const source = await openByteSource('/p.mp4', (p) => p)
257
+
258
+ expect(source.ranged).toBe(false)
259
+ expect(source.whole).not.toBeNull()
260
+ expect(source.size).toBe(file.length)
261
+ // Everything is already here, so reads are free and no second request ever
262
+ // goes out — the pre-ranged behavior, unchanged.
263
+ expect(await source.read(BLOCK_BYTES * 2, BLOCK_BYTES * 2 + 8)).toEqual(
264
+ file.subarray(BLOCK_BYTES * 2, BLOCK_BYTES * 2 + 8),
265
+ )
266
+ expect(host.fetch).toHaveBeenCalledTimes(1)
267
+ })
268
+
269
+ it('falls back when the host answers 206 without a usable total length', async () => {
270
+ const file = syntheticFile(BLOCK_BYTES * 3)
271
+ rangeHost(file, { unknownTotal: true })
272
+
273
+ const source = await openByteSource('/p.mp4', (p) => p)
274
+
275
+ expect(source.ranged).toBe(false)
276
+ expect(source.size).toBe(PROBE_BYTES)
277
+ })
278
+
279
+ it('treats a proxy small enough to arrive whole in the probe as whole-file', async () => {
280
+ const file = syntheticFile(Math.floor(PROBE_BYTES / 2))
281
+ const host = rangeHost(file)
282
+
283
+ const source = await openByteSource('/small_proxy.mp4', (p) => p)
284
+
285
+ expect(source.ranged).toBe(false)
286
+ expect(source.size).toBe(file.length)
287
+ expect(host.fetch).toHaveBeenCalledTimes(1)
288
+ })
289
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * SP4 T2 — `loadBytes`: the injected-`fileUrl` contract.
3
+ *
4
+ * The one thing worth pinning here is that the editor never invents a URL of
5
+ * its own. It is a library; a host path like `/Users/…/orig_proxy_x.mp4` is
6
+ * meaningless to `fetch`, and only the host knows the mapping. These tests
7
+ * assert `fileUrl` is applied to the raw `src` unconditionally — the same
8
+ * handling the legacy `<video>` path already uses in `useVideoPlayback.ts` —
9
+ * so T6 can pass `adapter.fileUrl` straight through with nothing else to
10
+ * reconcile.
11
+ */
12
+ import { describe, it, expect, vi, afterEach } from 'vitest'
13
+ import { loadBytes } from '../media-loader'
14
+
15
+ function okResponse(bytes: Uint8Array) {
16
+ return {
17
+ ok: true,
18
+ status: 200,
19
+ statusText: 'OK',
20
+ arrayBuffer: async () => bytes.buffer.slice(0) as ArrayBuffer,
21
+ } as unknown as Response
22
+ }
23
+
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals()
26
+ })
27
+
28
+ describe('loadBytes', () => {
29
+ it('resolves the src through fileUrl and fetches that URL', async () => {
30
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => okResponse(new Uint8Array([1, 2, 3])))
31
+ vi.stubGlobal('fetch', fetchMock)
32
+ const fileUrl = vi.fn((p: string) => `/api/files?path=${encodeURIComponent(p)}`)
33
+
34
+ const bytes = await loadBytes('/Users/sam/proj/orig_proxy_hable1.mp4', fileUrl)
35
+
36
+ expect(fileUrl).toHaveBeenCalledWith('/Users/sam/proj/orig_proxy_hable1.mp4')
37
+ expect(fetchMock).toHaveBeenCalledTimes(1)
38
+ expect(fetchMock.mock.calls[0][0]).toBe(
39
+ '/api/files?path=%2FUsers%2Fsam%2Fproj%2Forig_proxy_hable1.mp4',
40
+ )
41
+ expect(new Uint8Array(bytes)).toEqual(new Uint8Array([1, 2, 3]))
42
+ })
43
+
44
+ it('applies fileUrl unconditionally — the host owns the mapping, not the editor', async () => {
45
+ // A Hub-shaped adapter returns a presigned absolute URL; the editor must
46
+ // not pre-filter on `src.startsWith('/')` or similar.
47
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => okResponse(new Uint8Array([9])))
48
+ vi.stubGlobal('fetch', fetchMock)
49
+ const fileUrl = vi.fn(() => 'https://r2.example/presigned?sig=abc')
50
+
51
+ await loadBytes('media/abc123', fileUrl)
52
+
53
+ expect(fileUrl).toHaveBeenCalledWith('media/abc123')
54
+ expect(fetchMock.mock.calls[0][0]).toBe('https://r2.example/presigned?sig=abc')
55
+ })
56
+
57
+ it('throws with the src and status on a non-ok response', async () => {
58
+ vi.stubGlobal(
59
+ 'fetch',
60
+ vi.fn(async () => ({ ok: false, status: 404, statusText: 'Not Found' }) as unknown as Response),
61
+ )
62
+
63
+ await expect(loadBytes('/a/missing_proxy.mp4', (p) => p)).rejects.toThrow(
64
+ /\/a\/missing_proxy\.mp4 → 404 Not Found/,
65
+ )
66
+ })
67
+
68
+ it('rejects an empty src before touching fetch', async () => {
69
+ const fetchMock = vi.fn()
70
+ vi.stubGlobal('fetch', fetchMock)
71
+
72
+ await expect(loadBytes('', (p) => p)).rejects.toThrow('loadBytes: empty src')
73
+ expect(fetchMock).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('forwards an AbortSignal so T5 can cancel a load on a clip-boundary swap', async () => {
77
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => okResponse(new Uint8Array([0])))
78
+ vi.stubGlobal('fetch', fetchMock)
79
+ const controller = new AbortController()
80
+
81
+ await loadBytes('/a/p.mp4', (p) => p, { signal: controller.signal })
82
+
83
+ expect(fetchMock.mock.calls[0][1]).toEqual({ signal: controller.signal })
84
+ })
85
+
86
+ it('propagates an abort rejection rather than resolving a partial buffer', async () => {
87
+ vi.stubGlobal(
88
+ 'fetch',
89
+ vi.fn(async () => {
90
+ throw new DOMException('The operation was aborted.', 'AbortError')
91
+ }),
92
+ )
93
+ const controller = new AbortController()
94
+ controller.abort()
95
+
96
+ await expect(loadBytes('/a/p.mp4', (p) => p, { signal: controller.signal })).rejects.toThrow(
97
+ /aborted/i,
98
+ )
99
+ })
100
+ })