@mebius-io/web 0.4.7 → 0.4.8
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 +152 -18
- 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 +152 -18
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +152 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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;
|
|
@@ -277,6 +315,7 @@ var HlsViewTransport = class {
|
|
|
277
315
|
constructor(signaling, deliveryPath) {
|
|
278
316
|
this.signaling = signaling;
|
|
279
317
|
this.deliveryPath = deliveryPath;
|
|
318
|
+
this.kind = "hls";
|
|
280
319
|
this.hls = null;
|
|
281
320
|
this.video = null;
|
|
282
321
|
this.endedCb = null;
|
|
@@ -382,6 +421,7 @@ var FlvViewTransport = class {
|
|
|
382
421
|
constructor(signaling, deliveryPath) {
|
|
383
422
|
this.signaling = signaling;
|
|
384
423
|
this.deliveryPath = deliveryPath;
|
|
424
|
+
this.kind = "flv_js";
|
|
385
425
|
this.player = null;
|
|
386
426
|
this.video = null;
|
|
387
427
|
this.endedCb = null;
|
|
@@ -390,6 +430,8 @@ var FlvViewTransport = class {
|
|
|
390
430
|
this.listeners = null;
|
|
391
431
|
/** True when playback only started because the element had to be muted. */
|
|
392
432
|
this.mutedByPolicy = false;
|
|
433
|
+
/** Decoded-frame count and timestamp of the previous getStats() call. */
|
|
434
|
+
this.lastFrames = null;
|
|
393
435
|
}
|
|
394
436
|
onEnded(cb) {
|
|
395
437
|
this.endedCb = cb;
|
|
@@ -453,13 +495,33 @@ var FlvViewTransport = class {
|
|
|
453
495
|
}
|
|
454
496
|
this.video = null;
|
|
455
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Real playback statistics for this route.
|
|
500
|
+
*
|
|
501
|
+
* Both numbers used to be hardcoded zeros, which is worse than reporting
|
|
502
|
+
* nothing: the dashboard cannot tell a measured 0 kbps from an unmeasured
|
|
503
|
+
* one, so every flv.js viewer in production showed a downlink of 0 and the
|
|
504
|
+
* column read as a total outage. flv.js measures throughput itself
|
|
505
|
+
* (`statisticsInfo.speed`, KB/s), and the element counts decoded frames, so
|
|
506
|
+
* frame rate is the delta between two calls. Anything genuinely unavailable
|
|
507
|
+
* is left undefined rather than zeroed.
|
|
508
|
+
*/
|
|
456
509
|
async getStats() {
|
|
457
510
|
if (!this.video) return null;
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
511
|
+
const speedKBs = this.player?.statisticsInfo?.speed;
|
|
512
|
+
const bitrateKbps = typeof speedKBs === "number" ? Math.round(speedKBs * 8) : void 0;
|
|
513
|
+
let framesPerSecond;
|
|
514
|
+
const q = this.video.getVideoPlaybackQuality?.();
|
|
515
|
+
const count = q?.totalVideoFrames;
|
|
516
|
+
const atMs = Date.now();
|
|
517
|
+
if (typeof count === "number") {
|
|
518
|
+
const prev = this.lastFrames;
|
|
519
|
+
if (prev && atMs > prev.atMs && count >= prev.count) {
|
|
520
|
+
framesPerSecond = Math.round((count - prev.count) * 1e3 / (atMs - prev.atMs));
|
|
521
|
+
}
|
|
522
|
+
this.lastFrames = { count, atMs };
|
|
523
|
+
}
|
|
524
|
+
return { bitrateKbps, framesPerSecond, latencyMs: void 0 };
|
|
463
525
|
}
|
|
464
526
|
};
|
|
465
527
|
|
|
@@ -495,7 +557,7 @@ function createViewCandidates(mode, signaling, deliveries = []) {
|
|
|
495
557
|
}
|
|
496
558
|
|
|
497
559
|
// src/internal/telemetry.ts
|
|
498
|
-
var SDK_VERSION = "web/0.4.
|
|
560
|
+
var SDK_VERSION = "web/0.4.8";
|
|
499
561
|
var FLUSH_INTERVAL_MS = 15e3;
|
|
500
562
|
var MAX_BATCH = 64;
|
|
501
563
|
function describeDevice() {
|
|
@@ -508,11 +570,12 @@ function describeNetwork() {
|
|
|
508
570
|
return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
|
|
509
571
|
}
|
|
510
572
|
var QoeReporter = class {
|
|
511
|
-
constructor(target, role, streamId, userId) {
|
|
573
|
+
constructor(target, role, streamId, userId, playerKind) {
|
|
512
574
|
this.target = target;
|
|
513
575
|
this.role = role;
|
|
514
576
|
this.streamId = streamId;
|
|
515
577
|
this.userId = userId;
|
|
578
|
+
this.playerKind = playerKind;
|
|
516
579
|
this.sessionId = randomId();
|
|
517
580
|
this.buffer = [];
|
|
518
581
|
this.timer = null;
|
|
@@ -548,6 +611,7 @@ var QoeReporter = class {
|
|
|
548
611
|
streamId: this.streamId,
|
|
549
612
|
role: this.role,
|
|
550
613
|
userId: this.userId,
|
|
614
|
+
playerKind: this.playerKind,
|
|
551
615
|
samples,
|
|
552
616
|
device: describeDevice(),
|
|
553
617
|
network: describeNetwork()
|
|
@@ -672,7 +736,8 @@ var MebiusBroadcaster = class extends TypedEmitter {
|
|
|
672
736
|
ts: Math.floor(Date.now() / 1e3),
|
|
673
737
|
bitrateKbps: stats.bitrateKbps,
|
|
674
738
|
fps: stats.framesPerSecond,
|
|
675
|
-
rttMs: stats.rttMs
|
|
739
|
+
rttMs: stats.rttMs,
|
|
740
|
+
packetLossPct: stats.packetLossPct
|
|
676
741
|
});
|
|
677
742
|
}, STATS_INTERVAL_MS);
|
|
678
743
|
}
|
|
@@ -686,6 +751,59 @@ function normalize(c, fallback) {
|
|
|
686
751
|
return c;
|
|
687
752
|
}
|
|
688
753
|
|
|
754
|
+
// src/internal/freeze-clock.ts
|
|
755
|
+
var FreezeClock = class {
|
|
756
|
+
constructor(now = Date.now) {
|
|
757
|
+
this.now = now;
|
|
758
|
+
/** When the current stall began, or null when playback is running. */
|
|
759
|
+
this.stalledSinceMs = null;
|
|
760
|
+
/** Stall time that has ended but has not yet been shipped with a sample. */
|
|
761
|
+
this.pendingMs = 0;
|
|
762
|
+
}
|
|
763
|
+
/** True while a stall is in progress. */
|
|
764
|
+
get stalled() {
|
|
765
|
+
return this.stalledSinceMs !== null;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Begin a stall. Re-entering while already stalled is ignored rather than
|
|
769
|
+
* restarting the clock: flv.js fires `waiting` repeatedly through a single
|
|
770
|
+
* long stall, and resetting the start on each would report a fraction of the
|
|
771
|
+
* freeze that actually happened.
|
|
772
|
+
*/
|
|
773
|
+
beginStall() {
|
|
774
|
+
if (this.stalledSinceMs === null) this.stalledSinceMs = this.now();
|
|
775
|
+
}
|
|
776
|
+
/** End the current stall and bank its duration. No-op when not stalled. */
|
|
777
|
+
endStall() {
|
|
778
|
+
if (this.stalledSinceMs === null) return;
|
|
779
|
+
this.pendingMs += Math.max(0, this.now() - this.stalledSinceMs);
|
|
780
|
+
this.stalledSinceMs = null;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Freeze milliseconds to report on this tick, resetting the counter.
|
|
784
|
+
*
|
|
785
|
+
* A stall still in progress is counted up to now and its clock restarted, so
|
|
786
|
+
* a freeze longer than the sample interval is reported while it is happening
|
|
787
|
+
* rather than landing whole in whichever sample eventually follows it. Every
|
|
788
|
+
* millisecond is attributed exactly once — never dropped, never double-counted.
|
|
789
|
+
*/
|
|
790
|
+
take() {
|
|
791
|
+
if (this.stalledSinceMs !== null) {
|
|
792
|
+
const now = this.now();
|
|
793
|
+
this.pendingMs += Math.max(0, now - this.stalledSinceMs);
|
|
794
|
+
this.stalledSinceMs = now;
|
|
795
|
+
}
|
|
796
|
+
const ms = this.pendingMs;
|
|
797
|
+
this.pendingMs = 0;
|
|
798
|
+
return ms;
|
|
799
|
+
}
|
|
800
|
+
/** Forget everything. Called when a session ends. */
|
|
801
|
+
reset() {
|
|
802
|
+
this.stalledSinceMs = null;
|
|
803
|
+
this.pendingMs = 0;
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
689
807
|
// src/player.ts
|
|
690
808
|
var STATS_INTERVAL_MS2 = 2e3;
|
|
691
809
|
var FIRST_FRAME_TIMEOUT_MS = 8e3;
|
|
@@ -703,6 +821,8 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
703
821
|
this.reporter = null;
|
|
704
822
|
/** True between a `buffering` event and the element actually resuming. */
|
|
705
823
|
this.stalled = false;
|
|
824
|
+
/** Measures how long playback was actually frozen; see FreezeClock. */
|
|
825
|
+
this.freeze = new FreezeClock();
|
|
706
826
|
/** Cancels element listeners bound for the lifetime of one play(). */
|
|
707
827
|
this.elementListeners = null;
|
|
708
828
|
this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
|
|
@@ -721,6 +841,7 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
721
841
|
() => {
|
|
722
842
|
if (!this.stalled || !this.playing) return;
|
|
723
843
|
this.stalled = false;
|
|
844
|
+
this.freeze.endStall();
|
|
724
845
|
this.emit("playing", { streamId });
|
|
725
846
|
},
|
|
726
847
|
{ signal: this.elementListeners.signal }
|
|
@@ -736,7 +857,13 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
736
857
|
this.transport = candidate;
|
|
737
858
|
this.playing = true;
|
|
738
859
|
if (this.telemetry) {
|
|
739
|
-
this.reporter = new QoeReporter(
|
|
860
|
+
this.reporter = new QoeReporter(
|
|
861
|
+
this.telemetry,
|
|
862
|
+
"play",
|
|
863
|
+
streamId,
|
|
864
|
+
this.userId,
|
|
865
|
+
candidate.kind
|
|
866
|
+
);
|
|
740
867
|
this.reporter.start();
|
|
741
868
|
this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
|
|
742
869
|
}
|
|
@@ -764,6 +891,7 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
764
891
|
ELEMENT_OWNER.delete(this.video);
|
|
765
892
|
}
|
|
766
893
|
this.stalled = false;
|
|
894
|
+
this.freeze.reset();
|
|
767
895
|
this.stopStats();
|
|
768
896
|
await this.reporter?.stop();
|
|
769
897
|
this.reporter = null;
|
|
@@ -803,6 +931,7 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
803
931
|
});
|
|
804
932
|
transport.onBuffering(() => {
|
|
805
933
|
if (this.transport !== transport) return;
|
|
934
|
+
this.freeze.beginStall();
|
|
806
935
|
this.stalled = true;
|
|
807
936
|
this.emit("buffering", void 0);
|
|
808
937
|
});
|
|
@@ -810,12 +939,17 @@ var MebiusPlayer = class extends TypedEmitter {
|
|
|
810
939
|
startStats() {
|
|
811
940
|
this.statsTimer = setInterval(async () => {
|
|
812
941
|
const stats = await this.transport?.getStats();
|
|
813
|
-
|
|
942
|
+
const freezeMs = this.freeze.take();
|
|
943
|
+
if (!stats) {
|
|
944
|
+
if (freezeMs > 0) this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs });
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
814
947
|
this.emit("stats", stats);
|
|
815
948
|
this.reporter?.add({
|
|
816
949
|
ts: Math.floor(Date.now() / 1e3),
|
|
817
950
|
bitrateKbps: stats.bitrateKbps,
|
|
818
|
-
fps: stats.framesPerSecond
|
|
951
|
+
fps: stats.framesPerSecond,
|
|
952
|
+
freezeMs
|
|
819
953
|
});
|
|
820
954
|
}, STATS_INTERVAL_MS2);
|
|
821
955
|
}
|