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
package/dist/player.js
CHANGED
|
@@ -237,7 +237,7 @@ async function probe(source, transport) {
|
|
|
237
237
|
const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
|
|
238
238
|
if (hasUnknownCodec) {
|
|
239
239
|
try {
|
|
240
|
-
const { probeWithLibav } = await import('./avi-
|
|
240
|
+
const { probeWithLibav } = await import('./avi-GX2H34IQ.js');
|
|
241
241
|
return await probeWithLibav(normalized, sniffed);
|
|
242
242
|
} catch {
|
|
243
243
|
return result;
|
|
@@ -250,7 +250,7 @@ async function probe(source, transport) {
|
|
|
250
250
|
mediabunnyErr.message
|
|
251
251
|
);
|
|
252
252
|
try {
|
|
253
|
-
const { probeWithLibav } = await import('./avi-
|
|
253
|
+
const { probeWithLibav } = await import('./avi-GX2H34IQ.js');
|
|
254
254
|
return await probeWithLibav(normalized, sniffed);
|
|
255
255
|
} catch (libavErr) {
|
|
256
256
|
const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
|
|
@@ -264,7 +264,7 @@ async function probe(source, transport) {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
try {
|
|
267
|
-
const { probeWithLibav } = await import('./avi-
|
|
267
|
+
const { probeWithLibav } = await import('./avi-GX2H34IQ.js');
|
|
268
268
|
return await probeWithLibav(normalized, sniffed);
|
|
269
269
|
} catch (err) {
|
|
270
270
|
const inner = err instanceof Error ? err.message : String(err);
|
|
@@ -1020,22 +1020,25 @@ async function createRemuxPipeline(ctx, video) {
|
|
|
1020
1020
|
}
|
|
1021
1021
|
}
|
|
1022
1022
|
let mimePromise = null;
|
|
1023
|
+
const myToken = pumpToken;
|
|
1023
1024
|
const writable = new WritableStream({
|
|
1024
1025
|
write: async (chunk) => {
|
|
1025
|
-
if (destroyed) return;
|
|
1026
|
+
if (destroyed || pumpToken !== myToken) return;
|
|
1026
1027
|
if (!sink) {
|
|
1027
1028
|
const mime = await (mimePromise ??= output.getMimeType());
|
|
1029
|
+
if (destroyed || pumpToken !== myToken) return;
|
|
1028
1030
|
sink = new MseSink({ mime, video });
|
|
1029
1031
|
await sink.ready();
|
|
1032
|
+
if (destroyed || pumpToken !== myToken) return;
|
|
1030
1033
|
if (pendingStartTime > 0) {
|
|
1031
1034
|
sink.invalidate(pendingStartTime);
|
|
1032
1035
|
}
|
|
1033
1036
|
sink.setPlayOnSeek(pendingAutoPlay);
|
|
1034
1037
|
}
|
|
1035
|
-
while (sink && !destroyed && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
|
|
1038
|
+
while (sink && !destroyed && pumpToken === myToken && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
|
|
1036
1039
|
await new Promise((r) => setTimeout(r, 500));
|
|
1037
1040
|
}
|
|
1038
|
-
if (destroyed) return;
|
|
1041
|
+
if (destroyed || pumpToken !== myToken) return;
|
|
1039
1042
|
sink.append(chunk.data);
|
|
1040
1043
|
stats.bytesWritten += chunk.data.byteLength;
|
|
1041
1044
|
stats.fragments++;
|
|
@@ -1302,7 +1305,21 @@ var VideoRenderer = class {
|
|
|
1302
1305
|
framesPainted = 0;
|
|
1303
1306
|
framesDroppedLate = 0;
|
|
1304
1307
|
framesDroppedOverflow = 0;
|
|
1308
|
+
/** True once the head frame has been painted as a pre-roll poster
|
|
1309
|
+
* since the last flush. Used to ensure pre-roll paints exactly one
|
|
1310
|
+
* frame (held static) during the post-seek discard window. */
|
|
1305
1311
|
prerolled = false;
|
|
1312
|
+
/** PTS (µs) of the most recently painted frame. Used as the calibration
|
|
1313
|
+
* reference on the first post-flush snap: the pre-roll path paints one
|
|
1314
|
+
* frame *before* PTS-based playback starts, so the queue head's PTS at
|
|
1315
|
+
* first PTS-based paint is the *next* frame, off by one frameDur from
|
|
1316
|
+
* the actually-displayed frame. Calibrating against the painted frame
|
|
1317
|
+
* instead of the queue head removes that one-frame offset and yields
|
|
1318
|
+
* calib ≈ 0 instead of +frameDur. */
|
|
1319
|
+
lastPaintedPtsUs = 0;
|
|
1320
|
+
hasLastPaintedPts = false;
|
|
1321
|
+
/** Audio-clock reading (ms) at the previous paint, for overlay Δaud. */
|
|
1322
|
+
lastPaintAudMs = 0;
|
|
1306
1323
|
/** Wall-clock time of the last paint, in ms (performance.now()). */
|
|
1307
1324
|
lastPaintWall = 0;
|
|
1308
1325
|
/** Minimum ms between paints — paces video at roughly source fps. */
|
|
@@ -1333,32 +1350,47 @@ var VideoRenderer = class {
|
|
|
1333
1350
|
/** Resolves once the first decoded frame has been enqueued. */
|
|
1334
1351
|
firstFrameReady;
|
|
1335
1352
|
resolveFirstFrame;
|
|
1336
|
-
/**
|
|
1353
|
+
/**
|
|
1354
|
+
* True once at least one frame has been enqueued *since the last flush*.
|
|
1355
|
+
* Used by `readyState` — initial cold-start reports HAVE_NOTHING until
|
|
1356
|
+
* any frame has arrived, and after a seek we want the same semantics
|
|
1357
|
+
* (HAVE_NOTHING until post-seek frames arrive), so the cumulative
|
|
1358
|
+
* `framesPainted > 0` that used to live here was wrong: it kept the
|
|
1359
|
+
* state "true forever" after the first frame ever, so post-seek
|
|
1360
|
+
* `waitForBuffer()` would exit immediately with an empty queue and
|
|
1361
|
+
* leave video frozen while audio kept going.
|
|
1362
|
+
*/
|
|
1337
1363
|
hasFrames() {
|
|
1338
|
-
return this.queue.length > 0 || this.
|
|
1364
|
+
return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
|
|
1339
1365
|
}
|
|
1366
|
+
hasEverEnqueuedSinceFlush = false;
|
|
1340
1367
|
/** Current depth of the frame queue. Used by the decoder for backpressure. */
|
|
1341
1368
|
queueDepth() {
|
|
1342
1369
|
return this.queue.length;
|
|
1343
1370
|
}
|
|
1344
1371
|
/**
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1372
|
+
* Cap the decoder may fill the queue up to. Used by the decoder's
|
|
1373
|
+
* enqueue-side discard logic (it closes new frames instead of pushing
|
|
1374
|
+
* them when this is reached). Sized so a long post-seek catch-up
|
|
1375
|
+
* fits — the decoder produces frames at PTS T_kf onwards rapidly
|
|
1376
|
+
* while the demuxer is chewing through pre-target audio; if the
|
|
1377
|
+
* queue can hold the whole post-seek burst, the renderer plays
|
|
1378
|
+
* smoothly from pre-roll without a frozen-video gap when audio.start
|
|
1379
|
+
* fires. At ~340 KB per SD frame the cap is ~85 MB peak; at HD it's
|
|
1380
|
+
* larger but still bounded.
|
|
1350
1381
|
*/
|
|
1351
|
-
queueHighWater =
|
|
1382
|
+
queueHighWater = 256;
|
|
1352
1383
|
enqueue(frame) {
|
|
1353
1384
|
if (this.destroyed) {
|
|
1354
1385
|
frame.close();
|
|
1355
1386
|
return;
|
|
1356
1387
|
}
|
|
1357
1388
|
this.queue.push(frame);
|
|
1389
|
+
this.hasEverEnqueuedSinceFlush = true;
|
|
1358
1390
|
if (this.queue.length === 1 && this.framesPainted === 0) {
|
|
1359
1391
|
this.resolveFirstFrame();
|
|
1360
1392
|
}
|
|
1361
|
-
while (this.queue.length >
|
|
1393
|
+
while (this.queue.length > this.queueHighWater + 8) {
|
|
1362
1394
|
this.queue.shift()?.close();
|
|
1363
1395
|
this.framesDroppedOverflow++;
|
|
1364
1396
|
}
|
|
@@ -1440,12 +1472,9 @@ var VideoRenderer = class {
|
|
|
1440
1472
|
if (this.queue.length === 0) return;
|
|
1441
1473
|
const playing = this.clock.isPlaying();
|
|
1442
1474
|
if (!playing) {
|
|
1443
|
-
if (!this.prerolled) {
|
|
1444
|
-
const head = this.queue.shift();
|
|
1445
|
-
this.paint(head);
|
|
1446
|
-
head.close();
|
|
1475
|
+
if (!this.prerolled && this.queue.length > 0) {
|
|
1447
1476
|
this.prerolled = true;
|
|
1448
|
-
this.
|
|
1477
|
+
this.paint(this.queue[0]);
|
|
1449
1478
|
}
|
|
1450
1479
|
return;
|
|
1451
1480
|
}
|
|
@@ -1454,14 +1483,29 @@ var VideoRenderer = class {
|
|
|
1454
1483
|
const hasPts = headTs > 0 || this.queue.length > 1;
|
|
1455
1484
|
if (hasPts) {
|
|
1456
1485
|
const wallNow2 = performance.now();
|
|
1457
|
-
if (!this.ptsCalibrated
|
|
1458
|
-
this.
|
|
1486
|
+
if (!this.ptsCalibrated) {
|
|
1487
|
+
const anchorUs = (this.clock.anchorTime?.() ?? this.clock.now()) * 1e6;
|
|
1488
|
+
const referencePtsUs = this.hasLastPaintedPts ? this.lastPaintedPtsUs : headTs;
|
|
1489
|
+
this.ptsCalibrationUs = referencePtsUs - anchorUs;
|
|
1459
1490
|
this.ptsCalibrated = true;
|
|
1460
1491
|
this.lastCalibrationWall = wallNow2;
|
|
1492
|
+
if (isDebug()) {
|
|
1493
|
+
console.log(
|
|
1494
|
+
`[avbridge:renderer] CALIB-FIRST audioAnchor=${(anchorUs / 1e3).toFixed(1)}ms prerolledPTS=${this.hasLastPaintedPts ? (this.lastPaintedPtsUs / 1e3).toFixed(1) : "n/a"}ms queueHeadPTS=${(headTs / 1e3).toFixed(1)}ms rawAudioNow=${(rawAudioNowUs / 1e3).toFixed(1)}ms \u2192 calib=${(this.ptsCalibrationUs / 1e3).toFixed(1)}ms`
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
} else if (wallNow2 - this.lastCalibrationWall > 1e4) {
|
|
1498
|
+
const oldCalib = this.ptsCalibrationUs;
|
|
1499
|
+
this.ptsCalibrationUs = headTs - rawAudioNowUs;
|
|
1500
|
+
this.lastCalibrationWall = wallNow2;
|
|
1501
|
+
if (isDebug()) {
|
|
1502
|
+
console.log(
|
|
1503
|
+
`[avbridge:renderer] CALIB-RESNAP headPTS=${(headTs / 1e3).toFixed(1)}ms rawAudioNow=${(rawAudioNowUs / 1e3).toFixed(1)}ms calib ${(oldCalib / 1e3).toFixed(1)}ms \u2192 ${(this.ptsCalibrationUs / 1e3).toFixed(1)}ms (\u0394=${((this.ptsCalibrationUs - oldCalib) / 1e3).toFixed(1)}ms after 10s)`
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1461
1506
|
}
|
|
1462
1507
|
const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
|
|
1463
|
-
const
|
|
1464
|
-
const deadlineUs = audioNowUs + frameDurationUs;
|
|
1508
|
+
const deadlineUs = audioNowUs;
|
|
1465
1509
|
let bestIdx = -1;
|
|
1466
1510
|
for (let i = 0; i < this.queue.length; i++) {
|
|
1467
1511
|
const ts = this.queue[i].timestamp ?? 0;
|
|
@@ -1488,19 +1532,21 @@ var VideoRenderer = class {
|
|
|
1488
1532
|
}
|
|
1489
1533
|
return;
|
|
1490
1534
|
}
|
|
1491
|
-
const
|
|
1535
|
+
const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
|
|
1492
1536
|
let dropped = 0;
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1537
|
+
const initialBestIdx = bestIdx;
|
|
1538
|
+
if (!_relaxDrop) {
|
|
1539
|
+
while (bestIdx > 0) {
|
|
1496
1540
|
this.queue.shift()?.close();
|
|
1497
1541
|
this.framesDroppedLate++;
|
|
1498
1542
|
bestIdx--;
|
|
1499
1543
|
dropped++;
|
|
1500
|
-
} else {
|
|
1501
|
-
break;
|
|
1502
1544
|
}
|
|
1503
1545
|
}
|
|
1546
|
+
const paintTs = this.queue[0]?.timestamp ?? 0;
|
|
1547
|
+
if (isDebug()) {
|
|
1548
|
+
console.log(`[TRACE] PAINT bestIdx_initial=${initialBestIdx} dropped=${dropped} paintPts=${(paintTs / 1e3).toFixed(1)}ms audioNow=${(audioNowUs / 1e3).toFixed(1)}ms deadline=${(deadlineUs / 1e3).toFixed(1)}ms queueLen=${this.queue.length} wall=${performance.now().toFixed(0)}`);
|
|
1549
|
+
}
|
|
1504
1550
|
this.ticksPainted++;
|
|
1505
1551
|
if (isDebug()) {
|
|
1506
1552
|
const now = performance.now();
|
|
@@ -1536,6 +1582,33 @@ var VideoRenderer = class {
|
|
|
1536
1582
|
}
|
|
1537
1583
|
try {
|
|
1538
1584
|
this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
|
|
1585
|
+
if (isDebug()) {
|
|
1586
|
+
const wallNow = performance.now();
|
|
1587
|
+
const audNowMs = this.clock.now() * 1e3;
|
|
1588
|
+
const ptsMs = (frame.timestamp ?? 0) / 1e3;
|
|
1589
|
+
const dWall = this.lastPaintWall > 0 ? wallNow - this.lastPaintWall : 0;
|
|
1590
|
+
const dAud = this.lastPaintAudMs > 0 ? audNowMs - this.lastPaintAudMs : 0;
|
|
1591
|
+
const dPts = this.hasLastPaintedPts ? ptsMs - this.lastPaintedPtsUs / 1e3 : 0;
|
|
1592
|
+
this.ctx.save();
|
|
1593
|
+
this.ctx.font = "bold 18px monospace";
|
|
1594
|
+
const lines = [
|
|
1595
|
+
`#${this.framesPainted + 1} pts=${ptsMs.toFixed(0)} aud=${audNowMs.toFixed(0)} wall=${wallNow.toFixed(0)}`,
|
|
1596
|
+
`\u0394pts=${dPts.toFixed(0)} \u0394aud=${dAud.toFixed(0)} \u0394wall=${dWall.toFixed(0)}`
|
|
1597
|
+
];
|
|
1598
|
+
const lineHeight = 22;
|
|
1599
|
+
const padTop = 6;
|
|
1600
|
+
const stripH = padTop + lineHeight * lines.length;
|
|
1601
|
+
this.ctx.fillStyle = "rgba(0,0,0,0.7)";
|
|
1602
|
+
this.ctx.fillRect(0, 0, this.canvas.width, stripH);
|
|
1603
|
+
this.ctx.fillStyle = "#0f0";
|
|
1604
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1605
|
+
this.ctx.fillText(lines[i], 8, padTop + lineHeight * (i + 1) - 4);
|
|
1606
|
+
}
|
|
1607
|
+
this.ctx.restore();
|
|
1608
|
+
}
|
|
1609
|
+
this.lastPaintedPtsUs = frame.timestamp ?? 0;
|
|
1610
|
+
this.hasLastPaintedPts = true;
|
|
1611
|
+
this.lastPaintAudMs = this.clock.now() * 1e3;
|
|
1539
1612
|
this.framesPainted++;
|
|
1540
1613
|
} catch (err) {
|
|
1541
1614
|
if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
|
|
@@ -1548,17 +1621,30 @@ var VideoRenderer = class {
|
|
|
1548
1621
|
const count = this.queue.length;
|
|
1549
1622
|
while (this.queue.length > 0) this.queue.shift()?.close();
|
|
1550
1623
|
this.prerolled = false;
|
|
1624
|
+
this.hasLastPaintedPts = false;
|
|
1551
1625
|
this.ptsCalibrated = false;
|
|
1626
|
+
this.hasEverEnqueuedSinceFlush = false;
|
|
1552
1627
|
if (isDebug() && count > 0) {
|
|
1553
1628
|
console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
|
|
1554
1629
|
}
|
|
1555
1630
|
}
|
|
1556
1631
|
stats() {
|
|
1632
|
+
let queueSpanMs = 0;
|
|
1633
|
+
let queueHeadMs = 0;
|
|
1634
|
+
let queueTailMs = 0;
|
|
1635
|
+
if (this.queue.length > 0) {
|
|
1636
|
+
queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1e3);
|
|
1637
|
+
queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1e3);
|
|
1638
|
+
queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
|
|
1639
|
+
}
|
|
1557
1640
|
return {
|
|
1558
1641
|
framesPainted: this.framesPainted,
|
|
1559
1642
|
framesDroppedLate: this.framesDroppedLate,
|
|
1560
1643
|
framesDroppedOverflow: this.framesDroppedOverflow,
|
|
1561
|
-
queueDepth: this.queue.length
|
|
1644
|
+
queueDepth: this.queue.length,
|
|
1645
|
+
queueHeadMs,
|
|
1646
|
+
queueTailMs,
|
|
1647
|
+
queueSpanMs
|
|
1562
1648
|
};
|
|
1563
1649
|
}
|
|
1564
1650
|
destroy() {
|
|
@@ -1576,6 +1662,9 @@ var VideoRenderer = class {
|
|
|
1576
1662
|
};
|
|
1577
1663
|
|
|
1578
1664
|
// src/strategies/fallback/audio-output.ts
|
|
1665
|
+
function isDebug2() {
|
|
1666
|
+
return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
|
|
1667
|
+
}
|
|
1579
1668
|
var AudioOutput = class {
|
|
1580
1669
|
ctx;
|
|
1581
1670
|
gain;
|
|
@@ -1597,6 +1686,16 @@ var AudioOutput = class {
|
|
|
1597
1686
|
mediaTimeOfNext = 0;
|
|
1598
1687
|
/** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
|
|
1599
1688
|
mediaTimeOfAnchor = 0;
|
|
1689
|
+
/**
|
|
1690
|
+
* Ctx time at which the first audible chunk will start playing. `-1`
|
|
1691
|
+
* before any chunk has been scheduled successfully (clock is frozen);
|
|
1692
|
+
* the actual ctx time once one has. The renderer's `clock.now()` uses
|
|
1693
|
+
* this to avoid advancing during the silent-gap window between
|
|
1694
|
+
* `audio.start()` and the first chunk that schedules without being
|
|
1695
|
+
* dropped — that gap is what produces the "audio-less fast-forward"
|
|
1696
|
+
* the user sees post-seek when the gate releases on video-only grace.
|
|
1697
|
+
*/
|
|
1698
|
+
firstAudibleCtxStart = -1;
|
|
1600
1699
|
ctxTimeAtAnchor = 0;
|
|
1601
1700
|
pendingQueue = [];
|
|
1602
1701
|
framesScheduled = 0;
|
|
@@ -1669,10 +1768,16 @@ var AudioOutput = class {
|
|
|
1669
1768
|
return this.mediaTimeOfAnchor;
|
|
1670
1769
|
}
|
|
1671
1770
|
if (this.state === "playing") {
|
|
1771
|
+
if (this.firstAudibleCtxStart < 0) {
|
|
1772
|
+
return this.mediaTimeOfAnchor;
|
|
1773
|
+
}
|
|
1672
1774
|
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
|
|
1673
1775
|
}
|
|
1674
1776
|
return this.mediaTimeOfAnchor;
|
|
1675
1777
|
}
|
|
1778
|
+
anchorTime() {
|
|
1779
|
+
return this.mediaTimeOfAnchor;
|
|
1780
|
+
}
|
|
1676
1781
|
isPlaying() {
|
|
1677
1782
|
return this.state === "playing";
|
|
1678
1783
|
}
|
|
@@ -1699,18 +1804,81 @@ var AudioOutput = class {
|
|
|
1699
1804
|
* Schedule a chunk of decoded samples. Queues internally while idle (cold
|
|
1700
1805
|
* start or post-seek), schedules directly to the audio graph while playing.
|
|
1701
1806
|
* In wall-clock mode, samples are silently discarded.
|
|
1807
|
+
*
|
|
1808
|
+
* `ptsSec` is the chunk's source-domain content PTS in seconds, from
|
|
1809
|
+
* the demuxer. When provided, the chunk plays at the ctx-time
|
|
1810
|
+
* corresponding to that PTS — so pre-target audio after a seek
|
|
1811
|
+
* naturally drops (its computed `ctxStart` falls in the past) and
|
|
1812
|
+
* post-target audio plays at its true content time, without any
|
|
1813
|
+
* external trim or anchor rebase. When `ptsSec` is null (cold start
|
|
1814
|
+
* with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
|
|
1815
|
+
* the chunk is scheduled sequentially after `mediaTimeOfNext` — the
|
|
1816
|
+
* pre-refactor behavior.
|
|
1702
1817
|
*/
|
|
1703
|
-
schedule(samples, channels, sampleRate) {
|
|
1818
|
+
schedule(samples, channels, sampleRate, ptsSec) {
|
|
1704
1819
|
if (this.destroyed || this.noAudio) return;
|
|
1705
1820
|
const frameCount = samples.length / channels;
|
|
1706
1821
|
const durationSec = frameCount / sampleRate;
|
|
1822
|
+
const hasPts = ptsSec != null && Number.isFinite(ptsSec);
|
|
1823
|
+
if (hasPts && ptsSec + durationSec / this._rate < this.mediaTimeOfAnchor) {
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1707
1826
|
if (this.state === "idle" || this.state === "paused") {
|
|
1708
|
-
this.pendingQueue.push({
|
|
1827
|
+
this.pendingQueue.push({
|
|
1828
|
+
samples,
|
|
1829
|
+
channels,
|
|
1830
|
+
sampleRate,
|
|
1831
|
+
frameCount,
|
|
1832
|
+
durationSec,
|
|
1833
|
+
ptsSec: hasPts ? ptsSec : null
|
|
1834
|
+
});
|
|
1709
1835
|
return;
|
|
1710
1836
|
}
|
|
1711
|
-
this.scheduleNow(
|
|
1837
|
+
this.scheduleNow(
|
|
1838
|
+
samples,
|
|
1839
|
+
channels,
|
|
1840
|
+
sampleRate,
|
|
1841
|
+
frameCount,
|
|
1842
|
+
hasPts ? ptsSec : null
|
|
1843
|
+
);
|
|
1712
1844
|
}
|
|
1713
|
-
scheduleNow(samples, channels, sampleRate, frameCount) {
|
|
1845
|
+
scheduleNow(samples, channels, sampleRate, frameCount, ptsSec) {
|
|
1846
|
+
const durationSec = frameCount / sampleRate;
|
|
1847
|
+
let ctxStart;
|
|
1848
|
+
if (ptsSec != null) {
|
|
1849
|
+
ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
|
|
1850
|
+
if (isDebug2()) {
|
|
1851
|
+
console.log(`[TRACE-AUD] PTS sched #${this.framesScheduled} pts=${ptsSec.toFixed(3)} dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} rate=${this._rate}`);
|
|
1852
|
+
}
|
|
1853
|
+
if (ctxStart < this.ctx.currentTime - 1e-3) {
|
|
1854
|
+
if (isDebug2()) {
|
|
1855
|
+
console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
|
|
1856
|
+
}
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (this.firstAudibleCtxStart < 0) {
|
|
1860
|
+
this.firstAudibleCtxStart = ctxStart;
|
|
1861
|
+
this.mediaTimeOfAnchor = ptsSec;
|
|
1862
|
+
this.ctxTimeAtAnchor = ctxStart;
|
|
1863
|
+
if (isDebug2()) {
|
|
1864
|
+
console.log(`[TRACE-AUD] UNFREEZE clock \u2014 first audible chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} \u2192 anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)}`);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
const endMediaTime = ptsSec + durationSec / this._rate;
|
|
1868
|
+
if (endMediaTime > this.mediaTimeOfNext) {
|
|
1869
|
+
this.mediaTimeOfNext = endMediaTime;
|
|
1870
|
+
}
|
|
1871
|
+
} else {
|
|
1872
|
+
ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
1873
|
+
console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
|
|
1874
|
+
if (ctxStart < this.ctx.currentTime) {
|
|
1875
|
+
console.warn(`[TRACE-AUD] REBASE anchor was=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor was=${this.ctxTimeAtAnchor.toFixed(4)} \u2192 anchor=${this.mediaTimeOfNext.toFixed(3)} ctxAnchor=${this.ctx.currentTime.toFixed(4)}`);
|
|
1876
|
+
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1877
|
+
this.mediaTimeOfAnchor = this.mediaTimeOfNext;
|
|
1878
|
+
ctxStart = this.ctx.currentTime;
|
|
1879
|
+
}
|
|
1880
|
+
this.mediaTimeOfNext += durationSec;
|
|
1881
|
+
}
|
|
1714
1882
|
const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
|
|
1715
1883
|
for (let ch = 0; ch < channels; ch++) {
|
|
1716
1884
|
const channelData = buffer.getChannelData(ch);
|
|
@@ -1722,14 +1890,7 @@ var AudioOutput = class {
|
|
|
1722
1890
|
node.buffer = buffer;
|
|
1723
1891
|
node.connect(this.gain);
|
|
1724
1892
|
if (this._rate !== 1) node.playbackRate.value = this._rate;
|
|
1725
|
-
let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
1726
|
-
if (ctxStart < this.ctx.currentTime) {
|
|
1727
|
-
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1728
|
-
this.mediaTimeOfAnchor = this.mediaTimeOfNext;
|
|
1729
|
-
ctxStart = this.ctx.currentTime;
|
|
1730
|
-
}
|
|
1731
1893
|
node.start(ctxStart);
|
|
1732
|
-
this.mediaTimeOfNext += frameCount / sampleRate;
|
|
1733
1894
|
this.framesScheduled++;
|
|
1734
1895
|
}
|
|
1735
1896
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
@@ -1754,12 +1915,15 @@ var AudioOutput = class {
|
|
|
1754
1915
|
} catch {
|
|
1755
1916
|
}
|
|
1756
1917
|
if (this.state === "paused") {
|
|
1918
|
+
if (isDebug2()) {
|
|
1919
|
+
console.log(`[TRACE-AUD] START(resume) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} \u2192 ctxAnchor=${this.ctx.currentTime.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
|
|
1920
|
+
}
|
|
1757
1921
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1758
1922
|
this.state = "playing";
|
|
1759
1923
|
const drain2 = this.pendingQueue;
|
|
1760
1924
|
this.pendingQueue = [];
|
|
1761
1925
|
for (const c of drain2) {
|
|
1762
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
1926
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
1763
1927
|
}
|
|
1764
1928
|
return;
|
|
1765
1929
|
}
|
|
@@ -1767,10 +1931,13 @@ var AudioOutput = class {
|
|
|
1767
1931
|
this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
|
|
1768
1932
|
this.mediaTimeOfNext = this.mediaTimeOfAnchor;
|
|
1769
1933
|
this.state = "playing";
|
|
1934
|
+
if (isDebug2()) {
|
|
1935
|
+
console.log(`[TRACE-AUD] START(cold) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
|
|
1936
|
+
}
|
|
1770
1937
|
const drain = this.pendingQueue;
|
|
1771
1938
|
this.pendingQueue = [];
|
|
1772
1939
|
for (const c of drain) {
|
|
1773
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
1940
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
1774
1941
|
}
|
|
1775
1942
|
}
|
|
1776
1943
|
/** Pause playback. Suspends the audio context. */
|
|
@@ -1796,6 +1963,9 @@ var AudioOutput = class {
|
|
|
1796
1963
|
* supplying new samples) and then call `start()` to resume playback.
|
|
1797
1964
|
*/
|
|
1798
1965
|
async reset(newMediaTime) {
|
|
1966
|
+
if (isDebug2()) {
|
|
1967
|
+
console.log(`[TRACE-AUD] RESET to=${newMediaTime.toFixed(3)} prev_anchor=${this.mediaTimeOfAnchor.toFixed(3)} prev_mtNext=${this.mediaTimeOfNext.toFixed(3)} prev_ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} state=${this.state}`);
|
|
1968
|
+
}
|
|
1799
1969
|
if (this.noAudio) {
|
|
1800
1970
|
this.pendingQueue = [];
|
|
1801
1971
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
@@ -1814,6 +1984,7 @@ var AudioOutput = class {
|
|
|
1814
1984
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
1815
1985
|
this.mediaTimeOfNext = newMediaTime;
|
|
1816
1986
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1987
|
+
this.firstAudibleCtxStart = -1;
|
|
1817
1988
|
this.state = "idle";
|
|
1818
1989
|
if (this.ctx.state === "running") {
|
|
1819
1990
|
await this.ctx.suspend();
|
|
@@ -1993,28 +2164,6 @@ function asUint8(x) {
|
|
|
1993
2164
|
const ta = x;
|
|
1994
2165
|
return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
|
|
1995
2166
|
}
|
|
1996
|
-
function sanitizeFrameTimestamp(frame, nextUs, fallbackTimeBase) {
|
|
1997
|
-
const lo = frame.pts ?? 0;
|
|
1998
|
-
const hi = frame.ptshi ?? 0;
|
|
1999
|
-
const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
|
|
2000
|
-
if (isInvalid) {
|
|
2001
|
-
const us2 = nextUs();
|
|
2002
|
-
frame.pts = us2;
|
|
2003
|
-
frame.ptshi = 0;
|
|
2004
|
-
return;
|
|
2005
|
-
}
|
|
2006
|
-
const tb = fallbackTimeBase ?? [1, 1e6];
|
|
2007
|
-
const pts64 = hi * 4294967296 + lo;
|
|
2008
|
-
const us = Math.round(pts64 * 1e6 * tb[0] / tb[1]);
|
|
2009
|
-
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
2010
|
-
frame.pts = us;
|
|
2011
|
-
frame.ptshi = us < 0 ? -1 : 0;
|
|
2012
|
-
return;
|
|
2013
|
-
}
|
|
2014
|
-
const fallback = nextUs();
|
|
2015
|
-
frame.pts = fallback;
|
|
2016
|
-
frame.ptshi = 0;
|
|
2017
|
-
}
|
|
2018
2167
|
|
|
2019
2168
|
// src/strategies/hybrid/decoder.ts
|
|
2020
2169
|
async function startHybridDecoder(opts) {
|
|
@@ -2096,6 +2245,7 @@ async function startHybridDecoder(opts) {
|
|
|
2096
2245
|
}
|
|
2097
2246
|
let bsfCtx = null;
|
|
2098
2247
|
let bsfPkt = null;
|
|
2248
|
+
let bsfRequiredButMissing = false;
|
|
2099
2249
|
if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
|
|
2100
2250
|
try {
|
|
2101
2251
|
bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
@@ -2106,13 +2256,19 @@ async function startHybridDecoder(opts) {
|
|
|
2106
2256
|
bsfPkt = await libav.av_packet_alloc();
|
|
2107
2257
|
dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
|
|
2108
2258
|
} else {
|
|
2109
|
-
|
|
2259
|
+
bsfRequiredButMissing = true;
|
|
2110
2260
|
bsfCtx = null;
|
|
2111
2261
|
}
|
|
2112
2262
|
} catch (err) {
|
|
2113
|
-
|
|
2263
|
+
bsfRequiredButMissing = true;
|
|
2114
2264
|
bsfCtx = null;
|
|
2115
2265
|
bsfPkt = null;
|
|
2266
|
+
dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${err.message}`);
|
|
2267
|
+
}
|
|
2268
|
+
if (bsfRequiredButMissing) {
|
|
2269
|
+
console.error(
|
|
2270
|
+
"[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes BSF is unavailable in this libav variant. Files with packed B-frames will play with incorrect frame ordering. Rebuild the libav variant with the `avbsf` fragment included."
|
|
2271
|
+
);
|
|
2116
2272
|
}
|
|
2117
2273
|
}
|
|
2118
2274
|
async function applyBSF(packets) {
|
|
@@ -2122,7 +2278,6 @@ async function startHybridDecoder(opts) {
|
|
|
2122
2278
|
await libav.ff_copyin_packet(bsfPkt, pkt);
|
|
2123
2279
|
const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
|
|
2124
2280
|
if (sendErr < 0) {
|
|
2125
|
-
out.push(pkt);
|
|
2126
2281
|
continue;
|
|
2127
2282
|
}
|
|
2128
2283
|
while (true) {
|
|
@@ -2136,10 +2291,13 @@ async function startHybridDecoder(opts) {
|
|
|
2136
2291
|
async function flushBSF() {
|
|
2137
2292
|
if (!bsfCtx || !bsfPkt) return;
|
|
2138
2293
|
try {
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2294
|
+
if (libav.av_bsf_flush) {
|
|
2295
|
+
await libav.av_bsf_flush(bsfCtx);
|
|
2296
|
+
} else {
|
|
2297
|
+
while (true) {
|
|
2298
|
+
const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
2299
|
+
if (err < 0) break;
|
|
2300
|
+
}
|
|
2143
2301
|
}
|
|
2144
2302
|
} catch {
|
|
2145
2303
|
}
|
|
@@ -2153,7 +2311,6 @@ async function startHybridDecoder(opts) {
|
|
|
2153
2311
|
let videoChunksFed = 0;
|
|
2154
2312
|
let bufferedUntilSec = 0;
|
|
2155
2313
|
let syntheticVideoUs = 0;
|
|
2156
|
-
let syntheticAudioUs = 0;
|
|
2157
2314
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
2158
2315
|
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
2159
2316
|
const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
|
|
@@ -2185,7 +2342,13 @@ async function startHybridDecoder(opts) {
|
|
|
2185
2342
|
}
|
|
2186
2343
|
}
|
|
2187
2344
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2188
|
-
await decodeAudioBatch(
|
|
2345
|
+
await decodeAudioBatch(
|
|
2346
|
+
audioPackets,
|
|
2347
|
+
myToken,
|
|
2348
|
+
/*flush*/
|
|
2349
|
+
false,
|
|
2350
|
+
audioTimeBase
|
|
2351
|
+
);
|
|
2189
2352
|
}
|
|
2190
2353
|
if (myToken !== pumpToken || destroyed) return;
|
|
2191
2354
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -2232,8 +2395,11 @@ async function startHybridDecoder(opts) {
|
|
|
2232
2395
|
}
|
|
2233
2396
|
}
|
|
2234
2397
|
}
|
|
2235
|
-
async function decodeAudioBatch(pkts, myToken, flush = false) {
|
|
2398
|
+
async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
|
|
2236
2399
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
2400
|
+
const pktPtsSec = pkts.map(
|
|
2401
|
+
(p) => tb ? packetPtsSec(p, tb) : null
|
|
2402
|
+
);
|
|
2237
2403
|
const AUDIO_SUB_BATCH = 4;
|
|
2238
2404
|
let allFrames = [];
|
|
2239
2405
|
for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
|
|
@@ -2271,22 +2437,13 @@ async function startHybridDecoder(opts) {
|
|
|
2271
2437
|
}
|
|
2272
2438
|
if (myToken !== pumpToken || destroyed) return;
|
|
2273
2439
|
const frames = allFrames;
|
|
2274
|
-
for (
|
|
2440
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2275
2441
|
if (myToken !== pumpToken || destroyed) return;
|
|
2276
|
-
|
|
2277
|
-
f,
|
|
2278
|
-
() => {
|
|
2279
|
-
const ts = syntheticAudioUs;
|
|
2280
|
-
const samples2 = f.nb_samples ?? 1024;
|
|
2281
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
2282
|
-
syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
|
|
2283
|
-
return ts;
|
|
2284
|
-
},
|
|
2285
|
-
audioTimeBase
|
|
2286
|
-
);
|
|
2442
|
+
const f = frames[i];
|
|
2287
2443
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
2288
2444
|
if (samples) {
|
|
2289
|
-
|
|
2445
|
+
const pts = pktPtsSec[i] ?? null;
|
|
2446
|
+
opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
|
|
2290
2447
|
audioFramesDecoded++;
|
|
2291
2448
|
}
|
|
2292
2449
|
}
|
|
@@ -2396,7 +2553,6 @@ async function startHybridDecoder(opts) {
|
|
|
2396
2553
|
}
|
|
2397
2554
|
await flushBSF();
|
|
2398
2555
|
syntheticVideoUs = Math.round(timeSec * 1e6);
|
|
2399
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
2400
2556
|
pumpRunning = pumpLoop(newToken).catch(
|
|
2401
2557
|
(err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
|
|
2402
2558
|
);
|
|
@@ -2435,7 +2591,6 @@ async function startHybridDecoder(opts) {
|
|
|
2435
2591
|
}
|
|
2436
2592
|
await flushBSF();
|
|
2437
2593
|
syntheticVideoUs = Math.round(timeSec * 1e6);
|
|
2438
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
2439
2594
|
pumpRunning = pumpLoop(newToken).catch(
|
|
2440
2595
|
(err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
|
|
2441
2596
|
);
|
|
@@ -2451,6 +2606,7 @@ async function startHybridDecoder(opts) {
|
|
|
2451
2606
|
videoChunksFed,
|
|
2452
2607
|
audioFramesDecoded,
|
|
2453
2608
|
bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
|
|
2609
|
+
bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
|
|
2454
2610
|
videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
|
|
2455
2611
|
// Confirmed transport info — see fallback decoder for the pattern.
|
|
2456
2612
|
_transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
|
|
@@ -2687,6 +2843,9 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2687
2843
|
}
|
|
2688
2844
|
|
|
2689
2845
|
// src/strategies/fallback/decoder.ts
|
|
2846
|
+
function isDebug3() {
|
|
2847
|
+
return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
|
|
2848
|
+
}
|
|
2690
2849
|
async function startDecoder(opts) {
|
|
2691
2850
|
const variant = "avbridge";
|
|
2692
2851
|
const libav = await loadLibav(variant);
|
|
@@ -2750,6 +2909,7 @@ async function startDecoder(opts) {
|
|
|
2750
2909
|
}
|
|
2751
2910
|
let bsfCtx = null;
|
|
2752
2911
|
let bsfPkt = null;
|
|
2912
|
+
let bsfRequiredButMissing = false;
|
|
2753
2913
|
if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
|
|
2754
2914
|
try {
|
|
2755
2915
|
bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
@@ -2760,13 +2920,19 @@ async function startDecoder(opts) {
|
|
|
2760
2920
|
bsfPkt = await libav.av_packet_alloc();
|
|
2761
2921
|
dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
|
|
2762
2922
|
} else {
|
|
2763
|
-
|
|
2923
|
+
bsfRequiredButMissing = true;
|
|
2764
2924
|
bsfCtx = null;
|
|
2765
2925
|
}
|
|
2766
2926
|
} catch (err) {
|
|
2767
|
-
|
|
2927
|
+
bsfRequiredButMissing = true;
|
|
2768
2928
|
bsfCtx = null;
|
|
2769
2929
|
bsfPkt = null;
|
|
2930
|
+
dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${err.message}`);
|
|
2931
|
+
}
|
|
2932
|
+
if (bsfRequiredButMissing) {
|
|
2933
|
+
console.error(
|
|
2934
|
+
"[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes BSF is unavailable in this libav variant. Files with packed B-frames will play with incorrect frame ordering (backwards PTS jumps, heavy late-drop stuttering). Rebuild the libav variant with the `avbsf` fragment included. See docs/dev/POSTMORTEMS.md for details."
|
|
2935
|
+
);
|
|
2770
2936
|
}
|
|
2771
2937
|
}
|
|
2772
2938
|
async function applyBSF(packets) {
|
|
@@ -2776,7 +2942,6 @@ async function startDecoder(opts) {
|
|
|
2776
2942
|
await libav.ff_copyin_packet(bsfPkt, pkt);
|
|
2777
2943
|
const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
|
|
2778
2944
|
if (sendErr < 0) {
|
|
2779
|
-
out.push(pkt);
|
|
2780
2945
|
continue;
|
|
2781
2946
|
}
|
|
2782
2947
|
while (true) {
|
|
@@ -2790,10 +2955,13 @@ async function startDecoder(opts) {
|
|
|
2790
2955
|
async function flushBSF() {
|
|
2791
2956
|
if (!bsfCtx || !bsfPkt) return;
|
|
2792
2957
|
try {
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2958
|
+
if (libav.av_bsf_flush) {
|
|
2959
|
+
await libav.av_bsf_flush(bsfCtx);
|
|
2960
|
+
} else {
|
|
2961
|
+
while (true) {
|
|
2962
|
+
const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
2963
|
+
if (err < 0) break;
|
|
2964
|
+
}
|
|
2797
2965
|
}
|
|
2798
2966
|
} catch {
|
|
2799
2967
|
}
|
|
@@ -2809,8 +2977,28 @@ async function startDecoder(opts) {
|
|
|
2809
2977
|
let watchdogSlowSinceMs = 0;
|
|
2810
2978
|
let watchdogSlowWarned = false;
|
|
2811
2979
|
let watchdogOverflowWarned = false;
|
|
2812
|
-
let
|
|
2813
|
-
let
|
|
2980
|
+
let lastContentUs = -1;
|
|
2981
|
+
let firstValidPtsLoggedSinceSeek = false;
|
|
2982
|
+
let seenFirstAudioPacketSinceSeek = false;
|
|
2983
|
+
let seekTargetSec = 0;
|
|
2984
|
+
let diagPktsLoggedSinceSeek = 0;
|
|
2985
|
+
let diagFramesLoggedSinceSeek = 0;
|
|
2986
|
+
let diagFrameKeysDumped = false;
|
|
2987
|
+
const DIAG_MAX_PKTS = 100;
|
|
2988
|
+
const DIAG_MAX_FRAMES = 300;
|
|
2989
|
+
let videoDecodeMsTotal = 0;
|
|
2990
|
+
let audioDecodeMsTotal = 0;
|
|
2991
|
+
let videoDecodeBatches = 0;
|
|
2992
|
+
let audioDecodeBatches = 0;
|
|
2993
|
+
let readMsTotal = 0;
|
|
2994
|
+
let readBatches = 0;
|
|
2995
|
+
let pumpThrottleMsTotal = 0;
|
|
2996
|
+
let pumpThrottleEntries = 0;
|
|
2997
|
+
let slowestVideoBatchMs = 0;
|
|
2998
|
+
let newestVideoPtsUs = 0;
|
|
2999
|
+
let lastEmittedPtsUs = -1;
|
|
3000
|
+
let ptsRegressions = 0;
|
|
3001
|
+
let worstPtsRegressionMs = 0;
|
|
2814
3002
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
2815
3003
|
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
2816
3004
|
const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
|
|
@@ -2819,9 +3007,12 @@ async function startDecoder(opts) {
|
|
|
2819
3007
|
let readErr;
|
|
2820
3008
|
let packets;
|
|
2821
3009
|
try {
|
|
3010
|
+
const _readStart = performance.now();
|
|
2822
3011
|
[readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
|
|
2823
3012
|
limit: 16 * 1024
|
|
2824
3013
|
});
|
|
3014
|
+
readMsTotal += performance.now() - _readStart;
|
|
3015
|
+
readBatches++;
|
|
2825
3016
|
} catch (err) {
|
|
2826
3017
|
console.error("[avbridge] ff_read_frame_multi failed:", err);
|
|
2827
3018
|
return;
|
|
@@ -2833,6 +3024,18 @@ async function startDecoder(opts) {
|
|
|
2833
3024
|
for (const pkt of videoPackets) {
|
|
2834
3025
|
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
2835
3026
|
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
3027
|
+
if (isDebug3() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
|
|
3028
|
+
const rawHi = pkt.ptshi ?? 0;
|
|
3029
|
+
const rawLo = pkt.pts ?? 0;
|
|
3030
|
+
const isInvalidPts = rawHi === -2147483648 && rawLo === 0;
|
|
3031
|
+
const rawPts64 = isInvalidPts ? null : rawHi * 4294967296 + rawLo;
|
|
3032
|
+
const rawSec = rawPts64 != null && videoTimeBase ? rawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
|
|
3033
|
+
const pktKeys = diagPktsLoggedSinceSeek === 0 ? `[keys: ${Object.keys(pkt).join(",")}]` : "";
|
|
3034
|
+
console.log(
|
|
3035
|
+
`[DIAG-PKT] vidx=${diagPktsLoggedSinceSeek} pts=${isInvalidPts ? "NOPTS" : rawPts64} pts_sec=${rawSec != null ? rawSec.toFixed(3) : "n/a"} ptshi=${rawHi} ptslo=${rawLo} flags=0x${(pkt.flags ?? 0).toString(16)} keyframe=${(pkt.flags ?? 0) & 1 ? "Y" : "N"} stream=${pkt.stream_index} dataLen=${pkt.data?.length ?? 0} seekTarget=${seekTargetSec.toFixed(3)} ` + pktKeys
|
|
3036
|
+
);
|
|
3037
|
+
diagPktsLoggedSinceSeek++;
|
|
3038
|
+
}
|
|
2836
3039
|
}
|
|
2837
3040
|
}
|
|
2838
3041
|
if (audioPackets && audioTimeBase) {
|
|
@@ -2840,9 +3043,25 @@ async function startDecoder(opts) {
|
|
|
2840
3043
|
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
2841
3044
|
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2842
3045
|
}
|
|
3046
|
+
if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
|
|
3047
|
+
const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
|
|
3048
|
+
if (firstSec != null && Number.isFinite(firstSec)) {
|
|
3049
|
+
seenFirstAudioPacketSinceSeek = true;
|
|
3050
|
+
dbg.info(
|
|
3051
|
+
"av-anchor",
|
|
3052
|
+
`seek-target=${seekTargetSec.toFixed(3)}s, first-audio-pkt-pts=${firstSec.toFixed(3)}s (\u0394=${((firstSec - seekTargetSec) * 1e3).toFixed(1)}ms \u2014 pre-target packets will be skipped by AudioOutput)`
|
|
3053
|
+
);
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
2843
3056
|
}
|
|
2844
3057
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2845
|
-
await decodeAudioBatch(
|
|
3058
|
+
await decodeAudioBatch(
|
|
3059
|
+
audioPackets,
|
|
3060
|
+
myToken,
|
|
3061
|
+
/*flush*/
|
|
3062
|
+
false,
|
|
3063
|
+
audioTimeBase
|
|
3064
|
+
);
|
|
2846
3065
|
}
|
|
2847
3066
|
if (myToken !== pumpToken || destroyed) return;
|
|
2848
3067
|
if (videoDec && videoPackets && videoPackets.length > 0) {
|
|
@@ -2883,8 +3102,17 @@ async function startDecoder(opts) {
|
|
|
2883
3102
|
}
|
|
2884
3103
|
}
|
|
2885
3104
|
}
|
|
2886
|
-
|
|
2887
|
-
|
|
3105
|
+
{
|
|
3106
|
+
const _throttleStart = performance.now();
|
|
3107
|
+
let _throttled = false;
|
|
3108
|
+
while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
|
|
3109
|
+
_throttled = true;
|
|
3110
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
3111
|
+
}
|
|
3112
|
+
if (_throttled) {
|
|
3113
|
+
pumpThrottleMsTotal += performance.now() - _throttleStart;
|
|
3114
|
+
pumpThrottleEntries++;
|
|
3115
|
+
}
|
|
2888
3116
|
}
|
|
2889
3117
|
if (readErr === libav.AVERROR_EOF) {
|
|
2890
3118
|
if (videoDec) await decodeVideoBatch(
|
|
@@ -2910,6 +3138,7 @@ async function startDecoder(opts) {
|
|
|
2910
3138
|
async function decodeVideoBatch(pkts, myToken, flush = false) {
|
|
2911
3139
|
if (!videoDec || destroyed || myToken !== pumpToken) return;
|
|
2912
3140
|
let frames;
|
|
3141
|
+
const _t0 = performance.now();
|
|
2913
3142
|
try {
|
|
2914
3143
|
frames = await libav.ff_decode_multi(
|
|
2915
3144
|
videoDec.c,
|
|
@@ -2922,32 +3151,133 @@ async function startDecoder(opts) {
|
|
|
2922
3151
|
console.error("[avbridge] video decode batch failed:", err);
|
|
2923
3152
|
return;
|
|
2924
3153
|
}
|
|
3154
|
+
{
|
|
3155
|
+
const _dt = performance.now() - _t0;
|
|
3156
|
+
videoDecodeMsTotal += _dt;
|
|
3157
|
+
videoDecodeBatches++;
|
|
3158
|
+
if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
|
|
3159
|
+
}
|
|
2925
3160
|
if (myToken !== pumpToken || destroyed) return;
|
|
2926
3161
|
for (const f of frames) {
|
|
2927
3162
|
if (myToken !== pumpToken || destroyed) return;
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
3163
|
+
const _diagShouldLog = isDebug3() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
|
|
3164
|
+
const _diagRawHi = f.ptshi ?? 0;
|
|
3165
|
+
const _diagRawLo = f.pts ?? 0;
|
|
3166
|
+
const _diagInvalid = _diagRawHi === -2147483648 && _diagRawLo === 0;
|
|
3167
|
+
const _diagRawPts64 = _diagInvalid ? null : _diagRawHi * 4294967296 + _diagRawLo;
|
|
3168
|
+
const _diagRawSec = _diagRawPts64 != null && videoTimeBase ? _diagRawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
|
|
3169
|
+
if (_diagShouldLog && !diagFrameKeysDumped) {
|
|
3170
|
+
diagFrameKeysDumped = true;
|
|
3171
|
+
const allKeys = Object.keys(f);
|
|
3172
|
+
const fieldDump = {};
|
|
3173
|
+
for (const k of allKeys) {
|
|
3174
|
+
const v = f[k];
|
|
3175
|
+
if (k === "data") continue;
|
|
3176
|
+
if (typeof v === "object" && v !== null && "length" in v) continue;
|
|
3177
|
+
fieldDump[k] = v;
|
|
3178
|
+
}
|
|
3179
|
+
console.log(`[DIAG-FRAME] FIRST FRAME post-seek \u2014 all keys: ${allKeys.join(",")}`);
|
|
3180
|
+
console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
|
|
3181
|
+
}
|
|
3182
|
+
let rawUs = null;
|
|
3183
|
+
if (!_diagInvalid && _diagRawPts64 != null) {
|
|
3184
|
+
const tb = videoTimeBase ?? [1, 1e6];
|
|
3185
|
+
const us = Math.round(_diagRawPts64 * 1e6 * tb[0] / tb[1]);
|
|
3186
|
+
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
3187
|
+
rawUs = us;
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
const _diagLog = (decision, finalPtsUs, sanFallback) => {
|
|
3191
|
+
if (!_diagShouldLog) return;
|
|
3192
|
+
const ptsSrc = sanFallback ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})` : "LIBAV";
|
|
3193
|
+
console.log(
|
|
3194
|
+
`[DIAG-FRAME] vidx=${diagFramesLoggedSinceSeek} raw_pts=${_diagInvalid ? "NOPTS" : _diagRawPts64} raw_pts_sec=${_diagRawSec != null ? _diagRawSec.toFixed(3) : "n/a"} pts_src=${ptsSrc} final_pts_us=${finalPtsUs} final_pts_sec=${(finalPtsUs / 1e6).toFixed(3)} seekTarget=${seekTargetSec.toFixed(3)} offset_to_target_ms=${(finalPtsUs / 1e3 - seekTargetSec * 1e3).toFixed(1)} lastEmittedPts_us=${lastEmittedPtsUs} decision=${decision}`
|
|
3195
|
+
);
|
|
3196
|
+
diagFramesLoggedSinceSeek++;
|
|
3197
|
+
};
|
|
3198
|
+
let _diagSanFallbackFired = false;
|
|
3199
|
+
const seekTargetUs = Math.round(seekTargetSec * 1e6);
|
|
3200
|
+
if (lastContentUs < 0) {
|
|
3201
|
+
if (rawUs == null) {
|
|
3202
|
+
const isColdStartKeyframe = seekTargetSec === 0 && f.key_frame === 1;
|
|
3203
|
+
if (isColdStartKeyframe) {
|
|
3204
|
+
lastContentUs = 0;
|
|
3205
|
+
_diagSanFallbackFired = true;
|
|
3206
|
+
} else {
|
|
3207
|
+
_diagLog("PRE-ANCHOR-DROP", 0, true);
|
|
3208
|
+
continue;
|
|
3209
|
+
}
|
|
3210
|
+
} else {
|
|
3211
|
+
lastContentUs = rawUs;
|
|
3212
|
+
if (!firstValidPtsLoggedSinceSeek) {
|
|
3213
|
+
firstValidPtsLoggedSinceSeek = true;
|
|
3214
|
+
if (isDebug3()) {
|
|
3215
|
+
console.log(
|
|
3216
|
+
`[avbridge:decoder] post-seek anchor established: first valid raw pts = ${(rawUs / 1e3).toFixed(1)}ms (seekTarget = ${(seekTargetSec * 1e3).toFixed(1)}ms, \u0394 = ${((rawUs - seekTargetUs) / 1e3).toFixed(1)}ms)`
|
|
3217
|
+
);
|
|
3218
|
+
}
|
|
3219
|
+
if (rawUs >= seekTargetUs) {
|
|
3220
|
+
console.warn(
|
|
3221
|
+
`[avbridge:decoder] first valid raw pts \u2265 seek target \u2014 pre-anchor NOPTS frames may have straddled the target and been mis-discarded. First painted frame may be late by up to one keyframe interval.`
|
|
3222
|
+
);
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
} else {
|
|
3227
|
+
if (rawUs != null) {
|
|
3228
|
+
lastContentUs = rawUs;
|
|
3229
|
+
} else {
|
|
3230
|
+
lastContentUs += videoFrameStepUs;
|
|
3231
|
+
_diagSanFallbackFired = true;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
f.pts = lastContentUs;
|
|
3235
|
+
f.ptshi = lastContentUs < 0 ? -1 : 0;
|
|
3236
|
+
const _fPts = lastContentUs;
|
|
3237
|
+
if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
|
|
3238
|
+
if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
|
|
3239
|
+
_diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
|
|
3240
|
+
ptsRegressions++;
|
|
3241
|
+
const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
|
|
3242
|
+
if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
|
|
3243
|
+
if (ptsRegressions <= 10) {
|
|
3244
|
+
console.warn(
|
|
3245
|
+
`[avbridge:decoder] dropped out-of-order frame #${ptsRegressions}: pts=${(_fPts / 1e3).toFixed(1)}ms < previous=${(lastEmittedPtsUs / 1e3).toFixed(1)}ms (regression=${regressMs.toFixed(1)}ms). Typically a post-seek B-frame reorder tail.`
|
|
3246
|
+
);
|
|
3247
|
+
}
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
lastEmittedPtsUs = _fPts;
|
|
3251
|
+
const targetUs = Math.round(seekTargetSec * 1e6);
|
|
3252
|
+
if (_fPts < targetUs - videoFrameStepUs) {
|
|
3253
|
+
_diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
|
|
3254
|
+
continue;
|
|
3255
|
+
}
|
|
2937
3256
|
try {
|
|
2938
3257
|
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
|
|
2939
|
-
opts.renderer.
|
|
3258
|
+
if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
|
|
3259
|
+
vf.close();
|
|
3260
|
+
_diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
|
|
3261
|
+
} else {
|
|
3262
|
+
opts.renderer.enqueue(vf);
|
|
3263
|
+
_diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
|
|
3264
|
+
}
|
|
2940
3265
|
videoFramesDecoded++;
|
|
2941
3266
|
} catch (err) {
|
|
2942
3267
|
if (videoFramesDecoded === 0) {
|
|
2943
3268
|
console.warn("[avbridge] laFrameToVideoFrame failed:", err);
|
|
2944
3269
|
}
|
|
3270
|
+
_diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
|
|
2945
3271
|
}
|
|
2946
3272
|
}
|
|
2947
3273
|
}
|
|
2948
|
-
async function decodeAudioBatch(pkts, myToken, flush = false) {
|
|
3274
|
+
async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
|
|
2949
3275
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
3276
|
+
const pktPtsSec = pkts.map(
|
|
3277
|
+
(p) => tb ? packetPtsSec(p, tb) : null
|
|
3278
|
+
);
|
|
2950
3279
|
let frames;
|
|
3280
|
+
const _t0 = performance.now();
|
|
2951
3281
|
try {
|
|
2952
3282
|
frames = await libav.ff_decode_multi(
|
|
2953
3283
|
audioDec.c,
|
|
@@ -2960,23 +3290,20 @@ async function startDecoder(opts) {
|
|
|
2960
3290
|
console.error("[avbridge] audio decode batch failed:", err);
|
|
2961
3291
|
return;
|
|
2962
3292
|
}
|
|
3293
|
+
audioDecodeMsTotal += performance.now() - _t0;
|
|
3294
|
+
audioDecodeBatches++;
|
|
2963
3295
|
if (myToken !== pumpToken || destroyed) return;
|
|
2964
|
-
for (
|
|
3296
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2965
3297
|
if (myToken !== pumpToken || destroyed) return;
|
|
2966
|
-
|
|
2967
|
-
f,
|
|
2968
|
-
() => {
|
|
2969
|
-
const ts = syntheticAudioUs;
|
|
2970
|
-
const samples2 = f.nb_samples ?? 1024;
|
|
2971
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
2972
|
-
syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
|
|
2973
|
-
return ts;
|
|
2974
|
-
},
|
|
2975
|
-
audioTimeBase
|
|
2976
|
-
);
|
|
3298
|
+
const f = frames[i];
|
|
2977
3299
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
2978
3300
|
if (samples) {
|
|
2979
|
-
|
|
3301
|
+
const pts = pktPtsSec[i] ?? null;
|
|
3302
|
+
if (isDebug3()) {
|
|
3303
|
+
const dur = samples.data.length / samples.channels / samples.sampleRate;
|
|
3304
|
+
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}`);
|
|
3305
|
+
}
|
|
3306
|
+
opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
|
|
2980
3307
|
audioFramesDecoded++;
|
|
2981
3308
|
}
|
|
2982
3309
|
}
|
|
@@ -3079,13 +3406,17 @@ async function startDecoder(opts) {
|
|
|
3079
3406
|
} catch {
|
|
3080
3407
|
}
|
|
3081
3408
|
await flushBSF();
|
|
3082
|
-
|
|
3083
|
-
|
|
3409
|
+
lastContentUs = -1;
|
|
3410
|
+
lastEmittedPtsUs = -1;
|
|
3411
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
3084
3412
|
pumpRunning = pumpLoop(newToken).catch(
|
|
3085
3413
|
(err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
|
|
3086
3414
|
);
|
|
3087
3415
|
},
|
|
3088
3416
|
async seek(timeSec) {
|
|
3417
|
+
if (isDebug3()) {
|
|
3418
|
+
console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1e3).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
|
|
3419
|
+
}
|
|
3089
3420
|
const newToken = ++pumpToken;
|
|
3090
3421
|
if (pumpRunning) {
|
|
3091
3422
|
try {
|
|
@@ -3116,8 +3447,14 @@ async function startDecoder(opts) {
|
|
|
3116
3447
|
} catch {
|
|
3117
3448
|
}
|
|
3118
3449
|
await flushBSF();
|
|
3119
|
-
|
|
3120
|
-
|
|
3450
|
+
lastContentUs = -1;
|
|
3451
|
+
lastEmittedPtsUs = -1;
|
|
3452
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
3453
|
+
seenFirstAudioPacketSinceSeek = false;
|
|
3454
|
+
seekTargetSec = timeSec;
|
|
3455
|
+
diagPktsLoggedSinceSeek = 0;
|
|
3456
|
+
diagFramesLoggedSinceSeek = 0;
|
|
3457
|
+
diagFrameKeysDumped = false;
|
|
3121
3458
|
pumpRunning = pumpLoop(newToken).catch(
|
|
3122
3459
|
(err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
|
|
3123
3460
|
);
|
|
@@ -3131,7 +3468,24 @@ async function startDecoder(opts) {
|
|
|
3131
3468
|
packetsRead,
|
|
3132
3469
|
videoFramesDecoded,
|
|
3133
3470
|
audioFramesDecoded,
|
|
3471
|
+
// Throughput instrumentation — the stats panel turns these into
|
|
3472
|
+
// "decode fps actual / realtime target" and shows slowest batch
|
|
3473
|
+
// + producer throttle share.
|
|
3474
|
+
videoDecodeMsTotal,
|
|
3475
|
+
videoDecodeBatches,
|
|
3476
|
+
audioDecodeMsTotal,
|
|
3477
|
+
audioDecodeBatches,
|
|
3478
|
+
readMsTotal,
|
|
3479
|
+
readBatches,
|
|
3480
|
+
pumpThrottleMsTotal,
|
|
3481
|
+
pumpThrottleEntries,
|
|
3482
|
+
slowestVideoBatchMs,
|
|
3483
|
+
newestVideoPtsMs: Math.round(newestVideoPtsUs / 1e3),
|
|
3484
|
+
ptsRegressions,
|
|
3485
|
+
worstPtsRegressionMs,
|
|
3486
|
+
sourceFps: videoFps,
|
|
3134
3487
|
bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
|
|
3488
|
+
bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
|
|
3135
3489
|
// Confirmed transport info: once prepareLibavInput returns
|
|
3136
3490
|
// successfully, we *know* whether the source is http-range (probe
|
|
3137
3491
|
// succeeded and returned 206) or in-memory blob. Diagnostics hoists
|
|
@@ -5471,8 +5825,42 @@ var PROXY_ATTRIBUTES = [
|
|
|
5471
5825
|
];
|
|
5472
5826
|
var PLAYER_ATTRIBUTES = ["show-fit"];
|
|
5473
5827
|
var FIT_MODES = ["contain", "cover", "fill"];
|
|
5474
|
-
var AvbridgePlayerElement = class
|
|
5828
|
+
var AvbridgePlayerElement = class extends HTMLElement {
|
|
5475
5829
|
static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
|
|
5830
|
+
/**
|
|
5831
|
+
* Returns `true` if a DOM event originated from one of the player's
|
|
5832
|
+
* **interactive chrome elements** (seek bar, control buttons, settings
|
|
5833
|
+
* menu, overlay play button) rather than the bare video surface.
|
|
5834
|
+
*
|
|
5835
|
+
* This is the escape hatch for host pages that wrap the player in a
|
|
5836
|
+
* gesture recognizer (e.g. TikTok-style vertical-swipe pager). For
|
|
5837
|
+
* bubble-phase listeners the player's own handlers already call
|
|
5838
|
+
* `stopPropagation()` on chrome interactions — but **capture-phase**
|
|
5839
|
+
* listeners run *before* the player's handlers, so they need to check
|
|
5840
|
+
* the event's path themselves and bail. This helper does that check
|
|
5841
|
+
* via `composedPath()`, which traverses shadow boundaries correctly.
|
|
5842
|
+
*
|
|
5843
|
+
* Returns `false` for events on the bare video surface — host pages
|
|
5844
|
+
* remain free to claim those for their own gestures (e.g. swipe-to-pan
|
|
5845
|
+
* to the next video). Returns `false` for events that never hit a
|
|
5846
|
+
* player at all.
|
|
5847
|
+
*
|
|
5848
|
+
* @example
|
|
5849
|
+
* // TikTok-style vertical swipe on the document, capture phase:
|
|
5850
|
+
* document.addEventListener("pointerdown", (e) => {
|
|
5851
|
+
* if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return;
|
|
5852
|
+
* startSwipeGesture(e);
|
|
5853
|
+
* }, { capture: true });
|
|
5854
|
+
*/
|
|
5855
|
+
static isPlayerChromeEvent(event) {
|
|
5856
|
+
const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn";
|
|
5857
|
+
for (const node of event.composedPath()) {
|
|
5858
|
+
if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) {
|
|
5859
|
+
return true;
|
|
5860
|
+
}
|
|
5861
|
+
}
|
|
5862
|
+
return false;
|
|
5863
|
+
}
|
|
5476
5864
|
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
5477
5865
|
_video;
|
|
5478
5866
|
_playBtn;
|
|
@@ -5502,6 +5890,11 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5502
5890
|
_activeAudioTrackId = null;
|
|
5503
5891
|
_activeSubtitleTrackId = null;
|
|
5504
5892
|
_userSeeking = false;
|
|
5893
|
+
/** Last seek target the user committed. The thumb stays here (and
|
|
5894
|
+
* `_updateTime` skips updating from `timeupdate`) until the underlying
|
|
5895
|
+
* `currentTime` actually catches up — otherwise the thumb visibly snaps
|
|
5896
|
+
* back to the pre-seek position while the remux pipeline rebuilds. */
|
|
5897
|
+
_pendingSeekTarget = null;
|
|
5505
5898
|
_holdTimer = null;
|
|
5506
5899
|
_holdSpeedActive = false;
|
|
5507
5900
|
_savedPlaybackRate = 1;
|
|
@@ -5608,7 +6001,10 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5608
6001
|
);
|
|
5609
6002
|
});
|
|
5610
6003
|
}
|
|
5611
|
-
on(this._video, "loadstart", () =>
|
|
6004
|
+
on(this._video, "loadstart", () => {
|
|
6005
|
+
this._pendingSeekTarget = null;
|
|
6006
|
+
this._setState("loading");
|
|
6007
|
+
});
|
|
5612
6008
|
on(this._video, "ready", () => {
|
|
5613
6009
|
this._setState(this._video.paused ? "paused" : "playing");
|
|
5614
6010
|
this._seekInput.max = String(this._video.duration || 0);
|
|
@@ -5755,7 +6151,9 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5755
6151
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`;
|
|
5756
6152
|
}
|
|
5757
6153
|
_onSeekCommit() {
|
|
5758
|
-
|
|
6154
|
+
const target = Number(this._seekInput.value);
|
|
6155
|
+
this._pendingSeekTarget = target;
|
|
6156
|
+
this._video.currentTime = target;
|
|
5759
6157
|
this._userSeeking = false;
|
|
5760
6158
|
}
|
|
5761
6159
|
/** Linear click-to-time mapping across the full track width (no edge clamping). */
|
|
@@ -5765,40 +6163,57 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5765
6163
|
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
5766
6164
|
return frac * (this._video.duration || 0);
|
|
5767
6165
|
}
|
|
5768
|
-
/** Seekbar width below which drag-to-scrub seeks in real-time (vs
|
|
5769
|
-
* preview-only). On narrow bars precise positioning is hard, so
|
|
5770
|
-
* immediate video feedback is more useful than a time tooltip. */
|
|
5771
|
-
static SCRUB_WIDTH_THRESHOLD = 400;
|
|
5772
6166
|
_onSeekPointerDown(e) {
|
|
5773
6167
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
5774
6168
|
e.preventDefault();
|
|
6169
|
+
e.stopPropagation();
|
|
5775
6170
|
this._userSeeking = true;
|
|
5776
6171
|
const seekBar = this.shadowRoot.querySelector(".avp-seek");
|
|
5777
6172
|
seekBar.setPointerCapture(e.pointerId);
|
|
5778
6173
|
seekBar.setAttribute("data-seeking", "");
|
|
5779
|
-
const
|
|
5780
|
-
|
|
5781
|
-
const
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
6174
|
+
const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
|
|
6175
|
+
const startTime = coarse ? this._video.currentTime || 0 : 0;
|
|
6176
|
+
const startClientX = e.clientX;
|
|
6177
|
+
let lastCommit = 0;
|
|
6178
|
+
const timeAt = (clientX) => {
|
|
6179
|
+
if (coarse) {
|
|
6180
|
+
const rect = seekBar.getBoundingClientRect();
|
|
6181
|
+
const dx = clientX - startClientX;
|
|
6182
|
+
const dt = dx / rect.width * (this._video.duration || 0);
|
|
6183
|
+
return Math.max(0, Math.min(this._video.duration || 0, startTime + dt));
|
|
6184
|
+
}
|
|
6185
|
+
return this._timeFromSeekPointer(clientX);
|
|
6186
|
+
};
|
|
6187
|
+
const showTooltip = (t, clientX) => {
|
|
6188
|
+
if (coarse) this._updateSeekTooltipAtTime(t);
|
|
6189
|
+
else this._updateSeekTooltip(clientX);
|
|
6190
|
+
};
|
|
6191
|
+
if (!coarse) {
|
|
6192
|
+
const initial = timeAt(e.clientX);
|
|
6193
|
+
this._seekInput.value = String(initial);
|
|
6194
|
+
this._onSeekInput();
|
|
6195
|
+
showTooltip(initial, e.clientX);
|
|
6196
|
+
this._onSeekCommit();
|
|
6197
|
+
this._userSeeking = true;
|
|
6198
|
+
} else {
|
|
6199
|
+
showTooltip(startTime, e.clientX);
|
|
6200
|
+
}
|
|
5786
6201
|
const onMove = (ev) => {
|
|
5787
|
-
|
|
6202
|
+
ev.stopPropagation();
|
|
6203
|
+
const t = timeAt(ev.clientX);
|
|
5788
6204
|
this._seekInput.value = String(t);
|
|
5789
6205
|
this._onSeekInput();
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
this._userSeeking = true;
|
|
5797
|
-
}
|
|
6206
|
+
showTooltip(t, ev.clientX);
|
|
6207
|
+
const now = performance.now();
|
|
6208
|
+
if (now - lastCommit > 250) {
|
|
6209
|
+
lastCommit = now;
|
|
6210
|
+
this._onSeekCommit();
|
|
6211
|
+
this._userSeeking = true;
|
|
5798
6212
|
}
|
|
5799
6213
|
};
|
|
5800
6214
|
const onUp = (ev) => {
|
|
5801
|
-
|
|
6215
|
+
ev.stopPropagation();
|
|
6216
|
+
const t = timeAt(ev.clientX);
|
|
5802
6217
|
this._seekInput.value = String(t);
|
|
5803
6218
|
this._onSeekCommit();
|
|
5804
6219
|
this._seekInput.focus();
|
|
@@ -5825,6 +6240,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5825
6240
|
this._seekTooltip.textContent = formatTime(t);
|
|
5826
6241
|
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
5827
6242
|
}
|
|
6243
|
+
/** Position the tooltip over a specific time (vs. pointer X). Used by
|
|
6244
|
+
* relative-drag scrub on coarse pointers, where the displayed time
|
|
6245
|
+
* is decoupled from the finger position. */
|
|
6246
|
+
_updateSeekTooltipAtTime(t) {
|
|
6247
|
+
const dur = this._video.duration || 0;
|
|
6248
|
+
const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0;
|
|
6249
|
+
this._seekTooltip.textContent = formatTime(t);
|
|
6250
|
+
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
6251
|
+
}
|
|
5828
6252
|
_updateSeekVisuals(t) {
|
|
5829
6253
|
const dur = this._video.duration || 0;
|
|
5830
6254
|
const pct = dur > 0 ? t / dur * 100 : 0;
|
|
@@ -5836,6 +6260,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5836
6260
|
if (this._userSeeking) return;
|
|
5837
6261
|
const t = this._video.currentTime;
|
|
5838
6262
|
const d = this._video.duration;
|
|
6263
|
+
if (this._pendingSeekTarget !== null) {
|
|
6264
|
+
if (Math.abs(t - this._pendingSeekTarget) < 0.5) {
|
|
6265
|
+
this._pendingSeekTarget = null;
|
|
6266
|
+
} else {
|
|
6267
|
+
this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`;
|
|
6268
|
+
this._updateBuffered();
|
|
6269
|
+
return;
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
5839
6272
|
this._seekInput.value = String(t);
|
|
5840
6273
|
this._updateSeekVisuals(t);
|
|
5841
6274
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
|
|
@@ -6020,10 +6453,12 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
6020
6453
|
}
|
|
6021
6454
|
}
|
|
6022
6455
|
// ── Stats for nerds ────────────────────────────────────────────────────
|
|
6456
|
+
_statsPrev = null;
|
|
6023
6457
|
_toggleStats() {
|
|
6024
6458
|
this._statsOpen = !this._statsOpen;
|
|
6025
6459
|
this._statsEl.classList.toggle("open", this._statsOpen);
|
|
6026
6460
|
if (this._statsOpen) {
|
|
6461
|
+
this._statsPrev = null;
|
|
6027
6462
|
this._updateStats();
|
|
6028
6463
|
this._statsInterval = setInterval(() => this._updateStats(), 1e3);
|
|
6029
6464
|
} else {
|
|
@@ -6040,23 +6475,76 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
6040
6475
|
return;
|
|
6041
6476
|
}
|
|
6042
6477
|
const rt = d.runtime ?? {};
|
|
6043
|
-
const
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
|
|
6057
|
-
|
|
6478
|
+
const now = performance.now();
|
|
6479
|
+
const prev = this._statsPrev;
|
|
6480
|
+
const dtSec = prev ? Math.max(1e-3, (now - prev.ts) / 1e3) : 0;
|
|
6481
|
+
const delta = (key) => {
|
|
6482
|
+
if (!prev) return null;
|
|
6483
|
+
const a = rt[key];
|
|
6484
|
+
const b = prev.rt[key];
|
|
6485
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
6486
|
+
return null;
|
|
6487
|
+
};
|
|
6488
|
+
const rate = (key) => {
|
|
6489
|
+
const d_ = delta(key);
|
|
6490
|
+
return d_ != null ? d_ / dtSec : null;
|
|
6491
|
+
};
|
|
6492
|
+
const fmt = (n, digits = 1) => n == null ? "?" : n.toFixed(digits);
|
|
6493
|
+
const sourceFps = typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps;
|
|
6494
|
+
const lines = [];
|
|
6495
|
+
lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
|
|
6496
|
+
lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
|
|
6497
|
+
lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
|
|
6498
|
+
lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
|
|
6499
|
+
if (rt.videoFramesDecoded != null) {
|
|
6500
|
+
const decFps = rate("videoFramesDecoded");
|
|
6501
|
+
const paintFps = rate("framesPainted");
|
|
6502
|
+
const dropLateFps = rate("framesDroppedLate");
|
|
6503
|
+
const dropOverflowFps = rate("framesDroppedOverflow");
|
|
6504
|
+
const pct = sourceFps && decFps != null ? ` (${(decFps / sourceFps * 100).toFixed(0)}% of realtime)` : "";
|
|
6505
|
+
lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
|
|
6506
|
+
lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
|
|
6507
|
+
}
|
|
6508
|
+
if (typeof rt.videoDecodeMsTotal === "number") {
|
|
6509
|
+
const msDelta = delta("videoDecodeMsTotal");
|
|
6510
|
+
const batchesDelta = delta("videoDecodeBatches");
|
|
6511
|
+
const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
|
|
6512
|
+
const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
|
|
6513
|
+
lines.push(
|
|
6514
|
+
`Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs)}ms`
|
|
6515
|
+
);
|
|
6516
|
+
}
|
|
6517
|
+
if (typeof rt.audioDecodeMsTotal === "number") {
|
|
6518
|
+
const msDelta = delta("audioDecodeMsTotal");
|
|
6519
|
+
const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
|
|
6520
|
+
lines.push(`Audio decode: ${fmt(share)}% of wall`);
|
|
6521
|
+
}
|
|
6522
|
+
if (typeof rt.pumpThrottleMsTotal === "number") {
|
|
6523
|
+
const msDelta = delta("pumpThrottleMsTotal");
|
|
6524
|
+
const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
|
|
6525
|
+
lines.push(`Producer throttled: ${fmt(share)}% of wall`);
|
|
6526
|
+
}
|
|
6527
|
+
if (rt.queueDepth != null) {
|
|
6528
|
+
lines.push(
|
|
6529
|
+
`Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs)}ms head=${fmt(rt.queueHeadMs)}ms tail=${fmt(rt.queueTailMs)}ms`
|
|
6530
|
+
);
|
|
6531
|
+
}
|
|
6532
|
+
if (typeof rt.newestVideoPtsMs === "number") {
|
|
6533
|
+
lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1e3).toFixed(2)}s`);
|
|
6534
|
+
}
|
|
6535
|
+
if (rt.audioState != null) {
|
|
6536
|
+
lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead, 2)}s clock=${rt.clockMode ?? "?"}`);
|
|
6537
|
+
}
|
|
6538
|
+
if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
|
|
6539
|
+
lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs)}ms) \u2014 decoder emitting out of order`);
|
|
6540
|
+
}
|
|
6541
|
+
if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF active: ${rt.bsfApplied.join(", ")}`);
|
|
6542
|
+
if (rt.bsfMissing && rt.bsfMissing.length > 0) {
|
|
6543
|
+
lines.push(`BSF MISSING: ${rt.bsfMissing.join(", ")} (rebuild libav with avbsf)`);
|
|
6544
|
+
}
|
|
6058
6545
|
if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
|
|
6059
6546
|
this._statsEl.textContent = lines.join("\n");
|
|
6547
|
+
this._statsPrev = { ts: now, rt: { ...rt } };
|
|
6060
6548
|
}
|
|
6061
6549
|
// ── Controls: fullscreen ───────────────────────────────────────────────
|
|
6062
6550
|
_toggleFullscreen() {
|