avbridge 2.12.0 → 2.12.1
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 +76 -0
- package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
- package/dist/avi-32UABODO.cjs.map +1 -0
- package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
- package/dist/avi-5BPR6QUX.cjs.map +1 -0
- package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
- package/dist/avi-BLIH7KKV.js.map +1 -0
- package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
- package/dist/avi-GX2H34IQ.js.map +1 -0
- package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
- package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
- package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
- package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
- package/dist/{chunk-Z26PXRUY.js → chunk-BN7BRTLY.js} +137 -26
- package/dist/chunk-BN7BRTLY.js.map +1 -0
- package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
- package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
- package/dist/{chunk-7EF4VTUS.cjs → chunk-UM6WCSGL.cjs} +141 -30
- package/dist/chunk-UM6WCSGL.cjs.map +1 -0
- package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
- package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
- package/dist/element-browser.js +144 -25
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +3 -3
- package/dist/element.js +2 -2
- package/dist/index.cjs +18 -18
- package/dist/index.js +6 -6
- package/dist/player.cjs +207 -41
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +1 -0
- package/dist/player.d.ts +1 -0
- package/dist/player.js +207 -41
- package/dist/player.js.map +1 -1
- package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
- package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
- package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
- package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +87 -15
- package/src/probe/avi.ts +34 -2
- package/src/strategies/fallback/decoder.ts +148 -19
- package/src/strategies/fallback/video-renderer.ts +41 -3
- package/src/strategies/hybrid/decoder.ts +34 -9
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
- package/dist/avi-EQE6AR75.cjs.map +0 -1
- package/dist/avi-NNHH4AAA.js.map +0 -1
- package/dist/avi-S7EY54YA.js.map +0 -1
- package/dist/avi-Y3N325WZ.cjs.map +0 -1
- package/dist/chunk-7EF4VTUS.cjs.map +0 -1
- package/dist/chunk-Z26PXRUY.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
require('./chunk-
|
|
3
|
+
var chunkE5MAM2P4_cjs = require('./chunk-E5MAM2P4.cjs');
|
|
4
|
+
require('./chunk-VLI3Y6IJ.cjs');
|
|
5
5
|
require('./chunk-2IJ66NTD.cjs');
|
|
6
6
|
require('./chunk-QDJLQR53.cjs');
|
|
7
7
|
require('./chunk-HZUVMXBN.cjs');
|
|
@@ -13,23 +13,23 @@ require('./chunk-F3LQJKXK.cjs');
|
|
|
13
13
|
|
|
14
14
|
Object.defineProperty(exports, "createOutputFormat", {
|
|
15
15
|
enumerable: true,
|
|
16
|
-
get: function () { return
|
|
16
|
+
get: function () { return chunkE5MAM2P4_cjs.createOutputFormat; }
|
|
17
17
|
});
|
|
18
18
|
Object.defineProperty(exports, "generateFilename", {
|
|
19
19
|
enumerable: true,
|
|
20
|
-
get: function () { return
|
|
20
|
+
get: function () { return chunkE5MAM2P4_cjs.generateFilename; }
|
|
21
21
|
});
|
|
22
22
|
Object.defineProperty(exports, "mimeForFormat", {
|
|
23
23
|
enumerable: true,
|
|
24
|
-
get: function () { return
|
|
24
|
+
get: function () { return chunkE5MAM2P4_cjs.mimeForFormat; }
|
|
25
25
|
});
|
|
26
26
|
Object.defineProperty(exports, "remux", {
|
|
27
27
|
enumerable: true,
|
|
28
|
-
get: function () { return
|
|
28
|
+
get: function () { return chunkE5MAM2P4_cjs.remux; }
|
|
29
29
|
});
|
|
30
30
|
Object.defineProperty(exports, "validateRemuxEligibility", {
|
|
31
31
|
enumerable: true,
|
|
32
|
-
get: function () { return
|
|
32
|
+
get: function () { return chunkE5MAM2P4_cjs.validateRemuxEligibility; }
|
|
33
33
|
});
|
|
34
|
-
//# sourceMappingURL=remux-
|
|
35
|
-
//# sourceMappingURL=remux-
|
|
34
|
+
//# sourceMappingURL=remux-NSBJFMLG.cjs.map
|
|
35
|
+
//# sourceMappingURL=remux-NSBJFMLG.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-NSBJFMLG.cjs"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-
|
|
2
|
-
import './chunk-
|
|
1
|
+
export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-B76QWPFM.js';
|
|
2
|
+
import './chunk-5CX7BVVV.js';
|
|
3
3
|
import './chunk-CPJLFFCC.js';
|
|
4
4
|
import './chunk-LUFA47FP.js';
|
|
5
5
|
import './chunk-3YKWU4FM.js';
|
|
6
6
|
import './chunk-3AI5WFFN.js';
|
|
7
7
|
import './chunk-5DMTJVIU.js';
|
|
8
8
|
import './chunk-5YAWWKA3.js';
|
|
9
|
-
//# sourceMappingURL=remux-
|
|
10
|
-
//# sourceMappingURL=remux-
|
|
9
|
+
//# sourceMappingURL=remux-PHUHO3VV.js.map
|
|
10
|
+
//# sourceMappingURL=remux-PHUHO3VV.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-PHUHO3VV.js"}
|
package/package.json
CHANGED
|
@@ -752,10 +752,13 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
752
752
|
|
|
753
753
|
// ── Stats for nerds ────────────────────────────────────────────────────
|
|
754
754
|
|
|
755
|
+
private _statsPrev: { ts: number; rt: Record<string, unknown> } | null = null;
|
|
756
|
+
|
|
755
757
|
private _toggleStats(): void {
|
|
756
758
|
this._statsOpen = !this._statsOpen;
|
|
757
759
|
this._statsEl.classList.toggle("open", this._statsOpen);
|
|
758
760
|
if (this._statsOpen) {
|
|
761
|
+
this._statsPrev = null; // reset delta baseline
|
|
759
762
|
this._updateStats();
|
|
760
763
|
this._statsInterval = setInterval(() => this._updateStats(), 1000);
|
|
761
764
|
} else {
|
|
@@ -767,23 +770,92 @@ export class AvbridgePlayerElement extends HTMLElement {
|
|
|
767
770
|
const d = this._video.getDiagnostics() as Record<string, unknown> | null;
|
|
768
771
|
if (!d) { this._statsEl.textContent = "No diagnostics"; return; }
|
|
769
772
|
const rt = (d.runtime ?? {}) as Record<string, unknown>;
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
773
|
+
const now = performance.now();
|
|
774
|
+
const prev = this._statsPrev;
|
|
775
|
+
const dtSec = prev ? Math.max(0.001, (now - prev.ts) / 1000) : 0;
|
|
776
|
+
const delta = (key: string): number | null => {
|
|
777
|
+
if (!prev) return null;
|
|
778
|
+
const a = rt[key];
|
|
779
|
+
const b = prev.rt[key];
|
|
780
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
781
|
+
return null;
|
|
782
|
+
};
|
|
783
|
+
const rate = (key: string): number | null => {
|
|
784
|
+
const d_ = delta(key);
|
|
785
|
+
return d_ != null ? d_ / dtSec : null;
|
|
786
|
+
};
|
|
787
|
+
const fmt = (n: number | null, digits = 1) => (n == null ? "?" : n.toFixed(digits));
|
|
788
|
+
|
|
789
|
+
const sourceFps = (typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps) as number | undefined;
|
|
790
|
+
const lines: string[] = [];
|
|
791
|
+
|
|
792
|
+
// ── Identity ──────────────────────────────────────────────────────
|
|
793
|
+
lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
|
|
794
|
+
lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}×${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
|
|
795
|
+
lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
|
|
796
|
+
lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
|
|
797
|
+
|
|
798
|
+
// ── Realtime rates (deltas per second) ─────────────────────────────
|
|
799
|
+
if (rt.videoFramesDecoded != null) {
|
|
800
|
+
const decFps = rate("videoFramesDecoded");
|
|
801
|
+
const paintFps = rate("framesPainted");
|
|
802
|
+
const dropLateFps = rate("framesDroppedLate");
|
|
803
|
+
const dropOverflowFps = rate("framesDroppedOverflow");
|
|
804
|
+
const pct = sourceFps && decFps != null ? ` (${((decFps / sourceFps) * 100).toFixed(0)}% of realtime)` : "";
|
|
805
|
+
lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
|
|
806
|
+
lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ── Decode-time breakdown (wall ms spent inside libav) ────────────
|
|
810
|
+
if (typeof rt.videoDecodeMsTotal === "number") {
|
|
811
|
+
const msDelta = delta("videoDecodeMsTotal");
|
|
812
|
+
const batchesDelta = delta("videoDecodeBatches");
|
|
813
|
+
const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
|
|
814
|
+
const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
|
|
815
|
+
lines.push(
|
|
816
|
+
`Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs as number)}ms`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
if (typeof rt.audioDecodeMsTotal === "number") {
|
|
820
|
+
const msDelta = delta("audioDecodeMsTotal");
|
|
821
|
+
const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
|
|
822
|
+
lines.push(`Audio decode: ${fmt(share)}% of wall`);
|
|
823
|
+
}
|
|
824
|
+
if (typeof rt.pumpThrottleMsTotal === "number") {
|
|
825
|
+
const msDelta = delta("pumpThrottleMsTotal");
|
|
826
|
+
const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
|
|
827
|
+
lines.push(`Producer throttled: ${fmt(share)}% of wall`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ── Queue health ──────────────────────────────────────────────────
|
|
831
|
+
if (rt.queueDepth != null) {
|
|
832
|
+
lines.push(
|
|
833
|
+
`Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs as number)}ms ` +
|
|
834
|
+
`head=${fmt(rt.queueHeadMs as number)}ms tail=${fmt(rt.queueTailMs as number)}ms`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
if (typeof rt.newestVideoPtsMs === "number") {
|
|
838
|
+
lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1000).toFixed(2)}s`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── Audio ─────────────────────────────────────────────────────────
|
|
842
|
+
if (rt.audioState != null) {
|
|
843
|
+
lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead as number, 2)}s clock=${rt.clockMode ?? "?"}`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── BSF + warnings ────────────────────────────────────────────────
|
|
847
|
+
if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
|
|
848
|
+
lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs as number)}ms) — decoder emitting out of order`);
|
|
849
|
+
}
|
|
850
|
+
if (rt.bsfApplied && (rt.bsfApplied as string[]).length > 0) lines.push(`BSF active: ${(rt.bsfApplied as string[]).join(", ")}`);
|
|
851
|
+
if (rt.bsfMissing && (rt.bsfMissing as string[]).length > 0) {
|
|
852
|
+
lines.push(`BSF MISSING: ${(rt.bsfMissing as string[]).join(", ")} (rebuild libav with avbsf)`);
|
|
853
|
+
}
|
|
854
|
+
|
|
785
855
|
if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
|
|
856
|
+
|
|
786
857
|
this._statsEl.textContent = lines.join("\n");
|
|
858
|
+
this._statsPrev = { ts: now, rt: { ...rt } };
|
|
787
859
|
}
|
|
788
860
|
|
|
789
861
|
// ── Controls: fullscreen ───────────────────────────────────────────────
|
package/src/probe/avi.ts
CHANGED
|
@@ -77,7 +77,7 @@ export async function probeWithLibav(
|
|
|
77
77
|
codec: ffmpegToAvbridgeVideo(codecName),
|
|
78
78
|
width: codecpar?.width ?? 0,
|
|
79
79
|
height: codecpar?.height ?? 0,
|
|
80
|
-
fps: framerate(stream),
|
|
80
|
+
fps: await framerate(libav, stream),
|
|
81
81
|
});
|
|
82
82
|
} else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
|
|
83
83
|
audioTracks.push({
|
|
@@ -111,7 +111,27 @@ export async function probeWithLibav(
|
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Read frame rate from the stream's `AVCodecParameters.framerate`.
|
|
116
|
+
*
|
|
117
|
+
* `avg_frame_rate` / `r_frame_rate` live on the AVStream in C but libav.js
|
|
118
|
+
* doesn't expose them as JS properties on the stream record — only via
|
|
119
|
+
* dedicated accessor functions we don't ship. `codecpar.framerate` IS
|
|
120
|
+
* accessible via `AVCodecParameters_framerate_num/_den` and is populated
|
|
121
|
+
* for most containers (for AVI it's parsed from `dwRate`/`dwScale` in the
|
|
122
|
+
* stream header).
|
|
123
|
+
*
|
|
124
|
+
* Returns undefined if unavailable so the caller can fall back to a
|
|
125
|
+
* container-appropriate default (currently 30 fps, which is wrong for
|
|
126
|
+
* 25 fps PAL and 23.976 fps film content — hence the importance of
|
|
127
|
+
* reading this correctly).
|
|
128
|
+
*/
|
|
129
|
+
async function framerate(
|
|
130
|
+
libav: LibavInstance,
|
|
131
|
+
stream: LibavStream,
|
|
132
|
+
): Promise<number | undefined> {
|
|
133
|
+
// The stream record may still carry these (new libav.js versions) —
|
|
134
|
+
// prefer them when present.
|
|
115
135
|
if (typeof stream.avg_frame_rate_num === "number" && stream.avg_frame_rate_den) {
|
|
116
136
|
return stream.avg_frame_rate_num / stream.avg_frame_rate_den;
|
|
117
137
|
}
|
|
@@ -119,6 +139,15 @@ function framerate(stream: LibavStream): number | undefined {
|
|
|
119
139
|
if (stream.avg_frame_rate.den === 0) return undefined;
|
|
120
140
|
return stream.avg_frame_rate.num / stream.avg_frame_rate.den;
|
|
121
141
|
}
|
|
142
|
+
try {
|
|
143
|
+
const num = await libav.AVCodecParameters_framerate_num?.(stream.codecpar);
|
|
144
|
+
const den = await libav.AVCodecParameters_framerate_den?.(stream.codecpar);
|
|
145
|
+
if (typeof num === "number" && typeof den === "number" && den > 0 && num > 0) {
|
|
146
|
+
return num / den;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Fall through.
|
|
150
|
+
}
|
|
122
151
|
return undefined;
|
|
123
152
|
}
|
|
124
153
|
|
|
@@ -250,6 +279,9 @@ interface LibavInstance {
|
|
|
250
279
|
AVFormatContext_durationhi?(ctx: number): Promise<number>;
|
|
251
280
|
i64tof64?(lo: number, hi: number): number;
|
|
252
281
|
|
|
282
|
+
AVCodecParameters_framerate_num?(codecpar: number): Promise<number>;
|
|
283
|
+
AVCodecParameters_framerate_den?(codecpar: number): Promise<number>;
|
|
284
|
+
|
|
253
285
|
AVMEDIA_TYPE_VIDEO: number;
|
|
254
286
|
AVMEDIA_TYPE_AUDIO: number;
|
|
255
287
|
}
|
|
@@ -169,6 +169,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
169
169
|
// garbled frame ordering.
|
|
170
170
|
let bsfCtx: number | null = null;
|
|
171
171
|
let bsfPkt: number | null = null;
|
|
172
|
+
let bsfRequiredButMissing = false;
|
|
172
173
|
if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
|
|
173
174
|
try {
|
|
174
175
|
bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
|
|
@@ -179,15 +180,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
179
180
|
bsfPkt = await libav.av_packet_alloc();
|
|
180
181
|
dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
|
|
181
182
|
} else {
|
|
182
|
-
|
|
183
|
-
console.warn("[avbridge] mpeg4_unpack_bframes BSF not available — decoding without it");
|
|
183
|
+
bsfRequiredButMissing = true;
|
|
184
184
|
bsfCtx = null;
|
|
185
185
|
}
|
|
186
186
|
} catch (err) {
|
|
187
|
-
|
|
188
|
-
console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", (err as Error).message);
|
|
187
|
+
bsfRequiredButMissing = true;
|
|
189
188
|
bsfCtx = null;
|
|
190
189
|
bsfPkt = null;
|
|
190
|
+
dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${(err as Error).message}`);
|
|
191
|
+
}
|
|
192
|
+
if (bsfRequiredButMissing) {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.error(
|
|
195
|
+
"[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes " +
|
|
196
|
+
"BSF is unavailable in this libav variant. Files with packed B-frames " +
|
|
197
|
+
"will play with incorrect frame ordering (backwards PTS jumps, heavy " +
|
|
198
|
+
"late-drop stuttering). Rebuild the libav variant with the `avbsf` " +
|
|
199
|
+
"fragment included. See docs/dev/POSTMORTEMS.md for details.",
|
|
200
|
+
);
|
|
191
201
|
}
|
|
192
202
|
}
|
|
193
203
|
|
|
@@ -199,7 +209,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
199
209
|
await libav.ff_copyin_packet(bsfPkt, pkt);
|
|
200
210
|
const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
|
|
201
211
|
if (sendErr < 0) {
|
|
202
|
-
|
|
212
|
+
// BSF rejected — DON'T pass the original through. `ff_copyin_packet`
|
|
213
|
+
// above may have transferred pkt.data's ArrayBuffer into the worker,
|
|
214
|
+
// in which case re-posting the same packet to the decoder fails
|
|
215
|
+
// with DataCloneError on a detached buffer. Skipping the packet is
|
|
216
|
+
// safer; the decoder's error recovery will resync at the next
|
|
217
|
+
// keyframe if this was transient.
|
|
203
218
|
continue;
|
|
204
219
|
}
|
|
205
220
|
while (true) {
|
|
@@ -215,10 +230,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
215
230
|
async function flushBSF(): Promise<void> {
|
|
216
231
|
if (!bsfCtx || !bsfPkt) return;
|
|
217
232
|
try {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
233
|
+
// `av_bsf_flush` resets the BSF state without putting it in EOF
|
|
234
|
+
// mode. The old approach — sending a NULL packet — is the EOF
|
|
235
|
+
// signal; after that every subsequent `av_bsf_send_packet` fails,
|
|
236
|
+
// which made `applyBSF` fall back to pushing the ORIGINAL packet
|
|
237
|
+
// through (with its buffer already transferred to WASM by
|
|
238
|
+
// `ff_copyin_packet`). That detached buffer then failed to
|
|
239
|
+
// `postMessage` into the decoder worker with DataCloneError on
|
|
240
|
+
// the first post-seek batch.
|
|
241
|
+
if (libav.av_bsf_flush) {
|
|
242
|
+
await libav.av_bsf_flush(bsfCtx);
|
|
243
|
+
} else {
|
|
244
|
+
// Fallback for older libav.js variants without av_bsf_flush:
|
|
245
|
+
// drain any internal packets but DON'T send NULL-EOF.
|
|
246
|
+
while (true) {
|
|
247
|
+
const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
|
|
248
|
+
if (err < 0) break;
|
|
249
|
+
}
|
|
222
250
|
}
|
|
223
251
|
} catch { /* ignore flush errors */ }
|
|
224
252
|
}
|
|
@@ -251,6 +279,25 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
251
279
|
let syntheticVideoUs = 0;
|
|
252
280
|
let syntheticAudioUs = 0;
|
|
253
281
|
|
|
282
|
+
// Throughput instrumentation — answers "is the decoder keeping up?".
|
|
283
|
+
// All counters are cumulative since bootstrap (not reset on seek), so
|
|
284
|
+
// the stats panel can compute rolling deltas. Times are wall-ms spent
|
|
285
|
+
// inside the respective libav call; JS↔WASM boundary is inside the
|
|
286
|
+
// worker so this is the real cost the producer pays per batch.
|
|
287
|
+
let videoDecodeMsTotal = 0;
|
|
288
|
+
let audioDecodeMsTotal = 0;
|
|
289
|
+
let videoDecodeBatches = 0;
|
|
290
|
+
let audioDecodeBatches = 0;
|
|
291
|
+
let readMsTotal = 0;
|
|
292
|
+
let readBatches = 0;
|
|
293
|
+
let pumpThrottleMsTotal = 0;
|
|
294
|
+
let pumpThrottleEntries = 0;
|
|
295
|
+
let slowestVideoBatchMs = 0;
|
|
296
|
+
let newestVideoPtsUs = 0; // set by decodeVideoBatch after each emitted frame
|
|
297
|
+
let lastEmittedPtsUs = -1; // previous emitted frame's pts, for monotonicity check
|
|
298
|
+
let ptsRegressions = 0;
|
|
299
|
+
let worstPtsRegressionMs = 0;
|
|
300
|
+
|
|
254
301
|
const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
|
|
255
302
|
const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
|
|
256
303
|
const videoFrameStepUs = Math.max(1, Math.round(1_000_000 / videoFps));
|
|
@@ -274,9 +321,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
274
321
|
// typical bitrates, so the worst-case queue spike stays under
|
|
275
322
|
// `queueHighWater` and the throttle has a chance to apply
|
|
276
323
|
// backpressure *between* batches rather than within one.
|
|
324
|
+
const _readStart = performance.now();
|
|
277
325
|
[readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
|
|
278
326
|
limit: 16 * 1024,
|
|
279
327
|
});
|
|
328
|
+
readMsTotal += performance.now() - _readStart;
|
|
329
|
+
readBatches++;
|
|
280
330
|
} catch (err) {
|
|
281
331
|
console.error("[avbridge] ff_read_frame_multi failed:", err);
|
|
282
332
|
return;
|
|
@@ -388,13 +438,22 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
388
438
|
// - Renderer queue depth >= queueHighWater — the canvas can't
|
|
389
439
|
// drain fast enough. Without this, fast software decode of
|
|
390
440
|
// small frames piles up in the renderer and overflows.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
441
|
+
{
|
|
442
|
+
const _throttleStart = performance.now();
|
|
443
|
+
let _throttled = false;
|
|
444
|
+
while (
|
|
445
|
+
!destroyed &&
|
|
446
|
+
myToken === pumpToken &&
|
|
447
|
+
(opts.audio.bufferAhead() > 2.0 ||
|
|
448
|
+
opts.renderer.queueDepth() >= opts.renderer.queueHighWater)
|
|
449
|
+
) {
|
|
450
|
+
_throttled = true;
|
|
451
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
452
|
+
}
|
|
453
|
+
if (_throttled) {
|
|
454
|
+
pumpThrottleMsTotal += performance.now() - _throttleStart;
|
|
455
|
+
pumpThrottleEntries++;
|
|
456
|
+
}
|
|
398
457
|
}
|
|
399
458
|
|
|
400
459
|
if (readErr === libav.AVERROR_EOF) {
|
|
@@ -412,6 +471,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
412
471
|
async function decodeVideoBatch(pkts: LibavPacket[], myToken: number, flush = false) {
|
|
413
472
|
if (!videoDec || destroyed || myToken !== pumpToken) return;
|
|
414
473
|
let frames: LibavFrame[];
|
|
474
|
+
const _t0 = performance.now();
|
|
415
475
|
try {
|
|
416
476
|
frames = await libav.ff_decode_multi(
|
|
417
477
|
videoDec.c,
|
|
@@ -424,6 +484,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
424
484
|
console.error("[avbridge] video decode batch failed:", err);
|
|
425
485
|
return;
|
|
426
486
|
}
|
|
487
|
+
{
|
|
488
|
+
const _dt = performance.now() - _t0;
|
|
489
|
+
videoDecodeMsTotal += _dt;
|
|
490
|
+
videoDecodeBatches++;
|
|
491
|
+
if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
|
|
492
|
+
}
|
|
427
493
|
if (myToken !== pumpToken || destroyed) return;
|
|
428
494
|
|
|
429
495
|
for (const f of frames) {
|
|
@@ -431,14 +497,54 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
431
497
|
sanitizeFrameTimestamp(
|
|
432
498
|
f,
|
|
433
499
|
() => {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
500
|
+
// Anchor the synthetic timestamp to the last emitted frame's
|
|
501
|
+
// pts + one frame step. A plain counter (the old behavior)
|
|
502
|
+
// started at 0 and only advanced on invalid frames, which
|
|
503
|
+
// made the occasional AV_NOPTS_VALUE output get assigned a
|
|
504
|
+
// timestamp near the stream start — causing the renderer to
|
|
505
|
+
// paint backwards and drop healthy frames around it. Anchoring
|
|
506
|
+
// to `lastEmittedPtsUs` keeps invalid frames monotonic with
|
|
507
|
+
// their valid neighbors.
|
|
508
|
+
const base =
|
|
509
|
+
lastEmittedPtsUs >= 0
|
|
510
|
+
? lastEmittedPtsUs + videoFrameStepUs
|
|
511
|
+
: syntheticVideoUs;
|
|
512
|
+
syntheticVideoUs = base + videoFrameStepUs;
|
|
513
|
+
return base;
|
|
437
514
|
},
|
|
438
515
|
videoTimeBase,
|
|
439
516
|
);
|
|
440
517
|
// sanitizeFrameTimestamp normalizes pts to µs, so the bridge can
|
|
441
518
|
// always use the 1/1e6 timebase.
|
|
519
|
+
const _fPts = (f.ptshi ?? 0) * 0x100000000 + (f.pts ?? 0);
|
|
520
|
+
if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
|
|
521
|
+
if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
|
|
522
|
+
// Decoder emitted a frame with lower PTS than the previous
|
|
523
|
+
// output. Dropping out-of-order frames here is the right move:
|
|
524
|
+
// the renderer's paint loop assumes monotonic queue order and
|
|
525
|
+
// breaks (stale frame stuck at head, newer frames drop as late,
|
|
526
|
+
// paint cadence collapses) if we let them through. Two scenarios
|
|
527
|
+
// produce this in practice:
|
|
528
|
+
// - Post-seek tail of a B-frame reorder buffer that survives
|
|
529
|
+
// avcodec_flush_buffers + av_bsf_flush (rare but observed
|
|
530
|
+
// on mpeg4 after large seeks).
|
|
531
|
+
// - A BSF that doesn't repair packed B-frames perfectly and
|
|
532
|
+
// lets a DTS/PTS swap through.
|
|
533
|
+
// The decoder will catch up at the next I-frame.
|
|
534
|
+
ptsRegressions++;
|
|
535
|
+
const regressMs = (lastEmittedPtsUs - _fPts) / 1000;
|
|
536
|
+
if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
|
|
537
|
+
if (ptsRegressions <= 10) {
|
|
538
|
+
// eslint-disable-next-line no-console
|
|
539
|
+
console.warn(
|
|
540
|
+
`[avbridge:decoder] dropped out-of-order frame #${ptsRegressions}: ` +
|
|
541
|
+
`pts=${(_fPts / 1000).toFixed(1)}ms < previous=${(lastEmittedPtsUs / 1000).toFixed(1)}ms ` +
|
|
542
|
+
`(regression=${regressMs.toFixed(1)}ms). Typically a post-seek B-frame reorder tail.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
continue; // skip enqueue
|
|
546
|
+
}
|
|
547
|
+
lastEmittedPtsUs = _fPts;
|
|
442
548
|
try {
|
|
443
549
|
const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
|
|
444
550
|
opts.renderer.enqueue(vf);
|
|
@@ -454,6 +560,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
454
560
|
async function decodeAudioBatch(pkts: LibavPacket[], myToken: number, flush = false) {
|
|
455
561
|
if (!audioDec || destroyed || myToken !== pumpToken) return;
|
|
456
562
|
let frames: LibavFrame[];
|
|
563
|
+
const _t0 = performance.now();
|
|
457
564
|
try {
|
|
458
565
|
frames = await libav.ff_decode_multi(
|
|
459
566
|
audioDec.c,
|
|
@@ -466,6 +573,8 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
466
573
|
console.error("[avbridge] audio decode batch failed:", err);
|
|
467
574
|
return;
|
|
468
575
|
}
|
|
576
|
+
audioDecodeMsTotal += performance.now() - _t0;
|
|
577
|
+
audioDecodeBatches++;
|
|
469
578
|
if (myToken !== pumpToken || destroyed) return;
|
|
470
579
|
|
|
471
580
|
for (const f of frames) {
|
|
@@ -575,6 +684,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
575
684
|
|
|
576
685
|
syntheticVideoUs = Math.round(timeSec * 1_000_000);
|
|
577
686
|
syntheticAudioUs = Math.round(timeSec * 1_000_000);
|
|
687
|
+
lastEmittedPtsUs = -1;
|
|
578
688
|
|
|
579
689
|
pumpRunning = pumpLoop(newToken).catch((err) =>
|
|
580
690
|
console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err),
|
|
@@ -633,6 +743,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
633
743
|
// decoded frames start at the right media time.
|
|
634
744
|
syntheticVideoUs = Math.round(timeSec * 1_000_000);
|
|
635
745
|
syntheticAudioUs = Math.round(timeSec * 1_000_000);
|
|
746
|
+
lastEmittedPtsUs = -1;
|
|
636
747
|
|
|
637
748
|
// The renderer & audio output are reset by the fallback session
|
|
638
749
|
// wrapper that called us — see strategies/fallback/index.ts.
|
|
@@ -653,7 +764,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
|
|
|
653
764
|
packetsRead,
|
|
654
765
|
videoFramesDecoded,
|
|
655
766
|
audioFramesDecoded,
|
|
767
|
+
// Throughput instrumentation — the stats panel turns these into
|
|
768
|
+
// "decode fps actual / realtime target" and shows slowest batch
|
|
769
|
+
// + producer throttle share.
|
|
770
|
+
videoDecodeMsTotal,
|
|
771
|
+
videoDecodeBatches,
|
|
772
|
+
audioDecodeMsTotal,
|
|
773
|
+
audioDecodeBatches,
|
|
774
|
+
readMsTotal,
|
|
775
|
+
readBatches,
|
|
776
|
+
pumpThrottleMsTotal,
|
|
777
|
+
pumpThrottleEntries,
|
|
778
|
+
slowestVideoBatchMs,
|
|
779
|
+
newestVideoPtsMs: Math.round(newestVideoPtsUs / 1000),
|
|
780
|
+
ptsRegressions,
|
|
781
|
+
worstPtsRegressionMs,
|
|
782
|
+
sourceFps: videoFps,
|
|
656
783
|
bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
|
|
784
|
+
bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
|
|
657
785
|
// Confirmed transport info: once prepareLibavInput returns
|
|
658
786
|
// successfully, we *know* whether the source is http-range (probe
|
|
659
787
|
// succeeded and returned 206) or in-memory blob. Diagnostics hoists
|
|
@@ -776,6 +904,7 @@ interface LibavRuntime {
|
|
|
776
904
|
av_bsf_init(ctx: number): Promise<number>;
|
|
777
905
|
av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
|
|
778
906
|
av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
|
|
907
|
+
av_bsf_flush?(ctx: number): Promise<void>;
|
|
779
908
|
av_bsf_free(ctx: number): Promise<void>;
|
|
780
909
|
|
|
781
910
|
// Packet copy helpers — bridge JS packet objects to/from C-level pointers
|
|
@@ -141,11 +141,22 @@ export class VideoRenderer {
|
|
|
141
141
|
this.rafHandle = requestAnimationFrame(this.tick);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* True once at least one frame has been enqueued *since the last flush*.
|
|
146
|
+
* Used by `readyState` — initial cold-start reports HAVE_NOTHING until
|
|
147
|
+
* any frame has arrived, and after a seek we want the same semantics
|
|
148
|
+
* (HAVE_NOTHING until post-seek frames arrive), so the cumulative
|
|
149
|
+
* `framesPainted > 0` that used to live here was wrong: it kept the
|
|
150
|
+
* state "true forever" after the first frame ever, so post-seek
|
|
151
|
+
* `waitForBuffer()` would exit immediately with an empty queue and
|
|
152
|
+
* leave video frozen while audio kept going.
|
|
153
|
+
*/
|
|
145
154
|
hasFrames(): boolean {
|
|
146
|
-
return this.queue.length > 0 || this.
|
|
155
|
+
return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
|
|
147
156
|
}
|
|
148
157
|
|
|
158
|
+
private hasEverEnqueuedSinceFlush = false;
|
|
159
|
+
|
|
149
160
|
/** Current depth of the frame queue. Used by the decoder for backpressure. */
|
|
150
161
|
queueDepth(): number {
|
|
151
162
|
return this.queue.length;
|
|
@@ -166,6 +177,7 @@ export class VideoRenderer {
|
|
|
166
177
|
return;
|
|
167
178
|
}
|
|
168
179
|
this.queue.push(frame);
|
|
180
|
+
this.hasEverEnqueuedSinceFlush = true;
|
|
169
181
|
if (this.queue.length === 1 && this.framesPainted === 0) {
|
|
170
182
|
this.resolveFirstFrame();
|
|
171
183
|
}
|
|
@@ -342,7 +354,16 @@ export class VideoRenderer {
|
|
|
342
354
|
}
|
|
343
355
|
|
|
344
356
|
// Only drop frames that are more than 2 frame-durations behind.
|
|
345
|
-
|
|
357
|
+
// Diagnostic escape hatch: `globalThis.AVBRIDGE_RELAX_DROP = true`
|
|
358
|
+
// pushes the threshold so far back that frames are effectively
|
|
359
|
+
// never dropped as late. The display will run behind the audio
|
|
360
|
+
// clock but won't stutter from drop bursts. Useful for isolating
|
|
361
|
+
// "is the problem decode throughput or drop policy?".
|
|
362
|
+
const _relaxDrop =
|
|
363
|
+
(globalThis as { AVBRIDGE_RELAX_DROP?: boolean }).AVBRIDGE_RELAX_DROP === true;
|
|
364
|
+
const dropThresholdUs = _relaxDrop
|
|
365
|
+
? audioNowUs - 60 * 1_000_000 /* 60 s */
|
|
366
|
+
: audioNowUs - frameDurationUs * 2;
|
|
346
367
|
let dropped = 0;
|
|
347
368
|
while (bestIdx > 0) {
|
|
348
369
|
const ts = this.queue[0].timestamp ?? 0;
|
|
@@ -419,6 +440,7 @@ export class VideoRenderer {
|
|
|
419
440
|
while (this.queue.length > 0) this.queue.shift()?.close();
|
|
420
441
|
this.prerolled = false;
|
|
421
442
|
this.ptsCalibrated = false; // recalibrate at new seek position
|
|
443
|
+
this.hasEverEnqueuedSinceFlush = false; // so waitForBuffer() waits for post-flush frames
|
|
422
444
|
if (isDebug() && count > 0) {
|
|
423
445
|
// eslint-disable-next-line no-console
|
|
424
446
|
console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
|
|
@@ -426,11 +448,27 @@ export class VideoRenderer {
|
|
|
426
448
|
}
|
|
427
449
|
|
|
428
450
|
stats(): Record<string, unknown> {
|
|
451
|
+
// Queue span — the gap between the oldest and newest queued frame's
|
|
452
|
+
// PTS, in ms. If this collapses while audio keeps advancing, the
|
|
453
|
+
// producer has stalled. If it stays wide with stale head, the
|
|
454
|
+
// producer is bursting faster than realtime but the renderer can't
|
|
455
|
+
// catch up.
|
|
456
|
+
let queueSpanMs = 0;
|
|
457
|
+
let queueHeadMs = 0;
|
|
458
|
+
let queueTailMs = 0;
|
|
459
|
+
if (this.queue.length > 0) {
|
|
460
|
+
queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1000);
|
|
461
|
+
queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1000);
|
|
462
|
+
queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
|
|
463
|
+
}
|
|
429
464
|
return {
|
|
430
465
|
framesPainted: this.framesPainted,
|
|
431
466
|
framesDroppedLate: this.framesDroppedLate,
|
|
432
467
|
framesDroppedOverflow: this.framesDroppedOverflow,
|
|
433
468
|
queueDepth: this.queue.length,
|
|
469
|
+
queueHeadMs,
|
|
470
|
+
queueTailMs,
|
|
471
|
+
queueSpanMs,
|
|
434
472
|
};
|
|
435
473
|
}
|
|
436
474
|
|