@mebius-io/web 0.4.7 → 0.4.9
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/dist/index.cjs +171 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -9
- package/dist/index.d.ts +26 -9
- package/dist/index.global.js +171 -19
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +171 -19
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -107,6 +107,8 @@ var WhipPublishTransport = class {
|
|
|
107
107
|
this.signaling = signaling;
|
|
108
108
|
this.pc = null;
|
|
109
109
|
this.resourceUrl = null;
|
|
110
|
+
/** Bytes sent and packet counters at the previous getStats() call. */
|
|
111
|
+
this.lastOutbound = null;
|
|
110
112
|
}
|
|
111
113
|
async start(streamId, stream) {
|
|
112
114
|
const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
|
|
@@ -139,26 +141,61 @@ var WhipPublishTransport = class {
|
|
|
139
141
|
this.pc?.close();
|
|
140
142
|
this.pc = null;
|
|
141
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Live broadcast statistics.
|
|
146
|
+
*
|
|
147
|
+
* Two corrections over the obvious reading of RTCStats:
|
|
148
|
+
*
|
|
149
|
+
* `bitrateKbps` is the delta of `outbound-rtp.bytesSent`, not
|
|
150
|
+
* `availableOutgoingBitrate`. The latter is the congestion controller's
|
|
151
|
+
* ESTIMATE of headroom, so a broadcaster on a fast link reported several
|
|
152
|
+
* megabits while actually sending a fraction of that — the dashboard's
|
|
153
|
+
* "bitrate adherence" score was measuring the network, not the encoder.
|
|
154
|
+
*
|
|
155
|
+
* `packetLossPct` comes from the receiver's report (`remote-inbound-rtp`),
|
|
156
|
+
* which is the only place that knows what did not arrive. It was never
|
|
157
|
+
* reported at all, and publishQualityScore treats a missing value as zero
|
|
158
|
+
* loss — so every publisher scored full marks on a fifth of the rubric no
|
|
159
|
+
* matter how bad the uplink was.
|
|
160
|
+
*/
|
|
142
161
|
async getStats() {
|
|
143
162
|
if (!this.pc) return null;
|
|
144
163
|
const report = await this.pc.getStats();
|
|
145
|
-
let
|
|
146
|
-
let framesPerSecond = 0;
|
|
164
|
+
let framesPerSecond;
|
|
147
165
|
let rttMs;
|
|
166
|
+
let packetLossPct;
|
|
167
|
+
let bytesSent;
|
|
168
|
+
let packetsSent;
|
|
169
|
+
let packetsLost;
|
|
148
170
|
report.forEach((stat) => {
|
|
149
171
|
if (stat.type === "outbound-rtp" && !stat.isRemote) {
|
|
150
172
|
if (typeof stat.framesPerSecond === "number") framesPerSecond = stat.framesPerSecond;
|
|
173
|
+
if (typeof stat.bytesSent === "number") bytesSent = (bytesSent ?? 0) + stat.bytesSent;
|
|
174
|
+
if (typeof stat.packetsSent === "number") packetsSent = (packetsSent ?? 0) + stat.packetsSent;
|
|
175
|
+
}
|
|
176
|
+
if (stat.type === "remote-inbound-rtp") {
|
|
177
|
+
if (typeof stat.packetsLost === "number") packetsLost = (packetsLost ?? 0) + stat.packetsLost;
|
|
178
|
+
if (typeof stat.roundTripTime === "number") rttMs = Math.round(stat.roundTripTime * 1e3);
|
|
151
179
|
}
|
|
152
180
|
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
|
|
153
|
-
if (typeof stat.
|
|
154
|
-
bitrateKbps = Math.round(stat.availableOutgoingBitrate / 1e3);
|
|
155
|
-
}
|
|
156
|
-
if (typeof stat.currentRoundTripTime === "number") {
|
|
181
|
+
if (rttMs == null && typeof stat.currentRoundTripTime === "number") {
|
|
157
182
|
rttMs = Math.round(stat.currentRoundTripTime * 1e3);
|
|
158
183
|
}
|
|
159
184
|
}
|
|
160
185
|
});
|
|
161
|
-
|
|
186
|
+
let bitrateKbps;
|
|
187
|
+
const atMs = Date.now();
|
|
188
|
+
if (bytesSent != null) {
|
|
189
|
+
const prev = this.lastOutbound;
|
|
190
|
+
if (prev && atMs > prev.atMs && bytesSent >= prev.bytes) {
|
|
191
|
+
bitrateKbps = Math.round((bytesSent - prev.bytes) * 8 / 1e3 / ((atMs - prev.atMs) / 1e3));
|
|
192
|
+
}
|
|
193
|
+
this.lastOutbound = { bytes: bytesSent, atMs };
|
|
194
|
+
}
|
|
195
|
+
if (packetsLost != null && packetsSent != null && packetsSent > 0) {
|
|
196
|
+
packetLossPct = Math.max(0, Math.min(100, packetsLost / packetsSent * 100));
|
|
197
|
+
}
|
|
198
|
+
return { bitrateKbps, framesPerSecond, rttMs, packetLossPct };
|
|
162
199
|
}
|
|
163
200
|
};
|
|
164
201
|
|
|
@@ -188,6 +225,7 @@ function resetVideoElement(video) {
|
|
|
188
225
|
var WhepViewTransport = class {
|
|
189
226
|
constructor(signaling) {
|
|
190
227
|
this.signaling = signaling;
|
|
228
|
+
this.kind = "whep";
|
|
191
229
|
this.pc = null;
|
|
192
230
|
this.resourceUrl = null;
|
|
193
231
|
this.endedCb = null;
|
|
@@ -267,6 +305,9 @@ var WhepViewTransport = class {
|
|
|
267
305
|
};
|
|
268
306
|
|
|
269
307
|
// src/internal/scale-view-transport.ts
|
|
308
|
+
function retryWarmupNotFound(cfg, retryCount, res, retry) {
|
|
309
|
+
return retry || retryCount < (cfg?.maxNumRetry ?? 0) && res?.code === 404;
|
|
310
|
+
}
|
|
270
311
|
var HlsViewTransport = class {
|
|
271
312
|
/**
|
|
272
313
|
* deliveryPath, when given, is a gateway-relative path from the gateway's own
|
|
@@ -277,6 +318,7 @@ var HlsViewTransport = class {
|
|
|
277
318
|
constructor(signaling, deliveryPath) {
|
|
278
319
|
this.signaling = signaling;
|
|
279
320
|
this.deliveryPath = deliveryPath;
|
|
321
|
+
this.kind = "hls";
|
|
280
322
|
this.hls = null;
|
|
281
323
|
this.video = null;
|
|
282
324
|
this.endedCb = null;
|
|
@@ -313,7 +355,22 @@ var HlsViewTransport = class {
|
|
|
313
355
|
this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
|
|
314
356
|
return;
|
|
315
357
|
}
|
|
316
|
-
const hls = new Hls({
|
|
358
|
+
const hls = new Hls({
|
|
359
|
+
maxLiveSyncPlaybackRate: 1.1,
|
|
360
|
+
manifestLoadPolicy: {
|
|
361
|
+
default: {
|
|
362
|
+
maxTimeToFirstByteMs: 1e4,
|
|
363
|
+
maxLoadTimeMs: 2e4,
|
|
364
|
+
timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 },
|
|
365
|
+
errorRetry: {
|
|
366
|
+
maxNumRetry: 5,
|
|
367
|
+
retryDelayMs: 500,
|
|
368
|
+
maxRetryDelayMs: 2e3,
|
|
369
|
+
shouldRetry: (cfg, retryCount, _isTimeout, res, retry) => retryWarmupNotFound(cfg, retryCount, res, retry)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
317
374
|
this.hls = hls;
|
|
318
375
|
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
|
319
376
|
if (data.fatal) this.bufferingCb?.();
|
|
@@ -382,6 +439,7 @@ var FlvViewTransport = class {
|
|
|
382
439
|
constructor(signaling, deliveryPath) {
|
|
383
440
|
this.signaling = signaling;
|
|
384
441
|
this.deliveryPath = deliveryPath;
|
|
442
|
+
this.kind = "flv_js";
|
|
385
443
|
this.player = null;
|
|
386
444
|
this.video = null;
|
|
387
445
|
this.endedCb = null;
|
|
@@ -390,6 +448,8 @@ var FlvViewTransport = class {
|
|
|
390
448
|
this.listeners = null;
|
|
391
449
|
/** True when playback only started because the element had to be muted. */
|
|
392
450
|
this.mutedByPolicy = false;
|
|
451
|
+
/** Decoded-frame count and timestamp of the previous getStats() call. */
|
|
452
|
+
this.lastFrames = null;
|
|
393
453
|
}
|
|
394
454
|
onEnded(cb) {
|
|
395
455
|
this.endedCb = cb;
|
|
@@ -453,13 +513,33 @@ var FlvViewTransport = class {
|
|
|
453
513
|
}
|
|
454
514
|
this.video = null;
|
|
455
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* Real playback statistics for this route.
|
|
518
|
+
*
|
|
519
|
+
* Both numbers used to be hardcoded zeros, which is worse than reporting
|
|
520
|
+
* nothing: the dashboard cannot tell a measured 0 kbps from an unmeasured
|
|
521
|
+
* one, so every flv.js viewer in production showed a downlink of 0 and the
|
|
522
|
+
* column read as a total outage. flv.js measures throughput itself
|
|
523
|
+
* (`statisticsInfo.speed`, KB/s), and the element counts decoded frames, so
|
|
524
|
+
* frame rate is the delta between two calls. Anything genuinely unavailable
|
|
525
|
+
* is left undefined rather than zeroed.
|
|
526
|
+
*/
|
|
456
527
|
async getStats() {
|
|
457
528
|
if (!this.video) return null;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
529
|
+
const speedKBs = this.player?.statisticsInfo?.speed;
|
|
530
|
+
const bitrateKbps = typeof speedKBs === "number" ? Math.round(speedKBs * 8) : void 0;
|
|
531
|
+
let framesPerSecond;
|
|
532
|
+
const q = this.video.getVideoPlaybackQuality?.();
|
|
533
|
+
const count = q?.totalVideoFrames;
|
|
534
|
+
const atMs = Date.now();
|
|
535
|
+
if (typeof count === "number") {
|
|
536
|
+
const prev = this.lastFrames;
|
|
537
|
+
if (prev && atMs > prev.atMs && count >= prev.count) {
|
|
538
|
+
framesPerSecond = Math.round((count - prev.count) * 1e3 / (atMs - prev.atMs));
|
|
539
|
+
}
|
|
540
|
+
this.lastFrames = { count, atMs };
|
|
541
|
+
}
|
|
542
|
+
return { bitrateKbps, framesPerSecond, latencyMs: void 0 };
|
|
463
543
|
}
|
|
464
544
|
};
|
|
465
545
|
|
|
@@ -495,7 +575,7 @@ function createViewCandidates(mode, signaling, deliveries = []) {
|
|
|
495
575
|
}
|
|
496
576
|
|
|
497
577
|
// src/internal/telemetry.ts
|
|
498
|
-
var SDK_VERSION = "web/0.4.
|
|
578
|
+
var SDK_VERSION = "web/0.4.8";
|
|
499
579
|
var FLUSH_INTERVAL_MS = 15e3;
|
|
500
580
|
var MAX_BATCH = 64;
|
|
501
581
|
function describeDevice() {
|
|
@@ -508,11 +588,12 @@ function describeNetwork() {
|
|
|
508
588
|
return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
|
|
509
589
|
}
|
|
510
590
|
var QoeReporter = class {
|
|
511
|
-
constructor(target, role, streamId, userId) {
|
|
591
|
+
constructor(target, role, streamId, userId, playerKind) {
|
|
512
592
|
this.target = target;
|
|
513
593
|
this.role = role;
|
|
514
594
|
this.streamId = streamId;
|
|
515
595
|
this.userId = userId;
|
|
596
|
+
this.playerKind = playerKind;
|
|
516
597
|
this.sessionId = randomId();
|
|
517
598
|
this.buffer = [];
|
|
518
599
|
this.timer = null;
|
|
@@ -548,6 +629,7 @@ var QoeReporter = class {
|
|
|
548
629
|
streamId: this.streamId,
|
|
549
630
|
role: this.role,
|
|
550
631
|
userId: this.userId,
|
|
632
|
+
playerKind: this.playerKind,
|
|
551
633
|
samples,
|
|
552
634
|
device: describeDevice(),
|
|
553
635
|
network: describeNetwork()
|
|
@@ -672,7 +754,8 @@ var MebiusBroadcaster = class extends TypedEmitter {
|
|
|
672
754
|
ts: Math.floor(Date.now() / 1e3),
|
|
673
755
|
bitrateKbps: stats.bitrateKbps,
|
|
674
756
|
fps: stats.framesPerSecond,
|
|
675
|
-
rttMs: stats.rttMs
|
|
757
|
+
rttMs: stats.rttMs,
|
|
758
|
+
packetLossPct: stats.packetLossPct
|
|
676
759
|
});
|
|
677
760
|
}, STATS_INTERVAL_MS);
|
|
678
761
|
}
|
|
@@ -686,6 +769,59 @@ function normalize(c, fallback) {
|
|
|
686
769
|
return c;
|
|
687
770
|
}
|
|
688
771
|
|
|
772
|
+
// src/internal/freeze-clock.ts
|
|
773
|
+
var FreezeClock = class {
|
|
774
|
+
constructor(now = Date.now) {
|
|
775
|
+
this.now = now;
|
|
776
|
+
/** When the current stall began, or null when playback is running. */
|
|
777
|
+
this.stalledSinceMs = null;
|
|
778
|
+
/** Stall time that has ended but has not yet been shipped with a sample. */
|
|
779
|
+
this.pendingMs = 0;
|
|
780
|
+
}
|
|
781
|
+
/** True while a stall is in progress. */
|
|
782
|
+
get stalled() {
|
|
783
|
+
return this.stalledSinceMs !== null;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Begin a stall. Re-entering while already stalled is ignored rather than
|
|
787
|
+
* restarting the clock: flv.js fires `waiting` repeatedly through a single
|
|
788
|
+
* long stall, and resetting the start on each would report a fraction of the
|
|
789
|
+
* freeze that actually happened.
|
|
790
|
+
*/
|
|
791
|
+
beginStall() {
|
|
792
|
+
if (this.stalledSinceMs === null) this.stalledSinceMs = this.now();
|
|
793
|
+
}
|
|
794
|
+
/** End the current stall and bank its duration. No-op when not stalled. */
|
|
795
|
+
endStall() {
|
|
796
|
+
if (this.stalledSinceMs === null) return;
|
|
797
|
+
this.pendingMs += Math.max(0, this.now() - this.stalledSinceMs);
|
|
798
|
+
this.stalledSinceMs = null;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Freeze milliseconds to report on this tick, resetting the counter.
|
|
802
|
+
*
|
|
803
|
+
* A stall still in progress is counted up to now and its clock restarted, so
|
|
804
|
+
* a freeze longer than the sample interval is reported while it is happening
|
|
805
|
+
* rather than landing whole in whichever sample eventually follows it. Every
|
|
806
|
+
* millisecond is attributed exactly once — never dropped, never double-counted.
|
|
807
|
+
*/
|
|
808
|
+
take() {
|
|
809
|
+
if (this.stalledSinceMs !== null) {
|
|
810
|
+
const now = this.now();
|
|
811
|
+
this.pendingMs += Math.max(0, now - this.stalledSinceMs);
|
|
812
|
+
this.stalledSinceMs = now;
|
|
813
|
+
}
|
|
814
|
+
const ms = this.pendingMs;
|
|
815
|
+
this.pendingMs = 0;
|
|
816
|
+
return ms;
|
|
817
|
+
}
|
|
818
|
+
/** Forget everything. Called when a session ends. */
|
|
819
|
+
reset() {
|
|
820
|
+
this.stalledSinceMs = null;
|
|
821
|
+
this.pendingMs = 0;
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
|
|
689
825
|
// src/player.ts
|
|
690
826
|
var STATS_INTERVAL_MS2 = 2e3;
|
|
691
827
|
var FIRST_FRAME_TIMEOUT_MS = 8e3;
|
|
@@ -703,6 +839,8 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
703
839
|
this.reporter = null;
|
|
704
840
|
/** True between a `buffering` event and the element actually resuming. */
|
|
705
841
|
this.stalled = false;
|
|
842
|
+
/** Measures how long playback was actually frozen; see FreezeClock. */
|
|
843
|
+
this.freeze = new FreezeClock();
|
|
706
844
|
/** Cancels element listeners bound for the lifetime of one play(). */
|
|
707
845
|
this.elementListeners = null;
|
|
708
846
|
this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
|
|
@@ -721,6 +859,7 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
721
859
|
() => {
|
|
722
860
|
if (!this.stalled || !this.playing) return;
|
|
723
861
|
this.stalled = false;
|
|
862
|
+
this.freeze.endStall();
|
|
724
863
|
this.emit("playing", { streamId });
|
|
725
864
|
},
|
|
726
865
|
{ signal: this.elementListeners.signal }
|
|
@@ -736,7 +875,13 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
736
875
|
this.transport = candidate;
|
|
737
876
|
this.playing = true;
|
|
738
877
|
if (this.telemetry) {
|
|
739
|
-
this.reporter = new QoeReporter(
|
|
878
|
+
this.reporter = new QoeReporter(
|
|
879
|
+
this.telemetry,
|
|
880
|
+
"play",
|
|
881
|
+
streamId,
|
|
882
|
+
this.userId,
|
|
883
|
+
candidate.kind
|
|
884
|
+
);
|
|
740
885
|
this.reporter.start();
|
|
741
886
|
this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
|
|
742
887
|
}
|
|
@@ -764,6 +909,7 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
764
909
|
ELEMENT_OWNER.delete(this.video);
|
|
765
910
|
}
|
|
766
911
|
this.stalled = false;
|
|
912
|
+
this.freeze.reset();
|
|
767
913
|
this.stopStats();
|
|
768
914
|
await this.reporter?.stop();
|
|
769
915
|
this.reporter = null;
|
|
@@ -803,6 +949,7 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
803
949
|
});
|
|
804
950
|
transport.onBuffering(() => {
|
|
805
951
|
if (this.transport !== transport) return;
|
|
952
|
+
this.freeze.beginStall();
|
|
806
953
|
this.stalled = true;
|
|
807
954
|
this.emit("buffering", void 0);
|
|
808
955
|
});
|
|
@@ -810,12 +957,17 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
810
957
|
startStats() {
|
|
811
958
|
this.statsTimer = setInterval(async () => {
|
|
812
959
|
const stats = await this.transport?.getStats();
|
|
813
|
-
|
|
960
|
+
const freezeMs = this.freeze.take();
|
|
961
|
+
if (!stats) {
|
|
962
|
+
if (freezeMs > 0) this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs });
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
814
965
|
this.emit("stats", stats);
|
|
815
966
|
this.reporter?.add({
|
|
816
967
|
ts: Math.floor(Date.now() / 1e3),
|
|
817
968
|
bitrateKbps: stats.bitrateKbps,
|
|
818
|
-
fps: stats.framesPerSecond
|
|
969
|
+
fps: stats.framesPerSecond,
|
|
970
|
+
freezeMs
|
|
819
971
|
});
|
|
820
972
|
}, STATS_INTERVAL_MS2);
|
|
821
973
|
}
|