supervision 0.1.7 → 0.2.0-next.0
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/dist/detections/chunked-detection-frame-source.d.ts.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8856 -4559
- package/dist/index.js.map +1 -1
- package/dist/mask-preparation.worker.js +310 -164
- package/dist/mask-preparation.worker.js.map +1 -1
- package/dist/media/display-pixel-ratio.d.ts +15 -0
- package/dist/media/display-pixel-ratio.d.ts.map +1 -0
- package/dist/media/engine-import-failure.d.ts +16 -0
- package/dist/media/engine-import-failure.d.ts.map +1 -0
- package/dist/media/media-condition-probe.d.ts +51 -0
- package/dist/media/media-condition-probe.d.ts.map +1 -0
- package/dist/media/media-conditions.d.ts +28 -0
- package/dist/media/media-conditions.d.ts.map +1 -0
- package/dist/media/media-errors.d.ts +6 -0
- package/dist/media/media-errors.d.ts.map +1 -1
- package/dist/media/media-normalization.d.ts.map +1 -1
- package/dist/media/media-probe.d.ts.map +1 -1
- package/dist/media/media-source.d.ts +17 -0
- package/dist/media/media-source.d.ts.map +1 -1
- package/dist/media/mediabunny-media-source.d.ts.map +1 -1
- package/dist/media/video-engine-media-source.d.ts +44 -0
- package/dist/media/video-engine-media-source.d.ts.map +1 -0
- package/dist/render-preparation/mask-frame-artifact.d.ts +46 -12
- package/dist/render-preparation/mask-frame-artifact.d.ts.map +1 -1
- package/dist/render-preparation/mask-frame-compositor.d.ts +5 -5
- package/dist/render-preparation/mask-frame-compositor.d.ts.map +1 -1
- package/dist/render-preparation/mask-frame-preparer.d.ts.map +1 -1
- package/dist/render-preparation/mask-preparation-worker-count.d.ts +4 -4
- package/dist/render-preparation/mask-preparation-worker-protocol.d.ts +12 -3
- package/dist/render-preparation/mask-preparation-worker-protocol.d.ts.map +1 -1
- package/dist/render-preparation/prepared-render-window.d.ts +23 -0
- package/dist/render-preparation/prepared-render-window.d.ts.map +1 -1
- package/dist/render-preparation/prepared-window-timeline.d.ts +2 -6
- package/dist/render-preparation/prepared-window-timeline.d.ts.map +1 -1
- package/dist/renderers/mask-palette.d.ts +14 -0
- package/dist/renderers/mask-palette.d.ts.map +1 -0
- package/dist/renderers/mask-vertex.d.ts +14 -0
- package/dist/renderers/mask-vertex.d.ts.map +1 -0
- package/dist/renderers/media-renderer-core.d.ts.map +1 -1
- package/dist/renderers/media-renderer-scene.d.ts +37 -0
- package/dist/renderers/media-renderer-scene.d.ts.map +1 -1
- package/dist/renderers/media-renderer-state.d.ts +11 -1
- package/dist/renderers/media-renderer-state.d.ts.map +1 -1
- package/dist/renderers/media-renderer-transport.d.ts +59 -0
- package/dist/renderers/media-renderer-transport.d.ts.map +1 -0
- package/dist/renderers/pixi-box-layer.d.ts +5 -0
- package/dist/renderers/pixi-box-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-focus-layer.d.ts +18 -2
- package/dist/renderers/pixi-focus-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-frame-present.d.ts +77 -0
- package/dist/renderers/pixi-frame-present.d.ts.map +1 -0
- package/dist/renderers/pixi-id-mask-shader.d.ts +12 -2
- package/dist/renderers/pixi-id-mask-shader.d.ts.map +1 -1
- package/dist/renderers/pixi-interaction-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-interaction-presentation-layer.d.ts +13 -2
- package/dist/renderers/pixi-interaction-presentation-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-mask-halo.d.ts +24 -0
- package/dist/renderers/pixi-mask-halo.d.ts.map +1 -1
- package/dist/renderers/pixi-mask-layer.d.ts +71 -8
- package/dist/renderers/pixi-mask-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-media-scene.d.ts +67 -0
- package/dist/renderers/pixi-media-scene.d.ts.map +1 -1
- package/dist/renderers/pixi-polygon-layer.d.ts +7 -2
- package/dist/renderers/pixi-polygon-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-region-coverage-mask.d.ts +91 -0
- package/dist/renderers/pixi-region-coverage-mask.d.ts.map +1 -0
- package/dist/renderers/pixi-region-effect.d.ts +42 -0
- package/dist/renderers/pixi-region-effect.d.ts.map +1 -0
- package/dist/renderers/pixi-region-layer.d.ts +68 -2
- package/dist/renderers/pixi-region-layer.d.ts.map +1 -1
- package/dist/renderers/pixi-vector-layer.d.ts.map +1 -1
- package/dist/renderers/prepared-annotation-window.d.ts +47 -0
- package/dist/renderers/prepared-annotation-window.d.ts.map +1 -0
- package/dist/renderers/presented-frame-channel.d.ts +107 -0
- package/dist/renderers/presented-frame-channel.d.ts.map +1 -0
- package/dist/renderers/scene-render-scheduler.d.ts +20 -0
- package/dist/renderers/scene-render-scheduler.d.ts.map +1 -0
- package/dist/sessions/media-session-defaults.d.ts +10 -6
- package/dist/sessions/media-session-defaults.d.ts.map +1 -1
- package/dist/sessions/media-session-media.d.ts.map +1 -1
- package/dist/sessions/media-session-state.d.ts.map +1 -1
- package/dist/sessions/media-session.d.ts.map +1 -1
- package/dist/tracking.worker.js +58 -3
- package/dist/tracking.worker.js.map +1 -1
- package/dist/types/media-conditions.d.ts +149 -0
- package/dist/types/media-conditions.d.ts.map +1 -0
- package/dist/types/media-normalization.d.ts +34 -0
- package/dist/types/media-normalization.d.ts.map +1 -1
- package/dist/types/media-renderer.d.ts +31 -1
- package/dist/types/media-renderer.d.ts.map +1 -1
- package/dist/types/media-session.d.ts +89 -8
- package/dist/types/media-session.d.ts.map +1 -1
- package/dist/types/render-preparation.d.ts +101 -4
- package/dist/types/render-preparation.d.ts.map +1 -1
- package/dist/web-video-engine/analysis-session.d.ts +76 -0
- package/dist/web-video-engine/analysis-session.d.ts.map +1 -0
- package/dist/web-video-engine/analysis.d.ts +7 -0
- package/dist/web-video-engine/analysis.d.ts.map +1 -0
- package/dist/web-video-engine/analysis.js +1678 -0
- package/dist/web-video-engine/analysis.js.map +1 -0
- package/dist/web-video-engine/cache-budget.d.ts +19 -0
- package/dist/web-video-engine/cache-budget.d.ts.map +1 -0
- package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts +90 -0
- package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts.map +1 -0
- package/dist/web-video-engine/clock.d.ts +83 -0
- package/dist/web-video-engine/clock.d.ts.map +1 -0
- package/dist/web-video-engine/constants.d.ts +253 -0
- package/dist/web-video-engine/constants.d.ts.map +1 -0
- package/dist/web-video-engine/create-scrub-cursor.d.ts +62 -0
- package/dist/web-video-engine/create-scrub-cursor.d.ts.map +1 -0
- package/dist/web-video-engine/decode-resolution.d.ts +115 -0
- package/dist/web-video-engine/decode-resolution.d.ts.map +1 -0
- package/dist/web-video-engine/decode-scheduler.d.ts +356 -0
- package/dist/web-video-engine/decode-scheduler.d.ts.map +1 -0
- package/dist/web-video-engine/decode-session.d.ts +276 -0
- package/dist/web-video-engine/decode-session.d.ts.map +1 -0
- package/dist/web-video-engine/decode-source.d.ts +250 -0
- package/dist/web-video-engine/decode-source.d.ts.map +1 -0
- package/dist/web-video-engine/diagnostics-store.d.ts +18 -0
- package/dist/web-video-engine/diagnostics-store.d.ts.map +1 -0
- package/dist/web-video-engine/diagnostics.d.ts +316 -0
- package/dist/web-video-engine/diagnostics.d.ts.map +1 -0
- package/dist/web-video-engine/embedded-engine-worker.d.ts +2 -0
- package/dist/web-video-engine/embedded-engine-worker.d.ts.map +1 -0
- package/dist/web-video-engine/engine-core.d.ts +258 -0
- package/dist/web-video-engine/engine-core.d.ts.map +1 -0
- package/dist/web-video-engine/engine.d.ts +26 -0
- package/dist/web-video-engine/engine.d.ts.map +1 -0
- package/dist/web-video-engine/engine.js +873 -0
- package/dist/web-video-engine/engine.js.map +1 -0
- package/dist/web-video-engine/engine.worker.d.ts +2 -0
- package/dist/web-video-engine/engine.worker.d.ts.map +1 -0
- package/dist/web-video-engine/engine.worker.js +31320 -0
- package/dist/web-video-engine/engine.worker.js.map +1 -0
- package/dist/web-video-engine/frame-cache.d.ts +129 -0
- package/dist/web-video-engine/frame-cache.d.ts.map +1 -0
- package/dist/web-video-engine/frame-extractor.d.ts +28 -0
- package/dist/web-video-engine/frame-extractor.d.ts.map +1 -0
- package/dist/web-video-engine/frame-timeline-GINI8Gum.js +560 -0
- package/dist/web-video-engine/frame-timeline-GINI8Gum.js.map +1 -0
- package/dist/web-video-engine/frame-timeline.d.ts +71 -0
- package/dist/web-video-engine/frame-timeline.d.ts.map +1 -0
- package/dist/web-video-engine/frame-walker.d.ts +74 -0
- package/dist/web-video-engine/frame-walker.d.ts.map +1 -0
- package/dist/web-video-engine/index.d.ts +3 -0
- package/dist/web-video-engine/index.d.ts.map +1 -0
- package/dist/web-video-engine/index.js +3 -0
- package/dist/web-video-engine/index.js.map +1 -0
- package/dist/web-video-engine/key-packet.d.ts +65 -0
- package/dist/web-video-engine/key-packet.d.ts.map +1 -0
- package/dist/web-video-engine/keyframe-index.d.ts +91 -0
- package/dist/web-video-engine/keyframe-index.d.ts.map +1 -0
- package/dist/web-video-engine/mirror-store.d.ts +56 -0
- package/dist/web-video-engine/mirror-store.d.ts.map +1 -0
- package/dist/web-video-engine/renderer.d.ts +94 -0
- package/dist/web-video-engine/renderer.d.ts.map +1 -0
- package/dist/web-video-engine/rotation.d.ts +35 -0
- package/dist/web-video-engine/rotation.d.ts.map +1 -0
- package/dist/web-video-engine/scrub-controller.d.ts +376 -0
- package/dist/web-video-engine/scrub-controller.d.ts.map +1 -0
- package/dist/web-video-engine/scrub-cursor.d.ts +342 -0
- package/dist/web-video-engine/scrub-cursor.d.ts.map +1 -0
- package/dist/web-video-engine/scrub-trajectory.d.ts +30 -0
- package/dist/web-video-engine/scrub-trajectory.d.ts.map +1 -0
- package/dist/web-video-engine/source-residency.d.ts +57 -0
- package/dist/web-video-engine/source-residency.d.ts.map +1 -0
- package/dist/web-video-engine/trace-recorder.d.ts +164 -0
- package/dist/web-video-engine/trace-recorder.d.ts.map +1 -0
- package/dist/web-video-engine/types.d.ts +204 -0
- package/dist/web-video-engine/types.d.ts.map +1 -0
- package/dist/web-video-engine/video-engine.d.ts +426 -0
- package/dist/web-video-engine/video-engine.d.ts.map +1 -0
- package/dist/web-video-engine/webgpu-renderer.d.ts +62 -0
- package/dist/web-video-engine/webgpu-renderer.d.ts.map +1 -0
- package/dist/web-video-engine/worker-bridge.d.ts +12 -0
- package/dist/web-video-engine/worker-bridge.d.ts.map +1 -0
- package/dist/web-video-engine/worker-dispatch.d.ts +17 -0
- package/dist/web-video-engine/worker-dispatch.d.ts.map +1 -0
- package/dist/web-video-engine/worker-protocol.d.ts +267 -0
- package/dist/web-video-engine/worker-protocol.d.ts.map +1 -0
- package/node_modules/supervision-js-core/dist/detections/buffered-detection-timeline.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/detections/composite-detection-frame-source.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/index.d.ts +4 -3
- package/node_modules/supervision-js-core/dist/index.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/index.js +1075 -266
- package/node_modules/supervision-js-core/dist/index.js.map +1 -1
- package/node_modules/supervision-js-core/dist/styles/default-annotation-presentation.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/styles/interaction-style.d.ts +4 -36
- package/node_modules/supervision-js-core/dist/styles/interaction-style.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/styles/polyline-style.d.ts +5 -0
- package/node_modules/supervision-js-core/dist/styles/polyline-style.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/types/annotation-renderer.d.ts +92 -7
- package/node_modules/supervision-js-core/dist/types/annotation-renderer.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts +179 -17
- package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/types/media-rendering.d.ts +78 -2
- package/node_modules/supervision-js-core/dist/types/media-rendering.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/types/polyline-style.d.ts +2 -0
- package/node_modules/supervision-js-core/dist/types/polyline-style.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/types/session-lifecycle.d.ts +16 -0
- package/node_modules/supervision-js-core/dist/types/session-lifecycle.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/utils/detection-conversions.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/utils/detection-frames.d.ts +5 -0
- package/node_modules/supervision-js-core/dist/utils/detection-frames.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/utils/detection-masks.d.ts +9 -0
- package/node_modules/supervision-js-core/dist/utils/detection-masks.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/utils/detection-ranges.d.ts +18 -0
- package/node_modules/supervision-js-core/dist/utils/detection-ranges.d.ts.map +1 -0
- package/node_modules/supervision-js-core/dist/utils/id-mask-frame.d.ts +15 -2
- package/node_modules/supervision-js-core/dist/utils/id-mask-frame.d.ts.map +1 -1
- package/node_modules/supervision-js-core/dist/utils/wait-bound.d.ts +7 -0
- package/node_modules/supervision-js-core/dist/utils/wait-bound.d.ts.map +1 -0
- package/package.json +22 -1
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import { e as asPaintSeq, P as PlaybackStatus, V as VideoEngineError, a as VideoEngineErrorCode, d as FrameTimeline, f as canvasBindingRefused, g as resolvePlaybackRate, D as DIAGNOSTICS, H as HANG_RECOVERY, c as SourceKind } from './frame-timeline-GINI8Gum.js';
|
|
2
|
+
export { h as PLAYBACK_RATE, T as TRACE_RING_BOUNDS, i as asFps, b as asSec, j as cappedResolution, k as displayBoxResolution, n as nativeResolution, v as viewportResolution } from './frame-timeline-GINI8Gum.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sibling of MirrorStore for the diagnostics broadcast plane. A single
|
|
6
|
+
* channel holding the latest snapshot, shaped for useSyncExternalStore. Fed only
|
|
7
|
+
* by the 'diag' event; the playback mirror store and its subscribers never touch
|
|
8
|
+
* it, so a 10Hz diagnostics push never wakes a playback consumer.
|
|
9
|
+
*
|
|
10
|
+
* getSnapshot returns the same reference between writes, so a selector using
|
|
11
|
+
* Object.is short-circuits when nothing changed.
|
|
12
|
+
*/
|
|
13
|
+
class DiagnosticsStore {
|
|
14
|
+
snapshot = null;
|
|
15
|
+
listeners = new Set();
|
|
16
|
+
getSnapshot() {
|
|
17
|
+
return this.snapshot;
|
|
18
|
+
}
|
|
19
|
+
write(snapshot) {
|
|
20
|
+
this.snapshot = snapshot;
|
|
21
|
+
for (const listener of this.listeners)
|
|
22
|
+
listener();
|
|
23
|
+
}
|
|
24
|
+
subscribe(listener) {
|
|
25
|
+
this.listeners.add(listener);
|
|
26
|
+
return () => {
|
|
27
|
+
this.listeners.delete(listener);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EMBEDDED_ENGINE_WORKER_SOURCE = "(function () {\n 'use strict';\n\n /**\n * Default wall-clock-driven media clock. Uses performance.now() as the wall\n * source. Suitable for muted scrubbing where A/V sync is not a concern.\n */\n class PerformanceMediaClock {\n mediaTimeAtAnchor = 0;\n wallTimeAtAnchorMs = 0;\n _playing = false;\n _rate = 1;\n get playing() {\n return this._playing;\n }\n get rate() {\n return this._rate;\n }\n now() {\n if (this._playing) {\n return (this.mediaTimeAtAnchor +\n ((performance.now() - this.wallTimeAtAnchorMs) / 1000) * this._rate);\n }\n return this.mediaTimeAtAnchor;\n }\n setRate(rate) {\n this.mediaTimeAtAnchor = this.now();\n this.wallTimeAtAnchorMs = performance.now();\n this._rate = rate;\n }\n play(t) {\n if (t === undefined) {\n this.mediaTimeAtAnchor = this.now();\n }\n else {\n this.mediaTimeAtAnchor = t;\n }\n this.wallTimeAtAnchorMs = performance.now();\n this._playing = true;\n }\n pause() {\n if (this._playing) {\n this.mediaTimeAtAnchor = this.now();\n this._playing = false;\n }\n }\n seek(t) {\n this.mediaTimeAtAnchor = t;\n this.wallTimeAtAnchorMs = performance.now();\n }\n }\n\n /**\n * READ_AHEAD_CANVAS is the playback decode-ahead depth: how many decoded frames\n * the controller keeps queued ahead of the playhead so a decode that overruns a\n * frame interval does not starve the next tick (the cause of stutter even on\n * small, well-encoded clips, where playback otherwise pulls one frame per paint\n * with no cushion). Applies to canvas blits, which are independent and cheap to\n * hold; sample (zero-copy) frames pin a decoder slot each, so the controller\n * keeps those at a depth of one to spare the WebCodecs pool.\n *\n * REANCHOR_STALL_MS is the wall time past which a picture standing still stops\n * being a hitch to work through and becomes a stretch to re-anchor across. Two\n * gaps are measured against it, and they settle in opposite directions.\n *\n * A gap between playing render ticks is a loop nothing was driving, which a\n * hidden tab produces. Nobody was watching, so the clock is the truth and the\n * walk seeks forward onto the playhead.\n *\n * A gap between the walk's deliveries is a pipeline with nothing to show while\n * the loop ran, which a seek onto unbuffered ground produces. The viewer sat\n * through it waiting on a position they asked for, so the frame is the truth\n * and the clock is pulled back onto it. Left to catch up instead, the walk\n * replays the whole wait at decode speed on its way to the playhead: measured\n * against the deployed demo over six seeks, a mean 2.4x for a second, peaking\n * at 24x, with 44 source frames flashed past.\n *\n * Wall time is the reading in both, so a high rate legitimately moving the\n * media clock several frames per gap is not read as an absence.\n *\n * Half a second is where the tick-gap costs cross at 1x. A backlog costs one\n * decode per source frame in it, measured at 240 frames a second on the 30fps\n * demo clip (4.2ms each), against a keyframe-anchored seek at p95 47ms on the\n * same clip: half a second of 30fps backlog is 15 frames, so 63ms of decode\n * against that 47ms, and every higher rate puts more frames in the same gap. It\n * clears the worst legitimate gap of either kind by the same order: a foreground\n * loop leaves a long task or a GC pause to the frame-by-frame path, and forty\n * seconds of network-fed playback of the demo clip held its picture still for at\n * most 51ms.\n *\n * The present cadence is how often playback puts a frame on screen: the rate's\n * whole demand cut to the share of it the machine has been paying for, and never\n * under the source's own frame rate. At a full share the pump declines nothing,\n * which is where a machine with headroom stays at every rate.\n * A step moves the share by PRESENT_CADENCE_STEP, taking a quarter off it when\n * the pipeline has fallen behind the playhead and putting that same quarter back\n * when it has room to spare. The share bottoms out at one over\n * PLAYBACK_RATE.MAX, which is the deepest cut that means anything: below it even\n * the fastest rate asks for less than the source rate the cadence floors at.\n *\n * What takes the share down is a running bill, in frames, between what the clock\n * asked the pipeline for and what the play walk delivered. The bill accumulates\n * rather than being read per tick, which is what tells a shortfall from noise:\n * counting whole frames against a fractional demand leaves every tick out by up\n * to one either way and those cancel, while a machine that cannot sustain the\n * rate falls short every tick and adds up. Measured over 100ms windows on an M3\n * Max at rates 2x to 8x, a pipeline with headroom stayed inside 1.5 frames of\n * its demand either way, so a bill of PRESENT_CADENCE_EVIDENCE_FRAMES frames is\n * one it cannot run up.\n *\n * What puts the share back is the walk holding a frame the clock has not run\n * past, unbroken for the wall time the source takes to produce that many frames.\n * That is the only evidence a pipeline can offer that it has room to spare: a\n * machine at equilibrium delivers exactly what the clock asks for however much\n * headroom it has, so waiting for a surplus in the bill would leave every\n * machine at whatever its worst stretch measured. It is a live reading and the\n * bill is a cumulative one, which is why each takes one direction: ground lost\n * still reads lost once the walk is keeping up again, and catch-up pulls are\n * what carry the walk back onto the playhead.\n *\n * Neither reading is the depth of the decode-ahead queue, which belongs to the\n * decode path and not to the machine: on the zero-copy path, which pins a\n * decoder slot per queued frame and so buffers one, a single frame in hand is\n * full depth and one tick from empty alike.\n *\n * How far behind the machine is therefore sets the pace rather than the size of\n * the step: the bigger the shortfall the sooner the bill reaches that figure and\n * the sooner the next step lands. Zeroing on each step makes the next one gather\n * its own evidence, which is what stops a share sitting at the machine's limit\n * from pulsing.\n *\n * Frames the clock passed, frames the walk delivered and wall time on the\n * playhead: nothing either reading is made of knows what the panel refreshes at,\n * so one machine lands on one cadence on any panel. A faster panel offers more\n * slots and a machine with the headroom fills them; what it cannot do is hold\n * the pump above what the machine pays for, because the share comes down until\n * the pipeline is back on the playhead.\n *\n * PRESENT_CADENCE_SLACK is how far under the interval still counts as a full\n * one. Presents land on display ticks, so a panel refreshing at the ceiling\n * measures each gap as one interval give or take rAF jitter, and an exact\n * comparison would decline every second frame there. A quarter leaves 4.2ms of\n * jitter room at 60Hz.\n *\n * PRESENT_CADENCE_HZ is the demand above which frames the pump culled or\n * declined dominate the dropped-frame ledger, so the discarded-frames rule in\n * evaluateWarnings stands down rather than reading a count it cannot attribute.\n */\n const PLAYBACK = {\n READ_AHEAD_CANVAS: 3,\n PRESENT_CADENCE_EVIDENCE_FRAMES: 3,\n REANCHOR_STALL_MS: 500,\n PRESENT_CADENCE_STEP: 0.75,\n PRESENT_CADENCE_SLACK: 0.25,\n PRESENT_CADENCE_HZ: 60,\n };\n /**\n * Forward playback-rate range the engine accepts. Reverse playback needs a\n * backwards decode strategy the runtime does not have, so zero and negative\n * rates are refused rather than approximated.\n *\n * MAX is an API bound, not a promise that every source plays smoothly there.\n * Sustaining rate r means decoding r x nativeFps frames a second, and the\n * decode-ahead cushion is fixed at PLAYBACK.READ_AHEAD_CANVAS frames (raising it\n * would outrun the CanvasSink pool and tear), so how high a given source really\n * goes is a property of that source's frame size and encode. The engine measures\n * it instead of guessing: DiagnosticsSnapshot carries the commanded rate beside\n * the presented one, and PLAYBACK_RATE_NOT_SUSTAINED fires when they diverge.\n * SUSTAINED_SHORTFALL is the share of the commanded rate the presented one has\n * to hold to count as sustained.\n */\n const PLAYBACK_RATE = {\n MIN: 0.25,\n MAX: 8,\n SUSTAINED_SHORTFALL: 0.8,\n };\n /**\n * CanvasSink frame-pool size: how many decoded canvases the sink recycles\n * round-robin. The pool has no backpressure (a new decode overwrites the oldest\n * slot rather than blocking), and the playback queue hands the raw pool canvas\n * straight to the painter, so the pool must outnumber every canvas outstanding at\n * once: the full play read-ahead, the one in flight, and the one being painted.\n * Sized below that, a fresh decode stomps the pixels of a still-queued frame\n * before it paints (silent tearing). The cache is unaffected; it copies into its\n * own OffscreenCanvas. Derived from the read-ahead so the two cannot drift.\n */\n const SCRUB = {\n DEFAULT_POOL_SIZE: PLAYBACK.READ_AHEAD_CANVAS + 2,\n };\n /**\n * Bounds on building a track's frame timeline, the presentation-order table of\n * every frame's timestamp in the container's own integer grain.\n *\n * MAX_FRAMES caps the metadata walk that builds it. A 70-second 30fps source\n * walks 2113 packets in a measured 5.7ms; the cap is four hundred times that,\n * which no ordinary file reaches and a fragmented or endless source does. Past\n * it the load fails, so nothing silently runs without frame identity.\n *\n * FALLBACK_TICK_RATE is for a track backing that cannot state its grain. A\n * microsecond is the finest timestamp WebCodecs carries, so no real timestamp is\n * lost at that rate; what is lost is the guarantee that every timestamp is a\n * whole tick.\n */\n const FRAME_TIMELINE = {\n MAX_FRAMES: 1_000_000,\n FALLBACK_TICK_RATE: 1e6,\n };\n /**\n * Tiered frame-cache tunables. Both tiers hold OffscreenCanvas blits, never\n * VideoFrames: a raw-frame cache pins decoder output, stalling the decoder and\n * growing VRAM without bound. The exact tier holds crisp full-resolution frames\n * around the scrub playhead; the preview tier is the coarse downscaled history\n * that answers a scrub anywhere else instantly while a crisp decode lands.\n *\n * Only the exact tier's window is worth holding at full resolution. A crisp\n * frame the playhead has moved off is one re-anchored decode away: on the\n * session path that is a probe plus a walk from the enclosing sync sample,\n * measured at 49ms mean / 80ms worst on a 2840x2840 15fps source, flat in how\n * far the jump travelled. Coverage past the window is worth having, not worth\n * having crisp, so it lives in the preview tier at a 79th of the bytes.\n *\n * resolveCacheBudgets turns these into a per-source budget from\n * navigator.deviceMemory and the decode frame size. A crisp slot costs\n * decodeWidth x decodeHeight x 4 bytes, which spans two orders of magnitude\n * across sources: 0.9MB for a 640x360 decode, 32.3MB for a square 8MP native\n * decode. So a byte figure alone sets the slot count only for small frames. For\n * large ones any byte figure worth spending buys fewer slots than the access\n * pattern needs, and a slot floor sets the count with the bytes following from\n * it. The exact tier is therefore both: EXACT_BUDGET_BYTES_PER_GB times reported\n * RAM clamped to [MIN, MAX], raised to whatever MIN_EXACT_SLOTS costs at this\n * frame size.\n *\n * MIN_EXACT_SLOTS: the scrub prefetch window, a center frame plus\n * SCRUB_WINDOW_FRAMES each side. DecodeScheduler narrows its window to whatever\n * the exact tier holds, so this floor is what keeps an 8MP source at the full\n * window width, and slots past it would be speculative history the preview tier\n * already answers.\n *\n * PREVIEW_SLOTS_MAX: the preview slot count is its byte budget over the\n * downscaled frame size, capped here. No floor pairs with it: raising a starved\n * count means overspending the byte budget, when the frame width is what a wide\n * preview has to give up. At a 79th of a crisp slot the tier carries the\n * timeline coverage an 8MP source cannot afford crisply: 163 slots against the\n * exact tier's 13, 11s of a 15fps clip.\n *\n * SKIP_NEAR_MS: cache lookups that resolve to a frame within this many\n * milliseconds of what the visible canvas already shows are rejected so the\n * consumer falls through to a full-res decode. Keeps a frame-step gesture from\n * snapping back to the already-painted neighbor. Fixed in ms, so it spans one\n * frame at 15fps and three at 30fps.\n *\n * DEFAULT_BUCKET_MS: timestamp-bucket size when the source frame rate is\n * unknown. Finer than the frame interval at any rate up to 30fps, so distinct\n * frames never collapse onto one slot.\n *\n * DEFAULT_DEVICE_MEMORY_GB: the RAM assumed for every browser that reports\n * none, which is all of them outside Chromium and any page off a secure\n * context. It is a guess, and the byte clamps above are what bound a wrong\n * one: at 8 the exact tier sits on its ceiling and the preview tier at two\n * thirds of its own.\n */\n const FRAME_CACHE = {\n PREVIEW_WIDTH_PX: 320,\n SKIP_NEAR_MS: 100,\n DEFAULT_BUCKET_MS: 33,\n MIN_EXACT_SLOTS: 13,\n DEFAULT_DEVICE_MEMORY_GB: 8,\n EXACT_BUDGET_BYTES_PER_GB: 24 * 1024 * 1024,\n EXACT_BUDGET_BYTES_MIN: 64 * 1024 * 1024,\n EXACT_BUDGET_BYTES_MAX: 128 * 1024 * 1024,\n PREVIEW_BUDGET_BYTES_PER_GB: 8 * 1024 * 1024,\n PREVIEW_BUDGET_BYTES_MIN: 24 * 1024 * 1024,\n PREVIEW_BUDGET_BYTES_MAX: 96 * 1024 * 1024,\n PREVIEW_SLOTS_MAX: 512,\n };\n /**\n * Diagnostics instrument tunables. Inert unless a consumer opts in: the worker\n * broadcasts a snapshot at BROADCAST_HZ only while subscribed, the trace rings\n * allocate only while armed, and SCRUB_LATENCY_RING bounds the percentile sample.\n */\n const DIAGNOSTICS = {\n BROADCAST_HZ: 10,\n TRACE_EVENT_CAP: 2000,\n TRACE_SNAPSHOT_CAP: 600,\n SCRUB_LATENCY_RING: 64,\n };\n /**\n * Hang-recovery bounds for the worker video runtime. mediabunny's getCanvas/\n * getSample take no AbortSignal, so a decode that never settles cannot be\n * cancelled. These two timeouts bound the damage: a decode that outruns its\n * ceiling is abandoned and its provider rebuilt, and a main-thread caller\n * blocked on a wedged worker surfaces an error.\n *\n * DECODE_HANG_TIMEOUT_MS bounds a random-access decode operation, not a single\n * frame. A seek or a play-start lands mid-GOP and decodes the whole\n * keyframe-to-target prefix, so an 8-second GOP of 8MP frames legitimately runs\n * tens of seconds. A ceiling sized for one frame fires on healthy progress and\n * kills the decode it exists to protect.\n *\n * SEED_HANG_TIMEOUT_MS bounds the first-frame seed alone, which is why it is a\n * fraction of the figure above: the seed anchors on the track's own first\n * sample, so it walks no GOP prefix, and the one cost it carries that a later\n * decode does not is the decoder's cold hardware configuration. A re-anchored\n * decode of a 2840x2840 source measured 49ms mean / 80ms worst, so this leaves\n * two orders of magnitude of headroom over the decode plus its cold start, and\n * SEED_DECODE_ATTEMPTS of it still finish inside WORKER_COMMAND_TIMEOUT_MS with\n * the rebuilds between them. That is what lets a decoder which never starts\n * reach the caller as an error rather than as the facade giving up.\n *\n * That budget is the mediabunny sink paths', which carry no ceiling of their\n * own. The long-lived decode session bounds its own wait for output more\n * tightly and can tell a decoder that never started from one that went quiet\n * after working, so on that path the seed ends on the session's word and these\n * attempts go unspent.\n *\n * WORKER_COMMAND_TIMEOUT_MS is the main-thread backstop for an awaitable command\n * (load, commit, step). It derives from the decode ceiling so the two cannot\n * drift: the worker has to be able to reach its own hang path, recover, and\n * reply before the facade gives up on it.\n */\n const DECODE_HANG_TIMEOUT_MS = 30_000;\n const HANG_RECOVERY = {\n DECODE_HANG_TIMEOUT_MS,\n SEED_HANG_TIMEOUT_MS: 8_000,\n SEED_DECODE_ATTEMPTS: 3};\n\n /** navigator.deviceMemory reports GB in Chromium alone, and there only in a\n * secure context. Safari, Firefox and any plain-http page never measure at\n * all; they take the assumption below. */\n function deviceMemoryGb() {\n if (typeof navigator === \"undefined\")\n return FRAME_CACHE.DEFAULT_DEVICE_MEMORY_GB;\n const reported = navigator\n .deviceMemory;\n return typeof reported === \"number\" && reported > 0\n ? reported\n : FRAME_CACHE.DEFAULT_DEVICE_MEMORY_GB;\n }\n function clamp$1(value, lo, hi) {\n return Math.min(hi, Math.max(lo, value));\n }\n /**\n * Sizes both cache tiers to the device's reported RAM and the frame size.\n *\n * The exact tier holds a working set the access pattern fixes in frames, the\n * scrub prefetch window, so its slot floor survives any frame size and the RAM\n * band adds speculative slots on top only while a slot stays cheap. An 8MP\n * source lands on the floor and keeps nothing speculative, which is what holds\n * its resident footprint down; coverage past the window comes from the preview\n * tier at a 79th of the cost per slot. That tier has no working set to protect,\n * so its count is purely its byte budget over the downscaled frame size, which\n * gives a square 8MP source enough coarse history without over-allocating for a\n * small 16:9 one.\n */\n function resolveCacheBudgets(decodeWidth, decodeHeight, previewWidth) {\n const gb = deviceMemoryGb();\n const crispFrameBytes = Math.max(1, decodeWidth * decodeHeight * 4);\n const exactBudgetBytes = Math.max(clamp$1(Math.round(gb * FRAME_CACHE.EXACT_BUDGET_BYTES_PER_GB), FRAME_CACHE.EXACT_BUDGET_BYTES_MIN, FRAME_CACHE.EXACT_BUDGET_BYTES_MAX), FRAME_CACHE.MIN_EXACT_SLOTS * crispFrameBytes);\n const aspect = decodeWidth > 0 && decodeHeight > 0 ? decodeWidth / decodeHeight : 1;\n const previewHeight = Math.max(1, Math.round(previewWidth / aspect));\n const previewFrameBytes = Math.max(1, previewWidth * previewHeight * 4);\n const previewBudgetBytes = clamp$1(Math.round(gb * FRAME_CACHE.PREVIEW_BUDGET_BYTES_PER_GB), FRAME_CACHE.PREVIEW_BUDGET_BYTES_MIN, FRAME_CACHE.PREVIEW_BUDGET_BYTES_MAX);\n const previewCapacity = Math.min(Math.floor(previewBudgetBytes / previewFrameBytes), FRAME_CACHE.PREVIEW_SLOTS_MAX);\n return { exactBudgetBytes, previewCapacity };\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n function assert(x) {\n if (!x) {\n throw new Error('Assertion failed.');\n }\n }\n const normalizeRotation = (rotation) => {\n const mappedRotation = (rotation % 360 + 360) % 360;\n if (mappedRotation === 0 || mappedRotation === 90 || mappedRotation === 180 || mappedRotation === 270) {\n return mappedRotation;\n }\n else {\n throw new Error(`Invalid rotation ${rotation}.`);\n }\n };\n const last = (arr) => {\n return arr && arr[arr.length - 1];\n };\n /** Reads an exponential-Golomb universal code from a Bitstream. */\n const readExpGolomb = (bitstream) => {\n let leadingZeroBits = 0;\n while (bitstream.readBits(1) === 0 && leadingZeroBits < 32) {\n leadingZeroBits++;\n }\n if (leadingZeroBits >= 32) {\n throw new Error('Invalid exponential-Golomb code.');\n }\n const result = (1 << leadingZeroBits) - 1 + bitstream.readBits(leadingZeroBits);\n return result;\n };\n /** Reads a signed exponential-Golomb universal code from a Bitstream. */\n const readSignedExpGolomb = (bitstream) => {\n const codeNum = readExpGolomb(bitstream);\n return ((codeNum & 1) === 0)\n ? -(codeNum >> 1)\n : ((codeNum + 1) >> 1);\n };\n const toUint8Array = (source) => {\n if (source.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer\n return source;\n }\n else if (ArrayBuffer.isView(source)) {\n return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);\n }\n else {\n return new Uint8Array(source);\n }\n };\n const toDataView = (source) => {\n if (source.constructor === DataView) {\n return source;\n }\n else if (ArrayBuffer.isView(source)) {\n return new DataView(source.buffer, source.byteOffset, source.byteLength);\n }\n else {\n return new DataView(source);\n }\n };\n const textDecoder = /* #__PURE__ */ new TextDecoder();\n const invertObject = (object) => {\n return Object.fromEntries(Object.entries(object).map(([key, value]) => [value, key]));\n };\n // For the color space mappings, see Rec. ITU-T H.273.\n const COLOR_PRIMARIES_MAP = {\n bt709: 1, // ITU-R BT.709\n bt470bg: 5, // ITU-R BT.470BG\n smpte170m: 6, // ITU-R BT.601 525 - SMPTE 170M\n bt2020: 9, // ITU-R BT.202\n smpte432: 12, // SMPTE EG 432-1\n };\n const COLOR_PRIMARIES_MAP_INVERSE = /* #__PURE__ */ invertObject(COLOR_PRIMARIES_MAP);\n const TRANSFER_CHARACTERISTICS_MAP = {\n 'bt709': 1, // ITU-R BT.709\n 'smpte170m': 6, // SMPTE 170M\n 'linear': 8, // Linear transfer characteristics\n 'iec61966-2-1': 13, // IEC 61966-2-1\n 'pq': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system\n 'hlg': 18, // Rec. ITU-R BT.2100-2 hybrid loggamma (HLG) system\n };\n const TRANSFER_CHARACTERISTICS_MAP_INVERSE = /* #__PURE__ */ invertObject(TRANSFER_CHARACTERISTICS_MAP);\n const MATRIX_COEFFICIENTS_MAP = {\n 'rgb': 0, // Identity\n 'bt709': 1, // ITU-R BT.709\n 'bt470bg': 5, // ITU-R BT.470BG\n 'smpte170m': 6, // SMPTE 170M\n 'bt2020-ncl': 9, // ITU-R BT.2020-2 (non-constant luminance)\n };\n const MATRIX_COEFFICIENTS_MAP_INVERSE = /* #__PURE__ */ invertObject(MATRIX_COEFFICIENTS_MAP);\n const isAllowSharedBufferSource = (x) => {\n return (x instanceof ArrayBuffer\n || (typeof SharedArrayBuffer !== 'undefined' && x instanceof SharedArrayBuffer)\n || ArrayBuffer.isView(x));\n };\n class AsyncMutex {\n constructor() {\n this.currentPromise = Promise.resolve();\n this.pending = 0;\n }\n async acquire() {\n let resolver;\n const nextPromise = new Promise((resolve) => {\n let resolved = false;\n resolver = () => {\n if (resolved) {\n return;\n }\n resolve();\n this.pending--;\n resolved = true;\n };\n });\n const currentPromiseAlias = this.currentPromise;\n this.currentPromise = nextPromise;\n this.pending++;\n await currentPromiseAlias;\n return resolver;\n }\n }\n const HEX_STRING_REGEX = /^[0-9a-fA-F]+$/;\n const bytesToHexString = (bytes) => {\n return [...bytes].map(x => x.toString(16).padStart(2, '0')).join('');\n };\n const hexStringToBytes = (hexString) => {\n assert(hexString.length % 2 === 0);\n const bytes = new Uint8Array(hexString.length / 2);\n for (let i = 0; i < hexString.length; i += 2) {\n bytes[i / 2] = parseInt(hexString.slice(i, i + 2), 16);\n }\n return bytes;\n };\n const reverseBitsU32 = (x) => {\n x = ((x >> 1) & 0x55555555) | ((x & 0x55555555) << 1);\n x = ((x >> 2) & 0x33333333) | ((x & 0x33333333) << 2);\n x = ((x >> 4) & 0x0f0f0f0f) | ((x & 0x0f0f0f0f) << 4);\n x = ((x >> 8) & 0x00ff00ff) | ((x & 0x00ff00ff) << 8);\n x = ((x >> 16) & 0x0000ffff) | ((x & 0x0000ffff) << 16);\n return x >>> 0; // Ensure it's treated as an unsigned 32-bit integer\n };\n /** Returns the smallest index i such that val[i] === key, or -1 if no such index exists. */\n const binarySearchExact = (arr, key, valueGetter) => {\n let low = 0;\n let high = arr.length - 1;\n let ans = -1;\n while (low <= high) {\n const mid = (low + high) >> 1;\n const midVal = valueGetter(arr[mid]);\n if (midVal === key) {\n ans = mid;\n high = mid - 1; // Continue searching left to find the lowest index\n }\n else if (midVal < key) {\n low = mid + 1;\n }\n else {\n high = mid - 1;\n }\n }\n return ans;\n };\n /** Returns the largest index i such that val[i] <= key, or -1 if no such index exists. */\n const binarySearchLessOrEqual = (arr, key, valueGetter) => {\n let low = 0;\n let high = arr.length - 1;\n let ans = -1;\n while (low <= high) {\n const mid = (low + (high - low + 1) / 2) | 0;\n const midVal = valueGetter(arr[mid]);\n if (midVal <= key) {\n ans = mid;\n low = mid + 1;\n }\n else {\n high = mid - 1;\n }\n }\n return ans;\n };\n /** Assumes the array is already sorted. */\n const insertSorted = (arr, item, valueGetter) => {\n const insertionIndex = binarySearchLessOrEqual(arr, valueGetter(item), valueGetter);\n arr.splice(insertionIndex + 1, 0, item); // This even behaves correctly for the -1 case\n };\n const promiseWithResolvers = () => {\n let resolve;\n let reject;\n const promise = new Promise((res, rej) => {\n resolve = res;\n reject = rej;\n });\n return { promise, resolve: resolve, reject: reject };\n };\n const removeItem = (arr, item) => {\n const index = arr.indexOf(item);\n if (index !== -1) {\n arr.splice(index, 1);\n }\n };\n const findLast = (arr, predicate) => {\n for (let i = arr.length - 1; i >= 0; i--) {\n if (predicate(arr[i])) {\n return arr[i];\n }\n }\n return undefined;\n };\n const findLastIndex = (arr, predicate) => {\n for (let i = arr.length - 1; i >= 0; i--) {\n if (predicate(arr[i])) {\n return i;\n }\n }\n return -1;\n };\n const toAsyncIterator = async function* (source) {\n if (Symbol.iterator in source) {\n // @ts-expect-error Trust me\n yield* source[Symbol.iterator]();\n }\n else {\n // @ts-expect-error Trust me\n yield* source[Symbol.asyncIterator]();\n }\n };\n const validateAnyIterable = (iterable) => {\n if (!(Symbol.iterator in iterable) && !(Symbol.asyncIterator in iterable)) {\n throw new TypeError('Argument must be an iterable or async iterable.');\n }\n };\n const assertNever = (x) => {\n // eslint-disable-next-line @typescript-eslint/restrict-template-expressions\n throw new Error(`Unexpected value: ${x}`);\n };\n const getUint24 = (view, byteOffset, littleEndian) => {\n const byte1 = view.getUint8(byteOffset);\n const byte2 = view.getUint8(byteOffset + 1);\n const byte3 = view.getUint8(byteOffset + 2);\n {\n return (byte1 << 16) | (byte2 << 8) | byte3;\n }\n };\n const setUint24 = (view, byteOffset, value, littleEndian) => {\n // Ensure the value is within 24-bit unsigned range (0 to 16777215)\n value = value >>> 0; // Convert to unsigned 32-bit\n value = value & 0xFFFFFF; // Mask to 24 bits\n {\n view.setUint8(byteOffset, (value >>> 16) & 0xFF);\n view.setUint8(byteOffset + 1, (value >>> 8) & 0xFF);\n view.setUint8(byteOffset + 2, value & 0xFF);\n }\n };\n /**\n * Calls a function on each value spat out by an async generator. The reason for writing this manually instead of\n * using a generator function is that the generator function queues return() calls - here, we forward them immediately.\n */\n const mapAsyncGenerator = (generator, map) => {\n return {\n async next() {\n const result = await generator.next();\n if (result.done) {\n return { value: undefined, done: true };\n }\n else {\n return { value: map(result.value), done: false };\n }\n },\n return() {\n return generator.return();\n },\n throw(error) {\n return generator.throw(error);\n },\n [Symbol.asyncIterator]() {\n return this;\n },\n };\n };\n const clamp = (value, min, max) => {\n return Math.max(min, Math.min(max, value));\n };\n const UNDETERMINED_LANGUAGE = 'und';\n const roundIfAlmostInteger = (value) => {\n const rounded = Math.round(value);\n if (Math.abs(value / rounded - 1) < 10 * Number.EPSILON) {\n return rounded;\n }\n else {\n return value;\n }\n };\n const roundToMultiple = (value, multiple) => {\n return Math.round(value / multiple) * multiple;\n };\n const roundToDivisor = (value, multiple) => {\n return Math.round(value * multiple) / multiple;\n };\n const floorToMultiple = (value, multiple) => {\n return Math.floor(value / multiple) * multiple;\n };\n const ilog = (x) => {\n let ret = 0;\n while (x) {\n ret++;\n x >>= 1;\n }\n return ret;\n };\n const ISO_639_2_REGEX = /^[a-z]{3}$/;\n const isIso639Dash2LanguageCode = (x) => {\n return ISO_639_2_REGEX.test(x);\n };\n // Since the result will be truncated, add a bit of eps to compensate for floating point errors\n const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);\n /**\n * Merges two RequestInit objects with special handling for headers.\n * Headers are merged case-insensitively, but original casing is preserved.\n * init2 headers take precedence and will override case-insensitive matches from init1.\n */\n const mergeRequestInit = (init1, init2) => {\n const merged = { ...init1, ...init2 };\n // Special handling for headers\n if (init1.headers || init2.headers) {\n const headers1 = init1.headers ? normalizeHeaders(init1.headers) : {};\n const headers2 = init2.headers ? normalizeHeaders(init2.headers) : {};\n const mergedHeaders = { ...headers1 };\n // For each header in headers2, check if a case-insensitive match exists in mergedHeaders\n Object.entries(headers2).forEach(([key2, value2]) => {\n const existingKey = Object.keys(mergedHeaders).find(key1 => key1.toLowerCase() === key2.toLowerCase());\n if (existingKey) {\n delete mergedHeaders[existingKey];\n }\n mergedHeaders[key2] = value2;\n });\n merged.headers = mergedHeaders;\n }\n return merged;\n };\n /** Normalizes HeadersInit to a Record<string, string> format. */\n const normalizeHeaders = (headers) => {\n if (headers instanceof Headers) {\n const result = {};\n headers.forEach((value, key) => {\n result[key] = value;\n });\n return result;\n }\n if (Array.isArray(headers)) {\n const result = {};\n headers.forEach(([key, value]) => {\n result[key] = value;\n });\n return result;\n }\n return headers;\n };\n const retriedFetch = async (fetchFn, url, requestInit, getRetryDelay, shouldStop) => {\n let attempts = 0;\n while (true) {\n try {\n return await fetchFn(url, requestInit);\n }\n catch (error) {\n if (shouldStop()) {\n throw error;\n }\n attempts++;\n const retryDelayInSeconds = getRetryDelay(attempts, error, url);\n if (retryDelayInSeconds === null) {\n throw error;\n }\n Logging._error('Retrying failed fetch. Error:', error);\n if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) {\n throw new TypeError('Retry delay must be a non-negative finite number.');\n }\n if (retryDelayInSeconds > 0) {\n await wait(1000 * retryDelayInSeconds);\n }\n if (shouldStop()) {\n throw error;\n }\n }\n }\n };\n class CallSerializer {\n constructor() {\n this.currentPromise = Promise.resolve();\n }\n call(fn) {\n return this.currentPromise = this.currentPromise.then(fn);\n }\n }\n let isWebKitCache = null;\n const isWebKit = () => {\n if (isWebKitCache !== null) {\n return isWebKitCache;\n }\n // This even returns true for WebKit-wrapping browsers such as Chrome on iOS\n return isWebKitCache = !!(typeof navigator !== 'undefined'\n && (\n // eslint-disable-next-line @typescript-eslint/no-deprecated\n navigator.vendor?.match(/apple/i)\n // Or, in workers:\n || (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent))\n || /\\b(iPad|iPhone|iPod)\\b/.test(navigator.userAgent)));\n };\n let isFirefoxCache = null;\n const isFirefox = () => {\n if (isFirefoxCache !== null) {\n return isFirefoxCache;\n }\n return isFirefoxCache = typeof navigator !== 'undefined' && navigator.userAgent?.includes('Firefox');\n };\n let isChromiumCache = null;\n const isChromium = () => {\n if (isChromiumCache !== null) {\n return isChromiumCache;\n }\n return isChromiumCache = !!(typeof navigator !== 'undefined'\n // eslint-disable-next-line @typescript-eslint/no-deprecated\n && (navigator.vendor?.includes('Google Inc') || /Chrome/.test(navigator.userAgent)));\n };\n let chromiumVersionCache = null;\n const getChromiumVersion = () => {\n if (chromiumVersionCache !== null) {\n return chromiumVersionCache;\n }\n if (typeof navigator === 'undefined') {\n return null;\n }\n const match = /\\bChrome\\/(\\d+)/.exec(navigator.userAgent);\n if (!match) {\n return null;\n }\n return chromiumVersionCache = Number(match[1]);\n };\n /** Acts like `??` except the condition is -1 and not null/undefined. */\n const coalesceIndex = (a, b) => {\n return a !== -1 ? a : b;\n };\n const closedIntervalsOverlap = (startA, endA, startB, endB) => {\n return startA <= endB && startB <= endA;\n };\n const base64ToBytes = (base64) => {\n const decoded = atob(base64);\n const bytes = new Uint8Array(decoded.length);\n for (let i = 0; i < decoded.length; i++) {\n bytes[i] = decoded.charCodeAt(i);\n }\n return bytes;\n };\n const uint8ArraysAreEqual = (a, b) => {\n if (a.length !== b.length) {\n return false;\n }\n for (let i = 0; i < a.length; i++) {\n if (a[i] !== b[i]) {\n return false;\n }\n }\n return true;\n };\n const polyfillSymbolDispose = () => {\n // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html\n // @ts-expect-error Readonly\n Symbol.dispose ??= Symbol('Symbol.dispose');\n };\n const isNumber = (x) => {\n return typeof x === 'number' && !Number.isNaN(x);\n };\n const joinPaths = (basePath, relativePath) => {\n // If relativePath is a full URL with protocol, return it as-is\n if (relativePath.includes('://')) {\n return relativePath;\n }\n // Strip query parameters from URL base paths so their contents don't mess up the join\n if (basePath.includes('://')) {\n const queryIndex = basePath.indexOf('?');\n if (queryIndex !== -1) {\n basePath = basePath.slice(0, queryIndex);\n }\n }\n let result;\n if (relativePath.startsWith('/')) {\n const protocolIndex = basePath.indexOf('://');\n if (protocolIndex === -1) {\n result = relativePath;\n }\n else {\n const pathStart = basePath.indexOf('/', protocolIndex + 3);\n if (pathStart === -1) {\n result = basePath + relativePath;\n }\n else {\n result = basePath.slice(0, pathStart) + relativePath;\n }\n }\n }\n else {\n const lastSlash = basePath.lastIndexOf('/');\n if (lastSlash === -1) {\n result = relativePath;\n }\n else {\n result = basePath.slice(0, lastSlash + 1) + relativePath;\n }\n }\n // Normalize ./ and ../\n let prefix = '';\n const protocolIndex = result.indexOf('://');\n if (protocolIndex !== -1) {\n const pathStart = result.indexOf('/', protocolIndex + 3);\n if (pathStart !== -1) {\n prefix = result.slice(0, pathStart);\n result = result.slice(pathStart);\n }\n }\n const segments = result.split('/');\n const normalized = [];\n for (const segment of segments) {\n if (segment === '..') {\n normalized.pop();\n }\n else if (segment !== '.') {\n normalized.push(segment);\n }\n }\n return prefix + normalized.join('/');\n };\n const arrayCount = (array, predicate) => {\n let count = 0;\n for (let i = 0; i < array.length; i++) {\n if (predicate(array[i])) {\n count++;\n }\n }\n return count;\n };\n const arrayArgmin = (array, getValue) => {\n let minIndex = -1;\n let minValue = Infinity;\n for (let i = 0; i < array.length; i++) {\n const value = getValue(array[i]);\n if (value < minValue) {\n minValue = value;\n minIndex = i;\n }\n }\n return minIndex;\n };\n const simplifyRational = (rational) => {\n assert(Number.isInteger(rational.num));\n assert(Number.isInteger(rational.den));\n assert(rational.den !== 0);\n let a = Math.abs(rational.num);\n let b = Math.abs(rational.den);\n // Euclidean algorithm\n while (b !== 0) {\n const t = a % b;\n a = b;\n b = t;\n }\n const gcd = a || 1;\n return {\n num: rational.num / gcd,\n den: rational.den / gcd,\n };\n };\n const validateRectangle = (rect, propertyPath) => {\n if (typeof rect !== 'object' || !rect) {\n throw new TypeError(`${propertyPath} must be an object.`);\n }\n if (!Number.isInteger(rect.left) || rect.left < 0) {\n throw new TypeError(`${propertyPath}.left must be a non-negative integer.`);\n }\n if (!Number.isInteger(rect.top) || rect.top < 0) {\n throw new TypeError(`${propertyPath}.top must be a non-negative integer.`);\n }\n if (!Number.isInteger(rect.width) || rect.width < 0) {\n throw new TypeError(`${propertyPath}.width must be a non-negative integer.`);\n }\n if (!Number.isInteger(rect.height) || rect.height < 0) {\n throw new TypeError(`${propertyPath}.height must be a non-negative integer.`);\n }\n };\n const wait = (ms) => {\n return new Promise(resolve => setTimeout(resolve, ms));\n };\n /**\n * A class that manages event listeners and dispatches events to them.\n *\n * @group Miscellaneous\n * @public\n */\n class EventEmitter {\n constructor() {\n /** @internal */\n this._listeners = new Map();\n }\n /** Registers a listener for the given event. Returns a function that, when called, removes the listener again. */\n on(event, listener, options) {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n const entry = { fn: listener, once: options?.once ?? false };\n this._listeners.get(event).add(entry);\n return () => {\n this._listeners.get(event)?.delete(entry);\n };\n }\n /** @internal */\n _emit(...args) {\n const [event, data] = args;\n const listeners = this._listeners.get(event);\n if (!listeners) {\n return;\n }\n for (const entry of listeners) {\n try {\n entry.fn(data);\n }\n catch (error) {\n // Deliberately not routed through Logging here: Logging emits via an EventEmitter, so a throwing\n // log listener would recurse straight back into this handler.\n console.error(error);\n }\n if (entry.once) {\n listeners.delete(entry);\n }\n }\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /**\n * Controls how much information Mediabunny prints to the console. Higher levels include all lower levels.\n *\n * @group Logging\n * @public\n */\n var LogLevel;\n (function (LogLevel) {\n /** Nothing is printed to the console. */\n LogLevel[LogLevel[\"Silent\"] = 0] = \"Silent\";\n /** Only errors are printed. */\n LogLevel[LogLevel[\"Errors\"] = 1] = \"Errors\";\n /** Errors and warnings are printed. */\n LogLevel[LogLevel[\"Warnings\"] = 2] = \"Warnings\";\n /** Errors, warnings, and informational messages are printed. */\n LogLevel[LogLevel[\"Info\"] = 3] = \"Info\";\n })(LogLevel || (LogLevel = {}));\n /**\n * Mediabunny's central logging singleton. Use {@link Logging.level} to control how much is printed to the console,\n * and subscribe to log events using {@link Logging.on}.\n *\n * Having manual control over logging is useful for command-line applications where you want full say over the output.\n *\n * @group Logging\n * @public\n */\n class Logging {\n constructor() { }\n /** The current log level. Defaults to {@link LogLevel.Info}. */\n static get level() {\n return Logging._level;\n }\n static set level(value) {\n if (value !== LogLevel.Silent\n && value !== LogLevel.Errors\n && value !== LogLevel.Warnings\n && value !== LogLevel.Info) {\n throw new TypeError('Invalid log level. Use one of the values of the LogLevel enum.');\n }\n Logging._level = value;\n }\n /** @internal */\n static get _emitter() {\n // Created lazily to avoid touching the EventEmitter binding at module-eval time\n return Logging._emitterInstance ??= new EventEmitter();\n }\n /** Registers a listener for a log event. Returns a function that, when called, removes the listener again. */\n static on(event, listener, options) {\n return Logging._emitter.on(event, listener, options);\n }\n /** @internal */\n static _error(...args) {\n Logging._emitter._emit('error', args);\n if (Logging._level >= LogLevel.Errors) {\n console.error(...args);\n }\n }\n /** @internal */\n static _warn(...args) {\n Logging._emitter._emit('warn', args);\n if (Logging._level >= LogLevel.Warnings) {\n console.warn(...args);\n }\n }\n /** @internal */\n static _info(...args) {\n Logging._emitter._emit('info', args);\n if (Logging._level >= LogLevel.Info) {\n console.info(...args);\n }\n }\n }\n /** @internal */\n Logging._level = LogLevel.Info;\n /** @internal */\n Logging._emitterInstance = null;\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /**\n * Image data with additional metadata.\n *\n * @group Metadata tags\n * @public\n */\n class RichImageData {\n /** Creates a new {@link RichImageData}. */\n constructor(\n /** The raw image data. */\n data, \n /** An RFC 6838 MIME type (e.g. image/jpeg, image/png, etc.) */\n mimeType) {\n this.data = data;\n this.mimeType = mimeType;\n if (!(data instanceof Uint8Array)) {\n throw new TypeError('data must be a Uint8Array.');\n }\n if (typeof mimeType !== 'string') {\n throw new TypeError('mimeType must be a string.');\n }\n }\n }\n /**\n * A file attached to a media file.\n *\n * @group Metadata tags\n * @public\n */\n class AttachedFile {\n /** Creates a new {@link AttachedFile}. */\n constructor(\n /** The raw file data. */\n data, \n /** An RFC 6838 MIME type (e.g. image/jpeg, image/png, font/ttf, etc.) */\n mimeType, \n /** The name of the file. */\n name, \n /** A description of the file. */\n description) {\n this.data = data;\n this.mimeType = mimeType;\n this.name = name;\n this.description = description;\n if (!(data instanceof Uint8Array)) {\n throw new TypeError('data must be a Uint8Array.');\n }\n if (mimeType !== undefined && typeof mimeType !== 'string') {\n throw new TypeError('mimeType, when provided, must be a string.');\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError('name, when provided, must be a string.');\n }\n if (description !== undefined && typeof description !== 'string') {\n throw new TypeError('description, when provided, must be a string.');\n }\n }\n }\n const DEFAULT_TRACK_DISPOSITION = {\n default: true,\n primary: true,\n forced: false,\n original: false,\n commentary: false,\n hearingImpaired: false,\n visuallyImpaired: false,\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class Bitstream {\n constructor(bytes) {\n this.bytes = bytes;\n /** Current offset in bits. */\n this.pos = 0;\n }\n seekToByte(byteOffset) {\n this.pos = 8 * byteOffset;\n }\n readBit() {\n const byteIndex = Math.floor(this.pos / 8);\n const byte = this.bytes[byteIndex] ?? 0;\n const bitIndex = 0b111 - (this.pos & 0b111);\n const bit = (byte & (1 << bitIndex)) >> bitIndex;\n this.pos++;\n return bit;\n }\n readBits(n) {\n if (n === 1) {\n return this.readBit();\n }\n let result = 0;\n for (let i = 0; i < n; i++) {\n result <<= 1;\n result |= this.readBit();\n }\n return result;\n }\n writeBits(n, value) {\n const end = this.pos + n;\n for (let i = this.pos; i < end; i++) {\n const byteIndex = Math.floor(i / 8);\n let byte = this.bytes[byteIndex];\n const bitIndex = 0b111 - (i & 0b111);\n byte &= ~(1 << bitIndex);\n byte |= ((value & (1 << (end - i - 1))) >> (end - i - 1)) << bitIndex;\n this.bytes[byteIndex] = byte;\n }\n this.pos = end;\n }\n ;\n readAlignedByte() {\n if (this.pos % 8 !== 0) {\n throw new Error('Bitstream is not byte-aligned.');\n }\n const byteIndex = this.pos / 8;\n const byte = this.bytes[byteIndex] ?? 0;\n this.pos += 8;\n return byte;\n }\n skipBits(n) {\n this.pos += n;\n }\n getBitsLeft() {\n return this.bytes.length * 8 - this.pos;\n }\n clone() {\n const clone = new Bitstream(this.bytes);\n clone.pos = this.pos;\n return clone;\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const aacFrequencyTable = [\n 96000, 88200, 64000, 48000, 44100, 32000,\n 24000, 22050, 16000, 12000, 11025, 8000, 7350,\n ];\n const aacChannelMap = [-1, 1, 2, 3, 4, 5, 6, 8];\n const parseAacAudioSpecificConfig = (bytes) => {\n if (!bytes || bytes.byteLength < 2) {\n throw new TypeError('AAC description must be at least 2 bytes long.');\n }\n const bitstream = new Bitstream(bytes);\n let objectType = bitstream.readBits(5);\n if (objectType === 31) {\n objectType = 32 + bitstream.readBits(6);\n }\n const frequencyIndex = bitstream.readBits(4);\n let sampleRate = null;\n if (frequencyIndex === 15) {\n sampleRate = bitstream.readBits(24);\n }\n else {\n if (frequencyIndex < aacFrequencyTable.length) {\n sampleRate = aacFrequencyTable[frequencyIndex];\n }\n }\n const channelConfiguration = bitstream.readBits(4);\n let numberOfChannels = null;\n if (channelConfiguration >= 1 && channelConfiguration <= 7) {\n numberOfChannels = aacChannelMap[channelConfiguration];\n }\n return {\n objectType,\n frequencyIndex,\n sampleRate,\n channelConfiguration,\n numberOfChannels,\n };\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /**\n * List of known video codecs, ordered by encoding preference.\n * @group Codecs\n * @public\n */\n const VIDEO_CODECS = [\n 'avc',\n 'hevc',\n 'vp9',\n 'av1',\n 'vp8',\n 'prores',\n ];\n /**\n * List of known PCM (uncompressed) audio codecs, ordered by encoding preference.\n * @group Codecs\n * @public\n */\n const PCM_AUDIO_CODECS = [\n 'pcm-s16', // We don't prefix 'le' so we're compatible with the WebCodecs-registered PCM codec strings\n 'pcm-s16be',\n 'pcm-s24',\n 'pcm-s24be',\n 'pcm-s32',\n 'pcm-s32be',\n 'pcm-f32',\n 'pcm-f32be',\n 'pcm-f64',\n 'pcm-f64be',\n 'pcm-u8',\n 'pcm-s8',\n 'ulaw',\n 'alaw',\n ];\n /**\n * List of known compressed audio codecs, ordered by encoding preference.\n * @group Codecs\n * @public\n */\n const NON_PCM_AUDIO_CODECS = [\n 'aac',\n 'opus',\n 'mp3',\n 'vorbis',\n 'flac',\n 'ac3',\n 'eac3',\n ];\n /**\n * List of known audio codecs, ordered by encoding preference.\n * @group Codecs\n * @public\n */\n const AUDIO_CODECS = [\n ...NON_PCM_AUDIO_CODECS,\n ...PCM_AUDIO_CODECS,\n ];\n // https://en.wikipedia.org/wiki/Advanced_Video_Coding\n const AVC_LEVEL_TABLE = [\n { maxMacroblocks: 99, maxBitrate: 64000, maxDpbMbs: 396, level: 0x0A }, // Level 1\n { maxMacroblocks: 396, maxBitrate: 192000, maxDpbMbs: 900, level: 0x0B }, // Level 1.1\n { maxMacroblocks: 396, maxBitrate: 384000, maxDpbMbs: 2376, level: 0x0C }, // Level 1.2\n { maxMacroblocks: 396, maxBitrate: 768000, maxDpbMbs: 2376, level: 0x0D }, // Level 1.3\n { maxMacroblocks: 396, maxBitrate: 2000000, maxDpbMbs: 2376, level: 0x14 }, // Level 2\n { maxMacroblocks: 792, maxBitrate: 4000000, maxDpbMbs: 4752, level: 0x15 }, // Level 2.1\n { maxMacroblocks: 1620, maxBitrate: 4000000, maxDpbMbs: 8100, level: 0x16 }, // Level 2.2\n { maxMacroblocks: 1620, maxBitrate: 10000000, maxDpbMbs: 8100, level: 0x1E }, // Level 3\n { maxMacroblocks: 3600, maxBitrate: 14000000, maxDpbMbs: 18000, level: 0x1F }, // Level 3.1\n { maxMacroblocks: 5120, maxBitrate: 20000000, maxDpbMbs: 20480, level: 0x20 }, // Level 3.2\n { maxMacroblocks: 8192, maxBitrate: 20000000, maxDpbMbs: 32768, level: 0x28 }, // Level 4\n { maxMacroblocks: 8192, maxBitrate: 50000000, maxDpbMbs: 32768, level: 0x29 }, // Level 4.1\n { maxMacroblocks: 8704, maxBitrate: 50000000, maxDpbMbs: 34816, level: 0x2A }, // Level 4.2\n { maxMacroblocks: 22080, maxBitrate: 135000000, maxDpbMbs: 110400, level: 0x32 }, // Level 5\n { maxMacroblocks: 36864, maxBitrate: 240000000, maxDpbMbs: 184320, level: 0x33 }, // Level 5.1\n { maxMacroblocks: 36864, maxBitrate: 240000000, maxDpbMbs: 184320, level: 0x34 }, // Level 5.2\n { maxMacroblocks: 139264, maxBitrate: 240000000, maxDpbMbs: 696320, level: 0x3C }, // Level 6\n { maxMacroblocks: 139264, maxBitrate: 480000000, maxDpbMbs: 696320, level: 0x3D }, // Level 6.1\n { maxMacroblocks: 139264, maxBitrate: 800000000, maxDpbMbs: 696320, level: 0x3E }, // Level 6.2\n ];\n // https://en.wikipedia.org/wiki/VP9\n const VP9_LEVEL_TABLE = [\n { maxPictureSize: 36864, maxBitrate: 200000, level: 10 }, // Level 1\n { maxPictureSize: 73728, maxBitrate: 800000, level: 11 }, // Level 1.1\n { maxPictureSize: 122880, maxBitrate: 1800000, level: 20 }, // Level 2\n { maxPictureSize: 245760, maxBitrate: 3600000, level: 21 }, // Level 2.1\n { maxPictureSize: 552960, maxBitrate: 7200000, level: 30 }, // Level 3\n { maxPictureSize: 983040, maxBitrate: 12000000, level: 31 }, // Level 3.1\n { maxPictureSize: 2228224, maxBitrate: 18000000, level: 40 }, // Level 4\n { maxPictureSize: 2228224, maxBitrate: 30000000, level: 41 }, // Level 4.1\n { maxPictureSize: 8912896, maxBitrate: 60000000, level: 50 }, // Level 5\n { maxPictureSize: 8912896, maxBitrate: 120000000, level: 51 }, // Level 5.1\n { maxPictureSize: 8912896, maxBitrate: 180000000, level: 52 }, // Level 5.2\n { maxPictureSize: 35651584, maxBitrate: 180000000, level: 60 }, // Level 6\n { maxPictureSize: 35651584, maxBitrate: 240000000, level: 61 }, // Level 6.1\n { maxPictureSize: 35651584, maxBitrate: 480000000, level: 62 }, // Level 6.2\n ];\n const VP9_DEFAULT_SUFFIX = '.01.01.01.01.00';\n const AV1_DEFAULT_SUFFIX = '.0.110.01.01.01.0';\n const PRORES_FOURCCS = [\n 'ap4x', // ProRes 4444 XQ\n 'ap4h', // ProRes 4444\n 'apch', // ProRes 422 High Quality\n 'apcn', // ProRes 422 Standard Definition\n 'apcs', // ProRes 422 LT\n 'apco', // ProRes 422 Proxy\n ];\n const extractVideoCodecString = (trackInfo) => {\n const { codec, codecDescription, colorSpace, avcCodecInfo, hevcCodecInfo, vp9CodecInfo, av1CodecInfo, proresFormat, } = trackInfo;\n if (codec === 'avc') {\n assert(trackInfo.avcType !== null);\n if (avcCodecInfo) {\n const bytes = new Uint8Array([\n avcCodecInfo.avcProfileIndication,\n avcCodecInfo.profileCompatibility,\n avcCodecInfo.avcLevelIndication,\n ]);\n return `avc${trackInfo.avcType}.${bytesToHexString(bytes)}`;\n }\n if (!codecDescription || codecDescription.byteLength < 4) {\n throw new TypeError('AVC decoder description is not provided or is not at least 4 bytes long.');\n }\n return `avc${trackInfo.avcType}.${bytesToHexString(codecDescription.subarray(1, 4))}`;\n }\n else if (codec === 'hevc') {\n let generalProfileSpace;\n let generalProfileIdc;\n let compatibilityFlags;\n let generalTierFlag;\n let generalLevelIdc;\n let constraintFlags;\n if (hevcCodecInfo) {\n generalProfileSpace = hevcCodecInfo.generalProfileSpace;\n generalProfileIdc = hevcCodecInfo.generalProfileIdc;\n compatibilityFlags = reverseBitsU32(hevcCodecInfo.generalProfileCompatibilityFlags);\n generalTierFlag = hevcCodecInfo.generalTierFlag;\n generalLevelIdc = hevcCodecInfo.generalLevelIdc;\n constraintFlags = [...hevcCodecInfo.generalConstraintIndicatorFlags];\n }\n else {\n if (!codecDescription || codecDescription.byteLength < 23) {\n throw new TypeError('HEVC decoder description is not provided or is not at least 23 bytes long.');\n }\n const view = toDataView(codecDescription);\n const profileByte = view.getUint8(1);\n generalProfileSpace = (profileByte >> 6) & 0x03;\n generalProfileIdc = profileByte & 0x1F;\n compatibilityFlags = reverseBitsU32(view.getUint32(2));\n generalTierFlag = (profileByte >> 5) & 0x01;\n generalLevelIdc = view.getUint8(12);\n constraintFlags = [];\n for (let i = 0; i < 6; i++) {\n constraintFlags.push(view.getUint8(6 + i));\n }\n }\n let codecString = 'hev1.';\n codecString += ['', 'A', 'B', 'C'][generalProfileSpace] + generalProfileIdc;\n codecString += '.';\n codecString += compatibilityFlags.toString(16).toUpperCase();\n codecString += '.';\n codecString += generalTierFlag === 0 ? 'L' : 'H';\n codecString += generalLevelIdc;\n while (constraintFlags.length > 0 && constraintFlags[constraintFlags.length - 1] === 0) {\n constraintFlags.pop();\n }\n if (constraintFlags.length > 0) {\n codecString += '.';\n codecString += constraintFlags.map(x => x.toString(16).toUpperCase()).join('.');\n }\n return codecString;\n }\n else if (codec === 'vp8') {\n return 'vp8'; // Easy, this one\n }\n else if (codec === 'vp9') {\n if (!vp9CodecInfo) {\n // Calculate level based on dimensions\n const pictureSize = trackInfo.width * trackInfo.height;\n let level = last(VP9_LEVEL_TABLE).level; // Default to highest level\n for (const entry of VP9_LEVEL_TABLE) {\n if (pictureSize <= entry.maxPictureSize) {\n level = entry.level;\n break;\n }\n }\n // We don't really know better, so let's return a general-purpose, common codec string and hope for the best\n return `vp09.00.${level.toString().padStart(2, '0')}.08`;\n }\n const profile = vp9CodecInfo.profile.toString().padStart(2, '0');\n const level = vp9CodecInfo.level.toString().padStart(2, '0');\n const bitDepth = vp9CodecInfo.bitDepth.toString().padStart(2, '0');\n const chromaSubsampling = vp9CodecInfo.chromaSubsampling.toString().padStart(2, '0');\n const colourPrimaries = vp9CodecInfo.colourPrimaries.toString().padStart(2, '0');\n const transferCharacteristics = vp9CodecInfo.transferCharacteristics.toString().padStart(2, '0');\n const matrixCoefficients = vp9CodecInfo.matrixCoefficients.toString().padStart(2, '0');\n const videoFullRangeFlag = vp9CodecInfo.videoFullRangeFlag.toString().padStart(2, '0');\n let string = `vp09.${profile}.${level}.${bitDepth}.${chromaSubsampling}`;\n string += `.${colourPrimaries}.${transferCharacteristics}.${matrixCoefficients}.${videoFullRangeFlag}`;\n if (string.endsWith(VP9_DEFAULT_SUFFIX)) {\n string = string.slice(0, -VP9_DEFAULT_SUFFIX.length);\n }\n return string;\n }\n else if (codec === 'av1') {\n if (!av1CodecInfo) {\n // Calculate level based on dimensions\n const pictureSize = trackInfo.width * trackInfo.height;\n let level = last(VP9_LEVEL_TABLE).level; // Default to highest level\n for (const entry of VP9_LEVEL_TABLE) {\n if (pictureSize <= entry.maxPictureSize) {\n level = entry.level;\n break;\n }\n }\n // We don't really know better, so let's return a general-purpose, common codec string and hope for the best\n return `av01.0.${level.toString().padStart(2, '0')}M.08`;\n }\n // https://aomediacodec.github.io/av1-isobmff/#codecsparam\n const profile = av1CodecInfo.profile; // Single digit\n const level = av1CodecInfo.level.toString().padStart(2, '0');\n const tier = av1CodecInfo.tier ? 'H' : 'M';\n const bitDepth = av1CodecInfo.bitDepth.toString().padStart(2, '0');\n const monochrome = av1CodecInfo.monochrome ? '1' : '0';\n const chromaSubsampling = 100 * av1CodecInfo.chromaSubsamplingX\n + 10 * av1CodecInfo.chromaSubsamplingY\n + 1 * (av1CodecInfo.chromaSubsamplingX && av1CodecInfo.chromaSubsamplingY\n ? av1CodecInfo.chromaSamplePosition\n : 0);\n // The defaults are 1 (ITU-R BT.709)\n const colorPrimaries = colorSpace?.primaries ? COLOR_PRIMARIES_MAP[colorSpace.primaries] : 1;\n const transferCharacteristics = colorSpace?.transfer ? TRANSFER_CHARACTERISTICS_MAP[colorSpace.transfer] : 1;\n const matrixCoefficients = colorSpace?.matrix ? MATRIX_COEFFICIENTS_MAP[colorSpace.matrix] : 1;\n const videoFullRangeFlag = colorSpace?.fullRange ? 1 : 0;\n let string = `av01.${profile}.${level}${tier}.${bitDepth}`;\n string += `.${monochrome}.${chromaSubsampling.toString().padStart(3, '0')}`;\n string += `.${colorPrimaries.toString().padStart(2, '0')}`;\n string += `.${transferCharacteristics.toString().padStart(2, '0')}`;\n string += `.${matrixCoefficients.toString().padStart(2, '0')}`;\n string += `.${videoFullRangeFlag}`;\n if (string.endsWith(AV1_DEFAULT_SUFFIX)) {\n string = string.slice(0, -AV1_DEFAULT_SUFFIX.length);\n }\n return string;\n }\n else if (codec === 'prores') {\n return proresFormat ?? 'apch';\n }\n else if (codec !== null) {\n assertNever(codec);\n }\n throw new TypeError(`Unhandled codec '${codec}'.`);\n };\n const extractAudioCodecString = (trackInfo) => {\n const { codec, codecDescription, aacCodecInfo } = trackInfo;\n if (codec === 'aac') {\n if (!aacCodecInfo) {\n throw new TypeError('AAC codec info must be provided.');\n }\n if (aacCodecInfo.isMpeg2) {\n return 'mp4a.67';\n }\n else {\n let objectType;\n if (aacCodecInfo.objectType !== null) {\n objectType = aacCodecInfo.objectType;\n }\n else {\n const audioSpecificConfig = parseAacAudioSpecificConfig(codecDescription);\n objectType = audioSpecificConfig.objectType;\n }\n return `mp4a.40.${objectType}`;\n }\n }\n else if (codec === 'mp3') {\n return 'mp3';\n }\n else if (codec === 'opus') {\n return 'opus';\n }\n else if (codec === 'vorbis') {\n return 'vorbis';\n }\n else if (codec === 'flac') {\n return 'flac';\n }\n else if (codec === 'ac3') {\n return 'ac-3';\n }\n else if (codec === 'eac3') {\n return 'ec-3';\n }\n else if (codec && PCM_AUDIO_CODECS.includes(codec)) {\n return codec;\n }\n throw new TypeError(`Unhandled codec '${codec}'.`);\n };\n const OPUS_SAMPLE_RATE = 48_000;\n const PCM_CODEC_REGEX = /^pcm-([usf])(\\d+)(be)?$/;\n const parsePcmCodec = (codec) => {\n assert(PCM_AUDIO_CODECS.includes(codec));\n if (codec === 'ulaw') {\n return { dataType: 'ulaw', sampleSize: 1, littleEndian: true, silentValue: 255 };\n }\n else if (codec === 'alaw') {\n return { dataType: 'alaw', sampleSize: 1, littleEndian: true, silentValue: 213 };\n }\n const match = PCM_CODEC_REGEX.exec(codec);\n assert(match);\n let dataType;\n if (match[1] === 'u') {\n dataType = 'unsigned';\n }\n else if (match[1] === 's') {\n dataType = 'signed';\n }\n else {\n dataType = 'float';\n }\n const sampleSize = (Number(match[2]) / 8);\n const littleEndian = match[3] !== 'be';\n const silentValue = codec === 'pcm-u8' ? 2 ** 7 : 0;\n return { dataType, sampleSize, littleEndian, silentValue };\n };\n const inferCodecFromCodecString = (codecString) => {\n // Video codecs\n if (codecString.startsWith('avc1') || codecString.startsWith('avc3')) {\n return 'avc';\n }\n else if (codecString.startsWith('hev1') || codecString.startsWith('hvc1')) {\n return 'hevc';\n }\n else if (codecString === 'vp8') {\n return 'vp8';\n }\n else if (codecString.startsWith('vp09')) {\n return 'vp9';\n }\n else if (codecString.startsWith('av01')) {\n return 'av1';\n }\n else if (PRORES_FOURCCS.includes(codecString)) {\n return 'prores';\n }\n // Audio codecs\n if (codecString === 'mp3'\n || codecString === 'mp4a.69'\n || codecString === 'mp4a.6B'\n || codecString === 'mp4a.6b'\n || codecString === 'mp4a.40.34') {\n return 'mp3';\n }\n else if (codecString.startsWith('mp4a.40.') || codecString === 'mp4a.67') {\n return 'aac';\n }\n else if (codecString === 'opus') {\n return 'opus';\n }\n else if (codecString === 'vorbis') {\n return 'vorbis';\n }\n else if (codecString === 'flac') {\n return 'flac';\n }\n else if (codecString === 'ac-3' || codecString === 'ac3') {\n return 'ac3';\n }\n else if (codecString === 'ec-3' || codecString === 'eac3') {\n return 'eac3';\n }\n else if (codecString === 'ulaw') {\n return 'ulaw';\n }\n else if (codecString === 'alaw') {\n return 'alaw';\n }\n else if (PCM_CODEC_REGEX.test(codecString)) {\n return codecString;\n }\n // Subtitle codecs\n if (codecString === 'webvtt') {\n return 'webvtt';\n }\n return null;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const MP3_FRAME_HEADER_SIZE = 4;\n const SAMPLING_RATES = [44100, 48000, 32000];\n const KILOBIT_RATES = [\n // lowSamplingFrequency === 0\n -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // layer = 0\n -1, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1, // layer 1\n -1, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1, // layer = 2\n -1, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, -1, // layer = 3\n // lowSamplingFrequency === 1\n -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // layer = 0\n -1, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1, // layer = 1\n -1, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, -1, // layer = 2\n -1, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, -1, // layer = 3\n ];\n /** 'Xing' */\n const XING = 0x58696e67;\n /** 'Info' */\n const INFO = 0x496e666f;\n const computeMp3FrameSize = (lowSamplingFrequency, layer, bitrate, sampleRate, padding) => {\n if (layer === 0) {\n return 0; // Not expected that this is hit\n }\n else if (layer === 1) {\n return Math.floor(144 * bitrate / (sampleRate << lowSamplingFrequency)) + padding;\n }\n else if (layer === 2) {\n return Math.floor(144 * bitrate / sampleRate) + padding;\n }\n else { // layer === 3\n return (Math.floor(12 * bitrate / sampleRate) + padding) * 4;\n }\n };\n const computeAverageMp3FrameSize = (lowSamplingFrequency, layer, bitrate, sampleRate) => {\n if (layer === 0) {\n return 0; // Not expected that this is hit\n }\n else if (layer === 1) {\n return 144 * bitrate / (sampleRate << lowSamplingFrequency);\n }\n else if (layer === 2) {\n return 144 * bitrate / sampleRate;\n }\n else { // layer === 3\n return (12 * bitrate / sampleRate) * 4;\n }\n };\n const getXingOffset = (mpegVersionId, channel) => {\n return mpegVersionId === 3\n ? (channel === 3 ? 21 : 36)\n : (channel === 3 ? 13 : 21);\n };\n const readMp3FrameHeader = (word, remainingBytes) => {\n const firstByte = word >>> 24;\n const secondByte = (word >>> 16) & 0xff;\n const thirdByte = (word >>> 8) & 0xff;\n const fourthByte = word & 0xff;\n if (firstByte !== 0xff && secondByte !== 0xff && thirdByte !== 0xff && fourthByte !== 0xff) {\n return {\n header: null,\n bytesAdvanced: 4,\n };\n }\n if (firstByte !== 0xff) {\n return { header: null, bytesAdvanced: 1 };\n }\n if ((secondByte & 0xe0) !== 0xe0) {\n return { header: null, bytesAdvanced: 1 };\n }\n let lowSamplingFrequency = 0;\n let mpeg25 = 0;\n if (secondByte & (1 << 4)) {\n lowSamplingFrequency = (secondByte & (1 << 3)) ? 0 : 1;\n }\n else {\n lowSamplingFrequency = 1;\n mpeg25 = 1;\n }\n const mpegVersionId = (secondByte >> 3) & 0x3;\n const layer = (secondByte >> 1) & 0x3;\n const bitrateIndex = (thirdByte >> 4) & 0xf;\n const frequencyIndex = ((thirdByte >> 2) & 0x3) % 3; // FFmpeg effectively does % 3 (but in a roundabout way)\n const padding = (thirdByte >> 1) & 0x1;\n const channel = (fourthByte >> 6) & 0x3;\n const modeExtension = (fourthByte >> 4) & 0x3;\n const copyright = (fourthByte >> 3) & 0x1;\n const original = (fourthByte >> 2) & 0x1;\n const emphasis = fourthByte & 0x3;\n const kilobitRate = KILOBIT_RATES[lowSamplingFrequency * 16 * 4 + layer * 16 + bitrateIndex];\n if (kilobitRate === -1) {\n return { header: null, bytesAdvanced: 1 };\n }\n const bitrate = kilobitRate * 1000;\n const sampleRate = SAMPLING_RATES[frequencyIndex] >> (lowSamplingFrequency + mpeg25);\n const frameLength = computeMp3FrameSize(lowSamplingFrequency, layer, bitrate, sampleRate, padding);\n if (remainingBytes !== null && remainingBytes < frameLength) {\n // The frame doesn't fit into the rest of the file\n return { header: null, bytesAdvanced: 1 };\n }\n let audioSamplesInFrame;\n if (mpegVersionId === 3) {\n audioSamplesInFrame = layer === 3 ? 384 : 1152;\n }\n else {\n if (layer === 3) {\n audioSamplesInFrame = 384;\n }\n else if (layer === 2) {\n audioSamplesInFrame = 1152;\n }\n else {\n audioSamplesInFrame = 576;\n }\n }\n return {\n header: {\n totalSize: frameLength,\n mpegVersionId,\n lowSamplingFrequency,\n layer,\n bitrate,\n frequencyIndex,\n sampleRate,\n channel,\n modeExtension,\n copyright,\n original,\n emphasis,\n audioSamplesInFrame,\n },\n bytesAdvanced: 1,\n };\n };\n const decodeSynchsafe = (synchsafed) => {\n let mask = 0x7f000000;\n let unsynchsafed = 0;\n while (mask !== 0) {\n unsynchsafed >>= 1;\n unsynchsafed |= synchsafed & mask;\n mask >>= 8;\n }\n return unsynchsafed;\n };\n var XingFlags;\n (function (XingFlags) {\n XingFlags[XingFlags[\"FrameCount\"] = 1] = \"FrameCount\";\n XingFlags[XingFlags[\"FileSize\"] = 2] = \"FileSize\";\n XingFlags[XingFlags[\"Toc\"] = 4] = \"Toc\";\n })(XingFlags || (XingFlags = {}));\n const getMp3ChannelCount = (channel) => {\n return channel === 3 ? 1 : 2;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /** Sample rates indexed by fscod (Table 4.1) */\n const AC3_SAMPLE_RATES = [48000, 44100, 32000];\n /** E-AC-3 reduced sample rates for fscod2 per ATSC A/52:2018 */\n const EAC3_REDUCED_SAMPLE_RATES = [24000, 22050, 16000];\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n // References for AVC/HEVC code:\n // ISO 14496-15\n // Rec. ITU-T H.264\n // Rec. ITU-T H.265\n // https://stackoverflow.com/questions/24884827\n var AvcNalUnitType;\n (function (AvcNalUnitType) {\n AvcNalUnitType[AvcNalUnitType[\"NON_IDR_SLICE\"] = 1] = \"NON_IDR_SLICE\";\n AvcNalUnitType[AvcNalUnitType[\"SLICE_DPA\"] = 2] = \"SLICE_DPA\";\n AvcNalUnitType[AvcNalUnitType[\"SLICE_DPB\"] = 3] = \"SLICE_DPB\";\n AvcNalUnitType[AvcNalUnitType[\"SLICE_DPC\"] = 4] = \"SLICE_DPC\";\n AvcNalUnitType[AvcNalUnitType[\"IDR\"] = 5] = \"IDR\";\n AvcNalUnitType[AvcNalUnitType[\"SEI\"] = 6] = \"SEI\";\n AvcNalUnitType[AvcNalUnitType[\"SPS\"] = 7] = \"SPS\";\n AvcNalUnitType[AvcNalUnitType[\"PPS\"] = 8] = \"PPS\";\n AvcNalUnitType[AvcNalUnitType[\"AUD\"] = 9] = \"AUD\";\n AvcNalUnitType[AvcNalUnitType[\"SPS_EXT\"] = 13] = \"SPS_EXT\";\n })(AvcNalUnitType || (AvcNalUnitType = {}));\n var HevcNalUnitType;\n (function (HevcNalUnitType) {\n HevcNalUnitType[HevcNalUnitType[\"RASL_N\"] = 8] = \"RASL_N\";\n HevcNalUnitType[HevcNalUnitType[\"RASL_R\"] = 9] = \"RASL_R\";\n HevcNalUnitType[HevcNalUnitType[\"BLA_W_LP\"] = 16] = \"BLA_W_LP\";\n HevcNalUnitType[HevcNalUnitType[\"RSV_IRAP_VCL23\"] = 23] = \"RSV_IRAP_VCL23\";\n HevcNalUnitType[HevcNalUnitType[\"VPS_NUT\"] = 32] = \"VPS_NUT\";\n HevcNalUnitType[HevcNalUnitType[\"SPS_NUT\"] = 33] = \"SPS_NUT\";\n HevcNalUnitType[HevcNalUnitType[\"PPS_NUT\"] = 34] = \"PPS_NUT\";\n HevcNalUnitType[HevcNalUnitType[\"AUD_NUT\"] = 35] = \"AUD_NUT\";\n HevcNalUnitType[HevcNalUnitType[\"PREFIX_SEI_NUT\"] = 39] = \"PREFIX_SEI_NUT\";\n HevcNalUnitType[HevcNalUnitType[\"SUFFIX_SEI_NUT\"] = 40] = \"SUFFIX_SEI_NUT\";\n })(HevcNalUnitType || (HevcNalUnitType = {}));\n const iterateNalUnitsInAnnexB = function* (packetData) {\n let i = 0;\n let nalStart = -1;\n while (i < packetData.length - 2) {\n const zeroIndex = packetData.indexOf(0, i);\n if (zeroIndex === -1 || zeroIndex >= packetData.length - 2) {\n break;\n }\n i = zeroIndex;\n let startCodeLength = 0;\n // Check for 4-byte start code (0x00000001)\n if (i + 3 < packetData.length\n && packetData[i + 1] === 0\n && packetData[i + 2] === 0\n && packetData[i + 3] === 1) {\n startCodeLength = 4;\n }\n else if (packetData[i + 1] === 0 && packetData[i + 2] === 1) {\n // Check for 3-byte start code (0x000001)\n startCodeLength = 3;\n }\n if (startCodeLength === 0) {\n i++;\n continue;\n }\n // If we had a previous NAL unit, yield it\n if (nalStart !== -1 && i > nalStart) {\n yield {\n offset: nalStart,\n length: i - nalStart,\n };\n }\n nalStart = i + startCodeLength;\n i = nalStart;\n }\n // Yield the last NAL unit if there is one\n if (nalStart !== -1 && nalStart < packetData.length) {\n yield {\n offset: nalStart,\n length: packetData.length - nalStart,\n };\n }\n };\n const iterateNalUnitsInLengthPrefixed = function* (packetData, lengthSize) {\n let offset = 0;\n const dataView = new DataView(packetData.buffer, packetData.byteOffset, packetData.byteLength);\n while (offset + lengthSize <= packetData.length) {\n let nalUnitLength;\n if (lengthSize === 1) {\n nalUnitLength = dataView.getUint8(offset);\n }\n else if (lengthSize === 2) {\n nalUnitLength = dataView.getUint16(offset, false);\n }\n else if (lengthSize === 3) {\n nalUnitLength = getUint24(dataView, offset);\n }\n else {\n assert(lengthSize === 4);\n nalUnitLength = dataView.getUint32(offset, false);\n }\n offset += lengthSize;\n yield {\n offset,\n length: nalUnitLength,\n };\n offset += nalUnitLength;\n }\n };\n const iterateAvcNalUnits = (packetData, decoderConfig) => {\n if (decoderConfig.description) {\n const bytes = toUint8Array(decoderConfig.description);\n const lengthSizeMinusOne = bytes[4] & 0b11;\n const lengthSize = (lengthSizeMinusOne + 1);\n return iterateNalUnitsInLengthPrefixed(packetData, lengthSize);\n }\n else {\n return iterateNalUnitsInAnnexB(packetData);\n }\n };\n const extractNalUnitTypeForAvc = (byte) => {\n return byte & 0x1F;\n };\n const removeEmulationPreventionBytes = (data) => {\n const result = [];\n const len = data.length;\n for (let i = 0; i < len; i++) {\n // Look for the 0x000003 pattern\n if (i + 2 < len && data[i] === 0x00 && data[i + 1] === 0x00 && data[i + 2] === 0x03) {\n result.push(0x00, 0x00); // Push the first two bytes\n i += 2; // Skip the 0x03 byte\n }\n else {\n result.push(data[i]);\n }\n }\n return new Uint8Array(result);\n };\n const ANNEX_B_START_CODE = new Uint8Array([0, 0, 0, 1]);\n const concatNalUnitsInAnnexB = (nalUnits) => {\n const totalLength = nalUnits.reduce((a, b) => a + ANNEX_B_START_CODE.byteLength + b.byteLength, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const nalUnit of nalUnits) {\n result.set(ANNEX_B_START_CODE, offset);\n offset += ANNEX_B_START_CODE.byteLength;\n result.set(nalUnit, offset);\n offset += nalUnit.byteLength;\n }\n return result;\n };\n const concatNalUnitsInLengthPrefixed = (nalUnits, lengthSize) => {\n const totalLength = nalUnits.reduce((a, b) => a + lengthSize + b.byteLength, 0);\n const result = new Uint8Array(totalLength);\n let offset = 0;\n for (const nalUnit of nalUnits) {\n const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength);\n switch (lengthSize) {\n case 1:\n dataView.setUint8(offset, nalUnit.byteLength);\n break;\n case 2:\n dataView.setUint16(offset, nalUnit.byteLength, false);\n break;\n case 3:\n setUint24(dataView, offset, nalUnit.byteLength);\n break;\n case 4:\n dataView.setUint32(offset, nalUnit.byteLength, false);\n break;\n }\n offset += lengthSize;\n result.set(nalUnit, offset);\n offset += nalUnit.byteLength;\n }\n return result;\n };\n const concatAvcNalUnits = (nalUnits, decoderConfig) => {\n if (decoderConfig.description) {\n // Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config\n const bytes = toUint8Array(decoderConfig.description);\n const lengthSizeMinusOne = bytes[4] & 0b11;\n const lengthSize = (lengthSizeMinusOne + 1);\n return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);\n }\n else {\n // Stream is in Annex B format\n return concatNalUnitsInAnnexB(nalUnits);\n }\n };\n /** Builds an AvcDecoderConfigurationRecord from an AVC packet in Annex B format. */\n const extractAvcDecoderConfigurationRecord = (packetData) => {\n try {\n const spsUnits = [];\n const ppsUnits = [];\n const spsExtUnits = [];\n for (const loc of iterateNalUnitsInAnnexB(packetData)) {\n const nalUnit = packetData.subarray(loc.offset, loc.offset + loc.length);\n const type = extractNalUnitTypeForAvc(nalUnit[0]);\n if (type === AvcNalUnitType.SPS) {\n spsUnits.push(nalUnit);\n }\n else if (type === AvcNalUnitType.PPS) {\n ppsUnits.push(nalUnit);\n }\n else if (type === AvcNalUnitType.SPS_EXT) {\n spsExtUnits.push(nalUnit);\n }\n }\n if (spsUnits.length === 0) {\n return null;\n }\n if (ppsUnits.length === 0) {\n return null;\n }\n // Let's get the first SPS for profile and level information\n const spsData = spsUnits[0];\n const spsInfo = parseAvcSps(spsData);\n assert(spsInfo !== null);\n const hasExtendedData = spsInfo.profileIdc === 100\n || spsInfo.profileIdc === 110\n || spsInfo.profileIdc === 122\n || spsInfo.profileIdc === 144;\n return {\n configurationVersion: 1,\n avcProfileIndication: spsInfo.profileIdc,\n profileCompatibility: spsInfo.constraintFlags,\n avcLevelIndication: spsInfo.levelIdc,\n lengthSizeMinusOne: 3, // Typically 4 bytes for length field\n sequenceParameterSets: spsUnits,\n pictureParameterSets: ppsUnits,\n chromaFormat: hasExtendedData ? spsInfo.chromaFormatIdc : null,\n bitDepthLumaMinus8: hasExtendedData ? spsInfo.bitDepthLumaMinus8 : null,\n bitDepthChromaMinus8: hasExtendedData ? spsInfo.bitDepthChromaMinus8 : null,\n sequenceParameterSetExt: hasExtendedData ? spsExtUnits : null,\n };\n }\n catch (error) {\n Logging._error('Error building AVC Decoder Configuration Record:', error);\n return null;\n }\n };\n /** Deserializes an AvcDecoderConfigurationRecord from the format specified in Section 5.3.3.1 of ISO 14496-15. */\n const deserializeAvcDecoderConfigurationRecord = (data) => {\n try {\n const view = toDataView(data);\n let offset = 0;\n // Read header\n const configurationVersion = view.getUint8(offset++);\n const avcProfileIndication = view.getUint8(offset++);\n const profileCompatibility = view.getUint8(offset++);\n const avcLevelIndication = view.getUint8(offset++);\n const lengthSizeMinusOne = view.getUint8(offset++) & 0x03;\n const numOfSequenceParameterSets = view.getUint8(offset++) & 0x1F;\n // Read SPS\n const sequenceParameterSets = [];\n for (let i = 0; i < numOfSequenceParameterSets; i++) {\n const length = view.getUint16(offset, false);\n offset += 2;\n sequenceParameterSets.push(data.subarray(offset, offset + length));\n offset += length;\n }\n const numOfPictureParameterSets = view.getUint8(offset++);\n // Read PPS\n const pictureParameterSets = [];\n for (let i = 0; i < numOfPictureParameterSets; i++) {\n const length = view.getUint16(offset, false);\n offset += 2;\n pictureParameterSets.push(data.subarray(offset, offset + length));\n offset += length;\n }\n const record = {\n configurationVersion,\n avcProfileIndication,\n profileCompatibility,\n avcLevelIndication,\n lengthSizeMinusOne,\n sequenceParameterSets,\n pictureParameterSets,\n chromaFormat: null,\n bitDepthLumaMinus8: null,\n bitDepthChromaMinus8: null,\n sequenceParameterSetExt: null,\n };\n // Check if there are extended profile fields\n if ((avcProfileIndication === 100\n || avcProfileIndication === 110\n || avcProfileIndication === 122\n || avcProfileIndication === 144)\n && offset + 4 <= data.length) {\n const chromaFormat = view.getUint8(offset++) & 0x03;\n const bitDepthLumaMinus8 = view.getUint8(offset++) & 0x07;\n const bitDepthChromaMinus8 = view.getUint8(offset++) & 0x07;\n const numOfSequenceParameterSetExt = view.getUint8(offset++);\n record.chromaFormat = chromaFormat;\n record.bitDepthLumaMinus8 = bitDepthLumaMinus8;\n record.bitDepthChromaMinus8 = bitDepthChromaMinus8;\n // Read SPS Ext\n const sequenceParameterSetExt = [];\n for (let i = 0; i < numOfSequenceParameterSetExt; i++) {\n const length = view.getUint16(offset, false);\n offset += 2;\n sequenceParameterSetExt.push(data.subarray(offset, offset + length));\n offset += length;\n }\n record.sequenceParameterSetExt = sequenceParameterSetExt;\n }\n return record;\n }\n catch (error) {\n Logging._error('Error deserializing AVC Decoder Configuration Record:', error);\n return null;\n }\n };\n const AVC_HEVC_ASPECT_RATIO_IDC_TABLE = {\n 1: { num: 1, den: 1 },\n 2: { num: 12, den: 11 },\n 3: { num: 10, den: 11 },\n 4: { num: 16, den: 11 },\n 5: { num: 40, den: 33 },\n 6: { num: 24, den: 11 },\n 7: { num: 20, den: 11 },\n 8: { num: 32, den: 11 },\n 9: { num: 80, den: 33 },\n 10: { num: 18, den: 11 },\n 11: { num: 15, den: 11 },\n 12: { num: 64, den: 33 },\n 13: { num: 160, den: 99 },\n 14: { num: 4, den: 3 },\n 15: { num: 3, den: 2 },\n 16: { num: 2, den: 1 },\n };\n /** Parses an AVC SPS (Sequence Parameter Set) to extract basic information. */\n const parseAvcSps = (sps) => {\n try {\n const bitstream = new Bitstream(removeEmulationPreventionBytes(sps));\n bitstream.skipBits(1); // forbidden_zero_bit\n bitstream.skipBits(2); // nal_ref_idc\n const nalUnitType = bitstream.readBits(5);\n if (nalUnitType !== 7) { // SPS NAL unit type is 7\n return null;\n }\n const profileIdc = bitstream.readAlignedByte();\n const constraintFlags = bitstream.readAlignedByte();\n const levelIdc = bitstream.readAlignedByte();\n readExpGolomb(bitstream); // seq_parameter_set_id\n // \"When chroma_format_idc is not present, it shall be inferred to be equal to 1 (4:2:0 chroma format).\"\n let chromaFormatIdc = 1;\n // \"When bit_depth_luma_minus8 is not present, it shall be inferred to be equal to 0.\"\"\n let bitDepthLumaMinus8 = 0;\n // \"When bit_depth_chroma_minus8 is not present, it shall be inferred to be equal to 0.\"\n let bitDepthChromaMinus8 = 0;\n // \"When separate_colour_plane_flag is not present, it shall be inferred to be equal to 0.\"\n let separateColourPlaneFlag = 0;\n // Handle high profile chroma_format_idc\n if (profileIdc === 100\n || profileIdc === 110\n || profileIdc === 122\n || profileIdc === 244\n || profileIdc === 44\n || profileIdc === 83\n || profileIdc === 86\n || profileIdc === 118\n || profileIdc === 128) {\n chromaFormatIdc = readExpGolomb(bitstream);\n if (chromaFormatIdc === 3) {\n separateColourPlaneFlag = bitstream.readBits(1);\n }\n bitDepthLumaMinus8 = readExpGolomb(bitstream);\n bitDepthChromaMinus8 = readExpGolomb(bitstream);\n bitstream.skipBits(1); // qpprime_y_zero_transform_bypass_flag\n const seqScalingMatrixPresentFlag = bitstream.readBits(1);\n if (seqScalingMatrixPresentFlag) {\n for (let i = 0; i < (chromaFormatIdc !== 3 ? 8 : 12); i++) {\n const seqScalingListPresentFlag = bitstream.readBits(1);\n if (seqScalingListPresentFlag) {\n const sizeOfScalingList = i < 6 ? 16 : 64;\n let lastScale = 8;\n let nextScale = 8;\n for (let j = 0; j < sizeOfScalingList; j++) {\n if (nextScale !== 0) {\n const deltaScale = readSignedExpGolomb(bitstream);\n nextScale = (lastScale + deltaScale + 256) % 256;\n }\n lastScale = nextScale === 0 ? lastScale : nextScale;\n }\n }\n }\n }\n }\n readExpGolomb(bitstream); // log2_max_frame_num_minus4\n const picOrderCntType = readExpGolomb(bitstream);\n if (picOrderCntType === 0) {\n readExpGolomb(bitstream); // log2_max_pic_order_cnt_lsb_minus4\n }\n else if (picOrderCntType === 1) {\n bitstream.skipBits(1); // delta_pic_order_always_zero_flag\n readSignedExpGolomb(bitstream); // offset_for_non_ref_pic\n readSignedExpGolomb(bitstream); // offset_for_top_to_bottom_field\n const numRefFramesInPicOrderCntCycle = readExpGolomb(bitstream);\n for (let i = 0; i < numRefFramesInPicOrderCntCycle; i++) {\n readSignedExpGolomb(bitstream); // offset_for_ref_frame[i]\n }\n }\n readExpGolomb(bitstream); // max_num_ref_frames\n bitstream.skipBits(1); // gaps_in_frame_num_value_allowed_flag\n const picWidthInMbsMinus1 = readExpGolomb(bitstream);\n const picHeightInMapUnitsMinus1 = readExpGolomb(bitstream);\n const codedWidth = 16 * (picWidthInMbsMinus1 + 1);\n const codedHeight = 16 * (picHeightInMapUnitsMinus1 + 1);\n let displayWidth = codedWidth;\n let displayHeight = codedHeight;\n const frameMbsOnlyFlag = bitstream.readBits(1);\n if (!frameMbsOnlyFlag) {\n bitstream.skipBits(1); // mb_adaptive_frame_field_flag\n }\n bitstream.skipBits(1); // direct_8x8_inference_flag\n const frameCroppingFlag = bitstream.readBits(1);\n if (frameCroppingFlag) {\n const frameCropLeftOffset = readExpGolomb(bitstream);\n const frameCropRightOffset = readExpGolomb(bitstream);\n const frameCropTopOffset = readExpGolomb(bitstream);\n const frameCropBottomOffset = readExpGolomb(bitstream);\n let cropUnitX;\n let cropUnitY;\n const chromaArrayType = separateColourPlaneFlag === 0 ? chromaFormatIdc : 0;\n if (chromaArrayType === 0) {\n // \"If ChromaArrayType is equal to 0, CropUnitX and CropUnitY are derived as:\"\n cropUnitX = 1;\n cropUnitY = 2 - frameMbsOnlyFlag;\n }\n else {\n // \"Otherwise (ChromaArrayType is equal to 1, 2, or 3), CropUnitX and CropUnitY are derived as:\"\n const subWidthC = chromaFormatIdc === 3 ? 1 : 2;\n const subHeightC = chromaFormatIdc === 1 ? 2 : 1;\n cropUnitX = subWidthC;\n cropUnitY = subHeightC * (2 - frameMbsOnlyFlag);\n }\n displayWidth -= (cropUnitX * (frameCropLeftOffset + frameCropRightOffset));\n displayHeight -= (cropUnitY * (frameCropTopOffset + frameCropBottomOffset));\n }\n // 2 = unspecified\n let colourPrimaries = 2;\n let transferCharacteristics = 2;\n let matrixCoefficients = 2;\n let fullRangeFlag = 0;\n let pixelAspectRatio = { num: 1, den: 1 };\n let numReorderFrames = null;\n let maxDecFrameBuffering = null;\n const vuiParametersPresentFlag = bitstream.readBits(1);\n if (vuiParametersPresentFlag) {\n const aspectRatioInfoPresentFlag = bitstream.readBits(1);\n if (aspectRatioInfoPresentFlag) {\n const aspectRatioIdc = bitstream.readBits(8);\n if (aspectRatioIdc === 255) { // Extended_SAR\n pixelAspectRatio = {\n num: bitstream.readBits(16),\n den: bitstream.readBits(16),\n };\n }\n else {\n const aspectRatio = AVC_HEVC_ASPECT_RATIO_IDC_TABLE[aspectRatioIdc];\n if (aspectRatio) {\n pixelAspectRatio = aspectRatio;\n }\n }\n }\n const overscanInfoPresentFlag = bitstream.readBits(1);\n if (overscanInfoPresentFlag) {\n bitstream.skipBits(1); // overscan_appropriate_flag\n }\n const videoSignalTypePresentFlag = bitstream.readBits(1);\n if (videoSignalTypePresentFlag) {\n bitstream.skipBits(3); // video_format\n fullRangeFlag = bitstream.readBits(1);\n const colourDescriptionPresentFlag = bitstream.readBits(1);\n if (colourDescriptionPresentFlag) {\n colourPrimaries = bitstream.readBits(8);\n transferCharacteristics = bitstream.readBits(8);\n matrixCoefficients = bitstream.readBits(8);\n }\n }\n const chromaLocInfoPresentFlag = bitstream.readBits(1);\n if (chromaLocInfoPresentFlag) {\n readExpGolomb(bitstream); // chroma_sample_loc_type_top_field\n readExpGolomb(bitstream); // chroma_sample_loc_type_bottom_field\n }\n const timingInfoPresentFlag = bitstream.readBits(1);\n if (timingInfoPresentFlag) {\n bitstream.skipBits(32); // num_units_in_tick\n bitstream.skipBits(32); // time_scale\n bitstream.skipBits(1); // fixed_frame_rate_flag\n }\n const nalHrdParametersPresentFlag = bitstream.readBits(1);\n if (nalHrdParametersPresentFlag) {\n skipAvcHrdParameters(bitstream);\n }\n const vclHrdParametersPresentFlag = bitstream.readBits(1);\n if (vclHrdParametersPresentFlag) {\n skipAvcHrdParameters(bitstream);\n }\n if (nalHrdParametersPresentFlag || vclHrdParametersPresentFlag) {\n bitstream.skipBits(1); // low_delay_hrd_flag\n }\n bitstream.skipBits(1); // pic_struct_present_flag\n const bitstreamRestrictionFlag = bitstream.readBits(1);\n if (bitstreamRestrictionFlag) {\n bitstream.skipBits(1); // motion_vectors_over_pic_boundaries_flag\n readExpGolomb(bitstream); // max_bytes_per_pic_denom\n readExpGolomb(bitstream); // max_bits_per_mb_denom\n readExpGolomb(bitstream); // log2_max_mv_length_horizontal\n readExpGolomb(bitstream); // log2_max_mv_length_vertical\n numReorderFrames = readExpGolomb(bitstream);\n maxDecFrameBuffering = readExpGolomb(bitstream);\n }\n }\n if (numReorderFrames === null) {\n assert(maxDecFrameBuffering === null);\n const constraintSet3Flag = constraintFlags & 0b00010000;\n if ((profileIdc === 44 || profileIdc === 86 || profileIdc === 100\n || profileIdc === 110 || profileIdc === 122 || profileIdc === 244) && constraintSet3Flag) {\n // \"If profile_idc is equal to 44, 86, 100, 110, 122, or 244 and constraint_set3_flag is equal to 1, the\n // value of num_reorder_frames shall be inferred to be equal to 0.\"\n numReorderFrames = 0;\n maxDecFrameBuffering = 0;\n }\n else {\n const picWidthInMbs = picWidthInMbsMinus1 + 1;\n const picHeightInMapUnits = picHeightInMapUnitsMinus1 + 1;\n const frameHeightInMbs = (2 - frameMbsOnlyFlag) * picHeightInMapUnits;\n const levelInfo = AVC_LEVEL_TABLE.find(x => x.level >= levelIdc) ?? last(AVC_LEVEL_TABLE);\n // \"MaxDpbFrames is equal to\n // Min( MaxDpbMbs / ( picWidthInMbs * frameHeightInMbs ), 16 ) and MaxDpbMbs is given in Table A-1.\"\n const maxDpbFrames = Math.min(Math.floor(levelInfo.maxDpbMbs / (picWidthInMbs * frameHeightInMbs)), 16);\n // \"Otherwise, [...] the value of num_reorder_frames shall be inferred to be equal to MaxDpbFrames.\"\n numReorderFrames = maxDpbFrames;\n maxDecFrameBuffering = maxDpbFrames;\n }\n }\n assert(maxDecFrameBuffering !== null);\n return {\n profileIdc,\n constraintFlags,\n levelIdc,\n frameMbsOnlyFlag,\n chromaFormatIdc,\n bitDepthLumaMinus8,\n bitDepthChromaMinus8,\n codedWidth,\n codedHeight,\n displayWidth,\n displayHeight,\n pixelAspectRatio,\n colourPrimaries,\n matrixCoefficients,\n transferCharacteristics,\n fullRangeFlag,\n numReorderFrames,\n maxDecFrameBuffering,\n };\n }\n catch (error) {\n Logging._error('Error parsing AVC SPS:', error);\n return null;\n }\n };\n const skipAvcHrdParameters = (bitstream) => {\n const cpb_cnt_minus1 = readExpGolomb(bitstream);\n bitstream.skipBits(4); // bit_rate_scale\n bitstream.skipBits(4); // cpb_size_scale\n for (let i = 0; i <= cpb_cnt_minus1; i++) {\n readExpGolomb(bitstream); // bit_rate_value_minus1[i]\n readExpGolomb(bitstream); // cpb_size_value_minus1[i]\n bitstream.skipBits(1); // cbr_flag[i]\n }\n bitstream.skipBits(5); // initial_cpb_removal_delay_length_minus1\n bitstream.skipBits(5); // cpb_removal_delay_length_minus1\n bitstream.skipBits(5); // dpb_output_delay_length_minus1\n bitstream.skipBits(5); // time_offset_length\n };\n const concatHevcNalUnits = (nalUnits, decoderConfig) => {\n if (decoderConfig.description) {\n // Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config\n const bytes = toUint8Array(decoderConfig.description);\n const lengthSizeMinusOne = bytes[21] & 0b11;\n const lengthSize = (lengthSizeMinusOne + 1);\n return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);\n }\n else {\n // Stream is in Annex B format\n return concatNalUnitsInAnnexB(nalUnits);\n }\n };\n const iterateHevcNalUnits = (packetData, decoderConfig) => {\n if (decoderConfig.description) {\n const bytes = toUint8Array(decoderConfig.description);\n const lengthSizeMinusOne = bytes[21] & 0b11;\n const lengthSize = (lengthSizeMinusOne + 1);\n return iterateNalUnitsInLengthPrefixed(packetData, lengthSize);\n }\n else {\n return iterateNalUnitsInAnnexB(packetData);\n }\n };\n const extractNalUnitTypeForHevc = (byte) => {\n return (byte >> 1) & 0x3F;\n };\n /** Parses an HEVC SPS (Sequence Parameter Set) to extract video information. */\n const parseHevcSps = (sps) => {\n try {\n const bitstream = new Bitstream(removeEmulationPreventionBytes(sps));\n bitstream.skipBits(16); // NAL header\n bitstream.readBits(4); // sps_video_parameter_set_id\n const spsMaxSubLayersMinus1 = bitstream.readBits(3);\n const spsTemporalIdNestingFlag = bitstream.readBits(1);\n const { general_profile_space, general_tier_flag, general_profile_idc, general_profile_compatibility_flags, general_constraint_indicator_flags, general_level_idc, } = parseProfileTierLevel(bitstream, spsMaxSubLayersMinus1);\n readExpGolomb(bitstream); // sps_seq_parameter_set_id\n const chromaFormatIdc = readExpGolomb(bitstream);\n let separateColourPlaneFlag = 0;\n if (chromaFormatIdc === 3) {\n separateColourPlaneFlag = bitstream.readBits(1);\n }\n const picWidthInLumaSamples = readExpGolomb(bitstream);\n const picHeightInLumaSamples = readExpGolomb(bitstream);\n let displayWidth = picWidthInLumaSamples;\n let displayHeight = picHeightInLumaSamples;\n if (bitstream.readBits(1)) { // conformance_window_flag\n const confWinLeftOffset = readExpGolomb(bitstream);\n const confWinRightOffset = readExpGolomb(bitstream);\n const confWinTopOffset = readExpGolomb(bitstream);\n const confWinBottomOffset = readExpGolomb(bitstream);\n // SubWidthC and SubHeightC depend on chroma_format_idc and separate_colour_plane_flag\n let subWidthC = 1;\n let subHeightC = 1;\n const chromaArrayType = separateColourPlaneFlag === 0 ? chromaFormatIdc : 0;\n if (chromaArrayType === 1) {\n subWidthC = 2;\n subHeightC = 2;\n }\n else if (chromaArrayType === 2) {\n subWidthC = 2;\n subHeightC = 1;\n }\n displayWidth -= (confWinLeftOffset + confWinRightOffset) * subWidthC;\n displayHeight -= (confWinTopOffset + confWinBottomOffset) * subHeightC;\n }\n const bitDepthLumaMinus8 = readExpGolomb(bitstream);\n const bitDepthChromaMinus8 = readExpGolomb(bitstream);\n readExpGolomb(bitstream); // log2_max_pic_order_cnt_lsb_minus4\n const spsSubLayerOrderingInfoPresentFlag = bitstream.readBits(1);\n const startI = spsSubLayerOrderingInfoPresentFlag ? 0 : spsMaxSubLayersMinus1;\n let spsMaxNumReorderPics = 0;\n for (let i = startI; i <= spsMaxSubLayersMinus1; i++) {\n readExpGolomb(bitstream); // sps_max_dec_pic_buffering_minus1[i]\n spsMaxNumReorderPics = readExpGolomb(bitstream); // sps_max_num_reorder_pics[i]\n readExpGolomb(bitstream); // sps_max_latency_increase_plus1[i]\n }\n readExpGolomb(bitstream); // log2_min_luma_coding_block_size_minus3\n readExpGolomb(bitstream); // log2_diff_max_min_luma_coding_block_size\n readExpGolomb(bitstream); // log2_min_luma_transform_block_size_minus2\n readExpGolomb(bitstream); // log2_diff_max_min_luma_transform_block_size\n readExpGolomb(bitstream); // max_transform_hierarchy_depth_inter\n readExpGolomb(bitstream); // max_transform_hierarchy_depth_intra\n if (bitstream.readBits(1)) { // scaling_list_enabled_flag\n if (bitstream.readBits(1)) {\n skipScalingListData(bitstream);\n }\n }\n bitstream.skipBits(1); // amp_enabled_flag\n bitstream.skipBits(1); // sample_adaptive_offset_enabled_flag\n if (bitstream.readBits(1)) { // pcm_enabled_flag\n bitstream.skipBits(4); // pcm_sample_bit_depth_luma_minus1\n bitstream.skipBits(4); // pcm_sample_bit_depth_chroma_minus1\n readExpGolomb(bitstream); // log2_min_pcm_luma_coding_block_size_minus3\n readExpGolomb(bitstream); // log2_diff_max_min_pcm_luma_coding_block_size\n bitstream.skipBits(1); // pcm_loop_filter_disabled_flag\n }\n const numShortTermRefPicSets = readExpGolomb(bitstream);\n skipAllStRefPicSets(bitstream, numShortTermRefPicSets);\n if (bitstream.readBits(1)) { // long_term_ref_pics_present_flag\n const numLongTermRefPicsSps = readExpGolomb(bitstream);\n for (let i = 0; i < numLongTermRefPicsSps; i++) {\n readExpGolomb(bitstream); // lt_ref_pic_poc_lsb_sps[i]\n bitstream.skipBits(1); // used_by_curr_pic_lt_sps_flag[i]\n }\n }\n bitstream.skipBits(1); // sps_temporal_mvp_enabled_flag\n bitstream.skipBits(1); // strong_intra_smoothing_enabled_flag\n let colourPrimaries = 2;\n let transferCharacteristics = 2;\n let matrixCoefficients = 2;\n let fullRangeFlag = 0;\n let minSpatialSegmentationIdc = 0;\n let pixelAspectRatio = { num: 1, den: 1 };\n if (bitstream.readBits(1)) { // vui_parameters_present_flag\n const vui = parseHevcVui(bitstream, spsMaxSubLayersMinus1);\n pixelAspectRatio = vui.pixelAspectRatio;\n colourPrimaries = vui.colourPrimaries;\n transferCharacteristics = vui.transferCharacteristics;\n matrixCoefficients = vui.matrixCoefficients;\n fullRangeFlag = vui.fullRangeFlag;\n minSpatialSegmentationIdc = vui.minSpatialSegmentationIdc;\n }\n return {\n displayWidth,\n displayHeight,\n pixelAspectRatio,\n colourPrimaries,\n transferCharacteristics,\n matrixCoefficients,\n fullRangeFlag,\n maxDecFrameBuffering: spsMaxNumReorderPics + 1,\n spsMaxSubLayersMinus1,\n spsTemporalIdNestingFlag,\n generalProfileSpace: general_profile_space,\n generalTierFlag: general_tier_flag,\n generalProfileIdc: general_profile_idc,\n generalProfileCompatibilityFlags: general_profile_compatibility_flags,\n generalConstraintIndicatorFlags: general_constraint_indicator_flags,\n generalLevelIdc: general_level_idc,\n chromaFormatIdc,\n bitDepthLumaMinus8,\n bitDepthChromaMinus8,\n minSpatialSegmentationIdc,\n };\n }\n catch (error) {\n Logging._error('Error parsing HEVC SPS:', error);\n return null;\n }\n };\n /** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */\n const extractHevcDecoderConfigurationRecord = (packetData) => {\n try {\n const vpsUnits = [];\n const spsUnits = [];\n const ppsUnits = [];\n const seiUnits = [];\n for (const loc of iterateNalUnitsInAnnexB(packetData)) {\n const nalUnit = packetData.subarray(loc.offset, loc.offset + loc.length);\n const type = extractNalUnitTypeForHevc(nalUnit[0]);\n if (type === HevcNalUnitType.VPS_NUT) {\n vpsUnits.push(nalUnit);\n }\n else if (type === HevcNalUnitType.SPS_NUT) {\n spsUnits.push(nalUnit);\n }\n else if (type === HevcNalUnitType.PPS_NUT) {\n ppsUnits.push(nalUnit);\n }\n else if (type === HevcNalUnitType.PREFIX_SEI_NUT || type === HevcNalUnitType.SUFFIX_SEI_NUT) {\n seiUnits.push(nalUnit);\n }\n }\n if (spsUnits.length === 0 || ppsUnits.length === 0)\n return null;\n const spsInfo = parseHevcSps(spsUnits[0]);\n if (!spsInfo)\n return null;\n // Parse PPS for parallelismType\n let parallelismType = 0;\n if (ppsUnits.length > 0) {\n const pps = ppsUnits[0];\n const ppsBitstream = new Bitstream(removeEmulationPreventionBytes(pps));\n ppsBitstream.skipBits(16); // NAL header\n readExpGolomb(ppsBitstream); // pps_pic_parameter_set_id\n readExpGolomb(ppsBitstream); // pps_seq_parameter_set_id\n ppsBitstream.skipBits(1); // dependent_slice_segments_enabled_flag\n ppsBitstream.skipBits(1); // output_flag_present_flag\n ppsBitstream.skipBits(3); // num_extra_slice_header_bits\n ppsBitstream.skipBits(1); // sign_data_hiding_enabled_flag\n ppsBitstream.skipBits(1); // cabac_init_present_flag\n readExpGolomb(ppsBitstream); // num_ref_idx_l0_default_active_minus1\n readExpGolomb(ppsBitstream); // num_ref_idx_l1_default_active_minus1\n readSignedExpGolomb(ppsBitstream); // init_qp_minus26\n ppsBitstream.skipBits(1); // constrained_intra_pred_flag\n ppsBitstream.skipBits(1); // transform_skip_enabled_flag\n if (ppsBitstream.readBits(1)) { // cu_qp_delta_enabled_flag\n readExpGolomb(ppsBitstream); // diff_cu_qp_delta_depth\n }\n readSignedExpGolomb(ppsBitstream); // pps_cb_qp_offset\n readSignedExpGolomb(ppsBitstream); // pps_cr_qp_offset\n ppsBitstream.skipBits(1); // pps_slice_chroma_qp_offsets_present_flag\n ppsBitstream.skipBits(1); // weighted_pred_flag\n ppsBitstream.skipBits(1); // weighted_bipred_flag\n ppsBitstream.skipBits(1); // transquant_bypass_enabled_flag\n const tiles_enabled_flag = ppsBitstream.readBits(1);\n const entropy_coding_sync_enabled_flag = ppsBitstream.readBits(1);\n if (!tiles_enabled_flag && !entropy_coding_sync_enabled_flag)\n parallelismType = 0;\n else if (tiles_enabled_flag && !entropy_coding_sync_enabled_flag)\n parallelismType = 2;\n else if (!tiles_enabled_flag && entropy_coding_sync_enabled_flag)\n parallelismType = 3;\n else\n parallelismType = 0;\n }\n const arrays = [\n ...(vpsUnits.length\n ? [\n {\n arrayCompleteness: 1,\n nalUnitType: HevcNalUnitType.VPS_NUT,\n nalUnits: vpsUnits,\n },\n ]\n : []),\n ...(spsUnits.length\n ? [\n {\n arrayCompleteness: 1,\n nalUnitType: HevcNalUnitType.SPS_NUT,\n nalUnits: spsUnits,\n },\n ]\n : []),\n ...(ppsUnits.length\n ? [\n {\n arrayCompleteness: 1,\n nalUnitType: HevcNalUnitType.PPS_NUT,\n nalUnits: ppsUnits,\n },\n ]\n : []),\n ...(seiUnits.length\n ? [\n {\n arrayCompleteness: 1,\n nalUnitType: extractNalUnitTypeForHevc(seiUnits[0][0]),\n nalUnits: seiUnits,\n },\n ]\n : []),\n ];\n const record = {\n configurationVersion: 1,\n generalProfileSpace: spsInfo.generalProfileSpace,\n generalTierFlag: spsInfo.generalTierFlag,\n generalProfileIdc: spsInfo.generalProfileIdc,\n generalProfileCompatibilityFlags: spsInfo.generalProfileCompatibilityFlags,\n generalConstraintIndicatorFlags: spsInfo.generalConstraintIndicatorFlags,\n generalLevelIdc: spsInfo.generalLevelIdc,\n minSpatialSegmentationIdc: spsInfo.minSpatialSegmentationIdc,\n parallelismType,\n chromaFormatIdc: spsInfo.chromaFormatIdc,\n bitDepthLumaMinus8: spsInfo.bitDepthLumaMinus8,\n bitDepthChromaMinus8: spsInfo.bitDepthChromaMinus8,\n avgFrameRate: 0,\n constantFrameRate: 0,\n numTemporalLayers: spsInfo.spsMaxSubLayersMinus1 + 1,\n temporalIdNested: spsInfo.spsTemporalIdNestingFlag,\n lengthSizeMinusOne: 3,\n arrays,\n };\n return record;\n }\n catch (error) {\n Logging._error('Error building HEVC Decoder Configuration Record:', error);\n return null;\n }\n };\n const parseProfileTierLevel = (bitstream, maxNumSubLayersMinus1) => {\n const general_profile_space = bitstream.readBits(2);\n const general_tier_flag = bitstream.readBits(1);\n const general_profile_idc = bitstream.readBits(5);\n let general_profile_compatibility_flags = 0;\n for (let i = 0; i < 32; i++) {\n general_profile_compatibility_flags = (general_profile_compatibility_flags << 1) | bitstream.readBits(1);\n }\n const general_constraint_indicator_flags = new Uint8Array(6);\n for (let i = 0; i < 6; i++) {\n general_constraint_indicator_flags[i] = bitstream.readBits(8);\n }\n const general_level_idc = bitstream.readBits(8);\n const sub_layer_profile_present_flag = [];\n const sub_layer_level_present_flag = [];\n for (let i = 0; i < maxNumSubLayersMinus1; i++) {\n sub_layer_profile_present_flag.push(bitstream.readBits(1));\n sub_layer_level_present_flag.push(bitstream.readBits(1));\n }\n if (maxNumSubLayersMinus1 > 0) {\n for (let i = maxNumSubLayersMinus1; i < 8; i++) {\n bitstream.skipBits(2); // reserved_zero_2bits\n }\n }\n for (let i = 0; i < maxNumSubLayersMinus1; i++) {\n if (sub_layer_profile_present_flag[i])\n bitstream.skipBits(88);\n if (sub_layer_level_present_flag[i])\n bitstream.skipBits(8);\n }\n return {\n general_profile_space,\n general_tier_flag,\n general_profile_idc,\n general_profile_compatibility_flags,\n general_constraint_indicator_flags,\n general_level_idc,\n };\n };\n const skipScalingListData = (bitstream) => {\n for (let sizeId = 0; sizeId < 4; sizeId++) {\n for (let matrixId = 0; matrixId < (sizeId === 3 ? 2 : 6); matrixId++) {\n const scaling_list_pred_mode_flag = bitstream.readBits(1);\n if (!scaling_list_pred_mode_flag) {\n readExpGolomb(bitstream); // scaling_list_pred_matrix_id_delta\n }\n else {\n const coefNum = Math.min(64, 1 << (4 + (sizeId << 1)));\n if (sizeId > 1) {\n readSignedExpGolomb(bitstream); // scaling_list_dc_coef_minus8\n }\n for (let i = 0; i < coefNum; i++) {\n readSignedExpGolomb(bitstream); // scaling_list_delta_coef\n }\n }\n }\n }\n };\n const skipAllStRefPicSets = (bitstream, num_short_term_ref_pic_sets) => {\n const NumDeltaPocs = [];\n for (let stRpsIdx = 0; stRpsIdx < num_short_term_ref_pic_sets; stRpsIdx++) {\n NumDeltaPocs[stRpsIdx] = skipStRefPicSet(bitstream, stRpsIdx, num_short_term_ref_pic_sets, NumDeltaPocs);\n }\n };\n const skipStRefPicSet = (bitstream, stRpsIdx, num_short_term_ref_pic_sets, NumDeltaPocs) => {\n let NumDeltaPocsThis = 0;\n let inter_ref_pic_set_prediction_flag = 0;\n let RefRpsIdx = 0;\n if (stRpsIdx !== 0) {\n inter_ref_pic_set_prediction_flag = bitstream.readBits(1);\n }\n if (inter_ref_pic_set_prediction_flag) {\n if (stRpsIdx === num_short_term_ref_pic_sets) {\n const delta_idx_minus1 = readExpGolomb(bitstream);\n RefRpsIdx = stRpsIdx - (delta_idx_minus1 + 1);\n }\n else {\n RefRpsIdx = stRpsIdx - 1;\n }\n bitstream.readBits(1); // delta_rps_sign\n readExpGolomb(bitstream); // abs_delta_rps_minus1\n // The number of iterations is NumDeltaPocs[RefRpsIdx] + 1\n const numDelta = NumDeltaPocs[RefRpsIdx] ?? 0;\n for (let j = 0; j <= numDelta; j++) {\n const used_by_curr_pic_flag = bitstream.readBits(1);\n if (!used_by_curr_pic_flag) {\n bitstream.readBits(1); // use_delta_flag\n }\n }\n NumDeltaPocsThis = NumDeltaPocs[RefRpsIdx];\n }\n else {\n const num_negative_pics = readExpGolomb(bitstream);\n const num_positive_pics = readExpGolomb(bitstream);\n for (let i = 0; i < num_negative_pics; i++) {\n readExpGolomb(bitstream); // delta_poc_s0_minus1[i]\n bitstream.readBits(1); // used_by_curr_pic_s0_flag[i]\n }\n for (let i = 0; i < num_positive_pics; i++) {\n readExpGolomb(bitstream); // delta_poc_s1_minus1[i]\n bitstream.readBits(1); // used_by_curr_pic_s1_flag[i]\n }\n NumDeltaPocsThis = num_negative_pics + num_positive_pics;\n }\n return NumDeltaPocsThis;\n };\n const parseHevcVui = (bitstream, sps_max_sub_layers_minus1) => {\n // Defaults: 2 = unspecified\n let colourPrimaries = 2;\n let transferCharacteristics = 2;\n let matrixCoefficients = 2;\n let fullRangeFlag = 0;\n let minSpatialSegmentationIdc = 0;\n let pixelAspectRatio = { num: 1, den: 1 };\n if (bitstream.readBits(1)) { // aspect_ratio_info_present_flag\n const aspect_ratio_idc = bitstream.readBits(8);\n if (aspect_ratio_idc === 255) {\n pixelAspectRatio = {\n num: bitstream.readBits(16),\n den: bitstream.readBits(16),\n };\n }\n else {\n const aspectRatio = AVC_HEVC_ASPECT_RATIO_IDC_TABLE[aspect_ratio_idc];\n if (aspectRatio) {\n pixelAspectRatio = aspectRatio;\n }\n }\n }\n if (bitstream.readBits(1)) { // overscan_info_present_flag\n bitstream.readBits(1); // overscan_appropriate_flag\n }\n if (bitstream.readBits(1)) { // video_signal_type_present_flag\n bitstream.readBits(3); // video_format\n fullRangeFlag = bitstream.readBits(1);\n if (bitstream.readBits(1)) { // colour_description_present_flag\n colourPrimaries = bitstream.readBits(8);\n transferCharacteristics = bitstream.readBits(8);\n matrixCoefficients = bitstream.readBits(8);\n }\n }\n if (bitstream.readBits(1)) { // chroma_loc_info_present_flag\n readExpGolomb(bitstream); // chroma_sample_loc_type_top_field\n readExpGolomb(bitstream); // chroma_sample_loc_type_bottom_field\n }\n bitstream.readBits(1); // neutral_chroma_indication_flag\n bitstream.readBits(1); // field_seq_flag\n bitstream.readBits(1); // frame_field_info_present_flag\n if (bitstream.readBits(1)) { // default_display_window_flag\n readExpGolomb(bitstream); // def_disp_win_left_offset\n readExpGolomb(bitstream); // def_disp_win_right_offset\n readExpGolomb(bitstream); // def_disp_win_top_offset\n readExpGolomb(bitstream); // def_disp_win_bottom_offset\n }\n if (bitstream.readBits(1)) { // vui_timing_info_present_flag\n bitstream.readBits(32); // vui_num_units_in_tick\n bitstream.readBits(32); // vui_time_scale\n if (bitstream.readBits(1)) { // vui_poc_proportional_to_timing_flag\n readExpGolomb(bitstream); // vui_num_ticks_poc_diff_one_minus1\n }\n if (bitstream.readBits(1)) {\n skipHevcHrdParameters(bitstream, true, sps_max_sub_layers_minus1);\n }\n }\n if (bitstream.readBits(1)) { // bitstream_restriction_flag\n bitstream.readBits(1); // tiles_fixed_structure_flag\n bitstream.readBits(1); // motion_vectors_over_pic_boundaries_flag\n bitstream.readBits(1); // restricted_ref_pic_lists_flag\n minSpatialSegmentationIdc = readExpGolomb(bitstream);\n readExpGolomb(bitstream); // max_bytes_per_pic_denom\n readExpGolomb(bitstream); // max_bits_per_min_cu_denom\n readExpGolomb(bitstream); // log2_max_mv_length_horizontal\n readExpGolomb(bitstream); // log2_max_mv_length_vertical\n }\n return {\n pixelAspectRatio,\n colourPrimaries,\n transferCharacteristics,\n matrixCoefficients,\n fullRangeFlag,\n minSpatialSegmentationIdc,\n };\n };\n const skipHevcHrdParameters = (bitstream, commonInfPresentFlag, maxNumSubLayersMinus1) => {\n let nal_hrd_parameters_present_flag = false;\n let vcl_hrd_parameters_present_flag = false;\n let sub_pic_hrd_params_present_flag = false;\n {\n nal_hrd_parameters_present_flag = bitstream.readBits(1) === 1;\n vcl_hrd_parameters_present_flag = bitstream.readBits(1) === 1;\n if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) {\n sub_pic_hrd_params_present_flag = bitstream.readBits(1) === 1;\n if (sub_pic_hrd_params_present_flag) {\n bitstream.readBits(8); // tick_divisor_minus2\n bitstream.readBits(5); // du_cpb_removal_delay_increment_length_minus1\n bitstream.readBits(1); // sub_pic_cpb_params_in_pic_timing_sei_flag\n bitstream.readBits(5); // dpb_output_delay_du_length_minus1\n }\n bitstream.readBits(4); // bit_rate_scale\n bitstream.readBits(4); // cpb_size_scale\n if (sub_pic_hrd_params_present_flag) {\n bitstream.readBits(4); // cpb_size_du_scale\n }\n bitstream.readBits(5); // initial_cpb_removal_delay_length_minus1\n bitstream.readBits(5); // au_cpb_removal_delay_length_minus1\n bitstream.readBits(5); // dpb_output_delay_length_minus1\n }\n }\n for (let i = 0; i <= maxNumSubLayersMinus1; i++) {\n const fixed_pic_rate_general_flag = bitstream.readBits(1) === 1;\n let fixed_pic_rate_within_cvs_flag = true; // Default assumption if general is true\n if (!fixed_pic_rate_general_flag) {\n fixed_pic_rate_within_cvs_flag = bitstream.readBits(1) === 1;\n }\n let low_delay_hrd_flag = false; // Default assumption\n if (fixed_pic_rate_within_cvs_flag) {\n readExpGolomb(bitstream); // elemental_duration_in_tc_minus1[i]\n }\n else {\n low_delay_hrd_flag = bitstream.readBits(1) === 1;\n }\n let CpbCnt = 1; // Default if low_delay is true\n if (!low_delay_hrd_flag) {\n const cpb_cnt_minus1 = readExpGolomb(bitstream); // cpb_cnt_minus1[i]\n CpbCnt = cpb_cnt_minus1 + 1;\n }\n if (nal_hrd_parameters_present_flag) {\n skipSubLayerHrdParameters(bitstream, CpbCnt, sub_pic_hrd_params_present_flag);\n }\n if (vcl_hrd_parameters_present_flag) {\n skipSubLayerHrdParameters(bitstream, CpbCnt, sub_pic_hrd_params_present_flag);\n }\n }\n };\n const skipSubLayerHrdParameters = (bitstream, CpbCnt, sub_pic_hrd_params_present_flag) => {\n for (let i = 0; i < CpbCnt; i++) {\n readExpGolomb(bitstream); // bit_rate_value_minus1[i]\n readExpGolomb(bitstream); // cpb_size_value_minus1[i]\n if (sub_pic_hrd_params_present_flag) {\n readExpGolomb(bitstream); // cpb_size_du_value_minus1[i]\n readExpGolomb(bitstream); // bit_rate_du_value_minus1[i]\n }\n bitstream.readBits(1); // cbr_flag[i]\n }\n };\n var HevcNaluOrderState;\n (function (HevcNaluOrderState) {\n HevcNaluOrderState[HevcNaluOrderState[\"audAllowed\"] = 0] = \"audAllowed\";\n HevcNaluOrderState[HevcNaluOrderState[\"beforeFirstVcl\"] = 1] = \"beforeFirstVcl\";\n HevcNaluOrderState[HevcNaluOrderState[\"afterFirstVcl\"] = 2] = \"afterFirstVcl\";\n HevcNaluOrderState[HevcNaluOrderState[\"eoBitstreamAllowed\"] = 3] = \"eoBitstreamAllowed\";\n HevcNaluOrderState[HevcNaluOrderState[\"noMoreDataAllowed\"] = 4] = \"noMoreDataAllowed\";\n })(HevcNaluOrderState || (HevcNaluOrderState = {}));\n // This function sanitzes the contents of an HEVC packet such that\n // https://source.chromium.org/chromium/chromium/src/+/main:media/formats/mp4/hevc.cc's validation logic does not trip\n // up on its contents. The validation is often too strict and rejects packets that Chromium could decode just fine.\n // Chromium code retrieved on 2026-04-29.\n // See https://issues.chromium.org/issues/507611247.\n const sanitizeHevcPacketForChromium = (packetData, decoderConfig) => {\n const removedNalUnits = new Set();\n let orderState = HevcNaluOrderState.audAllowed;\n for (const loc of iterateHevcNalUnits(packetData, decoderConfig)) {\n if (orderState === HevcNaluOrderState.noMoreDataAllowed) {\n removedNalUnits.add(loc.offset);\n continue;\n }\n const type = extractNalUnitTypeForHevc(packetData[loc.offset]);\n if (orderState === HevcNaluOrderState.eoBitstreamAllowed && type !== 37 /* EOB_NUT */) {\n removedNalUnits.add(loc.offset);\n continue;\n }\n let remove = false;\n if (type === 35) { // AUD_NUT\n if (orderState > HevcNaluOrderState.audAllowed) {\n remove = true;\n }\n else {\n orderState = HevcNaluOrderState.beforeFirstVcl;\n }\n }\n else if (type <= 31) { // VCL (0-31)\n if (orderState > HevcNaluOrderState.afterFirstVcl) {\n remove = true;\n }\n else {\n orderState = HevcNaluOrderState.afterFirstVcl;\n }\n }\n else if (type === 36) { // EOS_NUT\n if (orderState !== HevcNaluOrderState.afterFirstVcl) {\n remove = true;\n }\n else {\n orderState = HevcNaluOrderState.eoBitstreamAllowed;\n }\n }\n else if (type === 37) { // EOB_NUT\n if (orderState < HevcNaluOrderState.afterFirstVcl) {\n remove = true;\n }\n else {\n orderState = HevcNaluOrderState.noMoreDataAllowed;\n }\n }\n else if (type === 32 || type === 33 || type === 34 || type === 39\n || (type >= 41 && type <= 44) || (type >= 48 && type <= 55)) { // VPS, SPS, PPS, PREFIX_SEI, RSV_NVCL41..44, UNSPEC48..55\n if (orderState > HevcNaluOrderState.beforeFirstVcl) {\n remove = true;\n }\n else {\n orderState = HevcNaluOrderState.beforeFirstVcl;\n }\n }\n else if (type === 38 || type === 40\n || (type >= 45 && type <= 47) || (type >= 56 && type <= 63)) { // FD, SUFFIX_SEI, RSV_NVCL45..47, UNSPEC56..63\n if (orderState < HevcNaluOrderState.afterFirstVcl) {\n remove = true;\n }\n }\n if (remove) {\n removedNalUnits.add(loc.offset);\n }\n }\n // If nothing violated the rules, return null to signal that\n if (removedNalUnits.size === 0) {\n return null;\n }\n const filteredNalUnits = [];\n for (const loc of iterateHevcNalUnits(packetData, decoderConfig)) {\n if (!removedNalUnits.has(loc.offset)) {\n filteredNalUnits.push(packetData.subarray(loc.offset, loc.offset + loc.length));\n }\n }\n return concatHevcNalUnits(filteredNalUnits, decoderConfig);\n };\n const extractVp9CodecInfoFromPacket = (packet) => {\n // eslint-disable-next-line @stylistic/max-len\n // https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.7-20170222-draft.pdf\n // http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf\n const bitstream = new Bitstream(packet);\n // Frame marker (0b10)\n const frameMarker = bitstream.readBits(2);\n if (frameMarker !== 2) {\n return null;\n }\n // Profile\n const profileLowBit = bitstream.readBits(1);\n const profileHighBit = bitstream.readBits(1);\n const profile = (profileHighBit << 1) + profileLowBit;\n // Skip reserved bit for profile 3\n if (profile === 3) {\n bitstream.skipBits(1);\n }\n // show_existing_frame\n const showExistingFrame = bitstream.readBits(1);\n if (showExistingFrame === 1) {\n return null;\n }\n // frame_type (0 = key frame)\n const frameType = bitstream.readBits(1);\n if (frameType !== 0) {\n return null;\n }\n // Skip show_frame and error_resilient_mode\n bitstream.skipBits(2);\n // Sync code (0x498342)\n const syncCode = bitstream.readBits(24);\n if (syncCode !== 0x498342) {\n return null;\n }\n // Color config\n let bitDepth = 8;\n if (profile >= 2) {\n const tenOrTwelveBit = bitstream.readBits(1);\n bitDepth = tenOrTwelveBit ? 12 : 10;\n }\n // Color space\n const colorSpace = bitstream.readBits(3);\n let chromaSubsampling = 0;\n let videoFullRangeFlag = 0;\n if (colorSpace !== 7) { // 7 is CS_RGB\n const colorRange = bitstream.readBits(1);\n videoFullRangeFlag = colorRange;\n if (profile === 1 || profile === 3) {\n const subsamplingX = bitstream.readBits(1);\n const subsamplingY = bitstream.readBits(1);\n // 0 = 4:2:0 vertical\n // 1 = 4:2:0 colocated\n // 2 = 4:2:2\n // 3 = 4:4:4\n chromaSubsampling = !subsamplingX && !subsamplingY\n ? 3 // 0,0 = 4:4:4\n : subsamplingX && !subsamplingY\n ? 2 // 1,0 = 4:2:2\n : 1; // 1,1 = 4:2:0 colocated (default)\n // Skip reserved bit\n bitstream.skipBits(1);\n }\n else {\n // For profile 0 and 2, always 4:2:0\n chromaSubsampling = 1; // Using colocated as default\n }\n }\n else {\n // RGB is always 4:4:4\n chromaSubsampling = 3;\n videoFullRangeFlag = 1;\n }\n // Parse frame size\n const widthMinusOne = bitstream.readBits(16);\n const heightMinusOne = bitstream.readBits(16);\n const width = widthMinusOne + 1;\n const height = heightMinusOne + 1;\n // Calculate level based on dimensions\n const pictureSize = width * height;\n let level = last(VP9_LEVEL_TABLE).level; // Default to highest level\n for (const entry of VP9_LEVEL_TABLE) {\n if (pictureSize <= entry.maxPictureSize) {\n level = entry.level;\n break;\n }\n }\n // Map color_space to standard values\n const matrixCoefficients = colorSpace === 7\n ? 0\n : colorSpace === 2\n ? 1\n : colorSpace === 1\n ? 6\n : 2;\n const colourPrimaries = colorSpace === 2\n ? 1\n : colorSpace === 1\n ? 6\n : 2;\n const transferCharacteristics = colorSpace === 2\n ? 1\n : colorSpace === 1\n ? 6\n : 2;\n return {\n profile,\n level,\n bitDepth,\n chromaSubsampling,\n videoFullRangeFlag,\n colourPrimaries,\n transferCharacteristics,\n matrixCoefficients,\n };\n };\n /** Iterates over all OBUs in an AV1 packet bitstream. */\n const iterateAv1PacketObus = function* (packet) {\n // https://aomediacodec.github.io/av1-spec/av1-spec.pdf\n const bitstream = new Bitstream(packet);\n const readLeb128 = () => {\n let value = 0;\n for (let i = 0; i < 8; i++) {\n const byte = bitstream.readAlignedByte();\n value |= ((byte & 0x7f) << (i * 7));\n if (!(byte & 0x80)) {\n break;\n }\n // Spec requirement\n if (i === 7 && (byte & 0x80)) {\n return null;\n }\n }\n // Spec requirement\n if (value >= 2 ** 32 - 1) {\n return null;\n }\n return value;\n };\n while (bitstream.getBitsLeft() >= 8) {\n // Parse OBU header\n bitstream.skipBits(1);\n const obuType = bitstream.readBits(4);\n const obuExtension = bitstream.readBits(1);\n const obuHasSizeField = bitstream.readBits(1);\n bitstream.skipBits(1);\n // Skip extension header if present\n if (obuExtension) {\n bitstream.skipBits(8);\n }\n // Read OBU size if present\n let obuSize;\n if (obuHasSizeField) {\n const obuSizeValue = readLeb128();\n if (obuSizeValue === null)\n return; // It was invalid\n obuSize = obuSizeValue;\n }\n else {\n // Calculate remaining bits and convert to bytes, rounding down\n obuSize = Math.floor(bitstream.getBitsLeft() / 8);\n }\n assert(bitstream.pos % 8 === 0);\n yield {\n type: obuType,\n data: packet.subarray(bitstream.pos / 8, bitstream.pos / 8 + obuSize),\n };\n // Move to next OBU\n bitstream.skipBits(obuSize * 8);\n }\n };\n /**\n * When AV1 codec information is not provided by the container, we can still try to extract the information by digging\n * into the AV1 bitstream.\n */\n const extractAv1CodecInfoFromPacket = (packet) => {\n // https://aomediacodec.github.io/av1-spec/av1-spec.pdf\n for (const { type, data } of iterateAv1PacketObus(packet)) {\n if (type !== 1) {\n continue; // 1 == OBU_SEQUENCE_HEADER\n }\n const bitstream = new Bitstream(data);\n // Read sequence header fields\n const seqProfile = bitstream.readBits(3);\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n bitstream.readBits(1);\n const reducedStillPictureHeader = bitstream.readBits(1);\n let seqLevel = 0;\n let seqTier = 0;\n let bufferDelayLengthMinus1 = 0;\n if (reducedStillPictureHeader) {\n seqLevel = bitstream.readBits(5);\n }\n else {\n // Parse timing_info_present_flag\n const timingInfoPresentFlag = bitstream.readBits(1);\n if (timingInfoPresentFlag) {\n // Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)\n bitstream.skipBits(32); // num_units_in_display_tick\n bitstream.skipBits(32); // time_scale\n const equalPictureInterval = bitstream.readBits(1);\n if (equalPictureInterval) {\n // Skip num_ticks_per_picture_minus_1 (uvlc)\n // Since this is variable length, we'd need to implement uvlc reading\n // For now, we'll return null as this is rare\n return null;\n }\n }\n // Parse decoder_model_info_present_flag\n const decoderModelInfoPresentFlag = bitstream.readBits(1);\n if (decoderModelInfoPresentFlag) {\n // Store buffer_delay_length_minus_1 instead of just skipping\n bufferDelayLengthMinus1 = bitstream.readBits(5);\n bitstream.skipBits(32); // num_units_in_decoding_tick\n bitstream.skipBits(5); // buffer_removal_time_length_minus_1\n bitstream.skipBits(5); // frame_presentation_time_length_minus_1\n }\n // Parse operating_points_cnt_minus_1\n const operatingPointsCntMinus1 = bitstream.readBits(5);\n // For each operating point\n for (let i = 0; i <= operatingPointsCntMinus1; i++) {\n // operating_point_idc[i]\n bitstream.skipBits(12);\n // seq_level_idx[i]\n const seqLevelIdx = bitstream.readBits(5);\n if (i === 0) {\n seqLevel = seqLevelIdx;\n }\n if (seqLevelIdx > 7) {\n // seq_tier[i]\n const seqTierTemp = bitstream.readBits(1);\n if (i === 0) {\n seqTier = seqTierTemp;\n }\n }\n if (decoderModelInfoPresentFlag) {\n // decoder_model_present_for_this_op[i]\n const decoderModelPresentForThisOp = bitstream.readBits(1);\n if (decoderModelPresentForThisOp) {\n const n = bufferDelayLengthMinus1 + 1;\n bitstream.skipBits(n); // decoder_buffer_delay[op]\n bitstream.skipBits(n); // encoder_buffer_delay[op]\n bitstream.skipBits(1); // low_delay_mode_flag[op]\n }\n }\n // initial_display_delay_present_flag\n const initialDisplayDelayPresentFlag = bitstream.readBits(1);\n if (initialDisplayDelayPresentFlag) {\n // initial_display_delay_minus_1[i]\n bitstream.skipBits(4);\n }\n }\n }\n // Frame size\n const frameWidthBitsMinus1 = bitstream.readBits(4);\n const frameHeightBitsMinus1 = bitstream.readBits(4);\n const n1 = frameWidthBitsMinus1 + 1;\n bitstream.skipBits(n1); // max_frame_width_minus_1\n const n2 = frameHeightBitsMinus1 + 1;\n bitstream.skipBits(n2); // max_frame_height_minus_1\n // Frame IDs\n let frameIdNumbersPresentFlag = 0;\n if (reducedStillPictureHeader) {\n frameIdNumbersPresentFlag = 0;\n }\n else {\n frameIdNumbersPresentFlag = bitstream.readBits(1);\n }\n if (frameIdNumbersPresentFlag) {\n bitstream.skipBits(4); // delta_frame_id_length_minus_2\n bitstream.skipBits(3); // additional_frame_id_length_minus_1\n }\n bitstream.skipBits(1); // use_128x128_superblock\n bitstream.skipBits(1); // enable_filter_intra\n bitstream.skipBits(1); // enable_intra_edge_filter\n if (!reducedStillPictureHeader) {\n bitstream.skipBits(1); // enable_interintra_compound\n bitstream.skipBits(1); // enable_masked_compound\n bitstream.skipBits(1); // enable_warped_motion\n bitstream.skipBits(1); // enable_dual_filter\n const enableOrderHint = bitstream.readBits(1);\n if (enableOrderHint) {\n bitstream.skipBits(1); // enable_jnt_comp\n bitstream.skipBits(1); // enable_ref_frame_mvs\n }\n const seqChooseScreenContentTools = bitstream.readBits(1);\n let seqForceScreenContentTools = 0;\n if (seqChooseScreenContentTools) {\n seqForceScreenContentTools = 2; // SELECT_SCREEN_CONTENT_TOOLS\n }\n else {\n seqForceScreenContentTools = bitstream.readBits(1);\n }\n if (seqForceScreenContentTools > 0) {\n const seqChooseIntegerMv = bitstream.readBits(1);\n if (!seqChooseIntegerMv) {\n bitstream.skipBits(1); // seq_force_integer_mv\n }\n }\n if (enableOrderHint) {\n bitstream.skipBits(3); // order_hint_bits_minus_1\n }\n }\n bitstream.skipBits(1); // enable_superres\n bitstream.skipBits(1); // enable_cdef\n bitstream.skipBits(1); // enable_restoration\n // color_config()\n const highBitdepth = bitstream.readBits(1);\n let bitDepth = 8;\n if (seqProfile === 2 && highBitdepth) {\n const twelveBit = bitstream.readBits(1);\n bitDepth = twelveBit ? 12 : 10;\n }\n else if (seqProfile <= 2) {\n bitDepth = highBitdepth ? 10 : 8;\n }\n let monochrome = 0;\n if (seqProfile !== 1) {\n monochrome = bitstream.readBits(1);\n }\n let chromaSubsamplingX = 1;\n let chromaSubsamplingY = 1;\n let chromaSamplePosition = 0;\n if (!monochrome) {\n if (seqProfile === 0) {\n chromaSubsamplingX = 1;\n chromaSubsamplingY = 1;\n }\n else if (seqProfile === 1) {\n chromaSubsamplingX = 0;\n chromaSubsamplingY = 0;\n }\n else {\n if (bitDepth === 12) {\n chromaSubsamplingX = bitstream.readBits(1);\n if (chromaSubsamplingX) {\n chromaSubsamplingY = bitstream.readBits(1);\n }\n }\n }\n if (chromaSubsamplingX && chromaSubsamplingY) {\n chromaSamplePosition = bitstream.readBits(2);\n }\n }\n return {\n profile: seqProfile,\n level: seqLevel,\n tier: seqTier,\n bitDepth,\n monochrome,\n chromaSubsamplingX,\n chromaSubsamplingY,\n chromaSamplePosition,\n };\n }\n return null;\n };\n const parseOpusIdentificationHeader = (bytes) => {\n const view = toDataView(bytes);\n const outputChannelCount = view.getUint8(9);\n const preSkip = view.getUint16(10, true);\n const inputSampleRate = view.getUint32(12, true);\n const outputGain = view.getInt16(16, true);\n const channelMappingFamily = view.getUint8(18);\n let channelMappingTable = null;\n if (channelMappingFamily) {\n channelMappingTable = bytes.subarray(19, 19 + 2 + outputChannelCount);\n }\n return {\n outputChannelCount,\n preSkip,\n inputSampleRate,\n outputGain,\n channelMappingFamily,\n channelMappingTable,\n };\n };\n // From https://datatracker.ietf.org/doc/html/rfc6716, in 48 kHz samples\n const OPUS_FRAME_DURATION_TABLE = [\n 480, 960, 1920, 2880,\n 480, 960, 1920, 2880,\n 480, 960, 1920, 2880,\n 480, 960,\n 480, 960,\n 120, 240, 480, 960,\n 120, 240, 480, 960,\n 120, 240, 480, 960,\n 120, 240, 480, 960,\n ];\n const parseOpusTocByte = (packet) => {\n const config = packet[0] >> 3;\n const code = packet[0] & 0b11;\n // A packet may pack more than one frame, in which case its duration is the frame duration times the number of\n // frames it carries. See https://datatracker.ietf.org/doc/html/rfc6716, section 3.2.\n let frameCount;\n if (code === 0) {\n frameCount = 1;\n }\n else if (code === 1 || code === 2) {\n frameCount = 2;\n }\n else {\n // Code 3: the frame count sits in the six low bits of the frame count byte\n frameCount = packet[1] & 0b111111;\n }\n return {\n durationInSamples: OPUS_FRAME_DURATION_TABLE[config] * frameCount,\n };\n };\n // Based on vorbis_parser.c from FFmpeg.\n const parseModesFromVorbisSetupPacket = (setupHeader) => {\n // Verify that this is a Setup header.\n if (setupHeader.length < 7) {\n throw new Error('Setup header is too short.');\n }\n if (setupHeader[0] !== 5) {\n throw new Error('Wrong packet type in Setup header.');\n }\n const signature = String.fromCharCode(...setupHeader.slice(1, 7));\n if (signature !== 'vorbis') {\n throw new Error('Invalid packet signature in Setup header.');\n }\n // Reverse the entire buffer.\n const bufSize = setupHeader.length;\n const revBuffer = new Uint8Array(bufSize);\n for (let i = 0; i < bufSize; i++) {\n revBuffer[i] = setupHeader[bufSize - 1 - i];\n }\n // Initialize a Bitstream on the reversed buffer.\n const bitstream = new Bitstream(revBuffer);\n // --- Find the framing bit.\n // In FFmpeg code, we scan until get_bits1() returns 1.\n let gotFramingBit = 0;\n while (bitstream.getBitsLeft() > 97) {\n if (bitstream.readBits(1) === 1) {\n gotFramingBit = bitstream.pos;\n break;\n }\n }\n if (gotFramingBit === 0) {\n throw new Error('Invalid Setup header: framing bit not found.');\n }\n // --- Search backwards for a valid mode header.\n // We try to “guess” the number of modes by reading a fixed pattern.\n let modeCount = 0;\n let gotModeHeader = false;\n let lastModeCount = 0;\n while (bitstream.getBitsLeft() >= 97) {\n const tempPos = bitstream.pos;\n const a = bitstream.readBits(8);\n const b = bitstream.readBits(16);\n const c = bitstream.readBits(16);\n // If a > 63 or b or c nonzero, assume we’ve gone too far.\n if (a > 63 || b !== 0 || c !== 0) {\n bitstream.pos = tempPos;\n break;\n }\n bitstream.skipBits(1);\n modeCount++;\n if (modeCount > 64) {\n break;\n }\n const bsClone = bitstream.clone();\n const candidate = bsClone.readBits(6) + 1;\n if (candidate === modeCount) {\n gotModeHeader = true;\n lastModeCount = modeCount;\n }\n }\n if (!gotModeHeader) {\n throw new Error('Invalid Setup header: mode header not found.');\n }\n if (lastModeCount > 63) {\n throw new Error(`Unsupported mode count: ${lastModeCount}.`);\n }\n const finalModeCount = lastModeCount;\n // --- Reinitialize the bitstream.\n bitstream.pos = 0;\n // Skip the bits up to the found framing bit.\n bitstream.skipBits(gotFramingBit);\n // --- Now read, for each mode (in reverse order), 40 bits then one bit.\n // That one bit is the mode blockflag.\n const modeBlockflags = Array(finalModeCount).fill(0);\n for (let i = finalModeCount - 1; i >= 0; i--) {\n bitstream.skipBits(40);\n modeBlockflags[i] = bitstream.readBits(1);\n }\n return { modeBlockflags };\n };\n /** Determines a packet's type (key or delta) by digging into the packet bitstream. */\n const determineVideoPacketType = (codec, decoderConfig, packetData) => {\n switch (codec) {\n case 'avc':\n {\n for (const loc of iterateAvcNalUnits(packetData, decoderConfig)) {\n const nalTypeByte = packetData[loc.offset];\n const type = extractNalUnitTypeForAvc(nalTypeByte);\n if (type >= AvcNalUnitType.NON_IDR_SLICE && type <= AvcNalUnitType.SLICE_DPC) {\n return 'delta';\n }\n if (type === AvcNalUnitType.IDR) {\n return 'key';\n }\n // In addition to IDR, Recovery Point SEI also counts as a valid H.264 keyframe by current consensus.\n // See https://github.com/w3c/webcodecs/issues/650 for the relevant discussion. WebKit and Firefox have\n // always supported them, but Chromium hasn't, therefore the (admittedly dirty) version check.\n if (type === AvcNalUnitType.SEI && (!isChromium() || getChromiumVersion() >= 144)) {\n const nalUnit = packetData.subarray(loc.offset, loc.offset + loc.length);\n const bytes = removeEmulationPreventionBytes(nalUnit);\n let pos = 1; // Skip NALU header\n // sei_rbsp()\n do {\n // sei_message()\n let payloadType = 0;\n while (true) {\n const nextByte = bytes[pos++];\n if (nextByte === undefined)\n break;\n payloadType += nextByte;\n if (nextByte < 255) {\n break;\n }\n }\n let payloadSize = 0;\n while (true) {\n const nextByte = bytes[pos++];\n if (nextByte === undefined)\n break;\n payloadSize += nextByte;\n if (nextByte < 255) {\n break;\n }\n }\n // sei_payload()\n const PAYLOAD_TYPE_RECOVERY_POINT = 6;\n if (payloadType === PAYLOAD_TYPE_RECOVERY_POINT) {\n const bitstream = new Bitstream(bytes);\n bitstream.pos = 8 * pos;\n const recoveryFrameCount = readExpGolomb(bitstream);\n const exactMatchFlag = bitstream.readBits(1);\n if (recoveryFrameCount === 0 && exactMatchFlag === 1) {\n // https://github.com/w3c/webcodecs/pull/910\n // \"recovery_frame_cnt == 0 and exact_match_flag=1 in the SEI recovery payload\"\n return 'key';\n }\n }\n pos += payloadSize;\n } while (pos < bytes.length - 1);\n }\n }\n return 'delta';\n }\n case 'hevc':\n {\n for (const loc of iterateHevcNalUnits(packetData, decoderConfig)) {\n const type = extractNalUnitTypeForHevc(packetData[loc.offset]);\n if (type < HevcNalUnitType.BLA_W_LP) {\n return 'delta';\n }\n if (type <= HevcNalUnitType.RSV_IRAP_VCL23) {\n return 'key';\n }\n }\n return 'delta';\n }\n case 'vp8':\n {\n // VP8, once again, by far the easiest to deal with.\n const frameType = packetData[0] & 0b1;\n return frameType === 0 ? 'key' : 'delta';\n }\n case 'vp9':\n {\n const bitstream = new Bitstream(packetData);\n if (bitstream.readBits(2) !== 2) {\n return null;\n }\n const profileLowBit = bitstream.readBits(1);\n const profileHighBit = bitstream.readBits(1);\n const profile = (profileHighBit << 1) + profileLowBit;\n // Skip reserved bit for profile 3\n if (profile === 3) {\n bitstream.skipBits(1);\n }\n const showExistingFrame = bitstream.readBits(1);\n if (showExistingFrame) {\n return null;\n }\n const frameType = bitstream.readBits(1);\n return frameType === 0 ? 'key' : 'delta';\n }\n case 'av1':\n {\n let reducedStillPictureHeader = false;\n for (const { type, data } of iterateAv1PacketObus(packetData)) {\n if (type === 1) { // OBU_SEQUENCE_HEADER\n const bitstream = new Bitstream(data);\n bitstream.skipBits(4);\n reducedStillPictureHeader = !!bitstream.readBits(1);\n }\n else if (type === 3 // OBU_FRAME_HEADER\n || type === 6 // OBU_FRAME\n || type === 7 // OBU_REDUNDANT_FRAME_HEADER\n ) {\n if (reducedStillPictureHeader) {\n return 'key';\n }\n const bitstream = new Bitstream(data);\n const showExistingFrame = bitstream.readBits(1);\n if (showExistingFrame) {\n return null;\n }\n const frameType = bitstream.readBits(2);\n return frameType === 0 ? 'key' : 'delta';\n }\n }\n return null;\n }\n case 'prores':\n {\n return 'key';\n }\n default:\n {\n assertNever(codec);\n assert(false);\n }\n }\n };\n var FlacBlockType;\n (function (FlacBlockType) {\n FlacBlockType[FlacBlockType[\"STREAMINFO\"] = 0] = \"STREAMINFO\";\n FlacBlockType[FlacBlockType[\"VORBIS_COMMENT\"] = 4] = \"VORBIS_COMMENT\";\n FlacBlockType[FlacBlockType[\"PICTURE\"] = 6] = \"PICTURE\";\n })(FlacBlockType || (FlacBlockType = {}));\n const readVorbisComments = (bytes, metadataTags) => {\n // https://datatracker.ietf.org/doc/html/rfc7845#section-5.2\n const commentView = toDataView(bytes);\n let commentPos = 0;\n const vendorStringLength = commentView.getUint32(commentPos, true);\n commentPos += 4;\n const vendorString = textDecoder.decode(bytes.subarray(commentPos, commentPos + vendorStringLength));\n commentPos += vendorStringLength;\n if (vendorStringLength > 0) {\n // Expose the vendor string in the raw metadata\n metadataTags.raw ??= {};\n metadataTags.raw['vendor'] ??= vendorString;\n }\n const listLength = commentView.getUint32(commentPos, true);\n commentPos += 4;\n // Loop over all metadata tags\n for (let i = 0; i < listLength; i++) {\n const stringLength = commentView.getUint32(commentPos, true);\n commentPos += 4;\n const string = textDecoder.decode(bytes.subarray(commentPos, commentPos + stringLength));\n commentPos += stringLength;\n const separatorIndex = string.indexOf('=');\n if (separatorIndex === -1) {\n continue;\n }\n const key = string.slice(0, separatorIndex).toUpperCase();\n const value = string.slice(separatorIndex + 1);\n metadataTags.raw ??= {};\n metadataTags.raw[key] ??= value;\n switch (key) {\n case 'TITLE':\n {\n metadataTags.title ??= value;\n }\n break;\n case 'DESCRIPTION':\n {\n metadataTags.description ??= value;\n }\n break;\n case 'ARTIST':\n {\n metadataTags.artist ??= value;\n }\n break;\n case 'ALBUM':\n {\n metadataTags.album ??= value;\n }\n break;\n case 'ALBUMARTIST':\n {\n metadataTags.albumArtist ??= value;\n }\n break;\n case 'COMMENT':\n {\n metadataTags.comment ??= value;\n }\n break;\n case 'LYRICS':\n {\n metadataTags.lyrics ??= value;\n }\n break;\n case 'TRACKNUMBER':\n {\n const parts = value.split('/');\n const trackNum = Number.parseInt(parts[0], 10);\n const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(trackNum) && trackNum > 0) {\n metadataTags.trackNumber ??= trackNum;\n }\n if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {\n metadataTags.tracksTotal ??= tracksTotal;\n }\n }\n break;\n case 'TRACKTOTAL':\n {\n const tracksTotal = Number.parseInt(value, 10);\n if (Number.isInteger(tracksTotal) && tracksTotal > 0) {\n metadataTags.tracksTotal ??= tracksTotal;\n }\n }\n break;\n case 'DISCNUMBER':\n {\n const parts = value.split('/');\n const discNum = Number.parseInt(parts[0], 10);\n const discsTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(discNum) && discNum > 0) {\n metadataTags.discNumber ??= discNum;\n }\n if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {\n metadataTags.discsTotal ??= discsTotal;\n }\n }\n break;\n case 'DISCTOTAL':\n {\n const discsTotal = Number.parseInt(value, 10);\n if (Number.isInteger(discsTotal) && discsTotal > 0) {\n metadataTags.discsTotal ??= discsTotal;\n }\n }\n break;\n case 'DATE':\n {\n const date = new Date(value);\n if (!Number.isNaN(date.getTime())) {\n metadataTags.date ??= date;\n }\n }\n break;\n case 'GENRE':\n {\n metadataTags.genre ??= value;\n }\n break;\n case 'METADATA_BLOCK_PICTURE':\n {\n // https://datatracker.ietf.org/doc/rfc9639/ Section 8.8\n const decoded = base64ToBytes(value);\n const view = toDataView(decoded);\n const pictureType = view.getUint32(0, false);\n const mediaTypeLength = view.getUint32(4, false);\n const mediaType = String.fromCharCode(...decoded.subarray(8, 8 + mediaTypeLength)); // ASCII\n const descriptionLength = view.getUint32(8 + mediaTypeLength, false);\n const description = textDecoder.decode(decoded.subarray(12 + mediaTypeLength, 12 + mediaTypeLength + descriptionLength));\n const dataLength = view.getUint32(mediaTypeLength + descriptionLength + 28);\n const data = decoded.subarray(mediaTypeLength + descriptionLength + 32, mediaTypeLength + descriptionLength + 32 + dataLength);\n metadataTags.images ??= [];\n metadataTags.images.push({\n data,\n mimeType: mediaType,\n kind: pictureType === 3 ? 'coverFront' : pictureType === 4 ? 'coverBack' : 'unknown',\n name: undefined,\n description: description || undefined,\n });\n }\n break;\n }\n }\n };\n // ============================================================================\n // AC-3 / E-AC-3 Parsing\n // Reference: ETSI TS 102 366 V1.4.1\n // ============================================================================\n /**\n * Channel counts indexed by acmod (Table 4.3).\n * Does NOT include LFE - add lfeon to get total channel count.\n */\n const AC3_ACMOD_CHANNEL_COUNTS = [2, 1, 2, 3, 3, 4, 4, 5];\n /**\n * Parse an AC-3 syncframe to extract BSI (Bit Stream Information) fields.\n * Section 4.3\n */\n const parseAc3SyncFrame = (data) => {\n if (data.length < 7) {\n return null;\n }\n // Check sync word (0x0B77)\n if (data[0] !== 0x0B || data[1] !== 0x77) {\n return null;\n }\n const bitstream = new Bitstream(data);\n bitstream.skipBits(16); // sync word\n bitstream.skipBits(16); // crc1\n const fscod = bitstream.readBits(2);\n if (fscod === 3) {\n return null; // Reserved, invalid\n }\n const frmsizecod = bitstream.readBits(6);\n const bsid = bitstream.readBits(5);\n // Verify this is AC-3\n if (bsid > 8) {\n return null;\n }\n const bsmod = bitstream.readBits(3);\n const acmod = bitstream.readBits(3);\n // Skip cmixlev (center downmix level) if three front channels are in use (L, C, R).\n if ((acmod & 0x1) !== 0 && acmod !== 0x1) {\n bitstream.skipBits(2);\n }\n // Skip surmixlev (surround downmix level) if surround channels are in use.\n if ((acmod & 0x4) !== 0) {\n bitstream.skipBits(2);\n }\n // Skip dsurmod if stereo (acmod === 2)\n if (acmod === 0x2) {\n bitstream.skipBits(2);\n }\n const lfeon = bitstream.readBits(1);\n const bitRateCode = Math.floor(frmsizecod / 2);\n return { fscod, bsid, bsmod, acmod, lfeon, bitRateCode };\n };\n /**\n * AC-3 frame sizes in bytes, indexed by [3 * frmsizecod + fscod].\n * fscod: 0=48kHz, 1=44.1kHz, 2=32kHz\n * Values are 16-bit words * 2 (to convert to bytes).\n * Table 4.13\n */\n const AC3_FRAME_SIZES = [\n // frmsizecod, [48kHz, 44.1kHz, 32kHz] in bytes\n 64 * 2, 69 * 2, 96 * 2,\n 64 * 2, 70 * 2, 96 * 2,\n 80 * 2, 87 * 2, 120 * 2,\n 80 * 2, 88 * 2, 120 * 2,\n 96 * 2, 104 * 2, 144 * 2,\n 96 * 2, 105 * 2, 144 * 2,\n 112 * 2, 121 * 2, 168 * 2,\n 112 * 2, 122 * 2, 168 * 2,\n 128 * 2, 139 * 2, 192 * 2,\n 128 * 2, 140 * 2, 192 * 2,\n 160 * 2, 174 * 2, 240 * 2,\n 160 * 2, 175 * 2, 240 * 2,\n 192 * 2, 208 * 2, 288 * 2,\n 192 * 2, 209 * 2, 288 * 2,\n 224 * 2, 243 * 2, 336 * 2,\n 224 * 2, 244 * 2, 336 * 2,\n 256 * 2, 278 * 2, 384 * 2,\n 256 * 2, 279 * 2, 384 * 2,\n 320 * 2, 348 * 2, 480 * 2,\n 320 * 2, 349 * 2, 480 * 2,\n 384 * 2, 417 * 2, 576 * 2,\n 384 * 2, 418 * 2, 576 * 2,\n 448 * 2, 487 * 2, 672 * 2,\n 448 * 2, 488 * 2, 672 * 2,\n 512 * 2, 557 * 2, 768 * 2,\n 512 * 2, 558 * 2, 768 * 2,\n 640 * 2, 696 * 2, 960 * 2,\n 640 * 2, 697 * 2, 960 * 2,\n 768 * 2, 835 * 2, 1152 * 2,\n 768 * 2, 836 * 2, 1152 * 2,\n 896 * 2, 975 * 2, 1344 * 2,\n 896 * 2, 976 * 2, 1344 * 2,\n 1024 * 2, 1114 * 2, 1536 * 2,\n 1024 * 2, 1115 * 2, 1536 * 2,\n 1152 * 2, 1253 * 2, 1728 * 2,\n 1152 * 2, 1254 * 2, 1728 * 2,\n 1280 * 2, 1393 * 2, 1920 * 2,\n 1280 * 2, 1394 * 2, 1920 * 2,\n ];\n /** Number of samples per AC-3 syncframe (always 1536) */\n const AC3_SAMPLES_PER_FRAME = 1536;\n /** Number of audio blocks per syncframe, indexed by numblkscod */\n const EAC3_NUMBLKS_TABLE = [1, 2, 3, 6];\n /**\n * Parse an E-AC-3 syncframe to extract BSI fields.\n * Section E.1.2\n */\n const parseEac3SyncFrame = (data) => {\n if (data.length < 6) {\n return null;\n }\n // Check sync word (0x0B77)\n if (data[0] !== 0x0B || data[1] !== 0x77) {\n return null;\n }\n const bitstream = new Bitstream(data);\n bitstream.skipBits(16); // sync word\n const strmtyp = bitstream.readBits(2);\n bitstream.skipBits(3); // substreamid\n // Only parse independent substreams (strmtyp 0 or 2)\n if (strmtyp !== 0 && strmtyp !== 2) {\n return null;\n }\n const frmsiz = bitstream.readBits(11);\n const fscod = bitstream.readBits(2);\n let fscod2 = 0;\n let numblkscod;\n if (fscod === 3) {\n // fscod2 enables reduced sample rates (24/22.05/16 kHz) per ATSC A/52:2018\n fscod2 = bitstream.readBits(2);\n numblkscod = 3; // Implicitly 6 blocks when fscod=3\n }\n else {\n numblkscod = bitstream.readBits(2);\n }\n const acmod = bitstream.readBits(3);\n const lfeon = bitstream.readBits(1);\n const bsid = bitstream.readBits(5);\n // Verify this is E-AC-3\n if (bsid < 11 || bsid > 16) {\n return null;\n }\n // Calculate data rate: ((frmsiz + 1) * fs) / (numblks * 16)\n const numblks = EAC3_NUMBLKS_TABLE[numblkscod];\n let fs;\n if (fscod < 3) {\n fs = AC3_SAMPLE_RATES[fscod] / 1000;\n }\n else {\n fs = EAC3_REDUCED_SAMPLE_RATES[fscod2] / 1000;\n }\n const dataRate = Math.round(((frmsiz + 1) * fs) / (numblks * 16));\n // These fields require parsing beyond the first frame.\n // Defaults are correct for almost all content.\n const bsmod = 0;\n const numDepSub = 0;\n const chanLoc = 0;\n const substream = {\n fscod,\n fscod2,\n bsid,\n bsmod,\n acmod,\n lfeon,\n numDepSub,\n chanLoc,\n };\n return {\n dataRate,\n substreams: [substream],\n };\n };\n /**\n * Parse a dec3 box to extract E-AC-3 parameters.\n * Section F.6\n */\n const parseEac3Config = (data) => {\n if (data.length < 2) {\n return null;\n }\n const bitstream = new Bitstream(data);\n const dataRate = bitstream.readBits(13);\n const numIndSub = bitstream.readBits(3);\n const substreams = [];\n for (let i = 0; i <= numIndSub; i++) {\n // Check we have enough data for this substream\n // Each substream needs at least 24 bits (3 bytes) without dependent subs\n if (Math.ceil(bitstream.pos / 8) + 3 > data.length) {\n break;\n }\n const fscod = bitstream.readBits(2);\n const bsid = bitstream.readBits(5);\n bitstream.skipBits(1); // reserved\n bitstream.skipBits(1); // asvc\n const bsmod = bitstream.readBits(3);\n const acmod = bitstream.readBits(3);\n const lfeon = bitstream.readBits(1);\n bitstream.skipBits(3); // reserved\n const numDepSub = bitstream.readBits(4);\n let chanLoc = 0;\n if (numDepSub > 0) {\n chanLoc = bitstream.readBits(9);\n }\n else {\n bitstream.skipBits(1); // reserved\n }\n substreams.push({\n fscod,\n fscod2: null,\n bsid,\n bsmod,\n acmod,\n lfeon,\n numDepSub,\n chanLoc,\n });\n }\n if (substreams.length === 0) {\n return null;\n }\n return { dataRate, substreams };\n };\n /**\n * Get sample rate from E-AC-3 config.\n * See ATSC A/52:2018 for handling fscod2.\n */\n const getEac3SampleRate = (config) => {\n const sub = config.substreams[0];\n assert(sub);\n if (sub.fscod < 3) {\n return AC3_SAMPLE_RATES[sub.fscod];\n }\n else if (sub.fscod2 !== null && sub.fscod2 < 3) {\n return EAC3_REDUCED_SAMPLE_RATES[sub.fscod2];\n }\n return null;\n };\n /**\n * Get channel count from E-AC-3 config (first independent substream only).\n */\n const getEac3ChannelCount = (config) => {\n const sub = config.substreams[0];\n assert(sub);\n let channels = AC3_ACMOD_CHANNEL_COUNTS[sub.acmod] + sub.lfeon;\n // Add channels from dependent substreams\n if (sub.numDepSub > 0) {\n const CHAN_LOC_COUNTS = [2, 2, 1, 1, 2, 2, 2, 1, 1];\n for (let bit = 0; bit < 9; bit++) {\n if (sub.chanLoc & (1 << (8 - bit))) {\n channels += CHAN_LOC_COUNTS[bit];\n }\n }\n }\n return channels;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class Demuxer {\n constructor(input) {\n this.input = input;\n }\n dispose() {\n // Can be overridden\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const PLACEHOLDER_DATA = /* #__PURE__ */ new Uint8Array(0);\n /**\n * Represents an encoded chunk of media. Mainly used as an expressive wrapper around WebCodecs API's\n * [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) and\n * [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk), but can also be used\n * standalone.\n * @group Packets\n * @public\n */\n class EncodedPacket {\n /** Creates a new {@link EncodedPacket} from raw bytes and timing information. */\n constructor(\n /**\n * The encoded data of this packet. For any given codec, this data must adhere to the format specified in the\n * Mediabunny Codec Registry.\n */\n data, \n /** The type of this packet. */\n type, \n /**\n * The presentation timestamp of this packet in seconds. May be negative. Samples with negative end timestamps\n * should not be presented.\n */\n timestamp, \n /** The duration of this packet in seconds. */\n duration, \n /**\n * The sequence number indicates the decode order of the packets. Packet A must be decoded before packet B if A\n * has a lower sequence number than B. If two packets have the same sequence number, they are the same packet.\n * Otherwise, sequence numbers are arbitrary and are not guaranteed to have any meaning besides their relative\n * ordering. Negative sequence numbers mean the sequence number is undefined.\n */\n sequenceNumber = -1, byteLength, sideData) {\n this.data = data;\n this.type = type;\n this.timestamp = timestamp;\n this.duration = duration;\n this.sequenceNumber = sequenceNumber;\n if (data === PLACEHOLDER_DATA && byteLength === undefined) {\n throw new Error('Internal error: byteLength must be explicitly provided when constructing metadata-only packets.');\n }\n if (byteLength === undefined) {\n byteLength = data.byteLength;\n }\n if (!(data instanceof Uint8Array)) {\n throw new TypeError('data must be a Uint8Array.');\n }\n if (type !== 'key' && type !== 'delta') {\n throw new TypeError('type must be either \"key\" or \"delta\".');\n }\n if (!Number.isFinite(timestamp)) {\n throw new TypeError('timestamp must be a number.');\n }\n if (!Number.isFinite(duration) || duration < 0) {\n throw new TypeError('duration must be a non-negative number.');\n }\n if (!Number.isFinite(sequenceNumber)) {\n throw new TypeError('sequenceNumber must be a number.');\n }\n if (!Number.isInteger(byteLength) || byteLength < 0) {\n throw new TypeError('byteLength must be a non-negative integer.');\n }\n if (sideData !== undefined && (typeof sideData !== 'object' || !sideData)) {\n throw new TypeError('sideData, when provided, must be an object.');\n }\n if (sideData?.alpha !== undefined && !(sideData.alpha instanceof Uint8Array)) {\n throw new TypeError('sideData.alpha, when provided, must be a Uint8Array.');\n }\n if (sideData?.alphaByteLength !== undefined\n && (!Number.isInteger(sideData.alphaByteLength) || sideData.alphaByteLength < 0)) {\n throw new TypeError('sideData.alphaByteLength, when provided, must be a non-negative integer.');\n }\n this.byteLength = byteLength;\n this.sideData = sideData ?? {};\n if (this.sideData.alpha && this.sideData.alphaByteLength === undefined) {\n this.sideData.alphaByteLength = this.sideData.alpha.byteLength;\n }\n }\n /**\n * If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. They are the\n * result of retrieving packets with {@link PacketRetrievalOptions.metadataOnly} set to `true`.\n */\n get isMetadataOnly() {\n return this.data === PLACEHOLDER_DATA;\n }\n /** The timestamp of this packet in microseconds. */\n get microsecondTimestamp() {\n return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);\n }\n /** The duration of this packet in microseconds. */\n get microsecondDuration() {\n return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);\n }\n /** Converts this packet to an\n * [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) for use with the\n * WebCodecs API. */\n toEncodedVideoChunk() {\n if (this.isMetadataOnly) {\n throw new TypeError('Metadata-only packets cannot be converted to a video chunk.');\n }\n if (typeof EncodedVideoChunk === 'undefined') {\n throw new Error('Your browser does not support EncodedVideoChunk.');\n }\n return new EncodedVideoChunk({\n data: this.data,\n type: this.type,\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration,\n });\n }\n /**\n * Converts this packet to an\n * [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) for use with the\n * WebCodecs API, using the alpha side data instead of the color data. Throws if no alpha side data is defined.\n */\n alphaToEncodedVideoChunk(type = this.type) {\n if (!this.sideData.alpha) {\n throw new TypeError('This packet does not contain alpha side data.');\n }\n if (this.isMetadataOnly) {\n throw new TypeError('Metadata-only packets cannot be converted to a video chunk.');\n }\n if (typeof EncodedVideoChunk === 'undefined') {\n throw new Error('Your browser does not support EncodedVideoChunk.');\n }\n return new EncodedVideoChunk({\n data: this.sideData.alpha,\n type,\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration,\n });\n }\n /** Converts this packet to an\n * [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk) for use with the\n * WebCodecs API. */\n toEncodedAudioChunk() {\n if (this.isMetadataOnly) {\n throw new TypeError('Metadata-only packets cannot be converted to an audio chunk.');\n }\n if (typeof EncodedAudioChunk === 'undefined') {\n throw new Error('Your browser does not support EncodedAudioChunk.');\n }\n return new EncodedAudioChunk({\n data: this.data,\n type: this.type,\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration,\n });\n }\n /**\n * Creates an {@link EncodedPacket} from an\n * [`EncodedVideoChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedVideoChunk) or\n * [`EncodedAudioChunk`](https://developer.mozilla.org/en-US/docs/Web/API/EncodedAudioChunk). This method is useful\n * for converting chunks from the WebCodecs API to `EncodedPacket` instances.\n */\n static fromEncodedChunk(chunk, sideData) {\n if (!(chunk instanceof EncodedVideoChunk || chunk instanceof EncodedAudioChunk)) {\n throw new TypeError('chunk must be an EncodedVideoChunk or EncodedAudioChunk.');\n }\n const data = new Uint8Array(chunk.byteLength);\n chunk.copyTo(data);\n return new EncodedPacket(data, chunk.type, chunk.timestamp / 1e6, (chunk.duration ?? 0) / 1e6, undefined, undefined, sideData);\n }\n /** Clones this packet while optionally modifying the new packet's data. */\n clone(options) {\n if (options !== undefined && (typeof options !== 'object' || options === null)) {\n throw new TypeError('options, when provided, must be an object.');\n }\n if (options?.data !== undefined && !(options.data instanceof Uint8Array)) {\n throw new TypeError('options.data, when provided, must be a Uint8Array.');\n }\n if (options?.type !== undefined && options.type !== 'key' && options.type !== 'delta') {\n throw new TypeError('options.type, when provided, must be either \"key\" or \"delta\".');\n }\n if (options?.timestamp !== undefined && !Number.isFinite(options.timestamp)) {\n throw new TypeError('options.timestamp, when provided, must be a number.');\n }\n if (options?.duration !== undefined && !Number.isFinite(options.duration)) {\n throw new TypeError('options.duration, when provided, must be a number.');\n }\n if (options?.sequenceNumber !== undefined && !Number.isFinite(options.sequenceNumber)) {\n throw new TypeError('options.sequenceNumber, when provided, must be a number.');\n }\n if (options?.sideData !== undefined && (typeof options.sideData !== 'object' || options.sideData === null)) {\n throw new TypeError('options.sideData, when provided, must be an object.');\n }\n return new EncodedPacket(options?.data ?? this.data, options?.type ?? this.type, options?.timestamp ?? this.timestamp, options?.duration ?? this.duration, options?.sequenceNumber ?? this.sequenceNumber, this.byteLength, options?.sideData ?? this.sideData);\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const buildIsobmffMimeType = (info) => {\n const base = info.hasVideo\n ? 'video/'\n : info.hasAudio\n ? 'audio/'\n : 'application/';\n let string = base + (info.isQuickTime ? 'quicktime' : 'mp4');\n if (info.codecStrings.length > 0) {\n const uniqueCodecMimeTypes = [...new Set(info.codecStrings)];\n string += `; codecs=\"${uniqueCodecMimeTypes.join(', ')}\"`;\n }\n return string;\n };\n const parsePsshBoxContents = (contents) => {\n const view = toDataView(contents);\n let pos = 0;\n const version = view.getUint8(pos);\n pos += 1;\n pos += 3; // Flags\n const systemId = bytesToHexString(contents.subarray(pos, pos + 16));\n pos += 16;\n let keyIds = null;\n if (version > 0) {\n const kidCount = view.getUint32(pos);\n pos += 4;\n if (kidCount > 0) {\n keyIds = [];\n for (let i = 0; i < kidCount; i++) {\n keyIds.push(bytesToHexString(contents.subarray(pos, pos + 16)));\n pos += 16;\n }\n }\n }\n const dataSize = view.getUint32(pos);\n pos += 4;\n return {\n systemId,\n keyIds,\n data: contents.slice(pos, pos + dataSize),\n };\n };\n const psshBoxesAreEqual = (a, b) => (a.systemId === b.systemId\n && uint8ArraysAreEqual(a.data, b.data));\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const MIN_BOX_HEADER_SIZE = 8;\n const MAX_BOX_HEADER_SIZE = 16;\n const readBoxHeader = (slice) => {\n let totalSize = readU32Be(slice);\n const name = readAscii(slice, 4);\n let headerSize = 8;\n const hasLargeSize = totalSize === 1;\n if (hasLargeSize) {\n totalSize = readU64Be(slice);\n headerSize = 16;\n }\n const contentSize = totalSize - headerSize;\n if (contentSize < 0) {\n return null; // Hardly a box is it\n }\n return { name, totalSize, headerSize, contentSize };\n };\n const readFixed_16_16 = (slice) => {\n return readI32Be(slice) / 0x10000;\n };\n const readFixed_2_30 = (slice) => {\n return readI32Be(slice) / 0x40000000;\n };\n const readIsomVariableInteger = (slice) => {\n let result = 0;\n for (let i = 0; i < 4; i++) {\n result <<= 7;\n const nextByte = readU8(slice);\n result |= nextByte & 0x7f;\n if ((nextByte & 0x80) === 0) {\n break;\n }\n }\n return result;\n };\n const readMetadataStringShort = (slice) => {\n let stringLength = readU16Be(slice);\n slice.skip(2); // Language\n stringLength = Math.min(stringLength, slice.remainingLength);\n return textDecoder.decode(readBytes(slice, stringLength));\n };\n const readDataBox = (slice) => {\n const header = readBoxHeader(slice);\n if (!header || header.name !== 'data') {\n return null;\n }\n if (slice.remainingLength < 8) {\n // Box is too small\n return null;\n }\n const typeIndicator = readU32Be(slice);\n slice.skip(4); // Locale indicator\n const data = readBytes(slice, header.contentSize - 8);\n switch (typeIndicator) {\n case 1: return textDecoder.decode(data); // UTF-8\n case 2: return new TextDecoder('utf-16be').decode(data); // UTF-16-BE\n case 13: return new RichImageData(data, 'image/jpeg');\n case 14: return new RichImageData(data, 'image/png');\n case 27: return new RichImageData(data, 'image/bmp');\n default: return data;\n }\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n // Inspired in part by https://github.com/halloweeks/AES-128-CBC/blob/main/AES_128_CBC.h\n const AES_128_BLOCK_SIZE = 16;\n const Te4 = new Uint32Array(256);\n const Td0 = new Uint32Array(256);\n const Td1 = new Uint32Array(256);\n const Td2 = new Uint32Array(256);\n const Td3 = new Uint32Array(256);\n const Td4 = new Uint32Array(256);\n const rcon = new Uint32Array(10);\n let tablesGenerated = false;\n // Generating the tables once is much more bundle size-efficient than shipping them in the bundle (entropy ftw)\n const generateAesTables = () => {\n const sbox = new Uint8Array(256);\n const log = new Uint8Array(256);\n const pow = new Uint8Array(256);\n // 1. Generate GF(2^8) log/exp tables\n // Primitive polynomial: x^8 + x^4 + x^3 + x + 1 (0x11B)\n for (let i = 0, p = 1; i < 256; i++) {\n pow[i] = p;\n log[p] = i;\n p = p ^ (p << 1) ^ (p & 0x80 ? 0x11B : 0);\n }\n // Helper: GF(2^8) multiplication\n const mul = (a, b) => (a && b) ? pow[(log[a] + log[b]) % 255] : 0;\n // 2. Generate S-Box and Inverse S-Box\n sbox[0] = 0x63; // Special case for 0\n // Loop for inverse (using log/exp) and Affine Transform\n for (let i = 1; i < 256; i++) {\n const x = pow[255 - log[i]]; // Multiplicative inverse\n let s = x ^ (x << 1) ^ (x << 2) ^ (x << 3) ^ (x << 4);\n s = (s >>> 8) ^ (s & 0xFF) ^ 0x63; // Affine transform\n sbox[i] = s;\n }\n // 3. Fill Tables\n for (let i = 0; i < 256; i++) {\n const s = sbox[i]; // Forward S-Box value\n const is = sbox.indexOf(i); // Inverse S-Box value\n // Te4: Forward S-Box packed\n Te4[i] = (s << 24) | (s << 16) | (s << 8) | s;\n // Td4: Inverse S-Box packed\n Td4[i] = (is << 24) | (is << 16) | (is << 8) | is;\n // Td0-Td3: Inverse MixColumns applied to Inverse S-Box\n // Coefficients: 0x0E, 0x09, 0x0D, 0x0B (Order specific to Td0 structure)\n const b0 = mul(is, 0x0E);\n const b1 = mul(is, 0x09);\n const b2 = mul(is, 0x0D);\n const b3 = mul(is, 0x0B);\n const w = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;\n Td0[i] = w;\n Td1[i] = (w >>> 8) | (w << 24); // Rotate right 8\n Td2[i] = (w >>> 16) | (w << 16); // Rotate right 16\n Td3[i] = (w >>> 24) | (w << 8); // Rotate right 24\n }\n // 4. Generate Rcon\n let r = 1;\n for (let i = 0; i < 10; i++) {\n rcon[i] = r << 24;\n r = (r << 1) ^ (r & 0x80 ? 0x11B : 0);\n }\n tablesGenerated = true;\n };\n /** A context for doing AES-128-CBC operations. Better than the Web Crypto API since we can stream it. */\n class Aes128CbcContext {\n constructor() {\n this.roundkey = new Uint32Array(44);\n this.iv = new Uint32Array(AES_128_BLOCK_SIZE / Uint32Array.BYTES_PER_ELEMENT);\n this.in = new Uint8Array(AES_128_BLOCK_SIZE);\n this.out = new Uint8Array(AES_128_BLOCK_SIZE);\n this.inView = new DataView(this.in.buffer);\n this.outView = new DataView(this.out.buffer);\n }\n init({ key, iv }) {\n assert(key.byteLength === 16);\n assert(iv.byteLength === 16);\n if (!tablesGenerated) {\n generateAesTables();\n }\n const keyView = new DataView(key.buffer, key.byteOffset, key.byteLength);\n const ivView = new DataView(iv.buffer, iv.byteOffset, iv.byteLength);\n this.roundkey[0] = keyView.getUint32(0, false);\n this.roundkey[1] = keyView.getUint32(4, false);\n this.roundkey[2] = keyView.getUint32(8, false);\n this.roundkey[3] = keyView.getUint32(12, false);\n this.iv[0] = ivView.getUint32(0, false);\n this.iv[1] = ivView.getUint32(4, false);\n this.iv[2] = ivView.getUint32(8, false);\n this.iv[3] = ivView.getUint32(12, false);\n for (let index = 4; index < 44; index += 4) {\n const temp = this.roundkey[index - 1];\n this.roundkey[index] = this.roundkey[index - 4]\n ^ (Te4[(temp >>> 16) & 0xff] & 0xff000000)\n ^ (Te4[(temp >>> 8) & 0xff] & 0x00ff0000)\n ^ (Te4[(temp >>> 0) & 0xff] & 0x0000ff00)\n ^ (Te4[(temp >>> 24) & 0xff] & 0x000000ff)\n ^ rcon[(index / 4) - 1];\n this.roundkey[index + 1] = this.roundkey[index - 3] ^ this.roundkey[index];\n this.roundkey[index + 2] = this.roundkey[index - 2] ^ this.roundkey[index + 1];\n this.roundkey[index + 3] = this.roundkey[index - 1] ^ this.roundkey[index + 2];\n }\n // Invert the order of the round keys\n for (let i = 0, j = 40; i < j; i += 4, j -= 4) {\n for (let k = 0; k < 4; k++) {\n const temp = this.roundkey[i + k];\n this.roundkey[i + k] = this.roundkey[j + k];\n this.roundkey[j + k] = temp;\n }\n }\n // Apply Inverse MixColumn transform to all round keys except first and last\n for (let index = 4; index < 40; index += 4) {\n for (let k = 0; k < 4; k++) {\n const rk = this.roundkey[index + k];\n this.roundkey[index + k]\n = Td0[Te4[(rk >>> 24) & 0xff] & 0xff]\n ^ Td1[Te4[(rk >>> 16) & 0xff] & 0xff]\n ^ Td2[Te4[(rk >>> 8) & 0xff] & 0xff]\n ^ Td3[Te4[(rk >>> 0) & 0xff] & 0xff];\n }\n }\n }\n decrypt() {\n let s0 = this.inView.getUint32(0, false) ^ this.roundkey[0];\n let s1 = this.inView.getUint32(4, false) ^ this.roundkey[1];\n let s2 = this.inView.getUint32(8, false) ^ this.roundkey[2];\n let s3 = this.inView.getUint32(12, false) ^ this.roundkey[3];\n // Store input for CBC XOR later\n const temp0 = this.inView.getUint32(0, false);\n const temp1 = this.inView.getUint32(4, false);\n const temp2 = this.inView.getUint32(8, false);\n const temp3 = this.inView.getUint32(12, false);\n let t0, t1, t2, t3;\n // Rounds 1-9\n for (let round = 1; round < 10; round++) {\n const offset = round * 4;\n t0 = Td0[s0 >>> 24]\n ^ Td1[(s3 >>> 16) & 0xff]\n ^ Td2[(s2 >>> 8) & 0xff]\n ^ Td3[s1 & 0xff]\n ^ this.roundkey[offset];\n t1 = Td0[s1 >>> 24]\n ^ Td1[(s0 >>> 16) & 0xff]\n ^ Td2[(s3 >>> 8) & 0xff]\n ^ Td3[s2 & 0xff]\n ^ this.roundkey[offset + 1];\n t2 = Td0[s2 >>> 24]\n ^ Td1[(s1 >>> 16) & 0xff]\n ^ Td2[(s0 >>> 8) & 0xff]\n ^ Td3[s3 & 0xff]\n ^ this.roundkey[offset + 2];\n t3 = Td0[s3 >>> 24]\n ^ Td1[(s2 >>> 16) & 0xff]\n ^ Td2[(s1 >>> 8) & 0xff]\n ^ Td3[s0 & 0xff]\n ^ this.roundkey[offset + 3];\n s0 = t0;\n s1 = t1;\n s2 = t2;\n s3 = t3;\n }\n // Final Round (10)\n const f0 = (Td4[(s0 >>> 24) & 0xff] & 0xff000000)\n ^ (Td4[(s3 >>> 16) & 0xff] & 0x00ff0000)\n ^ (Td4[(s2 >>> 8) & 0xff] & 0x0000ff00)\n ^ (Td4[(s1 >>> 0) & 0xff] & 0x000000ff)\n ^ this.roundkey[40];\n const f1 = (Td4[(s1 >>> 24) & 0xff] & 0xff000000)\n ^ (Td4[(s0 >>> 16) & 0xff] & 0x00ff0000)\n ^ (Td4[(s3 >>> 8) & 0xff] & 0x0000ff00)\n ^ (Td4[(s2 >>> 0) & 0xff] & 0x000000ff)\n ^ this.roundkey[41];\n const f2 = (Td4[(s2 >>> 24) & 0xff] & 0xff000000)\n ^ (Td4[(s1 >>> 16) & 0xff] & 0x00ff0000)\n ^ (Td4[(s0 >>> 8) & 0xff] & 0x0000ff00)\n ^ (Td4[(s3 >>> 0) & 0xff] & 0x000000ff)\n ^ this.roundkey[42];\n const f3 = (Td4[(s3 >>> 24) & 0xff] & 0xff000000)\n ^ (Td4[(s2 >>> 16) & 0xff] & 0x00ff0000)\n ^ (Td4[(s1 >>> 8) & 0xff] & 0x0000ff00)\n ^ (Td4[(s0 >>> 0) & 0xff] & 0x000000ff)\n ^ this.roundkey[43];\n // CBC XOR and output\n this.outView.setUint32(0, f0 ^ this.iv[0], false);\n this.outView.setUint32(4, f1 ^ this.iv[1], false);\n this.outView.setUint32(8, f2 ^ this.iv[2], false);\n this.outView.setUint32(12, f3 ^ this.iv[3], false);\n // Update IV for next block\n this.iv[0] = temp0;\n this.iv[1] = temp1;\n this.iv[2] = temp2;\n this.iv[3] = temp3;\n }\n }\n const createAes128CbcDecryptStream = (reader, getInit, close) => {\n let initted = false;\n let pos = 0;\n const CHUNK_SIZE = 2 ** 16;\n const BLOCK_SIZE = 16;\n const aesContext = new Aes128CbcContext();\n return new ReadableStream({\n pull: async (controller) => {\n if (!initted) {\n aesContext.init(await getInit());\n initted = true;\n }\n const requestedLength = CHUNK_SIZE + BLOCK_SIZE;\n let nextSlice = reader.requestSliceRange(pos, 0, requestedLength);\n if (nextSlice instanceof Promise)\n nextSlice = await nextSlice;\n if (!nextSlice || nextSlice.length === 0) {\n // Due to padding, this should never happen\n throw new Error('Invalid ciphertext.');\n }\n const sliceLength = nextSlice.length;\n if (sliceLength % 16 !== 0) {\n throw new Error('Invalid ciphertext.');\n }\n const bytesToRead = sliceLength === requestedLength\n ? sliceLength - BLOCK_SIZE // Don't read the last block\n : sliceLength;\n const input = readBytes(nextSlice, bytesToRead);\n const output = new Uint8Array(bytesToRead);\n for (let i = 0; i < bytesToRead; i += 16) {\n aesContext.in.set(input.subarray(i, i + 16));\n aesContext.decrypt();\n output.set(aesContext.out, i);\n }\n if (bytesToRead < sliceLength) {\n controller.enqueue(output);\n pos += bytesToRead;\n }\n else {\n // This is the last chunk\n const paddingLength = output[bytesToRead - 1];\n if (paddingLength === 0 || paddingLength > 16) {\n throw new Error('Invalid PKCS#7 padding. Incorrect key or corrupted data.');\n }\n const trimmedOutput = output.subarray(0, bytesToRead - paddingLength); // PKCS#7 padding\n controller.enqueue(trimmedOutput);\n controller.close();\n close();\n }\n },\n cancel: () => {\n close();\n },\n });\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class IsobmffDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.moovSlice = null;\n this.currentTrack = null;\n this.tracks = [];\n this.metadataPromise = null;\n this.movieTimescale = -1;\n this.movieDurationInTimescale = -1;\n this.isQuickTime = false;\n this.metadataTags = {};\n this.currentMetadataKeys = null;\n this.isFragmented = false;\n this.fragmentTrackDefaults = [];\n this.psshBoxes = [];\n this.currentFragment = null;\n /**\n * Caches the last fragment that was read. Based on the assumption that there will be multiple reads to the\n * same fragment in quick succession.\n */\n this.lastReadFragment = null;\n this.decryptionKeyCache = new Map();\n this.reader = input._reader;\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.tracks.map(track => track.trackBacking);\n }\n async getMimeType() {\n await this.readMetadata();\n const backings = await this.getTrackBackings();\n const codecStrings = await Promise.all(backings.map(x => x.getDecoderConfig().then(c => c?.codec ?? null)));\n return buildIsobmffMimeType({\n isQuickTime: this.isQuickTime,\n hasVideo: this.tracks.some(x => x.info?.type === 'video'),\n hasAudio: this.tracks.some(x => x.info?.type === 'audio'),\n codecStrings: codecStrings.filter(Boolean),\n });\n }\n async getMetadataTags() {\n await this.readMetadata();\n return this.metadataTags;\n }\n readMetadata() {\n return this.metadataPromise ??= (async () => {\n let currentPos = 0;\n let lookForMfraBox = false;\n while (true) {\n let slice = this.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const startPos = currentPos;\n const boxInfo = readBoxHeader(slice);\n if (!boxInfo) {\n break;\n }\n if (boxInfo.name === 'ftyp' || boxInfo.name === 'styp') {\n const majorBrand = readAscii(slice, 4);\n this.isQuickTime = majorBrand === 'qt ';\n }\n else if (boxInfo.name === 'moov') {\n // Found moov, load it\n let moovSlice = this.reader.requestSlice(slice.filePos, boxInfo.contentSize);\n if (moovSlice instanceof Promise)\n moovSlice = await moovSlice;\n if (!moovSlice)\n break;\n this.moovSlice = moovSlice;\n this.readContiguousBoxes(this.moovSlice);\n for (const track of this.tracks) {\n // Modify the edit list offset based on the previous segment durations. They are in different\n // timescales, so we first convert to seconds and then into the track timescale.\n const previousSegmentDurationsInSeconds = track.editListPreviousSegmentDurations / this.movieTimescale;\n track.editListOffset -= Math.round(previousSegmentDurationsInSeconds * track.timescale);\n }\n lookForMfraBox = this.isFragmented\n && this.reader.fileSize !== null\n && this.reader.fileSize > startPos + boxInfo.totalSize; // There's more after the moov box\n break;\n }\n else if (boxInfo.name === 'moof') {\n if (!this.input._initInput) {\n throw new Error('\"moof\" box encountered with no \"moov\" box present; this file is likely a Segment as'\n + ' described in ISO/IEC 14496-12 Section 8.16. A separate init file that contains a \"moov\"'\n + ' box is required to read this file, please provide it using InputOptions.initInput.');\n }\n const initDemuxer = (await this.input._initInput._getDemuxer());\n if (initDemuxer.constructor !== IsobmffDemuxer) {\n throw new Error('Init input must match the input\\'s format.');\n }\n await initDemuxer.readMetadata();\n this.movieTimescale = initDemuxer.movieTimescale;\n this.movieDurationInTimescale = initDemuxer.movieDurationInTimescale;\n this.metadataTags = initDemuxer.metadataTags;\n this.isFragmented = true;\n this.fragmentTrackDefaults = initDemuxer.fragmentTrackDefaults;\n this.psshBoxes = initDemuxer.psshBoxes;\n // Create tracks from the init input's tracks\n for (const foreignTrack of initDemuxer.tracks) {\n const track = {\n id: foreignTrack.id,\n demuxer: this,\n trackBacking: null,\n disposition: foreignTrack.disposition,\n timescale: foreignTrack.timescale,\n durationInMediaTimescale: foreignTrack.durationInMediaTimescale,\n durationInMovieTimescale: foreignTrack.durationInMovieTimescale,\n rotation: foreignTrack.rotation,\n internalCodecId: foreignTrack.internalCodecId,\n name: foreignTrack.name,\n languageCode: foreignTrack.languageCode,\n sampleTableByteOffset: null,\n sampleTable: null,\n fragmentLookupTable: [],\n currentFragmentState: null,\n fragmentPositionCache: [],\n editListPreviousSegmentDurations: foreignTrack.editListPreviousSegmentDurations,\n editListOffset: foreignTrack.editListOffset,\n encryptionInfo: foreignTrack.encryptionInfo,\n encryptionAuxInfo: null,\n frmaCodecString: null,\n info: foreignTrack.info,\n };\n if (foreignTrack.trackBacking) {\n assert(track.info);\n if (track.info.type === 'video' && track.info.width !== -1) {\n const videoTrack = track;\n track.trackBacking = new IsobmffVideoTrackBacking(videoTrack);\n this.tracks.push(track);\n }\n else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) {\n const audioTrack = track;\n track.trackBacking = new IsobmffAudioTrackBacking(audioTrack);\n this.tracks.push(track);\n }\n }\n }\n lookForMfraBox = false; // No point in doing it for segment files\n break;\n }\n currentPos = startPos + boxInfo.totalSize;\n }\n if (lookForMfraBox) {\n assert(this.reader.fileSize !== null);\n // The last 4 bytes may contain the size of the mfra box at the end of the file\n let lastWordSlice = this.reader.requestSlice(this.reader.fileSize - 4, 4);\n if (lastWordSlice instanceof Promise)\n lastWordSlice = await lastWordSlice;\n assert(lastWordSlice);\n const lastWord = readU32Be(lastWordSlice);\n const potentialMfraPos = this.reader.fileSize - lastWord;\n if (potentialMfraPos >= 0 && potentialMfraPos <= this.reader.fileSize - MAX_BOX_HEADER_SIZE) {\n let mfraHeaderSlice = this.reader.requestSliceRange(potentialMfraPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);\n if (mfraHeaderSlice instanceof Promise)\n mfraHeaderSlice = await mfraHeaderSlice;\n if (mfraHeaderSlice) {\n const boxInfo = readBoxHeader(mfraHeaderSlice);\n if (boxInfo && boxInfo.name === 'mfra') {\n // We found the mfra box, allowing for much better random access. Let's parse it.\n let mfraSlice = this.reader.requestSlice(mfraHeaderSlice.filePos, boxInfo.contentSize);\n if (mfraSlice instanceof Promise)\n mfraSlice = await mfraSlice;\n if (mfraSlice) {\n this.readContiguousBoxes(mfraSlice);\n }\n }\n }\n }\n }\n })();\n }\n getSampleTableForTrack(internalTrack) {\n if (internalTrack.sampleTable) {\n return internalTrack.sampleTable;\n }\n const sampleTable = {\n sampleTimingEntries: [],\n sampleCompositionTimeOffsets: [],\n sampleSizes: [],\n keySampleIndices: null,\n chunkOffsets: [],\n sampleToChunk: [],\n presentationTimestamps: null,\n presentationTimestampIndexMap: null,\n };\n internalTrack.sampleTable = sampleTable;\n if (internalTrack.sampleTableByteOffset === null) {\n // There's no sample table to read, it's in another file (happens with segments)\n return sampleTable;\n }\n assert(this.moovSlice);\n const stblContainerSlice = this.moovSlice.slice(internalTrack.sampleTableByteOffset);\n this.currentTrack = internalTrack;\n this.traverseBox(stblContainerSlice);\n this.currentTrack = null;\n const isPcmCodec = internalTrack.info?.type === 'audio'\n && internalTrack.info.codec\n && PCM_AUDIO_CODECS.includes(internalTrack.info.codec);\n if (isPcmCodec && sampleTable.sampleCompositionTimeOffsets.length === 0) {\n // If the audio has PCM samples, the way the samples are defined in the sample table is somewhat\n // suboptimal: Each individual audio sample is its own sample, meaning we can have 48000 samples per second.\n // Because we treat each sample as its own atomic unit that can be decoded, this would lead to a huge\n // amount of very short samples for PCM audio. So instead, we make a transformation: If the audio is in PCM,\n // we say that each chunk (that normally holds many samples) now is one big sample. We can this because\n // the samples in the chunk are contiguous and the format is PCM, so the entire chunk as one thing still\n // encodes valid audio information.\n assert(internalTrack.info?.type === 'audio');\n const pcmInfo = parsePcmCodec(internalTrack.info.codec);\n const newSampleTimingEntries = [];\n const newSampleSizes = [];\n for (let i = 0; i < sampleTable.sampleToChunk.length; i++) {\n const chunkEntry = sampleTable.sampleToChunk[i];\n const nextEntry = sampleTable.sampleToChunk[i + 1];\n const chunkCount = (nextEntry ? nextEntry.startChunkIndex : sampleTable.chunkOffsets.length)\n - chunkEntry.startChunkIndex;\n for (let j = 0; j < chunkCount; j++) {\n const startSampleIndex = chunkEntry.startSampleIndex + j * chunkEntry.samplesPerChunk;\n const endSampleIndex = startSampleIndex + chunkEntry.samplesPerChunk; // Exclusive, outside of chunk\n const startTimingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, startSampleIndex, x => x.startIndex);\n const startTimingEntry = sampleTable.sampleTimingEntries[startTimingEntryIndex];\n const endTimingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, endSampleIndex, x => x.startIndex);\n const endTimingEntry = sampleTable.sampleTimingEntries[endTimingEntryIndex];\n const firstSampleTimestamp = startTimingEntry.startDecodeTimestamp\n + (startSampleIndex - startTimingEntry.startIndex) * startTimingEntry.delta;\n const lastSampleTimestamp = endTimingEntry.startDecodeTimestamp\n + (endSampleIndex - endTimingEntry.startIndex) * endTimingEntry.delta;\n const delta = lastSampleTimestamp - firstSampleTimestamp;\n const lastSampleTimingEntry = last(newSampleTimingEntries);\n if (lastSampleTimingEntry && lastSampleTimingEntry.delta === delta) {\n lastSampleTimingEntry.count++;\n }\n else {\n // One sample for the entire chunk\n newSampleTimingEntries.push({\n startIndex: chunkEntry.startChunkIndex + j,\n startDecodeTimestamp: firstSampleTimestamp,\n count: 1,\n delta,\n });\n }\n // Instead of determining the chunk's size by looping over the samples sizes in the sample table, we\n // can directly compute it as we know how many PCM frames are in this chunk, and the size of each\n // PCM frame. This also improves compatibility with some files which fail to write proper sample\n // size values into their sample tables in the PCM case.\n const chunkSize = chunkEntry.samplesPerChunk\n * pcmInfo.sampleSize\n * internalTrack.info.numberOfChannels;\n newSampleSizes.push(chunkSize);\n }\n chunkEntry.startSampleIndex = chunkEntry.startChunkIndex;\n chunkEntry.samplesPerChunk = 1;\n }\n sampleTable.sampleTimingEntries = newSampleTimingEntries;\n sampleTable.sampleSizes = newSampleSizes;\n }\n if (sampleTable.sampleCompositionTimeOffsets.length > 0) {\n // If composition time offsets are defined, we must build a list of all presentation timestamps and then\n // sort them\n sampleTable.presentationTimestamps = [];\n for (const entry of sampleTable.sampleTimingEntries) {\n for (let i = 0; i < entry.count; i++) {\n sampleTable.presentationTimestamps.push({\n presentationTimestamp: entry.startDecodeTimestamp + i * entry.delta,\n sampleIndex: entry.startIndex + i,\n });\n }\n }\n for (const entry of sampleTable.sampleCompositionTimeOffsets) {\n for (let i = 0; i < entry.count; i++) {\n const sampleIndex = entry.startIndex + i;\n const sample = sampleTable.presentationTimestamps[sampleIndex];\n if (!sample) {\n continue;\n }\n sample.presentationTimestamp += entry.offset;\n }\n }\n sampleTable.presentationTimestamps.sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);\n sampleTable.presentationTimestampIndexMap = Array(sampleTable.presentationTimestamps.length).fill(-1);\n for (let i = 0; i < sampleTable.presentationTimestamps.length; i++) {\n sampleTable.presentationTimestampIndexMap[sampleTable.presentationTimestamps[i].sampleIndex] = i;\n }\n }\n return sampleTable;\n }\n async readFragment(startPos) {\n if (this.lastReadFragment?.moofOffset === startPos) {\n return this.lastReadFragment;\n }\n let headerSlice = this.reader.requestSliceRange(startPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n assert(headerSlice);\n const moofBoxInfo = readBoxHeader(headerSlice);\n assert(moofBoxInfo?.name === 'moof');\n let entireSlice = this.reader.requestSlice(startPos, moofBoxInfo.totalSize);\n if (entireSlice instanceof Promise)\n entireSlice = await entireSlice;\n assert(entireSlice);\n this.traverseBox(entireSlice);\n const fragment = this.lastReadFragment;\n assert(fragment && fragment.moofOffset === startPos);\n for (const [, trackData] of fragment.trackData) {\n const track = trackData.track;\n const { fragmentPositionCache } = track;\n if (!trackData.startTimestampIsFinal) {\n // It may be that some tracks don't define the base decode time, i.e. when the fragment begins. This\n // we'll need to figure out the start timestamp another way. We'll compute the timestamp by accessing\n // the lookup entries and fragment cache, which works out nicely with the lookup algorithm: If these\n // exist, then the lookup will automatically start at the furthest possible point. If they don't, the\n // lookup starts sequentially from the start, incrementally summing up all fragment durations. It's sort\n // of implicit, but it ends up working nicely.\n const lookupEntry = track.fragmentLookupTable.find(x => x.moofOffset === fragment.moofOffset);\n if (lookupEntry) {\n // There's a lookup entry, let's use its timestamp\n offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp);\n }\n else {\n const lastCacheIndex = binarySearchLessOrEqual(fragmentPositionCache, fragment.moofOffset - 1, x => x.moofOffset);\n if (lastCacheIndex !== -1) {\n // Let's use the timestamp of the previous fragment in the cache\n const lastCache = fragmentPositionCache[lastCacheIndex];\n offsetFragmentTrackDataByTimestamp(trackData, lastCache.endTimestamp);\n }\n }\n trackData.startTimestampIsFinal = true;\n }\n // Let's remember that a fragment with a given timestamp is here, speeding up future lookups if no\n // lookup table exists\n const insertionIndex = binarySearchLessOrEqual(fragmentPositionCache, trackData.startTimestamp, x => x.startTimestamp);\n if (insertionIndex === -1\n || fragmentPositionCache[insertionIndex].moofOffset !== fragment.moofOffset) {\n fragmentPositionCache.splice(insertionIndex + 1, 0, {\n moofOffset: fragment.moofOffset,\n startTimestamp: trackData.startTimestamp,\n endTimestamp: trackData.endTimestamp,\n });\n }\n // If senc wasn't parsed but saiz+saio were, fetch the aux info now and stamp each sample with it\n if (trackData.encryptionAuxInfo && track.encryptionInfo) {\n const entries = await resolveEncryptionAuxInfo(this.reader, track.encryptionInfo, trackData.encryptionAuxInfo);\n for (let i = 0; i < Math.min(trackData.samples.length, entries.length); i++) {\n const entry = entries[i];\n trackData.samples[i].encryption = entry;\n }\n }\n }\n return fragment;\n }\n readContiguousBoxes(slice) {\n const startIndex = slice.filePos;\n while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {\n const foundBox = this.traverseBox(slice);\n if (!foundBox) {\n break;\n }\n }\n }\n // eslint-disable-next-line @stylistic/generator-star-spacing\n *iterateContiguousBoxes(slice) {\n const startIndex = slice.filePos;\n while (slice.filePos - startIndex <= slice.length - MIN_BOX_HEADER_SIZE) {\n const startPos = slice.filePos;\n const boxInfo = readBoxHeader(slice);\n if (!boxInfo) {\n break;\n }\n yield { boxInfo, slice };\n slice.filePos = startPos + boxInfo.totalSize;\n }\n }\n traverseBox(slice) {\n const startPos = slice.filePos;\n const boxInfo = readBoxHeader(slice);\n if (!boxInfo) {\n return false;\n }\n const contentStartPos = slice.filePos;\n const boxEndPos = startPos + boxInfo.totalSize;\n switch (boxInfo.name) {\n case 'mdia':\n case 'minf':\n case 'dinf':\n case 'mfra':\n case 'edts':\n case 'sinf':\n case 'schi':\n {\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n }\n break;\n case 'mvhd':\n {\n const version = readU8(slice);\n slice.skip(3); // Flags\n if (version === 1) {\n slice.skip(8 + 8);\n this.movieTimescale = readU32Be(slice);\n this.movieDurationInTimescale = readU64Be(slice);\n }\n else {\n slice.skip(4 + 4);\n this.movieTimescale = readU32Be(slice);\n this.movieDurationInTimescale = readU32Be(slice);\n }\n }\n break;\n case 'trak':\n {\n const track = {\n id: -1,\n demuxer: this,\n trackBacking: null,\n disposition: {\n ...DEFAULT_TRACK_DISPOSITION,\n primary: false,\n },\n info: null,\n timescale: -1,\n durationInMovieTimescale: -1,\n durationInMediaTimescale: -1,\n rotation: 0,\n internalCodecId: null,\n name: null,\n languageCode: UNDETERMINED_LANGUAGE,\n sampleTableByteOffset: -1,\n sampleTable: null,\n fragmentLookupTable: [],\n currentFragmentState: null,\n fragmentPositionCache: [],\n editListPreviousSegmentDurations: 0,\n editListOffset: 0,\n encryptionInfo: null,\n encryptionAuxInfo: null,\n frmaCodecString: null,\n };\n this.currentTrack = track;\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n if (track.id !== -1 && track.timescale !== -1 && track.info !== null) {\n if (track.info.type === 'video' && track.info.width !== -1) {\n const videoTrack = track;\n track.trackBacking = new IsobmffVideoTrackBacking(videoTrack);\n this.tracks.push(track);\n }\n else if (track.info.type === 'audio' && track.info.numberOfChannels !== -1) {\n const audioTrack = track;\n track.trackBacking = new IsobmffAudioTrackBacking(audioTrack);\n this.tracks.push(track);\n }\n }\n this.currentTrack = null;\n }\n break;\n case 'tkhd':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n const version = readU8(slice);\n const flags = readU24Be(slice);\n // Spec says disabled tracks are to be treated like they don't exist, but in practice, they are treated\n // more like non-default tracks.\n const trackEnabled = !!(flags & 0x1);\n track.disposition.default = trackEnabled;\n // Skip over creation & modification time to reach the track ID\n if (version === 0) {\n slice.skip(8);\n track.id = readU32Be(slice);\n slice.skip(4);\n track.durationInMovieTimescale = readU32Be(slice);\n }\n else if (version === 1) {\n slice.skip(16);\n track.id = readU32Be(slice);\n slice.skip(4);\n track.durationInMovieTimescale = readU64Be(slice);\n }\n else {\n throw new Error(`Incorrect track header version ${version}.`);\n }\n slice.skip(2 * 4 + 2 + 2 + 2 + 2);\n const matrix = [\n readFixed_16_16(slice),\n readFixed_16_16(slice),\n readFixed_2_30(slice),\n readFixed_16_16(slice),\n readFixed_16_16(slice),\n readFixed_2_30(slice),\n readFixed_16_16(slice),\n readFixed_16_16(slice),\n readFixed_2_30(slice),\n ];\n const rotation = normalizeRotation(roundToMultiple(extractRotationFromMatrix(matrix), 90));\n assert(rotation === 0 || rotation === 90 || rotation === 180 || rotation === 270);\n track.rotation = rotation;\n }\n break;\n case 'elst':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n const version = readU8(slice);\n slice.skip(3); // Flags\n let relevantEntryFound = false;\n let previousSegmentDurations = 0;\n const entryCount = readU32Be(slice);\n for (let i = 0; i < entryCount; i++) {\n const segmentDuration = version === 1\n ? readU64Be(slice)\n : readU32Be(slice);\n const mediaTime = version === 1\n ? readI64Be(slice)\n : readI32Be(slice);\n const mediaRate = readFixed_16_16(slice);\n if (segmentDuration === 0) {\n // Don't care\n continue;\n }\n if (relevantEntryFound) {\n Logging._warn('Unsupported edit list: multiple edits are not currently supported. Only using first edit.');\n break;\n }\n if (mediaTime === -1) {\n previousSegmentDurations += segmentDuration;\n continue;\n }\n if (mediaRate !== 1) {\n Logging._warn('Unsupported edit list entry: media rate must be 1.');\n break;\n }\n track.editListPreviousSegmentDurations = previousSegmentDurations;\n track.editListOffset = mediaTime;\n relevantEntryFound = true;\n }\n }\n break;\n case 'mdhd':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n const version = readU8(slice);\n slice.skip(3); // Flags\n if (version === 0) {\n slice.skip(8);\n track.timescale = readU32Be(slice);\n track.durationInMediaTimescale = readU32Be(slice);\n }\n else if (version === 1) {\n slice.skip(16);\n track.timescale = readU32Be(slice);\n track.durationInMediaTimescale = readU64Be(slice);\n }\n let language = readU16Be(slice);\n if (language > 0) {\n track.languageCode = '';\n for (let i = 0; i < 3; i++) {\n track.languageCode = String.fromCharCode(0x60 + (language & 0b11111)) + track.languageCode;\n language >>= 5;\n }\n if (!isIso639Dash2LanguageCode(track.languageCode)) {\n // Sometimes the bytes are garbage\n track.languageCode = UNDETERMINED_LANGUAGE;\n }\n }\n }\n break;\n case 'hdlr':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n slice.skip(8); // Version + flags + pre-defined\n const handlerType = readAscii(slice, 4);\n if (handlerType === 'vide') {\n track.info = {\n type: 'video',\n width: -1,\n height: -1,\n squarePixelWidth: -1,\n squarePixelHeight: -1,\n codec: null,\n codecDescription: null,\n colorSpace: null,\n avcType: null,\n avcCodecInfo: null,\n hevcCodecInfo: null,\n vp9CodecInfo: null,\n av1CodecInfo: null,\n proresFormat: null,\n };\n }\n else if (handlerType === 'soun') {\n track.info = {\n type: 'audio',\n numberOfChannels: -1,\n sampleRate: -1,\n codec: null,\n codecDescription: null,\n aacCodecInfo: null,\n pcmLittleEndian: false,\n pcmSampleSize: null,\n };\n }\n }\n break;\n case 'stbl':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n track.sampleTableByteOffset = startPos;\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n }\n break;\n case 'stsd':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (track.info === null || track.sampleTable) {\n break;\n }\n const stsdVersion = readU8(slice);\n slice.skip(3); // Flags\n const entries = readU32Be(slice);\n for (let i = 0; i < entries; i++) {\n const sampleBoxStartPos = slice.filePos;\n const sampleBoxInfo = readBoxHeader(slice);\n if (!sampleBoxInfo) {\n break;\n }\n track.internalCodecId = sampleBoxInfo.name;\n const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();\n if (track.info.type === 'video') {\n slice.skip(6 * 1 + 2 + 2 + 2 + 3 * 4);\n track.info.width = readU16Be(slice);\n track.info.height = readU16Be(slice);\n track.info.squarePixelWidth = track.info.width;\n track.info.squarePixelHeight = track.info.height;\n slice.skip(4 + 4 + 4 + 2 + 32 + 2 + 2);\n track.frmaCodecString = null;\n this.readContiguousBoxes(slice.slice(slice.filePos, (sampleBoxStartPos + sampleBoxInfo.totalSize) - slice.filePos));\n const codecName = lowercaseBoxName === 'encv'\n ? track.frmaCodecString\n : lowercaseBoxName;\n track.frmaCodecString = null;\n if (codecName === 'avc1' || codecName === 'avc3') {\n track.info.codec = 'avc';\n track.info.avcType = codecName === 'avc1' ? 1 : 3;\n }\n else if (codecName === 'hvc1' || codecName === 'hev1') {\n track.info.codec = 'hevc';\n }\n else if (codecName === 'vp08') {\n track.info.codec = 'vp8';\n }\n else if (codecName === 'vp09') {\n track.info.codec = 'vp9';\n }\n else if (codecName === 'av01') {\n track.info.codec = 'av1';\n }\n else if (PRORES_FOURCCS.includes(lowercaseBoxName)) {\n track.info.codec = 'prores';\n track.info.proresFormat = lowercaseBoxName;\n }\n else if (codecName === null) {\n Logging._warn(`Unknown encrypted video codec due to missing frma box.`);\n }\n else {\n Logging._warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`);\n }\n }\n else {\n slice.skip(6 * 1 + 2);\n const version = readU16Be(slice);\n slice.skip(3 * 2);\n let channelCount = readU16Be(slice);\n let sampleSize = readU16Be(slice);\n slice.skip(2 * 2);\n // Can't use fixed16_16 as that's signed\n let sampleRate = readU32Be(slice) / 0x10000;\n let lpcmFlags = null;\n if (stsdVersion === 0 && version > 0) {\n // Additional QuickTime fields\n if (version === 1) {\n slice.skip(4);\n sampleSize = 8 * readU32Be(slice);\n slice.skip(2 * 4);\n }\n else if (version === 2) {\n slice.skip(4);\n sampleRate = readF64Be(slice);\n channelCount = readU32Be(slice);\n slice.skip(4); // Always 0x7f000000\n sampleSize = readU32Be(slice);\n lpcmFlags = readU32Be(slice);\n slice.skip(2 * 4);\n }\n }\n track.info.numberOfChannels = channelCount;\n track.info.sampleRate = sampleRate;\n track.frmaCodecString = null;\n this.readContiguousBoxes(slice.slice(slice.filePos, (sampleBoxStartPos + sampleBoxInfo.totalSize) - slice.filePos));\n const codecName = lowercaseBoxName === 'enca'\n ? track.frmaCodecString\n : lowercaseBoxName;\n track.frmaCodecString = null;\n // developer.apple.com/documentation/quicktime-file-format/sound_sample_descriptions/\n if (codecName === 'mp4a') ;\n else if (codecName === 'opus') {\n track.info.codec = 'opus';\n track.info.sampleRate = OPUS_SAMPLE_RATE; // Always the same\n }\n else if (codecName === 'flac') {\n track.info.codec = 'flac';\n }\n else if (codecName === 'ulaw') {\n track.info.codec = 'ulaw';\n }\n else if (codecName === 'alaw') {\n track.info.codec = 'alaw';\n }\n else if (codecName === 'ac-3') {\n track.info.codec = 'ac3';\n }\n else if (codecName === 'ec-3') {\n track.info.codec = 'eac3';\n }\n else if (codecName === 'twos') {\n if (sampleSize === 8) {\n track.info.codec = 'pcm-s8';\n }\n else if (sampleSize === 16) {\n track.info.codec = track.info.pcmLittleEndian ? 'pcm-s16' : 'pcm-s16be';\n }\n else {\n Logging._warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`);\n track.info.codec = null;\n }\n }\n else if (codecName === 'sowt') {\n if (sampleSize === 8) {\n track.info.codec = 'pcm-s8';\n }\n else if (sampleSize === 16) {\n track.info.codec = 'pcm-s16';\n }\n else {\n Logging._warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`);\n track.info.codec = null;\n }\n }\n else if (codecName === 'raw ') {\n track.info.codec = 'pcm-u8';\n }\n else if (codecName === 'in24') {\n track.info.codec = track.info.pcmLittleEndian ? 'pcm-s24' : 'pcm-s24be';\n }\n else if (codecName === 'in32') {\n track.info.codec = track.info.pcmLittleEndian ? 'pcm-s32' : 'pcm-s32be';\n }\n else if (codecName === 'fl32') {\n track.info.codec = track.info.pcmLittleEndian ? 'pcm-f32' : 'pcm-f32be';\n }\n else if (codecName === 'fl64') {\n track.info.codec = track.info.pcmLittleEndian ? 'pcm-f64' : 'pcm-f64be';\n }\n else if (codecName === 'ipcm') {\n const pcmSampleSize = track.info.pcmSampleSize;\n if (track.info.pcmLittleEndian) {\n if (pcmSampleSize === 16) {\n track.info.codec = 'pcm-s16';\n }\n else if (pcmSampleSize === 24) {\n track.info.codec = 'pcm-s24';\n }\n else if (pcmSampleSize === 32) {\n track.info.codec = 'pcm-s32';\n }\n else {\n Logging._warn(`Invalid ipcm sample size ${pcmSampleSize}.`);\n track.info.codec = null;\n }\n }\n else {\n if (pcmSampleSize === 16) {\n track.info.codec = 'pcm-s16be';\n }\n else if (pcmSampleSize === 24) {\n track.info.codec = 'pcm-s24be';\n }\n else if (pcmSampleSize === 32) {\n track.info.codec = 'pcm-s32be';\n }\n else {\n Logging._warn(`Invalid ipcm sample size ${pcmSampleSize}.`);\n track.info.codec = null;\n }\n }\n }\n else if (codecName === 'fpcm') {\n const pcmSampleSize = track.info.pcmSampleSize;\n if (track.info.pcmLittleEndian) {\n if (pcmSampleSize === 32) {\n track.info.codec = 'pcm-f32';\n }\n else if (pcmSampleSize === 64) {\n track.info.codec = 'pcm-f64';\n }\n else {\n Logging._warn(`Invalid fpcm sample size ${pcmSampleSize}.`);\n track.info.codec = null;\n }\n }\n else {\n if (pcmSampleSize === 32) {\n track.info.codec = 'pcm-f32be';\n }\n else if (pcmSampleSize === 64) {\n track.info.codec = 'pcm-f64be';\n }\n else {\n Logging._warn(`Invalid fpcm sample size ${pcmSampleSize}.`);\n track.info.codec = null;\n }\n }\n }\n else if (codecName === 'lpcm' && lpcmFlags !== null) {\n const bytesPerSample = (sampleSize + 7) >> 3;\n const isFloat = Boolean(lpcmFlags & 1);\n const isBigEndian = Boolean(lpcmFlags & 2);\n const sFlags = lpcmFlags & 4 ? -1 : 0; // I guess it means \"signed flags\" or something?\n if (sampleSize > 0 && sampleSize <= 64) {\n if (isFloat) {\n if (sampleSize === 32) {\n track.info.codec = isBigEndian ? 'pcm-f32be' : 'pcm-f32';\n }\n }\n else {\n if (sFlags & (1 << (bytesPerSample - 1))) {\n if (bytesPerSample === 1) {\n track.info.codec = 'pcm-s8';\n }\n else if (bytesPerSample === 2) {\n track.info.codec = isBigEndian ? 'pcm-s16be' : 'pcm-s16';\n }\n else if (bytesPerSample === 3) {\n track.info.codec = isBigEndian ? 'pcm-s24be' : 'pcm-s24';\n }\n else if (bytesPerSample === 4) {\n track.info.codec = isBigEndian ? 'pcm-s32be' : 'pcm-s32';\n }\n }\n else {\n if (bytesPerSample === 1) {\n track.info.codec = 'pcm-u8';\n }\n }\n }\n }\n if (track.info.codec === null) {\n Logging._warn('Unsupported PCM format.');\n }\n }\n else if (codecName === null) {\n Logging._warn(`Unknown encrypted audio codec due to missing frma box.`);\n }\n else {\n Logging._warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`);\n }\n }\n slice.filePos = sampleBoxStartPos + sampleBoxInfo.totalSize;\n }\n }\n break;\n case 'frma':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n const format = readAscii(slice, 4);\n const lowercase = format.toLowerCase();\n // Tells us what codec the encrypted track actually uses\n track.frmaCodecString = lowercase;\n }\n break;\n case 'schm':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n slice.skip(4); // Version + flags\n const schemeType = readAscii(slice, 4);\n if (schemeType === 'cenc' || schemeType === 'cens' || schemeType === 'cbcs') {\n track.encryptionInfo = {\n scheme: schemeType,\n defaultKid: null,\n defaultIsProtected: null,\n defaultPerSampleIvSize: null,\n defaultConstantIv: null,\n defaultCryptByteBlock: null,\n defaultSkipByteBlock: null,\n };\n }\n else {\n Logging._warn(`Unsupported encryption scheme '${schemeType}'.`);\n }\n }\n break;\n case 'tenc':\n {\n const track = this.currentTrack;\n if (!track || !track.encryptionInfo) {\n break;\n }\n const version = readU8(slice);\n slice.skip(3); // Flags\n slice.skip(1); // Reserved\n const patternByte = readU8(slice);\n if (version > 0) {\n track.encryptionInfo.defaultCryptByteBlock = patternByte >> 4;\n track.encryptionInfo.defaultSkipByteBlock = patternByte & 0xf;\n }\n else {\n track.encryptionInfo.defaultCryptByteBlock = 0;\n track.encryptionInfo.defaultSkipByteBlock = 0;\n }\n track.encryptionInfo.defaultIsProtected = readU8(slice) !== 0;\n track.encryptionInfo.defaultPerSampleIvSize = readU8(slice);\n track.encryptionInfo.defaultKid = bytesToHexString(readBytes(slice, 16));\n if (track.encryptionInfo.defaultIsProtected && track.encryptionInfo.defaultPerSampleIvSize === 0) {\n const constantIvSize = readU8(slice);\n const constantIv = new Uint8Array(16);\n constantIv.set(readBytes(slice, constantIvSize), 0);\n track.encryptionInfo.defaultConstantIv = constantIv;\n }\n }\n break;\n case 'avcC':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info);\n track.info.codecDescription = readBytes(slice, boxInfo.contentSize);\n }\n break;\n case 'hvcC':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info);\n track.info.codecDescription = readBytes(slice, boxInfo.contentSize);\n }\n break;\n case 'vpcC':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'video');\n slice.skip(4); // Version + flags\n const profile = readU8(slice);\n const level = readU8(slice);\n const thirdByte = readU8(slice);\n const bitDepth = thirdByte >> 4;\n const chromaSubsampling = (thirdByte >> 1) & 0b111;\n const videoFullRangeFlag = thirdByte & 1;\n const colourPrimaries = readU8(slice);\n const transferCharacteristics = readU8(slice);\n const matrixCoefficients = readU8(slice);\n track.info.vp9CodecInfo = {\n profile,\n level,\n bitDepth,\n chromaSubsampling,\n videoFullRangeFlag,\n colourPrimaries,\n transferCharacteristics,\n matrixCoefficients,\n };\n }\n break;\n case 'av1C':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'video');\n slice.skip(1); // Marker + version\n const secondByte = readU8(slice);\n const profile = secondByte >> 5;\n const level = secondByte & 0b11111;\n const thirdByte = readU8(slice);\n const tier = thirdByte >> 7;\n const highBitDepth = (thirdByte >> 6) & 1;\n const twelveBit = (thirdByte >> 5) & 1;\n const monochrome = (thirdByte >> 4) & 1;\n const chromaSubsamplingX = (thirdByte >> 3) & 1;\n const chromaSubsamplingY = (thirdByte >> 2) & 1;\n const chromaSamplePosition = thirdByte & 0b11;\n // Logic from https://aomediacodec.github.io/av1-spec/av1-spec.pdf\n const bitDepth = profile === 2 && highBitDepth ? (twelveBit ? 12 : 10) : (highBitDepth ? 10 : 8);\n track.info.av1CodecInfo = {\n profile,\n level,\n tier,\n bitDepth,\n monochrome,\n chromaSubsamplingX,\n chromaSubsamplingY,\n chromaSamplePosition,\n };\n }\n break;\n case 'colr':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'video');\n const colourType = readAscii(slice, 4);\n if (colourType !== 'nclx' && colourType !== 'nclc') {\n break;\n }\n const colourPrimaries = readU16Be(slice);\n const transferCharacteristics = readU16Be(slice);\n const matrixCoefficients = readU16Be(slice);\n let fullRange = undefined;\n if (colourType === 'nclx') {\n fullRange = Boolean(readU8(slice) & 0x80);\n }\n track.info.colorSpace = {\n primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries],\n transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics],\n matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients],\n fullRange,\n };\n }\n break;\n case 'pasp':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'video');\n const num = readU32Be(slice);\n const den = readU32Be(slice);\n // https://github.com/Vanilagy/mediabunny/issues/362\n if (num > 0 && den > 0) {\n if (num > den) {\n track.info.squarePixelWidth = Math.round(track.info.width * num / den);\n }\n else {\n track.info.squarePixelHeight = Math.round(track.info.height * den / num);\n }\n }\n }\n break;\n case 'wave':\n {\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n }\n break;\n case 'esds':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n slice.skip(4); // Version + flags\n const tag = readU8(slice);\n assert(tag === 0x03); // ES Descriptor\n readIsomVariableInteger(slice); // Length\n slice.skip(2); // ES ID\n const mixed = readU8(slice);\n const streamDependenceFlag = (mixed & 0x80) !== 0;\n const urlFlag = (mixed & 0x40) !== 0;\n const ocrStreamFlag = (mixed & 0x20) !== 0;\n if (streamDependenceFlag) {\n slice.skip(2);\n }\n if (urlFlag) {\n const urlLength = readU8(slice);\n slice.skip(urlLength);\n }\n if (ocrStreamFlag) {\n slice.skip(2);\n }\n const decoderConfigTag = readU8(slice);\n assert(decoderConfigTag === 0x04); // DecoderConfigDescriptor\n const decoderConfigDescriptorLength = readIsomVariableInteger(slice); // Length\n const payloadStart = slice.filePos;\n const objectTypeIndication = readU8(slice);\n if (objectTypeIndication === 0x40 || objectTypeIndication === 0x67) {\n track.info.codec = 'aac';\n track.info.aacCodecInfo = {\n isMpeg2: objectTypeIndication === 0x67,\n objectType: null,\n };\n }\n else if (objectTypeIndication === 0x69 || objectTypeIndication === 0x6b) {\n track.info.codec = 'mp3';\n }\n else if (objectTypeIndication === 0xdd) {\n track.info.codec = 'vorbis'; // \"nonstandard, gpac uses it\" - FFmpeg\n }\n else {\n Logging._warn(`Unsupported audio codec (objectTypeIndication ${objectTypeIndication}) - discarding track.`);\n }\n slice.skip(1 + 3 + 4 + 4);\n if (decoderConfigDescriptorLength > slice.filePos - payloadStart) {\n // There's a DecoderSpecificInfo at the end, let's read it\n const decoderSpecificInfoTag = readU8(slice);\n assert(decoderSpecificInfoTag === 0x05); // DecoderSpecificInfo\n const decoderSpecificInfoLength = readIsomVariableInteger(slice);\n track.info.codecDescription = readBytes(slice, decoderSpecificInfoLength);\n if (track.info.codec === 'aac') {\n // Let's try to deduce more accurate values directly from the AudioSpecificConfig:\n const audioSpecificConfig = parseAacAudioSpecificConfig(track.info.codecDescription);\n if (audioSpecificConfig.numberOfChannels !== null) {\n track.info.numberOfChannels = audioSpecificConfig.numberOfChannels;\n }\n if (audioSpecificConfig.sampleRate !== null) {\n track.info.sampleRate = audioSpecificConfig.sampleRate;\n }\n }\n }\n }\n break;\n case 'enda':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n track.info.pcmLittleEndian = !!(readU16Be(slice) & 0xff); // 0xff is from FFmpeg\n }\n break;\n case 'pcmC':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n slice.skip(1 + 3); // Version + flags\n // ISO/IEC 23003-5\n const formatFlags = readU8(slice);\n track.info.pcmLittleEndian = Boolean(formatFlags & 0x01);\n track.info.pcmSampleSize = readU8(slice);\n }\n break;\n case 'dOps':\n { // Used for Opus audio\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n slice.skip(1); // Version\n // https://www.opus-codec.org/docs/opus_in_isobmff.html\n const outputChannelCount = readU8(slice);\n const preSkip = readU16Be(slice);\n const inputSampleRate = readU32Be(slice);\n const outputGain = readI16Be(slice);\n const channelMappingFamily = readU8(slice);\n let channelMappingTable;\n if (channelMappingFamily !== 0) {\n channelMappingTable = readBytes(slice, 2 + outputChannelCount);\n }\n else {\n channelMappingTable = new Uint8Array(0);\n }\n // https://datatracker.ietf.org/doc/html/draft-ietf-codec-oggopus-06\n const description = new Uint8Array(8 + 1 + 1 + 2 + 4 + 2 + 1 + channelMappingTable.byteLength);\n const view = new DataView(description.buffer);\n view.setUint32(0, 0x4f707573, false); // 'Opus'\n view.setUint32(4, 0x48656164, false); // 'Head'\n view.setUint8(8, 1); // Version\n view.setUint8(9, outputChannelCount);\n view.setUint16(10, preSkip, true);\n view.setUint32(12, inputSampleRate, true);\n view.setInt16(16, outputGain, true);\n view.setUint8(18, channelMappingFamily);\n description.set(channelMappingTable, 19);\n track.info.codecDescription = description;\n track.info.numberOfChannels = outputChannelCount;\n // Don't copy the input sample rate, irrelevant, and output sample rate is fixed\n }\n break;\n case 'dfLa':\n { // Used for FLAC audio\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n slice.skip(4); // Version + flags\n // https://datatracker.ietf.org/doc/rfc9639/\n const BLOCK_TYPE_MASK = 0x7f;\n const LAST_METADATA_BLOCK_FLAG_MASK = 0x80;\n const startPos = slice.filePos;\n while (slice.filePos < boxEndPos) {\n const flagAndType = readU8(slice);\n const metadataBlockLength = readU24Be(slice);\n const type = flagAndType & BLOCK_TYPE_MASK;\n // It's a STREAMINFO block; let's extract the actual sample rate and channel count\n if (type === FlacBlockType.STREAMINFO) {\n slice.skip(10);\n // Extract sample rate and channel count\n const word = readU32Be(slice);\n const sampleRate = word >>> 12;\n const numberOfChannels = ((word >> 9) & 0b111) + 1;\n track.info.sampleRate = sampleRate;\n track.info.numberOfChannels = numberOfChannels;\n slice.skip(20);\n }\n else {\n // Simply skip ahead to the next block\n slice.skip(metadataBlockLength);\n }\n if (flagAndType & LAST_METADATA_BLOCK_FLAG_MASK) {\n break;\n }\n }\n const endPos = slice.filePos;\n slice.filePos = startPos;\n const bytes = readBytes(slice, endPos - startPos);\n const description = new Uint8Array(4 + bytes.byteLength);\n const view = new DataView(description.buffer);\n view.setUint32(0, 0x664c6143, false); // 'fLaC'\n description.set(bytes, 4);\n // Set the codec description to be 'fLaC' + all metadata blocks\n track.info.codecDescription = description;\n }\n break;\n case 'dac3':\n { // AC3SpecificBox\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n const bytes = readBytes(slice, 3);\n const bitstream = new Bitstream(bytes);\n const fscod = bitstream.readBits(2);\n bitstream.skipBits(5 + 3); // Skip bsid and bsmod\n const acmod = bitstream.readBits(3);\n const lfeon = bitstream.readBits(1);\n if (fscod < 3) {\n track.info.sampleRate = AC3_SAMPLE_RATES[fscod];\n }\n track.info.numberOfChannels = AC3_ACMOD_CHANNEL_COUNTS[acmod] + lfeon;\n }\n break;\n case 'dec3':\n { // EC3SpecificBox\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.info?.type === 'audio');\n const bytes = readBytes(slice, boxInfo.contentSize);\n const config = parseEac3Config(bytes);\n if (!config) {\n Logging._warn('Invalid dec3 box contents, ignoring.');\n break;\n }\n const sampleRate = getEac3SampleRate(config);\n if (sampleRate !== null) {\n track.info.sampleRate = sampleRate;\n }\n track.info.numberOfChannels = getEac3ChannelCount(config);\n }\n break;\n case 'stts':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4); // Version + flags\n const entryCount = readU32Be(slice);\n let currentIndex = 0;\n let currentTimestamp = 0;\n for (let i = 0; i < entryCount; i++) {\n const sampleCount = readU32Be(slice);\n const sampleDelta = readU32Be(slice);\n track.sampleTable.sampleTimingEntries.push({\n startIndex: currentIndex,\n startDecodeTimestamp: currentTimestamp,\n count: sampleCount,\n delta: sampleDelta,\n });\n currentIndex += sampleCount;\n currentTimestamp += sampleCount * sampleDelta;\n }\n }\n break;\n case 'ctts':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(1 + 3); // Version + flags\n const entryCount = readU32Be(slice);\n let sampleIndex = 0;\n for (let i = 0; i < entryCount; i++) {\n const sampleCount = readU32Be(slice);\n const sampleOffset = readI32Be(slice);\n track.sampleTable.sampleCompositionTimeOffsets.push({\n startIndex: sampleIndex,\n count: sampleCount,\n offset: sampleOffset,\n });\n sampleIndex += sampleCount;\n }\n }\n break;\n case 'stsz':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4); // Version + flags\n const sampleSize = readU32Be(slice);\n const sampleCount = readU32Be(slice);\n if (sampleSize === 0) {\n for (let i = 0; i < sampleCount; i++) {\n const sampleSize = readU32Be(slice);\n track.sampleTable.sampleSizes.push(sampleSize);\n }\n }\n else {\n track.sampleTable.sampleSizes.push(sampleSize);\n }\n }\n break;\n case 'stz2':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4); // Version + flags\n slice.skip(3); // Reserved\n const fieldSize = readU8(slice); // in bits\n const sampleCount = readU32Be(slice);\n const bytes = readBytes(slice, Math.ceil(sampleCount * fieldSize / 8));\n const bitstream = new Bitstream(bytes);\n for (let i = 0; i < sampleCount; i++) {\n const sampleSize = bitstream.readBits(fieldSize);\n track.sampleTable.sampleSizes.push(sampleSize);\n }\n }\n break;\n case 'stss':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4); // Version + flags\n track.sampleTable.keySampleIndices = [];\n const entryCount = readU32Be(slice);\n for (let i = 0; i < entryCount; i++) {\n const sampleIndex = readU32Be(slice) - 1; // Convert to 0-indexed\n track.sampleTable.keySampleIndices.push(sampleIndex);\n }\n if (track.sampleTable.keySampleIndices[0] !== 0) {\n // Some files don't mark the first sample a key sample, which is basically almost always incorrect.\n // Here, we correct for that mistake:\n track.sampleTable.keySampleIndices.unshift(0);\n }\n }\n break;\n case 'stsc':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4);\n const entryCount = readU32Be(slice);\n for (let i = 0; i < entryCount; i++) {\n const startChunkIndex = readU32Be(slice) - 1; // Convert to 0-indexed\n const samplesPerChunk = readU32Be(slice);\n const sampleDescriptionIndex = readU32Be(slice);\n track.sampleTable.sampleToChunk.push({\n startSampleIndex: -1,\n startChunkIndex,\n samplesPerChunk,\n sampleDescriptionIndex,\n });\n }\n let startSampleIndex = 0;\n for (let i = 0; i < track.sampleTable.sampleToChunk.length; i++) {\n track.sampleTable.sampleToChunk[i].startSampleIndex = startSampleIndex;\n if (i < track.sampleTable.sampleToChunk.length - 1) {\n const nextChunk = track.sampleTable.sampleToChunk[i + 1];\n const chunkCount = nextChunk.startChunkIndex\n - track.sampleTable.sampleToChunk[i].startChunkIndex;\n startSampleIndex += chunkCount * track.sampleTable.sampleToChunk[i].samplesPerChunk;\n }\n }\n }\n break;\n case 'stco':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4); // Version + flags\n const entryCount = readU32Be(slice);\n for (let i = 0; i < entryCount; i++) {\n const chunkOffset = readU32Be(slice);\n track.sampleTable.chunkOffsets.push(chunkOffset);\n }\n }\n break;\n case 'co64':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n if (!track.sampleTable) {\n break;\n }\n slice.skip(4); // Version + flags\n const entryCount = readU32Be(slice);\n for (let i = 0; i < entryCount; i++) {\n const chunkOffset = readU64Be(slice);\n track.sampleTable.chunkOffsets.push(chunkOffset);\n }\n }\n break;\n case 'mvex':\n {\n this.isFragmented = true;\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n }\n break;\n case 'mehd':\n {\n const version = readU8(slice);\n slice.skip(3); // Flags\n const fragmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);\n this.movieDurationInTimescale = fragmentDuration;\n }\n break;\n case 'trex':\n {\n slice.skip(4); // Version + flags\n const trackId = readU32Be(slice);\n const defaultSampleDescriptionIndex = readU32Be(slice);\n const defaultSampleDuration = readU32Be(slice);\n const defaultSampleSize = readU32Be(slice);\n const defaultSampleFlags = readU32Be(slice);\n // We store these separately rather than in the tracks since the tracks may not exist yet\n this.fragmentTrackDefaults.push({\n trackId,\n defaultSampleDescriptionIndex,\n defaultSampleDuration,\n defaultSampleSize,\n defaultSampleFlags,\n });\n }\n break;\n case 'tfra':\n {\n const version = readU8(slice);\n slice.skip(3); // Flags\n const trackId = readU32Be(slice);\n const track = this.tracks.find(x => x.id === trackId);\n if (!track) {\n break;\n }\n const word = readU32Be(slice);\n const lengthSizeOfTrafNum = (word & 0b110000) >> 4;\n const lengthSizeOfTrunNum = (word & 0b001100) >> 2;\n const lengthSizeOfSampleNum = word & 0b000011;\n const functions = [readU8, readU16Be, readU24Be, readU32Be];\n const readTrafNum = functions[lengthSizeOfTrafNum];\n const readTrunNum = functions[lengthSizeOfTrunNum];\n const readSampleNum = functions[lengthSizeOfSampleNum];\n const numberOfEntries = readU32Be(slice);\n for (let i = 0; i < numberOfEntries; i++) {\n const time = version === 1 ? readU64Be(slice) : readU32Be(slice);\n const moofOffset = version === 1 ? readU64Be(slice) : readU32Be(slice);\n readTrafNum(slice);\n readTrunNum(slice);\n readSampleNum(slice);\n track.fragmentLookupTable.push({\n timestamp: time,\n moofOffset,\n });\n }\n // Sort by timestamp in case it's not naturally sorted\n track.fragmentLookupTable.sort((a, b) => a.timestamp - b.timestamp);\n // Remove multiple entries for the same time\n for (let i = 0; i < track.fragmentLookupTable.length - 1; i++) {\n const entry1 = track.fragmentLookupTable[i];\n const entry2 = track.fragmentLookupTable[i + 1];\n if (entry1.timestamp === entry2.timestamp) {\n track.fragmentLookupTable.splice(i + 1, 1);\n i--;\n }\n }\n }\n break;\n case 'moof':\n {\n this.currentFragment = {\n moofOffset: startPos,\n moofSize: boxInfo.totalSize,\n implicitBaseDataOffset: startPos,\n trackData: new Map(),\n psshBoxes: [],\n };\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n this.lastReadFragment = this.currentFragment;\n this.currentFragment = null;\n }\n break;\n case 'traf':\n {\n assert(this.currentFragment);\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n // It is possible that there is no current track, for example when we don't care about the track\n // referenced in the track fragment header.\n if (this.currentTrack) {\n const trackData = this.currentFragment.trackData.get(this.currentTrack.id);\n cond: if (trackData) {\n if (trackData.samples.length === 0) {\n // Don't associate the fragment with the track if it has no samples, this simplifies\n // other code\n this.currentFragment.trackData.delete(this.currentTrack.id);\n break cond;\n }\n trackData.presentationTimestamps = trackData.samples\n .map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i }))\n .sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);\n for (let i = 0; i < trackData.presentationTimestamps.length; i++) {\n const currentEntry = trackData.presentationTimestamps[i];\n const currentSample = trackData.samples[currentEntry.sampleIndex];\n if (trackData.firstKeyFrameTimestamp === null && currentSample.isKeyFrame) {\n trackData.firstKeyFrameTimestamp = currentSample.presentationTimestamp;\n }\n if (i < trackData.presentationTimestamps.length - 1) {\n // Update sample durations based on presentation order\n const nextEntry = trackData.presentationTimestamps[i + 1];\n const duration = nextEntry.presentationTimestamp - currentEntry.presentationTimestamp;\n currentSample.duration = duration;\n }\n }\n const firstSample = trackData.samples[trackData.presentationTimestamps[0].sampleIndex];\n const lastSample = trackData.samples[last(trackData.presentationTimestamps).sampleIndex];\n trackData.startTimestamp = firstSample.presentationTimestamp;\n trackData.endTimestamp = lastSample.presentationTimestamp + lastSample.duration;\n const { currentFragmentState } = this.currentTrack;\n assert(currentFragmentState);\n if (currentFragmentState.startTimestamp !== null) {\n offsetFragmentTrackDataByTimestamp(trackData, currentFragmentState.startTimestamp);\n trackData.startTimestampIsFinal = true;\n }\n // Transfer the buffered saiz+saio state onto the track data, so readFragment can resolve it\n // once all boxes are parsed. Only relevant if senc wasn't already used to populate samples.\n if (currentFragmentState.encryptionAuxInfo && !trackData.samples[0].encryption) {\n trackData.encryptionAuxInfo = currentFragmentState.encryptionAuxInfo;\n }\n }\n this.currentTrack.currentFragmentState = null;\n this.currentTrack = null;\n }\n }\n break;\n case 'pssh':\n {\n if (this.input._formatOptions.isobmff?._suppressPsshParsing) {\n break;\n }\n const psshBox = parsePsshBoxContents(readBytes(slice, boxInfo.contentSize));\n if (this.currentFragment) {\n this.currentFragment.psshBoxes.push(psshBox);\n }\n else if (!this.currentTrack) {\n this.psshBoxes.push(psshBox);\n }\n }\n break;\n case 'tfhd':\n {\n assert(this.currentFragment);\n slice.skip(1); // Version\n const flags = readU24Be(slice);\n const baseDataOffsetPresent = Boolean(flags & 0x000001);\n const sampleDescriptionIndexPresent = Boolean(flags & 0x000002);\n const defaultSampleDurationPresent = Boolean(flags & 0x000008);\n const defaultSampleSizePresent = Boolean(flags & 0x000010);\n const defaultSampleFlagsPresent = Boolean(flags & 0x000020);\n const durationIsEmpty = Boolean(flags & 0x010000);\n const defaultBaseIsMoof = Boolean(flags & 0x020000);\n const trackId = readU32Be(slice);\n const track = this.tracks.find(x => x.id === trackId);\n if (!track) {\n // We don't care about this track\n break;\n }\n const defaults = this.fragmentTrackDefaults.find(x => x.trackId === trackId);\n this.currentTrack = track;\n track.currentFragmentState = {\n baseDataOffset: this.currentFragment.implicitBaseDataOffset,\n sampleDescriptionIndex: defaults?.defaultSampleDescriptionIndex ?? null,\n defaultSampleDuration: defaults?.defaultSampleDuration ?? null,\n defaultSampleSize: defaults?.defaultSampleSize ?? null,\n defaultSampleFlags: defaults?.defaultSampleFlags ?? null,\n startTimestamp: null,\n encryptionAuxInfo: null,\n };\n if (baseDataOffsetPresent) {\n track.currentFragmentState.baseDataOffset = readU64Be(slice);\n }\n else if (defaultBaseIsMoof) {\n track.currentFragmentState.baseDataOffset = this.currentFragment.moofOffset;\n }\n if (sampleDescriptionIndexPresent) {\n track.currentFragmentState.sampleDescriptionIndex = readU32Be(slice);\n }\n if (defaultSampleDurationPresent) {\n track.currentFragmentState.defaultSampleDuration = readU32Be(slice);\n }\n if (defaultSampleSizePresent) {\n track.currentFragmentState.defaultSampleSize = readU32Be(slice);\n }\n if (defaultSampleFlagsPresent) {\n track.currentFragmentState.defaultSampleFlags = readU32Be(slice);\n }\n if (durationIsEmpty) {\n track.currentFragmentState.defaultSampleDuration = 0;\n }\n }\n break;\n case 'tfdt':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(track.currentFragmentState);\n const version = readU8(slice);\n slice.skip(3); // Flags\n const baseMediaDecodeTime = version === 0 ? readU32Be(slice) : readU64Be(slice);\n track.currentFragmentState.startTimestamp = baseMediaDecodeTime;\n }\n break;\n case 'trun':\n {\n const track = this.currentTrack;\n if (!track) {\n break;\n }\n assert(this.currentFragment);\n assert(track.currentFragmentState);\n const version = readU8(slice);\n const flags = readU24Be(slice);\n const dataOffsetPresent = Boolean(flags & 0x000001);\n const firstSampleFlagsPresent = Boolean(flags & 0x000004);\n const sampleDurationPresent = Boolean(flags & 0x000100);\n const sampleSizePresent = Boolean(flags & 0x000200);\n const sampleFlagsPresent = Boolean(flags & 0x000400);\n const sampleCompositionTimeOffsetsPresent = Boolean(flags & 0x000800);\n const sampleCount = readU32Be(slice);\n let dataOffset = null;\n if (dataOffsetPresent) {\n dataOffset = readI32Be(slice);\n }\n let firstSampleFlags = null;\n if (firstSampleFlagsPresent) {\n firstSampleFlags = readU32Be(slice);\n }\n let trackData;\n if (this.currentFragment.trackData.has(track.id)) {\n trackData = this.currentFragment.trackData.get(track.id);\n if (dataOffset !== null) {\n trackData.currentOffset = track.currentFragmentState.baseDataOffset + dataOffset;\n }\n }\n else {\n trackData = {\n track,\n currentTimestamp: 0,\n currentOffset: track.currentFragmentState.baseDataOffset + (dataOffset ?? 0),\n startTimestamp: 0,\n endTimestamp: 0,\n firstKeyFrameTimestamp: null,\n samples: [],\n presentationTimestamps: [],\n startTimestampIsFinal: false,\n encryptionAuxInfo: null,\n };\n this.currentFragment.trackData.set(track.id, trackData);\n }\n for (let i = 0; i < sampleCount; i++) {\n let sampleDuration;\n if (sampleDurationPresent) {\n sampleDuration = readU32Be(slice);\n }\n else {\n assert(track.currentFragmentState.defaultSampleDuration !== null);\n sampleDuration = track.currentFragmentState.defaultSampleDuration;\n }\n let sampleSize;\n if (sampleSizePresent) {\n sampleSize = readU32Be(slice);\n }\n else {\n assert(track.currentFragmentState.defaultSampleSize !== null);\n sampleSize = track.currentFragmentState.defaultSampleSize;\n }\n let sampleFlags;\n if (sampleFlagsPresent) {\n sampleFlags = readU32Be(slice);\n }\n else {\n assert(track.currentFragmentState.defaultSampleFlags !== null);\n sampleFlags = track.currentFragmentState.defaultSampleFlags;\n }\n if (i === 0 && firstSampleFlags !== null) {\n sampleFlags = firstSampleFlags;\n }\n let sampleCompositionTimeOffset = 0;\n if (sampleCompositionTimeOffsetsPresent) {\n if (version === 0) {\n sampleCompositionTimeOffset = readU32Be(slice);\n }\n else {\n sampleCompositionTimeOffset = readI32Be(slice);\n }\n }\n const isKeyFrame = !(sampleFlags & 0x00010000);\n trackData.samples.push({\n presentationTimestamp: trackData.currentTimestamp + sampleCompositionTimeOffset,\n duration: sampleDuration,\n byteOffset: trackData.currentOffset,\n byteSize: sampleSize,\n isKeyFrame,\n encryption: null,\n });\n trackData.currentOffset += sampleSize;\n trackData.currentTimestamp += sampleDuration;\n }\n this.currentFragment.implicitBaseDataOffset = trackData.currentOffset;\n }\n break;\n case 'saiz':\n {\n // Sample Auxiliary Information Sizes - per-sample sizes of (typically) the encryption aux info.\n const track = this.currentTrack;\n if (!track || !track.encryptionInfo) {\n break;\n }\n slice.skip(1); // Version\n const flags = readU24Be(slice);\n if (flags & 0x01) {\n const auxInfoType = readAscii(slice, 4);\n const auxInfoTypeParam = readU32Be(slice);\n if (auxInfoType !== track.encryptionInfo.scheme || auxInfoTypeParam !== 0) {\n // Not the encryption aux info\n break;\n }\n }\n const defaultSampleInfoSize = readU8(slice);\n const sampleCount = readU32Be(slice);\n let sampleSizes = null;\n if (defaultSampleInfoSize === 0 && sampleCount > 0) {\n sampleSizes = readBytes(slice, sampleCount);\n }\n const aux = getOrCreateEncryptionAuxInfo(track);\n aux.defaultSampleInfoSize = defaultSampleInfoSize;\n aux.sampleSizes = sampleSizes;\n aux.sampleCount = sampleCount;\n }\n break;\n case 'saio':\n {\n // Sample Auxiliary Information Offsets - file offset(s) where the aux info lives.\n const track = this.currentTrack;\n if (!track || !track.encryptionInfo) {\n break;\n }\n const version = readU8(slice);\n const flags = readU24Be(slice);\n if (flags & 0x01) {\n const auxInfoType = readAscii(slice, 4);\n const auxInfoTypeParam = readU32Be(slice);\n if (auxInfoType !== track.encryptionInfo.scheme || auxInfoTypeParam !== 0) {\n break;\n }\n }\n const entryCount = readU32Be(slice);\n if (entryCount === 0) {\n break;\n }\n if (entryCount > 1) {\n Logging._warn('Multiple saio entries are not supported; using the first offset only.');\n }\n let offset = version === 0 ? readU32Be(slice) : Number(readU64Be(slice));\n // Per ISO/IEC 23001-7: when saio is inside a moof, offsets are relative to the start of the moof box.\n if (this.currentFragment) {\n offset += this.currentFragment.moofOffset;\n }\n const aux = getOrCreateEncryptionAuxInfo(track);\n aux.offset = offset;\n }\n break;\n case 'senc':\n {\n // Per-sample encryption info inside a 'traf'. Holds per-sample IV and optional subsample breakdown\n // for CENC-protected samples\n const track = this.currentTrack;\n if (!track || !track.encryptionInfo) {\n break;\n }\n assert(this.currentFragment);\n const trackData = this.currentFragment.trackData.get(track.id);\n if (!trackData) {\n break;\n }\n slice.skip(1); // Version\n const flags = readU24Be(slice);\n const useSubsamples = Boolean(flags & 0x000002);\n const sampleCount = readU32Be(slice);\n const ivSize = track.encryptionInfo.defaultPerSampleIvSize;\n assert(ivSize !== null);\n for (let i = 0; i < Math.min(sampleCount, trackData.samples.length); i++) {\n // Normalize the IV to 16 bytes so downstream code can assume a full-length buffer. For CTR with\n // an 8-byte per-sample IV the lower 8 bytes are zero (that's the CENC spec's block counter start);\n // for CBC/cbcs the IV is always 16 bytes by spec.\n const iv = new Uint8Array(16);\n if (ivSize > 0) {\n iv.set(readBytes(slice, ivSize), 0);\n }\n else {\n iv.set(track.encryptionInfo.defaultConstantIv, 0);\n }\n let subsamples = null;\n if (useSubsamples) {\n const subsampleCount = readU16Be(slice);\n subsamples = [];\n for (let j = 0; j < subsampleCount; j++) {\n const clearLen = readU16Be(slice);\n const protectedLen = readU32Be(slice);\n subsamples.push({ clearLen, protectedLen });\n }\n }\n const sample = trackData.samples[i];\n sample.encryption = { iv, subsamples };\n }\n }\n break;\n // Metadata section\n // https://exiftool.org/TagNames/QuickTime.html\n // https://mp4workshop.com/about\n case 'udta':\n { // Contains either movie metadata or track metadata\n const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n for (const { boxInfo, slice } of iterator) {\n if (boxInfo.name !== 'meta' && !this.currentTrack) {\n const startPos = slice.filePos;\n this.metadataTags.raw ??= {};\n if (boxInfo.name[0] === '©') {\n // https://mp4workshop.com/about\n // Box name starting with © indicates \"international text\"\n this.metadataTags.raw[boxInfo.name] ??= readMetadataStringShort(slice);\n }\n else {\n this.metadataTags.raw[boxInfo.name] ??= readBytes(slice, boxInfo.contentSize);\n }\n slice.filePos = startPos;\n }\n switch (boxInfo.name) {\n case 'meta':\n {\n slice.skip(-boxInfo.headerSize);\n this.traverseBox(slice);\n }\n break;\n case '©nam':\n case 'name':\n {\n if (this.currentTrack) {\n this.currentTrack.name = textDecoder.decode(readBytes(slice, boxInfo.contentSize));\n }\n else {\n this.metadataTags.title ??= readMetadataStringShort(slice);\n }\n }\n break;\n case '©des':\n {\n if (!this.currentTrack) {\n this.metadataTags.description ??= readMetadataStringShort(slice);\n }\n }\n break;\n case '©ART':\n {\n if (!this.currentTrack) {\n this.metadataTags.artist ??= readMetadataStringShort(slice);\n }\n }\n break;\n case '©alb':\n {\n if (!this.currentTrack) {\n this.metadataTags.album ??= readMetadataStringShort(slice);\n }\n }\n break;\n case 'albr':\n {\n if (!this.currentTrack) {\n this.metadataTags.albumArtist ??= readMetadataStringShort(slice);\n }\n }\n break;\n case '©gen':\n {\n if (!this.currentTrack) {\n this.metadataTags.genre ??= readMetadataStringShort(slice);\n }\n }\n break;\n case '©day':\n {\n if (!this.currentTrack) {\n const date = new Date(readMetadataStringShort(slice));\n if (!Number.isNaN(date.getTime())) {\n this.metadataTags.date ??= date;\n }\n }\n }\n break;\n case '©cmt':\n {\n if (!this.currentTrack) {\n this.metadataTags.comment ??= readMetadataStringShort(slice);\n }\n }\n break;\n case '©lyr':\n {\n if (!this.currentTrack) {\n this.metadataTags.lyrics ??= readMetadataStringShort(slice);\n }\n }\n break;\n }\n }\n }\n break;\n case 'meta':\n {\n if (this.currentTrack) {\n break; // Only care about movie-level metadata for now\n }\n // The 'meta' box comes in two flavors, one with flags/version and one without. To know which is which,\n // let's read the next 4 bytes, which are either the version or the size of the first subbox.\n const word = readU32Be(slice);\n const isQuickTime = word !== 0;\n this.currentMetadataKeys = new Map();\n if (isQuickTime) {\n this.readContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n }\n else {\n this.readContiguousBoxes(slice.slice(contentStartPos + 4, boxInfo.contentSize - 4));\n }\n this.currentMetadataKeys = null;\n }\n break;\n case 'keys':\n {\n if (!this.currentMetadataKeys) {\n break;\n }\n slice.skip(4); // Version + flags\n const entryCount = readU32Be(slice);\n for (let i = 0; i < entryCount; i++) {\n const keySize = readU32Be(slice);\n slice.skip(4); // Key namespace\n const keyName = textDecoder.decode(readBytes(slice, keySize - 8));\n this.currentMetadataKeys.set(i + 1, keyName);\n }\n }\n break;\n case 'ilst':\n {\n if (!this.currentMetadataKeys) {\n break;\n }\n const iterator = this.iterateContiguousBoxes(slice.slice(contentStartPos, boxInfo.contentSize));\n for (const { boxInfo, slice } of iterator) {\n let metadataKey = boxInfo.name;\n // Interpret the box name as a u32be\n const nameAsNumber = (metadataKey.charCodeAt(0) << 24)\n + (metadataKey.charCodeAt(1) << 16)\n + (metadataKey.charCodeAt(2) << 8)\n + metadataKey.charCodeAt(3);\n if (this.currentMetadataKeys.has(nameAsNumber)) {\n // An entry exists for this number\n metadataKey = this.currentMetadataKeys.get(nameAsNumber);\n }\n const data = readDataBox(slice);\n this.metadataTags.raw ??= {};\n this.metadataTags.raw[metadataKey] ??= data;\n switch (metadataKey) {\n case '©nam':\n case 'titl':\n case 'com.apple.quicktime.title':\n case 'title':\n {\n if (typeof data === 'string') {\n this.metadataTags.title ??= data;\n }\n }\n break;\n case '©des':\n case 'desc':\n case 'dscp':\n case 'com.apple.quicktime.description':\n case 'description':\n {\n if (typeof data === 'string') {\n this.metadataTags.description ??= data;\n }\n }\n break;\n case '©ART':\n case 'com.apple.quicktime.artist':\n case 'artist':\n {\n if (typeof data === 'string') {\n this.metadataTags.artist ??= data;\n }\n }\n break;\n case '©alb':\n case 'albm':\n case 'com.apple.quicktime.album':\n case 'album':\n {\n if (typeof data === 'string') {\n this.metadataTags.album ??= data;\n }\n }\n break;\n case 'aART':\n case 'album_artist':\n {\n if (typeof data === 'string') {\n this.metadataTags.albumArtist ??= data;\n }\n }\n break;\n case '©cmt':\n case 'com.apple.quicktime.comment':\n case 'comment':\n {\n if (typeof data === 'string') {\n this.metadataTags.comment ??= data;\n }\n }\n break;\n case '©gen':\n case 'gnre':\n case 'com.apple.quicktime.genre':\n case 'genre':\n {\n if (typeof data === 'string') {\n this.metadataTags.genre ??= data;\n }\n }\n break;\n case '©lyr':\n case 'lyrics':\n {\n if (typeof data === 'string') {\n this.metadataTags.lyrics ??= data;\n }\n }\n break;\n case '©day':\n case 'rldt':\n case 'com.apple.quicktime.creationdate':\n case 'date':\n {\n if (typeof data === 'string') {\n const date = new Date(data);\n if (!Number.isNaN(date.getTime())) {\n this.metadataTags.date ??= date;\n }\n }\n }\n break;\n case 'covr':\n case 'com.apple.quicktime.artwork':\n {\n if (data instanceof RichImageData) {\n this.metadataTags.images ??= [];\n this.metadataTags.images.push({\n data: data.data,\n kind: 'coverFront',\n mimeType: data.mimeType,\n });\n }\n else if (data instanceof Uint8Array) {\n this.metadataTags.images ??= [];\n this.metadataTags.images.push({\n data,\n kind: 'coverFront',\n mimeType: 'image/*',\n });\n }\n }\n break;\n case 'track':\n {\n if (typeof data === 'string') {\n const parts = data.split('/');\n const trackNum = Number.parseInt(parts[0], 10);\n const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(trackNum) && trackNum > 0) {\n this.metadataTags.trackNumber ??= trackNum;\n }\n if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {\n this.metadataTags.tracksTotal ??= tracksTotal;\n }\n }\n }\n break;\n case 'trkn':\n {\n if (data instanceof Uint8Array && data.length >= 6) {\n const view = toDataView(data);\n const trackNumber = view.getUint16(2, false);\n const tracksTotal = view.getUint16(4, false);\n if (trackNumber > 0) {\n this.metadataTags.trackNumber ??= trackNumber;\n }\n if (tracksTotal > 0) {\n this.metadataTags.tracksTotal ??= tracksTotal;\n }\n }\n }\n break;\n case 'disc':\n case 'disk':\n {\n if (data instanceof Uint8Array && data.length >= 6) {\n const view = toDataView(data);\n const discNumber = view.getUint16(2, false);\n const discNumberMax = view.getUint16(4, false);\n if (discNumber > 0) {\n this.metadataTags.discNumber ??= discNumber;\n }\n if (discNumberMax > 0) {\n this.metadataTags.discsTotal ??= discNumberMax;\n }\n }\n }\n break;\n }\n }\n }\n break;\n }\n slice.filePos = boxEndPos;\n return true;\n }\n }\n class IsobmffTrackBacking {\n constructor(internalTrack) {\n this.internalTrack = internalTrack;\n this.packetToSampleIndex = new WeakMap();\n this.packetToFragmentLocation = new WeakMap();\n }\n getId() {\n return this.internalTrack.id;\n }\n getNumber() {\n const demuxer = this.internalTrack.demuxer;\n const trackType = this.internalTrack.trackBacking.getType();\n let number = 0;\n for (const track of demuxer.tracks) {\n if (track.trackBacking.getType() === trackType) {\n number++;\n }\n if (track === this.internalTrack) {\n break;\n }\n }\n return number;\n }\n getCodec() {\n throw new Error('Not implemented on base class.');\n }\n getInternalCodecId() {\n return this.internalTrack.internalCodecId;\n }\n getName() {\n return this.internalTrack.name;\n }\n getLanguageCode() {\n return this.internalTrack.languageCode;\n }\n getTimeResolution() {\n return this.internalTrack.timescale;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getDisposition() {\n return this.internalTrack.disposition;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n const track = this.internalTrack;\n if (track.durationInMediaTimescale <= 0) {\n // The duration is often zero for fragmented files for example; return `null` to signal that the duration\n // must be computed instead.\n return null;\n }\n assert(track.trackBacking);\n const firstPacket = await track.trackBacking.getFirstPacket({ metadataOnly: true });\n return (firstPacket?.timestamp ?? 0) + track.durationInMediaTimescale / track.timescale;\n }\n async getLiveRefreshInterval() {\n return null;\n }\n async getFirstPacket(options) {\n const regularPacket = await this.fetchPacketForSampleIndex(0, options);\n if (regularPacket || !this.internalTrack.demuxer.isFragmented) {\n // If there's a non-fragmented packet, always prefer that\n return regularPacket;\n }\n return this.performFragmentedLookup(null, (fragment) => {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n if (trackData) {\n return {\n sampleIndex: 0,\n correctSampleFound: true,\n };\n }\n return {\n sampleIndex: -1,\n correctSampleFound: false,\n };\n }, -Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries\n Infinity, options);\n }\n mapTimestampIntoTimescale(timestamp) {\n // Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely\n // that the number was originally an integer divided by the timescale. For stability, it's best\n // to return the integer in this case.\n return roundIfAlmostInteger(timestamp * this.internalTrack.timescale) + this.internalTrack.editListOffset;\n }\n async getPacket(timestamp, options) {\n const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);\n const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);\n const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);\n const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);\n if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {\n // Prefer the non-fragmented packet\n return regularPacket;\n }\n return this.performFragmentedLookup(null, (fragment) => {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n if (!trackData) {\n return { sampleIndex: -1, correctSampleFound: false };\n }\n const index = binarySearchLessOrEqual(trackData.presentationTimestamps, timestampInTimescale, x => x.presentationTimestamp);\n const sampleIndex = index !== -1 ? trackData.presentationTimestamps[index].sampleIndex : -1;\n const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;\n return { sampleIndex, correctSampleFound };\n }, timestampInTimescale, timestampInTimescale, options);\n }\n async getNextPacket(packet, options) {\n const regularSampleIndex = this.packetToSampleIndex.get(packet);\n if (regularSampleIndex !== undefined) {\n // Prefer the non-fragmented packet\n return this.fetchPacketForSampleIndex(regularSampleIndex + 1, options);\n }\n const locationInFragment = this.packetToFragmentLocation.get(packet);\n if (locationInFragment === undefined) {\n throw new Error('Packet was not created from this track.');\n }\n return this.performFragmentedLookup(locationInFragment.fragment, (fragment) => {\n if (fragment === locationInFragment.fragment) {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {\n // We can simply take the next sample in the fragment\n return {\n sampleIndex: locationInFragment.sampleIndex + 1,\n correctSampleFound: true,\n };\n }\n }\n else {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n if (trackData) {\n return {\n sampleIndex: 0,\n correctSampleFound: true,\n };\n }\n }\n return {\n sampleIndex: -1,\n correctSampleFound: false,\n };\n }, -Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries\n Infinity, options);\n }\n async getKeyPacket(timestamp, options) {\n const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);\n const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);\n const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);\n const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);\n if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {\n // Prefer the non-fragmented packet\n return regularPacket;\n }\n return this.performFragmentedLookup(null, (fragment) => {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n if (!trackData) {\n return { sampleIndex: -1, correctSampleFound: false };\n }\n const index = findLastIndex(trackData.presentationTimestamps, (x) => {\n const sample = trackData.samples[x.sampleIndex];\n return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;\n });\n const sampleIndex = index !== -1 ? trackData.presentationTimestamps[index].sampleIndex : -1;\n const correctSampleFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;\n return { sampleIndex, correctSampleFound };\n }, timestampInTimescale, timestampInTimescale, options);\n }\n async getNextKeyPacket(packet, options) {\n const regularSampleIndex = this.packetToSampleIndex.get(packet);\n if (regularSampleIndex !== undefined) {\n // Prefer the non-fragmented packet\n const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);\n const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, regularSampleIndex);\n return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);\n }\n const locationInFragment = this.packetToFragmentLocation.get(packet);\n if (locationInFragment === undefined) {\n throw new Error('Packet was not created from this track.');\n }\n return this.performFragmentedLookup(locationInFragment.fragment, (fragment) => {\n if (fragment === locationInFragment.fragment) {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n const nextKeyFrameIndex = trackData.samples.findIndex((x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex);\n if (nextKeyFrameIndex !== -1) {\n // We can simply take the next key frame in the fragment\n return {\n sampleIndex: nextKeyFrameIndex,\n correctSampleFound: true,\n };\n }\n }\n else {\n const trackData = fragment.trackData.get(this.internalTrack.id);\n if (trackData && trackData.firstKeyFrameTimestamp !== null) {\n const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);\n assert(keyFrameIndex !== -1); // There must be one\n return {\n sampleIndex: keyFrameIndex,\n correctSampleFound: true,\n };\n }\n }\n return {\n sampleIndex: -1,\n correctSampleFound: false,\n };\n }, -Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries\n Infinity, options);\n }\n async fetchPacketForSampleIndex(sampleIndex, options) {\n if (sampleIndex === -1) {\n return null;\n }\n const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);\n const sampleInfo = getSampleInfo(sampleTable, sampleIndex);\n if (!sampleInfo) {\n return null;\n }\n let data;\n if (options.metadataOnly) {\n data = PLACEHOLDER_DATA;\n }\n else {\n let slice = this.internalTrack.demuxer.reader.requestSlice(sampleInfo.sampleOffset, sampleInfo.sampleSize);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null; // Data is outside\n }\n data = readBytes(slice, sampleInfo.sampleSize);\n if (this.internalTrack.encryptionAuxInfo) {\n assert(this.internalTrack.encryptionInfo);\n const entries = await resolveEncryptionAuxInfo(this.internalTrack.demuxer.reader, this.internalTrack.encryptionInfo, this.internalTrack.encryptionAuxInfo);\n if (sampleIndex < entries.length) {\n data = await decryptSample(this.internalTrack, entries[sampleIndex], data, null);\n }\n }\n }\n const timestamp = (sampleInfo.presentationTimestamp - this.internalTrack.editListOffset)\n / this.internalTrack.timescale;\n const duration = sampleInfo.duration / this.internalTrack.timescale;\n const packet = new EncodedPacket(data, sampleInfo.isKeyFrame ? 'key' : 'delta', timestamp, duration, sampleIndex, sampleInfo.sampleSize);\n this.packetToSampleIndex.set(packet, sampleIndex);\n return packet;\n }\n async fetchPacketInFragment(fragment, sampleIndex, options) {\n if (sampleIndex === -1) {\n return null;\n }\n const trackData = fragment.trackData.get(this.internalTrack.id);\n const fragmentSample = trackData.samples[sampleIndex];\n assert(fragmentSample);\n let data;\n if (options.metadataOnly) {\n data = PLACEHOLDER_DATA;\n }\n else {\n let slice = this.internalTrack.demuxer.reader.requestSlice(fragmentSample.byteOffset, fragmentSample.byteSize);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null; // Data is outside\n }\n data = readBytes(slice, fragmentSample.byteSize);\n if (fragmentSample.encryption) {\n data = await decryptSample(this.internalTrack, fragmentSample.encryption, data, fragment);\n }\n }\n const timestamp = (fragmentSample.presentationTimestamp - this.internalTrack.editListOffset)\n / this.internalTrack.timescale;\n const duration = fragmentSample.duration / this.internalTrack.timescale;\n const packet = new EncodedPacket(data, fragmentSample.isKeyFrame ? 'key' : 'delta', timestamp, duration, fragment.moofOffset + sampleIndex, fragmentSample.byteSize);\n this.packetToFragmentLocation.set(packet, { fragment, sampleIndex });\n return packet;\n }\n /** Looks for a packet in the fragments while trying to load as few fragments as possible to retrieve it. */\n async performFragmentedLookup(\n // The fragment where we start looking\n startFragment, \n // This function returns the best-matching sample in a given fragment\n getMatchInFragment, \n // The timestamp with which we can search the lookup table\n searchTimestamp, \n // The timestamp for which we know the correct sample will not come after it\n latestTimestamp, options) {\n const demuxer = this.internalTrack.demuxer;\n let currentFragment = null;\n let bestFragment = null;\n let bestSampleIndex = -1;\n if (startFragment) {\n const { sampleIndex, correctSampleFound } = getMatchInFragment(startFragment);\n if (correctSampleFound) {\n return this.fetchPacketInFragment(startFragment, sampleIndex, options);\n }\n if (sampleIndex !== -1) {\n bestFragment = startFragment;\n bestSampleIndex = sampleIndex;\n }\n }\n // Search for a lookup entry; this way, we won't need to start searching from the start of the file\n // but can jump right into the correct fragment (or at least nearby).\n const lookupEntryIndex = binarySearchLessOrEqual(this.internalTrack.fragmentLookupTable, searchTimestamp, x => x.timestamp);\n const lookupEntry = lookupEntryIndex !== -1\n ? this.internalTrack.fragmentLookupTable[lookupEntryIndex]\n : null;\n const positionCacheIndex = binarySearchLessOrEqual(this.internalTrack.fragmentPositionCache, searchTimestamp, x => x.startTimestamp);\n const positionCacheEntry = positionCacheIndex !== -1\n ? this.internalTrack.fragmentPositionCache[positionCacheIndex]\n : null;\n const lookupEntryPosition = Math.max(lookupEntry?.moofOffset ?? 0, positionCacheEntry?.moofOffset ?? 0) || null;\n let currentPos;\n if (!startFragment) {\n currentPos = lookupEntryPosition ?? 0;\n }\n else {\n if (lookupEntryPosition === null || startFragment.moofOffset >= lookupEntryPosition) {\n currentPos = startFragment.moofOffset + startFragment.moofSize;\n currentFragment = startFragment;\n }\n else {\n // Use the lookup entry\n currentPos = lookupEntryPosition;\n }\n }\n while (true) {\n if (currentFragment) {\n const trackData = currentFragment.trackData.get(this.internalTrack.id);\n if (trackData && trackData.startTimestamp > latestTimestamp) {\n // We're already past the upper bound, no need to keep searching\n break;\n }\n }\n // Load the header\n let slice = demuxer.reader.requestSliceRange(currentPos, MIN_BOX_HEADER_SIZE, MAX_BOX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const boxStartPos = currentPos;\n const boxInfo = readBoxHeader(slice);\n if (!boxInfo) {\n break;\n }\n if (boxInfo.name === 'moof') {\n currentFragment = await demuxer.readFragment(boxStartPos);\n const { sampleIndex, correctSampleFound } = getMatchInFragment(currentFragment);\n if (correctSampleFound) {\n return this.fetchPacketInFragment(currentFragment, sampleIndex, options);\n }\n if (sampleIndex !== -1) {\n bestFragment = currentFragment;\n bestSampleIndex = sampleIndex;\n }\n }\n currentPos = boxStartPos + boxInfo.totalSize;\n }\n // Catch faulty lookup table entries\n if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {\n // The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied\n // the match. In this case, let's search again but using the lookup entry before that.\n const previousLookupEntry = this.internalTrack.fragmentLookupTable[lookupEntryIndex - 1];\n assert(!previousLookupEntry || previousLookupEntry.timestamp < lookupEntry.timestamp);\n const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;\n return this.performFragmentedLookup(null, getMatchInFragment, newSearchTimestamp, latestTimestamp, options);\n }\n if (bestFragment) {\n // If we finished looping but didn't find a perfect match, still return the best match we found\n return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);\n }\n return null;\n }\n }\n class IsobmffVideoTrackBacking extends IsobmffTrackBacking {\n constructor(internalTrack) {\n super(internalTrack);\n this.decoderConfigPromise = null;\n this.internalTrack = internalTrack;\n }\n getType() {\n return 'video';\n }\n getCodec() {\n return this.internalTrack.info.codec;\n }\n getCodedWidth() {\n return this.internalTrack.info.width;\n }\n getCodedHeight() {\n return this.internalTrack.info.height;\n }\n getSquarePixelWidth() {\n return this.internalTrack.info.squarePixelWidth;\n }\n getSquarePixelHeight() {\n return this.internalTrack.info.squarePixelHeight;\n }\n getRotation() {\n return this.internalTrack.rotation;\n }\n async getColorSpace() {\n return {\n primaries: this.internalTrack.info.colorSpace?.primaries,\n transfer: this.internalTrack.info.colorSpace?.transfer,\n matrix: this.internalTrack.info.colorSpace?.matrix,\n fullRange: this.internalTrack.info.colorSpace?.fullRange,\n };\n }\n async canBeTransparent() {\n return this.internalTrack.info.codec === 'prores' && (this.internalTrack.info.proresFormat === 'ap4h'\n || this.internalTrack.info.proresFormat === 'ap4x');\n }\n async getDecoderConfig() {\n if (!this.internalTrack.info.codec) {\n return null;\n }\n return this.decoderConfigPromise ??= (async () => {\n if (this.internalTrack.info.codec === 'vp9' && !this.internalTrack.info.vp9CodecInfo) {\n const firstPacket = await this.getFirstPacket({});\n this.internalTrack.info.vp9CodecInfo = firstPacket && extractVp9CodecInfoFromPacket(firstPacket.data);\n }\n else if (this.internalTrack.info.codec === 'av1' && !this.internalTrack.info.av1CodecInfo) {\n const firstPacket = await this.getFirstPacket({});\n this.internalTrack.info.av1CodecInfo = firstPacket && extractAv1CodecInfoFromPacket(firstPacket.data);\n }\n const config = {\n codec: extractVideoCodecString(this.internalTrack.info),\n codedWidth: this.internalTrack.info.width,\n codedHeight: this.internalTrack.info.height,\n description: this.internalTrack.info.codecDescription ?? undefined,\n colorSpace: this.internalTrack.info.colorSpace ?? undefined,\n };\n if (this.internalTrack.info.width !== this.internalTrack.info.squarePixelWidth\n || this.internalTrack.info.height !== this.internalTrack.info.squarePixelHeight) {\n config.displayAspectWidth = this.internalTrack.info.squarePixelWidth;\n config.displayAspectHeight = this.internalTrack.info.squarePixelHeight;\n }\n return config;\n })();\n }\n }\n class IsobmffAudioTrackBacking extends IsobmffTrackBacking {\n constructor(internalTrack) {\n super(internalTrack);\n this.decoderConfig = null;\n this.internalTrack = internalTrack;\n }\n getType() {\n return 'audio';\n }\n getCodec() {\n return this.internalTrack.info.codec;\n }\n getNumberOfChannels() {\n return this.internalTrack.info.numberOfChannels;\n }\n getSampleRate() {\n return this.internalTrack.info.sampleRate;\n }\n async getDecoderConfig() {\n if (!this.internalTrack.info.codec) {\n return null;\n }\n return this.decoderConfig ??= {\n codec: extractAudioCodecString(this.internalTrack.info),\n numberOfChannels: this.internalTrack.info.numberOfChannels,\n sampleRate: this.internalTrack.info.sampleRate,\n description: this.internalTrack.info.codecDescription ?? undefined,\n };\n }\n }\n const getSampleIndexForTimestamp = (sampleTable, timescaleUnits) => {\n if (sampleTable.presentationTimestamps) {\n const index = binarySearchLessOrEqual(sampleTable.presentationTimestamps, timescaleUnits, x => x.presentationTimestamp);\n if (index === -1) {\n return -1;\n }\n return sampleTable.presentationTimestamps[index].sampleIndex;\n }\n else {\n const index = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, timescaleUnits, x => x.startDecodeTimestamp);\n if (index === -1) {\n return -1;\n }\n const entry = sampleTable.sampleTimingEntries[index];\n return entry.startIndex\n + Math.min(Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta), entry.count - 1);\n }\n };\n const getKeyframeSampleIndexForTimestamp = (sampleTable, timescaleUnits) => {\n if (!sampleTable.keySampleIndices) {\n // Every sample is a keyframe\n return getSampleIndexForTimestamp(sampleTable, timescaleUnits);\n }\n if (sampleTable.presentationTimestamps) {\n const index = binarySearchLessOrEqual(sampleTable.presentationTimestamps, timescaleUnits, x => x.presentationTimestamp);\n if (index === -1) {\n return -1;\n }\n // Walk the samples in presentation order until we find one that's a keyframe\n for (let i = index; i >= 0; i--) {\n const sampleIndex = sampleTable.presentationTimestamps[i].sampleIndex;\n const isKeyFrame = binarySearchExact(sampleTable.keySampleIndices, sampleIndex, x => x) !== -1;\n if (isKeyFrame) {\n return sampleIndex;\n }\n }\n return -1;\n }\n else {\n const sampleIndex = getSampleIndexForTimestamp(sampleTable, timescaleUnits);\n const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);\n return sampleTable.keySampleIndices[index] ?? -1;\n }\n };\n const getSampleInfo = (sampleTable, sampleIndex) => {\n const timingEntryIndex = binarySearchLessOrEqual(sampleTable.sampleTimingEntries, sampleIndex, x => x.startIndex);\n const timingEntry = sampleTable.sampleTimingEntries[timingEntryIndex];\n if (!timingEntry || timingEntry.startIndex + timingEntry.count <= sampleIndex) {\n return null;\n }\n const decodeTimestamp = timingEntry.startDecodeTimestamp\n + (sampleIndex - timingEntry.startIndex) * timingEntry.delta;\n let presentationTimestamp = decodeTimestamp;\n const offsetEntryIndex = binarySearchLessOrEqual(sampleTable.sampleCompositionTimeOffsets, sampleIndex, x => x.startIndex);\n const offsetEntry = sampleTable.sampleCompositionTimeOffsets[offsetEntryIndex];\n if (offsetEntry && sampleIndex - offsetEntry.startIndex < offsetEntry.count) {\n presentationTimestamp += offsetEntry.offset;\n }\n const sampleSize = sampleTable.sampleSizes[Math.min(sampleIndex, sampleTable.sampleSizes.length - 1)];\n const chunkEntryIndex = binarySearchLessOrEqual(sampleTable.sampleToChunk, sampleIndex, x => x.startSampleIndex);\n const chunkEntry = sampleTable.sampleToChunk[chunkEntryIndex];\n assert(chunkEntry);\n const chunkIndex = chunkEntry.startChunkIndex\n + Math.floor((sampleIndex - chunkEntry.startSampleIndex) / chunkEntry.samplesPerChunk);\n const chunkOffset = sampleTable.chunkOffsets[chunkIndex];\n const startSampleIndexOfChunk = chunkEntry.startSampleIndex\n + (chunkIndex - chunkEntry.startChunkIndex) * chunkEntry.samplesPerChunk;\n let chunkSize = 0;\n let sampleOffset = chunkOffset;\n if (sampleTable.sampleSizes.length === 1) {\n sampleOffset += sampleSize * (sampleIndex - startSampleIndexOfChunk);\n chunkSize += sampleSize * chunkEntry.samplesPerChunk;\n }\n else {\n for (let i = startSampleIndexOfChunk; i < startSampleIndexOfChunk + chunkEntry.samplesPerChunk; i++) {\n const sampleSize = sampleTable.sampleSizes[i];\n if (i < sampleIndex) {\n sampleOffset += sampleSize;\n }\n chunkSize += sampleSize;\n }\n }\n let duration = timingEntry.delta;\n if (sampleTable.presentationTimestamps) {\n // In order to accurately compute the duration, we need to take the duration to the next sample in presentation\n // order, not in decode order\n const presentationIndex = sampleTable.presentationTimestampIndexMap[sampleIndex];\n assert(presentationIndex !== undefined);\n if (presentationIndex < sampleTable.presentationTimestamps.length - 1) {\n const nextEntry = sampleTable.presentationTimestamps[presentationIndex + 1];\n const nextPresentationTimestamp = nextEntry.presentationTimestamp;\n duration = nextPresentationTimestamp - presentationTimestamp;\n }\n }\n return {\n presentationTimestamp,\n duration,\n sampleOffset,\n sampleSize,\n chunkOffset,\n chunkSize,\n isKeyFrame: sampleTable.keySampleIndices\n ? binarySearchExact(sampleTable.keySampleIndices, sampleIndex, x => x) !== -1\n : true,\n };\n };\n const getNextKeyframeIndexForSample = (sampleTable, sampleIndex) => {\n if (!sampleTable.keySampleIndices) {\n return sampleIndex + 1;\n }\n const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);\n return sampleTable.keySampleIndices[index + 1] ?? -1;\n };\n const offsetFragmentTrackDataByTimestamp = (trackData, timestamp) => {\n trackData.startTimestamp += timestamp;\n trackData.endTimestamp += timestamp;\n for (const sample of trackData.samples) {\n sample.presentationTimestamp += timestamp;\n }\n for (const entry of trackData.presentationTimestamps) {\n entry.presentationTimestamp += timestamp;\n }\n };\n /** Extracts the rotation component from a transformation matrix, in degrees. */\n const extractRotationFromMatrix = (matrix) => {\n const [a, b] = matrix; // (1, 0) projects onto (a, b), so that's all we need\n const radians = Math.atan2(b, a);\n if (!Number.isFinite(radians)) {\n // Can happen if the entire matrix is 0, for example\n return 0;\n }\n return radians * (180 / Math.PI);\n };\n const sampleTableIsEmpty = (sampleTable) => {\n return sampleTable.sampleSizes.length === 0;\n };\n const getOrCreateEncryptionAuxInfo = (track) => {\n if (track.currentFragmentState) {\n return track.currentFragmentState.encryptionAuxInfo ??= {\n defaultSampleInfoSize: 0,\n sampleSizes: null,\n sampleCount: 0,\n offset: null,\n resolved: null,\n };\n }\n else {\n return track.encryptionAuxInfo ??= {\n defaultSampleInfoSize: 0,\n sampleSizes: null,\n sampleCount: 0,\n offset: null,\n resolved: null,\n };\n }\n };\n const resolveEncryptionAuxInfo = async (reader, encryptionInfo, aux) => {\n if (aux.resolved) {\n return aux.resolved;\n }\n if (aux.offset === null || aux.sampleCount === 0) {\n throw new Error('Incomplete saiz/saio info; cannot resolve encryption data.');\n }\n let totalSize = 0;\n if (aux.defaultSampleInfoSize > 0) {\n totalSize = aux.defaultSampleInfoSize * aux.sampleCount;\n }\n else {\n assert(aux.sampleSizes);\n for (let i = 0; i < aux.sampleCount; i++) {\n totalSize += aux.sampleSizes[i];\n }\n }\n let slice = reader.requestSlice(aux.offset, totalSize);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n throw new Error('Failed to read auxiliary encryption info.');\n }\n const ivSize = encryptionInfo.defaultPerSampleIvSize;\n assert(ivSize !== null);\n // Each aux entry has the same byte layout as a senc entry: IV (of size ivSize, or the constant IV from tenc\n // when ivSize is 0), then optionally subsample count + [clearLen, protectedLen] pairs. Subsamples are present\n // iff the entry is larger than the IV.\n const entries = [];\n for (let i = 0; i < aux.sampleCount; i++) {\n const entrySize = aux.defaultSampleInfoSize > 0\n ? aux.defaultSampleInfoSize\n : aux.sampleSizes[i];\n const iv = new Uint8Array(16);\n if (ivSize > 0) {\n iv.set(readBytes(slice, ivSize), 0);\n }\n else {\n iv.set(encryptionInfo.defaultConstantIv, 0);\n }\n let subsamples = null;\n if (entrySize > ivSize) {\n const subsampleCount = readU16Be(slice);\n subsamples = [];\n for (let j = 0; j < subsampleCount; j++) {\n const clearLen = readU16Be(slice);\n const protectedLen = readU32Be(slice);\n subsamples.push({ clearLen, protectedLen });\n }\n }\n entries.push({ iv, subsamples });\n }\n aux.resolved = entries;\n return entries;\n };\n const decryptSample = async (track, sampleEncryption, data, fragment) => {\n assert(track.encryptionInfo);\n const encryptionInfo = track.encryptionInfo;\n assert(encryptionInfo.defaultKid !== null);\n const keyId = encryptionInfo.defaultKid;\n let keyBytes;\n const cacheEntry = track.demuxer.decryptionKeyCache.get(keyId);\n if (cacheEntry) {\n keyBytes = await cacheEntry;\n }\n else {\n if (!track.demuxer.input._formatOptions.isobmff?.resolveKeyId) {\n throw new Error('Encrypted media samples encountered. To decrypt them, please provide a callback for'\n + ' InputOptions.formatOptions.isobmff.resolveKeyId.');\n }\n const promise = (async () => {\n let psshBoxes = track.demuxer.psshBoxes;\n if (fragment) {\n psshBoxes = [\n ...psshBoxes,\n ...fragment.psshBoxes,\n ].filter(x => x.keyIds === null || x.keyIds.includes(keyId));\n // Filter out duplicates\n for (let i = 0; i < psshBoxes.length - 1; i++) {\n for (let j = i + 1; j < psshBoxes.length; j++) {\n if (psshBoxesAreEqual(psshBoxes[i], psshBoxes[j])) {\n psshBoxes.splice(j, 1);\n j--;\n }\n }\n }\n }\n const keyResult = await track.demuxer.input._formatOptions.isobmff.resolveKeyId({ keyId, psshBoxes });\n if (!((typeof keyResult === 'string' && keyResult.length === 32 && HEX_STRING_REGEX.test(keyResult))\n || (keyResult instanceof Uint8Array && keyResult.byteLength === 16))) {\n throw new TypeError('resolveKeyId must return a 32-character hex string or a 16-byte Uint8Array containing the'\n + ' decryption key.');\n }\n return keyResult instanceof Uint8Array\n ? keyResult\n : hexStringToBytes(keyResult);\n })();\n track.demuxer.decryptionKeyCache.set(keyId, promise);\n keyBytes = await promise;\n }\n if (encryptionInfo.scheme === 'cenc' || encryptionInfo.scheme === 'cens') {\n return decryptCtr(keyBytes, encryptionInfo, sampleEncryption, data);\n }\n else {\n return decryptCbcs(keyBytes, encryptionInfo, sampleEncryption, data);\n }\n };\n const decryptCtr = async (key, encryptionInfo, sampleEncryption, data) => {\n const counter = new Uint8Array(16);\n counter.set(sampleEncryption.iv, 0);\n const cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'AES-CTR' }, false, ['decrypt']);\n const cryptApply = async (input) => {\n const plaintext = await crypto.subtle.decrypt({ name: 'AES-CTR', counter, length: 64 }, cryptoKey, input);\n return new Uint8Array(plaintext);\n };\n if (!sampleEncryption.subsamples) {\n // Whole sample is protected, no pattern\n return cryptApply(data);\n }\n assert(encryptionInfo.defaultCryptByteBlock !== null && encryptionInfo.defaultSkipByteBlock !== null);\n const cryptRanges = collectCryptRanges(sampleEncryption.subsamples, encryptionInfo.defaultCryptByteBlock, encryptionInfo.defaultSkipByteBlock);\n // Concatenate all crypt ranges into a single buffer so the continuous CTR counter behavior is preserved\n let totalCryptLen = 0;\n for (const range of cryptRanges) {\n for (const seg of range.perSubsample) {\n totalCryptLen += seg.length;\n }\n }\n const cryptBuffer = new Uint8Array(totalCryptLen);\n let writePos = 0;\n for (const range of cryptRanges) {\n for (const seg of range.perSubsample) {\n cryptBuffer.set(data.subarray(seg.offset, seg.offset + seg.length), writePos);\n writePos += seg.length;\n }\n }\n const plain = await cryptApply(cryptBuffer);\n // Now let's build the output\n const output = new Uint8Array(data);\n let readPos = 0;\n for (const range of cryptRanges) {\n for (const seg of range.perSubsample) {\n output.set(plain.subarray(readPos, readPos + seg.length), seg.offset);\n readPos += seg.length;\n }\n }\n return output;\n };\n const decryptCbcs = (key, encryptionInfo, sampleEncryption, data) => {\n const ctx = new Aes128CbcContext();\n ctx.init({ key, iv: sampleEncryption.iv });\n const cryptByteBlock = encryptionInfo.defaultCryptByteBlock;\n const skipByteBlock = encryptionInfo.defaultSkipByteBlock;\n assert(cryptByteBlock !== null && skipByteBlock !== null);\n if (!sampleEncryption.subsamples) {\n // Whole-sample encryption: straightforward CBC over floor(size / 16) blocks, any trailing bytes stay clear\n const output = new Uint8Array(data);\n const numBlocks = Math.floor(data.length / 16);\n for (let b = 0; b < numBlocks; b++) {\n const off = b * 16;\n ctx.in.set(data.subarray(off, off + 16));\n ctx.decrypt();\n output.set(ctx.out, off);\n }\n return output;\n }\n if (cryptByteBlock === 0 && skipByteBlock === 0) {\n throw new Error('cbcs with subsamples requires pattern encryption.');\n }\n const output = new Uint8Array(data);\n // Pattern decryption: IV is reset at the start of each subsample. Within a subsample, the CBC chain continues\n // across skipped blocks (the IV after a crypt group carries over to the next crypt group's first block).\n const cryptRanges = collectCryptRanges(sampleEncryption.subsamples, cryptByteBlock, skipByteBlock);\n const ivView = new DataView(sampleEncryption.iv.buffer, sampleEncryption.iv.byteOffset, 16);\n for (const range of cryptRanges) {\n // Reset IV per subsample\n ctx.iv[0] = ivView.getUint32(0, false);\n ctx.iv[1] = ivView.getUint32(4, false);\n ctx.iv[2] = ivView.getUint32(8, false);\n ctx.iv[3] = ivView.getUint32(12, false);\n for (const seg of range.perSubsample) {\n // Decrypt length / 16 blocks at this offset\n const numBlocks = seg.length / 16;\n for (let b = 0; b < numBlocks; b++) {\n const offset = seg.offset + b * 16;\n ctx.in.set(data.subarray(offset, offset + 16));\n ctx.decrypt();\n output.set(ctx.out, offset);\n }\n }\n }\n return output;\n };\n const collectCryptRanges = (subsamples, cryptByteBlock, skipByteBlock) => {\n const ranges = [];\n const hasPattern = cryptByteBlock !== 0 || skipByteBlock !== 0;\n let cursor = 0;\n for (const subsample of subsamples) {\n cursor += subsample.clearLen;\n const perSubsample = [];\n if (!hasPattern) {\n if (subsample.protectedLen > 0) {\n perSubsample.push({ offset: cursor, length: subsample.protectedLen });\n }\n cursor += subsample.protectedLen;\n }\n else {\n let remaining = subsample.protectedLen;\n let pos = cursor;\n while (remaining > 0) {\n if (remaining < 16 * cryptByteBlock) {\n break; // Partial final crypt group stays clear\n }\n const cryptBytes = 16 * cryptByteBlock;\n perSubsample.push({ offset: pos, length: cryptBytes });\n pos += cryptBytes;\n remaining -= cryptBytes;\n const skipBytes = Math.min(16 * skipByteBlock, remaining);\n pos += skipBytes;\n remaining -= skipBytes;\n }\n cursor += subsample.protectedLen;\n }\n ranges.push({ perSubsample });\n }\n return ranges;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /** Defines some of the EBML IDs used by Matroska files. */\n var EBMLId;\n (function (EBMLId) {\n EBMLId[EBMLId[\"EBML\"] = 440786851] = \"EBML\";\n EBMLId[EBMLId[\"EBMLVersion\"] = 17030] = \"EBMLVersion\";\n EBMLId[EBMLId[\"EBMLReadVersion\"] = 17143] = \"EBMLReadVersion\";\n EBMLId[EBMLId[\"EBMLMaxIDLength\"] = 17138] = \"EBMLMaxIDLength\";\n EBMLId[EBMLId[\"EBMLMaxSizeLength\"] = 17139] = \"EBMLMaxSizeLength\";\n EBMLId[EBMLId[\"DocType\"] = 17026] = \"DocType\";\n EBMLId[EBMLId[\"DocTypeVersion\"] = 17031] = \"DocTypeVersion\";\n EBMLId[EBMLId[\"DocTypeReadVersion\"] = 17029] = \"DocTypeReadVersion\";\n EBMLId[EBMLId[\"Void\"] = 236] = \"Void\";\n EBMLId[EBMLId[\"Segment\"] = 408125543] = \"Segment\";\n EBMLId[EBMLId[\"SeekHead\"] = 290298740] = \"SeekHead\";\n EBMLId[EBMLId[\"Seek\"] = 19899] = \"Seek\";\n EBMLId[EBMLId[\"SeekID\"] = 21419] = \"SeekID\";\n EBMLId[EBMLId[\"SeekPosition\"] = 21420] = \"SeekPosition\";\n EBMLId[EBMLId[\"Duration\"] = 17545] = \"Duration\";\n EBMLId[EBMLId[\"Info\"] = 357149030] = \"Info\";\n EBMLId[EBMLId[\"TimestampScale\"] = 2807729] = \"TimestampScale\";\n EBMLId[EBMLId[\"MuxingApp\"] = 19840] = \"MuxingApp\";\n EBMLId[EBMLId[\"WritingApp\"] = 22337] = \"WritingApp\";\n EBMLId[EBMLId[\"Tracks\"] = 374648427] = \"Tracks\";\n EBMLId[EBMLId[\"TrackEntry\"] = 174] = \"TrackEntry\";\n EBMLId[EBMLId[\"TrackNumber\"] = 215] = \"TrackNumber\";\n EBMLId[EBMLId[\"TrackUID\"] = 29637] = \"TrackUID\";\n EBMLId[EBMLId[\"TrackType\"] = 131] = \"TrackType\";\n EBMLId[EBMLId[\"FlagEnabled\"] = 185] = \"FlagEnabled\";\n EBMLId[EBMLId[\"FlagDefault\"] = 136] = \"FlagDefault\";\n EBMLId[EBMLId[\"FlagForced\"] = 21930] = \"FlagForced\";\n EBMLId[EBMLId[\"FlagOriginal\"] = 21934] = \"FlagOriginal\";\n EBMLId[EBMLId[\"FlagHearingImpaired\"] = 21931] = \"FlagHearingImpaired\";\n EBMLId[EBMLId[\"FlagVisualImpaired\"] = 21932] = \"FlagVisualImpaired\";\n EBMLId[EBMLId[\"FlagCommentary\"] = 21935] = \"FlagCommentary\";\n EBMLId[EBMLId[\"FlagLacing\"] = 156] = \"FlagLacing\";\n EBMLId[EBMLId[\"Name\"] = 21358] = \"Name\";\n EBMLId[EBMLId[\"Language\"] = 2274716] = \"Language\";\n EBMLId[EBMLId[\"LanguageBCP47\"] = 2274717] = \"LanguageBCP47\";\n EBMLId[EBMLId[\"CodecID\"] = 134] = \"CodecID\";\n EBMLId[EBMLId[\"CodecPrivate\"] = 25506] = \"CodecPrivate\";\n EBMLId[EBMLId[\"CodecDelay\"] = 22186] = \"CodecDelay\";\n EBMLId[EBMLId[\"SeekPreRoll\"] = 22203] = \"SeekPreRoll\";\n EBMLId[EBMLId[\"DefaultDuration\"] = 2352003] = \"DefaultDuration\";\n EBMLId[EBMLId[\"Video\"] = 224] = \"Video\";\n EBMLId[EBMLId[\"PixelWidth\"] = 176] = \"PixelWidth\";\n EBMLId[EBMLId[\"PixelHeight\"] = 186] = \"PixelHeight\";\n EBMLId[EBMLId[\"DisplayWidth\"] = 21680] = \"DisplayWidth\";\n EBMLId[EBMLId[\"DisplayHeight\"] = 21690] = \"DisplayHeight\";\n EBMLId[EBMLId[\"DisplayUnit\"] = 21682] = \"DisplayUnit\";\n EBMLId[EBMLId[\"AlphaMode\"] = 21440] = \"AlphaMode\";\n EBMLId[EBMLId[\"Audio\"] = 225] = \"Audio\";\n EBMLId[EBMLId[\"SamplingFrequency\"] = 181] = \"SamplingFrequency\";\n EBMLId[EBMLId[\"Channels\"] = 159] = \"Channels\";\n EBMLId[EBMLId[\"BitDepth\"] = 25188] = \"BitDepth\";\n EBMLId[EBMLId[\"SimpleBlock\"] = 163] = \"SimpleBlock\";\n EBMLId[EBMLId[\"BlockGroup\"] = 160] = \"BlockGroup\";\n EBMLId[EBMLId[\"Block\"] = 161] = \"Block\";\n EBMLId[EBMLId[\"BlockAdditions\"] = 30113] = \"BlockAdditions\";\n EBMLId[EBMLId[\"BlockMore\"] = 166] = \"BlockMore\";\n EBMLId[EBMLId[\"BlockAdditional\"] = 165] = \"BlockAdditional\";\n EBMLId[EBMLId[\"BlockAddID\"] = 238] = \"BlockAddID\";\n EBMLId[EBMLId[\"BlockDuration\"] = 155] = \"BlockDuration\";\n EBMLId[EBMLId[\"ReferenceBlock\"] = 251] = \"ReferenceBlock\";\n EBMLId[EBMLId[\"Cluster\"] = 524531317] = \"Cluster\";\n EBMLId[EBMLId[\"Timestamp\"] = 231] = \"Timestamp\";\n EBMLId[EBMLId[\"Cues\"] = 475249515] = \"Cues\";\n EBMLId[EBMLId[\"CuePoint\"] = 187] = \"CuePoint\";\n EBMLId[EBMLId[\"CueTime\"] = 179] = \"CueTime\";\n EBMLId[EBMLId[\"CueTrackPositions\"] = 183] = \"CueTrackPositions\";\n EBMLId[EBMLId[\"CueTrack\"] = 247] = \"CueTrack\";\n EBMLId[EBMLId[\"CueClusterPosition\"] = 241] = \"CueClusterPosition\";\n EBMLId[EBMLId[\"Colour\"] = 21936] = \"Colour\";\n EBMLId[EBMLId[\"MatrixCoefficients\"] = 21937] = \"MatrixCoefficients\";\n EBMLId[EBMLId[\"TransferCharacteristics\"] = 21946] = \"TransferCharacteristics\";\n EBMLId[EBMLId[\"Primaries\"] = 21947] = \"Primaries\";\n EBMLId[EBMLId[\"Range\"] = 21945] = \"Range\";\n EBMLId[EBMLId[\"Projection\"] = 30320] = \"Projection\";\n EBMLId[EBMLId[\"ProjectionType\"] = 30321] = \"ProjectionType\";\n EBMLId[EBMLId[\"ProjectionPoseRoll\"] = 30325] = \"ProjectionPoseRoll\";\n EBMLId[EBMLId[\"Attachments\"] = 423732329] = \"Attachments\";\n EBMLId[EBMLId[\"AttachedFile\"] = 24999] = \"AttachedFile\";\n EBMLId[EBMLId[\"FileDescription\"] = 18046] = \"FileDescription\";\n EBMLId[EBMLId[\"FileName\"] = 18030] = \"FileName\";\n EBMLId[EBMLId[\"FileMediaType\"] = 18016] = \"FileMediaType\";\n EBMLId[EBMLId[\"FileData\"] = 18012] = \"FileData\";\n EBMLId[EBMLId[\"FileUID\"] = 18094] = \"FileUID\";\n EBMLId[EBMLId[\"Chapters\"] = 272869232] = \"Chapters\";\n EBMLId[EBMLId[\"Tags\"] = 307544935] = \"Tags\";\n EBMLId[EBMLId[\"Tag\"] = 29555] = \"Tag\";\n EBMLId[EBMLId[\"Targets\"] = 25536] = \"Targets\";\n EBMLId[EBMLId[\"TargetTypeValue\"] = 26826] = \"TargetTypeValue\";\n EBMLId[EBMLId[\"TargetType\"] = 25546] = \"TargetType\";\n EBMLId[EBMLId[\"TagTrackUID\"] = 25541] = \"TagTrackUID\";\n EBMLId[EBMLId[\"TagEditionUID\"] = 25545] = \"TagEditionUID\";\n EBMLId[EBMLId[\"TagChapterUID\"] = 25540] = \"TagChapterUID\";\n EBMLId[EBMLId[\"TagAttachmentUID\"] = 25542] = \"TagAttachmentUID\";\n EBMLId[EBMLId[\"SimpleTag\"] = 26568] = \"SimpleTag\";\n EBMLId[EBMLId[\"TagName\"] = 17827] = \"TagName\";\n EBMLId[EBMLId[\"TagLanguage\"] = 17530] = \"TagLanguage\";\n EBMLId[EBMLId[\"TagString\"] = 17543] = \"TagString\";\n EBMLId[EBMLId[\"TagBinary\"] = 17541] = \"TagBinary\";\n EBMLId[EBMLId[\"ContentEncodings\"] = 28032] = \"ContentEncodings\";\n EBMLId[EBMLId[\"ContentEncoding\"] = 25152] = \"ContentEncoding\";\n EBMLId[EBMLId[\"ContentEncodingOrder\"] = 20529] = \"ContentEncodingOrder\";\n EBMLId[EBMLId[\"ContentEncodingScope\"] = 20530] = \"ContentEncodingScope\";\n EBMLId[EBMLId[\"ContentCompression\"] = 20532] = \"ContentCompression\";\n EBMLId[EBMLId[\"ContentCompAlgo\"] = 16980] = \"ContentCompAlgo\";\n EBMLId[EBMLId[\"ContentCompSettings\"] = 16981] = \"ContentCompSettings\";\n EBMLId[EBMLId[\"ContentEncryption\"] = 20533] = \"ContentEncryption\";\n })(EBMLId || (EBMLId = {}));\n const LEVEL_0_EBML_IDS = [\n EBMLId.EBML,\n EBMLId.Segment,\n ];\n // All the stuff that can appear in a segment, basically\n const LEVEL_1_EBML_IDS = [\n EBMLId.SeekHead,\n EBMLId.Info,\n EBMLId.Cluster,\n EBMLId.Tracks,\n EBMLId.Cues,\n EBMLId.Attachments,\n EBMLId.Chapters,\n EBMLId.Tags,\n ];\n const LEVEL_0_AND_1_EBML_IDS = [\n ...LEVEL_0_EBML_IDS,\n ...LEVEL_1_EBML_IDS,\n ];\n const MAX_VAR_INT_SIZE = 8;\n const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size\n const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size\n const readVarIntSize = (slice) => {\n if (slice.remainingLength < 1) {\n return null;\n }\n const firstByte = readU8(slice);\n slice.skip(-1);\n if (firstByte === 0) {\n return null; // Invalid VINT\n }\n let width = 1;\n let mask = 0x80;\n while ((firstByte & mask) === 0) {\n width++;\n mask >>= 1;\n }\n // Check if we have enough bytes to read the full varint\n if (slice.remainingLength < width) {\n return null;\n }\n return width;\n };\n const readVarInt = (slice) => {\n if (slice.remainingLength < 1) {\n return null;\n }\n // Read the first byte to determine the width of the variable-length integer\n const firstByte = readU8(slice);\n if (firstByte === 0) {\n return null; // Invalid VINT\n }\n // Find the position of VINT_MARKER, which determines the width\n let width = 1;\n let mask = 1 << 7;\n while ((firstByte & mask) === 0) {\n width++;\n mask >>= 1;\n }\n if (slice.remainingLength < width - 1) {\n // Not enough bytes\n return null;\n }\n // First byte's value needs the marker bit cleared\n let value = firstByte & (mask - 1);\n // Read remaining bytes\n for (let i = 1; i < width; i++) {\n value *= 1 << 8;\n value += readU8(slice);\n }\n return value;\n };\n const readUnsignedInt = (slice, width) => {\n if (width < 1 || width > 8) {\n throw new Error('Bad unsigned int size ' + width);\n }\n let value = 0;\n // Read bytes from most significant to least significant\n for (let i = 0; i < width; i++) {\n value *= 1 << 8;\n value += readU8(slice);\n }\n return value;\n };\n const readUnsignedBigInt = (slice, width) => {\n if (width < 1) {\n throw new Error('Bad unsigned int size ' + width);\n }\n let value = 0n;\n for (let i = 0; i < width; i++) {\n value <<= 8n;\n value += BigInt(readU8(slice));\n }\n return value;\n };\n const readElementId = (slice) => {\n const size = readVarIntSize(slice);\n if (size === null) {\n return null;\n }\n if (slice.remainingLength < size) {\n return null; // It don't fit\n }\n const id = readUnsignedInt(slice, size);\n return id;\n };\n /** Returns `undefined` to indicate the EBML undefined size. Returns `null` if the size couldn't be read. */\n const readElementSize = (slice) => {\n // Need at least 1 byte to read the size\n if (slice.remainingLength < 1) {\n return null;\n }\n const firstByte = readU8(slice);\n if (firstByte === 0xff) {\n return undefined;\n }\n slice.skip(-1);\n const size = readVarInt(slice);\n if (size === null) {\n return null;\n }\n // In some (livestreamed) files, this is the value of the size field. While this technically is just a very\n // large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We\n // catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works\n // nonetheless.\n // eslint-disable-next-line no-loss-of-precision\n if (size === 0x00ffffffffffffff) {\n return undefined;\n }\n return size;\n };\n const readElementHeader = (slice) => {\n assert(slice.remainingLength >= MIN_HEADER_SIZE);\n const id = readElementId(slice);\n if (id === null) {\n return null;\n }\n const size = readElementSize(slice);\n if (size === null) {\n return null;\n }\n return { id, size };\n };\n const readAsciiString = (slice, length) => {\n const bytes = readBytes(slice, length);\n // Actual string length might be shorter due to null terminators\n let strLength = 0;\n while (strLength < length && bytes[strLength] !== 0) {\n strLength += 1;\n }\n return String.fromCharCode(...bytes.subarray(0, strLength));\n };\n const readUnicodeString = (slice, length) => {\n const bytes = readBytes(slice, length);\n // Actual string length might be shorter due to null terminators\n let strLength = 0;\n while (strLength < length && bytes[strLength] !== 0) {\n strLength += 1;\n }\n return textDecoder.decode(bytes.subarray(0, strLength));\n };\n const readFloat = (slice, width) => {\n if (width === 0) {\n return 0;\n }\n if (width !== 4 && width !== 8) {\n throw new Error('Bad float size ' + width);\n }\n return width === 4 ? readF32Be(slice) : readF64Be(slice);\n };\n /** Returns the byte offset in the file of the next element with a matching ID. */\n const searchForNextElementId = async (reader, startPos, ids, until) => {\n const idsSet = new Set(ids);\n let currentPos = startPos;\n while (until === null || currentPos < until) {\n let slice = reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const elementHeader = readElementHeader(slice);\n if (!elementHeader) {\n break;\n }\n if (idsSet.has(elementHeader.id)) {\n return { pos: currentPos, found: true };\n }\n assertDefinedSize(elementHeader.size);\n currentPos = slice.filePos + elementHeader.size;\n }\n return { pos: (until !== null && until > currentPos) ? until : currentPos, found: false };\n };\n /** Searches for the next occurrence of an element ID using a naive byte-wise search. */\n const resync = async (reader, startPos, ids, until) => {\n const CHUNK_SIZE = 2 ** 16; // So we don't need to grab thousands of slices\n const idsSet = new Set(ids);\n let currentPos = startPos;\n while (currentPos < until) {\n let slice = reader.requestSliceRange(currentPos, 0, Math.min(CHUNK_SIZE, until - currentPos));\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n if (slice.length < MAX_VAR_INT_SIZE)\n break;\n for (let i = 0; i < slice.length - MAX_VAR_INT_SIZE; i++) {\n slice.filePos = currentPos;\n const elementId = readElementId(slice);\n if (elementId !== null && idsSet.has(elementId)) {\n return currentPos;\n }\n currentPos++;\n }\n }\n return null;\n };\n const CODEC_STRING_MAP = {\n 'avc': 'V_MPEG4/ISO/AVC',\n 'hevc': 'V_MPEGH/ISO/HEVC',\n 'vp8': 'V_VP8',\n 'vp9': 'V_VP9',\n 'av1': 'V_AV1',\n 'prores': 'V_PRORES',\n 'aac': 'A_AAC',\n 'mp3': 'A_MPEG/L3',\n 'opus': 'A_OPUS',\n 'vorbis': 'A_VORBIS',\n 'flac': 'A_FLAC',\n 'ac3': 'A_AC3',\n 'eac3': 'A_EAC3'};\n function assertDefinedSize(size) {\n if (size === undefined) {\n throw new Error('Undefined element size is used in a place where it is not supported.');\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const buildMatroskaMimeType = (info) => {\n const base = info.hasVideo\n ? 'video/'\n : info.hasAudio\n ? 'audio/'\n : 'application/';\n let string = base + (info.isWebM ? 'webm' : 'x-matroska');\n if (info.codecStrings.length > 0) {\n const uniqueCodecMimeTypes = [...new Set(info.codecStrings.filter(Boolean))];\n string += `; codecs=\"${uniqueCodecMimeTypes.join(', ')}\"`;\n }\n return string;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n var BlockLacing;\n (function (BlockLacing) {\n BlockLacing[BlockLacing[\"None\"] = 0] = \"None\";\n BlockLacing[BlockLacing[\"Xiph\"] = 1] = \"Xiph\";\n BlockLacing[BlockLacing[\"FixedSize\"] = 2] = \"FixedSize\";\n BlockLacing[BlockLacing[\"Ebml\"] = 3] = \"Ebml\";\n })(BlockLacing || (BlockLacing = {}));\n var ContentEncodingScope;\n (function (ContentEncodingScope) {\n ContentEncodingScope[ContentEncodingScope[\"Block\"] = 1] = \"Block\";\n ContentEncodingScope[ContentEncodingScope[\"Private\"] = 2] = \"Private\";\n ContentEncodingScope[ContentEncodingScope[\"Next\"] = 4] = \"Next\";\n })(ContentEncodingScope || (ContentEncodingScope = {}));\n var ContentCompAlgo;\n (function (ContentCompAlgo) {\n ContentCompAlgo[ContentCompAlgo[\"Zlib\"] = 0] = \"Zlib\";\n ContentCompAlgo[ContentCompAlgo[\"Bzlib\"] = 1] = \"Bzlib\";\n ContentCompAlgo[ContentCompAlgo[\"lzo1x\"] = 2] = \"lzo1x\";\n ContentCompAlgo[ContentCompAlgo[\"HeaderStripping\"] = 3] = \"HeaderStripping\";\n })(ContentCompAlgo || (ContentCompAlgo = {}));\n const METADATA_ELEMENTS = [\n { id: EBMLId.SeekHead, flag: 'seekHeadSeen' },\n { id: EBMLId.Info, flag: 'infoSeen' },\n { id: EBMLId.Tracks, flag: 'tracksSeen' },\n { id: EBMLId.Cues, flag: 'cuesSeen' },\n ];\n const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB\n class MatroskaDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.readMetadataPromise = null;\n this.segments = [];\n this.currentSegment = null;\n this.currentTrack = null;\n this.currentCluster = null;\n this.currentBlock = null;\n this.currentBlockAdditional = null;\n this.currentCueTime = null;\n this.currentDecodingInstruction = null;\n this.currentTagTargetIsMovie = true;\n this.currentSimpleTagName = null;\n this.currentAttachedFile = null;\n this.isWebM = false;\n this.reader = input._reader;\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.segments.flatMap(segment => segment.tracks.map(track => track.trackBacking));\n }\n async getMimeType() {\n await this.readMetadata();\n const backings = await this.getTrackBackings();\n const codecStrings = await Promise.all(backings.map(x => x.getDecoderConfig().then(c => c?.codec ?? null)));\n return buildMatroskaMimeType({\n isWebM: this.isWebM,\n hasVideo: this.segments.some(segment => segment.tracks.some(x => x.info?.type === 'video')),\n hasAudio: this.segments.some(segment => segment.tracks.some(x => x.info?.type === 'audio')),\n codecStrings: codecStrings.filter(Boolean),\n });\n }\n async getMetadataTags() {\n await this.readMetadata();\n // Load metadata tags from each segment lazily (only once)\n for (const segment of this.segments) {\n if (!segment.metadataTagsCollected) {\n if (this.reader.fileSize !== null) {\n await this.loadSegmentMetadata(segment);\n }\n segment.metadataTagsCollected = true;\n }\n }\n // This is kinda handwavy, and how we handle multiple segments isn't suuuuper well-defined anyway; so we just\n // shallow-merge metadata tags from all (usually just one) segments.\n let metadataTags = {};\n for (const segment of this.segments) {\n metadataTags = { ...metadataTags, ...segment.metadataTags };\n }\n return metadataTags;\n }\n readMetadata() {\n return this.readMetadataPromise ??= (async () => {\n let currentPos = 0;\n // Loop over all top-level elements in the file\n while (true) {\n let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const header = readElementHeader(slice);\n if (!header) {\n break; // Zero padding at the end of the file triggers this, for example\n }\n const id = header.id;\n let size = header.size;\n const dataStartPos = slice.filePos;\n if (id === EBMLId.EBML) {\n assertDefinedSize(size);\n let slice = this.reader.requestSlice(dataStartPos, size);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n this.readContiguousElements(slice);\n }\n else if (id === EBMLId.Segment) { // Segment found!\n await this.readSegment(dataStartPos, size);\n if (size === undefined) {\n // Segment sizes can be undefined (common in livestreamed files), so assume this is the last\n // and only segment\n break;\n }\n if (this.reader.fileSize === null) {\n break; // Stop at the first segment\n }\n }\n else if (id === EBMLId.Cluster) {\n if (this.reader.fileSize === null) {\n break; // Shouldn't be reached anyway, since we stop at the first segment\n }\n // Clusters are not a top-level element in Matroska, but some files contain a Segment whose size\n // doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if\n // we find a top-level cluster, attribute it to the previous segment.\n if (size === undefined) {\n // Just in case this is one of those weird sizeless clusters, let's do our best and still try to\n // determine its size.\n const nextElementPos = await searchForNextElementId(this.reader, dataStartPos, LEVEL_0_AND_1_EBML_IDS, this.reader.fileSize);\n size = nextElementPos.pos - dataStartPos;\n }\n const lastSegment = last(this.segments);\n if (lastSegment) {\n // Extend the previous segment's size\n lastSegment.elementEndPos = dataStartPos + size;\n }\n }\n assertDefinedSize(size);\n currentPos = dataStartPos + size;\n }\n })();\n }\n async readSegment(segmentDataStart, dataSize) {\n this.currentSegment = {\n seekHeadSeen: false,\n infoSeen: false,\n tracksSeen: false,\n cuesSeen: false,\n tagsSeen: false,\n attachmentsSeen: false,\n timestampScale: -1,\n timestampFactor: -1,\n duration: -1,\n seekEntries: [],\n tracks: [],\n cuePoints: [],\n dataStartPos: segmentDataStart,\n elementEndPos: dataSize === undefined\n ? null // Assume it goes until the end of the file\n : segmentDataStart + dataSize,\n clusterSeekStartPos: segmentDataStart,\n lastReadCluster: null,\n metadataTags: {},\n metadataTagsCollected: false,\n };\n this.segments.push(this.currentSegment);\n let currentPos = segmentDataStart;\n while (this.currentSegment.elementEndPos === null || currentPos < this.currentSegment.elementEndPos) {\n let slice = this.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const elementStartPos = currentPos;\n const header = readElementHeader(slice);\n if (!header || (!LEVEL_1_EBML_IDS.includes(header.id) && header.id !== EBMLId.Void)) {\n // Potential junk. Let's try to resync\n const nextPos = await resync(this.reader, elementStartPos, LEVEL_1_EBML_IDS, Math.min(this.currentSegment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH));\n if (nextPos) {\n currentPos = nextPos;\n continue;\n }\n else {\n break; // Resync failed\n }\n }\n const { id, size } = header;\n const dataStartPos = slice.filePos;\n const metadataElementIndex = METADATA_ELEMENTS.findIndex(x => x.id === id);\n if (metadataElementIndex !== -1) {\n const field = METADATA_ELEMENTS[metadataElementIndex].flag;\n this.currentSegment[field] = true;\n assertDefinedSize(size);\n let slice = this.reader.requestSlice(dataStartPos, size);\n if (slice instanceof Promise)\n slice = await slice;\n if (slice) {\n this.readContiguousElements(slice);\n }\n }\n else if (id === EBMLId.Tags || id === EBMLId.Attachments) {\n // Metadata found at the beginning of the segment, great, let's parse it\n if (id === EBMLId.Tags) {\n this.currentSegment.tagsSeen = true;\n }\n else {\n this.currentSegment.attachmentsSeen = true;\n }\n assertDefinedSize(size);\n let slice = this.reader.requestSlice(dataStartPos, size);\n if (slice instanceof Promise)\n slice = await slice;\n if (slice) {\n this.readContiguousElements(slice);\n }\n }\n else if (id === EBMLId.Cluster) {\n this.currentSegment.clusterSeekStartPos = elementStartPos;\n break; // Stop at the first cluster\n }\n if (size === undefined) {\n break;\n }\n else {\n currentPos = dataStartPos + size;\n }\n }\n // Sort the seek entries by file position so reading them exhibits a sequential pattern\n this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition);\n if (this.reader.fileSize !== null) {\n // Use the seek head to read missing metadata elements\n for (const seekEntry of this.currentSegment.seekEntries) {\n const target = METADATA_ELEMENTS.find(x => x.id === seekEntry.id);\n if (!target) {\n continue;\n }\n if (this.currentSegment[target.flag])\n continue;\n let slice = this.reader.requestSliceRange(segmentDataStart + seekEntry.segmentPosition, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n continue;\n const header = readElementHeader(slice);\n if (!header)\n continue;\n const { id, size } = header;\n if (id !== target.id)\n continue;\n assertDefinedSize(size);\n this.currentSegment[target.flag] = true;\n let dataSlice = this.reader.requestSlice(slice.filePos, size);\n if (dataSlice instanceof Promise)\n dataSlice = await dataSlice;\n if (!dataSlice)\n continue;\n this.readContiguousElements(dataSlice);\n }\n }\n if (this.currentSegment.timestampScale === -1) {\n // TimestampScale element is missing. Technically an invalid file, but let's default to the typical value,\n // which is 1e6.\n this.currentSegment.timestampScale = 1e6;\n this.currentSegment.timestampFactor = 1e9 / 1e6;\n }\n // Compute default duration for all tracks now that we have the timestamp factor\n for (const track of this.currentSegment.tracks) {\n if (track.defaultDurationNs !== null) {\n track.defaultDuration = (this.currentSegment.timestampFactor * track.defaultDurationNs) / 1e9;\n }\n }\n // Now, let's distribute the cue points to the tracks\n const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x]));\n // Assign cue points to their respective tracks\n for (const cuePoint of this.currentSegment.cuePoints) {\n const track = idToTrack.get(cuePoint.trackId);\n if (track) {\n track.cuePoints.push(cuePoint);\n }\n }\n for (const track of this.currentSegment.tracks) {\n // Sort cue points by time\n track.cuePoints.sort((a, b) => a.time - b.time);\n // Remove multiple cue points for the same time\n for (let i = 0; i < track.cuePoints.length - 1; i++) {\n const cuePoint1 = track.cuePoints[i];\n const cuePoint2 = track.cuePoints[i + 1];\n if (cuePoint1.time === cuePoint2.time) {\n track.cuePoints.splice(i + 1, 1);\n i--;\n }\n }\n }\n let trackWithMostCuePoints = null;\n let maxCuePointCount = -Infinity;\n for (const track of this.currentSegment.tracks) {\n if (track.cuePoints.length > maxCuePointCount) {\n maxCuePointCount = track.cuePoints.length;\n trackWithMostCuePoints = track;\n }\n }\n // For every track that has received 0 cue points (can happen, often only the video track receives cue points),\n // we still want to have better seeking. Therefore, let's give it the cue points of the track with the most cue\n // points, which should provide us with the most fine-grained seeking.\n for (const track of this.currentSegment.tracks) {\n if (track.cuePoints.length === 0) {\n track.cuePoints = trackWithMostCuePoints.cuePoints;\n }\n }\n this.currentSegment = null;\n }\n async readCluster(startPos, segment) {\n if (segment.lastReadCluster?.elementStartPos === startPos) {\n return segment.lastReadCluster;\n }\n let headerSlice = this.reader.requestSliceRange(startPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n assert(headerSlice);\n const elementStartPos = startPos;\n const elementHeader = readElementHeader(headerSlice);\n assert(elementHeader);\n const id = elementHeader.id;\n assert(id === EBMLId.Cluster);\n let size = elementHeader.size;\n const dataStartPos = headerSlice.filePos;\n if (size === undefined) {\n // The cluster's size is undefined (can happen in livestreamed files). We'd still like to know the size of\n // it, so we have no other choice but to iterate over the EBML structure until we find an element at level\n // 0 or 1, indicating the end of the cluster (all elements inside the cluster are at level 2).\n const nextElementPos = await searchForNextElementId(this.reader, dataStartPos, LEVEL_0_AND_1_EBML_IDS, segment.elementEndPos);\n size = nextElementPos.pos - dataStartPos;\n }\n // Load the entire cluster\n let dataSlice = this.reader.requestSlice(dataStartPos, size);\n if (dataSlice instanceof Promise)\n dataSlice = await dataSlice;\n const cluster = {\n segment,\n elementStartPos,\n elementEndPos: dataStartPos + size,\n dataStartPos,\n timestamp: -1,\n trackData: new Map(),\n };\n this.currentCluster = cluster;\n if (dataSlice) {\n // Read the children of the cluster, stopping early at level 0 or 1 EBML elements. We do this because some\n // clusters have incorrect sizes that are too large\n const endPos = this.readContiguousElements(dataSlice, LEVEL_0_AND_1_EBML_IDS);\n cluster.elementEndPos = endPos;\n }\n for (const [, trackData] of cluster.trackData) {\n const track = trackData.track;\n // This must hold, as track datas only get created if a block for that track is encountered\n assert(trackData.blocks.length > 0);\n let hasLacedBlocks = false;\n for (let i = 0; i < trackData.blocks.length; i++) {\n const block = trackData.blocks[i];\n block.timestamp += cluster.timestamp;\n hasLacedBlocks ||= block.lacing !== BlockLacing.None;\n }\n trackData.presentationTimestamps = trackData.blocks\n .map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))\n .sort((a, b) => a.timestamp - b.timestamp);\n for (let i = 0; i < trackData.presentationTimestamps.length; i++) {\n const currentEntry = trackData.presentationTimestamps[i];\n const currentBlock = trackData.blocks[currentEntry.blockIndex];\n if (trackData.firstKeyFrameTimestamp === null && currentBlock.isKeyFrame) {\n trackData.firstKeyFrameTimestamp = currentBlock.timestamp;\n }\n if (i < trackData.presentationTimestamps.length - 1) {\n // Update block durations based on presentation order\n const nextEntry = trackData.presentationTimestamps[i + 1];\n currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;\n }\n else if (currentBlock.duration === 0) {\n if (track.defaultDuration != null) {\n if (currentBlock.lacing === BlockLacing.None) {\n currentBlock.duration = track.defaultDuration;\n }\n }\n }\n }\n if (hasLacedBlocks) {\n // Perform lace resolution. Here, we expand each laced block into multiple blocks where each contains\n // one frame of the lace. We do this after determining block timestamps so we can properly distribute\n // the block's duration across the laced frames.\n this.expandLacedBlocks(trackData.blocks, track);\n // Recompute since blocks have changed\n trackData.presentationTimestamps = trackData.blocks\n .map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))\n .sort((a, b) => a.timestamp - b.timestamp);\n }\n const firstBlock = trackData.blocks[trackData.presentationTimestamps[0].blockIndex];\n const lastBlock = trackData.blocks[last(trackData.presentationTimestamps).blockIndex];\n trackData.startTimestamp = firstBlock.timestamp;\n trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;\n // Let's remember that a cluster with a given timestamp is here, speeding up future lookups if no cues exist\n const insertionIndex = binarySearchLessOrEqual(track.clusterPositionCache, trackData.startTimestamp, x => x.startTimestamp);\n if (insertionIndex === -1\n || track.clusterPositionCache[insertionIndex].elementStartPos !== elementStartPos) {\n track.clusterPositionCache.splice(insertionIndex + 1, 0, {\n elementStartPos: cluster.elementStartPos,\n startTimestamp: trackData.startTimestamp,\n });\n }\n }\n segment.lastReadCluster = cluster;\n return cluster;\n }\n getTrackDataInCluster(cluster, trackNumber) {\n let trackData = cluster.trackData.get(trackNumber);\n if (!trackData) {\n const track = cluster.segment.tracks.find(x => x.id === trackNumber);\n if (!track) {\n return null;\n }\n trackData = {\n track,\n startTimestamp: 0,\n endTimestamp: 0,\n firstKeyFrameTimestamp: null,\n blocks: [],\n presentationTimestamps: [],\n };\n cluster.trackData.set(trackNumber, trackData);\n }\n return trackData;\n }\n expandLacedBlocks(blocks, track) {\n // https://www.matroska.org/technical/notes.html#block-lacing\n for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {\n const originalBlock = blocks[blockIndex];\n if (originalBlock.lacing === BlockLacing.None) {\n continue;\n }\n // Decode the block data if it hasn't been decoded yet (needed for lacing expansion)\n if (!originalBlock.decoded) {\n originalBlock.data = this.decodeBlockData(track, originalBlock.data);\n originalBlock.decoded = true;\n }\n const slice = FileSlice.tempFromBytes(originalBlock.data);\n const frameSizes = [];\n const frameCount = readU8(slice) + 1;\n switch (originalBlock.lacing) {\n case BlockLacing.Xiph:\n {\n let totalUsedSize = 0;\n // Xiph lacing, just like in Ogg\n for (let i = 0; i < frameCount - 1; i++) {\n let frameSize = 0;\n while (slice.bufferPos < slice.length) {\n const value = readU8(slice);\n frameSize += value;\n if (value < 255) {\n frameSizes.push(frameSize);\n totalUsedSize += frameSize;\n break;\n }\n }\n }\n // Compute the last frame's size from whatever's left\n frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));\n }\n break;\n case BlockLacing.FixedSize:\n {\n // Fixed size lacing: all frames have same size\n const totalDataSize = slice.length - 1; // Minus the frame count byte\n const frameSize = Math.floor(totalDataSize / frameCount);\n for (let i = 0; i < frameCount; i++) {\n frameSizes.push(frameSize);\n }\n }\n break;\n case BlockLacing.Ebml:\n {\n // EBML lacing: first size absolute, subsequent ones are coded as signed differences from the last\n const firstResult = readVarInt(slice);\n assert(firstResult !== null); // Assume it's not an invalid VINT\n let currentSize = firstResult;\n frameSizes.push(currentSize);\n let totalUsedSize = currentSize;\n for (let i = 1; i < frameCount - 1; i++) {\n const startPos = slice.bufferPos;\n const diffResult = readVarInt(slice);\n assert(diffResult !== null);\n const unsignedDiff = diffResult;\n const width = slice.bufferPos - startPos;\n const bias = (1 << (width * 7 - 1)) - 1; // Typo-corrected version of 2^((7*n)-1)^-1\n const diff = unsignedDiff - bias;\n currentSize += diff;\n frameSizes.push(currentSize);\n totalUsedSize += currentSize;\n }\n // Compute the last frame's size from whatever's left\n frameSizes.push(slice.length - (slice.bufferPos + totalUsedSize));\n }\n break;\n default: assert(false);\n }\n assert(frameSizes.length === frameCount);\n blocks.splice(blockIndex, 1); // Remove the original block\n const blockDuration = originalBlock.duration || frameCount * (track.defaultDuration ?? 0);\n // Now, let's insert each frame as its own block\n for (let i = 0; i < frameCount; i++) {\n const frameSize = frameSizes[i];\n const frameData = readBytes(slice, frameSize);\n // Distribute timestamps evenly across the block duration\n const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);\n const frameDuration = blockDuration / frameCount;\n blocks.splice(blockIndex + i, 0, {\n timestamp: frameTimestamp,\n duration: frameDuration,\n isKeyFrame: originalBlock.isKeyFrame,\n data: frameData,\n lacing: BlockLacing.None,\n decoded: true,\n postProcessed: false,\n mainAdditional: originalBlock.mainAdditional,\n });\n }\n blockIndex += frameCount; // Skip the blocks we just added\n blockIndex--;\n }\n }\n async loadSegmentMetadata(segment) {\n for (const seekEntry of segment.seekEntries) {\n if (seekEntry.id === EBMLId.Tags && !segment.tagsSeen) ;\n else if (seekEntry.id === EBMLId.Attachments && !segment.attachmentsSeen) ;\n else {\n continue;\n }\n let slice = this.reader.requestSliceRange(segment.dataStartPos + seekEntry.segmentPosition, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n continue;\n const header = readElementHeader(slice);\n if (!header || header.id !== seekEntry.id)\n continue;\n const { size } = header;\n assertDefinedSize(size);\n assert(!this.currentSegment);\n this.currentSegment = segment;\n let dataSlice = this.reader.requestSlice(slice.filePos, size);\n if (dataSlice instanceof Promise)\n dataSlice = await dataSlice;\n if (dataSlice) {\n this.readContiguousElements(dataSlice);\n }\n this.currentSegment = null;\n // Mark as seen\n if (seekEntry.id === EBMLId.Tags) {\n segment.tagsSeen = true;\n }\n else if (seekEntry.id === EBMLId.Attachments) {\n segment.attachmentsSeen = true;\n }\n }\n }\n readContiguousElements(slice, stopIds) {\n while (slice.remainingLength >= MIN_HEADER_SIZE) {\n const startPos = slice.filePos;\n const foundElement = this.traverseElement(slice, stopIds);\n if (!foundElement) {\n return startPos;\n }\n }\n return slice.filePos;\n }\n traverseElement(slice, stopIds) {\n const header = readElementHeader(slice);\n if (!header) {\n return false;\n }\n if (stopIds && stopIds.includes(header.id)) {\n return false;\n }\n const { id, size } = header;\n const dataStartPos = slice.filePos;\n assertDefinedSize(size);\n switch (id) {\n case EBMLId.DocType:\n {\n this.isWebM = readAsciiString(slice, size) === 'webm';\n }\n break;\n case EBMLId.Seek:\n {\n if (!this.currentSegment)\n break;\n const seekEntry = { id: -1, segmentPosition: -1 };\n this.currentSegment.seekEntries.push(seekEntry);\n this.readContiguousElements(slice.slice(dataStartPos, size));\n if (seekEntry.id === -1 || seekEntry.segmentPosition === -1) {\n this.currentSegment.seekEntries.pop();\n }\n }\n break;\n case EBMLId.SeekID:\n {\n const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];\n if (!lastSeekEntry)\n break;\n lastSeekEntry.id = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.SeekPosition:\n {\n const lastSeekEntry = this.currentSegment?.seekEntries[this.currentSegment.seekEntries.length - 1];\n if (!lastSeekEntry)\n break;\n lastSeekEntry.segmentPosition = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.TimestampScale:\n {\n if (!this.currentSegment)\n break;\n this.currentSegment.timestampScale = readUnsignedInt(slice, size);\n this.currentSegment.timestampFactor = 1e9 / this.currentSegment.timestampScale;\n }\n break;\n case EBMLId.Duration:\n {\n if (!this.currentSegment)\n break;\n this.currentSegment.duration = readFloat(slice, size);\n }\n break;\n case EBMLId.TrackEntry:\n {\n if (!this.currentSegment)\n break;\n this.currentTrack = {\n id: -1,\n segment: this.currentSegment,\n demuxer: this,\n clusterPositionCache: [],\n cuePoints: [],\n disposition: {\n ...DEFAULT_TRACK_DISPOSITION,\n primary: false,\n },\n trackBacking: null,\n codecId: null,\n codecPrivate: null,\n defaultDuration: null,\n defaultDurationNs: null,\n name: null,\n languageCode: 'eng', // The default in Matroska\n hasLanguageBcp47: false,\n decodingInstructions: [],\n info: null,\n };\n this.readContiguousElements(slice.slice(dataStartPos, size));\n // Check if track was disabled during parsing (e.g., by FlagEnabled being 0)\n if (!this.currentTrack) {\n break;\n }\n if (this.currentTrack.decodingInstructions.some((instruction) => {\n return instruction.data?.type !== 'decompress'\n || instruction.scope !== ContentEncodingScope.Block\n || instruction.data.algorithm !== ContentCompAlgo.HeaderStripping;\n })) {\n Logging._warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`);\n this.currentTrack = null;\n }\n if (this.currentTrack\n && this.currentTrack.id !== -1\n && this.currentTrack.codecId\n && this.currentTrack.info) {\n const slashIndex = this.currentTrack.codecId.indexOf('/');\n const codecIdWithoutSuffix = slashIndex === -1\n ? this.currentTrack.codecId\n : this.currentTrack.codecId.slice(0, slashIndex);\n if (this.currentTrack.info.type === 'video'\n && this.currentTrack.info.width !== -1\n && this.currentTrack.info.height !== -1) {\n this.currentTrack.info.squarePixelWidth = this.currentTrack.info.width;\n this.currentTrack.info.squarePixelHeight = this.currentTrack.info.height;\n if (this.currentTrack.info.displayWidth !== null\n && this.currentTrack.info.displayHeight !== null) {\n const num = this.currentTrack.info.displayWidth * this.currentTrack.info.height;\n const den = this.currentTrack.info.displayHeight * this.currentTrack.info.width;\n if (num > 0 && den > 0) {\n if (num > den) {\n this.currentTrack.info.squarePixelWidth = Math.round(this.currentTrack.info.width * num / den);\n }\n else {\n this.currentTrack.info.squarePixelHeight = Math.round(this.currentTrack.info.height * den / num);\n }\n }\n }\n if (this.currentTrack.codecId === CODEC_STRING_MAP.avc) {\n this.currentTrack.info.codec = 'avc';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (this.currentTrack.codecId === CODEC_STRING_MAP.hevc) {\n this.currentTrack.info.codec = 'hevc';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vp8) {\n this.currentTrack.info.codec = 'vp8';\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vp9) {\n this.currentTrack.info.codec = 'vp9';\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.av1) {\n this.currentTrack.info.codec = 'av1';\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.prores) {\n const format = this.currentTrack.codecPrivate\n ? textDecoder.decode(this.currentTrack.codecPrivate)\n : '';\n if (PRORES_FOURCCS.includes(format)) {\n this.currentTrack.info.codec = 'prores';\n this.currentTrack.info.proresFormat = format;\n }\n }\n const videoTrack = this.currentTrack;\n this.currentTrack.trackBacking = new MatroskaVideoTrackBacking(videoTrack);\n this.currentSegment.tracks.push(this.currentTrack);\n }\n else if (this.currentTrack.info.type === 'audio') {\n if (codecIdWithoutSuffix === CODEC_STRING_MAP.aac) {\n this.currentTrack.info.codec = 'aac';\n this.currentTrack.info.aacCodecInfo = {\n isMpeg2: this.currentTrack.codecId.includes('MPEG2'),\n objectType: null,\n };\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (this.currentTrack.codecId === CODEC_STRING_MAP.mp3) {\n this.currentTrack.info.codec = 'mp3';\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.opus) {\n this.currentTrack.info.codec = 'opus';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n this.currentTrack.info.sampleRate = OPUS_SAMPLE_RATE; // Always the same\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vorbis) {\n this.currentTrack.info.codec = 'vorbis';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.flac) {\n this.currentTrack.info.codec = 'flac';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.ac3) {\n this.currentTrack.info.codec = 'ac3';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (codecIdWithoutSuffix === CODEC_STRING_MAP.eac3) {\n this.currentTrack.info.codec = 'eac3';\n this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;\n }\n else if (this.currentTrack.codecId === 'A_PCM/INT/LIT') {\n if (this.currentTrack.info.bitDepth === 8) {\n this.currentTrack.info.codec = 'pcm-u8';\n }\n else if (this.currentTrack.info.bitDepth === 16) {\n this.currentTrack.info.codec = 'pcm-s16';\n }\n else if (this.currentTrack.info.bitDepth === 24) {\n this.currentTrack.info.codec = 'pcm-s24';\n }\n else if (this.currentTrack.info.bitDepth === 32) {\n this.currentTrack.info.codec = 'pcm-s32';\n }\n }\n else if (this.currentTrack.codecId === 'A_PCM/INT/BIG') {\n if (this.currentTrack.info.bitDepth === 8) {\n this.currentTrack.info.codec = 'pcm-u8';\n }\n else if (this.currentTrack.info.bitDepth === 16) {\n this.currentTrack.info.codec = 'pcm-s16be';\n }\n else if (this.currentTrack.info.bitDepth === 24) {\n this.currentTrack.info.codec = 'pcm-s24be';\n }\n else if (this.currentTrack.info.bitDepth === 32) {\n this.currentTrack.info.codec = 'pcm-s32be';\n }\n }\n else if (this.currentTrack.codecId === 'A_PCM/FLOAT/IEEE') {\n if (this.currentTrack.info.bitDepth === 32) {\n this.currentTrack.info.codec = 'pcm-f32';\n }\n else if (this.currentTrack.info.bitDepth === 64) {\n this.currentTrack.info.codec = 'pcm-f64';\n }\n }\n const audioTrack = this.currentTrack;\n this.currentTrack.trackBacking = new MatroskaAudioTrackBacking(audioTrack);\n this.currentSegment.tracks.push(this.currentTrack);\n }\n }\n this.currentTrack = null;\n }\n break;\n case EBMLId.TrackNumber:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.id = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.TrackType:\n {\n if (!this.currentTrack)\n break;\n const type = readUnsignedInt(slice, size);\n if (type === 1) {\n this.currentTrack.info = {\n type: 'video',\n width: -1,\n height: -1,\n displayWidth: null,\n displayHeight: null,\n displayUnit: null,\n squarePixelWidth: -1,\n squarePixelHeight: -1,\n rotation: 0,\n codec: null,\n codecDescription: null,\n colorSpace: null,\n alphaMode: false,\n proresFormat: null,\n };\n }\n else if (type === 2) {\n this.currentTrack.info = {\n type: 'audio',\n numberOfChannels: 1, // Default value\n sampleRate: 8000, // Default value\n bitDepth: -1,\n codec: null,\n codecDescription: null,\n aacCodecInfo: null,\n };\n }\n }\n break;\n case EBMLId.FlagEnabled:\n {\n if (!this.currentTrack)\n break;\n const enabled = readUnsignedInt(slice, size);\n if (!enabled) {\n this.currentTrack = null;\n }\n }\n break;\n case EBMLId.FlagDefault:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.disposition.default = !!readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.FlagForced:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.disposition.forced = !!readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.FlagOriginal:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.disposition.original = !!readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.FlagHearingImpaired:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.disposition.hearingImpaired = !!readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.FlagVisualImpaired:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.disposition.visuallyImpaired = !!readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.FlagCommentary:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.disposition.commentary = !!readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.CodecID:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.codecId = readAsciiString(slice, size);\n }\n break;\n case EBMLId.CodecPrivate:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.codecPrivate = readBytes(slice, size);\n }\n break;\n case EBMLId.DefaultDuration:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.defaultDurationNs = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.Name:\n {\n if (!this.currentTrack)\n break;\n this.currentTrack.name = readUnicodeString(slice, size);\n }\n break;\n case EBMLId.Language:\n {\n if (!this.currentTrack)\n break;\n if (this.currentTrack.hasLanguageBcp47) {\n // LanguageBCP47 was present, which takes precedence\n break;\n }\n this.currentTrack.languageCode = readAsciiString(slice, size);\n if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {\n this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;\n }\n }\n break;\n case EBMLId.LanguageBCP47:\n {\n if (!this.currentTrack)\n break;\n const bcp47 = readAsciiString(slice, size);\n const languageSubtag = bcp47.split('-')[0];\n if (languageSubtag) {\n // Technically invalid, for now: The language subtag might be a language code from ISO 639-1,\n // ISO 639-2, ISO 639-3, ISO 639-5 or some other thing (source: Wikipedia). But, `languageCode` is\n // documented as ISO 639-2. Changing the definition would be a breaking change. This will get\n // cleaned up in the future by defining languageCode to be BCP 47 instead.\n this.currentTrack.languageCode = languageSubtag;\n }\n else {\n this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;\n }\n this.currentTrack.hasLanguageBcp47 = true;\n }\n break;\n case EBMLId.Video:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.PixelWidth:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.width = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.PixelHeight:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.height = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.DisplayWidth:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.displayWidth = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.DisplayHeight:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.displayHeight = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.DisplayUnit:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.displayUnit = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.AlphaMode:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.alphaMode = readUnsignedInt(slice, size) === 1;\n }\n break;\n case EBMLId.Colour:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.currentTrack.info.colorSpace = {};\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.MatrixCoefficients:\n {\n if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)\n break;\n const matrixCoefficients = readUnsignedInt(slice, size);\n const mapped = MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients] ?? null;\n this.currentTrack.info.colorSpace.matrix = mapped;\n }\n break;\n case EBMLId.Range:\n {\n if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)\n break;\n this.currentTrack.info.colorSpace.fullRange = readUnsignedInt(slice, size) === 2;\n }\n break;\n case EBMLId.TransferCharacteristics:\n {\n if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)\n break;\n const transferCharacteristics = readUnsignedInt(slice, size);\n const mapped = TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics] ?? null;\n this.currentTrack.info.colorSpace.transfer = mapped;\n }\n break;\n case EBMLId.Primaries:\n {\n if (this.currentTrack?.info?.type !== 'video' || !this.currentTrack.info.colorSpace)\n break;\n const primaries = readUnsignedInt(slice, size);\n const mapped = COLOR_PRIMARIES_MAP_INVERSE[primaries] ?? null;\n this.currentTrack.info.colorSpace.primaries = mapped;\n }\n break;\n case EBMLId.Projection:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.ProjectionPoseRoll:\n {\n if (this.currentTrack?.info?.type !== 'video')\n break;\n const rotation = readFloat(slice, size);\n const flippedRotation = -rotation; // Convert counter-clockwise to clockwise\n try {\n this.currentTrack.info.rotation = normalizeRotation(flippedRotation);\n }\n catch {\n // It wasn't a valid rotation\n }\n }\n break;\n case EBMLId.Audio:\n {\n if (this.currentTrack?.info?.type !== 'audio')\n break;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.SamplingFrequency:\n {\n if (this.currentTrack?.info?.type !== 'audio')\n break;\n this.currentTrack.info.sampleRate = readFloat(slice, size);\n }\n break;\n case EBMLId.Channels:\n {\n if (this.currentTrack?.info?.type !== 'audio')\n break;\n this.currentTrack.info.numberOfChannels = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.BitDepth:\n {\n if (this.currentTrack?.info?.type !== 'audio')\n break;\n this.currentTrack.info.bitDepth = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.CuePoint:\n {\n if (!this.currentSegment)\n break;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n this.currentCueTime = null;\n }\n break;\n case EBMLId.CueTime:\n {\n this.currentCueTime = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.CueTrackPositions:\n {\n if (this.currentCueTime === null)\n break;\n assert(this.currentSegment);\n const cuePoint = { time: this.currentCueTime, trackId: -1, clusterPosition: -1 };\n this.currentSegment.cuePoints.push(cuePoint);\n this.readContiguousElements(slice.slice(dataStartPos, size));\n if (cuePoint.trackId === -1 || cuePoint.clusterPosition === -1) {\n this.currentSegment.cuePoints.pop();\n }\n }\n break;\n case EBMLId.CueTrack:\n {\n const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];\n if (!lastCuePoint)\n break;\n lastCuePoint.trackId = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.CueClusterPosition:\n {\n const lastCuePoint = this.currentSegment?.cuePoints[this.currentSegment.cuePoints.length - 1];\n if (!lastCuePoint)\n break;\n assert(this.currentSegment);\n lastCuePoint.clusterPosition = this.currentSegment.dataStartPos + readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.Timestamp:\n {\n if (!this.currentCluster)\n break;\n this.currentCluster.timestamp = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.SimpleBlock:\n {\n if (!this.currentCluster)\n break;\n const trackNumber = readVarInt(slice);\n if (trackNumber === null)\n break;\n const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);\n if (!trackData)\n break; // Not a track we care about\n const relativeTimestamp = readI16Be(slice);\n const flags = readU8(slice);\n const lacing = (flags >> 1) & 0x3; // If the block is laced, we'll expand it later\n let isKeyFrame = !!(flags & 0x80);\n if (trackData.track.info?.type === 'audio' && trackData.track.info.codec) {\n // Some files don't mark their audio packets as key packets (I'm looking at you, Firefox). But, we\n // can fix this in most cases: if we recognize the codec of the track, then we know every packet is\n // necessarily a key packet, no matter what the container says.\n // https://github.com/Vanilagy/mediabunny/issues/192\n isKeyFrame = true;\n }\n const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));\n const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;\n trackData.blocks.push({\n timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later\n duration: 0, // Will set later\n isKeyFrame,\n data: blockData,\n lacing,\n decoded: !hasDecodingInstructions,\n postProcessed: false,\n mainAdditional: null,\n });\n }\n break;\n case EBMLId.BlockGroup:\n {\n if (!this.currentCluster)\n break;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n this.currentBlock = null;\n }\n break;\n case EBMLId.Block:\n {\n if (!this.currentCluster)\n break;\n const trackNumber = readVarInt(slice);\n if (trackNumber === null)\n break;\n const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);\n if (!trackData)\n break;\n const relativeTimestamp = readI16Be(slice);\n const flags = readU8(slice);\n const lacing = (flags >> 1) & 0x3; // If the block is laced, we'll expand it later\n const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));\n const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;\n this.currentBlock = {\n timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later\n duration: 0, // Will set later\n isKeyFrame: true,\n data: blockData,\n lacing,\n decoded: !hasDecodingInstructions,\n postProcessed: false,\n mainAdditional: null,\n };\n trackData.blocks.push(this.currentBlock);\n }\n break;\n case EBMLId.BlockAdditions:\n {\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.BlockMore:\n {\n if (!this.currentBlock)\n break;\n this.currentBlockAdditional = {\n addId: 1,\n data: null,\n };\n this.readContiguousElements(slice.slice(dataStartPos, size));\n if (this.currentBlockAdditional.data && this.currentBlockAdditional.addId === 1) {\n this.currentBlock.mainAdditional = this.currentBlockAdditional.data;\n }\n this.currentBlockAdditional = null;\n }\n break;\n case EBMLId.BlockAdditional:\n {\n if (!this.currentBlockAdditional)\n break;\n this.currentBlockAdditional.data = readBytes(slice, size);\n }\n break;\n case EBMLId.BlockAddID:\n {\n if (!this.currentBlockAdditional)\n break;\n this.currentBlockAdditional.addId = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.BlockDuration:\n {\n if (!this.currentBlock)\n break;\n this.currentBlock.duration = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.ReferenceBlock:\n {\n if (!this.currentBlock)\n break;\n this.currentBlock.isKeyFrame = false;\n // We ignore the actual value here, we just use the reference as an indicator for \"not a key frame\".\n // This is in line with FFmpeg's behavior.\n }\n break;\n case EBMLId.Tag:\n {\n this.currentTagTargetIsMovie = true;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.Targets:\n {\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.TargetTypeValue:\n {\n const targetTypeValue = readUnsignedInt(slice, size);\n if (targetTypeValue !== 50) {\n this.currentTagTargetIsMovie = false;\n }\n }\n break;\n case EBMLId.TagTrackUID:\n case EBMLId.TagEditionUID:\n case EBMLId.TagChapterUID:\n case EBMLId.TagAttachmentUID:\n {\n this.currentTagTargetIsMovie = false;\n }\n break;\n case EBMLId.SimpleTag:\n {\n if (!this.currentTagTargetIsMovie)\n break;\n this.currentSimpleTagName = null;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.TagName:\n {\n this.currentSimpleTagName = readUnicodeString(slice, size);\n }\n break;\n case EBMLId.TagString:\n {\n if (!this.currentSimpleTagName)\n break;\n const value = readUnicodeString(slice, size);\n this.processTagValue(this.currentSimpleTagName, value);\n }\n break;\n case EBMLId.TagBinary:\n {\n if (!this.currentSimpleTagName)\n break;\n const value = readBytes(slice, size);\n this.processTagValue(this.currentSimpleTagName, value);\n }\n break;\n case EBMLId.AttachedFile:\n {\n if (!this.currentSegment)\n break;\n this.currentAttachedFile = {\n fileUid: null,\n fileName: null,\n fileMediaType: null,\n fileData: null,\n fileDescription: null,\n };\n this.readContiguousElements(slice.slice(dataStartPos, size));\n const tags = this.currentSegment.metadataTags;\n if (this.currentAttachedFile.fileUid && this.currentAttachedFile.fileData) {\n // All attached files get surfaced in the `raw` metadata tags\n tags.raw ??= {};\n tags.raw[this.currentAttachedFile.fileUid.toString()] = new AttachedFile(this.currentAttachedFile.fileData, this.currentAttachedFile.fileMediaType ?? undefined, this.currentAttachedFile.fileName ?? undefined, this.currentAttachedFile.fileDescription ?? undefined);\n }\n // Only process image attachments\n if (this.currentAttachedFile.fileMediaType?.startsWith('image/') && this.currentAttachedFile.fileData) {\n const fileName = this.currentAttachedFile.fileName;\n let kind = 'unknown';\n if (fileName) {\n const lowerName = fileName.toLowerCase();\n if (lowerName.startsWith('cover.')) {\n kind = 'coverFront';\n }\n else if (lowerName.startsWith('back.')) {\n kind = 'coverBack';\n }\n }\n tags.images ??= [];\n tags.images.push({\n data: this.currentAttachedFile.fileData,\n mimeType: this.currentAttachedFile.fileMediaType,\n kind,\n name: this.currentAttachedFile.fileName ?? undefined,\n description: this.currentAttachedFile.fileDescription ?? undefined,\n });\n }\n this.currentAttachedFile = null;\n }\n break;\n case EBMLId.FileUID:\n {\n if (!this.currentAttachedFile)\n break;\n this.currentAttachedFile.fileUid = readUnsignedBigInt(slice, size);\n }\n break;\n case EBMLId.FileName:\n {\n if (!this.currentAttachedFile)\n break;\n this.currentAttachedFile.fileName = readUnicodeString(slice, size);\n }\n break;\n case EBMLId.FileMediaType:\n {\n if (!this.currentAttachedFile)\n break;\n this.currentAttachedFile.fileMediaType = readAsciiString(slice, size);\n }\n break;\n case EBMLId.FileData:\n {\n if (!this.currentAttachedFile)\n break;\n this.currentAttachedFile.fileData = readBytes(slice, size);\n }\n break;\n case EBMLId.FileDescription:\n {\n if (!this.currentAttachedFile)\n break;\n this.currentAttachedFile.fileDescription = readUnicodeString(slice, size);\n }\n break;\n case EBMLId.ContentEncodings:\n {\n if (!this.currentTrack)\n break;\n this.readContiguousElements(slice.slice(dataStartPos, size));\n // \"**MUST** start with the `ContentEncoding` with the highest `ContentEncodingOrder`\"\n this.currentTrack.decodingInstructions.sort((a, b) => b.order - a.order);\n }\n break;\n case EBMLId.ContentEncoding:\n {\n this.currentDecodingInstruction = {\n order: 0,\n scope: ContentEncodingScope.Block,\n data: null,\n };\n this.readContiguousElements(slice.slice(dataStartPos, size));\n if (this.currentDecodingInstruction.data) {\n this.currentTrack.decodingInstructions.push(this.currentDecodingInstruction);\n }\n this.currentDecodingInstruction = null;\n }\n break;\n case EBMLId.ContentEncodingOrder:\n {\n if (!this.currentDecodingInstruction)\n break;\n this.currentDecodingInstruction.order = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.ContentEncodingScope:\n {\n if (!this.currentDecodingInstruction)\n break;\n this.currentDecodingInstruction.scope = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.ContentCompression:\n {\n if (!this.currentDecodingInstruction)\n break;\n this.currentDecodingInstruction.data = {\n type: 'decompress',\n algorithm: ContentCompAlgo.Zlib,\n settings: null,\n };\n this.readContiguousElements(slice.slice(dataStartPos, size));\n }\n break;\n case EBMLId.ContentCompAlgo:\n {\n if (this.currentDecodingInstruction?.data?.type !== 'decompress')\n break;\n this.currentDecodingInstruction.data.algorithm = readUnsignedInt(slice, size);\n }\n break;\n case EBMLId.ContentCompSettings:\n {\n if (this.currentDecodingInstruction?.data?.type !== 'decompress')\n break;\n this.currentDecodingInstruction.data.settings = readBytes(slice, size);\n }\n break;\n case EBMLId.ContentEncryption:\n {\n if (!this.currentDecodingInstruction)\n break;\n this.currentDecodingInstruction.data = {\n type: 'decrypt',\n };\n }\n break;\n }\n slice.filePos = dataStartPos + size;\n return true;\n }\n decodeBlockData(track, rawData) {\n assert(track.decodingInstructions.length > 0); // This method shouldn't be called otherwise\n let currentData = rawData;\n for (const instruction of track.decodingInstructions) {\n assert(instruction.data);\n switch (instruction.data.type) {\n case 'decompress':\n {\n switch (instruction.data.algorithm) {\n case ContentCompAlgo.HeaderStripping:\n {\n if (instruction.data.settings && instruction.data.settings.length > 0) {\n const prefix = instruction.data.settings;\n const newData = new Uint8Array(prefix.length + currentData.length);\n newData.set(prefix, 0);\n newData.set(currentData, prefix.length);\n currentData = newData;\n }\n }\n break;\n }\n }\n break;\n }\n }\n return currentData;\n }\n processTagValue(name, value) {\n if (!this.currentSegment?.metadataTags)\n return;\n const metadataTags = this.currentSegment.metadataTags;\n metadataTags.raw ??= {};\n metadataTags.raw[name] ??= value;\n if (typeof value === 'string') {\n switch (name.toLowerCase()) {\n case 'title':\n {\n metadataTags.title ??= value;\n }\n break;\n case 'description':\n {\n metadataTags.description ??= value;\n }\n break;\n case 'artist':\n {\n metadataTags.artist ??= value;\n }\n break;\n case 'album':\n {\n metadataTags.album ??= value;\n }\n break;\n case 'album_artist':\n {\n metadataTags.albumArtist ??= value;\n }\n break;\n case 'genre':\n {\n metadataTags.genre ??= value;\n }\n break;\n case 'comment':\n {\n metadataTags.comment ??= value;\n }\n break;\n case 'lyrics':\n {\n metadataTags.lyrics ??= value;\n }\n break;\n case 'date':\n {\n const date = new Date(value);\n if (!Number.isNaN(date.getTime())) {\n metadataTags.date ??= date;\n }\n }\n break;\n case 'track_number':\n case 'part_number':\n {\n const parts = value.split('/');\n const trackNum = Number.parseInt(parts[0], 10);\n const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(trackNum) && trackNum > 0) {\n metadataTags.trackNumber ??= trackNum;\n }\n if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {\n metadataTags.tracksTotal ??= tracksTotal;\n }\n }\n break;\n case 'disc_number':\n case 'disc':\n {\n const discParts = value.split('/');\n const discNum = Number.parseInt(discParts[0], 10);\n const discsTotal = discParts[1] && Number.parseInt(discParts[1], 10);\n if (Number.isInteger(discNum) && discNum > 0) {\n metadataTags.discNumber ??= discNum;\n }\n if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {\n metadataTags.discsTotal ??= discsTotal;\n }\n }\n break;\n }\n }\n }\n }\n class MatroskaTrackBacking {\n constructor(internalTrack) {\n this.internalTrack = internalTrack;\n this.packetToClusterLocation = new WeakMap();\n }\n getId() {\n return this.internalTrack.id;\n }\n getNumber() {\n const demuxer = this.internalTrack.demuxer;\n const trackType = this.internalTrack.trackBacking.getType();\n let number = 0;\n for (const segment of demuxer.segments) {\n for (const track of segment.tracks) {\n if (track.trackBacking.getType() === trackType) {\n number++;\n }\n if (track === this.internalTrack) {\n break;\n }\n }\n }\n return number;\n }\n getCodec() {\n throw new Error('Not implemented on base class.');\n }\n getInternalCodecId() {\n return this.internalTrack.codecId;\n }\n getName() {\n return this.internalTrack.name;\n }\n getLanguageCode() {\n return this.internalTrack.languageCode;\n }\n getTimeResolution() {\n return this.internalTrack.segment.timestampFactor;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getDisposition() {\n return this.internalTrack.disposition;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n const segment = this.internalTrack.segment;\n if (segment.duration <= 0) {\n return null;\n }\n let endTimestamp = segment.duration / segment.timestampFactor;\n const firstPacket = await this.getFirstPacket({ metadataOnly: true });\n endTimestamp += firstPacket?.timestamp ?? 0;\n return endTimestamp;\n }\n async getLiveRefreshInterval() {\n return null;\n }\n async getFirstPacket(options) {\n return this.performClusterLookup(null, (cluster) => {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n if (trackData) {\n return {\n blockIndex: 0,\n correctBlockFound: true,\n };\n }\n return {\n blockIndex: -1,\n correctBlockFound: false,\n };\n }, -Infinity, // Use -Infinity as a search timestamp to avoid using the cues\n Infinity, options);\n }\n intoTimescale(timestamp) {\n // Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely\n // that the number was originally an integer divided by the timescale. For stability, it's best\n // to return the integer in this case.\n return roundIfAlmostInteger(timestamp * this.internalTrack.segment.timestampFactor);\n }\n async getPacket(timestamp, options) {\n const timestampInTimescale = this.intoTimescale(timestamp);\n return this.performClusterLookup(null, (cluster) => {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n if (!trackData) {\n return { blockIndex: -1, correctBlockFound: false };\n }\n const index = binarySearchLessOrEqual(trackData.presentationTimestamps, timestampInTimescale, x => x.timestamp);\n const blockIndex = index !== -1 ? trackData.presentationTimestamps[index].blockIndex : -1;\n const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;\n return { blockIndex, correctBlockFound };\n }, timestampInTimescale, timestampInTimescale, options);\n }\n async getNextPacket(packet, options) {\n const locationInCluster = this.packetToClusterLocation.get(packet);\n if (locationInCluster === undefined) {\n throw new Error('Packet was not created from this track.');\n }\n return this.performClusterLookup(locationInCluster.cluster, (cluster) => {\n if (cluster === locationInCluster.cluster) {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n if (locationInCluster.blockIndex + 1 < trackData.blocks.length) {\n // We can simply take the next block in the cluster\n return {\n blockIndex: locationInCluster.blockIndex + 1,\n correctBlockFound: true,\n };\n }\n }\n else {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n if (trackData) {\n return {\n blockIndex: 0,\n correctBlockFound: true,\n };\n }\n }\n return {\n blockIndex: -1,\n correctBlockFound: false,\n };\n }, -Infinity, // Use -Infinity as a search timestamp to avoid using the cues\n Infinity, options);\n }\n async getKeyPacket(timestamp, options) {\n const timestampInTimescale = this.intoTimescale(timestamp);\n return this.performClusterLookup(null, (cluster) => {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n if (!trackData) {\n return { blockIndex: -1, correctBlockFound: false };\n }\n const index = findLastIndex(trackData.presentationTimestamps, (x) => {\n const block = trackData.blocks[x.blockIndex];\n return block.isKeyFrame && x.timestamp <= timestampInTimescale;\n });\n const blockIndex = index !== -1 ? trackData.presentationTimestamps[index].blockIndex : -1;\n const correctBlockFound = index !== -1 && timestampInTimescale < trackData.endTimestamp;\n return { blockIndex, correctBlockFound };\n }, timestampInTimescale, timestampInTimescale, options);\n }\n async getNextKeyPacket(packet, options) {\n const locationInCluster = this.packetToClusterLocation.get(packet);\n if (locationInCluster === undefined) {\n throw new Error('Packet was not created from this track.');\n }\n return this.performClusterLookup(locationInCluster.cluster, (cluster) => {\n if (cluster === locationInCluster.cluster) {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n const nextKeyFrameIndex = trackData.blocks.findIndex((x, i) => x.isKeyFrame && i > locationInCluster.blockIndex);\n if (nextKeyFrameIndex !== -1) {\n // We can simply take the next key frame in the cluster\n return {\n blockIndex: nextKeyFrameIndex,\n correctBlockFound: true,\n };\n }\n }\n else {\n const trackData = cluster.trackData.get(this.internalTrack.id);\n if (trackData && trackData.firstKeyFrameTimestamp !== null) {\n const keyFrameIndex = trackData.blocks.findIndex(x => x.isKeyFrame);\n assert(keyFrameIndex !== -1); // There must be one\n return {\n blockIndex: keyFrameIndex,\n correctBlockFound: true,\n };\n }\n }\n return {\n blockIndex: -1,\n correctBlockFound: false,\n };\n }, -Infinity, // Use -Infinity as a search timestamp to avoid using the cues\n Infinity, options);\n }\n async fetchPacketInCluster(cluster, blockIndex, options) {\n if (blockIndex === -1) {\n return null;\n }\n const trackData = cluster.trackData.get(this.internalTrack.id);\n const block = trackData.blocks[blockIndex];\n assert(block);\n // Perform lazy decoding if needed\n if (!block.decoded) {\n block.data = this.internalTrack.demuxer.decodeBlockData(this.internalTrack, block.data);\n block.decoded = true;\n }\n if (!block.postProcessed) {\n if (this.internalTrack.info?.codec === 'prores') {\n // For some reason, ProRes packets are stored in Matroska without the frame container atom. FFmpeg cites\n // the \"Matroska spec\" but the actual spec says nothing about this.\n const hasFrameContainer = block.data.length >= 8\n && block.data[4] === 105 // 'i'\n && block.data[5] === 99 // 'c'\n && block.data[6] === 112 // 'p'\n && block.data[7] === 102; // 'f'\n if (!hasFrameContainer) {\n // Wrap the frame in a frame container\n const newData = new Uint8Array(block.data.length + 8);\n const newDataView = toDataView(newData);\n newDataView.setUint32(0, newData.length, false);\n newData[4] = 105; // 'i'\n newData[5] = 99; // 'c'\n newData[6] = 112; // 'p'\n newData[7] = 102; // 'f'\n newData.set(block.data, 8);\n block.data = newData;\n }\n }\n block.postProcessed = true;\n }\n const data = options.metadataOnly ? PLACEHOLDER_DATA : block.data;\n const timestamp = block.timestamp / this.internalTrack.segment.timestampFactor;\n const duration = block.duration / this.internalTrack.segment.timestampFactor;\n const sideData = {};\n if (block.mainAdditional && this.internalTrack.info?.type === 'video' && this.internalTrack.info.alphaMode) {\n sideData.alpha = options.metadataOnly ? PLACEHOLDER_DATA : block.mainAdditional;\n sideData.alphaByteLength = block.mainAdditional.byteLength;\n }\n const packet = new EncodedPacket(data, block.isKeyFrame ? 'key' : 'delta', timestamp, duration, cluster.dataStartPos + blockIndex, block.data.byteLength, sideData);\n this.packetToClusterLocation.set(packet, { cluster, blockIndex });\n return packet;\n }\n /** Looks for a packet in the clusters while trying to load as few clusters as possible to retrieve it. */\n async performClusterLookup(\n // The cluster where we start looking\n startCluster, \n // This function returns the best-matching block in a given cluster\n getMatchInCluster, \n // The timestamp with which we can search the lookup table\n searchTimestamp, \n // The timestamp for which we know the correct block will not come after it\n latestTimestamp, options) {\n const { demuxer, segment } = this.internalTrack;\n let currentCluster = null;\n let bestCluster = null;\n let bestBlockIndex = -1;\n if (startCluster) {\n const { blockIndex, correctBlockFound } = getMatchInCluster(startCluster);\n if (correctBlockFound) {\n return this.fetchPacketInCluster(startCluster, blockIndex, options);\n }\n if (blockIndex !== -1) {\n bestCluster = startCluster;\n bestBlockIndex = blockIndex;\n }\n }\n // Search for a cue point; this way, we won't need to start searching from the start of the file\n // but can jump right into the correct cluster (or at least nearby).\n const cuePointIndex = binarySearchLessOrEqual(this.internalTrack.cuePoints, searchTimestamp, x => x.time);\n const cuePoint = cuePointIndex !== -1\n ? this.internalTrack.cuePoints[cuePointIndex]\n : null;\n // Also check the position cache\n const positionCacheIndex = binarySearchLessOrEqual(this.internalTrack.clusterPositionCache, searchTimestamp, x => x.startTimestamp);\n const positionCacheEntry = positionCacheIndex !== -1\n ? this.internalTrack.clusterPositionCache[positionCacheIndex]\n : null;\n const lookupEntryPosition = Math.max(cuePoint?.clusterPosition ?? 0, positionCacheEntry?.elementStartPos ?? 0) || null;\n let currentPos;\n if (!startCluster) {\n currentPos = lookupEntryPosition ?? segment.clusterSeekStartPos;\n }\n else {\n if (lookupEntryPosition === null || startCluster.elementStartPos >= lookupEntryPosition) {\n currentPos = startCluster.elementEndPos;\n currentCluster = startCluster;\n }\n else {\n // Use the lookup entry\n currentPos = lookupEntryPosition;\n }\n }\n while (segment.elementEndPos === null || currentPos <= segment.elementEndPos - MIN_HEADER_SIZE) {\n if (currentCluster) {\n const trackData = currentCluster.trackData.get(this.internalTrack.id);\n if (trackData && trackData.startTimestamp > latestTimestamp) {\n // We're already past the upper bound, no need to keep searching\n break;\n }\n }\n // Load the header\n let slice = demuxer.reader.requestSliceRange(currentPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const elementStartPos = currentPos;\n const elementHeader = readElementHeader(slice);\n if (!elementHeader\n || (!LEVEL_1_EBML_IDS.includes(elementHeader.id) && elementHeader.id !== EBMLId.Void)) {\n // There's an element here that shouldn't be here. Might be garbage. In this case, let's\n // try and resync to the next valid element.\n const nextPos = await resync(demuxer.reader, elementStartPos, LEVEL_1_EBML_IDS, Math.min(segment.elementEndPos ?? Infinity, elementStartPos + MAX_RESYNC_LENGTH));\n if (nextPos) {\n currentPos = nextPos;\n continue;\n }\n else {\n break; // Resync failed\n }\n }\n const id = elementHeader.id;\n let size = elementHeader.size;\n const dataStartPos = slice.filePos;\n if (id === EBMLId.Cluster) {\n currentCluster = await demuxer.readCluster(elementStartPos, segment);\n // readCluster computes the proper size even if it's undefined in the header, so let's use that instead\n size = currentCluster.elementEndPos - dataStartPos;\n const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);\n if (correctBlockFound) {\n return this.fetchPacketInCluster(currentCluster, blockIndex, options);\n }\n if (blockIndex !== -1) {\n bestCluster = currentCluster;\n bestBlockIndex = blockIndex;\n }\n }\n if (size === undefined) {\n // Undefined element size (can happen in livestreamed files). In this case, we need to do some\n // searching to determine the actual size of the element.\n assert(id !== EBMLId.Cluster); // Undefined cluster sizes are fixed further up\n // Search for the next element at level 0 or 1\n const nextElementPos = await searchForNextElementId(demuxer.reader, dataStartPos, LEVEL_0_AND_1_EBML_IDS, segment.elementEndPos);\n size = nextElementPos.pos - dataStartPos;\n }\n const endPos = dataStartPos + size;\n if (segment.elementEndPos === null) {\n // Check the next element. If it's a new segment, we know this segment ends here. The new\n // segment is just ignored, since we're likely in a livestreamed file and thus only care about\n // the first segment.\n let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const elementId = readElementId(slice);\n if (elementId === EBMLId.Segment) {\n segment.elementEndPos = endPos; // We now know the segment's size\n break;\n }\n }\n currentPos = endPos;\n }\n // Catch faulty cue points\n if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {\n // The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this\n // case, let's search again but using the cue point before that.\n const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];\n assert(!previousCuePoint || previousCuePoint.time < cuePoint.time);\n const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;\n return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);\n }\n if (bestCluster) {\n // If we finished looping but didn't find a perfect match, still return the best match we found\n return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);\n }\n return null;\n }\n }\n class MatroskaVideoTrackBacking extends MatroskaTrackBacking {\n constructor(internalTrack) {\n super(internalTrack);\n this.decoderConfigPromise = null;\n this.internalTrack = internalTrack;\n }\n getType() {\n return 'video';\n }\n getCodec() {\n return this.internalTrack.info.codec;\n }\n getCodedWidth() {\n return this.internalTrack.info.width;\n }\n getCodedHeight() {\n return this.internalTrack.info.height;\n }\n getSquarePixelWidth() {\n return this.internalTrack.info.squarePixelWidth;\n }\n getSquarePixelHeight() {\n return this.internalTrack.info.squarePixelHeight;\n }\n getRotation() {\n return this.internalTrack.info.rotation;\n }\n async getColorSpace() {\n return {\n primaries: this.internalTrack.info.colorSpace?.primaries,\n transfer: this.internalTrack.info.colorSpace?.transfer,\n matrix: this.internalTrack.info.colorSpace?.matrix,\n fullRange: this.internalTrack.info.colorSpace?.fullRange,\n };\n }\n async canBeTransparent() {\n return this.internalTrack.info.alphaMode || (this.internalTrack.info.codec === 'prores' && (this.internalTrack.info.proresFormat === 'ap4h'\n || this.internalTrack.info.proresFormat === 'ap4x'));\n }\n async getDecoderConfig() {\n if (!this.internalTrack.info.codec) {\n return null;\n }\n return this.decoderConfigPromise ??= (async () => {\n let firstPacket = null;\n const needsPacketForAdditionalInfo = this.internalTrack.info.codec === 'vp9'\n || this.internalTrack.info.codec === 'av1'\n // Packets are in Annex B format:\n || (this.internalTrack.info.codec === 'avc' && !this.internalTrack.info.codecDescription)\n // Packets are in Annex B format:\n || (this.internalTrack.info.codec === 'hevc' && !this.internalTrack.info.codecDescription);\n if (needsPacketForAdditionalInfo) {\n firstPacket = await this.getFirstPacket({});\n }\n const config = {\n codec: extractVideoCodecString({\n width: this.internalTrack.info.width,\n height: this.internalTrack.info.height,\n codec: this.internalTrack.info.codec,\n codecDescription: this.internalTrack.info.codecDescription,\n colorSpace: this.internalTrack.info.colorSpace,\n avcType: 1, // We don't know better (or do we?) so just assume 'avc1'\n avcCodecInfo: this.internalTrack.info.codec === 'avc' && firstPacket\n ? extractAvcDecoderConfigurationRecord(firstPacket.data)\n : null,\n hevcCodecInfo: this.internalTrack.info.codec === 'hevc' && firstPacket\n ? extractHevcDecoderConfigurationRecord(firstPacket.data)\n : null,\n vp9CodecInfo: this.internalTrack.info.codec === 'vp9' && firstPacket\n ? extractVp9CodecInfoFromPacket(firstPacket.data)\n : null,\n av1CodecInfo: this.internalTrack.info.codec === 'av1' && firstPacket\n ? extractAv1CodecInfoFromPacket(firstPacket.data)\n : null,\n proresFormat: this.internalTrack.info.proresFormat,\n }),\n codedWidth: this.internalTrack.info.width,\n codedHeight: this.internalTrack.info.height,\n description: this.internalTrack.info.codecDescription ?? undefined,\n colorSpace: this.internalTrack.info.colorSpace ?? undefined,\n };\n if (this.internalTrack.info.width !== this.internalTrack.info.squarePixelWidth\n || this.internalTrack.info.height !== this.internalTrack.info.squarePixelHeight) {\n config.displayAspectWidth = this.internalTrack.info.squarePixelWidth;\n config.displayAspectHeight = this.internalTrack.info.squarePixelHeight;\n }\n return config;\n })();\n }\n }\n class MatroskaAudioTrackBacking extends MatroskaTrackBacking {\n constructor(internalTrack) {\n super(internalTrack);\n this.decoderConfig = null;\n this.internalTrack = internalTrack;\n }\n getType() {\n return 'audio';\n }\n getCodec() {\n return this.internalTrack.info.codec;\n }\n getNumberOfChannels() {\n return this.internalTrack.info.numberOfChannels;\n }\n getSampleRate() {\n return this.internalTrack.info.sampleRate;\n }\n async getDecoderConfig() {\n if (!this.internalTrack.info.codec) {\n return null;\n }\n return this.decoderConfig ??= {\n codec: extractAudioCodecString({\n codec: this.internalTrack.info.codec,\n codecDescription: this.internalTrack.info.codecDescription,\n aacCodecInfo: this.internalTrack.info.aacCodecInfo,\n }),\n numberOfChannels: this.internalTrack.info.numberOfChannels,\n sampleRate: this.internalTrack.info.sampleRate,\n description: this.internalTrack.info.codecDescription ?? undefined,\n };\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const readNextMp3FrameHeader = async (reader, startPos, until, ref = null) => {\n const CHUNK_SIZE = 2 ** 16; // So we don't need to grab thousands of slices\n let currentPos = startPos;\n while (until === null || currentPos < until) {\n const maxLength = until !== null\n ? Math.min(CHUNK_SIZE, until - currentPos)\n : CHUNK_SIZE;\n let slice = reader.requestSliceRange(currentPos, MP3_FRAME_HEADER_SIZE, maxLength);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice || slice.length < MP3_FRAME_HEADER_SIZE)\n break;\n while (slice.remainingLength >= MP3_FRAME_HEADER_SIZE) {\n const posBeforeRead = slice.filePos;\n const word = readU32Be(slice);\n const remainingBytes = reader.fileSize !== null\n ? reader.fileSize - currentPos\n : null;\n const result = readMp3FrameHeader(word, remainingBytes);\n if (result.header\n && (!ref || (\n // This condition helps us recover malformed streams\n // https://stackoverflow.com/a/20884944\n result.header.sampleRate === ref.sampleRate\n && result.header.mpegVersionId === ref.mpegVersionId\n && result.header.layer === ref.layer\n && getMp3ChannelCount(result.header.channel) === getMp3ChannelCount(ref.channel)))) {\n return { header: result.header, startPos: currentPos };\n }\n slice.filePos = posBeforeRead + result.bytesAdvanced;\n currentPos = slice.filePos;\n }\n }\n return null;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class Mp3Demuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.metadataPromise = null;\n this.firstFrameHeader = null;\n this.firstFrameHeaderPos = null;\n this.loadedSamples = []; // All samples from the start of the file to lastLoadedPos\n this.metadataTags = null;\n this.xingData = null;\n this.trackBackings = [];\n this.readingMutex = new AsyncMutex();\n this.lastSampleLoaded = false;\n this.lastLoadedPos = 0;\n this.nextTimestampInSamples = 0;\n this.reader = input._reader;\n }\n async readMetadata() {\n return this.metadataPromise ??= (async () => {\n // Keep loading until we find the first frame header\n while (!this.firstFrameHeader && !this.lastSampleLoaded) {\n await this.advanceReader();\n }\n if (!this.firstFrameHeader) {\n throw new Error('No valid MP3 frame found.');\n }\n this.trackBackings = [new Mp3AudioTrackBacking(this)];\n })();\n }\n async advanceReader() {\n if (this.lastLoadedPos === 0) {\n // Let's skip all ID3v2 tags at the start of the file\n while (true) {\n let slice = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n this.lastSampleLoaded = true;\n return;\n }\n const id3V2Header = readId3V2Header(slice);\n if (!id3V2Header) {\n break;\n }\n this.lastLoadedPos = slice.filePos + id3V2Header.size;\n }\n }\n const result = await readNextMp3FrameHeader(this.reader, this.lastLoadedPos, this.reader.fileSize, this.firstFrameHeader);\n if (!result) {\n this.lastSampleLoaded = true;\n return;\n }\n const header = result.header;\n this.lastLoadedPos = result.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short\n const xingOffset = getXingOffset(header.mpegVersionId, header.channel);\n let slice = this.reader.requestSlice(result.startPos + xingOffset, 4);\n if (slice instanceof Promise)\n slice = await slice;\n if (slice) {\n const word = readU32Be(slice);\n const isXing = word === XING || word === INFO;\n if (isXing) {\n // There's no actual audio data in this frame, so let's skip it\n if (!this.xingData) {\n let xingDataSlice = this.reader.requestSlice(result.startPos + xingOffset + 4, 12);\n if (xingDataSlice instanceof Promise)\n xingDataSlice = await xingDataSlice;\n if (xingDataSlice) {\n const xingData = readBytes(xingDataSlice, 12);\n const view = toDataView(xingData);\n const flags = view.getUint32(0, false);\n this.xingData = {\n frameCount: (flags & XingFlags.FrameCount)\n ? view.getUint32(4, false)\n : null,\n fileSize: (flags & XingFlags.FileSize)\n ? view.getUint32(8, false)\n : null,\n };\n }\n }\n return;\n }\n }\n if (!this.firstFrameHeader) {\n this.firstFrameHeader = header;\n this.firstFrameHeaderPos = result.startPos;\n }\n const sampleDuration = header.audioSamplesInFrame / this.firstFrameHeader.sampleRate;\n const sample = {\n timestamp: this.nextTimestampInSamples / this.firstFrameHeader.sampleRate,\n duration: sampleDuration,\n dataStart: result.startPos,\n dataSize: header.totalSize,\n };\n this.loadedSamples.push(sample);\n this.nextTimestampInSamples += header.audioSamplesInFrame;\n return;\n }\n async getMimeType() {\n return 'audio/mpeg';\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.trackBackings;\n }\n async getMetadataTags() {\n const release = await this.readingMutex.acquire();\n try {\n await this.readMetadata();\n if (this.metadataTags) {\n return this.metadataTags;\n }\n this.metadataTags = {};\n let currentPos = 0;\n let id3V2HeaderFound = false;\n while (true) {\n let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n if (!headerSlice)\n break;\n const id3V2Header = readId3V2Header(headerSlice);\n if (!id3V2Header) {\n break;\n }\n id3V2HeaderFound = true;\n let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);\n if (contentSlice instanceof Promise)\n contentSlice = await contentSlice;\n if (!contentSlice)\n break;\n parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);\n currentPos = headerSlice.filePos + id3V2Header.size;\n }\n if (!id3V2HeaderFound && this.reader.fileSize !== null && this.reader.fileSize >= ID3_V1_TAG_SIZE) {\n // Try reading an ID3v1 tag at the end of the file\n let slice = this.reader.requestSlice(this.reader.fileSize - ID3_V1_TAG_SIZE, ID3_V1_TAG_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n assert(slice);\n const tag = readAscii(slice, 3);\n if (tag === 'TAG') {\n parseId3V1Tag(slice, this.metadataTags);\n }\n }\n return this.metadataTags;\n }\n finally {\n release();\n }\n }\n }\n class Mp3AudioTrackBacking {\n constructor(demuxer) {\n this.demuxer = demuxer;\n }\n getType() {\n return 'audio';\n }\n getId() {\n return 1;\n }\n getNumber() {\n return 1;\n }\n getTimeResolution() {\n assert(this.demuxer.firstFrameHeader);\n return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n const demuxer = this.demuxer;\n assert(demuxer.firstFrameHeader !== null);\n assert(demuxer.firstFrameHeaderPos !== null);\n if (demuxer.xingData) {\n if (demuxer.xingData.frameCount !== null) {\n return demuxer.xingData.frameCount\n * demuxer.firstFrameHeader.audioSamplesInFrame\n / demuxer.firstFrameHeader.sampleRate;\n }\n }\n else {\n // No Xing, assuming CBR\n if (demuxer.reader.fileSize !== null) {\n const averageFrameSize = computeAverageMp3FrameSize(demuxer.firstFrameHeader.lowSamplingFrequency, demuxer.firstFrameHeader.layer, demuxer.firstFrameHeader.bitrate, demuxer.firstFrameHeader.sampleRate);\n const frameCount = (demuxer.reader.fileSize - demuxer.firstFrameHeaderPos) / averageFrameSize;\n return Math.round(frameCount)\n * demuxer.firstFrameHeader.audioSamplesInFrame\n / demuxer.firstFrameHeader.sampleRate;\n }\n }\n return null;\n }\n async getLiveRefreshInterval() {\n return null;\n }\n getName() {\n return null;\n }\n getLanguageCode() {\n return UNDETERMINED_LANGUAGE;\n }\n getCodec() {\n return 'mp3';\n }\n getInternalCodecId() {\n return null;\n }\n getNumberOfChannels() {\n assert(this.demuxer.firstFrameHeader);\n return getMp3ChannelCount(this.demuxer.firstFrameHeader.channel);\n }\n getSampleRate() {\n assert(this.demuxer.firstFrameHeader);\n return this.demuxer.firstFrameHeader.sampleRate;\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n };\n }\n async getDecoderConfig() {\n assert(this.demuxer.firstFrameHeader);\n return {\n codec: 'mp3',\n numberOfChannels: getMp3ChannelCount(this.demuxer.firstFrameHeader.channel),\n sampleRate: this.demuxer.firstFrameHeader.sampleRate,\n };\n }\n async getPacketAtIndex(sampleIndex, options) {\n if (sampleIndex === -1) {\n return null;\n }\n const rawSample = this.demuxer.loadedSamples[sampleIndex];\n if (!rawSample) {\n return null;\n }\n let data;\n if (options.metadataOnly) {\n data = PLACEHOLDER_DATA;\n }\n else {\n let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null; // Data didn't fit into the rest of the file\n }\n data = readBytes(slice, rawSample.dataSize);\n }\n return new EncodedPacket(data, 'key', rawSample.timestamp, rawSample.duration, sampleIndex, rawSample.dataSize);\n }\n getFirstPacket(options) {\n return this.getPacketAtIndex(0, options);\n }\n async getNextPacket(packet, options) {\n const release = await this.demuxer.readingMutex.acquire();\n try {\n const sampleIndex = binarySearchExact(this.demuxer.loadedSamples, packet.timestamp, x => x.timestamp);\n if (sampleIndex === -1) {\n throw new Error('Packet was not created from this track.');\n }\n const nextIndex = sampleIndex + 1;\n // Ensure the next sample exists\n while (nextIndex >= this.demuxer.loadedSamples.length\n && !this.demuxer.lastSampleLoaded) {\n await this.demuxer.advanceReader();\n }\n return this.getPacketAtIndex(nextIndex, options);\n }\n finally {\n release();\n }\n }\n async getPacket(timestamp, options) {\n const release = await this.demuxer.readingMutex.acquire();\n try {\n while (true) {\n const index = binarySearchLessOrEqual(this.demuxer.loadedSamples, timestamp, x => x.timestamp);\n if (index === -1 && this.demuxer.loadedSamples.length > 0) {\n // We're before the first sample\n return null;\n }\n if (this.demuxer.lastSampleLoaded) {\n // All data is loaded, return what we found\n return this.getPacketAtIndex(index, options);\n }\n if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {\n // The next packet also exists, we're done\n return this.getPacketAtIndex(index, options);\n }\n // Otherwise, keep loading data\n await this.demuxer.advanceReader();\n }\n }\n finally {\n release();\n }\n }\n getKeyPacket(timestamp, options) {\n return this.getPacket(timestamp, options);\n }\n getNextKeyPacket(packet, options) {\n return this.getNextPacket(packet, options);\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const OGGS = 0x5367674f; // 'OggS'\n const OGG_CRC_POLYNOMIAL = 0x04c11db7;\n const OGG_CRC_TABLE = new Uint32Array(256);\n for (let n = 0; n < 256; n++) {\n let crc = n << 24;\n for (let k = 0; k < 8; k++) {\n crc = (crc & 0x80000000)\n ? ((crc << 1) ^ OGG_CRC_POLYNOMIAL)\n : (crc << 1);\n }\n OGG_CRC_TABLE[n] = (crc >>> 0) & 0xffffffff;\n }\n const computeOggPageCrc = (bytes) => {\n const view = toDataView(bytes);\n const originalChecksum = view.getUint32(22, true);\n view.setUint32(22, 0, true); // Zero out checksum field\n let crc = 0;\n for (let i = 0; i < bytes.length; i++) {\n const byte = bytes[i];\n crc = ((crc << 8) ^ OGG_CRC_TABLE[(crc >>> 24) ^ byte]) >>> 0;\n }\n view.setUint32(22, originalChecksum, true); // Restore checksum field\n return crc;\n };\n const extractSampleMetadata = (data, codecInfo, vorbisLastBlocksize) => {\n let durationInSamples = 0;\n let currentBlocksize = null;\n if (data.length > 0) {\n // To know sample duration, we'll need to peak inside the packet\n if (codecInfo.codec === 'vorbis') {\n assert(codecInfo.vorbisInfo);\n const vorbisModeCount = codecInfo.vorbisInfo.modeBlockflags.length;\n const bitCount = ilog(vorbisModeCount - 1);\n const modeMask = ((1 << bitCount) - 1) << 1;\n const modeNumber = (data[0] & modeMask) >> 1;\n if (modeNumber >= codecInfo.vorbisInfo.modeBlockflags.length) {\n throw new Error('Invalid mode number.');\n }\n // In Vorbis, packet duration also depends on the blocksize of the previous packet\n let prevBlocksize = vorbisLastBlocksize;\n const blockflag = codecInfo.vorbisInfo.modeBlockflags[modeNumber];\n currentBlocksize = codecInfo.vorbisInfo.blocksizes[blockflag];\n if (blockflag === 1) {\n const prevMask = (modeMask | 0x1) + 1;\n const flag = data[0] & prevMask ? 1 : 0;\n prevBlocksize = codecInfo.vorbisInfo.blocksizes[flag];\n }\n durationInSamples = prevBlocksize !== null\n ? (prevBlocksize + currentBlocksize) >> 2\n : 0; // The first sample outputs no audio data and therefore has a duration of 0\n }\n else if (codecInfo.codec === 'opus') {\n const toc = parseOpusTocByte(data);\n durationInSamples = toc.durationInSamples;\n }\n }\n return {\n durationInSamples,\n vorbisBlockSize: currentBlocksize,\n };\n };\n const buildOggMimeType = (info) => {\n let string = 'audio/ogg';\n if (info.codecStrings) {\n const uniqueCodecMimeTypes = [...new Set(info.codecStrings)];\n string += `; codecs=\"${uniqueCodecMimeTypes.join(', ')}\"`;\n }\n return string;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const MIN_PAGE_HEADER_SIZE = 27;\n const MAX_PAGE_HEADER_SIZE = 27 + 255;\n const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;\n const readPageHeader = (slice) => {\n const startPos = slice.filePos;\n const capturePattern = readU32Le(slice);\n if (capturePattern !== OGGS) {\n return null;\n }\n slice.skip(1); // Version\n const headerType = readU8(slice);\n const granulePosition = readI64Le(slice);\n const serialNumber = readU32Le(slice);\n const sequenceNumber = readU32Le(slice);\n const checksum = readU32Le(slice);\n const numberPageSegments = readU8(slice);\n const lacingValues = new Uint8Array(numberPageSegments);\n for (let i = 0; i < numberPageSegments; i++) {\n lacingValues[i] = readU8(slice);\n }\n const headerSize = 27 + numberPageSegments;\n const dataSize = lacingValues.reduce((a, b) => a + b, 0);\n const totalSize = headerSize + dataSize;\n return {\n headerStartPos: startPos,\n totalSize,\n dataStartPos: startPos + headerSize,\n dataSize,\n headerType,\n granulePosition,\n serialNumber,\n sequenceNumber,\n checksum,\n lacingValues,\n };\n };\n const findNextPageHeader = (slice, until) => {\n while (slice.filePos < until - (4 - 1)) { // Size of word minus 1\n const word = readU32Le(slice);\n const firstByte = word & 0xff;\n const secondByte = (word >>> 8) & 0xff;\n const thirdByte = (word >>> 16) & 0xff;\n const fourthByte = (word >>> 24) & 0xff;\n const O = 0x4f; // 'O'\n if (firstByte !== O && secondByte !== O && thirdByte !== O && fourthByte !== O) {\n continue;\n }\n slice.skip(-4);\n if (word === OGGS) {\n // We have found the capture pattern\n return true;\n }\n slice.skip(1);\n }\n return false;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class OggDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.metadataPromise = null;\n this.bitstreams = [];\n this.trackBackings = [];\n this.metadataTags = {};\n this.reader = input._reader;\n }\n async readMetadata() {\n return this.metadataPromise ??= (async () => {\n let currentPos = 0;\n while (true) {\n let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const page = readPageHeader(slice);\n if (!page) {\n break;\n }\n const isBos = !!(page.headerType & 0x02);\n if (!isBos) {\n // All bos pages for all bitstreams are required to be at the start, so if the page is not bos then\n // we know we've seen all bitstreams (minus chaining)\n break;\n }\n this.bitstreams.push({\n serialNumber: page.serialNumber,\n bosPage: page,\n description: null,\n numberOfChannels: -1,\n sampleRate: -1,\n codecInfo: {\n codec: null,\n vorbisInfo: null,\n opusInfo: null,\n },\n lastMetadataPacket: null,\n });\n currentPos = page.headerStartPos + page.totalSize;\n }\n for (const bitstream of this.bitstreams) {\n const firstPacket = await this.readPacket(bitstream.bosPage, 0);\n if (!firstPacket) {\n continue;\n }\n if (\n // Check for Vorbis\n firstPacket.data.byteLength >= 7\n && firstPacket.data[0] === 0x01 // Packet type 1 = identification header\n && firstPacket.data[1] === 0x76 // 'v'\n && firstPacket.data[2] === 0x6f // 'o'\n && firstPacket.data[3] === 0x72 // 'r'\n && firstPacket.data[4] === 0x62 // 'b'\n && firstPacket.data[5] === 0x69 // 'i'\n && firstPacket.data[6] === 0x73 // 's'\n ) {\n await this.readVorbisMetadata(firstPacket, bitstream);\n }\n else if (\n // Check for Opus\n firstPacket.data.byteLength >= 8\n && firstPacket.data[0] === 0x4f // 'O'\n && firstPacket.data[1] === 0x70 // 'p'\n && firstPacket.data[2] === 0x75 // 'u'\n && firstPacket.data[3] === 0x73 // 's'\n && firstPacket.data[4] === 0x48 // 'H'\n && firstPacket.data[5] === 0x65 // 'e'\n && firstPacket.data[6] === 0x61 // 'a'\n && firstPacket.data[7] === 0x64 // 'd'\n ) {\n await this.readOpusMetadata(firstPacket, bitstream);\n }\n if (bitstream.codecInfo.codec !== null) {\n this.trackBackings.push(new OggAudioTrackBacking(bitstream, this));\n }\n }\n })();\n }\n async readVorbisMetadata(firstPacket, bitstream) {\n let nextPacketPosition = await this.findNextPacketStart(firstPacket);\n if (!nextPacketPosition) {\n return;\n }\n const secondPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);\n if (!secondPacket) {\n return;\n }\n nextPacketPosition = await this.findNextPacketStart(secondPacket);\n if (!nextPacketPosition) {\n return;\n }\n const thirdPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);\n if (!thirdPacket) {\n return;\n }\n if (secondPacket.data[0] !== 0x03 || thirdPacket.data[0] !== 0x05) {\n return;\n }\n const lacingValues = [];\n const addBytesToSegmentTable = (bytes) => {\n while (true) {\n lacingValues.push(Math.min(255, bytes));\n if (bytes < 255) {\n break;\n }\n bytes -= 255;\n }\n };\n addBytesToSegmentTable(firstPacket.data.length);\n addBytesToSegmentTable(secondPacket.data.length);\n // We don't add the last packet to the segment table, as it is assumed to be whatever bytes remain\n const description = new Uint8Array(1 + lacingValues.length\n + firstPacket.data.length + secondPacket.data.length + thirdPacket.data.length);\n description[0] = 2; // Num entries in the segment table\n description.set(lacingValues, 1);\n description.set(firstPacket.data, 1 + lacingValues.length);\n description.set(secondPacket.data, 1 + lacingValues.length + firstPacket.data.length);\n description.set(thirdPacket.data, 1 + lacingValues.length + firstPacket.data.length + secondPacket.data.length);\n bitstream.codecInfo.codec = 'vorbis';\n bitstream.description = description;\n bitstream.lastMetadataPacket = thirdPacket;\n const view = toDataView(firstPacket.data);\n bitstream.numberOfChannels = view.getUint8(11);\n bitstream.sampleRate = view.getUint32(12, true);\n const blockSizeByte = view.getUint8(28);\n bitstream.codecInfo.vorbisInfo = {\n blocksizes: [\n 1 << (blockSizeByte & 0xf),\n 1 << (blockSizeByte >> 4),\n ],\n modeBlockflags: parseModesFromVorbisSetupPacket(thirdPacket.data).modeBlockflags,\n };\n readVorbisComments(secondPacket.data.subarray(7), this.metadataTags); // Skip header type and 'vorbis'\n }\n async readOpusMetadata(firstPacket, bitstream) {\n // From https://datatracker.ietf.org/doc/html/rfc7845#section-5:\n // \"An Ogg Opus logical stream contains exactly two mandatory header packets: an identification header and a\n // comment header.\"\n const nextPacketPosition = await this.findNextPacketStart(firstPacket);\n if (!nextPacketPosition) {\n return;\n }\n const secondPacket = await this.readPacket(nextPacketPosition.startPage, nextPacketPosition.startSegmentIndex);\n if (!secondPacket) {\n return;\n }\n bitstream.codecInfo.codec = 'opus';\n bitstream.description = firstPacket.data;\n bitstream.lastMetadataPacket = secondPacket;\n const header = parseOpusIdentificationHeader(firstPacket.data);\n bitstream.numberOfChannels = header.outputChannelCount;\n bitstream.sampleRate = OPUS_SAMPLE_RATE; // Always the same\n bitstream.codecInfo.opusInfo = {\n preSkip: header.preSkip,\n };\n readVorbisComments(secondPacket.data.subarray(8), this.metadataTags); // Skip 'OpusTags'\n }\n async readPacket(startPage, startSegmentIndex) {\n assert(startSegmentIndex < startPage.lacingValues.length);\n let startDataOffset = 0;\n for (let i = 0; i < startSegmentIndex; i++) {\n startDataOffset += startPage.lacingValues[i];\n }\n let currentPage = startPage;\n let currentDataOffset = startDataOffset;\n let currentSegmentIndex = startSegmentIndex;\n const chunks = [];\n outer: while (true) {\n // Load the entire page data\n let pageSlice = this.reader.requestSlice(currentPage.dataStartPos, currentPage.dataSize);\n if (pageSlice instanceof Promise)\n pageSlice = await pageSlice;\n assert(pageSlice);\n const pageData = readBytes(pageSlice, currentPage.dataSize);\n while (true) {\n if (currentSegmentIndex === currentPage.lacingValues.length) {\n chunks.push(pageData.subarray(startDataOffset, currentDataOffset));\n break;\n }\n const lacingValue = currentPage.lacingValues[currentSegmentIndex];\n currentDataOffset += lacingValue;\n if (lacingValue < 255) {\n chunks.push(pageData.subarray(startDataOffset, currentDataOffset));\n break outer;\n }\n currentSegmentIndex++;\n }\n // The packet extends to the next page; let's find it\n let currentPos = currentPage.headerStartPos + currentPage.totalSize;\n while (true) {\n let headerSlice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n if (!headerSlice) {\n return null;\n }\n const nextPage = readPageHeader(headerSlice);\n if (!nextPage) {\n return null;\n }\n currentPage = nextPage;\n if (currentPage.serialNumber === startPage.serialNumber) {\n break;\n }\n currentPos = currentPage.headerStartPos + currentPage.totalSize;\n }\n startDataOffset = 0;\n currentDataOffset = 0;\n currentSegmentIndex = 0;\n }\n const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n if (totalPacketSize === 0) {\n return null; // Invalid packet, treat it as end of stream\n }\n const packetData = new Uint8Array(totalPacketSize);\n let offset = 0;\n for (let i = 0; i < chunks.length; i++) {\n const chunk = chunks[i];\n packetData.set(chunk, offset);\n offset += chunk.length;\n }\n return {\n data: packetData,\n endPage: currentPage,\n endSegmentIndex: currentSegmentIndex,\n };\n }\n async findNextPacketStart(lastPacket) {\n // If there's another segment in the same page, return it\n if (lastPacket.endSegmentIndex < lastPacket.endPage.lacingValues.length - 1) {\n return { startPage: lastPacket.endPage, startSegmentIndex: lastPacket.endSegmentIndex + 1 };\n }\n const isEos = !!(lastPacket.endPage.headerType & 0x04);\n if (isEos) {\n // The page is marked as the last page of the logical bitstream, so we won't find anything beyond it\n return null;\n }\n // Otherwise, search for the next page belonging to the same bitstream\n let currentPos = lastPacket.endPage.headerStartPos + lastPacket.endPage.totalSize;\n while (true) {\n let slice = this.reader.requestSliceRange(currentPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null;\n }\n const nextPage = readPageHeader(slice);\n if (!nextPage) {\n return null;\n }\n if (nextPage.serialNumber === lastPacket.endPage.serialNumber) {\n return { startPage: nextPage, startSegmentIndex: 0 };\n }\n currentPos = nextPage.headerStartPos + nextPage.totalSize;\n }\n }\n async getMimeType() {\n await this.readMetadata();\n const codecStrings = await Promise.all(this.trackBackings.map(x => x.getDecoderConfig().then(c => c?.codec ?? null)));\n return buildOggMimeType({\n codecStrings: codecStrings.filter(Boolean),\n });\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.trackBackings;\n }\n async getMetadataTags() {\n await this.readMetadata();\n return this.metadataTags;\n }\n }\n class OggAudioTrackBacking {\n constructor(bitstream, demuxer) {\n this.bitstream = bitstream;\n this.demuxer = demuxer;\n this.encodedPacketToMetadata = new WeakMap();\n this.sequentialScanCache = [];\n this.sequentialScanMutex = new AsyncMutex();\n // Opus always uses a fixed sample rate for its internal calculations, even if the actual rate is different\n this.internalSampleRate = bitstream.codecInfo.codec === 'opus'\n ? OPUS_SAMPLE_RATE\n : bitstream.sampleRate;\n }\n getType() {\n return 'audio';\n }\n getId() {\n return this.bitstream.serialNumber;\n }\n getNumber() {\n // All Ogg tracks are audio, so the track's index + 1 is its number\n const index = this.demuxer.trackBackings.findIndex(x => x.bitstream === this.bitstream);\n assert(index !== -1);\n return index + 1;\n }\n getNumberOfChannels() {\n return this.bitstream.numberOfChannels;\n }\n getSampleRate() {\n return this.bitstream.sampleRate;\n }\n getTimeResolution() {\n return this.bitstream.sampleRate;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n return null; // Not stored anywhere\n }\n async getLiveRefreshInterval() {\n return null;\n }\n getCodec() {\n return this.bitstream.codecInfo.codec;\n }\n getInternalCodecId() {\n return null;\n }\n async getDecoderConfig() {\n assert(this.bitstream.codecInfo.codec);\n return {\n codec: this.bitstream.codecInfo.codec,\n numberOfChannels: this.bitstream.numberOfChannels,\n sampleRate: this.bitstream.sampleRate,\n description: this.bitstream.description ?? undefined,\n };\n }\n getName() {\n return null;\n }\n getLanguageCode() {\n return UNDETERMINED_LANGUAGE;\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n primary: false,\n };\n }\n granulePositionToTimestampInSamples(granulePosition) {\n if (this.bitstream.codecInfo.codec === 'opus') {\n assert(this.bitstream.codecInfo.opusInfo);\n return granulePosition - this.bitstream.codecInfo.opusInfo.preSkip;\n }\n return granulePosition;\n }\n createEncodedPacketFromOggPacket(packet, additional, options) {\n if (!packet) {\n return null;\n }\n const { durationInSamples, vorbisBlockSize } = extractSampleMetadata(packet.data, this.bitstream.codecInfo, additional.vorbisLastBlocksize);\n const encodedPacket = new EncodedPacket(options.metadataOnly ? PLACEHOLDER_DATA : packet.data, 'key', Math.max(0, additional.timestampInSamples) / this.internalSampleRate, durationInSamples / this.internalSampleRate, packet.endPage.headerStartPos + packet.endSegmentIndex, packet.data.byteLength);\n this.encodedPacketToMetadata.set(encodedPacket, {\n packet,\n timestampInSamples: additional.timestampInSamples,\n durationInSamples,\n vorbisLastBlockSize: additional.vorbisLastBlocksize,\n vorbisBlockSize,\n });\n return encodedPacket;\n }\n async getFirstPacket(options) {\n assert(this.bitstream.lastMetadataPacket);\n const packetPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);\n if (!packetPosition) {\n return null;\n }\n let timestampInSamples = 0;\n if (this.bitstream.codecInfo.codec === 'opus') {\n assert(this.bitstream.codecInfo.opusInfo);\n timestampInSamples -= this.bitstream.codecInfo.opusInfo.preSkip;\n }\n const packet = await this.demuxer.readPacket(packetPosition.startPage, packetPosition.startSegmentIndex);\n return this.createEncodedPacketFromOggPacket(packet, {\n timestampInSamples,\n vorbisLastBlocksize: null,\n }, options);\n }\n async getNextPacket(prevPacket, options) {\n const prevMetadata = this.encodedPacketToMetadata.get(prevPacket);\n if (!prevMetadata) {\n throw new Error('Packet was not created from this track.');\n }\n const packetPosition = await this.demuxer.findNextPacketStart(prevMetadata.packet);\n if (!packetPosition) {\n return null;\n }\n const timestampInSamples = prevMetadata.timestampInSamples + prevMetadata.durationInSamples;\n const packet = await this.demuxer.readPacket(packetPosition.startPage, packetPosition.startSegmentIndex);\n return this.createEncodedPacketFromOggPacket(packet, {\n timestampInSamples,\n vorbisLastBlocksize: prevMetadata.vorbisBlockSize,\n }, options);\n }\n async getPacket(timestamp, options) {\n if (this.demuxer.reader.fileSize === null) {\n // No file size known, can't do binary search, but fall back to sequential algo instead\n return this.getPacketSequential(timestamp, options);\n }\n const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);\n if (timestampInSamples === 0) {\n // Fast path for timestamp 0 - avoids binary search when playing back from the start\n return this.getFirstPacket(options);\n }\n if (timestampInSamples < 0) {\n // There's nothing here\n return null;\n }\n assert(this.bitstream.lastMetadataPacket);\n const startPosition = await this.demuxer.findNextPacketStart(this.bitstream.lastMetadataPacket);\n if (!startPosition) {\n return null;\n }\n let lowPage = startPosition.startPage;\n let high = this.demuxer.reader.fileSize;\n const lowPages = [lowPage];\n // First, let's perform a binary search (bisection search) on the file to find the approximate page where\n // we'll find the packet. We want to find a page whose end packet position is less than or equal to the\n // packet position we're searching for.\n // Outer loop: Does the binary search\n outer: while (lowPage.headerStartPos + lowPage.totalSize < high) {\n const low = lowPage.headerStartPos;\n const mid = Math.floor((low + high) / 2);\n let searchStartPos = mid;\n // Inner loop: Does a linear forward scan if the page cannot be found immediately\n while (true) {\n const until = Math.min(searchStartPos + MAX_PAGE_SIZE, high - MIN_PAGE_HEADER_SIZE);\n let searchSlice = this.demuxer.reader.requestSlice(searchStartPos, until - searchStartPos);\n if (searchSlice instanceof Promise)\n searchSlice = await searchSlice;\n assert(searchSlice);\n const found = findNextPageHeader(searchSlice, until);\n if (!found) {\n high = mid + MIN_PAGE_HEADER_SIZE;\n continue outer;\n }\n let headerSlice = this.demuxer.reader.requestSliceRange(searchSlice.filePos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n assert(headerSlice);\n const page = readPageHeader(headerSlice);\n assert(page);\n let pageValid = false;\n if (page.serialNumber === this.bitstream.serialNumber) {\n // Serial numbers are basically random numbers, and the chance of finding a fake page with\n // matching serial number is astronomically low, so we can be pretty sure this page is legit.\n pageValid = true;\n }\n else {\n let pageSlice = this.demuxer.reader.requestSlice(page.headerStartPos, page.totalSize);\n if (pageSlice instanceof Promise)\n pageSlice = await pageSlice;\n assert(pageSlice);\n // Validate the page by checking checksum\n const bytes = readBytes(pageSlice, page.totalSize);\n const crc = computeOggPageCrc(bytes);\n pageValid = crc === page.checksum;\n }\n if (!pageValid) {\n // Keep searching for a valid page\n searchStartPos = page.headerStartPos + 4; // 'OggS' is 4 bytes\n continue;\n }\n if (pageValid && page.serialNumber !== this.bitstream.serialNumber) {\n // Page is valid but from a different bitstream, so keep searching forward until we find one\n // belonging to the our bitstream\n searchStartPos = page.headerStartPos + page.totalSize;\n continue;\n }\n const isContinuationPage = page.granulePosition === -1;\n if (isContinuationPage) {\n // No packet ends on this page - keep looking\n searchStartPos = page.headerStartPos + page.totalSize;\n continue;\n }\n // The page is valid and belongs to our bitstream; let's check its granule position to see where we\n // need to take the bisection search.\n if (this.granulePositionToTimestampInSamples(page.granulePosition) > timestampInSamples) {\n high = page.headerStartPos;\n }\n else {\n lowPage = page;\n lowPages.push(page);\n }\n continue outer;\n }\n }\n // Now we have the last page with a packet position <= the packet position we're looking for, but there\n // might be multiple pages with the packet position, in which case we actually need to find the first of\n // such pages. We'll do this in two steps: First, let's find the latest page we know with an earlier packet\n // position, and then linear scan ourselves forward until we find the correct page.\n let lowerPage = startPosition.startPage;\n for (const otherLowPage of lowPages) {\n if (otherLowPage.granulePosition === lowPage.granulePosition) {\n break;\n }\n if (!lowerPage || otherLowPage.headerStartPos > lowerPage.headerStartPos) {\n lowerPage = otherLowPage;\n }\n }\n let currentPage = lowerPage;\n // Keep track of the pages we traversed, we need these later for backwards seeking\n const previousPages = [currentPage];\n while (true) {\n // This loop must terminate as we'll eventually reach lowPage\n if (currentPage.serialNumber === this.bitstream.serialNumber\n && currentPage.granulePosition === lowPage.granulePosition) {\n break;\n }\n const nextPos = currentPage.headerStartPos + currentPage.totalSize;\n let slice = this.demuxer.reader.requestSliceRange(nextPos, MIN_PAGE_HEADER_SIZE, MAX_PAGE_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n assert(slice);\n const nextPage = readPageHeader(slice);\n assert(nextPage);\n currentPage = nextPage;\n if (currentPage.serialNumber === this.bitstream.serialNumber) {\n previousPages.push(currentPage);\n }\n }\n assert(currentPage.granulePosition !== -1);\n let currentSegmentIndex = null;\n let currentTimestampInSamples;\n let currentTimestampIsCorrect;\n // These indicate the end position of the packet that the granule position belongs to\n let endPage = currentPage;\n let endSegmentIndex = 0;\n if (currentPage.headerStartPos === startPosition.startPage.headerStartPos) {\n currentTimestampInSamples = this.granulePositionToTimestampInSamples(0);\n currentTimestampIsCorrect = true;\n currentSegmentIndex = 0;\n }\n else {\n currentTimestampInSamples = 0; // Placeholder value! We'll refine it once we can\n currentTimestampIsCorrect = false;\n // Find the segment index of the next packet\n for (let i = currentPage.lacingValues.length - 1; i >= 0; i--) {\n const value = currentPage.lacingValues[i];\n if (value < 255) {\n // We know the last packet ended at i, so the next one starts at i + 1\n currentSegmentIndex = i + 1;\n break;\n }\n }\n // This must hold: Since this page has a granule position set, that means there must be a packet that\n // ends in this page.\n if (currentSegmentIndex === null) {\n throw new Error('Invalid page with granule position: no packets end on this page.');\n }\n endSegmentIndex = currentSegmentIndex - 1;\n const pseudopacket = {\n data: PLACEHOLDER_DATA,\n endPage,\n endSegmentIndex,\n };\n const nextPosition = await this.demuxer.findNextPacketStart(pseudopacket);\n if (nextPosition) {\n // Let's rewind a single step (packet) - this previous packet ensures that we'll correctly compute\n // the duration for the packet we're looking for.\n const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);\n assert(endPosition);\n const startPosition = findPacketStartPosition(previousPages, endPosition.page, endPosition.segmentIndex);\n if (startPosition) {\n currentPage = startPosition.page;\n currentSegmentIndex = startPosition.segmentIndex;\n }\n }\n else {\n // There is no next position, which means we're looking for the last packet in the bitstream. The\n // granule position on the last page tends to be fucky, so let's instead start the search on the\n // page before that. So let's loop until we find a packet that ends in a previous page.\n while (true) {\n const endPosition = findPreviousPacketEndPosition(previousPages, currentPage, currentSegmentIndex);\n if (!endPosition) {\n break;\n }\n const startPosition = findPacketStartPosition(previousPages, endPosition.page, endPosition.segmentIndex);\n if (!startPosition) {\n break;\n }\n currentPage = startPosition.page;\n currentSegmentIndex = startPosition.segmentIndex;\n if (endPosition.page.headerStartPos !== endPage.headerStartPos) {\n endPage = endPosition.page;\n endSegmentIndex = endPosition.segmentIndex;\n break;\n }\n }\n }\n }\n let lastEncodedPacket = null;\n let lastEncodedPacketMetadata = null;\n // Alright, now it's time for the final, granular seek: We keep iterating over packets until we've found the\n // one with the correct timestamp - i.e., the last one with a timestamp <= the timestamp we're looking for.\n while (currentPage !== null) {\n assert(currentSegmentIndex !== null);\n const packet = await this.demuxer.readPacket(currentPage, currentSegmentIndex);\n if (!packet) {\n break;\n }\n // We might need to skip the packet if it's a metadata one\n const skipPacket = currentPage.headerStartPos === startPosition.startPage.headerStartPos\n && currentSegmentIndex < startPosition.startSegmentIndex;\n if (!skipPacket) {\n let encodedPacket = this.createEncodedPacketFromOggPacket(packet, {\n timestampInSamples: currentTimestampInSamples,\n vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null,\n }, options);\n assert(encodedPacket);\n let encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);\n assert(encodedPacketMetadata);\n if (!currentTimestampIsCorrect\n && packet.endPage.headerStartPos === endPage.headerStartPos\n && packet.endSegmentIndex === endSegmentIndex) {\n // We know this packet end timestamp can be derived from the page's granule position\n currentTimestampInSamples = this.granulePositionToTimestampInSamples(currentPage.granulePosition);\n currentTimestampIsCorrect = true;\n // Let's backpatch the packet we just created with the correct timestamp\n encodedPacket = this.createEncodedPacketFromOggPacket(packet, {\n timestampInSamples: currentTimestampInSamples - encodedPacketMetadata.durationInSamples,\n vorbisLastBlocksize: lastEncodedPacketMetadata?.vorbisBlockSize ?? null,\n }, options);\n assert(encodedPacket);\n encodedPacketMetadata = this.encodedPacketToMetadata.get(encodedPacket);\n assert(encodedPacketMetadata);\n }\n else {\n currentTimestampInSamples += encodedPacketMetadata.durationInSamples;\n }\n lastEncodedPacket = encodedPacket;\n lastEncodedPacketMetadata = encodedPacketMetadata;\n if (currentTimestampIsCorrect\n && (\n // Next timestamp will be too late\n Math.max(currentTimestampInSamples, 0) > timestampInSamples\n // This timestamp already matches\n || Math.max(encodedPacketMetadata.timestampInSamples, 0) === timestampInSamples)) {\n break;\n }\n }\n const nextPosition = await this.demuxer.findNextPacketStart(packet);\n if (!nextPosition) {\n break;\n }\n currentPage = nextPosition.startPage;\n currentSegmentIndex = nextPosition.startSegmentIndex;\n }\n return lastEncodedPacket;\n }\n // A slower but simpler and sequential algorithm for finding a packet in a file\n async getPacketSequential(timestamp, options) {\n const release = await this.sequentialScanMutex.acquire(); // Requires exclusivity because we write to a cache\n try {\n const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);\n timestamp = timestampInSamples / this.internalSampleRate;\n const index = binarySearchLessOrEqual(this.sequentialScanCache, timestampInSamples, x => x.timestampInSamples);\n let currentPacket;\n if (index !== -1) {\n // We don't need to start from the beginning, we can start at a previous scan point\n const cacheEntry = this.sequentialScanCache[index];\n currentPacket = this.createEncodedPacketFromOggPacket(cacheEntry.packet, {\n timestampInSamples: cacheEntry.timestampInSamples,\n vorbisLastBlocksize: cacheEntry.vorbisLastBlockSize,\n }, options);\n }\n else {\n currentPacket = await this.getFirstPacket(options);\n }\n let i = 0;\n while (currentPacket && currentPacket.timestamp < timestamp) {\n const nextPacket = await this.getNextPacket(currentPacket, options);\n if (!nextPacket || nextPacket.timestamp > timestamp) {\n break;\n }\n currentPacket = nextPacket;\n i++;\n if (i === 100) {\n // Add \"checkpoints\" every once in a while to speed up subsequent random accesses\n i = 0;\n const metadata = this.encodedPacketToMetadata.get(currentPacket);\n assert(metadata);\n if (this.sequentialScanCache.length > 0) {\n // If we reach this case, we must be at the end of the cache\n assert(last(this.sequentialScanCache).timestampInSamples <= metadata.timestampInSamples);\n }\n this.sequentialScanCache.push(metadata);\n }\n }\n return currentPacket;\n }\n finally {\n release();\n }\n }\n getKeyPacket(timestamp, options) {\n return this.getPacket(timestamp, options);\n }\n getNextKeyPacket(packet, options) {\n return this.getNextPacket(packet, options);\n }\n }\n /** Finds the start position of a packet given its end position. */\n const findPacketStartPosition = (pageList, endPage, endSegmentIndex) => {\n let page = endPage;\n let segmentIndex = endSegmentIndex;\n outer: while (true) {\n segmentIndex--;\n for (segmentIndex; segmentIndex >= 0; segmentIndex--) {\n const lacingValue = page.lacingValues[segmentIndex];\n if (lacingValue < 255) {\n segmentIndex++; // We know the last packet starts here\n break outer;\n }\n }\n assert(segmentIndex === -1);\n const pageStartsWithFreshPacket = !(page.headerType & 0x01);\n if (pageStartsWithFreshPacket) {\n // Fast exit: We know we don't need to look in the previous page\n segmentIndex = 0;\n break;\n }\n const previousPage = findLast(pageList, x => x.headerStartPos < page.headerStartPos);\n if (!previousPage) {\n return null;\n }\n page = previousPage;\n segmentIndex = page.lacingValues.length;\n }\n assert(segmentIndex !== -1);\n if (segmentIndex === page.lacingValues.length) {\n // Wrap back around to the first segment of the next page\n const nextPage = pageList[pageList.indexOf(page) + 1];\n assert(nextPage);\n page = nextPage;\n segmentIndex = 0;\n }\n return { page, segmentIndex };\n };\n /** Finds the end position of a packet given the start position of the following packet. */\n const findPreviousPacketEndPosition = (pageList, startPage, startSegmentIndex) => {\n if (startSegmentIndex > 0) {\n // Easy\n return { page: startPage, segmentIndex: startSegmentIndex - 1 };\n }\n const previousPage = findLast(pageList, x => x.headerStartPos < startPage.headerStartPos);\n if (!previousPage) {\n return null;\n }\n return { page: previousPage, segmentIndex: previousPage.lacingValues.length - 1 };\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n var WaveFormat;\n (function (WaveFormat) {\n WaveFormat[WaveFormat[\"PCM\"] = 1] = \"PCM\";\n WaveFormat[WaveFormat[\"IEEE_FLOAT\"] = 3] = \"IEEE_FLOAT\";\n WaveFormat[WaveFormat[\"ALAW\"] = 6] = \"ALAW\";\n WaveFormat[WaveFormat[\"MULAW\"] = 7] = \"MULAW\";\n WaveFormat[WaveFormat[\"EXTENSIBLE\"] = 65534] = \"EXTENSIBLE\";\n })(WaveFormat || (WaveFormat = {}));\n class WaveDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.metadataPromise = null;\n this.dataStart = -1;\n this.dataSize = -1;\n this.audioInfo = null;\n this.trackBackings = [];\n this.lastKnownPacketIndex = 0;\n this.metadataTags = {};\n this.reader = input._reader;\n }\n async readMetadata() {\n return this.metadataPromise ??= (async () => {\n let slice = this.reader.requestSlice(0, 12);\n if (slice instanceof Promise)\n slice = await slice;\n assert(slice);\n const riffType = readAscii(slice, 4);\n const littleEndian = riffType !== 'RIFX';\n const isRf64 = riffType === 'RF64';\n const outerChunkSize = readU32(slice, littleEndian);\n let totalFileSize = isRf64\n ? this.reader.fileSize\n : Math.min(outerChunkSize + 8, this.reader.fileSize ?? Infinity);\n const format = readAscii(slice, 4);\n if (format !== 'WAVE') {\n throw new Error('Invalid WAVE file - wrong format');\n }\n let chunksRead = 0;\n let dataChunkSize = null;\n let currentPos = slice.filePos;\n while (totalFileSize === null || currentPos < totalFileSize) {\n let slice = this.reader.requestSlice(currentPos, 8);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const chunkId = readAscii(slice, 4);\n const chunkSize = readU32(slice, littleEndian);\n const startPos = slice.filePos;\n if (isRf64 && chunksRead === 0 && chunkId !== 'ds64') {\n throw new Error('Invalid RF64 file: First chunk must be \"ds64\".');\n }\n if (chunkId === 'fmt ') {\n await this.parseFmtChunk(startPos, chunkSize, littleEndian);\n }\n else if (chunkId === 'data') {\n dataChunkSize ??= chunkSize;\n this.dataStart = slice.filePos;\n this.dataSize = Math.min(dataChunkSize, (totalFileSize ?? Infinity) - this.dataStart);\n if (this.reader.fileSize === null) {\n break; // Stop once we hit the data chunk\n }\n }\n else if (chunkId === 'ds64') {\n // File and data chunk sizes are defined in here instead\n let ds64Slice = this.reader.requestSlice(startPos, chunkSize);\n if (ds64Slice instanceof Promise)\n ds64Slice = await ds64Slice;\n if (!ds64Slice)\n break;\n const riffChunkSize = readU64(ds64Slice, littleEndian);\n dataChunkSize = readU64(ds64Slice, littleEndian);\n totalFileSize = Math.min(riffChunkSize + 8, this.reader.fileSize ?? Infinity);\n }\n else if (chunkId === 'LIST') {\n await this.parseListChunk(startPos, chunkSize, littleEndian);\n }\n else if (chunkId === 'ID3 ' || chunkId === 'id3 ') {\n await this.parseId3Chunk(startPos, chunkSize);\n }\n currentPos = startPos + chunkSize + (chunkSize & 1); // Handle padding\n chunksRead++;\n }\n if (!this.audioInfo) {\n throw new Error('Invalid WAVE file - missing \"fmt \" chunk');\n }\n if (this.dataStart === -1) {\n throw new Error('Invalid WAVE file - missing \"data\" chunk');\n }\n const blockSize = this.audioInfo.blockSizeInBytes;\n this.dataSize = Math.floor(this.dataSize / blockSize) * blockSize;\n this.trackBackings.push(new WaveAudioTrackBacking(this));\n })();\n }\n async parseFmtChunk(startPos, size, littleEndian) {\n let slice = this.reader.requestSlice(startPos, size);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return; // File too short\n let formatTag = readU16(slice, littleEndian);\n const numChannels = readU16(slice, littleEndian);\n const sampleRate = readU32(slice, littleEndian);\n slice.skip(4); // Bytes per second\n const blockAlign = readU16(slice, littleEndian);\n let bitsPerSample;\n if (size === 14) { // Plain WAVEFORMAT\n bitsPerSample = 8;\n }\n else {\n bitsPerSample = readU16(slice, littleEndian);\n }\n // Handle WAVEFORMATEXTENSIBLE\n if (size >= 18 && formatTag !== 0x0165) {\n const cbSize = readU16(slice, littleEndian);\n const remainingSize = size - 18;\n const extensionSize = Math.min(remainingSize, cbSize);\n if (extensionSize >= 22 && formatTag === WaveFormat.EXTENSIBLE) {\n // Parse WAVEFORMATEXTENSIBLE\n slice.skip(2 + 4);\n const subFormat = readBytes(slice, 16);\n // Get actual format from subFormat GUID\n formatTag = subFormat[0] | (subFormat[1] << 8);\n }\n }\n if (formatTag === WaveFormat.MULAW || formatTag === WaveFormat.ALAW) {\n bitsPerSample = 8;\n }\n if (formatTag !== WaveFormat.PCM\n && formatTag !== WaveFormat.IEEE_FLOAT\n && formatTag !== WaveFormat.ALAW\n && formatTag !== WaveFormat.MULAW) {\n throw new Error(`Unsupported WAVE codec (format tag ${formatTag}). Only integer/float PCM, A-law, and μ-law are`\n + ` supported.`);\n }\n if (formatTag === WaveFormat.PCM && ![8, 16, 24, 32].includes(bitsPerSample)) {\n throw new Error(`Unsupported WAVE PCM bit depth (${bitsPerSample}). Only 8, 16, 24, and 32 bits are supported.`);\n }\n if (formatTag === WaveFormat.IEEE_FLOAT && ![32, 64].includes(bitsPerSample)) {\n throw new Error(`Unsupported WAVE float bit depth (${bitsPerSample}). Only 32 and 64 bits are supported.`);\n }\n this.audioInfo = {\n format: formatTag,\n numberOfChannels: numChannels,\n sampleRate,\n sampleSizeInBytes: Math.ceil(bitsPerSample / 8),\n blockSizeInBytes: blockAlign,\n };\n }\n async parseListChunk(startPos, size, littleEndian) {\n let slice = this.reader.requestSlice(startPos, size);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return; // File too short\n const infoType = readAscii(slice, 4);\n if (infoType !== 'INFO' && infoType !== 'INF0') { // exiftool.org claims INF0 can happen\n return; // Not an INFO chunk\n }\n let currentPos = slice.filePos;\n while (currentPos <= startPos + size - 8) {\n slice.filePos = currentPos;\n const chunkName = readAscii(slice, 4);\n const chunkSize = readU32(slice, littleEndian);\n const bytes = readBytes(slice, chunkSize);\n let stringLength = 0;\n for (let i = 0; i < bytes.length; i++) {\n if (bytes[i] === 0) {\n break;\n }\n stringLength++;\n }\n const value = String.fromCharCode(...bytes.subarray(0, stringLength));\n this.metadataTags.raw ??= {};\n this.metadataTags.raw[chunkName] = value;\n switch (chunkName) {\n case 'INAM':\n case 'TITL':\n {\n this.metadataTags.title ??= value;\n }\n break;\n case 'TIT3':\n {\n this.metadataTags.description ??= value;\n }\n break;\n case 'IART':\n {\n this.metadataTags.artist ??= value;\n }\n break;\n case 'IPRD':\n {\n this.metadataTags.album ??= value;\n }\n break;\n case 'IPRT':\n case 'ITRK':\n case 'TRCK':\n {\n const parts = value.split('/');\n const trackNum = Number.parseInt(parts[0], 10);\n const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(trackNum) && trackNum > 0) {\n this.metadataTags.trackNumber ??= trackNum;\n }\n if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {\n this.metadataTags.tracksTotal ??= tracksTotal;\n }\n }\n break;\n case 'ICRD':\n case 'IDIT':\n {\n const date = new Date(value);\n if (!Number.isNaN(date.getTime())) {\n this.metadataTags.date ??= date;\n }\n }\n break;\n case 'YEAR':\n {\n const year = Number.parseInt(value, 10);\n if (Number.isInteger(year) && year > 0) {\n this.metadataTags.date ??= new Date(year, 0, 1);\n }\n }\n break;\n case 'IGNR':\n case 'GENR':\n {\n this.metadataTags.genre ??= value;\n }\n break;\n case 'ICMT':\n case 'CMNT':\n case 'COMM':\n {\n this.metadataTags.comment ??= value;\n }\n break;\n }\n currentPos += 8 + chunkSize + (chunkSize & 1); // Handle padding\n }\n }\n async parseId3Chunk(startPos, size) {\n // Parse ID3 tag embedded in WAV file (non-default, but used a lot in practice anyway)\n let slice = this.reader.requestSlice(startPos, size);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return; // File too short\n const id3V2Header = readId3V2Header(slice);\n if (id3V2Header) {\n // Clamp to the available data in case the ID3 header claims more than the WAV chunk provides\n // https://github.com/Vanilagy/mediabunny/issues/300\n const availableSize = size - ID3_V2_HEADER_SIZE;\n id3V2Header.size = Math.min(id3V2Header.size, availableSize);\n if (id3V2Header.size > 0) {\n const contentSlice = slice.slice(startPos + ID3_V2_HEADER_SIZE, id3V2Header.size);\n parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);\n }\n }\n }\n getCodec() {\n assert(this.audioInfo);\n if (this.audioInfo.format === WaveFormat.MULAW) {\n return 'ulaw';\n }\n if (this.audioInfo.format === WaveFormat.ALAW) {\n return 'alaw';\n }\n if (this.audioInfo.format === WaveFormat.PCM) {\n // All formats are little-endian\n if (this.audioInfo.sampleSizeInBytes === 1) {\n return 'pcm-u8';\n }\n else if (this.audioInfo.sampleSizeInBytes === 2) {\n return 'pcm-s16';\n }\n else if (this.audioInfo.sampleSizeInBytes === 3) {\n return 'pcm-s24';\n }\n else if (this.audioInfo.sampleSizeInBytes === 4) {\n return 'pcm-s32';\n }\n }\n if (this.audioInfo.format === WaveFormat.IEEE_FLOAT) {\n if (this.audioInfo.sampleSizeInBytes === 4) {\n return 'pcm-f32';\n }\n else if (this.audioInfo.sampleSizeInBytes === 8) {\n return 'pcm-f64';\n }\n }\n assert(false);\n }\n async getMimeType() {\n return 'audio/wav';\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.trackBackings;\n }\n async getMetadataTags() {\n await this.readMetadata();\n return this.metadataTags;\n }\n }\n const PACKET_SIZE_IN_FRAMES = 2048;\n class WaveAudioTrackBacking {\n constructor(demuxer) {\n this.demuxer = demuxer;\n }\n getType() {\n return 'audio';\n }\n getId() {\n return 1;\n }\n getNumber() {\n return 1;\n }\n getCodec() {\n return this.demuxer.getCodec();\n }\n getInternalCodecId() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.format;\n }\n async getDecoderConfig() {\n const codec = this.demuxer.getCodec();\n if (!codec) {\n return null;\n }\n assert(this.demuxer.audioInfo);\n return {\n codec,\n numberOfChannels: this.demuxer.audioInfo.numberOfChannels,\n sampleRate: this.demuxer.audioInfo.sampleRate,\n };\n }\n getNumberOfChannels() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.numberOfChannels;\n }\n getSampleRate() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.sampleRate;\n }\n getTimeResolution() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.sampleRate;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n assert(this.demuxer.dataSize !== -1);\n return this.demuxer.dataSize / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;\n }\n async getLiveRefreshInterval() {\n return null;\n }\n getName() {\n return null;\n }\n getLanguageCode() {\n return UNDETERMINED_LANGUAGE;\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n };\n }\n async getPacketAtIndex(packetIndex, options) {\n assert(packetIndex >= 0);\n assert(this.demuxer.audioInfo);\n const startOffset = packetIndex * PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes;\n if (startOffset >= this.demuxer.dataSize) {\n return null;\n }\n const sizeInBytes = Math.min(PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes, this.demuxer.dataSize - startOffset);\n if (this.demuxer.reader.fileSize === null) {\n // If the file size is unknown, we weren't able to cap the dataSize in the init logic and we instead have to\n // rely on the headers telling us how large the file is. But, these might be wrong, so let's check if the\n // requested slice actually exists.\n let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null;\n }\n }\n let data;\n if (options.metadataOnly) {\n data = PLACEHOLDER_DATA;\n }\n else {\n let slice = this.demuxer.reader.requestSlice(this.demuxer.dataStart + startOffset, sizeInBytes);\n if (slice instanceof Promise)\n slice = await slice;\n assert(slice);\n data = readBytes(slice, sizeInBytes);\n }\n const timestamp = packetIndex * PACKET_SIZE_IN_FRAMES / this.demuxer.audioInfo.sampleRate;\n const duration = sizeInBytes / this.demuxer.audioInfo.blockSizeInBytes / this.demuxer.audioInfo.sampleRate;\n this.demuxer.lastKnownPacketIndex = Math.max(packetIndex, this.demuxer.lastKnownPacketIndex);\n return new EncodedPacket(data, 'key', timestamp, duration, packetIndex, sizeInBytes);\n }\n getFirstPacket(options) {\n return this.getPacketAtIndex(0, options);\n }\n async getPacket(timestamp, options) {\n assert(this.demuxer.audioInfo);\n const packetIndex = Math.floor(Math.min(timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES, (this.demuxer.dataSize - 1) / (PACKET_SIZE_IN_FRAMES * this.demuxer.audioInfo.blockSizeInBytes)));\n if (packetIndex < 0) {\n return null;\n }\n const packet = await this.getPacketAtIndex(packetIndex, options);\n if (packet) {\n return packet;\n }\n if (packetIndex === 0) {\n return null; // Empty data chunk\n }\n assert(this.demuxer.reader.fileSize === null);\n // The file is shorter than we thought, meaning the packet we were looking for doesn't exist. So, let's find\n // the last packet by doing a sequential scan, instead.\n let currentPacket = await this.getPacketAtIndex(this.demuxer.lastKnownPacketIndex, options);\n while (currentPacket) {\n const nextPacket = await this.getNextPacket(currentPacket, options);\n if (!nextPacket) {\n break;\n }\n currentPacket = nextPacket;\n }\n return currentPacket;\n }\n getNextPacket(packet, options) {\n assert(this.demuxer.audioInfo);\n const packetIndex = Math.round(packet.timestamp * this.demuxer.audioInfo.sampleRate / PACKET_SIZE_IN_FRAMES);\n return this.getPacketAtIndex(packetIndex + 1, options);\n }\n getKeyPacket(timestamp, options) {\n return this.getPacket(timestamp, options);\n }\n getNextKeyPacket(packet, options) {\n return this.getNextPacket(packet, options);\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const MIN_ADTS_FRAME_HEADER_SIZE = 7;\n const MAX_ADTS_FRAME_HEADER_SIZE = 9;\n const readAdtsFrameHeader = (slice) => {\n // https://wiki.multimedia.cx/index.php/ADTS (last visited: 2025/08/17)\n const startPos = slice.filePos;\n const bytes = readBytes(slice, 9); // 9 with CRC, 7 without CRC\n const bitstream = new Bitstream(bytes);\n const syncword = bitstream.readBits(12);\n if (syncword !== 0b1111_11111111) {\n return null;\n }\n bitstream.skipBits(1); // MPEG version\n const layer = bitstream.readBits(2);\n if (layer !== 0) {\n return null;\n }\n const protectionAbsence = bitstream.readBits(1);\n const objectType = bitstream.readBits(2) + 1;\n const samplingFrequencyIndex = bitstream.readBits(4);\n if (samplingFrequencyIndex === 15) {\n return null;\n }\n bitstream.skipBits(1); // Private bit\n const channelConfiguration = bitstream.readBits(3);\n if (channelConfiguration === 0) {\n throw new Error('ADTS frames with channel configuration 0 are not supported.');\n }\n bitstream.skipBits(1); // Originality\n bitstream.skipBits(1); // Home\n bitstream.skipBits(1); // Copyright ID bit\n bitstream.skipBits(1); // Copyright ID start\n const frameLength = bitstream.readBits(13);\n bitstream.skipBits(11); // Buffer fullness\n const numberOfAacFrames = bitstream.readBits(2) + 1;\n if (numberOfAacFrames !== 1) {\n throw new Error('ADTS frames with more than one AAC frame are not supported.');\n }\n let crcCheck = null;\n if (protectionAbsence === 1) { // No CRC\n slice.filePos -= 2;\n }\n else { // CRC\n crcCheck = bitstream.readBits(16);\n }\n return {\n objectType,\n samplingFrequencyIndex,\n channelConfiguration,\n frameLength,\n numberOfAacFrames,\n crcCheck,\n startPos,\n };\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const SAMPLES_PER_AAC_FRAME = 1024;\n class AdtsDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.metadataPromise = null;\n this.firstFrameHeader = null;\n this.loadedSamples = [];\n this.metadataTags = null;\n this.trackBackings = [];\n this.readingMutex = new AsyncMutex();\n this.lastSampleLoaded = false;\n this.lastLoadedPos = 0;\n this.nextTimestampInSamples = 0;\n this.reader = input._reader;\n }\n async readMetadata() {\n return this.metadataPromise ??= (async () => {\n // Keep loading until we find the first frame header\n while (!this.firstFrameHeader && !this.lastSampleLoaded) {\n await this.advanceReader();\n }\n // There has to be a frame if this demuxer got selected\n assert(this.firstFrameHeader);\n // Create the single audio track\n this.trackBackings = [new AdtsAudioTrackBacking(this)];\n })();\n }\n async advanceReader() {\n if (this.lastLoadedPos === 0) {\n // Skip all ID3v2 tags at the start of the file\n while (true) {\n let slice = this.reader.requestSlice(this.lastLoadedPos, ID3_V2_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n this.lastSampleLoaded = true;\n return;\n }\n const id3V2Header = readId3V2Header(slice);\n if (!id3V2Header) {\n break;\n }\n this.lastLoadedPos = slice.filePos + id3V2Header.size;\n }\n }\n let slice = this.reader.requestSliceRange(this.lastLoadedPos, MIN_ADTS_FRAME_HEADER_SIZE, MAX_ADTS_FRAME_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n this.lastSampleLoaded = true;\n return;\n }\n const header = readAdtsFrameHeader(slice);\n if (!header) {\n this.lastSampleLoaded = true;\n return;\n }\n if (this.reader.fileSize !== null && header.startPos + header.frameLength > this.reader.fileSize) {\n // Frame doesn't fit in the rest of the file\n this.lastSampleLoaded = true;\n return;\n }\n if (!this.firstFrameHeader) {\n this.firstFrameHeader = header;\n }\n const sampleRate = aacFrequencyTable[header.samplingFrequencyIndex];\n assert(sampleRate !== undefined);\n const sampleDuration = SAMPLES_PER_AAC_FRAME / sampleRate;\n const sample = {\n timestamp: this.nextTimestampInSamples / sampleRate,\n duration: sampleDuration,\n dataStart: header.startPos,\n dataSize: header.frameLength,\n };\n this.loadedSamples.push(sample);\n this.nextTimestampInSamples += SAMPLES_PER_AAC_FRAME;\n this.lastLoadedPos = header.startPos + header.frameLength;\n }\n async getMimeType() {\n return 'audio/aac';\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.trackBackings;\n }\n async getMetadataTags() {\n const release = await this.readingMutex.acquire();\n try {\n await this.readMetadata();\n if (this.metadataTags) {\n return this.metadataTags;\n }\n this.metadataTags = {};\n let currentPos = 0;\n while (true) {\n let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n if (!headerSlice)\n break;\n const id3V2Header = readId3V2Header(headerSlice);\n if (!id3V2Header) {\n break;\n }\n let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);\n if (contentSlice instanceof Promise)\n contentSlice = await contentSlice;\n if (!contentSlice)\n break;\n parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);\n currentPos = headerSlice.filePos + id3V2Header.size;\n }\n return this.metadataTags;\n }\n finally {\n release();\n }\n }\n }\n class AdtsAudioTrackBacking {\n constructor(demuxer) {\n this.demuxer = demuxer;\n }\n getType() {\n return 'audio';\n }\n getId() {\n return 1;\n }\n getNumber() {\n return 1;\n }\n getTimeResolution() {\n const sampleRate = this.getSampleRate();\n return sampleRate / SAMPLES_PER_AAC_FRAME;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n return null; // No way\n }\n async getLiveRefreshInterval() {\n return null;\n }\n getName() {\n return null;\n }\n getLanguageCode() {\n return UNDETERMINED_LANGUAGE;\n }\n getCodec() {\n return 'aac';\n }\n getInternalCodecId() {\n assert(this.demuxer.firstFrameHeader);\n return this.demuxer.firstFrameHeader.objectType;\n }\n getNumberOfChannels() {\n assert(this.demuxer.firstFrameHeader);\n const numberOfChannels = aacChannelMap[this.demuxer.firstFrameHeader.channelConfiguration];\n assert(numberOfChannels !== undefined);\n return numberOfChannels;\n }\n getSampleRate() {\n assert(this.demuxer.firstFrameHeader);\n const sampleRate = aacFrequencyTable[this.demuxer.firstFrameHeader.samplingFrequencyIndex];\n assert(sampleRate !== undefined);\n return sampleRate;\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n };\n }\n async getDecoderConfig() {\n assert(this.demuxer.firstFrameHeader);\n return {\n codec: `mp4a.40.${this.demuxer.firstFrameHeader.objectType}`,\n numberOfChannels: this.getNumberOfChannels(),\n sampleRate: this.getSampleRate(),\n };\n }\n async getPacketAtIndex(sampleIndex, options) {\n if (sampleIndex === -1) {\n return null;\n }\n const rawSample = this.demuxer.loadedSamples[sampleIndex];\n if (!rawSample) {\n return null;\n }\n let data;\n if (options.metadataOnly) {\n data = PLACEHOLDER_DATA;\n }\n else {\n let slice = this.demuxer.reader.requestSlice(rawSample.dataStart, rawSample.dataSize);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null; // Data didn't fit into the rest of the file\n }\n data = readBytes(slice, rawSample.dataSize);\n }\n return new EncodedPacket(data, 'key', rawSample.timestamp, rawSample.duration, sampleIndex, rawSample.dataSize);\n }\n getFirstPacket(options) {\n return this.getPacketAtIndex(0, options);\n }\n async getNextPacket(packet, options) {\n const release = await this.demuxer.readingMutex.acquire();\n try {\n const sampleIndex = binarySearchExact(this.demuxer.loadedSamples, packet.timestamp, x => x.timestamp);\n if (sampleIndex === -1) {\n throw new Error('Packet was not created from this track.');\n }\n const nextIndex = sampleIndex + 1;\n // Ensure the next sample exists\n while (nextIndex >= this.demuxer.loadedSamples.length\n && !this.demuxer.lastSampleLoaded) {\n await this.demuxer.advanceReader();\n }\n return this.getPacketAtIndex(nextIndex, options);\n }\n finally {\n release();\n }\n }\n async getPacket(timestamp, options) {\n const release = await this.demuxer.readingMutex.acquire();\n try {\n while (true) {\n const index = binarySearchLessOrEqual(this.demuxer.loadedSamples, timestamp, x => x.timestamp);\n if (index === -1 && this.demuxer.loadedSamples.length > 0) {\n // We're before the first sample\n return null;\n }\n if (this.demuxer.lastSampleLoaded) {\n // All data is loaded, return what we found\n return this.getPacketAtIndex(index, options);\n }\n if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {\n // The next packet also exists, we're done\n return this.getPacketAtIndex(index, options);\n }\n // Otherwise, keep loading data\n await this.demuxer.advanceReader();\n }\n }\n finally {\n release();\n }\n }\n getKeyPacket(timestamp, options) {\n return this.getPacket(timestamp, options);\n }\n getNextKeyPacket(packet, options) {\n return this.getNextPacket(packet, options);\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-block-size-bits\n const getBlockSizeOrUncommon = (bits) => {\n if (bits === 0b0000) {\n return null;\n }\n else if (bits === 0b0001) {\n return 192;\n }\n else if (bits >= 0b0010 && bits <= 0b0101) {\n return 144 * 2 ** bits;\n }\n else if (bits === 0b0110) {\n return 'uncommon-u8';\n }\n else if (bits === 0b0111) {\n return 'uncommon-u16';\n }\n else if (bits >= 0b1000 && bits <= 0b1111) {\n return 2 ** bits;\n }\n else {\n return null;\n }\n };\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-sample-rate-bits\n const getSampleRateOrUncommon = (sampleRateBits, streamInfoSampleRate) => {\n switch (sampleRateBits) {\n case 0b0000: return streamInfoSampleRate;\n case 0b0001: return 88200;\n case 0b0010: return 176400;\n case 0b0011: return 192000;\n case 0b0100: return 8000;\n case 0b0101: return 16000;\n case 0b0110: return 22050;\n case 0b0111: return 24000;\n case 0b1000: return 32000;\n case 0b1001: return 44100;\n case 0b1010: return 48000;\n case 0b1011: return 96000;\n case 0b1100: return 'uncommon-u8';\n case 0b1101: return 'uncommon-u16';\n case 0b1110: return 'uncommon-u16-10';\n default: return null;\n }\n };\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-coded-number\n const readCodedNumber = (fileSlice) => {\n let ones = 0;\n const bitstream1 = new Bitstream(readBytes(fileSlice, 1));\n while (bitstream1.readBits(1) === 1) {\n ones++;\n }\n if (ones === 0) {\n return bitstream1.readBits(7);\n }\n const bitArray = [];\n const extraBytes = ones - 1;\n const bitstream2 = new Bitstream(readBytes(fileSlice, extraBytes));\n const firstByteBits = 8 - ones - 1;\n for (let i = 0; i < firstByteBits; i++) {\n bitArray.unshift(bitstream1.readBits(1));\n }\n for (let i = 0; i < extraBytes; i++) {\n for (let j = 0; j < 8; j++) {\n const val = bitstream2.readBits(1);\n if (j < 2) {\n continue;\n }\n bitArray.unshift(val);\n }\n }\n const encoded = bitArray.reduce((acc, bit, index) => {\n return acc | (bit << index);\n }, 0);\n return encoded;\n };\n const readBlockSize = (slice, blockSizeBits) => {\n if (blockSizeBits === 'uncommon-u16') {\n return readU16Be(slice) + 1;\n }\n else if (blockSizeBits === 'uncommon-u8') {\n return readU8(slice) + 1;\n }\n else if (typeof blockSizeBits === 'number') {\n return blockSizeBits;\n }\n else {\n assertNever(blockSizeBits);\n assert(false);\n }\n };\n const readSampleRate = (slice, sampleRateOrUncommon) => {\n if (sampleRateOrUncommon === 'uncommon-u16') {\n return readU16Be(slice);\n }\n if (sampleRateOrUncommon === 'uncommon-u16-10') {\n return readU16Be(slice) * 10;\n }\n if (sampleRateOrUncommon === 'uncommon-u8') {\n return readU8(slice);\n }\n if (typeof sampleRateOrUncommon === 'number') {\n return sampleRateOrUncommon;\n }\n return null;\n };\n // https://www.rfc-editor.org/rfc/rfc9639.html#section-9.1.1\n const calculateCrc8 = (data) => {\n const polynomial = 0x07; // x^8 + x^2 + x^1 + x^0\n let crc = 0x00; // Initialize CRC to 0\n for (const byte of data) {\n crc ^= byte; // XOR byte into least significant byte of crc\n for (let i = 0; i < 8; i++) {\n // For each bit in the byte\n if ((crc & 0x80) !== 0) {\n // If the leftmost bit (MSB) is set\n crc = (crc << 1) ^ polynomial; // Shift left and XOR with polynomial\n }\n else {\n crc <<= 1; // Just shift left\n }\n crc &= 0xff; // Ensure CRC remains 8-bit\n }\n }\n return crc;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class FlacDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.loadedSamples = []; // All samples from the start of the file to lastLoadedPos\n this.metadataPromise = null;\n this.trackBacking = null;\n this.metadataTags = {};\n this.audioInfo = null;\n this.lastLoadedPos = null;\n this.blockingBit = null;\n this.readingMutex = new AsyncMutex();\n this.lastSampleLoaded = false;\n this.reader = input._reader;\n }\n async getMetadataTags() {\n await this.readMetadata();\n return this.metadataTags;\n }\n async getTrackBackings() {\n await this.readMetadata();\n assert(this.trackBacking);\n return [this.trackBacking];\n }\n async getMimeType() {\n return 'audio/flac';\n }\n async readMetadata() {\n return (this.metadataPromise ??= (async () => {\n // Read all ID3v2 tags at the start of the file\n let currentPos = 0;\n while (true) {\n let headerSlice = this.reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n if (!headerSlice) {\n this.lastSampleLoaded = true;\n return;\n }\n const id3V2Header = readId3V2Header(headerSlice);\n if (!id3V2Header) {\n break;\n }\n let contentSlice = this.reader.requestSlice(headerSlice.filePos, id3V2Header.size);\n if (contentSlice instanceof Promise)\n contentSlice = await contentSlice;\n assert(contentSlice);\n parseId3V2Tag(contentSlice, id3V2Header, this.metadataTags);\n currentPos = headerSlice.filePos + id3V2Header.size;\n }\n currentPos += 4; // Skip 'fLaC'\n while (this.reader.fileSize === null\n || currentPos < this.reader.fileSize) {\n let sizeSlice = this.reader.requestSlice(currentPos, 4);\n if (sizeSlice instanceof Promise)\n sizeSlice = await sizeSlice;\n currentPos += 4;\n if (sizeSlice === null) {\n throw new Error(`Metadata block at position ${currentPos} is too small! Corrupted file.`);\n }\n assert(sizeSlice);\n const byte = readU8(sizeSlice); // first bit: isLastMetadata, remaining 7 bits: metaBlockType\n const size = readU24Be(sizeSlice);\n const isLastMetadata = (byte & 0x80) !== 0;\n const metaBlockType = byte & 0x7f;\n switch (metaBlockType) {\n case FlacBlockType.STREAMINFO: {\n // Parse streaminfo block\n // https://www.rfc-editor.org/rfc/rfc9639.html#section-8.2\n let streamInfoBlock = this.reader.requestSlice(currentPos, size);\n if (streamInfoBlock instanceof Promise)\n streamInfoBlock = await streamInfoBlock;\n assert(streamInfoBlock);\n if (streamInfoBlock === null) {\n throw new Error(`StreamInfo block at position ${currentPos} is too small! Corrupted file.`);\n }\n const streamInfoBytes = readBytes(streamInfoBlock, 34);\n const bitstream = new Bitstream(streamInfoBytes);\n const minimumBlockSize = bitstream.readBits(16);\n const maximumBlockSize = bitstream.readBits(16);\n const minimumFrameSize = bitstream.readBits(24);\n const maximumFrameSize = bitstream.readBits(24);\n const sampleRate = bitstream.readBits(20);\n const numberOfChannels = bitstream.readBits(3) + 1;\n bitstream.readBits(5); // bitsPerSample - 1\n const totalSamples = bitstream.readBits(36);\n // https://www.w3.org/TR/webcodecs-flac-codec-registration/#audiodecoderconfig-description\n // description is required, and has to be the following:\n // 1. The bytes 0x66 0x4C 0x61 0x43 (\"fLaC\" in ASCII)\n // 2. A metadata block (called the STREAMINFO block) as described in section 7 of [FLAC]\n // 3. Optionally (sic) other metadata blocks, that are not used by the specification\n bitstream.skipBits(16 * 8); // md5 hash\n const description = new Uint8Array(42);\n // 1. \"fLaC\"\n description.set(new Uint8Array([0x66, 0x4c, 0x61, 0x43]), 0);\n // 2. STREAMINFO block\n description.set(new Uint8Array([128, 0, 0, 34]), 4);\n // 3. Other metadata blocks\n description.set(streamInfoBytes, 8);\n this.audioInfo = {\n numberOfChannels,\n sampleRate,\n totalSamples,\n minimumBlockSize,\n maximumBlockSize,\n minimumFrameSize,\n maximumFrameSize,\n description,\n };\n this.trackBacking = new FlacAudioTrackBacking(this);\n break;\n }\n case FlacBlockType.VORBIS_COMMENT: {\n // Parse vorbis comment block\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-vorbis-comment\n let vorbisCommentBlock = this.reader.requestSlice(currentPos, size);\n if (vorbisCommentBlock instanceof Promise)\n vorbisCommentBlock = await vorbisCommentBlock;\n assert(vorbisCommentBlock);\n readVorbisComments(readBytes(vorbisCommentBlock, size), this.metadataTags);\n break;\n }\n case FlacBlockType.PICTURE: {\n // Parse picture block\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-picture\n let pictureBlock = this.reader.requestSlice(currentPos, size);\n if (pictureBlock instanceof Promise)\n pictureBlock = await pictureBlock;\n assert(pictureBlock);\n const pictureType = readU32Be(pictureBlock);\n const mediaTypeLength = readU32Be(pictureBlock);\n const mediaType = textDecoder.decode(readBytes(pictureBlock, mediaTypeLength));\n const descriptionLength = readU32Be(pictureBlock);\n const description = textDecoder.decode(readBytes(pictureBlock, descriptionLength));\n pictureBlock.skip(4 + 4 + 4 + 4); // Skip width, height, color depth, number of indexed colors\n const dataLength = readU32Be(pictureBlock);\n const data = readBytes(pictureBlock, dataLength);\n this.metadataTags.images ??= [];\n this.metadataTags.images.push({\n data,\n mimeType: mediaType,\n // https://www.rfc-editor.org/rfc/rfc9639.html#table13\n kind: pictureType === 3\n ? 'coverFront'\n : pictureType === 4\n ? 'coverBack'\n : 'unknown',\n description,\n });\n break;\n }\n }\n currentPos += size;\n if (isLastMetadata) {\n this.lastLoadedPos = currentPos;\n break;\n }\n }\n if (!this.audioInfo) {\n throw new Error('Missing STREAMINFO metadata block! Corrupted FLAC file.');\n }\n })());\n }\n async readNextFlacFrame({ startPos, isFirstPacket, }) {\n assert(this.audioInfo);\n // we expect that there are at least `minimumFrameSize` bytes left in the file\n // Ideally we also want to validate the next header is valid\n // to throw out an accidental sync word\n // The shortest valid FLAC header I can think of, based off the code\n // of readFlacFrameHeader:\n // 4 bytes used for bitstream from syncword to bit depth\n // 1 byte coded number\n // (uncommon values, no bytes read)\n // 1 byte crc\n // --> 6 bytes\n const minimumHeaderLength = 6;\n // If we read everything in readFlacFrameHeader, we read 16 bytes\n const maximumHeaderLength = 16;\n // The shortest valid FLAC frame per RFC 9639:\n // 6 bytes header (see minimumHeaderLength above)\n // 2 bytes subframe (constant subframe with minimum bit depth,\n // padded to byte boundary)\n // 2 bytes footer (CRC-16)\n // --> 10 bytes\n const minimumFrameLength = 10;\n // The longest valid FLAC frame per RFC 9639:\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-prediction\n // https://www.rfc-editor.org/rfc/rfc9639.html#name-frame-structure\n // maximumBlockSize * numberOfChannels * 4 bytes (max 32 bps verbatim)\n // + 16 bytes header (see maximumHeaderSize above)\n // + 2 bytes footer (CRC-16)\n const maximumFrameLength = this.audioInfo.maximumBlockSize\n * this.audioInfo.numberOfChannels\n * 4\n + maximumHeaderLength\n + 2;\n // Per RFC 9639, a value of 0 means \"unknown\" for frame sizes.\n const effectiveMinFrameSize = this.audioInfo.minimumFrameSize || minimumFrameLength;\n const effectiveMaxFrameSize = this.audioInfo.maximumFrameSize || maximumFrameLength;\n const maximumSliceLength = effectiveMaxFrameSize + maximumHeaderLength;\n const slice = await this.reader.requestSliceRange(startPos, maximumHeaderLength, maximumSliceLength);\n if (!slice) {\n return null;\n }\n const frameHeader = this.readFlacFrameHeader({\n slice,\n isFirstPacket: isFirstPacket,\n });\n if (!frameHeader) {\n return null;\n }\n // We don't know exactly how long the packet is, we only know the `minimumFrameSize` and `maximumFrameSize`\n // The packet is over if the next 2 bytes are the sync word followed by a valid header\n // or the end of the file is reached\n // The next sync word is expected at earliest when `minimumFrameSize` is reached,\n // we can skip over anything before that\n slice.filePos = startPos + effectiveMinFrameSize;\n while (true) {\n // Reached end of the file, packet is over\n if (slice.filePos > slice.end - minimumHeaderLength) {\n return {\n num: frameHeader.num,\n blockSize: frameHeader.blockSize,\n sampleRate: frameHeader.sampleRate,\n size: slice.end - startPos,\n isLastFrame: true,\n };\n }\n const nextByte = readU8(slice);\n if (nextByte === 0xff) {\n const positionBeforeReading = slice.filePos;\n const byteAfterNextByte = readU8(slice);\n const expected = this.blockingBit === 1 ? 0b1111_1001 : 0b1111_1000;\n if (byteAfterNextByte !== expected) {\n slice.filePos = positionBeforeReading;\n continue;\n }\n slice.skip(-2);\n const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos;\n const nextFrameHeader = this.readFlacFrameHeader({\n slice,\n isFirstPacket: false,\n });\n if (!nextFrameHeader) {\n slice.filePos = positionBeforeReading;\n continue;\n }\n // Ensure the frameOrSampleNum is consecutive.\n // https://github.com/Vanilagy/mediabunny/issues/194\n if (this.blockingBit === 0) {\n // Case A: If the stream is fixed block size, this is the frame number, which increments by 1\n if (nextFrameHeader.num - frameHeader.num !== 1) {\n slice.filePos = positionBeforeReading;\n continue;\n }\n }\n else {\n // Case B: If the stream is variable block size, this is the sample number, which increments by\n // amount of samples in a frame.\n if (nextFrameHeader.num - frameHeader.num !== frameHeader.blockSize) {\n slice.filePos = positionBeforeReading;\n continue;\n }\n }\n return {\n num: frameHeader.num,\n blockSize: frameHeader.blockSize,\n sampleRate: frameHeader.sampleRate,\n size: lengthIfNextFlacFrameHeaderIsLegit,\n isLastFrame: false,\n };\n }\n }\n }\n readFlacFrameHeader({ slice, isFirstPacket, }) {\n // In this function, generally it is not safe to throw errors.\n // We might end up here because we stumbled upon a syncword,\n // but the data might not actually be a FLAC frame, it might be random bitstream\n // data, in that case we should return null and continue.\n const startOffset = slice.filePos;\n // https://www.rfc-editor.org/rfc/rfc9639.html#section-9.1\n // Each frame MUST start on a byte boundary and start with the 15-bit frame\n // sync code 0b111111111111100. Following the sync code is the blocking strategy\n // bit, which MUST NOT change during the audio stream.\n const bytes = readBytes(slice, 4);\n const bitstream = new Bitstream(bytes);\n const bits = bitstream.readBits(15);\n if (bits !== 0b111111111111100) {\n // This cannot be a valid FLAC frame, must start with the syncword\n return null;\n }\n if (this.blockingBit === null) {\n assert(isFirstPacket);\n const newBlockingBit = bitstream.readBits(1);\n this.blockingBit = newBlockingBit;\n }\n else if (this.blockingBit === 1) {\n assert(!isFirstPacket);\n const newBlockingBit = bitstream.readBits(1);\n if (newBlockingBit !== 1) {\n // This cannot be a valid FLAC frame, expected 1 but got 0\n return null;\n }\n }\n else if (this.blockingBit === 0) {\n assert(!isFirstPacket);\n const newBlockingBit = bitstream.readBits(1);\n if (newBlockingBit !== 0) {\n // This cannot be a valid FLAC frame, expected 0 but got 1\n return null;\n }\n }\n else {\n throw new Error('Invalid blocking bit');\n }\n const blockSizeOrUncommon = getBlockSizeOrUncommon(bitstream.readBits(4));\n if (!blockSizeOrUncommon) {\n // This cannot be a valid FLAC frame, the syncword was just coincidental\n return null;\n }\n assert(this.audioInfo);\n const sampleRateOrUncommon = getSampleRateOrUncommon(bitstream.readBits(4), this.audioInfo.sampleRate);\n if (!sampleRateOrUncommon) {\n // This cannot be a valid FLAC frame, the syncword was just coincidental\n return null;\n }\n bitstream.readBits(4); // channel count\n bitstream.readBits(3); // bit depth\n const reservedZero = bitstream.readBits(1); // reserved zero\n if (reservedZero !== 0) {\n // This cannot be a valid FLAC frame, the syncword was just coincidental\n return null;\n }\n const num = readCodedNumber(slice);\n const blockSize = readBlockSize(slice, blockSizeOrUncommon);\n const sampleRate = readSampleRate(slice, sampleRateOrUncommon);\n if (sampleRate === null) {\n // This cannot be a valid FLAC frame, the syncword was just coincidental\n return null;\n }\n if (sampleRate !== this.audioInfo.sampleRate) {\n // This cannot be a valid FLAC frame, the sample rate is not the same as in the stream info\n return null;\n }\n const size = slice.filePos - startOffset;\n const crc = readU8(slice);\n slice.skip(-size);\n slice.skip(-1);\n const crcCalculated = calculateCrc8(readBytes(slice, size));\n if (crc !== crcCalculated) {\n // Maybe this wasn't a FLAC frame at all, the syncword was just coincidentally\n // in the bitstream\n return null;\n }\n return { num, blockSize, sampleRate };\n }\n async advanceReader() {\n await this.readMetadata();\n assert(this.lastLoadedPos !== null);\n assert(this.audioInfo);\n const startPos = this.lastLoadedPos;\n const frame = await this.readNextFlacFrame({\n startPos,\n isFirstPacket: this.loadedSamples.length === 0,\n });\n if (!frame) {\n // Unexpected case, failed to read next FLAC frame\n // handling gracefully\n this.lastSampleLoaded = true;\n return;\n }\n const lastSample = this.loadedSamples[this.loadedSamples.length - 1];\n const blockOffset = lastSample\n ? lastSample.blockOffset + lastSample.blockSize\n : 0;\n const sample = {\n blockOffset,\n blockSize: frame.blockSize,\n byteOffset: startPos,\n byteSize: frame.size,\n };\n this.lastLoadedPos = this.lastLoadedPos + frame.size;\n this.loadedSamples.push(sample);\n if (frame.isLastFrame) {\n this.lastSampleLoaded = true;\n return;\n }\n }\n }\n class FlacAudioTrackBacking {\n constructor(demuxer) {\n this.demuxer = demuxer;\n }\n getType() {\n return 'audio';\n }\n getId() {\n return 1;\n }\n getNumber() {\n return 1;\n }\n getCodec() {\n return 'flac';\n }\n getInternalCodecId() {\n return null;\n }\n getNumberOfChannels() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.numberOfChannels;\n }\n getSampleRate() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.sampleRate;\n }\n getName() {\n return null;\n }\n getLanguageCode() {\n return UNDETERMINED_LANGUAGE;\n }\n getTimeResolution() {\n assert(this.demuxer.audioInfo);\n return this.demuxer.audioInfo.sampleRate;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n assert(this.demuxer.audioInfo);\n if (this.demuxer.audioInfo.totalSamples === 0) {\n return null;\n }\n return this.demuxer.audioInfo.totalSamples / this.demuxer.audioInfo.sampleRate;\n }\n async getLiveRefreshInterval() {\n return null;\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n };\n }\n async getDecoderConfig() {\n assert(this.demuxer.audioInfo);\n return {\n codec: 'flac',\n numberOfChannels: this.demuxer.audioInfo.numberOfChannels,\n sampleRate: this.demuxer.audioInfo.sampleRate,\n description: this.demuxer.audioInfo.description,\n };\n }\n async getPacket(timestamp, options) {\n assert(this.demuxer.audioInfo);\n if (timestamp < 0) {\n return null;\n }\n const release = await this.demuxer.readingMutex.acquire();\n try {\n while (true) {\n const packetIndex = binarySearchLessOrEqual(this.demuxer.loadedSamples, timestamp, x => x.blockOffset / this.demuxer.audioInfo.sampleRate);\n if (packetIndex === -1) {\n await this.demuxer.advanceReader();\n continue;\n }\n const packet = this.demuxer.loadedSamples[packetIndex];\n const sampleTimestamp = packet.blockOffset / this.demuxer.audioInfo.sampleRate;\n const sampleDuration = packet.blockSize / this.demuxer.audioInfo.sampleRate;\n if (sampleTimestamp + sampleDuration <= timestamp) {\n if (this.demuxer.lastSampleLoaded) {\n return this.getPacketAtIndex(this.demuxer.loadedSamples.length - 1, options);\n }\n await this.demuxer.advanceReader();\n continue;\n }\n return this.getPacketAtIndex(packetIndex, options);\n }\n }\n finally {\n release();\n }\n }\n async getNextPacket(packet, options) {\n const release = await this.demuxer.readingMutex.acquire();\n try {\n const nextIndex = packet.sequenceNumber + 1;\n if (this.demuxer.lastSampleLoaded\n && nextIndex >= this.demuxer.loadedSamples.length) {\n return null;\n }\n // Ensure the next sample exists\n while (nextIndex >= this.demuxer.loadedSamples.length\n && !this.demuxer.lastSampleLoaded) {\n await this.demuxer.advanceReader();\n }\n return this.getPacketAtIndex(nextIndex, options);\n }\n finally {\n release();\n }\n }\n getKeyPacket(timestamp, options) {\n return this.getPacket(timestamp, options);\n }\n getNextKeyPacket(packet, options) {\n return this.getNextPacket(packet, options);\n }\n async getPacketAtIndex(sampleIndex, options) {\n const rawSample = this.demuxer.loadedSamples[sampleIndex];\n if (!rawSample) {\n return null;\n }\n let data;\n if (options.metadataOnly) {\n data = PLACEHOLDER_DATA;\n }\n else {\n let slice = this.demuxer.reader.requestSlice(rawSample.byteOffset, rawSample.byteSize);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null; // Data didn't fit into the rest of the file\n }\n data = readBytes(slice, rawSample.byteSize);\n }\n assert(this.demuxer.audioInfo);\n const timestamp = rawSample.blockOffset / this.demuxer.audioInfo.sampleRate;\n const duration = rawSample.blockSize / this.demuxer.audioInfo.sampleRate;\n return new EncodedPacket(data, 'key', timestamp, duration, sampleIndex, rawSample.byteSize);\n }\n async getFirstPacket(options) {\n // Ensure the next sample exists\n while (this.demuxer.loadedSamples.length === 0\n && !this.demuxer.lastSampleLoaded) {\n await this.demuxer.advanceReader();\n }\n return this.getPacketAtIndex(0, options);\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const TIMESCALE = 90_000; // MPEG-TS timestamps run on a 90 kHz clock\n const TS_PACKET_SIZE = 188;\n const buildMpegTsMimeType = (codecStrings) => {\n let string = 'video/MP2T';\n const uniqueCodecStrings = [...new Set(codecStrings.filter(Boolean))];\n if (uniqueCodecStrings.length > 0) {\n string += `; codecs=\"${uniqueCodecStrings.join(', ')}\"`;\n }\n return string;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n // Resources:\n // ISO/IEC 13818-1\n const MISSING_PTS_ERROR_MESSAGE = 'PES packet is missing PTS where it was expected. PES packets without PTS are not'\n + ' currently supported. If you think this file should be supported, please report it.';\n // Remember them so the warning doesn't get spammed\n const ignoredStreamTypes = new Set();\n class MpegTsDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.metadataPromise = null;\n this.elementaryStreams = [];\n this.trackBackingEntries = [];\n this.packetOffset = 0;\n this.packetStride = -1;\n this.sectionEndPositions = [];\n this.seekChunkSize = 5 * 1024 * 1024; // 5 MiB, picked because most HLS segments are below this size\n this.minReferencePointByteDistance = -1;\n this.reader = input._reader;\n }\n async readMetadata() {\n return this.metadataPromise ??= (async () => {\n const lengthToCheck = TS_PACKET_SIZE + 16 + 1;\n let startingSlice = this.reader.requestSlice(0, lengthToCheck);\n if (startingSlice instanceof Promise)\n startingSlice = await startingSlice;\n assert(startingSlice);\n const startingBytes = readBytes(startingSlice, lengthToCheck);\n if (startingBytes[0] === 0x47 && startingBytes[TS_PACKET_SIZE] === 0x47) {\n // Regular MPEG-TS\n this.packetOffset = 0;\n this.packetStride = TS_PACKET_SIZE;\n }\n else if (startingBytes[0] === 0x47 && startingBytes[TS_PACKET_SIZE + 16] === 0x47) {\n // MPEG-TS with Forward Error Correction\n this.packetOffset = 0;\n this.packetStride = TS_PACKET_SIZE + 16;\n }\n else if (startingBytes[4] === 0x47 && startingBytes[4 + TS_PACKET_SIZE + 4] === 0x47) {\n // MPEG-2-TS (DVHS)\n this.packetOffset = 4;\n this.packetStride = TS_PACKET_SIZE + 4;\n }\n else {\n throw new Error('Unreachable.');\n }\n const MIN_REFERENCE_POINT_PACKET_DISTANCE = 256;\n this.minReferencePointByteDistance = MIN_REFERENCE_POINT_PACKET_DISTANCE * this.packetStride;\n let currentPos = this.packetOffset;\n let programMapPid = null;\n // Some files contain these multiple times, but we only care about their first appearance\n let hasProgramAssociationTable = false;\n let hasProgramMap = false;\n while (true) {\n const packetHeader = await this.readPacketHeader(currentPos);\n if (!packetHeader) {\n break;\n }\n if (packetHeader.payloadUnitStartIndicator === 0) {\n // Not the start of a section\n currentPos += this.packetStride;\n continue;\n }\n if (hasProgramMap && !this.elementaryStreams.some(x => x.pid === packetHeader.pid)) {\n // Don't care about this PID\n currentPos += this.packetStride;\n continue;\n }\n const section = await this.readSection(currentPos, true, !hasProgramMap);\n if (!section) {\n break;\n }\n const BYTES_BEFORE_SECTION_LENGTH = 3;\n const BITS_IN_CRC_32 = 32; // Duh\n // Some streams don't contain a PAT for some reason, so we must do some guesswork to figure out where\n // the PMT is.\n let isProbablyProgramMap = false;\n if (!hasProgramMap && section.pid !== 0) {\n const isPesPacket = section.payload[0] === 0x00 && section.payload[1] === 0x00 && section.payload[2] === 0x01;\n if (!isPesPacket) {\n // Assume it's a PSI\n const bitstream = new Bitstream(section.payload);\n const pointerField = bitstream.readAlignedByte();\n bitstream.skipBits(8 * pointerField);\n const tableId = bitstream.readBits(8);\n isProbablyProgramMap = tableId === 0x02; // 0x02 == TS_program_map_section\n }\n }\n if (section.pid === 0 && !hasProgramAssociationTable) {\n const bitstream = new Bitstream(section.payload);\n const pointerField = bitstream.readAlignedByte();\n bitstream.skipBits(8 * pointerField);\n bitstream.skipBits(14);\n const sectionLength = bitstream.readBits(10);\n bitstream.skipBits(40);\n while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {\n const programNumber = bitstream.readBits(16);\n bitstream.skipBits(3); // Reserved\n const id = bitstream.readBits(13);\n if (programNumber !== 0) {\n if (programMapPid !== null) {\n throw new Error('Only files with a single program are supported.');\n }\n else {\n programMapPid = id;\n }\n }\n }\n if (programMapPid === null) {\n throw new Error('Program Association Table must link to a Program Map Table.');\n }\n hasProgramAssociationTable = true;\n }\n else if ((section.pid === programMapPid || isProbablyProgramMap) && !hasProgramMap) {\n const bitstream = new Bitstream(section.payload);\n const pointerField = bitstream.readAlignedByte();\n bitstream.skipBits(8 * pointerField);\n bitstream.skipBits(12);\n const sectionLength = bitstream.readBits(12);\n bitstream.skipBits(43);\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n bitstream.readBits(13);\n bitstream.skipBits(6);\n // \"The remaining 10 bits specify the number of bytes of the descriptors immediately following the\n // program_info_length field\"\n const programInfoLength = bitstream.readBits(10);\n bitstream.skipBits(8 * programInfoLength);\n while (8 * (sectionLength + BYTES_BEFORE_SECTION_LENGTH) - bitstream.pos > BITS_IN_CRC_32) {\n const streamType = bitstream.readBits(8);\n bitstream.skipBits(3);\n const elementaryPid = bitstream.readBits(13);\n bitstream.skipBits(6);\n const esInfoLength = bitstream.readBits(10);\n // Check ES descriptors to detect AC-3/E-AC-3 in System B\n const esInfoEndPos = bitstream.pos + 8 * esInfoLength;\n let hasAc3Descriptor = false;\n let hasEac3Descriptor = false;\n while (bitstream.pos < esInfoEndPos) {\n const descriptorTag = bitstream.readBits(8);\n const descriptorLength = bitstream.readBits(8);\n if (descriptorTag === 0x6a) {\n hasAc3Descriptor = true;\n }\n else if (descriptorTag === 0x7a || descriptorTag === 0xcc) {\n hasEac3Descriptor = true;\n }\n bitstream.skipBits(8 * descriptorLength);\n }\n let info = null;\n switch (streamType) {\n case 27 /* MpegTsStreamType.AVC */:\n case 36 /* MpegTsStreamType.HEVC */:\n {\n const codec = streamType === 27 /* MpegTsStreamType.AVC */ ? 'avc' : 'hevc';\n info = {\n type: 'video',\n codec,\n decoderConfig: null,\n avcCodecInfo: null,\n hevcCodecInfo: null,\n colorSpace: {\n primaries: null,\n transfer: null,\n matrix: null,\n fullRange: null,\n },\n width: -1,\n height: -1,\n squarePixelWidth: -1,\n squarePixelHeight: -1,\n reorderSize: -1,\n };\n }\n break;\n case 3 /* MpegTsStreamType.MP3_MPEG1 */:\n case 4 /* MpegTsStreamType.MP3_MPEG2 */:\n case 15 /* MpegTsStreamType.AAC */:\n case 129 /* MpegTsStreamType.AC3_SYSTEM_A */:\n case 135 /* MpegTsStreamType.EAC3_SYSTEM_A */:\n {\n let codec;\n if (streamType === 3 /* MpegTsStreamType.MP3_MPEG1 */\n || streamType === 4 /* MpegTsStreamType.MP3_MPEG2 */) {\n codec = 'mp3';\n }\n else if (streamType === 15 /* MpegTsStreamType.AAC */) {\n codec = 'aac';\n }\n else if (streamType === 129 /* MpegTsStreamType.AC3_SYSTEM_A */) {\n codec = 'ac3';\n }\n else if (streamType === 135 /* MpegTsStreamType.EAC3_SYSTEM_A */) {\n codec = 'eac3';\n }\n else {\n throw new Error('Unreachable.');\n }\n info = {\n type: 'audio',\n codec,\n decoderConfig: null,\n aacCodecInfo: null,\n numberOfChannels: -1,\n sampleRate: -1,\n };\n }\n break;\n case 6 /* MpegTsStreamType.PRIVATE_DATA */:\n {\n if (hasEac3Descriptor) {\n info = {\n type: 'audio',\n codec: 'eac3',\n decoderConfig: null,\n aacCodecInfo: null,\n numberOfChannels: -1,\n sampleRate: -1,\n };\n }\n else if (hasAc3Descriptor) {\n info = {\n type: 'audio',\n codec: 'ac3',\n decoderConfig: null,\n aacCodecInfo: null,\n numberOfChannels: -1,\n sampleRate: -1,\n };\n }\n }\n break;\n default: {\n // If we don't recognize the codec, we don't surface the track at all. This is because\n // we can't determine its metadata and also have no idea how to packetize its data.\n if (!ignoredStreamTypes.has(streamType)) {\n Logging._warn(`Note: MPEG-TS streams with stream_type 0x${streamType.toString(16)} are not`\n + ` currently supported.`);\n ignoredStreamTypes.add(streamType);\n }\n }\n }\n if (info) {\n this.elementaryStreams.push({\n demuxer: this,\n pid: elementaryPid,\n streamType,\n initialized: false,\n firstSection: null,\n canBeTrustedWithKeyPackets: false,\n info,\n referencePesPackets: [],\n });\n }\n }\n hasProgramMap = true;\n }\n else {\n const elementaryStream = this.elementaryStreams.find(x => x.pid === section.pid);\n outer: if (elementaryStream && !elementaryStream.initialized) {\n const pesPacket = readPesPacket(section, true);\n if (!pesPacket) {\n throw new Error(`Couldn't read first PES packet for Elementary Stream with PID ${elementaryStream.pid}`);\n }\n elementaryStream.firstSection = section;\n elementaryStream.canBeTrustedWithKeyPackets = section.randomAccessIndicator === 1;\n if (this.input._initInput) {\n const initDemuxer = (await this.input._initInput._getDemuxer());\n const matchingStream = initDemuxer.elementaryStreams.find(x => (x.pid === section.pid && x.info.codec === elementaryStream.info.codec));\n if (matchingStream) {\n elementaryStream.info = matchingStream.info;\n elementaryStream.initialized = true;\n break outer; // We have the stream info, we're done\n }\n }\n const context = new PacketReadingContext(elementaryStream, pesPacket);\n if (elementaryStream.info.type === 'video') {\n // We loop because in some files, the video parameters are not in the first packet\n while (true) {\n const contextAlias = context; // TyyyyypeScript 😩\n contextAlias.suppliedPacket = null;\n await context.markNextPacket();\n if (elementaryStream.info.codec === 'avc') {\n if (!context.suppliedPacket) {\n throw new Error('Invalid AVC video stream; could not extract AVCDecoderConfigurationRecord'\n + ' from any packet.');\n }\n elementaryStream.info.avcCodecInfo\n = extractAvcDecoderConfigurationRecord(context.suppliedPacket.data);\n if (!elementaryStream.info.avcCodecInfo) {\n continue; // Search the next packet for it\n }\n const spsUnit = elementaryStream.info.avcCodecInfo.sequenceParameterSets[0];\n assert(spsUnit);\n const spsInfo = parseAvcSps(spsUnit);\n elementaryStream.info.width = spsInfo.displayWidth;\n elementaryStream.info.height = spsInfo.displayHeight;\n const num = spsInfo.pixelAspectRatio.num;\n const den = spsInfo.pixelAspectRatio.den;\n if (num > 0 && den > 0) {\n if (num > den) {\n elementaryStream.info.squarePixelWidth = Math.round(elementaryStream.info.width * num / den);\n elementaryStream.info.squarePixelHeight = elementaryStream.info.height;\n }\n else {\n elementaryStream.info.squarePixelWidth = elementaryStream.info.width;\n elementaryStream.info.squarePixelHeight = Math.round(elementaryStream.info.height * den / num);\n }\n }\n elementaryStream.info.colorSpace = {\n primaries: COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries],\n transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics],\n matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients],\n fullRange: !!spsInfo.fullRangeFlag,\n };\n elementaryStream.info.reorderSize = spsInfo.maxDecFrameBuffering;\n break;\n }\n else if (elementaryStream.info.codec === 'hevc') {\n if (!context.suppliedPacket) {\n throw new Error('Invalid HEVC video stream; could not extract HVCDecoderConfigurationRecord'\n + ' from first packet.');\n }\n elementaryStream.info.hevcCodecInfo\n = extractHevcDecoderConfigurationRecord(context.suppliedPacket.data);\n if (!elementaryStream.info.hevcCodecInfo) {\n continue; // Search the next packet for it\n }\n const spsArray = elementaryStream.info.hevcCodecInfo.arrays.find(a => a.nalUnitType === HevcNalUnitType.SPS_NUT);\n const spsUnit = spsArray.nalUnits[0];\n assert(spsUnit);\n const spsInfo = parseHevcSps(spsUnit);\n elementaryStream.info.width = spsInfo.displayWidth;\n elementaryStream.info.height = spsInfo.displayHeight;\n if (spsInfo.pixelAspectRatio.num > spsInfo.pixelAspectRatio.den) {\n elementaryStream.info.squarePixelWidth = Math.round(elementaryStream.info.width\n * spsInfo.pixelAspectRatio.num / spsInfo.pixelAspectRatio.den);\n elementaryStream.info.squarePixelHeight = elementaryStream.info.height;\n }\n else {\n elementaryStream.info.squarePixelWidth = elementaryStream.info.width;\n elementaryStream.info.squarePixelHeight = Math.round(elementaryStream.info.height\n * spsInfo.pixelAspectRatio.den / spsInfo.pixelAspectRatio.num);\n }\n elementaryStream.info.colorSpace = {\n primaries: COLOR_PRIMARIES_MAP_INVERSE[spsInfo.colourPrimaries],\n transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[spsInfo.transferCharacteristics],\n matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[spsInfo.matrixCoefficients],\n fullRange: !!spsInfo.fullRangeFlag,\n };\n elementaryStream.info.reorderSize = spsInfo.maxDecFrameBuffering;\n break;\n }\n else {\n throw new Error('Unhandled.');\n }\n }\n elementaryStream.info.decoderConfig = {\n codec: extractVideoCodecString({\n width: elementaryStream.info.width,\n height: elementaryStream.info.height,\n codec: elementaryStream.info.codec,\n codecDescription: null,\n colorSpace: elementaryStream.info.colorSpace,\n avcType: 1,\n avcCodecInfo: elementaryStream.info.avcCodecInfo,\n hevcCodecInfo: elementaryStream.info.hevcCodecInfo,\n vp9CodecInfo: null,\n av1CodecInfo: null,\n proresFormat: null,\n }),\n codedWidth: elementaryStream.info.width,\n codedHeight: elementaryStream.info.height,\n colorSpace: elementaryStream.info.colorSpace,\n };\n if (elementaryStream.info.width !== elementaryStream.info.squarePixelWidth\n || elementaryStream.info.height !== elementaryStream.info.squarePixelHeight) {\n elementaryStream.info.decoderConfig.displayAspectWidth\n = elementaryStream.info.squarePixelWidth;\n elementaryStream.info.decoderConfig.displayAspectHeight\n = elementaryStream.info.squarePixelHeight;\n }\n elementaryStream.initialized = true;\n }\n else {\n await context.markNextPacket();\n if (!context.suppliedPacket) {\n throw new Error(`Couldn't parse first media packet for Elementary Stream with`\n + ` PID ${elementaryStream.pid}`);\n }\n if (elementaryStream.info.codec === 'aac') {\n const slice = FileSlice.tempFromBytes(context.suppliedPacket.data);\n const header = readAdtsFrameHeader(slice);\n if (!header) {\n throw new Error('Invalid AAC audio stream; could not read ADTS frame header from first packet.');\n }\n elementaryStream.info.aacCodecInfo = {\n isMpeg2: false,\n objectType: header.objectType,\n };\n elementaryStream.info.numberOfChannels\n = aacChannelMap[header.channelConfiguration];\n elementaryStream.info.sampleRate\n = aacFrequencyTable[header.samplingFrequencyIndex];\n }\n else if (elementaryStream.info.codec === 'mp3') {\n const word = readU32Be(FileSlice.tempFromBytes(context.suppliedPacket.data));\n const result = readMp3FrameHeader(word, context.suppliedPacket.data.byteLength);\n if (!result.header) {\n throw new Error('Invalid MP3 audio stream; could not read frame header from first packet.');\n }\n elementaryStream.info.numberOfChannels = getMp3ChannelCount(result.header.channel);\n elementaryStream.info.sampleRate = result.header.sampleRate;\n }\n else if (elementaryStream.info.codec === 'ac3') {\n const frameInfo = parseAc3SyncFrame(context.suppliedPacket.data);\n if (!frameInfo) {\n throw new Error('Invalid AC-3 audio stream; could not read sync frame from first packet.');\n }\n if (frameInfo.fscod === 3) {\n throw new Error('Invalid AC-3 audio stream; reserved sample rate code found in first packet.');\n }\n elementaryStream.info.numberOfChannels\n = AC3_ACMOD_CHANNEL_COUNTS[frameInfo.acmod] + frameInfo.lfeon;\n elementaryStream.info.sampleRate = AC3_SAMPLE_RATES[frameInfo.fscod];\n }\n else if (elementaryStream.info.codec === 'eac3') {\n const frameInfo = parseEac3SyncFrame(context.suppliedPacket.data);\n if (!frameInfo) {\n throw new Error('Invalid E-AC-3 audio stream; could not read sync frame from first packet.');\n }\n const sampleRate = getEac3SampleRate(frameInfo);\n if (sampleRate === null) {\n throw new Error('Invalid E-AC-3 audio stream; reserved sample rate code found in first packet.');\n }\n elementaryStream.info.numberOfChannels = getEac3ChannelCount(frameInfo);\n elementaryStream.info.sampleRate = sampleRate;\n }\n else {\n throw new Error('Unhandled.');\n }\n elementaryStream.info.decoderConfig = {\n codec: extractAudioCodecString({\n codec: elementaryStream.info.codec,\n codecDescription: null,\n aacCodecInfo: elementaryStream.info.aacCodecInfo,\n }),\n numberOfChannels: elementaryStream.info.numberOfChannels,\n sampleRate: elementaryStream.info.sampleRate,\n };\n elementaryStream.initialized = true;\n }\n }\n }\n const isDone = hasProgramMap && this.elementaryStreams.every(x => x.initialized);\n if (isDone) {\n break;\n }\n currentPos += this.packetStride;\n }\n if (!hasProgramMap) {\n if (!hasProgramAssociationTable) {\n throw new Error('No Program Association Table found in the file.');\n }\n throw new Error('No Program Map Table found in the file.');\n }\n for (const stream of this.elementaryStreams) {\n if (stream.info.type === 'video') {\n this.trackBackingEntries.push(new MpegTsVideoTrackBacking(stream));\n }\n else {\n this.trackBackingEntries.push(new MpegTsAudioTrackBacking(stream));\n }\n }\n })();\n }\n async getTrackBackings() {\n await this.readMetadata();\n return this.trackBackingEntries;\n }\n async getMetadataTags() {\n return {}; // Nothing for now\n }\n async getMimeType() {\n await this.readMetadata();\n const codecStrings = await Promise.all(this.trackBackingEntries.map(x => x.getDecoderConfig().then(c => c?.codec ?? null)));\n return buildMpegTsMimeType(codecStrings);\n }\n async readSection(startPos, full, contiguous = false) {\n let endPos = startPos;\n let currentPos = startPos;\n const chunks = [];\n let chunksByteLength = 0;\n let firstPacket = null;\n let mustAddSectionEnd = true;\n let randomAccessIndicator = 0;\n while (true) {\n const packet = await this.readPacket(currentPos);\n currentPos += this.packetStride;\n if (!packet) {\n break;\n }\n if (!firstPacket) {\n if (packet.payloadUnitStartIndicator === 0) {\n break;\n }\n firstPacket = packet;\n }\n else {\n if (packet.pid !== firstPacket.pid) {\n if (contiguous) {\n break; // End of section\n }\n else {\n continue; // Ignore this packet\n }\n }\n if (packet.payloadUnitStartIndicator === 1) {\n break;\n }\n }\n const hasAdaptationField = !!(packet.adaptationFieldControl & 0b10);\n const hasPayload = !!(packet.adaptationFieldControl & 0b01);\n let adaptationFieldLength = 0;\n if (hasAdaptationField) {\n adaptationFieldLength = 1 + packet.body[0];\n // Extract random_access_indicator from first packet's adaptation field\n if (packet === firstPacket && adaptationFieldLength > 1) {\n randomAccessIndicator = (packet.body[1] >> 6) & 1;\n }\n }\n if (hasPayload) {\n if (adaptationFieldLength === 0) {\n chunks.push(packet.body);\n chunksByteLength += packet.body.byteLength;\n }\n else {\n chunks.push(packet.body.subarray(adaptationFieldLength));\n chunksByteLength += packet.body.byteLength - adaptationFieldLength;\n }\n }\n endPos = currentPos;\n // 64 is just \"a bit of data\", enough for the PES packet header\n if (!full && chunksByteLength >= 64) {\n mustAddSectionEnd = false; // Not the actual section end\n break;\n }\n // Check if we already know this is a section end\n const isKnownSectionEnd = binarySearchExact(this.sectionEndPositions, endPos, x => x) !== -1;\n if (isKnownSectionEnd) {\n mustAddSectionEnd = false;\n break;\n }\n }\n if (mustAddSectionEnd) {\n const index = binarySearchLessOrEqual(this.sectionEndPositions, endPos, x => x);\n this.sectionEndPositions.splice(index + 1, 0, endPos);\n }\n if (!firstPacket) {\n return null;\n }\n let merged;\n if (chunks.length === 1) {\n merged = chunks[0];\n }\n else {\n const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);\n merged = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.length;\n }\n }\n return {\n startPos,\n endPos: full ? endPos : null,\n pid: firstPacket.pid,\n payload: merged,\n randomAccessIndicator,\n };\n }\n async readPacketHeader(pos) {\n let slice = this.reader.requestSlice(pos, 4);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null;\n }\n const syncByte = readU8(slice);\n if (syncByte !== 0x47) {\n throw new Error('Invalid TS packet sync byte. Likely an internal bug, please report this file.');\n }\n const nextTwoBytes = readU16Be(slice);\n const payloadUnitStartIndicator = (nextTwoBytes >> 14) & 0x1;\n const pid = nextTwoBytes & 0x1FFF;\n const nextByte = readU8(slice);\n const adaptationFieldControl = (nextByte >> 4) & 0x3;\n return {\n payloadUnitStartIndicator,\n pid,\n adaptationFieldControl,\n };\n }\n async readPacket(pos) {\n // Code in here is duplicated from readPacketHeader for performance reasons\n let slice = this.reader.requestSlice(pos, TS_PACKET_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice) {\n return null;\n }\n const bytes = readBytes(slice, TS_PACKET_SIZE);\n const syncByte = bytes[0];\n if (syncByte !== 0x47) {\n throw new Error('Invalid TS packet sync byte. Likely an internal bug, please report this file.');\n }\n const nextTwoBytes = (bytes[1] << 8) + bytes[2];\n const payloadUnitStartIndicator = (nextTwoBytes >> 14) & 0x1;\n const pid = nextTwoBytes & 0x1FFF;\n const nextByte = bytes[3];\n const adaptationFieldControl = (nextByte >> 4) & 0x3;\n return {\n payloadUnitStartIndicator,\n pid,\n adaptationFieldControl,\n body: bytes.subarray(4),\n };\n }\n }\n const readPesPacketHeader = (section, expectPts) => {\n if (section.payload.byteLength < 3) {\n return null;\n }\n const bitstream = new Bitstream(section.payload);\n const startCodePrefix = bitstream.readBits(24);\n if (startCodePrefix !== 0x000001) {\n return null;\n }\n const streamId = bitstream.readBits(8);\n bitstream.skipBits(16);\n if (streamId === 0b10111100 // program_stream_map\n || streamId === 0b10111110 // padding_stream\n || streamId === 0b10111111 // private_stream_2\n || streamId === 0b11110000 // ECM\n || streamId === 0b11110001 // EMM\n || streamId === 0b11111111 // program_stream_directory\n || streamId === 0b11110010 // DSMCC_stream\n || streamId === 0b11111000 // ITU-T Rec. H.222.1 type E stream\n ) {\n return null;\n }\n bitstream.skipBits(8);\n const ptsDtsFlags = bitstream.readBits(2);\n bitstream.skipBits(14);\n let pts = null;\n if (ptsDtsFlags === 0b10 || ptsDtsFlags === 0b11) {\n pts = 0;\n bitstream.skipBits(4);\n pts += bitstream.readBits(3) * (1 << 30);\n bitstream.skipBits(1);\n pts += bitstream.readBits(15) * (1 << 15);\n bitstream.skipBits(1);\n pts += bitstream.readBits(15);\n }\n else {\n if (expectPts) {\n throw new Error(MISSING_PTS_ERROR_MESSAGE);\n }\n }\n return {\n sectionStartPos: section.startPos,\n sectionEndPos: section.endPos,\n pts,\n randomAccessIndicator: section.randomAccessIndicator,\n };\n };\n const readPesPacket = (section, expectPts) => {\n assert(section.endPos !== null); // Can only read full PES packets from fully read sections\n const header = readPesPacketHeader(section, expectPts);\n if (!header) {\n return null;\n }\n const bitstream = new Bitstream(section.payload);\n bitstream.skipBits(32);\n const pesPacketLength = bitstream.readBits(16);\n const BYTES_UNTIL_END_OF_PES_PACKET_LENGTH = 6;\n bitstream.skipBits(16);\n const pesHeaderDataLength = bitstream.readBits(8);\n const pesHeaderEndPos = bitstream.pos + 8 * pesHeaderDataLength;\n bitstream.pos = pesHeaderEndPos;\n const bytePos = pesHeaderEndPos / 8;\n assert(Number.isInteger(bytePos));\n const data = section.payload.subarray(bytePos, \n // \"A value of 0 indicates that the PES packet length is neither specified nor bounded and is allowed only in\n // PES packets whose payload consists of bytes from a video elementary stream contained in\n // transport stream packets.\"\n pesPacketLength > 0\n ? BYTES_UNTIL_END_OF_PES_PACKET_LENGTH + pesPacketLength\n : section.payload.byteLength);\n return {\n ...header,\n data,\n };\n };\n class MpegTsTrackBacking {\n constructor(elementaryStream) {\n this.elementaryStream = elementaryStream;\n this.packetBuffers = new WeakMap();\n /** Used for recreating PacketBuffers if necessary. */\n this.packetSectionStarts = new WeakMap();\n }\n getId() {\n return this.elementaryStream.pid;\n }\n getNumber() {\n const demuxer = this.elementaryStream.demuxer;\n const trackType = this.elementaryStream.info.type;\n let number = 0;\n for (const backing of demuxer.trackBackingEntries) {\n if (backing.getType() === trackType) {\n number++;\n }\n assert(backing instanceof MpegTsTrackBacking);\n if (backing.elementaryStream === this.elementaryStream) {\n break;\n }\n }\n return number;\n }\n getCodec() {\n throw new Error('Not implemented on base class.');\n }\n getInternalCodecId() {\n return this.elementaryStream.streamType;\n }\n getName() {\n return null;\n }\n getLanguageCode() {\n return UNDETERMINED_LANGUAGE;\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n primary: false,\n };\n }\n getTimeResolution() {\n return TIMESCALE;\n }\n isRelativeToUnixEpoch() {\n return false;\n }\n getUnixTimeForTimestamp() {\n return null;\n }\n getPairingMask() {\n return 1n;\n }\n getBitrate() {\n return null;\n }\n getAverageBitrate() {\n return null;\n }\n async getDurationFromMetadata() {\n return null;\n }\n async getLiveRefreshInterval() {\n return null;\n }\n createEncodedPacket(suppliedPacket, duration, options) {\n let packetType;\n if (this.allPacketsAreKeyPackets()) {\n packetType = 'key';\n }\n else {\n packetType = suppliedPacket.randomAccessIndicator === 1\n ? 'key'\n : 'delta';\n }\n return new EncodedPacket(options.metadataOnly ? PLACEHOLDER_DATA : suppliedPacket.data, packetType, suppliedPacket.pts / TIMESCALE, Math.max(duration / TIMESCALE, 0), suppliedPacket.sequenceNumber, suppliedPacket.data.byteLength);\n }\n async getFirstPacket(options) {\n const section = this.elementaryStream.firstSection;\n assert(section);\n const pesPacket = readPesPacket(section, true);\n assert(pesPacket);\n const context = new PacketReadingContext(this.elementaryStream, pesPacket);\n const buffer = new PacketBuffer(this, context);\n const result = await buffer.readNext();\n if (!result) {\n return null;\n }\n const packet = this.createEncodedPacket(result.packet, result.duration, options);\n this.packetBuffers.set(packet, buffer);\n this.packetSectionStarts.set(packet, result.packet.sectionStartPos);\n return packet;\n }\n async getNextPacket(packet, options) {\n let buffer = this.packetBuffers.get(packet);\n if (buffer) {\n // Fast path\n const result = await buffer.readNext();\n if (!result) {\n return null;\n }\n // Remove PacketBuffer access from the old packet, it belongs to the next packet now\n this.packetBuffers.delete(packet);\n const newPacket = this.createEncodedPacket(result.packet, result.duration, options);\n this.packetBuffers.set(newPacket, buffer);\n this.packetSectionStarts.set(newPacket, result.packet.sectionStartPos);\n return newPacket;\n }\n // No buffer, we gotta do some rereading\n const sectionStartPos = this.packetSectionStarts.get(packet);\n if (sectionStartPos === undefined) {\n throw new Error('Packet was not created from this track.');\n }\n const demuxer = this.elementaryStream.demuxer;\n const section = await demuxer.readSection(sectionStartPos, true);\n assert(section);\n const pesPacket = readPesPacket(section, true);\n assert(pesPacket);\n const context = new PacketReadingContext(this.elementaryStream, pesPacket);\n buffer = new PacketBuffer(this, context);\n // Advance until we pass the current packet's sequence number\n const targetSequenceNumber = packet.sequenceNumber;\n while (true) {\n const result = await buffer.readNext();\n if (!result) {\n return null;\n }\n if (result.packet.sequenceNumber > targetSequenceNumber) {\n // We found the next packet!\n const newPacket = this.createEncodedPacket(result.packet, result.duration, options);\n this.packetBuffers.set(newPacket, buffer);\n this.packetSectionStarts.set(newPacket, result.packet.sectionStartPos);\n return newPacket;\n }\n }\n }\n async getNextKeyPacket(packet, options) {\n let currentPacket = packet;\n // Just loop until we hit one\n while (true) {\n currentPacket = await this.getNextPacket(currentPacket, options);\n if (!currentPacket) {\n return null;\n }\n if (currentPacket.type === 'key') {\n return currentPacket;\n }\n }\n }\n getPacket(timestamp, options) {\n return this.doPacketLookup(timestamp, false, options);\n }\n getKeyPacket(timestamp, options) {\n return this.doPacketLookup(timestamp, true, options);\n }\n /**\n * Searches for the packet with the largest timestamp not larger than `timestamp` in the file, using a combination\n * of chunk-based binary search and linear refinement. The reason the coarse search is done in large chunks is to\n * make it more performant for small files and over high-latency readers such as the network.\n */\n async doPacketLookup(timestamp, keyframesOnly, options) {\n const searchPts = roundIfAlmostInteger(timestamp * TIMESCALE);\n const demuxer = this.elementaryStream.demuxer;\n const { reader, seekChunkSize } = demuxer;\n const pid = this.elementaryStream.pid;\n const findFirstPesPacketHeaderInChunk = async (startPos, endPos, readSectionInFull) => {\n let currentPos = startPos;\n while (currentPos < endPos) {\n const packetHeader = await demuxer.readPacketHeader(currentPos);\n if (!packetHeader) {\n return null;\n }\n if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {\n const section = await demuxer.readSection(currentPos, readSectionInFull);\n if (!section) {\n return null;\n }\n const pesPacketHeader = readPesPacketHeader(section, false);\n if (pesPacketHeader && pesPacketHeader.pts !== null) {\n return {\n pesPacketHeader: pesPacketHeader,\n section,\n };\n }\n }\n currentPos += demuxer.packetStride;\n }\n return null;\n };\n // Get the first PES packet of the track\n const firstSection = this.elementaryStream.firstSection;\n assert(firstSection);\n const firstPesPacketHeader = readPesPacketHeader(firstSection, true);\n assert(firstPesPacketHeader);\n if (searchPts < firstPesPacketHeader.pts) {\n // We're before the first packet, definitely nothing here\n return null;\n }\n let scanStartPos;\n const referencePesPackets = this.elementaryStream.referencePesPackets;\n const referencePointIndex = binarySearchLessOrEqual(referencePesPackets, searchPts, x => x.pts);\n const referencePoint = referencePointIndex !== -1 ? referencePesPackets[referencePointIndex] : null;\n if (referencePoint && searchPts - referencePoint.pts < TIMESCALE / 2) {\n // Reference point ain't too far away, prefer it over the chunk search\n scanStartPos = referencePoint.sectionStartPos;\n }\n else {\n let startChunkIndex = 0;\n if (reader.fileSize !== null) {\n const numChunks = Math.ceil(reader.fileSize / seekChunkSize);\n if (numChunks > 1) {\n // Binary search to find the chunk with highest index whose first PES has pts <= searchPts\n let low = 0;\n let high = numChunks - 1;\n startChunkIndex = low;\n while (low <= high) {\n const mid = Math.floor((low + high) / 2);\n const chunkStartPos = floorToMultiple(mid * seekChunkSize, demuxer.packetStride)\n + firstPesPacketHeader.sectionStartPos;\n const chunkEndPos = chunkStartPos + seekChunkSize;\n const result = await findFirstPesPacketHeaderInChunk(chunkStartPos, chunkEndPos, false);\n if (!result) {\n // No PES packet found in this chunk, search left\n high = mid - 1;\n continue;\n }\n if (result.pesPacketHeader.pts <= searchPts) {\n // This chunk's first PES is <= searchPts, it's a candidate\n startChunkIndex = mid;\n low = mid + 1; // Search right\n }\n else {\n // Search left\n high = mid - 1;\n }\n }\n }\n }\n scanStartPos = floorToMultiple(startChunkIndex * seekChunkSize, demuxer.packetStride) + firstPesPacketHeader.sectionStartPos;\n }\n // Find the first PES packet at or after scanStartPos\n const result = await findFirstPesPacketHeaderInChunk(scanStartPos, reader.fileSize ?? Infinity, false);\n let currentPesHeader = result?.pesPacketHeader ?? null;\n if (!currentPesHeader) {\n // Fall back to first packet\n currentPesHeader = firstPesPacketHeader;\n }\n const reorderSize = this.getReorderSize();\n const retrieveEncodedPacket = async (sectionStartPos, predicate) => {\n // Load the relevant section in full\n const section = await demuxer.readSection(sectionStartPos, true);\n assert(section);\n const pesPacket = readPesPacket(section, true);\n assert(pesPacket);\n const context = new PacketReadingContext(this.elementaryStream, pesPacket);\n const buffer = new PacketBuffer(this, context);\n // Advance until the top-most presentation timestamp crosses or equals searchPts\n while (true) {\n const topPts = last(buffer.presentationOrderPackets)?.pts ?? -Infinity;\n if (topPts >= searchPts) {\n break;\n }\n const didRead = await buffer.readNextPacket();\n if (!didRead) {\n break;\n }\n }\n const targetIndex = findLastIndex(buffer.presentationOrderPackets, predicate);\n if (targetIndex === -1) {\n return null;\n }\n const targetPacket = buffer.presentationOrderPackets[targetIndex];\n const lastDuration = targetIndex === 0\n ? 0\n : targetPacket.pts - buffer.presentationOrderPackets[targetIndex - 1].pts;\n // Pop packets in decode order until we hit the target packet\n while (buffer.decodeOrderPackets[0] !== targetPacket) {\n buffer.decodeOrderPackets.shift();\n }\n buffer.lastDuration = lastDuration; // Kinda ugly but necessary fix\n const result = await buffer.readNext();\n assert(result);\n const packet = this.createEncodedPacket(result.packet, result.duration, options);\n this.packetBuffers.set(packet, buffer);\n this.packetSectionStarts.set(packet, result.packet.sectionStartPos);\n return packet;\n };\n if (!keyframesOnly || this.allPacketsAreKeyPackets()) {\n // Normat packet lookup case. Slightly easier since we just need to search (mostly) forward to find the\n // packet.\n // Linear scan to find the PES packet with largest pts <= searchPts. This will be used as the \"midpoint\"\n // of the next refinement step (which is needed because of B-frames).\n outer: while (true) {\n let currentPos = currentPesHeader.sectionStartPos + demuxer.packetStride;\n while (true) {\n const packetHeader = await demuxer.readPacketHeader(currentPos);\n if (!packetHeader) {\n break outer; // End of file\n }\n if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {\n const section = await demuxer.readSection(currentPos, false);\n if (section) {\n const nextPesHeader = readPesPacketHeader(section, false);\n if (nextPesHeader && nextPesHeader.pts !== null) {\n if (nextPesHeader.pts > searchPts) {\n break outer;\n }\n currentPesHeader = nextPesHeader;\n maybeInsertReferencePacket(this.elementaryStream, currentPesHeader);\n break;\n }\n }\n }\n currentPos += demuxer.packetStride;\n }\n }\n // Rewind by reorderSize + 1 PES packets (even for audio! To ensure proper durations)\n outer: for (let i = 0; i < reorderSize + 1; i++) {\n let pos = currentPesHeader.sectionStartPos - demuxer.packetStride;\n while (pos >= demuxer.packetOffset) {\n const packetHeader = await demuxer.readPacketHeader(pos);\n if (!packetHeader) {\n break outer;\n }\n if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {\n const section = await demuxer.readSection(pos, false);\n if (section) {\n const header = readPesPacketHeader(section, false);\n if (header && header.pts !== null) {\n currentPesHeader = header;\n break;\n }\n }\n }\n pos -= demuxer.packetStride;\n }\n }\n return retrieveEncodedPacket(currentPesHeader.sectionStartPos, p => p.pts <= searchPts);\n }\n else {\n // Key packet lookup case. Slightly harder since the starting chunk may not have a key packet at all, which\n // means we might need to search the previous chunks until we find something.\n let currentChunkStartPos = scanStartPos;\n let nextChunkStartPos = null; // \"next\" as in later in the file, even tho we scan backwards\n const readSectionsInFull = !this.elementaryStream.canBeTrustedWithKeyPackets;\n while (true) {\n let bestKeyPesHeader = null;\n const isFirstChunk = currentChunkStartPos <= firstPesPacketHeader.sectionStartPos;\n let pesHeader;\n let pesHeaderSection = null;\n if (isFirstChunk) {\n pesHeader = firstPesPacketHeader;\n pesHeaderSection = firstSection;\n }\n else {\n const result = await findFirstPesPacketHeaderInChunk(currentChunkStartPos, reader.fileSize ?? Infinity, readSectionsInFull);\n pesHeader = result?.pesPacketHeader ?? null;\n pesHeaderSection = result?.section ?? null;\n }\n let passedSearchPts = false;\n let lookaheadCount = 0;\n outer: while (pesHeader) {\n if (nextChunkStartPos !== null && pesHeader.sectionStartPos >= nextChunkStartPos) {\n // Stop at the next chunk boundary\n break;\n }\n if (pesHeader.pts <= searchPts) {\n let isKeyPacket;\n if (this.elementaryStream.canBeTrustedWithKeyPackets) {\n isKeyPacket = pesHeader.randomAccessIndicator === 1;\n }\n else {\n assert(pesHeaderSection);\n const pesPacket = readPesPacket(pesHeaderSection, true);\n assert(pesPacket);\n const context = new PacketReadingContext(this.elementaryStream, pesPacket);\n await context.markNextPacket();\n isKeyPacket = context.suppliedPacket?.randomAccessIndicator === 1;\n }\n if (isKeyPacket) {\n bestKeyPesHeader = pesHeader;\n }\n }\n if (pesHeader.pts > searchPts) {\n passedSearchPts = true;\n }\n // If we've passed searchPts, do lookahead for reorderSize more packets just to be sure\n if (passedSearchPts) {\n lookaheadCount++;\n if (lookaheadCount > reorderSize) {\n break;\n }\n }\n // Find next PES packet\n let currentPos = pesHeader.sectionStartPos + demuxer.packetStride;\n while (true) {\n const packetHeader = await demuxer.readPacketHeader(currentPos);\n if (!packetHeader) {\n break outer; // End of file\n }\n if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {\n const section = await demuxer.readSection(currentPos, readSectionsInFull);\n if (section) {\n const nextPesHeader = readPesPacketHeader(section, false);\n if (nextPesHeader && nextPesHeader.pts !== null) {\n pesHeader = nextPesHeader;\n pesHeaderSection = section;\n maybeInsertReferencePacket(this.elementaryStream, pesHeader);\n break;\n }\n }\n }\n currentPos += demuxer.packetStride;\n }\n }\n if (bestKeyPesHeader) {\n let startPesHeader = bestKeyPesHeader;\n if (lookaheadCount === 0) {\n // Packet is at the end of stream, let's rewind a little to obtain the correct packet duration\n outer: for (let i = 0; i < reorderSize; i++) {\n let pos = startPesHeader.sectionStartPos - demuxer.packetStride;\n while (pos >= demuxer.packetOffset) {\n const packetHeader = await demuxer.readPacketHeader(pos);\n if (!packetHeader) {\n break outer;\n }\n if (packetHeader.pid === pid && packetHeader.payloadUnitStartIndicator === 1) {\n const section = await demuxer.readSection(pos, readSectionsInFull);\n if (section) {\n const header = readPesPacketHeader(section, false);\n if (header && header.pts !== null) {\n startPesHeader = header;\n break;\n }\n }\n }\n pos -= demuxer.packetStride;\n }\n }\n }\n const encodedPacket = await retrieveEncodedPacket(startPesHeader.sectionStartPos, p => p.pts <= searchPts && p.randomAccessIndicator === 1);\n assert(encodedPacket); // There must be one\n return encodedPacket;\n }\n if (isFirstChunk) {\n return null;\n }\n // No key frame found in this chunk, move one chunk to the left\n nextChunkStartPos = currentChunkStartPos;\n currentChunkStartPos = Math.max(floorToMultiple(currentChunkStartPos - firstPesPacketHeader.sectionStartPos - seekChunkSize, demuxer.packetStride) + firstPesPacketHeader.sectionStartPos, firstPesPacketHeader.sectionStartPos);\n }\n }\n }\n }\n class MpegTsVideoTrackBacking extends MpegTsTrackBacking {\n getType() {\n return 'video';\n }\n getCodec() {\n return this.elementaryStream.info.codec;\n }\n getCodedWidth() {\n return this.elementaryStream.info.width;\n }\n getCodedHeight() {\n return this.elementaryStream.info.height;\n }\n getSquarePixelWidth() {\n return this.elementaryStream.info.squarePixelWidth;\n }\n getSquarePixelHeight() {\n return this.elementaryStream.info.squarePixelHeight;\n }\n getRotation() {\n return 0;\n }\n async getColorSpace() {\n return this.elementaryStream.info.colorSpace;\n }\n async canBeTransparent() {\n return false;\n }\n async getDecoderConfig() {\n assert(this.elementaryStream.info.decoderConfig);\n return this.elementaryStream.info.decoderConfig;\n }\n allPacketsAreKeyPackets() {\n return false;\n }\n getReorderSize() {\n return this.elementaryStream.info.reorderSize;\n }\n }\n class MpegTsAudioTrackBacking extends MpegTsTrackBacking {\n getType() {\n return 'audio';\n }\n getCodec() {\n return this.elementaryStream.info.codec;\n }\n getNumberOfChannels() {\n return this.elementaryStream.info.numberOfChannels;\n }\n getSampleRate() {\n return this.elementaryStream.info.sampleRate;\n }\n async getDecoderConfig() {\n assert(this.elementaryStream.info.decoderConfig);\n return this.elementaryStream.info.decoderConfig;\n }\n allPacketsAreKeyPackets() {\n return true;\n }\n getReorderSize() {\n return 0; // No reordering, since no B-frames because goated\n }\n }\n const maybeInsertReferencePacket = (elementaryStream, pesPacketHeader) => {\n const referencePesPackets = elementaryStream.referencePesPackets;\n const index = binarySearchLessOrEqual(referencePesPackets, pesPacketHeader.sectionStartPos, x => x.sectionStartPos);\n if (index >= 0) {\n // Since pts and file position don't necessarily have a monotonic relationship (since pts can go crazy),\n // let's see if inserting at the given index would violate the pts order. If so, return.\n const entry = referencePesPackets[index];\n if (pesPacketHeader.pts <= entry.pts) {\n return false;\n }\n const minByteDistance = elementaryStream.demuxer.minReferencePointByteDistance;\n if (pesPacketHeader.sectionStartPos - entry.sectionStartPos < minByteDistance) {\n // Too close\n return false;\n }\n if (index < referencePesPackets.length - 1) {\n const nextEntry = referencePesPackets[index + 1];\n if (nextEntry.pts < pesPacketHeader.pts) {\n // Out of order\n return false;\n }\n if (nextEntry.sectionStartPos - pesPacketHeader.sectionStartPos < minByteDistance) {\n // Too close\n return false;\n }\n }\n }\n referencePesPackets.splice(index + 1, 0, pesPacketHeader);\n return true;\n };\n /** Stateful context used to extract exact encoded packets from the underlying data stream. */\n class PacketReadingContext {\n constructor(elementaryStream, startingPesPacket) {\n this.currentPos = 0; // Relative to the data in startingPesPacket\n this.pesPackets = [];\n this.currentPesPacketIndex = 0;\n this.currentPesPacketPos = 0;\n this.endPos = 0;\n this.lastSuppliedPesPacket = null;\n this.nextPts = null;\n this.suppliedPacket = null;\n this.elementaryStream = elementaryStream;\n this.pid = elementaryStream.pid;\n this.demuxer = elementaryStream.demuxer;\n this.startingPesPacket = startingPesPacket;\n }\n ensureBuffered(length) {\n const remaining = this.endPos - this.currentPos;\n if (remaining >= length) {\n return length;\n }\n return this.bufferData(length - remaining)\n .then(() => Math.min(this.endPos - this.currentPos, length));\n }\n getCurrentPesPacket() {\n const packet = this.pesPackets[this.currentPesPacketIndex];\n assert(packet);\n return packet;\n }\n async bufferData(length) {\n const targetEndPos = this.endPos + length;\n while (this.endPos < targetEndPos) {\n let pesPacket;\n if (this.pesPackets.length === 0) {\n pesPacket = this.startingPesPacket;\n }\n else {\n // Find the next PES packet\n let currentPos = last(this.pesPackets).sectionEndPos;\n assert(currentPos !== null);\n while (true) {\n const packetHeader = await this.demuxer.readPacketHeader(currentPos);\n if (!packetHeader) {\n return;\n }\n if (packetHeader.pid === this.pid) {\n const nextSection = await this.demuxer.readSection(currentPos, true);\n if (!nextSection) {\n return;\n }\n const nextPesPacket = readPesPacket(nextSection, false);\n if (nextPesPacket) {\n pesPacket = nextPesPacket;\n break;\n }\n }\n currentPos += this.demuxer.packetStride;\n }\n }\n this.pesPackets.push(pesPacket);\n this.endPos += pesPacket.data.byteLength;\n }\n }\n readBytes(length) {\n const currentPesPacket = this.getCurrentPesPacket();\n const relativeStartOffset = this.currentPos - this.currentPesPacketPos;\n const relativeEndOffset = relativeStartOffset + length;\n this.currentPos += length;\n if (relativeEndOffset <= currentPesPacket.data.byteLength) {\n // Request can be satisfied with one PES packet\n return currentPesPacket.data.subarray(relativeStartOffset, relativeEndOffset);\n }\n // Data spans multiple PES packets, we must do some merging\n const result = new Uint8Array(length);\n result.set(currentPesPacket.data.subarray(relativeStartOffset));\n let offset = currentPesPacket.data.byteLength - relativeStartOffset;\n while (true) {\n this.advanceCurrentPacket();\n const currentPesPacket = this.getCurrentPesPacket();\n const relativeEndOffset = length - offset;\n if (relativeEndOffset <= currentPesPacket.data.byteLength) {\n result.set(currentPesPacket.data.subarray(0, relativeEndOffset), offset);\n break;\n }\n result.set(currentPesPacket.data, offset);\n offset += currentPesPacket.data.byteLength;\n }\n return result;\n }\n readU8() {\n let currentPesPacket = this.getCurrentPesPacket();\n const relativeOffset = this.currentPos - this.currentPesPacketPos;\n this.currentPos++;\n if (relativeOffset < currentPesPacket.data.byteLength) {\n return currentPesPacket.data[relativeOffset];\n }\n this.advanceCurrentPacket();\n currentPesPacket = this.getCurrentPesPacket();\n return currentPesPacket.data[0];\n }\n seekTo(pos) {\n if (pos === this.currentPos) {\n return;\n }\n if (pos < this.currentPos) {\n while (pos < this.currentPesPacketPos) {\n // Move to the previous PES packet\n this.currentPesPacketIndex--;\n const currentPacket = this.getCurrentPesPacket();\n this.currentPesPacketPos -= currentPacket.data.byteLength;\n }\n }\n else {\n while (true) {\n // Move to the next PES packet\n const currentPesPacket = this.getCurrentPesPacket();\n const currentEndPos = this.currentPesPacketPos + currentPesPacket.data.byteLength;\n if (pos < currentEndPos) {\n break;\n }\n this.currentPesPacketPos += currentPesPacket.data.byteLength;\n this.currentPesPacketIndex++;\n }\n }\n this.currentPos = pos;\n }\n skip(n) {\n this.seekTo(this.currentPos + n);\n }\n advanceCurrentPacket() {\n this.currentPesPacketPos += this.getCurrentPesPacket().data.byteLength;\n this.currentPesPacketIndex++;\n }\n async markNextPacket() {\n assert(!this.suppliedPacket);\n const elementaryStream = this.elementaryStream;\n if (elementaryStream.info.type === 'video') {\n // Our job here is to separate the video stream into access units. Sometimes this is easy (like when AUDs\n // are present), sometimes it's a little harder.\n const codec = elementaryStream.info.codec;\n const CHUNK_SIZE = 1024;\n if (codec !== 'avc' && codec !== 'hevc') {\n throw new Error('Unhandled.');\n }\n const nalHeaderSize = codec === 'avc' ? 1 : 2;\n let packetStartPos = null;\n let frameStartFound = false;\n let lastFirstMacroblockInSlice = 0;\n while (true) {\n let remaining = this.ensureBuffered(CHUNK_SIZE);\n if (remaining instanceof Promise)\n remaining = await remaining;\n if (remaining === 0) {\n break;\n }\n const chunkStartPos = this.currentPos;\n const chunk = this.readBytes(remaining);\n const length = chunk.byteLength;\n let i = 0;\n while (i < length) {\n const zeroIndex = chunk.indexOf(0, i);\n if (zeroIndex === -1 || zeroIndex >= length) {\n break;\n }\n i = zeroIndex;\n const posBeforeZero = chunkStartPos + i;\n // Need 3 more bytes after the 0x00 to recognize a start code prefix\n if (i + 3 >= length) {\n // Not enough data in current chunk, seek back and let the next iteration handle it\n this.seekTo(posBeforeZero);\n break;\n }\n const b1 = chunk[i + 1];\n const b2 = chunk[i + 2];\n const b3 = chunk[i + 3];\n let startCodeLength = 0;\n // Check for 4-byte start code (0x00000001)\n if (b1 === 0x00 && b2 === 0x00 && b3 === 0x01) {\n startCodeLength = 4;\n }\n else if (b1 === 0x00 && b2 === 0x01) {\n // 3-byte start code (0x000001)\n startCodeLength = 3;\n }\n if (startCodeLength === 0) {\n // Not a start code, continue\n i++;\n continue;\n }\n const startCodePos = posBeforeZero;\n // The packet only really begins at the first NAL unit; anything before it isn't usable\n packetStartPos ??= startCodePos;\n const nalHeaderStart = i + startCodeLength;\n const payloadStart = nalHeaderStart + nalHeaderSize;\n // Bytes peeked from the start of a slice header to decode first_mb_in_slice. Six bytes (48 bits)\n // comfortably covers the exp-Golomb code for any realistic macroblock count\n const AVC_SLICE_HEADER_PEEK_SIZE = 6;\n // We read the NAL header plus, for slices, the start of the slice header to decode the\n // first_mb_in_slice / first_slice_segment_in_pic_flag. Make sure all of it is buffered.\n const bytesNeeded = payloadStart + (codec === 'avc' ? AVC_SLICE_HEADER_PEEK_SIZE : 1);\n if (bytesNeeded > length) {\n this.seekTo(posBeforeZero);\n break;\n }\n const headerByte0 = chunk[nalHeaderStart];\n let nalUnitType;\n let isSlice;\n let isAccessUnitStart;\n if (codec === 'avc') {\n nalUnitType = extractNalUnitTypeForAvc(headerByte0);\n isSlice = nalUnitType === AvcNalUnitType.NON_IDR_SLICE\n || nalUnitType === AvcNalUnitType.SLICE_DPA\n || nalUnitType === AvcNalUnitType.IDR;\n isAccessUnitStart = nalUnitType === AvcNalUnitType.SEI\n || nalUnitType === AvcNalUnitType.SPS\n || nalUnitType === AvcNalUnitType.PPS\n || nalUnitType === AvcNalUnitType.AUD;\n }\n else {\n nalUnitType = extractNalUnitTypeForHevc(headerByte0);\n const layerId = ((headerByte0 & 1) << 5) | (chunk[nalHeaderStart + 1] >> 3);\n if (layerId > 0) {\n // Higher layers don't delimit the base-layer frames we care about\n i += startCodeLength;\n continue;\n }\n // VCL slices: 0..RASL_R, plus the IRAP range BLA_W_LP..CRA_NUT\n isSlice = nalUnitType <= HevcNalUnitType.RASL_R\n || (nalUnitType >= HevcNalUnitType.BLA_W_LP && nalUnitType <= 21);\n // VPS..FD, prefix SEI, and the reserved/unspecified non-VCL ranges\n isAccessUnitStart = (nalUnitType >= HevcNalUnitType.VPS_NUT && nalUnitType <= 37)\n || nalUnitType === HevcNalUnitType.PREFIX_SEI_NUT\n || (nalUnitType >= 41 && nalUnitType <= 44)\n || (nalUnitType >= 48 && nalUnitType <= 55);\n }\n let isFrameBoundary = false;\n if (isSlice) {\n let startsNewPicture;\n if (codec === 'avc') {\n const headerBytes = chunk.subarray(payloadStart, payloadStart + AVC_SLICE_HEADER_PEEK_SIZE);\n const firstMacroblockInSlice = readExpGolomb(new Bitstream(headerBytes));\n startsNewPicture = !frameStartFound || firstMacroblockInSlice <= lastFirstMacroblockInSlice;\n lastFirstMacroblockInSlice = firstMacroblockInSlice;\n }\n else {\n startsNewPicture = (chunk[payloadStart] >> 7) === 1;\n }\n if (startsNewPicture) {\n if (frameStartFound) {\n isFrameBoundary = true;\n }\n else {\n frameStartFound = true;\n }\n }\n }\n else if (isAccessUnitStart && frameStartFound) {\n isFrameBoundary = true;\n }\n if (isFrameBoundary) {\n // End the packet at this start code (the next frame begins here)\n const packetLength = startCodePos - packetStartPos;\n this.seekTo(packetStartPos);\n return this.supplyPacket(packetLength, 0);\n }\n i += startCodeLength;\n }\n if (remaining < CHUNK_SIZE) {\n // End of stream\n break;\n }\n }\n // End of stream - emit whatever's left as the final packet\n if (packetStartPos !== null && this.endPos > packetStartPos) {\n const packetLength = this.endPos - packetStartPos;\n this.seekTo(packetStartPos);\n return this.supplyPacket(packetLength, 0);\n }\n }\n else {\n const codec = elementaryStream.info.codec;\n const CHUNK_SIZE = 128;\n while (true) {\n let remaining = this.ensureBuffered(CHUNK_SIZE);\n if (remaining instanceof Promise)\n remaining = await remaining;\n const startPos = this.currentPos;\n while (this.currentPos - startPos < remaining) {\n const byte = this.readU8();\n if (codec === 'aac') {\n if (byte !== 0xff) {\n continue;\n }\n this.skip(-1);\n const possibleHeaderStartPos = this.currentPos;\n let remaining = this.ensureBuffered(MAX_ADTS_FRAME_HEADER_SIZE);\n if (remaining instanceof Promise)\n remaining = await remaining;\n if (remaining < MAX_ADTS_FRAME_HEADER_SIZE) {\n return;\n }\n const headerBytes = this.readBytes(MAX_ADTS_FRAME_HEADER_SIZE);\n const header = readAdtsFrameHeader(FileSlice.tempFromBytes(headerBytes));\n if (header) {\n this.seekTo(possibleHeaderStartPos);\n let remaining = this.ensureBuffered(header.frameLength);\n if (remaining instanceof Promise)\n remaining = await remaining;\n return this.supplyPacket(remaining, Math.round(SAMPLES_PER_AAC_FRAME * TIMESCALE / elementaryStream.info.sampleRate));\n }\n else {\n this.seekTo(possibleHeaderStartPos + 1);\n }\n }\n else if (codec === 'mp3') {\n if (byte !== 0xff) {\n continue;\n }\n this.skip(-1);\n const possibleHeaderStartPos = this.currentPos;\n let remaining = this.ensureBuffered(MP3_FRAME_HEADER_SIZE);\n if (remaining instanceof Promise)\n remaining = await remaining;\n if (remaining < MP3_FRAME_HEADER_SIZE) {\n return;\n }\n const headerBytes = this.readBytes(MP3_FRAME_HEADER_SIZE);\n const word = toDataView(headerBytes).getUint32(0);\n const result = readMp3FrameHeader(word, null);\n if (result.header) {\n this.seekTo(possibleHeaderStartPos);\n let remaining = this.ensureBuffered(result.header.totalSize);\n if (remaining instanceof Promise)\n remaining = await remaining;\n const duration = result.header.audioSamplesInFrame * TIMESCALE\n / elementaryStream.info.sampleRate;\n return this.supplyPacket(remaining, Math.round(duration));\n }\n else {\n this.seekTo(possibleHeaderStartPos + 1);\n }\n }\n else if (codec === 'ac3') {\n if (byte !== 0x0b) {\n continue;\n }\n this.skip(-1);\n const possibleSyncPos = this.currentPos;\n // Need at least 5 bytes for sync word + CRC + fscod/frmsizecod\n let remaining = this.ensureBuffered(5);\n if (remaining instanceof Promise)\n remaining = await remaining;\n if (remaining < 5) {\n return;\n }\n const headerBytes = this.readBytes(5);\n // Verify sync word (0x0B77)\n if (headerBytes[0] !== 0x0b || headerBytes[1] !== 0x77) {\n this.seekTo(possibleSyncPos + 1);\n continue;\n }\n const fscod = headerBytes[4] >> 6;\n const frmsizecod = headerBytes[4] & 0x3f;\n if (fscod === 3 || frmsizecod > 37) {\n // Invalid\n this.seekTo(possibleSyncPos + 1);\n continue;\n }\n const frameSize = AC3_FRAME_SIZES[3 * frmsizecod + fscod];\n assert(frameSize !== undefined);\n this.seekTo(possibleSyncPos);\n remaining = this.ensureBuffered(frameSize);\n if (remaining instanceof Promise)\n remaining = await remaining;\n const duration = Math.round(AC3_SAMPLES_PER_FRAME * TIMESCALE / elementaryStream.info.sampleRate);\n return this.supplyPacket(remaining, duration);\n }\n else if (codec === 'eac3') {\n if (byte !== 0x0b) {\n continue;\n }\n this.skip(-1);\n const possibleSyncPos = this.currentPos;\n // Need at least 5 bytes for E-AC-3 header parsing (sync word + frmsiz + fscod/numblkscod)\n let remaining = this.ensureBuffered(5);\n if (remaining instanceof Promise)\n remaining = await remaining;\n if (remaining < 5) {\n return;\n }\n const headerBytes = this.readBytes(5);\n if (headerBytes[0] !== 0x0b || headerBytes[1] !== 0x77) {\n this.seekTo(possibleSyncPos + 1);\n continue;\n }\n const frmsiz = ((headerBytes[2] & 0x07) << 8) | headerBytes[3];\n const frameSize = (frmsiz + 1) * 2;\n const fscod = headerBytes[4] >> 6;\n const numblkscod = fscod === 3 ? 3 : (headerBytes[4] >> 4) & 0x03;\n const numblks = EAC3_NUMBLKS_TABLE[numblkscod];\n this.seekTo(possibleSyncPos);\n remaining = this.ensureBuffered(frameSize);\n if (remaining instanceof Promise)\n remaining = await remaining;\n // Duration = numblks * 256 samples per block\n const samplesPerFrame = numblks * 256;\n const duration = Math.round(samplesPerFrame * TIMESCALE / elementaryStream.info.sampleRate);\n return this.supplyPacket(remaining, duration);\n }\n else {\n throw new Error('Unhandled.');\n }\n }\n if (remaining < CHUNK_SIZE) {\n break;\n }\n }\n }\n }\n /** Supplies the context with a new encoded packet, beginning at the current position. */\n supplyPacket(packetLength, intrinsicDuration) {\n const currentPesPacket = this.getCurrentPesPacket();\n let pts;\n if (this.lastSuppliedPesPacket === currentPesPacket) {\n assert(this.nextPts !== null);\n pts = this.nextPts;\n }\n else {\n if (currentPesPacket.pts === null) {\n throw new Error(MISSING_PTS_ERROR_MESSAGE);\n }\n pts = currentPesPacket.pts;\n maybeInsertReferencePacket(this.elementaryStream, currentPesPacket);\n }\n this.lastSuppliedPesPacket = currentPesPacket;\n this.nextPts = pts + intrinsicDuration;\n const sectionStartPos = currentPesPacket.sectionStartPos;\n // The sequence number is the starting position of the section the PES packet is in, PLUS the offset within the\n // PES packet where the packet starts.\n const sequenceNumber = sectionStartPos + (this.currentPos - this.currentPesPacketPos);\n const data = this.readBytes(packetLength);\n let randomAccessIndicator = currentPesPacket.randomAccessIndicator;\n if (randomAccessIndicator === 0 && !this.elementaryStream.canBeTrustedWithKeyPackets) {\n if (this.elementaryStream.info.type === 'audio') {\n randomAccessIndicator = 1;\n }\n else {\n if (this.elementaryStream.info.decoderConfig) {\n const isKey = determineVideoPacketType(this.elementaryStream.info.codec, this.elementaryStream.info.decoderConfig, data) === 'key';\n randomAccessIndicator = Number(isKey);\n }\n }\n }\n this.suppliedPacket = {\n pts,\n data,\n sequenceNumber,\n sectionStartPos,\n randomAccessIndicator,\n };\n this.pesPackets.splice(0, this.currentPesPacketIndex);\n this.currentPesPacketIndex = 0;\n }\n }\n /**\n * A buffer that simulates decoder frame reordering to compute packet durations. Packets arrive in decode order but\n * durations are based on presentation order.\n */\n class PacketBuffer {\n constructor(backing, context) {\n this.decodeOrderPackets = [];\n this.reorderBuffer = [];\n this.presentationOrderPackets = [];\n this.reachedEnd = false;\n this.lastDuration = 0;\n this.backing = backing;\n this.context = context;\n this.reorderSize = backing.getReorderSize();\n assert(this.reorderSize >= 0);\n }\n async readNext() {\n if (this.decodeOrderPackets.length === 0) {\n // We need the next packet\n const didRead = await this.readNextPacket();\n if (!didRead) {\n return null;\n }\n }\n // Ensure we know the next packet in presentation order so we can compute the current packet's duration\n await this.ensureCurrentPacketHasNext();\n const packet = this.decodeOrderPackets[0];\n // Let's compute the duration\n const presentationIndex = this.presentationOrderPackets.indexOf(packet);\n assert(presentationIndex !== -1);\n let duration;\n if (presentationIndex === this.presentationOrderPackets.length - 1) {\n duration = this.lastDuration; // Reasonable heuristic\n }\n else {\n const nextPacket = this.presentationOrderPackets[presentationIndex + 1];\n duration = nextPacket.pts - packet.pts;\n this.lastDuration = duration;\n }\n this.decodeOrderPackets.shift();\n // Shrink the presentation array as much as possible\n while (this.presentationOrderPackets.length > 0) {\n const first = this.presentationOrderPackets[0];\n if (this.decodeOrderPackets.includes(first)) {\n break;\n }\n this.presentationOrderPackets.shift();\n }\n return { packet, duration };\n }\n async readNextPacket() {\n if (this.reachedEnd) {\n return false;\n }\n let suppliedPacket;\n if (this.context.suppliedPacket) {\n // Small optimization: there was already a supplied packet in the context, so let's first use that one\n suppliedPacket = this.context.suppliedPacket;\n }\n else {\n await this.context.markNextPacket();\n suppliedPacket = this.context.suppliedPacket;\n }\n this.context.suppliedPacket = null;\n if (!suppliedPacket) {\n this.reachedEnd = true;\n this.flushReorderBuffer();\n return false;\n }\n this.decodeOrderPackets.push(suppliedPacket);\n this.processPacketThroughReorderBuffer(suppliedPacket);\n return true;\n }\n async ensureCurrentPacketHasNext() {\n const current = this.decodeOrderPackets[0];\n assert(current);\n while (true) {\n const presentationIndex = this.presentationOrderPackets.indexOf(current);\n // Check if current packet has a next packet\n if (presentationIndex !== -1 && presentationIndex <= this.presentationOrderPackets.length - 2) {\n break;\n }\n const didRead = await this.readNextPacket();\n if (!didRead) {\n break;\n }\n }\n }\n processPacketThroughReorderBuffer(packet) {\n this.reorderBuffer.push(packet);\n // If buffer is now overfull, output the packet with smallest PTS\n if (this.reorderBuffer.length > this.reorderSize) {\n let minIndex = 0;\n for (let i = 1; i < this.reorderBuffer.length; i++) {\n if (this.reorderBuffer[i].pts < this.reorderBuffer[minIndex].pts) {\n minIndex = i;\n }\n }\n const packet = this.reorderBuffer[minIndex];\n this.presentationOrderPackets.push(packet);\n this.reorderBuffer.splice(minIndex, 1);\n }\n }\n flushReorderBuffer() {\n this.reorderBuffer.sort((a, b) => a.pts - b.pts);\n this.presentationOrderPackets.push(...this.reorderBuffer);\n this.reorderBuffer.length = 0;\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const HLS_MIME_TYPE = 'application/vnd.apple.mpegurl';\n const TAG_STREAM_INF = '#EXT-X-STREAM-INF:';\n const TAG_I_FRAME_STREAM_INF = '#EXT-X-I-FRAME-STREAM-INF:';\n const TAG_MEDIA = '#EXT-X-MEDIA:';\n const TAG_EXTINF = '#EXTINF:';\n const TAG_MAP = '#EXT-X-MAP:';\n const TAG_KEY = '#EXT-X-KEY:';\n const TAG_MEDIA_SEQUENCE = '#EXT-X-MEDIA-SEQUENCE:';\n const TAG_BYTERANGE = '#EXT-X-BYTERANGE:';\n const TAG_PROGRAM_DATE_TIME = '#EXT-X-PROGRAM-DATE-TIME:';\n const TAG_DISCONTINUITY = '#EXT-X-DISCONTINUITY';\n const TAG_TARGETDURATION = '#EXT-X-TARGETDURATION:';\n const TAG_ENDLIST = '#EXT-X-ENDLIST';\n const TAG_PLAYLIST_TYPE = '#EXT-X-PLAYLIST-TYPE:';\n const TAG_I_FRAMES_ONLY = '#EXT-X-I-FRAMES-ONLY';\n const canIgnoreLine = (line) => line.length === 0 || (line.startsWith('#') && !line.startsWith('#EXT'));\n class AttributeList {\n constructor(str) {\n this._attributes = {};\n let key = '';\n let value = '';\n let inValue = false;\n let inQuotes = false;\n for (let i = 0; i < str.length; i++) {\n const char = str[i];\n if (char === '\"') {\n inQuotes = !inQuotes;\n }\n else if (char === '=' && !inValue && !inQuotes) {\n inValue = true;\n }\n else if (char === ',' && !inQuotes) {\n if (key) {\n this._attributes[key.trim().toLowerCase()] = value;\n }\n key = '';\n value = '';\n inValue = false;\n }\n else if (inValue) {\n value += char;\n }\n else {\n key += char;\n }\n }\n if (key) {\n this._attributes[key.trim().toLowerCase()] = value;\n }\n }\n get(name) {\n return this._attributes[name.toLowerCase()] ?? null;\n }\n getAsNumber(name) {\n const value = this.get(name);\n if (value === null) {\n return null;\n }\n const num = Number(value);\n return Number.isFinite(num) ? num : null;\n }\n merge(other) {\n Object.assign(this._attributes, other._attributes);\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class SegmentedInput {\n constructor(input, path, trackDeclarations) {\n this.nextInputCacheAge = 0;\n this.inputCache = [];\n this.trackBackingsPromise = null;\n this.firstSegment = null;\n this.firstSegmentFirstTimestamps = new WeakMap();\n this.firstTimestampCache = new WeakMap();\n this.input = input;\n this.path = path;\n this.trackDeclarations = trackDeclarations;\n }\n async getDurationFromMetadata(options) {\n const lastSegment = await this.getSegmentAt(Infinity, {\n skipLiveWait: options.skipLiveWait,\n });\n if (!lastSegment) {\n return null;\n }\n return lastSegment.timestamp + lastSegment.duration;\n }\n async getUnixTimeForTimestamp(timestamp) {\n let segment = await this.getSegmentAt(timestamp, {});\n segment ??= await this.getFirstSegment({});\n if (!segment || segment.unixEpochTimestamp === null) {\n return null;\n }\n const elapsed = timestamp - segment.timestamp;\n return segment.unixEpochTimestamp + elapsed;\n }\n async getTrackBackings() {\n return this.trackBackingsPromise ??= (async () => {\n const backings = [];\n if (this.trackDeclarations) {\n for (const decl of this.trackDeclarations) {\n if (decl.type === 'video') {\n const number = arrayCount(backings, x => x.getType() === 'video') + 1;\n backings.push(new SegmentedInputInputVideoTrackBacking(this, decl, number));\n }\n else if (decl.type === 'audio') {\n const number = arrayCount(backings, x => x.getType() === 'audio') + 1;\n backings.push(new SegmentedInputInputAudioTrackBacking(this, decl, number));\n }\n }\n }\n else {\n // There are no declarations, we must determine the tracks from the first segment\n this.firstSegment = await this.getFirstSegment({});\n if (!this.firstSegment) {\n return [];\n }\n const input = this.getInputForSegment(this.firstSegment);\n const inputTracks = await input.getTracks();\n for (const track of inputTracks) {\n if (track.type === 'video') {\n const number = arrayCount(backings, x => x.getType() === 'video') + 1;\n backings.push(new SegmentedInputInputVideoTrackBacking(this, {\n id: backings.length + 1,\n type: 'video',\n }, number));\n }\n else if (track.type === 'audio') {\n const number = arrayCount(backings, x => x.getType() === 'audio') + 1;\n backings.push(new SegmentedInputInputAudioTrackBacking(this, {\n id: backings.length + 1,\n type: 'audio',\n }, number));\n }\n }\n }\n return backings;\n })();\n }\n // This operation is done a lot and can be semi-expensive, so it's good to have a cache for it\n async getFirstTimestampForInput(input) {\n const existing = this.firstTimestampCache.get(input);\n if (existing !== undefined) {\n return existing;\n }\n const firstTimestamp = await input.getFirstTimestamp();\n this.firstTimestampCache.set(input, firstTimestamp);\n return firstTimestamp;\n }\n async getMediaOffset(segment, input) {\n const firstSegment = segment.firstSegment ?? segment;\n let firstSegmentFirstTimestamp;\n if (this.firstSegmentFirstTimestamps.has(firstSegment)) {\n firstSegmentFirstTimestamp = this.firstSegmentFirstTimestamps.get(firstSegment);\n }\n else {\n const firstInput = this.getInputForSegment(firstSegment);\n firstSegmentFirstTimestamp = await this.getFirstTimestampForInput(firstInput);\n this.firstSegmentFirstTimestamps.set(firstSegment, firstSegmentFirstTimestamp);\n }\n if (firstSegment === segment) {\n return firstSegment.timestamp - firstSegmentFirstTimestamp;\n }\n const segmentFirstTimestamp = await this.getFirstTimestampForInput(input);\n const segmentElapsed = segment.timestamp - firstSegment.timestamp;\n const inputElapsed = segmentFirstTimestamp - firstSegmentFirstTimestamp;\n const difference = inputElapsed - segmentElapsed;\n if (Math.abs(difference) <= Math.min(0.25, segmentElapsed)) { // Heuristic\n // We're close enough\n return firstSegment.timestamp - firstSegmentFirstTimestamp;\n }\n else {\n // Ideally, each segment has absolute timestamps that are relative to some outside clock which is\n // consistent across segments. This is often the case, but not always. Either the container format used is\n // not timestamped at all (like ADTS), or the segments are just fucky. In this case, use the segment's\n // relative timestamp to determine where we are, and completely offset out the segment's input start\n // timestamp.\n return segment.timestamp - segmentFirstTimestamp;\n }\n }\n dispose() {\n for (const entry of this.inputCache) {\n entry.input.dispose();\n }\n this.inputCache.length = 0;\n }\n }\n class SegmentedInputInputTrackBacking {\n constructor(segmentedInput, decl, number) {\n this.packetInfos = new WeakMap();\n this.hydrationPromise = null;\n this.firstInputTrack = null;\n this.segmentedInput = segmentedInput;\n this.decl = decl;\n this.number = number;\n }\n hydrate() {\n return this.hydrationPromise ??= (async () => {\n this.segmentedInput.firstSegment ??= await this.segmentedInput.getFirstSegment({});\n if (!this.segmentedInput.firstSegment) {\n throw new Error('Missing first segment, can\\'t retrieve track.');\n }\n const input = this.segmentedInput.getInputForSegment(this.segmentedInput.firstSegment);\n const inputTracks = await input.getTracks();\n const track = inputTracks.find(x => x.type === this.decl.type && x.number === this.number);\n if (!track) {\n throw new Error('No matching track found in underlying media data.');\n }\n this.firstInputTrack = track;\n })();\n }\n getId() {\n return this.decl.id;\n }\n getType() {\n return this.decl.type;\n }\n getNumber() {\n return this.number;\n }\n /** If the backing track is already present, delegate synchronously; otherwise, hydrate first. */\n delegate(fn) {\n if (this.firstInputTrack) {\n return fn();\n }\n return this.hydrate().then(fn);\n }\n async getDecoderConfig() {\n return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig());\n }\n getHasOnlyKeyPackets() {\n return this.delegate(() => this.firstInputTrack._backing.getHasOnlyKeyPackets?.() ?? null);\n }\n getPairingMask() {\n return 1n;\n }\n getCodec() {\n return this.delegate(() => this.firstInputTrack._backing.getCodec());\n }\n getInternalCodecId() {\n return this.delegate(() => this.firstInputTrack._backing.getInternalCodecId());\n }\n getDisposition() {\n return this.delegate(() => this.firstInputTrack._backing.getDisposition());\n }\n getLanguageCode() {\n return this.delegate(() => this.firstInputTrack._backing.getLanguageCode());\n }\n getName() {\n return this.delegate(() => this.firstInputTrack._backing.getName());\n }\n getTimeResolution() {\n return this.delegate(() => this.firstInputTrack._backing.getTimeResolution());\n }\n async isRelativeToUnixEpoch() {\n await this.hydrate();\n assert(this.segmentedInput.firstSegment);\n return this.segmentedInput.firstSegment.unixEpochTimestamp === this.segmentedInput.firstSegment.timestamp;\n }\n getUnixTimeForTimestamp(timestamp) {\n return this.segmentedInput.getUnixTimeForTimestamp(timestamp);\n }\n getBitrate() {\n return this.delegate(() => this.firstInputTrack._backing.getBitrate());\n }\n getAverageBitrate() {\n return this.delegate(() => this.firstInputTrack._backing.getAverageBitrate());\n }\n getDurationFromMetadata(options) {\n return this.segmentedInput.getDurationFromMetadata(options);\n }\n getLiveRefreshInterval() {\n return this.segmentedInput.getLiveRefreshInterval();\n }\n async createAdjustedPacket(packet, segment, track) {\n assert(packet.sequenceNumber >= 0);\n assert(this.segmentedInput.firstSegment);\n const mediaOffset = await this.segmentedInput.getMediaOffset(segment, track.input);\n // If we didn't do this then sequence numbers would exceed Number.MAX_SAFE_INTEGER for Unix-timestamped segments\n const segmentTimestampRelativeToFirst = segment.timestamp - this.segmentedInput.firstSegment.timestamp;\n const modified = packet.clone({\n timestamp: roundToDivisor(packet.timestamp + mediaOffset, await track.getTimeResolution()),\n // The 1e8 assumes a max of 100 MB per second, highly unlikely to be hit, so this should guarantee\n // monotonically increasing sequence numbers across segments.\n sequenceNumber: Math.floor(1e8 * segmentTimestampRelativeToFirst) + packet.sequenceNumber,\n });\n this.packetInfos.set(modified, {\n segment,\n track,\n sourcePacket: packet,\n });\n return modified;\n }\n async getFirstPacket(options) {\n await this.hydrate();\n assert(this.segmentedInput.firstSegment);\n assert(this.firstInputTrack);\n const packet = await this.firstInputTrack._backing.getFirstPacket(options);\n if (!packet) {\n return null;\n }\n return this.createAdjustedPacket(packet, this.segmentedInput.firstSegment, this.firstInputTrack);\n }\n getNextPacket(packet, options) {\n return this._getNextInternal(packet, options, false);\n }\n getNextKeyPacket(packet, options) {\n return this._getNextInternal(packet, options, true);\n }\n async _getNextInternal(packet, options, keyframesOnly) {\n const info = this.packetInfos.get(packet);\n if (!info) {\n throw new Error('Packet was not created from this track.');\n }\n const nextPacket = keyframesOnly\n ? await info.track._backing.getNextKeyPacket(info.sourcePacket, options)\n : await info.track._backing.getNextPacket(info.sourcePacket, options);\n if (nextPacket) {\n return this.createAdjustedPacket(nextPacket, info.segment, info.track);\n }\n let currentSegment = info.segment;\n while (true) {\n const nextSegment = await this.segmentedInput.getNextSegment(currentSegment, {\n skipLiveWait: options.skipLiveWait,\n });\n if (!nextSegment) {\n return null;\n }\n const nextInput = this.segmentedInput.getInputForSegment(nextSegment);\n const nextTracks = await nextInput.getTracks();\n const nextTrack = nextTracks.find(t => t.type === info.track.type && t.number === info.track.number);\n if (!nextTrack) {\n currentSegment = nextSegment;\n continue;\n }\n const firstPacket = await nextTrack._backing.getFirstPacket(options);\n if (!firstPacket) {\n return null;\n }\n return this.createAdjustedPacket(firstPacket, nextSegment, nextTrack);\n }\n }\n getPacket(timestamp, options) {\n return this._getPacketInternal(timestamp, options, false);\n }\n getKeyPacket(timestamp, options) {\n return this._getPacketInternal(timestamp, options, true);\n }\n async _getPacketInternal(timestamp, options, keyframesOnly) {\n let currentSegment = await this.segmentedInput.getSegmentAt(timestamp, {\n skipLiveWait: options.skipLiveWait,\n });\n if (!currentSegment) {\n return null;\n }\n await this.hydrate();\n while (currentSegment) {\n const input = this.segmentedInput.getInputForSegment(currentSegment);\n const tracks = await input.getTracks();\n const track = tracks.find(t => (t.type === this.firstInputTrack.type && t.number === this.firstInputTrack.number));\n if (!track) {\n // Search the previous segment\n currentSegment = await this.segmentedInput.getPreviousSegment(currentSegment, {\n skipLiveWait: options.skipLiveWait,\n });\n continue;\n }\n const mediaOffset = await this.segmentedInput.getMediaOffset(currentSegment, input);\n const offsetTimestamp = timestamp - mediaOffset;\n const packet = keyframesOnly\n ? await track._backing.getKeyPacket(offsetTimestamp, options)\n : await track._backing.getPacket(offsetTimestamp, options);\n if (!packet) {\n // Search the previous segment\n currentSegment = await this.segmentedInput.getPreviousSegment(currentSegment, {\n skipLiveWait: options.skipLiveWait,\n });\n continue;\n }\n return this.createAdjustedPacket(packet, currentSegment, track);\n }\n return null;\n }\n }\n class SegmentedInputInputVideoTrackBacking extends SegmentedInputInputTrackBacking {\n getType() {\n return 'video';\n }\n getCodec() {\n return this.delegate(() => this.firstInputTrack._backing.getCodec());\n }\n getCodedWidth() {\n return this.delegate(() => this.firstInputTrack._backing.getCodedWidth());\n }\n getCodedHeight() {\n return this.delegate(() => this.firstInputTrack._backing.getCodedHeight());\n }\n getSquarePixelWidth() {\n return this.delegate(() => this.firstInputTrack._backing.getSquarePixelWidth());\n }\n getSquarePixelHeight() {\n return this.delegate(() => this.firstInputTrack._backing.getSquarePixelHeight());\n }\n getRotation() {\n return this.delegate(() => this.firstInputTrack._backing.getRotation());\n }\n async getColorSpace() {\n return this.delegate(() => this.firstInputTrack._backing.getColorSpace());\n }\n async canBeTransparent() {\n return this.delegate(() => this.firstInputTrack._backing.canBeTransparent());\n }\n async getDecoderConfig() {\n return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig());\n }\n }\n class SegmentedInputInputAudioTrackBacking extends SegmentedInputInputTrackBacking {\n getType() {\n return 'audio';\n }\n getCodec() {\n return this.delegate(() => this.firstInputTrack._backing.getCodec());\n }\n getNumberOfChannels() {\n return this.delegate(() => this.firstInputTrack._backing.getNumberOfChannels());\n }\n getSampleRate() {\n return this.delegate(() => this.firstInputTrack._backing.getSampleRate());\n }\n async getDecoderConfig() {\n return this.delegate(() => this.firstInputTrack._backing.getDecoderConfig());\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n polyfillSymbolDispose();\n const DEFAULT_MIN_READ_POSITION = 0;\n const DEFAULT_MAX_READ_POSITION = Infinity;\n if (typeof FinalizationRegistry !== 'undefined') {\n new FinalizationRegistry((cleanup) => {\n cleanup();\n });\n }\n /**\n * The source base class, representing a resource from which bytes can be read.\n * @group Input sources\n * @public\n */\n class Source extends EventEmitter {\n constructor() {\n super();\n /** @internal */\n this._disposed = false;\n /** @internal */\n this._refCount = 0;\n /**\n * Used internally to mark if a source stems from an HLS reading operation. Used to suppress certain warnings.\n * @internal\n */\n this._usedForHls = false;\n /**\n * FinalizationRegistry for rogue refs to this source that didn't get freed. It lives on the Source itself so that\n * in case the Source transitively points back to itself and forms a cycle (for example through a custom\n * CustomSource callback) that we're not leaking memory.\n * @internal\n */\n this._refFinalizationRegistry = null;\n /** @internal */\n this._sizePromise = null;\n /**\n * Called each time data is retrieved from the source. Will be called with the retrieved range (end exclusive).\n *\n * @deprecated Use `source.on('read', ({ start, end }) => ...)` instead.\n */\n this.onread = null;\n if (typeof FinalizationRegistry !== 'undefined') {\n this._refFinalizationRegistry = new FinalizationRegistry((source) => {\n source._decrementRefCount();\n });\n }\n }\n /**\n * Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call\n * will retrieve the size.\n *\n * Returns null if the source is unsized.\n */\n async getSizeOrNull() {\n if (this._disposed) {\n throw new InputDisposedError();\n }\n return this._sizePromise ??= (async () => {\n let size = this._getFileSize();\n if (size !== undefined) {\n return size;\n }\n await this._read(0, 1, DEFAULT_MIN_READ_POSITION, DEFAULT_MAX_READ_POSITION);\n size = this._getFileSize();\n assert(size !== undefined);\n return size;\n })();\n }\n /**\n * Resolves with the total size of the file in bytes. This function is memoized, meaning only the first call\n * will retrieve the size.\n *\n * Throws an error if the source is unsized.\n */\n async getSize() {\n if (this._disposed) {\n throw new InputDisposedError();\n }\n const result = await this.getSizeOrNull();\n if (result === null) {\n throw new Error('Cannot determine the size of an unsized source.');\n }\n return result;\n }\n /**\n * Returns a new {@link RangedSource} that maps data onto this source using the given offset and length. If a length\n * is not provided, the ranged source spans until the end of this source's data.\n *\n * Useful for reading files that are embedded within larger files.\n */\n slice(offset, length) {\n if (!Number.isInteger(offset) || offset < 0) {\n throw new TypeError('offset must be a non-negative integer.');\n }\n if (length !== undefined && (!Number.isInteger(length) || length < 0)) {\n throw new TypeError('length, when provided, must be a non-negative integer.');\n }\n return new RangedSource(this, offset, length);\n }\n /** @internal */\n _dispatchRead(start, end) {\n // eslint-disable-next-line @typescript-eslint/no-deprecated\n this.onread?.(start, end);\n this._emit('read', { start, end });\n }\n /**\n * Creates a new `SourceRef` pointing to this source. You are expected to call `.free()` on said `SourceRef` when\n * you're done with it.\n */\n ref() {\n return new SourceRef(this);\n }\n /** @internal */\n _incrementRefCount() {\n this._refCount++;\n }\n /** @internal */\n _decrementRefCount() {\n this._refCount--;\n if (this._refCount === 0) {\n this._dispose();\n this._disposed = true;\n }\n }\n }\n /**\n * A reference to a {@link Source}, used to manage a source's lifecycle. Creating a `SourceRef` via {@link Source.ref}\n * increases that source's internal reference count. As long as a source has a non-zero reference count, it is assumed\n * to still be in use. Once all references are freed via {@link SourceRef.free}, the source gets disposed.\n *\n * @group Input sources\n * @public\n */\n class SourceRef {\n /** @internal */\n constructor(source) {\n /** @internal */\n this._freed = false;\n if (source._disposed) {\n throw new Error('Cannot ref a disposed source.');\n }\n source._incrementRefCount();\n source._refFinalizationRegistry?.register(this, source, this);\n this._source = source;\n }\n /** The {@link Source} this ref references. Accessing this field throws an error after having freed the ref. */\n get source() {\n if (!this._source) {\n throw new Error('Can\\'t get source; ref has already been freed.');\n }\n return this._source;\n }\n /** Whether or not this reference has been freed via {@link SourceRef.free}. */\n get freed() {\n return this._freed;\n }\n /**\n * Frees the ref, decrementing the source's internal reference count. If the source's internal reference count\n * reaches zero, it gets disposed. To catch bugs, this method throws if the ref is already freed.\n */\n free() {\n if (this._freed) {\n throw new Error('Illegal operation: double free on SourceRef.');\n }\n const source = this.source;\n assert(source._refCount > 0);\n source._decrementRefCount();\n source._refFinalizationRegistry?.unregister(this);\n this._freed = true;\n this._source = null;\n }\n /**\n * Calls {@link SourceRef.free}.\n */\n [Symbol.dispose]() {\n if (!this.freed) {\n this.free();\n }\n }\n }\n /**\n * A source which can create new sources from file paths. Required for multi-file inputs such as HLS playlists.\n * @public\n * @group Input sources\n */\n class PathedSource extends Source {\n constructor(\n /**\n * The path that points to the root file; the entry file of the media.\n *\n * This path may be modified by the source to indicate a redirect: an updated path to perform new requests\n * relative to.\n */\n rootPath, \n /** The callback that is called for each requested file; must return a {@link Source} or {@link SourceRef}. */\n requestHandler) {\n if (typeof rootPath !== 'string') {\n throw new TypeError('rootPath must be a string.');\n }\n if (typeof requestHandler !== 'function') {\n throw new TypeError('requestHandler must be a function.');\n }\n super();\n this.rootPath = rootPath;\n this.requestHandler = requestHandler;\n }\n /** @internal */\n _resolveRequest(request) {\n const result = this.requestHandler(request);\n const handle = (result) => {\n if (!(result instanceof Source || result instanceof SourceRef)) {\n throw new TypeError('requestHandler must return or resolve to a Source or SourceRef.');\n }\n const ref = result instanceof Source\n ? result.ref()\n : result;\n ref.source._usedForHls ||= this._usedForHls;\n return ref;\n };\n if (result instanceof Promise) {\n return result.then(handle);\n }\n else {\n return handle(result);\n }\n }\n }\n const sourceRequestsAreEqual = (a, b) => {\n return a.path === b.path;\n };\n /**\n * A custom multi-file source where each file is uniquely identified by a {@link FilePath} and can be resolved to\n * an arbitrary {@link Source}.\n *\n * @public\n * @group Input sources\n */\n class CustomPathedSource extends PathedSource {\n constructor() {\n super(...arguments);\n /** @internal */\n this._root = null;\n /** @internal */\n this._rootRequest = null;\n }\n /** @internal */\n _read(start, end, minReadPosition, maxReadPosition) {\n if (!this._root) {\n if (!this._rootRequest) {\n const result = this._resolveRequest({ path: this.rootPath, isRoot: true });\n const handle = (result) => {\n const ref = result instanceof Source\n ? result.ref()\n : result;\n this._root = ref;\n this._rootRequest = null;\n return ref;\n };\n if (result instanceof Promise) {\n this._rootRequest = result.then(handle);\n }\n else {\n handle(result);\n assert(this._root);\n }\n }\n if (this._rootRequest) {\n return this._rootRequest.then(ref => ref.source._read(start, end, minReadPosition, maxReadPosition));\n }\n }\n return this._root.source._read(start, end, minReadPosition, maxReadPosition);\n }\n /** @internal */\n _getFileSize() {\n if (this._root) {\n return this._root.source._getFileSize();\n }\n return undefined;\n }\n /** @internal */\n _dispose() {\n if (this._root) {\n this._root.free();\n }\n else if (this._rootRequest) {\n void this._rootRequest\n .then(ref => ref.free());\n }\n }\n }\n /**\n * A source backed by a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). Since a\n * [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) is also a `Blob`, this is the source to use when\n * reading files off the disk.\n * @group Input sources\n * @public\n */\n class BlobSource extends Source {\n /**\n * Creates a new {@link BlobSource} backed by the specified\n * [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob).\n */\n constructor(blob, options = {}) {\n if (!(blob instanceof Blob)) {\n throw new TypeError('blob must be a Blob.');\n }\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.maxCacheSize !== undefined\n && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {\n throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');\n }\n if (options.useStreamReader !== undefined && typeof options.useStreamReader !== 'boolean') {\n throw new TypeError('options.useStreamReader, when provided, must be a boolean.');\n }\n super();\n /** @internal */\n this._readers = new WeakMap();\n this._blob = blob;\n this._options = options;\n this._orchestrator = new ReadOrchestrator({\n maxCacheSize: options.maxCacheSize ?? (8 * 2 ** 20 /* 8 MiB */),\n maxWorkerCount: 4,\n runWorker: this._runWorker.bind(this),\n prefetchProfile: PREFETCH_PROFILES.fileSystem,\n });\n this._orchestrator.fileSize = blob.size;\n }\n /** @internal */\n _getFileSize() {\n return this._orchestrator.fileSize; // Faster than blob.size\n }\n /** @internal */\n _read(start, end, minReadPosition, maxReadPosition) {\n return this._orchestrator.read(start, end, minReadPosition, maxReadPosition);\n }\n /** @internal */\n async _runWorker(worker) {\n assert(worker.strictTarget);\n let reader = this._readers.get(worker);\n if (reader === undefined) {\n // https://github.com/Vanilagy/mediabunny/issues/184\n // WebKit has critical bugs with blob.stream():\n // - WebKitBlobResource error 1 when streaming large files\n // - Memory buildup and reload loops on iOS (network process crashes)\n // - ReadableStream stalls under backpressure (especially video)\n // Affects Safari and all iOS browsers (Chrome, Firefox, etc.).\n // Use arrayBuffer() fallback for WebKit browsers.\n if ('stream' in this._blob && !isWebKit() && this._options.useStreamReader !== false) {\n // Get a reader of the blob starting at the required offset, and then keep it around\n const slice = this._blob.slice(worker.currentPos);\n reader = slice.stream().getReader();\n }\n else {\n // We'll need to use more primitive ways\n reader = null;\n }\n this._readers.set(worker, reader);\n }\n while (worker.currentPos < worker.targetPos && !worker.aborted) {\n if (reader) {\n const { done, value } = await reader.read();\n if (done) {\n this._orchestrator.onWorkerFinished(worker);\n throw new Error('Blob reader stopped unexpectedly before all requested data was read.');\n }\n if (worker.aborted) {\n break;\n }\n this._dispatchRead(worker.currentPos, worker.currentPos + value.length);\n this._orchestrator.supplyWorkerData(worker, value);\n }\n else {\n const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer();\n if (worker.aborted) {\n break;\n }\n this._dispatchRead(worker.currentPos, worker.currentPos + data.byteLength);\n this._orchestrator.supplyWorkerData(worker, new Uint8Array(data));\n }\n }\n this._orchestrator.signalWorkerStoppedRunning(worker);\n if (worker.aborted) {\n // MDN: \"Calling this method signals a loss of interest in the stream by a consumer.\"\n await reader?.cancel();\n }\n }\n /** @internal */\n _dispose() {\n this._orchestrator.dispose();\n }\n }\n const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB\n const DEFAULT_RETRY_DELAY = ((previousAttempts, error, src) => {\n // Check if this could be a CORS error. If so, we cannot recover from it and\n // should not attempt to retry.\n // CORS errors are intentionally not opaque, so we need to rely on heuristics.\n const couldBeCorsError = error instanceof Error\n && (error.message.includes('Failed to fetch') // Chrome\n || error.message.includes('Load failed') // Safari\n || error.message.includes('NetworkError when attempting to fetch resource') // Firefox\n )\n && typeof window !== 'undefined'; // CORS only happens in browser environments\n if (couldBeCorsError) {\n let originOfSrc = null;\n // Checking if the origin is different, because only then a CORS error could originate\n try {\n if (typeof window !== 'undefined' && typeof window.location !== 'undefined') {\n originOfSrc = new URL(src instanceof Request ? src.url : src, window.location.href).origin;\n }\n }\n catch {\n // URL parse failed\n }\n // If user is offline, it is probably not a CORS error.\n const isOnline = typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;\n if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {\n Logging._warn(`Request will not be retried because a CORS error was suspected due to different origins. You can`\n + ` modify this behavior by providing your own function for the 'getRetryDelay' option.`);\n return null;\n }\n }\n return Math.min(2 ** (previousAttempts - 2), 16);\n });\n const warnedOrigins = new Set();\n /**\n * A source backed by a URL. This is useful for reading data from the network. Requests will be made using an optimized\n * reading and prefetching pattern to minimize request count and latency.\n * @group Input sources\n * @public\n */\n class UrlSource extends PathedSource {\n /**\n * Creates a new {@link UrlSource} backed by the resource at the specified URL.\n *\n * When passing a `Request` instance, note that its `signal` will be overridden by Mediabunny; if you want to cancel\n * ongoing requests, use {@link Input.dispose}.\n */\n constructor(url, options = {}) {\n if (typeof url !== 'string'\n && !(url instanceof URL)\n && !(typeof Request !== 'undefined' && url instanceof Request)) {\n throw new TypeError('url must be a string, URL or Request.');\n }\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.requestInit !== undefined && (!options.requestInit || typeof options.requestInit !== 'object')) {\n throw new TypeError('options.requestInit, when provided, must be an object.');\n }\n if (options.getRetryDelay !== undefined && typeof options.getRetryDelay !== 'function') {\n throw new TypeError('options.getRetryDelay, when provided, must be a function.');\n }\n if (options.maxCacheSize !== undefined\n && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {\n throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');\n }\n if (options.parallelism !== undefined && (!Number.isInteger(options.parallelism) || options.parallelism < 1)) {\n throw new TypeError('options.parallelism, when provided, must be a positive number.');\n }\n if (options.fetchFn !== undefined && typeof options.fetchFn !== 'function') {\n throw new TypeError('options.fetchFn, when provided, must be a function.');\n // Won't bother validating this function beyond this\n }\n const urlString = url instanceof Request\n ? url.url\n : url instanceof URL\n ? url.href\n : url;\n super(urlString, request => new UrlSource(request.path, this._options));\n /** @internal */\n this._offset = 0;\n /** @internal */\n this._length = null;\n /**\n * Note that this value being true does NOT mean the file size can't change anymore; it just signals that we have at\n * least checked if we know the file size or not.\n * @internal\n */\n this._fileSizeDetermined = false;\n this._url = url;\n this._options = options;\n this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;\n // A user-supplied Range header is interpreted as a byte offset (and optional length) into the resource. We\n // pull it out of the request and remember it for subsequent requests.\n this._requestInit = { ...options.requestInit };\n let rangeHeaderValue = null;\n if (options.requestInit?.headers) {\n const headers = { ...normalizeHeaders(options.requestInit.headers) };\n const rangeKey = Object.keys(headers).find(key => key.toLowerCase() === 'range');\n if (rangeKey !== undefined) {\n rangeHeaderValue = headers[rangeKey];\n delete headers[rangeKey];\n this._requestInit.headers = headers;\n }\n }\n if (url instanceof Request) {\n const requestRange = url.headers.get('Range');\n if (requestRange !== null) {\n rangeHeaderValue ??= requestRange;\n // Clone the request so we don't mutate the user's object, then strip the Range header\n const strippedRequest = new Request(url);\n strippedRequest.headers.delete('Range');\n this._url = strippedRequest;\n }\n }\n if (rangeHeaderValue !== null) {\n const parsed = parseByteRangeHeader(rangeHeaderValue);\n if (parsed) {\n this._offset = parsed.offset;\n this._length = parsed.length;\n }\n }\n // Most files in the real-world have a single sequential access pattern, but having two in parallel can\n // also happen\n const DEFAULT_PARALLELISM = 2;\n this._orchestrator = new ReadOrchestrator({\n maxCacheSize: options.maxCacheSize ?? (64 * 2 ** 20 /* 64 MiB */),\n maxWorkerCount: options.parallelism ?? DEFAULT_PARALLELISM,\n runWorker: this._runWorker.bind(this),\n prefetchProfile: PREFETCH_PROFILES.network,\n });\n }\n /** @internal */\n _getFileSize() {\n if (!this._fileSizeDetermined) {\n return this._length !== null ? this._length : undefined;\n }\n const baseSize = this._orchestrator.fileSize;\n if (baseSize === null) {\n return this._length !== null ? this._length : null;\n }\n return clamp(baseSize - this._offset, 0, this._length ?? Infinity);\n }\n /** @internal */\n _read(start, end, minReadPosition, maxReadPosition) {\n if (this._length !== null && end > this._length) {\n return null;\n }\n const offset = this._offset;\n const result = this._orchestrator.read(offset + start, offset + end, Math.max(offset + minReadPosition, offset), offset + Math.min(maxReadPosition, this._length ?? Infinity));\n const processResult = (result) => {\n if (!result) {\n return null;\n }\n result.offset -= this._offset;\n return result;\n };\n if (result instanceof Promise) {\n return result.then(processResult);\n }\n else {\n return processResult(result);\n }\n }\n /** @internal */\n async _runWorker(worker) {\n // The outer loop is for resuming a request if it dies mid-response\n while (true) {\n const abortController = new AbortController();\n const response = await retriedFetch(this._options.fetchFn ?? fetch, this._url, mergeRequestInit(this._requestInit, {\n headers: {\n // Always sending a range request is a good way to probe if the server supports them\n Range: `bytes=${worker.currentPos}-`,\n },\n signal: abortController.signal,\n }), this._getRetryDelay, () => this._disposed);\n if (!response.ok) {\n // eslint-disable-next-line @typescript-eslint/no-base-to-string\n throw new Error(`Error fetching ${String(this._url)}: ${response.status} ${response.statusText}`);\n }\n if (response.redirected) {\n // Modify our own root path so that future subrequests get made relative to the redirected URL\n this.rootPath = response.url;\n }\n outer: if (this._orchestrator.fileSize === null) {\n // See if we can deduce the file size from the response\n const contentRange = response.headers.get('Content-Range');\n if (contentRange) {\n const match = /\\/(\\d+)/.exec(contentRange);\n if (match) {\n this._orchestrator.supplyFileSize(Number(match[1]));\n break outer;\n }\n }\n const contentLength = response.headers.get('Content-Length');\n if (contentLength) {\n // Note: For range requests, this is _technically_ not correct, as the range response could contain\n // less data than was requested. In practice, it seems most servers don't do this though, and the\n // Content-Length header actually contains the length until the end of the file.\n this._orchestrator.supplyFileSize(worker.currentPos + Number(contentLength));\n }\n }\n this._fileSizeDetermined = true; // Yes, this is correct even if file size is still null\n if (response.status !== 206) {\n if (!this._usedForHls) {\n const url = new URL(this._url instanceof Request ? this._url.url : this._url, typeof window !== 'undefined' ? window.location.href : undefined);\n if (url.origin !== 'null'\n // Don't show the warning for M3U8 playlist files, it's irrelevant for those\n && !(url.pathname.endsWith('.m3u8') || url.pathname.endsWith('.m3u'))) {\n if (!warnedOrigins.has(url.origin)) {\n Logging._warn(`HTTP server (origin ${url.origin}) did not respond to a range request with 206 Partial`\n + ' Content, meaning the entire resource will now be downloaded. To enable efficient'\n + ' media file streaming across a network, please make sure your server supports'\n + ' range requests.');\n warnedOrigins.add(url.origin);\n }\n }\n }\n worker.currentPos = 0;\n this._orchestrator.options.maxCacheSize = Infinity; // 🤷\n if (this._orchestrator.fileSize !== null) {\n worker.targetPos = this._orchestrator.fileSize;\n }\n else {\n // The server is dumb, doesn't even surface the content length, but we'll work with it.\n worker.targetPos = Infinity;\n worker.strictTarget = false;\n }\n this._orchestrator.consolidateEverythingIntoOneWorker(worker);\n }\n if (!response.body) {\n throw new Error('Missing HTTP response body stream. The used fetch function must provide the response body as a'\n + ' ReadableStream.');\n }\n const reader = response.body.getReader();\n while (true) {\n if (worker.currentPos >= worker.targetPos || worker.aborted) {\n abortController.abort();\n this._orchestrator.signalWorkerStoppedRunning(worker);\n return;\n }\n let readResult;\n try {\n readResult = await reader.read();\n }\n catch (error) {\n if (this._disposed) {\n // No need to try to retry\n throw error;\n }\n const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);\n if (retryDelayInSeconds !== null) {\n Logging._error('Error while reading response stream. Attempting to resume.', error);\n await wait(1000 * retryDelayInSeconds);\n break;\n }\n else {\n throw error;\n }\n }\n if (worker.aborted) {\n continue; // Cleanup happens in next iteration\n }\n const { done, value } = readResult;\n if (done) {\n if (worker.currentPos >= worker.targetPos) {\n // All data was delivered, we're good\n this._orchestrator.onWorkerFinished(worker);\n return;\n }\n if (worker.strictTarget) {\n // The response stopped early, before the target. This can happen if server decides to cap range\n // requests arbitrarily, even if the request had an uncapped end. In this case, let's fetch the\n // rest of the data using a new request.\n break;\n }\n else {\n // Assume we have simply reached the end of the resource\n this._orchestrator.onWorkerFinished(worker);\n return;\n }\n }\n this._dispatchRead(worker.currentPos, worker.currentPos + value.length);\n this._orchestrator.supplyWorkerData(worker, value);\n }\n }\n // The previous UrlSource had logic for circumventing https://issues.chromium.org/issues/436025873; I haven't\n // been able to observe this bug with the new UrlSource (maybe because we're using response streaming), so the\n // logic for that has vanished for now. Leaving a comment here if this becomes relevant again.\n }\n /** @internal */\n _dispose() {\n this._orchestrator.dispose();\n }\n }\n const BYTE_RANGE_REGEX = /^bytes=(\\d+)-(\\d*)$/;\n const parseByteRangeHeader = (value) => {\n const match = BYTE_RANGE_REGEX.exec(value.trim());\n if (!match) {\n return null;\n }\n const offset = Number(match[1]);\n const end = match[2] === '' ? null : Number(match[2]);\n if (end !== null && end < offset) {\n return null;\n }\n return {\n offset,\n length: end !== null ? end - offset + 1 : null,\n };\n };\n /**\n * A source backed by a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) of\n * `Uint8Array`, representing an append-only byte stream of unknown length. This is the source to use for incrementally\n * streaming in input files that are still being constructed and whose size we don't yet know, like for example the\n * output chunks of [MediaRecorder](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder).\n *\n * This source is *unsized*, meaning calls to `.getSize()` will throw and readers are more limited due to the\n * lack of random file access. You should only use this source with sequential access patterns, such as reading all\n * packets from start to end. This source does not work well with random access patterns unless you increase its\n * max cache size.\n *\n * @group Input sources\n * @public\n */\n class ReadableStreamSource extends Source {\n /** Creates a new {@link ReadableStreamSource} backed by the specified `ReadableStream<Uint8Array>`. */\n constructor(stream, options = {}) {\n if (!(stream instanceof ReadableStream)) {\n throw new TypeError('stream must be a ReadableStream.');\n }\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.maxCacheSize !== undefined\n && (!isNumber(options.maxCacheSize) || options.maxCacheSize < 0)) {\n throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');\n }\n super();\n /** @internal */\n this._reader = null;\n /** @internal */\n this._cache = [];\n /** @internal */\n this._pendingSlices = [];\n /** @internal */\n this._currentIndex = 0;\n /** @internal */\n this._targetIndex = 0;\n /** @internal */\n this._maxRequestedIndex = 0;\n /** @internal */\n this._endIndex = null;\n /** @internal */\n this._pulling = false;\n this._stream = stream;\n this._maxCacheSize = options.maxCacheSize ?? (32 * 2 ** 20 /* 32 MiB */);\n }\n /** @internal */\n _getFileSize() {\n return this._endIndex; // Starts out as null, meaning this source is unsized\n }\n /** @internal */\n _read(start, end) {\n if (this._endIndex !== null && end > this._endIndex) {\n return null;\n }\n this._maxRequestedIndex = Math.max(this._maxRequestedIndex, end);\n const cacheStartIndex = binarySearchLessOrEqual(this._cache, start, x => x.start);\n const cacheStartEntry = cacheStartIndex !== -1 ? this._cache[cacheStartIndex] : null;\n if (cacheStartEntry && cacheStartEntry.start <= start && end <= cacheStartEntry.end) {\n // The request can be satisfied with a single cache entry\n return {\n bytes: cacheStartEntry.bytes,\n view: cacheStartEntry.view,\n offset: cacheStartEntry.start,\n };\n }\n let lastEnd = start;\n const bytes = new Uint8Array(end - start);\n if (cacheStartIndex !== -1) {\n // Walk over the cache to see if we can satisfy the request using multiple cache entries\n for (let i = cacheStartIndex; i < this._cache.length; i++) {\n const cacheEntry = this._cache[i];\n if (cacheEntry.start >= end) {\n break;\n }\n const cappedStart = Math.max(start, cacheEntry.start);\n if (cappedStart > lastEnd) {\n // We're too far behind\n this._throwDueToCacheMiss();\n }\n const cappedEnd = Math.min(end, cacheEntry.end);\n if (cappedStart < cappedEnd) {\n bytes.set(cacheEntry.bytes.subarray(cappedStart - cacheEntry.start, cappedEnd - cacheEntry.start), cappedStart - start);\n lastEnd = cappedEnd;\n }\n }\n }\n if (lastEnd === end) {\n return {\n bytes,\n view: toDataView(bytes),\n offset: start,\n };\n }\n // We need to pull more data\n if (this._currentIndex > lastEnd) {\n // We're too far behind\n this._throwDueToCacheMiss();\n }\n const { promise, resolve, reject } = promiseWithResolvers();\n this._pendingSlices.push({\n start,\n end,\n bytes,\n resolve,\n reject,\n });\n this._targetIndex = Math.max(this._targetIndex, end);\n // Start pulling from the stream if we're not already doing it\n if (!this._pulling) {\n this._pulling = true;\n void this._pull()\n .catch((error) => {\n this._pulling = false;\n if (this._pendingSlices.length > 0) {\n this._pendingSlices.forEach(x => x.reject(error)); // Make sure to propagate any errors\n this._pendingSlices.length = 0;\n }\n else {\n throw error; // So it doesn't get swallowed\n }\n });\n }\n return promise;\n }\n /** @internal */\n _throwDueToCacheMiss() {\n throw new Error('Read is before the cached region. With ReadableStreamSource, you must access the data more'\n + ' sequentially or increase the size of its cache.');\n }\n /** @internal */\n async _pull() {\n this._reader ??= this._stream.getReader();\n // This is the loop that keeps pulling data from the stream until a target index is reached, filling requests\n // in the process\n while (this._currentIndex < this._targetIndex && !this._disposed) {\n const { done, value } = await this._reader.read();\n if (done) {\n for (const pendingSlice of this._pendingSlices) {\n pendingSlice.resolve(null);\n }\n this._pendingSlices.length = 0;\n this._endIndex = this._currentIndex; // We know how long the file is now!\n break;\n }\n const startIndex = this._currentIndex;\n const endIndex = this._currentIndex + value.byteLength;\n this._dispatchRead(startIndex, endIndex);\n // Fill the pending slices with the data\n for (let i = 0; i < this._pendingSlices.length; i++) {\n const pendingSlice = this._pendingSlices[i];\n const cappedStart = Math.max(startIndex, pendingSlice.start);\n const cappedEnd = Math.min(endIndex, pendingSlice.end);\n if (cappedStart < cappedEnd) {\n pendingSlice.bytes.set(value.subarray(cappedStart - startIndex, cappedEnd - startIndex), cappedStart - pendingSlice.start);\n if (cappedEnd === pendingSlice.end) {\n // Pending slice fully filled\n pendingSlice.resolve({\n bytes: pendingSlice.bytes,\n view: toDataView(pendingSlice.bytes),\n offset: pendingSlice.start,\n });\n this._pendingSlices.splice(i, 1);\n i--;\n }\n }\n }\n this._cache.push({\n start: startIndex,\n end: endIndex,\n bytes: value,\n view: toDataView(value),\n age: 0, // Unused\n });\n // Do cache eviction, based on the distance from the last-requested index. It's important that we do it like\n // this and not based on where the reader is at, because if the reader is fast, we'll unnecessarily evict\n // data that we still might need.\n while (this._cache.length > 0) {\n const firstEntry = this._cache[0];\n const distance = this._maxRequestedIndex - firstEntry.end;\n if (distance <= this._maxCacheSize) {\n break;\n }\n this._cache.shift();\n }\n this._currentIndex += value.byteLength;\n }\n this._pulling = false;\n }\n /** @internal */\n _dispose() {\n for (const pendingSlice of this._pendingSlices) {\n pendingSlice.reject(new InputDisposedError());\n }\n this._pendingSlices.length = 0;\n this._cache.length = 0;\n void this._reader?.cancel();\n }\n }\n const PREFETCH_PROFILES = {\n fileSystem: (start, end) => {\n const padding = 2 ** 16;\n start = Math.floor((start - padding) / padding) * padding;\n end = Math.ceil((end + padding) / padding) * padding;\n return { start, end };\n },\n network: (start, end, workers) => {\n // Add a slight bit of start padding because backwards reading is painful\n const paddingStart = 2 ** 16;\n start = Math.max(0, Math.floor((start - paddingStart) / paddingStart) * paddingStart);\n // Remote resources have extreme latency (relatively speaking), so the benefit from intelligent\n // prefetching is great. The network prefetch strategy is as follows: When we notice\n // successive reads to a worker's read region, we prefetch more data at the end of that region,\n // growing exponentially (up to a cap). This performs well for real-world use cases: Either we read a\n // small part of the file once and then never need it again, in which case the requested about of data\n // is small. Or, we're repeatedly doing a sequential access pattern (common in media files), in which\n // case we can become more and more confident to prefetch more and more data.\n for (const worker of workers) {\n const maxExtensionAmount = 8 * 2 ** 20; // 8 MiB\n // When the read region cross the threshold point, we trigger a prefetch. This point is typically\n // in the middle of the worker's read region, or a fixed offset from the end if the region has grown\n // really large.\n const thresholdPoint = Math.max((worker.startPos + worker.targetPos) / 2, worker.targetPos - maxExtensionAmount);\n if (closedIntervalsOverlap(start, end, thresholdPoint, worker.targetPos)) {\n const size = worker.targetPos - worker.startPos;\n // If we extend by maxExtensionAmount\n const a = Math.ceil((size + 1) / maxExtensionAmount) * maxExtensionAmount;\n // If we extend to the next power of 2\n const b = 2 ** Math.ceil(Math.log2(size + 1));\n const extent = Math.min(b, a);\n end = Math.max(end, worker.startPos + extent);\n }\n }\n end = Math.max(end, start + URL_SOURCE_MIN_LOAD_AMOUNT);\n return {\n start,\n end,\n };\n },\n };\n /**\n * Godclass for orchestrating complex, cached read operations. The reading model is as follows: Any reading task is\n * delegated to a *worker*, which is a sequential reader positioned somewhere along the file. All workers run in\n * parallel and can be stopped and resumed in their forward movement. When read requests come in, this orchestrator will\n * first try to satisfy the request with only the cached data. If this isn't possible, workers are spun up for all\n * missing parts (or existing workers are repurposed), and these workers will then fill the holes in the data as they\n * march along the file.\n */\n class ReadOrchestrator {\n constructor(options) {\n this.options = options;\n this.fileSize = null;\n this.nextAge = 0; // Used for multiple things\n this.workers = [];\n this.cache = [];\n this.currentCacheSize = 0;\n this.disposed = false;\n this.queuedReads = [];\n }\n read(innerStart, innerEnd, minReadPosition, maxReadPosition) {\n assert(!this.disposed);\n const prefetchRange = this.options.prefetchProfile(innerStart, innerEnd, this.workers);\n const outerStart = Math.max(prefetchRange.start, minReadPosition);\n const outerEnd = Math.min(prefetchRange.end, this.fileSize ?? Infinity, maxReadPosition);\n assert(outerStart <= innerStart && innerEnd <= outerEnd);\n let result = null;\n const innerCacheStartIndex = binarySearchLessOrEqual(this.cache, innerStart, x => x.start);\n const innerStartEntry = innerCacheStartIndex !== -1 ? this.cache[innerCacheStartIndex] : null;\n // See if the read request can be satisfied by a single cache entry\n if (innerStartEntry && innerStartEntry.start <= innerStart && innerEnd <= innerStartEntry.end) {\n innerStartEntry.age = this.nextAge++;\n result = {\n bytes: innerStartEntry.bytes,\n view: innerStartEntry.view,\n offset: innerStartEntry.start,\n };\n // Can't return yet though, still need to check if the prefetch range might lie outside the cached area\n }\n const outerCacheStartIndex = binarySearchLessOrEqual(this.cache, outerStart, x => x.start);\n const bytes = result ? null : new Uint8Array(innerEnd - innerStart);\n let contiguousBytesWriteEnd = 0; // Used to track if the cache is able to completely cover the bytes\n let lastEnd = outerStart;\n // The \"holes\" in the cache (the parts we need to load)\n const outerHoles = [];\n // Loop over the cache and build up the list of holes\n if (outerCacheStartIndex !== -1) {\n for (let i = outerCacheStartIndex; i < this.cache.length; i++) {\n const entry = this.cache[i];\n if (entry.start >= outerEnd) {\n break;\n }\n if (entry.end <= outerStart) {\n continue;\n }\n const cappedOuterStart = Math.max(outerStart, entry.start);\n const cappedOuterEnd = Math.min(outerEnd, entry.end);\n assert(cappedOuterStart <= cappedOuterEnd);\n if (lastEnd < cappedOuterStart) {\n outerHoles.push({ start: lastEnd, end: cappedOuterStart });\n }\n lastEnd = cappedOuterEnd;\n if (bytes) {\n const cappedInnerStart = Math.max(innerStart, entry.start);\n const cappedInnerEnd = Math.min(innerEnd, entry.end);\n if (cappedInnerStart < cappedInnerEnd) {\n const relativeOffset = cappedInnerStart - innerStart;\n // Fill the relevant section of the bytes with the cached data\n bytes.set(entry.bytes.subarray(cappedInnerStart - entry.start, cappedInnerEnd - entry.start), relativeOffset);\n if (relativeOffset === contiguousBytesWriteEnd) {\n contiguousBytesWriteEnd = cappedInnerEnd - innerStart;\n }\n }\n }\n entry.age = this.nextAge++;\n }\n if (lastEnd < outerEnd) {\n outerHoles.push({ start: lastEnd, end: outerEnd });\n }\n }\n else {\n outerHoles.push({ start: outerStart, end: outerEnd });\n }\n if (bytes && contiguousBytesWriteEnd >= bytes.length) {\n // Multiple cache entries were able to completely cover the requested bytes!\n result = {\n bytes,\n view: toDataView(bytes),\n offset: innerStart,\n };\n }\n if (outerHoles.length === 0) {\n assert(result);\n return result;\n }\n // We need to read more data, so now we're in async land\n const { promise, resolve, reject } = promiseWithResolvers();\n const innerHoles = [];\n for (const outerHole of outerHoles) {\n const cappedStart = Math.max(innerStart, outerHole.start);\n const cappedEnd = Math.min(innerEnd, outerHole.end);\n if (cappedStart === outerHole.start && cappedEnd === outerHole.end) {\n innerHoles.push(outerHole); // Can reuse without allocating a new object\n }\n else if (cappedStart < cappedEnd) {\n innerHoles.push({ start: cappedStart, end: cappedEnd });\n }\n }\n const pendingSlice = bytes && {\n start: innerStart,\n bytes,\n holes: innerHoles,\n resolve,\n reject,\n };\n // Fire off workers to take care of patching the holes\n outer: for (const outerHole of outerHoles) {\n for (const worker of this.workers) {\n const addedToWorker = this.checkHoleAgainstWorker(worker, outerHole, pendingSlice ? [pendingSlice] : []);\n if (addedToWorker) {\n this.checkQueuedReadsAgainstWorker(worker);\n continue outer;\n }\n }\n // We need to spawn a new worker\n const strictTarget = outerHole.end < outerEnd || this.fileSize !== null;\n const newWorker = this.createWorker(outerHole.start, outerHole.end, strictTarget);\n if (newWorker) {\n if (pendingSlice) {\n newWorker.pendingSlices = [pendingSlice];\n }\n this.runWorker(newWorker);\n }\n else {\n // Max worker count has been reached, let's queue a read for later\n let index = binarySearchLessOrEqual(this.queuedReads, outerHole.start, x => x.hole.start);\n let entry = index !== -1\n ? this.queuedReads[index]\n : null;\n if (entry && outerHole.start <= entry.hole.end) {\n entry.hole.end = Math.max(entry.hole.end, outerHole.end);\n entry.strictTarget &&= strictTarget;\n if (pendingSlice) {\n entry.pendingSlices.push(pendingSlice);\n }\n }\n else {\n index++;\n entry = {\n hole: {\n // Clone the hole because it might be mutated later\n start: outerHole.start,\n end: outerHole.end,\n },\n strictTarget,\n pendingSlices: pendingSlice ? [pendingSlice] : [],\n age: this.nextAge++,\n };\n this.queuedReads.splice(index, 0, entry);\n }\n // Merge with any subsequent entries that overlap\n while (index + 1 < this.queuedReads.length) {\n const nextEntry = this.queuedReads[index + 1];\n if (nextEntry.hole.start > entry.hole.end) {\n break;\n }\n entry.hole.end = Math.max(entry.hole.end, nextEntry.hole.end);\n entry.pendingSlices.push(...nextEntry.pendingSlices);\n entry.strictTarget &&= nextEntry.strictTarget;\n entry.age = Math.min(entry.age, nextEntry.age);\n this.queuedReads.splice(index + 1, 1);\n }\n }\n }\n if (!result) {\n assert(bytes);\n result = promise.then(bytes => bytes && {\n bytes,\n view: toDataView(bytes),\n offset: innerStart,\n });\n }\n else {\n // The requested region was satisfied by the cache, but the entire prefetch region was not\n promise.catch((error) => {\n if (this.disposed) {\n return; // Swallow the error\n }\n // Nobody's awaiting this result but an errored read is still notable\n throw error;\n });\n }\n return result;\n }\n checkHoleAgainstWorker(worker, hole, pendingSlices) {\n // A small tolerance in the case that the requested region is *just* after the target position of an\n // existing worker. In that case, it's probably more efficient to repurpose that worker than to spawn\n // another one so close to it\n const gapTolerance = 2 ** 17;\n // This check also implies worker.currentPos <= hole.start, a critical condition\n if (closedIntervalsOverlap(hole.start - gapTolerance, hole.start, worker.currentPos, worker.targetPos)) {\n worker.targetPos = Math.max(worker.targetPos, hole.end); // Update the worker's target position\n for (let i = 0; i < pendingSlices.length; i++) {\n const pendingSlice = pendingSlices[i];\n if (!worker.pendingSlices.includes(pendingSlice)) {\n worker.pendingSlices.push(pendingSlice);\n }\n }\n if (!worker.running) {\n // Kick it off if it's idle\n this.runWorker(worker);\n }\n return true;\n }\n return false;\n }\n checkQueuedReadsAgainstWorker(worker) {\n let wasTrueOnce = false;\n for (let i = 0; i < this.queuedReads.length; i++) {\n const queuedRead = this.queuedReads[i];\n const result = this.checkHoleAgainstWorker(worker, queuedRead.hole, queuedRead.pendingSlices);\n if (result) {\n this.queuedReads.splice(i, 1);\n i--;\n wasTrueOnce = true;\n }\n else if (wasTrueOnce) {\n // We can stop since the holes are sorted\n break;\n }\n }\n }\n createWorker(startPos, targetPos, strictTarget) {\n if (this.workers.length >= this.options.maxWorkerCount) {\n let oldestWorker = null;\n let oldestIndex = null;\n for (let i = 0; i < this.workers.length; i++) {\n const worker = this.workers[i];\n if (!worker.running\n && worker.pendingSlices.length === 0\n && (!oldestWorker || worker.age < oldestWorker.age)) {\n oldestIndex = i;\n oldestWorker = worker;\n }\n }\n if (oldestWorker) {\n // LRU eviction\n assert(oldestIndex !== null);\n assert(oldestWorker.pendingSlices.length === 0);\n this.workers.splice(oldestIndex, 1);\n }\n else {\n return null; // All workers are still running, we can't create a new one\n }\n }\n const worker = {\n startPos,\n currentPos: startPos,\n targetPos,\n strictTarget,\n running: false,\n // Due to async shenanigans, it can happen that workers are started after disposal. In this case, instead of\n // simply not creating the worker, we allow it to run but immediately label it as aborted, so it can then\n // shut itself down.\n aborted: this.disposed,\n pendingSlices: [],\n age: this.nextAge++,\n };\n this.workers.push(worker);\n return worker;\n }\n runWorker(worker) {\n assert(!worker.running);\n assert(worker.currentPos < worker.targetPos);\n worker.running = true;\n worker.age = this.nextAge++;\n void this.options.runWorker(worker)\n .catch((error) => {\n worker.running = false;\n if (worker.pendingSlices.length > 0) {\n worker.pendingSlices.forEach(x => x.reject(error)); // Make sure to propagate any errors\n worker.pendingSlices.length = 0;\n }\n else if (!worker.aborted && !this.disposed) {\n throw error; // So it doesn't get swallowed\n }\n })\n .finally(() => {\n if (worker.running) {\n // Rare, but can happen with multiple concurrent reads. In this case, don't do anything.\n return;\n }\n if (this.queuedReads.length > 0) {\n let oldestIndex = 0;\n for (let i = 1; i < this.queuedReads.length; i++) {\n const queuedRead = this.queuedReads[i];\n if (queuedRead.age < this.queuedReads[oldestIndex].age) {\n oldestIndex = i;\n }\n }\n const queuedRead = this.queuedReads[oldestIndex];\n const newWorker = this.createWorker(queuedRead.hole.start, queuedRead.hole.end, queuedRead.strictTarget);\n if (!newWorker) {\n // In high-contention cases, it could be that we've already reached max worker count, so in this\n // case we don't do anything.\n return;\n }\n this.queuedReads.splice(oldestIndex, 1);\n newWorker.pendingSlices = queuedRead.pendingSlices;\n this.runWorker(newWorker);\n }\n });\n }\n consolidateEverythingIntoOneWorker(worker) {\n // Here we merge everything into one \"megaworker\" that spans the entire file. We assume the passed-in worker\n // is already configured to be a megaworker.\n const uniqueSlices = new Set(worker.pendingSlices);\n for (let i = 0; i < this.workers.length; i++) {\n const otherWorker = this.workers[i];\n if (otherWorker === worker) {\n continue;\n }\n for (const slice of otherWorker.pendingSlices) {\n uniqueSlices.add(slice);\n }\n otherWorker.aborted = true;\n otherWorker.pendingSlices.length = 0;\n this.workers.splice(i, 1);\n i--;\n }\n for (let i = 0; i < this.queuedReads.length; i++) {\n const queuedRead = this.queuedReads[i];\n for (const slice of queuedRead.pendingSlices) {\n uniqueSlices.add(slice);\n }\n }\n worker.pendingSlices = [...uniqueSlices];\n this.queuedReads.length = 0;\n }\n /** Called by a worker when it has read some data. */\n supplyWorkerData(worker, bytes) {\n assert(!worker.aborted);\n const start = worker.currentPos;\n const end = start + bytes.length;\n this.insertIntoCache({\n start,\n end,\n bytes,\n view: toDataView(bytes),\n age: this.nextAge++,\n });\n worker.currentPos += bytes.length;\n if (worker.currentPos > worker.targetPos) {\n // In case it overshoots\n worker.targetPos = worker.currentPos;\n this.checkQueuedReadsAgainstWorker(worker);\n }\n // Now, let's see if we can use the read bytes to fill any pending slice\n for (let i = 0; i < worker.pendingSlices.length; i++) {\n const pendingSlice = worker.pendingSlices[i];\n const clampedStart = Math.max(start, pendingSlice.start);\n const clampedEnd = Math.min(end, pendingSlice.start + pendingSlice.bytes.length);\n if (clampedStart < clampedEnd) {\n pendingSlice.bytes.set(bytes.subarray(clampedStart - start, clampedEnd - start), clampedStart - pendingSlice.start);\n }\n for (let j = 0; j < pendingSlice.holes.length; j++) {\n // The hole is intentionally not modified here if the read section starts somewhere in the middle of\n // the hole. We don't need to do \"hole splitting\", since the workers are spawned *by* the holes,\n // meaning there's always a worker which will consume the hole left to right.\n const hole = pendingSlice.holes[j];\n if (start <= hole.start && end > hole.start) {\n hole.start = end;\n }\n if (hole.end <= hole.start) {\n pendingSlice.holes.splice(j, 1);\n j--;\n }\n }\n if (pendingSlice.holes.length === 0) {\n // The slice has been fulfilled, everything has been read. Let's resolve the promise\n pendingSlice.resolve(pendingSlice.bytes);\n worker.pendingSlices.splice(i, 1);\n i--;\n }\n }\n // Remove other idle workers if we \"ate\" into their territory\n for (let i = 0; i < this.workers.length; i++) {\n const otherWorker = this.workers[i];\n if (worker === otherWorker || otherWorker.running) {\n continue;\n }\n if (closedIntervalsOverlap(start, end, otherWorker.currentPos, otherWorker.targetPos)) {\n this.workers.splice(i, 1);\n i--;\n }\n }\n }\n supplyFileSize(size) {\n assert(this.fileSize === null);\n this.fileSize = size;\n // Trim the workers with this new information\n for (const worker of this.workers) {\n worker.targetPos = Math.min(worker.targetPos, size);\n worker.strictTarget = true;\n for (let i = 0; i < worker.pendingSlices.length; i++) {\n const pendingSlice = worker.pendingSlices[i];\n for (const hole of pendingSlice.holes) {\n if (hole.end > size) {\n // Can't satisfy this slice anymore\n pendingSlice.resolve(null);\n worker.pendingSlices.splice(i, 1);\n i--;\n break;\n }\n }\n }\n }\n // Trim the queued reads as well\n for (let i = 0; i < this.queuedReads.length; i++) {\n const queuedRead = this.queuedReads[i];\n if (queuedRead.hole.start >= size) {\n // Entirely out of bounds\n for (const slice of queuedRead.pendingSlices)\n slice.resolve(null);\n this.queuedReads.splice(i, 1);\n i--;\n }\n else if (queuedRead.hole.end > size) {\n // Partially out of bounds\n queuedRead.hole.end = size;\n queuedRead.strictTarget = true;\n for (let j = 0; j < queuedRead.pendingSlices.length; j++) {\n const slice = queuedRead.pendingSlices[j];\n // If the slice itself is out of bounds, resolve it\n if (slice.start >= size) {\n slice.resolve(null);\n queuedRead.pendingSlices.splice(j, 1);\n j--;\n }\n }\n }\n }\n }\n signalWorkerStoppedRunning(worker) {\n worker.running = false;\n if (!worker.aborted) {\n // When a worker stops running, that means it has hit its targetPos. It might still have pendingSlices\n // assigned, but this is because those pending slices cover data that other workers are assigned to fill.\n // Since targetPos has been reached, we can confidently say that this worker has completed its share of work\n // on the pending slices and must no longer care about them.\n worker.pendingSlices.length = 0;\n }\n }\n /** Called when a worker reaches the end of the underlying data and must be cleaned up. */\n onWorkerFinished(worker) {\n const index = this.workers.indexOf(worker);\n assert(index !== -1);\n worker.running = false;\n this.workers.splice(index, 1);\n if (this.fileSize === null) {\n // We can now deduce the file size!\n this.supplyFileSize(worker.currentPos);\n }\n for (const pendingSlice of worker.pendingSlices) {\n pendingSlice.resolve(null);\n }\n }\n insertIntoCache(entry) {\n if (this.options.maxCacheSize === 0) {\n return; // No caching\n }\n let insertionIndex = binarySearchLessOrEqual(this.cache, entry.start, x => x.start) + 1;\n if (insertionIndex > 0) {\n const previous = this.cache[insertionIndex - 1];\n if (previous.end >= entry.end) {\n // Previous entry swallows the one to be inserted; we don't need to do anything\n return;\n }\n if (previous.end > entry.start) {\n // Partial overlap with the previous entry, let's join\n const joined = new Uint8Array(entry.end - previous.start);\n joined.set(previous.bytes, 0);\n joined.set(entry.bytes, entry.start - previous.start);\n this.currentCacheSize += entry.end - previous.end;\n previous.bytes = joined;\n previous.view = toDataView(joined);\n previous.end = entry.end;\n // Do the rest of the logic with the previous entry instead\n insertionIndex--;\n entry = previous;\n }\n else {\n this.cache.splice(insertionIndex, 0, entry);\n this.currentCacheSize += entry.bytes.length;\n }\n }\n else {\n this.cache.splice(insertionIndex, 0, entry);\n this.currentCacheSize += entry.bytes.length;\n }\n for (let i = insertionIndex + 1; i < this.cache.length; i++) {\n const next = this.cache[i];\n if (entry.end <= next.start) {\n // Even if they touch, we don't wanna merge them, no need\n break;\n }\n if (entry.end >= next.end) {\n // The inserted entry completely swallows the next entry\n this.cache.splice(i, 1);\n this.currentCacheSize -= next.bytes.length;\n i--;\n continue;\n }\n // Partial overlap, let's join\n const joined = new Uint8Array(next.end - entry.start);\n joined.set(entry.bytes, 0);\n joined.set(next.bytes, next.start - entry.start);\n this.currentCacheSize -= entry.end - next.start; // Subtract the overlap\n entry.bytes = joined;\n entry.view = toDataView(joined);\n entry.end = next.end;\n this.cache.splice(i, 1);\n break; // After the join case, we're done: the next entry cannot possibly overlap with the inserted one.\n }\n // LRU eviction of cache entries\n while (this.currentCacheSize > this.options.maxCacheSize) {\n let oldestIndex = 0;\n let oldestEntry = this.cache[0];\n for (let i = 1; i < this.cache.length; i++) {\n const entry = this.cache[i];\n if (entry.age < oldestEntry.age) {\n oldestIndex = i;\n oldestEntry = entry;\n }\n }\n if (this.currentCacheSize - oldestEntry.bytes.length <= this.options.maxCacheSize) {\n // Don't evict if it would shrink the cache below the max size\n break;\n }\n this.cache.splice(oldestIndex, 1);\n this.currentCacheSize -= oldestEntry.bytes.length;\n }\n }\n dispose() {\n for (const worker of this.workers) {\n for (const slice of worker.pendingSlices) {\n slice.reject(new InputDisposedError());\n }\n worker.pendingSlices.length = 0;\n worker.aborted = true;\n }\n for (const queuedRead of this.queuedReads) {\n for (const slice of queuedRead.pendingSlices) {\n slice.reject(new InputDisposedError());\n }\n }\n this.workers.length = 0;\n this.cache.length = 0;\n this.queuedReads.length = 0;\n this.disposed = true;\n }\n }\n /**\n * A source that covers a range (offset + length) of another source. Useful for reading files that are embedded within\n * larger files.\n *\n * @group Input sources\n * @public\n */\n class RangedSource extends Source {\n /** @internal */\n constructor(baseSource, offset, length) {\n super();\n /** @internal */\n this._ref = null;\n if (baseSource._disposed) {\n throw new Error('Cannot create a slice of a disposed source.');\n }\n this._baseSource = baseSource;\n this._offset = offset;\n this._length = length ?? null;\n }\n /** @internal */\n _getFileSize() {\n const baseSize = this._baseSource._getFileSize();\n if (baseSize === undefined) {\n return this._length !== null\n ? this._length\n : undefined;\n }\n if (baseSize === null) {\n if (this._length !== null) {\n return this._length;\n }\n else {\n return null;\n }\n }\n return clamp(baseSize - this._offset, 0, this._length ?? Infinity);\n }\n /** @internal */\n _read(start, end, minReadPosition, maxReadPosition) {\n if (this._length !== null && end > this._length) {\n return null;\n }\n const result = this._baseSource._read(this._offset + start, this._offset + end, this._offset + minReadPosition, this._offset + maxReadPosition);\n const processResult = (result) => {\n if (!result) {\n return null;\n }\n result.offset -= this._offset;\n return result;\n };\n if (result instanceof Promise) {\n return result.then(processResult);\n }\n else {\n return processResult(result);\n }\n }\n /** @internal */\n _dispose() {\n this._ref?.free();\n }\n ref() {\n this._ref ??= this._baseSource.ref();\n return super.ref();\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n var __addDisposableResource$1 = (undefined && undefined.__addDisposableResource) || function (env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n };\n var __disposeResources$1 = (undefined && undefined.__disposeResources) || (function (SuppressedError) {\n return function (env) {\n function fail(e) {\n env.error = env.hasError ? new SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n };\n })(typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n });\n const IV_STRING_REGEX = /^0[xX][0-9a-fA-F]+$/;\n const BASE64_DATA_URI_REGEX = /^data:.*;base64,/i;\n class HlsSegmentedInput extends SegmentedInput {\n constructor(demuxer, path, trackDeclarations, lines) {\n super(demuxer.input, path, trackDeclarations);\n this.segments = [];\n this.nextLines = null;\n this.currentUpdateSegmentsPromise = null;\n this.streamHasEnded = false;\n this.lastSegmentUpdateTime = -Infinity;\n this.refreshInterval = 5; // Reasonable default in case the playlist doesn't specify it\n this.rootPath = path;\n this.demuxer = demuxer;\n this.nextLines = lines;\n }\n runUpdateSegments() {\n return this.currentUpdateSegmentsPromise ??= (async () => {\n try {\n const remainingWaitTimeMs = this.getRemainingWaitTimeMs();\n if (remainingWaitTimeMs > 0) {\n await wait(remainingWaitTimeMs);\n }\n this.lastSegmentUpdateTime = performance.now();\n await this.updateSegments();\n }\n finally {\n this.currentUpdateSegmentsPromise = null;\n }\n })();\n }\n getRemainingWaitTimeMs() {\n const elapsed = performance.now() - this.lastSegmentUpdateTime;\n const result = Math.max(0, 1000 * this.refreshInterval - elapsed);\n if (result <= 50) {\n // If only a little bit of time is left, don't wait at all; this removes the chance for timing race\n // conditions when running a task every `refreshInterval` seconds\n return 0;\n }\n return result;\n }\n /**\n * Reads and parses the segment info from the playlist file. When called more than one, it updates the existing\n * segments by appending the new ones. Existing segments are never removed.\n */\n async updateSegments() {\n let lines = this.nextLines;\n this.nextLines = null;\n if (!lines) {\n const env_1 = { stack: [], error: void 0, hasError: false };\n try {\n const ref = __addDisposableResource$1(env_1, await this.demuxer.input._getSourceUncached({ path: this.rootPath, isRoot: false }), false);\n const reader = new Reader(ref.source);\n const slice = await reader.requestEntireFile();\n assert(slice);\n lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine });\n if (ref.source instanceof PathedSource) {\n // Copy back the source's path to become aware of potential redirects\n this.rootPath = ref.source.rootPath;\n }\n }\n catch (e_1) {\n env_1.error = e_1;\n env_1.hasError = true;\n }\n finally {\n __disposeResources$1(env_1);\n }\n }\n const offsetTimestampsByDateTime = this.input._formatOptions.hls?.offsetTimestampsByDateTime !== false;\n let headerRead = false;\n let accumulatedTime = 0;\n let accumulatedUnixTime = null;\n let nextSegmentDuration = null;\n let currentKey = null;\n let nextSequenceNumber = 0;\n let currentFirstSegment = null;\n let currentInitSegment = null;\n let lastByteRangeEnd = null;\n let nextByteRange = null;\n let lastProgramDateTimeSeconds = null;\n let targetDuration = null;\n let segmentSeen = false;\n // Used for repeated parses where our job it is to only add the new segments\n let prevLastSegment = last(this.segments) ?? null;\n const parseByteRange = (content) => {\n const atIndex = content.indexOf('@');\n const length = Number(atIndex === -1 ? content : content.slice(0, atIndex));\n if (!Number.isInteger(length) || length < 0) {\n throw new Error(`Invalid #EXT-X-BYTERANGE length '${content}'.`);\n }\n let offset = null;\n if (atIndex !== -1) {\n offset = Number(content.slice(atIndex + 1));\n if (!Number.isInteger(offset) || offset < 0) {\n throw new Error(`Invalid #EXT-X-BYTERANGE offset '${content}'.`);\n }\n }\n return { length, offset };\n };\n const setNextSequenceNumber = (number) => {\n nextSequenceNumber = number;\n if (prevLastSegment) {\n assert(prevLastSegment.sequenceNumber !== null);\n if (prevLastSegment.sequenceNumber < number) {\n // The sequence number has finally exceeded the last sequence number we knew, meaning we can now\n // continue the segment list from there. Set some data to continue where we left off.\n accumulatedTime = prevLastSegment.timestamp + prevLastSegment.duration;\n currentFirstSegment = prevLastSegment.firstSegment;\n currentInitSegment = prevLastSegment.initSegment;\n lastProgramDateTimeSeconds = prevLastSegment.lastProgramDateTimeSeconds;\n accumulatedUnixTime = prevLastSegment.unixEpochTimestamp !== null\n ? prevLastSegment.unixEpochTimestamp + prevLastSegment.duration\n : null;\n prevLastSegment = null;\n }\n }\n };\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n if (!headerRead) {\n if (line !== '#EXTM3U') {\n throw new Error('Invalid M3U8 file; expected first line to be #EXTM3U.');\n }\n headerRead = true;\n continue;\n }\n if (!line.startsWith('#')) {\n if (!prevLastSegment) {\n if (nextSegmentDuration === null) {\n throw new Error('Invalid M3U8 file; a segment must be preceded by an #EXTINF tag.');\n }\n let key = currentKey;\n if (key && key.method === 'AES-128' && !key.iv) {\n // \"the Media Sequence Number is to be used as the IV when decrypting a Media Segment, by\n // putting its big-endian binary representation into a 16-octet (128-bit) buffer and padding\n // (on the left) with zeros\"\n const iv = new Uint8Array(AES_128_BLOCK_SIZE);\n const view = toDataView(iv);\n view.setUint32(8, Math.floor(nextSequenceNumber / (2 ** 32)));\n view.setUint32(12, nextSequenceNumber);\n key = { ...key, iv };\n }\n const fullPath = joinPaths(this.rootPath, line);\n const location = {\n path: fullPath,\n offset: nextByteRange?.offset ?? 0,\n length: nextByteRange?.length ?? null,\n };\n const segment = {\n timestamp: accumulatedTime,\n unixEpochTimestamp: accumulatedUnixTime,\n firstSegment: currentFirstSegment,\n sequenceNumber: nextSequenceNumber,\n location,\n duration: nextSegmentDuration,\n encryption: key,\n initSegment: currentInitSegment,\n lastProgramDateTimeSeconds,\n };\n currentFirstSegment ??= segment;\n accumulatedTime += nextSegmentDuration;\n if (accumulatedUnixTime !== null) {\n accumulatedUnixTime += nextSegmentDuration;\n }\n this.segments.push(segment);\n }\n nextSegmentDuration = null;\n if (nextByteRange === null) {\n lastByteRangeEnd = null;\n }\n else {\n nextByteRange = null;\n }\n setNextSequenceNumber(nextSequenceNumber + 1);\n }\n if (line.startsWith(TAG_EXTINF)) {\n if (prevLastSegment) {\n segmentSeen = true;\n continue;\n }\n if (!segmentSeen) {\n if (lastProgramDateTimeSeconds === null && nextSequenceNumber > 0 && targetDuration !== null) {\n // Offset the first segment's start timestamp by the following:\n accumulatedTime = nextSequenceNumber * targetDuration;\n }\n segmentSeen = true;\n }\n const extinfContent = line.slice(TAG_EXTINF.length);\n const commaIndex = extinfContent.indexOf(',');\n const durationStr = commaIndex === -1 ? extinfContent : extinfContent.slice(0, commaIndex);\n const duration = Number(durationStr);\n if (!Number.isFinite(duration) || duration < 0) {\n throw new Error(`Invalid #EXTINF tag duration '${durationStr}'.`);\n }\n nextSegmentDuration = duration;\n }\n else if (line.startsWith(TAG_MAP)) {\n const attributes = new AttributeList(line.slice(TAG_MAP.length));\n const uri = attributes.get('uri');\n if (!uri) {\n throw new Error('Invalid #EXT-X-MAP tag; missing URI attribute.');\n }\n const byteRange = attributes.get('byterange');\n let parsedByteRange = null;\n if (byteRange !== null) {\n parsedByteRange = parseByteRange(byteRange);\n }\n if (parsedByteRange && parsedByteRange.offset === null) {\n throw new Error('Invalid #EXT-X-MAP tag; BYTERANGE attribute must have a specified offset.');\n }\n if (!prevLastSegment) {\n const fullPath = joinPaths(this.rootPath, uri);\n const location = {\n path: fullPath,\n offset: parsedByteRange?.offset ?? 0,\n length: parsedByteRange?.length ?? null,\n };\n if (currentKey?.method === 'AES-128' && !currentKey.iv) {\n // Required by the spec\n throw new Error('IV attribute must be set on #EXT-X-KEY tag preceding the #EXT-X-MAP tag.');\n }\n const segment = {\n timestamp: accumulatedTime,\n unixEpochTimestamp: accumulatedUnixTime,\n firstSegment: null,\n sequenceNumber: null,\n location,\n duration: 0,\n encryption: currentKey,\n initSegment: null,\n lastProgramDateTimeSeconds,\n };\n // Accumulated time and sequence number are not updated in this case\n currentInitSegment = segment;\n }\n nextSegmentDuration = null;\n if (nextByteRange === null) {\n lastByteRangeEnd = null;\n }\n else {\n nextByteRange = null;\n }\n }\n else if (line.startsWith(TAG_KEY)) {\n const attributes = new AttributeList(line.slice(TAG_KEY.length));\n const method = attributes.get('method');\n if (method === 'NONE') {\n currentKey = null;\n }\n else if (method === 'AES-128') {\n const uri = attributes.get('uri');\n if (!uri) {\n throw new Error('Invalid #EXT-X-KEY: AES-128 requires a URI attribute.');\n }\n let iv = null;\n const ivString = attributes.get('iv');\n if (ivString) {\n if (!IV_STRING_REGEX.test(ivString)) {\n throw new Error(`Unsupported IV format '${ivString}'.`);\n }\n let hex = ivString.slice(2);\n hex = hex.padStart(AES_128_BLOCK_SIZE * 2, '0');\n iv = new Uint8Array(AES_128_BLOCK_SIZE);\n for (let i = 0; i < AES_128_BLOCK_SIZE; i++) {\n const startIndex = -AES_128_BLOCK_SIZE * 2 + i;\n iv[i] = parseInt(hex.slice(startIndex, startIndex + 2), 16);\n }\n }\n const keyFormat = attributes.get('keyformat') ?? 'identity';\n if (keyFormat !== 'identity') {\n throw new Error('For AES-128 encryption, only the \\'identity\\' KEYFORMAT is currently supported. If you'\n + ' think other formats should be supported, please raise an issue.');\n }\n currentKey = {\n method: 'AES-128',\n keyUri: joinPaths(this.rootPath, uri),\n iv,\n keyFormat,\n };\n }\n else if (method === 'SAMPLE-AES' || method === 'SAMPLE-AES-CTR') {\n const uri = attributes.get('uri');\n if (!uri) {\n throw new Error(`Invalid #EXT-X-KEY: ${method} requires a URI attribute.`);\n }\n const keyFormat = attributes.get('keyformat') ?? 'identity';\n if (keyFormat === 'identity') {\n throw new Error('For SAMPLE-AES and SAMPLE-AES-CTR encryption, the \\'identity\\' KEYFORMAT is not'\n + ' supported. If you think this format should be supported, please raise an issue.');\n }\n let psshBox = null;\n if (BASE64_DATA_URI_REGEX.test(uri)) {\n const commaIndex = uri.indexOf(',');\n const bytes = base64ToBytes(uri.slice(commaIndex + 1));\n if (bytes.length >= 8\n && bytes[4] === 0x70\n && bytes[5] === 0x73\n && bytes[6] === 0x73\n && bytes[7] === 0x68) {\n const size = toDataView(bytes).getUint32(0);\n psshBox = parsePsshBoxContents(bytes.subarray(8, Math.min(size, bytes.length)));\n }\n }\n currentKey = {\n method,\n psshBox,\n };\n }\n else {\n throw new Error(`Unsupported encryption method '${method}'. If you think this method should be supported,`\n + ` please raise an issue.`);\n }\n }\n else if (line.startsWith(TAG_MEDIA_SEQUENCE)) {\n const value = line.slice(TAG_MEDIA_SEQUENCE.length);\n const number = Number(value);\n if (!Number.isInteger(number) || number < 0) {\n throw new Error(`Invalid EXT-X-MEDIA-SEQUENCE value '${value}'.`);\n }\n setNextSequenceNumber(number);\n }\n else if (line.startsWith(TAG_BYTERANGE)) {\n const parsed = parseByteRange(line.slice(TAG_BYTERANGE.length));\n if (parsed.offset === null) {\n if (lastByteRangeEnd === null) {\n throw new Error('Invalid M3U8 file; #EXT-X-BYTERANGE without offset requires a previous byte range.');\n }\n parsed.offset = lastByteRangeEnd;\n }\n nextByteRange = parsed;\n lastByteRangeEnd = parsed.offset + parsed.length;\n }\n else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) {\n if (prevLastSegment) {\n // No need to spend effort parsing dates if we're gonna discard it anyway. Also would be wrong to do\n // the segment shifting!\n continue;\n }\n const dateTime = line.slice(TAG_PROGRAM_DATE_TIME.length);\n const dateTimeMs = Date.parse(dateTime);\n if (!Number.isFinite(dateTimeMs)) {\n continue;\n }\n const dateTimeSeconds = dateTimeMs / 1000;\n if (lastProgramDateTimeSeconds === dateTimeSeconds) {\n continue;\n }\n if (lastProgramDateTimeSeconds === null && this.segments.length > 0) {\n // \"If the first EXT-X-PROGRAM-DATE-TIME tag in a Playlist appears after\n // one or more Media Segment URIs, the client SHOULD extrapolate\n // backward from that tag (using EXTINF durations and/or media\n // timestamps) to associate dates with those segments.\"\n const lastSegment = last(this.segments);\n const lastSegmentEnd = lastSegment.timestamp + lastSegment.duration;\n const offset = dateTimeSeconds - lastSegmentEnd;\n for (const segment of this.segments) {\n segment.unixEpochTimestamp = segment.timestamp + offset;\n if (offsetTimestampsByDateTime) {\n segment.timestamp = segment.unixEpochTimestamp;\n }\n }\n }\n lastProgramDateTimeSeconds = dateTimeSeconds;\n accumulatedUnixTime = dateTimeSeconds;\n if (offsetTimestampsByDateTime) {\n accumulatedTime = dateTimeSeconds; // Snap the accumulated time into Unix space\n }\n }\n else if (line === TAG_DISCONTINUITY) {\n currentFirstSegment = null;\n // Note: the init segment is not reset; the #EXT-X-MAP statement simply lasts until the next\n // #EXT-X-MAP statement.\n }\n else if (line.startsWith(TAG_TARGETDURATION)) {\n const value = line.slice(TAG_TARGETDURATION.length);\n const duration = Number(value);\n if (!Number.isFinite(duration) || duration < 0) {\n throw new Error(`Invalid EXT-X-TARGETDURATION value '${value}'.`);\n }\n this.refreshInterval = duration;\n targetDuration = duration;\n }\n else if (line === TAG_ENDLIST) {\n this.streamHasEnded = true;\n break; // No need to keep reading after this\n }\n else if (line.startsWith(TAG_PLAYLIST_TYPE)) {\n const type = line.slice(TAG_PLAYLIST_TYPE.length);\n if (type.toLowerCase() === 'vod') {\n // A VOD playlist cannot be updated per spec so we can be sure the stream has ended\n this.streamHasEnded = true;\n }\n }\n }\n if (!headerRead) {\n throw new Error('Invalid M3U8 file; no #EXTM3U header.');\n }\n }\n async getFirstSegment() {\n if (this.segments.length === 0) {\n await this.runUpdateSegments();\n }\n return this.segments[0] ?? null;\n }\n async getSegmentAt(timestamp, options) {\n if (this.segments.length === 0) {\n await this.runUpdateSegments();\n }\n // If we're skipping the live wait BUT there's no wait time, we're actually not lazy for the first iteration\n let isLazy = !!options.skipLiveWait && this.getRemainingWaitTimeMs() > 0;\n while (true) {\n const index = binarySearchLessOrEqual(this.segments, timestamp, x => x.timestamp);\n if (index === -1) {\n return null;\n }\n if (index < this.segments.length - 1 || this.streamHasEnded || isLazy) {\n return this.segments[index];\n }\n const segment = this.segments[index];\n if (timestamp < segment.timestamp + segment.duration) {\n return segment;\n }\n await this.runUpdateSegments();\n if (options.skipLiveWait) {\n isLazy = true; // Definitely lazy in the next iteration\n }\n }\n }\n async getNextSegment(segment, options) {\n const index = this.segments.indexOf(segment);\n assert(index !== -1);\n const nextIndex = index + 1;\n // If we're skipping the live wait BUT there's no wait time, we're actually not lazy for the first iteration\n let isLazy = !!options.skipLiveWait && this.getRemainingWaitTimeMs() > 0;\n while (true) {\n if (nextIndex < this.segments.length) {\n return this.segments[nextIndex];\n }\n if (this.streamHasEnded || isLazy) {\n return null;\n }\n await this.runUpdateSegments();\n if (options.skipLiveWait) {\n isLazy = true; // Definitely lazy in the next iteration\n }\n }\n }\n async getPreviousSegment(segment) {\n const index = this.segments.indexOf(segment);\n assert(index !== -1);\n return this.segments[index - 1] ?? null;\n }\n getInputForSegment(segment) {\n const hlsSegment = segment;\n const cacheEntry = this.inputCache.find(x => x.segment === hlsSegment);\n if (cacheEntry) {\n cacheEntry.age = this.nextInputCacheAge++;\n return cacheEntry.input;\n }\n let initInput = null;\n if (hlsSegment.initSegment || hlsSegment.firstSegment) {\n initInput = this.getInputForSegment((hlsSegment.initSegment ?? hlsSegment.firstSegment));\n }\n const formatOptions = {\n ...this.input._formatOptions,\n isobmff: {\n ...this.input._formatOptions.isobmff,\n // Intercept calls to resolveKeyId to inject our psshBox knowledge into it\n resolveKeyId: this.input._formatOptions.isobmff?.resolveKeyId && ((options) => {\n if (!hlsSegment.encryption\n || !(hlsSegment.encryption.method === 'SAMPLE-AES'\n || hlsSegment.encryption.method === 'SAMPLE-AES-CTR')\n || !hlsSegment.encryption.psshBox) {\n return this.input._formatOptions.isobmff.resolveKeyId(options);\n }\n let psshBoxes = options.psshBoxes;\n const { psshBox } = hlsSegment.encryption;\n if ((psshBox.keyIds === null || psshBox.keyIds.includes(options.keyId))\n && !psshBoxes.some(x => psshBoxesAreEqual(x, psshBox))) {\n psshBoxes = [...psshBoxes, psshBox];\n }\n return this.input._formatOptions.isobmff.resolveKeyId({ ...options, psshBoxes });\n }),\n },\n };\n const input = new Input({\n source: new CustomPathedSource(hlsSegment.location.path, async (request) => {\n assert(request.isRoot); // Shouldn't fail since we don't allow recursive HLS\n const proxiedRequest = {\n ...request,\n isRoot: false,\n };\n let ref;\n const needsSlice = hlsSegment.location.offset > 0 || hlsSegment.location.length !== null;\n if (!hlsSegment.encryption\n || hlsSegment.encryption.method === 'SAMPLE-AES'\n || hlsSegment.encryption.method === 'SAMPLE-AES-CTR') {\n ref = await this.input._getSourceCached(proxiedRequest);\n if (needsSlice) {\n const slice = ref.source.slice(hlsSegment.location.offset, hlsSegment.location.length ?? undefined);\n const sliceRef = slice.ref();\n ref.free();\n ref = sliceRef;\n }\n }\n else if (hlsSegment.encryption.method === 'AES-128') {\n const encryption = hlsSegment.encryption;\n assert(encryption.iv);\n let ciphertextRef = await this.input._getSourceCached(proxiedRequest);\n if (needsSlice) {\n // Slice before decrypting\n const slice = ciphertextRef.source.slice(hlsSegment.location.offset, hlsSegment.location.length ?? undefined);\n const sliceRef = slice.ref();\n ciphertextRef.free();\n ciphertextRef = sliceRef;\n }\n const ciphertextReader = new Reader(ciphertextRef.source);\n const stream = createAes128CbcDecryptStream(ciphertextReader, async () => {\n const env_2 = { stack: [], error: void 0, hasError: false };\n try {\n const keyRef = __addDisposableResource$1(env_2, await this.input._getSourceCached({ path: encryption.keyUri, isRoot: false }, ENCRYPTION_KEY_CACHE_GROUP), false);\n const keyReader = new Reader(keyRef.source);\n const keySlice = await keyReader.requestSlice(0, AES_128_BLOCK_SIZE);\n if (!keySlice) {\n throw new Error('Invalid AES-128 key; expected at least 16 bytes of data.');\n }\n const key = readBytes(keySlice, AES_128_BLOCK_SIZE);\n return { key, iv: encryption.iv };\n }\n catch (e_2) {\n env_2.error = e_2;\n env_2.hasError = true;\n }\n finally {\n __disposeResources$1(env_2);\n }\n }, () => {\n ciphertextRef.free();\n });\n ref = new ReadableStreamSource(stream).ref();\n }\n else {\n assert(false);\n }\n return ref;\n }),\n // Do not allow recursive HLS. Cool on paper, but allows for nasty infinite-depth request trees.\n formats: this.input._formats.filter(x => !(x instanceof HlsInputFormat)),\n initInput: initInput ?? undefined,\n formatOptions,\n });\n input._onFormatDetermined = (format) => {\n if ((hlsSegment.encryption?.method === 'SAMPLE-AES' || hlsSegment.encryption?.method === 'SAMPLE-AES-CTR')\n && !format._isIsobmff) {\n // These methods can also be used for formats such as MPEG-TS\n // eslint-disable-next-line @stylistic/max-len\n // (see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/HLS_Sample_Encryption/Encryption/Encryption.html)\n // but we don't support them there yet, so instead of silently decrypting nothing, we throw an error.\n throw new Error('The SAMPLE-AES and SAMPLE-AES-CTR encryption methods are currently only supported for'\n + ' ISOBMFF files.');\n }\n };\n this.inputCache.push({\n segment: hlsSegment,\n input,\n age: this.nextInputCacheAge++,\n });\n const MAX_INPUT_CACHE_SIZE = 4;\n if (this.inputCache.length > MAX_INPUT_CACHE_SIZE) {\n const minAgeIndex = arrayArgmin(this.inputCache, x => x.age);\n assert(minAgeIndex !== -1);\n this.inputCache.splice(minAgeIndex, 1);\n // DON'T dispose here; the Input might still be used! The source disposal will happen with GC logic\n }\n return input;\n }\n async getLiveRefreshInterval() {\n if (this.getRemainingWaitTimeMs() === 0) {\n await this.runUpdateSegments();\n }\n return this.streamHasEnded ? null : this.refreshInterval;\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class HlsDemuxer extends Demuxer {\n constructor(input) {\n super(input);\n this.metadataPromise = null;\n this.trackBackings = null;\n this.internalTracks = null;\n this.segmentedInputs = [];\n this.hasMasterPlaylist = true;\n }\n readMetadata() {\n return this.metadataPromise ??= (async () => {\n assert(this.input._rootSource instanceof PathedSource);\n const slice = await this.input._reader.requestEntireFile();\n assert(slice);\n const lines = readAllLines(slice, slice.length, { ignore: canIgnoreLine });\n // Important: get the root path AFTER reading data to get the final root path, possibly affected by\n // redirects. Any follow requests should be related to the redirected path, not the original one.\n const { rootPath } = this.input._rootSource;\n const variantStreams = [];\n const mediaTags = [];\n // Let's first iterate through the entire file, collecting all variant streams and media tags\n for (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.startsWith(TAG_STREAM_INF)) {\n const streamInfLineNumber = i;\n const playlistPath = lines[++i];\n if (playlistPath === undefined) {\n throw new Error('Incorrect M3U8 file; a line must follow the #EXT-X-STREAM-INF tag.');\n }\n const fullPath = joinPaths(rootPath, playlistPath);\n const attributes = new AttributeList(line.slice(TAG_STREAM_INF.length));\n const bandwidth = attributes.getAsNumber('bandwidth');\n if (bandwidth === null) {\n throw new Error('Invalid M3U8 file; #EXT-X-STREAM-INF tag requires a BANDWIDTH attribute with a valid'\n + ' numerical value.');\n }\n variantStreams.push({\n fullPath,\n attributes,\n lineNumber: streamInfLineNumber,\n hasOnlyKeyPackets: false,\n });\n }\n else if (line.startsWith(TAG_I_FRAME_STREAM_INF)) {\n const attributes = new AttributeList(line.slice(TAG_I_FRAME_STREAM_INF.length));\n const playlistPath = attributes.get('uri');\n if (playlistPath === null) {\n throw new Error('Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a URI attribute.');\n }\n const bandwidth = attributes.getAsNumber('bandwidth');\n if (bandwidth === null) {\n throw new Error('Invalid M3U8 file; #EXT-X-I-FRAME-STREAM-INF tag requires a BANDWIDTH attribute with a'\n + ' valid numerical value.');\n }\n const fullPath = joinPaths(rootPath, playlistPath);\n variantStreams.push({\n fullPath,\n attributes,\n lineNumber: i,\n hasOnlyKeyPackets: true,\n });\n }\n else if (line.startsWith(TAG_MEDIA)) {\n const attributes = new AttributeList(line.slice(TAG_MEDIA.length));\n const type = attributes.get('type');\n if (type === null) {\n throw new Error('Invalid M3U8 file; #EXT-X-MEDIA tag requires a TYPE attribute.');\n }\n const groupId = attributes.get('group-id');\n if (groupId === null) {\n throw new Error('Invalid M3U8 file; #EXT-X-MEDIA tag requires a GROUP-ID attribute.');\n }\n let fullPath = null;\n const uri = attributes.get('uri');\n if (uri !== null) {\n fullPath = joinPaths(rootPath, uri);\n }\n mediaTags.push({ fullPath, attributes, lineNumber: i });\n }\n else if (line === TAG_I_FRAMES_ONLY) ;\n else if (line.startsWith(TAG_EXTINF)) {\n // This is a media playlist, not a master playlist\n const segmentedInput = new HlsSegmentedInput(this, rootPath, null, lines);\n this.segmentedInputs = [segmentedInput];\n this.hasMasterPlaylist = false;\n this.trackBackings = await segmentedInput.getTrackBackings();\n return;\n }\n }\n const videoGroupIds = [...new Set(mediaTags\n .filter(tag => tag.attributes.get('type').toLowerCase() === 'video')\n .map(tag => tag.attributes.get('group-id'))),\n ];\n const audioGroupIds = [...new Set(mediaTags\n .filter(tag => tag.attributes.get('type').toLowerCase() === 'audio')\n .map(tag => tag.attributes.get('group-id'))),\n ];\n // Now, let's process & resolve all variant streams in parallel, mapping each of them to tracks.\n const internalTracksByVariant = await Promise.all(variantStreams.map(async (variantStream, i) => {\n const result = [];\n const codecsList = variantStream.attributes.get('codecs');\n let codecStrings;\n if (codecsList) {\n codecStrings = codecsList.split(',').map(x => x.trim());\n }\n else {\n // No codecs were specified, we need to read the underlying media data\n const segmentedInput = this.getSegmentedInputForPath(variantStream.fullPath);\n const trackBackings = await segmentedInput.getTrackBackings();\n const tracksWithCodec = await Promise.all(trackBackings.map(async (t) => ({ track: t, codec: await t.getCodec() })));\n codecStrings = await Promise.all(tracksWithCodec\n .filter(x => x.codec !== null)\n .map(x => x.track.getDecoderConfig().then(x => x.codec)));\n }\n const videoGroupId = variantStream.attributes.get('video');\n const audioGroupId = variantStream.attributes.get('audio');\n const containsVideoCodecs = codecStrings.some(x => VIDEO_CODECS.includes(inferCodecFromCodecString(x)));\n const containsAudioCodecs = codecStrings.some(x => AUDIO_CODECS.includes(inferCodecFromCodecString(x)));\n if (videoGroupId !== null && !containsVideoCodecs) {\n // A video group is linked but no video codec is listed, sigh. Let's resolve the video codec.\n if (!videoGroupIds.includes(videoGroupId)) {\n throw new Error(`Invalid M3U8 file; variant stream references video group \"${videoGroupId}\" which`\n + ` is not defined in any #EXT-X-MEDIA tags.`);\n }\n // We only need to look at the first matching tag, since all tags are required to have the same\n // codec anyway\n const matchingVideoMediaTag = mediaTags.find((mediaTag) => {\n const groupId = mediaTag.attributes.get('group-id');\n const type = mediaTag.attributes.get('type');\n return groupId === videoGroupId && type.toLowerCase() === 'video';\n });\n outer: if (matchingVideoMediaTag) {\n const uri = matchingVideoMediaTag.attributes.get('uri');\n if (uri === null) {\n break outer;\n }\n const fullPath = joinPaths(rootPath, uri);\n const segmentedInput = this.getSegmentedInputForPath(fullPath);\n const trackBackings = await segmentedInput.getTrackBackings();\n const videoTrack = trackBackings.find(x => x.getType() === 'video');\n if (!videoTrack || (await videoTrack.getCodec()) === null) {\n break outer;\n }\n const additionalCodecString = await videoTrack.getDecoderConfig().then(x => x?.codec ?? null);\n assert(additionalCodecString !== null);\n codecStrings.push(additionalCodecString);\n }\n }\n if (audioGroupId !== null && !containsAudioCodecs) {\n // An audio group is linked but no audio codec is listed, sigh. Let's resolve the audio codec.\n if (!audioGroupIds.includes(audioGroupId)) {\n throw new Error(`Invalid M3U8 file; variant stream references audio group \"${audioGroupId}\" which`\n + ` is not defined in any #EXT-X-MEDIA tags.`);\n }\n // We only need to look at the first matching tag, since all tags are required to have the same\n // codec anyway\n const matchingAudioMediaTag = mediaTags.find((tag) => {\n const groupId = tag.attributes.get('group-id');\n const type = tag.attributes.get('type');\n return groupId === audioGroupId && type.toLowerCase() === 'audio';\n });\n outer: if (matchingAudioMediaTag) {\n const uri = matchingAudioMediaTag.attributes.get('uri');\n if (uri === null) {\n break outer;\n }\n const fullPath = joinPaths(rootPath, uri);\n const segmentedInput = this.getSegmentedInputForPath(fullPath);\n const trackBackings = await segmentedInput.getTrackBackings();\n const audioTrack = trackBackings.find(x => x.getType() === 'audio');\n if (!audioTrack || (await audioTrack.getCodec()) === null) {\n break outer;\n }\n const additionalCodecString = await audioTrack.getDecoderConfig().then(x => x?.codec ?? null);\n assert(additionalCodecString !== null);\n codecStrings.push(additionalCodecString);\n }\n }\n // Unique that shit\n codecStrings = [...new Set(codecStrings)];\n let videoCodecString = null;\n let audioCodecString = null;\n const bandwidth = variantStream.attributes.getAsNumber('bandwidth');\n assert(bandwidth !== null);\n const averageBandwidth = variantStream.attributes.getAsNumber('average-bandwidth');\n const name = variantStream.attributes.get('name');\n // Now, finally, loop over each codec string for the variant and resolve each one to one or more tracks.\n for (const codecString of codecStrings) {\n const inferredCodec = inferCodecFromCodecString(codecString);\n if (inferredCodec === null) {\n continue;\n }\n if (VIDEO_CODECS.includes(inferredCodec)) {\n if (videoCodecString !== null) {\n throw new Error('Unsupported M3U8 file; multiple video codecs found in the CODECS attribute of a'\n + ' variant stream.');\n }\n videoCodecString = codecString;\n const videoGroupId = variantStream.attributes.get('video');\n if (videoGroupId === null) {\n const resolution = variantStream.attributes.get('resolution');\n let width = null;\n let height = null;\n if (resolution) {\n const match = resolution.match(/^(\\d+)x(\\d+)$/);\n if (match) {\n width = Number(match[1]);\n height = Number(match[2]);\n }\n }\n result.push({\n id: -1,\n demuxer: this,\n backingTrack: null,\n default: true,\n autoselect: true,\n languageCode: UNDETERMINED_LANGUAGE,\n lineNumber: variantStream.lineNumber,\n fullPath: variantStream.fullPath,\n fullCodecString: videoCodecString,\n pairingMask: 1n << BigInt(i),\n peakBitrate: bandwidth,\n averageBitrate: averageBandwidth,\n name,\n hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,\n info: {\n type: 'video',\n width,\n height,\n },\n });\n }\n else {\n if (!videoGroupIds.includes(videoGroupId)) {\n throw new Error(`Invalid M3U8 file; variant stream references video group \"${videoGroupId}\"`\n + ` which is not defined in any #EXT-X-MEDIA tags.`);\n }\n for (const mediaTag of mediaTags) {\n const groupId = mediaTag.attributes.get('group-id');\n const type = mediaTag.attributes.get('type');\n if (groupId !== videoGroupId || type.toLowerCase() !== 'video') {\n continue;\n }\n const resolution = mediaTag.attributes.get('resolution')\n ?? variantStream.attributes.get('resolution');\n let width = null;\n let height = null;\n if (resolution) {\n const match = resolution.match(/^(\\d+)x(\\d+)$/);\n if (match) {\n width = Number(match[1]);\n height = Number(match[2]);\n }\n }\n result.push({\n id: -1,\n demuxer: this,\n backingTrack: null,\n default: getMediaTagDefault(mediaTag.attributes),\n // Autoselect is inferred to be true if the default is true\n autoselect: getMediaTagDefault(mediaTag.attributes)\n || getMediaTagAutoselect(mediaTag.attributes),\n languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')),\n lineNumber: mediaTag.lineNumber,\n fullPath: mediaTag.fullPath ?? variantStream.fullPath,\n fullCodecString: videoCodecString,\n pairingMask: 1n << BigInt(i),\n peakBitrate: null,\n averageBitrate: null,\n name: mediaTag.attributes.get('name'),\n hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,\n info: {\n type: 'video',\n width,\n height,\n },\n });\n }\n }\n }\n else if (AUDIO_CODECS.includes(inferredCodec)) {\n if (audioCodecString !== null) {\n throw new Error('Unsupported M3U8 file; multiple audio codecs found in the CODECS attribute of a'\n + ' variant stream.');\n }\n audioCodecString = codecString;\n const audioGroupId = variantStream.attributes.get('audio');\n if (audioGroupId === null) {\n const channels = variantStream.attributes.get('channels');\n const parsedChannels = channels !== null\n ? Number(channels.split('/')[0])\n : null;\n result.push({\n id: -1,\n demuxer: this,\n backingTrack: null,\n default: true,\n autoselect: true,\n languageCode: UNDETERMINED_LANGUAGE,\n lineNumber: variantStream.lineNumber,\n fullPath: variantStream.fullPath,\n fullCodecString: audioCodecString,\n pairingMask: 1n << BigInt(i),\n peakBitrate: bandwidth,\n averageBitrate: averageBandwidth,\n name,\n hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,\n info: {\n type: 'audio',\n numberOfChannels: parsedChannels !== null\n && Number.isInteger(parsedChannels)\n && parsedChannels > 0\n ? parsedChannels\n : null,\n },\n });\n }\n else {\n if (!audioGroupIds.includes(audioGroupId)) {\n throw new Error(`Invalid M3U8 file; variant stream references audio group \"${audioGroupId}\"`\n + ` which is not defined in any #EXT-X-MEDIA tags.`);\n }\n for (const mediaTag of mediaTags) {\n const groupId = mediaTag.attributes.get('group-id');\n const type = mediaTag.attributes.get('type');\n if (groupId !== audioGroupId || type.toLowerCase() !== 'audio') {\n continue;\n }\n const channels = mediaTag.attributes.get('channels')\n ?? variantStream.attributes.get('channels');\n const parsedChannels = channels !== null\n ? Number(channels.split('/')[0])\n : null;\n result.push({\n id: -1,\n demuxer: this,\n backingTrack: null,\n default: getMediaTagDefault(mediaTag.attributes),\n // Autoselect is inferred to be true if the default is true\n autoselect: getMediaTagDefault(mediaTag.attributes)\n || getMediaTagAutoselect(mediaTag.attributes),\n languageCode: preprocessLanguageCode(mediaTag.attributes.get('language')),\n lineNumber: mediaTag.lineNumber,\n fullPath: mediaTag.fullPath ?? variantStream.fullPath,\n fullCodecString: audioCodecString,\n pairingMask: 1n << BigInt(i),\n peakBitrate: null,\n averageBitrate: null,\n name: mediaTag.attributes.get('name'),\n hasOnlyKeyPackets: variantStream.hasOnlyKeyPackets,\n info: {\n type: 'audio',\n numberOfChannels: parsedChannels !== null\n && Number.isInteger(parsedChannels)\n && parsedChannels > 0\n ? parsedChannels\n : null,\n },\n });\n }\n }\n }\n }\n return result;\n }));\n const internalTracks = [];\n const addInternalTrack = (track) => {\n const existingTrack = internalTracks.find(x => x.fullPath === track.fullPath && x.info.type === track.info.type);\n if (existingTrack) {\n existingTrack.pairingMask |= track.pairingMask;\n existingTrack.default ||= track.default;\n existingTrack.autoselect ||= track.autoselect;\n existingTrack.lineNumber = Math.min(existingTrack.lineNumber, track.lineNumber);\n if (track.peakBitrate !== null) {\n existingTrack.peakBitrate = Math.max(existingTrack.peakBitrate ?? -Infinity, track.peakBitrate);\n }\n if (track.averageBitrate !== null) {\n existingTrack.averageBitrate = Math.max(existingTrack.averageBitrate ?? -Infinity, track.averageBitrate);\n }\n if (existingTrack.languageCode === UNDETERMINED_LANGUAGE) {\n existingTrack.languageCode = track.languageCode;\n }\n }\n else {\n track.id = internalTracks.length + 1;\n internalTracks.push(track);\n }\n };\n for (const variantInternalTracks of internalTracksByVariant) {\n for (const trackEntry of variantInternalTracks) {\n addInternalTrack(trackEntry);\n }\n }\n // Order tracks by how they appear in the file\n internalTracks.sort((a, b) => a.lineNumber - b.lineNumber);\n this.trackBackings = [];\n for (const internalTrack of internalTracks) {\n if (internalTrack.info.type === 'video') {\n this.trackBackings.push(new HlsInputVideoTrackBacking(internalTrack));\n }\n else {\n this.trackBackings.push(new HlsInputAudioTrackBacking(internalTrack));\n }\n }\n this.internalTracks = internalTracks;\n })();\n }\n async getTrackBackings() {\n await this.readMetadata();\n assert(this.trackBackings);\n return this.trackBackings;\n }\n getSegmentedInputForPath(path) {\n let segmentedInput = this.segmentedInputs.find(x => x.path === path);\n if (segmentedInput) {\n return segmentedInput;\n }\n let decls = null;\n if (this.internalTracks) {\n const tracks = this.internalTracks.filter(x => x.fullPath === path);\n decls = tracks.map(x => ({\n id: x.id,\n type: x.info.type,\n }));\n }\n segmentedInput = new HlsSegmentedInput(this, path, decls, null);\n this.segmentedInputs.push(segmentedInput);\n return segmentedInput;\n }\n async getMetadataTags() {\n return {};\n }\n async getMimeType() {\n return HLS_MIME_TYPE;\n }\n dispose() {\n if (this.segmentedInputs) {\n for (const segInput of this.segmentedInputs) {\n segInput.dispose();\n }\n this.segmentedInputs.length = 0;\n }\n }\n }\n class HlsInputTrackBacking {\n constructor(internalTrack) {\n this.internalTrack = internalTrack;\n this.hydrationPromise = null;\n }\n hydrate() {\n return this.hydrationPromise ??= (async () => {\n const segmentedInput = this.internalTrack.demuxer.getSegmentedInputForPath(this.internalTrack.fullPath);\n let trackBacking = null;\n const trackBackings = await segmentedInput.getTrackBackings();\n const matchingType = trackBackings.filter(x => x.getType() === this.getType());\n if (matchingType.length === 1) {\n // Avoids reading fields on the track\n trackBacking = matchingType[0];\n }\n else {\n if (this instanceof HlsInputVideoTrackBacking) {\n for (const backing of matchingType) {\n if ((await backing.getCodec()) === this.getCodec()) {\n trackBacking = backing;\n break;\n }\n }\n }\n else {\n assert(this instanceof HlsInputAudioTrackBacking);\n for (const backing of matchingType) {\n if ((await backing.getCodec()) === this.getCodec()) {\n trackBacking = backing;\n break;\n }\n }\n }\n }\n if (!trackBacking) {\n throw new Error('Could not find matching track in underlying media data.');\n }\n this.internalTrack.backingTrack = trackBacking;\n })();\n }\n /** If the backing track is already present, delegate synchronously; otherwise, hydrate first. */\n delegate(fn) {\n if (this.internalTrack.backingTrack) {\n return fn();\n }\n return this.hydrate().then(fn);\n }\n getCodec() {\n throw new Error('Not implemented on base class.');\n }\n getDisposition() {\n return {\n ...DEFAULT_TRACK_DISPOSITION,\n // Meanings are swapped in HLS: \"Default\" means that a track is the primary track.\n default: this.internalTrack.autoselect,\n primary: this.internalTrack.default,\n };\n }\n getId() {\n return this.internalTrack.id;\n }\n getPairingMask() {\n return this.internalTrack.pairingMask;\n }\n getInternalCodecId() {\n return null;\n }\n getLanguageCode() {\n return this.internalTrack.languageCode;\n }\n getName() {\n return this.internalTrack.name;\n }\n getNumber() {\n assert(this.internalTrack.demuxer.internalTracks);\n const trackType = this.internalTrack.info.type;\n let number = 0;\n for (const track of this.internalTrack.demuxer.internalTracks) {\n if (track.info.type === trackType) {\n number++;\n }\n if (track === this.internalTrack) {\n break;\n }\n }\n return number;\n }\n getTimeResolution() {\n return this.delegate(() => this.internalTrack.backingTrack.getTimeResolution());\n }\n isRelativeToUnixEpoch() {\n return this.delegate(() => this.internalTrack.backingTrack.isRelativeToUnixEpoch());\n }\n getUnixTimeForTimestamp(timestamp) {\n return this.delegate(() => this.internalTrack.backingTrack.getUnixTimeForTimestamp(timestamp));\n }\n getBitrate() {\n return this.internalTrack.peakBitrate;\n }\n getAverageBitrate() {\n return this.internalTrack.averageBitrate;\n }\n async getDurationFromMetadata(options) {\n await this.hydrate();\n return this.internalTrack.backingTrack.getDurationFromMetadata(options);\n }\n async getLiveRefreshInterval() {\n await this.hydrate();\n return this.internalTrack.backingTrack.getLiveRefreshInterval();\n }\n getHasOnlyKeyPackets() {\n return this.internalTrack.hasOnlyKeyPackets || null;\n }\n async getFirstPacket(options) {\n await this.hydrate();\n return this.internalTrack.backingTrack.getFirstPacket(options);\n }\n async getPacket(timestamp, options) {\n await this.hydrate();\n return this.internalTrack.backingTrack.getPacket(timestamp, options);\n }\n async getKeyPacket(timestamp, options) {\n await this.hydrate();\n return this.internalTrack.backingTrack.getKeyPacket(timestamp, options);\n }\n async getNextPacket(packet, options) {\n await this.hydrate();\n return this.internalTrack.backingTrack.getNextPacket(packet, options);\n }\n async getNextKeyPacket(packet, options) {\n await this.hydrate();\n return this.internalTrack.backingTrack.getNextKeyPacket(packet, options);\n }\n }\n class HlsInputVideoTrackBacking extends HlsInputTrackBacking {\n constructor(internalTrack) {\n super(internalTrack);\n }\n get backingVideoTrack() {\n return this.internalTrack.backingTrack;\n }\n getType() {\n return 'video';\n }\n getCodec() {\n const inferredCodec = inferCodecFromCodecString(this.internalTrack.fullCodecString);\n return inferredCodec;\n }\n getCodedWidth() {\n return this.delegate(() => this.backingVideoTrack.getCodedWidth());\n }\n getCodedHeight() {\n return this.delegate(() => this.backingVideoTrack.getCodedHeight());\n }\n getSquarePixelWidth() {\n return this.delegate(() => this.backingVideoTrack.getSquarePixelWidth());\n }\n getSquarePixelHeight() {\n return this.delegate(() => this.backingVideoTrack.getSquarePixelHeight());\n }\n getMetadataDisplayWidth() {\n if (this.backingVideoTrack) {\n return null;\n }\n return this.internalTrack.info.width;\n }\n getMetadataDisplayHeight() {\n if (this.backingVideoTrack) {\n return null;\n }\n return this.internalTrack.info.height;\n }\n getRotation() {\n return this.delegate(() => this.backingVideoTrack.getRotation());\n }\n async getColorSpace() {\n await this.hydrate();\n return this.backingVideoTrack.getColorSpace();\n }\n async canBeTransparent() {\n await this.hydrate();\n return this.backingVideoTrack.canBeTransparent();\n }\n getMetadataCodecParameterString() {\n if (this.backingVideoTrack) {\n return null;\n }\n return this.internalTrack.fullCodecString;\n }\n async getDecoderConfig() {\n await this.hydrate();\n return this.backingVideoTrack.getDecoderConfig();\n }\n }\n class HlsInputAudioTrackBacking extends HlsInputTrackBacking {\n constructor(internalTrack) {\n super(internalTrack);\n }\n get backingAudioTrack() {\n return this.internalTrack.backingTrack;\n }\n getType() {\n return 'audio';\n }\n getCodec() {\n const inferredCodec = inferCodecFromCodecString(this.internalTrack.fullCodecString);\n return inferredCodec;\n }\n getNumberOfChannels() {\n if (this.internalTrack.info.numberOfChannels !== null) {\n return this.internalTrack.info.numberOfChannels;\n }\n return this.delegate(() => this.backingAudioTrack.getNumberOfChannels());\n }\n getSampleRate() {\n return this.delegate(() => this.backingAudioTrack.getSampleRate());\n }\n getMetadataCodecParameterString() {\n if (this.backingAudioTrack) {\n return null;\n }\n return this.internalTrack.fullCodecString;\n }\n async getDecoderConfig() {\n await this.hydrate();\n return this.backingAudioTrack.getDecoderConfig();\n }\n }\n const getMediaTagDefault = (attributes) => {\n const value = attributes.get('default');\n if (value === null) {\n return false;\n }\n const normalized = value.toUpperCase();\n if (normalized === 'YES') {\n return true;\n }\n if (normalized === 'NO') {\n return false;\n }\n throw new Error(`Invalid M3U8 file; #EXT-X-MEDIA DEFAULT attribute must be YES or NO, got \"${value}\".`);\n };\n const getMediaTagAutoselect = (attributes) => {\n const value = attributes.get('autoselect');\n if (value === null) {\n return false;\n }\n const normalized = value.toUpperCase();\n if (normalized === 'YES') {\n return true;\n }\n if (normalized === 'NO') {\n return false;\n }\n throw new Error(`Invalid M3U8 file; #EXT-X-MEDIA AUTOSELECT attribute must be YES or NO, got \"${value}\".`);\n };\n const preprocessLanguageCode = (code) => {\n if (code === null) {\n return UNDETERMINED_LANGUAGE;\n }\n const languageSubtag = code.split('-')[0];\n if (!languageSubtag) {\n return UNDETERMINED_LANGUAGE;\n }\n // Technically invalid, for now: The language subtag might be a language code from ISO 639-1,\n // ISO 639-2, ISO 639-3, ISO 639-5 or some other thing (source: Wikipedia). But, `languageCode` is\n // documented as ISO 639-2. Changing the definition would be a breaking change. This will get\n // cleaned up in the future by defining languageCode to be BCP 47 instead.\n return languageSubtag;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /**\n * Base class representing an input media file format.\n * @group Input formats\n * @public\n */\n class InputFormat {\n constructor() {\n /**\n * Provided for tree-shakable checking.\n * @internal\n */\n this._isIsobmff = false;\n }\n }\n /**\n * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files.\n *\n * This format can make use of {@link InputOptions.initInput}. When the file contents are fragmented but no track\n * initialization info is provided (no `moov` atom), then it must be provided via `initInput`.\n *\n * @group Input formats\n * @public\n */\n class IsobmffInputFormat extends InputFormat {\n constructor() {\n super(...arguments);\n /** @internal */\n this._isIsobmff = true;\n }\n /** @internal */\n async _getMajorBrand(input) {\n let slice = input._reader.requestSlice(0, 12);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return null;\n slice.skip(4);\n const fourCc = readAscii(slice, 4);\n if (fourCc !== 'ftyp'\n && fourCc !== 'styp' // Segment\n ) {\n return null;\n }\n return readAscii(slice, 4);\n }\n /** @internal */\n _createDemuxer(input) {\n return new IsobmffDemuxer(input);\n }\n }\n /**\n * MPEG-4 Part 14 (MP4) file format.\n *\n * Do not instantiate this class; use the {@link MP4} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class Mp4InputFormat extends IsobmffInputFormat {\n /** @internal */\n async _canReadInput(input) {\n const majorBrand = await this._getMajorBrand(input);\n if (majorBrand !== null) {\n return majorBrand !== 'qt ';\n }\n let slice = input._reader.requestSlice(4, 4);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const fourCc = readAscii(slice, 4);\n return fourCc === 'moof' || fourCc === 'sidx'; // Seen in HLS for example\n }\n get name() {\n return 'MP4';\n }\n get mimeType() {\n return 'video/mp4';\n }\n }\n /**\n * QuickTime File Format (QTFF), often called MOV.\n *\n * Do not instantiate this class; use the {@link QTFF} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class QuickTimeInputFormat extends IsobmffInputFormat {\n /** @internal */\n async _canReadInput(input) {\n const majorBrand = await this._getMajorBrand(input);\n return majorBrand === 'qt ';\n }\n get name() {\n return 'QuickTime File Format';\n }\n get mimeType() {\n return 'video/quicktime';\n }\n }\n /**\n * Matroska file format.\n *\n * Do not instantiate this class; use the {@link MATROSKA} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class MatroskaInputFormat extends InputFormat {\n /** @internal */\n async isSupportedEBMLOfDocType(input, desiredDocType) {\n let headerSlice = input._reader.requestSlice(0, MAX_HEADER_SIZE);\n if (headerSlice instanceof Promise)\n headerSlice = await headerSlice;\n if (!headerSlice)\n return false;\n const varIntSize = readVarIntSize(headerSlice);\n if (varIntSize === null) {\n return false;\n }\n if (varIntSize < 1 || varIntSize > 8) {\n return false;\n }\n const id = readUnsignedInt(headerSlice, varIntSize);\n if (id !== EBMLId.EBML) {\n return false;\n }\n const dataSize = readElementSize(headerSlice);\n if (typeof dataSize !== 'number') {\n return false; // Miss me with that shit\n }\n let dataSlice = input._reader.requestSlice(headerSlice.filePos, dataSize);\n if (dataSlice instanceof Promise)\n dataSlice = await dataSlice;\n if (!dataSlice)\n return false;\n const startPos = headerSlice.filePos;\n while (dataSlice.filePos <= startPos + dataSize - MIN_HEADER_SIZE) {\n const header = readElementHeader(dataSlice);\n if (!header)\n break;\n const { id, size } = header;\n const dataStartPos = dataSlice.filePos;\n if (size === undefined)\n return false;\n switch (id) {\n case EBMLId.EBMLVersion:\n {\n const ebmlVersion = readUnsignedInt(dataSlice, size);\n if (ebmlVersion !== 1) {\n return false;\n }\n }\n break;\n case EBMLId.EBMLReadVersion:\n {\n const ebmlReadVersion = readUnsignedInt(dataSlice, size);\n if (ebmlReadVersion !== 1) {\n return false;\n }\n }\n break;\n case EBMLId.DocType:\n {\n const docType = readAsciiString(dataSlice, size);\n if (docType !== desiredDocType) {\n return false;\n }\n }\n break;\n case EBMLId.DocTypeVersion:\n {\n const docTypeVersion = readUnsignedInt(dataSlice, size);\n if (docTypeVersion > 4) { // Support up to Matroska v4\n return false;\n }\n }\n break;\n }\n dataSlice.filePos = dataStartPos + size;\n }\n return true;\n }\n /** @internal */\n _canReadInput(input) {\n return this.isSupportedEBMLOfDocType(input, 'matroska');\n }\n /** @internal */\n _createDemuxer(input) {\n return new MatroskaDemuxer(input);\n }\n get name() {\n return 'Matroska';\n }\n get mimeType() {\n return 'video/x-matroska';\n }\n }\n /**\n * WebM file format, based on Matroska.\n *\n * Do not instantiate this class; use the {@link WEBM} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class WebMInputFormat extends MatroskaInputFormat {\n /** @internal */\n _canReadInput(input) {\n return this.isSupportedEBMLOfDocType(input, 'webm');\n }\n get name() {\n return 'WebM';\n }\n get mimeType() {\n return 'video/webm';\n }\n }\n /**\n * MP3 file format.\n *\n * Do not instantiate this class; use the {@link MP3} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class Mp3InputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n let currentPos = 0;\n while (true) {\n let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const id3V2Header = readId3V2Header(slice);\n if (!id3V2Header) {\n break;\n }\n currentPos = slice.filePos + id3V2Header.size;\n }\n const firstResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + 4096);\n if (!firstResult) {\n return false;\n }\n const firstHeader = firstResult.header;\n const xingOffset = getXingOffset(firstHeader.mpegVersionId, firstHeader.channel);\n let slice = input._reader.requestSlice(firstResult.startPos + xingOffset, 4);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const word = readU32Be(slice);\n const isXing = word === XING || word === INFO;\n if (isXing) {\n // Gotta be MP3\n return true;\n }\n currentPos = firstResult.startPos + firstResult.header.totalSize;\n // Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find\n // another header right after it:\n const secondResult = await readNextMp3FrameHeader(input._reader, currentPos, currentPos + MP3_FRAME_HEADER_SIZE);\n if (!secondResult) {\n return false;\n }\n const secondHeader = secondResult.header;\n // In a well-formed MP3 file, we'd expect these two frames to share some similarities:\n if (firstHeader.channel !== secondHeader.channel || firstHeader.sampleRate !== secondHeader.sampleRate) {\n return false;\n }\n // We have found two matching consecutive MP3 frames, a strong indicator that this is an MP3 file\n return true;\n }\n /** @internal */\n _createDemuxer(input) {\n return new Mp3Demuxer(input);\n }\n get name() {\n return 'MP3';\n }\n get mimeType() {\n return 'audio/mpeg';\n }\n }\n /**\n * WAVE file format, based on RIFF.\n *\n * Do not instantiate this class; use the {@link WAVE} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class WaveInputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n let slice = input._reader.requestSlice(0, 12);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const riffType = readAscii(slice, 4);\n if (riffType !== 'RIFF' && riffType !== 'RIFX' && riffType !== 'RF64') {\n return false;\n }\n slice.skip(4);\n const format = readAscii(slice, 4);\n return format === 'WAVE';\n }\n /** @internal */\n _createDemuxer(input) {\n return new WaveDemuxer(input);\n }\n get name() {\n return 'WAVE';\n }\n get mimeType() {\n return 'audio/wav';\n }\n }\n /**\n * Ogg file format.\n *\n * Do not instantiate this class; use the {@link OGG} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class OggInputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n let slice = input._reader.requestSlice(0, 4);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n return readAscii(slice, 4) === 'OggS';\n }\n /** @internal */\n _createDemuxer(input) {\n return new OggDemuxer(input);\n }\n get name() {\n return 'Ogg';\n }\n get mimeType() {\n return 'application/ogg';\n }\n }\n /**\n * FLAC file format.\n *\n * Do not instantiate this class; use the {@link FLAC} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class FlacInputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n let currentPos = 0;\n // There might be ID3v2 headers at the start, skip 'em\n while (true) {\n let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const id3V2Header = readId3V2Header(slice);\n if (!id3V2Header) {\n break;\n }\n currentPos = slice.filePos + id3V2Header.size;\n }\n let slice = input._reader.requestSlice(currentPos, 4);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n return readAscii(slice, 4) === 'fLaC';\n }\n get name() {\n return 'FLAC';\n }\n get mimeType() {\n return 'audio/flac';\n }\n /** @internal */\n _createDemuxer(input) {\n return new FlacDemuxer(input);\n }\n }\n /**\n * ADTS file format.\n *\n * Do not instantiate this class; use the {@link ADTS} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class AdtsInputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n let currentPos = 0;\n while (true) {\n let slice = input._reader.requestSlice(currentPos, ID3_V2_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n break;\n const id3V2Header = readId3V2Header(slice);\n if (!id3V2Header) {\n break;\n }\n currentPos = slice.filePos + id3V2Header.size;\n }\n let slice = input._reader.requestSliceRange(currentPos, MIN_ADTS_FRAME_HEADER_SIZE, MAX_ADTS_FRAME_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const firstHeader = readAdtsFrameHeader(slice);\n if (!firstHeader) {\n return false;\n }\n currentPos += firstHeader.frameLength;\n slice = input._reader.requestSliceRange(currentPos, MIN_ADTS_FRAME_HEADER_SIZE, MAX_ADTS_FRAME_HEADER_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const secondHeader = readAdtsFrameHeader(slice);\n if (!secondHeader) {\n return false;\n }\n return firstHeader.objectType === secondHeader.objectType\n && firstHeader.samplingFrequencyIndex === secondHeader.samplingFrequencyIndex\n && firstHeader.channelConfiguration === secondHeader.channelConfiguration;\n }\n /** @internal */\n _createDemuxer(input) {\n return new AdtsDemuxer(input);\n }\n get name() {\n return 'ADTS';\n }\n get mimeType() {\n return 'audio/aac';\n }\n }\n /**\n * MPEG Transport Stream (MPEG-TS) file format.\n *\n * This format can make use of {@link InputOptions.initInput} to initialize track information even when no\n * initialization information is provided for the track, for example because it has no key frames. In this case, tracks\n * are matched to each other based on their PID.\n *\n * Do not instantiate this class; use the {@link MPEG_TS} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class MpegTsInputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n const lengthToCheck = TS_PACKET_SIZE + 16 + 1;\n let slice = input._reader.requestSlice(0, lengthToCheck);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const bytes = readBytes(slice, lengthToCheck);\n if (bytes[0] === 0x47 && bytes[TS_PACKET_SIZE] === 0x47) {\n // Regular MPEG-TS\n return true;\n }\n else if (bytes[0] === 0x47 && bytes[TS_PACKET_SIZE + 16] === 0x47) {\n // MPEG-TS with Forward Error Correction\n return true;\n }\n else if (bytes[4] === 0x47 && bytes[4 + TS_PACKET_SIZE + 4] === 0x47) {\n // MPEG-2-TS (DVHS)\n return true;\n }\n return false;\n }\n /** @internal */\n _createDemuxer(input) {\n return new MpegTsDemuxer(input);\n }\n get name() {\n return 'MPEG Transport Stream';\n }\n get mimeType() {\n return 'video/MP2T';\n }\n }\n /**\n * Media described using the HTTP Live Streaming (HLS) protocol, with playlists in the M3U8 format.\n *\n * Do not instantiate this class; use the {@link HLS} singleton instead.\n *\n * @group Input formats\n * @public\n */\n class HlsInputFormat extends InputFormat {\n /** @internal */\n async _canReadInput(input) {\n let slice = input._reader.requestSlice(0, 7);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice)\n return false;\n const isM3u8 = readAscii(slice, 7) === '#EXTM3U';\n if (!isM3u8) {\n return false;\n }\n if (!(input._rootSource instanceof PathedSource)) {\n throw new TypeError('HLS inputs require `InputOptions.source` to be a PathedSource or a ref to one.');\n }\n input._rootSource._usedForHls = true;\n return true;\n }\n /** @internal */\n _createDemuxer(input) {\n return new HlsDemuxer(input);\n }\n get name() {\n return 'HTTP Live Streaming (HLS)';\n }\n get mimeType() {\n return HLS_MIME_TYPE;\n }\n }\n /**\n * MP4 input format singleton.\n * @group Input formats\n * @public\n */\n const MP4 = /* #__PURE__ */ new Mp4InputFormat();\n /**\n * QuickTime File Format input format singleton.\n * @group Input formats\n * @public\n */\n const QTFF = /* #__PURE__ */ new QuickTimeInputFormat();\n /**\n * Matroska input format singleton.\n * @group Input formats\n * @public\n */\n const MATROSKA = /* #__PURE__ */ new MatroskaInputFormat();\n /**\n * WebM input format singleton.\n * @group Input formats\n * @public\n */\n const WEBM = /* #__PURE__ */ new WebMInputFormat();\n /**\n * MP3 input format singleton.\n * @group Input formats\n * @public\n */\n const MP3 = /* #__PURE__ */ new Mp3InputFormat();\n /**\n * WAVE input format singleton.\n * @group Input formats\n * @public\n */\n const WAVE = /* #__PURE__ */ new WaveInputFormat();\n /**\n * Ogg input format singleton.\n * @group Input formats\n * @public\n */\n const OGG = /* #__PURE__ */ new OggInputFormat();\n /**\n * ADTS input format singleton.\n * @group Input formats\n * @public\n */\n const ADTS = /* #__PURE__ */ new AdtsInputFormat();\n /**\n * FLAC input format singleton.\n * @group Input formats\n * @public\n */\n const FLAC = /* #__PURE__ */ new FlacInputFormat();\n /**\n * MPEG-TS input format singleton.\n * @group Input formats\n * @public\n */\n const MPEG_TS = /* #__PURE__ */ new MpegTsInputFormat();\n /**\n * HLS input format singleton.\n * @group Input formats\n * @public\n */\n const HLS = /* #__PURE__ */ new HlsInputFormat();\n /**\n * List of all input format singletons. If you don't need to support all input formats, you should specify the\n * formats individually for better tree shaking.\n * @group Input formats\n * @public\n */\n const ALL_FORMATS = [HLS, MP4, QTFF, MATROSKA, WEBM, WAVE, OGG, FLAC, MP3, ADTS, MPEG_TS];\n const validateInputFormatOptions = (options, prefix) => {\n if (!options || typeof options !== 'object') {\n throw new TypeError(`${prefix}, when provided, must be an object.`);\n }\n if (options.isobmff !== undefined) {\n if (!options.isobmff || typeof options.isobmff !== 'object') {\n throw new TypeError(`${prefix}.isobmff, when provided, must be an object.`);\n }\n if (options.isobmff.resolveKeyId !== undefined && typeof options.isobmff.resolveKeyId !== 'function') {\n throw new TypeError(`${prefix}.isobmff.resolveKeyId, when provided, must be a function.`);\n }\n }\n if (options.hls !== undefined) {\n if (!options.hls || typeof options.hls !== 'object') {\n throw new TypeError(`${prefix}.hls, when provided, must be an object.`);\n }\n if (options.hls.offsetTimestampsByDateTime !== undefined\n && typeof options.hls.offsetTimestampsByDateTime !== 'boolean') {\n throw new TypeError(`${prefix}.hls.offsetTimestampsByDateTime, when provided, must be a boolean.`);\n }\n }\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n var __addDisposableResource = (undefined && undefined.__addDisposableResource) || function (env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n };\n var __disposeResources = (undefined && undefined.__disposeResources) || (function (SuppressedError) {\n return function (env) {\n function fail(e) {\n env.error = env.hasError ? new SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n };\n })(typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n });\n polyfillSymbolDispose();\n // Let's manually handle logging the garbage collection errors that are typically logged by the browser. This way, they\n // also kick for audio samples (which is normally not the case), making sure any incorrect code is quickly caught.\n let lastVideoGcErrorLog = -Infinity;\n let lastAudioGcErrorLog = -Infinity;\n let finalizationRegistry = null;\n if (typeof FinalizationRegistry !== 'undefined') {\n finalizationRegistry = new FinalizationRegistry((value) => {\n const now = performance.now();\n if (value.type === 'video') {\n if (now - lastVideoGcErrorLog >= 1000) {\n // This error is annoying but oh so important\n Logging._error(`A VideoSample was garbage collected without first being closed. For proper resource management,`\n + ` make sure to call close() on all your VideoSamples as soon as you're done using them.`);\n lastVideoGcErrorLog = now;\n }\n if (typeof VideoFrame !== 'undefined' && value.data instanceof VideoFrame) {\n value.data.close(); // Prevent the browser error since we're logging our own\n }\n }\n else {\n if (now - lastAudioGcErrorLog >= 1000) {\n Logging._error(`An AudioSample was garbage collected without first being closed. For proper resource management,`\n + ` make sure to call close() on all your AudioSamples as soon as you're done using them.`);\n lastAudioGcErrorLog = now;\n }\n if (typeof AudioData !== 'undefined' && value.data instanceof AudioData) {\n value.data.close();\n }\n }\n });\n }\n /**\n * Abstract base class for custom video sample resources. Implement this class to provide custom backing\n * for VideoSample instances.\n * @group Samples\n * @public\n */\n class VideoSampleResource {\n constructor() {\n /** @internal */\n this._referenceCount = 0;\n /** @internal */\n this._lastAllocationBuffer = null;\n }\n }\n /**\n * The list of {@link VideoSample} pixel formats.\n * @group Samples\n * @public\n */\n const VIDEO_SAMPLE_PIXEL_FORMATS = [\n // 4:2:0 Y, U, V\n 'I420',\n 'I420P10',\n 'I420P12',\n // 4:2:0 Y, U, V, A\n 'I420A',\n 'I420AP10',\n 'I420AP12',\n // 4:2:2 Y, U, V\n 'I422',\n 'I422P10',\n 'I422P12',\n // 4:2:2 Y, U, V, A\n 'I422A',\n 'I422AP10',\n 'I422AP12',\n // 4:4:4 Y, U, V\n 'I444',\n 'I444P10',\n 'I444P12',\n // 4:4:4 Y, U, V, A\n 'I444A',\n 'I444AP10',\n 'I444AP12',\n // 4:2:0 Y, UV\n 'NV12',\n // 4:4:4 RGBA\n 'RGBA',\n // 4:4:4 RGBX (opaque)\n 'RGBX',\n // 4:4:4 BGRA\n 'BGRA',\n // 4:4:4 BGRX (opaque)\n 'BGRX',\n ];\n const VIDEO_SAMPLE_PIXEL_FORMATS_SET = new Set(VIDEO_SAMPLE_PIXEL_FORMATS);\n /**\n * Represents a raw, unencoded video sample (frame). Mainly used as an expressive wrapper around WebCodecs API's\n * [`VideoFrame`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame), but can also be used standalone.\n * @group Samples\n * @public\n */\n class VideoSample {\n /** The width of the frame in pixels. */\n get codedWidth() {\n // This is wrong, but the fix is a v2 thing\n return this.visibleRect.width;\n }\n /** The height of the frame in pixels. */\n get codedHeight() {\n // Same here\n return this.visibleRect.height;\n }\n /** The display width of the frame in pixels, after aspect ratio adjustment and rotation. */\n get displayWidth() {\n return this.rotation % 180 === 0 ? this.squarePixelWidth : this.squarePixelHeight;\n }\n /** The display height of the frame in pixels, after aspect ratio adjustment and rotation. */\n get displayHeight() {\n return this.rotation % 180 === 0 ? this.squarePixelHeight : this.squarePixelWidth;\n }\n /** The presentation timestamp of the frame in microseconds. */\n get microsecondTimestamp() {\n return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.timestamp);\n }\n /** The duration of the frame in microseconds. */\n get microsecondDuration() {\n return Math.trunc(SECOND_TO_MICROSECOND_FACTOR * this.duration);\n }\n /**\n * Whether this sample uses a pixel format that can hold transparency data. Note that this doesn't necessarily mean\n * that the sample is transparent.\n */\n get hasAlpha() {\n return this.format && this.format.includes('A');\n }\n constructor(data, init) {\n /** @internal */\n this._closed = false;\n if (data instanceof ArrayBuffer\n || (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer)\n || ArrayBuffer.isView(data)) {\n if (!init || typeof init !== 'object') {\n throw new TypeError('init must be an object.');\n }\n if (init.format === undefined || !VIDEO_SAMPLE_PIXEL_FORMATS_SET.has(init.format)) {\n throw new TypeError('init.format must be one of: ' + VIDEO_SAMPLE_PIXEL_FORMATS.join(', '));\n }\n if (!Number.isInteger(init.codedWidth) || init.codedWidth <= 0) {\n throw new TypeError('init.codedWidth must be a positive integer.');\n }\n if (!Number.isInteger(init.codedHeight) || init.codedHeight <= 0) {\n throw new TypeError('init.codedHeight must be a positive integer.');\n }\n if (init.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {\n throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');\n }\n if (!Number.isFinite(init.timestamp)) {\n throw new TypeError('init.timestamp must be a number.');\n }\n if (init.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {\n throw new TypeError('init.duration, when provided, must be a non-negative number.');\n }\n if (init.layout !== undefined) {\n if (!Array.isArray(init.layout)) {\n throw new TypeError('init.layout, when provided, must be an array.');\n }\n for (const plane of init.layout) {\n if (!plane || typeof plane !== 'object' || Array.isArray(plane)) {\n throw new TypeError('Each entry in init.layout must be an object.');\n }\n if (!Number.isInteger(plane.offset) || plane.offset < 0) {\n throw new TypeError('plane.offset must be a non-negative integer.');\n }\n if (!Number.isInteger(plane.stride) || plane.stride < 0) {\n throw new TypeError('plane.stride must be a non-negative integer.');\n }\n }\n }\n if (init.visibleRect !== undefined) {\n validateRectangle(init.visibleRect, 'init.visibleRect');\n }\n if (init.displayWidth !== undefined\n && (!Number.isInteger(init.displayWidth) || init.displayWidth <= 0)) {\n throw new TypeError('init.displayWidth, when provided, must be a positive integer.');\n }\n if (init.displayHeight !== undefined\n && (!Number.isInteger(init.displayHeight) || init.displayHeight <= 0)) {\n throw new TypeError('init.displayHeight, when provided, must be a positive integer.');\n }\n if ((init.displayWidth !== undefined) !== (init.displayHeight !== undefined)) {\n throw new TypeError('init.displayWidth and init.displayHeight must be either both provided or both omitted.');\n }\n this.format = init.format;\n this.rotation = init.rotation ?? 0;\n this.timestamp = init.timestamp;\n this.duration = init.duration ?? 0;\n const layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth, init.codedHeight);\n let colorSpaceInit = init.colorSpace ?? null;\n if (colorSpaceInit === null) {\n if (this.format === 'RGBA' || this.format === 'RGBX'\n || this.format === 'BGRA' || this.format === 'BGRX') {\n // sRGB Color Space\n colorSpaceInit = {\n primaries: 'bt709',\n transfer: 'iec61966-2-1',\n matrix: 'rgb',\n fullRange: true,\n };\n }\n else {\n // REC709 Color Space\n colorSpaceInit = {\n primaries: 'bt709',\n transfer: 'bt709',\n matrix: 'bt709',\n fullRange: false,\n };\n }\n }\n this.visibleRect = {\n left: init.visibleRect?.left ?? 0,\n top: init.visibleRect?.top ?? 0,\n width: init.visibleRect?.width ?? init.codedWidth,\n height: init.visibleRect?.height ?? init.codedHeight,\n };\n if (init.displayWidth !== undefined) {\n this.squarePixelWidth = this.rotation % 180 === 0 ? init.displayWidth : init.displayHeight;\n this.squarePixelHeight = this.rotation % 180 === 0 ? init.displayHeight : init.displayWidth;\n }\n else {\n this.squarePixelWidth = this.visibleRect.width;\n this.squarePixelHeight = this.visibleRect.height;\n }\n // As an optimization, one could check if VideoFrame is defined and if it is, create a VideoFrame here from\n // the data. Since VideoFrames are typically needed anyway, doing it this way would avoid an additional\n // copy of the frame data. But due to https://issues.chromium.org/issues/529413114, this is currently\n // not done.\n this._data = init._doNotCopy\n ? toUint8Array(data)\n : toUint8Array(data).slice(); // Copy it\n this._layout = layout;\n this.colorSpace = new VideoSampleColorSpace(colorSpaceInit);\n }\n else if (typeof VideoFrame !== 'undefined' && data instanceof VideoFrame) {\n if (init?.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {\n throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');\n }\n if (init?.timestamp !== undefined && !Number.isFinite(init?.timestamp)) {\n throw new TypeError('init.timestamp, when provided, must be a number.');\n }\n if (init?.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {\n throw new TypeError('init.duration, when provided, must be a non-negative number.');\n }\n if (init?.visibleRect !== undefined) {\n validateRectangle(init.visibleRect, 'init.visibleRect');\n }\n this._data = data;\n this._layout = null;\n this.format = data.format;\n this.visibleRect = {\n left: data.visibleRect?.x ?? 0,\n top: data.visibleRect?.y ?? 0,\n width: data.visibleRect?.width ?? data.codedWidth,\n height: data.visibleRect?.height ?? data.codedHeight,\n };\n // The VideoFrame's rotation is ignored here. It's still a new field, and I'm not sure of any application\n // where the browser makes use of it. If a case gets found, I'll add it.\n this.rotation = init?.rotation ?? 0;\n // Assuming no innate VideoFrame rotation here\n this.squarePixelWidth = data.displayWidth;\n this.squarePixelHeight = data.displayHeight;\n this.timestamp = init?.timestamp ?? data.timestamp / 1e6;\n this.duration = init?.duration ?? (data.duration ?? 0) / 1e6;\n this.colorSpace = new VideoSampleColorSpace(data.colorSpace);\n }\n else if ((typeof HTMLImageElement !== 'undefined' && data instanceof HTMLImageElement)\n || (typeof SVGImageElement !== 'undefined' && data instanceof SVGImageElement)\n || (typeof ImageBitmap !== 'undefined' && data instanceof ImageBitmap)\n || (typeof HTMLVideoElement !== 'undefined' && data instanceof HTMLVideoElement)\n || (typeof HTMLCanvasElement !== 'undefined' && data instanceof HTMLCanvasElement)\n || (typeof OffscreenCanvas !== 'undefined' && data instanceof OffscreenCanvas)) {\n if (!init || typeof init !== 'object') {\n throw new TypeError('init must be an object.');\n }\n if (init.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {\n throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');\n }\n if (!Number.isFinite(init.timestamp)) {\n throw new TypeError('init.timestamp must be a number.');\n }\n if (init.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {\n throw new TypeError('init.duration, when provided, must be a non-negative number.');\n }\n if (typeof VideoFrame !== 'undefined') {\n return new VideoSample(new VideoFrame(data, {\n timestamp: Math.trunc(init.timestamp * SECOND_TO_MICROSECOND_FACTOR),\n // Drag 0 to undefined\n duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR) || undefined,\n }), init);\n }\n let width = 0;\n let height = 0;\n // Determine the dimensions of the thing\n if ('naturalWidth' in data) {\n width = data.naturalWidth;\n height = data.naturalHeight;\n }\n else if ('videoWidth' in data) {\n width = data.videoWidth;\n height = data.videoHeight;\n }\n else if ('width' in data) {\n width = Number(data.width);\n height = Number(data.height);\n }\n if (!width || !height) {\n throw new TypeError('Could not determine dimensions.');\n }\n const canvas = new OffscreenCanvas(width, height);\n const context = canvas.getContext('2d', {\n alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases\n willReadFrequently: true,\n });\n if (!context) {\n throw new Error('OffscreenCanvas must have support for the \\'2d\\' context in order to create a VideoSample from'\n + ' this data.');\n }\n // Draw it to a canvas\n context.drawImage(data, 0, 0);\n this._data = canvas;\n this._layout = null;\n this.format = 'RGBX';\n this.visibleRect = { left: 0, top: 0, width, height };\n this.squarePixelWidth = width;\n this.squarePixelHeight = height;\n this.rotation = init.rotation ?? 0;\n this.timestamp = init.timestamp;\n this.duration = init.duration ?? 0;\n this.colorSpace = new VideoSampleColorSpace({\n matrix: 'rgb',\n primaries: 'bt709',\n transfer: 'iec61966-2-1',\n fullRange: true,\n });\n }\n else if (data instanceof VideoSampleResource) {\n if (!init || typeof init !== 'object') {\n throw new TypeError('init must be an object.');\n }\n if (init.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {\n throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');\n }\n if (!Number.isFinite(init.timestamp)) {\n throw new TypeError('init.timestamp must be a number.');\n }\n if (init.duration !== undefined && (!Number.isFinite(init.duration) || init.duration < 0)) {\n throw new TypeError('init.duration, when provided, must be a non-negative number.');\n }\n this._data = data;\n data._referenceCount++;\n this.format = data.getFormat();\n if (this.format !== null && !VIDEO_SAMPLE_PIXEL_FORMATS.includes(this.format)) {\n throw new TypeError('getFormat() must return a VideoSamplePixelFormat or null.');\n }\n this.visibleRect = {\n left: 0,\n top: 0,\n width: data.getCodedWidth(),\n height: data.getCodedHeight(),\n };\n if (!Number.isInteger(this.visibleRect.width) || this.visibleRect.width <= 0) {\n throw new TypeError('getCodedWidth() must return a positive integer.');\n }\n if (!Number.isInteger(this.visibleRect.height) || this.visibleRect.height <= 0) {\n throw new TypeError('getCodedHeight() must return a positive integer.');\n }\n this.squarePixelWidth = data.getSquarePixelWidth();\n if (!Number.isInteger(this.squarePixelWidth) || this.squarePixelWidth <= 0) {\n throw new TypeError('getSquarePixelWidth() must return a positive integer.');\n }\n this.squarePixelHeight = data.getSquarePixelHeight();\n if (!Number.isInteger(this.squarePixelHeight) || this.squarePixelHeight <= 0) {\n throw new TypeError('getSquarePixelHeight() must return a positive integer.');\n }\n this.rotation = init.rotation ?? 0;\n this.timestamp = init.timestamp;\n this.duration = init.duration ?? 0;\n this.colorSpace = data.getColorSpace();\n }\n else {\n throw new TypeError('Invalid data type: Must be a BufferSource, CanvasImageSource, or VideoSampleResource.');\n }\n this.encodeOptions = init?.encodeOptions ?? {};\n this.pixelAspectRatio = simplifyRational({\n num: this.squarePixelWidth * this.codedHeight,\n den: this.squarePixelHeight * this.codedWidth,\n });\n finalizationRegistry?.register(this, { type: 'video', data: this._data }, this);\n }\n /** Clones this video sample. */\n clone() {\n if (this._closed) {\n throw new Error('VideoSample is closed.');\n }\n assert(this._data !== null);\n if (this._data instanceof VideoSampleResource) {\n return new VideoSample(this._data, {\n timestamp: this.timestamp,\n duration: this.duration,\n rotation: this.rotation,\n encodeOptions: this.encodeOptions,\n });\n }\n else if (isVideoFrame(this._data)) {\n return new VideoSample(this._data.clone(), {\n timestamp: this.timestamp,\n duration: this.duration,\n rotation: this.rotation,\n encodeOptions: this.encodeOptions,\n });\n }\n else if (this._data instanceof Uint8Array) {\n assert(this._layout);\n return new VideoSample(this._data, {\n format: this.format,\n layout: this._layout,\n codedWidth: this.codedWidth,\n codedHeight: this.codedHeight,\n timestamp: this.timestamp,\n duration: this.duration,\n colorSpace: this.colorSpace,\n rotation: this.rotation,\n visibleRect: this.visibleRect,\n displayWidth: this.displayWidth,\n displayHeight: this.displayHeight,\n encodeOptions: this.encodeOptions,\n // It's already been copied, if we copy it again we make the clone unnecessarily expensive\n _doNotCopy: true,\n });\n }\n else {\n return new VideoSample(this._data, {\n format: this.format,\n codedWidth: this.codedWidth,\n codedHeight: this.codedHeight,\n timestamp: this.timestamp,\n duration: this.duration,\n colorSpace: this.colorSpace,\n rotation: this.rotation,\n visibleRect: this.visibleRect,\n displayWidth: this.displayWidth,\n displayHeight: this.displayHeight,\n encodeOptions: this.encodeOptions,\n });\n }\n }\n /**\n * Closes this video sample, releasing held resources. Video samples should be closed as soon as they are not\n * needed anymore.\n */\n close() {\n if (this._closed) {\n return;\n }\n finalizationRegistry?.unregister(this);\n if (this._data instanceof VideoSampleResource) {\n this._data._referenceCount--;\n if (this._data._referenceCount === 0) {\n this._data.close();\n }\n }\n else if (isVideoFrame(this._data)) {\n this._data.close();\n }\n else {\n this._data = null; // GC that shit\n }\n this._closed = true;\n }\n /**\n * Returns the number of bytes required to hold this video sample's pixel data.\n */\n allocationSize(options = {}) {\n validateVideoFrameCopyToOptions(options);\n if (this._closed) {\n throw new Error('VideoSample is closed.');\n }\n if ((options.format ?? this.format) == null) {\n // https://github.com/Vanilagy/mediabunny/issues/267\n // https://github.com/w3c/webcodecs/issues/920\n throw new Error('Cannot get allocation size when format is null.');\n }\n if (isVideoFrame(this._data)) {\n // Call the native method purely for performance\n return this._data.allocationSize(options);\n }\n const combinedLayout = ParseVideoFrameCopyToOptions(this, options);\n return combinedLayout.allocationSize;\n }\n /**\n * Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView.\n * @returns The byte layout of the planes of the copied data.\n */\n async copyTo(destination, options = {}) {\n if (!isAllowSharedBufferSource(destination)) {\n throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.');\n }\n validateVideoFrameCopyToOptions(options);\n if (this._closed) {\n throw new Error('VideoSample is closed.');\n }\n if ((options.format ?? this.format) == null) {\n throw new Error('Cannot copy video sample data when format is null.');\n }\n assert(this._data !== null);\n if (isVideoFrame(this._data)) {\n return this._data.copyTo(destination, options);\n }\n // Detect non-RGB to RGB conversion\n if (options.format\n && !['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(this.format)\n && ['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format)) {\n // RGB conversion for custom VideoSampleResource\n if (this._data instanceof VideoSampleResource) {\n const env_1 = { stack: [], error: void 0, hasError: false };\n try {\n const rgbSample = __addDisposableResource(env_1, await this._data.toRgbSample({\n timestamp: this.timestamp,\n duration: this.duration,\n rotation: this.rotation,\n }, options.colorSpace ?? 'srgb'), false);\n if (!(rgbSample instanceof VideoSample)) {\n throw new TypeError('toRgbSample() must return a VideoSample.');\n }\n if (!['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(rgbSample.format)) {\n throw new Error(`Sample returned by toRgbSample was expected to have an RGB format, got`\n + ` '${rgbSample.format}' instead.`);\n }\n // Note that we DON'T force the RGB format to be exactly what was requested; any RGB format will do\n return await rgbSample.copyTo(destination, options); // 'await' is intentional here cuz of using\n }\n catch (e_1) {\n env_1.error = e_1;\n env_1.hasError = true;\n }\n finally {\n __disposeResources(env_1);\n }\n }\n else {\n if (typeof VideoFrame === 'undefined') {\n throw new Error('For this sample, converting from a non-RGB to an RGB format requires VideoFrame to'\n + ' be defined.');\n }\n const tempFrame = this.toVideoFrame();\n const result = await tempFrame.copyTo(destination, options);\n tempFrame.close();\n return result;\n }\n }\n const combinedLayout = ParseVideoFrameCopyToOptions(this, options);\n assert(this.format);\n // 4. If destination.byteLength is less than combinedLayout’s allocationSize, return a promise rejected with\n const destBytes = toUint8Array(destination);\n if (destBytes.byteLength < combinedLayout.allocationSize) {\n throw new TypeError(`Destination buffer too small. Required: ${combinedLayout.allocationSize},`\n + ` Available: ${destBytes.byteLength}`);\n }\n const planeConfigs = getPlaneConfigs(this.format);\n let dataPlanes;\n if (this._data instanceof VideoSampleResource) {\n let result = this._data.getDataPlanes();\n if (result instanceof Promise)\n result = await result;\n if (!Array.isArray(result)\n || result.some(x => !(x.data instanceof Uint8Array) || !Number.isInteger(x.stride) || x.stride < 0)) {\n throw new TypeError('getDataPlanes() must return an array of objects with a Uint8Array \"data\" property and a'\n + ' non-negative integer \"stride\" property.');\n }\n dataPlanes = result;\n }\n else if (this._data instanceof Uint8Array) {\n assert(this._layout);\n assert(this._layout.length === planeConfigs.length);\n dataPlanes = this._layout.map((planeLayout, i) => {\n const height = Math.ceil(this.codedHeight / planeConfigs[i].heightDivisor);\n return {\n data: this._data.subarray(planeLayout.offset, planeLayout.offset + planeLayout.stride * height),\n stride: planeLayout.stride,\n };\n });\n }\n else {\n const canvas = this._data;\n const context = canvas.getContext('2d');\n assert(context); // We already got it earlier so it's definitely available\n const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);\n dataPlanes = [{\n data: toUint8Array(imageData.data),\n stride: 4 * this.codedWidth,\n }];\n }\n // Algo taken from WebCodecs spec:\n // 6. Let p be a new Promise. (Implicit)\n // 7. Let copyStepsQueue be the result of starting a new parallel queue. (Implicit)\n // 8. Let planeLayouts be a new list.\n const planeLayouts = [];\n // Enqueue the following steps to copyStepsQueue: (fuck the queuing part)\n // Let resource be the media resource referenced by [[resource reference]].\n // (this.data)\n // Let numPlanes be the number of planes as defined by [[format]].\n const numPlanes = planeConfigs.length;\n // Let planeIndex be 0.\n // While planeIndex is less than combinedLayout’s numPlanes:\n for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {\n const computedLayout = combinedLayout.computedLayouts[planeIndex];\n // Let sourceStride be the stride of the plane in resource as identified by planeIndex.\n const sourceStride = dataPlanes[planeIndex].stride;\n const sourceData = dataPlanes[planeIndex].data;\n // Let sourceOffset be the product of multiplying computedLayout’s sourceTop by sourceStride\n let sourceOffset = computedLayout.sourceTop * sourceStride;\n // Add computedLayout’s sourceLeftBytes to sourceOffset.\n sourceOffset += computedLayout.sourceLeftBytes;\n // Let destinationOffset be computedLayout’s destinationOffset.\n let destinationOffset = computedLayout.destinationOffset;\n // Let rowBytes be computedLayout’s sourceWidthBytes.\n const rowBytes = computedLayout.sourceWidthBytes;\n // Let layout be a new PlaneLayout, with offset set to destinationOffset and stride set to rowBytes.\n // This is a spec error actually (https://github.com/w3c/webcodecs/issues/918)\n const layout = {\n offset: destinationOffset,\n stride: computedLayout.destinationStride,\n };\n // Let row be 0.\n // While row is less than computedLayout’s sourceHeight:\n for (let row = 0; row < computedLayout.sourceHeight; row++) {\n // Copy rowBytes bytes from resource starting at sourceOffset to destination starting\n // at destinationOffset.\n if (sourceOffset + rowBytes > sourceData.byteLength) {\n throw new Error(`Source buffer OOB read.`);\n }\n if (destinationOffset + rowBytes > destBytes.byteLength) {\n throw new Error(`Destination buffer OOB write.`);\n }\n const srcSub = sourceData.subarray(sourceOffset, sourceOffset + rowBytes);\n destBytes.set(srcSub, destinationOffset);\n // Increment sourceOffset by sourceStride.\n sourceOffset += sourceStride;\n // Increment destinationOffset by computedLayout’s destinationStride.\n destinationOffset += computedLayout.destinationStride;\n }\n // Append layout to planeLayouts.\n planeLayouts.push(layout);\n }\n // Now, handle converting between different RGB formats\n if (options.format !== undefined) {\n const needsRgbConversion = this.format.startsWith('RGB') !== options.format.startsWith('RGB');\n // Going X->A requires setting the alpha to 255, going the other way doesn't since the value of X is w/e\n const needsAlphaConversion = this.format.includes('X') && options.format.includes('A');\n if (needsRgbConversion || needsAlphaConversion) {\n // Loop over the destination bytes\n for (let i = 0; i < combinedLayout.allocationSize; i += 4) {\n if (needsRgbConversion) {\n // Swap R with B\n const r = destBytes[i];\n const b = destBytes[i + 2];\n destBytes[i] = b;\n destBytes[i + 2] = r;\n }\n if (needsAlphaConversion) {\n destBytes[i + 3] = 255;\n }\n }\n }\n }\n // Queue a task to resolve p with planeLayouts.\n return planeLayouts;\n }\n /**\n * Converts this video sample to a VideoFrame for use with the WebCodecs API. The VideoFrame returned by this\n * method *must* be closed separately from this video sample.\n */\n toVideoFrame() {\n if (this._closed) {\n throw new Error('VideoSample is closed.');\n }\n assert(this._data !== null);\n if (this._data instanceof VideoSampleResource) {\n if (this.format === null) {\n throw new Error('Cannot convert a VideoSampleResource-backed VideoSample to VideoFrame if format is null.');\n }\n const planes = this._data.getDataPlanes();\n if (planes instanceof Promise) {\n throw new Error('Cannot convert a VideoSampleResource-backed VideoSample to VideoFrame if getDataPlanes() returns'\n + ' a promise.');\n }\n // We can't use allocationSize since that method assumes a tight packing\n const size = planes.reduce((a, b) => a + b.data.byteLength, 0);\n const buffer = new Uint8Array(size);\n let offset = 0;\n const offsets = [];\n for (const plane of planes) {\n buffer.set(plane.data, offset);\n offsets.push(offset);\n offset += plane.data.byteLength;\n }\n return new VideoFrame(buffer, {\n format: this.format,\n layout: planes.map((x, i) => ({\n offset: offsets[i],\n stride: x.stride,\n })),\n codedWidth: this.codedWidth,\n codedHeight: this.codedHeight,\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration,\n colorSpace: this.colorSpace,\n visibleRect: this.visibleRect,\n displayWidth: this.squarePixelWidth, // Not display* since we're not passing rotation\n displayHeight: this.squarePixelHeight,\n });\n }\n else if (isVideoFrame(this._data)) {\n return new VideoFrame(this._data, {\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration || undefined, // Drag 0 duration to undefined, glitches some codecs\n });\n }\n else if (this._data instanceof Uint8Array) {\n assert(this._layout);\n return new VideoFrame(this._data, {\n format: this.format,\n codedWidth: this.codedWidth, // This is technically wrong! codedWidth is a lie technically. But, since\n codedHeight: this.codedHeight, // we pass the layout (which contains the true coded width), we're good.\n layout: this._layout,\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration || undefined,\n colorSpace: this.colorSpace,\n visibleRect: this.visibleRect,\n displayWidth: this.squarePixelWidth, // Not display* since we're not passing rotation\n displayHeight: this.squarePixelHeight,\n });\n }\n else {\n return new VideoFrame(this._data, {\n timestamp: this.microsecondTimestamp,\n duration: this.microsecondDuration || undefined,\n });\n }\n }\n draw(context, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {\n let sx = 0;\n let sy = 0;\n let sWidth = this.displayWidth;\n let sHeight = this.displayHeight;\n let dx = 0;\n let dy = 0;\n let dWidth = this.displayWidth;\n let dHeight = this.displayHeight;\n if (arg5 !== undefined) {\n sx = arg1;\n sy = arg2;\n sWidth = arg3;\n sHeight = arg4;\n dx = arg5;\n dy = arg6;\n if (arg7 !== undefined) {\n dWidth = arg7;\n dHeight = arg8;\n }\n else {\n dWidth = sWidth;\n dHeight = sHeight;\n }\n }\n else {\n dx = arg1;\n dy = arg2;\n if (arg3 !== undefined) {\n dWidth = arg3;\n dHeight = arg4;\n }\n }\n if (!((typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)\n || (typeof OffscreenCanvasRenderingContext2D !== 'undefined'\n && context instanceof OffscreenCanvasRenderingContext2D))) {\n throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');\n }\n if (!Number.isFinite(sx)) {\n throw new TypeError('sx must be a number.');\n }\n if (!Number.isFinite(sy)) {\n throw new TypeError('sy must be a number.');\n }\n if (!Number.isFinite(sWidth) || sWidth < 0) {\n throw new TypeError('sWidth must be a non-negative number.');\n }\n if (!Number.isFinite(sHeight) || sHeight < 0) {\n throw new TypeError('sHeight must be a non-negative number.');\n }\n if (!Number.isFinite(dx)) {\n throw new TypeError('dx must be a number.');\n }\n if (!Number.isFinite(dy)) {\n throw new TypeError('dy must be a number.');\n }\n if (!Number.isFinite(dWidth) || dWidth < 0) {\n throw new TypeError('dWidth must be a non-negative number.');\n }\n if (!Number.isFinite(dHeight) || dHeight < 0) {\n throw new TypeError('dHeight must be a non-negative number.');\n }\n if (this._closed) {\n throw new Error('VideoSample is closed.');\n }\n ({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));\n const source = this.toCanvasImageSource();\n context.save();\n const centerX = dx + dWidth / 2;\n const centerY = dy + dHeight / 2;\n context.translate(centerX, centerY);\n context.rotate(this.rotation * Math.PI / 180);\n const aspectRatioChange = this.rotation % 180 === 0 ? 1 : dWidth / dHeight;\n // Scale to compensate for aspect ratio changes when rotated\n context.scale(1 / aspectRatioChange, aspectRatioChange);\n context.drawImage(source, sx, sy, sWidth, sHeight, -dWidth / 2, -dHeight / 2, dWidth, dHeight);\n context.restore();\n }\n /**\n * Draws the sample in the middle of the canvas corresponding to the context with the specified fit behavior.\n */\n drawWithFit(context, options) {\n if (!((typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)\n || (typeof OffscreenCanvasRenderingContext2D !== 'undefined'\n && context instanceof OffscreenCanvasRenderingContext2D))) {\n throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');\n }\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (!['fill', 'contain', 'cover'].includes(options.fit)) {\n throw new TypeError('options.fit must be \\'fill\\', \\'contain\\', or \\'cover\\'.');\n }\n if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {\n throw new TypeError('options.rotation, when provided, must be 0, 90, 180, or 270.');\n }\n if (options.crop !== undefined) {\n validateCropRectangle(options.crop, 'options.');\n }\n const canvasWidth = context.canvas.width;\n const canvasHeight = context.canvas.height;\n const rotation = options.rotation ?? this.rotation;\n const [rotatedWidth, rotatedHeight] = rotation % 180 === 0\n ? [this.squarePixelWidth, this.squarePixelHeight]\n : [this.squarePixelHeight, this.squarePixelWidth];\n let finalCrop = options.crop;\n if (finalCrop) {\n finalCrop = clampCropRectangle(finalCrop, rotatedWidth, rotatedHeight);\n }\n // These variables specify where the final sample will be drawn on the canvas\n let dx;\n let dy;\n let newWidth;\n let newHeight;\n const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(options.crop?.left ?? 0, options.crop?.top ?? 0, options.crop?.width ?? rotatedWidth, options.crop?.height ?? rotatedHeight, rotation);\n if (options.fit === 'fill') {\n dx = 0;\n dy = 0;\n newWidth = canvasWidth;\n newHeight = canvasHeight;\n }\n else {\n const [sampleWidth, sampleHeight] = options.crop\n ? [options.crop.width, options.crop.height]\n : [rotatedWidth, rotatedHeight];\n const scale = options.fit === 'contain'\n ? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)\n : Math.max(canvasWidth / sampleWidth, canvasHeight / sampleHeight);\n newWidth = sampleWidth * scale;\n newHeight = sampleHeight * scale;\n dx = (canvasWidth - newWidth) / 2;\n dy = (canvasHeight - newHeight) / 2;\n }\n context.save();\n const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;\n context.translate(canvasWidth / 2, canvasHeight / 2);\n context.rotate(rotation * Math.PI / 180);\n // This aspect ratio compensation is done so that we can draw the sample with the intended dimensions and\n // don't need to think about how those dimensions change after the rotation\n context.scale(1 / aspectRatioChange, aspectRatioChange);\n context.translate(-canvasWidth / 2, -canvasHeight / 2);\n // Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it\n // ourselves here\n context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);\n context.restore();\n }\n /** @internal */\n _rotateSourceRegion(sx, sy, sWidth, sHeight, rotation) {\n // The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is\n // stored. Therefore, we must map these back onto the original, pre-rotation image.\n if (rotation === 90) {\n [sx, sy, sWidth, sHeight] = [\n sy,\n this.squarePixelHeight - sx - sWidth,\n sHeight,\n sWidth,\n ];\n }\n else if (rotation === 180) {\n [sx, sy] = [\n this.squarePixelWidth - sx - sWidth,\n this.squarePixelHeight - sy - sHeight,\n ];\n }\n else if (rotation === 270) {\n [sx, sy, sWidth, sHeight] = [\n this.squarePixelWidth - sy - sHeight,\n sx,\n sHeight,\n sWidth,\n ];\n }\n return { sx, sy, sWidth, sHeight };\n }\n /**\n * Converts this video sample to a\n * [`CanvasImageSource`](https://udn.realityripple.com/docs/Web/API/CanvasImageSource) for drawing to a canvas.\n *\n * You must use the value returned by this method immediately, as any VideoFrame created internally may\n * automatically be closed in the next microtask.\n */\n toCanvasImageSource() {\n if (this._closed) {\n throw new Error('VideoSample is closed.');\n }\n assert(this._data !== null);\n if (this._data instanceof VideoSampleResource || this._data instanceof Uint8Array) {\n // Requires VideoFrame to be defined\n const videoFrame = this.toVideoFrame();\n queueMicrotask(() => videoFrame.close()); // Let's automatically close the frame in the next microtask\n return videoFrame;\n }\n else {\n return this._data;\n }\n }\n /**\n * Transform this video sample to a new video sample given the options. Can be used to resize, rotate, and crop\n * the sample.\n *\n * In non-browser environments, this method will not work by default. To make it work, register a custom\n * transformer function via {@link registerVideoSampleTransformer}.\n */\n async transform(options) {\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.width !== undefined && (!Number.isInteger(options.width) || options.width <= 0)) {\n throw new TypeError('options.width, when provided, must be a positive integer.');\n }\n if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {\n throw new TypeError('options.height, when provided, must be a positive integer.');\n }\n if (options.roundDimensionsTo !== undefined\n && (!Number.isInteger(options.roundDimensionsTo) || options.roundDimensionsTo <= 0)) {\n throw new TypeError('options.roundDimensionsTo, when provided, must be a positive integer.');\n }\n if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) {\n throw new TypeError('options.fit, when provided, must be one of \"fill\", \"contain\", or \"cover\".');\n }\n if (options.width !== undefined\n && options.height !== undefined\n && options.fit === undefined) {\n throw new TypeError('When both options.width and options.height are provided, options.fit must also be provided.');\n }\n if (options.rotate !== undefined && ![0, 90, 180, 270].includes(options.rotate)) {\n throw new TypeError('options.rotate, when provided, must be 0, 90, 180 or 270.');\n }\n if (options.crop !== undefined) {\n validateCropRectangle(options.crop, 'options.');\n }\n if (options.alpha !== undefined && !['keep', 'discard'].includes(options.alpha)) {\n throw new TypeError('options.alpha, when provided, must be \\'keep\\' or \\'discard\\'.');\n }\n const rotation = normalizeRotation(this.rotation + (options.rotate ?? 0));\n const [rotatedWidth, rotatedHeight] = rotation % 180 === 0\n ? [this.squarePixelWidth, this.squarePixelHeight]\n : [this.squarePixelHeight, this.squarePixelWidth];\n // Clamp crop rectangle to the rotated video dimensions\n let finalCrop = options.crop;\n if (finalCrop) {\n finalCrop = clampCropRectangle(finalCrop, rotatedWidth, rotatedHeight);\n }\n const cropWidth = finalCrop ? finalCrop.width : rotatedWidth;\n const cropHeight = finalCrop ? finalCrop.height : rotatedHeight;\n const originalAspectRatio = cropWidth / cropHeight;\n let targetWidth;\n let targetHeight;\n if (options.width !== undefined && options.height === undefined) {\n targetWidth = options.width;\n targetHeight = targetWidth / originalAspectRatio;\n }\n else if (options.width === undefined && options.height !== undefined) {\n targetHeight = options.height;\n targetWidth = targetHeight * originalAspectRatio;\n }\n else if (options.width !== undefined && options.height !== undefined) {\n targetWidth = options.width;\n targetHeight = options.height;\n }\n else {\n targetWidth = cropWidth;\n targetHeight = cropHeight;\n }\n targetWidth = roundToMultiple(targetWidth, options.roundDimensionsTo ?? 1);\n targetHeight = roundToMultiple(targetHeight, options.roundDimensionsTo ?? 1);\n const description = {\n width: targetWidth,\n height: targetHeight,\n fit: options.fit ?? 'fill',\n rotation,\n crop: finalCrop ?? {\n left: 0,\n top: 0,\n width: rotatedWidth,\n height: rotatedHeight,\n },\n alpha: options.alpha ?? 'keep',\n };\n // Description's finalized; let's see if a registered transformer wants to handle it\n for (const transformer of registeredVideoSampleTransformers) {\n let result = transformer(this, description);\n if (result instanceof Promise)\n result = await result;\n if (result !== null) {\n return result;\n }\n }\n // We need to handle it ourselves, and we use canvases to do it\n let canvas = null;\n let canvasIsNew = false;\n for (const entry of transformationCanvasCache) {\n if (entry.canvas.width === description.width && entry.canvas.height === description.height) {\n canvas = entry.canvas;\n entry.age = transformationCanvasCacheNextAge++;\n break;\n }\n }\n if (canvas === null) {\n if (typeof OffscreenCanvas !== 'undefined') {\n canvas = new OffscreenCanvas(description.width, description.height);\n }\n else {\n if (typeof window === 'undefined' || typeof document === 'undefined') {\n throw new Error('Cannot transform VideoSamples in this environment. Either run in an environment with'\n + ' OffscreenCanvas or HTMLCanvasElement, or supply a custom VideoSample transformer using'\n + ' registerVideoSampleTransformer().');\n }\n canvas = document.createElement('canvas');\n canvas.width = description.width;\n canvas.height = description.height;\n }\n canvasIsNew = true;\n if (transformationCanvasCache.length >= TRANSFORMATION_CANVAS_CACHE_MAX_SIZE) {\n transformationCanvasCache.splice(arrayArgmin(transformationCanvasCache, x => x.age), 1);\n }\n transformationCanvasCache.push({\n canvas,\n age: transformationCanvasCacheNextAge++,\n });\n }\n const context = canvas.getContext('2d', {\n alpha: true,\n });\n if (!context) {\n throw new Error('The \\'2d\\' canvas context is required to transform VideoSamples. Register a custom transformer using'\n + ' registerVideoSampleTransformer to work around this limitation.');\n }\n context.imageSmoothingQuality = 'high';\n if (description.alpha === 'discard') {\n context.fillStyle = 'black';\n context.fillRect(0, 0, description.width, description.height);\n }\n else if (!canvasIsNew) {\n // Cached canvases carry stale pixels from a prior draw\n context.clearRect(0, 0, description.width, description.height);\n }\n this.drawWithFit(context, {\n fit: description.fit,\n rotation: description.rotation,\n crop: description.crop,\n });\n return new VideoSample(canvas, {\n timestamp: this.timestamp,\n duration: this.duration,\n rotation: 0, // Any previous rotation is now baked in\n });\n }\n /** Sets the rotation metadata of this video sample. */\n setRotation(newRotation) {\n if (![0, 90, 180, 270].includes(newRotation)) {\n throw new TypeError('newRotation must be 0, 90, 180, or 270.');\n }\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion\n this.rotation = newRotation;\n }\n /** Sets the presentation timestamp of this video sample, in seconds. */\n setTimestamp(newTimestamp) {\n if (!Number.isFinite(newTimestamp)) {\n throw new TypeError('newTimestamp must be a number.');\n }\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion\n this.timestamp = newTimestamp;\n }\n /** Sets the duration of this video sample, in seconds. */\n setDuration(newDuration) {\n if (!Number.isFinite(newDuration) || newDuration < 0) {\n throw new TypeError('newDuration must be a non-negative number.');\n }\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion\n this.duration = newDuration;\n }\n /** Sets the encode options used when this sample is passed to an encoder. */\n setEncodeOptions(newEncodeOptions) {\n if (!newEncodeOptions || typeof newEncodeOptions !== 'object') {\n throw new TypeError('newEncodeOptions must be an object.');\n }\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion\n this.encodeOptions = newEncodeOptions;\n }\n /** Calls `.close()`. */\n [Symbol.dispose]() {\n this.close();\n }\n }\n const registeredVideoSampleTransformers = [];\n const TRANSFORMATION_CANVAS_CACHE_MAX_SIZE = 3;\n const transformationCanvasCache = [];\n let transformationCanvasCacheNextAge = 0;\n /**\n * Describes the color space of a {@link VideoSample}. Corresponds to the WebCodecs API's VideoColorSpace.\n * @group Samples\n * @public\n */\n class VideoSampleColorSpace {\n /** Creates a new VideoSampleColorSpace. */\n constructor(init) {\n if (init !== undefined) {\n if (!init || typeof init !== 'object') {\n throw new TypeError('init.colorSpace, when provided, must be an object.');\n }\n const primariesValues = Object.keys(COLOR_PRIMARIES_MAP);\n if (init.primaries != null && !primariesValues.includes(init.primaries)) {\n throw new TypeError(`init.colorSpace.primaries, when provided, must be one of ${primariesValues.join(', ')}.`);\n }\n const transferValues = Object.keys(TRANSFER_CHARACTERISTICS_MAP);\n if (init.transfer != null && !transferValues.includes(init.transfer)) {\n throw new TypeError(`init.colorSpace.transfer, when provided, must be one of ${transferValues.join(', ')}.`);\n }\n const matrixValues = Object.keys(MATRIX_COEFFICIENTS_MAP);\n if (init.matrix != null && !matrixValues.includes(init.matrix)) {\n throw new TypeError(`init.colorSpace.matrix, when provided, must be one of ${matrixValues.join(', ')}.`);\n }\n if (init.fullRange != null && typeof init.fullRange !== 'boolean') {\n throw new TypeError('init.colorSpace.fullRange, when provided, must be a boolean.');\n }\n }\n this.primaries = init?.primaries ?? null;\n this.transfer = init?.transfer ?? null;\n this.matrix = init?.matrix ?? null;\n this.fullRange = init?.fullRange ?? null;\n }\n /** Serializes the color space to a JSON object. */\n toJSON() {\n return {\n primaries: this.primaries,\n transfer: this.transfer,\n matrix: this.matrix,\n fullRange: this.fullRange,\n };\n }\n }\n const isVideoFrame = (x) => {\n return typeof VideoFrame !== 'undefined' && x instanceof VideoFrame;\n };\n const clampCropRectangle = (crop, outerWidth, outerHeight) => {\n const left = Math.min(crop.left, outerWidth);\n const top = Math.min(crop.top, outerHeight);\n const width = Math.min(crop.width, outerWidth - left);\n const height = Math.min(crop.height, outerHeight - top);\n assert(width >= 0);\n assert(height >= 0);\n return { left, top, width, height };\n };\n const validateCropRectangle = (crop, prefix) => {\n if (!crop || typeof crop !== 'object') {\n throw new TypeError(prefix + 'crop, when provided, must be an object.');\n }\n if (!Number.isInteger(crop.left) || crop.left < 0) {\n throw new TypeError(prefix + 'crop.left must be a non-negative integer.');\n }\n if (!Number.isInteger(crop.top) || crop.top < 0) {\n throw new TypeError(prefix + 'crop.top must be a non-negative integer.');\n }\n if (!Number.isInteger(crop.width) || crop.width < 0) {\n throw new TypeError(prefix + 'crop.width must be a non-negative integer.');\n }\n if (!Number.isInteger(crop.height) || crop.height < 0) {\n throw new TypeError(prefix + 'crop.height must be a non-negative integer.');\n }\n };\n const validateVideoFrameCopyToOptions = (options) => {\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.colorSpace !== undefined && !['display-p3', 'srgb'].includes(options.colorSpace)) {\n throw new TypeError('options.colorSpace, when provided, must be \\'display-p3\\' or \\'srgb\\'.');\n }\n if (options.format !== undefined && typeof options.format !== 'string') {\n throw new TypeError('options.format, when provided, must be a string.');\n }\n if (options.layout !== undefined) {\n if (!Array.isArray(options.layout)) {\n throw new TypeError('options.layout, when provided, must be an array.');\n }\n for (const plane of options.layout) {\n if (!plane || typeof plane !== 'object') {\n throw new TypeError('Each entry in options.layout must be an object.');\n }\n if (!Number.isInteger(plane.offset) || plane.offset < 0) {\n throw new TypeError('plane.offset must be a non-negative integer.');\n }\n if (!Number.isInteger(plane.stride) || plane.stride < 0) {\n throw new TypeError('plane.stride must be a non-negative integer.');\n }\n }\n }\n if (options.rect !== undefined) {\n if (!options.rect || typeof options.rect !== 'object') {\n throw new TypeError('options.rect, when provided, must be an object.');\n }\n if (options.rect.x !== undefined && (!Number.isInteger(options.rect.x) || options.rect.x < 0)) {\n throw new TypeError('options.rect.x, when provided, must be a non-negative integer.');\n }\n if (options.rect.y !== undefined && (!Number.isInteger(options.rect.y) || options.rect.y < 0)) {\n throw new TypeError('options.rect.y, when provided, must be a non-negative integer.');\n }\n if (options.rect.width !== undefined && (!Number.isInteger(options.rect.width) || options.rect.width < 0)) {\n throw new TypeError('options.rect.width, when provided, must be a non-negative integer.');\n }\n if (options.rect.height !== undefined && (!Number.isInteger(options.rect.height) || options.rect.height < 0)) {\n throw new TypeError('options.rect.height, when provided, must be a non-negative integer.');\n }\n }\n };\n /** Implements logic from WebCodecs § 9.4.6 \"Compute Layout and Allocation Size\" */\n const createDefaultPlaneLayout = (format, codedWidth, codedHeight) => {\n const planes = getPlaneConfigs(format);\n const layouts = [];\n let currentOffset = 0;\n for (const plane of planes) {\n // Per § 9.8, dimensions are usually \"rounded up to the nearest integer\".\n const planeWidth = Math.ceil(codedWidth / plane.widthDivisor);\n const planeHeight = Math.ceil(codedHeight / plane.heightDivisor);\n const stride = planeWidth * plane.sampleBytes;\n // Tight packing\n const planeSize = stride * planeHeight;\n layouts.push({\n offset: currentOffset,\n stride: stride,\n });\n currentOffset += planeSize;\n }\n return layouts;\n };\n /** Helper to retrieve plane configurations based on WebCodecs § 9.8 Pixel Format definitions. */\n const getPlaneConfigs = (format) => {\n // Helper for standard YUV planes\n const yuv = (yBytes, uvBytes, subX, subY, hasAlpha) => {\n const configs = [\n { sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 },\n { sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },\n { sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },\n ];\n if (hasAlpha) {\n // Match luma dimensions\n configs.push({ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 });\n }\n return configs;\n };\n switch (format) {\n case 'I420':\n return yuv(1, 1, 2, 2, false);\n case 'I420P10':\n case 'I420P12':\n return yuv(2, 2, 2, 2, false);\n case 'I420A':\n return yuv(1, 1, 2, 2, true);\n case 'I420AP10':\n case 'I420AP12':\n return yuv(2, 2, 2, 2, true);\n case 'I422':\n return yuv(1, 1, 2, 1, false);\n case 'I422P10':\n case 'I422P12':\n return yuv(2, 2, 2, 1, false);\n case 'I422A':\n return yuv(1, 1, 2, 1, true);\n case 'I422AP10':\n case 'I422AP12':\n return yuv(2, 2, 2, 1, true);\n case 'I444':\n return yuv(1, 1, 1, 1, false);\n case 'I444P10':\n case 'I444P12':\n return yuv(2, 2, 1, 1, false);\n case 'I444A':\n return yuv(1, 1, 1, 1, true);\n case 'I444AP10':\n case 'I444AP12':\n return yuv(2, 2, 1, 1, true);\n case 'NV12':\n return [\n { sampleBytes: 1, widthDivisor: 1, heightDivisor: 1 },\n { sampleBytes: 2, widthDivisor: 2, heightDivisor: 2 }, // Interleaved U and V\n ];\n case 'RGBA':\n case 'RGBX':\n case 'BGRA':\n case 'BGRX':\n return [\n { sampleBytes: 4, widthDivisor: 1, heightDivisor: 1 },\n ];\n default:\n assertNever(format);\n assert(false);\n }\n };\n /** Taken from the WebCodecs spec. */\n const ParseVideoFrameCopyToOptions = (sample, options) => {\n // 1. Let defaultRect be the result of performing the getter steps for visibleRect.\n const defaultRect = {\n left: 0,\n top: 0,\n width: sample.codedWidth,\n height: sample.codedHeight,\n };\n // 2. Let overrideRect be undefined.\n // 3. If options.rect exists, assign the value of options.rect to overrideRect.\n const overrideRect = options.rect;\n // 4. Let parsedRect be the result of running the Parse Visible Rect algorithm...\n const parsedRect = ParseVisibleRect(defaultRect, overrideRect, sample.codedWidth, sample.codedHeight, sample.format);\n // 5. If parsedRect is an exception, return parsedRect. (Handled by throw)\n // 6. Let optLayout be undefined.\n // 7. If options.layout exists, assign its value to optLayout.\n const optLayout = options.layout;\n // 8. Let format be undefined.\n let format;\n // 9. If options.format does not exist, assign [[format]] to format.\n if (!options.format || options.format === sample.format) {\n format = sample.format;\n }\n else if (['RGBA', 'RGBX', 'BGRA', 'BGRX'].includes(options.format)) {\n // 10. Otherwise, if options.format is equal to one of RGBA, RGBX, BGRA, BGRX, then assign options.format\n // to format...\n format = options.format;\n }\n else {\n throw new Error('NotSupportedError: Invalid destination format.');\n }\n // 11. Let combinedLayout be the result of running the Compute Layout and Allocation Size algorithm...\n return ComputeLayoutAndAllocationSize(parsedRect, format, optLayout);\n };\n /** Taken from the WebCodecs spec. */\n const ParseVisibleRect = (defaultRect, overrideRect, codedWidth, codedHeight, format) => {\n // 1. Let sourceRect be defaultRect\n const sourceRect = { ...defaultRect };\n // 2. If overrideRect is not undefined:\n if (overrideRect !== undefined) {\n // If either of overrideRect.width or height is 0, return a TypeError.\n if (overrideRect.width === 0 || overrideRect.height === 0) {\n throw new TypeError('visibleRect dimensions cannot be zero.');\n }\n // If the sum of overrideRect.x and overrideRect.width is greater than codedWidth, return a TypeError.\n if ((overrideRect.x || 0) + (overrideRect.width || 0) > codedWidth) {\n throw new TypeError('visibleRect exceeds codedWidth.');\n }\n // If the sum of overrideRect.y and overrideRect.height is greater than codedHeight, return a TypeError.\n if ((overrideRect.y || 0) + (overrideRect.height || 0) > codedHeight) {\n throw new TypeError('visibleRect exceeds codedHeight.');\n }\n // Assign overrideRect to sourceRect.\n sourceRect.x = overrideRect.x || 0;\n sourceRect.y = overrideRect.y || 0;\n sourceRect.width = overrideRect.width || 0;\n sourceRect.height = overrideRect.height || 0;\n }\n // 3. Let validAlignment be the result of running the Verify Rect Offset Alignment algorithm.\n const validAlignment = VerifyRectOffsetAlignment(format, sourceRect);\n // 4. If validAlignment is false, throw a TypeError.\n if (!validAlignment) {\n throw new TypeError('visibleRect alignment is invalid for the format.');\n }\n // 5. Return sourceRect.\n return sourceRect;\n };\n /** Taken from the WebCodecs spec. */\n const VerifyRectOffsetAlignment = (format, rect) => {\n // 1. If format is null, return true.\n if (format === null)\n return true;\n const planes = getPlaneConfigs(format);\n // 2. Let planeIndex be 0.\n // 3. Let numPlanes be the number of planes as defined by format.\n // 4. While planeIndex is less than numPlanes:\n for (let planeIndex = 0; planeIndex < planes.length; planeIndex++) {\n const plane = planes[planeIndex];\n const sampleWidth = plane.widthDivisor;\n const sampleHeight = plane.heightDivisor;\n // If rect.x is not a multiple of sampleWidth, return false.\n if ((rect.x || 0) % sampleWidth !== 0)\n return false;\n // If rect.y is not a multiple of sampleHeight, return false.\n if ((rect.y || 0) % sampleHeight !== 0)\n return false;\n }\n return true;\n };\n /** Taken from the WebCodecs spec. */\n const ComputeLayoutAndAllocationSize = (parsedRect, format, layout) => {\n const planes = getPlaneConfigs(format);\n // 1. Let numPlanes be the number of planes as defined by format.\n const numPlanes = planes.length;\n // 2. If layout is not undefined and its length does not equal numPlanes, throw a TypeError.\n if (layout !== undefined && layout.length !== numPlanes) {\n throw new TypeError(`Layout must have ${numPlanes} planes.`);\n }\n // 3. Let minAllocationSize be 0.\n let minAllocationSize = 0;\n // 4. Let computedLayouts be a new list.\n const computedLayouts = [];\n // 5. Let endOffsets be a new list.\n const endOffsets = [];\n // 6. Let planeIndex be 0.\n // 7. While planeIndex < numPlanes:\n for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {\n const plane = planes[planeIndex];\n const sampleBytes = plane.sampleBytes;\n const sampleWidth = plane.widthDivisor;\n const sampleHeight = plane.heightDivisor;\n // Let computedLayout be a new computed plane layout.\n const computedLayout = {\n destinationOffset: 0,\n destinationStride: 0,\n sourceTop: 0,\n sourceHeight: 0,\n sourceLeftBytes: 0,\n sourceWidthBytes: 0,\n };\n // Set computedLayout’s sourceTop...\n computedLayout.sourceTop = Math.ceil(Math.trunc(parsedRect.y || 0) / sampleHeight);\n // Set computedLayout’s sourceHeight...\n computedLayout.sourceHeight = Math.ceil(Math.trunc(parsedRect.height || 0) / sampleHeight);\n // Set computedLayout’s sourceLeftBytes...\n computedLayout.sourceLeftBytes = Math.floor(Math.trunc(parsedRect.x || 0) / sampleWidth) * sampleBytes;\n // Set computedLayout’s sourceWidthBytes...\n computedLayout.sourceWidthBytes = Math.floor(Math.trunc(parsedRect.width || 0) / sampleWidth) * sampleBytes;\n // If layout is not undefined:\n if (layout !== undefined) {\n const planeLayout = layout[planeIndex];\n // If planeLayout.stride is less than computedLayout’s sourceWidthBytes, return a TypeError.\n if (planeLayout.stride < computedLayout.sourceWidthBytes) {\n throw new TypeError(`Stride for plane ${planeIndex} is too small.`);\n }\n // Assign planeLayout.offset to computedLayout’s destinationOffset.\n computedLayout.destinationOffset = planeLayout.offset;\n // Assign planeLayout.stride to computedLayout’s destinationStride.\n computedLayout.destinationStride = planeLayout.stride;\n }\n else {\n // Otherwise:\n // Assign minAllocationSize to computedLayout’s destinationOffset.\n computedLayout.destinationOffset = minAllocationSize;\n // Assign computedLayout’s sourceWidthBytes to computedLayout’s destinationStride.\n computedLayout.destinationStride = computedLayout.sourceWidthBytes;\n }\n // Let planeSize be the product of multiplying computedLayout’s destinationStride and sourceHeight.\n const planeSize = computedLayout.destinationStride * computedLayout.sourceHeight;\n // Let planeEnd be the sum of planeSize and computedLayout’s destinationOffset.\n const planeEnd = planeSize + computedLayout.destinationOffset;\n // If planeSize or planeEnd is greater than maximum range of unsigned long, return a TypeError.\n if (planeEnd > 4294967295) {\n throw new TypeError('Allocation size exceeds limit.');\n }\n // Append planeEnd to endOffsets.\n endOffsets.push(planeEnd);\n // Assign the maximum of minAllocationSize and planeEnd to minAllocationSize.\n minAllocationSize = Math.max(minAllocationSize, planeEnd);\n // Check for overlap\n for (let earlierPlaneIndex = 0; earlierPlaneIndex < planeIndex; earlierPlaneIndex++) {\n const earlierLayout = computedLayouts[earlierPlaneIndex];\n // If plane A ends before plane B starts, they do not overlap.\n if (endOffsets[planeIndex] <= earlierLayout.destinationOffset\n || endOffsets[earlierPlaneIndex] <= computedLayout.destinationOffset) {\n continue;\n }\n throw new TypeError('Planes overlap.');\n }\n computedLayouts.push(computedLayout);\n }\n // 12. Return combinedLayout.\n return {\n allocationSize: minAllocationSize,\n computedLayouts: computedLayouts,\n };\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const customVideoDecoders = [];\n const customAudioDecoders = [];\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n const validatePacketRetrievalOptions = (options) => {\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.metadataOnly !== undefined && typeof options.metadataOnly !== 'boolean') {\n throw new TypeError('options.metadataOnly, when defined, must be a boolean.');\n }\n if (options.verifyKeyPackets !== undefined && typeof options.verifyKeyPackets !== 'boolean') {\n throw new TypeError('options.verifyKeyPackets, when defined, must be a boolean.');\n }\n if (options.verifyKeyPackets && options.metadataOnly) {\n throw new TypeError('options.verifyKeyPackets and options.metadataOnly cannot be enabled together.');\n }\n if (options.skipLiveWait !== undefined && typeof options.skipLiveWait !== 'boolean') {\n throw new TypeError('options.skipLiveWait, when defined, must be a boolean.');\n }\n };\n const validateTimestamp = (timestamp) => {\n if (!isNumber(timestamp)) {\n throw new TypeError('timestamp must be a number.'); // It can be non-finite, that's fine\n }\n };\n const maybeFixPacketType = (track, promise, options) => {\n if (options.verifyKeyPackets) {\n return promise.then(async (packet) => {\n if (!packet || packet.type === 'delta') {\n return packet;\n }\n const determinedType = await track.determinePacketType(packet);\n if (determinedType) {\n // @ts-expect-error Technically readonly\n packet.type = determinedType;\n }\n return packet;\n });\n }\n else {\n return promise;\n }\n };\n /**\n * Sink for retrieving encoded packets from an input track.\n * @group Media sinks\n * @public\n */\n class EncodedPacketSink {\n /** Creates a new {@link EncodedPacketSink} for the given {@link InputTrack}. */\n constructor(track) {\n if (!(track instanceof InputTrack)) {\n throw new TypeError('track must be an InputTrack.');\n }\n this._track = track;\n }\n /**\n * Retrieves the track's first packet (in decode order), or null if it has no packets. The first packet is very\n * likely to be a key packet, but it doesn't have to be.\n */\n async getFirstPacket(options = {}) {\n validatePacketRetrievalOptions(options);\n if (this._track.input._disposed) {\n throw new InputDisposedError();\n }\n return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);\n }\n /** Retrieves the track's first key packet (in decode order), or null if it has no key packets. */\n async getFirstKeyPacket(options = {}) {\n validatePacketRetrievalOptions(options);\n const firstPacket = await this.getFirstPacket(options);\n if (!firstPacket) {\n return null;\n }\n if (firstPacket.type === 'key') {\n // Great\n return firstPacket;\n }\n return this.getNextKeyPacket(firstPacket, options);\n }\n /**\n * Retrieves the packet corresponding to the given timestamp, in seconds. More specifically, returns the last packet\n * (in presentation order) with a start timestamp less than or equal to the given timestamp. This method can be\n * used to retrieve a track's last packet using `getPacket(Infinity)`. The method returns null if the timestamp\n * is before the first packet in the track.\n *\n * @param timestamp - The timestamp used for retrieval, in seconds.\n */\n async getPacket(timestamp, options = {}) {\n validateTimestamp(timestamp);\n validatePacketRetrievalOptions(options);\n if (this._track.input._disposed) {\n throw new InputDisposedError();\n }\n return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options);\n }\n /**\n * Retrieves the packet following the given packet (in decode order), or null if the given packet is the\n * last packet.\n */\n async getNextPacket(packet, options = {}) {\n if (!(packet instanceof EncodedPacket)) {\n throw new TypeError('packet must be an EncodedPacket.');\n }\n validatePacketRetrievalOptions(options);\n if (this._track.input._disposed) {\n throw new InputDisposedError();\n }\n return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options);\n }\n /**\n * Retrieves the key packet corresponding to the given timestamp, in seconds. More specifically, returns the last\n * key packet (in presentation order) with a start timestamp less than or equal to the given timestamp. A key packet\n * is a packet that doesn't require previous packets to be decoded. This method can be used to retrieve a track's\n * last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first\n * key packet in the track.\n *\n * To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.\n *\n * @param timestamp - The timestamp used for retrieval, in seconds.\n */\n async getKeyPacket(timestamp, options = {}) {\n validateTimestamp(timestamp);\n validatePacketRetrievalOptions(options);\n if (this._track.input._disposed) {\n throw new InputDisposedError();\n }\n if (!options.verifyKeyPackets) {\n return this._track._backing.getKeyPacket(timestamp, options);\n }\n const packet = await this._track._backing.getKeyPacket(timestamp, options);\n if (!packet) {\n return packet;\n }\n assert(packet.type === 'key');\n const determinedType = await this._track.determinePacketType(packet);\n if (determinedType === 'delta') {\n // Try returning the previous key packet (in hopes that it's actually a key packet)\n return this.getKeyPacket(packet.timestamp - 1 / await this._track.getTimeResolution(), options);\n }\n return packet;\n }\n /**\n * Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last\n * key packet.\n *\n * To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.\n */\n async getNextKeyPacket(packet, options = {}) {\n if (!(packet instanceof EncodedPacket)) {\n throw new TypeError('packet must be an EncodedPacket.');\n }\n validatePacketRetrievalOptions(options);\n if (this._track.input._disposed) {\n throw new InputDisposedError();\n }\n if (!options.verifyKeyPackets) {\n return this._track._backing.getNextKeyPacket(packet, options);\n }\n const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);\n if (!nextPacket) {\n return nextPacket;\n }\n assert(nextPacket.type === 'key');\n const determinedType = await this._track.determinePacketType(nextPacket);\n if (determinedType === 'delta') {\n // Try returning the next key packet (in hopes that it's actually a key packet)\n return this.getNextKeyPacket(nextPacket, options);\n }\n return nextPacket;\n }\n /**\n * Creates an async iterator that yields the packets in this track in decode order. To enable fast iteration, this\n * method will intelligently preload packets based on the speed of the consumer.\n *\n * @param startPacket - (optional) The packet from which iteration should begin. This packet will also be yielded.\n * @param endPacket - (optional) The packet at which iteration should end. This packet will _not_ be yielded.\n */\n packets(startPacket, endPacket, options = {}) {\n if (startPacket !== undefined && !(startPacket instanceof EncodedPacket)) {\n throw new TypeError('startPacket must be an EncodedPacket.');\n }\n if (startPacket !== undefined && startPacket.isMetadataOnly && !options?.metadataOnly) {\n throw new TypeError('startPacket can only be metadata-only if options.metadataOnly is enabled.');\n }\n if (endPacket !== undefined && !(endPacket instanceof EncodedPacket)) {\n throw new TypeError('endPacket must be an EncodedPacket.');\n }\n validatePacketRetrievalOptions(options);\n if (this._track.input._disposed) {\n throw new InputDisposedError();\n }\n const packetQueue = [];\n let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();\n let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();\n let ended = false;\n let terminated = false;\n // This stores errors that are \"out of band\" in the sense that they didn't occur in the normal flow of this\n // method but instead in a different context. This error should not go unnoticed and must be bubbled up to\n // the consumer.\n let outOfBandError = null;\n let hasOutOfBandError = false;\n const timestamps = [];\n // The queue should always be big enough to hold 1 second worth of packets\n const maxQueueSize = () => Math.max(2, timestamps.length);\n // The following is the \"pump\" process that keeps pumping packets into the queue\n (async () => {\n let packet = startPacket ?? await this.getFirstPacket(options);\n while (packet && !terminated && !this._track.input._disposed) {\n if (endPacket && packet.sequenceNumber >= endPacket?.sequenceNumber) {\n break;\n }\n if (packetQueue.length > maxQueueSize()) {\n ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());\n await queueDequeue;\n continue;\n }\n packetQueue.push(packet);\n onQueueNotEmpty();\n ({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());\n packet = await this.getNextPacket(packet, options);\n }\n ended = true;\n onQueueNotEmpty();\n })().catch((error) => {\n if (!hasOutOfBandError) {\n outOfBandError = error;\n hasOutOfBandError = true;\n onQueueNotEmpty();\n }\n });\n const track = this._track;\n return {\n async next() {\n while (true) {\n if (track.input._disposed) {\n throw new InputDisposedError();\n }\n else if (terminated) {\n return { value: undefined, done: true };\n }\n else if (hasOutOfBandError) {\n throw outOfBandError;\n }\n else if (packetQueue.length > 0) {\n const value = packetQueue.shift();\n const now = performance.now();\n timestamps.push(now);\n while (timestamps.length > 0 && now - timestamps[0] >= 1000) {\n timestamps.shift();\n }\n onQueueDequeue();\n return { value, done: false };\n }\n else if (ended) {\n return { value: undefined, done: true };\n }\n else {\n await queueNotEmpty;\n }\n }\n },\n async return() {\n terminated = true;\n onQueueDequeue();\n onQueueNotEmpty();\n return { value: undefined, done: true };\n },\n async throw(error) {\n throw error;\n },\n [Symbol.asyncIterator]() {\n return this;\n },\n };\n }\n }\n class DecoderWrapper {\n constructor(onSample, onError) {\n this.onSample = onSample;\n this.onError = onError;\n }\n }\n /**\n * Base class for decoded media sample sinks.\n * @group Media sinks\n * @public\n */\n class BaseMediaSampleSink {\n /** @internal */\n mediaSamplesInRange(startTimestamp = -Infinity, endTimestamp = Infinity, options) {\n validateTimestamp(startTimestamp);\n validateTimestamp(endTimestamp);\n const sampleQueue = [];\n let firstSampleQueued = false;\n let lastSample = null;\n let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();\n let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();\n let decoderIsFlushed = false;\n let ended = false;\n let terminated = false;\n let decoder = null;\n // This stores errors that are \"out of band\" in the sense that they didn't occur in the normal flow of this\n // method but instead in a different context. This error should not go unnoticed and must be bubbled up to\n // the consumer.\n let outOfBandError = null;\n let hasOutOfBandError = false;\n const packetRetrievalOptions = {\n ...options,\n verifyKeyPackets: true,\n metadataOnly: false,\n };\n // The following is the \"pump\" process that keeps pumping packets into the decoder\n (async () => {\n decoder = await this._createDecoder((sample) => {\n onQueueDequeue();\n if (sample.timestamp >= endTimestamp) {\n ended = true;\n }\n if (ended) {\n sample.close();\n return;\n }\n if (lastSample) {\n if (sample.timestamp > startTimestamp) {\n // We don't know ahead of time what the first first is. This is because the first first is the\n // last first whose timestamp is less than or equal to the start timestamp. Therefore we need to\n // wait for the first first after the start timestamp, and then we'll know that the previous\n // first was the first first.\n sampleQueue.push(lastSample);\n firstSampleQueued = true;\n }\n else {\n lastSample.close();\n }\n }\n if (sample.timestamp >= startTimestamp) {\n sampleQueue.push(sample);\n firstSampleQueued = true;\n }\n lastSample = firstSampleQueued ? null : sample;\n if (sampleQueue.length > 0) {\n onQueueNotEmpty();\n ({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());\n }\n }, (error) => {\n if (!hasOutOfBandError) {\n outOfBandError = error;\n hasOutOfBandError = true;\n onQueueNotEmpty();\n }\n });\n const packetSink = this._createPacketSink();\n const keyPacket = await packetSink.getKeyPacket(startTimestamp, packetRetrievalOptions)\n ?? await packetSink.getFirstKeyPacket(packetRetrievalOptions);\n let currentPacket = keyPacket;\n // B-frames make it exceedingly difficult to properly define an upper bound for packet iteration if an end\n // timestamp is set, so we just don't do it. The case that makes it especially tricky is when the frames\n // following a key frame have a lower timestamp than the keyframe; something that quite frequently happens\n // in HEVC streams. The price to pay for not upper-bounding the packet iterator is a slight increase in\n // decoder work at the end of the range, but the added correctness and reliability makes this tradeoff worth\n // it.\n const endPacket = undefined;\n const packets = packetSink.packets(keyPacket ?? undefined, endPacket, packetRetrievalOptions);\n await packets.next(); // Skip the start packet as we already have it\n while (currentPacket && !ended && !this._track.input._disposed) {\n const maxQueueSize = computeMaxQueueSize(sampleQueue.length);\n if (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize) {\n ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());\n await queueDequeue;\n continue;\n }\n decoder.decode(currentPacket);\n const packetResult = await packets.next();\n if (packetResult.done) {\n break;\n }\n currentPacket = packetResult.value;\n }\n await packets.return();\n if (!terminated && !this._track.input._disposed) {\n await decoder.flush();\n }\n if (!firstSampleQueued && lastSample) {\n sampleQueue.push(lastSample);\n }\n decoderIsFlushed = true;\n onQueueNotEmpty(); // To unstuck (unstick?) the generator\n })().catch((error) => {\n if (!hasOutOfBandError) {\n outOfBandError = error;\n hasOutOfBandError = true;\n onQueueNotEmpty();\n }\n }).finally(() => {\n decoder?.close();\n });\n const track = this._track;\n const closeSamples = () => {\n lastSample?.close();\n for (const sample of sampleQueue) {\n sample.close();\n }\n };\n return {\n async next() {\n while (true) {\n if (track.input._disposed) {\n closeSamples();\n throw new InputDisposedError();\n }\n else if (terminated) {\n return { value: undefined, done: true };\n }\n else if (hasOutOfBandError) {\n closeSamples();\n throw outOfBandError;\n }\n else if (sampleQueue.length > 0) {\n const value = sampleQueue.shift();\n onQueueDequeue();\n return { value, done: false };\n }\n else if (!decoderIsFlushed) {\n await queueNotEmpty;\n }\n else {\n return { value: undefined, done: true };\n }\n }\n },\n async return() {\n terminated = true;\n ended = true;\n onQueueDequeue();\n onQueueNotEmpty();\n closeSamples();\n return { value: undefined, done: true };\n },\n async throw(error) {\n throw error;\n },\n [Symbol.asyncIterator]() {\n return this;\n },\n };\n }\n /** @internal */\n mediaSamplesAtTimestamps(timestamps, options) {\n validateAnyIterable(timestamps);\n const timestampIterator = toAsyncIterator(timestamps);\n const timestampsOfInterest = [];\n const sampleQueue = [];\n let { promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers();\n let { promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers();\n let decoderIsFlushed = false;\n let terminated = false;\n let decoder = null;\n // This stores errors that are \"out of band\" in the sense that they didn't occur in the normal flow of this\n // method but instead in a different context. This error should not go unnoticed and must be bubbled up to\n // the consumer.\n let outOfBandError = null;\n let hasOutOfBandError = false;\n const pushToQueue = (sample) => {\n sampleQueue.push(sample);\n onQueueNotEmpty();\n ({ promise: queueNotEmpty, resolve: onQueueNotEmpty } = promiseWithResolvers());\n };\n const retrievalOptions = {\n ...options,\n verifyKeyPackets: true,\n metadataOnly: false,\n };\n // The following is the \"pump\" process that keeps pumping packets into the decoder\n (async () => {\n decoder = await this._createDecoder((sample) => {\n onQueueDequeue();\n if (terminated) {\n sample.close();\n return;\n }\n let sampleUses = 0;\n while (timestampsOfInterest.length > 0\n && sample.timestamp - timestampsOfInterest[0] > -1e-10 // Give it a little epsilon\n ) {\n sampleUses++;\n timestampsOfInterest.shift();\n }\n if (sampleUses > 0) {\n for (let i = 0; i < sampleUses; i++) {\n // Clone the sample if we need to emit it multiple times\n pushToQueue((i < sampleUses - 1 ? sample.clone() : sample));\n }\n }\n else {\n sample.close();\n }\n }, (error) => {\n if (!hasOutOfBandError) {\n outOfBandError = error;\n hasOutOfBandError = true;\n onQueueNotEmpty();\n }\n });\n const packetSink = this._createPacketSink();\n let lastPacket = null;\n let lastKeyPacket = null;\n // The end sequence number (inclusive) in the next batch of packets that will be decoded. The batch starts\n // at the last key frame and goes until this sequence number.\n let maxSequenceNumber = -1;\n const decodePackets = async () => {\n assert(lastKeyPacket);\n assert(decoder);\n // Start at the current key packet\n let currentPacket = lastKeyPacket;\n decoder.decode(currentPacket);\n while (currentPacket.sequenceNumber < maxSequenceNumber) {\n const maxQueueSize = computeMaxQueueSize(sampleQueue.length);\n while (sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize && !terminated) {\n ({ promise: queueDequeue, resolve: onQueueDequeue } = promiseWithResolvers());\n await queueDequeue;\n }\n if (terminated) {\n break;\n }\n const nextPacket = await packetSink.getNextPacket(currentPacket, retrievalOptions);\n assert(nextPacket);\n decoder.decode(nextPacket);\n currentPacket = nextPacket;\n }\n maxSequenceNumber = -1;\n };\n const flushDecoder = async () => {\n assert(decoder);\n await decoder.flush();\n // We don't expect this list to have any elements in it anymore, but in case it does, let's emit\n // nulls for every remaining element, then clear it.\n for (let i = 0; i < timestampsOfInterest.length; i++) {\n pushToQueue(null);\n }\n timestampsOfInterest.length = 0;\n };\n for await (const timestamp of timestampIterator) {\n validateTimestamp(timestamp);\n if (terminated || this._track.input._disposed) {\n break;\n }\n const targetPacket = await packetSink.getPacket(timestamp, retrievalOptions);\n const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp, retrievalOptions);\n if (!keyPacket) {\n if (maxSequenceNumber !== -1) {\n await decodePackets();\n await flushDecoder();\n }\n pushToQueue(null);\n lastPacket = null;\n continue;\n }\n // Check if the key packet has changed or if we're going back in time\n if (lastPacket\n && (keyPacket.sequenceNumber !== lastKeyPacket.sequenceNumber\n || targetPacket.timestamp < lastPacket.timestamp)) {\n await decodePackets();\n await flushDecoder(); // Always flush here, improves decoder compatibility\n }\n timestampsOfInterest.push(targetPacket.timestamp);\n maxSequenceNumber = Math.max(targetPacket.sequenceNumber, maxSequenceNumber);\n lastPacket = targetPacket;\n lastKeyPacket = keyPacket;\n }\n if (!terminated && !this._track.input._disposed) {\n if (maxSequenceNumber !== -1) {\n // We still need to decode packets\n await decodePackets();\n }\n await flushDecoder();\n }\n decoderIsFlushed = true;\n onQueueNotEmpty(); // To unstuck the generator\n })().catch((error) => {\n if (!hasOutOfBandError) {\n outOfBandError = error;\n hasOutOfBandError = true;\n onQueueNotEmpty();\n }\n }).finally(() => {\n decoder?.close();\n });\n const track = this._track;\n const closeSamples = () => {\n for (const sample of sampleQueue) {\n sample?.close();\n }\n };\n return {\n async next() {\n while (true) {\n if (track.input._disposed) {\n closeSamples();\n throw new InputDisposedError();\n }\n else if (terminated) {\n return { value: undefined, done: true };\n }\n else if (hasOutOfBandError) {\n closeSamples();\n throw outOfBandError;\n }\n else if (sampleQueue.length > 0) {\n const value = sampleQueue.shift();\n assert(value !== undefined);\n onQueueDequeue();\n return { value, done: false };\n }\n else if (!decoderIsFlushed) {\n await queueNotEmpty;\n }\n else {\n return { value: undefined, done: true };\n }\n }\n },\n async return() {\n terminated = true;\n onQueueDequeue();\n onQueueNotEmpty();\n closeSamples();\n return { value: undefined, done: true };\n },\n async throw(error) {\n throw error;\n },\n [Symbol.asyncIterator]() {\n return this;\n },\n };\n }\n }\n const computeMaxQueueSize = (decodedSampleQueueSize) => {\n // If we have decoded samples lying around, limit the total queue size to a small value (decoded samples can use up\n // a lot of memory). If not, we're fine with a much bigger queue of encoded packets waiting to be decoded. In fact,\n // some decoders only start flushing out decoded chunks when the packet queue is large enough.\n return decodedSampleQueueSize === 0 ? 40 : 8;\n };\n class VideoDecoderWrapper extends DecoderWrapper {\n constructor(onSample, onError, codec, decoderConfig, rotation, timeResolution) {\n super(onSample, onError);\n this.codec = codec;\n this.decoderConfig = decoderConfig;\n this.rotation = rotation;\n this.timeResolution = timeResolution;\n this.decoder = null;\n this.customDecoder = null;\n this.customDecoderCallSerializer = new CallSerializer();\n this.customDecoderQueueSize = 0;\n this.inputTimestamps = []; // Timestamps input into the decoder, sorted.\n this.sampleQueue = []; // Safari-specific thing, check usage.\n this.currentPacketIndex = 0;\n this.raslSkipped = false; // For HEVC stuff\n // Alpha stuff\n this.alphaDecoder = null;\n this.alphaHadKeyframe = false;\n this.colorQueue = [];\n this.alphaQueue = [];\n this.merger = null;\n this.decodedAlphaChunkCount = 0;\n this.alphaDecoderQueueSize = 0;\n /** Each value is the number of decoded alpha chunks at which a null alpha frame should be added. */\n this.nullAlphaFrameQueue = [];\n this.currentAlphaPacketIndex = 0;\n this.alphaRaslSkipped = false; // For HEVC stuff\n this.finalSamples = [];\n this.mergeAlphaPromises = [];\n const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));\n if (MatchingCustomDecoder) {\n // @ts-expect-error \"Can't create instance of abstract class 🤓\"\n this.customDecoder = new MatchingCustomDecoder();\n // @ts-expect-error It's technically readonly\n this.customDecoder.codec = codec;\n // @ts-expect-error It's technically readonly\n this.customDecoder.config = decoderConfig;\n // @ts-expect-error It's technically readonly\n this.customDecoder.onSample = (sample) => {\n if (!(sample instanceof VideoSample)) {\n throw new TypeError('The argument passed to onSample must be a VideoSample.');\n }\n this.finalizeAndEmitSample(sample);\n };\n // @ts-expect-error It's technically readonly\n this.customDecoder.onError = (error) => {\n onError(error);\n };\n void this.customDecoderCallSerializer\n .call(() => this.customDecoder.init())\n .catch(error => onError(error));\n }\n else {\n const colorHandler = (frame) => {\n if (this.alphaQueue.length > 0) {\n // Even when no alpha data is present (most of the time), there will be nulls in this queue\n const alphaFrame = this.alphaQueue.shift();\n assert(alphaFrame !== undefined);\n void this.mergeAlpha(frame, alphaFrame);\n }\n else {\n this.colorQueue.push(frame);\n }\n };\n if (codec === 'avc' && this.decoderConfig.description && isChromium()) {\n // Chromium has/had a bug with playing interlaced AVC (https://issues.chromium.org/issues/456919096)\n // which can be worked around by requesting that software decoding be used. So, here we peek into the\n // AVC description, if present, and switch to software decoding if we find interlaced content.\n const record = deserializeAvcDecoderConfigurationRecord(toUint8Array(this.decoderConfig.description));\n if (record && record.sequenceParameterSets.length > 0) {\n const sps = parseAvcSps(record.sequenceParameterSets[0]);\n if (sps && sps.frameMbsOnlyFlag === 0) {\n this.decoderConfig = {\n ...this.decoderConfig,\n hardwareAcceleration: 'prefer-software',\n };\n }\n }\n }\n const stack = new Error('Decoding error').stack;\n this.decoder = new VideoDecoder({\n output: (frame) => {\n try {\n colorHandler(frame);\n }\n catch (error) {\n this.onError(error);\n }\n },\n error: (error) => {\n error.stack = stack; // Provide a more useful stack trace, the default one sucks\n this.onError(error);\n },\n });\n this.decoder.configure(this.decoderConfig);\n }\n }\n getDecodeQueueSize() {\n if (this.customDecoder) {\n return this.customDecoderQueueSize;\n }\n else {\n assert(this.decoder);\n return Math.max(this.decoder.decodeQueueSize, this.alphaDecoder?.decodeQueueSize ?? 0);\n }\n }\n decode(packet) {\n if (this.codec === 'hevc' && this.currentPacketIndex > 0 && !this.raslSkipped) {\n if (this.hasHevcRaslPicture(packet.data)) {\n return; // Drop\n }\n this.raslSkipped = true;\n }\n if (this.customDecoder) {\n this.customDecoderQueueSize++;\n void this.customDecoderCallSerializer\n .call(() => this.customDecoder.decode(packet))\n .catch(error => this.onError(error))\n .finally(() => this.customDecoderQueueSize--);\n }\n else {\n assert(this.decoder);\n if (!isWebKit()) {\n insertSorted(this.inputTimestamps, packet.timestamp, x => x);\n }\n if (isChromium() && this.currentPacketIndex === 0) {\n if (this.codec === 'avc') {\n // Workaround for https://issues.chromium.org/issues/470109459\n const filteredNalUnits = [];\n let hasFrameData = false;\n for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {\n const type = extractNalUnitTypeForAvc(packet.data[loc.offset]);\n hasFrameData ||= type >= 1 && type <= 5;\n if (type === AvcNalUnitType.AUD) {\n if (hasFrameData) {\n // Already has actual frame data, so treat an AUD as simply the end of the packet\n break;\n }\n else {\n // If packets contain an AUD and have NALUs before it, this trips up Chromium's key\n // frame detector. Clear the NALUs if an AUD is encountered.\n // https://github.com/Vanilagy/mediabunny/issues/396\n filteredNalUnits.length = 0;\n }\n }\n // These trip up Chromium's key frame detection, so let's strip them\n if (!(type >= 20 && type <= 31)) {\n filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));\n }\n }\n const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);\n packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);\n }\n else if (this.codec === 'hevc') {\n // Workaround for https://issues.chromium.org/issues/507611247\n const sanitizedData = sanitizeHevcPacketForChromium(packet.data, this.decoderConfig);\n if (sanitizedData) {\n packet = new EncodedPacket(sanitizedData, packet.type, packet.timestamp, packet.duration);\n }\n }\n }\n this.decoder.decode(packet.toEncodedVideoChunk());\n this.decodeAlphaData(packet);\n }\n this.currentPacketIndex++;\n }\n decodeAlphaData(packet) {\n if (!packet.sideData.alpha) {\n // No alpha side data in the packet, most common case\n this.pushNullAlphaFrame();\n return;\n }\n if (!this.merger) {\n this.merger = new ColorAlphaMerger();\n }\n // Check if we need to set up the alpha decoder\n if (!this.alphaDecoder) {\n const alphaHandler = (frame) => {\n if (this.colorQueue.length > 0) {\n const colorFrame = this.colorQueue.shift();\n assert(colorFrame !== undefined);\n void this.mergeAlpha(colorFrame, frame);\n }\n else {\n this.alphaQueue.push(frame);\n }\n // Check if any null frames have been queued for this point\n this.decodedAlphaChunkCount++;\n while (this.nullAlphaFrameQueue.length > 0\n && this.nullAlphaFrameQueue[0] === this.decodedAlphaChunkCount) {\n this.nullAlphaFrameQueue.shift();\n if (this.colorQueue.length > 0) {\n const colorFrame = this.colorQueue.shift();\n assert(colorFrame !== undefined);\n void this.mergeAlpha(colorFrame, null);\n }\n else {\n this.alphaQueue.push(null);\n }\n }\n this.alphaDecoderQueueSize--;\n };\n const stack = new Error('Decoding error').stack;\n this.alphaDecoder = new VideoDecoder({\n output: (frame) => {\n try {\n alphaHandler(frame);\n }\n catch (error) {\n this.onError(error);\n }\n },\n error: (error) => {\n error.stack = stack; // Provide a more useful stack trace, the default one sucks\n this.onError(error);\n },\n });\n this.alphaDecoder.configure(this.decoderConfig);\n }\n const type = determineVideoPacketType(this.codec, this.decoderConfig, packet.sideData.alpha);\n // Alpha packets might follow a different key frame rhythm than the main packets. Therefore, before we start\n // decoding, we must first find a packet that's actually a key frame. Until then, we treat the image as opaque.\n if (!this.alphaHadKeyframe) {\n this.alphaHadKeyframe = type === 'key';\n }\n if (this.alphaHadKeyframe) {\n // Same RASL skipping logic as for color, unlikely to be hit (since who uses HEVC with separate alpha??) but\n // here for symmetry.\n if (this.codec === 'hevc' && this.currentAlphaPacketIndex > 0 && !this.alphaRaslSkipped) {\n if (this.hasHevcRaslPicture(packet.sideData.alpha)) {\n this.pushNullAlphaFrame();\n return;\n }\n this.alphaRaslSkipped = true;\n }\n this.currentAlphaPacketIndex++;\n this.alphaDecoder.decode(packet.alphaToEncodedVideoChunk(type ?? packet.type));\n this.alphaDecoderQueueSize++;\n }\n else {\n this.pushNullAlphaFrame();\n }\n }\n pushNullAlphaFrame() {\n if (this.alphaDecoderQueueSize === 0) {\n // Easy\n this.alphaQueue.push(null);\n }\n else {\n // There are still alpha chunks being decoded, so pushing `null` immediately would result in out-of-order\n // data and be incorrect. Instead, we need to enqueue a \"null frame\" for when the current decoder workload\n // has finished.\n this.nullAlphaFrameQueue.push(this.decodedAlphaChunkCount + this.alphaDecoderQueueSize);\n }\n }\n /**\n * If we're using HEVC, we need to make sure to skip any RASL slices that follow a non-IDR key frame such as\n * CRA_NUT. This is because RASL slices cannot be decoded without data before the CRA_NUT. Browsers behave\n * differently here: Chromium drops the packets, Safari throws a decoder error. Either way, it's not good\n * and causes bugs upstream. So, let's take the dropping into our own hands.\n */\n hasHevcRaslPicture(packetData) {\n for (const loc of iterateHevcNalUnits(packetData, this.decoderConfig)) {\n const type = extractNalUnitTypeForHevc(packetData[loc.offset]);\n if (type === HevcNalUnitType.RASL_N || type === HevcNalUnitType.RASL_R) {\n return true;\n }\n }\n return false;\n }\n /** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */\n sampleHandler(sample) {\n if (isWebKit()) {\n // For correct B-frame handling, we don't just hand over the frames directly but instead add them to\n // a queue, because we want to ensure frames are emitted in presentation order. We flush the queue\n // each time we receive a frame with a timestamp larger than the highest we've seen so far, as we\n // can sure that is not a B-frame. Typically, WebCodecs automatically guarantees that frames are\n // emitted in presentation order, but Safari doesn't always follow this rule.\n if (this.sampleQueue.length > 0 && (sample.timestamp >= last(this.sampleQueue).timestamp)) {\n for (const sample of this.sampleQueue) {\n this.finalizeAndEmitSample(sample);\n }\n this.sampleQueue.length = 0;\n }\n insertSorted(this.sampleQueue, sample, x => x.timestamp);\n }\n else {\n // Assign it the next earliest timestamp from the input. We do this because browsers, by spec, are\n // required to emit decoded frames in presentation order *while* retaining the timestamp of their\n // originating EncodedVideoChunk. For files with B-frames but no out-of-order timestamps (like a\n // missing ctts box, for example), this causes a mismatch. We therefore fix the timestamps and\n // ensure they are sorted by doing this.\n const timestamp = this.inputTimestamps.shift();\n // There's no way we'd have more decoded frames than encoded packets we passed in. Actually, the\n // correspondence should be 1:1.\n assert(timestamp !== undefined);\n sample.setTimestamp(timestamp);\n this.finalizeAndEmitSample(sample);\n }\n }\n finalizeAndEmitSample(sample) {\n // Round the timestamps to the time resolution\n sample.setTimestamp(Math.round(sample.timestamp * this.timeResolution) / this.timeResolution);\n sample.setDuration(Math.round(sample.duration * this.timeResolution) / this.timeResolution);\n sample.setRotation(this.rotation);\n this.onSample(sample);\n }\n async mergeAlpha(color, alpha) {\n const resolver = promiseWithResolvers();\n this.mergeAlphaPromises.push(resolver.promise);\n // Alpha merging is concurrent but the samples must still be emitted in the same order in which the merging\n // began. Therefore, serialize the results in an array.\n const result = { sample: null };\n this.finalSamples.push(result);\n try {\n if (!alpha) {\n // Nothing needs to be merged\n result.sample = new VideoSample(color);\n }\n else {\n assert(this.merger);\n // The merger takes ownership of the frames, so no need to close them ourselves\n const finalFrame = await this.merger.merge(color, alpha);\n result.sample = new VideoSample(finalFrame);\n }\n // Emit any leading samples that are ready, preserving input order\n while (this.finalSamples.length > 0 && this.finalSamples[0].sample !== null) {\n const next = this.finalSamples.shift();\n this.sampleHandler(next.sample);\n }\n }\n catch (error) {\n removeItem(this.finalSamples, result);\n this.onError(error);\n }\n finally {\n removeItem(this.mergeAlphaPromises, resolver.promise);\n resolver.resolve();\n }\n }\n async flush() {\n if (this.customDecoder) {\n await this.customDecoderCallSerializer.call(() => this.customDecoder.flush());\n }\n else {\n assert(this.decoder);\n await Promise.all([\n this.decoder.flush(),\n this.alphaDecoder?.flush(),\n ]);\n await Promise.all(this.mergeAlphaPromises);\n this.colorQueue.forEach(x => x.close());\n this.colorQueue.length = 0;\n this.alphaQueue.forEach(x => x?.close());\n this.alphaQueue.length = 0;\n this.alphaHadKeyframe = false;\n this.decodedAlphaChunkCount = 0;\n this.alphaDecoderQueueSize = 0;\n this.nullAlphaFrameQueue.length = 0;\n this.currentAlphaPacketIndex = 0;\n this.alphaRaslSkipped = false;\n }\n if (isWebKit()) {\n for (const sample of this.sampleQueue) {\n this.finalizeAndEmitSample(sample);\n }\n this.sampleQueue.length = 0;\n }\n this.currentPacketIndex = 0;\n this.raslSkipped = false;\n }\n close() {\n if (this.customDecoder) {\n void this.customDecoderCallSerializer.call(() => this.customDecoder.close());\n }\n else {\n assert(this.decoder);\n this.decoder.close();\n this.alphaDecoder?.close();\n this.colorQueue.forEach(x => x.close());\n this.colorQueue.length = 0;\n this.alphaQueue.forEach(x => x?.close());\n this.alphaQueue.length = 0;\n this.merger?.close();\n }\n for (const sample of this.sampleQueue) {\n sample.close();\n }\n this.sampleQueue.length = 0;\n }\n }\n let mergerWorkerUrl = null;\n /** Utility class that merges together color and alpha information on the CPU in a pool of workers. */\n class ColorAlphaMerger {\n constructor() {\n this.workers = [];\n this.nextWorkerIndex = 0;\n this.pendingRequests = new Map();\n this.nextRequestId = 0;\n }\n merge(color, alpha) {\n if (this.workers.length === 0) {\n if (!mergerWorkerUrl) {\n const blob = new Blob([`(${colorAlphaMergerWorkerCode.toString()})()`], { type: 'application/javascript' });\n mergerWorkerUrl = URL.createObjectURL(blob);\n }\n const poolSize = clamp(navigator.hardwareConcurrency, 1, 4);\n for (let i = 0; i < poolSize; i++) {\n const worker = new Worker(mergerWorkerUrl);\n worker.addEventListener('message', (event) => {\n const data = event.data;\n const pending = this.pendingRequests.get(data.id);\n if (!pending) {\n return;\n }\n this.pendingRequests.delete(data.id);\n if ('error' in data) {\n pending.reject(new Error(data.error));\n }\n else {\n pending.resolve(data.frame);\n }\n });\n worker.addEventListener('error', (event) => {\n const error = new Error(event.message || 'Color/alpha merge worker error.');\n for (const pending of this.pendingRequests.values()) {\n pending.reject(error);\n }\n this.pendingRequests.clear();\n });\n this.workers.push(worker);\n }\n }\n const id = this.nextRequestId++;\n const pending = promiseWithResolvers();\n this.pendingRequests.set(id, pending);\n // Hand the job to the next worker in round-robin fashion\n const worker = this.workers[this.nextWorkerIndex];\n this.nextWorkerIndex = (this.nextWorkerIndex + 1) % this.workers.length;\n worker.postMessage({ id, color, alpha }, { transfer: [color, alpha] });\n return pending.promise;\n }\n close() {\n for (const worker of this.workers) {\n worker.terminate();\n }\n this.workers.length = 0;\n const error = new Error('Color/alpha merger closed.');\n for (const pending of this.pendingRequests.values()) {\n pending.reject(error);\n }\n this.pendingRequests.clear();\n }\n }\n const colorAlphaMergerWorkerCode = () => {\n // These buffers are reused across frames as long as the size matches, since consecutive frames usually share\n // dimensions\n let cpuAlphaBuffer = null;\n let cpuColorBuffer = null;\n // Serialize execution internally so concurrent requests don't race on the shared cpu*Buffer state.\n let chain = Promise.resolve();\n self.addEventListener('message', (event) => {\n const { id, color, alpha } = event.data;\n chain = chain.then(async () => {\n try {\n const frame = await merge(color, alpha);\n self.postMessage({ id, frame }, { transfer: [frame] });\n }\n catch (error) {\n self.postMessage({ id, error: error.message });\n }\n finally {\n // We took ownership of the inputs via transfer; close them now that the merge (or its error) is done.\n color.close();\n alpha.close();\n }\n });\n });\n const merge = async (color, alpha) => {\n const format = color.format;\n const alphaFormat = alpha.format;\n if (!format || !alphaFormat) {\n throw new Error('CPU color/alpha merging requires a known VideoFrame format.');\n }\n // The alpha frame must have the same bit depth as the color frame\n const colorIs10 = format.includes('P10');\n const colorIs12 = format.includes('P12');\n const alphaIs10 = alphaFormat.includes('P10');\n const alphaIs12 = alphaFormat.includes('P12');\n if (alphaIs10 !== colorIs10 || alphaIs12 !== colorIs12) {\n throw new Error(`CPU color/alpha merging requires the alpha frame to have the same bit depth as the color frame`\n + ` (color: '${format}', alpha: '${alphaFormat}').`);\n }\n if (format === 'RGBX' || format === 'RGBA' || format === 'BGRX' || format === 'BGRA') {\n return await mergeInterleavedRgba(color, alpha, format);\n }\n else if (format === 'I420' || format === 'I420P10' || format === 'I420P12'\n || format === 'I422' || format === 'I422P10' || format === 'I422P12'\n || format === 'I444' || format === 'I444P10' || format === 'I444P12') {\n return await mergePlanarYuv(color, alpha, format);\n }\n else if (format === 'NV12') {\n return await mergeNv12(color, alpha);\n }\n throw new Error(`CPU color/alpha merging does not support format '${format}'.`);\n };\n const mergeInterleavedRgba = async (color, alpha, format) => {\n const width = color.visibleRect?.width ?? color.codedWidth;\n const height = color.visibleRect?.height ?? color.codedHeight;\n const pixelCount = width * height;\n const output = new Uint8Array(pixelCount * 4);\n // Color goes straight into the output buffer via copyTo, no intermediate copy needed\n await color.copyTo(output);\n // And now add the alpha data\n const alphaY = await readAlpha(alpha, width, height, 1);\n for (let i = 0, j = 3; i < pixelCount; i++, j += 4) {\n output[j] = alphaY[i];\n }\n const outputFormat = (format === 'RGBX' || format === 'RGBA') ? 'RGBA' : 'BGRA';\n const init = {\n format: outputFormat,\n codedWidth: width,\n codedHeight: height,\n timestamp: color.timestamp,\n duration: color.duration ?? undefined,\n transfer: [output.buffer],\n };\n return new VideoFrame(output, init);\n };\n const mergePlanarYuv = async (color, alpha, format) => {\n const width = color.visibleRect?.width ?? color.codedWidth;\n const height = color.visibleRect?.height ?? color.codedHeight;\n const is10 = format.includes('P10');\n const is12 = format.includes('P12');\n const bytesPerSample = (is10 || is12) ? 2 : 1;\n let chromaW;\n let chromaH;\n if (format.startsWith('I420')) {\n chromaW = Math.ceil(width / 2);\n chromaH = Math.ceil(height / 2);\n }\n else if (format.startsWith('I422')) {\n chromaW = Math.ceil(width / 2);\n chromaH = height;\n }\n else {\n chromaW = width;\n chromaH = height;\n }\n const ySamples = width * height;\n const uvSamples = chromaW * chromaH;\n const yBytes = ySamples * bytesPerSample;\n const uvBytes = uvSamples * bytesPerSample;\n const aBytes = ySamples * bytesPerSample;\n const outputBytes = yBytes + 2 * uvBytes + aBytes;\n const output = new Uint8Array(outputBytes);\n // Write color planes directly into the output buffer via copyTo, no intermediate copy\n await color.copyTo(output);\n const alphaY = await readAlpha(alpha, width, height, bytesPerSample);\n const aOffset = yBytes + 2 * uvBytes;\n output.set(alphaY, aOffset);\n const outputFormat = (format.slice(0, 4) + 'A' + format.slice(4));\n const init = {\n format: outputFormat,\n codedWidth: width,\n codedHeight: height,\n timestamp: color.timestamp,\n duration: color.duration ?? undefined,\n transfer: [output.buffer],\n };\n return new VideoFrame(output, init);\n };\n const mergeNv12 = async (color, alpha) => {\n const width = color.visibleRect?.width ?? color.codedWidth;\n const height = color.visibleRect?.height ?? color.codedHeight;\n const ySize = width * height;\n const chromaW = Math.ceil(width / 2);\n const chromaH = Math.ceil(height / 2);\n const uvSize = chromaW * chromaH;\n const sourceSize = color.allocationSize();\n if (!cpuColorBuffer || cpuColorBuffer.byteLength !== sourceSize) {\n cpuColorBuffer = new Uint8Array(sourceSize);\n }\n await color.copyTo(cpuColorBuffer);\n const output = new Uint8Array(ySize + 2 * uvSize + ySize);\n // Y plane copies straight over\n output.set(cpuColorBuffer.subarray(0, ySize), 0);\n // Deinterleave the UV plane into separate U and V planes\n const uOffset = ySize;\n const vOffset = ySize + uvSize;\n const uvStart = ySize;\n for (let i = 0; i < uvSize; i++) {\n output[uOffset + i] = cpuColorBuffer[uvStart + i * 2];\n output[vOffset + i] = cpuColorBuffer[uvStart + i * 2 + 1];\n }\n const alphaY = await readAlpha(alpha, width, height, 1);\n output.set(alphaY, ySize + 2 * uvSize);\n const init = {\n format: 'I420A',\n codedWidth: width,\n codedHeight: height,\n timestamp: color.timestamp,\n duration: color.duration ?? undefined,\n transfer: [output.buffer],\n };\n return new VideoFrame(output, init);\n };\n const readAlpha = async (alpha, width, height, bytesPerSample) => {\n const size = alpha.allocationSize();\n if (!cpuAlphaBuffer || cpuAlphaBuffer.byteLength !== size) {\n cpuAlphaBuffer = new Uint8Array(size);\n }\n await alpha.copyTo(cpuAlphaBuffer);\n const format = alpha.format;\n if (format === 'RGBA' || format === 'BGRA' || format === 'RGBX' || format === 'BGRX') {\n // Pack alpha data tightly. Assume alpha is stored in RGB, so sample just from R for simplicity.\n const rOffset = (format === 'RGBA' || format === 'RGBX') ? 0 : 2;\n const pixelCount = width * height;\n for (let i = 0; i < pixelCount; i++) {\n cpuAlphaBuffer[i] = cpuAlphaBuffer[i * 4 + rOffset];\n }\n return cpuAlphaBuffer.subarray(0, pixelCount);\n }\n else {\n // For Y-plane-first formats (I*** and NV12), the leading width*height samples are the Y plane\n return cpuAlphaBuffer.subarray(0, width * height * bytesPerSample);\n }\n };\n };\n const validateVideoSinkDecoderOptions = (decoderOptions) => {\n if (!decoderOptions || typeof decoderOptions !== 'object') {\n throw new TypeError('decoderOptions must be an object.');\n }\n if (decoderOptions.hardwareAcceleration !== undefined\n && !['no-preference', 'prefer-hardware', 'prefer-software'].includes(decoderOptions.hardwareAcceleration)) {\n throw new TypeError('decoderOptions.hardwareAcceleration, when provided, must be \\'no-preference\\', \\'prefer-hardware\\' or'\n + ' \\'prefer-software\\'.');\n }\n if (decoderOptions.optimizeForLatency !== undefined && typeof decoderOptions.optimizeForLatency !== 'boolean') {\n throw new TypeError('decoderOptions.optimizeForLatency, when provided, must be a boolean.');\n }\n };\n /**\n * A sink that retrieves decoded video samples (video frames) from a video track.\n * @group Media sinks\n * @public\n */\n class VideoSampleSink extends BaseMediaSampleSink {\n /** Creates a new {@link VideoSampleSink} for the given {@link InputVideoTrack}. */\n constructor(videoTrack, decoderOptions = {}) {\n if (!(videoTrack instanceof InputVideoTrack)) {\n throw new TypeError('videoTrack must be an InputVideoTrack.');\n }\n validateVideoSinkDecoderOptions(decoderOptions);\n super();\n this._track = videoTrack;\n this._decoderOptions = decoderOptions;\n }\n /** @internal */\n async _createDecoder(onSample, onError) {\n if (!(await this._track.canDecode())) {\n throw new Error('This video track cannot be decoded by this browser. Make sure to check decodability before using'\n + ' a track.');\n }\n const codec = await this._track.getCodec();\n const rotation = await this._track.getRotation();\n let decoderConfig = await this._track.getDecoderConfig();\n const timeResolution = await this._track.getTimeResolution();\n assert(codec && decoderConfig);\n decoderConfig = {\n ...decoderConfig,\n hardwareAcceleration: this._decoderOptions.hardwareAcceleration,\n optimizeForLatency: this._decoderOptions.optimizeForLatency,\n };\n return new VideoDecoderWrapper(onSample, onError, codec, decoderConfig, rotation, timeResolution);\n }\n /** @internal */\n _createPacketSink() {\n return new EncodedPacketSink(this._track);\n }\n /**\n * Retrieves the video sample (frame) corresponding to the given timestamp, in seconds. More specifically, returns\n * the last video sample (in presentation order) with a start timestamp less than or equal to the given timestamp.\n * Returns null if the timestamp is before the track's first timestamp.\n *\n * @param timestamp - The timestamp used for retrieval, in seconds.\n * @param options - Options used for the underlying packet retrieval.\n */\n async getSample(timestamp, options = {}) {\n validateTimestamp(timestamp);\n for await (const sample of this.mediaSamplesAtTimestamps([timestamp], options)) {\n return sample;\n }\n throw new Error('Internal error: Iterator returned nothing.');\n }\n /**\n * Creates an async iterator that yields the video samples (frames) of this track in presentation order. This method\n * will intelligently pre-decode a few frames ahead to enable fast iteration.\n *\n * @param startTimestamp - The timestamp in seconds at which to start yielding samples (inclusive).\n * @param endTimestamp - The timestamp in seconds at which to stop yielding samples (exclusive).\n * @param options - Options used for the underlying packet retrieval.\n */\n samples(startTimestamp, endTimestamp, options = {}) {\n return this.mediaSamplesInRange(startTimestamp, endTimestamp, options);\n }\n /**\n * Creates an async iterator that yields a video sample (frame) for each timestamp in the argument. This method\n * uses an optimized decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most\n * once, and is therefore more efficient than manually getting the sample for every timestamp. The iterator may\n * yield null if no frame is available for a given timestamp.\n *\n * This method is good for sparse access of media data. If you want primarily sequential media access, prefer\n * {@link VideoSampleSink.samples} instead.\n *\n * @param timestamps - An iterable or async iterable of timestamps in seconds.\n * @param options - Options used for the underlying packet retrieval.\n */\n samplesAtTimestamps(timestamps, options = {}) {\n return this.mediaSamplesAtTimestamps(timestamps, options);\n }\n }\n /**\n * A sink that renders video samples (frames) of the given video track to canvases. This is often more useful than\n * directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation\n * metadata.\n *\n * This sink will yield `HTMLCanvasElement`s when in a DOM context, and `OffscreenCanvas`es otherwise.\n *\n * @group Media sinks\n * @public\n */\n class CanvasSink {\n /** Creates a new {@link CanvasSink} for the given {@link InputVideoTrack}. */\n constructor(videoTrack, options = {}) {\n /** @internal */\n this._rotation = 0;\n /** @internal */\n this._initPromise = null;\n /** @internal */\n this._nextCanvasIndex = 0;\n if (!(videoTrack instanceof InputVideoTrack)) {\n throw new TypeError('videoTrack must be an InputVideoTrack.');\n }\n if (options && typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (options.alpha !== undefined && typeof options.alpha !== 'boolean') {\n throw new TypeError('options.alpha, when provided, must be a boolean.');\n }\n if (options.width !== undefined && (!Number.isInteger(options.width) || options.width <= 0)) {\n throw new TypeError('options.width, when defined, must be a positive integer.');\n }\n if (options.height !== undefined && (!Number.isInteger(options.height) || options.height <= 0)) {\n throw new TypeError('options.height, when defined, must be a positive integer.');\n }\n if (options.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.fit)) {\n throw new TypeError('options.fit, when provided, must be one of \"fill\", \"contain\", or \"cover\".');\n }\n if (options.width !== undefined\n && options.height !== undefined\n && options.fit === undefined) {\n throw new TypeError('When both options.width and options.height are provided, options.fit must also be provided.');\n }\n if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {\n throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.');\n }\n if (options.crop !== undefined) {\n validateCropRectangle(options.crop, 'options.');\n }\n if (options.poolSize !== undefined\n && (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0)) {\n throw new TypeError('poolSize must be a non-negative integer.');\n }\n if (options.decoderOptions !== undefined) {\n validateVideoSinkDecoderOptions(options.decoderOptions);\n }\n this._videoTrack = videoTrack;\n this._alpha = options.alpha ?? false;\n this._options = options;\n this._fit = options.fit ?? 'fill';\n this._videoSampleSink = new VideoSampleSink(videoTrack, options.decoderOptions);\n this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);\n }\n /** @internal */\n _ensureInit() {\n return this._initPromise ??= (async () => {\n const options = this._options;\n const videoTrack = this._videoTrack;\n const rotation = options.rotation ?? await videoTrack.getRotation();\n const squarePixelWidth = await videoTrack.getSquarePixelWidth();\n const squarePixelHeight = await videoTrack.getSquarePixelHeight();\n const [rotatedWidth, rotatedHeight] = rotation % 180 === 0\n ? [squarePixelWidth, squarePixelHeight]\n : [squarePixelHeight, squarePixelWidth];\n let crop = options.crop;\n if (crop) {\n crop = clampCropRectangle(crop, rotatedWidth, rotatedHeight);\n }\n let [width, height] = crop\n ? [crop.width, crop.height]\n : [rotatedWidth, rotatedHeight];\n const originalAspectRatio = width / height;\n // If width and height aren't defined together, deduce the missing value using the aspect ratio\n if (options.width !== undefined && options.height === undefined) {\n width = options.width;\n height = Math.round(width / originalAspectRatio);\n }\n else if (options.width === undefined && options.height !== undefined) {\n height = options.height;\n width = Math.round(height * originalAspectRatio);\n }\n else if (options.width !== undefined && options.height !== undefined) {\n width = options.width;\n height = options.height;\n }\n this._width = width;\n this._height = height;\n this._rotation = rotation;\n this._crop = crop;\n })();\n }\n /** @internal */\n _videoSampleToWrappedCanvas(sample) {\n const width = this._width;\n const height = this._height;\n let canvas = this._canvasPool[this._nextCanvasIndex];\n let canvasIsNew = false;\n if (!canvas) {\n if (typeof document !== 'undefined') {\n // Prefer an HTMLCanvasElement\n canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n }\n else {\n canvas = new OffscreenCanvas(width, height);\n }\n if (this._canvasPool.length > 0) {\n this._canvasPool[this._nextCanvasIndex] = canvas;\n }\n canvasIsNew = true;\n }\n if (this._canvasPool.length > 0) {\n this._nextCanvasIndex = (this._nextCanvasIndex + 1) % this._canvasPool.length;\n }\n const context = canvas.getContext('2d', {\n alpha: this._alpha || isFirefox(), // Firefox has VideoFrame glitches with opaque canvases\n });\n assert(context);\n context.imageSmoothingQuality = 'high';\n if (!canvasIsNew) {\n if (!this._alpha && isFirefox()) {\n context.fillStyle = 'black';\n context.fillRect(0, 0, width, height);\n }\n else {\n context.clearRect(0, 0, width, height);\n }\n }\n sample.drawWithFit(context, {\n fit: this._fit,\n rotation: this._rotation,\n crop: this._crop,\n });\n const result = {\n canvas,\n timestamp: sample.timestamp,\n duration: sample.duration,\n };\n sample.close();\n return result;\n }\n /**\n * Retrieves a canvas with the video frame corresponding to the given timestamp, in seconds. More specifically,\n * returns the last video frame (in presentation order) with a start timestamp less than or equal to the given\n * timestamp. Returns null if the timestamp is before the track's first timestamp.\n *\n * @param timestamp - The timestamp used for retrieval, in seconds.\n * @param options - Options used for the underlying packet retrieval.\n */\n async getCanvas(timestamp, options) {\n validateTimestamp(timestamp);\n await this._ensureInit();\n const sample = await this._videoSampleSink.getSample(timestamp, options);\n return sample && this._videoSampleToWrappedCanvas(sample);\n }\n /**\n * Creates an async iterator that yields canvases with the video frames of this track in presentation order. This\n * method will intelligently pre-decode a few frames ahead to enable fast iteration.\n *\n * @param startTimestamp - The timestamp in seconds at which to start yielding canvases (inclusive).\n * @param endTimestamp - The timestamp in seconds at which to stop yielding canvases (exclusive).\n * @param options - Options used for the underlying packet retrieval.\n */\n async *canvases(startTimestamp, endTimestamp, options) {\n await this._ensureInit();\n yield* mapAsyncGenerator(this._videoSampleSink.samples(startTimestamp, endTimestamp, options), sample => this._videoSampleToWrappedCanvas(sample));\n }\n /**\n * Creates an async iterator that yields a canvas for each timestamp in the argument. This method uses an optimized\n * decoding pipeline if these timestamps are monotonically sorted, decoding each packet at most once, and is\n * therefore more efficient than manually getting the canvas for every timestamp. The iterator may yield null if\n * no frame is available for a given timestamp.\n *\n * This method is good for sparse access of media data. If you want primarily sequential media access, prefer\n * {@link CanvasSink.canvases} instead.\n *\n * @param timestamps - An iterable or async iterable of timestamps in seconds.\n * @param options - Options used for the underlying packet retrieval.\n */\n async *canvasesAtTimestamps(timestamps, options) {\n await this._ensureInit();\n yield* mapAsyncGenerator(this._videoSampleSink.samplesAtTimestamps(timestamps, options), sample => sample && this._videoSampleToWrappedCanvas(sample));\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n /**\n * Represents a media track in an input file.\n * @group Input files & tracks\n * @public\n */\n class InputTrack {\n /** @internal */\n constructor(input, backing) {\n this.input = input;\n this._backing = backing;\n }\n /** Returns true if and only if this track is a video track. */\n isVideoTrack() {\n return this instanceof InputVideoTrack;\n }\n /** Returns true if and only if this track is an audio track. */\n isAudioTrack() {\n return this instanceof InputAudioTrack;\n }\n /** The unique ID of this track in the input file. */\n get id() {\n return this._backing.getId();\n }\n /**\n * The 1-based index of this track among all tracks of the same type in the input file. For example, the first\n * video track has number 1, the second video track has number 2, and so on. The index refers to the order in\n * which the tracks are returned by {@link Input.getTracks}.\n */\n get number() {\n return this._backing.getNumber();\n }\n /**\n * Returns the identifier of the codec used internally by the container. It is not homogenized by Mediabunny\n * and depends entirely on the container format.\n *\n * This method can be used to determine the codec of a track in case Mediabunny doesn't know that codec.\n *\n * - For ISOBMFF files, this resolves to the name of the Sample Description Box (e.g. `'avc1'`).\n * - For Matroska files, this resolves to the value of the `CodecID` element.\n * - For WAVE files, this resolves to the value of the format tag in the `'fmt '` chunk.\n * - For ADTS files, this resolves to the `MPEG-4 Audio Object Type`.\n * - For MPEG-TS files, this resolves to the `streamType` value from the Program Map Table.\n * - In all other cases, this resolves to `null`.\n */\n async getInternalCodecId() {\n return this._backing.getInternalCodecId();\n }\n /**\n * See {@link InputTrack.getInternalCodecId}.\n * @deprecated Use {@link InputTrack.getInternalCodecId} instead.\n */\n get internalCodecId() {\n return requireSync(this._backing.getInternalCodecId(), 'internalCodecId', 'getInternalCodecId');\n }\n /**\n * Returns the ISO 639-2/T language code for this track. If the language is unknown, this resolves to `'und'`\n * (undetermined).\n */\n async getLanguageCode() {\n return this._backing.getLanguageCode();\n }\n /**\n * The ISO 639-2/T language code for this track. If the language is unknown, this field is `'und'` (undetermined).\n * @deprecated Use {@link InputTrack.getLanguageCode} instead.\n */\n get languageCode() {\n return requireSync(this._backing.getLanguageCode(), 'languageCode', 'getLanguageCode');\n }\n /** Returns the user-defined name for this track. */\n async getName() {\n return this._backing.getName();\n }\n /**\n * A user-defined name for this track.\n * @deprecated Use {@link InputTrack.getName} instead.\n */\n get name() {\n return requireSync(this._backing.getName(), 'name', 'getName');\n }\n /**\n * Returns a positive number x such that all timestamps and durations of all packets of this track are\n * integer multiples of 1/x.\n */\n async getTimeResolution() {\n return this._backing.getTimeResolution();\n }\n /**\n * A positive number x such that all timestamps and durations of all packets of this track are\n * integer multiples of 1/x.\n * @deprecated Use {@link InputTrack.getTimeResolution} instead.\n */\n get timeResolution() {\n return requireSync(this._backing.getTimeResolution(), 'timeResolution', 'getTimeResolution');\n }\n /**\n * Returns whether the timestamps of this track are relative to the Unix epoch (January 1, 1970 00:00:00 UTC).\n * When `true`, each timestamp maps to a definitive point in time.\n */\n async isRelativeToUnixEpoch() {\n return this._backing.isRelativeToUnixEpoch();\n }\n /**\n * Returns the Unix time (in seconds since January 1, 1970 00:00:00 UTC) that the given track timestamp (in seconds)\n * maps to, or `null` if there is no such mapping. This provides a piecewise-continuous mapping from this track's\n * timestamp space into wall-clock time. Such mapping exists, for example, for HLS playlists with\n * `#EXT-X-PROGRAM-DATE-TIME` tags present.\n *\n * This mapping can be available even when {@link InputTrack.isRelativeToUnixEpoch} is `false`, for example for HLS\n * streams with program date time information but with {@link HlsInputFormatOptions.offsetTimestampsByDateTime}\n * set to `false`.\n */\n async getUnixTimeForTimestamp(timestamp) {\n return this._backing.getUnixTimeForTimestamp(timestamp);\n }\n /**\n * Whether the track's timestamps can be mapped to Unix wall clock time via\n * {@link InputTrack.getUnixTimeForTimestamp}.\n */\n async hasUnixTimeMapping() {\n return (await this._backing.getUnixTimeForTimestamp(await this.getFirstTimestamp())) !== null;\n }\n /** Returns the track's disposition, i.e. information about its intended usage. */\n async getDisposition() {\n return this._backing.getDisposition();\n }\n /**\n * The track's disposition, i.e. information about its intended usage.\n * @deprecated Use {@link InputTrack.getDisposition} instead.\n */\n get disposition() {\n return requireSync(this._backing.getDisposition(), 'disposition', 'getDisposition');\n }\n /**\n * Returns the peak bitrate of the track in bits per second, as specified in the track's metadata. This might not\n * match the actual media data's bitrate.\n */\n async getBitrate() {\n return this._backing.getBitrate();\n }\n /**\n * Returns the average bitrate of the track in bits per second, as specified in the track's metadata. This might\n * not match the actual media data's bitrate.\n */\n async getAverageBitrate() {\n return this._backing.getAverageBitrate();\n }\n /**\n * Returns the start timestamp of the first packet of this track, in seconds. While often near zero, this value\n * may be positive or even negative. A negative starting timestamp means the track's timing has been offset. Samples\n * with a negative timestamp should not be presented.\n */\n async getFirstTimestamp() {\n const firstPacket = await this._backing.getFirstPacket({ metadataOnly: true });\n return firstPacket?.timestamp ?? 0;\n }\n /**\n * Returns the end timestamp of the last packet of this track, in seconds.\n *\n * By default, when the underlying media is live, this method will only resolve once the live stream ends. If you\n * want to query the current end timestamp of the stream, set {@link PacketRetrievalOptions.skipLiveWait} to `true`\n * in the options.\n */\n async computeDuration(options) {\n const lastPacket = await this._backing.getPacket(Infinity, { metadataOnly: true, ...options });\n const result = (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);\n return roundToDivisor(result, await this.getTimeResolution());\n }\n /**\n * Gets the duration (end timestamp) in seconds of this track from metadata stored in the file. This value may be\n * approximate or diverge from the actual, precise duration returned by `.computeDuration()`, but compared to that\n * method, this method is cheaper. When the duration cannot be determined from the file metadata, `null`\n * is returned.\n *\n * By default, when the underlying media is live, this method will only resolve once the live stream\n * ends. If you want to query the current duration of the media, set\n * {@link DurationMetadataRequestOptions.skipLiveWait} to `true` in the options.\n */\n async getDurationFromMetadata(options = {}) {\n return this._backing.getDurationFromMetadata(options);\n }\n /**\n * Computes aggregate packet statistics for this track, such as average packet rate or bitrate.\n *\n * @param targetPacketCount - This optional parameter sets a target for how many packets this method must have\n * looked at before it can return early; this means, you can use it to aggregate only a subset (prefix) of all\n * packets. This is very useful for getting a great estimate of video frame rate without having to scan through the\n * entire file.\n *\n * By default, when the underlying media is live and `targetPacketCount` is not set, this method will only resolve\n * once the live stream ends. If you want to query the current packet statistics of the stream, set\n * {@link PacketRetrievalOptions.skipLiveWait} to `true` in the options.\n */\n async computePacketStats(targetPacketCount = Infinity, options) {\n const sink = new EncodedPacketSink(this);\n let startTimestamp = Infinity;\n let endTimestamp = -Infinity;\n let packetCount = 0;\n let totalPacketBytes = 0;\n for await (const packet of sink.packets(undefined, undefined, { metadataOnly: true, ...options })) {\n if (packetCount >= targetPacketCount\n // This additional condition is needed to produce correct results with out-of-presentation-order packets\n && packet.timestamp >= endTimestamp) {\n break;\n }\n startTimestamp = Math.min(startTimestamp, packet.timestamp);\n endTimestamp = Math.max(endTimestamp, packet.timestamp + packet.duration);\n packetCount++;\n totalPacketBytes += packet.byteLength;\n }\n return {\n packetCount,\n averagePacketRate: packetCount\n ? Number((packetCount / (endTimestamp - startTimestamp)).toPrecision(16))\n : 0,\n averageBitrate: packetCount\n ? Number((8 * totalPacketBytes / (endTimestamp - startTimestamp)).toPrecision(16))\n : 0,\n };\n }\n /**\n * Whether or not this track is currently live, meaning the media's end is still unknown.\n *\n * The value returned by this method may change over time as the track stops being live. To keep track of the\n * track's live status, poll this method at the track's refresh interval\n * via {@link InputTrack.getLiveRefreshInterval}.\n */\n async isLive() {\n return (await this._backing.getLiveRefreshInterval()) !== null;\n }\n /**\n * Returns the track's live refresh interval in seconds, or `null` if the track is not live. This interval describes\n * the time it takes, on average, for new live media data to become available.\n */\n async getLiveRefreshInterval() {\n return this._backing.getLiveRefreshInterval();\n }\n /**\n * Returns `true` if this track can be paired with the given track. Two tracks being pairable means they can be\n * presented (displayed) together.\n *\n * Returns `false` if `other` equals `this`.\n */\n canBePairedWith(other) {\n if (!(other instanceof InputTrack)) {\n throw new TypeError('other must be an InputTrack.');\n }\n if (this.input !== other.input || this === other) {\n return false;\n }\n return (this._backing.getPairingMask() & other._backing.getPairingMask()) !== 0n;\n }\n /**\n * Gets the list of other tracks that can be paired with this track. An optional query can be provided to narrow\n * down the results.\n */\n async getPairableTracks(query) {\n return this.input.getTracks(mergeInputTrackQueries({\n filter: t => t.canBePairedWith(this),\n }, query));\n }\n /**\n * Gets the list of other video tracks that can be paired with this track. An optional query can be provided to\n * narrow down the results.\n */\n async getPairableVideoTracks(query) {\n return this.input.getVideoTracks(mergeInputTrackQueries({\n filter: t => t.canBePairedWith(this),\n }, query));\n }\n /**\n * Gets the list of other audio tracks that can be paired with this track. An optional query can be provided to\n * narrow down the results.\n */\n async getPairableAudioTracks(query) {\n return this.input.getAudioTracks(mergeInputTrackQueries({\n filter: t => t.canBePairedWith(this),\n }, query));\n }\n /** Returns the primary track that can be paired with this track, optionally steered by the provided query. */\n async getPrimaryPairableVideoTrack(query) {\n return this.input.getPrimaryVideoTrack(mergeInputTrackQueries({\n filter: t => t.canBePairedWith(this),\n }, query));\n }\n /** Returns the primary track that can be paired with this track, optionally steered by the provided query. */\n async getPrimaryPairableAudioTrack(query) {\n return this.input.getPrimaryAudioTrack(mergeInputTrackQueries({\n filter: t => t.canBePairedWith(this),\n }, query));\n }\n /** Returns `true` if there is another track that can be paired with this track. */\n async hasPairableTrack(predicate) {\n predicate &&= toValidatedPredicate(predicate);\n const tracks = await this.input.getTracks();\n for (const track of tracks) {\n if (!this.canBePairedWith(track)) {\n continue;\n }\n if (!predicate || await predicate(track)) {\n return true;\n }\n }\n return false;\n }\n /** Returns `true` if there is a video track that can be paired with this track. */\n hasPairableVideoTrack(predicate) {\n predicate &&= toValidatedPredicate(predicate);\n return this.hasPairableTrack(async (x) => x.isVideoTrack() && (!predicate || await predicate(x)));\n }\n /** Returns `true` if there is an audio track that can be paired with this track. */\n hasPairableAudioTrack(predicate) {\n predicate &&= toValidatedPredicate(predicate);\n return this.hasPairableTrack(async (x) => x.isAudioTrack() && (!predicate || await predicate(x)));\n }\n }\n const requireSync = (value, getterName, asyncName) => {\n if (value instanceof Promise) {\n throw new Error(`'${getterName}' is deprecated and not available synchronously for this track. Use the preferred`\n + ` '${asyncName}()' instead.`);\n }\n return value;\n };\n const toValidatedPredicate = (predicate) => {\n if (predicate !== undefined && typeof predicate !== 'function') {\n throw new TypeError('predicate, when provided, must be a function.');\n }\n return predicate\n ? (track) => {\n const handle = (result) => {\n if (typeof result !== 'boolean') {\n throw new TypeError('predicate must return or resolve to a boolean value.');\n }\n return result;\n };\n const result = predicate(track);\n if (result instanceof Promise) {\n return result.then(handle);\n }\n return handle(result);\n }\n : undefined;\n };\n /**\n * Represents a video track in an input file.\n * @group Input files & tracks\n * @public\n */\n class InputVideoTrack extends InputTrack {\n /** @internal */\n constructor(input, backing) {\n super(input, backing);\n /** @internal */\n this._pixelAspectRatioCache = null;\n this._backing = backing;\n }\n get type() {\n return 'video';\n }\n /** The codec of the track's packets. */\n async getCodec() {\n return this._backing.getCodec();\n }\n /**\n * The codec of the track's packets.\n * @deprecated Use {@link InputVideoTrack.getCodec} instead.\n */\n get codec() {\n return requireSync(this._backing.getCodec(), 'codec', 'getCodec');\n }\n async hasOnlyKeyPackets() {\n return (await this._backing.getHasOnlyKeyPackets?.())\n ?? (await this._backing.getCodec() === 'prores'); // Only ProRes is fully intra-frame\n }\n /** Returns the width in pixels of the track's coded samples, before any transformations or rotations. */\n async getCodedWidth() {\n return this._backing.getCodedWidth();\n }\n /**\n * The width in pixels of the track's coded samples, before any transformations or rotations.\n * @deprecated Use {@link InputVideoTrack.getCodedWidth} instead.\n */\n get codedWidth() {\n return requireSync(this._backing.getCodedWidth(), 'codedWidth', 'getCodedWidth');\n }\n /** Returns the height in pixels of the track's coded samples, before any transformations or rotations. */\n async getCodedHeight() {\n return this._backing.getCodedHeight();\n }\n /**\n * The height in pixels of the track's coded samples, before any transformations or rotations.\n * @deprecated Use {@link InputVideoTrack.getCodedHeight} instead.\n */\n get codedHeight() {\n return requireSync(this._backing.getCodedHeight(), 'codedHeight', 'getCodedHeight');\n }\n /** Returns the angle in degrees by which the track's frames should be rotated (clockwise). */\n async getRotation() {\n return this._backing.getRotation();\n }\n /**\n * The angle in degrees by which the track's frames should be rotated (clockwise).\n * @deprecated Use {@link InputVideoTrack.getRotation} instead.\n */\n get rotation() {\n return requireSync(this._backing.getRotation(), 'rotation', 'getRotation');\n }\n /**\n * Returns the width of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation.\n */\n async getSquarePixelWidth() {\n return this._backing.getSquarePixelWidth();\n }\n /**\n * The width of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation.\n * @deprecated Use {@link InputVideoTrack.getSquarePixelWidth} instead.\n */\n get squarePixelWidth() {\n return requireSync(this._backing.getSquarePixelWidth(), 'squarePixelWidth', 'getSquarePixelWidth');\n }\n /**\n * Returns the height of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation.\n */\n async getSquarePixelHeight() {\n return this._backing.getSquarePixelHeight();\n }\n /**\n * The height of the track's frames in square pixels, adjusted for pixel aspect ratio but before rotation.\n * @deprecated Use {@link InputVideoTrack.getSquarePixelHeight} instead.\n */\n get squarePixelHeight() {\n return requireSync(this._backing.getSquarePixelHeight(), 'squarePixelHeight', 'getSquarePixelHeight');\n }\n /**\n * Returns the pixel aspect ratio of the track's frames as a rational number in its reduced form. Most videos use\n * square pixels (1:1).\n */\n async getPixelAspectRatio() {\n // Potential minor async race condition here if called twice, but doesn't matter since the computation is\n // so cheap\n return this._pixelAspectRatioCache ??= simplifyRational({\n num: (await this.getSquarePixelWidth()) * (await this.getCodedHeight()),\n den: (await this.getSquarePixelHeight()) * (await this.getCodedWidth()),\n });\n }\n /**\n * The pixel aspect ratio of the track's frames, as a rational number in its reduced form. Most videos use\n * square pixels (1:1).\n * @deprecated Use {@link InputVideoTrack.getPixelAspectRatio} instead.\n */\n get pixelAspectRatio() {\n return this._pixelAspectRatioCache ??= simplifyRational({\n num: requireSync(this._backing.getSquarePixelWidth(), 'pixelAspectRatio', 'getPixelAspectRatio')\n * requireSync(this._backing.getCodedHeight(), 'pixelAspectRatio', 'getPixelAspectRatio'),\n den: requireSync(this._backing.getSquarePixelHeight(), 'pixelAspectRatio', 'getPixelAspectRatio')\n * requireSync(this._backing.getCodedWidth(), 'pixelAspectRatio', 'getPixelAspectRatio'),\n });\n }\n /** Returns the display width of the track's frames in pixels, after aspect ratio adjustment and rotation. */\n async getDisplayWidth() {\n const metadata = await this._backing.getMetadataDisplayWidth?.();\n if (metadata != null) {\n return metadata;\n }\n const rotation = await this.getRotation();\n return rotation % 180 === 0 ? this.getSquarePixelWidth() : this.getSquarePixelHeight();\n }\n /**\n * The display width of the track's frames in pixels, after aspect ratio adjustment and rotation.\n * @deprecated Use {@link InputVideoTrack.getDisplayWidth} instead.\n */\n get displayWidth() {\n const metadataRaw = this._backing.getMetadataDisplayWidth?.();\n if (metadataRaw !== undefined) {\n const metadata = requireSync(metadataRaw, 'displayWidth', 'getDisplayWidth');\n if (metadata !== null) {\n return metadata;\n }\n }\n const rotation = requireSync(this._backing.getRotation(), 'displayWidth', 'getDisplayWidth');\n const value = rotation % 180 === 0\n ? this._backing.getSquarePixelWidth()\n : this._backing.getSquarePixelHeight();\n return requireSync(value, 'displayWidth', 'getDisplayWidth');\n }\n /** Returns the display height of the track's frames in pixels, after aspect ratio adjustment and rotation. */\n async getDisplayHeight() {\n const metadata = await this._backing.getMetadataDisplayHeight?.();\n if (metadata != null) {\n return metadata;\n }\n const rotation = await this.getRotation();\n return rotation % 180 === 0 ? this.getSquarePixelHeight() : this.getSquarePixelWidth();\n }\n /**\n * The display height of the track's frames in pixels, after aspect ratio adjustment and rotation.\n * @deprecated Use {@link InputVideoTrack.getDisplayHeight} instead.\n */\n get displayHeight() {\n const metadataRaw = this._backing.getMetadataDisplayHeight?.();\n if (metadataRaw !== undefined) {\n const metadata = requireSync(metadataRaw, 'displayHeight', 'getDisplayHeight');\n if (metadata !== null) {\n return metadata;\n }\n }\n const rotation = requireSync(this._backing.getRotation(), 'displayHeight', 'getDisplayHeight');\n const value = rotation % 180 === 0\n ? this._backing.getSquarePixelHeight()\n : this._backing.getSquarePixelWidth();\n return requireSync(value, 'displayHeight', 'getDisplayHeight');\n }\n /** Returns the color space of the track's samples. */\n async getColorSpace() {\n return this._backing.getColorSpace();\n }\n /** If this method returns true, the track's samples use a high dynamic range (HDR). */\n async hasHighDynamicRange() {\n const colorSpace = await this._backing.getColorSpace();\n return colorSpace.primaries === 'bt2020' || colorSpace.primaries === 'smpte432'\n || colorSpace.transfer === 'pq' || colorSpace.transfer === 'hlg'\n || colorSpace.matrix === 'bt2020-ncl';\n }\n /** Checks if this track may contain transparent samples with alpha data. */\n async canBeTransparent() {\n return this._backing.canBeTransparent();\n }\n /**\n * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#video-decoder-config) for decoding the\n * track's packets using a [`VideoDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder). Returns\n * null if the track's codec is unknown.\n */\n async getDecoderConfig() {\n return this._backing.getDecoderConfig();\n }\n async getCodecParameterString() {\n const fromMetadata = await this._backing.getMetadataCodecParameterString?.();\n if (fromMetadata != null) {\n return fromMetadata;\n }\n const decoderConfig = await this._backing.getDecoderConfig();\n return decoderConfig?.codec ?? null;\n }\n async canDecode() {\n try {\n const decoderConfig = await this._backing.getDecoderConfig();\n if (!decoderConfig) {\n return false;\n }\n const codec = await this._backing.getCodec();\n assert(codec !== null);\n if (customVideoDecoders.some(x => x.supports(codec, decoderConfig))) {\n return true;\n }\n if (typeof VideoDecoder === 'undefined') {\n return false;\n }\n const support = await VideoDecoder.isConfigSupported(decoderConfig);\n return support.supported === true;\n }\n catch (error) {\n Logging._error('Error during decodability check:', error);\n return false;\n }\n }\n async determinePacketType(packet) {\n if (!(packet instanceof EncodedPacket)) {\n throw new TypeError('packet must be an EncodedPacket.');\n }\n if (packet.isMetadataOnly) {\n throw new TypeError('packet must not be metadata-only to determine its type.');\n }\n const codec = await this.getCodec();\n if (codec === null) {\n return null;\n }\n const decoderConfig = await this.getDecoderConfig();\n assert(decoderConfig);\n return determineVideoPacketType(codec, decoderConfig, packet.data);\n }\n }\n /**\n * Represents an audio track in an input file.\n * @group Input files & tracks\n * @public\n */\n class InputAudioTrack extends InputTrack {\n /** @internal */\n constructor(input, backing) {\n super(input, backing);\n this._backing = backing;\n }\n get type() {\n return 'audio';\n }\n /** The codec of the track's packets. */\n async getCodec() {\n return this._backing.getCodec();\n }\n /**\n * The codec of the track's packets.\n * @deprecated Use {@link InputAudioTrack.getCodec} instead.\n */\n get codec() {\n return requireSync(this._backing.getCodec(), 'codec', 'getCodec');\n }\n async hasOnlyKeyPackets() {\n return (await this._backing.getHasOnlyKeyPackets?.()) ?? true;\n }\n /** Returns the number of audio channels in the track. */\n async getNumberOfChannels() {\n return this._backing.getNumberOfChannels();\n }\n /**\n * The number of audio channels in the track.\n * @deprecated Use {@link InputAudioTrack.getNumberOfChannels} instead.\n */\n get numberOfChannels() {\n return requireSync(this._backing.getNumberOfChannels(), 'numberOfChannels', 'getNumberOfChannels');\n }\n /** Returns the track's audio sample rate in hertz. */\n async getSampleRate() {\n return this._backing.getSampleRate();\n }\n /**\n * The track's audio sample rate in hertz.\n * @deprecated Use {@link InputAudioTrack.getSampleRate} instead.\n */\n get sampleRate() {\n return requireSync(this._backing.getSampleRate(), 'sampleRate', 'getSampleRate');\n }\n /**\n * Returns the [decoder configuration](https://www.w3.org/TR/webcodecs/#audio-decoder-config) for decoding the\n * track's packets using an [`AudioDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/AudioDecoder). Returns\n * null if the track's codec is unknown.\n */\n async getDecoderConfig() {\n return this._backing.getDecoderConfig();\n }\n async getCodecParameterString() {\n const fromMetadata = await this._backing.getMetadataCodecParameterString?.();\n if (fromMetadata != null) {\n return fromMetadata;\n }\n const decoderConfig = await this._backing.getDecoderConfig();\n return decoderConfig?.codec ?? null;\n }\n async canDecode() {\n try {\n const decoderConfig = await this._backing.getDecoderConfig();\n if (!decoderConfig) {\n return false;\n }\n const codec = await this._backing.getCodec();\n assert(codec !== null);\n if (customAudioDecoders.some(x => x.supports(codec, decoderConfig))) {\n return true;\n }\n if (decoderConfig.codec.startsWith('pcm-')) {\n return true; // Since we decode it ourselves\n }\n else {\n if (typeof AudioDecoder === 'undefined') {\n return false;\n }\n const support = await AudioDecoder.isConfigSupported(decoderConfig);\n return support.supported === true;\n }\n }\n catch (error) {\n Logging._error('Error during decodability check:', error);\n return false;\n }\n }\n async determinePacketType(packet) {\n if (!(packet instanceof EncodedPacket)) {\n throw new TypeError('packet must be an EncodedPacket.');\n }\n if ((await this.getCodec()) === null) {\n return null;\n }\n return 'key'; // No audio codec with delta packets\n }\n }\n /**\n * Helper function for use in {@link InputTrackQuery.sortBy}, used to describe sorting tracks by a numeric property in\n * descending order. `null` and `undefined` are accepted too and are last in the order (sorted to the end).\n *\n * @group Input files & tracks\n * @public\n */\n const desc = (value) => {\n return -(value ?? -Infinity); // nulls and undefined last\n };\n /**\n * Helper function for use in {@link InputTrackQuery.sortBy}, used to sort tracks by boolean properties. `true` is\n * sorted to the start, `false` to the end. Useful for expressing soft preferences (e.g., \"I'd prefer 1080p, but other\n * resolutions are fine too\") as opposed to {@link InputTrackQuery.filter} which expresses hard requirements for\n * tracks.\n *\n * @group Input files & tracks\n * @public\n */\n const prefer = (value) => {\n return -value;\n };\n const toValidatedInputTrackQuery = (query) => {\n if (typeof query !== 'object' || !query) {\n throw new TypeError('query must be an object.');\n }\n if (query.filter !== undefined && typeof query.filter !== 'function') {\n throw new TypeError('query.filter, when provided, must be a function.');\n }\n if (query.sortBy !== undefined && typeof query.sortBy !== 'function') {\n throw new TypeError('query.sortBy, when provided, must be a function.');\n }\n // Instead of validating the return types of the functions everywhere the query is used, simply return a new query\n // which wraps the old one while validating it.\n return {\n filter: query.filter\n ? (track) => {\n const handle = (bool) => {\n if (typeof bool !== 'boolean') {\n throw new TypeError('query.filter must return or resolve to a boolean.');\n }\n return bool;\n };\n const result = query.filter(track);\n if (result instanceof Promise) {\n return result.then(handle);\n }\n else {\n return handle(result);\n }\n }\n : undefined,\n sortBy: query.sortBy\n ? (track) => {\n const handle = (value) => {\n if (typeof value !== 'number'\n && (!Array.isArray(value) || !value.every(x => typeof x === 'number'))) {\n throw new TypeError('query.sortBy must return or resolve to a number or an array of numbers.');\n }\n return value;\n };\n const result = query.sortBy(track);\n if (result instanceof Promise) {\n return result.then(handle);\n }\n else {\n return handle(result);\n }\n }\n : undefined,\n };\n };\n const mergeInputTrackQueries = (queryA, queryB) => {\n return {\n filter: queryA?.filter || queryB?.filter\n ? (track) => {\n const resultA = queryA?.filter?.(track) ?? true;\n const handleResultA = (resultA) => {\n if (resultA === false) {\n return false;\n }\n return queryB?.filter?.(track) ?? true;\n };\n if (resultA instanceof Promise) {\n return resultA.then(handleResultA);\n }\n else {\n return handleResultA(resultA);\n }\n }\n : undefined,\n sortBy: queryA?.sortBy || queryB?.sortBy\n ? (track) => {\n const resultA = queryA?.sortBy?.(track) ?? [];\n const resultB = queryB?.sortBy?.(track) ?? [];\n const join = (resultA, resultB) => {\n return [\n ...(Array.isArray(resultA) ? resultA : [resultA]),\n ...(Array.isArray(resultB) ? resultB : [resultB]),\n ];\n };\n if (resultA instanceof Promise || resultB instanceof Promise) {\n return Promise.all([resultA, resultB]).then(([resultA, resultB]) => {\n return join(resultA, resultB);\n });\n }\n else {\n return join(resultA, resultB);\n }\n }\n : undefined,\n };\n };\n const queryInputTracks = async (tracks, query) => {\n let matched = tracks;\n if (query?.filter) {\n const filterMatches = tracks.map(t => query.filter(t));\n const hasAsyncFilter = filterMatches.some(x => x instanceof Promise);\n if (hasAsyncFilter) {\n // eslint-disable-next-line @typescript-eslint/await-thenable\n const resolvedFilterMatches = await Promise.all(filterMatches);\n matched = tracks.filter((_, i) => resolvedFilterMatches[i]);\n }\n else {\n matched = tracks.filter((_, i) => filterMatches[i]);\n }\n }\n if (!query?.sortBy) {\n return matched;\n }\n const sortValues = matched.map(t => query.sortBy(t));\n const hasAsyncSort = sortValues.some(x => x instanceof Promise);\n const resolvedSortValues = hasAsyncSort\n // eslint-disable-next-line @typescript-eslint/await-thenable\n ? await Promise.all(sortValues)\n : sortValues;\n return matched\n .map((track, i) => ({ track, sortValue: resolvedSortValues[i] }))\n .sort((a, b) => {\n const aValues = Array.isArray(a.sortValue) ? a.sortValue : [a.sortValue];\n const bValues = Array.isArray(b.sortValue) ? b.sortValue : [b.sortValue];\n const maxLength = Math.max(aValues.length, bValues.length);\n for (let i = 0; i < maxLength; i++) {\n const aValue = aValues[i] ?? 0;\n const bValue = bValues[i] ?? 0;\n if (aValue === bValue) {\n continue;\n }\n return aValue - bValue;\n }\n return 0;\n })\n .map(x => x.track);\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n polyfillSymbolDispose();\n const DEFAULT_SOURCE_CACHE_GROUP = 1;\n const ENCRYPTION_KEY_CACHE_GROUP = 2;\n /**\n * Represents input media, backed by a single file or multiple files depending on the format.\n *\n * This is the root object from which all media read operations start.\n * @group Input files & tracks\n * @public\n */\n class Input extends EventEmitter {\n /** True if the input has been disposed. */\n get disposed() {\n return this._disposed;\n }\n /**\n * Creates a new input file from the specified options. No reading operations will be performed until methods are\n * called on this instance.\n */\n constructor(options) {\n super();\n /** @internal */\n this._demuxerPromise = null;\n /** @internal */\n this._format = null;\n /** @internal */\n this._trackBackingsCache = null;\n /** @internal */\n this._backingToTrack = new Map();\n /** @internal */\n this._disposed = false;\n /** @internal */\n this._nextSourceCacheAge = 0;\n /** @internal */\n this._sourceRefs = [];\n /** @internal */\n this._sourceCache = [];\n /** @internal */\n this._sourceCachePromises = [];\n /** @internal */\n this._onFormatDetermined = null;\n if (!options || typeof options !== 'object') {\n throw new TypeError('options must be an object.');\n }\n if (!Array.isArray(options.formats) || options.formats.some(x => !(x instanceof InputFormat))) {\n throw new TypeError('options.formats must be an array of InputFormat.');\n }\n if (!(options.source instanceof Source || options.source instanceof SourceRef)) {\n throw new TypeError('options.source must be a Source or SourceRef.');\n }\n if (options.source instanceof Source && options.source._disposed) {\n throw new TypeError('options.source must not be a disposed Source.');\n }\n if (options.initInput !== undefined && !(options.initInput instanceof Input)) {\n throw new TypeError('options.initInput, when provided, must be an Input.');\n }\n if (options.formatOptions !== undefined) {\n validateInputFormatOptions(options.formatOptions, 'formatOptions');\n }\n this._formats = options.formats;\n this._initInput = options.initInput ?? null;\n this._formatOptions = options.formatOptions ?? {};\n if (options.source instanceof Source) {\n this._rootRef = options.source.ref();\n }\n else {\n this._rootRef = options.source;\n }\n this._sourceRefs.push(this._rootRef);\n }\n /** @internal */\n get _rootSource() {\n return this._rootRef.source;\n }\n /** @internal */\n async _getSourceUncached(request) {\n assert(this._rootSource instanceof PathedSource);\n const ref = await this._rootSource._resolveRequest(request);\n this._emit('source', { source: ref.source, request, isRoot: request.isRoot });\n return ref;\n }\n /** @internal */\n _getSourceCached(request, cacheGroup = DEFAULT_SOURCE_CACHE_GROUP) {\n const cachedEntry = this._sourceCache.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request));\n if (cachedEntry) {\n cachedEntry.age++;\n return Promise.resolve(cachedEntry.sourceRef.source.ref());\n }\n const cachedPromiseEntry = this._sourceCachePromises.find(x => x.cacheGroup === cacheGroup && sourceRequestsAreEqual(x.request, request));\n if (cachedPromiseEntry) {\n return cachedPromiseEntry.promise.then(x => x.sourceRef.source.ref());\n }\n const promise = (async () => {\n const sourceRef = await this._getSourceUncached(request);\n const MAX_SOURCE_CACHE_SIZE = 4;\n const count = arrayCount(this._sourceCache, x => x.cacheGroup === cacheGroup && x.sourceRef.source._refCount === 1);\n if (count >= MAX_SOURCE_CACHE_SIZE) {\n const minAgeIndex = arrayArgmin(this._sourceCache, x => x.cacheGroup === cacheGroup && x.sourceRef.source._refCount === 1 ? x.age : Infinity);\n assert(minAgeIndex !== -1);\n const entry = this._sourceCache[minAgeIndex];\n this._sourceCache.splice(minAgeIndex, 1);\n entry.sourceRef.free();\n removeItem(this._sourceRefs, entry.sourceRef);\n }\n this._sourceRefs.push(sourceRef);\n const promiseIndex = this._sourceCachePromises.findIndex(x => x.request === request);\n assert(promiseIndex !== -1);\n this._sourceCachePromises.splice(promiseIndex, 1);\n const cacheEntry = {\n request,\n sourceRef,\n age: this._nextSourceCacheAge++,\n cacheGroup,\n };\n return cacheEntry;\n })();\n this._sourceCachePromises.push({\n request,\n cacheGroup,\n promise,\n });\n return promise.then((entry) => {\n const ref = entry.sourceRef.source.ref();\n // We need to add it to the cache this late to avoid the ref being freed prematurely due to race conditions\n this._sourceCache.push(entry);\n return ref;\n });\n }\n /** @internal */\n _getDemuxer() {\n return this._demuxerPromise ??= (async () => {\n this._reader = new Reader(this._rootSource);\n this._emit('source', { source: this._rootSource, request: null, isRoot: true });\n for (const format of this._formats) {\n const canRead = await format._canReadInput(this);\n if (canRead) {\n this._format = format;\n this._onFormatDetermined?.(format);\n return format._createDemuxer(this);\n }\n }\n throw new UnsupportedInputFormatError();\n })();\n }\n /**\n * Returns the source from which this input file reads data for the root path.\n */\n get source() {\n return this._rootSource;\n }\n /**\n * Returns the format of the input file. You can compare this result directly to the {@link InputFormat} singletons\n * or use `instanceof` checks for subset-aware logic (for example, `format instanceof MatroskaInputFormat` is true\n * for both MKV and WebM).\n */\n async getFormat() {\n await this._getDemuxer();\n assert(this._format);\n return this._format;\n }\n /** Returns `true` if the format of the input file is known and the file can be read, `false` otherwise. */\n async canRead() {\n try {\n await this._getDemuxer();\n return true;\n }\n catch (error) {\n if (error instanceof UnsupportedInputFormatError) {\n return false;\n }\n throw error;\n }\n }\n /**\n * Returns the timestamp at which the input file starts. More precisely, returns the smallest starting timestamp\n * among all tracks.\n *\n * Optionally, you can pass in the list of tracks for which you want to compute the starting timestamp.\n *\n * Note that this method is potentially expensive for inputs with many tracks (such as HLS manifests), since it\n * probes every track.\n */\n async getFirstTimestamp(tracks) {\n tracks ??= await this.getTracks();\n const filtered = tracks.filter(x => x !== null);\n if (filtered.length === 0) {\n return 0;\n }\n const firstTimestamps = await Promise.all(filtered.map(x => x.getFirstTimestamp()));\n return Math.min(...firstTimestamps);\n }\n /**\n * Computes the duration of the input file, in seconds. More precisely, returns the largest end timestamp among\n * all tracks.\n *\n * Optionally, you can pass in the list of tracks for which you want to compute the duration.\n *\n * This method can be potentially expensive depending on the underlying file format, because it returns the most\n * accurate duration possible and must check all tracks. Use {@link Input.getDurationFromMetadata} for a faster but\n * less accurate estimate of duration.\n *\n * By default, when any track in the underlying media is live, this method will only resolve once the live stream\n * ends. If you want to query the current duration of the media, set {@link PacketRetrievalOptions.skipLiveWait}\n * to `true` in the options.\n */\n async computeDuration(tracks, options) {\n tracks ??= await this.getTracks();\n const filtered = tracks.filter(x => x !== null);\n if (filtered.length === 0) {\n return 0;\n }\n const tracksDurations = await Promise.all(filtered.map(x => x.computeDuration(options)));\n return Math.max(...tracksDurations);\n }\n /**\n * Gets the duration (end timestamp) in seconds of the input file from metadata stored in the file. This value may\n * be approximate or diverge from the actual, precise duration returned by `.computeDuration()`, but compared to\n * that method, this method is cheaper. When the duration cannot be determined from the file metadata, `null`\n * is returned.\n *\n * Optionally, you can pass in the list of tracks for which you want to get the duration from metadata.\n *\n * By default, when the underlying media is live, this method will only resolve once the live stream\n * ends. If you want to query the current duration of the media, set\n * {@link DurationMetadataRequestOptions.skipLiveWait} to `true` in the options.\n */\n async getDurationFromMetadata(tracks, options) {\n tracks ??= await this.getTracks();\n const filtered = tracks.filter(x => x !== null);\n const tracksDurations = await Promise.all(filtered.map(x => x.getDurationFromMetadata(options)));\n const nonNullDurations = tracksDurations.filter(x => x !== null);\n if (nonNullDurations.length === 0) {\n return null;\n }\n return Math.max(...nonNullDurations);\n }\n /**\n * Returns the list of all tracks of this input file in the order in which they appear in the file. An optional\n * query can be provided.\n */\n async getTracks(query) {\n query &&= toValidatedInputTrackQuery(query);\n const backings = await this._getTrackBackings();\n const tracks = backings.map(backing => this._wrapBackingAsTrack(backing));\n return queryInputTracks(tracks, query);\n }\n /** Returns the list of all video tracks of this input file. An optional query can be provided. */\n async getVideoTracks(query) {\n query &&= toValidatedInputTrackQuery(query);\n const tracks = await this.getTracks();\n const videoTracks = tracks.filter((x) => x.isVideoTrack());\n return queryInputTracks(videoTracks, query);\n }\n /** Returns the list of all audio tracks of this input file. An optional query can be provided. */\n async getAudioTracks(query) {\n query &&= toValidatedInputTrackQuery(query);\n const tracks = await this.getTracks();\n const audioTracks = tracks.filter((x) => x.isAudioTrack());\n return queryInputTracks(audioTracks, query);\n }\n /**\n * Returns the primary video track of this input file, or null if there are no video tracks.\n *\n * Multiple factors determine which track is considered primary, including its position in the file, disposition,\n * bitrate (higher bitrate is preferred), and if it can be paired with an audio track.\n */\n async getPrimaryVideoTrack(query) {\n query &&= toValidatedInputTrackQuery(query);\n const merged = mergeInputTrackQueries(query, {\n sortBy: async (t) => [\n prefer((await t.getDisposition()).default),\n prefer(await t.hasPairableAudioTrack()),\n prefer(!(await t.hasOnlyKeyPackets())),\n desc(await t.getBitrate()),\n ],\n });\n const sorted = await this.getVideoTracks(merged);\n return sorted[0] ?? null;\n }\n /**\n * Returns the primary audio track of this input file, or null if there are no audio tracks.\n *\n * Multiple factors determine which track is considered primary, including its position in the file, disposition,\n * bitrate (higher bitrate is preferred), and if it can be paired with the primary video track.\n */\n async getPrimaryAudioTrack(query) {\n query &&= toValidatedInputTrackQuery(query);\n const primaryVideoTrack = await this.getPrimaryVideoTrack();\n const merged = mergeInputTrackQueries(query, {\n sortBy: async (t) => [\n prefer(!primaryVideoTrack || t.canBePairedWith(primaryVideoTrack)),\n prefer((await t.getDisposition()).default),\n desc(await t.getBitrate()),\n ],\n });\n const sorted = await this.getAudioTracks(merged);\n return sorted[0] ?? null;\n }\n /** @internal */\n async _getTrackBackings() {\n const demuxer = await this._getDemuxer();\n return this._trackBackingsCache ??= await demuxer.getTrackBackings();\n }\n /** @internal */\n _wrapBackingAsTrack(backing) {\n const existing = this._backingToTrack.get(backing);\n if (existing) {\n return existing;\n }\n const type = backing.getType();\n const track = type === 'video'\n ? new InputVideoTrack(this, backing)\n : new InputAudioTrack(this, backing);\n this._backingToTrack.set(backing, track);\n return track;\n }\n /** Returns the full MIME type of this input file, including track codecs. */\n async getMimeType() {\n const demuxer = await this._getDemuxer();\n return demuxer.getMimeType();\n }\n /**\n * Returns descriptive metadata tags about the media file, such as title, author, date, cover art, or other\n * attached files.\n */\n async getMetadataTags() {\n const demuxer = await this._getDemuxer();\n return demuxer.getMetadataTags();\n }\n /**\n * Disposes this input and frees connected resources. When an input is disposed, ongoing read operations will be\n * canceled, all future read operations will fail, any open decoders will be closed, and all ongoing media sink\n * operations will be canceled. Disallowed and canceled operations will throw an {@link InputDisposedError}.\n *\n * You are expected not to use an input after disposing it. While some operations may still work, it is not\n * specified and may change in any future update.\n */\n dispose() {\n if (this._disposed) {\n return;\n }\n this._disposed = true;\n for (const ref of this._sourceRefs) {\n ref.free();\n }\n this._sourceRefs.length = 0;\n if (this._demuxerPromise) {\n // The demuxer promise may already be rejected after failed format detection.\n void this._demuxerPromise\n .then(demuxer => demuxer.dispose())\n .catch(() => { });\n }\n }\n /**\n * Calls `.dispose()` on the input, implementing the `Disposable` interface for use with\n * JavaScript Explicit Resource Management features.\n */\n [Symbol.dispose]() {\n this.dispose();\n }\n }\n /**\n * Thrown when trying to operate on an input that has an unsupported or unrecognizable format.\n * @group Input files & tracks\n * @public\n */\n class UnsupportedInputFormatError extends Error {\n /** Creates a new {@link UnsupportedInputFormatError}. */\n constructor(message = 'Input has an unsupported or unrecognizable format.') {\n super(message);\n this.name = 'UnsupportedInputFormatError';\n }\n }\n /**\n * Thrown when an operation was prevented because the corresponding {@link Input} has been disposed.\n * @group Input files & tracks\n * @public\n */\n class InputDisposedError extends Error {\n /** Creates a new {@link InputDisposedError}. */\n constructor(message = 'Input has been disposed.') {\n super(message);\n this.name = 'InputDisposedError';\n }\n }\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n class Reader {\n constructor(source) {\n this.source = source;\n }\n get fileSize() {\n const size = this.source._getFileSize();\n if (size === undefined) {\n throw new Error('Reading file size too early; read required first.');\n }\n return size;\n }\n get fileSizeNonStrict() {\n return this.source._getFileSize() ?? null;\n }\n requestSlice(start, length) {\n if (this.source._disposed) {\n throw new InputDisposedError();\n }\n if (start < 0) {\n return null;\n }\n if (this.fileSizeNonStrict !== null && start + length > this.fileSizeNonStrict) {\n return null;\n }\n if (length === 0) {\n const buffer = new Uint8Array(0);\n return new FileSlice(buffer, toDataView(buffer), 0, start, start);\n }\n const end = start + length;\n const result = this.source._read(start, end, DEFAULT_MIN_READ_POSITION, DEFAULT_MAX_READ_POSITION);\n if (result instanceof Promise) {\n return result.then((x) => {\n if (!x) {\n return null;\n }\n return new FileSlice(x.bytes, x.view, x.offset, start, end);\n });\n }\n else {\n if (!result) {\n return null;\n }\n return new FileSlice(result.bytes, result.view, result.offset, start, end);\n }\n }\n requestSliceRange(start, minLength, maxLength) {\n if (this.source._disposed) {\n throw new InputDisposedError();\n }\n if (start < 0) {\n return null;\n }\n if (this.fileSizeNonStrict !== null) {\n return this.requestSlice(start, clamp(this.fileSizeNonStrict - start, minLength, maxLength));\n }\n else {\n const promisedAttempt = this.requestSlice(start, maxLength);\n const handleAttempt = (attempt) => {\n if (attempt) {\n return attempt;\n }\n // The slice couldn't fit, meaning we must know the file size now\n assert(this.fileSizeNonStrict !== null);\n return this.requestSlice(start, clamp(this.fileSizeNonStrict - start, minLength, maxLength));\n };\n if (promisedAttempt instanceof Promise) {\n return promisedAttempt.then(handleAttempt);\n }\n else {\n return handleAttempt(promisedAttempt);\n }\n }\n }\n requestEntireFile() {\n if (this.fileSizeNonStrict !== null) {\n return this.requestSlice(0, this.fileSizeNonStrict);\n }\n const CHUNK_SIZE = 1024;\n return (async () => {\n const chunks = [];\n let currentSize = 0;\n while (true) {\n if (chunks.length === 1 && this.fileSizeNonStrict !== null) {\n // It only took one read to get to know the whole file size\n return this.requestSlice(0, this.fileSizeNonStrict);\n }\n let slice = this.requestSliceRange(currentSize, 0, CHUNK_SIZE);\n if (slice instanceof Promise)\n slice = await slice;\n if (!slice || slice.length === 0) {\n break;\n }\n const chunk = readBytes(slice, slice.length);\n chunks.push(chunk);\n currentSize += slice.length;\n }\n const joined = new Uint8Array(currentSize);\n let offset = 0;\n for (const chunk of chunks) {\n joined.set(chunk, offset);\n offset += chunk.length;\n }\n return new FileSlice(joined, toDataView(joined), 0, 0, currentSize);\n })();\n }\n }\n class FileSlice {\n constructor(\n /** The underlying bytes backing this slice. Avoid using this directly and prefer reader functions instead. */\n bytes, \n /** A view into the bytes backing this slice. Avoid using this directly and prefer reader functions instead. */\n view, \n /** The offset in \"file bytes\" at which `bytes` begins in the file. */\n offset, \n /** The offset in \"file bytes\" where this slice begins. */\n start, \n /** The offset in \"file bytes\" where this slice ends (exclusive). */\n end) {\n this.bytes = bytes;\n this.view = view;\n this.offset = offset;\n this.start = start;\n this.end = end;\n this.bufferPos = start - offset;\n }\n static tempFromBytes(bytes) {\n return new FileSlice(bytes, toDataView(bytes), 0, 0, bytes.length);\n }\n get length() {\n return this.end - this.start;\n }\n get filePos() {\n return this.offset + this.bufferPos;\n }\n set filePos(value) {\n this.bufferPos = value - this.offset;\n }\n /** The number of bytes left from the current pos to the end of the slice. */\n get remainingLength() {\n return Math.max(this.end - this.filePos, 0);\n }\n skip(byteCount) {\n this.bufferPos += byteCount;\n }\n /** Creates a new subslice of this slice whose byte range must be contained within this slice. */\n slice(filePos, length = this.end - filePos) {\n if (filePos < this.start || filePos + length > this.end) {\n throw new RangeError('Slicing outside of original slice.');\n }\n return new FileSlice(this.bytes, this.view, this.offset, filePos, filePos + length);\n }\n }\n const checkIsInRange = (slice, bytesToRead) => {\n if (slice.filePos < slice.start || slice.filePos + bytesToRead > slice.end) {\n throw new RangeError(`Tried reading [${slice.filePos}, ${slice.filePos + bytesToRead}), but slice is`\n + ` [${slice.start}, ${slice.end}). This is likely an internal error, please report it alongside the file`\n + ` that caused it.`);\n }\n };\n const readBytes = (slice, length) => {\n checkIsInRange(slice, length);\n const bytes = slice.bytes.subarray(slice.bufferPos, slice.bufferPos + length);\n slice.bufferPos += length;\n return bytes;\n };\n const readU8 = (slice) => {\n checkIsInRange(slice, 1);\n return slice.view.getUint8(slice.bufferPos++);\n };\n const readU16 = (slice, littleEndian) => {\n checkIsInRange(slice, 2);\n const value = slice.view.getUint16(slice.bufferPos, littleEndian);\n slice.bufferPos += 2;\n return value;\n };\n const readU16Be = (slice) => {\n checkIsInRange(slice, 2);\n const value = slice.view.getUint16(slice.bufferPos, false);\n slice.bufferPos += 2;\n return value;\n };\n const readU24Be = (slice) => {\n checkIsInRange(slice, 3);\n const value = getUint24(slice.view, slice.bufferPos);\n slice.bufferPos += 3;\n return value;\n };\n const readI16Be = (slice) => {\n checkIsInRange(slice, 2);\n const value = slice.view.getInt16(slice.bufferPos, false);\n slice.bufferPos += 2;\n return value;\n };\n const readU32 = (slice, littleEndian) => {\n checkIsInRange(slice, 4);\n const value = slice.view.getUint32(slice.bufferPos, littleEndian);\n slice.bufferPos += 4;\n return value;\n };\n const readU32Be = (slice) => {\n checkIsInRange(slice, 4);\n const value = slice.view.getUint32(slice.bufferPos, false);\n slice.bufferPos += 4;\n return value;\n };\n const readU32Le = (slice) => {\n checkIsInRange(slice, 4);\n const value = slice.view.getUint32(slice.bufferPos, true);\n slice.bufferPos += 4;\n return value;\n };\n const readI32Be = (slice) => {\n checkIsInRange(slice, 4);\n const value = slice.view.getInt32(slice.bufferPos, false);\n slice.bufferPos += 4;\n return value;\n };\n const readI32Le = (slice) => {\n checkIsInRange(slice, 4);\n const value = slice.view.getInt32(slice.bufferPos, true);\n slice.bufferPos += 4;\n return value;\n };\n const readU64 = (slice, littleEndian) => {\n let low;\n let high;\n if (littleEndian) {\n low = readU32(slice, true);\n high = readU32(slice, true);\n }\n else {\n high = readU32(slice, false);\n low = readU32(slice, false);\n }\n return high * 0x100000000 + low;\n };\n const readU64Be = (slice) => {\n const high = readU32Be(slice);\n const low = readU32Be(slice);\n return high * 0x100000000 + low;\n };\n const readI64Be = (slice) => {\n const high = readI32Be(slice);\n const low = readU32Be(slice);\n return high * 0x100000000 + low;\n };\n const readI64Le = (slice) => {\n const low = readU32Le(slice);\n const high = readI32Le(slice);\n return high * 0x100000000 + low;\n };\n const readF32Be = (slice) => {\n checkIsInRange(slice, 4);\n const value = slice.view.getFloat32(slice.bufferPos, false);\n slice.bufferPos += 4;\n return value;\n };\n const readF64Be = (slice) => {\n checkIsInRange(slice, 8);\n const value = slice.view.getFloat64(slice.bufferPos, false);\n slice.bufferPos += 8;\n return value;\n };\n const readAscii = (slice, length) => {\n checkIsInRange(slice, length);\n let str = '';\n for (let i = 0; i < length; i++) {\n str += String.fromCharCode(slice.bytes[slice.bufferPos++]);\n }\n return str;\n };\n const readAllLines = (slice, length, options) => {\n const text = textDecoder.decode(readBytes(slice, length));\n const lines = text.split('\\n')\n .map(x => x.trim())\n .filter(x => x.length > 0 && !options?.ignore?.(x));\n return lines;\n };\n\n /*!\n * Copyright (c) 2026-present, Vanilagy and contributors\n *\n * This Source Code Form is subject to the terms of the Mozilla Public\n * License, v. 2.0. If a copy of the MPL was not distributed with this\n * file, You can obtain one at https://mozilla.org/MPL/2.0/.\n */\n var Id3V2HeaderFlags;\n (function (Id3V2HeaderFlags) {\n Id3V2HeaderFlags[Id3V2HeaderFlags[\"Unsynchronisation\"] = 128] = \"Unsynchronisation\";\n Id3V2HeaderFlags[Id3V2HeaderFlags[\"ExtendedHeader\"] = 64] = \"ExtendedHeader\";\n Id3V2HeaderFlags[Id3V2HeaderFlags[\"ExperimentalIndicator\"] = 32] = \"ExperimentalIndicator\";\n Id3V2HeaderFlags[Id3V2HeaderFlags[\"Footer\"] = 16] = \"Footer\";\n })(Id3V2HeaderFlags || (Id3V2HeaderFlags = {}));\n var Id3V2TextEncoding;\n (function (Id3V2TextEncoding) {\n Id3V2TextEncoding[Id3V2TextEncoding[\"ISO_8859_1\"] = 0] = \"ISO_8859_1\";\n Id3V2TextEncoding[Id3V2TextEncoding[\"UTF_16_WITH_BOM\"] = 1] = \"UTF_16_WITH_BOM\";\n Id3V2TextEncoding[Id3V2TextEncoding[\"UTF_16_BE_NO_BOM\"] = 2] = \"UTF_16_BE_NO_BOM\";\n Id3V2TextEncoding[Id3V2TextEncoding[\"UTF_8\"] = 3] = \"UTF_8\";\n })(Id3V2TextEncoding || (Id3V2TextEncoding = {}));\n const ID3_V1_TAG_SIZE = 128;\n const ID3_V2_HEADER_SIZE = 10;\n const ID3_V1_GENRES = [\n 'Blues', 'Classic rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge', 'Hip-hop', 'Jazz',\n 'Metal', 'New age', 'Oldies', 'Other', 'Pop', 'Rhythm and blues', 'Rap', 'Reggae', 'Rock',\n 'Techno', 'Industrial', 'Alternative', 'Ska', 'Death metal', 'Pranks', 'Soundtrack',\n 'Euro-techno', 'Ambient', 'Trip-hop', 'Vocal', 'Jazz & funk', 'Fusion', 'Trance', 'Classical',\n 'Instrumental', 'Acid', 'House', 'Game', 'Sound clip', 'Gospel', 'Noise', 'Alternative rock',\n 'Bass', 'Soul', 'Punk', 'Space', 'Meditative', 'Instrumental pop', 'Instrumental rock',\n 'Ethnic', 'Gothic', 'Darkwave', 'Techno-industrial', 'Electronic', 'Pop-folk', 'Eurodance',\n 'Dream', 'Southern rock', 'Comedy', 'Cult', 'Gangsta', 'Top 40', 'Christian rap', 'Pop/funk',\n 'Jungle music', 'Native US', 'Cabaret', 'New wave', 'Psychedelic', 'Rave', 'Showtunes',\n 'Trailer', 'Lo-fi', 'Tribal', 'Acid punk', 'Acid jazz', 'Polka', 'Retro', 'Musical',\n 'Rock \\'n\\' roll', 'Hard rock', 'Folk', 'Folk rock', 'National folk', 'Swing', 'Fast fusion',\n 'Bebop', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic rock',\n 'Progressive rock', 'Psychedelic rock', 'Symphonic rock', 'Slow rock', 'Big band', 'Chorus',\n 'Easy listening', 'Acoustic', 'Humour', 'Speech', 'Chanson', 'Opera', 'Chamber music',\n 'Sonata', 'Symphony', 'Booty bass', 'Primus', 'Porn groove', 'Satire', 'Slow jam', 'Club',\n 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power ballad', 'Rhythmic Soul', 'Freestyle', 'Duet',\n 'Punk rock', 'Drum solo', 'A cappella', 'Euro-house', 'Dance hall', 'Goa music', 'Drum & bass',\n 'Club-house', 'Hardcore techno', 'Terror', 'Indie', 'Britpop', 'Negerpunk', 'Polsk punk',\n 'Beat', 'Christian gangsta rap', 'Heavy metal', 'Black metal', 'Crossover',\n 'Contemporary Christian', 'Christian rock', 'Merengue', 'Salsa', 'Thrash metal', 'Anime',\n 'Jpop', 'Synthpop', 'Christmas', 'Art rock', 'Baroque', 'Bhangra', 'Big beat', 'Breakbeat',\n 'Chillout', 'Downtempo', 'Dub', 'EBM', 'Eclectic', 'Electro', 'Electroclash', 'Emo',\n 'Experimental', 'Garage', 'Global', 'IDM', 'Illbient', 'Industro-Goth', 'Jam Band',\n 'Krautrock', 'Leftfield', 'Lounge', 'Math rock', 'New romantic', 'Nu-breakz', 'Post-punk',\n 'Post-rock', 'Psytrance', 'Shoegaze', 'Space rock', 'Trop rock', 'World music', 'Neoclassical',\n 'Audiobook', 'Audio theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie rock', 'G-Funk',\n 'Dubstep', 'Garage rock', 'Psybient',\n ];\n const parseId3V1Tag = (slice, tags) => {\n const startPos = slice.filePos;\n tags.raw ??= {};\n tags.raw['TAG'] ??= readBytes(slice, ID3_V1_TAG_SIZE - 3); // Dump the whole tag into the raw metadata\n slice.filePos = startPos;\n const title = readId3V1String(slice, 30);\n if (title)\n tags.title ??= title;\n const artist = readId3V1String(slice, 30);\n if (artist)\n tags.artist ??= artist;\n const album = readId3V1String(slice, 30);\n if (album)\n tags.album ??= album;\n const yearText = readId3V1String(slice, 4);\n const year = Number.parseInt(yearText, 10);\n if (Number.isInteger(year) && year > 0) {\n tags.date ??= new Date(String(year)); // String so that it parses as UTC\n }\n const commentBytes = readBytes(slice, 30);\n let comment;\n // Check for the ID3v1.1 track number format:\n // The 29th byte (index 28) is a null terminator, and the 30th byte is the track number.\n if (commentBytes[28] === 0 && commentBytes[29] !== 0) {\n const trackNum = commentBytes[29];\n if (trackNum > 0) {\n tags.trackNumber ??= trackNum;\n }\n slice.skip(-30);\n comment = readId3V1String(slice, 28);\n slice.skip(2);\n }\n else {\n slice.skip(-30);\n comment = readId3V1String(slice, 30);\n }\n if (comment)\n tags.comment ??= comment;\n const genreIndex = readU8(slice);\n if (genreIndex < ID3_V1_GENRES.length) {\n tags.genre ??= ID3_V1_GENRES[genreIndex];\n }\n };\n const readId3V1String = (slice, length) => {\n const bytes = readBytes(slice, length);\n const endIndex = coalesceIndex(bytes.indexOf(0), bytes.length);\n const relevantBytes = bytes.subarray(0, endIndex);\n // Decode as ISO-8859-1\n let str = '';\n for (let i = 0; i < relevantBytes.length; i++) {\n str += String.fromCharCode(relevantBytes[i]);\n }\n return str.trimEnd(); // String also may be padded with spaces\n };\n const readId3V2Header = (slice) => {\n const startPos = slice.filePos;\n const tag = readAscii(slice, 3);\n const majorVersion = readU8(slice);\n const revision = readU8(slice);\n const flags = readU8(slice);\n const sizeRaw = readU32Be(slice);\n if (tag !== 'ID3' || majorVersion === 0xff || revision === 0xff || (sizeRaw & 0x80808080) !== 0) {\n slice.filePos = startPos;\n return null;\n }\n let size = decodeSynchsafe(sizeRaw);\n if (flags & Id3V2HeaderFlags.Footer) {\n size += ID3_V2_HEADER_SIZE;\n }\n return { majorVersion, revision, flags, size };\n };\n const parseId3V2Tag = (slice, header, tags) => {\n // https://id3.org/id3v2.3.0\n if (![2, 3, 4].includes(header.majorVersion)) {\n Logging._warn(`Unsupported ID3v2 major version: ${header.majorVersion}`);\n return;\n }\n const dataSize = (header.flags & Id3V2HeaderFlags.Footer)\n ? header.size - ID3_V2_HEADER_SIZE\n : header.size;\n const bytes = readBytes(slice, dataSize);\n const reader = new Id3V2Reader(header, bytes);\n if ((header.flags & Id3V2HeaderFlags.Unsynchronisation) && header.majorVersion === 3) {\n reader.ununsynchronizeAll();\n }\n if (header.flags & Id3V2HeaderFlags.ExtendedHeader) {\n const extendedHeaderSize = reader.readU32();\n if (header.majorVersion === 3) {\n reader.pos += extendedHeaderSize; // The extended header size excludes itself\n }\n else {\n reader.pos += extendedHeaderSize - 4; // The extended header size includes itself\n }\n }\n while (reader.pos <= reader.bytes.length - reader.frameHeaderSize()) {\n const frame = reader.readId3V2Frame();\n if (!frame) {\n break;\n }\n const frameStartPos = reader.pos;\n const frameEndPos = reader.pos + frame.size;\n let frameEncrypted = false;\n let frameCompressed = false;\n let frameUnsynchronized = false;\n if (header.majorVersion === 3) {\n frameEncrypted = !!(frame.flags & (1 << 6));\n frameCompressed = !!(frame.flags & (1 << 7));\n }\n else if (header.majorVersion === 4) {\n frameEncrypted = !!(frame.flags & (1 << 2));\n frameCompressed = !!(frame.flags & (1 << 3));\n frameUnsynchronized = !!(frame.flags & (1 << 1))\n || !!(header.flags & Id3V2HeaderFlags.Unsynchronisation);\n }\n if (frameEncrypted) {\n Logging._warn(`Skipping encrypted ID3v2 frame ${frame.id}`);\n reader.pos = frameEndPos;\n continue;\n }\n if (frameCompressed) {\n Logging._warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk\n reader.pos = frameEndPos;\n continue;\n }\n if (frameUnsynchronized) {\n reader.ununsynchronizeRegion(reader.pos, frameEndPos);\n }\n tags.raw ??= {};\n if (frame.id === 'TXXX') {\n const txxx = tags.raw['TXXX'] ??= {};\n const encoding = reader.readId3V2TextEncoding();\n const description = reader.readId3V2Text(encoding, frameEndPos);\n const value = reader.readId3V2Text(encoding, frameEndPos);\n txxx[description] ??= value;\n }\n else if (frame.id[0] === 'T') {\n // It's a text frame, let's decode as text\n tags.raw[frame.id] ??= reader.readId3V2EncodingAndText(frameEndPos);\n }\n else {\n // For the others, let's just get the bytes\n tags.raw[frame.id] ??= reader.readBytes(frame.size);\n }\n reader.pos = frameStartPos;\n switch (frame.id) {\n case 'TIT2':\n case 'TT2':\n {\n tags.title ??= reader.readId3V2EncodingAndText(frameEndPos);\n }\n break;\n case 'TIT3':\n case 'TT3':\n {\n tags.description ??= reader.readId3V2EncodingAndText(frameEndPos);\n }\n break;\n case 'TPE1':\n case 'TP1':\n {\n tags.artist ??= reader.readId3V2EncodingAndText(frameEndPos);\n }\n break;\n case 'TALB':\n case 'TAL':\n {\n tags.album ??= reader.readId3V2EncodingAndText(frameEndPos);\n }\n break;\n case 'TPE2':\n case 'TP2':\n {\n tags.albumArtist ??= reader.readId3V2EncodingAndText(frameEndPos);\n }\n break;\n case 'TRCK':\n case 'TRK':\n {\n const trackText = reader.readId3V2EncodingAndText(frameEndPos);\n const parts = trackText.split('/');\n const trackNum = Number.parseInt(parts[0], 10);\n const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(trackNum) && trackNum > 0) {\n tags.trackNumber ??= trackNum;\n }\n if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {\n tags.tracksTotal ??= tracksTotal;\n }\n }\n break;\n case 'TPOS':\n case 'TPA':\n {\n const discText = reader.readId3V2EncodingAndText(frameEndPos);\n const parts = discText.split('/');\n const discNum = Number.parseInt(parts[0], 10);\n const discsTotal = parts[1] && Number.parseInt(parts[1], 10);\n if (Number.isInteger(discNum) && discNum > 0) {\n tags.discNumber ??= discNum;\n }\n if (discsTotal && Number.isInteger(discsTotal) && discsTotal > 0) {\n tags.discsTotal ??= discsTotal;\n }\n }\n break;\n case 'TCON':\n case 'TCO':\n {\n const genreText = reader.readId3V2EncodingAndText(frameEndPos);\n let match = /^\\((\\d+)\\)/.exec(genreText);\n if (match) {\n const genreNumber = Number.parseInt(match[1]);\n if (ID3_V1_GENRES[genreNumber] !== undefined) {\n tags.genre ??= ID3_V1_GENRES[genreNumber];\n break;\n }\n }\n match = /^\\d+$/.exec(genreText);\n if (match) {\n const genreNumber = Number.parseInt(match[0]);\n if (ID3_V1_GENRES[genreNumber] !== undefined) {\n tags.genre ??= ID3_V1_GENRES[genreNumber];\n break;\n }\n }\n tags.genre ??= genreText;\n }\n break;\n case 'TDRC':\n case 'TDAT':\n {\n const dateText = reader.readId3V2EncodingAndText(frameEndPos);\n const date = new Date(dateText);\n if (!Number.isNaN(date.getTime())) {\n tags.date ??= date;\n }\n }\n break;\n case 'TYER':\n case 'TYE':\n {\n const yearText = reader.readId3V2EncodingAndText(frameEndPos);\n const year = Number.parseInt(yearText, 10);\n if (Number.isInteger(year)) {\n tags.date ??= new Date(String(year)); // String so that it parses as UTC\n }\n }\n break;\n case 'USLT':\n case 'ULT':\n {\n const encoding = reader.readU8();\n reader.pos += 3; // Skip language\n reader.readId3V2Text(encoding, frameEndPos); // Short content description\n tags.lyrics ??= reader.readId3V2Text(encoding, frameEndPos);\n }\n break;\n case 'COMM':\n case 'COM':\n {\n const encoding = reader.readU8();\n reader.pos += 3; // Skip language\n reader.readId3V2Text(encoding, frameEndPos); // Short content description\n tags.comment ??= reader.readId3V2Text(encoding, frameEndPos);\n }\n break;\n case 'APIC':\n case 'PIC':\n {\n const encoding = reader.readId3V2TextEncoding();\n let mimeType;\n if (header.majorVersion === 2) {\n const imageFormat = reader.readAscii(3);\n mimeType = imageFormat === 'PNG'\n ? 'image/png'\n : imageFormat === 'JPG'\n ? 'image/jpeg'\n : 'image/*';\n }\n else {\n mimeType = reader.readId3V2Text(encoding, frameEndPos);\n }\n const pictureType = reader.readU8();\n const description = reader.readId3V2Text(encoding, frameEndPos).trimEnd(); // Trim ending spaces\n const imageDataSize = frameEndPos - reader.pos;\n if (imageDataSize >= 0) {\n const imageData = reader.readBytes(imageDataSize);\n if (!tags.images)\n tags.images = [];\n tags.images.push({\n data: imageData,\n mimeType,\n kind: pictureType === 3\n ? 'coverFront'\n : pictureType === 4\n ? 'coverBack'\n : 'unknown',\n description,\n });\n }\n }\n break;\n default:\n {\n reader.pos += frame.size;\n }\n break;\n }\n reader.pos = frameEndPos;\n }\n };\n // https://id3.org/id3v2.3.0\n class Id3V2Reader {\n constructor(header, bytes) {\n this.header = header;\n this.bytes = bytes;\n this.pos = 0;\n this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n }\n frameHeaderSize() {\n return this.header.majorVersion === 2 ? 6 : 10;\n }\n ununsynchronizeAll() {\n const newBytes = [];\n for (let i = 0; i < this.bytes.length; i++) {\n const value1 = this.bytes[i];\n newBytes.push(value1);\n if (value1 === 0xff && i !== this.bytes.length - 1) {\n const value2 = this.bytes[i];\n if (value2 === 0x00) {\n i++;\n }\n }\n }\n this.bytes = new Uint8Array(newBytes);\n this.view = new DataView(this.bytes.buffer);\n }\n ununsynchronizeRegion(start, end) {\n const newBytes = [];\n for (let i = start; i < end; i++) {\n const value1 = this.bytes[i];\n newBytes.push(value1);\n if (value1 === 0xff && i !== end - 1) {\n const value2 = this.bytes[i + 1];\n if (value2 === 0x00) {\n i++;\n }\n }\n }\n const before = this.bytes.subarray(0, start);\n const after = this.bytes.subarray(end);\n this.bytes = new Uint8Array(before.length + newBytes.length + after.length);\n this.bytes.set(before, 0);\n this.bytes.set(newBytes, before.length);\n this.bytes.set(after, before.length + newBytes.length);\n this.view = new DataView(this.bytes.buffer);\n }\n readBytes(length) {\n const slice = this.bytes.subarray(this.pos, this.pos + length);\n this.pos += length;\n return slice;\n }\n readU8() {\n const value = this.view.getUint8(this.pos);\n this.pos += 1;\n return value;\n }\n readU16() {\n const value = this.view.getUint16(this.pos, false);\n this.pos += 2;\n return value;\n }\n readU24() {\n const high = this.view.getUint16(this.pos, false);\n const low = this.view.getUint8(this.pos + 2);\n this.pos += 3;\n return high * 0x100 + low;\n }\n readU32() {\n const value = this.view.getUint32(this.pos, false);\n this.pos += 4;\n return value;\n }\n readAscii(length) {\n let str = '';\n for (let i = 0; i < length; i++) {\n str += String.fromCharCode(this.view.getUint8(this.pos + i));\n }\n this.pos += length;\n return str;\n }\n readId3V2Frame() {\n if (this.header.majorVersion === 2) {\n const id = this.readAscii(3);\n if (id === '\\x00\\x00\\x00') {\n return null;\n }\n const size = this.readU24();\n return { id, size, flags: 0 };\n }\n else {\n const id = this.readAscii(4);\n if (id === '\\x00\\x00\\x00\\x00') {\n // We've landed in the padding section\n return null;\n }\n const sizeRaw = this.readU32();\n let size = this.header.majorVersion === 4\n ? decodeSynchsafe(sizeRaw)\n : sizeRaw;\n const flags = this.readU16();\n const headerEndPos = this.pos;\n // Some files may have incorrectly synchsafed/unsynchsafed sizes. To validate which interpretation is valid,\n // we validate a size by skipping ahead and seeing if we land at a valid frame header (or at the end of the\n // tag.\n const isSizeValid = (size) => {\n const nextPos = this.pos + size;\n if (nextPos > this.bytes.length) {\n return false;\n }\n if (nextPos <= this.bytes.length - this.frameHeaderSize()) {\n this.pos += size;\n const nextId = this.readAscii(4);\n if (nextId !== '\\x00\\x00\\x00\\x00' && !/[0-9A-Z]{4}/.test(nextId)) {\n return false;\n }\n }\n return true;\n };\n if (!isSizeValid(size)) {\n // Flip the synchsafing, and try if this one makes more sense\n const otherSize = this.header.majorVersion === 4\n ? sizeRaw\n : decodeSynchsafe(sizeRaw);\n if (isSizeValid(otherSize)) {\n size = otherSize;\n }\n }\n this.pos = headerEndPos;\n return { id, size, flags };\n }\n }\n readId3V2TextEncoding() {\n const number = this.readU8();\n if (number > 3) {\n throw new Error(`Unsupported text encoding: ${number}`);\n }\n return number;\n }\n readId3V2Text(encoding, until) {\n const startPos = this.pos;\n const data = this.readBytes(until - this.pos);\n switch (encoding) {\n case Id3V2TextEncoding.ISO_8859_1: {\n let str = '';\n for (let i = 0; i < data.length; i++) {\n const value = data[i];\n if (value === 0) {\n this.pos = startPos + i + 1;\n break;\n }\n str += String.fromCharCode(value);\n }\n return str;\n }\n case Id3V2TextEncoding.UTF_16_WITH_BOM: {\n if (data[0] === 0xff && data[1] === 0xfe) {\n const decoder = new TextDecoder('utf-16le');\n const endIndex = coalesceIndex(data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), data.length);\n this.pos = startPos + Math.min(endIndex + 2, data.length);\n return decoder.decode(data.subarray(2, endIndex));\n }\n else if (data[0] === 0xfe && data[1] === 0xff) {\n const decoder = new TextDecoder('utf-16be');\n const endIndex = coalesceIndex(data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), data.length);\n this.pos = startPos + Math.min(endIndex + 2, data.length);\n return decoder.decode(data.subarray(2, endIndex));\n }\n else {\n // Treat it like UTF-8, some files do this\n const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length);\n this.pos = startPos + Math.min(endIndex + 1, data.length);\n return textDecoder.decode(data.subarray(0, endIndex));\n }\n }\n case Id3V2TextEncoding.UTF_16_BE_NO_BOM: {\n const decoder = new TextDecoder('utf-16be');\n const endIndex = coalesceIndex(data.findIndex((x, i) => x === 0 && data[i + 1] === 0 && i % 2 === 0), data.length);\n this.pos = startPos + Math.min(endIndex + 2, data.length);\n return decoder.decode(data.subarray(0, endIndex));\n }\n case Id3V2TextEncoding.UTF_8: {\n const endIndex = coalesceIndex(data.findIndex(x => x === 0), data.length);\n this.pos = startPos + Math.min(endIndex + 1, data.length);\n return textDecoder.decode(data.subarray(0, endIndex));\n }\n }\n }\n readId3V2EncodingAndText(until) {\n if (this.pos >= until) {\n return '';\n }\n const encoding = this.readId3V2TextEncoding();\n return this.readId3V2Text(encoding, until);\n }\n }\n\n /**\n * An H.264 sync sample that is not an IDR frame cannot open a WebCodecs decode\n * session: Chrome rejects the chunk with \"An EncodedVideoChunk was marked as\n * type key but wasn't a key frame\" and emits no frames. Adding a recovery-point\n * SEI to the access unit declares it a valid entry point, which is enough for the\n * decoder to accept it and decode forward from there.\n *\n * The decoder demands an entry point for exactly one chunk, the first after\n * configure() or flush(), and putting the SEI on any other chunk misdescribes\n * the stream. KeyPacketRequirement is what tracks which chunk that is.\n */\n /**\n * SEI NAL (unit type 6) carrying one recovery_point payload: recovery_frame_cnt\n * 0, so this access unit is itself the recovery point, and exact_match_flag 1,\n * so frames from here reconstruct exactly. Closed by RBSP trailing bits.\n */\n const RECOVERY_POINT_SEI_NAL = Uint8Array.of(0x06, 0x06, 0x01, 0xc2, 0x80);\n /** Low five bits of a NAL's first byte, the AVC unit type. */\n const NAL_TYPE_MASK = 0x1f;\n /**\n * An access unit delimiter has to be the first NAL of its access unit, so it is\n * the one unit the SEI goes behind rather than ahead of.\n */\n const ACCESS_UNIT_DELIMITER = 9;\n /** Coded slice of an IDR picture. */\n const IDR_SLICE = 5;\n /**\n * Scalable and multiview extension NALs. A decoder configured from an avcC\n * record decodes the base layer and reads none of them, and Chromium's key-frame\n * detector reads the opening chunk as something other than a key frame when they\n * are in it.\n */\n const EXTENSION_NAL_FIRST = 20;\n const EXTENSION_NAL_LAST = 31;\n /** Byte of the avcC record holding lengthSizeMinusOne in its low two bits. */\n const LENGTH_SIZE_MINUS_ONE_OFFSET = 4;\n const LENGTH_SIZE_MINUS_ONE_MASK = 0b11;\n /**\n * Prefix width per lengthSizeMinusOne encoding. ISO/IEC 14496-15 reserves the\n * encoding 2, so a record carrying it names no width at all.\n */\n const PREFIX_WIDTHS = [1, 2, undefined, 4];\n /**\n * Width of the AVCC length prefix this track frames every NAL with, read from\n * its avcC record, or null when the description names no usable width. Files\n * carry 1, 2, and 4, so nothing downstream may assume one of them.\n */\n function nalPrefixWidth(description) {\n if (!description)\n return null;\n const record = avccRecord(description);\n if (record.length <= LENGTH_SIZE_MINUS_ONE_OFFSET)\n return null;\n const encoding = record[LENGTH_SIZE_MINUS_ONE_OFFSET] & LENGTH_SIZE_MINUS_ONE_MASK;\n return PREFIX_WIDTHS[encoding] ?? null;\n }\n function avccRecord(description) {\n if (ArrayBuffer.isView(description)) {\n return new Uint8Array(description.buffer, description.byteOffset, description.byteLength);\n }\n return new Uint8Array(description);\n }\n /** Big-endian NAL length over exactly `width` bytes, the AVCC framing. */\n function writePrefix(target, width, length) {\n for (let i = 0; i < width; i++) {\n target[i] = (length >>> ((width - 1 - i) * 8)) & 0xff;\n }\n }\n /**\n * The packet's NAL units in order, or null when the prefix widths do not walk\n * to exactly the end of the packet. That reading is the wrong one, and dropping\n * or reordering anything on it would throw away picture data.\n */\n function nalUnits(packetBytes, prefixWidth) {\n const units = [];\n let offset = 0;\n while (offset < packetBytes.length) {\n if (offset + prefixWidth > packetBytes.length)\n return null;\n let length = 0;\n for (let i = 0; i < prefixWidth; i++)\n length = length * 256 + packetBytes[offset + i];\n offset += prefixWidth;\n if (length === 0 || offset + length > packetBytes.length)\n return null;\n units.push(packetBytes.subarray(offset, offset + length));\n offset += length;\n }\n return units;\n }\n function nalType(unit) {\n return unit[0] & NAL_TYPE_MASK;\n }\n /** The units re-framed at `prefixWidth`, the only framing this track's decoder\n * can parse the packet under. */\n function frameNalUnits(units, prefixWidth) {\n let total = 0;\n for (const unit of units)\n total += prefixWidth + unit.length;\n const framed = new Uint8Array(total);\n let offset = 0;\n for (const unit of units) {\n writePrefix(framed.subarray(offset), prefixWidth, unit.length);\n offset += prefixWidth;\n framed.set(unit, offset);\n offset += unit.length;\n }\n return framed;\n }\n /**\n * Whether this access unit carries an IDR slice.\n *\n * A container's sync table names sync samples, and an H.264 sync sample is\n * usually an ordinary I picture rather than an IDR. The two differ in what a\n * decoder may assume about the pictures that follow: an IDR empties the\n * reference list, so nothing after it can name a picture from before it, while\n * an I picture leaves the list standing and the next reference frame is free to\n * name pictures the entry point never decoded. Only the IDR is an entry point\n * the bitstream cannot invalidate.\n *\n * Unreadable framing reads as not-IDR: the caller's fallback is to enter\n * further back, which costs a walk and is never wrong.\n */\n function isIdrAccessUnit(packetBytes, prefixWidth) {\n const units = nalUnits(packetBytes, prefixWidth);\n if (!units)\n return false;\n return units.some((unit) => nalType(unit) === IDR_SLICE);\n }\n /**\n * The access unit as the chunk that opens a decode, carrying the recovery-point\n * SEI that makes it a legal entry point.\n *\n * Where the SEI goes is the whole of it: an access unit delimiter, when the\n * packet has one, is required to be the access unit's first NAL, so the SEI goes\n * behind it. Ahead of it the access unit is malformed, and a decoder reading the\n * opening chunk to decide whether it is really a key frame is entitled to say it\n * is not.\n */\n function openingKeyPacket(packetBytes, prefixWidth) {\n const units = nalUnits(packetBytes, prefixWidth);\n if (!units)\n return seiAhead(packetBytes, prefixWidth);\n const kept = units.filter((unit) => {\n const type = nalType(unit);\n return type < EXTENSION_NAL_FIRST || type > EXTENSION_NAL_LAST;\n });\n const seiAt = kept.length > 0 && nalType(kept[0]) === ACCESS_UNIT_DELIMITER ? 1 : 0;\n kept.splice(seiAt, 0, RECOVERY_POINT_SEI_NAL);\n return frameNalUnits(kept, prefixWidth);\n }\n /** `packetBytes` behind a recovery-point SEI framed at the file's own prefix\n * width, since a prefix of any other width makes the whole packet unparseable. */\n function seiAhead(packetBytes, prefixWidth) {\n const seiBytes = prefixWidth + RECOVERY_POINT_SEI_NAL.length;\n const framed = new Uint8Array(seiBytes + packetBytes.length);\n writePrefix(framed, prefixWidth, RECOVERY_POINT_SEI_NAL.length);\n framed.set(RECOVERY_POINT_SEI_NAL, prefixWidth);\n framed.set(packetBytes, seiBytes);\n return framed;\n }\n /**\n * WebCodecs' standing demand for a key frame, which configure() and flush()\n * both arm and the next submitted chunk clears.\n */\n class KeyPacketRequirement {\n prefixWidth;\n pending = true;\n constructor(prefixWidth) {\n this.prefixWidth = prefixWidth;\n }\n /** Whether the demand still stands, so the chunk about to satisfy it can\n * also be typed as the key chunk the decoder is waiting for. */\n get armed() {\n return this.pending;\n }\n /** Call after every configure() and flush(). */\n rearm() {\n this.pending = true;\n }\n /**\n * Bytes to submit for the next packet in decode order, carrying the\n * recovery-point SEI while the demand stands. The container reports sync\n * samples, never IDR frames, so the SEI goes on without inspecting the\n * packet; ahead of a true IDR it is a NAL the decoder ignores.\n */\n satisfy(packetBytes) {\n if (!this.pending)\n return packetBytes;\n this.pending = false;\n return openingKeyPacket(packetBytes, this.prefixWidth);\n }\n }\n\n /**\n * Draws an image upright into a destination box, mirroring the transform\n * mediabunny's own VideoSample.draw applies so a frame decoded by the runtime\n * and a frame decoded by a mediabunny sink land the same pixels.\n *\n * dWidth and dHeight name the destination AFTER the turn, which is what the\n * track's display size already is. So on a quarter turn the source is drawn at\n * swapped extents inside the turned frame, and the scale below undoes the\n * aspect that swap introduces.\n */\n function drawRotated(context, image, rotation, dx, dy, dWidth, dHeight) {\n if (rotation === 0) {\n context.drawImage(image, dx, dy, dWidth, dHeight);\n return;\n }\n context.save();\n context.translate(dx + dWidth / 2, dy + dHeight / 2);\n context.rotate((rotation * Math.PI) / 180);\n const aspectRatioChange = rotation % 180 === 0 ? 1 : dWidth / dHeight;\n context.scale(1 / aspectRatioChange, aspectRatioChange);\n context.drawImage(image, -dWidth / 2, -dHeight / 2, dWidth, dHeight);\n context.restore();\n }\n /**\n * The 2x2 rotation, row-major, that turns a destination texture coordinate\n * about (0.5, 0.5) into the source coordinate to sample. Each entry is\n * [cos -sin; sin cos] evaluated at MINUS the rotation, written out because a\n * quarter turn is exact and trig at these angles is not.\n *\n * The negation is the whole point: sampling turns opposite to the picture, so\n * showing a frame turned clockwise by R means reading it counter-clockwise by\n * R. The unit square is closed under quarter turns about its centre, so the\n * whole source still maps onto the whole destination and the shader's existing\n * stretch-to-fill absorbs the aspect change for free.\n */\n function uvRotationMatrix(rotation) {\n return UV_ROTATIONS[rotation];\n }\n const UV_ROTATIONS = {\n 0: [1, 0, 0, 1],\n 90: [0, 1, -1, 0],\n 180: [-1, 0, 0, -1],\n 270: [0, -1, 1, 0],\n };\n\n const asSec = (n) => n;\n var SourceKind;\n (function (SourceKind) {\n SourceKind[\"Url\"] = \"url\";\n SourceKind[\"Blob\"] = \"blob\";\n SourceKind[\"Stream\"] = \"stream\";\n })(SourceKind || (SourceKind = {}));\n /**\n * Coarse-grained engine status. Updates rarely (load, play, pause, end,\n * error). Sits on its own emit channel so subscribers do not wake up on\n * 60Hz time ticks.\n */\n var PlaybackStatus;\n (function (PlaybackStatus) {\n PlaybackStatus[\"Idle\"] = \"IDLE\";\n PlaybackStatus[\"Loading\"] = \"LOADING\";\n PlaybackStatus[\"Ready\"] = \"READY\";\n PlaybackStatus[\"Playing\"] = \"PLAYING\";\n PlaybackStatus[\"Paused\"] = \"PAUSED\";\n PlaybackStatus[\"Seeking\"] = \"SEEKING\";\n PlaybackStatus[\"Ended\"] = \"ENDED\";\n PlaybackStatus[\"Errored\"] = \"ERRORED\";\n })(PlaybackStatus || (PlaybackStatus = {}));\n var VideoEngineErrorCode;\n (function (VideoEngineErrorCode) {\n VideoEngineErrorCode[\"DecodeUnsupported\"] = \"DECODE_UNSUPPORTED\";\n VideoEngineErrorCode[\"SourceUnreadable\"] = \"SOURCE_UNREADABLE\";\n /**\n * The demuxer refused the file outright: its container is not one this build\n * reads, so no track was ever listed and no decoder was ever asked.\n */\n VideoEngineErrorCode[\"ContainerUnreadable\"] = \"CONTAINER_UNREADABLE\";\n /**\n * The container opened and the demuxer parsed no track at all out of it. The\n * file's streams are in formats it does not carry, so their video cannot be\n * reached even though it is there.\n */\n VideoEngineErrorCode[\"VideoTrackUnreadable\"] = \"VIDEO_TRACK_UNREADABLE\";\n /**\n * The container opened, its tracks listed, and none of them is video. This\n * is the only case where the file itself is what lacks video.\n */\n VideoEngineErrorCode[\"NoVideoTrack\"] = \"NO_VIDEO_TRACK\";\n VideoEngineErrorCode[\"BackendCrashed\"] = \"BACKEND_CRASHED\";\n VideoEngineErrorCode[\"Aborted\"] = \"ABORTED\";\n /** A canvas was offered to an engine loaded in \"frames\" presentation mode,\n * where the host owns the only canvas. */\n VideoEngineErrorCode[\"PresentationMismatch\"] = \"PRESENTATION_MISMATCH\";\n /**\n * The decoder cannot decode this source at all: it refused to configure, it\n * errored, or it acknowledged decode requests and never produced a frame.\n * Distinct from BackendCrashed, which a rebuild can recover; this one\n * survives every rebuild, so the runtime stops rebuilding and says so. The\n * usual cause is outside the page: another tab holding every hardware\n * decoder session the machine has.\n */\n VideoEngineErrorCode[\"DecoderStalled\"] = \"DECODER_STALLED\";\n /** A playback rate outside the forward range the engine supports. */\n VideoEngineErrorCode[\"RateUnsupported\"] = \"RATE_UNSUPPORTED\";\n })(VideoEngineErrorCode || (VideoEngineErrorCode = {}));\n /**\n * Thrown by createScrubCursor / VideoEngine.load when decode is unsupported\n * or the source is unreadable. Branch on `error.code` (VideoEngineErrorCode)\n * to differentiate decode failures from network failures.\n */\n class VideoEngineError extends Error {\n code;\n cause;\n constructor(code, message, cause) {\n super(message);\n this.code = code;\n this.cause = cause;\n }\n }\n /** Shared by the facade and the core so a host gets the same refusal wherever\n * its canvas is caught. */\n function canvasBindingRefused() {\n return new VideoEngineError(VideoEngineErrorCode.PresentationMismatch, 'presentation \"frames\" leaves the canvas to the host: this engine paints nothing and hands out VideoFrames instead');\n }\n /**\n * Validates a requested playback rate and returns it. The facade and the core\n * both call it, so a rate is refused at whichever boundary it arrives at and\n * with the same message: the facade posts fire-and-forget, so a refusal raised\n * only in the worker would reach nobody.\n *\n * Reverse is rejected, not clamped: backwards playback is a different decode\n * problem, and a -1 quietly serviced as +0.25 plays the wrong direction while\n * reporting success.\n */\n function resolvePlaybackRate(rate) {\n if (!Number.isFinite(rate) ||\n rate < PLAYBACK_RATE.MIN ||\n rate > PLAYBACK_RATE.MAX) {\n throw new VideoEngineError(VideoEngineErrorCode.RateUnsupported, `playback rate ${rate} is outside the supported forward range ${PLAYBACK_RATE.MIN}-${PLAYBACK_RATE.MAX}; reverse playback is not supported`);\n }\n return rate;\n }\n\n /**\n * Half a microsecond: a submitted timestamp survives the round trip through the\n * decoder as whole microseconds, so anything closer than this is the same\n * packet and anything further is a decoder counting on a clock of its own.\n */\n const TIMING_MATCH_S = 5e-7;\n function anchorKey(timestampS) {\n return Math.round(timestampS * MICROSECONDS_PER_SECOND);\n }\n /**\n * Verification rejects every sync sample that is not a true IDR, which collapses\n * a container's sync table down to the handful of real IDRs and drags the anchor\n * back by that much. Off, the honest table stands and a recovery-point SEI makes\n * the non-IDR anchor legal to decode from.\n *\n * The anchor is read with its bytes: it is both the packet the walk starts from\n * and the first chunk submitted, and a reader refuses a metadata-only packet as\n * the start of a full-data walk.\n */\n const ANCHOR_PROBE = { verifyKeyPackets: false };\n /**\n * The fallback anchor read: the last IDR at or before the target, found by\n * reading bitstreams rather than trusting the sync table. Reached only once an\n * optimistic anchor has actually failed, since it drags the entry point back to\n * the previous IDR and every picture between there and the target is decoded\n * and thrown away.\n */\n const IDR_PROBE = { verifyKeyPackets: true };\n /** The packet that closes the anchor span is read for its timestamp alone. */\n const SPAN_END_PROBE = {\n metadataOnly: true,\n verifyKeyPackets: false,\n };\n /**\n * Chunks kept outstanding in the decoder. H.264 lets a decoder hold up to\n * sixteen pictures before it has to emit one, and a stream that uses the whole\n * buffer emits nothing at all until the seventeenth chunk arrives, so a\n * shallower pipeline waits forever on the frame it just asked for.\n */\n const DECODER_PIPELINE_CHUNKS = 17;\n /** Decoded frames held before the session stops feeding the decoder. These are\n * full-resolution and the session's own to retain, unlike the pipeline above,\n * which is the decoder's. */\n const READY_FRAMES = 4;\n /** Absorbs float error when a requested bound lands on a frame's timestamp. */\n const BOUND_EPSILON_S = 1e-6;\n /**\n * Ceiling on waiting for ONE decoder output while chunks are in flight, which a\n * healthy decoder answers in single-digit milliseconds. It is not the ceiling on\n * a walk (that is the caller's, and a legitimate GOP walk runs far longer); it\n * exists so a decoder that goes silent surfaces as a failure instead of parking\n * the surface for the length of the walk ceiling. It only ever runs while output\n * is owed, so an idle session cannot trip it however long it sits.\n */\n const OUTPUT_TIMEOUT_MS = 5_000;\n const MICROSECONDS_PER_SECOND = 1e6;\n /** The recovery-point SEI is an H.264 NAL behind an AVCC length prefix, so the\n * session opens only codecs framed that way. */\n const AVCC_H264_CODEC = /^avc1\\./;\n /**\n * Width the session must frame its SEI at to drive a track with this config, or\n * null when it cannot drive the track at all. The SEI it prepends to a non-IDR\n * anchor is an H.264 NAL behind an AVCC length prefix, and the width of that\n * prefix is only knowable from the avcC record in `description`, so a config\n * without one is unusable however well-formed the rest of it looks.\n */\n function drivablePrefixWidth(config) {\n if (!AVCC_H264_CODEC.test(config.codec))\n return null;\n return nalPrefixWidth(config.description);\n }\n /**\n * Whether the session can drive a track with this decoder config. The gate that\n * routes a source here and the constructor that refuses everything else read the\n * same predicate.\n */\n function sessionDrivable(config) {\n return drivablePrefixWidth(config) !== null;\n }\n /**\n * One long-lived VideoDecoder, held across seeks.\n *\n * A decoder can only ever move forward, and every flush re-arms its demand for a\n * key frame, so a runtime that flushes per retrieval pays a walk back to the\n * nearest true IDR on every seek. This session flushes only at end of stream:\n * a seek at-or-ahead of the read head, inside the current anchor span, decodes\n * only the packets between the two, and a backward jump or a jump past the span\n * re-anchors. Positioning is the only thing that costs a walk.\n *\n * One decoder serving several readers is the whole point, and it is also the\n * hazard: a scrub's frameAt and playback's framesFrom drive the same decoder,\n * the same packet iterator, and the same queue of decoded frames. So every\n * retrieval takes the decoder exclusively for the length of one frame, and a\n * reader displaced by someone else's re-anchor is told, rather than left to read\n * the emptied queue as end of stream and stop for good.\n *\n * Frames are handed out as VideoSampleLike and their close is the caller's, the\n * same obligation the zero-copy sample path carries.\n */\n class DecodeSession {\n options;\n keyPacket;\n prefixWidth;\n decoder = null;\n iterator = null;\n peeked = null;\n /** Timestamp of the key packet after the anchor: the end of the span a\n * forward seek can reach without re-anchoring. */\n spanEndS = Infinity;\n exhausted = true;\n /** Timings the decoder still owes pictures for, in presentation order. */\n pending = [];\n decoded = [];\n wake = null;\n /**\n * Latched once the decoder is judged unable to decode this source at all.\n * Every later retrieval refuses with it instead of re-anchoring onto the\n * same failure, which is what turns a silent forever-retry into one honest\n * error the caller can show.\n */\n stalledError = null;\n /**\n * The current entry point's failure, held only until the walk that is owed a\n * picture decides what it means. It condemns the anchor, never the session:\n * a source whose sync table names one bad entry point still decodes from\n * every other one, and latching the first failure is what turned a seek into\n * a poisoned GOP into a player that never painted again.\n */\n anchorError = null;\n /** The entry currently feeding the decoder, or null before the first one. */\n entry = null;\n /** Entry points a decoder error has ruled out, by whole-microsecond\n * timestamp. Only ever grows: a bitstream does not change. */\n rejectedAnchors = new Set();\n /** Where the live retrieval wants to be, so a re-anchor after a failed entry\n * aims at the position the caller asked for rather than the anchor's. */\n entryTargetS = 0;\n /** The last picture handed out since the caller last chose a position, so a\n * re-anchor mid-walk resumes rather than replays. */\n servedS = -Infinity;\n closed = false;\n anchors = 0;\n framesDecodedCount = 0;\n /** Where the decoder sits, kept in step with what it hands out. Read\n * synchronously by callers deciding what to ask for, so it cannot await. */\n reachable = -Infinity;\n /** Serializes retrievals; see the class note on shared-decoder exclusivity. */\n tail = Promise.resolve();\n /** Bumped by every re-anchor, so a reader can tell whether the decoder is\n * still where it left it or has been moved under it by another reader. */\n epoch = 0;\n outputTimeoutMs;\n rotation;\n constructor(options) {\n this.options = options;\n this.outputTimeoutMs = options.outputTimeoutMs ?? OUTPUT_TIMEOUT_MS;\n this.rotation = options.rotation;\n const prefixWidth = drivablePrefixWidth(options.config);\n if (prefixWidth === null) {\n throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, `DecodeSession: ${options.config.codec} is not AVCC-framed H.264`);\n }\n this.prefixWidth = prefixWidth;\n this.keyPacket = new KeyPacketRequirement(prefixWidth);\n }\n /** Times the session has configured the decoder onto an anchor. */\n get anchorCount() {\n return this.anchors;\n }\n /** Every frame the decoder has ever output, including walk pre-roll that is\n * discarded before the target: the honest denominator for what a paint\n * actually cost. Monotonic for the session's lifetime. */\n get framesDecoded() {\n return this.framesDecodedCount;\n }\n /** See SessionFrameSource. The oldest frame decoded but not yet handed out\n * when there is one, since those are still servable, else the last position\n * handed out. */\n get reachableFromS() {\n const queued = this.decoded[0];\n if (queued)\n return queued.timestampS;\n return this.reachable;\n }\n /** The frame at or before `targetS`, or null when nothing precedes it. */\n async frameAt(targetS) {\n const landed = await this.exclusive(() => this.land(targetS));\n return landed ? videoFrameSample(landed, this.rotation) : null;\n }\n /**\n * Frames from `startS` onward, beginning at the frame at or before it. The\n * walk from the anchor up to `startS` is decoded but not yielded, since the\n * caller asked to start there.\n *\n * Playback rides this for the length of a session, so it outlives any number\n * of scrubs. When one of those moved the decoder, the walk resumes from the\n * last frame it handed out rather than ending.\n */\n async *framesFrom(startS) {\n let handedOutS = null;\n let seenEpoch = this.epoch;\n for (;;) {\n const picture = await this.exclusive(() => {\n if (handedOutS === null)\n return this.land(startS);\n if (seenEpoch !== this.epoch)\n return this.resumeAfter(handedOutS);\n return this.pull(Infinity);\n });\n if (!picture)\n return;\n handedOutS = picture.timestampS;\n seenEpoch = this.epoch;\n yield videoFrameSample(picture, this.rotation);\n }\n }\n /**\n * Every frame decoded while covering `[startS, endS]`, including the walk\n * from the anchor. Those prefix frames cost the same decode either way, so\n * yielding them hands the caller a span of frames for the price of the one\n * it asked for.\n *\n * This serves speculative sweeps, so a re-anchor by anyone else ends it:\n * chasing the span back would spend a foreground decode's worth of decoder\n * time on frames nobody is waiting for.\n */\n async *framesCovering(startS, endS) {\n let positioned = false;\n let seenEpoch = 0;\n for (;;) {\n const picture = await this.exclusive(async () => {\n if (!positioned) {\n await this.positionFor(startS);\n positioned = true;\n }\n else if (seenEpoch !== this.epoch) {\n return null;\n }\n return this.pull(endS);\n });\n if (!picture)\n return;\n seenEpoch = this.epoch;\n yield videoFrameSample(picture, this.rotation);\n }\n }\n /**\n * Runs `op` with the decoder to itself. Retrievals interleave at frame\n * granularity, which bounds how long a foreground seek waits behind a\n * background sweep at one frame, while keeping any one walk's view of the\n * packet iterator, the in-flight count, and the decoded queue consistent.\n */\n exclusive(op) {\n const run = this.tail.then(op, op);\n this.tail = run.then(() => undefined, () => undefined);\n return run;\n }\n /**\n * The first frame after `afterS` once another reader has moved the decoder.\n * Re-positions and discards the frames already handed out, so a walk picks\n * up exactly where it left off however far away the decoder was taken.\n */\n async resumeAfter(afterS) {\n await this.positionFor(afterS);\n for (;;) {\n const picture = await this.pull(Infinity);\n if (!picture)\n return null;\n if (picture.timestampS > afterS + BOUND_EPSILON_S)\n return picture;\n picture.frame.close();\n }\n }\n close() {\n if (this.closed)\n return;\n this.closed = true;\n void this.iterator?.return();\n this.iterator = null;\n this.peeked = null;\n this.exhausted = true;\n this.discardDecoded();\n this.decoder?.close();\n this.decoder = null;\n this.wake?.();\n }\n /** Decodes through `targetS` and returns the last frame at or before it,\n * closing the frames walked past. */\n async land(targetS) {\n await this.positionFor(targetS);\n let landed = null;\n for (;;) {\n const picture = await this.pull(targetS);\n if (!picture)\n return landed;\n landed?.frame.close();\n landed = picture;\n }\n }\n async positionFor(targetS) {\n if (this.closed)\n return;\n if (this.stalledError)\n throw this.stalledError;\n this.entryTargetS = targetS;\n if (this.decoder && !this.exhausted) {\n const headS = await this.readHeadS();\n if (headS !== null && targetS >= headS && targetS < this.spanEndS)\n return;\n }\n this.servedS = -Infinity;\n await this.anchorAt(targetS);\n }\n /** The earliest position still reachable without re-anchoring: the oldest\n * frame decoded but not yet handed out, or the next packet in line when\n * there is none. Decode runs ahead of the read, so the packet alone would\n * put the head past frames the session can still serve. */\n async readHeadS() {\n const queued = this.decoded[0];\n if (queued)\n return queued.timestampS;\n const packet = await this.peek();\n return packet ? packet.timestamp : null;\n }\n async anchorAt(targetS) {\n const entry = await this.resolveEntry(targetS);\n void this.iterator?.return();\n this.iterator = null;\n this.peeked = null;\n this.exhausted = true;\n this.entry = null;\n this.anchorError = null;\n this.quiesce();\n if (!entry || this.closed)\n return;\n this.spanEndS = await this.spanEndAfter(entry.anchor, targetS);\n this.configureDecoder();\n this.iterator = this.options.packets.packets(entry.anchor);\n this.entry = entry;\n this.exhausted = false;\n this.anchors++;\n this.epoch++;\n this.reachable = entry.anchor.timestamp;\n }\n /**\n * The packet to open a decode of `targetS` from: the container's own sync\n * sample, unless a decoder has already proved that one is not a legal entry\n * point, in which case the last verified IDR at or before the target.\n *\n * A sync sample that is itself an IDR is already the furthest-back entry\n * worth reaching for, so a failure there is a failure of the source.\n */\n async resolveEntry(targetS) {\n const sync = await this.options.packets.getKeyPacket(targetS, ANCHOR_PROBE);\n if (sync) {\n const key = anchorKey(sync.timestamp);\n if (!this.rejectedAnchors.has(key)) {\n return {\n anchor: sync,\n key,\n hasFallback: !isIdrAccessUnit(sync.data, this.prefixWidth),\n };\n }\n }\n const idr = await this.options.packets.getKeyPacket(targetS, IDR_PROBE);\n if (!idr)\n return null;\n return { anchor: idr, key: anchorKey(idr.timestamp), hasFallback: false };\n }\n /**\n * Where the span a forward seek can reach without re-anchoring ends: the\n * first sync sample past the target that is still worth anchoring at.\n *\n * Skipping the rejected ones is what keeps the pre-roll paid for once. Ending\n * the span at an entry point already known not to decode would send the very\n * next seek into that GOP back to the same rejected anchor and back through\n * the same walk to recover from it.\n */\n async spanEndAfter(anchor, targetS) {\n let key = anchor;\n for (;;) {\n const next = await this.options.packets.getNextKeyPacket(key, SPAN_END_PROBE);\n if (!next)\n return Infinity;\n if (next.timestamp > targetS + BOUND_EPSILON_S &&\n !this.rejectedAnchors.has(anchorKey(next.timestamp))) {\n return next.timestamp;\n }\n key = next;\n }\n }\n /** Drops the work in flight and the frames it produced, in one turn, so no\n * output from the old anchor can land against the new one. */\n quiesce() {\n this.decoder?.reset();\n this.pending.length = 0;\n this.discardDecoded();\n }\n configureDecoder() {\n try {\n if (!this.decoder) {\n // Named so the callback can check that the decoder reporting the\n // failure is still the one driving the session: a decoder dropped for\n // erroring may report again afterwards, and that report must not\n // condemn the anchor built to replace it.\n const built = this.options.createDecoder({\n output: (frame) => this.receive(frame),\n error: (error) => this.fail(built, error),\n });\n this.decoder = built;\n }\n // Without prompt per-frame emission a session that never flushes has\n // no way to get its frames out, so the whole flush-free design rests\n // here.\n this.decoder.configure({\n ...this.options.config,\n optimizeForLatency: true,\n });\n }\n catch (cause) {\n throw this.stall(\"the decoder refused to configure\", cause);\n }\n this.keyPacket.rearm();\n }\n /** One frame at or before `boundS`, or null once the bound is passed. A frame\n * decoded past the bound stays queued for the next read rather than being\n * handed out or thrown away. */\n async pull(boundS) {\n // Every hand-out moves the decoder forward past that frame.\n for (;;) {\n if (this.closed)\n return null;\n if (this.stalledError)\n throw this.stalledError;\n const failed = this.anchorError;\n if (failed) {\n await this.enterFurtherBack(failed);\n continue;\n }\n await this.fill();\n const ready = this.decoded[0];\n if (ready) {\n // Re-entering further back re-decodes ground the walk already\n // covered, and the caller has seen those pictures.\n if (ready.timestampS <= this.servedS + BOUND_EPSILON_S) {\n this.decoded.shift();\n ready.frame.close();\n continue;\n }\n if (ready.timestampS > boundS + BOUND_EPSILON_S)\n return null;\n this.decoded.shift();\n this.reachable = ready.timestampS;\n this.servedS = ready.timestampS;\n return ready;\n }\n // The pipeline holds requests a failed decoder will never answer, so\n // waiting on them is waiting out the ceiling for nothing.\n if (this.anchorError)\n continue;\n if (this.pending.length === 0)\n return null;\n // Nothing is left to submit at end of stream, so a flush is the only\n // way to get the last frames out.\n if (this.exhausted)\n await this.drain();\n else\n await this.awaitOutput();\n }\n }\n /**\n * Re-opens the decode from the last entry point ahead of the failed one,\n * which for an open GOP means the previous IDR plus a walk to the target.\n *\n * The failed anchor is struck off for the life of the session, so the cost is\n * paid once per bad entry point rather than once per seek into it. When there\n * is nothing further back to enter from, the failure is the source's and it\n * latches here.\n */\n async enterFurtherBack(failure) {\n const entry = this.entry;\n if (!entry?.hasFallback)\n throw this.latch(failure);\n this.rejectedAnchors.add(entry.key);\n // An errored WebCodecs decoder is already closed and cannot be\n // reconfigured, so the replacement anchor needs a replacement decoder.\n this.decoder = null;\n await this.anchorAt(Math.max(this.entryTargetS, this.servedS));\n if (!this.entry)\n throw this.latch(failure);\n }\n /**\n * Keeps the decoder's pipeline fed, regardless of which frame is being read.\n * A decoder emits nothing until it holds enough pictures, so submitting only\n * as far as the requested frame leaves the session waiting on output it is\n * refusing to make possible. Feeding stops once the frames already decoded\n * pile up, which is what bounds the memory the session holds.\n */\n async fill() {\n while (this.decoder &&\n // The error arrives between two of the reads below, and a decoder that\n // has reported one is closed: every further chunk is refused, and being\n // refused is what would condemn the whole session for a failure the\n // anchor already owns.\n !this.anchorError &&\n this.pending.length < DECODER_PIPELINE_CHUNKS &&\n this.decoded.length < READY_FRAMES) {\n const packet = await this.peek();\n if (!packet)\n return;\n if (this.closed || this.anchorError)\n return;\n this.peeked = null;\n this.awaitTiming({\n timestampS: packet.timestamp,\n durationS: packet.duration,\n });\n // The chunk that opens the decode is the only one submitted as a key\n // chunk: a later sync sample is a recovery point, not an IDR, and the\n // decoder verifies the claim. It is the same chunk that carries the\n // SEI, so one latch answers both.\n const opensDecode = this.keyPacket.armed;\n const data = this.keyPacket.satisfy(packet.data);\n try {\n this.decoder.decode({\n type: opensDecode ? \"key\" : \"delta\",\n timestamp: Math.round(packet.timestamp * MICROSECONDS_PER_SECOND),\n duration: Math.round(packet.duration * MICROSECONDS_PER_SECOND),\n data,\n });\n }\n catch (cause) {\n throw this.stall(\"the decoder refused a chunk\", cause);\n }\n }\n }\n async peek() {\n if (this.peeked)\n return this.peeked;\n if (!this.iterator || this.exhausted)\n return null;\n const result = await this.iterator.next();\n if (result.done) {\n this.exhausted = true;\n return null;\n }\n this.peeked = result.value;\n return this.peeked;\n }\n async drain() {\n const decoder = this.decoder;\n if (!decoder)\n return;\n await decoder.flush();\n this.pending.length = 0;\n this.keyPacket.rearm();\n }\n awaitOutput() {\n return new Promise((resolve, reject) => {\n const owed = this.pending.length;\n const timer = setTimeout(() => {\n this.wake = null;\n // Drop the work this was waiting on before handing the failure\n // out. The chunks it timed out on stay counted as in flight\n // otherwise, and the next retrieval inherits a decoder that owes\n // frames it will never produce, so it waits out the same timeout\n // again instead of re-anchoring onto a clean one.\n this.quiesce();\n this.exhausted = true;\n // Which failure this is turns on the output counter, not on the\n // timer: a decoder that has handed frames back and then gone quiet\n // is one a rebuild can recover, and a decoder that has answered a\n // full pipeline of requests with nothing at all may never have been\n // given a legal place to start, which is the anchor's to answer for.\n if (this.framesDecodedCount === 0) {\n this.anchorError ??= new VideoEngineError(VideoEngineErrorCode.DecoderStalled, `DecodeSession: the decoder acknowledged ${owed} decode requests and produced no frame`);\n resolve();\n return;\n }\n reject(new VideoEngineError(VideoEngineErrorCode.BackendCrashed, `DecodeSession: decoder produced no output in ${this.outputTimeoutMs}ms with ${owed} chunks in flight`));\n }, this.outputTimeoutMs);\n this.wake = () => {\n this.wake = null;\n clearTimeout(timer);\n resolve();\n };\n });\n }\n /**\n * Files a submitted chunk's timing in presentation order, which is the order\n * pictures come back in and is not the order chunks go in on a B-frame\n * source.\n */\n awaitTiming(timing) {\n let at = this.pending.length;\n while (at > 0 && this.pending[at - 1].timestampS > timing.timestampS)\n at--;\n this.pending.splice(at, 0, timing);\n }\n /**\n * A decoder that echoes the timestamp it was handed names its own pending\n * entry, which survives an output being dropped. One that counts from an\n * origin of its own names nothing, and position is all that is left. Taking\n * position alone would let a single dropped output shift every picture after\n * it onto the wrong detections, permanently and without a symptom.\n */\n claimTiming(frame) {\n const submittedS = frame.timestamp / MICROSECONDS_PER_SECOND;\n const at = this.pending.findIndex((timing) => Math.abs(timing.timestampS - submittedS) <= TIMING_MATCH_S);\n return at === -1 ? this.pending.shift() : this.pending.splice(at, 1)[0];\n }\n receive(frame) {\n this.framesDecodedCount += 1;\n const timing = this.claimTiming(frame);\n if (this.closed || !timing) {\n frame.close();\n return;\n }\n this.decoded.push({ frame, ...timing });\n this.wake?.();\n }\n fail(from, error) {\n if (from !== this.decoder)\n return;\n this.anchorError ??= new VideoEngineError(VideoEngineErrorCode.DecoderStalled, \"DecodeSession: the decoder reported an error\", error);\n this.wake?.();\n }\n /** Latches the terminal failure and returns it. First writer wins, so the\n * cause a caller is handed is the one that started the failure rather than\n * whichever consequence surfaced last. */\n stall(what, cause) {\n return this.latch(new VideoEngineError(VideoEngineErrorCode.DecoderStalled, `DecodeSession: ${what}`, cause));\n }\n latch(error) {\n this.stalledError ??= error;\n return this.stalledError;\n }\n discardDecoded() {\n for (const picture of this.decoded)\n picture.frame.close();\n this.decoded.length = 0;\n }\n }\n /** A decoded frame in the runtime's own sample vocabulary, so a session frame\n * and a VideoSampleSink frame reach the renderer the same way. */\n function videoFrameSample({ frame, timestampS, durationS }, rotation) {\n const quarterTurn = rotation % 180 !== 0;\n return {\n timestamp: timestampS,\n duration: durationS,\n rotation,\n toVideoFrame: () => frame.clone(),\n draw: (ctx, dx, dy, dWidth, dHeight) => {\n drawRotated(ctx, frame, rotation, dx, dy, dWidth ?? (quarterTurn ? frame.displayHeight : frame.displayWidth), dHeight ?? (quarterTurn ? frame.displayWidth : frame.displayHeight));\n },\n close: () => frame.close(),\n };\n }\n\n /**\n * Decode-resolution strategies. A strategy decides the pixel width each video\n * frame is decoded to, independent of the source's native resolution. Height\n * follows from native aspect, so a strategy only ever returns a width.\n *\n * Decoding below native trades preview sharpness for paint cost and frame-cache\n * memory. It does NOT cut decode cost: the codec decodes the full coded frame\n * regardless of target width, and CanvasSink resizes the result afterward. The\n * win scales with the gap between source resolution and the on-screen box: a 4K\n * source shown in a 640px surface still pays full 4K decode, but the smaller\n * surface cuts the per-frame paint work and the cached blit size.\n *\n * A strategy applies wherever frames decode through this seam: the live scrub\n * surface and the analysis / extraction path alike. AnalysisSession decodes at\n * whatever strategy it is given (native when omitted), so a thumbnail pass can\n * cache a small blit instead of a native-size one.\n */\n /** Decode at native resolution. The conservative default. */\n function nativeResolution() {\n return { kind: \"native\" };\n }\n /** Runs the descriptor against a context to produce a target decode width. */\n function strategyWidth(strategy, ctx) {\n switch (strategy.kind) {\n case \"native\":\n return ctx.nativeWidth;\n case \"viewport\": {\n if (ctx.displayWidth === null || ctx.displayWidth <= 0)\n return ctx.nativeWidth;\n const dpr = clampDpr(ctx.devicePixelRatio, strategy.maxDevicePixelRatio);\n return Math.ceil(ctx.displayWidth * dpr);\n }\n case \"capped\":\n return Math.min(strategy.maxWidth, ctx.nativeWidth);\n case \"displayBox\": {\n const { boxWidth, boxHeight } = strategy;\n if (!(boxWidth > 0) || !(boxHeight > 0))\n return ctx.nativeWidth;\n const fit = Math.min(boxWidth / ctx.nativeWidth, boxHeight / ctx.nativeHeight);\n const dpr = clampDpr(strategy.devicePixelRatio, strategy.maxDevicePixelRatio);\n return Math.ceil(ctx.nativeWidth * fit * dpr);\n }\n }\n }\n /**\n * Runs a strategy and returns native-bounded, aspect-preserving integer\n * dimensions safe to hand to CanvasSink and to size a canvas backing store.\n * A strategy that returns garbage (non-finite, non-positive) falls back to\n * native rather than producing a broken surface.\n */\n function resolveDecodeDimensions(strategy, ctx) {\n const nativeWidth = Math.max(1, Math.round(ctx.nativeWidth));\n const nativeHeight = Math.max(1, Math.round(ctx.nativeHeight));\n const raw = strategyWidth(strategy, ctx);\n const width = Number.isFinite(raw) && raw > 0\n ? Math.min(Math.round(raw), nativeWidth)\n : nativeWidth;\n const height = Math.max(1, Math.round((width * nativeHeight) / nativeWidth));\n return { width, height };\n }\n function clampDpr(dpr, maxDpr) {\n if (!Number.isFinite(dpr) || dpr <= 0)\n return 1;\n return Math.min(dpr, maxDpr);\n }\n\n /**\n * One entry per presentation instant.\n *\n * A container may carry several coded pictures on one timestamp, and they\n * occupy no time between them: a decode for that instant answers with a single\n * picture, so the rest are frames no reader can reach or name. Counting them\n * would leave a step across the group moving the picture nowhere, and a search\n * for a time inside it answering with the group's last entry whatever it was\n * asked.\n */\n function oneEntryPerInstant(data) {\n const { ticks } = data;\n let at = 1;\n while (at < ticks.length && ticks[at] !== ticks[at - 1])\n at += 1;\n if (at === ticks.length)\n return data;\n const distinct = new Float64Array(ticks.length);\n distinct.set(ticks.subarray(0, at));\n let size = at;\n for (let i = at + 1; i < ticks.length; i += 1) {\n if (ticks[i] === distinct[size - 1])\n continue;\n distinct[size] = ticks[i];\n size += 1;\n }\n return { ...data, ticks: distinct.slice(0, size) };\n }\n /**\n * Every real frame of one track, in presentation order, by its container tick\n * timestamp.\n *\n * A container states its timestamps as integer multiples of 1/tickRate, so a\n * frame's tick count is exact and comparisons between two readers of the same\n * container are integer comparisons. `timeAt` is the same division of the same\n * two integers the demuxer itself performs, which is what lets a consumer\n * holding only seconds match a producer's frame with `===`.\n */\n class FrameTimeline {\n data;\n ticksArray;\n constructor(data) {\n this.data = data;\n this.ticksArray = data.ticks;\n }\n static from(data) {\n if (data.ticks.length === 0) {\n throw new RangeError(\"FrameTimeline: a track with no frames has no timeline\");\n }\n if (!(data.tickRate > 0)) {\n throw new RangeError(`FrameTimeline: tick rate ${data.tickRate} is not positive`);\n }\n return new FrameTimeline(oneEntryPerInstant(data));\n }\n /** A synthetic constant-rate table. Tests and fakes only. The default tick\n * rate is a whole multiple of `fps`, so no test table needs rounding. */\n static uniform(fps, frameCount, tickRate = fps * 1000) {\n const step = tickRate / fps;\n const ticks = Float64Array.from({ length: frameCount }, (_, index) => index * step);\n return FrameTimeline.from({ lastDurationTicks: step, tickRate, ticks });\n }\n get tickRate() {\n return this.data.tickRate;\n }\n get frameCount() {\n return this.ticksArray.length;\n }\n toData() {\n return this.data;\n }\n ticksAt(index) {\n return this.ticksArray[this.clampIndex(index)];\n }\n timeAt(index) {\n return asSec(this.ticksAt(index) / this.data.tickRate);\n }\n idAt(index) {\n const at = this.clampIndex(index);\n return { index: at, ticks: this.ticksArray[at] };\n }\n landingAt(index) {\n const at = this.clampIndex(index);\n return { frame: this.idAt(at), mediaTimeS: this.timeAt(at) };\n }\n /** Ticks the frame at `index` ends at, which is where the next one starts. */\n endTicksAt(index) {\n const at = this.clampIndex(index);\n return at + 1 < this.ticksArray.length\n ? this.ticksArray[at + 1]\n : this.ticksArray[at] + this.data.lastDurationTicks;\n }\n /**\n * The frame covering `timeS`, which is what a decode for it returns.\n *\n * The comparison divides, as `timeAt` does, so a frame's own published\n * second answers with that frame.\n */\n indexAtOrBefore(timeS) {\n let low = 0;\n let high = this.ticksArray.length - 1;\n let found = 0;\n while (low <= high) {\n const mid = (low + high) >> 1;\n if (this.ticksArray[mid] / this.data.tickRate <= timeS) {\n found = mid;\n low = mid + 1;\n }\n else {\n high = mid - 1;\n }\n }\n return found;\n }\n /**\n * The frame a decoded sample is.\n *\n * A decoded timestamp has been through the WebCodecs microsecond plane, so\n * it can miss its own tick by up to half a microsecond. Snapping to the\n * nearer neighbour absorbs that by four orders of magnitude, since the\n * narrowest real frame gap measured across the fixtures is 31667 us.\n */\n indexOfDecoded(timeS) {\n const wanted = Math.round(timeS * this.data.tickRate);\n const at = this.indexAtOrBefore(wanted / this.data.tickRate);\n const next = at + 1;\n if (next >= this.ticksArray.length)\n return at;\n return wanted - this.ticksArray[at] <= this.ticksArray[next] - wanted\n ? at\n : next;\n }\n clampIndex(index) {\n if (index < 0)\n return 0;\n const last = this.ticksArray.length - 1;\n return index > last ? last : index;\n }\n }\n\n var ScrubCursorState;\n (function (ScrubCursorState) {\n ScrubCursorState[\"Idle\"] = \"idle\";\n ScrubCursorState[\"Seeking\"] = \"seeking\";\n ScrubCursorState[\"Closed\"] = \"closed\";\n })(ScrubCursorState || (ScrubCursorState = {}));\n /**\n * The turn a frame's raw pixels still need. A canvas frame has none left: a\n * CanvasSink decode arrives upright, and a cache blit was drawn through a\n * sample's own draw, which applies it. Only a live sample still owes one.\n */\n function frameRotation(frame) {\n return frame.kind === \"sample\" ? frame.sample.rotation : 0;\n }\n /**\n * Wraps a raw sample so its close() runs at most once, then no-ops. The\n * lifetime is hard to keep linear: a sample is drawn into the cache, stashed for\n * paint, then closed after paint, and may be closed again on teardown if a\n * gesture left it unpainted. An idempotent close lets every path call it\n * defensively without double-free.\n */\n function idempotentSample(sample) {\n let closed = false;\n return {\n toVideoFrame: () => sample.toVideoFrame(),\n rotation: sample.rotation,\n draw: (ctx, dx, dy, dWidth, dHeight) => sample.draw(ctx, dx, dy, dWidth, dHeight),\n close: () => {\n if (closed)\n return;\n closed = true;\n sample.close();\n },\n get timestamp() {\n return sample.timestamp;\n },\n get duration() {\n return sample.duration;\n },\n };\n }\n /**\n * Whether a frame earns the screen over what is already showing, judged from\n * where the user is pointing rather than from what happens to be up. Closer\n * wins; equally close wins only by being sharper, since repainting the same\n * ground with the same or worse pixels is a visible change for no information.\n *\n * Both painters share this. They used to disagree: one applied it to coarse\n * frames only and the other to everything, so which rule you got depended on\n * which path served the frame.\n */\n function earnsScreen(candidate, showing, targetMs) {\n if (!showing)\n return true;\n const candidateGap = Math.abs(candidate.timestampMs - targetMs);\n const showingGap = Math.abs(showing.timestampMs - targetMs);\n if (candidateGap !== showingGap)\n return candidateGap < showingGap;\n return candidate.quality === \"exact\" && showing.quality === \"preview\";\n }\n\n function isSampleHandle(handle) {\n return \"sampleSink\" in handle;\n }\n function isSessionHandle(handle) {\n return \"session\" in handle;\n }\n /**\n * Wraps any opened-source handle in the unified FrameProvider. The two sample\n * paths wrap each yielded VideoSample in idempotentSample so the cursor can\n * discharge the close obligation from any path without double-free.\n */\n function openFrameProvider(handle) {\n if (isSessionHandle(handle))\n return sessionProvider(handle);\n if (isSampleHandle(handle))\n return sampleProvider(handle);\n return canvasProvider(handle);\n }\n function canvasProvider(handle) {\n let yielded = 0;\n const wrap = (w) => {\n yielded += 1;\n return { kind: \"canvas\", canvas: w.canvas, timestamp: w.timestamp };\n };\n return {\n decodePath: \"canvas\",\n playReadAhead: PLAYBACK.READ_AHEAD_CANVAS,\n track: handle.track,\n keyframeProbe: handle.keyframeProbe,\n wouldReanchor: () => false,\n async getFrame(t) {\n const w = await handle.sink.getCanvas(t);\n return w ? wrap(w) : null;\n },\n async *frames(startS) {\n for await (const w of handle.sink.canvases(startS))\n yield wrap(w);\n },\n async *framesAt(timestamps) {\n // mediabunny's sink walks the track once and expects its timestamps\n // to climb, so this path sorts whatever order the caller chose.\n const ascending = [...timestamps].sort((a, b) => a - b);\n for await (const w of handle.sink.canvasesAtTimestamps(ascending)) {\n yield w ? wrap(w) : null;\n }\n },\n framesDecoded: () => yielded,\n dispose: () => handle.dispose(),\n };\n }\n const sampleFrame = (s) => ({\n kind: \"sample\",\n sample: idempotentSample(s),\n timestamp: s.timestamp,\n });\n function sampleProvider(handle) {\n let yielded = 0;\n const count = (s) => {\n yielded += 1;\n return sampleFrame(s);\n };\n return {\n decodePath: \"sample\",\n // Pool-bound: every outstanding frame is a slot the sink cannot refill.\n playReadAhead: 1,\n track: handle.track,\n keyframeProbe: handle.keyframeProbe,\n wouldReanchor: () => false,\n async getFrame(t) {\n const s = await handle.sampleSink.getSample(t);\n return s ? count(s) : null;\n },\n async *frames(startS) {\n for await (const s of handle.sampleSink.samples(startS))\n yield count(s);\n },\n async *framesAt(timestamps) {\n const ascending = [...timestamps].sort((a, b) => a - b);\n for await (const s of handle.sampleSink.samplesAtTimestamps(ascending)) {\n yield s ? count(s) : null;\n }\n },\n framesDecoded: () => yielded,\n dispose: () => handle.dispose(),\n };\n }\n /**\n * Wraps the long-lived decode session in the FrameProvider. framesAt walks to\n * each requested timestamp in turn and yields every frame the walk decodes, not\n * only the requested ones: a decoder cannot skip forward inside a span, so the\n * frames in between are a free harvest of a decode already paid for. Walking to\n * each in turn rather than covering the whole request in one span is what keeps\n * a sweep of widely spaced timestamps from decoding everything between them; the\n * session re-anchors between them instead.\n */\n function sessionProvider(handle) {\n return {\n decodePath: \"session\",\n playReadAhead: PLAYBACK.READ_AHEAD_CANVAS,\n track: handle.track,\n keyframeProbe: handle.keyframeProbe,\n async getFrame(t) {\n const s = await handle.session.frameAt(t);\n return s ? sampleFrame(s) : null;\n },\n async *frames(startS) {\n for await (const s of handle.session.framesFrom(startS))\n yield sampleFrame(s);\n },\n wouldReanchor: (t) => t < handle.session.reachableFromS,\n async *framesAt(timestamps) {\n // In the caller's order, deliberately. Ascending looks like the\n // decoder-friendly choice, and within one anchor span it is, but a\n // target behind the read head re-anchors and walks its whole prefix.\n // Sorting ascending puts the furthest BACKWARD target first, so that\n // walk runs before any frame ahead of the gesture. The caller knows\n // which frames the user is about to need; it orders them.\n for (const t of timestamps) {\n for await (const s of handle.session.framesCovering(t, t))\n yield sampleFrame(s);\n }\n },\n framesDecoded: () => handle.session.framesDecoded,\n dispose: () => handle.dispose(),\n };\n }\n /**\n * Opens a source and routes it to the fastest decode path it supports: the\n * long-lived DecodeSession, then the zero-copy VideoSampleSink, then the\n * CanvasSink.\n *\n * The session's condition is the track's codec, which nothing can answer until\n * the container has been read, so the input opens first and the path is chosen\n * against what the opened track reports. The one input then serves whichever\n * path won.\n */\n async function openScrubSource(options) {\n const opened = await openInput(options);\n const decoderConfig = await opened.videoTrack.getDecoderConfig();\n if (decoderConfig &&\n decodeSessionViable({\n videoDecoderAvailable: detectVideoDecoder(),\n decoderConfig,\n })) {\n return sessionHandle(opened, decoderConfig);\n }\n if (zeroCopyViable({\n prefer2d: options.prefer2d ?? false,\n decodesNative: opened.strategy.kind === \"native\",\n webgpuImportAvailable: detectWebgpuImport(),\n })) {\n return sampleHandle(opened);\n }\n return canvasHandle(opened, options.poolSize ?? SCRUB.DEFAULT_POOL_SIZE);\n }\n function canvasHandle(opened, poolSize) {\n // Width-only: CanvasSink derives height from native aspect. This sizes the\n // canvas the frame is drawn into; it does NOT reduce decode cost. The codec\n // decodes the full coded frame regardless, and CanvasSink resizes after, so\n // the win is paint work and cached-blit memory, not decode throughput.\n const sink = new CanvasSink(opened.videoTrack, {\n poolSize,\n width: opened.track.decodeWidth,\n });\n return {\n track: opened.track,\n sink,\n keyframeProbe: new EncodedPacketSink(opened.videoTrack),\n dispose: async () => {\n opened.input.dispose();\n },\n };\n }\n /** The zero-copy sample path: a VideoSampleSink in place of the CanvasSink. It\n * takes no width, so frames arrive at the source's own dimensions. */\n function sampleHandle(opened) {\n return {\n track: opened.track,\n sampleSink: new VideoSampleSink(opened.videoTrack),\n keyframeProbe: new EncodedPacketSink(opened.videoTrack),\n dispose: async () => {\n opened.input.dispose();\n },\n };\n }\n /**\n * The long-lived-decoder path: an EncodedPacketSink feeding a DecodeSession that\n * holds one VideoDecoder across seeks. The same sink answers the keyframe probe,\n * so anchor resolution and packet reads share one reader. The session drives the\n * decoder itself and every frame arrives at the source's own dimensions; the\n * resolved decodeWidth still sizes the canvas and the cache blits, which scale\n * the native frame down as they draw it.\n */\n function sessionHandle(opened, config) {\n const packets = new EncodedPacketSink(opened.videoTrack);\n const session = new DecodeSession({\n packets,\n config,\n createDecoder: webCodecsDecoder,\n rotation: opened.track.rotation,\n });\n return {\n track: opened.track,\n session,\n keyframeProbe: packets,\n dispose: async () => {\n session.close();\n opened.input.dispose();\n },\n };\n }\n function webCodecsDecoder(init) {\n const decoder = new VideoDecoder(init);\n return {\n configure: (config) => decoder.configure(config),\n decode: (chunk) => decoder.decode(new EncodedVideoChunk(chunk)),\n flush: () => decoder.flush(),\n // Reporting an error closes a WebCodecs decoder, and both of these throw\n // on one that is already closed. Teardown of a decoder that failed is a\n // routine path, so the platform's rule is answered here rather than left\n // for every caller to remember.\n reset: () => {\n if (decoder.state !== \"closed\")\n decoder.reset();\n },\n close: () => {\n if (decoder.state !== \"closed\")\n decoder.close();\n },\n };\n }\n /** Opens the input, resolves the primary video track, and runs the decode\n * resolution math, the work every path shares before one is chosen.\n *\n * Four ways to get no frames out of a file, and each one throws its own code\n * so a host can say which happened: the container is not one the demuxer\n * reads (ContainerUnreadable), the container reads but nothing inside it does\n * (VideoTrackUnreadable), the tracks read and none is video (NoVideoTrack), or\n * the video track reads and the browser has no decoder for its codec\n * (DecodeUnsupported). */\n async function openInput(options) {\n const { source } = options;\n const input = new Input({\n formats: ALL_FORMATS,\n source: toMediabunnySource(source, options.sourceResidency, options.urlSource),\n });\n const videoTrack = await resolveVideoTrack(input);\n if (!(await videoTrack.canDecode())) {\n const decoderConfig = await videoTrack.getDecoderConfig();\n throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, `openInput: browser cannot decode this video track's codec ${decoderConfig?.codec ?? \"(unknown)\"}`);\n }\n const displayWidth = videoTrack.displayWidth;\n const displayHeight = videoTrack.displayHeight;\n const durationS = asSec(await readTrackDurationS(videoTrack));\n const timeline = await readFrameTimeline(videoTrack);\n // Mediabunny ships a measured native fps via computePacketStats:\n // averagePacketRate \"for video tracks, equals the average frame rate\". We\n // bound the packet sample to a small slice so the load promise doesn't stall\n // on long files. The result is good enough for 1/nativeFps step math;\n // sub-frame precision falls through to stepForward/stepBackward.\n const nativeFps = await readTrackFps(videoTrack);\n const strategy = options.decodeStrategy ?? nativeResolution();\n const { width: decodeWidth, height: decodeHeight } = resolveDecodeDimensions(strategy, {\n nativeWidth: displayWidth,\n nativeHeight: displayHeight,\n displayWidth: options.viewport?.displayWidth ?? null,\n devicePixelRatio: options.viewport?.devicePixelRatio ?? 1,\n });\n return {\n input,\n videoTrack,\n strategy,\n track: {\n width: displayWidth,\n height: displayHeight,\n decodeWidth,\n decodeHeight,\n rotation: videoTrack.rotation,\n nativeFps,\n durationS,\n firstTimestampS: timeline.timeAt(0),\n timeline,\n },\n };\n }\n /**\n * The primary video track, or the typed refusal that says which of the three\n * ways to reach no track this file took. Only the last of them, where the\n * demuxer listed tracks and none was video, is a statement about the file\n * itself; the other two say what this build can read.\n */\n async function resolveVideoTrack(input) {\n let videoTrack;\n try {\n videoTrack = await input.getPrimaryVideoTrack();\n }\n catch (error) {\n if (error instanceof UnsupportedInputFormatError) {\n throw new VideoEngineError(VideoEngineErrorCode.ContainerUnreadable, \"openInput: the demuxer does not read this file's container\", error);\n }\n throw error;\n }\n if (videoTrack)\n return videoTrack;\n const tracks = await input.getTracks();\n throw tracks.length === 0\n ? new VideoEngineError(VideoEngineErrorCode.VideoTrackUnreadable, \"openInput: the container opened and the demuxer parsed no track out of it\")\n : new VideoEngineError(VideoEngineErrorCode.NoVideoTrack, \"openInput: the container's tracks read and none of them carries video\");\n }\n /**\n * Walks the track's packets metadata-only and records each one's timestamp in\n * the container's own integer grain, which is the grain every timestamp of the\n * track is a whole multiple of.\n *\n * Decode order is not presentation order on a B-frame source, so the table is\n * sorted before it is indexed, and the trailing frame's duration comes from the\n * packet that ends up last in that order.\n */\n async function readFrameTimeline(videoTrack) {\n const track = videoTrack;\n const tickRate = typeof track.getTimeResolution === \"function\"\n ? await track.getTimeResolution()\n : (track.timeResolution ?? FRAME_TIMELINE.FALLBACK_TICK_RATE);\n const sink = new EncodedPacketSink(videoTrack);\n const ticks = [];\n let lastTicks = -Infinity;\n let lastDurationTicks = 0;\n for await (const packet of sink.packets(undefined, undefined, {\n metadataOnly: true,\n })) {\n const at = Math.round(packet.timestamp * tickRate);\n if (ticks.length >= FRAME_TIMELINE.MAX_FRAMES) {\n throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, `openInput: source video track carries more than ${FRAME_TIMELINE.MAX_FRAMES} frames`);\n }\n ticks.push(at);\n if (at >= lastTicks) {\n lastTicks = at;\n lastDurationTicks = Math.round(packet.duration * tickRate);\n }\n }\n if (ticks.length === 0) {\n throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, \"openInput: source video track has no frames\");\n }\n ticks.sort((a, b) => a - b);\n return FrameTimeline.from({\n lastDurationTicks,\n tickRate,\n ticks: Float64Array.from(ticks),\n });\n }\n /**\n * The gate. True only when every condition holds: the 2D path is not pinned, the\n * strategy decodes at native (no downscale), and the realm offers WebGPU's\n * importExternalTexture. False keeps the byte-identical CanvasSink path; this is\n * the one place the additive path is allowed to switch on.\n */\n function zeroCopyViable(ctx) {\n if (ctx.prefer2d)\n return false;\n if (!ctx.decodesNative)\n return false;\n if (!ctx.webgpuImportAvailable)\n return false;\n return true;\n }\n /**\n * The gate. The session reaches a non-IDR anchor by declaring it a recovery\n * point, which only holds for AVCC-framed H.264 carrying its parameter sets, so\n * every other source keeps the mediabunny sinks.\n */\n function decodeSessionViable(ctx) {\n if (!ctx.videoDecoderAvailable)\n return false;\n return sessionDrivable(ctx.decoderConfig);\n }\n /** Whether this realm exposes the WebCodecs decoder the session constructs. */\n function detectVideoDecoder() {\n return (typeof globalThis.VideoDecoder ===\n \"function\");\n }\n /**\n * Probes the worker realm for WebGPU with importExternalTexture. Checks for the\n * method on the prototype rather than acquiring a device, so it is synchronous\n * and cheap; the actual device acquisition still happens (and can still fail\n * back to 2D) in the renderer.\n */\n function detectWebgpuImport() {\n const nav = globalThis.navigator;\n if (!nav?.gpu)\n return false;\n const deviceCtor = globalThis\n .GPUDevice;\n const proto = deviceCtor?.prototype;\n return typeof proto?.importExternalTexture === \"function\";\n }\n function urlRequestInit(crossOrigin) {\n if (!crossOrigin)\n return undefined;\n return {\n requestInit: {\n mode: \"cors\",\n credentials: crossOrigin === \"use-credentials\" ? \"include\" : \"omit\",\n },\n };\n }\n function toMediabunnySource(source, residency, urlSource) {\n switch (source.kind) {\n case SourceKind.Url: {\n const options = {\n ...urlRequestInit(source.crossOrigin),\n ...(residency ? { fetchFn: residency.fetchFn } : {}),\n ...(urlSource?.maxCacheSize === undefined\n ? {}\n : { maxCacheSize: urlSource.maxCacheSize }),\n ...(urlSource?.parallelism === undefined\n ? {}\n : { parallelism: urlSource.parallelism }),\n };\n return new UrlSource(source.url, Object.keys(options).length > 0 ? options : undefined);\n }\n case SourceKind.Blob:\n return new BlobSource(source.blob);\n case SourceKind.Stream:\n return new ReadableStreamSource(source.stream);\n }\n }\n /**\n * Whether a source can be opened a second time, which the hang-recovery path\n * needs to rebuild a wedged decoder. A URL re-fetches and a Blob re-reads, so\n * both re-open cleanly. A ReadableStream is consumed once and cannot be rewound,\n * so a rebuild on it would open onto an exhausted stream; the watchdog falls back\n * to a degraded-but-alive state instead of rebuilding for that kind.\n */\n function isReopenableSource(source) {\n return source.kind !== SourceKind.Stream;\n }\n async function readTrackDurationS(track) {\n const t = track;\n if (typeof t.computeDuration === \"function\") {\n const d = await t.computeDuration();\n return Number.isFinite(d) ? d : 0;\n }\n if (typeof t.duration === \"number\")\n return t.duration;\n if (typeof t.durationS === \"number\")\n return t.durationS;\n return 0;\n }\n /**\n * Reads the native frame rate from mediabunny's computePacketStats. Bounded to a\n * small packet sample so load() does not stall on long files. Returns null when\n * the track exposes no stats API or the value is non-finite.\n */\n async function readTrackFps(track) {\n const t = track;\n if (typeof t.computePacketStats !== \"function\")\n return null;\n try {\n const stats = await t.computePacketStats(120);\n const fps = stats?.averagePacketRate;\n if (typeof fps !== \"number\" || !Number.isFinite(fps) || fps <= 0)\n return null;\n return fps;\n }\n catch {\n return null;\n }\n }\n\n /**\n * Uncached cursor seam: a thin latest-wins queue and play-mode forward iterator\n * over an opened decode source. This is the cacheStrategy \"none\" backend, kept\n * for consumers that do not want the scheduler's memory footprint.\n *\n * Random-access seeks ride the provider's getFrame(t), which does the keyframe\n * walk and GOP decode internally. Forward playback uses frames(start) as a\n * streaming iterator, alive only between attachPlay and detachPlay so a paused\n * cursor never holds a decoder pipeline open.\n *\n * On the canvas source it emits CanvasScrubFrame; on the zero-copy sample source\n * a fresh decode emits SampleScrubFrame, whose close the controller owns after\n * paint. With no cache this cursor never blits a sample, so every fresh sample\n * is handed straight to the consumer.\n *\n * Coalescing: seekTo/seekToKey overwrite pendingSeekTargetS; drainSeek processes\n * only the latest target after the in-flight one settles, so a burst of scrub\n * seeks collapses to the most recent.\n */\n class CanvasSinkScrubCursor {\n provider;\n trackInfo;\n disposeSource;\n /**\n * Forward-playback iterator. Lives only between attachPlay and detachPlay so\n * a paused cursor never holds a decoder pipeline open.\n */\n iterator = null;\n /**\n * Serializes next() calls. AsyncGenerator iterators are single-consumer;\n * without this chain a rAF tick racing a backup pull could double-advance\n * and emit duplicate frames.\n */\n nextInFlight = Promise.resolve();\n nextPending = 0;\n currentState = ScrubCursorState.Closed;\n listeners = new Set();\n idleResolvers = [];\n settleResolvers = [];\n pendingSeekTargetS = null;\n pendingSeekKeyOnly = false;\n seekDraining = false;\n closed = false;\n lastEmittedFrame = null;\n constructor(source) {\n this.provider = openFrameProvider(source);\n this.trackInfo = this.provider.track;\n this.disposeSource = () => this.provider.dispose();\n }\n get state() {\n return this.currentState;\n }\n get track() {\n return this.trackInfo;\n }\n get isIdle() {\n return (this.currentState === ScrubCursorState.Idle &&\n this.nextPending === 0 &&\n this.pendingSeekTargetS === null &&\n !this.seekDraining);\n }\n async open() {\n // Seed the first paint via the same random-access path used for every\n // seek. Cheaper than a one-shot iterator just for the seed. Seed at the\n // track's real first timestamp, not a hardcoded 0: a trimmed or offset\n // clip's first frame is not at the origin, so getFrame(0) would land\n // before the first sample and seed nothing.\n const seed = await this.provider.getFrame(this.trackInfo.firstTimestampS);\n if (this.closed)\n return;\n if (seed)\n this.emitFrame(seed);\n this.currentState = ScrubCursorState.Idle;\n }\n /** No cache to read; the scheduler is the caching backend. */\n peekCached() {\n return null;\n }\n /**\n * Decodes one named frame of the source and emits it.\n *\n * The retrieval is at-or-before a time the frame table produced, and the\n * demuxer normalises that back to the same integer tick it came from, so it\n * lands on that frame and no other. Nothing here walks, compares or skips,\n * so a step across a frame boundary needs no epsilon.\n */\n async seekToFrame(frame) {\n if (this.closed)\n return null;\n const decoded = await this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index));\n if (this.closed || !decoded)\n return null;\n this.emitFrame(decoded);\n return this.lastEmittedFrame;\n }\n seekTo(timestamp) {\n this.pendingSeekTargetS = this.clampToOrigin(timestamp);\n this.pendingSeekKeyOnly = false;\n void this.drainSeek();\n }\n /**\n * getFrame(t) lands at or before t, which is the keyframe-or-earlier\n * semantics seekToKey implies. The emitted frame is marked as a keyframe\n * result via the keyOnly flag.\n */\n seekToKey(timestamp) {\n this.pendingSeekTargetS = this.clampToOrigin(timestamp);\n this.pendingSeekKeyOnly = true;\n void this.drainSeek();\n }\n /** Floors a seek target to the track's first timestamp. A seek below the\n * origin (an offset/trimmed clip) has no sample to land on, so it would\n * emit nothing; clamping lands it on the first frame instead. */\n clampToOrigin(timestamp) {\n return Math.max(this.trackInfo.firstTimestampS, timestamp);\n }\n attachPlay(startS) {\n if (this.closed)\n return;\n void this.iterator?.return();\n this.iterator = this.provider.frames(startS);\n }\n detachPlay() {\n void this.iterator?.return();\n this.iterator = null;\n }\n next() {\n // No-op while paused: pulling the iterator when none is attached would\n // silently advance media time across a paused canvas. The controller\n // calls next() only inside its playing branch.\n if (!this.iterator)\n return;\n // Cap at one outstanding pull so a slow decoder cannot accumulate a\n // backlog whose delayed burst overwrites the stash and drops frames.\n if (this.nextPending > 0)\n return;\n this.nextPending++;\n this.nextInFlight = this.nextInFlight\n .catch(() => undefined)\n .then(() => this.pullForwardOne())\n .finally(() => {\n this.nextPending--;\n });\n }\n idle() {\n if (this.isIdle)\n return Promise.resolve();\n return new Promise((resolve) => this.idleResolvers.push(resolve));\n }\n seekSettled() {\n if (this.closed)\n return Promise.resolve();\n if (this.pendingSeekTargetS === null && !this.seekDraining)\n return Promise.resolve();\n return new Promise((resolve) => this.settleResolvers.push(resolve));\n }\n subscribe(listener) {\n this.listeners.add(listener);\n // Replay the most recently emitted frame so a controller that wires up\n // after open() does not miss the seed frame.\n if (this.lastEmittedFrame)\n listener(this.lastEmittedFrame);\n return () => {\n this.listeners.delete(listener);\n };\n }\n async close() {\n this.closed = true;\n this.pendingSeekTargetS = null;\n this.currentState = ScrubCursorState.Closed;\n this.listeners.clear();\n this.flushIdleResolvers();\n this.flushSettleResolvers();\n this.lastEmittedFrame = null;\n await this.iterator?.return();\n this.iterator = null;\n await this.disposeSource();\n }\n async drainSeek() {\n if (this.seekDraining || this.closed)\n return;\n this.seekDraining = true;\n try {\n while (this.pendingSeekTargetS !== null && !this.closed) {\n const t = this.pendingSeekTargetS;\n const keyOnly = this.pendingSeekKeyOnly;\n this.pendingSeekTargetS = null;\n await this.runSeek(asSec(t), keyOnly);\n this.flushSettleResolvers();\n }\n }\n finally {\n this.seekDraining = false;\n if (!this.closed)\n this.flushIdleResolvers();\n this.flushSettleResolvers();\n }\n }\n async runSeek(t, keyOnly) {\n if (this.closed)\n return;\n this.currentState = ScrubCursorState.Seeking;\n const frame = await this.provider.getFrame(t);\n if (this.closed)\n return;\n if (frame)\n this.emitFrame(frame, keyOnly);\n if (!this.closed)\n this.currentState = ScrubCursorState.Idle;\n }\n async pullForwardOne() {\n const iterator = this.iterator;\n if (!iterator || this.closed)\n return;\n // Skip while a seek is in flight; the seek emits the settled frame and\n // otherwise we would race the iterator state.\n if (this.seekDraining)\n return;\n const next = (await iterator.next()).value ?? null;\n if (this.closed || iterator !== this.iterator || !next) {\n if (next?.kind === \"sample\")\n next.sample.close();\n return;\n }\n this.emitFrame(next);\n }\n emitFrame(decoded, isKey = false) {\n const frame = decoded.kind === \"sample\"\n ? {\n kind: \"sample\",\n sample: decoded.sample,\n timestampS: decoded.timestamp,\n width: this.trackInfo.decodeWidth,\n height: this.trackInfo.decodeHeight,\n isKeyFrame: isKey,\n quality: \"exact\",\n }\n : {\n kind: \"canvas\",\n source: decoded.canvas,\n timestampS: decoded.timestamp,\n width: this.trackInfo.decodeWidth,\n height: this.trackInfo.decodeHeight,\n isKeyFrame: isKey,\n quality: \"exact\",\n };\n this.lastEmittedFrame = frame;\n this.listeners.forEach((l) => l(frame));\n }\n flushIdleResolvers() {\n const pending = this.idleResolvers;\n this.idleResolvers = [];\n pending.forEach((r) => r());\n }\n flushSettleResolvers() {\n const pending = this.settleResolvers;\n this.settleResolvers = [];\n pending.forEach((r) => r());\n }\n }\n\n /** Absorbs the sub-millisecond error in a frame timestamp that a whole-number\n * bucket grid cannot represent, so a frame sitting exactly on the target is not\n * read as sitting after it. */\n const BUCKET_EPSILON_MS = 1;\n /** Gaps below this are the same frame stored twice with float slop, not two\n * frames: keys are whole milliseconds, so anything closer straddles one key. */\n const MIN_LEARNABLE_INTERVAL_MS = 1;\n /** RGBA bytes per cached pixel, used to turn a RAM budget into a slot count. */\n const BYTES_PER_PIXEL = 4;\n /** A VideoSampleLike exposes draw(); a CanvasImageSource does not. */\n function isSample(src) {\n return \"draw\" in src;\n }\n var FrameTier;\n (function (FrameTier) {\n FrameTier[\"Exact\"] = \"exact\";\n FrameTier[\"Preview\"] = \"preview\";\n })(FrameTier || (FrameTier = {}));\n class FrameCache {\n exact;\n preview;\n exactHits = 0;\n previewHits = 0;\n misses = 0;\n bucketMs;\n exactBudgetBytes;\n constructor(options) {\n const exactWidth = Math.max(1, Math.round(options.exactWidth));\n const exactHeight = Math.max(1, Math.round(options.exactHeight));\n const aspect = exactWidth / exactHeight;\n const previewWidth = Math.max(1, Math.round(options.previewWidth));\n const previewHeight = Math.max(1, Math.round(previewWidth / aspect));\n const frameBytes = exactWidth * exactHeight * BYTES_PER_PIXEL;\n const floor = Math.max(1, Math.floor(options.minExactSlots ?? 1));\n const exactCapacity = options.exactBudgetBytes <= 0\n ? 0\n : Math.max(floor, Math.floor(options.exactBudgetBytes / frameBytes));\n this.bucketMs = Math.max(1, Math.round(options.bucketMs));\n // When the slot floor outgrows the byte budget, the resident bytes can\n // exceed the configured ceiling. Report the larger of the two as the\n // budget so the fill percentage stays an honest <=100% rather than\n // pretending the floor's frames fit a smaller budget.\n this.exactBudgetBytes = Math.max(Math.max(0, options.exactBudgetBytes), exactCapacity * frameBytes);\n this.exact = new TierStore(exactCapacity, exactWidth, exactHeight);\n this.preview = new TierStore(Math.max(0, Math.floor(options.previewCapacity)), previewWidth, previewHeight);\n }\n /** Store a crisp full-resolution frame for the exact (scrub) tier. */\n putExact(timestampMs, src, srcWidth, srcHeight) {\n this.exact.put(timestampMs, src, srcWidth, srcHeight);\n }\n /** Store a downscaled frame for the coarse preview tier. */\n putPreview(timestampMs, src, srcWidth, srcHeight) {\n this.preview.put(timestampMs, src, srcWidth, srcHeight);\n }\n /**\n * Best cached frame for `timestampMs`, consulting the exact tier then the\n * preview tier: a crisp hit within `exactTolMs`, else a coarse hit within\n * `previewTolMs`, else null. The two tolerances differ because the exact tier\n * is keyed per source frame while the preview tier may only hold sparse\n * keyframes.\n *\n * `exactTolMs` is an upper bound, not the reach: a crisp hit is also held to\n * the frame span the exact tier has observed, since a tolerance wider than\n * one frame (50ms is, at every rate from 24 to 60fps) would let a neighbour\n * answer as the exact frame.\n */\n get(timestampMs, exactTolMs, previewTolMs) {\n const exact = this.exact.get(timestampMs, exactTolMs, true);\n if (exact) {\n this.exactHits += 1;\n return {\n canvas: exact.canvas,\n timestampMs: exact.timestampMs,\n tier: FrameTier.Exact,\n };\n }\n const coarse = this.preview.get(timestampMs, previewTolMs);\n if (coarse) {\n this.previewHits += 1;\n return {\n canvas: coarse.canvas,\n timestampMs: coarse.timestampMs,\n tier: FrameTier.Preview,\n };\n }\n this.misses += 1;\n return null;\n }\n /**\n * Same lookup as get() but without touching the hit/miss accounting. The\n * synchronous render-loop peek and the authoritative seek both hit the cache\n * for one user gesture; counting both double-books every scrub. The peek\n * uses this so only the seek's get() is tallied, keeping the hit-rate a\n * per-lookup rate rather than two-per-gesture.\n */\n peek(timestampMs, exactTolMs, previewTolMs) {\n const exact = this.exact.get(timestampMs, exactTolMs, true);\n if (exact) {\n return {\n canvas: exact.canvas,\n timestampMs: exact.timestampMs,\n tier: FrameTier.Exact,\n };\n }\n const coarse = this.preview.get(timestampMs, previewTolMs);\n if (coarse) {\n return {\n canvas: coarse.canvas,\n timestampMs: coarse.timestampMs,\n tier: FrameTier.Preview,\n };\n }\n return null;\n }\n /**\n * Marks the exact entry nearest `timestampMs` as most-recently-used without\n * counting a hit. Lets the scheduler protect the just-shown center frame\n * from being the LRU victim of its own neighbor sweep. No-op on a miss.\n */\n bumpExact(timestampMs) {\n this.exact.touch(timestampMs);\n }\n clear() {\n this.exact.clear();\n this.preview.clear();\n }\n get stats() {\n return {\n exactHits: this.exactHits,\n previewHits: this.previewHits,\n misses: this.misses,\n exactSize: this.exact.size,\n previewSize: this.preview.size,\n exactCapacity: this.exact.capacity,\n previewCapacity: this.preview.capacity,\n exactTimestampsMs: this.exact.timestampsSnapshot(),\n previewTimestampsMs: this.preview.timestampsSnapshot(),\n bucketMs: this.bucketMs,\n exactEvictions: this.exact.evictions,\n previewEvictions: this.preview.evictions,\n bucketCollapses: this.exact.bucketCollapses + this.preview.bucketCollapses,\n exactFrameWidth: this.exact.width,\n exactFrameHeight: this.exact.height,\n previewFrameWidth: this.preview.width,\n previewFrameHeight: this.preview.height,\n exactBudgetBytes: this.exactBudgetBytes,\n };\n }\n }\n /**\n * One cache tier: a fixed-capacity, MRU-ordered set of OffscreenCanvas copies\n * keyed by whole-millisecond timestamp. Overflow evicts the least-recently-used\n * entry; only re-decodes of one frame share a key, since two source frames a\n * millisecond apart would need a rate past 500fps. Lookups match on the stored\n * frame's true timestamp, so a cache-served frame carries the same timestamp a\n * fresh decode would.\n */\n class TierStore {\n capacity;\n width;\n height;\n /** MRU-ordered bucket keys. Index 0 is the LRU, the last index is the MRU. */\n keys = [];\n entries = new Map();\n /** Canvases released by eviction, held for the next put to fill. */\n spare = [];\n evictionCount = 0;\n bucketCollapseCount = 0;\n /** Smallest gap between two stored frames, which is the source frame interval\n * once neighbours are resident. Learned from the timestamps the tier is\n * handed, so it is what the source really did rather than a declared rate.\n * Null until two frames have landed. */\n observedIntervalMs = null;\n constructor(capacity, width, height) {\n this.capacity = capacity;\n this.width = width;\n this.height = height;\n }\n /** Frames dropped to the LRU policy; a cache-pressure signal for diagnostics. */\n get evictions() {\n return this.evictionCount;\n }\n /** Puts that landed in an occupied bucket (re-decodes collapsed to one slot). */\n get bucketCollapses() {\n return this.bucketCollapseCount;\n }\n put(timestampMs, src, srcWidth, srcHeight) {\n // capacity 0 is the disabled-tier shape; skip the OffscreenCanvas alloc.\n if (this.capacity === 0)\n return;\n const key = this.key(timestampMs);\n const existing = this.entries.get(key);\n // Allocating an accelerated canvas at these sizes costs several times the\n // blit that fills it, so an evicted slot's canvas is reused rather than\n // freed and reallocated on the next put.\n const canvas = existing?.canvas ??\n this.spare.pop() ??\n new OffscreenCanvas(this.width, this.height);\n // Cached frames are opaque video; alpha:false skips per-pixel blend.\n const ctx = canvas.getContext(\"2d\", { alpha: false });\n if (!ctx)\n return;\n ctx.clearRect(0, 0, this.width, this.height);\n // A live sample draws (and scales) itself into the blit and is never\n // retained; a canvas/image goes through drawImage. Either way the stored\n // pixels are the tier-sized blit, not the source.\n if (isSample(src)) {\n src.draw(ctx, 0, 0, this.width, this.height);\n }\n else {\n ctx.drawImage(src, 0, 0, srcWidth, srcHeight, 0, 0, this.width, this.height);\n }\n if (existing) {\n existing.timestampMs = timestampMs;\n this.bump(key);\n this.bucketCollapseCount += 1;\n }\n else {\n this.observeSpacing(timestampMs);\n this.entries.set(key, { canvas, timestampMs });\n this.keys.push(key);\n if (this.keys.length > this.capacity) {\n const lru = this.keys.shift();\n if (lru !== undefined) {\n const dropped = this.entries.get(lru);\n this.entries.delete(lru);\n if (dropped)\n this.spare.push(dropped.canvas);\n this.evictionCount += 1;\n }\n }\n }\n }\n /**\n * Nearest resident frame to `timestampMs` within `tolMs`, or null.\n *\n * `atOrBefore` restricts it to frames at or before the target, which is the\n * contract a decode answers a seek with. Without it the tier can answer with\n * the frame AFTER the target, one no decode would ever return, so the same\n * pointer position paints a different frame depending on whether the cache or\n * the decoder served it, and the picture steps forward and back as the two\n * alternate.\n */\n get(timestampMs, tolMs, atOrBefore = false) {\n const best = this.nearest(timestampMs, atOrBefore);\n if (!best || best.delta > tolMs)\n return null;\n const entry = this.entries.get(best.key);\n if (!entry)\n return null;\n this.bump(best.key);\n return { canvas: entry.canvas, timestampMs: entry.timestampMs };\n }\n /** Promotes the entry nearest `timestampMs` to most-recently-used. Scans\n * rather than keys off the timestamp: callers pass a gesture position, which\n * is not a frame timestamp, so a keyed lookup would miss the very frame on\n * screen. No accounting; it is not a lookup. */\n touch(timestampMs) {\n const best = this.nearest(timestampMs, false);\n if (best)\n this.bump(best.key);\n }\n clear() {\n this.keys.length = 0;\n this.entries.clear();\n this.spare.length = 0;\n this.observedIntervalMs = null;\n }\n get size() {\n return this.keys.length;\n }\n /** True timestamps (ms) of resident frames, for diagnostics. Bounded by capacity. */\n timestampsSnapshot() {\n return Array.from(this.entries.values(), (entry) => entry.timestampMs);\n }\n bump(key) {\n const idx = this.keys.indexOf(key);\n if (idx === -1 || idx === this.keys.length - 1)\n return;\n this.keys.splice(idx, 1);\n this.keys.push(key);\n }\n /**\n * Best candidate for `timestampMs`, or null when nothing qualifies.\n *\n * Under `atOrBefore` a stored frame stands for the span from its own\n * timestamp up to the next source frame; a target past that span belongs to\n * the next frame, which is what a decode for it returns. Answering it from\n * this tier would paint a frame no decode for that time ever produces, so a\n * candidate outside its own frame's span is not eligible however generous the\n * caller's tolerance is. Until the tier has seen two frames it has no spacing\n * to bound with and the caller's tolerance stands alone.\n */\n nearest(timestampMs, atOrBefore) {\n const spanMs = atOrBefore\n ? (this.observedIntervalMs ?? Infinity)\n : Infinity;\n let bestKey = null;\n let bestDelta = Infinity;\n for (const key of this.keys) {\n const entry = this.entries.get(key);\n if (!entry)\n continue;\n // Against the frame's real timestamp, not its key: the key is\n // rounded, so it can sit the far side of the target from the frame it\n // holds.\n if (atOrBefore && entry.timestampMs > timestampMs + BUCKET_EPSILON_MS)\n continue;\n const delta = Math.abs(entry.timestampMs - timestampMs);\n if (delta >= spanMs)\n continue;\n if (delta < bestDelta) {\n bestDelta = delta;\n bestKey = key;\n }\n }\n return bestKey === null ? null : { key: bestKey, delta: bestDelta };\n }\n /** Narrows the learned frame interval to the smallest gap seen so far. It\n * only ever tightens, so a tier that has watched an unrepresentative stretch\n * of a variable-rate source bounds lookups too tightly (a decode) rather\n * than too loosely (the wrong frame). */\n observeSpacing(timestampMs) {\n for (const entry of this.entries.values()) {\n const gap = Math.abs(entry.timestampMs - timestampMs);\n if (gap < MIN_LEARNABLE_INTERVAL_MS)\n continue;\n if (this.observedIntervalMs === null || gap < this.observedIntervalMs) {\n this.observedIntervalMs = gap;\n }\n }\n }\n key(timestampMs) {\n return Math.round(timestampMs);\n }\n }\n\n /**\n * Lazy index of a video track's keyframe timestamps, in seconds.\n *\n * Seeking decodes forward from the keyframe at-or-before the target, so every\n * seek needs that anchor. Resolving one costs a container round-trip, so the\n * index remembers each keyframe it discovers; repeated seeks around the same\n * region then resolve synchronously through `cachedAtOrBefore`.\n */\n // Only timestamps are read, never frame bytes, so every probe is metadata-only.\n const METADATA_ONLY = { metadataOnly: true };\n class KeyframeIndex {\n probe;\n times = [];\n probeRoundTrips = 0;\n /** Set once a full walk has completed, so a second call is a free no-op. */\n fullyIndexed = false;\n constructor(probe) {\n this.probe = probe;\n }\n /** Keyframe timestamps discovered so far, ascending and deduplicated. */\n get known() {\n return this.times;\n }\n /** Container queries the index has made; the rest resolve from memory. */\n get probeCount() {\n return this.probeRoundTrips;\n }\n /**\n * GOP-gap distribution over the keyframes discovered so far. Pass the track\n * duration so density reflects keyframes across the whole clip, not just the\n * clustered swept span; without it a few discovered keyframes in one region\n * would read as a high density that does not hold track-wide.\n */\n gopStats(durationS) {\n const t = this.times;\n const n = t.length;\n if (n < 2) {\n return {\n count: n,\n avgGopS: 0,\n maxGopS: 0,\n minGopS: 0,\n stddevS: 0,\n densityPerS: 0,\n };\n }\n const gaps = n - 1;\n let sum = 0;\n let max = -Infinity;\n let min = Infinity;\n for (let i = 1; i < n; i++) {\n const gap = t[i] - t[i - 1];\n sum += gap;\n if (gap > max)\n max = gap;\n if (gap < min)\n min = gap;\n }\n const avg = sum / gaps;\n let variance = 0;\n for (let i = 1; i < n; i++) {\n const d = t[i] - t[i - 1] - avg;\n variance += d * d;\n }\n const span = durationS && durationS > 0 ? durationS : t[n - 1] - t[0];\n return {\n count: n,\n avgGopS: avg,\n maxGopS: max,\n minGopS: min,\n stddevS: Math.sqrt(variance / gaps),\n densityPerS: span > 0 ? gaps / span : 0,\n };\n }\n /**\n * Nearest keyframe at or before `tSec`, the anchor a seek decodes forward\n * from. Null when `tSec` precedes the first keyframe.\n */\n async keyframeAtOrBefore(tSec) {\n this.probeRoundTrips++;\n const packet = await this.probe.getKeyPacket(tSec, METADATA_ONLY);\n if (!packet)\n return null;\n this.record(packet.timestamp);\n return packet.timestamp;\n }\n /**\n * First keyframe strictly after `tSec`, the boundary a forward decode runs\n * up to. Null when no keyframe follows, or when `tSec` precedes the first\n * keyframe (no anchor to step from).\n */\n async nextKeyframeAfter(tSec) {\n this.probeRoundTrips++;\n const anchor = await this.probe.getKeyPacket(tSec, METADATA_ONLY);\n if (!anchor)\n return null;\n this.record(anchor.timestamp);\n this.probeRoundTrips++;\n const next = await this.probe.getNextKeyPacket(anchor, METADATA_ONLY);\n if (!next)\n return null;\n this.record(next.timestamp);\n return next.timestamp;\n }\n /**\n * Every keyframe needed to decode the window `[startS, endS]`: the anchor at\n * or before `startS` plus each keyframe up to `endS`. Empty when `startS`\n * precedes the first keyframe.\n */\n async keyframesCovering(startS, endS) {\n this.probeRoundTrips++;\n let packet = await this.probe.getKeyPacket(startS, METADATA_ONLY);\n if (!packet)\n return [];\n const covering = [];\n while (packet && packet.timestamp <= endS) {\n this.record(packet.timestamp);\n covering.push(packet.timestamp);\n this.probeRoundTrips++;\n packet = await this.probe.getNextKeyPacket(packet, METADATA_ONLY);\n }\n return covering;\n }\n /**\n * Walks the whole track once, recording every keyframe timestamp. Metadata\n * only: it threads the same getKeyPacket / getNextKeyPacket pattern as the\n * lazy probes, so it never decodes a pixel. Idempotent: a second call after a\n * completed walk returns at once. Meant for the diagnostics lane, off the hot\n * seek path, so the purple lane reflects the whole file rather than only the\n * regions a scrub has visited.\n */\n async ensureFullyIndexed(fromS = 0) {\n if (this.fullyIndexed)\n return;\n this.probeRoundTrips++;\n let packet = await this.probe.getKeyPacket(fromS, METADATA_ONLY);\n while (packet) {\n this.record(packet.timestamp);\n this.probeRoundTrips++;\n packet = await this.probe.getNextKeyPacket(packet, METADATA_ONLY);\n }\n this.fullyIndexed = true;\n }\n /**\n * Largest discovered keyframe at or before `tSec`, resolved synchronously\n * from memory. Null when nothing at or before `tSec` has been discovered\n * yet; callers fall back to the async probes above.\n */\n cachedAtOrBefore(tSec) {\n const i = floorIndex(this.times, tSec);\n return i < 0 ? null : this.times[i];\n }\n record(t) {\n const i = lowerBound(this.times, t);\n if (i < this.times.length && this.times[i] === t)\n return;\n this.times.splice(i, 0, t);\n }\n }\n /** First index whose value is `>= target` (insertion point for `target`). */\n function lowerBound(values, target) {\n let lo = 0;\n let hi = values.length;\n while (lo < hi) {\n const mid = (lo + hi) >>> 1;\n if (values[mid] < target)\n lo = mid + 1;\n else\n hi = mid;\n }\n return lo;\n }\n /** Largest index whose value is `<= target`, or -1 when all exceed `target`. */\n function floorIndex(values, target) {\n let lo = 0;\n let hi = values.length;\n while (lo < hi) {\n const mid = (lo + hi) >>> 1;\n if (values[mid] <= target)\n lo = mid + 1;\n else\n hi = mid;\n }\n return lo - 1;\n }\n\n const DEFAULT_SAMPLE_CAPACITY = 8;\n /** Samples a gesture needs before its direction is asserted at all. Two\n * describe a single step, and a heading change resets the ring to one, so two\n * is exactly what a stray backward pixel leaves behind. */\n const MIN_SAMPLES_FOR_HEADING = 3;\n /**\n * Reads which way a scrub gesture is travelling, so a prefetch window can spend\n * its budget on the side of the playhead the hand is moving toward.\n *\n * The heading spans a bounded ring of recent samples, so one jittery pointer\n * move cannot swing it. A step opposing the ring's heading drops the samples\n * behind it, leaving the reversal as the only evidence of where the gesture is\n * going.\n */\n class ScrubTrajectory {\n capacity;\n positionsS;\n timesMs;\n count = 0;\n writeIndex = 0;\n constructor(capacity = DEFAULT_SAMPLE_CAPACITY) {\n this.capacity = Math.max(2, Math.floor(capacity));\n this.positionsS = new Float64Array(this.capacity);\n this.timesMs = new Float64Array(this.capacity);\n }\n sample(positionS, atMs) {\n if (this.count > 0) {\n const latest = this.indexFromNewest(0);\n // A clock that has not advanced would divide the step by a zero interval.\n if (atMs <= this.timesMs[latest]) {\n this.positionsS[latest] = positionS;\n return;\n }\n // The samples behind a heading change describe a gesture that is over;\n // keeping them in keeps reporting the direction the hand left.\n //\n // Only against an established heading, though. Right after a reset\n // the ring holds the reversing step and nothing else, so the net\n // displacement IS that step: the hand resuming its original direction\n // then reads as a second reversal and resets again, and a single\n // twitch can suppress the heading for the rest of the gesture. With a\n // heading behind it, the third sample joins the ring and the evidence\n // decides instead: resuming forward outweighs the twitch, a real\n // reversal does not.\n if (this.count >= MIN_SAMPLES_FOR_HEADING &&\n (positionS - this.positionsS[latest]) * this.netDisplacementS() < 0) {\n this.count = 1;\n }\n }\n this.positionsS[this.writeIndex] = positionS;\n this.timesMs[this.writeIndex] = atMs;\n this.writeIndex = (this.writeIndex + 1) % this.capacity;\n if (this.count < this.capacity)\n this.count += 1;\n }\n /**\n * Which way the gesture is travelling: 1 forward, -1 backward, 0 when there\n * is not enough of a gesture to say. The direction survives being uncertain\n * about how far, which is all a window split needs.\n */\n heading() {\n // A direction taken from two samples is a direction taken from one step,\n // which jitter alone can produce.\n if (this.count < MIN_SAMPLES_FOR_HEADING)\n return 0;\n const net = this.netDisplacementS();\n if (net > 0)\n return 1;\n if (net < 0)\n return -1;\n return 0;\n }\n reset() {\n this.count = 0;\n this.writeIndex = 0;\n }\n netDisplacementS() {\n return (this.positionsS[this.indexFromNewest(0)] -\n this.positionsS[this.indexFromOldest(0)]);\n }\n indexFromNewest(back) {\n return (this.writeIndex - 1 - back + this.capacity) % this.capacity;\n }\n indexFromOldest(forward) {\n return ((this.writeIndex - this.count + forward + this.capacity) % this.capacity);\n }\n }\n\n const DEFAULT_EXACT_TOLERANCE_MS = 50;\n const DEFAULT_PREVIEW_TOLERANCE_MS = 250;\n /** Watchdog-race sentinel; a distinct symbol so it never collides with a frame. */\n const HUNG = Symbol(\"decode-hung\");\n /** Cancellation-race sentinel, in the same shape as HUNG. */\n const ABANDONED = Symbol(\"prefetch-abandoned\");\n const UNANSWERED = { answered: false };\n /** Frame interval fallback when the track exposes no measured rate. */\n const FALLBACK_FPS$1 = 30;\n /** Rebuild attempts before a source is called dead rather than unlucky. */\n const MAX_FAILED_REBUILDS = 3;\n /**\n * Decode requests the live provider may leave unanswered before its decoder is\n * called stalled rather than unlucky. Counted in requests, and only requests\n * issued against the provider currently live: an idle engine owes nothing and\n * cannot reach the budget however long it sits, and a decode abandoned by a\n * rebuild is not charged to the rebuilt provider. Any answered decode clears it,\n * so a decoder that is working cannot be torn down by it.\n *\n * Where the source can be re-opened at all, a rebuild runs between each of\n * these, so reaching the budget means the source was re-opened that many times\n * and the decoder still answered nothing.\n */\n const MAX_UNANSWERED_DECODES = 6;\n /** Frames prefetched each side of the playhead while scrubbing. */\n const SCRUB_WINDOW_FRAMES = 6;\n /** Share of a scrub window spent ahead of a gesture that has a heading. The rest\n * covers the ground just behind it, which is what a reversal lands on before the\n * new heading is established. */\n const WINDOW_LEAD_FRACTION = 0.75;\n /** Frames prefetched each side while stepping; tighter than a scrub. */\n const STEP_WINDOW_FRAMES = 2;\n /** Seconds each side of the playhead the idle keyframe sweep covers. */\n const PREVIEW_SWEEP_SPAN_S = 4;\n /** Keyframes one idle sweep decodes; a short-GOP source offers far more inside\n * the span than a background walk should hold the decoder for. */\n const PREVIEW_SWEEP_MAX_FRAMES = 8;\n /** Spends the sweep budget nearest the playhead, then returns to ascending\n * order: the decoder flushes and re-walks the GOP on a backward step, so the\n * batch it receives has to climb even though the budget was spent outward. */\n function selectPreviewTargets(keyframesS, aroundS) {\n return [...keyframesS]\n .sort((a, b) => Math.abs(a - aroundS) - Math.abs(b - aroundS))\n .slice(0, PREVIEW_SWEEP_MAX_FRAMES)\n .sort((a, b) => a - b);\n }\n /** How the consumer is moving, which shapes the prefetch window. */\n var AccessMode;\n (function (AccessMode) {\n AccessMode[\"Idle\"] = \"idle\";\n AccessMode[\"Scrubbing\"] = \"scrubbing\";\n AccessMode[\"Seeking\"] = \"seeking\";\n AccessMode[\"Stepping\"] = \"stepping\";\n AccessMode[\"Playing\"] = \"playing\";\n })(AccessMode || (AccessMode = {}));\n /**\n * Caching scrub cursor. Wraps the same decode primitives as CanvasSinkScrubCursor\n * but routes every random-access seek through a two-tier FrameCache and prefetches\n * a decode window around the playhead so the next move is already in hand.\n *\n * A scrub first reads the cache. An exact hit needs no decode. A preview hit\n * paints a coarse frame at once, then a crisp decode follows. A miss decodes\n * straight away. Once the foreground frame settles, a background sweep fills the\n * cache around the playhead, shaped by how the consumer is moving: neighbor\n * frames while scrubbing or stepping, a coarse keyframe pass while idle.\n *\n * A new gesture signals the sweep to bail and proceeds without waiting for it, so\n * speculative work never sits on the critical path; mediabunny builds a decoder\n * per iterator, so a sweep still unwinding alongside a foreground decode costs\n * memory, not correctness. Every decoded frame is copied into the cache, never\n * held as a raw VideoFrame, so the decoder is never pinned.\n */\n class DecodeScheduler {\n provider;\n cache;\n keyframeIndex;\n trackInfo;\n exactTolMs;\n previewTolMs;\n now;\n /** Re-opens the source after a hung decode, or null when it cannot be re-opened. */\n reopen;\n decodeHangTimeoutMs;\n seedHangTimeoutMs;\n /** Single-flight latch: a watchdog recovery already running. A second hang\n * during the rebuild awaits the same recovery rather than racing a second\n * dispose/rebuild of the provider. */\n recovering = null;\n /** True once a hang hit a non-re-openable source: the provider is dead and\n * could not be rebuilt, so decodes no-op rather than hang. Surfaced via stats\n * so a consumer can show a degraded state instead of a silent freeze. */\n decoderDead = false;\n /** Consecutive failed rebuilds. One is a transient read; a run of them is a\n * source that cannot be re-opened, which is what decoderDead describes. */\n failedRebuilds = 0;\n /** True once the decoder is judged unable to decode this source at all. See\n * SchedulerStats.decoderStalled; latched, and it stops the rebuild loop. */\n decoderStalled = false;\n /** The failure markStalled latched, so a caller still awaiting a decode is\n * handed the same one the consumer was told about rather than a parallel\n * account of it. */\n stallFailure = null;\n /** Decode requests the live provider has left unanswered in a row; see\n * MAX_UNANSWERED_DECODES. */\n unansweredDecodes = 0;\n onDecodeFailure;\n iterator = null;\n nextInFlight = Promise.resolve();\n nextPending = 0;\n currentState = ScrubCursorState.Closed;\n listeners = new Set();\n idleResolvers = [];\n settleResolvers = [];\n pendingSeekTargetS = null;\n pendingSeekKeyOnly = false;\n seekDraining = false;\n closed = false;\n lastEmittedFrame = null;\n mode = AccessMode.Idle;\n /** Recent scrub motion. Only its heading is read, which is what decides the\n * side of the playhead a scrub window spends most of its budget on. */\n trajectory = new ScrubTrajectory();\n /** Outstanding background prefetch chain, or null once it unwinds. Awaited\n * only by close(), so teardown cannot outrun a sweep still holding a decoder. */\n prefetchTask = null;\n /** Bumped when a running prefetch is cancelled; that prefetch bails when it\n * sees the token move, which is how cancellation propagates. */\n prefetchGen = 0;\n /** Whether a sweep is still depending on the live token. Cleared both by the\n * cancellation that supersedes the sweep and by the sweep finishing. */\n prefetchArmed = false;\n /** Woken when the token moves, so a sweep parked on a decode unwinds at the\n * gesture rather than at the end of the decode it is parked on. */\n abandonResolvers = [];\n scrubSamples = 0;\n scrubSumMs = 0;\n scrubMaxMs = 0;\n scrubLastMs = 0;\n /** Ring of recent scrub-decode latencies for p50/p95. Overwrite-oldest so the\n * percentile tracks the recent region, not an all-time tail. One push/seek. */\n scrubLatencyRing = new Float64Array(DIAGNOSTICS.SCRUB_LATENCY_RING);\n scrubLatencyCount = 0;\n scrubLatencyHead = 0;\n targetVsLandedLastMs = 0;\n timeToCrispLastMs = 0;\n // Per-decode / per-seek counters. Incremented only at points that already\n // run once per decode or seek, never per-pixel or per-rAF, so they cost\n // nothing the engine wasn't already paying.\n foregroundDecodes = 0;\n prefetchExactFrames = 0;\n prefetchPreviewFrames = 0;\n keyframeAnchoredEmits = 0;\n exactSeeks = 0;\n keySeeks = 0;\n seekCoalesceOverwrites = 0;\n constructor(options) {\n this.provider = openFrameProvider(options.source);\n this.cache = options.cache;\n this.keyframeIndex = new KeyframeIndex(this.provider.keyframeProbe);\n this.trackInfo = this.provider.track;\n this.exactTolMs = options.exactToleranceMs ?? DEFAULT_EXACT_TOLERANCE_MS;\n this.previewTolMs =\n options.previewToleranceMs ?? DEFAULT_PREVIEW_TOLERANCE_MS;\n this.now = options.now ?? (() => performance.now());\n this.reopen = options.reopen ?? null;\n this.decodeHangTimeoutMs =\n options.decodeHangTimeoutMs ?? HANG_RECOVERY.DECODE_HANG_TIMEOUT_MS;\n this.seedHangTimeoutMs =\n options.seedHangTimeoutMs ?? HANG_RECOVERY.SEED_HANG_TIMEOUT_MS;\n this.onDecodeFailure = options.onDecodeFailure ?? null;\n }\n get state() {\n return this.currentState;\n }\n get track() {\n return this.trackInfo;\n }\n get playReadAhead() {\n return this.provider.playReadAhead;\n }\n get isIdle() {\n return (this.currentState === ScrubCursorState.Idle &&\n this.nextPending === 0 &&\n this.pendingSeekTargetS === null &&\n !this.seekDraining);\n }\n async open() {\n // Seed at the track's real first timestamp, not a hardcoded 0: a trimmed\n // or offset clip's first frame is not at the origin, so getFrame(0) would\n // land before the first sample and seed nothing.\n const origin = this.trackInfo.firstTimestampS;\n const seed = await this.seedFirstFrame(origin);\n if (this.closed) {\n if (seed?.kind === \"sample\")\n seed.sample.close();\n return;\n }\n if (seed)\n this.emitDecoded(seed, false);\n this.currentState = ScrubCursorState.Idle;\n // Seed coarse coverage so the first distant scrub lands a preview.\n this.schedulePrefetch(origin);\n }\n /**\n * The first frame, on the seed ceiling and its own attempt budget.\n *\n * A decoder that accepts this decode and never answers it is the one failure\n * the rest of the machinery cannot see: nothing has been asked of it twice\n * yet, so no request budget can run out, and open() is what the load path\n * awaits, so the wait ends only when the caller above gives up on the whole\n * worker. Bounding it here turns that into a decoder failure named at the\n * point it happened.\n *\n * Attempts are spent against rebuilt providers wherever the source can be\n * re-opened, so exhausting them means the source was re-opened that many\n * times and the first frame never arrived.\n */\n async seedFirstFrame(origin) {\n for (let attempt = 0; attempt < HANG_RECOVERY.SEED_DECODE_ATTEMPTS; attempt++) {\n const seed = await this.raceWatchdog(this.provider.getFrame(origin), true, this.seedHangTimeoutMs);\n // A decoder that answers with no frame has answered: this source\n // carries nothing at its own first timestamp, which is a track worth\n // opening empty rather than a decoder worth condemning.\n if (seed.answered)\n return seed.value;\n if (this.closed)\n return null;\n // A decoder the runtime has already condemned answers no differently\n // on a second ask, and the ask lands on a provider about to be\n // disposed. An attempt is worth spending only where a rebuild can\n // change the answer.\n if (this.decoderStalled || this.decoderDead)\n break;\n await this.recovering;\n }\n const failure = this.stallFailure ??\n new VideoEngineError(VideoEngineErrorCode.DecoderStalled, `video decode: the first frame went undecoded across ${HANG_RECOVERY.SEED_DECODE_ATTEMPTS} attempts on the ${this.provider.decodePath} path`);\n this.markStalled(failure);\n throw failure;\n }\n /**\n * Synchronous best-effort lookup the render loop calls on scrub to paint a\n * frame this same tick. Pure: it reads the cache and returns a paintable\n * frame without touching decode or cursor state.\n */\n peekCached(timeMs) {\n // Non-counting: the authoritative seek for this same gesture also reads\n // the cache, so counting both would double-book every scrub's hit rate.\n const hit = this.cache.peek(timeMs, this.exactTolMs, this.previewTolMs);\n return hit ? this.frameFromCache(hit) : null;\n }\n /**\n * Resolves once foreground work has settled and any background prefetch\n * kicked by that settle has drained. The plain idle() contract excludes\n * prefetch so commits resolve promptly; this awaits full quiescence.\n */\n async whenSettled() {\n await this.idle();\n await this.prefetchTask?.catch(() => undefined);\n }\n /**\n * Walks the whole track's keyframes into the index, metadata only, so the\n * diagnostics keyframe lane fills out across the entire file. Decoder-less\n * (it rides the EncodedPacketSink probe, never the frame sink), so it does\n * not contend with the foreground decode. Idempotent and best-effort: a\n * failure just leaves the lane as sparse as the lazy probes made it.\n */\n async ensureKeyframeIndex() {\n if (this.closed)\n return;\n try {\n await this.keyframeIndex.ensureFullyIndexed(this.trackInfo.firstTimestampS);\n }\n catch {\n // Best-effort: metadata I/O only, so a failure costs nothing but a\n // sparser diagnostics lane.\n }\n }\n getStats() {\n return {\n mode: this.mode,\n decodePath: this.provider.decodePath,\n cache: this.cache.stats,\n scrub: {\n samples: this.scrubSamples,\n lastMs: this.scrubLastMs,\n avgMs: this.scrubSamples > 0 ? this.scrubSumMs / this.scrubSamples : 0,\n maxMs: this.scrubMaxMs,\n p50Ms: this.scrubPercentile(0.5),\n p95Ms: this.scrubPercentile(0.95),\n targetVsLandedMs: this.targetVsLandedLastMs,\n timeToCrispMs: this.timeToCrispLastMs,\n },\n decode: {\n foreground: this.foregroundDecodes,\n prefetchExact: this.prefetchExactFrames,\n prefetchPreview: this.prefetchPreviewFrames,\n framesOut: this.provider.framesDecoded(),\n keyframeAnchored: this.keyframeAnchoredEmits,\n nextPending: this.nextPending,\n },\n seek: {\n exact: this.exactSeeks,\n key: this.keySeeks,\n coalesceDepth: this.seekCoalesceOverwrites,\n },\n gop: this.keyframeIndex.gopStats(this.trackInfo.durationS),\n probeRoundTrips: this.keyframeIndex.probeCount,\n keyframesMs: this.keyframeIndex.known.map((s) => Math.round(s * 1000)),\n prefetch: this.prefetchPlanMs(),\n prefetchState: {\n inFlight: this.prefetchTask !== null,\n generation: this.prefetchGen,\n },\n exactToleranceMs: this.exactTolMs,\n previewToleranceMs: this.previewTolMs,\n decoderDead: this.decoderDead,\n decoderStalled: this.decoderStalled,\n drain: {\n draining: this.seekDraining,\n pendingTargetMs: this.pendingSeekTargetS === null\n ? null\n : Math.round(this.pendingSeekTargetS * 1000),\n recovering: this.recovering !== null,\n },\n };\n }\n /** Percentile over the bounded recent-latency ring. Copies the live samples\n * into a small scratch array and sorts; bounded by SCRUB_LATENCY_RING, and\n * this runs only at snapshot time, never per seek. */\n scrubPercentile(q) {\n const n = Math.min(this.scrubLatencyCount, this.scrubLatencyRing.length);\n if (n === 0)\n return 0;\n const sample = Array.from(this.scrubLatencyRing.subarray(0, n)).sort((a, b) => a - b);\n const idx = Math.min(n - 1, Math.max(0, Math.ceil(q * n) - 1));\n return sample[idx];\n }\n /** The targets the next prefetch sweep would decode for the current mode\n * and position; a pure recompute from existing state. The Idle plan is\n * built from the keyframes discovered so far, so it grows as the index\n * learns, exactly like the sweep it predicts. */\n prefetchPlanMs() {\n if (this.mode === AccessMode.Playing)\n return null;\n const pos = this.currentPositionS();\n const targetsS = this.mode === AccessMode.Idle\n ? selectPreviewTargets(this.keyframeIndex.known.filter((k) => Math.abs(k - pos) <= PREVIEW_SWEEP_SPAN_S), pos)\n : this.windowTimestamps(pos);\n if (!targetsS.length)\n return null;\n // Ascending for the axis; the sweep orders its own copy for the decoder.\n return {\n targetsMs: targetsS\n .map((t) => Math.round(t * 1000))\n .sort((a, b) => a - b),\n };\n }\n seekTo(timestamp, intent = \"gesture\") {\n timestamp = this.clampToOrigin(timestamp);\n if (intent === \"gesture\") {\n this.enterMode(AccessMode.Scrubbing);\n this.trajectory.sample(timestamp, this.now());\n }\n else {\n this.enterMode(AccessMode.Seeking);\n }\n // A pending target overwritten before it drained means the drag outran\n // the decoder; that intermediate position never paints.\n if (this.pendingSeekTargetS !== null)\n this.seekCoalesceOverwrites += 1;\n this.pendingSeekTargetS = timestamp;\n this.pendingSeekKeyOnly = false;\n void this.drainSeek();\n }\n seekToKey(timestamp) {\n timestamp = this.clampToOrigin(timestamp);\n this.enterMode(AccessMode.Scrubbing);\n this.trajectory.sample(timestamp, this.now());\n if (this.pendingSeekTargetS !== null)\n this.seekCoalesceOverwrites += 1;\n this.pendingSeekTargetS = timestamp;\n this.pendingSeekKeyOnly = true;\n void this.drainSeek();\n }\n /** Floors a seek target to the track's first timestamp. A seek below the\n * origin (an offset/trimmed clip) has no sample to land on, so it would\n * emit nothing; clamping lands it on the first frame instead. */\n clampToOrigin(timestamp) {\n return asSec(Math.max(this.trackInfo.firstTimestampS, timestamp));\n }\n /** Motion sampled across a pause, a step, or a stretch of playback describes\n * no hand still on the timeline, so leaving a scrub drops the gesture. */\n enterMode(mode) {\n if (mode !== AccessMode.Scrubbing)\n this.trajectory.reset();\n this.mode = mode;\n }\n attachPlay(startS) {\n if (this.closed)\n return;\n this.enterMode(AccessMode.Playing);\n // Signal any running sweep to bail; the first pull awaits its teardown.\n this.abandonPrefetch();\n void this.iterator?.return();\n this.iterator = this.provider.frames(startS);\n // Reset the in-flight latch for the new session. An old-iterator pull may\n // still be settling; its finally is iterator-identity-aware (see next()),\n // so it will not decrement this fresh counter and drive it negative.\n this.nextPending = 0;\n // And start a fresh chain. The chain is strictly FIFO, so a pull left\n // parked on the previous session's generator would hold back every pull\n // issued against this one, including the ones re-priming the pump.\n this.nextInFlight = Promise.resolve();\n }\n detachPlay() {\n void this.iterator?.return();\n this.iterator = null;\n // A pull in flight at detach never decrements, since its finally only\n // fires for the iterator it was issued against. Left stranded, the count\n // reports a pull that will never land and isIdle never reads true again.\n this.nextPending = 0;\n this.enterMode(AccessMode.Idle);\n this.schedulePrefetch(this.currentPositionS());\n }\n next() {\n if (!this.iterator)\n return;\n // The consumer stops asking once its queue reaches the depth it wants, so\n // this ceiling only stops a runaway. Capping it at one starves the queue\n // it exists to fill: the consumer's refill call runs inside the pull it is\n // chaining from, before that pull's finally has decremented the count.\n if (this.nextPending >= PLAYBACK.READ_AHEAD_CANVAS)\n return;\n // Bind this pull to the iterator it was issued against. attachPlay resets\n // nextPending to 0 for the new session, so an orphaned pull from a prior\n // iterator must not decrement the fresh counter, which would drive it\n // negative. Decrement only when the session this pull belongs to is still\n // the live one.\n const iteratorAtIssue = this.iterator;\n this.nextPending++;\n this.nextInFlight = this.nextInFlight\n .catch(() => undefined)\n .then(() => this.pullForwardOne())\n .finally(() => {\n if (iteratorAtIssue === this.iterator)\n this.nextPending--;\n });\n }\n /**\n * Decodes one named frame of the source and emits it.\n *\n * The retrieval is at-or-before a time the frame table produced, and the\n * demuxer normalises that back to the same integer tick it came from, so it\n * lands on that frame and no other. Nothing here walks, compares or skips,\n * so a step across a frame boundary needs no epsilon, and a burst of steps\n * reuses whatever decode position the session already holds, with no\n * iterator opened per press.\n */\n async seekToFrame(frame) {\n if (this.closed)\n return null;\n this.enterMode(AccessMode.Stepping);\n this.abandonPrefetch();\n const decoded = await this.withDecodeWatchdog(this.provider.getFrame(this.trackInfo.timeline.timeAt(frame.index)));\n if (this.closed || !decoded) {\n if (decoded?.kind === \"sample\")\n decoded.sample.close();\n return null;\n }\n this.emitDecoded(decoded, false);\n this.schedulePrefetch(this.currentPositionS());\n return this.lastEmittedFrame;\n }\n idle() {\n if (this.isIdle)\n return Promise.resolve();\n return new Promise((resolve) => this.idleResolvers.push(resolve));\n }\n seekSettled() {\n // seekTo carries no closed guard, so a target armed after close has no\n // drain left to service it.\n if (this.closed)\n return Promise.resolve();\n if (this.pendingSeekTargetS === null && !this.seekDraining)\n return Promise.resolve();\n return new Promise((resolve) => this.settleResolvers.push(resolve));\n }\n subscribe(listener) {\n this.listeners.add(listener);\n if (this.lastEmittedFrame)\n listener(this.lastEmittedFrame);\n return () => {\n this.listeners.delete(listener);\n };\n }\n async close() {\n this.closed = true;\n this.abandonPrefetch();\n this.pendingSeekTargetS = null;\n this.currentState = ScrubCursorState.Closed;\n this.listeners.clear();\n this.flushIdleResolvers();\n this.flushSettleResolvers();\n this.lastEmittedFrame = null;\n // Not awaited: a native async generator queues return() behind whatever\n // next() is running, so awaiting it here ties teardown to a decode that\n // may never settle and the provider below would never be disposed.\n void this.iterator?.return();\n this.iterator = null;\n await this.prefetchTask?.catch(() => undefined);\n this.cache.clear();\n await this.provider.dispose();\n }\n async drainSeek() {\n if (this.seekDraining || this.closed)\n return;\n let settledS = null;\n try {\n // Raised inside the try so the flag cannot outlive the finally that\n // clears it: while it is set, every play pull is dropped.\n this.seekDraining = true;\n this.abandonPrefetch();\n // Yield once so a synchronous burst of seekTo calls collapses to its\n // latest target before the first decode starts. Each intermediate\n // target costs a full keyframe-to-target walk on a long-GOP source.\n await Promise.resolve();\n while (this.pendingSeekTargetS !== null && !this.closed) {\n const t = this.pendingSeekTargetS;\n const keyOnly = this.pendingSeekKeyOnly;\n this.pendingSeekTargetS = null;\n settledS = t;\n await this.runSeek(asSec(t), keyOnly);\n // Released per lap: the loop re-arms for as long as the hand keeps\n // moving, so a waiter held to the bottom of the drain waits out the\n // whole gesture.\n this.flushSettleResolvers();\n }\n }\n finally {\n this.seekDraining = false;\n // Flush even when closed: a resolver registered while the seek was\n // draining would otherwise never fire if close() already ran its one\n // flush, hanging the awaiter. Resolving idle after close is harmless.\n this.flushIdleResolvers();\n this.flushSettleResolvers();\n if (!this.closed && settledS !== null) {\n // One light, mode-appropriate prefetch around the settle (the\n // exact neighbor window while scrubbing). A heavier eager preview\n // sweep here floods the decoder: the next scrub has to cancel and\n // await it, and play collides with it, so scrubbing and play stall.\n this.schedulePrefetch(asSec(settledS));\n }\n }\n }\n async runSeek(t, keyOnly) {\n if (this.closed)\n return;\n this.currentState = ScrubCursorState.Seeking;\n try {\n if (keyOnly) {\n await this.runKeySeek(t);\n }\n else {\n await this.runExactSeek(t);\n }\n }\n finally {\n // Restored on the way out however the seek ended: left on Seeking,\n // isIdle never reads true again and every awaiter of a settled\n // cursor waits for a seek that already finished.\n if (!this.closed)\n this.currentState = ScrubCursorState.Idle;\n }\n }\n async runExactSeek(t) {\n this.exactSeeks += 1;\n const ms = Math.round(t * 1000);\n const cached = this.cache.get(ms, this.exactTolMs, this.previewTolMs);\n const painted = cached !== null && this.worthPainting(cached, ms);\n if (painted)\n this.emitCached(cached);\n // An exact hit is the crisp frame already; a coarse preview hit still\n // owes a full-resolution decode. Record a ~0ms sample for the instant hit\n // so the scrub-latency aggregate reflects every felt scrub, not just the\n // decode-bound ones that would otherwise pin the average.\n if (cached?.tier === FrameTier.Exact) {\n this.recordScrubLatency(0);\n this.targetVsLandedLastMs = ms - cached.timestampMs;\n return;\n }\n // A preview paint started the clock on how long the user stares at a\n // blurry frame before the crisp decode lands.\n const previewPaintedAt = painted && cached.tier === FrameTier.Preview ? this.now() : null;\n const startedAt = this.now();\n const frame = await this.withDecodeWatchdog(this.provider.getFrame(t));\n if (this.closed || !frame) {\n if (frame?.kind === \"sample\")\n frame.sample.close();\n return;\n }\n const nextTargetS = this.pendingSeekTargetS;\n if (nextTargetS !== null && !this.gainsOnTarget(frame, nextTargetS)) {\n this.cacheDecoded(frame);\n }\n else {\n this.emitDecoded(frame, false);\n }\n this.recordScrubLatency(this.now() - startedAt);\n this.targetVsLandedLastMs = ms - Math.round(frame.timestamp * 1000);\n if (previewPaintedAt !== null)\n this.timeToCrispLastMs = this.now() - previewPaintedAt;\n }\n /**\n * Whether a decode that outlived its target still improves the picture,\n * judged from where the gesture has since reached. A drag that outruns the\n * decoder supersedes every decode it starts, and the screen holds something\n * older than all of them, so refusing the lot pins the picture at whatever\n * was up when the drag began.\n *\n * Only a gesture reads this way. A jump has one destination and no\n * intermediate positions worth showing, so a superseded decode there is\n * work for a place nobody is looking.\n */\n gainsOnTarget(decoded, nextTargetS) {\n if (this.mode !== AccessMode.Scrubbing)\n return false;\n const showing = this.lastEmittedFrame;\n return earnsScreen({ timestampMs: Math.round(decoded.timestamp * 1000), quality: \"exact\" }, showing\n ? {\n timestampMs: Math.round(showing.timestampS * 1000),\n quality: showing.quality,\n }\n : null, Math.round(nextTargetS * 1000));\n }\n /**\n * Whether a cached frame earns the screen over what is already on it. A\n * crisp frame always does. A coarse stand-in exists to bridge the wait for a\n * decode, so it earns the screen only by landing closer to the target than\n * what is showing: replacing a crisp frame with a blurry one at the same\n * position is a visible drop in quality for nothing, and on a slow drag,\n * where each step lands within a frame of the last, it repeats several times\n * a second and reads as the picture flickering between sharp and soft.\n */\n worthPainting(cached, targetMs) {\n // A crisp hit is the answer to this seek, so it is always emitted. The\n // emit is how the landing position is reported, not only how pixels\n // reach the screen, and suppressing it would also skip the decode that\n // the early return below is only safe to take because the answer is in\n // hand. Whether those pixels actually repaint is the render loop's call,\n // which applies the same rule at the one place that owns the canvas.\n if (cached.tier === FrameTier.Exact)\n return true;\n // Only the coarse tier reaches here, the crisp one having returned above.\n const showing = this.lastEmittedFrame;\n return earnsScreen({ timestampMs: cached.timestampMs, quality: \"preview\" }, showing\n ? {\n timestampMs: Math.round(showing.timestampS * 1000),\n quality: showing.quality,\n }\n : null, targetMs);\n }\n async runKeySeek(t) {\n this.keySeeks += 1;\n // getFrame already anchors on the enclosing keyframe, so re-anchoring the\n // request collapses every target within a GOP onto that one frame. A\n // source carrying a single keyframe answers its whole timeline with the\n // first frame.\n const frame = await this.withDecodeWatchdog(this.provider.getFrame(t));\n if (this.closed || !frame) {\n if (frame?.kind === \"sample\")\n frame.sample.close();\n return;\n }\n this.emitDecoded(frame, true);\n }\n async pullForwardOne() {\n const iterator = this.iterator;\n if (!iterator || this.closed)\n return;\n if (this.seekDraining)\n return;\n this.abandonPrefetch();\n if (this.closed || iterator !== this.iterator)\n return;\n // Watchdog the play pull too: a hung play decode would otherwise wedge the\n // play pump (nextPending stuck high), so the empty-tick re-pull in the\n // controller could never recover. On timeout this returns null, recovers\n // the decoder, and lets the pump pull again next tick.\n const pulled = await this.withDecodeWatchdog(iterator.next());\n const next = pulled?.value ?? null;\n if (this.closed || iterator !== this.iterator || !next) {\n if (next?.kind === \"sample\")\n next.sample.close();\n return;\n }\n // Seed only the coarse tier: playback puts a frame every interval, which\n // would churn the whole crisp window within a second and evict the\n // neighbors a scrub settles onto. A scrub back over watched footage\n // paints that coarse frame at once and pays one re-anchored decode, which\n // the sync-sample spacing bounds.\n this.emitDecoded(next, false, false);\n }\n /**\n * Bounds a decode so a hung mediabunny decode (which takes no AbortSignal and\n * cannot be cancelled) never wedges the caller. On timeout it returns null and\n * kicks provider recovery; the orphaned decode promise is abandoned, since\n * there is no handle to cancel it. The healthy path resolves before the timer\n * and behaves exactly as a bare await: the loser timer is cleared, so it adds\n * no work to a decode that lands in time.\n *\n * A rejected decode takes the same route as a hung one. Every caller here is\n * mid-gesture with no way to answer a decode failure other than to try again\n * on a working decoder, and a throw would escape the fire-and-forget seek\n * drain and the play pull chain as an unhandled rejection. Taking that route\n * is also why an unanswered decode has to be counted here: the failure is\n * absorbed at this one point, so this is the only place that can tell a\n * decoder having a bad moment from one that will never answer again.\n */\n withDecodeWatchdog(decode) {\n return this.watchdogged(decode, true);\n }\n /**\n * The same watchdog over a container metadata read. Deliberately unbooked:\n * the keyframe probe never touches the decoder, so its answer is no evidence\n * that decoding works, and clearing the stall budget on one would keep a\n * background sweep forgiving a decoder that answers nothing.\n */\n withProbeWatchdog(probe) {\n return this.watchdogged(probe, false);\n }\n async watchdogged(work, booked, timeoutMs = this.decodeHangTimeoutMs) {\n const outcome = await this.raceWatchdog(work, booked, timeoutMs);\n return outcome.answered ? outcome.value : null;\n }\n async raceWatchdog(work, booked, timeoutMs) {\n if (this.decoderDead || this.decoderStalled) {\n // The caller built the decode before this could refuse it, so its\n // rejection still needs an owner even though nobody wants the value.\n void work.catch(() => undefined);\n return UNANSWERED;\n }\n // An abandoned sweep leaves its decode running with this timer still\n // armed. Recovering on it would tear down whatever provider is live when\n // it fires, which by then can be a healthy rebuild.\n const issuedAgainst = this.provider;\n let timer;\n let reason = null;\n const hang = new Promise((resolve) => {\n timer = setTimeout(() => resolve(HUNG), timeoutMs);\n });\n try {\n const result = await Promise.race([\n work.catch((cause) => {\n reason = cause;\n return HUNG;\n }),\n hang,\n ]);\n if (result === HUNG) {\n if (issuedAgainst !== this.provider)\n return UNANSWERED;\n if (booked)\n this.noteUnanswered(reason);\n else\n void this.recoverDecoder();\n return UNANSWERED;\n }\n // The decoder answered, so whatever it was doing before, it is\n // decoding now. A resolved null counts: it is an answer about a\n // position, not a decoder that failed to reach one.\n if (booked)\n this.unansweredDecodes = 0;\n return { answered: true, value: result };\n }\n finally {\n if (timer !== undefined)\n clearTimeout(timer);\n }\n }\n /**\n * Books one decode the live provider failed to answer, and decides whether\n * to rebuild or to give up. A decoder that refuses outright says so in the\n * rejection and there is nothing to rebuild toward; otherwise the runtime\n * rebuilds until the request budget runs out, which is what stops a source\n * that re-opens cleanly and then decodes nothing from being rebuilt forever\n * while every surface reads as healthy.\n */\n noteUnanswered(reason) {\n this.unansweredDecodes += 1;\n if (reason instanceof VideoEngineError &&\n reason.code === VideoEngineErrorCode.DecoderStalled) {\n this.markStalled(reason);\n return;\n }\n if (this.unansweredDecodes >= MAX_UNANSWERED_DECODES) {\n this.markStalled(new VideoEngineError(VideoEngineErrorCode.DecoderStalled, `video decode: ${this.unansweredDecodes} decode requests in a row went unanswered on the ${this.provider.decodePath} path`, reason));\n return;\n }\n void this.recoverDecoder();\n }\n /** Latches the terminal decoder failure and hands it out exactly once. */\n markStalled(error) {\n if (this.decoderStalled)\n return;\n this.decoderStalled = true;\n this.stallFailure = error;\n this.onDecodeFailure?.(error);\n }\n /**\n * Rebuilds the decoder after a hung decode. The old provider is wedged on an\n * uncancellable decode, so it is disposed and a fresh source opened in its\n * place; the keyframe index is repointed at the new probe since disposing the\n * old input invalidated the old one. Single-flight: a second hang during the\n * rebuild awaits the same recovery. When the source cannot be re-opened (a\n * one-shot stream), the decoder is marked dead so later decodes no-op rather\n * than hang, leaving the engine degraded but alive instead of frozen.\n */\n recoverDecoder() {\n if (this.recovering)\n return this.recovering;\n if (this.closed || this.decoderDead || this.decoderStalled)\n return Promise.resolve();\n if (!this.reopen) {\n // No way to re-open this source (a one-shot stream): the wedged decoder\n // cannot be rebuilt, so degrade to a no-op decode state rather than\n // leave the drain flag latched and the engine frozen.\n this.decoderDead = true;\n return Promise.resolve();\n }\n const reopen = this.reopen;\n this.recovering = (async () => {\n const dead = this.provider;\n try {\n const handle = await reopen();\n if (this.closed) {\n await handle.dispose();\n return;\n }\n this.provider = openFrameProvider(handle);\n // Disposing the old input invalidated the old probe, so the index\n // has to be rebuilt against the new one. It starts empty, and\n // nothing else re-walks it: the source's keyframes are exactly\n // where they were, so re-walk them rather than leaving the\n // consumer to believe the file has none.\n this.keyframeIndex = new KeyframeIndex(this.provider.keyframeProbe);\n void this.ensureKeyframeIndex();\n // The play iterator streams from the provider about to be disposed,\n // and the pump's staleness guards compare iterator identity, which\n // swapping the provider leaves untouched. Without re-deriving it\n // here the pump pulls a generator over a disposed input forever.\n //\n // Whether one existed is the question, not what the consumer is\n // doing now: a seek or a step in flight when the decode hung has\n // already moved the mode off Playing, and the pump's only way\n // back is a pull, which no-ops while the iterator is null.\n this.failedRebuilds = 0;\n const resumePlay = this.iterator !== null;\n const modeBefore = this.mode;\n this.iterator = null;\n if (resumePlay) {\n this.attachPlay(this.currentPositionS());\n this.mode = modeBefore;\n }\n }\n catch {\n // The rebuild failed. A re-openable source can fail once, on a\n // transient read, so one failure leaves the decoder recoverable\n // rather than no-opping every later decode. A source that keeps\n // failing is not transient, and pretending otherwise retries\n // forever while reporting a healthy decoder.\n this.failedRebuilds += 1;\n if (this.failedRebuilds >= MAX_FAILED_REBUILDS)\n this.decoderDead = true;\n }\n finally {\n // Best-effort dispose of the wedged provider; its hung decode is\n // abandoned and may never settle, so do not await indefinitely.\n void dead.dispose().catch(() => undefined);\n this.recovering = null;\n }\n })();\n return this.recovering;\n }\n /**\n * The only writer of the generation token, so no bump can move it without\n * also waking the sweep it cancels. A running sweep bails at its next\n * checkpoint and is never joined: joining it puts an unbounded background\n * walk on the critical path of every seek and play pull. The task handle\n * stays so close() can await teardown.\n */\n abandonPrefetch() {\n // Only an armed token has a sweep to cancel: a play pull cancels here on\n // every frame, and playback schedules no sweeps at all, so an unguarded\n // bump measures the play pump rather than prefetch.\n if (this.prefetchArmed) {\n this.prefetchArmed = false;\n this.prefetchGen++;\n }\n const pending = this.abandonResolvers;\n if (pending.length === 0)\n return;\n this.abandonResolvers = [];\n pending.forEach((r) => r());\n }\n /** Resolves the moment `gen` stops being the live prefetch generation. */\n whenAbandoned(gen) {\n return new Promise((resolve) => {\n if (gen !== this.prefetchGen)\n resolve(ABANDONED);\n else\n this.abandonResolvers.push(() => resolve(ABANDONED));\n });\n }\n /**\n * Kicks a background sweep around `aroundS` for the current access mode.\n * Fire-and-forget: it waits for any prior sweep to tear down so the decoder\n * is never used twice at once, then runs under a captured generation token.\n */\n schedulePrefetch(aroundS) {\n if (this.mode === AccessMode.Playing)\n return;\n this.abandonPrefetch();\n const gen = this.prefetchGen;\n const prev = this.prefetchTask;\n this.prefetchArmed = true;\n const sweep = (async () => {\n try {\n await prev?.catch(() => undefined);\n if (gen !== this.prefetchGen || this.closed)\n return;\n await this.runPrefetch(aroundS, gen);\n }\n catch {\n // Best-effort: a failed sweep just leaves a future cache miss.\n }\n })();\n const task = sweep.finally(() => {\n if (this.prefetchTask !== task)\n return;\n this.prefetchTask = null;\n this.prefetchArmed = false;\n });\n this.prefetchTask = task;\n }\n async runPrefetch(aroundS, gen) {\n if (this.mode === AccessMode.Idle) {\n await this.runPreviewSweep(aroundS, gen);\n }\n else {\n await this.runExactWindow(aroundS, gen);\n }\n }\n async runExactWindow(aroundS, gen) {\n const targets = this.windowTimestamps(aroundS);\n await this.decodeInto(targets, gen, false);\n if (gen !== this.prefetchGen || this.closed)\n return;\n // The center frame went into the tier before this sweep, so it is the\n // oldest insertion and the first LRU victim once the window fills. It is\n // also the frame on screen and the most likely target of a micro-adjust,\n // so promote it back to most-recently-used after the neighbors land.\n this.cache.bumpExact(aroundS * 1000);\n }\n async runPreviewSweep(aroundS, gen) {\n const lo = Math.max(0, aroundS - PREVIEW_SWEEP_SPAN_S);\n const hi = Math.min(this.trackInfo.durationS, aroundS + PREVIEW_SWEEP_SPAN_S);\n const keyframes = await Promise.race([\n this.withProbeWatchdog(this.keyframeIndex.keyframesCovering(lo, hi)),\n this.whenAbandoned(gen),\n ]);\n if (keyframes === ABANDONED ||\n !keyframes ||\n gen !== this.prefetchGen ||\n this.closed)\n return;\n await this.decodeInto(selectPreviewTargets(keyframes, aroundS), gen, true);\n }\n async decodeInto(timestamps, gen, preview) {\n if (!timestamps.length || gen !== this.prefetchGen || this.closed)\n return;\n const w = this.trackInfo.decodeWidth;\n const h = this.trackInfo.decodeHeight;\n const iter = this.provider.framesAt(timestamps);\n try {\n let result = await this.pullSwept(iter, gen);\n while (result && !result.done) {\n if (gen !== this.prefetchGen || this.closed) {\n // A gesture cancelled the sweep (or close raced it) after this\n // frame decoded but before it was drawn into the cache; close\n // the orphaned sample here, since the cache will never own it.\n if (result.value?.kind === \"sample\")\n result.value.sample.close();\n return;\n }\n const frame = result.value;\n if (frame) {\n const ms = frame.timestamp * 1000;\n const blit = frame.kind === \"sample\" ? frame.sample : frame.canvas;\n if (preview) {\n this.cache.putPreview(ms, blit, w, h);\n this.prefetchPreviewFrames += 1;\n }\n else {\n this.cache.putExact(ms, blit, w, h);\n this.prefetchExactFrames += 1;\n }\n // A swept sample is drawn into the cache and never emitted, so\n // its close obligation ends here. The cache retains only the\n // blit, never the sample.\n if (frame.kind === \"sample\")\n frame.sample.close();\n }\n result = await this.pullSwept(iter, gen);\n }\n }\n finally {\n void iter.return();\n }\n }\n /**\n * One sweep pull, ended by abandonment rather than by the decode it is\n * waiting on. A native async generator serializes its request queue, so the\n * return() that cancellation would issue is queued behind the in-flight\n * next() and lands only once that walk finishes; the pull itself cannot be\n * called back. The sweep therefore stops awaiting it and leaves it orphaned,\n * closing the sample it may still deliver, which the cache will never own.\n */\n async pullSwept(iter, gen) {\n const pull = this.withDecodeWatchdog(iter.next());\n const settled = await Promise.race([pull, this.whenAbandoned(gen)]);\n if (settled !== ABANDONED)\n return settled;\n void pull\n .then((late) => {\n if (late && late.value?.kind === \"sample\")\n late.value.sample.close();\n })\n .catch(() => undefined);\n return null;\n }\n windowTimestamps(aroundS) {\n const n = this.perSideWindowFrames();\n if (n <= 0)\n return [];\n const stepS = this.frameIntervalS();\n const targets = [];\n const admit = (t) => {\n if (t >= 0 && t <= this.trackInfo.durationS)\n targets.push(t);\n };\n // Spend the budget where the gesture is going. Splitting it evenly puts\n // half of every sweep behind a hand that is moving away from there, and\n // those frames are stale before they land: measured on a forward drag,\n // 8% of the resident crisp frames were ahead of the pointer and the rest\n // trailed it by up to six seconds. A minority still goes the other way,\n // since a scrub reverses without warning and a few frames behind cover\n // the moment before the heading is re-established.\n const { withTravel, againstTravel } = this.splitByHeading(2 * n);\n // A gesture with no heading keeps a symmetric window; the split above\n // hands it an even budget, so +1 just picks a side to call forward.\n const travel = this.trajectory.heading() || 1;\n for (let i = 1; i <= withTravel; i++)\n admit(aroundS + travel * i * stepS);\n for (let i = 1; i <= againstTravel; i++)\n admit(aroundS - travel * i * stepS);\n // Nearest the center first above, so trimming to the budget drops the\n // farthest frames. Then the frames the gesture is about to reach are\n // decoded before the ones behind it. Ascending across the whole batch\n // reads as decoder-friendly, and inside one anchor span it is, but a\n // target behind the read head re-anchors and decodes its entire prefix:\n // on a source whose only sync sample is at the start, that is a walk\n // from zero, ahead of every frame the user actually needs. Each half\n // still climbs, so neither half re-walks within itself.\n // A target the decoder can still reach going forward costs the frames in\n // between and nothing else. One behind it cannot be reached by moving\n // forward at all, so it restarts from the keyframe before it and decodes\n // everything between: a few frames on a short GOP, the whole file on a\n // source whose only keyframe is at the start. Speculative frames are\n // worth the first and not the second.\n const budgeted = targets\n .slice(0, 2 * n)\n .filter((t) => !this.provider.wouldReanchor(t) ||\n this.walkToTargetAffordable(t, 2 * n));\n const ahead = budgeted.filter((t) => t >= aroundS).sort((a, b) => a - b);\n const behind = budgeted.filter((t) => t < aroundS).sort((a, b) => a - b);\n return [...ahead, ...behind];\n }\n /**\n * How a window's slots divide between the way the gesture is heading and the\n * way it came from. An unmoving gesture has no direction to favour, so it\n * splits evenly, which is also what a step or an idle settle wants.\n */\n splitByHeading(budget) {\n if (this.trajectory.heading() === 0) {\n const half = Math.floor(budget / 2);\n return { withTravel: half, againstTravel: budget - half };\n }\n const withTravel = Math.max(1, Math.round(budget * WINDOW_LEAD_FRACTION));\n return { withTravel, againstTravel: Math.max(0, budget - withTravel) };\n }\n /**\n * Whether reaching `t` backwards is worth it, measured in frames the walk\n * from its keyframe would decode. The index is what the runtime knows so far,\n * so an empty one is no evidence and does not veto: guessing a source has no\n * keyframes would throw away every backward slot on a source that has plenty.\n */\n walkToTargetAffordable(t, budgetFrames) {\n const keyframes = this.keyframeIndex.known;\n if (!keyframes.length)\n return true;\n let anchor = null;\n for (const k of keyframes) {\n if (k <= t)\n anchor = k;\n else\n break;\n }\n if (anchor === null)\n return true;\n return t - anchor <= budgetFrames * this.frameIntervalS();\n }\n /**\n * Per-side neighbor count for the current mode, clamped so the whole window\n * (both sides plus the center already in hand) fits the exact tier. A window\n * wider than the tier would evict its own freshly decoded frames, so a\n * re-scrub into the swept region would still miss.\n */\n perSideWindowFrames() {\n const desired = this.mode === AccessMode.Stepping\n ? STEP_WINDOW_FRAMES\n : SCRUB_WINDOW_FRAMES;\n const capacity = this.cache.stats.exactCapacity;\n if (capacity <= 1)\n return 0;\n // One slot holds the center; the rest split across the two sides.\n const fits = Math.floor((capacity - 1) / 2);\n return Math.min(desired, fits);\n }\n frameIntervalS() {\n const fps = this.trackInfo.nativeFps;\n return fps && fps > 0 ? 1 / fps : 1 / FALLBACK_FPS$1;\n }\n currentPositionS() {\n return this.lastEmittedFrame ? this.lastEmittedFrame.timestampS : asSec(0);\n }\n emitDecoded(decoded, isKey, cacheExact = true) {\n this.foregroundDecodes += 1;\n if (isKey)\n this.keyframeAnchoredEmits += 1;\n // The cache buckets on its own rounded key and reports back whatever\n // timestamp it was handed, so rounding here would make every cache-served\n // paint claim a time no frame has.\n const ms = decoded.timestamp * 1000;\n const w = this.trackInfo.decodeWidth;\n const h = this.trackInfo.decodeHeight;\n const blit = decoded.kind === \"sample\" ? decoded.sample : decoded.canvas;\n // Blit into the cache first. The cache draws the sample and retains only\n // the blit, so the live sample below is still valid for the controller to\n // paint and then close.\n if (cacheExact)\n this.cache.putExact(ms, blit, w, h);\n // Always seed the coarse tier too, on every path. The exact tier serves a\n // scrub back over the exact spot; the long preview tier lets a re-scrub\n // near (not on) this spot paint an instant coarse frame while a crisp one\n // decodes.\n this.cache.putPreview(ms, blit, w, h);\n if (decoded.kind === \"sample\") {\n // The controller closes the sample after it paints; the cache already\n // holds an independent blit.\n this.emit({\n kind: \"sample\",\n sample: decoded.sample,\n timestampS: decoded.timestamp,\n width: w,\n height: h,\n isKeyFrame: isKey,\n quality: \"exact\",\n });\n }\n else {\n this.emit({\n kind: \"canvas\",\n source: decoded.canvas,\n timestampS: decoded.timestamp,\n width: w,\n height: h,\n isKeyFrame: isKey,\n quality: \"exact\",\n });\n }\n }\n /** Puts a decoded frame in both tiers without painting it, and releases it.\n * For a frame worth keeping whose moment to be seen has passed. */\n cacheDecoded(decoded) {\n this.foregroundDecodes += 1;\n const ms = decoded.timestamp * 1000;\n const w = this.trackInfo.decodeWidth;\n const h = this.trackInfo.decodeHeight;\n const blit = decoded.kind === \"sample\" ? decoded.sample : decoded.canvas;\n this.cache.putExact(ms, blit, w, h);\n this.cache.putPreview(ms, blit, w, h);\n if (decoded.kind === \"sample\")\n decoded.sample.close();\n }\n emitCached(cached) {\n this.emit(this.frameFromCache(cached));\n }\n emit(frame) {\n this.lastEmittedFrame = frame;\n this.listeners.forEach((l) => l(frame));\n }\n frameFromCache(cached) {\n return {\n kind: \"canvas\",\n timestampS: asSec(cached.timestampMs / 1000),\n source: cached.canvas,\n width: cached.canvas.width,\n height: cached.canvas.height,\n // The cache keeps blits, not the sample's flags, so whether this\n // frame was a keyframe is simply not known here.\n isKeyFrame: false,\n quality: cached.tier === FrameTier.Preview ? \"preview\" : \"exact\",\n };\n }\n recordScrubLatency(ms) {\n this.scrubSamples += 1;\n this.scrubSumMs += ms;\n this.scrubLastMs = ms;\n if (ms > this.scrubMaxMs)\n this.scrubMaxMs = ms;\n this.scrubLatencyRing[this.scrubLatencyHead] = ms;\n this.scrubLatencyHead =\n (this.scrubLatencyHead + 1) % this.scrubLatencyRing.length;\n this.scrubLatencyCount += 1;\n }\n flushIdleResolvers() {\n const pending = this.idleResolvers;\n this.idleResolvers = [];\n pending.forEach((r) => r());\n }\n flushSettleResolvers() {\n const pending = this.settleResolvers;\n this.settleResolvers = [];\n pending.forEach((r) => r());\n }\n }\n\n /**\n * Opens the decode source and chooses the cursor backend: the caching\n * DecodeScheduler when a cache config is given, otherwise the uncached\n * CanvasSinkScrubCursor. The returned cursor is already seeded with its first\n * frame, so callers receive a ready cursor with track metadata resolved.\n *\n * This is the single place the backend is chosen, so the gate is one branch and\n * both backends share the same opened source.\n */\n const createScrubCursor = async (options) => {\n const openSource = () => openScrubSource(options);\n const source = await openSource();\n // A URL/Blob re-opens cleanly for hang recovery; a one-shot stream cannot, so\n // the scheduler degrades rather than rebuilds when its source is a stream.\n const reopen = isReopenableSource(options.source) ? openSource : null;\n const cursor = buildCursor(source, options, reopen);\n try {\n await cursor.open();\n }\n catch (failure) {\n // An open that never produced a first frame still built a decoder and an\n // input over the source, and the caller has no cursor to close them with.\n await cursor.close().catch(() => undefined);\n throw failure;\n }\n return cursor;\n };\n function buildCursor(source, options, reopen) {\n const cache = options.cache ?? null;\n if (cache) {\n return new DecodeScheduler({\n source,\n cache: buildCache(source.track, cache),\n reopen,\n onDecodeFailure: options.onDecodeFailure,\n });\n }\n return new CanvasSinkScrubCursor(source);\n }\n function buildCache(track, config) {\n const fps = track.nativeFps;\n const bucketMs = fps && fps > 0\n ? Math.max(1, Math.round(1000 / fps))\n : FRAME_CACHE.DEFAULT_BUCKET_MS;\n const budgets = resolveCacheBudgets(track.decodeWidth, track.decodeHeight, config.previewWidth);\n return new FrameCache({\n exactWidth: track.decodeWidth,\n exactHeight: track.decodeHeight,\n previewWidth: config.previewWidth,\n exactBudgetBytes: config.exactBudgetBytes ?? budgets.exactBudgetBytes,\n previewCapacity: config.previewCapacity ?? budgets.previewCapacity,\n bucketMs,\n // Floor the exact tier to one full prefetch window so a huge-frame source\n // does not evict the very neighbors a settle just decoded.\n minExactSlots: FRAME_CACHE.MIN_EXACT_SLOTS,\n });\n }\n\n /** Frame interval fallback when the source rate is unknown; mirrors the engine. */\n const FALLBACK_FPS = 30;\n /** Broadcast intervals per paint-rate window: ~600ms at the 10Hz broadcast. */\n const PAINT_RATE_WINDOW = 6;\n /**\n * Painted-frame rate over a trailing window of broadcast intervals, fed once\n * per snapshot. A single interval can only hold whole paints, so its rate\n * quantises hard; the trailing window smooths that without collapsing the\n * series into a whole-capture average, which could confirm \"playback is slow\"\n * but never locate it in time.\n */\n class PaintRateMeter {\n windowSize;\n samples = [];\n constructor(windowSize = PAINT_RATE_WINDOW) {\n this.windowSize = windowSize;\n }\n /** Rate in paints/second, or null unless playback was up across the whole\n * window. A pause or a paints reset (each play session restarts the\n * counter) invalidates the window, not just the sample. */\n sample(atMs, paints, playing) {\n const lastPaints = this.samples.at(-1)?.paints ?? 0;\n if (!playing || paints < lastPaints)\n this.samples.length = 0;\n if (!playing)\n return null;\n this.samples.push({ atMs, paints });\n if (this.samples.length > this.windowSize)\n this.samples.shift();\n if (this.samples.length < 2)\n return null;\n const first = this.samples[0];\n const last = this.samples[this.samples.length - 1];\n const dtMs = last.atMs - first.atMs;\n if (dtMs <= 0)\n return null;\n return ((last.paints - first.paints) / dtMs) * 1000;\n }\n }\n /**\n * Playback speed measured off what reached the canvas: media seconds presented\n * per wall second, over the same trailing window PaintRateMeter uses. Fed the\n * last painted media position once per snapshot.\n *\n * It reads painted positions, not paint counts: a source can paint at a healthy\n * 30fps while the clock runs at 4x, and only the distance the picture travelled\n * shows that the two disagree.\n */\n class PresentedRateMeter {\n windowSize;\n samples = [];\n constructor(windowSize = PAINT_RATE_WINDOW) {\n this.windowSize = windowSize;\n }\n /** Rate in media seconds per wall second, or null unless playback was up\n * across the whole window with the picture moving forward through it. */\n sample(atMs, paintedMs, playing) {\n const last = this.samples.at(-1);\n // A backward move is a seek, not a slower playback: measuring across it\n // would report the jump as a negative rate.\n if (!playing ||\n paintedMs === null ||\n (last !== undefined && paintedMs < last.paintedMs)) {\n this.samples.length = 0;\n }\n if (!playing || paintedMs === null)\n return null;\n this.samples.push({ atMs, paintedMs });\n if (this.samples.length > this.windowSize)\n this.samples.shift();\n if (this.samples.length < 2)\n return null;\n const first = this.samples[0];\n const latest = this.samples[this.samples.length - 1];\n const wallMs = latest.atMs - first.atMs;\n if (wallMs <= 0)\n return null;\n return (latest.paintedMs - first.paintedMs) / wallMs;\n }\n /** Drops the window, so a rate change is not measured across its own edge. */\n reset() {\n this.samples.length = 0;\n }\n }\n /**\n * Cache lookups across every tier. Only an exact seek looks the cache up, so\n * playback and key seeks never move this and a play-only session sits at zero.\n * A rule or readout that reads the hit rate without this denominator reports a\n * cold cache as a failing one.\n */\n function cacheLookups(cache) {\n if (!cache)\n return 0;\n return cache.exactHits + cache.previewHits + cache.misses;\n }\n /**\n * Pure threshold evaluator. Reads a snapshot and returns the active diagnoses,\n * critical first. Used both in the worker (so the broadcast carries warnings the\n * UI and the trace share) and in tests. No I/O, no side effects.\n */\n function evaluateWarnings(snapshot) {\n const out = [];\n const { gop, geometry, scrub, counters, realtime, scheduler } = snapshot;\n const nativeFps = snapshot.nativeFps ?? null;\n const status = snapshot.status;\n const lookups = cacheLookups(scheduler?.cache);\n const cacheHit = lookups > 0 ? scrub.cacheHitRatePct : null;\n const hitRateText = cacheHit === null\n ? \"n/a (0 lookups)\"\n : `${cacheHit.toFixed(0)}% over ${lookups} lookups`;\n const exactCapacity = scheduler?.cache.exactCapacity ?? 0;\n const evictions = scheduler?.cache.exactEvictions ?? 0;\n // GOP stats are computed over the lazily-discovered keyframe subset. A\n // handful of non-adjacent anchors can fabricate or hide a long GOP, so wait\n // for a representative sample before raising the critical.\n const GOP_MIN_SAMPLE = 8;\n if (gop.count >= GOP_MIN_SAMPLE &&\n (gop.maxGopS > 6 ||\n (gop.avgGopS > 5 && gop.estimatedGopWalkDepthFrames > 60))) {\n out.push({\n id: \"LONG_GOP\",\n severity: \"critical\",\n title: \"Long GOPs\",\n scenario: \"Long GOPs make every off-anchor scrub decode a wall of frames before it can paint.\",\n advice: \"Re-encode with keyint ~= 2x fps, or navigate with key-seek instead.\",\n evidence: `maxGopS=${gop.maxGopS.toFixed(2)}, avgGopS=${gop.avgGopS.toFixed(2)}, est walk=${gop.estimatedGopWalkDepthFrames} frames over ${gop.count} discovered keyframes`,\n });\n }\n if (geometry.decodeVsDisplayAreaRatio !== null &&\n geometry.decodeVsDisplayAreaRatio > 2) {\n out.push({\n id: \"DECODE_LARGER_THAN_DISPLAY\",\n severity: \"critical\",\n title: \"Decoding larger than display\",\n scenario: \"Decoding far larger than you paint wastes decode bandwidth every frame.\",\n advice: \"Enable or tighten the decode-resolution downscale to track the canvas.\",\n evidence: `decode=${geometry.decodeWidth}x${geometry.decodeHeight}, areaRatio=${geometry.decodeVsDisplayAreaRatio?.toFixed(2) ?? \"n/a\"}`,\n });\n }\n if (scheduler !== null && exactCapacity <= 3) {\n out.push({\n id: \"CACHE_STARVED\",\n severity: \"critical\",\n title: \"Cache starved\",\n scenario: \"The exact tier holds only a couple of slots, so neighbor scrubs re-decode.\",\n advice: \"Raise the exact-cache budget or decode smaller so more frames fit.\",\n evidence: `exactCapacity=${exactCapacity}, hitRate=${hitRateText}`,\n });\n }\n const catchUpThreshold = 3 * (1000 / Math.max(nativeFps ?? FALLBACK_FPS, 30));\n // catchUpMs is only meaningful while playing; a paused paint that landed\n // short of its target would otherwise trip a false critical.\n if (status === \"PLAYING\" && realtime.catchUpMs > catchUpThreshold) {\n out.push({\n id: \"PLAYBACK_BEHIND\",\n severity: \"critical\",\n title: \"Playback behind\",\n scenario: \"Playback cannot keep up with the source rate.\",\n advice: \"Lower decode resolution, prefer WebGPU, avoid play-start deep in a long GOP.\",\n evidence: `catchUpMs=${realtime.catchUpMs} over a ${Math.round(catchUpThreshold)}ms budget, nativeFps=${nativeFps?.toFixed(1) ?? \"n/a\"}`,\n });\n }\n // A commanded rate is a promise about the picture, and the clock keeps that\n // promise whether or not the pipeline can: media time runs at rate either\n // way, so the transport reads healthy while the picture falls further behind\n // every second. The measured rate is what says so. Scoped to a non-default\n // rate, whose ceiling is a property of the source; at 1x the same shortfall\n // is PLAYBACK_BEHIND.\n //\n // Both halves are required. presentedRate quantises hard when paints are\n // sparse, which a slow rate on a low-fps source produces from a perfectly\n // healthy pipeline, so catch-up depth has to corroborate that the picture\n // really has fallen behind.\n const presented = snapshot.presentedRate;\n if (status === \"PLAYING\" &&\n snapshot.rate !== 1 &&\n presented !== null &&\n presented < snapshot.rate * PLAYBACK_RATE.SUSTAINED_SHORTFALL &&\n realtime.catchUpMs > catchUpThreshold) {\n out.push({\n id: \"PLAYBACK_RATE_NOT_SUSTAINED\",\n severity: \"warn\",\n title: \"Playback rate not sustained\",\n scenario: \"The clock is running at the rate it was given but the picture is not keeping up, so what you see is slower than what the playhead reports.\",\n advice: \"This source cannot decode that many frames a second. Drop to a rate the measured one can reach, or decode smaller.\",\n evidence: `rate=${snapshot.rate}x, presented=${presented.toFixed(2)}x, effectivePaintFps=${realtime.effectivePaintFps?.toFixed(1) ?? \"n/a\"}, nativeFps=${nativeFps?.toFixed(1) ?? \"n/a\"}, catchUpMs=${realtime.catchUpMs}`,\n });\n }\n // A stall is only booked after the session's first paint, so a pump that\n // never paints books none and PLAYBACK_STARVING cannot see it. 120 ticks is\n // ~2s of rAF, past any plausible first-frame decode.\n const PUMP_DEAD_TICKS = 120;\n const drain = scheduler?.drain;\n if (status === \"PLAYING\" &&\n realtime.ticks >= PUMP_DEAD_TICKS &&\n realtime.paints === 0) {\n out.push({\n id: \"PLAYBACK_PUMP_DEAD\",\n severity: \"critical\",\n title: \"Playback pump dead\",\n scenario: \"Playback is running and the render loop is ticking, but not one frame has reached the canvas this play session.\",\n advice: drain?.draining\n ? \"A seek is still draining, and every play pull is refused while one is. Find why that seek has not finished; the dead pump is its symptom, not the cause.\"\n : \"Follow the play pull: a lost or abandoned iterator pull leaves the loop ticking over an empty queue forever, which reads as a frozen canvas.\",\n evidence: `ticks=${realtime.ticks}, paints=0, playQueueDepth=${realtime.playQueueDepth}, nextPending=${counters.nextPending}, decoderDead=${scheduler?.decoderDead ?? false}, seekDraining=${drain?.draining ?? false}, seekDrainingForMs=${Math.round(counters.seekDrainingForMs)}`,\n });\n }\n // A drain outliving every decode it can be waiting on IS the wedge: it\n // refuses every play pull for as long as it lasts, and the pump warning\n // above only fires once playback has been asked for and denied.\n // Half the ceiling the runtime allows one decode, so this can only fire on a\n // drain that has outlived a decode the runtime itself would still be waiting\n // on. A flat two seconds fired on GOP walks the hang guard documents as\n // healthy.\n const DRAIN_STUCK_MS = HANG_RECOVERY.DECODE_HANG_TIMEOUT_MS / 2;\n if (drain?.draining && counters.seekDrainingForMs >= DRAIN_STUCK_MS) {\n out.push({\n id: \"SEEK_DRAIN_STUCK\",\n severity: \"critical\",\n title: \"Seek never settled\",\n scenario: \"A seek has been draining far longer than a decode takes. Play pulls are refused for the whole time, so the picture is frozen while the transport still reads as playing.\",\n advice: \"The seek's decode has not come back. Look for a walk holding the decode session, and check whether the hang watchdog has fired yet.\",\n evidence: `seekDrainingForMs=${Math.round(counters.seekDrainingForMs)}, pendingTargetMs=${drain.pendingTargetMs}, recovering=${drain.recovering}, foregroundDecodes=${counters.foregroundDecodes}`,\n });\n }\n // Frames decoded and thrown away before reaching the canvas: the provenance\n // guard drops frames that arrived for a position playback had left, and the\n // horizon flush drops a buffer the clock moved backwards under. A few around\n // a seek are the mechanism working; a stream of them while playing is decode\n // bandwidth spent on frames nobody ever saw.\n //\n // Once the rate asks for more frames a second than the present cadence, the\n // pump declines the surplus on purpose and charges it to this same ledger,\n // fast enough to clear any threshold within a second. Nothing in the\n // snapshot tells a declined frame from a wasted one, so above the cadence\n // this rule has nothing left to read and stays quiet.\n const DROPPED_FRAMES_LIMIT = 30;\n const sourceFps = nativeFps ?? FALLBACK_FPS;\n const rateOutrunsCadence = snapshot.rate * sourceFps >\n Math.max(PLAYBACK.PRESENT_CADENCE_HZ, sourceFps);\n if (status === \"PLAYING\" &&\n !rateOutrunsCadence &&\n realtime.droppedFrames >= DROPPED_FRAMES_LIMIT) {\n out.push({\n id: \"PLAYBACK_FRAMES_DISCARDED\",\n severity: \"warn\",\n title: \"Decoded frames are being thrown away\",\n scenario: \"Playback keeps decoding frames that are then discarded before they reach the canvas, so decode time is being spent on frames nobody sees.\",\n advice: \"Check what keeps arriving for a position playback has left: a seek that outlives its gesture, or a clock moving backwards under the buffer.\",\n evidence: `droppedFrames=${realtime.droppedFrames}, playQueueDepth=${realtime.playQueueDepth}, paints=${realtime.paints}, rate=${snapshot.rate}x, nativeFps=${nativeFps?.toFixed(1) ?? \"n/a\"}`,\n });\n }\n if (scheduler?.decoderDead === true) {\n out.push({\n id: \"DECODER_DEAD\",\n severity: \"critical\",\n title: \"Decoder dead\",\n scenario: \"A decode hung and the source could not be re-opened, so every later decode no-ops: the canvas holds its last frame and nothing new ever lands.\",\n advice: \"Reload the source. A re-openable source (a URL or a File rather than a one-shot stream) lets the runtime rebuild the decoder instead of degrading.\",\n evidence: `decoderDead=true, foregroundDecodes=${counters.foregroundDecodes}, playQueueDepth=${realtime.playQueueDepth}`,\n });\n }\n if (realtime.stalls > 2 && status === \"PLAYING\") {\n out.push({\n id: \"PLAYBACK_STARVING\",\n severity: \"critical\",\n title: \"Playback starving\",\n scenario: \"The canvas is freezing mid-play; the pipeline is out of buffered output.\",\n advice: \"Reduce decode resolution, warm the forward iterator, or use a lighter encode.\",\n evidence: `stalls=${realtime.stalls}, status=${status}`,\n });\n }\n if (cacheHit !== null && cacheHit < 50 && scrub.avgMs > 200) {\n out.push({\n id: \"SCRUB_DECODE_BOUND\",\n severity: \"warn\",\n title: \"Scrub decode-bound\",\n scenario: \"Most drags wait on a fresh decode rather than the cache.\",\n advice: \"Shrink decode resolution; let the playhead settle so the sweep warms the region.\",\n evidence: `hitRate=${hitRateText}, avgMs=${scrub.avgMs.toFixed(0)}`,\n });\n }\n if (scrub.p95Ms > 350 && scrub.avgMs <= 200) {\n out.push({\n id: \"SCRUB_P95_JANK\",\n severity: \"warn\",\n title: \"Worst-case scrub jank\",\n scenario: \"Worst-case seeks are janky though averages look fine: irregular GOP regions.\",\n advice: \"Check the max GOP and keyframe regularity.\",\n evidence: `p95Ms=${scrub.p95Ms.toFixed(0)}, avgMs=${scrub.avgMs.toFixed(0)}`,\n });\n }\n if (scrub.targetVsLandedMs > 200) {\n out.push({\n id: \"PLAYHEAD_STICKS\",\n severity: \"warn\",\n title: \"Playhead sticks short\",\n scenario: \"The playhead lands short of where you aimed; a long GOP forces a distant anchor.\",\n advice: \"Use shorter keyframe spacing or lower decode resolution.\",\n evidence: `targetVsLandedMs=${scrub.targetVsLandedMs}`,\n });\n }\n if (counters.seekCoalesceDepth > 6) {\n out.push({\n id: \"SCRUB_OUTRUNS_DECODER\",\n severity: \"warn\",\n title: \"Scrub outruns the decoder\",\n scenario: \"Dragging faster than the decoder resolves; only the final position lands.\",\n advice: \"Reduce per-frame decode cost so the cache can warm as you drag.\",\n evidence: `seekCoalesceDepth=${counters.seekCoalesceDepth}`,\n });\n }\n if (scrub.timeToCrispMs > 300) {\n out.push({\n id: \"SLOW_TIME_TO_CRISP\",\n severity: \"warn\",\n title: \"Slow time-to-crisp\",\n scenario: \"Preview frames stay blurry too long; the crisp decode behind them is slow.\",\n advice: \"A large frame or long GOP is the cause; lower decode resolution.\",\n evidence: `timeToCrispMs=${scrub.timeToCrispMs.toFixed(0)}`,\n });\n }\n if (evictions > 0 &&\n cacheHit !== null &&\n cacheHit < 60 &&\n exactCapacity > 0 &&\n evictions > exactCapacity) {\n out.push({\n id: \"CACHE_THRASH\",\n severity: \"warn\",\n title: \"Cache thrash\",\n scenario: \"Frames you just used keep getting evicted; the budget is too small for the frame size.\",\n advice: \"Reduce decode resolution or raise the cache budget.\",\n evidence: `evictions=${evictions}, exactCapacity=${exactCapacity}, hitRate=${hitRateText}`,\n });\n }\n if (gop.count >= GOP_MIN_SAMPLE && gop.minGopS < 0.05 && gop.avgGopS < 0.2) {\n out.push({\n id: \"ALL_INTRA\",\n severity: \"info\",\n title: \"All-intra encode\",\n scenario: \"A tiny-GOP or all-intra encode is burning decode bandwidth.\",\n advice: \"A normal 1-2s GOP cuts file size and decode bandwidth.\",\n evidence: `minGopS=${gop.minGopS.toFixed(3)}, avgGopS=${gop.avgGopS.toFixed(3)} over ${gop.count} discovered keyframes`,\n });\n }\n if (nativeFps === null) {\n out.push({\n id: \"NATIVE_FPS_UNKNOWN\",\n severity: \"info\",\n title: \"Frame rate unknown\",\n scenario: \"Frame rate is unknown; step and prefetch math fall back to 30fps.\",\n advice: \"Prefer real stepForward/Backward, or re-mux with timing metadata.\",\n evidence: \"nativeFps=null\",\n });\n }\n return out.sort((a, b) => severityRank(b.severity) - severityRank(a.severity));\n }\n function severityRank(severity) {\n return severity === \"critical\" ? 2 : severity === \"warn\" ? 1 : 0;\n }\n\n /// <reference types=\"@webgpu/types\" />\n /**\n * WebGPU renderer behind the Renderer seam. It samples each frame across a\n * fullscreen triangle so the display canvas is GPU-composited. This is the\n * render seam WebGPU needs and the foundation for render-time zoom / crop /\n * overlay / color work.\n *\n * Two paths, picked per frame:\n *\n * - Canvas upload (texture_2d, copyExternalImageToTexture): works wherever\n * WebGPU is present, including Firefox. The frame's pixels are copied into a\n * sampled texture. Used for canvas frames (CanvasSink decodes, cache blits),\n * and for a decoded frame converted through the renderer's own 2D canvas.\n *\n * - Zero-copy import (texture_external, importExternalTexture): the decoded\n * VideoFrame is wrapped as an external texture and sampled directly, no copy.\n * Used only for sample frames and only where importExternalTexture exists.\n * The external texture and the VideoFrame it wraps are valid only for the\n * current task, so import, draw, submit, and close happen in one tick. The\n * renderer closes the transient VideoFrame it creates; the controller still\n * owns and closes the VideoSample.\n *\n * A decoded frame carries its own colour space and the browser converts it on\n * whichever route the frame takes. The routes are not guaranteed to agree: a\n * platform can convert a hardware-decoded frame one way when it is imported\n * straight onto the GPU and another way when it is drawn through a canvas. The\n * cache holds canvas blits, so a disagreement reads as the picture shifting\n * between a frame painted live and the same frame served from the cache. The\n * renderer therefore renders one frame both ways and compares them before it\n * paints through the import.\n *\n * GPU execution is verified in a browser, not in CI: a worker test environment\n * has no adapter, so createRenderer falls back to the 2D renderer there.\n */\n const VERTEX_STAGE = /* wgsl */ `\nstruct VsOut {\n @builtin(position) pos: vec4f,\n @location(0) uv: vec2f,\n};\n\n// The frame's quarter turn as a 2x2, row-major: (m00, m01, m10, m11).\n@group(0) @binding(2) var<uniform> uvRotation: vec4f;\n\n@vertex fn vs(@builtin(vertex_index) i: u32) -> VsOut {\n var corners = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));\n var out: VsOut;\n let p = corners[i];\n out.pos = vec4f(p, 0.0, 1.0);\n // Map clip space to texture space, flipping Y so the frame is upright.\n let uv = p * vec2f(0.5, -0.5) + vec2f(0.5, 0.5);\n let d = uv - vec2f(0.5, 0.5);\n out.uv = vec2f(dot(uvRotation.xy, d), dot(uvRotation.zw, d)) + vec2f(0.5, 0.5);\n return out;\n}\n`;\n const SHADER = /* wgsl */ `\n@group(0) @binding(0) var samp: sampler;\n@group(0) @binding(1) var tex: texture_2d<f32>;\n${VERTEX_STAGE}\n@fragment fn fs(in: VsOut) -> @location(0) vec4f {\n return textureSample(tex, samp, in.uv);\n}\n`;\n /** External-texture variant. textureSampleBaseClampToEdge is the only sampling\n * builtin valid for texture_external, so the two routes cannot be one module\n * however much of their source they share. */\n const EXTERNAL_SHADER = /* wgsl */ `\n@group(0) @binding(0) var samp: sampler;\n@group(0) @binding(1) var tex: texture_external;\n${VERTEX_STAGE}\n@fragment fn fs(in: VsOut) -> @location(0) vec4f {\n return textureSampleBaseClampToEdge(tex, samp, in.uv);\n}\n`;\n /** Side of the square render the two routes are compared over. They resample\n * differently on the way down, so the comparison rides on the mean channel\n * value, which resampling preserves; over this many samples its noise sits far\n * below the divergence being looked for. */\n const IMPORT_CHECK_SIZE = 64;\n /** Mean-channel gap above which the routes are taken to disagree. Rounding and\n * chroma siting move the mean by a fraction of a level. */\n const IMPORT_CHECK_TOLERANCE = 3;\n /** One vec4f: the frame's quarter turn as a 2x2. */\n const ROTATION_BUFFER_BYTES = 16;\n class WebGpuRenderer {\n canvas;\n device;\n context;\n format;\n pipeline;\n sampler;\n rotationBuffer;\n name = \"webgpu\";\n texture = null;\n bindGroup = null;\n textureWidth = 0;\n textureHeight = 0;\n /** Lazily built the first time a sample frame is drawn and import is\n * available; never built on the canvas-only path. */\n externalPipeline = null;\n directImport;\n /** The 2D surface a decoded frame is converted through, built on first use. */\n conversion = null;\n /** What the rotation uniform currently holds, so an unturned source pays no\n * buffer write per frame. */\n uvRotation = null;\n constructor(canvas, device, context, format, pipeline, sampler, rotationBuffer) {\n this.canvas = canvas;\n this.device = device;\n this.context = context;\n this.format = format;\n this.pipeline = pipeline;\n this.sampler = sampler;\n this.rotationBuffer = rotationBuffer;\n this.directImport =\n typeof device.importExternalTexture === \"function\"\n ? \"unknown\"\n : \"unusable\";\n }\n /** Cheap pre-check before the async create(): is the API even present? */\n static isSupported() {\n return typeof navigator !== \"undefined\" && navigator.gpu != null;\n }\n /** Acquires a device and configures the canvas. Throws if WebGPU is unusable. */\n static async create(canvas) {\n const adapter = await navigator.gpu.requestAdapter();\n if (!adapter)\n throw new Error(\"WebGpuRenderer: no GPU adapter\");\n const device = await adapter.requestDevice();\n const context = canvas.getContext(\"webgpu\");\n if (!context)\n throw new Error(\"WebGpuRenderer: no webgpu context\");\n const format = navigator.gpu.getPreferredCanvasFormat();\n context.configure({\n device,\n format,\n alphaMode: \"opaque\",\n colorSpace: DISPLAY_COLOR_SPACE,\n });\n const module = device.createShaderModule({ code: SHADER });\n const pipeline = device.createRenderPipeline({\n layout: \"auto\",\n vertex: { module, entryPoint: \"vs\" },\n fragment: { module, entryPoint: \"fs\", targets: [{ format }] },\n primitive: { topology: \"triangle-list\" },\n });\n const sampler = device.createSampler({\n magFilter: \"linear\",\n minFilter: \"linear\",\n });\n const rotationBuffer = device.createBuffer({\n size: ROTATION_BUFFER_BYTES,\n usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,\n });\n const renderer = new WebGpuRenderer(canvas, device, context, format, pipeline, sampler, rotationBuffer);\n renderer.setUvRotation(0);\n return renderer;\n }\n draw(frame) {\n if (this.canvas.width === 0 || this.canvas.height === 0)\n return;\n const rotation = frameRotation(frame);\n this.setUvRotation(rotation);\n if (frame.kind !== \"sample\") {\n // Size the upload to the SOURCE frame, not the display canvas. A preview\n // (coarse) cache frame is downscaled (e.g. 320px wide) while the canvas is\n // full decode size; copying at canvas size overruns the small source, the\n // copy fails, and WebGPU then cannot paint preview frames during a scrub\n // (the GPU error also stalls later draws, so play stops painting). The\n // fullscreen-triangle shader samples 0..1, so any texture size scales to fill.\n this.drawCanvas(frame.source, frame.width, frame.height);\n return;\n }\n if (this.directImport === \"usable\") {\n this.drawExternal(frame.sample.toVideoFrame());\n return;\n }\n if (this.directImport === \"unknown\") {\n this.directImport = \"measuring\";\n void this.measureDirectImport(frame.sample.toVideoFrame());\n // The probe encodes its passes before it yields, and leaves the uniform\n // unturned behind it.\n this.setUvRotation(rotation);\n }\n this.drawConverted(frame.sample.toVideoFrame(), rotation);\n }\n /**\n * Points the shader's sampling at the turn this frame's pixels still owe.\n * Queue writes and submits run in order, so the value a pass reads is the one\n * written before it was encoded.\n */\n setUvRotation(rotation) {\n if (this.uvRotation === rotation)\n return;\n this.uvRotation = rotation;\n this.device.queue.writeBuffer(this.rotationBuffer, 0, new Float32Array(uvRotationMatrix(rotation)));\n }\n dispose() {\n this.texture?.destroy();\n this.texture = null;\n this.bindGroup = null;\n this.conversion = null;\n this.rotationBuffer.destroy();\n this.device.destroy();\n }\n /** Zero-copy: wrap the VideoFrame as an external texture, sample it, submit,\n * then close the transient VideoFrame. The external texture is valid only\n * for this task, so the bind group is rebuilt every frame. */\n drawExternal(videoFrame) {\n try {\n const pipeline = this.ensureExternalPipeline();\n const external = this.device.importExternalTexture({\n source: videoFrame,\n colorSpace: DISPLAY_COLOR_SPACE,\n });\n const bindGroup = this.device.createBindGroup({\n layout: pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: this.sampler },\n { binding: 1, resource: external },\n { binding: 2, resource: { buffer: this.rotationBuffer } },\n ],\n });\n this.renderPass(pipeline, bindGroup);\n }\n finally {\n videoFrame.close();\n }\n }\n /** Draws the frame through a 2D canvas and uploads that, which is the\n * conversion the cache blits and the 2D renderer already go through. The\n * canvas is the display size, or its transpose on a quarter turn, so the\n * downscale happens once here and the shader only has to turn and stretch. */\n drawConverted(videoFrame, rotation) {\n try {\n // The display canvas is already the turned size, so on a quarter turn the\n // conversion surface is its transpose: the frame lands unturned and at\n // its own aspect, and the shader turns it on the way to the screen.\n const quarterTurn = rotation % 180 !== 0;\n const width = quarterTurn ? this.canvas.height : this.canvas.width;\n const height = quarterTurn ? this.canvas.width : this.canvas.height;\n const conversion = this.conversionFor(width, height);\n if (!conversion)\n return;\n conversion.drawImage(videoFrame, 0, 0, width, height);\n this.drawCanvas(conversion.canvas, width, height);\n }\n finally {\n videoFrame.close();\n }\n }\n conversionFor(width, height) {\n if (!this.conversion) {\n this.conversion = new OffscreenCanvas(width, height).getContext(\"2d\", {\n alpha: false,\n colorSpace: DISPLAY_COLOR_SPACE,\n });\n return this.conversion;\n }\n const canvas = this.conversion.canvas;\n if (canvas.width !== width)\n canvas.width = width;\n if (canvas.height !== height)\n canvas.height = height;\n return this.conversion;\n }\n /**\n * Renders one frame through both routes into offscreen targets and compares\n * what came out, which decides whether later frames are painted by importing\n * them directly. Reading the result back costs a round-trip to the GPU, so it\n * happens once and the frames drawn meanwhile take the converted route.\n */\n async measureDirectImport(videoFrame) {\n const size = IMPORT_CHECK_SIZE;\n const bytesPerRow = size * 4;\n const region = bytesPerRow * size;\n const scratch = [];\n // Both probe routes read the frame unturned: what is being compared is how\n // the two paths handle colour, and a turn either route applied would only\n // move where the disagreement is sampled from.\n this.setUvRotation(0);\n const hold = (resource) => {\n scratch.push(resource);\n return resource;\n };\n try {\n const renderTarget = () => hold(this.device.createTexture({\n size: [size, size],\n format: this.format,\n usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,\n }));\n const imported = renderTarget();\n const converted = renderTarget();\n const reference = hold(this.device.createTexture({\n size: [size, size],\n format: \"rgba8unorm\",\n // RENDER_ATTACHMENT is what makes a texture a legal\n // copyExternalImageToTexture destination. Without it the copy\n // is rejected and the reference reads back black, which the\n // comparison cannot tell apart from two routes that disagree.\n usage: GPUTextureUsage.TEXTURE_BINDING |\n GPUTextureUsage.COPY_DST |\n GPUTextureUsage.RENDER_ATTACHMENT,\n }));\n const readback = hold(this.device.createBuffer({\n size: region * 2,\n usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,\n }));\n const source = new OffscreenCanvas(size, size);\n const context = source.getContext(\"2d\", {\n alpha: false,\n colorSpace: DISPLAY_COLOR_SPACE,\n });\n if (!context) {\n this.directImport = \"unusable\";\n return;\n }\n context.drawImage(videoFrame, 0, 0, size, size);\n this.device.queue.copyExternalImageToTexture({ source }, { texture: reference, colorSpace: DISPLAY_COLOR_SPACE }, [size, size]);\n const externalPipeline = this.ensureExternalPipeline();\n const encoder = this.device.createCommandEncoder();\n this.encodePass(encoder, externalPipeline, this.device.createBindGroup({\n layout: externalPipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: this.sampler },\n {\n binding: 1,\n resource: this.device.importExternalTexture({\n source: videoFrame,\n colorSpace: DISPLAY_COLOR_SPACE,\n }),\n },\n { binding: 2, resource: { buffer: this.rotationBuffer } },\n ],\n }), imported.createView());\n this.encodePass(encoder, this.pipeline, this.device.createBindGroup({\n layout: this.pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: this.sampler },\n { binding: 1, resource: reference.createView() },\n { binding: 2, resource: { buffer: this.rotationBuffer } },\n ],\n }), converted.createView());\n encoder.copyTextureToBuffer({ texture: imported }, { buffer: readback, bytesPerRow }, [size, size]);\n encoder.copyTextureToBuffer({ texture: converted }, { buffer: readback, bytesPerRow, offset: region }, [size, size]);\n this.device.queue.submit([encoder.finish()]);\n await readback.mapAsync(GPUMapMode.READ);\n const pixels = new Uint8Array(readback.getMappedRange());\n const gap = Math.abs(meanChannel(pixels.subarray(0, region)) -\n meanChannel(pixels.subarray(region)));\n readback.unmap();\n this.directImport = gap <= IMPORT_CHECK_TOLERANCE ? \"usable\" : \"unusable\";\n }\n catch {\n // Latched, not retried: a measurement that threw leaves no evidence\n // the import agrees, and the converted route is always correct.\n this.directImport = \"unusable\";\n }\n finally {\n videoFrame.close();\n for (const resource of scratch)\n resource.destroy();\n }\n }\n drawCanvas(source, width, height) {\n this.ensureTexture(width, height);\n const texture = this.texture;\n if (!texture || !this.bindGroup)\n return;\n this.device.queue.copyExternalImageToTexture({ source }, { texture, colorSpace: DISPLAY_COLOR_SPACE }, [width, height]);\n this.renderPass(this.pipeline, this.bindGroup);\n }\n renderPass(pipeline, bindGroup) {\n const encoder = this.device.createCommandEncoder();\n this.encodePass(encoder, pipeline, bindGroup, this.context.getCurrentTexture().createView());\n this.device.queue.submit([encoder.finish()]);\n }\n encodePass(encoder, pipeline, bindGroup, view) {\n const pass = encoder.beginRenderPass({\n colorAttachments: [\n {\n view,\n clearValue: { r: 0, g: 0, b: 0, a: 1 },\n loadOp: \"clear\",\n storeOp: \"store\",\n },\n ],\n });\n pass.setPipeline(pipeline);\n pass.setBindGroup(0, bindGroup);\n pass.draw(3);\n pass.end();\n }\n ensureExternalPipeline() {\n if (this.externalPipeline)\n return this.externalPipeline;\n const module = this.device.createShaderModule({ code: EXTERNAL_SHADER });\n this.externalPipeline = this.device.createRenderPipeline({\n layout: \"auto\",\n vertex: { module, entryPoint: \"vs\" },\n fragment: {\n module,\n entryPoint: \"fs\",\n targets: [{ format: this.format }],\n },\n primitive: { topology: \"triangle-list\" },\n });\n return this.externalPipeline;\n }\n /** (Re)allocates the sampled texture and bind group when the size changes. */\n ensureTexture(width, height) {\n if (this.texture &&\n this.textureWidth === width &&\n this.textureHeight === height)\n return;\n this.texture?.destroy();\n this.texture = this.device.createTexture({\n size: [width, height],\n format: \"rgba8unorm\",\n usage: GPUTextureUsage.TEXTURE_BINDING |\n GPUTextureUsage.COPY_DST |\n GPUTextureUsage.RENDER_ATTACHMENT,\n });\n this.textureWidth = width;\n this.textureHeight = height;\n this.bindGroup = this.device.createBindGroup({\n layout: this.pipeline.getBindGroupLayout(0),\n entries: [\n { binding: 0, resource: this.sampler },\n { binding: 1, resource: this.texture.createView() },\n { binding: 2, resource: { buffer: this.rotationBuffer } },\n ],\n });\n }\n }\n /** Assumes the readback has no row padding, which holds while IMPORT_CHECK_SIZE\n * keeps bytesPerRow on WebGPU's 256-byte alignment. */\n function meanChannel(pixels) {\n let sum = 0;\n for (let i = 0; i < pixels.length; i += 4) {\n sum += pixels[i] + pixels[i + 1] + pixels[i + 2];\n }\n return sum / ((pixels.length / 4) * 3);\n }\n\n /**\n * The colour space every render surface declares. A decoded frame carries its\n * own and the browser converts it into whatever the destination names, but only\n * on the canvas routes: the zero-copy import converts to sRGB whatever colour\n * space it is asked for. A wider value here would therefore reach some routes\n * and not others, on the same frame.\n */\n const DISPLAY_COLOR_SPACE = \"srgb\";\n /**\n * 2D-canvas renderer: one blit of an already-decoded frame through a 2D context.\n * It is the graceful-degradation fallback wherever WebGPU is unavailable and the\n * path prefer2d pins. Per the Renderer contract the loop owns cadence, so a draw\n * happens only when a new frame is decoded. This is not a re-decode and not a\n * per-tick repaint; it differs from the WebGPU path only in compositing a frame\n * through a 2D context rather than on the GPU. Canvas dimensions are read live,\n * so a resize between draws is picked up on the next frame.\n */\n class CanvasRenderer {\n canvas;\n name = \"2d\";\n ctx;\n constructor(canvas) {\n this.canvas = canvas;\n // Video is opaque, so alpha:false skips per-pixel blend.\n this.ctx = canvas.getContext(\"2d\", {\n alpha: false,\n colorSpace: DISPLAY_COLOR_SPACE,\n });\n }\n draw(frame) {\n if (!this.ctx)\n return;\n const w = this.canvas.width;\n const h = this.canvas.height;\n this.ctx.clearRect(0, 0, w, h);\n // A sample draws (and scales) itself through the same 2D context; a\n // canvas frame goes through drawImage. Identical pixels either way.\n if (frame.kind === \"sample\") {\n frame.sample.draw(this.ctx, 0, 0, w, h);\n }\n else {\n this.ctx.drawImage(frame.source, 0, 0, w, h);\n }\n }\n dispose() { }\n }\n /** Pixels to the engine's display canvas, through the resolved Renderer. */\n class RendererFrameSink {\n renderer;\n constructor(renderer) {\n this.renderer = renderer;\n }\n get name() {\n return this.renderer.name;\n }\n present(frame) {\n this.renderer.draw(frame);\n return null;\n }\n dispose() {\n this.renderer.dispose();\n }\n }\n /**\n * Pixels out of the worker as a VideoFrame, for a host that owns the canvas.\n * A sample hands over its own frame, so a live decode reaches the host without\n * a detour through a canvas. A cache blit has only canvas pixels to give, and\n * the wrap has to be told the media time, which canvas pixels do not carry.\n */\n class TransferFrameSink {\n name = null;\n present(frame) {\n if (frame.kind === \"sample\")\n return frame.sample.toVideoFrame();\n return new VideoFrame(frame.source, {\n timestamp: Math.round(frame.timestampS * 1e6),\n });\n }\n dispose() { }\n }\n /**\n * Chooses the render backend for a canvas. WebGPU is the default and probed at\n * runtime; an explicit prefer2d, or any failure to acquire a device, yields the\n * 2D renderer instead, so a caller always gets a working renderer.\n */\n async function createRenderer(canvas, options = {}) {\n if (!options.prefer2d && WebGpuRenderer.isSupported()) {\n try {\n return await WebGpuRenderer.create(canvas);\n }\n catch {\n // Device acquisition or context config failed; fall back to 2D.\n }\n }\n return new CanvasRenderer(canvas);\n }\n\n /**\n * Per-rAF diagnostics gate. False by default, so the render loop pays a single\n * predicted-false branch per tick and runs no diagnostics math or allocation.\n * The engine flips it on diagnosticsStart and off on diagnosticsStop. Module\n * level (not per-instance) because the loop reads it on the hottest path and a\n * field read through `this.deps` would be one more indirection per tick.\n */\n let diagnosticsEnabled = false;\n function setDiagnosticsEnabled(enabled) {\n diagnosticsEnabled = enabled;\n }\n class ScrubController {\n deps;\n rafHandle = 0;\n /** Paused/seek path: the single most-recent landed frame. */\n nextFrame = null;\n /** Playing path: a bounded decode-ahead buffer so a decode that overruns a\n * frame interval does not starve the next tick. Drained one frame per tick,\n * refilled toward the read-ahead depth. */\n playQueue = [];\n sink = null;\n /** The canvas the latest bind targeted; guards the async renderer race. */\n boundCanvas = null;\n disposed = false;\n unsub = null;\n /** Milliseconds-rounded timestamp of whatever the visible canvas\n * currently shows. Updated on every paint (cursor emit OR cache hit).\n * Drives the skip-near-current check in tryPaintFromCache. */\n lastPaintedMs = null;\n /** Quality of what the canvas shows, so a repaint can be judged on sharpness\n * as well as position. */\n lastPaintedQuality = \"preview\";\n /** Set true when runTick detects clock past duration so we only emit\n * the onEnded transition once per playback session. */\n endedFired = false;\n // Per-rAF realtime needles, advanced only while diagnostics are enabled.\n ticks = 0;\n paints = 0;\n lateFrames = 0;\n stalls = 0;\n catchUpMs = 0;\n droppedFrames = 0;\n /** Lifetime companion to droppedFrames for the pipeline ledger: never reset\n * by a play session and not gated on diagnostics, so the ledger is honest\n * even about what happened before the HUD opened. */\n droppedFramesTotal = 0;\n /** False from every attach of the play walk until its first frame paints.\n * The bootstrap pull is async, so the opening ticks find nextFrame null\n * and would each book a stall the user never saw. */\n paintedSincePlay = false;\n /** Whether a play session the user started is still running. A seek while\n * playing re-attaches the walk through endPlay and beginPlay without ever\n * stopping the transport, and a drag stops the transport for the length of\n * the gesture and walks the playhead across the source under it. Neither is\n * the user putting the picture down, and the per-session needles count the\n * session the user is watching, not the walk. */\n playSessionLive = false;\n /** Clock reading the transport stopped at, or null while it runs. A stopped\n * clock moves only by being seeked, so a stopped tick reading anything else\n * is a seek, which is why the comparison against it is exact. */\n stoppedAtS = null;\n /** Media time the current play session was attached at, which is what makes\n * a frame produced for a position the session has left recognisable. */\n playAnchorS = 0;\n /** Media time the previous playing tick read, so a tick can price its own\n * span in source frames. Null until the session's second tick. */\n lastTickS = null;\n /** Clock reading at the play walk's last delivery, or null when no walk is\n * attached. Set at the attach too, so the walk's opening frame is priced\n * against the wait for it. */\n lastDeliveryS = null;\n /** Source frames this tick may pull past the one it paints, refreshed per\n * tick from the span the clock covered. Sourced from ticks so a loop that\n * stops (a hidden tab parks rAF) takes the pull chain down with it. */\n catchUpBudget = 0;\n /** Clock reading at the last paint this play session put on screen, which is\n * what the wall-time gap between presents is measured from. Null until the\n * session's first paint, so an opening frame is never held for cadence. */\n lastPresentTickS = null;\n /** The share of the rate's demand the pump allows itself to present. Held as\n * a share rather than a frames-a-second figure so it survives a rate change\n * as the same statement about the machine: a pipeline that was keeping up\n * at 1x opens 4x at everything 4x asks for, with no ramp to climb. */\n presentShare = 1;\n /** Frames the running play walk has delivered into the queue, and the two\n * readings the cadence loop last took: the clock, and that same count.\n * Between them a tick knows what it asked the pipeline for and what it got. */\n walkDelivered = 0;\n cadenceTickS = null;\n cadenceDeliveredAt = 0;\n /** Frames the pipeline owes the playhead since the cadence last moved: what\n * the clock has asked for, less what the walk delivered. Zeroed on every\n * move, so it holds the evidence for the next one rather than the ground\n * lost over the session. */\n cadenceBacklog = 0;\n /** Wall seconds the walk has held a frame the clock has not run past, without\n * a break, which is the only evidence a pipeline can offer that it has room\n * to spare. */\n cadenceCushionS = 0;\n constructor(deps) {\n this.deps = deps;\n this.unsub = this.deps.cursor.subscribe((frame) => {\n this.stash(frame);\n });\n // Frames mode binds no canvas, so the loop has no other start.\n if (deps.presentation === \"frames\") {\n this.sink = new TransferFrameSink();\n this.start();\n }\n }\n /**\n * Routes an arriving frame. While playing it appends to the decode-ahead\n * queue and chains another pull until the read-ahead depth is reached (the\n * cursor's one-in-flight latch makes the extra pulls free no-ops). While\n * paused it replaces the single stashed frame, closing the superseded one\n * when it is an unpainted sample so a fast drag does not leak VideoSamples.\n */\n stash(frame) {\n if (this.deps.clock.playing) {\n if (!this.belongsToPlaySession(frame)) {\n if (frame.kind === \"sample\")\n frame.sample.close();\n this.droppedFramesTotal += 1;\n if (diagnosticsEnabled)\n this.droppedFrames += 1;\n return;\n }\n this.reanchorStarvedWalk(frame);\n this.playQueue.push(frame);\n this.walkDelivered += 1;\n const t = this.deps.clock.now();\n this.lastDeliveryS = t;\n this.dropFramesTheClockHasPassed(t);\n if (this.playQueue.length < this.readAhead()) {\n this.deps.cursor.next();\n return;\n }\n if (this.catchUpBudget > 0 && this.trailsTheClock(t)) {\n this.catchUpBudget -= 1;\n this.deps.cursor.next();\n }\n return;\n }\n const prev = this.nextFrame;\n if (prev && prev !== frame && prev.kind === \"sample\")\n prev.sample.close();\n this.nextFrame = frame;\n }\n /**\n * Decoded frames to keep queued ahead of the playhead. The decode path\n * decides it, not the shape of the frame: reading it off the frame treated\n * every sample-carrying path as pool-bound, which the long-lived decode\n * session is not, so the default path ran with a queue of one and no cushion\n * at all. A cushion is the whole point, it is what stops a decode that\n * overruns one frame interval from starving the next paint.\n */\n readAhead() {\n return this.deps.cursor.playReadAhead ?? PLAYBACK.READ_AHEAD_CANVAS;\n }\n /**\n * Whether a frame arriving while the clock runs belongs to the running play\n * session. The controller receives every frame the cursor emits, not only\n * the ones its own pulls asked for, so a seek still landing when playback\n * resumes delivers a frame for a position the session has already left.\n * Queued, that frame sits at the head never due, which blocks the paint and\n * the refill alike, and playback resumes only once the clock has run all the\n * way to it: exactly as long as the jump the user just made.\n */\n belongsToPlaySession(frame) {\n // A session opens on the frame at or before its anchor, so one interval\n // behind the anchor is a frame it legitimately starts on.\n if (frame.timestampS < this.playAnchorS - this.frameIntervalS())\n return false;\n return !this.beyondPlayHorizon(frame, this.deps.clock.now());\n }\n /**\n * Whether a queued frame is further ahead of the clock than playback can\n * legitimately buffer. Play frames come from an iterator anchored at the\n * playhead, so the buffer leads the clock by its own depth and no more. A\n * frame past that either arrived for a position the session has left, or was\n * overtaken when the clock moved backwards under the queue, which a step or\n * a seek during playback both do.\n */\n beyondPlayHorizon(frame, t) {\n return (frame.timestampS >\n t + (PLAYBACK.READ_AHEAD_CANVAS + 2) * this.frameIntervalS());\n }\n /**\n * Discards every queued frame the clock has already run past except the last\n * one, which is the frame that belongs on screen now.\n *\n * A tick can only present one frame, so whenever more than one falls due in\n * the same tick the surplus has nowhere to go. Serving them one a tick\n * instead makes the picture drift further behind the playhead every tick,\n * which is what a rate past display-refresh over source-fps produces on a\n * pipeline that is otherwise keeping up perfectly. Skipping keeps the picture\n * on the playhead and charges the difference to the dropped-frame ledger,\n * where a consumer can see what the rate cost.\n */\n dropFramesTheClockHasPassed(t) {\n // Surplus is what the clock outran while the picture was keeping up. With\n // nothing on screen yet, the session's opening frame is not surplus: it is\n // the frame the session was anchored on, and a walk that costs wall time\n // to open delivers it far enough behind the clock for this rule to drop it\n // before anything reaches the screen.\n if (!this.paintedSincePlay)\n return;\n let dropped = 0;\n while (this.playQueue.length > 1 && this.playQueue[1].timestampS <= t) {\n const stale = this.playQueue.shift();\n if (stale?.kind === \"sample\")\n stale.sample.close();\n dropped += 1;\n }\n if (dropped === 0)\n return;\n this.droppedFramesTotal += dropped;\n if (diagnosticsEnabled)\n this.droppedFrames += dropped;\n }\n /**\n * Source frames this tick may pull past the one it paints, measured as the\n * span the clock covered. One of them covers that span, which is what a rate\n * demanding more than a frame per tick needs and cannot get from a queue\n * bounded by read-ahead depth; the rest work off a backlog, so a pipeline a\n * slow stretch knocked behind can return to the playhead.\n */\n catchUpAllowance(t) {\n const previous = this.lastTickS;\n this.lastTickS = t;\n if (previous === null || t <= previous)\n return 0;\n return Math.max(1, Math.round((t - previous) / this.frameIntervalS()));\n }\n /**\n * Presents a wall second the pump allows itself right now: the rate's demand\n * cut to the share the machine has been paying for, and never under the\n * source's own frame rate. Rate 1 asks for exactly the source rate, so its\n * floor and its demand are the same figure and no share can come between\n * them, which is what leaves 1x painting every frame of every source.\n */\n presentCadence() {\n const fps = 1 / this.frameIntervalS();\n return Math.max(fps, this.deps.clock.rate * fps * this.presentShare);\n }\n /** Forgets what the cadence loop had gathered. The walk is about to be\n * attached somewhere else, and a span across that move charges the pipeline\n * for frames it was never asked to have ready. */\n resetCadenceTracking() {\n this.walkDelivered = 0;\n this.cadenceTickS = null;\n this.cadenceDeliveredAt = 0;\n this.cadenceBacklog = 0;\n this.cadenceCushionS = 0;\n }\n /**\n * Prices this tick against the pipeline and moves the share one step once\n * either reading has gathered the evidence a step takes.\n *\n * What takes the share down is a running bill, in frames, between what the\n * clock passed and what the play walk delivered over the same span.\n * Accumulating rather than thresholding each tick is what tells a shortfall\n * from noise: counting whole frames against a fractional demand leaves every\n * tick out by up to one either way and those cancel, while a machine that\n * cannot sustain the rate falls short every tick and adds up. A machine that\n * can sustain it has no way to run the bill up, since the queue only takes a\n * frame as the clock passes one and catch-up pulls repay any span the walk\n * came up short on, which bounds the bill by the depth being held.\n *\n * What puts the share back is the walk holding a frame the clock has not run\n * past, unbroken for the wall time the source takes to produce that many.\n * Nothing else would do, since a machine at equilibrium delivers exactly what\n * the clock asks for however much headroom it has, so a surplus never appears\n * in the bill to be read.\n *\n * Neither reading is the depth of the decode-ahead queue, which belongs to\n * the decode path and not to the machine: on the zero-copy path, which\n * buffers one frame, a single frame in hand is full depth and one tick from\n * empty alike.\n *\n * Nothing either reading is made of knows what the panel refreshes at, so one\n * machine lands on one cadence on any panel; a present the share declines\n * hands its time back to decode, so the loop this closes is a negative one;\n * and zeroing both on each step makes the next one gather its own evidence,\n * which is what a share sitting at the machine's limit needs to stop it\n * pulsing.\n */\n observePresentCadence(t) {\n const previousT = this.cadenceTickS;\n const previousDelivered = this.cadenceDeliveredAt;\n this.cadenceTickS = t;\n this.cadenceDeliveredAt = this.walkDelivered;\n // Before the walk's first frame the clock is passing frames nothing was\n // given the chance to have ready, which is the opening buffering window.\n if (this.walkDelivered === 0)\n return;\n if (previousT === null || t <= previousT)\n return;\n const intervalS = this.frameIntervalS();\n this.cadenceBacklog +=\n (t - previousT) / intervalS - (this.walkDelivered - previousDelivered);\n if (this.playQueue.length > 0 && !this.trailsTheClock(t)) {\n this.cadenceCushionS += (t - previousT) / this.deps.clock.rate;\n }\n else {\n this.cadenceCushionS = 0;\n }\n const cushionS = PLAYBACK.PRESENT_CADENCE_EVIDENCE_FRAMES * intervalS;\n if (this.cadenceBacklog >= PLAYBACK.PRESENT_CADENCE_EVIDENCE_FRAMES) {\n this.presentShare = Math.max(1 / PLAYBACK_RATE.MAX, this.presentShare * PLAYBACK.PRESENT_CADENCE_STEP);\n }\n else if (this.cadenceCushionS >= cushionS) {\n this.presentShare = Math.min(1, this.presentShare / PLAYBACK.PRESENT_CADENCE_STEP);\n }\n else {\n return;\n }\n this.cadenceBacklog = 0;\n this.cadenceCushionS = 0;\n }\n /**\n * Whether the frame that is due arrives too soon after the last painted one\n * to earn the screen. Wall time, read as the media span over the clock's\n * slope, so the same gap decides at every rate. The pump paints at most one\n * frame per display tick, so it can only decline one on a panel whose ticks\n * fall closer together than the interval it compares against.\n *\n * A cadence sitting at its ceiling never reaches the wall-time test, so a\n * machine keeping up declines nothing at any rate, and the ceiling equals the\n * demand at rate 1, which paints every frame of every source. A clock that\n * moved backwards under the pump reads as a non-positive span and paints,\n * which is what stops a step or a seek during playback from holding the\n * picture for the length of the jump it made.\n */\n skipsForPresentCadence(t) {\n if (this.lastPresentTickS === null)\n return false;\n const rate = this.deps.clock.rate;\n const cadenceHz = this.presentCadence();\n const demandHz = rate / this.frameIntervalS();\n if (demandHz <= cadenceHz)\n return false;\n const sinceMs = ((t - this.lastPresentTickS) / rate) * 1000;\n if (sinceMs <= 0)\n return false;\n return sinceMs < (1000 / cadenceHz) * (1 - PLAYBACK.PRESENT_CADENCE_SLACK);\n }\n /**\n * Re-anchors the playback walk when the clock ran further past the previous\n * tick than a loop that was actually running could have let it, and reports\n * whether it did.\n *\n * A hidden tab parks rAF while the clock keeps wall time, so the tick that\n * resumes finds a backlog as deep as the whole absence. Walked, it costs one\n * decode per frame nobody was there to see, and the picture replays the\n * absence at decode rate on its way to the playhead: 30 seconds hidden\n * measured 4961ms of 8x fast-forward. Anchoring at the playhead lands the\n * frame that walk was travelling toward for one keyframe seek, flat in how\n * far the clock went.\n *\n * Dividing the span by the clock's slope is what makes the reading wall time,\n * so a high rate legitimately moving the clock several frames per tick is not\n * read as a loop nobody was driving.\n */\n reanchorParkedWalk(t) {\n const previous = this.lastTickS;\n if (previous === null)\n return false;\n const wallMs = ((t - previous) / this.deps.clock.rate) * 1000;\n if (wallMs <= PLAYBACK.REANCHOR_STALL_MS)\n return false;\n this.lastTickS = t;\n this.lastDeliveryS = t;\n this.catchUpBudget = 0;\n this.resetCadenceTracking();\n this.droppedFramesTotal += this.playQueue.length;\n if (diagnosticsEnabled)\n this.droppedFrames += this.playQueue.length;\n this.clearPlayQueue();\n this.playAnchorS = t;\n this.tryPaintFromCache(t * 1000);\n this.deps.cursor.attachPlay(t);\n this.deps.cursor.next();\n return true;\n }\n /**\n * Puts the clock back onto the frame a starved walk has just delivered.\n *\n * A seek onto unbuffered ground leaves the render loop ticking with nothing\n * to paint, so no tick gap ever opens and reanchorParkedWalk never sees it;\n * what stopped is the walk. The clock runs through the whole wait, so the\n * frame the viewer asked for arrives that far behind it, and a walk left to\n * catch up spends the wait replaying it at decode speed: the position they\n * asked to see, flashed past on the way to one they did not.\n */\n reanchorStarvedWalk(frame) {\n const since = this.lastDeliveryS;\n if (since === null)\n return;\n const wallMs = ((this.deps.clock.now() - since) / this.deps.clock.rate) * 1000;\n if (wallMs <= PLAYBACK.REANCHOR_STALL_MS)\n return;\n this.deps.clock.seek(frame.timestampS);\n this.lastTickS = null;\n this.catchUpBudget = 0;\n this.lastPresentTickS = null;\n this.resetCadenceTracking();\n }\n /**\n * Whether the newest decoded frame is already older than the clock, which\n * makes the queue a backlog and the next pull catch-up. Read-ahead depth\n * cannot bound catch-up as well: a path that holds one frame consumes one\n * source frame per tick, which pins the picture to tick rate over source fps\n * whatever rate was commanded.\n */\n trailsTheClock(t) {\n const newest = this.playQueue[this.playQueue.length - 1];\n return (newest !== undefined && t - newest.timestampS > this.frameIntervalS());\n }\n /** Drops every queued play frame, closing any that are unpainted samples. */\n clearPlayQueue() {\n for (const frame of this.playQueue) {\n if (frame.kind === \"sample\")\n frame.sample.close();\n }\n this.playQueue = [];\n }\n bindCanvas(el) {\n if (!el) {\n this.stop();\n this.boundCanvas = null;\n this.sink?.dispose();\n this.sink = null;\n // Nothing will paint queued frames now; release any held samples.\n this.clearStash();\n this.clearPlayQueue();\n return;\n }\n const w = this.deps.cursor.track.decodeWidth;\n const h = this.deps.cursor.track.decodeHeight;\n if (w > 0 && h > 0) {\n el.width = w;\n el.height = h;\n }\n this.boundCanvas = el;\n this.sink?.dispose();\n this.sink = null;\n // The render loop starts now and no-ops until the renderer resolves;\n // renderer creation is async because WebGPU device acquisition is.\n this.start();\n void this.attachRenderer(el);\n }\n /** The active render backend, for diagnostics; null until it resolves. */\n rendererName() {\n return this.sink?.name ?? null;\n }\n async attachRenderer(el) {\n const renderer = await createRenderer(el, { prefer2d: this.deps.prefer2d });\n // A re-bind or dispose during the async init supersedes this renderer.\n if (this.disposed || this.boundCanvas !== el) {\n renderer.dispose();\n return;\n }\n this.sink = new RendererFrameSink(renderer);\n }\n dispose() {\n this.disposed = true;\n this.stop();\n this.unsub?.();\n this.unsub = null;\n this.clearStash();\n this.clearPlayQueue();\n this.sink?.dispose();\n this.sink = null;\n }\n /** Drops the stashed frame, closing it if it is an unpainted sample. */\n clearStash() {\n const frame = this.nextFrame;\n this.nextFrame = null;\n if (frame?.kind === \"sample\")\n frame.sample.close();\n }\n /**\n * Millisecond timestamp of whatever the visible canvas currently shows.\n * Engine.step uses this as the base for \"next frame after current\" so\n * step lands on the actual neighbor frame, not on a 1/fps arithmetic\n * guess from store.timeMs (which holds user intent during scrub).\n */\n getLastPaintedMs() {\n return this.lastPaintedMs;\n }\n getLastPaintedQuality() {\n return this.lastPaintedQuality;\n }\n /**\n * Called by the engine on play(), and again by a seek that re-anchors a walk\n * already running. Attaches the cursor's forward iterator at the current\n * media time and kicks the bootstrap pull. Also clears the EOF latch so a\n * play-restart from past-end can fire onEnded again the next time the clock\n * crosses the duration.\n */\n beginPlay(startS) {\n this.endedFired = false;\n this.playAnchorS = startS;\n // The realtime needles are per-session rates, not lifetime totals: a\n // cumulative count crosses its band on the first heavy file and trips its\n // critical on every later snapshot even once playback is smooth. Zeroing\n // them for a stop the user did not make would take the numbers the HUD is\n // showing backwards in the middle of a session they never interrupted.\n if (!this.playSessionLive) {\n this.ticks = 0;\n this.paints = 0;\n this.lateFrames = 0;\n this.stalls = 0;\n this.catchUpMs = 0;\n this.droppedFrames = 0;\n // What the share had come down to belongs to the source that measured\n // it. A seek re-anchors the walk mid-session and keeps it; the user\n // putting the picture down and starting again does not.\n this.presentShare = 1;\n }\n this.playSessionLive = true;\n this.stoppedAtS = null;\n this.paintedSincePlay = false;\n this.lastTickS = null;\n this.lastDeliveryS = startS;\n this.catchUpBudget = 0;\n this.lastPresentTickS = null;\n this.resetCadenceTracking();\n this.clearPlayQueue();\n this.deps.cursor.attachPlay(startS);\n this.deps.cursor.next();\n }\n /** Called by the engine on pause(), and by a seek re-anchoring a running\n * walk. Detaches the iterator and drops the read-ahead so paused ticks\n * never paint a stale play frame. */\n endPlay() {\n // Every caller that ends a session stops the clock before it detaches the\n // walk, so a clock still running here is a seek re-anchoring mid-session.\n this.playSessionLive = this.deps.clock.playing;\n this.stoppedAtS = this.playSessionLive ? null : this.deps.clock.now();\n this.deps.cursor.detachPlay();\n this.lastTickS = null;\n this.lastDeliveryS = null;\n this.catchUpBudget = 0;\n this.lastPresentTickS = null;\n this.resetCadenceTracking();\n this.clearPlayQueue();\n }\n /**\n * Cache lookup helper used by the engine on scrub. Asks the cursor for a\n * cached frame near the target and, if it is far enough from what the canvas\n * already shows, paints it and returns true. Either way the engine still\n * calls cursor.seekTo to land the full-res frame; the cursor's emit listener\n * stashes it for the next tick.\n *\n * The skip-near-current check rejects hits whose timestamp falls within\n * cacheSkipNearMs of the currently-displayed frame. This is the knob\n * consumers tune to control single-frame-step UX: a higher value forces a\n * fresh decode on tighter movements, a value of 0 always serves the cache.\n */\n tryPaintFromCache(timestampMs) {\n if (!this.sink)\n return false;\n const hit = this.deps.cursor.peekCached(timestampMs);\n if (!hit)\n return false;\n const hitMs = Math.round(hit.timestampS * 1000);\n if (this.lastPaintedMs !== null) {\n if (Math.abs(hitMs - this.lastPaintedMs) < this.deps.cacheSkipNearMs)\n return false;\n // And it has to be an improvement on what is showing, measured from\n // where the user is pointing. The check above measures only distance\n // from the current frame, which is blind to direction: a cached frame\n // on the far side of the playhead clears it just as easily as one\n // between the two, so scrubbing forward could paint a frame further\n // behind than the one already up.\n const showing = {\n timestampMs: this.lastPaintedMs,\n quality: this.lastPaintedQuality,\n };\n if (!earnsScreen({ timestampMs: hitMs, quality: hit.quality }, showing, timestampMs)) {\n return false;\n }\n }\n // A cache hit is always a canvas blit, never a live sample, so there is\n // no close obligation here.\n const presented = this.sink.present(hit);\n this.lastPaintedMs = hitMs;\n this.lastPaintedQuality = hit.quality;\n // This is a full frame on the display canvas and the user sees the\n // picture change, so it is a paint. Reporting only decoded frames left\n // every cache-served scrub invisible to the instruments.\n if (diagnosticsEnabled)\n this.paints += 1;\n this.deps.onPaint(hit, undefined, presented);\n return true;\n }\n start() {\n const tick = () => {\n if (this.disposed)\n return;\n const t = this.deps.clock.now();\n const playing = this.deps.clock.playing;\n this.runTick(t, playing);\n this.rafHandle = requestAnimationFrame(tick);\n };\n this.rafHandle = requestAnimationFrame(tick);\n }\n runTick(t, playing) {\n if (!this.sink)\n return;\n if (diagnosticsEnabled)\n this.ticks += 1;\n if (!playing) {\n // A reading away from where the transport stopped is a gesture\n // walking the playhead through a stop it made for itself. The engine\n // detaches the walk the same way for that stop and for a pause, so\n // this tick is the only place the two are told apart.\n if (this.stoppedAtS !== null && t !== this.stoppedAtS) {\n this.playSessionLive = true;\n this.stoppedAtS = null;\n }\n // Paused: paint a freshly-arrived frame from the most recent\n // seek exactly once. Cursor never advances on its own.\n const frame = this.nextFrame;\n if (!frame)\n return;\n this.paint(frame, t, false);\n this.nextFrame = null;\n return;\n }\n // End-of-source detection. mediabunny's iterator silently yields\n // done past EOF; without this check the playing branch keeps calling\n // cursor.next() forever and the engine never transitions out of\n // Playing status. cursor.track.durationS is set once at open().\n const durS = this.deps.cursor.track.durationS;\n if (!this.endedFired && durS > 0 && t >= durS) {\n this.endedFired = true;\n this.deps.onEnded();\n return;\n }\n if (this.reanchorParkedWalk(t))\n return;\n this.catchUpBudget = this.catchUpAllowance(t);\n // Playing: paint at most one buffered frame per rAF, then pull the next.\n // The cursor's nextPending latch holds one pull in flight, so the pump is\n // self-healing -- paint-then-pull, or pull again when empty (below) -- and\n // that one-pull cap is what makes the empty-tick re-pull safe.\n const frame = this.playQueue[0] ?? null;\n if (!frame) {\n // A playing tick with nothing to paint is the pipeline out of buffered\n // output: a stall the user sees as a freeze. The opening buffering\n // window before the first paint is expected, not a stall.\n if (diagnosticsEnabled && this.paintedSincePlay)\n this.stalls += 1;\n this.observePresentCadence(t);\n // Re-prime the pump. beginPlay's single bootstrap pull can be lost --\n // bailing on an in-flight drainSeek, or aimed at an iterator a\n // scrub-during-play already swapped -- which otherwise freezes\n // playback for good. The nextPending latch caps work at one decode,\n // so re-pulling on every empty tick recovers without stacking.\n this.deps.cursor.next();\n return;\n }\n // Overtaken by a backward clock move: waiting for the clock to reach it\n // would hold the paint and the refill for the length of the jump. Drop\n // the buffer and refill from where the clock actually is.\n if (this.beyondPlayHorizon(frame, t)) {\n this.droppedFramesTotal += this.playQueue.length;\n if (diagnosticsEnabled)\n this.droppedFrames += this.playQueue.length;\n this.clearPlayQueue();\n this.deps.cursor.next();\n return;\n }\n this.dropFramesTheClockHasPassed(t);\n this.observePresentCadence(t);\n const due = this.playQueue[0];\n // Front frame not due yet: keep it queued (the buffer is running ahead of\n // the clock, which is exactly the cushion we want).\n if (due.timestampS > t)\n return;\n this.playQueue.shift();\n if (this.skipsForPresentCadence(t)) {\n if (due.kind === \"sample\")\n due.sample.close();\n this.droppedFramesTotal += 1;\n if (diagnosticsEnabled)\n this.droppedFrames += 1;\n }\n else {\n if (diagnosticsEnabled && t - due.timestampS > this.frameIntervalS()) {\n this.lateFrames += 1;\n }\n this.lastPresentTickS = t;\n this.paint(due, t, true);\n }\n // Refill toward the read-ahead depth so a slow decode does not starve the\n // next tick. Latched to one in-flight pull, so this never stacks decodes.\n if (this.playQueue.length < this.readAhead())\n this.deps.cursor.next();\n }\n paint(frame, t, playing) {\n if (!this.sink) {\n // No sink to consume it, but we still own the sample's close.\n if (frame.kind === \"sample\")\n frame.sample.close();\n return;\n }\n const presented = this.sink.present(frame);\n if (playing)\n this.paintedSincePlay = true;\n this.lastPaintedMs = Math.round(frame.timestampS * 1000);\n this.lastPaintedQuality = frame.quality;\n // The controller owns the sample lifetime: once presented, the live\n // sample is done. Idempotent close means a teardown that also closes it\n // is safe. A VideoFrame taken out of the sample is a separate reference\n // with its own owner, so this close does not reach it.\n if (frame.kind === \"sample\")\n frame.sample.close();\n if (!diagnosticsEnabled) {\n this.deps.onPaint(frame, undefined, presented);\n return;\n }\n this.paints += 1;\n // How far the clock has run past the frame we just painted; the live\n // catch-up depth the realtime needle reads. Only meaningful while\n // playing: paused, clock.now() holds the seek target while the canvas\n // shows the landed frame, so the gap would read as a phantom lag.\n if (playing) {\n this.catchUpMs = Math.max(0, Math.round(t * 1000) - this.lastPaintedMs);\n }\n else {\n this.catchUpMs = 0;\n }\n this.deps.onPaint(frame, playing ? this.catchUpMs : undefined, presented);\n }\n /** Frame interval in seconds; the source rate when known, else a 30fps\n * fallback. Used to decide whether a playing frame painted late. */\n frameIntervalS() {\n const fps = this.deps.cursor.track.nativeFps;\n return fps && fps > 0 ? 1 / fps : 1 / 30;\n }\n /** Realtime needles for the diagnostics snapshot. Plain reads; the worker\n * broadcaster calls this synchronously. */\n getRealtimeStats() {\n return {\n ticks: this.ticks,\n paints: this.paints,\n lateFrames: this.lateFrames,\n stalls: this.stalls,\n catchUpMs: this.catchUpMs,\n playQueueDepth: this.playQueue.length,\n endedFired: this.endedFired,\n droppedFrames: this.droppedFrames,\n droppedFramesTotal: this.droppedFramesTotal,\n };\n }\n stop() {\n if (this.rafHandle)\n cancelAnimationFrame(this.rafHandle);\n this.rafHandle = 0;\n }\n }\n\n /**\n * Worker-realm trace recorder. Two fixed-capacity ring buffers capture a rolling\n * window of what the runtime did: an EVENT ring (paints, scrubs, seeks, and\n * status transitions, the moments EngineCore drives directly) and a SNAPSHOT\n * ring (the same DiagnosticsSnapshot references already assembled for the\n * broadcast). Both overwrite oldest in place, so a long session has a constant,\n * predictable footprint and the appends are O(1) with no growth or GC churn.\n *\n * Stall and decode-spike depth are not separate events: stalls ride the snapshot\n * ring (realtime.stalls), and per-decode latency rides the scrub stats, so the\n * event ring stays to the moments the engine itself initiates.\n *\n * Allocated on arm, freed on disarm or export, so a disarmed recorder costs\n * zero memory. assemble() walks both rings ascending into one self-describing\n * versioned JSON document an agent or a human can read cold: the summary carries\n * the exact Warning diagnoses the UI shows, so the trace explains itself.\n *\n * Pure data. It never touches decode, the sink, or the cursor.\n */\n const TRACE_SCHEMA = \"roboflow.videoEngine.trace/2\";\n /** A fixed-capacity ring that overwrites oldest. Pre-allocated on construction;\n * writes are one assignment plus a wrapping index bump, never a grow. */\n class Ring {\n capacity;\n slots;\n head = 0;\n count = 0;\n overwritten = 0;\n constructor(capacity) {\n this.capacity = capacity;\n this.slots = new Array(capacity);\n }\n /** Entries pushed out to make room. Non-zero means the ring no longer\n * reaches back to arm. */\n get dropped() {\n return this.overwritten;\n }\n push(value) {\n if (this.count === this.capacity)\n this.overwritten += 1;\n this.slots[this.head] = value;\n this.head = (this.head + 1) % this.capacity;\n if (this.count < this.capacity)\n this.count += 1;\n }\n /** Stored values oldest-first. */\n toArray() {\n const out = [];\n const start = this.count < this.capacity ? 0 : this.head;\n for (let i = 0; i < this.count; i++) {\n const value = this.slots[(start + i) % this.capacity];\n if (value !== undefined)\n out.push(value);\n }\n return out;\n }\n }\n /** Coverage for one ring from the entries it kept. A ring that never overwrote\n * anything still reaches back to arm, whatever its oldest entry is stamped. */\n function ringCoverage(ring, entries, durationMs) {\n const oldestTMs = entries.length ? entries[0].tMs : null;\n const newestTMs = entries.length ? entries[entries.length - 1].tMs : null;\n const fromMs = ring.dropped > 0 && oldestTMs !== null ? oldestTMs : 0;\n return {\n capacity: ring.capacity,\n retained: entries.length,\n dropped: ring.dropped,\n oldestTMs,\n newestTMs,\n coveredMs: Math.max(0, durationMs - fromMs),\n };\n }\n class TraceRecorder {\n env;\n now;\n events;\n snapshots;\n armOriginMs;\n paintedFrames = 0;\n playingMs = 0;\n lastSnapshotAtMs = null;\n lastPaints = 0;\n lastSnapshotPlaying = false;\n truncatedReason = null;\n constructor(env, now = () => performance.now(), eventCap = DIAGNOSTICS.TRACE_EVENT_CAP, snapshotCap = DIAGNOSTICS.TRACE_SNAPSHOT_CAP) {\n this.env = env;\n this.now = now;\n this.events = new Ring(eventCap);\n this.snapshots = new Ring(snapshotCap);\n this.armOriginMs = this.now();\n }\n pushEvent(event) {\n this.events.push(event);\n }\n /**\n * Stamps the broadcast snapshot with when it was taken and rings it. The\n * stamp is the whole point of keeping a series: without it the snapshots are\n * an unordered pile with no way to line them up against the events, which\n * carry one, or to tell a freeze from a slow stretch.\n */\n pushSnapshot(snapshot) {\n this.accumulatePaintRate(snapshot);\n // The worker leaves webgpuAvailable empty for the main thread to fill on\n // the broadcast copy, and the ring holds the worker's own snapshot, so\n // an unstamped series reads false on every machine.\n this.snapshots.push({\n ...snapshot,\n webgpuAvailable: this.env.webgpuAvailable,\n tMs: this.elapsedMs(),\n });\n }\n /** Records that the capture was cut short by something other than a reader\n * closing it, so an assembled trace says why it ends where it does. The\n * first reason sticks: it is the one that ended the capture. */\n truncate(reason) {\n this.truncatedReason ??= reason;\n }\n /**\n * Paints between two consecutive snapshots over the wall time between them.\n * An interval counts only when playback was up at both ends, since one\n * folded-in pause paints nothing and sinks the rate far below what the user\n * watched. The paint counter restarts with each play session, so a negative\n * delta means a new session began and that interval is dropped.\n */\n accumulatePaintRate(snapshot) {\n const atMs = this.now();\n const delta = snapshot.realtime.paints - this.lastPaints;\n const spanWasPlaying = this.lastSnapshotPlaying && snapshot.status === \"PLAYING\";\n if (spanWasPlaying && this.lastSnapshotAtMs !== null && delta >= 0) {\n this.playingMs += atMs - this.lastSnapshotAtMs;\n this.paintedFrames += delta;\n }\n this.lastSnapshotAtMs = atMs;\n this.lastPaints = snapshot.realtime.paints;\n this.lastSnapshotPlaying = snapshot.status === \"PLAYING\";\n }\n /** Worker-clock milliseconds since arm; the stamp every pushed event uses. */\n elapsedMs() {\n return this.now() - this.armOriginMs;\n }\n assemble() {\n const events = this.events.toArray();\n const snapshots = this.snapshots.toArray();\n const durationMs = this.now() - this.armOriginMs;\n return {\n schema: TRACE_SCHEMA,\n capturedAt: Date.now(),\n armOriginMs: this.armOriginMs,\n durationMs,\n coverage: {\n events: ringCoverage(this.events, events, durationMs),\n snapshots: ringCoverage(this.snapshots, snapshots, durationMs),\n },\n truncatedReason: this.truncatedReason,\n environment: {\n userAgent: this.env.userAgent,\n webgpuAvailable: this.env.webgpuAvailable,\n devicePixelRatio: this.env.devicePixelRatio,\n hardwareConcurrency: this.env.hardwareConcurrency,\n },\n events,\n snapshots,\n summary: this.summarize(snapshots),\n };\n }\n summarize(snapshots) {\n const last = snapshots.at(-1) ?? null;\n let maxCatchUpMs = 0;\n let lateFrames = 0;\n let stalls = 0;\n for (const snap of snapshots) {\n // Peaks, not the last reading. These are per-play-session counters\n // that reset on every play, so taking the last one reports whatever\n // the most recent session happened to be at and hides every stall\n // the capture was opened to catch.\n if (snap.realtime.catchUpMs > maxCatchUpMs)\n maxCatchUpMs = snap.realtime.catchUpMs;\n if (snap.realtime.lateFrames > lateFrames)\n lateFrames = snap.realtime.lateFrames;\n if (snap.realtime.stalls > stalls)\n stalls = snap.realtime.stalls;\n }\n const lookups = cacheLookups(last?.scheduler?.cache);\n return {\n scrubP50Ms: last?.scrub.p50Ms ?? 0,\n scrubP95Ms: last?.scrub.p95Ms ?? 0,\n scrubMaxMs: last?.scrub.maxMs ?? 0,\n effectivePaintFps: this.playingMs > 0\n ? (this.paintedFrames / this.playingMs) * 1000\n : null,\n lateFrames,\n stalls,\n maxCatchUpMs,\n cacheHitRatePct: lookups > 0 ? (last?.scrub.cacheHitRatePct ?? null) : null,\n cacheLookups: lookups,\n exactEvictions: last?.scheduler?.cache.exactEvictions ?? 0,\n warnings: last?.warnings ?? [],\n };\n }\n }\n\n /**\n * Byte residency for a URL source: the bytes this process holds, the fetch that\n * serves them to the demuxer, and a background walk that fills the rest in.\n *\n * The browser HTTP cache does not do this job for media. A 200 response is\n * stored whole and read back in about a millisecond, but the demuxer never\n * issues one: it opens the file with `Range: bytes=N-` and abandons the\n * response once it has what it wanted, so every read is stored as sparse data\n * against a single cache entry. Sparse data is evicted long before a hundred\n * megabytes of it accumulates, and a whole-file fetch issued after the demuxer\n * has already touched the URL lands in that same sparse entry and is discarded\n * with it. Bytes held here are held for as long as this session wants them,\n * whatever the cache does.\n */\n const DEFAULT_CHUNK_BYTES = 4 * 1024 * 1024;\n /** Granularity at which a streaming read is banked. Mediabunny never asks\n * the network for less than half a mebibyte, so every read it makes banks\n * at least one block. */\n const READ_BLOCK_BYTES = 512 * 1024;\n const RANGE_HEADER = /^bytes=(\\d+)-(\\d*)$/;\n const segmentEnd = (segment) => segment.start + segment.bytes.length;\n function readRangeHeader(init) {\n const headers = new Headers(init?.headers ?? undefined);\n const value = headers.get(\"Range\");\n if (!value)\n return null;\n const match = RANGE_HEADER.exec(value.trim());\n return match ? Number(match[1]) : null;\n }\n /**\n * Bytes held in one buffer per contiguous run, merged on insert so a lookup is\n * a single scan and a served range is a single subarray with nothing copied.\n */\n class ByteStore {\n #segments = [];\n #bytes = 0;\n get residentBytes() {\n return this.#bytes;\n }\n ranges() {\n return this.#segments.map((segment) => ({\n start: segment.start,\n end: segmentEnd(segment),\n }));\n }\n /** The held run from `offset` to the end of its run, or null. */\n runAt(offset) {\n for (const segment of this.#segments) {\n if (segment.start > offset)\n return null;\n if (segmentEnd(segment) > offset) {\n return segment.bytes.subarray(offset - segment.start);\n }\n }\n return null;\n }\n /** Where the gap containing `offset` ends, or null when nothing is held past it. */\n nextHeldStart(offset) {\n for (const segment of this.#segments) {\n if (segment.start > offset)\n return segment.start;\n }\n return null;\n }\n insert(start, bytes) {\n if (bytes.length === 0)\n return;\n const end = start + bytes.length;\n const kept = [];\n let merged = { start, bytes };\n for (const segment of this.#segments) {\n if (segmentEnd(segment) < merged.start || segment.start > end) {\n kept.push(segment);\n continue;\n }\n this.#bytes -= segment.bytes.length;\n merged = joinSegments(segment, merged);\n }\n kept.push(merged);\n kept.sort((left, right) => left.start - right.start);\n this.#segments = kept;\n this.#bytes += merged.bytes.length;\n }\n /** Drops whole runs, furthest from `focus` first, until the budget is met. */\n evictTo(budgetBytes, focus) {\n while (this.#bytes > budgetBytes && this.#segments.length > 1) {\n let worstIndex = 0;\n let worstDistance = -1;\n for (const [index, segment] of this.#segments.entries()) {\n const distance = focus < segment.start\n ? segment.start - focus\n : Math.max(0, focus - segmentEnd(segment));\n if (distance > worstDistance) {\n worstDistance = distance;\n worstIndex = index;\n }\n }\n const [dropped] = this.#segments.splice(worstIndex, 1);\n if (dropped)\n this.#bytes -= dropped.bytes.length;\n }\n }\n clear() {\n this.#segments = [];\n this.#bytes = 0;\n }\n }\n function joinSegments(left, right) {\n const start = Math.min(left.start, right.start);\n const end = Math.max(segmentEnd(left), segmentEnd(right));\n const bytes = new Uint8Array(end - start);\n bytes.set(left.bytes, left.start - start);\n bytes.set(right.bytes, right.start - start);\n return { start, bytes };\n }\n function partialResponse(bytes, start, totalBytes) {\n const end = start + bytes.length - 1;\n const headers = new Headers({\n \"Content-Length\": String(bytes.length),\n \"Content-Range\": `bytes ${start}-${end}/${totalBytes ?? \"*\"}`,\n \"Accept-Ranges\": \"bytes\",\n });\n const body = new ReadableStream({\n start(controller) {\n controller.enqueue(bytes);\n controller.close();\n },\n });\n return new Response(body, { status: 206, headers });\n }\n function totalFromHeaders(response) {\n const contentRange = response.headers.get(\"Content-Range\");\n const match = contentRange ? /\\/(\\d+)/.exec(contentRange) : null;\n if (match)\n return Number(match[1]);\n const contentLength = response.headers.get(\"Content-Length\");\n return response.status === 200 && contentLength\n ? Number(contentLength)\n : null;\n }\n function createSourceResidency(options) {\n const realFetch = options.fetchImpl ?? fetch;\n const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;\n const store = new ByteStore();\n let totalBytes = null;\n let prefetchedBytes = 0;\n let focus = 0;\n let warming = false;\n let walking = false;\n let foregroundReads = 0;\n let disposed = false;\n const warmAborts = new Set();\n /* A background chunk shares one connection and one link with the read the\n * viewer is waiting on, so the walk stands down while a read is outstanding\n * rather than lengthening it. */\n const foregroundIdle = () => foregroundReads === 0;\n /* The demuxer opens a read as an open-ended range and abandons the response\n * the moment it holds what it asked for, and a transform whose reader walked\n * away runs neither its flush nor its cancel in Chrome. */\n const teeInto = (start, response) => {\n if (!response.body)\n return response;\n let blockStart = start;\n let block = [];\n let blockBytes = 0;\n const bank = () => {\n if (disposed || blockBytes === 0)\n return;\n const banked = new Uint8Array(blockBytes);\n let at = 0;\n for (const chunk of block) {\n banked.set(chunk, at);\n at += chunk.length;\n }\n store.insert(blockStart, banked);\n store.evictTo(options.budgetBytes, focus);\n blockStart += blockBytes;\n block = [];\n blockBytes = 0;\n };\n const stream = response.body.pipeThrough(new TransformStream({\n transform(chunk, controller) {\n controller.enqueue(chunk);\n block.push(chunk);\n blockBytes += chunk.length;\n if (blockBytes >= READ_BLOCK_BYTES)\n bank();\n },\n flush: bank,\n }));\n return new Response(stream, {\n status: response.status,\n statusText: response.statusText,\n headers: response.headers,\n });\n };\n const fetchFn = async (input, init) => {\n const start = readRangeHeader(init);\n if (start !== null) {\n const held = store.runAt(start);\n if (held && held.length > 0) {\n return partialResponse(held, start, totalBytes);\n }\n }\n foregroundReads += 1;\n try {\n const response = await realFetch(input, init);\n totalBytes ??= totalFromHeaders(response);\n if (start === null || !response.ok)\n return response;\n /* A server that ignores the range answers 200 with the whole file, so\n * what arrives begins at zero whatever was asked for. */\n return teeInto(response.status === 206 ? start : 0, response);\n }\n finally {\n foregroundReads -= 1;\n }\n };\n /** The first gap at or after `from`, bounded by the chunk size. */\n const nextGap = (from) => {\n if (totalBytes === null || from >= totalBytes)\n return null;\n let cursor = from;\n while (cursor < totalBytes) {\n const held = store.runAt(cursor);\n if (!held)\n break;\n cursor += held.length;\n }\n if (cursor >= totalBytes)\n return null;\n const nextHeld = store.nextHeldStart(cursor) ?? totalBytes;\n return {\n start: cursor,\n end: Math.min(cursor + chunkBytes, nextHeld, totalBytes),\n };\n };\n const pullChunk = async (gap) => {\n const controller = new AbortController();\n warmAborts.add(controller);\n try {\n const response = await realFetch(options.url, {\n ...options.requestInit,\n headers: {\n ...options.requestInit?.headers,\n Range: `bytes=${gap.start}-${gap.end - 1}`,\n },\n signal: controller.signal,\n });\n if (!response.ok)\n return false;\n totalBytes ??= totalFromHeaders(response);\n const bytes = new Uint8Array(await response.arrayBuffer());\n if (disposed)\n return false;\n store.insert(gap.start, bytes);\n prefetchedBytes += bytes.length;\n store.evictTo(options.budgetBytes, focus);\n return true;\n }\n catch {\n return false;\n }\n finally {\n warmAborts.delete(controller);\n }\n };\n const walk = async () => {\n if (walking)\n return;\n walking = true;\n try {\n while (warming && !disposed) {\n if (!foregroundIdle() || store.residentBytes >= options.budgetBytes) {\n await new Promise((resolve) => setTimeout(resolve, 100));\n continue;\n }\n const gap = nextGap(focus) ?? nextGap(0);\n if (!gap)\n break;\n if (!(await pullChunk(gap)))\n break;\n }\n }\n finally {\n walking = false;\n warming = warming && !disposed && nextGap(0) !== null;\n }\n };\n return {\n fetchFn,\n snapshot: () => ({\n ranges: store.ranges(),\n residentBytes: store.residentBytes,\n totalBytes,\n prefetchedBytes,\n warming,\n }),\n focusAt: (offset) => {\n focus = Math.max(0, Math.floor(offset));\n },\n startWarming: () => {\n if (disposed || warming)\n return;\n warming = true;\n void walk();\n },\n stopWarming: () => {\n warming = false;\n for (const controller of warmAborts)\n controller.abort();\n warmAborts.clear();\n },\n dispose: () => {\n disposed = true;\n warming = false;\n for (const controller of warmAborts)\n controller.abort();\n warmAborts.clear();\n store.clear();\n },\n };\n }\n\n function serializeEngineError(error) {\n return { code: error.code, message: error.message };\n }\n\n /**\n * The engine, with every main-thread concern removed. Owns the clock, cursor,\n * and render controller; runs entirely inside the worker. It never touches the\n * DOM, a React store, the playhead, or the imperative handle.\n *\n * It speaks two languages. Broadcast state (time, frame index, status,\n * duration, seeking) leaves through the `emit` callback. Command results that\n * the main thread cannot predict (load metadata, the frame a step lands on)\n * are method return values the worker host correlates back to the caller.\n *\n * Time is emitted only while playing. Paused positions are owned by the main\n * thread (written optimistically on scrub, or applied from step's return), so\n * the core never emits a competing time that would yank a settled playhead.\n */\n class EngineCore {\n emit;\n emitDiagnostics;\n emitPresentedFrame;\n clock;\n cursor = null;\n residency = null;\n controller = null;\n canvas = null;\n viewport = null;\n /** The box the host said it paints these frames into, kept from the load\n * command. Under \"frames\" presentation nothing ever measures a canvas here,\n * so this is the only display geometry the engine is ever given. */\n displayBox = null;\n metadata = null;\n /**\n * The decoder failure the runtime has given up on, latched. Nothing the\n * transport is asked to do afterwards can decode a frame, so nothing it is\n * asked to do may report otherwise: a play() that answered \"playing\" over a\n * decoder that had already been condemned is how a source with one unusable\n * entry point read as healthy for the rest of the session.\n */\n decodeFailure = null;\n presentation = \"canvas\";\n durationMs = 0;\n paintSeq = 0;\n playing = false;\n /** The frame the last step landed on. A serialized burst resolves each\n * step before its rAF paints, so getLastPaintedMs lags by one or more\n * steps; basing the next step on the actual landing keeps every step in a\n * burst advancing exactly one frame instead of re-stepping a stale base. */\n lastStepLanded = null;\n /** Where the current seek's walk actually landed, known once a crisp frame\n * for it has painted. A requested time is not a frame time: it comes from a\n * marker rail or a pointer, so it usually sits between two samples. Walking\n * backward from it reaches the sample already on screen and the press reads\n * as dead. Null until that crisp paint arrives, so a step pressed before it\n * still falls back to the clock. */\n seekLanded = null;\n /** The seek still waiting on that paint, or null when none is. Every paint\n * the engine makes runs through this latch, and the render loop paints one\n * frame per animation frame from a stash a previous seek may have filled,\n * so without the target on it any leftover frame answers for the seek in\n * flight and publishes itself as the playhead. */\n awaitedSeek = null;\n /** Wall stamp of a seek being served by re-anchoring the playback walk, or\n * null when no such seek is waiting on its crisp frame. */\n playSeekStartedAtMs = null;\n playSeeks = 0;\n playSeekSamples = 0;\n playSeekSumMs = 0;\n playSeekMaxMs = 0;\n /** The frame the last paint put up, for the diagnostics screen readout. */\n lastPaintedId = null;\n /** Mirrors VideoEngine's interactive-seek latch: true when a drag paused a\n * playing engine, so endInteractiveSeek knows to resume. */\n resumeAfterInteractiveSeek = false;\n /** Worker-realm broadcast timer handle, or null when diagnostics are off. */\n diagnosticsTimer = null;\n /** Wall time the cursor was first seen draining a seek, or null while it is\n * not. How long a state has lasted is a diagnostics question, so it is\n * timed here rather than on the scheduler, whose clock is the seam tests\n * drive latency measurements through. */\n drainingSinceMs = null;\n paintRateMeter = new PaintRateMeter();\n presentedRateMeter = new PresentedRateMeter();\n /** Lazily allocated on traceArm, freed on disarm; zero memory off. */\n traceRecorder = null;\n /** The last assembled trace, kept after disarm so a stopped capture stays\n * downloadable until the next arm. Null while armed or never captured. */\n stoppedTrace = null;\n constructor(options) {\n this.emit = options.emit;\n this.emitDiagnostics = options.emitDiagnostics ?? null;\n this.emitPresentedFrame = options.emitPresentedFrame ?? null;\n this.clock = options.clock ?? new PerformanceMediaClock();\n }\n /**\n * Receives the transferred display canvas and its measured box. Stored\n * even when the controller does not exist yet (bindCanvas can arrive\n * before load) and bound as soon as load builds the controller. Passing\n * null tears the binding down and stops the render loop.\n */\n setCanvas(canvas, viewport) {\n if (canvas && this.presentation === \"frames\")\n throw canvasBindingRefused();\n this.canvas = canvas;\n if (viewport)\n this.viewport = viewport;\n this.controller?.bindCanvas(canvas);\n }\n async load(config) {\n this.presentation = config.presentation ?? \"canvas\";\n this.displayBox =\n config.decodeStrategy?.kind === \"displayBox\"\n ? {\n cssWidth: config.decodeStrategy.boxWidth,\n cssHeight: config.decodeStrategy.boxHeight,\n devicePixelRatio: config.decodeStrategy.devicePixelRatio,\n }\n : null;\n // A canvas may arrive before load, so a bind that predates the mode can\n // only be judged here, where the mode first exists.\n if (this.canvas && this.presentation === \"frames\")\n throw canvasBindingRefused();\n // The verdict belongs to the source that earned it, not to the engine.\n this.decodeFailure = null;\n this.emitStatus(PlaybackStatus.Loading);\n this.residency =\n config.sourceResidency && config.source.kind === SourceKind.Url\n ? createSourceResidency({\n url: config.source.url,\n budgetBytes: config.sourceResidency.budgetBytes,\n requestInit: urlRequestInit(config.source.crossOrigin)?.requestInit,\n })\n : null;\n this.cursor = await createScrubCursor({\n source: config.source,\n sourceResidency: this.residency ?? undefined,\n urlSource: config.urlSource,\n decodeStrategy: config.decodeStrategy ?? nativeResolution(),\n viewport: this.viewport ?? { displayWidth: null, devicePixelRatio: 1 },\n prefer2d: config.prefer2d,\n cache: config.cacheStrategy === \"none\"\n ? null\n : {\n previewWidth: config.previewWidth ?? FRAME_CACHE.PREVIEW_WIDTH_PX,\n // Budgets omitted: resolveCacheBudgets sizes both tiers\n // from device memory and frame size. previewCapacity is\n // still honored as an explicit caller override.\n previewCapacity: config.previewCapacity,\n },\n onDecodeFailure: (error) => this.handleDecodeFailure(error),\n });\n const nativeFps = this.cursor.track.nativeFps;\n this.controller = new ScrubController({\n cursor: this.cursor,\n clock: this.clock,\n onPaint: (frame, catchUpMs, presented) => this.onPaint(frame, catchUpMs, presented),\n onEnded: () => this.handleEnded(),\n cacheSkipNearMs: config.cacheSkipNearMs ?? FRAME_CACHE.SKIP_NEAR_MS,\n prefer2d: config.prefer2d,\n presentation: this.presentation,\n });\n if (this.canvas)\n this.controller.bindCanvas(this.canvas);\n this.durationMs = Math.max(0, Math.round(this.cursor.track.durationS * 1000));\n this.metadata = {\n // Recorded here rather than where the caller passed it: the cursor above\n // has resolved, so the demuxer really was opened over this kind of\n // reader. toMediabunnySource maps the three kinds one for one and\n // substitutes nothing, so what opened is what was named.\n byteSource: config.source.kind,\n durationMs: this.durationMs,\n nativeFps: nativeFps === null\n ? null\n : nativeFps,\n naturalWidth: this.cursor.track.width,\n naturalHeight: this.cursor.track.height,\n firstTimestampMs: Math.max(0, Math.round(this.cursor.track.firstTimestampS * 1000)),\n timeline: this.cursor.track.timeline.toData(),\n codec: null,\n // Reaching here means the open path's canDecode() check passed:\n // openInput throws VideoEngineError(DecodeUnsupported) before the\n // cursor is built when the codec is undecodable, so a resolved cursor\n // is decodable by construction.\n canDecode: true,\n };\n this.emit({ type: \"duration\", durationMs: this.durationMs });\n this.emitStatus(PlaybackStatus.Ready);\n if (config.sourceResidency?.prefetch)\n this.residency?.startWarming();\n return this.metadata;\n }\n play() {\n if (!this.cursor || !this.controller)\n return;\n if (this.decodeFailure)\n return this.republishFailure();\n // Resume-from-end: a play after the clock crossed duration would tick\n // straight to Ended. Snap back to 0 first.\n const durS = this.durationMs / 1000;\n if (durS > 0 && this.clock.now() >= durS) {\n const first = this.timeline().landingAt(0);\n this.clock.seek(first.mediaTimeS);\n this.emit({\n type: \"playhead\",\n frameId: first.frame,\n mediaTimeS: first.mediaTimeS,\n });\n }\n this.playing = true;\n // Playback advances the playhead, so a step after pausing must re-base on\n // wherever play left it, not the last manual step.\n this.lastStepLanded = null;\n this.forgetSeekLanding();\n this.clock.play();\n this.controller.beginPlay(this.clock.now());\n this.emitStatus(PlaybackStatus.Playing);\n }\n pause() {\n // A pause anyone asked for outranks the drag's own, so releasing the\n // drag has nothing left to resume.\n this.resumeAfterInteractiveSeek = false;\n this.playing = false;\n this.clock.pause();\n this.controller?.endPlay();\n if (this.decodeFailure)\n return this.republishFailure();\n this.emitStatus(PlaybackStatus.Paused);\n }\n togglePlayback() {\n // A drag holding the engine paused as a mechanic did not change what the\n // user chose, and their choice is what there is to toggle.\n if (this.playing || this.resumeAfterInteractiveSeek)\n this.pause();\n else\n this.play();\n }\n /**\n * Sets how fast media time runs against wall time. The clock re-anchors, so\n * a change mid-playback bends the slope without moving the playhead, and the\n * decode-ahead buffer keeps its frames: they are positions in media time,\n * which a rate change does not touch. Applied while paused it simply waits,\n * since a paused clock advances at no rate at all.\n *\n * Throws on a rate outside the supported forward range.\n */\n setPlaybackRate(rate) {\n const next = resolvePlaybackRate(rate);\n if (next === this.clock.rate)\n return;\n this.clock.setRate(next);\n // A new slope makes every sample in the presented-rate window a\n // measurement of the old one.\n this.presentedRateMeter.reset();\n this.emit({ type: \"rate\", rate: next });\n this.pushTraceEvent({ type: \"rate\", rate: next });\n }\n getPlaybackRate() {\n return this.clock.rate;\n }\n /** Fire-and-forget seek onto a named frame. Emits no playhead: the main\n * thread snapped the gesture and wrote it. Paints a cache preview, then\n * walks the cursor. */\n scrub(frameIndex, intent = \"gesture\") {\n if (!this.cursor)\n return;\n const target = this.timeline().landingAt(frameIndex);\n // A scrub moves the playhead off the last step, so the next step must\n // re-base on the scrub target, not the stale step landing.\n this.lastStepLanded = null;\n this.awaitSeekLanding(target.frame);\n this.clock.seek(target.mediaTimeS);\n this.controller?.tryPaintFromCache(target.mediaTimeS * 1000);\n if (!this.controller)\n return;\n if (this.playing) {\n this.reanchorPlayback(target.mediaTimeS);\n this.pushTraceEvent({\n type: \"scrub\",\n targetMs: target.mediaTimeS * 1000,\n });\n return;\n }\n this.emit({ type: \"seeking\", seeking: true });\n this.cursor.seekTo(asSec(target.mediaTimeS), intent);\n this.pushTraceEvent({ type: \"scrub\", targetMs: target.mediaTimeS * 1000 });\n void this.observeSeekIdle();\n }\n /** Moves a running playback walk to `tSec`. The clock is already there. */\n reanchorPlayback(tSec) {\n this.playSeeks += 1;\n this.playSeekStartedAtMs = performance.now();\n // The cache paint each seek path tries first can already have put the\n // crisp frame up, and then the wait is over before the walk restarts.\n if (!this.awaitedSeek)\n this.closePlaySeekWait();\n this.controller?.endPlay();\n this.controller?.beginPlay(tSec);\n }\n /** Closes the wait of a seek served by re-anchoring, at the crisp paint that\n * ends it. A seek that walked the cursor leaves no stamp; the scheduler\n * times those. Stopping the transport abandons the wait: its frame can\n * still paint minutes later, and the idle time between is not a wait\n * anyone had, so the seek keeps its count and loses its sample. */\n closePlaySeekWait() {\n const startedAtMs = this.playSeekStartedAtMs;\n if (startedAtMs === null)\n return;\n this.playSeekStartedAtMs = null;\n if (!this.playing)\n return;\n const waitMs = performance.now() - startedAtMs;\n this.playSeekSamples += 1;\n this.playSeekSumMs += waitMs;\n if (waitMs > this.playSeekMaxMs)\n this.playSeekMaxMs = waitMs;\n }\n async commit(frameIndex) {\n if (!this.cursor)\n return null;\n const target = this.timeline().landingAt(frameIndex);\n const tSec = target.mediaTimeS;\n this.lastStepLanded = null;\n this.awaitSeekLanding(target.frame);\n this.clock.seek(tSec);\n this.controller?.tryPaintFromCache(tSec * 1000);\n // While playing, moving the playhead means re-anchoring the playback\n // walk, exactly as scrub does. Seeking the cursor instead leaves the walk\n // where it was, so playback carries on from the old position and the seek\n // reads as ignored.\n if (this.playing && this.controller) {\n this.reanchorPlayback(tSec);\n this.pushTraceEvent({ type: \"seek\", targetMs: tSec * 1000 });\n return null;\n }\n this.emit({ type: \"seeking\", seeking: true });\n this.cursor.seekTo(asSec(tSec));\n await this.cursor.seekSettled();\n this.pushTraceEvent({\n type: \"seek\",\n targetMs: tSec * 1000,\n landedMs: this.controller?.getLastPaintedMs() ?? undefined,\n });\n this.emit({ type: \"seeking\", seeking: false });\n // A long GOP can end the walk on a different frame from the one aimed\n // at, and the crisp paint is what recorded which.\n const landed = this.landingOf(this.seekLanded);\n this.closeSeekLanding();\n return landed;\n }\n async seekToKey(timeMs) {\n if (!this.cursor)\n return null;\n const tSec = timeMs / 1000;\n const timeline = this.timeline();\n const target = timeline.idAt(timeline.indexAtOrBefore(asSec(tSec)));\n this.lastStepLanded = null;\n // Playing, this seek re-anchors the playback walk rather than running a\n // key walk, and an ordinary walk answers beside the target.\n this.awaitSeekLanding(target, !this.playing);\n this.clock.seek(tSec);\n this.controller?.tryPaintFromCache(timeMs);\n if (this.playing && this.controller) {\n this.reanchorPlayback(tSec);\n this.pushTraceEvent({ type: \"seek\", targetMs: timeMs, keyOnly: true });\n return null;\n }\n this.emit({ type: \"seeking\", seeking: true });\n this.cursor.seekToKey(asSec(tSec));\n await this.cursor.seekSettled();\n this.pushTraceEvent({\n type: \"seek\",\n targetMs: timeMs,\n landedMs: this.controller?.getLastPaintedMs() ?? undefined,\n keyOnly: true,\n });\n this.emit({ type: \"seeking\", seeking: false });\n const landed = this.landingOf(this.seekLanded);\n this.closeSeekLanding();\n return landed;\n }\n /**\n * Moves one frame along the source in presentation order and returns where\n * it landed, or null at either end. Only the worker knows which frame a\n * decode produced, so the landing is the command's return value. The stepped\n * frame paints worker-side via the controller and emits a paint like any\n * other.\n */\n async step(direction) {\n if (!this.cursor || !this.controller)\n return null;\n const timeline = this.timeline();\n // Base a serialized burst on its own last landing, else on where the\n // last seek landed, else on the clock. The order matters: the clock\n // holds what was REQUESTED, so it is a frame only once snapped, while a\n // landing already is one.\n const base = this.lastStepLanded ??\n this.seekLanded ??\n timeline.idAt(timeline.indexAtOrBefore(this.clock.now()));\n const target = timeline.landingAt(base.index + direction);\n if (target.frame.index === base.index)\n return null;\n const next = await this.cursor.seekToFrame(target.frame);\n if (!next)\n return null;\n this.lastStepLanded = target.frame;\n this.clock.seek(target.mediaTimeS);\n this.pushTraceEvent({\n type: \"seek\",\n targetMs: timeline.timeAt(base.index) * 1000,\n landedMs: target.mediaTimeS * 1000,\n });\n return target;\n }\n /** Append a runtime event to the trace ring, stamped with worker-clock time\n * since arm. Predicted-false and free when no trace is armed. */\n pushTraceEvent(event) {\n const recorder = this.traceRecorder;\n if (!recorder)\n return;\n recorder.pushEvent({ ...event, tMs: recorder.elapsedMs() });\n }\n /** Broadcast a status transition and record it on the trace ring, so an\n * exported trace shows the playback-state timeline alongside paints/seeks. */\n emitStatus(status, error = null) {\n this.emit({\n type: \"status\",\n status,\n error: error ? serializeEngineError(error) : null,\n });\n this.pushTraceEvent({ type: \"status\", status });\n }\n /**\n * The decoder cannot decode this source, and nothing else was going to say\n * so: a stalled decoder keeps accepting commands and the clock keeps\n * running, so playback reads as healthy over a canvas that will never change\n * again. Stop the transport first, then report, so the status a consumer\n * sees and what the engine is doing agree.\n */\n handleDecodeFailure(error) {\n this.decodeFailure ??= error;\n this.playing = false;\n this.clock.pause();\n this.controller?.endPlay();\n this.republishFailure();\n }\n /** Says the failure again for whoever just asked the transport to move. A\n * consumer that only listens for changes has already been told; one that\n * reads the status after its own command has to find the same answer. */\n republishFailure() {\n this.emitStatus(PlaybackStatus.Errored, this.decodeFailure);\n }\n beginInteractiveSeek() {\n if (this.resumeAfterInteractiveSeek)\n return;\n if (!this.playing)\n return;\n this.pause();\n this.resumeAfterInteractiveSeek = true;\n }\n endInteractiveSeek() {\n if (!this.resumeAfterInteractiveSeek)\n return;\n this.resumeAfterInteractiveSeek = false;\n this.play();\n }\n async dispose() {\n this.playing = false;\n this.diagnosticsStop();\n // A capture still running is evidence someone asked for; keep it, marked\n // with what cut it short, so it survives to the final traceExport.\n if (this.traceRecorder) {\n this.traceRecorder.truncate(\"engine disposed while the capture was armed\");\n this.stoppedTrace = this.traceRecorder.assemble();\n }\n this.traceRecorder = null;\n this.controller?.dispose();\n this.controller = null;\n await this.cursor?.close();\n this.cursor = null;\n this.residency?.dispose();\n this.residency = null;\n this.emitStatus(PlaybackStatus.Idle);\n }\n /** True only when the cursor exists and reports settled. Mirrors\n * VideoEngine.isIdle for tests; the facade derives its own from state. */\n isIdle() {\n const cursor = this.cursor;\n if (!cursor)\n return true;\n if (cursor.state === ScrubCursorState.Closed)\n return false;\n return cursor.isIdle;\n }\n /** Engine diagnostics snapshot (renderer, track facts, scheduler stats), or\n * null before load. The scheduler field is null on the uncached cursor. */\n getStats() {\n const cursor = this.cursor;\n if (!cursor)\n return null;\n return {\n renderer: this.controller?.rendererName() ?? null,\n track: {\n decodeWidth: cursor.track.decodeWidth,\n decodeHeight: cursor.track.decodeHeight,\n nativeFps: cursor.track.nativeFps,\n durationS: cursor.track.durationS,\n },\n scheduler: cursor.getStats?.() ?? null,\n };\n }\n async observeSeekIdle() {\n if (!this.cursor)\n return;\n await this.cursor.idle();\n this.emit({ type: \"seeking\", seeking: false });\n }\n handleEnded() {\n this.playing = false;\n this.clock.pause();\n this.controller?.endPlay();\n if (this.decodeFailure)\n return this.republishFailure();\n this.emitStatus(PlaybackStatus.Ended);\n }\n awaitSeekLanding(target, keyOnly = false) {\n this.seekLanded = null;\n this.awaitedSeek = { keyOnly, target };\n this.playSeekStartedAtMs = null;\n }\n forgetSeekLanding() {\n this.seekLanded = null;\n this.awaitedSeek = null;\n this.playSeekStartedAtMs = null;\n }\n /** Releases the latch of a seek that has already answered its caller. The\n * landing rode back on the return value, so a paint arriving after it would\n * move the playhead to somewhere the caller was never told about. */\n closeSeekLanding() {\n this.awaitedSeek = null;\n }\n /**\n * Whether this paint is the landing of the seek waiting on one.\n *\n * An exact walk answers at or before the frame it aimed at, and no further\n * back than the sample next to it, so the target falls inside the answering\n * frame's own span. That span is the whole test: a frame decoded for some\n * earlier position sits outside it however recently it was painted.\n */\n answersAwaitedSeek(painted, isKeyFrame) {\n const awaited = this.awaitedSeek;\n if (!awaited)\n return false;\n if (painted.ticks > awaited.target.ticks)\n return false;\n if (awaited.keyOnly)\n return isKeyFrame;\n return awaited.target.ticks <= this.timeline().endTicksAt(painted.index);\n }\n /** The frame table of the loaded source. Reached only from paths that have\n * already established a cursor, which is what carries it. */\n timeline() {\n const cursor = this.cursor;\n if (!cursor)\n throw new Error(\"EngineCore: no source is loaded\");\n return cursor.track.timeline;\n }\n landingOf(frame) {\n return frame === null ? null : this.timeline().landingAt(frame.index);\n }\n /**\n * Points the background walk at where the viewer is, as a byte offset. The\n * file's own byte layout is not exposed, so this reads position as a share of\n * duration: enough to order the walk outward from the playhead, and wrong by\n * at most the amount the bitrate varies.\n */\n aimResidency(mediaMs) {\n const residency = this.residency;\n if (!residency || this.durationMs <= 0)\n return;\n const totalBytes = residency.snapshot().totalBytes;\n if (totalBytes === null)\n return;\n residency.focusAt((mediaMs / this.durationMs) * totalBytes);\n }\n onPaint(frame, catchUpMs, presented) {\n this.paintSeq += 1;\n const timeline = this.timeline();\n // The decoded timestamp has been through the microsecond plane; this is\n // the one place in the engine that turns it back into a frame, and every\n // position published downstream comes from the table, not from it.\n const landing = timeline.landingAt(timeline.indexOfDecoded(frame.timestampS));\n const { frame: frameId, mediaTimeS } = landing;\n const mediaTimeMs = mediaTimeS * 1000;\n this.lastPaintedId = frameId;\n this.aimResidency(mediaTimeMs);\n // Only a crisp frame names where the walk landed. The coarse stand-in\n // that paints the instant a seek is issued can be a long way off.\n if (frame.quality === \"exact\" &&\n this.answersAwaitedSeek(frameId, frame.isKeyFrame)) {\n this.seekLanded = frameId;\n this.awaitedSeek = null;\n this.closePlaySeekWait();\n // Paused paints normally never move the playhead, but the landing\n // of an awaited seek is the one paint that says where the request\n // really settled; without this the readout keeps the requested\n // time, which almost never falls on a sample.\n if (!this.clock.playing) {\n this.emit({ type: \"playhead\", frameId, mediaTimeS });\n }\n }\n // Position and quality ride every paint. A consumer that only learns a\n // paint happened cannot tell whether the picture matches the playhead,\n // nor a crisp frame from the coarse stand-in, which is the whole\n // question while scrubbing. The catch-up needle is still playing-only.\n this.emit({\n type: \"frame\",\n paintSeq: this.paintSeq,\n frameId,\n mediaTimeS,\n quality: frame.quality,\n ...(catchUpMs === undefined ? {} : { catchUpMs }),\n });\n if (presented)\n this.present(presented, landing, frame.quality, frameRotation(frame));\n // Append to the trace event ring only while armed (predicted-false when\n // disarmed), at the same paint point that already fires.\n this.traceRecorder?.pushEvent({\n type: \"paint\",\n tMs: this.traceRecorder.elapsedMs(),\n mediaTimeMs: Math.round(mediaTimeMs),\n paintSeq: this.paintSeq,\n frameIndex: frameId.index,\n catchUpMs,\n quality: frame.quality,\n // The clock holds what this paint was serving: the last commanded\n // target while paused, the running position while playing. Recorded\n // per paint so a trace can put every paint against its target\n // without a second clock.\n targetMs: Math.round(this.clock.now() * 1000),\n });\n // Two sources of truth gated by clock state. Playing: paint owns time.\n // Paused: the main thread owns it, so stay quiet.\n if (!this.clock.playing)\n return;\n this.emit({ type: \"playhead\", frameId, mediaTimeS });\n }\n /**\n * Posts one frame with the identity of the paint it came from, in the same\n * message. With no sink wired there is no receiver, so the frame is closed\n * here: the controller has already let go of it, and a frame left open pins\n * a decoder buffer.\n */\n present(frame, landing, quality, rotation) {\n const emit = this.emitPresentedFrame;\n if (!emit) {\n frame.close();\n return;\n }\n emit({\n type: \"presentedFrame\",\n paintSeq: this.paintSeq,\n frameId: landing.frame,\n mediaTimeS: landing.mediaTimeS,\n mediaTimeMs: landing.mediaTimeS * 1000,\n quality,\n rotation,\n frame,\n }, [frame]);\n }\n // -----------------------------------------------------------------------\n // Diagnostics plane: opt-in worker broadcast + on-demand trace.\n // -----------------------------------------------------------------------\n /**\n * Starts the worker-realm broadcast timer at hz (default BROADCAST_HZ) and\n * flips the controller's per-rAF counter gate on. Decode-independent: the\n * timer callback does synchronous reads plus one post, never an await and\n * never the sink, so it runs between event-loop tasks, not inside a decode.\n */\n diagnosticsStart(hz = DIAGNOSTICS.BROADCAST_HZ) {\n setDiagnosticsEnabled(true);\n if (this.diagnosticsTimer !== null)\n clearInterval(this.diagnosticsTimer);\n const periodMs = 1000 / Math.max(1, hz);\n this.diagnosticsTimer = setInterval(() => this.broadcastDiagnostics(), periodMs);\n // Fill the keyframe lane across the whole file once diagnostics are on.\n // Fire-and-forget and metadata-only (the decoder-less EncodedPacketSink),\n // so it never blocks this call nor contends with the foreground decode;\n // it runs only because diagnostics just started, never when they are off.\n void this.cursor?.ensureKeyframeIndex?.();\n }\n diagnosticsStop() {\n setDiagnosticsEnabled(false);\n if (this.diagnosticsTimer !== null) {\n clearInterval(this.diagnosticsTimer);\n this.diagnosticsTimer = null;\n }\n }\n /** Lazily allocate the trace rings and begin capturing. windowMs sizes the\n * snapshot ring alone: the event ring is fed by paints and gestures at no\n * fixed rate, so wall clock cannot size it. */\n traceArm(windowMs) {\n this.traceRecorder = new TraceRecorder(this.traceEnvironment(), undefined, DIAGNOSTICS.TRACE_EVENT_CAP, snapshotCapForWindow(windowMs));\n this.stoppedTrace = null;\n }\n /** Stop capturing and free the rings, but keep the assembled trace so it can\n * still be exported. A record-then-stop-then-download flow would otherwise\n * lose the capture, since stop used to null the recorder outright. */\n traceDisarm() {\n if (this.traceRecorder)\n this.stoppedTrace = this.traceRecorder.assemble();\n this.traceRecorder = null;\n }\n /** The current capture if still armed, else the last stopped capture. Non\n * destructive so a download can be retried; a new arm clears the stopped one. */\n traceExport() {\n return this.traceRecorder?.assemble() ?? this.stoppedTrace;\n }\n /**\n * Assembles ONE snapshot and posts it as the 'diag' event. When a trace is\n * armed, the SAME reference is ring-appended, so there is no second\n * serialization. Synchronous: reads current stats, never awaits, never paints.\n */\n broadcastDiagnostics() {\n if (!this.emitDiagnostics)\n return;\n const snapshot = this.assembleSnapshot();\n this.emitDiagnostics({ type: \"diag\", snapshot });\n this.traceRecorder?.pushSnapshot(snapshot);\n }\n assembleSnapshot() {\n const base = this.getStats();\n const scheduler = base?.scheduler ?? null;\n const cache = scheduler?.cache ?? null;\n const realtimeRaw = this.controller?.getRealtimeStats() ?? {\n ticks: 0,\n paints: 0,\n lateFrames: 0,\n stalls: 0,\n catchUpMs: 0,\n playQueueDepth: 0,\n droppedFrames: 0,\n droppedFramesTotal: 0,\n };\n const nativeFps = this.cursor?.track.nativeFps ?? null;\n const exactBytes = cache\n ? cache.exactSize * cache.exactFrameWidth * cache.exactFrameHeight * 4\n : 0;\n const previewBytes = cache\n ? cache.previewSize *\n cache.previewFrameWidth *\n cache.previewFrameHeight *\n 4\n : 0;\n const exactBudgetBytes = cache?.exactBudgetBytes ?? 0;\n const decodeW = this.cursor?.track.decodeWidth ?? 0;\n const decodeH = this.cursor?.track.decodeHeight ?? 0;\n const nativeW = this.cursor?.track.width ?? null;\n const nativeH = this.cursor?.track.height ?? null;\n // The backing-store size equals the decode size by construction (the\n // controller forces it), so it cannot answer \"decode vs display\". The\n // real painted box is the measured CSS width scaled by device pixels.\n const displayCssWidth = this.viewport?.displayWidth ?? this.paintedBoxCssWidth(nativeW, nativeH);\n const dpr = this.devicePixelRatio();\n const boundW = this.canvas?.width ?? null;\n const boundH = this.canvas?.height ?? null;\n const downscaleRatio = nativeW && nativeW > 0 ? decodeW / nativeW : null;\n const decodeArea = decodeW * decodeH;\n // Square the painted physical width against the decode height so the\n // ratio compares physical pixels painted to physical pixels decoded.\n // Null when the display box is unmeasured, so the metric reads n/a\n // rather than a misleading 1.00.\n const paintedW = displayCssWidth !== null && dpr !== null ? displayCssWidth * dpr : null;\n const paintedArea = paintedW !== null ? paintedW * paintedW : null;\n const decodeVsDisplayAreaRatio = paintedArea && paintedArea > 0 ? decodeArea / paintedArea : null;\n const gop = scheduler?.gop ?? {\n count: 0,\n avgGopS: 0,\n maxGopS: 0,\n minGopS: 0,\n stddevS: 0,\n densityPerS: 0,\n };\n const fpsForWalk = nativeFps && nativeFps > 0 ? nativeFps : 30;\n const estimatedGopWalkDepthFrames = Math.round(gop.avgGopS * fpsForWalk);\n const scrub = scheduler?.scrub ?? null;\n const hits = cache ? cache.exactHits + cache.previewHits : 0;\n const lookups = cache ? hits + cache.misses : 0;\n const cacheHitRatePct = lookups > 0 ? (hits / lookups) * 100 : 0;\n const nowMs = performance.now();\n const playheadMs = this.cursor ? Math.round(this.clock.now() * 1000) : null;\n const paintedMs = this.controller?.getLastPaintedMs() ?? null;\n const screen = paintedMs === null || !this.controller || !this.lastPaintedId\n ? null\n : {\n frameId: this.lastPaintedId,\n mediaTimeMs: paintedMs,\n quality: this.controller.getLastPaintedQuality(),\n };\n const draining = scheduler?.drain.draining ?? false;\n if (!draining)\n this.drainingSinceMs = null;\n else\n this.drainingSinceMs ??= performance.now();\n const seekDrainingForMs = this.drainingSinceMs === null\n ? 0\n : performance.now() - this.drainingSinceMs;\n const snapshot = {\n presentation: this.presentation,\n renderer: base?.renderer ?? null,\n track: base?.track ?? null,\n scheduler,\n realtime: {\n effectivePaintFps: this.paintRateMeter.sample(nowMs, realtimeRaw.paints, this.playing),\n catchUpMs: realtimeRaw.catchUpMs,\n lateFrames: realtimeRaw.lateFrames,\n stalls: realtimeRaw.stalls,\n ticks: realtimeRaw.ticks,\n paints: realtimeRaw.paints,\n playQueueDepth: realtimeRaw.playQueueDepth,\n droppedFrames: realtimeRaw.droppedFrames,\n },\n pipeline: {\n decodedFrames: scheduler?.decode.framesOut ?? null,\n paintedFrames: this.paintSeq,\n droppedFrames: realtimeRaw.droppedFramesTotal,\n },\n sourceResidency: this.residency?.snapshot() ?? null,\n cacheBytes: {\n exactBytes,\n previewBytes,\n exactBudgetBytes,\n exactBytesPct: exactBudgetBytes > 0 ? (exactBytes / exactBudgetBytes) * 100 : 0,\n },\n geometry: {\n nativeWidth: nativeW,\n nativeHeight: nativeH,\n decodeWidth: decodeW,\n decodeHeight: decodeH,\n downscaleRatio,\n decodeVsDisplayAreaRatio,\n boundCanvasWidth: boundW,\n boundCanvasHeight: boundH,\n },\n gop: {\n ...gop,\n distanceToNearestKeyframeS: playheadMs === null\n ? null\n : nearestKeyframeDistanceS(scheduler?.keyframesMs ?? [], playheadMs),\n estimatedGopWalkDepthFrames,\n },\n scrub: {\n samples: scrub?.samples ?? 0,\n avgMs: scrub?.avgMs ?? 0,\n maxMs: scrub?.maxMs ?? 0,\n p50Ms: scrub?.p50Ms ?? 0,\n p95Ms: scrub?.p95Ms ?? 0,\n targetVsLandedMs: scrub?.targetVsLandedMs ?? 0,\n timeToCrispMs: scrub?.timeToCrispMs ?? 0,\n cacheHitRatePct,\n },\n playSeek: {\n seeks: this.playSeeks,\n samples: this.playSeekSamples,\n avgMs: this.playSeekSamples > 0\n ? this.playSeekSumMs / this.playSeekSamples\n : 0,\n maxMs: this.playSeekMaxMs,\n },\n counters: {\n foregroundDecodes: scheduler?.decode.foreground ?? 0,\n prefetchExact: scheduler?.decode.prefetchExact ?? 0,\n prefetchPreview: scheduler?.decode.prefetchPreview ?? 0,\n keyframeAnchored: scheduler?.decode.keyframeAnchored ?? 0,\n exactSeeks: scheduler?.seek.exact ?? 0,\n keySeeks: scheduler?.seek.key ?? 0,\n seekCoalesceDepth: scheduler?.seek.coalesceDepth ?? 0,\n probeRoundTrips: scheduler?.probeRoundTrips ?? 0,\n prefetchInFlight: scheduler?.prefetchState.inFlight ?? false,\n prefetchGeneration: scheduler?.prefetchState.generation ?? 0,\n nextPending: scheduler?.decode.nextPending ?? 0,\n seekDrainingForMs,\n },\n memory: { jsHeapUsedBytes: null },\n nativeFps,\n rate: this.clock.rate,\n presentedRate: this.presentedRateMeter.sample(nowMs, paintedMs, this.playing),\n playheadMs,\n screen,\n status: this.statusString(),\n webgpuAvailable: false,\n warnings: [],\n };\n const warnings = evaluateWarnings(snapshot);\n return { ...snapshot, warnings };\n }\n statusString() {\n if (!this.cursor)\n return PlaybackStatus.Idle;\n if (this.decodeFailure)\n return PlaybackStatus.Errored;\n return this.playing ? PlaybackStatus.Playing : PlaybackStatus.Paused;\n }\n /**\n * CSS width the picture occupies inside the box the host declared, native\n * aspect fitted into it, or null when no box was declared. A portrait source\n * in a landscape box paints far narrower than the box, so the fit is what the\n * ratio has to be measured against.\n */\n paintedBoxCssWidth(nativeWidth, nativeHeight) {\n const box = this.displayBox;\n if (!box || !(box.cssWidth > 0) || !(box.cssHeight > 0))\n return null;\n if (!nativeWidth || !nativeHeight)\n return box.cssWidth;\n return (nativeWidth *\n Math.min(box.cssWidth / nativeWidth, box.cssHeight / nativeHeight));\n }\n /** The ratio whichever side measured one reported, or null when neither did.\n * The engine has no window of its own to read it off. */\n devicePixelRatio() {\n return (this.viewport?.devicePixelRatio ??\n this.displayBox?.devicePixelRatio ??\n null);\n }\n traceEnvironment() {\n const nav = globalThis.navigator;\n return {\n userAgent: nav?.userAgent ?? \"\",\n webgpuAvailable: typeof globalThis.GPU !== \"undefined\",\n devicePixelRatio: this.devicePixelRatio(),\n hardwareConcurrency: nav?.hardwareConcurrency ?? 0,\n };\n }\n }\n function nearestKeyframeDistanceS(keyframesMs, playheadMs) {\n let nearestMs = null;\n for (const keyframeMs of keyframesMs) {\n const distanceMs = Math.abs(keyframeMs - playheadMs);\n if (nearestMs === null || distanceMs < nearestMs)\n nearestMs = distanceMs;\n }\n return nearestMs === null ? null : nearestMs / 1000;\n }\n /** Snapshots needed to reach back windowMs at the broadcast rate, bounded by\n * the ceiling that caps what an armed capture costs in memory. */\n function snapshotCapForWindow(windowMs) {\n const wanted = Math.round((windowMs / 1000) * DIAGNOSTICS.BROADCAST_HZ);\n return Math.min(DIAGNOSTICS.TRACE_SNAPSHOT_CAP, Math.max(1, wanted));\n }\n\n function toSerializedError(error) {\n if (error instanceof VideoEngineError)\n return serializeEngineError(error);\n return {\n code: VideoEngineErrorCode.BackendCrashed,\n message: error instanceof Error ? error.message : String(error),\n };\n }\n async function settle(post, requestId, run) {\n try {\n post(await run());\n }\n catch (error) {\n post({ type: \"error\", requestId, error: toSerializedError(error) });\n }\n }\n async function handleEngineCommand(engine, command, post) {\n switch (command.type) {\n case \"bindCanvas\":\n engine.setCanvas(command.canvas, command.viewport);\n return;\n case \"unbindCanvas\":\n engine.setCanvas(null);\n return;\n case \"scrub\":\n engine.scrub(command.frameIndex, command.intent);\n return;\n case \"pause\":\n engine.pause();\n return;\n case \"togglePlayback\":\n engine.togglePlayback();\n return;\n case \"beginInteractiveSeek\":\n engine.beginInteractiveSeek();\n return;\n case \"setPlaybackRate\":\n engine.setPlaybackRate(command.rate);\n return;\n case \"diagnosticsStart\":\n engine.diagnosticsStart(command.hz);\n return;\n case \"diagnosticsStop\":\n engine.diagnosticsStop();\n return;\n case \"traceArm\":\n engine.traceArm(command.windowMs);\n return;\n case \"traceDisarm\":\n engine.traceDisarm();\n return;\n case \"load\":\n return settle(post, command.requestId, async () => ({\n type: \"ready\",\n requestId: command.requestId,\n metadata: await engine.load(command.config),\n }));\n case \"play\":\n return settle(post, command.requestId, () => {\n engine.play();\n return { type: \"ack\", requestId: command.requestId };\n });\n case \"commit\":\n return settle(post, command.requestId, async () => {\n const landing = await engine.commit(command.frameIndex);\n return {\n type: \"ack\",\n requestId: command.requestId,\n landing: landing ?? undefined,\n };\n });\n case \"seekToKey\":\n return settle(post, command.requestId, async () => {\n const landing = await engine.seekToKey(command.timeMs);\n return {\n type: \"ack\",\n requestId: command.requestId,\n landing: landing ?? undefined,\n };\n });\n case \"step\":\n return settle(post, command.requestId, async () => {\n const landing = await engine.step(command.direction);\n return {\n type: \"ack\",\n requestId: command.requestId,\n landing: landing ?? undefined,\n };\n });\n case \"endInteractiveSeek\":\n return settle(post, command.requestId, () => {\n engine.endInteractiveSeek();\n return { type: \"ack\", requestId: command.requestId };\n });\n case \"getStats\":\n return settle(post, command.requestId, () => ({\n type: \"stats\",\n requestId: command.requestId,\n stats: engine.getStats(),\n }));\n case \"traceExport\":\n return settle(post, command.requestId, () => ({\n type: \"traceExport\",\n requestId: command.requestId,\n trace: engine.traceExport(),\n }));\n case \"dispose\":\n return settle(post, command.requestId, async () => {\n await engine.dispose();\n return { type: \"ack\", requestId: command.requestId };\n });\n default: {\n const unreachable = command;\n return unreachable;\n }\n }\n }\n\n const ctx = globalThis;\n const engine = new EngineCore({\n emit: (event) => ctx.postMessage(event),\n emitDiagnostics: (event) => ctx.postMessage(event),\n // Its own channel because emit's arrow would silently drop a transfer list:\n // the VideoFrame would be structured-cloned, leaving the worker's copy open\n // and pinning a decoder buffer per paint.\n emitPresentedFrame: (event, transfer) => ctx.postMessage(event, transfer),\n });\n ctx.addEventListener(\"message\", (event) => {\n void handleEngineCommand(engine, event.data, (out) => ctx.postMessage(out));\n });\n\n})();";
|
|
33
|
+
|
|
34
|
+
const WORKER_NAME = "supervision-js-web-video-engine";
|
|
35
|
+
let workerUrl;
|
|
36
|
+
/**
|
|
37
|
+
* The one place the engine worker is spawned. Isolated for two reasons:
|
|
38
|
+
*
|
|
39
|
+
* - The build embeds the worker as source text, so it spawns from a Blob
|
|
40
|
+
* object URL and a host needs no bundler configuration to ship it. The URL
|
|
41
|
+
* is held for the lifetime of the module so every engine instance reuses it.
|
|
42
|
+
* - Every consumer mocks this module rather than spawn a real worker. Keeping
|
|
43
|
+
* it a one-function file means the mock surface is a single factory, and the
|
|
44
|
+
* facade under test stays real.
|
|
45
|
+
*/
|
|
46
|
+
function createEngineWorker() {
|
|
47
|
+
workerUrl ??= URL.createObjectURL(new Blob([EMBEDDED_ENGINE_WORKER_SOURCE], { type: "text/javascript" }));
|
|
48
|
+
return new Worker(workerUrl, { name: WORKER_NAME });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Tiny external store powering useSyncExternalStore subscriptions. Five
|
|
53
|
+
* emit channels by mutation cadence:
|
|
54
|
+
*
|
|
55
|
+
* - time: emits when the playhead moves to a different frame. Consumers
|
|
56
|
+
* wanting a slower rerender cadence apply their own selector.
|
|
57
|
+
* - frame: emits on every settled cursor-yielded frame, for marker rails.
|
|
58
|
+
* - state: status transitions only. Rare. Driven by load, play, pause,
|
|
59
|
+
* seek, end, error.
|
|
60
|
+
* - duration: emits once per loaded source.
|
|
61
|
+
* - seeking: emits when the cursor transitions in/out of an in-flight
|
|
62
|
+
* scrub. Powers an opt-in scrub-in-flight indicator. Cache-hit
|
|
63
|
+
* scrubs typically don't trip this because the cache paint is
|
|
64
|
+
* synchronous, so the engine writes true only when the cursor
|
|
65
|
+
* is actually doing a decode walk.
|
|
66
|
+
* - rate: emits on a playback-rate change. Rarest channel of the six.
|
|
67
|
+
*
|
|
68
|
+
* Channels are separate so a status-only consumer does not wake up at
|
|
69
|
+
* 60Hz when only time advances.
|
|
70
|
+
*/
|
|
71
|
+
class MirrorStore {
|
|
72
|
+
playhead = {
|
|
73
|
+
frame: { index: 0, ticks: 0 },
|
|
74
|
+
mediaTimeS: 0,
|
|
75
|
+
};
|
|
76
|
+
paintSeq = asPaintSeq(0);
|
|
77
|
+
durationMs = 0;
|
|
78
|
+
status = PlaybackStatus.Idle;
|
|
79
|
+
error = null;
|
|
80
|
+
seeking = false;
|
|
81
|
+
rate = 1;
|
|
82
|
+
// Cached so getPlaybackState returns a stable reference between state
|
|
83
|
+
// emits. Selectors using Object.is can short-circuit without the caller
|
|
84
|
+
// having to thread a custom equality fn.
|
|
85
|
+
playbackStateSnapshot = {
|
|
86
|
+
status: PlaybackStatus.Idle,
|
|
87
|
+
error: null,
|
|
88
|
+
};
|
|
89
|
+
channelListeners = {
|
|
90
|
+
time: new Set(),
|
|
91
|
+
frame: new Set(),
|
|
92
|
+
state: new Set(),
|
|
93
|
+
duration: new Set(),
|
|
94
|
+
seeking: new Set(),
|
|
95
|
+
rate: new Set(),
|
|
96
|
+
};
|
|
97
|
+
getPlayhead() {
|
|
98
|
+
return this.playhead;
|
|
99
|
+
}
|
|
100
|
+
getDurationMs() {
|
|
101
|
+
return this.durationMs;
|
|
102
|
+
}
|
|
103
|
+
getStatus() {
|
|
104
|
+
return this.status;
|
|
105
|
+
}
|
|
106
|
+
getError() {
|
|
107
|
+
return this.error;
|
|
108
|
+
}
|
|
109
|
+
getPaintSeq() {
|
|
110
|
+
return this.paintSeq;
|
|
111
|
+
}
|
|
112
|
+
getPlaybackState() {
|
|
113
|
+
return this.playbackStateSnapshot;
|
|
114
|
+
}
|
|
115
|
+
getSeeking() {
|
|
116
|
+
return this.seeking;
|
|
117
|
+
}
|
|
118
|
+
getRate() {
|
|
119
|
+
return this.rate;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Moves the playhead onto a frame. Ticks are integers, so the same frame
|
|
123
|
+
* arriving twice is recognised as the same frame and wakes nobody; the
|
|
124
|
+
* millisecond plane this replaced could not tell two positions apart at all,
|
|
125
|
+
* and emitted on every paint.
|
|
126
|
+
*/
|
|
127
|
+
writePlayhead(frame, mediaTimeS) {
|
|
128
|
+
if (this.playhead.frame.ticks === frame.ticks)
|
|
129
|
+
return;
|
|
130
|
+
this.playhead = { frame, mediaTimeS };
|
|
131
|
+
this.emit("time");
|
|
132
|
+
}
|
|
133
|
+
writePaintSeq(value) {
|
|
134
|
+
if (value === this.paintSeq)
|
|
135
|
+
return;
|
|
136
|
+
this.paintSeq = value;
|
|
137
|
+
this.emit("frame");
|
|
138
|
+
}
|
|
139
|
+
writeDurationMs(value) {
|
|
140
|
+
if (value === this.durationMs)
|
|
141
|
+
return;
|
|
142
|
+
this.durationMs = value;
|
|
143
|
+
this.emit("duration");
|
|
144
|
+
}
|
|
145
|
+
writeStatus(status, error = null) {
|
|
146
|
+
if (status === this.status && error === this.error)
|
|
147
|
+
return;
|
|
148
|
+
this.status = status;
|
|
149
|
+
this.error = error;
|
|
150
|
+
this.playbackStateSnapshot = { status, error };
|
|
151
|
+
this.emit("state");
|
|
152
|
+
}
|
|
153
|
+
writeSeeking(isSeeking) {
|
|
154
|
+
if (isSeeking === this.seeking)
|
|
155
|
+
return;
|
|
156
|
+
this.seeking = isSeeking;
|
|
157
|
+
this.emit("seeking");
|
|
158
|
+
}
|
|
159
|
+
writeRate(value) {
|
|
160
|
+
if (value === this.rate)
|
|
161
|
+
return;
|
|
162
|
+
this.rate = value;
|
|
163
|
+
this.emit("rate");
|
|
164
|
+
}
|
|
165
|
+
subscribe(channel, listener) {
|
|
166
|
+
this.channelListeners[channel].add(listener);
|
|
167
|
+
return () => {
|
|
168
|
+
this.channelListeners[channel].delete(listener);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
emit(channel) {
|
|
172
|
+
for (const l of this.channelListeners[channel])
|
|
173
|
+
l();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function deserializeEngineError(error) {
|
|
178
|
+
return error ? new VideoEngineError(error.code, error.message) : null;
|
|
179
|
+
}
|
|
180
|
+
const MIRROR_EVENT_TYPES = new Set([
|
|
181
|
+
"playhead",
|
|
182
|
+
"frame",
|
|
183
|
+
"status",
|
|
184
|
+
"duration",
|
|
185
|
+
"seeking",
|
|
186
|
+
"rate",
|
|
187
|
+
]);
|
|
188
|
+
/** True for broadcast state events only. 'diag' and 'presentedFrame' are
|
|
189
|
+
* excluded on purpose so both planes are routed before the mirror reducer ever
|
|
190
|
+
* sees them. */
|
|
191
|
+
function isMirrorEvent(event) {
|
|
192
|
+
return MIRROR_EVENT_TYPES.has(event.type);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Pure reducer: applies one broadcast state event to the mirror store. The
|
|
196
|
+
* store's own change-detection (write-on-change) decides whether subscribers
|
|
197
|
+
* wake, so replaying an idempotent event is a no-op.
|
|
198
|
+
*/
|
|
199
|
+
function applyMirrorEvent(store, event) {
|
|
200
|
+
switch (event.type) {
|
|
201
|
+
case "playhead":
|
|
202
|
+
store.writePlayhead(event.frameId, event.mediaTimeS);
|
|
203
|
+
return;
|
|
204
|
+
case "frame":
|
|
205
|
+
store.writePaintSeq(asPaintSeq(event.paintSeq));
|
|
206
|
+
return;
|
|
207
|
+
case "status":
|
|
208
|
+
store.writeStatus(event.status, deserializeEngineError(event.error));
|
|
209
|
+
return;
|
|
210
|
+
case "duration":
|
|
211
|
+
store.writeDurationMs(event.durationMs);
|
|
212
|
+
return;
|
|
213
|
+
case "seeking":
|
|
214
|
+
store.writeSeeking(event.seeking);
|
|
215
|
+
return;
|
|
216
|
+
case "rate":
|
|
217
|
+
store.writeRate(event.rate);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Deliveries between the frame the ownership check watches and the read of it.
|
|
224
|
+
* Past any legitimate double buffer and well under the decoder's frame pool, so
|
|
225
|
+
* a host that never closes is named while frames are still flowing rather than
|
|
226
|
+
* after the pool it starved has stopped them.
|
|
227
|
+
*/
|
|
228
|
+
const FRAME_OWNERSHIP_CHECK_GAP = 8;
|
|
229
|
+
/**
|
|
230
|
+
* Main-thread facade for the worker-hosted video engine. Owns the worker
|
|
231
|
+
* (spawned lazily on the first command), the mirror store React reads through
|
|
232
|
+
* useSyncExternalStore, and the imperative handle. The decode + render loop runs
|
|
233
|
+
* in the worker (EngineCore); this class never touches a cursor or clock directly.
|
|
234
|
+
*
|
|
235
|
+
* Three planes cross the worker boundary (see workerProtocol): commands go out,
|
|
236
|
+
* broadcast state comes back as MirrorEvents fed into the store, and the display
|
|
237
|
+
* canvas is transferred once on bindCanvas. Awaitable commands carry a requestId
|
|
238
|
+
* the worker echoes back so each call settles its own promise.
|
|
239
|
+
*
|
|
240
|
+
* Time has two owners, gated by playback. While playing, the worker emits time;
|
|
241
|
+
* while paused, the main thread owns the position: scrub/commit/step write it
|
|
242
|
+
* optimistically so a late worker paint never yanks it back. Consumers render the
|
|
243
|
+
* playhead by reading getTimeMs/getDurationMs at their own cadence.
|
|
244
|
+
*
|
|
245
|
+
* Engine outlives a single React render. Recreate the engine only when the
|
|
246
|
+
* source identity changes.
|
|
247
|
+
*/
|
|
248
|
+
class VideoEngine {
|
|
249
|
+
options;
|
|
250
|
+
store = new MirrorStore();
|
|
251
|
+
diagnosticsStore = new DiagnosticsStore();
|
|
252
|
+
createWorker;
|
|
253
|
+
port = null;
|
|
254
|
+
disposed = false;
|
|
255
|
+
lastTrace = null;
|
|
256
|
+
traceArmed = false;
|
|
257
|
+
pending = new Map();
|
|
258
|
+
nextRequestId = 1;
|
|
259
|
+
metadata = null;
|
|
260
|
+
/** The loaded source's frame table, held here so a gesture is resolved to a
|
|
261
|
+
* frame in the tick it arrives, with no worker round trip. */
|
|
262
|
+
timeline = null;
|
|
263
|
+
transferredCanvas = null;
|
|
264
|
+
presentedFrameHandler = null;
|
|
265
|
+
/** Deliveries counted toward the one frame-ownership check, frozen past it,
|
|
266
|
+
* and the frame that check reads. Weak, so watching costs the frame no
|
|
267
|
+
* lifetime: a host that closes and drops it leaves nothing to find, which is
|
|
268
|
+
* itself the answer. */
|
|
269
|
+
presentedDeliveries = 0;
|
|
270
|
+
watchedFrame = null;
|
|
271
|
+
cachedHandle = null;
|
|
272
|
+
/** WebGPU support is a main-thread fact the worker cannot cheaply probe, so
|
|
273
|
+
* the facade fills it onto each diagnostics snapshot for the warning rules. */
|
|
274
|
+
webgpuAvailable = typeof navigator !== "undefined" && "gpu" in navigator;
|
|
275
|
+
constructor(options, createWorker = createEngineWorker) {
|
|
276
|
+
this.options = options;
|
|
277
|
+
this.createWorker = createWorker;
|
|
278
|
+
}
|
|
279
|
+
async load() {
|
|
280
|
+
// Reflect Loading synchronously so a freshly mounted surface does not
|
|
281
|
+
// sit on stale Idle until the worker's first status mirror lands.
|
|
282
|
+
this.store.writeStatus(PlaybackStatus.Loading);
|
|
283
|
+
const config = {
|
|
284
|
+
source: toWorkerSource(this.options.source),
|
|
285
|
+
presentation: this.options.presentation,
|
|
286
|
+
cacheStrategy: this.options.cacheStrategy,
|
|
287
|
+
previewCapacity: this.options.previewCapacity,
|
|
288
|
+
previewWidth: this.options.previewWidth,
|
|
289
|
+
cacheSkipNearMs: this.options.cacheSkipNearMs,
|
|
290
|
+
decodeStrategy: this.options.decodeStrategy,
|
|
291
|
+
prefer2d: this.options.prefer2d,
|
|
292
|
+
sourceResidency: this.options.sourceResidency,
|
|
293
|
+
urlSource: this.options.urlSource,
|
|
294
|
+
};
|
|
295
|
+
try {
|
|
296
|
+
const response = await this.request((requestId) => ({
|
|
297
|
+
type: "load",
|
|
298
|
+
requestId,
|
|
299
|
+
config,
|
|
300
|
+
}));
|
|
301
|
+
if (response.type !== "ready") {
|
|
302
|
+
throw new VideoEngineError(VideoEngineErrorCode.BackendCrashed, "engine load did not return metadata");
|
|
303
|
+
}
|
|
304
|
+
this.metadata = response.metadata;
|
|
305
|
+
this.timeline = FrameTimeline.from(response.metadata.timeline);
|
|
306
|
+
this.writePlayheadAt(0);
|
|
307
|
+
return this.metadata;
|
|
308
|
+
}
|
|
309
|
+
catch (cause) {
|
|
310
|
+
const error = toEngineError(cause);
|
|
311
|
+
// Aborted is dispose draining this request, and dispose has already
|
|
312
|
+
// settled the store on Idle. Anything else is a real load failure the
|
|
313
|
+
// surface must be able to render.
|
|
314
|
+
if (error.code !== VideoEngineErrorCode.Aborted) {
|
|
315
|
+
this.store.writeStatus(PlaybackStatus.Errored, error);
|
|
316
|
+
}
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Transfers the display canvas to the worker exactly once. The transfer is
|
|
322
|
+
* permanent (it neuters the element's 2D/GPU context), so a repeat call with
|
|
323
|
+
* the same element, or a null detach, is a no-op; the binding lives until
|
|
324
|
+
* dispose terminates the worker. The viewport box is measured here, where
|
|
325
|
+
* layout lives, and shipped so a viewport-aware decode strategy can size the
|
|
326
|
+
* sink worker-side.
|
|
327
|
+
*/
|
|
328
|
+
bindCanvas = (el) => {
|
|
329
|
+
if (el && this.options.presentation === "frames")
|
|
330
|
+
throw canvasBindingRefused();
|
|
331
|
+
if (!el || el === this.transferredCanvas)
|
|
332
|
+
return;
|
|
333
|
+
const viewport = this.measureViewport(el);
|
|
334
|
+
const offscreen = el.transferControlToOffscreen();
|
|
335
|
+
this.transferredCanvas = el;
|
|
336
|
+
this.post({ type: "bindCanvas", canvas: offscreen, viewport }, [offscreen]);
|
|
337
|
+
};
|
|
338
|
+
play = async () => {
|
|
339
|
+
await this.request((requestId) => ({ type: "play", requestId }));
|
|
340
|
+
};
|
|
341
|
+
pause = () => {
|
|
342
|
+
this.post({ type: "pause" });
|
|
343
|
+
};
|
|
344
|
+
togglePlayback = () => {
|
|
345
|
+
this.post({ type: "togglePlayback" });
|
|
346
|
+
};
|
|
347
|
+
/**
|
|
348
|
+
* Forward playback speed, in media seconds per wall second. Takes effect at
|
|
349
|
+
* once while playing and on the next play while paused; either way it
|
|
350
|
+
* survives pause, seek, interactive-seek, and replay from the end, because
|
|
351
|
+
* it is the clock's slope and none of those touch it.
|
|
352
|
+
*
|
|
353
|
+
* Throws synchronously on a rate outside the supported forward range,
|
|
354
|
+
* including any reverse rate. The refusal has to happen here: the command is
|
|
355
|
+
* fire-and-forget, so a worker-side throw would reach no caller.
|
|
356
|
+
*/
|
|
357
|
+
setPlaybackRate = (rate) => {
|
|
358
|
+
const next = resolvePlaybackRate(rate);
|
|
359
|
+
this.store.writeRate(next);
|
|
360
|
+
this.post({ type: "setPlaybackRate", rate: next });
|
|
361
|
+
};
|
|
362
|
+
getPlaybackRate() {
|
|
363
|
+
return this.store.getRate();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Fire-and-forget seek: latest-wins, does not await idle. A pointer position
|
|
367
|
+
* is not a frame time and never can be, so it is resolved to the frame
|
|
368
|
+
* covering it before it reaches the store or the worker; the table lives on
|
|
369
|
+
* this thread, so the playhead lands on a frame in the same tick the gesture
|
|
370
|
+
* arrives, with no round trip.
|
|
371
|
+
*
|
|
372
|
+
* intent steers what the cache prepares next. Ignored when cacheStrategy is
|
|
373
|
+
* "none": that backend has no access modes to switch and no prefetch to aim.
|
|
374
|
+
*/
|
|
375
|
+
scrub = (target, intent) => {
|
|
376
|
+
const index = this.snap(target);
|
|
377
|
+
this.writePlayheadAt(index);
|
|
378
|
+
this.post({ type: "scrub", frameIndex: index, intent });
|
|
379
|
+
};
|
|
380
|
+
/**
|
|
381
|
+
* Pause-during-drag entry point. Consumers that handle pointer-driven
|
|
382
|
+
* scrub gestures call this on pointerdown so playback freezes and the
|
|
383
|
+
* decoder is not continuously chasing the cursor; the corresponding
|
|
384
|
+
* endInteractiveSeek call on pointerup resumes play if the engine was
|
|
385
|
+
* playing when the drag started. Matches the way native <video> pauses
|
|
386
|
+
* during the scrub-bar drag and resumes on release.
|
|
387
|
+
*/
|
|
388
|
+
beginInteractiveSeek = () => {
|
|
389
|
+
this.post({ type: "beginInteractiveSeek" });
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Pointerup counterpart to beginInteractiveSeek. Resumes play only if the
|
|
393
|
+
* engine was playing when the drag started; an idle-to-idle drag leaves the
|
|
394
|
+
* engine paused. Resolves once the worker has applied the release.
|
|
395
|
+
*/
|
|
396
|
+
endInteractiveSeek = async () => {
|
|
397
|
+
await this.request((requestId) => ({
|
|
398
|
+
type: "endInteractiveSeek",
|
|
399
|
+
requestId,
|
|
400
|
+
}));
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Awaited seek. Resolves after the cursor settles. Use on pointer-up so
|
|
404
|
+
* downstream logic (export, telemetry, marker placement) sees the
|
|
405
|
+
* settled frame.
|
|
406
|
+
*/
|
|
407
|
+
commit = async (target) => {
|
|
408
|
+
const index = this.snap(target);
|
|
409
|
+
this.writePlayheadAt(index);
|
|
410
|
+
const response = await this.request((requestId) => ({
|
|
411
|
+
type: "commit",
|
|
412
|
+
requestId,
|
|
413
|
+
frameIndex: index,
|
|
414
|
+
}));
|
|
415
|
+
// The snap above said which frame was aimed at; the ack says which one
|
|
416
|
+
// the walk reached, and a long GOP can make those differ.
|
|
417
|
+
if (response.type === "ack" && response.landing) {
|
|
418
|
+
this.writePlayhead(response.landing);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
seekToKey = async (target) => {
|
|
422
|
+
const timeline = this.requireTimeline();
|
|
423
|
+
const index = this.snap(target);
|
|
424
|
+
this.writePlayheadAt(index);
|
|
425
|
+
const response = await this.request((requestId) => ({
|
|
426
|
+
type: "seekToKey",
|
|
427
|
+
requestId,
|
|
428
|
+
timeMs: timeline.timeAt(index) * 1000,
|
|
429
|
+
}));
|
|
430
|
+
if (response.type === "ack" && response.landing) {
|
|
431
|
+
this.writePlayhead(response.landing);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
/**
|
|
435
|
+
* Moves one frame along the source in presentation order. Which frame that
|
|
436
|
+
* is depends on where the walk actually settled, which only the worker
|
|
437
|
+
* knows, so the landing rides back on the ack and is written here, keeping
|
|
438
|
+
* the paused playhead in step with the freshly painted frame.
|
|
439
|
+
*/
|
|
440
|
+
stepChain = Promise.resolve();
|
|
441
|
+
/**
|
|
442
|
+
* Serialized so concurrent callers (rapid key-repeat, mashed buttons, a
|
|
443
|
+
* programmatic loop) never run overlapping steps that race the cursor's
|
|
444
|
+
* one-shot decode iterator and wedge it. Each call chains after the previous,
|
|
445
|
+
* so every invocation advances exactly one frame from the settled position,
|
|
446
|
+
* on any surface.
|
|
447
|
+
*/
|
|
448
|
+
step = (direction) => {
|
|
449
|
+
const run = () => this.runStep(direction);
|
|
450
|
+
this.stepChain = this.stepChain.then(run, run);
|
|
451
|
+
return this.stepChain;
|
|
452
|
+
};
|
|
453
|
+
async runStep(direction) {
|
|
454
|
+
const response = await this.request((requestId) => ({
|
|
455
|
+
type: "step",
|
|
456
|
+
requestId,
|
|
457
|
+
direction,
|
|
458
|
+
}));
|
|
459
|
+
if (response.type === "ack" && response.landing) {
|
|
460
|
+
this.writePlayhead(response.landing);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Settled iff the engine is between operations. Derived from mirror state
|
|
465
|
+
* rather than the cursor (which lives in the worker): Idle/Loading read as
|
|
466
|
+
* settled, otherwise a live seek is the only thing that unsettles it.
|
|
467
|
+
*/
|
|
468
|
+
isIdle() {
|
|
469
|
+
const status = this.store.getStatus();
|
|
470
|
+
if (status === PlaybackStatus.Idle || status === PlaybackStatus.Loading)
|
|
471
|
+
return true;
|
|
472
|
+
return !this.store.getSeeking();
|
|
473
|
+
}
|
|
474
|
+
getSeeking() {
|
|
475
|
+
return this.store.getSeeking();
|
|
476
|
+
}
|
|
477
|
+
/** Where the transport has settled: a frame of the source, never a request
|
|
478
|
+
* and never a clock reading. */
|
|
479
|
+
getPlayhead() {
|
|
480
|
+
return this.store.getPlayhead();
|
|
481
|
+
}
|
|
482
|
+
getTimeMs() {
|
|
483
|
+
return this.store.getPlayhead().mediaTimeS * 1000;
|
|
484
|
+
}
|
|
485
|
+
getDurationMs() {
|
|
486
|
+
return this.store.getDurationMs();
|
|
487
|
+
}
|
|
488
|
+
getStatus() {
|
|
489
|
+
return this.store.getStatus();
|
|
490
|
+
}
|
|
491
|
+
getPaintSeq() {
|
|
492
|
+
return this.store.getPaintSeq();
|
|
493
|
+
}
|
|
494
|
+
getMetadata() {
|
|
495
|
+
return this.metadata;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Cache hit-rate, scrub-decode latency, and access mode from the worker, or
|
|
499
|
+
* null on the uncached cursor. A round-trip per call; for diagnostics, not
|
|
500
|
+
* the hot path.
|
|
501
|
+
*/
|
|
502
|
+
async getStats() {
|
|
503
|
+
const response = await this.request((requestId) => ({
|
|
504
|
+
type: "getStats",
|
|
505
|
+
requestId,
|
|
506
|
+
}));
|
|
507
|
+
return response.type === "stats" ? response.stats : null;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Opt-in diagnostics broadcast. Starts the worker's BROADCAST_HZ timer and
|
|
511
|
+
* flips on the per-rAF counters; snapshots arrive on the diag plane and land
|
|
512
|
+
* in the DiagnosticsStore. The instrument calls this on mount and
|
|
513
|
+
* stopDiagnostics on unmount, so a closed panel costs the engine nothing.
|
|
514
|
+
*/
|
|
515
|
+
startDiagnostics = (hz = DIAGNOSTICS.BROADCAST_HZ) => {
|
|
516
|
+
this.post({ type: "diagnosticsStart", hz });
|
|
517
|
+
};
|
|
518
|
+
stopDiagnostics = () => {
|
|
519
|
+
this.post({ type: "diagnosticsStop" });
|
|
520
|
+
};
|
|
521
|
+
/** Arm the worker trace rings. Fire-and-forget; disarmTrace frees them. */
|
|
522
|
+
armTrace = (windowMs) => {
|
|
523
|
+
this.traceArmed = true;
|
|
524
|
+
this.post({ type: "traceArm", windowMs });
|
|
525
|
+
};
|
|
526
|
+
disarmTrace = () => {
|
|
527
|
+
this.traceArmed = false;
|
|
528
|
+
this.post({ type: "traceDisarm" });
|
|
529
|
+
};
|
|
530
|
+
/** The capture rescued from a dispose that landed mid-recording, or null. */
|
|
531
|
+
getLastTrace = () => this.lastTrace;
|
|
532
|
+
/** Awaitable: assembles the worker trace and returns it for download. Null
|
|
533
|
+
* when nothing was armed. */
|
|
534
|
+
exportTrace = async () => {
|
|
535
|
+
const response = await this.request((requestId) => ({
|
|
536
|
+
type: "traceExport",
|
|
537
|
+
requestId,
|
|
538
|
+
}));
|
|
539
|
+
return response.type === "traceExport" ? response.trace : null;
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* Registers THE consumer of presented frames, in "frames" presentation mode.
|
|
543
|
+
* A second registration replaces the first, so at most one holder exists.
|
|
544
|
+
* The handler owns every frame it is given and must close() it; one left
|
|
545
|
+
* open pins a decoder buffer and stalls the decoder.
|
|
546
|
+
*
|
|
547
|
+
* There is deliberately no counterpart that reports the frame most recently
|
|
548
|
+
* presented. A frame readable apart from the message that carried it can be
|
|
549
|
+
* read at a moment when a newer one has already been handed out, which is
|
|
550
|
+
* the desync this plane is shaped to make impossible.
|
|
551
|
+
*/
|
|
552
|
+
onPresentedFrame = (handler) => {
|
|
553
|
+
this.presentedFrameHandler = handler;
|
|
554
|
+
};
|
|
555
|
+
/** Subscribe to diagnostics pushes; fires on every diag broadcast. Separate
|
|
556
|
+
* from the playback subscribe channels so a diagnostics consumer never wakes
|
|
557
|
+
* on playback state and vice versa. */
|
|
558
|
+
subscribeDiagnostics = (listener) => {
|
|
559
|
+
return this.diagnosticsStore.subscribe(listener);
|
|
560
|
+
};
|
|
561
|
+
/** Latest broadcast snapshot, or null before the first push. */
|
|
562
|
+
getLatestDiagnostics = () => {
|
|
563
|
+
return this.diagnosticsStore.getSnapshot();
|
|
564
|
+
};
|
|
565
|
+
subscribe(channel, listener) {
|
|
566
|
+
return this.store.subscribe(channel, listener);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Tears the engine down: drains pending requests so an in-flight load/commit
|
|
570
|
+
* rejects rather than hanging, and terminates the worker. The dispose ack is
|
|
571
|
+
* awaited first so the worker closes its cursor cleanly before the realm is
|
|
572
|
+
* killed.
|
|
573
|
+
*
|
|
574
|
+
* The engine is inert afterwards. A fire-and-forget command is dropped and an
|
|
575
|
+
* awaitable one rejects with {@link VideoEngineErrorCode.Aborted}; neither
|
|
576
|
+
* starts another worker.
|
|
577
|
+
*/
|
|
578
|
+
async dispose() {
|
|
579
|
+
const port = this.port;
|
|
580
|
+
this.disposed = true;
|
|
581
|
+
if (!port) {
|
|
582
|
+
this.store.writeStatus(PlaybackStatus.Idle);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
await this.request((requestId) => ({ type: "dispose", requestId }));
|
|
587
|
+
// Terminating the port takes the rings with it, so an armed capture
|
|
588
|
+
// is pulled out while the worker can still answer.
|
|
589
|
+
if (this.traceArmed)
|
|
590
|
+
this.lastTrace = await this.exportTrace();
|
|
591
|
+
this.traceArmed = false;
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Teardown must always complete. A wedged worker that never acks the
|
|
595
|
+
// dispose (or one whose command timeout fires) still gets terminated
|
|
596
|
+
// below; swallow the rejection so dispose never throws.
|
|
597
|
+
}
|
|
598
|
+
finally {
|
|
599
|
+
port.terminate();
|
|
600
|
+
this.port = null;
|
|
601
|
+
this.rejectAllPending(new VideoEngineError(VideoEngineErrorCode.Aborted, "video engine disposed"));
|
|
602
|
+
this.store.writeStatus(PlaybackStatus.Idle);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Curated subset exposed to React via useImperativeHandle. Cached so the
|
|
607
|
+
* handle keeps a stable identity for the lifetime of the engine (consumers
|
|
608
|
+
* may list it in useEffect deps).
|
|
609
|
+
*/
|
|
610
|
+
toHandle() {
|
|
611
|
+
if (this.cachedHandle)
|
|
612
|
+
return this.cachedHandle;
|
|
613
|
+
this.cachedHandle = {
|
|
614
|
+
play: this.play,
|
|
615
|
+
pause: this.pause,
|
|
616
|
+
togglePlayback: this.togglePlayback,
|
|
617
|
+
setPlaybackRate: this.setPlaybackRate,
|
|
618
|
+
getPlaybackRate: this.getPlaybackRateBound,
|
|
619
|
+
scrub: this.scrub,
|
|
620
|
+
commit: this.commit,
|
|
621
|
+
seekToKey: this.seekToKey,
|
|
622
|
+
step: this.step,
|
|
623
|
+
beginInteractiveSeek: this.beginInteractiveSeek,
|
|
624
|
+
endInteractiveSeek: this.endInteractiveSeek,
|
|
625
|
+
isIdle: this.isIdleBound,
|
|
626
|
+
getPlayhead: this.getPlayheadBound,
|
|
627
|
+
getTimeMs: this.getTimeMsBound,
|
|
628
|
+
getDurationMs: this.getDurationMsBound,
|
|
629
|
+
getPaintSeq: this.getPaintSeqBound,
|
|
630
|
+
getMetadata: this.getMetadataBound,
|
|
631
|
+
getStats: this.getStatsBound,
|
|
632
|
+
getPlaybackState: this.getPlaybackStateBound,
|
|
633
|
+
getSeeking: this.getSeekingBound,
|
|
634
|
+
bindCanvas: this.bindCanvas,
|
|
635
|
+
onPresentedFrame: this.onPresentedFrame,
|
|
636
|
+
subscribe: this.subscribeBound,
|
|
637
|
+
startDiagnostics: this.startDiagnostics,
|
|
638
|
+
stopDiagnostics: this.stopDiagnostics,
|
|
639
|
+
armTrace: this.armTrace,
|
|
640
|
+
disarmTrace: this.disarmTrace,
|
|
641
|
+
exportTrace: this.exportTrace,
|
|
642
|
+
getLastTrace: this.getLastTrace,
|
|
643
|
+
subscribeDiagnostics: this.subscribeDiagnostics,
|
|
644
|
+
getLatestDiagnostics: this.getLatestDiagnostics,
|
|
645
|
+
};
|
|
646
|
+
return this.cachedHandle;
|
|
647
|
+
}
|
|
648
|
+
// Bound aliases. Arrow methods (play, pause, scrub, ...) are already
|
|
649
|
+
// bound at declaration; these wrap the per-call methods so the handle can
|
|
650
|
+
// hold direct references without `() => this.foo()` re-wrappers.
|
|
651
|
+
isIdleBound = () => this.isIdle();
|
|
652
|
+
getPlayheadBound = () => this.getPlayhead();
|
|
653
|
+
getTimeMsBound = () => this.getTimeMs();
|
|
654
|
+
getDurationMsBound = () => this.getDurationMs();
|
|
655
|
+
getPaintSeqBound = () => this.getPaintSeq();
|
|
656
|
+
getMetadataBound = () => this.getMetadata();
|
|
657
|
+
getStatsBound = () => this.getStats();
|
|
658
|
+
getPlaybackStateBound = () => this.store.getPlaybackState();
|
|
659
|
+
getSeekingBound = () => this.getSeeking();
|
|
660
|
+
getPlaybackRateBound = () => this.getPlaybackRate();
|
|
661
|
+
subscribeBound = (c, l) => this.subscribe(c, l);
|
|
662
|
+
onWorkerMessage = (event) => {
|
|
663
|
+
const data = event.data;
|
|
664
|
+
// The diagnostics plane is routed to its own store before the mirror
|
|
665
|
+
// reducer or the response settler ever see it, so a 10Hz push never
|
|
666
|
+
// enters applyMirrorEvent and never wakes a playback subscriber.
|
|
667
|
+
if (data.type === "diag") {
|
|
668
|
+
this.diagnosticsStore.write({
|
|
669
|
+
...data.snapshot,
|
|
670
|
+
memory: { jsHeapUsedBytes: readPageHeapBytes() },
|
|
671
|
+
webgpuAvailable: this.webgpuAvailable,
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (data.type === "presentedFrame") {
|
|
676
|
+
this.deliverPresentedFrame(data);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (isMirrorEvent(data)) {
|
|
680
|
+
applyMirrorEvent(this.store, data);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
this.settle(data);
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* Hands one frame to the registered consumer, which owns it from here. With
|
|
687
|
+
* nobody registered it is closed at once rather than dropped on the floor:
|
|
688
|
+
* the worker has already let go of it, so an unclaimed frame is a leak that
|
|
689
|
+
* pins a decoder buffer.
|
|
690
|
+
*/
|
|
691
|
+
deliverPresentedFrame(event) {
|
|
692
|
+
const handler = this.presentedFrameHandler;
|
|
693
|
+
if (!handler) {
|
|
694
|
+
event.frame.close();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
handler({
|
|
698
|
+
paintSeq: event.paintSeq,
|
|
699
|
+
frameId: event.frameId,
|
|
700
|
+
mediaTimeS: event.mediaTimeS,
|
|
701
|
+
mediaTimeMs: event.mediaTimeMs,
|
|
702
|
+
quality: event.quality,
|
|
703
|
+
rotation: event.rotation,
|
|
704
|
+
frame: event.frame,
|
|
705
|
+
});
|
|
706
|
+
this.checkFrameOwnership(event.frame);
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Says out loud that this host does not close the frames it is handed.
|
|
710
|
+
* Nothing else in the runtime can: the frame left the worker on the transfer
|
|
711
|
+
* list, so no engine-side counter ever sees it again, and the decoder it
|
|
712
|
+
* starves reports the damage as a hung decode instead.
|
|
713
|
+
*
|
|
714
|
+
* One frame is watched and read once, so the check costs a host that closes a
|
|
715
|
+
* single allocation for the whole session and every later delivery one
|
|
716
|
+
* predicted-false branch.
|
|
717
|
+
*/
|
|
718
|
+
checkFrameOwnership(frame) {
|
|
719
|
+
const deliveries = this.presentedDeliveries;
|
|
720
|
+
if (deliveries > FRAME_OWNERSHIP_CHECK_GAP)
|
|
721
|
+
return;
|
|
722
|
+
this.presentedDeliveries = deliveries + 1;
|
|
723
|
+
if (deliveries === 0) {
|
|
724
|
+
this.watchedFrame = new WeakRef(frame);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (deliveries < FRAME_OWNERSHIP_CHECK_GAP)
|
|
728
|
+
return;
|
|
729
|
+
const watched = this.watchedFrame?.deref();
|
|
730
|
+
this.watchedFrame = null;
|
|
731
|
+
if (!watched || watched.format === null)
|
|
732
|
+
return;
|
|
733
|
+
console.warn(`[video-engine] a frame handed to onPresentedFrame was still open ${FRAME_OWNERSHIP_CHECK_GAP} frames later. ` +
|
|
734
|
+
"Each presented frame is the handler's to close(); one left open pins a decoder buffer, " +
|
|
735
|
+
"and a few of them stall decode, which surfaces later as a hung decode rather than as this.");
|
|
736
|
+
}
|
|
737
|
+
settle(response) {
|
|
738
|
+
const pending = this.pending.get(response.requestId);
|
|
739
|
+
if (!pending)
|
|
740
|
+
return;
|
|
741
|
+
clearTimeout(pending.timer);
|
|
742
|
+
this.pending.delete(response.requestId);
|
|
743
|
+
if (response.type === "error") {
|
|
744
|
+
pending.reject(deserializeEngineError(response.error));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
pending.resolve(response);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Spawns the worker on first use and wires the state plane into the mirror
|
|
751
|
+
* store. Deferring the spawn keeps construction side-effect-free, so a
|
|
752
|
+
* VideoEngine built during render (e.g. a useState initializer) never leaks
|
|
753
|
+
* a worker when React discards a duplicate.
|
|
754
|
+
*/
|
|
755
|
+
ensurePort() {
|
|
756
|
+
if (this.port)
|
|
757
|
+
return this.port;
|
|
758
|
+
// A command arriving after teardown must not spawn a replacement worker:
|
|
759
|
+
// nothing would hold it, so nothing would ever terminate it.
|
|
760
|
+
if (this.disposed)
|
|
761
|
+
return null;
|
|
762
|
+
const port = this.createWorker();
|
|
763
|
+
port.addEventListener("message", this.onWorkerMessage);
|
|
764
|
+
this.port = port;
|
|
765
|
+
return port;
|
|
766
|
+
}
|
|
767
|
+
request(build) {
|
|
768
|
+
const port = this.ensurePort();
|
|
769
|
+
if (!port) {
|
|
770
|
+
return Promise.reject(new VideoEngineError(VideoEngineErrorCode.Aborted, "video engine disposed"));
|
|
771
|
+
}
|
|
772
|
+
const requestId = this.nextRequestId++;
|
|
773
|
+
return new Promise((resolve, reject) => {
|
|
774
|
+
// Backstop a wedged worker: a command whose reply never lands (the
|
|
775
|
+
// worker stuck awaiting a hung decode that even the worker-side
|
|
776
|
+
// watchdog could not recover) would otherwise hang this promise
|
|
777
|
+
// forever, leaving the UI on a permanent spinner. Reject after a
|
|
778
|
+
// generous bound and drop the entry so the caller surfaces an error.
|
|
779
|
+
const timer = setTimeout(() => {
|
|
780
|
+
if (!this.pending.has(requestId))
|
|
781
|
+
return;
|
|
782
|
+
this.pending.delete(requestId);
|
|
783
|
+
reject(new VideoEngineError(VideoEngineErrorCode.BackendCrashed, "video engine command timed out waiting for the worker"));
|
|
784
|
+
}, HANG_RECOVERY.WORKER_COMMAND_TIMEOUT_MS);
|
|
785
|
+
this.pending.set(requestId, { resolve, reject, timer });
|
|
786
|
+
port.postMessage(build(requestId), []);
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
post(command, transfer = []) {
|
|
790
|
+
this.ensurePort()?.postMessage(command, transfer);
|
|
791
|
+
}
|
|
792
|
+
rejectAllPending(reason) {
|
|
793
|
+
for (const pending of this.pending.values()) {
|
|
794
|
+
clearTimeout(pending.timer);
|
|
795
|
+
pending.reject(reason);
|
|
796
|
+
}
|
|
797
|
+
this.pending.clear();
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The frame a target names.
|
|
801
|
+
*
|
|
802
|
+
* A frame names itself. A millisecond position is narrowed in seconds and
|
|
803
|
+
* then settled against `timeAt(i) * 1000`, the expression getTimeMs
|
|
804
|
+
* publishes: dividing a published millisecond back into seconds re-rounds
|
|
805
|
+
* it, and on tick rates like NTSC's 24000 the quotient can land just under
|
|
806
|
+
* the frame's own second, one frame early.
|
|
807
|
+
*/
|
|
808
|
+
snap(target) {
|
|
809
|
+
if (typeof target !== "number")
|
|
810
|
+
return target.index;
|
|
811
|
+
const timeline = this.requireTimeline();
|
|
812
|
+
const at = timeline.indexAtOrBefore(target / 1000);
|
|
813
|
+
const next = at + 1;
|
|
814
|
+
return next < timeline.frameCount && timeline.timeAt(next) * 1000 <= target
|
|
815
|
+
? next
|
|
816
|
+
: at;
|
|
817
|
+
}
|
|
818
|
+
writePlayheadAt(index) {
|
|
819
|
+
this.writePlayhead(this.requireTimeline().landingAt(index));
|
|
820
|
+
}
|
|
821
|
+
writePlayhead(landing) {
|
|
822
|
+
this.store.writePlayhead(landing.frame, landing.mediaTimeS);
|
|
823
|
+
}
|
|
824
|
+
requireTimeline() {
|
|
825
|
+
const timeline = this.timeline;
|
|
826
|
+
if (!timeline) {
|
|
827
|
+
throw new VideoEngineError(VideoEngineErrorCode.BackendCrashed, "video engine moved before load resolved its frame timeline");
|
|
828
|
+
}
|
|
829
|
+
return timeline;
|
|
830
|
+
}
|
|
831
|
+
measureViewport(el) {
|
|
832
|
+
const rect = el.getBoundingClientRect();
|
|
833
|
+
return {
|
|
834
|
+
displayWidth: rect.width > 0 ? rect.width : null,
|
|
835
|
+
devicePixelRatio: globalThis.devicePixelRatio || 1,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Spawning the worker can fail outright with a DOMException, so not every
|
|
841
|
+
* throw on the way to a loaded engine arrives as a VideoEngineError.
|
|
842
|
+
*/
|
|
843
|
+
/**
|
|
844
|
+
* The engine worker is spawned from a Blob object URL, whose base URL is opaque:
|
|
845
|
+
* inside it `new URL("/clip.mp4", self.location.href)` throws and `fetch` on a
|
|
846
|
+
* relative path fails to parse. The facade runs on the page, so it is the last
|
|
847
|
+
* place a relative source URL can be resolved against the document a host means.
|
|
848
|
+
*/
|
|
849
|
+
function toWorkerSource(source) {
|
|
850
|
+
if (source.kind !== SourceKind.Url)
|
|
851
|
+
return source;
|
|
852
|
+
return {
|
|
853
|
+
...source,
|
|
854
|
+
url: new URL(source.url, globalThis.location?.href).href,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Blink exposes performance.memory on Window only, so the worker that assembles
|
|
859
|
+
* the snapshot can never read it and the facade fills it in on receipt. Null off
|
|
860
|
+
* Blink.
|
|
861
|
+
*/
|
|
862
|
+
function readPageHeapBytes() {
|
|
863
|
+
const timing = performance;
|
|
864
|
+
return timing.memory?.usedJSHeapSize ?? null;
|
|
865
|
+
}
|
|
866
|
+
function toEngineError(cause) {
|
|
867
|
+
if (cause instanceof VideoEngineError)
|
|
868
|
+
return cause;
|
|
869
|
+
return new VideoEngineError(VideoEngineErrorCode.BackendCrashed, cause instanceof Error ? cause.message : String(cause), cause);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export { DIAGNOSTICS, FrameTimeline, PlaybackStatus, SourceKind, VideoEngine, VideoEngineError, VideoEngineErrorCode, asPaintSeq };
|
|
873
|
+
//# sourceMappingURL=engine.js.map
|