@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.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 bitrateKbps = 0;
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.availableOutgoingBitrate === "number") {
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
- return { bitrateKbps, framesPerSecond, rttMs };
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
- return {
459
- bitrateKbps: 0,
460
- framesPerSecond: 0,
461
- latencyMs: void 0
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.6";
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(this.telemetry, "play", streamId, this.userId);
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
- if (!stats) return;
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
  }