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