avbridge 2.12.0 → 2.13.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 +177 -0
- package/README.md +33 -0
- package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
- package/dist/avi-32UABODO.cjs.map +1 -0
- package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
- package/dist/avi-5BPR6QUX.cjs.map +1 -0
- package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
- package/dist/avi-BLIH7KKV.js.map +1 -0
- package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
- package/dist/avi-GX2H34IQ.js.map +1 -0
- package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
- package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
- package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
- package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
- package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
- package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
- package/dist/{chunk-7EF4VTUS.cjs → chunk-OFJYEITB.cjs} +489 -113
- package/dist/chunk-OFJYEITB.cjs.map +1 -0
- package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
- package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
- package/dist/{chunk-Z26PXRUY.js → chunk-VOC24LYF.js} +486 -110
- package/dist/chunk-VOC24LYF.js.map +1 -0
- package/dist/element-browser.js +492 -130
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +3 -3
- package/dist/element.js +2 -2
- package/dist/index.cjs +18 -18
- package/dist/index.js +6 -6
- package/dist/player.cjs +658 -170
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +36 -4
- package/dist/player.d.ts +36 -4
- package/dist/player.js +658 -170
- package/dist/player.js.map +1 -1
- package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
- package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
- package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
- package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +223 -43
- package/src/probe/avi.ts +34 -2
- package/src/strategies/fallback/audio-output.ts +164 -35
- package/src/strategies/fallback/decoder.ts +467 -60
- package/src/strategies/fallback/video-renderer.ts +209 -29
- package/src/strategies/hybrid/decoder.ts +56 -28
- package/src/strategies/remux/pipeline.ts +12 -3
- 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/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
- package/dist/avi-EQE6AR75.cjs.map +0 -1
- package/dist/avi-NNHH4AAA.js.map +0 -1
- package/dist/avi-S7EY54YA.js.map +0 -1
- package/dist/avi-Y3N325WZ.cjs.map +0 -1
- package/dist/chunk-7EF4VTUS.cjs.map +0 -1
- package/dist/chunk-Z26PXRUY.js.map +0 -1
|
@@ -29,8 +29,15 @@ import { AudioOutput } from "./audio-output.js";
|
|
|
29
29
|
import type { MediaContext } from "../../types.js";
|
|
30
30
|
import { pickLibavVariant } from "./variant-routing.js";
|
|
31
31
|
import { dbg } from "../../util/debug.js";
|
|
32
|
+
|
|
33
|
+
/** True when `globalThis.AVBRIDGE_DEBUG` is set. Used to gate verbose
|
|
34
|
+
* per-packet / per-frame trace lines that are useful for debugging
|
|
35
|
+
* post-seek pts behavior but unreadable in normal use. */
|
|
36
|
+
function isDebug(): boolean {
|
|
37
|
+
return typeof globalThis !== "undefined"
|
|
38
|
+
&& !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
|
|
39
|
+
}
|
|
32
40
|
import {
|
|
33
|
-
sanitizeFrameTimestamp,
|
|
34
41
|
libavFrameToInterleavedFloat32,
|
|
35
42
|
packetPtsSec,
|
|
36
43
|
} from "../../util/libav-demux.js";
|
|
@@ -169,6 +176,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
169
176
|
// garbled frame ordering.
|
|
170
177
|
let bsfCtx: number | null = null;
|
|
171
178
|
let bsfPkt: number | null = null;
|
|
179
|
+
let bsfRequiredButMissing = false;
|
|
172
180
|
if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
|
|
173
181
|
try {
|
|
174
182
|
bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
@@ -179,15 +187,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
179
187
|
bsfPkt = await libav.av_packet_alloc();
|
|
180
188
|
dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
|
|
181
189
|
} else {
|
|
182
|
-
|
|
183
|
-
console.warn("[avbridge] mpeg4_unpack_bframes BSF not available — decoding without it");
|
|
190
|
+
bsfRequiredButMissing = true;
|
|
184
191
|
bsfCtx = null;
|
|
185
192
|
}
|
|
186
193
|
} catch (err) {
|
|
187
|
-
|
|
188
|
-
console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", (err as Error).message);
|
|
194
|
+
bsfRequiredButMissing = true;
|
|
189
195
|
bsfCtx = null;
|
|
190
196
|
bsfPkt = null;
|
|
197
|
+
dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${(err as Error).message}`);
|
|
198
|
+
}
|
|
199
|
+
if (bsfRequiredButMissing) {
|
|
200
|
+
// eslint-disable-next-line no-console
|
|
201
|
+
console.error(
|
|
202
|
+
"[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes " +
|
|
203
|
+
"BSF is unavailable in this libav variant. Files with packed B-frames " +
|
|
204
|
+
"will play with incorrect frame ordering (backwards PTS jumps, heavy " +
|
|
205
|
+
"late-drop stuttering). Rebuild the libav variant with the `avbsf` " +
|
|
206
|
+
"fragment included. See docs/dev/POSTMORTEMS.md for details.",
|
|
207
|
+
);
|
|
191
208
|
}
|
|
192
209
|
}
|
|
193
210
|
|
|
@@ -199,7 +216,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
199
216
|
await libav.ff_copyin_packet(bsfPkt, pkt);
|
|
200
217
|
const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
|
|
201
218
|
if (sendErr < 0) {
|
|
202
|
-
|
|
219
|
+
// BSF rejected — DON'T pass the original through. `ff_copyin_packet`
|
|
220
|
+
// above may have transferred pkt.data's ArrayBuffer into the worker,
|
|
221
|
+
// in which case re-posting the same packet to the decoder fails
|
|
222
|
+
// with DataCloneError on a detached buffer. Skipping the packet is
|
|
223
|
+
// safer; the decoder's error recovery will resync at the next
|
|
224
|
+
// keyframe if this was transient.
|
|
203
225
|
continue;
|
|
204
226
|
}
|
|
205
227
|
while (true) {
|
|
@@ -215,10 +237,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
215
237
|
async function flushBSF(): Promise<void> {
|
|
216
238
|
if (!bsfCtx || !bsfPkt) return;
|
|
217
239
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
240
|
+
// `av_bsf_flush` resets the BSF state without putting it in EOF
|
|
241
|
+
// mode. The old approach — sending a NULL packet — is the EOF
|
|
242
|
+
// signal; after that every subsequent `av_bsf_send_packet` fails,
|
|
243
|
+
// which made `applyBSF` fall back to pushing the ORIGINAL packet
|
|
244
|
+
// through (with its buffer already transferred to WASM by
|
|
245
|
+
// `ff_copyin_packet`). That detached buffer then failed to
|
|
246
|
+
// `postMessage` into the decoder worker with DataCloneError on
|
|
247
|
+
// the first post-seek batch.
|
|
248
|
+
if (libav.av_bsf_flush) {
|
|
249
|
+
await libav.av_bsf_flush(bsfCtx);
|
|
250
|
+
} else {
|
|
251
|
+
// Fallback for older libav.js variants without av_bsf_flush:
|
|
252
|
+
// drain any internal packets but DON'T send NULL-EOF.
|
|
253
|
+
while (true) {
|
|
254
|
+
const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
255
|
+
if (err < 0) break;
|
|
256
|
+
}
|
|
222
257
|
}
|
|
223
258
|
} catch { /* ignore flush errors */ }
|
|
224
259
|
}
|
|
@@ -247,9 +282,60 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
247
282
|
let watchdogSlowWarned = false;
|
|
248
283
|
let watchdogOverflowWarned = false;
|
|
249
284
|
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
285
|
+
// Content clock for video frames. Tracks the last frame's content time
|
|
286
|
+
// in µs. The invariant per emit:
|
|
287
|
+
// - raw libav pts valid → lastContentUs = raw_pts (sync to truth)
|
|
288
|
+
// - raw libav pts NOPTS → lastContentUs += frameStep (extend by one frame)
|
|
289
|
+
// This makes synthetic labels always relative to the *immediately
|
|
290
|
+
// preceding* frame's real content, self-correcting at every valid pts.
|
|
291
|
+
// -1 means "unanchored" — pre-anchor NOPTS frames are discarded outright
|
|
292
|
+
// because we don't know where the decoder actually landed. The anchor
|
|
293
|
+
// is established at the first valid raw pts post-seek.
|
|
294
|
+
//
|
|
295
|
+
// This replaced an older "synthetic counter reset to seekTarget on seek"
|
|
296
|
+
// path which stamped NOPTS preroll frames with the user's requested seek
|
|
297
|
+
// time — producing labels 4+ seconds ahead of actual content, dropping
|
|
298
|
+
// every valid-pts frame as a "regression", and surfacing as a ~2s
|
|
299
|
+
// post-seek fast-forward as the slow-advancing synthetic counter slowly
|
|
300
|
+
// converged with real content. See POSTMORTEMS.md (2026-06-01).
|
|
301
|
+
let lastContentUs = -1;
|
|
302
|
+
let firstValidPtsLoggedSinceSeek = false;
|
|
303
|
+
|
|
304
|
+
// Diagnostic: first post-seek audio packet's PTS. Logged once per seek
|
|
305
|
+
// so the operator can see the demuxer's actual content alignment vs
|
|
306
|
+
// the user's click. With PTS-based audio scheduling, audio packets
|
|
307
|
+
// with PTS before the seek target *naturally* don't get scheduled
|
|
308
|
+
// (their computed ctxStart falls in the past) — no manual trim needed.
|
|
309
|
+
let seenFirstAudioPacketSinceSeek = false;
|
|
310
|
+
let seekTargetSec = 0;
|
|
311
|
+
// Post-seek diagnostic counters. Capture raw pts/dts/pos for the first
|
|
312
|
+
// ~N packets and frames after each seek so we can tell whether libav
|
|
313
|
+
// hands us a valid pts at seek landing, when (if ever) it becomes
|
|
314
|
+
// valid mid-stream, and whether sanitize's NOPTS fallback is firing.
|
|
315
|
+
let diagPktsLoggedSinceSeek = 0;
|
|
316
|
+
let diagFramesLoggedSinceSeek = 0;
|
|
317
|
+
let diagFrameKeysDumped = false;
|
|
318
|
+
const DIAG_MAX_PKTS = 100;
|
|
319
|
+
const DIAG_MAX_FRAMES = 300;
|
|
320
|
+
|
|
321
|
+
// Throughput instrumentation — answers "is the decoder keeping up?".
|
|
322
|
+
// All counters are cumulative since bootstrap (not reset on seek), so
|
|
323
|
+
// the stats panel can compute rolling deltas. Times are wall-ms spent
|
|
324
|
+
// inside the respective libav call; JS↔WASM boundary is inside the
|
|
325
|
+
// worker so this is the real cost the producer pays per batch.
|
|
326
|
+
let videoDecodeMsTotal = 0;
|
|
327
|
+
let audioDecodeMsTotal = 0;
|
|
328
|
+
let videoDecodeBatches = 0;
|
|
329
|
+
let audioDecodeBatches = 0;
|
|
330
|
+
let readMsTotal = 0;
|
|
331
|
+
let readBatches = 0;
|
|
332
|
+
let pumpThrottleMsTotal = 0;
|
|
333
|
+
let pumpThrottleEntries = 0;
|
|
334
|
+
let slowestVideoBatchMs = 0;
|
|
335
|
+
let newestVideoPtsUs = 0; // set by decodeVideoBatch after each emitted frame
|
|
336
|
+
let lastEmittedPtsUs = -1; // previous emitted frame's pts, for monotonicity check
|
|
337
|
+
let ptsRegressions = 0;
|
|
338
|
+
let worstPtsRegressionMs = 0;
|
|
253
339
|
|
|
254
340
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
255
341
|
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
@@ -274,9 +360,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
274
360
|
// typical bitrates, so the worst-case queue spike stays under
|
|
275
361
|
// `queueHighWater` and the throttle has a chance to apply
|
|
276
362
|
// backpressure *between* batches rather than within one.
|
|
363
|
+
const _readStart = performance.now();
|
|
277
364
|
[readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
|
|
278
365
|
limit: 16 * 1024,
|
|
279
366
|
});
|
|
367
|
+
readMsTotal += performance.now() - _readStart;
|
|
368
|
+
readBatches++;
|
|
280
369
|
} catch (err) {
|
|
281
370
|
console.error("[avbridge] ff_read_frame_multi failed:", err);
|
|
282
371
|
return;
|
|
@@ -295,6 +384,37 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
295
384
|
for (const pkt of videoPackets) {
|
|
296
385
|
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
297
386
|
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
387
|
+
// [DIAG-PKT] Raw pre-sanitize packet fields for the first N
|
|
388
|
+
// post-seek video packets. Most important question: does the
|
|
389
|
+
// FIRST packet after av_seek_frame carry a valid pts? If yes,
|
|
390
|
+
// we can anchor synthetic counter to that — cheap & robust.
|
|
391
|
+
// If no, fall back to pkt_pos → AVI index → chunk_idx × frameDur.
|
|
392
|
+
if (isDebug() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
|
|
393
|
+
const rawHi = (pkt as { ptshi?: number }).ptshi ?? 0;
|
|
394
|
+
const rawLo = pkt.pts ?? 0;
|
|
395
|
+
const isInvalidPts = (rawHi === -2147483648 && rawLo === 0);
|
|
396
|
+
const rawPts64 = isInvalidPts ? null : (rawHi * 0x100000000 + rawLo);
|
|
397
|
+
const rawSec = rawPts64 != null && videoTimeBase
|
|
398
|
+
? (rawPts64 * videoTimeBase[0]) / videoTimeBase[1]
|
|
399
|
+
: null;
|
|
400
|
+
const pktKeys = diagPktsLoggedSinceSeek === 0
|
|
401
|
+
? `[keys: ${Object.keys(pkt).join(",")}]`
|
|
402
|
+
: "";
|
|
403
|
+
// eslint-disable-next-line no-console
|
|
404
|
+
console.log(
|
|
405
|
+
`[DIAG-PKT] vidx=${diagPktsLoggedSinceSeek} ` +
|
|
406
|
+
`pts=${isInvalidPts ? "NOPTS" : rawPts64} ` +
|
|
407
|
+
`pts_sec=${rawSec != null ? rawSec.toFixed(3) : "n/a"} ` +
|
|
408
|
+
`ptshi=${rawHi} ptslo=${rawLo} ` +
|
|
409
|
+
`flags=0x${(pkt.flags ?? 0).toString(16)} ` +
|
|
410
|
+
`keyframe=${((pkt.flags ?? 0) & 1) ? "Y" : "N"} ` +
|
|
411
|
+
`stream=${pkt.stream_index} ` +
|
|
412
|
+
`dataLen=${pkt.data?.length ?? 0} ` +
|
|
413
|
+
`seekTarget=${seekTargetSec.toFixed(3)} ` +
|
|
414
|
+
pktKeys,
|
|
415
|
+
);
|
|
416
|
+
diagPktsLoggedSinceSeek++;
|
|
417
|
+
}
|
|
298
418
|
}
|
|
299
419
|
}
|
|
300
420
|
if (audioPackets && audioTimeBase) {
|
|
@@ -302,6 +422,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
302
422
|
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
303
423
|
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
304
424
|
}
|
|
425
|
+
// Diagnostic: log the first post-seek audio packet's PTS. With
|
|
426
|
+
// PTS-based scheduling, packets whose PTS is before the seek
|
|
427
|
+
// target won't be played (AudioOutput skips them silently), so
|
|
428
|
+
// this is informational only — it tells you how far off the
|
|
429
|
+
// demuxer's seek granularity is from the user's click.
|
|
430
|
+
if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
|
|
431
|
+
const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
|
|
432
|
+
if (firstSec != null && Number.isFinite(firstSec)) {
|
|
433
|
+
seenFirstAudioPacketSinceSeek = true;
|
|
434
|
+
dbg.info("av-anchor",
|
|
435
|
+
`seek-target=${seekTargetSec.toFixed(3)}s, ` +
|
|
436
|
+
`first-audio-pkt-pts=${firstSec.toFixed(3)}s ` +
|
|
437
|
+
`(Δ=${((firstSec - seekTargetSec) * 1000).toFixed(1)}ms — ` +
|
|
438
|
+
`pre-target packets will be skipped by AudioOutput)`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
305
442
|
}
|
|
306
443
|
|
|
307
444
|
// Decode audio BEFORE video. On software-decode-bound content
|
|
@@ -313,7 +450,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
313
450
|
// packet for cook/mp3/aac, so doing it first barely delays
|
|
314
451
|
// video decoding at all.
|
|
315
452
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
316
|
-
await decodeAudioBatch(audioPackets, myToken);
|
|
453
|
+
await decodeAudioBatch(audioPackets, myToken, /*flush*/ false, audioTimeBase);
|
|
317
454
|
}
|
|
318
455
|
if (myToken !== pumpToken || destroyed) return;
|
|
319
456
|
if (videoDec && videoPackets && videoPackets.length > 0) {
|
|
@@ -381,20 +518,30 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
381
518
|
}
|
|
382
519
|
}
|
|
383
520
|
|
|
384
|
-
// Throttle:
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
521
|
+
// Throttle: only on audio buffer (mediaTimeOfNext - now() > 2 s).
|
|
522
|
+
// Renderer queue backpressure is enforced at the *enqueue* side in
|
|
523
|
+
// `decodeVideoBatch` — when the queue is at `queueHighWater`, the
|
|
524
|
+
// freshly decoded VideoFrame is closed without being enqueued, so
|
|
525
|
+
// the decoder keeps consuming packets in order. That preserves the
|
|
526
|
+
// reference-frame state needed to decode P/B frames cleanly during
|
|
527
|
+
// post-seek catch-up. Throttling the *pump* on queue depth here
|
|
528
|
+
// would block demuxer reads, which would also stall audio packet
|
|
529
|
+
// processing and starve `audio.bufferAhead()`.
|
|
530
|
+
{
|
|
531
|
+
const _throttleStart = performance.now();
|
|
532
|
+
let _throttled = false;
|
|
533
|
+
while (
|
|
534
|
+
!destroyed &&
|
|
535
|
+
myToken === pumpToken &&
|
|
536
|
+
opts.audio.bufferAhead() > 2.0
|
|
537
|
+
) {
|
|
538
|
+
_throttled = true;
|
|
539
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
540
|
+
}
|
|
541
|
+
if (_throttled) {
|
|
542
|
+
pumpThrottleMsTotal += performance.now() - _throttleStart;
|
|
543
|
+
pumpThrottleEntries++;
|
|
544
|
+
}
|
|
398
545
|
}
|
|
399
546
|
|
|
400
547
|
if (readErr === libav.AVERROR_EOF) {
|
|
@@ -412,6 +559,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
412
559
|
async function decodeVideoBatch(pkts: LibavPacket[], myToken: number, flush = false) {
|
|
413
560
|
if (!videoDec || destroyed || myToken !== pumpToken) return;
|
|
414
561
|
let frames: LibavFrame[];
|
|
562
|
+
const _t0 = performance.now();
|
|
415
563
|
try {
|
|
416
564
|
frames = await libav.ff_decode_multi(
|
|
417
565
|
videoDec.c,
|
|
@@ -424,36 +572,262 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
424
572
|
console.error("[avbridge] video decode batch failed:", err);
|
|
425
573
|
return;
|
|
426
574
|
}
|
|
575
|
+
{
|
|
576
|
+
const _dt = performance.now() - _t0;
|
|
577
|
+
videoDecodeMsTotal += _dt;
|
|
578
|
+
videoDecodeBatches++;
|
|
579
|
+
if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
|
|
580
|
+
}
|
|
427
581
|
if (myToken !== pumpToken || destroyed) return;
|
|
428
582
|
|
|
429
583
|
for (const f of frames) {
|
|
430
584
|
if (myToken !== pumpToken || destroyed) return;
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
585
|
+
// [DIAG-FRAME] Capture raw pre-sanitize fields. One-shot key dump
|
|
586
|
+
// on first frame post-seek so we can see which fields libav
|
|
587
|
+
// actually exposes (best_effort_timestamp? pkt_dts? pkt_pos?).
|
|
588
|
+
const _diagShouldLog = isDebug() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
|
|
589
|
+
const _diagRawHi = f.ptshi ?? 0;
|
|
590
|
+
const _diagRawLo = f.pts ?? 0;
|
|
591
|
+
const _diagInvalid = (_diagRawHi === -2147483648 && _diagRawLo === 0);
|
|
592
|
+
const _diagRawPts64 = _diagInvalid ? null : (_diagRawHi * 0x100000000 + _diagRawLo);
|
|
593
|
+
const _diagRawSec = _diagRawPts64 != null && videoTimeBase
|
|
594
|
+
? (_diagRawPts64 * videoTimeBase[0]) / videoTimeBase[1]
|
|
595
|
+
: null;
|
|
596
|
+
if (_diagShouldLog && !diagFrameKeysDumped) {
|
|
597
|
+
diagFrameKeysDumped = true;
|
|
598
|
+
const allKeys = Object.keys(f);
|
|
599
|
+
const fieldDump: Record<string, unknown> = {};
|
|
600
|
+
for (const k of allKeys) {
|
|
601
|
+
const v = (f as unknown as Record<string, unknown>)[k];
|
|
602
|
+
// Skip the data buffer; everything else is metadata.
|
|
603
|
+
if (k === "data") continue;
|
|
604
|
+
if (typeof v === "object" && v !== null && "length" in (v as object)) continue;
|
|
605
|
+
fieldDump[k] = v;
|
|
606
|
+
}
|
|
607
|
+
// eslint-disable-next-line no-console
|
|
608
|
+
console.log(`[DIAG-FRAME] FIRST FRAME post-seek — all keys: ${allKeys.join(",")}`);
|
|
609
|
+
// eslint-disable-next-line no-console
|
|
610
|
+
console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
|
|
611
|
+
}
|
|
612
|
+
// Convert raw libav pts (in stream timebase) to µs, or null if NOPTS.
|
|
613
|
+
let rawUs: number | null = null;
|
|
614
|
+
if (!_diagInvalid && _diagRawPts64 != null) {
|
|
615
|
+
const tb = videoTimeBase ?? [1, 1_000_000];
|
|
616
|
+
const us = Math.round((_diagRawPts64 * 1_000_000 * tb[0]) / tb[1]);
|
|
617
|
+
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
618
|
+
rawUs = us;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Forward declare _diagLog so PRE-ANCHOR-DROP can call it.
|
|
623
|
+
// Final pts isn't known until after the anchor/step block, so we pass
|
|
624
|
+
// it as a parameter rather than closing over a `let`.
|
|
625
|
+
const _diagLog = (decision: string, finalPtsUs: number, sanFallback: boolean): void => {
|
|
626
|
+
if (!_diagShouldLog) return;
|
|
627
|
+
const ptsSrc = sanFallback
|
|
628
|
+
? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})`
|
|
629
|
+
: "LIBAV";
|
|
630
|
+
// eslint-disable-next-line no-console
|
|
631
|
+
console.log(
|
|
632
|
+
`[DIAG-FRAME] vidx=${diagFramesLoggedSinceSeek} ` +
|
|
633
|
+
`raw_pts=${_diagInvalid ? "NOPTS" : _diagRawPts64} ` +
|
|
634
|
+
`raw_pts_sec=${_diagRawSec != null ? _diagRawSec.toFixed(3) : "n/a"} ` +
|
|
635
|
+
`pts_src=${ptsSrc} ` +
|
|
636
|
+
`final_pts_us=${finalPtsUs} ` +
|
|
637
|
+
`final_pts_sec=${(finalPtsUs / 1_000_000).toFixed(3)} ` +
|
|
638
|
+
`seekTarget=${seekTargetSec.toFixed(3)} ` +
|
|
639
|
+
`offset_to_target_ms=${((finalPtsUs / 1000) - (seekTargetSec * 1000)).toFixed(1)} ` +
|
|
640
|
+
`lastEmittedPts_us=${lastEmittedPtsUs} ` +
|
|
641
|
+
`decision=${decision}`,
|
|
642
|
+
);
|
|
643
|
+
diagFramesLoggedSinceSeek++;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// Anchor + step invariant.
|
|
647
|
+
// - Unanchored (post-seek, no valid pts seen yet) AND NOPTS frame
|
|
648
|
+
// → discard outright. We don't know where the decoder landed, so
|
|
649
|
+
// stamping a synthetic label would be a lie (this was the source
|
|
650
|
+
// of the post-seek fast-forward bug).
|
|
651
|
+
// - First valid raw pts → anchor `lastContentUs` to it. The pipeline
|
|
652
|
+
// below will then drop this and subsequent frames as pre-target
|
|
653
|
+
// until content reaches seekTarget.
|
|
654
|
+
// - Anchored AND valid → sync `lastContentUs` to truth.
|
|
655
|
+
// - Anchored AND NOPTS → step `lastContentUs += frameStep`.
|
|
656
|
+
let _diagSanFallbackFired = false;
|
|
657
|
+
const seekTargetUs = Math.round(seekTargetSec * 1_000_000);
|
|
658
|
+
if (lastContentUs < 0) {
|
|
659
|
+
if (rawUs == null) {
|
|
660
|
+
// Cold-start keyframe special case. At seekTargetSec === 0 the
|
|
661
|
+
// demuxer guarantees the very first emitted keyframe is content
|
|
662
|
+
// 0 (container start=0.000000). Anchoring there directly avoids
|
|
663
|
+
// discarding the opening I-frame — without this, cold start
|
|
664
|
+
// loses 1-2 frames and the first paint is ~80ms late.
|
|
665
|
+
//
|
|
666
|
+
// STRICTLY gated to seekTarget === 0. The seek path proved
|
|
667
|
+
// correct via the offset-ground-truth experiment (POSTMORTEMS
|
|
668
|
+
// 2026-06-01); this branch must not change its behavior.
|
|
669
|
+
//
|
|
670
|
+
// Why keyframe-pin instead of back-computing from the first
|
|
671
|
+
// valid pts: the I/P/B reorder is densest at the stream head,
|
|
672
|
+
// so `firstValidPts − N × frameStep` is off by however many
|
|
673
|
+
// early B-frames the decoder dropped. The keyframe identity
|
|
674
|
+
// (`f.key_frame === 1`) is the only signal that doesn't depend
|
|
675
|
+
// on frame-spacing assumptions.
|
|
676
|
+
const isColdStartKeyframe =
|
|
677
|
+
seekTargetSec === 0
|
|
678
|
+
&& (f as { key_frame?: number }).key_frame === 1;
|
|
679
|
+
if (isColdStartKeyframe) {
|
|
680
|
+
lastContentUs = 0;
|
|
681
|
+
_diagSanFallbackFired = true;
|
|
682
|
+
// Fall through: the frame gets labeled 0 and runs through
|
|
683
|
+
// the regression/pre-target/enqueue pipeline normally.
|
|
684
|
+
} else {
|
|
685
|
+
// Pre-anchor NOPTS: discard. Decoder retains the frame internally
|
|
686
|
+
// as a reference — we just don't expose it to the renderer.
|
|
687
|
+
_diagLog("PRE-ANCHOR-DROP", 0, true);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
} else {
|
|
691
|
+
// First valid raw pts post-seek = the anchor.
|
|
692
|
+
lastContentUs = rawUs;
|
|
693
|
+
if (!firstValidPtsLoggedSinceSeek) {
|
|
694
|
+
firstValidPtsLoggedSinceSeek = true;
|
|
695
|
+
if (isDebug()) {
|
|
696
|
+
// eslint-disable-next-line no-console
|
|
697
|
+
console.log(
|
|
698
|
+
`[avbridge:decoder] post-seek anchor established: ` +
|
|
699
|
+
`first valid raw pts = ${(rawUs / 1000).toFixed(1)}ms ` +
|
|
700
|
+
`(seekTarget = ${(seekTargetSec * 1000).toFixed(1)}ms, ` +
|
|
701
|
+
`Δ = ${((rawUs - seekTargetUs) / 1000).toFixed(1)}ms)`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
// Guard: if the first valid pts is at or beyond the seek
|
|
705
|
+
// target, the pre-anchor NOPTS frames we already discarded
|
|
706
|
+
// may have straddled the target. In normal AVI MPEG-4 seeks,
|
|
707
|
+
// the demuxer lands well before the target (previous
|
|
708
|
+
// keyframe), so this shouldn't happen — log a warning if it
|
|
709
|
+
// does so we know to implement a pkt_pos→AVI-index
|
|
710
|
+
// back-computation path. The cold-start case (seekTarget=0)
|
|
711
|
+
// is handled by the keyframe-pin branch above and shouldn't
|
|
712
|
+
// reach this warning.
|
|
713
|
+
if (rawUs >= seekTargetUs) {
|
|
714
|
+
// eslint-disable-next-line no-console
|
|
715
|
+
console.warn(
|
|
716
|
+
`[avbridge:decoder] first valid raw pts ≥ seek target — ` +
|
|
717
|
+
`pre-anchor NOPTS frames may have straddled the target ` +
|
|
718
|
+
`and been mis-discarded. First painted frame may be late ` +
|
|
719
|
+
`by up to one keyframe interval.`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} else {
|
|
725
|
+
if (rawUs != null) {
|
|
726
|
+
lastContentUs = rawUs; // sync to truth on every valid pts
|
|
727
|
+
} else {
|
|
728
|
+
lastContentUs += videoFrameStepUs; // extend from last truth
|
|
729
|
+
_diagSanFallbackFired = true;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// Write the content label into the frame so the bridge sees it.
|
|
733
|
+
f.pts = lastContentUs;
|
|
734
|
+
f.ptshi = lastContentUs < 0 ? -1 : 0;
|
|
735
|
+
const _fPts = lastContentUs;
|
|
736
|
+
if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
|
|
737
|
+
if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
|
|
738
|
+
_diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
|
|
739
|
+
// Decoder emitted a frame with lower PTS than the previous
|
|
740
|
+
// output. Dropping out-of-order frames here is the right move:
|
|
741
|
+
// the renderer's paint loop assumes monotonic queue order and
|
|
742
|
+
// breaks (stale frame stuck at head, newer frames drop as late,
|
|
743
|
+
// paint cadence collapses) if we let them through. Two scenarios
|
|
744
|
+
// produce this in practice:
|
|
745
|
+
// - Post-seek tail of a B-frame reorder buffer that survives
|
|
746
|
+
// avcodec_flush_buffers + av_bsf_flush (rare but observed
|
|
747
|
+
// on mpeg4 after large seeks).
|
|
748
|
+
// - A BSF that doesn't repair packed B-frames perfectly and
|
|
749
|
+
// lets a DTS/PTS swap through.
|
|
750
|
+
// The decoder will catch up at the next I-frame.
|
|
751
|
+
ptsRegressions++;
|
|
752
|
+
const regressMs = (lastEmittedPtsUs - _fPts) / 1000;
|
|
753
|
+
if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
|
|
754
|
+
if (ptsRegressions <= 10) {
|
|
755
|
+
// eslint-disable-next-line no-console
|
|
756
|
+
console.warn(
|
|
757
|
+
`[avbridge:decoder] dropped out-of-order frame #${ptsRegressions}: ` +
|
|
758
|
+
`pts=${(_fPts / 1000).toFixed(1)}ms < previous=${(lastEmittedPtsUs / 1000).toFixed(1)}ms ` +
|
|
759
|
+
`(regression=${regressMs.toFixed(1)}ms). Typically a post-seek B-frame reorder tail.`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
continue; // skip enqueue
|
|
763
|
+
}
|
|
764
|
+
lastEmittedPtsUs = _fPts;
|
|
765
|
+
// Decode-to-display: after a seek the demuxer lands at the
|
|
766
|
+
// keyframe ≤ click target and the decoder produces frames
|
|
767
|
+
// starting there. Pre-target frames are still DECODED (they're
|
|
768
|
+
// reference frames for later P/B decodes) but they MUST NOT be
|
|
769
|
+
// displayed — otherwise the renderer paints them in a brief
|
|
770
|
+
// fast-forward burst as it catches up to audio (T_click). Drop
|
|
771
|
+
// them at the enqueue boundary; the decoder doesn't care.
|
|
772
|
+
//
|
|
773
|
+
// Tolerance of one frame duration: source frames are quantized
|
|
774
|
+
// (PTS = N × frameStep) but the user's click is arbitrary, so
|
|
775
|
+
// the frame nearest the click is typically a few ms *before* it.
|
|
776
|
+
// Convention (matches `<video>.currentTime = T` and ffplay):
|
|
777
|
+
// display the frame at the largest PTS ≤ T.
|
|
778
|
+
const targetUs = Math.round(seekTargetSec * 1_000_000);
|
|
779
|
+
if (_fPts < targetUs - videoFrameStepUs) {
|
|
780
|
+
_diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
442
783
|
try {
|
|
443
784
|
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
|
|
444
|
-
|
|
785
|
+
// Renderer-queue backpressure at the enqueue side. Discarding
|
|
786
|
+
// here (rather than throttling the pump on `queueHighWater`)
|
|
787
|
+
// keeps the decoder consuming packets sequentially so its
|
|
788
|
+
// reference-frame state stays intact — essential during
|
|
789
|
+
// post-seek catch-up, when the pump must continue reading
|
|
790
|
+
// packets to advance the demuxer past pre-target audio. Without
|
|
791
|
+
// sequential decode, the next batch's P/B frames decode against
|
|
792
|
+
// a stale reference and produce gray + glitchy output until
|
|
793
|
+
// the next keyframe.
|
|
794
|
+
if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
|
|
795
|
+
vf.close();
|
|
796
|
+
_diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
|
|
797
|
+
} else {
|
|
798
|
+
opts.renderer.enqueue(vf);
|
|
799
|
+
_diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
|
|
800
|
+
}
|
|
445
801
|
videoFramesDecoded++;
|
|
446
802
|
} catch (err) {
|
|
447
803
|
if (videoFramesDecoded === 0) {
|
|
448
804
|
console.warn("[avbridge] laFrameToVideoFrame failed:", err);
|
|
449
805
|
}
|
|
806
|
+
_diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
|
|
450
807
|
}
|
|
451
808
|
}
|
|
452
809
|
}
|
|
453
810
|
|
|
454
|
-
async function decodeAudioBatch(
|
|
811
|
+
async function decodeAudioBatch(
|
|
812
|
+
pkts: LibavPacket[],
|
|
813
|
+
myToken: number,
|
|
814
|
+
flush = false,
|
|
815
|
+
tb?: [number, number],
|
|
816
|
+
) {
|
|
455
817
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
818
|
+
// Capture the packet-level PTS *before* decoding. libav's reported
|
|
819
|
+
// `frame.pts` after decode is unreliable for mp3-in-AVI (returns a
|
|
820
|
+
// value that doesn't agree with the stream's reported time base —
|
|
821
|
+
// see POSTMORTEMS.md 2026-05-31). The demuxer's packet PTS is
|
|
822
|
+
// reliable, and for mp3/aac the packet→frame mapping is 1:1, so we
|
|
823
|
+
// forward each packet's PTS to the matching output frame. For codecs
|
|
824
|
+
// where the mapping isn't 1:1, the trailing frames fall back to a
|
|
825
|
+
// synthetic running counter — same behavior as before this change.
|
|
826
|
+
const pktPtsSec: (number | null)[] = pkts.map((p) =>
|
|
827
|
+
tb ? packetPtsSec(p, tb) : null,
|
|
828
|
+
);
|
|
456
829
|
let frames: LibavFrame[];
|
|
830
|
+
const _t0 = performance.now();
|
|
457
831
|
try {
|
|
458
832
|
frames = await libav.ff_decode_multi(
|
|
459
833
|
audioDec.c,
|
|
@@ -466,24 +840,25 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
466
840
|
console.error("[avbridge] audio decode batch failed:", err);
|
|
467
841
|
return;
|
|
468
842
|
}
|
|
843
|
+
audioDecodeMsTotal += performance.now() - _t0;
|
|
844
|
+
audioDecodeBatches++;
|
|
469
845
|
if (myToken !== pumpToken || destroyed) return;
|
|
470
846
|
|
|
471
|
-
for (
|
|
847
|
+
for (let i = 0; i < frames.length; i++) {
|
|
472
848
|
if (myToken !== pumpToken || destroyed) return;
|
|
473
|
-
|
|
474
|
-
f,
|
|
475
|
-
() => {
|
|
476
|
-
const ts = syntheticAudioUs;
|
|
477
|
-
const samples = f.nb_samples ?? 1024;
|
|
478
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
479
|
-
syntheticAudioUs += Math.round((samples * 1_000_000) / sampleRate);
|
|
480
|
-
return ts;
|
|
481
|
-
},
|
|
482
|
-
audioTimeBase,
|
|
483
|
-
);
|
|
849
|
+
const f = frames[i];
|
|
484
850
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
485
851
|
if (samples) {
|
|
486
|
-
|
|
852
|
+
const pts = pktPtsSec[i] ?? null;
|
|
853
|
+
if (isDebug()) {
|
|
854
|
+
const dur = samples.data.length / samples.channels / samples.sampleRate;
|
|
855
|
+
// Log every frame — we need to see what happens around seeks.
|
|
856
|
+
// Also surface explicitly when the per-frame PTS is null, which
|
|
857
|
+
// would route the chunk to the LEGACY rebase path in AudioOutput.
|
|
858
|
+
// eslint-disable-next-line no-console
|
|
859
|
+
console.log(`[TRACE-DEC] audio frame #${audioFramesDecoded} pts=${pts != null ? pts.toFixed(4) : "NULL"} dur=${dur.toFixed(4)} samples=${samples.data.length / samples.channels} sr=${samples.sampleRate} ch=${samples.channels} pktsIn=${pkts.length} framesOut=${frames.length}`);
|
|
860
|
+
}
|
|
861
|
+
opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
|
|
487
862
|
audioFramesDecoded++;
|
|
488
863
|
}
|
|
489
864
|
}
|
|
@@ -573,8 +948,9 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
573
948
|
try { if (videoDec) await libav.avcodec_flush_buffers?.(videoDec.c); } catch { /* ignore */ }
|
|
574
949
|
await flushBSF();
|
|
575
950
|
|
|
576
|
-
|
|
577
|
-
|
|
951
|
+
lastContentUs = -1;
|
|
952
|
+
lastEmittedPtsUs = -1;
|
|
953
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
578
954
|
|
|
579
955
|
pumpRunning = pumpLoop(newToken).catch((err) =>
|
|
580
956
|
console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err),
|
|
@@ -582,6 +958,10 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
582
958
|
},
|
|
583
959
|
|
|
584
960
|
async seek(timeSec) {
|
|
961
|
+
if (isDebug()) {
|
|
962
|
+
// eslint-disable-next-line no-console
|
|
963
|
+
console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1000).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
|
|
964
|
+
}
|
|
585
965
|
// Cancel the current pump and wait for it to actually exit before
|
|
586
966
|
// we start moving file pointers around — concurrent ff_decode_multi
|
|
587
967
|
// and av_seek_frame on the same context would be a recipe for memory
|
|
@@ -629,10 +1009,19 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
629
1009
|
} catch { /* ignore */ }
|
|
630
1010
|
await flushBSF();
|
|
631
1011
|
|
|
632
|
-
// Reset
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
1012
|
+
// Reset the content clock to "unanchored". The next decode loop
|
|
1013
|
+
// will discard NOPTS frames until the first valid libav pts
|
|
1014
|
+
// establishes a real anchor, then label every frame relative to
|
|
1015
|
+
// truth. Do NOT set anything to seekTarget here — that lie was
|
|
1016
|
+
// the post-seek fast-forward bug.
|
|
1017
|
+
lastContentUs = -1;
|
|
1018
|
+
lastEmittedPtsUs = -1;
|
|
1019
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
1020
|
+
seenFirstAudioPacketSinceSeek = false;
|
|
1021
|
+
seekTargetSec = timeSec;
|
|
1022
|
+
diagPktsLoggedSinceSeek = 0;
|
|
1023
|
+
diagFramesLoggedSinceSeek = 0;
|
|
1024
|
+
diagFrameKeysDumped = false;
|
|
636
1025
|
|
|
637
1026
|
// The renderer & audio output are reset by the fallback session
|
|
638
1027
|
// wrapper that called us — see strategies/fallback/index.ts.
|
|
@@ -653,7 +1042,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
653
1042
|
packetsRead,
|
|
654
1043
|
videoFramesDecoded,
|
|
655
1044
|
audioFramesDecoded,
|
|
1045
|
+
// Throughput instrumentation — the stats panel turns these into
|
|
1046
|
+
// "decode fps actual / realtime target" and shows slowest batch
|
|
1047
|
+
// + producer throttle share.
|
|
1048
|
+
videoDecodeMsTotal,
|
|
1049
|
+
videoDecodeBatches,
|
|
1050
|
+
audioDecodeMsTotal,
|
|
1051
|
+
audioDecodeBatches,
|
|
1052
|
+
readMsTotal,
|
|
1053
|
+
readBatches,
|
|
1054
|
+
pumpThrottleMsTotal,
|
|
1055
|
+
pumpThrottleEntries,
|
|
1056
|
+
slowestVideoBatchMs,
|
|
1057
|
+
newestVideoPtsMs: Math.round(newestVideoPtsUs / 1000),
|
|
1058
|
+
ptsRegressions,
|
|
1059
|
+
worstPtsRegressionMs,
|
|
1060
|
+
sourceFps: videoFps,
|
|
656
1061
|
bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
|
|
1062
|
+
bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
|
|
657
1063
|
// Confirmed transport info: once prepareLibavInput returns
|
|
658
1064
|
// successfully, we *know* whether the source is http-range (probe
|
|
659
1065
|
// succeeded and returned 206) or in-memory blob. Diagnostics hoists
|
|
@@ -776,6 +1182,7 @@ interface LibavRuntime {
|
|
|
776
1182
|
av_bsf_init(ctx: number): Promise<number>;
|
|
777
1183
|
av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
|
|
778
1184
|
av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
|
|
1185
|
+
av_bsf_flush?(ctx: number): Promise<void>;
|
|
779
1186
|
av_bsf_free(ctx: number): Promise<void>;
|
|
780
1187
|
|
|
781
1188
|
// Packet copy helpers — bridge JS packet objects to/from C-level pointers
|