@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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- 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
|
+
})
|