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.cjs
CHANGED
|
@@ -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. */
|
|
@@ -1354,13 +1371,17 @@ var VideoRenderer = class {
|
|
|
1354
1371
|
return this.queue.length;
|
|
1355
1372
|
}
|
|
1356
1373
|
/**
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
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.
|
|
1362
1383
|
*/
|
|
1363
|
-
queueHighWater =
|
|
1384
|
+
queueHighWater = 256;
|
|
1364
1385
|
enqueue(frame) {
|
|
1365
1386
|
if (this.destroyed) {
|
|
1366
1387
|
frame.close();
|
|
@@ -1371,7 +1392,7 @@ var VideoRenderer = class {
|
|
|
1371
1392
|
if (this.queue.length === 1 && this.framesPainted === 0) {
|
|
1372
1393
|
this.resolveFirstFrame();
|
|
1373
1394
|
}
|
|
1374
|
-
while (this.queue.length >
|
|
1395
|
+
while (this.queue.length > this.queueHighWater + 8) {
|
|
1375
1396
|
this.queue.shift()?.close();
|
|
1376
1397
|
this.framesDroppedOverflow++;
|
|
1377
1398
|
}
|
|
@@ -1453,12 +1474,9 @@ var VideoRenderer = class {
|
|
|
1453
1474
|
if (this.queue.length === 0) return;
|
|
1454
1475
|
const playing = this.clock.isPlaying();
|
|
1455
1476
|
if (!playing) {
|
|
1456
|
-
if (!this.prerolled) {
|
|
1457
|
-
const head = this.queue.shift();
|
|
1458
|
-
this.paint(head);
|
|
1459
|
-
head.close();
|
|
1477
|
+
if (!this.prerolled && this.queue.length > 0) {
|
|
1460
1478
|
this.prerolled = true;
|
|
1461
|
-
this.
|
|
1479
|
+
this.paint(this.queue[0]);
|
|
1462
1480
|
}
|
|
1463
1481
|
return;
|
|
1464
1482
|
}
|
|
@@ -1467,14 +1485,29 @@ var VideoRenderer = class {
|
|
|
1467
1485
|
const hasPts = headTs > 0 || this.queue.length > 1;
|
|
1468
1486
|
if (hasPts) {
|
|
1469
1487
|
const wallNow2 = performance.now();
|
|
1470
|
-
if (!this.ptsCalibrated
|
|
1471
|
-
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;
|
|
1472
1492
|
this.ptsCalibrated = true;
|
|
1473
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
|
+
}
|
|
1474
1508
|
}
|
|
1475
1509
|
const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
|
|
1476
|
-
const
|
|
1477
|
-
const deadlineUs = audioNowUs + frameDurationUs;
|
|
1510
|
+
const deadlineUs = audioNowUs;
|
|
1478
1511
|
let bestIdx = -1;
|
|
1479
1512
|
for (let i = 0; i < this.queue.length; i++) {
|
|
1480
1513
|
const ts = this.queue[i].timestamp ?? 0;
|
|
@@ -1502,19 +1535,20 @@ var VideoRenderer = class {
|
|
|
1502
1535
|
return;
|
|
1503
1536
|
}
|
|
1504
1537
|
const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
|
|
1505
|
-
const dropThresholdUs = _relaxDrop ? audioNowUs - 60 * 1e6 : audioNowUs - frameDurationUs * 2;
|
|
1506
1538
|
let dropped = 0;
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1539
|
+
const initialBestIdx = bestIdx;
|
|
1540
|
+
if (!_relaxDrop) {
|
|
1541
|
+
while (bestIdx > 0) {
|
|
1510
1542
|
this.queue.shift()?.close();
|
|
1511
1543
|
this.framesDroppedLate++;
|
|
1512
1544
|
bestIdx--;
|
|
1513
1545
|
dropped++;
|
|
1514
|
-
} else {
|
|
1515
|
-
break;
|
|
1516
1546
|
}
|
|
1517
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
|
+
}
|
|
1518
1552
|
this.ticksPainted++;
|
|
1519
1553
|
if (isDebug()) {
|
|
1520
1554
|
const now = performance.now();
|
|
@@ -1550,6 +1584,33 @@ var VideoRenderer = class {
|
|
|
1550
1584
|
}
|
|
1551
1585
|
try {
|
|
1552
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;
|
|
1553
1614
|
this.framesPainted++;
|
|
1554
1615
|
} catch (err) {
|
|
1555
1616
|
if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
|
|
@@ -1562,6 +1623,7 @@ var VideoRenderer = class {
|
|
|
1562
1623
|
const count = this.queue.length;
|
|
1563
1624
|
while (this.queue.length > 0) this.queue.shift()?.close();
|
|
1564
1625
|
this.prerolled = false;
|
|
1626
|
+
this.hasLastPaintedPts = false;
|
|
1565
1627
|
this.ptsCalibrated = false;
|
|
1566
1628
|
this.hasEverEnqueuedSinceFlush = false;
|
|
1567
1629
|
if (isDebug() && count > 0) {
|
|
@@ -1602,6 +1664,9 @@ var VideoRenderer = class {
|
|
|
1602
1664
|
};
|
|
1603
1665
|
|
|
1604
1666
|
// src/strategies/fallback/audio-output.ts
|
|
1667
|
+
function isDebug2() {
|
|
1668
|
+
return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
|
|
1669
|
+
}
|
|
1605
1670
|
var AudioOutput = class {
|
|
1606
1671
|
ctx;
|
|
1607
1672
|
gain;
|
|
@@ -1623,6 +1688,16 @@ var AudioOutput = class {
|
|
|
1623
1688
|
mediaTimeOfNext = 0;
|
|
1624
1689
|
/** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
|
|
1625
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;
|
|
1626
1701
|
ctxTimeAtAnchor = 0;
|
|
1627
1702
|
pendingQueue = [];
|
|
1628
1703
|
framesScheduled = 0;
|
|
@@ -1695,10 +1770,16 @@ var AudioOutput = class {
|
|
|
1695
1770
|
return this.mediaTimeOfAnchor;
|
|
1696
1771
|
}
|
|
1697
1772
|
if (this.state === "playing") {
|
|
1773
|
+
if (this.firstAudibleCtxStart < 0) {
|
|
1774
|
+
return this.mediaTimeOfAnchor;
|
|
1775
|
+
}
|
|
1698
1776
|
return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
|
|
1699
1777
|
}
|
|
1700
1778
|
return this.mediaTimeOfAnchor;
|
|
1701
1779
|
}
|
|
1780
|
+
anchorTime() {
|
|
1781
|
+
return this.mediaTimeOfAnchor;
|
|
1782
|
+
}
|
|
1702
1783
|
isPlaying() {
|
|
1703
1784
|
return this.state === "playing";
|
|
1704
1785
|
}
|
|
@@ -1725,18 +1806,81 @@ var AudioOutput = class {
|
|
|
1725
1806
|
* Schedule a chunk of decoded samples. Queues internally while idle (cold
|
|
1726
1807
|
* start or post-seek), schedules directly to the audio graph while playing.
|
|
1727
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.
|
|
1728
1819
|
*/
|
|
1729
|
-
schedule(samples, channels, sampleRate) {
|
|
1820
|
+
schedule(samples, channels, sampleRate, ptsSec) {
|
|
1730
1821
|
if (this.destroyed || this.noAudio) return;
|
|
1731
1822
|
const frameCount = samples.length / channels;
|
|
1732
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
|
+
}
|
|
1733
1828
|
if (this.state === "idle" || this.state === "paused") {
|
|
1734
|
-
this.pendingQueue.push({
|
|
1829
|
+
this.pendingQueue.push({
|
|
1830
|
+
samples,
|
|
1831
|
+
channels,
|
|
1832
|
+
sampleRate,
|
|
1833
|
+
frameCount,
|
|
1834
|
+
durationSec,
|
|
1835
|
+
ptsSec: hasPts ? ptsSec : null
|
|
1836
|
+
});
|
|
1735
1837
|
return;
|
|
1736
1838
|
}
|
|
1737
|
-
this.scheduleNow(
|
|
1839
|
+
this.scheduleNow(
|
|
1840
|
+
samples,
|
|
1841
|
+
channels,
|
|
1842
|
+
sampleRate,
|
|
1843
|
+
frameCount,
|
|
1844
|
+
hasPts ? ptsSec : null
|
|
1845
|
+
);
|
|
1738
1846
|
}
|
|
1739
|
-
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
|
+
}
|
|
1740
1884
|
const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
|
|
1741
1885
|
for (let ch = 0; ch < channels; ch++) {
|
|
1742
1886
|
const channelData = buffer.getChannelData(ch);
|
|
@@ -1748,14 +1892,7 @@ var AudioOutput = class {
|
|
|
1748
1892
|
node.buffer = buffer;
|
|
1749
1893
|
node.connect(this.gain);
|
|
1750
1894
|
if (this._rate !== 1) node.playbackRate.value = this._rate;
|
|
1751
|
-
let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
|
|
1752
|
-
if (ctxStart < this.ctx.currentTime) {
|
|
1753
|
-
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1754
|
-
this.mediaTimeOfAnchor = this.mediaTimeOfNext;
|
|
1755
|
-
ctxStart = this.ctx.currentTime;
|
|
1756
|
-
}
|
|
1757
1895
|
node.start(ctxStart);
|
|
1758
|
-
this.mediaTimeOfNext += frameCount / sampleRate;
|
|
1759
1896
|
this.framesScheduled++;
|
|
1760
1897
|
}
|
|
1761
1898
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
@@ -1780,12 +1917,15 @@ var AudioOutput = class {
|
|
|
1780
1917
|
} catch {
|
|
1781
1918
|
}
|
|
1782
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
|
+
}
|
|
1783
1923
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1784
1924
|
this.state = "playing";
|
|
1785
1925
|
const drain2 = this.pendingQueue;
|
|
1786
1926
|
this.pendingQueue = [];
|
|
1787
1927
|
for (const c of drain2) {
|
|
1788
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
1928
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
1789
1929
|
}
|
|
1790
1930
|
return;
|
|
1791
1931
|
}
|
|
@@ -1793,10 +1933,13 @@ var AudioOutput = class {
|
|
|
1793
1933
|
this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
|
|
1794
1934
|
this.mediaTimeOfNext = this.mediaTimeOfAnchor;
|
|
1795
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
|
+
}
|
|
1796
1939
|
const drain = this.pendingQueue;
|
|
1797
1940
|
this.pendingQueue = [];
|
|
1798
1941
|
for (const c of drain) {
|
|
1799
|
-
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
|
|
1942
|
+
this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
|
|
1800
1943
|
}
|
|
1801
1944
|
}
|
|
1802
1945
|
/** Pause playback. Suspends the audio context. */
|
|
@@ -1822,6 +1965,9 @@ var AudioOutput = class {
|
|
|
1822
1965
|
* supplying new samples) and then call `start()` to resume playback.
|
|
1823
1966
|
*/
|
|
1824
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
|
+
}
|
|
1825
1971
|
if (this.noAudio) {
|
|
1826
1972
|
this.pendingQueue = [];
|
|
1827
1973
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
@@ -1840,6 +1986,7 @@ var AudioOutput = class {
|
|
|
1840
1986
|
this.mediaTimeOfAnchor = newMediaTime;
|
|
1841
1987
|
this.mediaTimeOfNext = newMediaTime;
|
|
1842
1988
|
this.ctxTimeAtAnchor = this.ctx.currentTime;
|
|
1989
|
+
this.firstAudibleCtxStart = -1;
|
|
1843
1990
|
this.state = "idle";
|
|
1844
1991
|
if (this.ctx.state === "running") {
|
|
1845
1992
|
await this.ctx.suspend();
|
|
@@ -2019,28 +2166,6 @@ function asUint8(x) {
|
|
|
2019
2166
|
const ta = x;
|
|
2020
2167
|
return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
|
|
2021
2168
|
}
|
|
2022
|
-
function sanitizeFrameTimestamp(frame, nextUs, fallbackTimeBase) {
|
|
2023
|
-
const lo = frame.pts ?? 0;
|
|
2024
|
-
const hi = frame.ptshi ?? 0;
|
|
2025
|
-
const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
|
|
2026
|
-
if (isInvalid) {
|
|
2027
|
-
const us2 = nextUs();
|
|
2028
|
-
frame.pts = us2;
|
|
2029
|
-
frame.ptshi = 0;
|
|
2030
|
-
return;
|
|
2031
|
-
}
|
|
2032
|
-
const tb = fallbackTimeBase ?? [1, 1e6];
|
|
2033
|
-
const pts64 = hi * 4294967296 + lo;
|
|
2034
|
-
const us = Math.round(pts64 * 1e6 * tb[0] / tb[1]);
|
|
2035
|
-
if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
|
|
2036
|
-
frame.pts = us;
|
|
2037
|
-
frame.ptshi = us < 0 ? -1 : 0;
|
|
2038
|
-
return;
|
|
2039
|
-
}
|
|
2040
|
-
const fallback = nextUs();
|
|
2041
|
-
frame.pts = fallback;
|
|
2042
|
-
frame.ptshi = 0;
|
|
2043
|
-
}
|
|
2044
2169
|
|
|
2045
2170
|
// src/strategies/hybrid/decoder.ts
|
|
2046
2171
|
async function startHybridDecoder(opts) {
|
|
@@ -2188,7 +2313,6 @@ async function startHybridDecoder(opts) {
|
|
|
2188
2313
|
let videoChunksFed = 0;
|
|
2189
2314
|
let bufferedUntilSec = 0;
|
|
2190
2315
|
let syntheticVideoUs = 0;
|
|
2191
|
-
let syntheticAudioUs = 0;
|
|
2192
2316
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
2193
2317
|
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
2194
2318
|
const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
|
|
@@ -2220,7 +2344,13 @@ async function startHybridDecoder(opts) {
|
|
|
2220
2344
|
}
|
|
2221
2345
|
}
|
|
2222
2346
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2223
|
-
await decodeAudioBatch(
|
|
2347
|
+
await decodeAudioBatch(
|
|
2348
|
+
audioPackets,
|
|
2349
|
+
myToken,
|
|
2350
|
+
/*flush*/
|
|
2351
|
+
false,
|
|
2352
|
+
audioTimeBase
|
|
2353
|
+
);
|
|
2224
2354
|
}
|
|
2225
2355
|
if (myToken !== pumpToken || destroyed) return;
|
|
2226
2356
|
await new Promise((r) => setTimeout(r, 0));
|
|
@@ -2267,8 +2397,11 @@ async function startHybridDecoder(opts) {
|
|
|
2267
2397
|
}
|
|
2268
2398
|
}
|
|
2269
2399
|
}
|
|
2270
|
-
async function decodeAudioBatch(pkts, myToken, flush = false) {
|
|
2400
|
+
async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
|
|
2271
2401
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
2402
|
+
const pktPtsSec = pkts.map(
|
|
2403
|
+
(p) => tb ? packetPtsSec(p, tb) : null
|
|
2404
|
+
);
|
|
2272
2405
|
const AUDIO_SUB_BATCH = 4;
|
|
2273
2406
|
let allFrames = [];
|
|
2274
2407
|
for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
|
|
@@ -2306,22 +2439,13 @@ async function startHybridDecoder(opts) {
|
|
|
2306
2439
|
}
|
|
2307
2440
|
if (myToken !== pumpToken || destroyed) return;
|
|
2308
2441
|
const frames = allFrames;
|
|
2309
|
-
for (
|
|
2442
|
+
for (let i = 0; i < frames.length; i++) {
|
|
2310
2443
|
if (myToken !== pumpToken || destroyed) return;
|
|
2311
|
-
|
|
2312
|
-
f,
|
|
2313
|
-
() => {
|
|
2314
|
-
const ts = syntheticAudioUs;
|
|
2315
|
-
const samples2 = f.nb_samples ?? 1024;
|
|
2316
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
2317
|
-
syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
|
|
2318
|
-
return ts;
|
|
2319
|
-
},
|
|
2320
|
-
audioTimeBase
|
|
2321
|
-
);
|
|
2444
|
+
const f = frames[i];
|
|
2322
2445
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
2323
2446
|
if (samples) {
|
|
2324
|
-
|
|
2447
|
+
const pts = pktPtsSec[i] ?? null;
|
|
2448
|
+
opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
|
|
2325
2449
|
audioFramesDecoded++;
|
|
2326
2450
|
}
|
|
2327
2451
|
}
|
|
@@ -2431,7 +2555,6 @@ async function startHybridDecoder(opts) {
|
|
|
2431
2555
|
}
|
|
2432
2556
|
await flushBSF();
|
|
2433
2557
|
syntheticVideoUs = Math.round(timeSec * 1e6);
|
|
2434
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
2435
2558
|
pumpRunning = pumpLoop(newToken).catch(
|
|
2436
2559
|
(err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
|
|
2437
2560
|
);
|
|
@@ -2470,7 +2593,6 @@ async function startHybridDecoder(opts) {
|
|
|
2470
2593
|
}
|
|
2471
2594
|
await flushBSF();
|
|
2472
2595
|
syntheticVideoUs = Math.round(timeSec * 1e6);
|
|
2473
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
2474
2596
|
pumpRunning = pumpLoop(newToken).catch(
|
|
2475
2597
|
(err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
|
|
2476
2598
|
);
|
|
@@ -2723,6 +2845,9 @@ async function createHybridSession(ctx, target, transport) {
|
|
|
2723
2845
|
}
|
|
2724
2846
|
|
|
2725
2847
|
// src/strategies/fallback/decoder.ts
|
|
2848
|
+
function isDebug3() {
|
|
2849
|
+
return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
|
|
2850
|
+
}
|
|
2726
2851
|
async function startDecoder(opts) {
|
|
2727
2852
|
const variant = "avbridge";
|
|
2728
2853
|
const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
|
|
@@ -2854,8 +2979,15 @@ async function startDecoder(opts) {
|
|
|
2854
2979
|
let watchdogSlowSinceMs = 0;
|
|
2855
2980
|
let watchdogSlowWarned = false;
|
|
2856
2981
|
let watchdogOverflowWarned = false;
|
|
2857
|
-
let
|
|
2858
|
-
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;
|
|
2859
2991
|
let videoDecodeMsTotal = 0;
|
|
2860
2992
|
let audioDecodeMsTotal = 0;
|
|
2861
2993
|
let videoDecodeBatches = 0;
|
|
@@ -2894,6 +3026,18 @@ async function startDecoder(opts) {
|
|
|
2894
3026
|
for (const pkt of videoPackets) {
|
|
2895
3027
|
const sec = packetPtsSec(pkt, videoTimeBase);
|
|
2896
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
|
+
}
|
|
2897
3041
|
}
|
|
2898
3042
|
}
|
|
2899
3043
|
if (audioPackets && audioTimeBase) {
|
|
@@ -2901,9 +3045,25 @@ async function startDecoder(opts) {
|
|
|
2901
3045
|
const sec = packetPtsSec(pkt, audioTimeBase);
|
|
2902
3046
|
if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
|
|
2903
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
|
+
}
|
|
2904
3058
|
}
|
|
2905
3059
|
if (audioDec && audioPackets && audioPackets.length > 0) {
|
|
2906
|
-
await decodeAudioBatch(
|
|
3060
|
+
await decodeAudioBatch(
|
|
3061
|
+
audioPackets,
|
|
3062
|
+
myToken,
|
|
3063
|
+
/*flush*/
|
|
3064
|
+
false,
|
|
3065
|
+
audioTimeBase
|
|
3066
|
+
);
|
|
2907
3067
|
}
|
|
2908
3068
|
if (myToken !== pumpToken || destroyed) return;
|
|
2909
3069
|
if (videoDec && videoPackets && videoPackets.length > 0) {
|
|
@@ -2947,7 +3107,7 @@ async function startDecoder(opts) {
|
|
|
2947
3107
|
{
|
|
2948
3108
|
const _throttleStart = performance.now();
|
|
2949
3109
|
let _throttled = false;
|
|
2950
|
-
while (!destroyed && myToken === pumpToken &&
|
|
3110
|
+
while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
|
|
2951
3111
|
_throttled = true;
|
|
2952
3112
|
await new Promise((r) => setTimeout(r, 50));
|
|
2953
3113
|
}
|
|
@@ -3002,18 +3162,83 @@ async function startDecoder(opts) {
|
|
|
3002
3162
|
if (myToken !== pumpToken || destroyed) return;
|
|
3003
3163
|
for (const f of frames) {
|
|
3004
3164
|
if (myToken !== pumpToken || destroyed) return;
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
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;
|
|
3015
3239
|
if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
|
|
3016
3240
|
if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
|
|
3241
|
+
_diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
|
|
3017
3242
|
ptsRegressions++;
|
|
3018
3243
|
const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
|
|
3019
3244
|
if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
|
|
@@ -3025,19 +3250,34 @@ async function startDecoder(opts) {
|
|
|
3025
3250
|
continue;
|
|
3026
3251
|
}
|
|
3027
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
|
+
}
|
|
3028
3258
|
try {
|
|
3029
3259
|
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
|
|
3030
|
-
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
|
+
}
|
|
3031
3267
|
videoFramesDecoded++;
|
|
3032
3268
|
} catch (err) {
|
|
3033
3269
|
if (videoFramesDecoded === 0) {
|
|
3034
3270
|
console.warn("[avbridge] laFrameToVideoFrame failed:", err);
|
|
3035
3271
|
}
|
|
3272
|
+
_diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
|
|
3036
3273
|
}
|
|
3037
3274
|
}
|
|
3038
3275
|
}
|
|
3039
|
-
async function decodeAudioBatch(pkts, myToken, flush = false) {
|
|
3276
|
+
async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
|
|
3040
3277
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
3278
|
+
const pktPtsSec = pkts.map(
|
|
3279
|
+
(p) => tb ? packetPtsSec(p, tb) : null
|
|
3280
|
+
);
|
|
3041
3281
|
let frames;
|
|
3042
3282
|
const _t0 = performance.now();
|
|
3043
3283
|
try {
|
|
@@ -3055,22 +3295,17 @@ async function startDecoder(opts) {
|
|
|
3055
3295
|
audioDecodeMsTotal += performance.now() - _t0;
|
|
3056
3296
|
audioDecodeBatches++;
|
|
3057
3297
|
if (myToken !== pumpToken || destroyed) return;
|
|
3058
|
-
for (
|
|
3298
|
+
for (let i = 0; i < frames.length; i++) {
|
|
3059
3299
|
if (myToken !== pumpToken || destroyed) return;
|
|
3060
|
-
|
|
3061
|
-
f,
|
|
3062
|
-
() => {
|
|
3063
|
-
const ts = syntheticAudioUs;
|
|
3064
|
-
const samples2 = f.nb_samples ?? 1024;
|
|
3065
|
-
const sampleRate = f.sample_rate ?? 44100;
|
|
3066
|
-
syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
|
|
3067
|
-
return ts;
|
|
3068
|
-
},
|
|
3069
|
-
audioTimeBase
|
|
3070
|
-
);
|
|
3300
|
+
const f = frames[i];
|
|
3071
3301
|
const samples = libavFrameToInterleavedFloat32(f);
|
|
3072
3302
|
if (samples) {
|
|
3073
|
-
|
|
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);
|
|
3074
3309
|
audioFramesDecoded++;
|
|
3075
3310
|
}
|
|
3076
3311
|
}
|
|
@@ -3173,14 +3408,17 @@ async function startDecoder(opts) {
|
|
|
3173
3408
|
} catch {
|
|
3174
3409
|
}
|
|
3175
3410
|
await flushBSF();
|
|
3176
|
-
|
|
3177
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
3411
|
+
lastContentUs = -1;
|
|
3178
3412
|
lastEmittedPtsUs = -1;
|
|
3413
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
3179
3414
|
pumpRunning = pumpLoop(newToken).catch(
|
|
3180
3415
|
(err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
|
|
3181
3416
|
);
|
|
3182
3417
|
},
|
|
3183
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
|
+
}
|
|
3184
3422
|
const newToken = ++pumpToken;
|
|
3185
3423
|
if (pumpRunning) {
|
|
3186
3424
|
try {
|
|
@@ -3211,9 +3449,14 @@ async function startDecoder(opts) {
|
|
|
3211
3449
|
} catch {
|
|
3212
3450
|
}
|
|
3213
3451
|
await flushBSF();
|
|
3214
|
-
|
|
3215
|
-
syntheticAudioUs = Math.round(timeSec * 1e6);
|
|
3452
|
+
lastContentUs = -1;
|
|
3216
3453
|
lastEmittedPtsUs = -1;
|
|
3454
|
+
firstValidPtsLoggedSinceSeek = false;
|
|
3455
|
+
seenFirstAudioPacketSinceSeek = false;
|
|
3456
|
+
seekTargetSec = timeSec;
|
|
3457
|
+
diagPktsLoggedSinceSeek = 0;
|
|
3458
|
+
diagFramesLoggedSinceSeek = 0;
|
|
3459
|
+
diagFrameKeysDumped = false;
|
|
3217
3460
|
pumpRunning = pumpLoop(newToken).catch(
|
|
3218
3461
|
(err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
|
|
3219
3462
|
);
|
|
@@ -5584,8 +5827,42 @@ var PROXY_ATTRIBUTES = [
|
|
|
5584
5827
|
];
|
|
5585
5828
|
var PLAYER_ATTRIBUTES = ["show-fit"];
|
|
5586
5829
|
var FIT_MODES = ["contain", "cover", "fill"];
|
|
5587
|
-
var AvbridgePlayerElement = class
|
|
5830
|
+
var AvbridgePlayerElement = class extends HTMLElement {
|
|
5588
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
|
+
}
|
|
5589
5866
|
// ── Internal DOM refs ──────────────────────────────────────────────────
|
|
5590
5867
|
_video;
|
|
5591
5868
|
_playBtn;
|
|
@@ -5615,6 +5892,11 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5615
5892
|
_activeAudioTrackId = null;
|
|
5616
5893
|
_activeSubtitleTrackId = null;
|
|
5617
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;
|
|
5618
5900
|
_holdTimer = null;
|
|
5619
5901
|
_holdSpeedActive = false;
|
|
5620
5902
|
_savedPlaybackRate = 1;
|
|
@@ -5721,7 +6003,10 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5721
6003
|
);
|
|
5722
6004
|
});
|
|
5723
6005
|
}
|
|
5724
|
-
on(this._video, "loadstart", () =>
|
|
6006
|
+
on(this._video, "loadstart", () => {
|
|
6007
|
+
this._pendingSeekTarget = null;
|
|
6008
|
+
this._setState("loading");
|
|
6009
|
+
});
|
|
5725
6010
|
on(this._video, "ready", () => {
|
|
5726
6011
|
this._setState(this._video.paused ? "paused" : "playing");
|
|
5727
6012
|
this._seekInput.max = String(this._video.duration || 0);
|
|
@@ -5868,7 +6153,9 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5868
6153
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`;
|
|
5869
6154
|
}
|
|
5870
6155
|
_onSeekCommit() {
|
|
5871
|
-
|
|
6156
|
+
const target = Number(this._seekInput.value);
|
|
6157
|
+
this._pendingSeekTarget = target;
|
|
6158
|
+
this._video.currentTime = target;
|
|
5872
6159
|
this._userSeeking = false;
|
|
5873
6160
|
}
|
|
5874
6161
|
/** Linear click-to-time mapping across the full track width (no edge clamping). */
|
|
@@ -5878,40 +6165,57 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5878
6165
|
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
5879
6166
|
return frac * (this._video.duration || 0);
|
|
5880
6167
|
}
|
|
5881
|
-
/** Seekbar width below which drag-to-scrub seeks in real-time (vs
|
|
5882
|
-
* preview-only). On narrow bars precise positioning is hard, so
|
|
5883
|
-
* immediate video feedback is more useful than a time tooltip. */
|
|
5884
|
-
static SCRUB_WIDTH_THRESHOLD = 400;
|
|
5885
6168
|
_onSeekPointerDown(e) {
|
|
5886
6169
|
if (e.button !== 0 && e.pointerType === "mouse") return;
|
|
5887
6170
|
e.preventDefault();
|
|
6171
|
+
e.stopPropagation();
|
|
5888
6172
|
this._userSeeking = true;
|
|
5889
6173
|
const seekBar = this.shadowRoot.querySelector(".avp-seek");
|
|
5890
6174
|
seekBar.setPointerCapture(e.pointerId);
|
|
5891
6175
|
seekBar.setAttribute("data-seeking", "");
|
|
5892
|
-
const
|
|
5893
|
-
|
|
5894
|
-
const
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
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
|
+
}
|
|
5899
6203
|
const onMove = (ev) => {
|
|
5900
|
-
|
|
6204
|
+
ev.stopPropagation();
|
|
6205
|
+
const t = timeAt(ev.clientX);
|
|
5901
6206
|
this._seekInput.value = String(t);
|
|
5902
6207
|
this._onSeekInput();
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
this._userSeeking = true;
|
|
5910
|
-
}
|
|
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;
|
|
5911
6214
|
}
|
|
5912
6215
|
};
|
|
5913
6216
|
const onUp = (ev) => {
|
|
5914
|
-
|
|
6217
|
+
ev.stopPropagation();
|
|
6218
|
+
const t = timeAt(ev.clientX);
|
|
5915
6219
|
this._seekInput.value = String(t);
|
|
5916
6220
|
this._onSeekCommit();
|
|
5917
6221
|
this._seekInput.focus();
|
|
@@ -5938,6 +6242,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5938
6242
|
this._seekTooltip.textContent = formatTime(t);
|
|
5939
6243
|
this._seekTooltip.style.left = `${frac * 100}%`;
|
|
5940
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
|
+
}
|
|
5941
6254
|
_updateSeekVisuals(t) {
|
|
5942
6255
|
const dur = this._video.duration || 0;
|
|
5943
6256
|
const pct = dur > 0 ? t / dur * 100 : 0;
|
|
@@ -5949,6 +6262,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
|
|
|
5949
6262
|
if (this._userSeeking) return;
|
|
5950
6263
|
const t = this._video.currentTime;
|
|
5951
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
|
+
}
|
|
5952
6274
|
this._seekInput.value = String(t);
|
|
5953
6275
|
this._updateSeekVisuals(t);
|
|
5954
6276
|
this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
|