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