avbridge 2.2.0 → 2.3.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/CHANGELOG.md +125 -1
- package/NOTICE.md +2 -2
- package/README.md +100 -74
- package/THIRD_PARTY_LICENSES.md +2 -2
- package/dist/avi-2JPBSHGA.js +183 -0
- package/dist/avi-2JPBSHGA.js.map +1 -0
- package/dist/avi-F6WZJK5T.cjs +185 -0
- package/dist/avi-F6WZJK5T.cjs.map +1 -0
- package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
- package/dist/avi-NJXAXUXK.js.map +1 -0
- package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
- package/dist/avi-W6L3BTWU.cjs.map +1 -0
- package/dist/{chunk-ILKDNBSE.js → chunk-2PGRFCWB.js} +59 -10
- package/dist/chunk-2PGRFCWB.js.map +1 -0
- package/dist/chunk-5YAWWKA3.js +18 -0
- package/dist/chunk-5YAWWKA3.js.map +1 -0
- package/dist/chunk-6UUT4BEA.cjs +219 -0
- package/dist/chunk-6UUT4BEA.cjs.map +1 -0
- package/dist/{chunk-OE66B34H.cjs → chunk-7RGG6ME7.cjs} +562 -94
- package/dist/chunk-7RGG6ME7.cjs.map +1 -0
- package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
- package/dist/chunk-DCSOQH2N.js.map +1 -0
- package/dist/chunk-F3LQJKXK.cjs +20 -0
- package/dist/chunk-F3LQJKXK.cjs.map +1 -0
- package/dist/chunk-IAYKFGFG.js +200 -0
- package/dist/chunk-IAYKFGFG.js.map +1 -0
- package/dist/chunk-NNVOHKXJ.cjs +204 -0
- package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
- package/dist/{chunk-C5VA5U5O.js → chunk-NV7ILLWH.js} +556 -92
- package/dist/chunk-NV7ILLWH.js.map +1 -0
- package/dist/{chunk-HZLQNKFN.cjs → chunk-QQXBPW72.js} +54 -15
- package/dist/chunk-QQXBPW72.js.map +1 -0
- package/dist/chunk-XKPSTC34.cjs +210 -0
- package/dist/chunk-XKPSTC34.cjs.map +1 -0
- package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
- package/dist/chunk-Z33SBWL5.cjs.map +1 -0
- package/dist/element-browser.js +631 -103
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +4 -4
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +3 -3
- package/dist/index.cjs +174 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -4
- package/dist/index.d.ts +48 -4
- package/dist/index.js +93 -12
- package/dist/index.js.map +1 -1
- package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
- package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-http-reader-AZLE7YFS.cjs.map} +1 -1
- package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
- package/dist/{libav-http-reader-NQJVY273.js.map → libav-http-reader-WXG3Z7AI.js.map} +1 -1
- package/dist/{player-DUyvltvy.d.cts → player-B6WB74RD.d.cts} +63 -3
- package/dist/{player-DUyvltvy.d.ts → player-B6WB74RD.d.ts} +63 -3
- package/dist/player.cjs +5500 -0
- package/dist/player.cjs.map +1 -0
- package/dist/player.d.cts +649 -0
- package/dist/player.d.ts +649 -0
- package/dist/player.js +5498 -0
- package/dist/player.js.map +1 -0
- package/dist/source-73CAH6HW.cjs +28 -0
- package/dist/{source-CN43EI7Z.cjs.map → source-73CAH6HW.cjs.map} +1 -1
- package/dist/source-F656KYYV.js +3 -0
- package/dist/{source-FFZ7TW2B.js.map → source-F656KYYV.js.map} +1 -1
- package/dist/source-QJR3OHTW.js +3 -0
- package/dist/source-QJR3OHTW.js.map +1 -0
- package/dist/source-VB74JQ7Z.cjs +28 -0
- package/dist/source-VB74JQ7Z.cjs.map +1 -0
- package/dist/variant-routing-434STYAB.js +3 -0
- package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
- package/dist/variant-routing-HONNAA6R.cjs +12 -0
- package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
- package/package.json +9 -1
- package/src/classify/rules.ts +27 -5
- package/src/convert/remux.ts +8 -0
- package/src/convert/transcode.ts +41 -8
- package/src/element/avbridge-player.ts +845 -0
- package/src/element/player-icons.ts +25 -0
- package/src/element/player-styles.ts +472 -0
- package/src/errors.ts +47 -0
- package/src/index.ts +23 -0
- package/src/player-element.ts +18 -0
- package/src/player.ts +127 -27
- package/src/plugins/builtin.ts +2 -2
- package/src/probe/avi.ts +4 -0
- package/src/probe/index.ts +40 -10
- package/src/strategies/fallback/audio-output.ts +31 -0
- package/src/strategies/fallback/decoder.ts +83 -2
- package/src/strategies/fallback/index.ts +34 -1
- package/src/strategies/fallback/variant-routing.ts +7 -13
- package/src/strategies/fallback/video-renderer.ts +129 -33
- package/src/strategies/hybrid/decoder.ts +131 -20
- package/src/strategies/hybrid/index.ts +36 -2
- package/src/strategies/remux/index.ts +13 -1
- package/src/strategies/remux/mse.ts +12 -2
- package/src/strategies/remux/pipeline.ts +6 -0
- package/src/subtitles/index.ts +7 -3
- package/src/types.ts +53 -1
- package/src/util/libav-http-reader.ts +5 -1
- package/src/util/source.ts +28 -8
- package/src/util/transport.ts +26 -0
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/dist/avi-6SJLWIWW.cjs.map +0 -1
- package/dist/avi-GCGM7OJI.js.map +0 -1
- package/dist/chunk-C5VA5U5O.js.map +0 -1
- package/dist/chunk-HZLQNKFN.cjs.map +0 -1
- package/dist/chunk-ILKDNBSE.js.map +0 -1
- package/dist/chunk-J5MCMN3S.js +0 -27
- package/dist/chunk-J5MCMN3S.js.map +0 -1
- package/dist/chunk-L4NPOJ36.cjs.map +0 -1
- package/dist/chunk-NZU7W256.cjs +0 -29
- package/dist/chunk-NZU7W256.cjs.map +0 -1
- package/dist/chunk-OE66B34H.cjs.map +0 -1
- package/dist/chunk-WD2ZNQA7.js.map +0 -1
- package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
- package/dist/libav-http-reader-NQJVY273.js +0 -3
- package/dist/source-CN43EI7Z.cjs +0 -28
- package/dist/source-FFZ7TW2B.js +0 -3
- package/dist/variant-routing-GOHB2RZN.cjs +0 -12
- package/dist/variant-routing-JOBWXYKD.js +0 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MediaContext, PlaybackSession } from "../../types.js";
|
|
1
|
+
import type { MediaContext, PlaybackSession, TransportConfig } from "../../types.js";
|
|
2
2
|
import { VideoRenderer } from "./video-renderer.js";
|
|
3
3
|
import { AudioOutput } from "./audio-output.js";
|
|
4
4
|
import { startDecoder, type DecoderHandles } from "./decoder.js";
|
|
@@ -56,6 +56,7 @@ const READY_TIMEOUT_SECONDS = 3;
|
|
|
56
56
|
export async function createFallbackSession(
|
|
57
57
|
ctx: MediaContext,
|
|
58
58
|
target: HTMLVideoElement,
|
|
59
|
+
transport?: TransportConfig,
|
|
59
60
|
): Promise<PlaybackSession> {
|
|
60
61
|
// Normalize the source so URL inputs go through the libav HTTP block
|
|
61
62
|
// reader instead of being buffered into memory.
|
|
@@ -74,6 +75,7 @@ export async function createFallbackSession(
|
|
|
74
75
|
context: ctx,
|
|
75
76
|
renderer,
|
|
76
77
|
audio,
|
|
78
|
+
transport,
|
|
77
79
|
});
|
|
78
80
|
} catch (err) {
|
|
79
81
|
audio.destroy();
|
|
@@ -93,6 +95,31 @@ export async function createFallbackSession(
|
|
|
93
95
|
void doSeek(v);
|
|
94
96
|
},
|
|
95
97
|
});
|
|
98
|
+
// Mirror `paused` / `volume` / `muted` from the audio output — the
|
|
99
|
+
// underlying <video> never has its own src, so its native state is
|
|
100
|
+
// meaningless. This lets HTMLMediaElement consumers (<avbridge-player>
|
|
101
|
+
// controls) see the real values and control volume through the audio
|
|
102
|
+
// output's GainNode.
|
|
103
|
+
Object.defineProperty(target, "paused", {
|
|
104
|
+
configurable: true,
|
|
105
|
+
get: () => !audio.isPlaying(),
|
|
106
|
+
});
|
|
107
|
+
Object.defineProperty(target, "volume", {
|
|
108
|
+
configurable: true,
|
|
109
|
+
get: () => audio.getVolume(),
|
|
110
|
+
set: (v: number) => {
|
|
111
|
+
audio.setVolume(v);
|
|
112
|
+
target.dispatchEvent(new Event("volumechange"));
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
Object.defineProperty(target, "muted", {
|
|
116
|
+
configurable: true,
|
|
117
|
+
get: () => audio.getMuted(),
|
|
118
|
+
set: (m: boolean) => {
|
|
119
|
+
audio.setMuted(m);
|
|
120
|
+
target.dispatchEvent(new Event("volumechange"));
|
|
121
|
+
},
|
|
122
|
+
});
|
|
96
123
|
// Mirror duration so the demo's controls can use target.duration too.
|
|
97
124
|
if (ctx.duration && Number.isFinite(ctx.duration)) {
|
|
98
125
|
Object.defineProperty(target, "duration", {
|
|
@@ -215,11 +242,14 @@ export async function createFallbackSession(
|
|
|
215
242
|
if (!audio.isPlaying()) {
|
|
216
243
|
await waitForBuffer();
|
|
217
244
|
await audio.start();
|
|
245
|
+
target.dispatchEvent(new Event("play"));
|
|
246
|
+
target.dispatchEvent(new Event("playing"));
|
|
218
247
|
}
|
|
219
248
|
},
|
|
220
249
|
|
|
221
250
|
pause() {
|
|
222
251
|
void audio.pause();
|
|
252
|
+
target.dispatchEvent(new Event("pause"));
|
|
223
253
|
},
|
|
224
254
|
|
|
225
255
|
async seek(time) {
|
|
@@ -244,6 +274,9 @@ export async function createFallbackSession(
|
|
|
244
274
|
try {
|
|
245
275
|
delete (target as unknown as Record<string, unknown>).currentTime;
|
|
246
276
|
delete (target as unknown as Record<string, unknown>).duration;
|
|
277
|
+
delete (target as unknown as Record<string, unknown>).paused;
|
|
278
|
+
delete (target as unknown as Record<string, unknown>).volume;
|
|
279
|
+
delete (target as unknown as Record<string, unknown>).muted;
|
|
247
280
|
} catch { /* ignore */ }
|
|
248
281
|
},
|
|
249
282
|
|
|
@@ -19,25 +19,19 @@ import type { LibavVariant } from "./libav-loader.js";
|
|
|
19
19
|
|
|
20
20
|
const LEGACY_CONTAINERS = new Set(["avi", "asf", "flv"]);
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"rv40",
|
|
27
|
-
"mpeg2",
|
|
28
|
-
"mpeg1",
|
|
29
|
-
"theora",
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
const LEGACY_AUDIO_CODECS = new Set<AudioCodec>(["wmav2", "wmapro", "ac3", "eac3"]);
|
|
22
|
+
/** Codecs the webcodecs variant can handle (native browser codecs only).
|
|
23
|
+
* Anything not in these sets needs the custom avbridge variant. */
|
|
24
|
+
const WEBCODECS_AUDIO = new Set<AudioCodec>(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
25
|
+
const WEBCODECS_VIDEO = new Set<VideoCodec>(["h264", "h265", "vp8", "vp9", "av1"]);
|
|
33
26
|
|
|
34
27
|
export function pickLibavVariant(ctx: MediaContext): LibavVariant {
|
|
35
28
|
if (LEGACY_CONTAINERS.has(ctx.container)) return "avbridge";
|
|
36
29
|
for (const v of ctx.videoTracks) {
|
|
37
|
-
|
|
30
|
+
// Any codec the webcodecs variant can't handle → need avbridge
|
|
31
|
+
if (!WEBCODECS_VIDEO.has(v.codec)) return "avbridge";
|
|
38
32
|
}
|
|
39
33
|
for (const a of ctx.audioTracks) {
|
|
40
|
-
if (
|
|
34
|
+
if (!WEBCODECS_AUDIO.has(a.codec)) return "avbridge";
|
|
41
35
|
}
|
|
42
36
|
return "webcodecs";
|
|
43
37
|
}
|
|
@@ -20,6 +20,13 @@ import type { ClockSource } from "./audio-output.js";
|
|
|
20
20
|
* decoder was still warming up, and every frame was already in the past by
|
|
21
21
|
* the time it landed in the queue.
|
|
22
22
|
*/
|
|
23
|
+
// Periodic debug log — throttled to once per second so it doesn't
|
|
24
|
+
// flood the console at 60Hz rAF rate.
|
|
25
|
+
function isDebug(): boolean {
|
|
26
|
+
return typeof globalThis !== "undefined" && !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
|
|
27
|
+
}
|
|
28
|
+
let lastDebugLog = 0;
|
|
29
|
+
|
|
23
30
|
export class VideoRenderer {
|
|
24
31
|
private canvas: HTMLCanvasElement;
|
|
25
32
|
private ctx: CanvasRenderingContext2D;
|
|
@@ -35,6 +42,21 @@ export class VideoRenderer {
|
|
|
35
42
|
private lastPaintWall = 0;
|
|
36
43
|
/** Minimum ms between paints — paces video at roughly source fps. */
|
|
37
44
|
private paintIntervalMs: number;
|
|
45
|
+
/** Cumulative count of frames skipped because all PTS are in the future. */
|
|
46
|
+
private ticksWaiting = 0;
|
|
47
|
+
/** Cumulative count of ticks where PTS mode painted a frame. */
|
|
48
|
+
private ticksPainted = 0;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Calibration offset (microseconds) between video PTS and audio clock.
|
|
52
|
+
* Video PTS and AudioContext.currentTime can drift ~0.1% relative to
|
|
53
|
+
* each other (different clock domains). Over 45 minutes that's 2.6s.
|
|
54
|
+
* We measure the offset on the first painted frame and update it
|
|
55
|
+
* periodically so the PTS comparison stays calibrated.
|
|
56
|
+
*/
|
|
57
|
+
private ptsCalibrationUs = 0;
|
|
58
|
+
private ptsCalibrated = false;
|
|
59
|
+
private lastCalibrationWall = 0;
|
|
38
60
|
|
|
39
61
|
/** Resolves once the first decoded frame has been enqueued. */
|
|
40
62
|
readonly firstFrameReady: Promise<void>;
|
|
@@ -51,8 +73,12 @@ export class VideoRenderer {
|
|
|
51
73
|
});
|
|
52
74
|
|
|
53
75
|
this.canvas = document.createElement("canvas");
|
|
76
|
+
// object-fit:contain letterboxes the canvas bitmap (sized to
|
|
77
|
+
// frame.displayWidth × displayHeight in paint()) inside the stage so
|
|
78
|
+
// portrait / non-stage-aspect content isn't stretched. Canvas is a
|
|
79
|
+
// replaced element, so object-fit applies.
|
|
54
80
|
this.canvas.style.cssText =
|
|
55
|
-
"position:absolute;left:0;top:0;width:100%;height:100%;background:black;";
|
|
81
|
+
"position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
|
|
56
82
|
|
|
57
83
|
// Attach the canvas next to the video. When the video lives inside an
|
|
58
84
|
// `<avbridge-video>` shadow root, `target.parentElement` is the
|
|
@@ -150,45 +176,109 @@ export class VideoRenderer {
|
|
|
150
176
|
return;
|
|
151
177
|
}
|
|
152
178
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// per-frame audio-gate that caused massive overflow during decode bursts.
|
|
158
|
-
//
|
|
159
|
-
// Drift correction (runs every ~1 sec):
|
|
160
|
-
// - Video > 150 ms behind audio → drop one frame (catch up)
|
|
161
|
-
// - Video > 150 ms ahead of audio → skip one paint (let audio catch up)
|
|
179
|
+
// PTS-based painting: find the latest frame whose presentation time
|
|
180
|
+
// has arrived (timestamp ≤ audio clock), paint it, and discard any
|
|
181
|
+
// older frames. This produces correct cadence at any display refresh
|
|
182
|
+
// rate and any source fps — no 3:2 pulldown artifacts.
|
|
162
183
|
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
|
|
184
|
+
// Fallback: if frame timestamps are unreliable (all zero, synthetic),
|
|
185
|
+
// fall back to wall-clock pacing as before.
|
|
186
|
+
const rawAudioNowUs = this.clock.now() * 1_000_000;
|
|
187
|
+
const headTs = this.queue[0].timestamp ?? 0;
|
|
188
|
+
const hasPts = headTs > 0 || this.queue.length > 1;
|
|
169
189
|
|
|
170
|
-
if (
|
|
190
|
+
if (hasPts) {
|
|
191
|
+
// Calibration: video PTS and audio clock (AudioContext.currentTime)
|
|
192
|
+
// live in different clock domains with a fixed offset (different epoch)
|
|
193
|
+
// plus a small rate drift (~7ms/s). We snap the offset on first paint
|
|
194
|
+
// and re-snap every 10 seconds. Between snaps, max drift is ~70ms
|
|
195
|
+
// (under 2 frames at 24fps, below lip-sync perception threshold).
|
|
196
|
+
const wallNow = performance.now();
|
|
197
|
+
if (!this.ptsCalibrated || wallNow - this.lastCalibrationWall > 10_000) {
|
|
198
|
+
this.ptsCalibrationUs = headTs - rawAudioNowUs;
|
|
199
|
+
this.ptsCalibrated = true;
|
|
200
|
+
this.lastCalibrationWall = wallNow;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
|
|
204
|
+
const frameDurationUs = this.paintIntervalMs * 1000;
|
|
205
|
+
const deadlineUs = audioNowUs + frameDurationUs;
|
|
206
|
+
|
|
207
|
+
let bestIdx = -1;
|
|
208
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
209
|
+
const ts = this.queue[i].timestamp ?? 0;
|
|
210
|
+
if (ts <= deadlineUs) {
|
|
211
|
+
bestIdx = i;
|
|
212
|
+
} else {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
171
216
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
217
|
+
if (bestIdx < 0) {
|
|
218
|
+
this.ticksWaiting++;
|
|
219
|
+
if (isDebug()) {
|
|
220
|
+
const now = performance.now();
|
|
221
|
+
if (now - lastDebugLog > 1000) {
|
|
222
|
+
const headPtsMs = (headTs / 1000).toFixed(1);
|
|
223
|
+
const audioMs = (audioNowUs / 1000).toFixed(1);
|
|
224
|
+
const rawDriftMs = ((headTs - rawAudioNowUs) / 1000).toFixed(1);
|
|
225
|
+
const calibMs = (this.ptsCalibrationUs / 1000).toFixed(1);
|
|
226
|
+
// eslint-disable-next-line no-console
|
|
227
|
+
console.log(
|
|
228
|
+
`[avbridge:renderer] WAIT q=${this.queue.length} headPTS=${headPtsMs}ms calibAudio=${audioMs}ms ` +
|
|
229
|
+
`rawDrift=${rawDriftMs}ms calib=${calibMs}ms painted=${this.framesPainted} dropped=${this.framesDroppedLate}`,
|
|
230
|
+
);
|
|
231
|
+
lastDebugLog = now;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
188
234
|
return;
|
|
189
235
|
}
|
|
236
|
+
|
|
237
|
+
// Only drop frames that are more than 2 frame-durations behind.
|
|
238
|
+
const dropThresholdUs = audioNowUs - frameDurationUs * 2;
|
|
239
|
+
let dropped = 0;
|
|
240
|
+
while (bestIdx > 0) {
|
|
241
|
+
const ts = this.queue[0].timestamp ?? 0;
|
|
242
|
+
if (ts < dropThresholdUs) {
|
|
243
|
+
this.queue.shift()?.close();
|
|
244
|
+
this.framesDroppedLate++;
|
|
245
|
+
bestIdx--;
|
|
246
|
+
dropped++;
|
|
247
|
+
} else {
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.ticksPainted++;
|
|
253
|
+
|
|
254
|
+
if (isDebug()) {
|
|
255
|
+
const now = performance.now();
|
|
256
|
+
if (now - lastDebugLog > 1000) {
|
|
257
|
+
const paintedTs = (this.queue[0]?.timestamp ?? 0);
|
|
258
|
+
const audioMs = (audioNowUs / 1000).toFixed(1);
|
|
259
|
+
const ptsMs = (paintedTs / 1000).toFixed(1);
|
|
260
|
+
const rawDriftMs = ((paintedTs - rawAudioNowUs) / 1000).toFixed(1);
|
|
261
|
+
const calibMs = (this.ptsCalibrationUs / 1000).toFixed(1);
|
|
262
|
+
// eslint-disable-next-line no-console
|
|
263
|
+
console.log(
|
|
264
|
+
`[avbridge:renderer] PAINT q=${this.queue.length} calibAudio=${audioMs}ms nextPTS=${ptsMs}ms ` +
|
|
265
|
+
`rawDrift=${rawDriftMs}ms calib=${calibMs}ms dropped=${dropped} total_drops=${this.framesDroppedLate} painted=${this.framesPainted}`,
|
|
266
|
+
);
|
|
267
|
+
lastDebugLog = now;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const frame = this.queue.shift()!;
|
|
272
|
+
this.paint(frame);
|
|
273
|
+
frame.close();
|
|
274
|
+
this.lastPaintWall = performance.now();
|
|
275
|
+
return;
|
|
190
276
|
}
|
|
191
277
|
|
|
278
|
+
// Wall-clock fallback: used when timestamps are unreliable (all zero).
|
|
279
|
+
const wallNow = performance.now();
|
|
280
|
+
if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
|
|
281
|
+
|
|
192
282
|
const frame = this.queue.shift()!;
|
|
193
283
|
this.paint(frame);
|
|
194
284
|
frame.close();
|
|
@@ -218,8 +308,14 @@ export class VideoRenderer {
|
|
|
218
308
|
|
|
219
309
|
/** Discard all queued frames. Used by seek to drop stale buffers. */
|
|
220
310
|
flush(): void {
|
|
311
|
+
const count = this.queue.length;
|
|
221
312
|
while (this.queue.length > 0) this.queue.shift()?.close();
|
|
222
313
|
this.prerolled = false;
|
|
314
|
+
this.ptsCalibrated = false; // recalibrate at new seek position
|
|
315
|
+
if (isDebug() && count > 0) {
|
|
316
|
+
// eslint-disable-next-line no-console
|
|
317
|
+
console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
|
|
318
|
+
}
|
|
223
319
|
}
|
|
224
320
|
|
|
225
321
|
stats(): Record<string, unknown> {
|
|
@@ -18,6 +18,7 @@ import { loadLibav, type LibavVariant } from "../fallback/libav-loader.js";
|
|
|
18
18
|
import { VideoRenderer } from "../fallback/video-renderer.js";
|
|
19
19
|
import { AudioOutput } from "../fallback/audio-output.js";
|
|
20
20
|
import type { MediaContext } from "../../types.js";
|
|
21
|
+
import { dbg } from "../../util/debug.js";
|
|
21
22
|
import { pickLibavVariant } from "../fallback/variant-routing.js";
|
|
22
23
|
|
|
23
24
|
export interface HybridDecoderHandles {
|
|
@@ -34,6 +35,7 @@ export interface StartHybridDecoderOptions {
|
|
|
34
35
|
context: MediaContext;
|
|
35
36
|
renderer: VideoRenderer;
|
|
36
37
|
audio: AudioOutput;
|
|
38
|
+
transport?: import("../../types.js").TransportConfig;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promise<HybridDecoderHandles> {
|
|
@@ -45,7 +47,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
45
47
|
// libav demuxes via Range requests. For Blob sources, it falls back to
|
|
46
48
|
// mkreadaheadfile (in-memory). The returned handle owns cleanup.
|
|
47
49
|
const { prepareLibavInput } = await import("../../util/libav-http-reader.js");
|
|
48
|
-
const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source);
|
|
50
|
+
const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source, opts.transport);
|
|
49
51
|
|
|
50
52
|
const readPkt = await libav.av_packet_alloc();
|
|
51
53
|
const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
|
|
@@ -134,6 +136,58 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
134
136
|
throw new Error("hybrid decoder: could not initialize any decoders");
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
// ── Bitstream filter for MPEG-4 Part 2 packed B-frames ───────────────
|
|
140
|
+
let bsfCtx: number | null = null;
|
|
141
|
+
let bsfPkt: number | null = null;
|
|
142
|
+
if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
|
|
143
|
+
try {
|
|
144
|
+
bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
145
|
+
if (bsfCtx != null && bsfCtx >= 0) {
|
|
146
|
+
const parIn = await libav.AVBSFContext_par_in(bsfCtx);
|
|
147
|
+
await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
|
|
148
|
+
await libav.av_bsf_init(bsfCtx);
|
|
149
|
+
bsfPkt = await libav.av_packet_alloc();
|
|
150
|
+
dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
|
|
151
|
+
} else {
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
|
|
154
|
+
bsfCtx = null;
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// eslint-disable-next-line no-console
|
|
158
|
+
console.warn("[avbridge] hybrid: failed to init BSF:", (err as Error).message);
|
|
159
|
+
bsfCtx = null;
|
|
160
|
+
bsfPkt = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function applyBSF(packets: LibavPacket[]): Promise<LibavPacket[]> {
|
|
165
|
+
if (!bsfCtx || !bsfPkt) return packets;
|
|
166
|
+
const out: LibavPacket[] = [];
|
|
167
|
+
for (const pkt of packets) {
|
|
168
|
+
await libav.ff_copyin_packet(bsfPkt, pkt);
|
|
169
|
+
const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
|
|
170
|
+
if (sendErr < 0) { out.push(pkt); continue; }
|
|
171
|
+
while (true) {
|
|
172
|
+
const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
173
|
+
if (recvErr < 0) break;
|
|
174
|
+
out.push(await libav.ff_copyout_packet(bsfPkt));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function flushBSF(): Promise<void> {
|
|
181
|
+
if (!bsfCtx || !bsfPkt) return;
|
|
182
|
+
try {
|
|
183
|
+
await libav.av_bsf_send_packet(bsfCtx, 0);
|
|
184
|
+
while (true) {
|
|
185
|
+
const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
186
|
+
if (err < 0) break;
|
|
187
|
+
}
|
|
188
|
+
} catch { /* ignore */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
137
191
|
// ── Mutable state ─────────────────────────────────────────────────────
|
|
138
192
|
let destroyed = false;
|
|
139
193
|
let pumpToken = 0;
|
|
@@ -171,9 +225,27 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
171
225
|
const videoPackets = videoStream ? packets[videoStream.index] : undefined;
|
|
172
226
|
const audioPackets = audioStream ? packets[audioStream.index] : undefined;
|
|
173
227
|
|
|
174
|
-
//
|
|
228
|
+
// Decode audio BEFORE video. Same rationale as fallback decoder
|
|
229
|
+
// (POSTMORTEMS.md entry 1, fix #2): audio decode via libav's
|
|
230
|
+
// ff_decode_multi is a blocking WASM call that prevents rAF from
|
|
231
|
+
// firing. For heavy codecs like DTS, a single batch can take
|
|
232
|
+
// 10-50 ms. Processing audio first ensures the audio scheduler is
|
|
233
|
+
// fed before video decode starts, reducing perceived stutter.
|
|
234
|
+
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
235
|
+
await decodeAudioBatch(audioPackets, myToken);
|
|
236
|
+
}
|
|
237
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
238
|
+
|
|
239
|
+
// Yield to the event loop so the video renderer's rAF callback
|
|
240
|
+
// can fire between the audio decode (blocking) and the video feed
|
|
241
|
+
// (async). Without this, the renderer starves during DTS decode.
|
|
242
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
243
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
244
|
+
|
|
245
|
+
// Feed video packets to WebCodecs VideoDecoder (after BSF if applicable)
|
|
175
246
|
if (videoDecoder && videoPackets && videoPackets.length > 0) {
|
|
176
|
-
|
|
247
|
+
const processed = await applyBSF(videoPackets);
|
|
248
|
+
for (const pkt of processed) {
|
|
177
249
|
if (myToken !== pumpToken || destroyed) return;
|
|
178
250
|
sanitizePacketTimestamp(pkt, () => {
|
|
179
251
|
const ts = syntheticVideoUs;
|
|
@@ -194,11 +266,6 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
194
266
|
}
|
|
195
267
|
}
|
|
196
268
|
|
|
197
|
-
// Decode audio with libav software decoder
|
|
198
|
-
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
199
|
-
await decodeAudioBatch(audioPackets, myToken);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
269
|
packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
|
|
203
270
|
|
|
204
271
|
// Backpressure: WebCodecs decodeQueueSize + audio buffer + renderer queue
|
|
@@ -230,20 +297,49 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
230
297
|
|
|
231
298
|
async function decodeAudioBatch(pkts: LibavPacket[], myToken: number, flush = false) {
|
|
232
299
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
300
|
+
|
|
301
|
+
// For heavy codecs (DTS, AC3), decode in small sub-batches and yield
|
|
302
|
+
// between them so the event loop can run rAF for video painting.
|
|
303
|
+
// Each ff_decode_multi call is a blocking WASM invocation.
|
|
304
|
+
const AUDIO_SUB_BATCH = 4; // packets per sub-batch
|
|
305
|
+
let allFrames: LibavFrame[] = [];
|
|
306
|
+
|
|
307
|
+
for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
|
|
308
|
+
if (myToken !== pumpToken || destroyed) return;
|
|
309
|
+
const slice = pkts.slice(i, i + AUDIO_SUB_BATCH);
|
|
310
|
+
const isLast = i + AUDIO_SUB_BATCH >= pkts.length;
|
|
311
|
+
try {
|
|
312
|
+
const frames = await libav.ff_decode_multi(
|
|
313
|
+
audioDec.c,
|
|
314
|
+
audioDec.pkt,
|
|
315
|
+
audioDec.frame,
|
|
316
|
+
slice,
|
|
317
|
+
isLast && flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true },
|
|
318
|
+
);
|
|
319
|
+
allFrames = allFrames.concat(frames);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error("[avbridge] hybrid audio decode failed:", err);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Yield between sub-batches so rAF can fire
|
|
325
|
+
if (!isLast) await new Promise((r) => setTimeout(r, 0));
|
|
245
326
|
}
|
|
327
|
+
|
|
328
|
+
// Handle flush-only call (empty pkts array)
|
|
329
|
+
if (pkts.length === 0 && flush) {
|
|
330
|
+
try {
|
|
331
|
+
allFrames = await libav.ff_decode_multi(
|
|
332
|
+
audioDec.c, audioDec.pkt, audioDec.frame, [],
|
|
333
|
+
{ fin: true, ignoreErrors: true },
|
|
334
|
+
);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error("[avbridge] hybrid audio flush failed:", err);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
246
341
|
if (myToken !== pumpToken || destroyed) return;
|
|
342
|
+
const frames = allFrames;
|
|
247
343
|
|
|
248
344
|
for (const f of frames) {
|
|
249
345
|
if (myToken !== pumpToken || destroyed) return;
|
|
@@ -283,6 +379,8 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
283
379
|
destroyed = true;
|
|
284
380
|
pumpToken++;
|
|
285
381
|
try { await pumpRunning; } catch { /* ignore */ }
|
|
382
|
+
try { if (bsfCtx) await libav.av_bsf_free(bsfCtx); } catch { /* ignore */ }
|
|
383
|
+
try { if (bsfPkt) await libav.av_packet_free?.(bsfPkt); } catch { /* ignore */ }
|
|
286
384
|
try { if (videoDecoder && videoDecoder.state !== "closed") videoDecoder.close(); } catch { /* ignore */ }
|
|
287
385
|
try { if (audioDec) await libav.ff_free_decoder?.(audioDec.c, audioDec.pkt, audioDec.frame); } catch { /* ignore */ }
|
|
288
386
|
try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
|
|
@@ -324,6 +422,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
324
422
|
try {
|
|
325
423
|
if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
|
|
326
424
|
} catch { /* ignore */ }
|
|
425
|
+
await flushBSF();
|
|
327
426
|
|
|
328
427
|
syntheticVideoUs = Math.round(timeSec * 1_000_000);
|
|
329
428
|
syntheticAudioUs = Math.round(timeSec * 1_000_000);
|
|
@@ -340,6 +439,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
|
|
|
340
439
|
videoFramesDecoded,
|
|
341
440
|
videoChunksFed,
|
|
342
441
|
audioFramesDecoded,
|
|
442
|
+
bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
|
|
343
443
|
videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
|
|
344
444
|
// Confirmed transport info — see fallback decoder for the pattern.
|
|
345
445
|
_transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
|
|
@@ -636,6 +736,17 @@ interface LibavRuntime {
|
|
|
636
736
|
avcodec_flush_buffers?(c: number): Promise<void>;
|
|
637
737
|
avformat_close_input_js(ctx: number): Promise<void>;
|
|
638
738
|
f64toi64?(val: number): [number, number];
|
|
739
|
+
|
|
740
|
+
// BSF methods
|
|
741
|
+
av_bsf_list_parse_str_js(str: string): Promise<number>;
|
|
742
|
+
AVBSFContext_par_in(ctx: number): Promise<number>;
|
|
743
|
+
avcodec_parameters_copy(dst: number, src: number): Promise<number>;
|
|
744
|
+
av_bsf_init(ctx: number): Promise<number>;
|
|
745
|
+
av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
|
|
746
|
+
av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
|
|
747
|
+
av_bsf_free(ctx: number): Promise<void>;
|
|
748
|
+
ff_copyin_packet(pktPtr: number, packet: LibavPacket): Promise<void>;
|
|
749
|
+
ff_copyout_packet(pkt: number): Promise<LibavPacket>;
|
|
639
750
|
}
|
|
640
751
|
|
|
641
752
|
interface BridgeModule {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MediaContext, PlaybackSession } from "../../types.js";
|
|
1
|
+
import type { MediaContext, PlaybackSession, TransportConfig } from "../../types.js";
|
|
2
2
|
import { VideoRenderer } from "../fallback/video-renderer.js";
|
|
3
3
|
import { AudioOutput } from "../fallback/audio-output.js";
|
|
4
4
|
import { startHybridDecoder, type HybridDecoderHandles } from "./decoder.js";
|
|
@@ -20,6 +20,7 @@ const READY_TIMEOUT_SECONDS = 10;
|
|
|
20
20
|
export async function createHybridSession(
|
|
21
21
|
ctx: MediaContext,
|
|
22
22
|
target: HTMLVideoElement,
|
|
23
|
+
transport?: TransportConfig,
|
|
23
24
|
): Promise<PlaybackSession> {
|
|
24
25
|
// Normalize the source so URL inputs go through the libav HTTP block
|
|
25
26
|
// reader instead of being buffered into memory.
|
|
@@ -38,6 +39,7 @@ export async function createHybridSession(
|
|
|
38
39
|
context: ctx,
|
|
39
40
|
renderer,
|
|
40
41
|
audio,
|
|
42
|
+
transport,
|
|
41
43
|
});
|
|
42
44
|
} catch (err) {
|
|
43
45
|
audio.destroy();
|
|
@@ -45,12 +47,36 @@ export async function createHybridSession(
|
|
|
45
47
|
throw err;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
// Patch <video> element for the unified player layer
|
|
50
|
+
// Patch <video> element for the unified player layer. The underlying
|
|
51
|
+
// <video> never has its own src; all playback state lives in the audio
|
|
52
|
+
// clock + canvas renderer. We expose that state via property getters
|
|
53
|
+
// so standard HTMLMediaElement consumers (like <avbridge-player>'s
|
|
54
|
+
// controls UI) see the real values.
|
|
49
55
|
Object.defineProperty(target, "currentTime", {
|
|
50
56
|
configurable: true,
|
|
51
57
|
get: () => audio.now(),
|
|
52
58
|
set: (v: number) => { void doSeek(v); },
|
|
53
59
|
});
|
|
60
|
+
Object.defineProperty(target, "paused", {
|
|
61
|
+
configurable: true,
|
|
62
|
+
get: () => !audio.isPlaying(),
|
|
63
|
+
});
|
|
64
|
+
Object.defineProperty(target, "volume", {
|
|
65
|
+
configurable: true,
|
|
66
|
+
get: () => audio.getVolume(),
|
|
67
|
+
set: (v: number) => {
|
|
68
|
+
audio.setVolume(v);
|
|
69
|
+
target.dispatchEvent(new Event("volumechange"));
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
Object.defineProperty(target, "muted", {
|
|
73
|
+
configurable: true,
|
|
74
|
+
get: () => audio.getMuted(),
|
|
75
|
+
set: (m: boolean) => {
|
|
76
|
+
audio.setMuted(m);
|
|
77
|
+
target.dispatchEvent(new Event("volumechange"));
|
|
78
|
+
},
|
|
79
|
+
});
|
|
54
80
|
if (ctx.duration && Number.isFinite(ctx.duration)) {
|
|
55
81
|
Object.defineProperty(target, "duration", {
|
|
56
82
|
configurable: true,
|
|
@@ -95,11 +121,16 @@ export async function createHybridSession(
|
|
|
95
121
|
if (!audio.isPlaying()) {
|
|
96
122
|
await waitForBuffer();
|
|
97
123
|
await audio.start();
|
|
124
|
+
// Dispatch play/playing events so HTMLMediaElement consumers
|
|
125
|
+
// (e.g. <avbridge-player>'s controls UI) update their state.
|
|
126
|
+
target.dispatchEvent(new Event("play"));
|
|
127
|
+
target.dispatchEvent(new Event("playing"));
|
|
98
128
|
}
|
|
99
129
|
},
|
|
100
130
|
|
|
101
131
|
pause() {
|
|
102
132
|
void audio.pause();
|
|
133
|
+
target.dispatchEvent(new Event("pause"));
|
|
103
134
|
},
|
|
104
135
|
|
|
105
136
|
async seek(time) {
|
|
@@ -129,6 +160,9 @@ export async function createHybridSession(
|
|
|
129
160
|
try {
|
|
130
161
|
delete (target as unknown as Record<string, unknown>).currentTime;
|
|
131
162
|
delete (target as unknown as Record<string, unknown>).duration;
|
|
163
|
+
delete (target as unknown as Record<string, unknown>).paused;
|
|
164
|
+
delete (target as unknown as Record<string, unknown>).volume;
|
|
165
|
+
delete (target as unknown as Record<string, unknown>).muted;
|
|
132
166
|
} catch { /* ignore */ }
|
|
133
167
|
},
|
|
134
168
|
|
|
@@ -36,7 +36,19 @@ export async function createRemuxSession(
|
|
|
36
36
|
await pipeline.start(video.currentTime || 0, true);
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
// seek() may have already started the pump with autoPlay=false
|
|
40
|
+
// (strategy-switch flow calls seek before play). Flip the pipeline's
|
|
41
|
+
// pending autoPlay so the MseSink fires video.play() once buffered
|
|
42
|
+
// data lands, and also attempt an immediate video.play() in case the
|
|
43
|
+
// sink is already wired up. The immediate call can reject when
|
|
44
|
+
// video.src hasn't been set yet — that's fine, the deferred path will
|
|
45
|
+
// catch it.
|
|
46
|
+
pipeline.setAutoPlay(true);
|
|
47
|
+
try {
|
|
48
|
+
await video.play();
|
|
49
|
+
} catch {
|
|
50
|
+
/* sink not ready yet; setAutoPlay will handle playback on first buffered write */
|
|
51
|
+
}
|
|
40
52
|
},
|
|
41
53
|
pause() {
|
|
42
54
|
wantPlay = false;
|