@mebius-io/web 0.4.6 → 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 CHANGED
@@ -148,6 +148,8 @@ var WhipPublishTransport = class {
148
148
  this.signaling = signaling;
149
149
  this.pc = null;
150
150
  this.resourceUrl = null;
151
+ /** Bytes sent and packet counters at the previous getStats() call. */
152
+ this.lastOutbound = null;
151
153
  }
152
154
  async start(streamId, stream) {
153
155
  const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
@@ -180,26 +182,61 @@ var WhipPublishTransport = class {
180
182
  this.pc?.close();
181
183
  this.pc = null;
182
184
  }
185
+ /**
186
+ * Live broadcast statistics.
187
+ *
188
+ * Two corrections over the obvious reading of RTCStats:
189
+ *
190
+ * `bitrateKbps` is the delta of `outbound-rtp.bytesSent`, not
191
+ * `availableOutgoingBitrate`. The latter is the congestion controller's
192
+ * ESTIMATE of headroom, so a broadcaster on a fast link reported several
193
+ * megabits while actually sending a fraction of that — the dashboard's
194
+ * "bitrate adherence" score was measuring the network, not the encoder.
195
+ *
196
+ * `packetLossPct` comes from the receiver's report (`remote-inbound-rtp`),
197
+ * which is the only place that knows what did not arrive. It was never
198
+ * reported at all, and publishQualityScore treats a missing value as zero
199
+ * loss — so every publisher scored full marks on a fifth of the rubric no
200
+ * matter how bad the uplink was.
201
+ */
183
202
  async getStats() {
184
203
  if (!this.pc) return null;
185
204
  const report = await this.pc.getStats();
186
- let bitrateKbps = 0;
187
- let framesPerSecond = 0;
205
+ let framesPerSecond;
188
206
  let rttMs;
207
+ let packetLossPct;
208
+ let bytesSent;
209
+ let packetsSent;
210
+ let packetsLost;
189
211
  report.forEach((stat) => {
190
212
  if (stat.type === "outbound-rtp" && !stat.isRemote) {
191
213
  if (typeof stat.framesPerSecond === "number") framesPerSecond = stat.framesPerSecond;
214
+ if (typeof stat.bytesSent === "number") bytesSent = (bytesSent ?? 0) + stat.bytesSent;
215
+ if (typeof stat.packetsSent === "number") packetsSent = (packetsSent ?? 0) + stat.packetsSent;
216
+ }
217
+ if (stat.type === "remote-inbound-rtp") {
218
+ if (typeof stat.packetsLost === "number") packetsLost = (packetsLost ?? 0) + stat.packetsLost;
219
+ if (typeof stat.roundTripTime === "number") rttMs = Math.round(stat.roundTripTime * 1e3);
192
220
  }
193
221
  if (stat.type === "candidate-pair" && stat.state === "succeeded") {
194
- if (typeof stat.availableOutgoingBitrate === "number") {
195
- bitrateKbps = Math.round(stat.availableOutgoingBitrate / 1e3);
196
- }
197
- if (typeof stat.currentRoundTripTime === "number") {
222
+ if (rttMs == null && typeof stat.currentRoundTripTime === "number") {
198
223
  rttMs = Math.round(stat.currentRoundTripTime * 1e3);
199
224
  }
200
225
  }
201
226
  });
202
- return { bitrateKbps, framesPerSecond, rttMs };
227
+ let bitrateKbps;
228
+ const atMs = Date.now();
229
+ if (bytesSent != null) {
230
+ const prev = this.lastOutbound;
231
+ if (prev && atMs > prev.atMs && bytesSent >= prev.bytes) {
232
+ bitrateKbps = Math.round((bytesSent - prev.bytes) * 8 / 1e3 / ((atMs - prev.atMs) / 1e3));
233
+ }
234
+ this.lastOutbound = { bytes: bytesSent, atMs };
235
+ }
236
+ if (packetsLost != null && packetsSent != null && packetsSent > 0) {
237
+ packetLossPct = Math.max(0, Math.min(100, packetsLost / packetsSent * 100));
238
+ }
239
+ return { bitrateKbps, framesPerSecond, rttMs, packetLossPct };
203
240
  }
204
241
  };
205
242
 
@@ -229,6 +266,7 @@ function resetVideoElement(video) {
229
266
  var WhepViewTransport = class {
230
267
  constructor(signaling) {
231
268
  this.signaling = signaling;
269
+ this.kind = "whep";
232
270
  this.pc = null;
233
271
  this.resourceUrl = null;
234
272
  this.endedCb = null;
@@ -318,6 +356,7 @@ var HlsViewTransport = class {
318
356
  constructor(signaling, deliveryPath) {
319
357
  this.signaling = signaling;
320
358
  this.deliveryPath = deliveryPath;
359
+ this.kind = "hls";
321
360
  this.hls = null;
322
361
  this.video = null;
323
362
  this.endedCb = null;
@@ -354,7 +393,7 @@ var HlsViewTransport = class {
354
393
  this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
355
394
  return;
356
395
  }
357
- const hls = new Hls({ maxLiveSyncPlaybackRate: 1.5 });
396
+ const hls = new Hls({ maxLiveSyncPlaybackRate: 1.1 });
358
397
  this.hls = hls;
359
398
  hls.on(Hls.Events.ERROR, (_evt, data) => {
360
399
  if (data.fatal) this.bufferingCb?.();
@@ -423,6 +462,7 @@ var FlvViewTransport = class {
423
462
  constructor(signaling, deliveryPath) {
424
463
  this.signaling = signaling;
425
464
  this.deliveryPath = deliveryPath;
465
+ this.kind = "flv_js";
426
466
  this.player = null;
427
467
  this.video = null;
428
468
  this.endedCb = null;
@@ -431,6 +471,8 @@ var FlvViewTransport = class {
431
471
  this.listeners = null;
432
472
  /** True when playback only started because the element had to be muted. */
433
473
  this.mutedByPolicy = false;
474
+ /** Decoded-frame count and timestamp of the previous getStats() call. */
475
+ this.lastFrames = null;
434
476
  }
435
477
  onEnded(cb) {
436
478
  this.endedCb = cb;
@@ -494,13 +536,33 @@ var FlvViewTransport = class {
494
536
  }
495
537
  this.video = null;
496
538
  }
539
+ /**
540
+ * Real playback statistics for this route.
541
+ *
542
+ * Both numbers used to be hardcoded zeros, which is worse than reporting
543
+ * nothing: the dashboard cannot tell a measured 0 kbps from an unmeasured
544
+ * one, so every flv.js viewer in production showed a downlink of 0 and the
545
+ * column read as a total outage. flv.js measures throughput itself
546
+ * (`statisticsInfo.speed`, KB/s), and the element counts decoded frames, so
547
+ * frame rate is the delta between two calls. Anything genuinely unavailable
548
+ * is left undefined rather than zeroed.
549
+ */
497
550
  async getStats() {
498
551
  if (!this.video) return null;
499
- return {
500
- bitrateKbps: 0,
501
- framesPerSecond: 0,
502
- latencyMs: void 0
503
- };
552
+ const speedKBs = this.player?.statisticsInfo?.speed;
553
+ const bitrateKbps = typeof speedKBs === "number" ? Math.round(speedKBs * 8) : void 0;
554
+ let framesPerSecond;
555
+ const q = this.video.getVideoPlaybackQuality?.();
556
+ const count = q?.totalVideoFrames;
557
+ const atMs = Date.now();
558
+ if (typeof count === "number") {
559
+ const prev = this.lastFrames;
560
+ if (prev && atMs > prev.atMs && count >= prev.count) {
561
+ framesPerSecond = Math.round((count - prev.count) * 1e3 / (atMs - prev.atMs));
562
+ }
563
+ this.lastFrames = { count, atMs };
564
+ }
565
+ return { bitrateKbps, framesPerSecond, latencyMs: void 0 };
504
566
  }
505
567
  };
506
568
 
@@ -536,7 +598,7 @@ function createViewCandidates(mode, signaling, deliveries = []) {
536
598
  }
537
599
 
538
600
  // src/internal/telemetry.ts
539
- var SDK_VERSION = "web/0.4.6";
601
+ var SDK_VERSION = "web/0.4.8";
540
602
  var FLUSH_INTERVAL_MS = 15e3;
541
603
  var MAX_BATCH = 64;
542
604
  function describeDevice() {
@@ -549,11 +611,12 @@ function describeNetwork() {
549
611
  return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
550
612
  }
551
613
  var QoeReporter = class {
552
- constructor(target, role, streamId, userId) {
614
+ constructor(target, role, streamId, userId, playerKind) {
553
615
  this.target = target;
554
616
  this.role = role;
555
617
  this.streamId = streamId;
556
618
  this.userId = userId;
619
+ this.playerKind = playerKind;
557
620
  this.sessionId = randomId();
558
621
  this.buffer = [];
559
622
  this.timer = null;
@@ -589,6 +652,7 @@ var QoeReporter = class {
589
652
  streamId: this.streamId,
590
653
  role: this.role,
591
654
  userId: this.userId,
655
+ playerKind: this.playerKind,
592
656
  samples,
593
657
  device: describeDevice(),
594
658
  network: describeNetwork()
@@ -713,7 +777,8 @@ var MebiusBroadcaster = class extends TypedEmitter {
713
777
  ts: Math.floor(Date.now() / 1e3),
714
778
  bitrateKbps: stats.bitrateKbps,
715
779
  fps: stats.framesPerSecond,
716
- rttMs: stats.rttMs
780
+ rttMs: stats.rttMs,
781
+ packetLossPct: stats.packetLossPct
717
782
  });
718
783
  }, STATS_INTERVAL_MS);
719
784
  }
@@ -727,6 +792,59 @@ function normalize(c, fallback) {
727
792
  return c;
728
793
  }
729
794
 
795
+ // src/internal/freeze-clock.ts
796
+ var FreezeClock = class {
797
+ constructor(now = Date.now) {
798
+ this.now = now;
799
+ /** When the current stall began, or null when playback is running. */
800
+ this.stalledSinceMs = null;
801
+ /** Stall time that has ended but has not yet been shipped with a sample. */
802
+ this.pendingMs = 0;
803
+ }
804
+ /** True while a stall is in progress. */
805
+ get stalled() {
806
+ return this.stalledSinceMs !== null;
807
+ }
808
+ /**
809
+ * Begin a stall. Re-entering while already stalled is ignored rather than
810
+ * restarting the clock: flv.js fires `waiting` repeatedly through a single
811
+ * long stall, and resetting the start on each would report a fraction of the
812
+ * freeze that actually happened.
813
+ */
814
+ beginStall() {
815
+ if (this.stalledSinceMs === null) this.stalledSinceMs = this.now();
816
+ }
817
+ /** End the current stall and bank its duration. No-op when not stalled. */
818
+ endStall() {
819
+ if (this.stalledSinceMs === null) return;
820
+ this.pendingMs += Math.max(0, this.now() - this.stalledSinceMs);
821
+ this.stalledSinceMs = null;
822
+ }
823
+ /**
824
+ * Freeze milliseconds to report on this tick, resetting the counter.
825
+ *
826
+ * A stall still in progress is counted up to now and its clock restarted, so
827
+ * a freeze longer than the sample interval is reported while it is happening
828
+ * rather than landing whole in whichever sample eventually follows it. Every
829
+ * millisecond is attributed exactly once — never dropped, never double-counted.
830
+ */
831
+ take() {
832
+ if (this.stalledSinceMs !== null) {
833
+ const now = this.now();
834
+ this.pendingMs += Math.max(0, now - this.stalledSinceMs);
835
+ this.stalledSinceMs = now;
836
+ }
837
+ const ms = this.pendingMs;
838
+ this.pendingMs = 0;
839
+ return ms;
840
+ }
841
+ /** Forget everything. Called when a session ends. */
842
+ reset() {
843
+ this.stalledSinceMs = null;
844
+ this.pendingMs = 0;
845
+ }
846
+ };
847
+
730
848
  // src/player.ts
731
849
  var STATS_INTERVAL_MS2 = 2e3;
732
850
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
@@ -744,6 +862,8 @@ var MebiusPlayer = class extends TypedEmitter {
744
862
  this.reporter = null;
745
863
  /** True between a `buffering` event and the element actually resuming. */
746
864
  this.stalled = false;
865
+ /** Measures how long playback was actually frozen; see FreezeClock. */
866
+ this.freeze = new FreezeClock();
747
867
  /** Cancels element listeners bound for the lifetime of one play(). */
748
868
  this.elementListeners = null;
749
869
  this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
@@ -762,6 +882,7 @@ var MebiusPlayer = class extends TypedEmitter {
762
882
  () => {
763
883
  if (!this.stalled || !this.playing) return;
764
884
  this.stalled = false;
885
+ this.freeze.endStall();
765
886
  this.emit("playing", { streamId });
766
887
  },
767
888
  { signal: this.elementListeners.signal }
@@ -777,7 +898,13 @@ var MebiusPlayer = class extends TypedEmitter {
777
898
  this.transport = candidate;
778
899
  this.playing = true;
779
900
  if (this.telemetry) {
780
- this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
901
+ this.reporter = new QoeReporter(
902
+ this.telemetry,
903
+ "play",
904
+ streamId,
905
+ this.userId,
906
+ candidate.kind
907
+ );
781
908
  this.reporter.start();
782
909
  this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
783
910
  }
@@ -805,6 +932,7 @@ var MebiusPlayer = class extends TypedEmitter {
805
932
  ELEMENT_OWNER.delete(this.video);
806
933
  }
807
934
  this.stalled = false;
935
+ this.freeze.reset();
808
936
  this.stopStats();
809
937
  await this.reporter?.stop();
810
938
  this.reporter = null;
@@ -844,6 +972,7 @@ var MebiusPlayer = class extends TypedEmitter {
844
972
  });
845
973
  transport.onBuffering(() => {
846
974
  if (this.transport !== transport) return;
975
+ this.freeze.beginStall();
847
976
  this.stalled = true;
848
977
  this.emit("buffering", void 0);
849
978
  });
@@ -851,12 +980,17 @@ var MebiusPlayer = class extends TypedEmitter {
851
980
  startStats() {
852
981
  this.statsTimer = setInterval(async () => {
853
982
  const stats = await this.transport?.getStats();
854
- if (!stats) return;
983
+ const freezeMs = this.freeze.take();
984
+ if (!stats) {
985
+ if (freezeMs > 0) this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs });
986
+ return;
987
+ }
855
988
  this.emit("stats", stats);
856
989
  this.reporter?.add({
857
990
  ts: Math.floor(Date.now() / 1e3),
858
991
  bitrateKbps: stats.bitrateKbps,
859
- fps: stats.framesPerSecond
992
+ fps: stats.framesPerSecond,
993
+ freezeMs
860
994
  });
861
995
  }, STATS_INTERVAL_MS2);
862
996
  }