avbridge 2.12.1 → 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 +101 -0
- package/README.md +33 -0
- package/dist/{chunk-UM6WCSGL.cjs → chunk-OFJYEITB.cjs} +356 -91
- package/dist/chunk-OFJYEITB.cjs.map +1 -0
- package/dist/{chunk-BN7BRTLY.js → chunk-VOC24LYF.js} +357 -92
- package/dist/chunk-VOC24LYF.js.map +1 -0
- package/dist/element-browser.js +354 -111
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +2 -2
- package/dist/element.js +1 -1
- package/dist/index.cjs +8 -8
- package/dist/index.js +1 -1
- package/dist/player.cjs +457 -135
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +35 -4
- package/dist/player.d.ts +35 -4
- package/dist/player.js +457 -135
- package/dist/player.js.map +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +136 -28
- package/src/strategies/fallback/audio-output.ts +164 -35
- package/src/strategies/fallback/decoder.ts +336 -58
- package/src/strategies/fallback/video-renderer.ts +176 -34
- package/src/strategies/hybrid/decoder.ts +22 -19
- package/src/strategies/remux/pipeline.ts +12 -3
- package/dist/chunk-BN7BRTLY.js.map +0 -1
- package/dist/chunk-UM6WCSGL.cjs.map +0 -1
package/dist/player.js
CHANGED
|
@@ -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. */
|
|
@@ -1352,13 +1369,17 @@ var VideoRenderer = class {
|
|
|
1352
1369
|
return this.queue.length;
|
|
1353
1370
|
}
|
|
1354
1371
|
/**
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
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.
|
|
1360
1381
|
*/
|
|
1361
|
-
queueHighWater =
|
|
1382
|
+
queueHighWater = 256;
|
|
1362
1383
|
enqueue(frame) {
|
|
1363
1384
|
if (this.destroyed) {
|
|
1364
1385
|
frame.close();
|
|
@@ -1369,7 +1390,7 @@ var VideoRenderer = class {
|
|
|
1369
1390
|
if (this.queue.length === 1 && this.framesPainted === 0) {
|
|
1370
1391
|
this.resolveFirstFrame();
|
|
1371
1392
|
}
|
|
1372
|
-
while (this.queue.length >
|
|
1393
|
+
while (this.queue.length > this.queueHighWater + 8) {
|
|
1373
1394
|
this.queue.shift()?.close();
|
|
1374
1395
|
this.framesDroppedOverflow++;
|
|
1375
1396
|
}
|
|
@@ -1451,12 +1472,9 @@ var VideoRenderer = class {
|
|
|
1451
1472
|
if (this.queue.length === 0) return;
|
|
1452
1473
|
const playing = this.clock.isPlaying();
|
|
1453
1474
|
if (!playing) {
|
|
1454
|
-
if (!this.prerolled) {
|
|
1455
|
-
const head = this.queue.shift();
|
|
1456
|
-
this.paint(head);
|
|
1457
|
-
head.close();
|
|
1475
|
+
if (!this.prerolled && this.queue.length > 0) {
|
|
1458
1476
|
this.prerolled = true;
|
|
1459
|
-
this.
|
|
1477
|
+
this.paint(this.queue[0]);
|
|
1460
1478
|
}
|
|
1461
1479
|
return;
|
|
1462
1480
|
}
|
|
@@ -1465,14 +1483,29 @@ var VideoRenderer = class {
|
|
|
1465
1483
|
const hasPts = headTs > 0 || this.queue.length > 1;
|
|
1466
1484
|
if (hasPts) {
|
|
1467
1485
|
const wallNow2 = performance.now();
|
|
1468
|
-
if (!this.ptsCalibrated
|
|
1469
|
-
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;
|
|
1470
1490
|
this.ptsCalibrated = true;
|
|
1471
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
|
+
}
|
|
1472
1506
|
}
|
|
1473
1507
|
const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
|
|
1474
|
-
const
|
|
1475
|
-
const deadlineUs = audioNowUs + frameDurationUs;
|
|
1508
|
+
const deadlineUs = audioNowUs;
|
|
1476
1509
|
let bestIdx = -1;
|
|
1477
1510
|
for (let i = 0; i < this.queue.length; i++) {
|
|
1478
1511
|
const ts = this.queue[i].timestamp ?? 0;
|
|
@@ -1500,19 +1533,20 @@ var VideoRenderer = class {
|
|
|
1500
1533
|
return;
|
|
1501
1534
|
}
|
|
1502
1535
|
const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
|
|
1503
|
-
const dropThresholdUs = _relaxDrop ? audioNowUs - 60 * 1e6 : audioNowUs - frameDurationUs * 2;
|
|
1504
1536
|
let dropped = 0;
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1537
|
+
const initialBestIdx = bestIdx;
|
|
1538
|
+
if (!_relaxDrop) {
|
|
1539
|
+
while (bestIdx > 0) {
|
|
1508
1540
|
this.queue.shift()?.close();
|
|
1509
1541
|
this.framesDroppedLate++;
|
|
1510
1542
|
bestIdx--;
|
|
1511
1543
|
dropped++;
|
|
1512
|
-
} else {
|
|
1513
|
-
break;
|
|
1514
1544
|
}
|
|
1515
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
|
+
}
|
|
1516
1550
|
this.ticksPainted++;
|
|
1517
1551
|
if (isDebug()) {
|
|
1518
1552
|
const now = performance.now();
|
|
@@ -1548,6 +1582,33 @@ var VideoRenderer = class {
|
|
|
1548
1582
|
}
|
|
1549
1583
|
try {
|
|
1550
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;
|
|
1551
1612
|
this.framesPainted++;
|
|
1552
1613
|
} catch (err) {
|
|
1553
1614
|
if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
|
|
@@ -1560,6 +1621,7 @@ var VideoRenderer = class {
|
|
|
1560
1621
|
const count = this.queue.length;
|
|
1561
1622
|
while (this.queue.length > 0) this.queue.shift()?.close();
|
|
1562
1623
|
this.prerolled = false;
|
|
1624
|
+
this.hasLastPaintedPts = false;
|
|
1563
1625
|
this.ptsCalibrated = false;
|
|
1564
1626
|
this.hasEverEnqueuedSinceFlush = false;
|
|
1565
1627
|
if (isDebug() && count > 0) {
|
|
@@ -1600,6 +1662,9 @@ var VideoRenderer = class {
|
|
|
1600
1662
|
};
|
|
1601
1663
|
|
|
1602
1664
|
// src/strategies/fallback/audio-output.ts
|
|
1665
|
+
function isDebug2() {
|
|
1666
|
+
return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
|
|
1667
|
+
}
|
|
1603
1668
|
var AudioOutput = class {
|
|
1604
1669
|
ctx;
|
|
1605
1670
|
gain;
|
|
@@ -1621,6 +1686,16 @@ var AudioOutput = class {
|
|
|
1621
1686
|
mediaTimeOfNext = 0;
|
|
1622
1687
|
/** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
|
|
1623
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;
|
|
1624
1699
|
ctxTimeAtAnchor = 0;
|
|
1625
1700
|
pendingQueue = [];
|
|
1626
1701
|
framesScheduled = 0;
|
|
@@ -1693,10 +1768,16 @@ var AudioOutput = class {
|
|
|
1693
1768
|
return this.mediaTimeOfAnchor;
|
|
1694
1769
|
}
|
|
1695
1770
|
if (this.state === "playing") {
|
|
1771
|
+
if (this.firstAudibleCtxStart < 0) {
|
|
1772
|
+
return this.mediaTimeOfAnchor;
|
|
1773
|
+
}
|
|
1696
1774
|
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
|
|
1697
1775
|
}
|
|
1698
1776
|
return this.mediaTimeOfAnchor;
|
|
1699
1777
|
}
|
|
1778
|
+
anchorTime() {
|
|
1779
|
+
return this.mediaTimeOfAnchor;
|
|
1780
|
+
}
|
|
1700
1781
|
isPlaying() {
|
|
1701
1782
|
return this.state === "playing";
|
|
1702
1783
|
}
|
|
@@ -1723,18 +1804,81 @@ var AudioOutput = class {
|
|
|
1723
1804
|
* Schedule a chunk of decoded samples. Queues internally while idle (cold
|
|
1724
1805
|
* start or post-seek), schedules directly to the audio graph while playing.
|
|
1725
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.
|
|
1726
1817
|
*/
|
|
1727
|
-
schedule(samples, channels, sampleRate) {
|
|
1818
|
+
schedule(samples, channels, sampleRate, ptsSec) {
|
|
1728
1819
|
if (this.destroyed || this.noAudio) return;
|
|
1729
1820
|
const frameCount = samples.length / channels;
|
|
1730
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
|
+
}
|
|
1731
1826
|
if (this.state === "idle" || this.state === "paused") {
|
|
1732
|
-
this.pendingQueue.push({
|
|
1827
|
+
this.pendingQueue.push({
|
|
1828
|
+
samples,
|
|
1829
|
+
channels,
|
|
1830
|
+
sampleRate,
|
|
1831
|
+
frameCount,
|
|
1832
|
+
durationSec,
|
|
1833
|
+
ptsSec: hasPts ? ptsSec : null
|
|
1834
|
+
});
|
|
1733
1835
|
return;
|
|
1734
1836
|
}
|
|
1735
|
-
this.scheduleNow(
|
|
1837
|
+
this.scheduleNow(
|
|
1838
|
+
samples,
|
|
1839
|
+
channels,
|
|
1840
|
+
sampleRate,
|
|
1841
|
+
frameCount,
|
|
1842
|
+
hasPts ? ptsSec : null
|
|
1843
|
+
);
|
|
1736
1844
|
}
|
|
1737
|
-
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
|
+
}
|
|
1738
1882
|
const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
|
|
1739
1883
|
for (let ch = 0; ch < channels; ch++) {
|
|
1740
1884
|
const channelData = buffer.getChannelData(ch);
|
|
@@ -1746,14 +1890,7 @@ var AudioOutput = class {
|
|
|
1746
1890
|
node.buffer = buffer;
|
|
1747
1891
|
node.connect(this.gain);
|
|
1748
1892
|
if (this._rate !== 1) node.playbackRate.value = this._rate;
|
|
1749
|
-
let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
1750
|
-
if (ctxStart < this.ctx.currentTime) {
|
|
1751
|
-
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1752
|
-
this.mediaTimeOfAnchor = this.mediaTimeOfNext;
|
|
1753
|
-
ctxStart = this.ctx.currentTime;
|
|
1754
|
-
}
|
|
1755
1893
|
node.start(ctxStart);
|
|
1756
|
-
this.mediaTimeOfNext += frameCount / sampleRate;
|
|
1757
1894
|
this.framesScheduled++;
|
|
1758
1895
|
}
|
|
1759
1896
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
@@ -1778,12 +1915,15 @@ var AudioOutput = class {
|
|
|
1778
1915
|
} catch {
|
|
1779
1916
|
}
|
|
1780
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
|
+
}
|
|
1781
1921
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1782
1922
|
this.state = "playing";
|
|
1783
1923
|
const drain2 = this.pendingQueue;
|
|
1784
1924
|
this.pendingQueue = [];
|
|
1785
1925
|
for (const c of drain2) {
|
|
1786
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
1926
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
1787
1927
|
}
|
|
1788
1928
|
return;
|
|
1789
1929
|
}
|
|
@@ -1791,10 +1931,13 @@ var AudioOutput = class {
|
|
|
1791
1931
|
this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
|
|
1792
1932
|
this.mediaTimeOfNext = this.mediaTimeOfAnchor;
|
|
1793
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
|
+
}
|
|
1794
1937
|
const drain = this.pendingQueue;
|
|
1795
1938
|
this.pendingQueue = [];
|
|
1796
1939
|
for (const c of drain) {
|
|
1797
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
1940
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
1798
1941
|
}
|
|
1799
1942
|
}
|
|
1800
1943
|
/** Pause playback. Suspends the audio context. */
|
|
@@ -1820,6 +1963,9 @@ var AudioOutput = class {
|
|
|
1820
1963
|
* supplying new samples) and then call `start()` to resume playback.
|
|
1821
1964
|
*/
|
|
1822
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
|
+
}
|
|
1823
1969
|
if (this.noAudio) {
|
|
1824
1970
|
this.pendingQueue = [];
|
|
1825
1971
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
@@ -1838,6 +1984,7 @@ var AudioOutput = class {
|
|
|
1838
1984
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
1839
1985
|
this.mediaTimeOfNext = newMediaTime;
|
|
1840
1986
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1987
|
+
this.firstAudibleCtxStart = -1;
|
|
1841
1988
|
this.state = "idle";
|
|
1842
1989
|
if (this.ctx.state === "running") {
|
|
1843
1990
|
await this.ctx.suspend();
|
|
@@ -2017,28 +2164,6 @@ function asUint8(x) {
|
|
|
2017
2164
|
const ta = x;
|
|
2018
2165
|
return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
|
|
2019
2166
|
}
|
|
2020
|
-
function sanitizeFrameTimestamp(frame, nextUs, fallbackTimeBase) {
|
|
2021
|
-
const lo = frame.pts ?? 0;
|
|
2022
|
-
const hi = frame.ptshi ?? 0;
|
|
2023
|
-
const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
|
|
2024
|
-
if (isInvalid) {
|
|
2025
|
-
const us2 = nextUs();
|
|
2026
|
-
frame.pts = us2;
|
|
2027
|
-
frame.ptshi = 0;
|
|
2028
|
-
return;
|
|
2029
|
-
}
|
|
2030
|
-
const tb = fallbackTimeBase ?? [1, 1e6];
|
|
2031
|
-
const pts64 = hi * 4294967296 + lo;
|
|
2032
|
-
const us = Math.round(pts64 * 1e6 * tb[0] / tb[1]);
|
|
2033
|
-
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
2034
|
-
frame.pts = us;
|
|
2035
|
-
frame.ptshi = us < 0 ? -1 : 0;
|
|
2036
|
-
return;
|
|
2037
|
-
}
|
|
2038
|
-
const fallback = nextUs();
|
|
2039
|
-
frame.pts = fallback;
|
|
2040
|
-
frame.ptshi = 0;
|
|
2041
|
-
}
|
|
2042
2167
|
|
|
2043
2168
|
// src/strategies/hybrid/decoder.ts
|
|
2044
2169
|
async function startHybridDecoder(opts) {
|
|
@@ -2186,7 +2311,6 @@ async function startHybridDecoder(opts) {
|
|
|
2186
2311
|
let videoChunksFed = 0;
|
|
2187
2312
|
let bufferedUntilSec = 0;
|
|
2188
2313
|
let syntheticVideoUs = 0;
|
|
2189
|
-
let syntheticAudioUs = 0;
|
|
2190
2314
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
2191
2315
|
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
2192
2316
|
const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
|
|
@@ -2218,7 +2342,13 @@ async function startHybridDecoder(opts) {
|
|
|
2218
2342
|
}
|
|
2219
2343
|
}
|
|
2220
2344
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2221
|
-
await decodeAudioBatch(
|
|
2345
|
+
await decodeAudioBatch(
|
|
2346
|
+
audioPackets,
|
|
2347
|
+
myToken,
|
|
2348
|
+
/*flush*/
|
|
2349
|
+
false,
|
|
2350
|
+
audioTimeBase
|
|
2351
|
+
);
|
|
2222
2352
|
}
|
|
2223
2353
|
if (myToken !== pumpToken || destroyed) return;
|
|
2224
2354
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -2265,8 +2395,11 @@ async function startHybridDecoder(opts) {
|
|
|
2265
2395
|
}
|
|
2266
2396
|
}
|
|
2267
2397
|
}
|
|
2268
|
-
async function decodeAudioBatch(pkts, myToken, flush = false) {
|
|
2398
|
+
async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
|
|
2269
2399
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
2400
|
+
const pktPtsSec = pkts.map(
|
|
2401
|
+
(p) => tb ? packetPtsSec(p, tb) : null
|
|
2402
|
+
);
|
|
2270
2403
|
const AUDIO_SUB_BATCH = 4;
|
|
2271
2404
|
let allFrames = [];
|
|
2272
2405
|
for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
|
|
@@ -2304,22 +2437,13 @@ async function startHybridDecoder(opts) {
|
|
|
2304
2437
|
}
|
|
2305
2438
|
if (myToken !== pumpToken || destroyed) return;
|
|
2306
2439
|
const frames = allFrames;
|
|
2307
|
-
for (
|
|
2440
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2308
2441
|
if (myToken !== pumpToken || destroyed) return;
|
|
2309
|
-
|
|
2310
|
-
f,
|
|
2311
|
-
() => {
|
|
2312
|
-
const ts = syntheticAudioUs;
|
|
2313
|
-
const samples2 = f.nb_samples ?? 1024;
|
|
2314
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
2315
|
-
syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
|
|
2316
|
-
return ts;
|
|
2317
|
-
},
|
|
2318
|
-
audioTimeBase
|
|
2319
|
-
);
|
|
2442
|
+
const f = frames[i];
|
|
2320
2443
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
2321
2444
|
if (samples) {
|
|
2322
|
-
|
|
2445
|
+
const pts = pktPtsSec[i] ?? null;
|
|
2446
|
+
opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
|
|
2323
2447
|
audioFramesDecoded++;
|
|
2324
2448
|
}
|
|
2325
2449
|
}
|
|
@@ -2429,7 +2553,6 @@ async function startHybridDecoder(opts) {
|
|
|
2429
2553
|
}
|
|
2430
2554
|
await flushBSF();
|
|
2431
2555
|
syntheticVideoUs = Math.round(timeSec * 1e6);
|
|
2432
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
2433
2556
|
pumpRunning = pumpLoop(newToken).catch(
|
|
2434
2557
|
(err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
|
|
2435
2558
|
);
|
|
@@ -2468,7 +2591,6 @@ async function startHybridDecoder(opts) {
|
|
|
2468
2591
|
}
|
|
2469
2592
|
await flushBSF();
|
|
2470
2593
|
syntheticVideoUs = Math.round(timeSec * 1e6);
|
|
2471
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
2472
2594
|
pumpRunning = pumpLoop(newToken).catch(
|
|
2473
2595
|
(err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
|
|
2474
2596
|
);
|
|
@@ -2721,6 +2843,9 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2721
2843
|
}
|
|
2722
2844
|
|
|
2723
2845
|
// src/strategies/fallback/decoder.ts
|
|
2846
|
+
function isDebug3() {
|
|
2847
|
+
return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
|
|
2848
|
+
}
|
|
2724
2849
|
async function startDecoder(opts) {
|
|
2725
2850
|
const variant = "avbridge";
|
|
2726
2851
|
const libav = await loadLibav(variant);
|
|
@@ -2852,8 +2977,15 @@ async function startDecoder(opts) {
|
|
|
2852
2977
|
let watchdogSlowSinceMs = 0;
|
|
2853
2978
|
let watchdogSlowWarned = false;
|
|
2854
2979
|
let watchdogOverflowWarned = false;
|
|
2855
|
-
let
|
|
2856
|
-
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;
|
|
2857
2989
|
let videoDecodeMsTotal = 0;
|
|
2858
2990
|
let audioDecodeMsTotal = 0;
|
|
2859
2991
|
let videoDecodeBatches = 0;
|
|
@@ -2892,6 +3024,18 @@ async function startDecoder(opts) {
|
|
|
2892
3024
|
for (const pkt of videoPackets) {
|
|
2893
3025
|
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
2894
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
|
+
}
|
|
2895
3039
|
}
|
|
2896
3040
|
}
|
|
2897
3041
|
if (audioPackets && audioTimeBase) {
|
|
@@ -2899,9 +3043,25 @@ async function startDecoder(opts) {
|
|
|
2899
3043
|
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
2900
3044
|
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2901
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
|
+
}
|
|
2902
3056
|
}
|
|
2903
3057
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2904
|
-
await decodeAudioBatch(
|
|
3058
|
+
await decodeAudioBatch(
|
|
3059
|
+
audioPackets,
|
|
3060
|
+
myToken,
|
|
3061
|
+
/*flush*/
|
|
3062
|
+
false,
|
|
3063
|
+
audioTimeBase
|
|
3064
|
+
);
|
|
2905
3065
|
}
|
|
2906
3066
|
if (myToken !== pumpToken || destroyed) return;
|
|
2907
3067
|
if (videoDec && videoPackets && videoPackets.length > 0) {
|
|
@@ -2945,7 +3105,7 @@ async function startDecoder(opts) {
|
|
|
2945
3105
|
{
|
|
2946
3106
|
const _throttleStart = performance.now();
|
|
2947
3107
|
let _throttled = false;
|
|
2948
|
-
while (!destroyed && myToken === pumpToken &&
|
|
3108
|
+
while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
|
|
2949
3109
|
_throttled = true;
|
|
2950
3110
|
await new Promise((r) => setTimeout(r, 50));
|
|
2951
3111
|
}
|
|
@@ -3000,18 +3160,83 @@ async function startDecoder(opts) {
|
|
|
3000
3160
|
if (myToken !== pumpToken || destroyed) return;
|
|
3001
3161
|
for (const f of frames) {
|
|
3002
3162
|
if (myToken !== pumpToken || destroyed) return;
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
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;
|
|
3013
3237
|
if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
|
|
3014
3238
|
if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
|
|
3239
|
+
_diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
|
|
3015
3240
|
ptsRegressions++;
|
|
3016
3241
|
const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
|
|
3017
3242
|
if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
|
|
@@ -3023,19 +3248,34 @@ async function startDecoder(opts) {
|
|
|
3023
3248
|
continue;
|
|
3024
3249
|
}
|
|
3025
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
|
+
}
|
|
3026
3256
|
try {
|
|
3027
3257
|
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
|
|
3028
|
-
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
|
+
}
|
|
3029
3265
|
videoFramesDecoded++;
|
|
3030
3266
|
} catch (err) {
|
|
3031
3267
|
if (videoFramesDecoded === 0) {
|
|
3032
3268
|
console.warn("[avbridge] laFrameToVideoFrame failed:", err);
|
|
3033
3269
|
}
|
|
3270
|
+
_diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
|
|
3034
3271
|
}
|
|
3035
3272
|
}
|
|
3036
3273
|
}
|
|
3037
|
-
async function decodeAudioBatch(pkts, myToken, flush = false) {
|
|
3274
|
+
async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
|
|
3038
3275
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
3276
|
+
const pktPtsSec = pkts.map(
|
|
3277
|
+
(p) => tb ? packetPtsSec(p, tb) : null
|
|
3278
|
+
);
|
|
3039
3279
|
let frames;
|
|
3040
3280
|
const _t0 = performance.now();
|
|
3041
3281
|
try {
|
|
@@ -3053,22 +3293,17 @@ async function startDecoder(opts) {
|
|
|
3053
3293
|
audioDecodeMsTotal += performance.now() - _t0;
|
|
3054
3294
|
audioDecodeBatches++;
|
|
3055
3295
|
if (myToken !== pumpToken || destroyed) return;
|
|
3056
|
-
for (
|
|
3296
|
+
for (let i = 0; i < frames.length; i++) {
|
|
3057
3297
|
if (myToken !== pumpToken || destroyed) return;
|
|
3058
|
-
|
|
3059
|
-
f,
|
|
3060
|
-
() => {
|
|
3061
|
-
const ts = syntheticAudioUs;
|
|
3062
|
-
const samples2 = f.nb_samples ?? 1024;
|
|
3063
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
3064
|
-
syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
|
|
3065
|
-
return ts;
|
|
3066
|
-
},
|
|
3067
|
-
audioTimeBase
|
|
3068
|
-
);
|
|
3298
|
+
const f = frames[i];
|
|
3069
3299
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
3070
3300
|
if (samples) {
|
|
3071
|
-
|
|
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);
|
|
3072
3307
|
audioFramesDecoded++;
|
|
3073
3308
|
}
|
|
3074
3309
|
}
|
|
@@ -3171,14 +3406,17 @@ async function startDecoder(opts) {
|
|
|
3171
3406
|
} catch {
|
|
3172
3407
|
}
|
|
3173
3408
|
await flushBSF();
|
|
3174
|
-
|
|
3175
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
3409
|
+
lastContentUs = -1;
|
|
3176
3410
|
lastEmittedPtsUs = -1;
|
|
3411
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
3177
3412
|
pumpRunning = pumpLoop(newToken).catch(
|
|
3178
3413
|
(err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
|
|
3179
3414
|
);
|
|
3180
3415
|
},
|
|
3181
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
|
+
}
|
|
3182
3420
|
const newToken = ++pumpToken;
|
|
3183
3421
|
if (pumpRunning) {
|
|
3184
3422
|
try {
|
|
@@ -3209,9 +3447,14 @@ async function startDecoder(opts) {
|
|
|
3209
3447
|
} catch {
|
|
3210
3448
|
}
|
|
3211
3449
|
await flushBSF();
|
|
3212
|
-
|
|
3213
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
3450
|
+
lastContentUs = -1;
|
|
3214
3451
|
lastEmittedPtsUs = -1;
|
|
3452
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
3453
|
+
seenFirstAudioPacketSinceSeek = false;
|
|
3454
|
+
seekTargetSec = timeSec;
|
|
3455
|
+
diagPktsLoggedSinceSeek = 0;
|
|
3456
|
+
diagFramesLoggedSinceSeek = 0;
|
|
3457
|
+
diagFrameKeysDumped = false;
|
|
3215
3458
|
pumpRunning = pumpLoop(newToken).catch(
|
|
3216
3459
|
(err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
|
|
3217
3460
|
);
|
|
@@ -5582,8 +5825,42 @@ var PROXY_ATTRIBUTES = [
|
|
|
5582
5825
|
];
|
|
5583
5826
|
var PLAYER_ATTRIBUTES = ["show-fit"];
|
|
5584
5827
|
var FIT_MODES = ["contain", "cover", "fill"];
|
|
5585
|
-
var AvbridgePlayerElement = class
|
|
5828
|
+
var AvbridgePlayerElement = class extends HTMLElement {
|
|
5586
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
|
+
}
|
|
5587
5864
|
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
5588
5865
|
_video;
|
|
5589
5866
|
_playBtn;
|
|
@@ -5613,6 +5890,11 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5613
5890
|
_activeAudioTrackId = null;
|
|
5614
5891
|
_activeSubtitleTrackId = null;
|
|
5615
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;
|
|
5616
5898
|
_holdTimer = null;
|
|
5617
5899
|
_holdSpeedActive = false;
|
|
5618
5900
|
_savedPlaybackRate = 1;
|
|
@@ -5719,7 +6001,10 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5719
6001
|
);
|
|
5720
6002
|
});
|
|
5721
6003
|
}
|
|
5722
|
-
on(this._video, "loadstart", () =>
|
|
6004
|
+
on(this._video, "loadstart", () => {
|
|
6005
|
+
this._pendingSeekTarget = null;
|
|
6006
|
+
this._setState("loading");
|
|
6007
|
+
});
|
|
5723
6008
|
on(this._video, "ready", () => {
|
|
5724
6009
|
this._setState(this._video.paused ? "paused" : "playing");
|
|
5725
6010
|
this._seekInput.max = String(this._video.duration || 0);
|
|
@@ -5866,7 +6151,9 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5866
6151
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`;
|
|
5867
6152
|
}
|
|
5868
6153
|
_onSeekCommit() {
|
|
5869
|
-
|
|
6154
|
+
const target = Number(this._seekInput.value);
|
|
6155
|
+
this._pendingSeekTarget = target;
|
|
6156
|
+
this._video.currentTime = target;
|
|
5870
6157
|
this._userSeeking = false;
|
|
5871
6158
|
}
|
|
5872
6159
|
/** Linear click-to-time mapping across the full track width (no edge clamping). */
|
|
@@ -5876,40 +6163,57 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5876
6163
|
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
5877
6164
|
return frac * (this._video.duration || 0);
|
|
5878
6165
|
}
|
|
5879
|
-
/** Seekbar width below which drag-to-scrub seeks in real-time (vs
|
|
5880
|
-
* preview-only). On narrow bars precise positioning is hard, so
|
|
5881
|
-
* immediate video feedback is more useful than a time tooltip. */
|
|
5882
|
-
static SCRUB_WIDTH_THRESHOLD = 400;
|
|
5883
6166
|
_onSeekPointerDown(e) {
|
|
5884
6167
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
5885
6168
|
e.preventDefault();
|
|
6169
|
+
e.stopPropagation();
|
|
5886
6170
|
this._userSeeking = true;
|
|
5887
6171
|
const seekBar = this.shadowRoot.querySelector(".avp-seek");
|
|
5888
6172
|
seekBar.setPointerCapture(e.pointerId);
|
|
5889
6173
|
seekBar.setAttribute("data-seeking", "");
|
|
5890
|
-
const
|
|
5891
|
-
|
|
5892
|
-
const
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
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
|
+
}
|
|
5897
6201
|
const onMove = (ev) => {
|
|
5898
|
-
|
|
6202
|
+
ev.stopPropagation();
|
|
6203
|
+
const t = timeAt(ev.clientX);
|
|
5899
6204
|
this._seekInput.value = String(t);
|
|
5900
6205
|
this._onSeekInput();
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
this._userSeeking = true;
|
|
5908
|
-
}
|
|
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;
|
|
5909
6212
|
}
|
|
5910
6213
|
};
|
|
5911
6214
|
const onUp = (ev) => {
|
|
5912
|
-
|
|
6215
|
+
ev.stopPropagation();
|
|
6216
|
+
const t = timeAt(ev.clientX);
|
|
5913
6217
|
this._seekInput.value = String(t);
|
|
5914
6218
|
this._onSeekCommit();
|
|
5915
6219
|
this._seekInput.focus();
|
|
@@ -5936,6 +6240,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5936
6240
|
this._seekTooltip.textContent = formatTime(t);
|
|
5937
6241
|
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
5938
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
|
+
}
|
|
5939
6252
|
_updateSeekVisuals(t) {
|
|
5940
6253
|
const dur = this._video.duration || 0;
|
|
5941
6254
|
const pct = dur > 0 ? t / dur * 100 : 0;
|
|
@@ -5947,6 +6260,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5947
6260
|
if (this._userSeeking) return;
|
|
5948
6261
|
const t = this._video.currentTime;
|
|
5949
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
|
+
}
|
|
5950
6272
|
this._seekInput.value = String(t);
|
|
5951
6273
|
this._updateSeekVisuals(t);
|
|
5952
6274
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
|