@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 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;
@@ -308,6 +346,9 @@ var WhepViewTransport = class {
308
346
  };
309
347
 
310
348
  // src/internal/scale-view-transport.ts
349
+ function retryWarmupNotFound(cfg, retryCount, res, retry) {
350
+ return retry || retryCount < (cfg?.maxNumRetry ?? 0) && res?.code === 404;
351
+ }
311
352
  var HlsViewTransport = class {
312
353
  /**
313
354
  * deliveryPath, when given, is a gateway-relative path from the gateway's own
@@ -318,6 +359,7 @@ var HlsViewTransport = class {
318
359
  constructor(signaling, deliveryPath) {
319
360
  this.signaling = signaling;
320
361
  this.deliveryPath = deliveryPath;
362
+ this.kind = "hls";
321
363
  this.hls = null;
322
364
  this.video = null;
323
365
  this.endedCb = null;
@@ -354,7 +396,22 @@ var HlsViewTransport = class {
354
396
  this.mutedByPolicy = (await playWithAutoplayFallback(video)).mutedByPolicy;
355
397
  return;
356
398
  }
357
- const hls = new Hls({ maxLiveSyncPlaybackRate: 1.1 });
399
+ const hls = new Hls({
400
+ maxLiveSyncPlaybackRate: 1.1,
401
+ manifestLoadPolicy: {
402
+ default: {
403
+ maxTimeToFirstByteMs: 1e4,
404
+ maxLoadTimeMs: 2e4,
405
+ timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 },
406
+ errorRetry: {
407
+ maxNumRetry: 5,
408
+ retryDelayMs: 500,
409
+ maxRetryDelayMs: 2e3,
410
+ shouldRetry: (cfg, retryCount, _isTimeout, res, retry) => retryWarmupNotFound(cfg, retryCount, res, retry)
411
+ }
412
+ }
413
+ }
414
+ });
358
415
  this.hls = hls;
359
416
  hls.on(Hls.Events.ERROR, (_evt, data) => {
360
417
  if (data.fatal) this.bufferingCb?.();
@@ -423,6 +480,7 @@ var FlvViewTransport = class {
423
480
  constructor(signaling, deliveryPath) {
424
481
  this.signaling = signaling;
425
482
  this.deliveryPath = deliveryPath;
483
+ this.kind = "flv_js";
426
484
  this.player = null;
427
485
  this.video = null;
428
486
  this.endedCb = null;
@@ -431,6 +489,8 @@ var FlvViewTransport = class {
431
489
  this.listeners = null;
432
490
  /** True when playback only started because the element had to be muted. */
433
491
  this.mutedByPolicy = false;
492
+ /** Decoded-frame count and timestamp of the previous getStats() call. */
493
+ this.lastFrames = null;
434
494
  }
435
495
  onEnded(cb) {
436
496
  this.endedCb = cb;
@@ -494,13 +554,33 @@ var FlvViewTransport = class {
494
554
  }
495
555
  this.video = null;
496
556
  }
557
+ /**
558
+ * Real playback statistics for this route.
559
+ *
560
+ * Both numbers used to be hardcoded zeros, which is worse than reporting
561
+ * nothing: the dashboard cannot tell a measured 0 kbps from an unmeasured
562
+ * one, so every flv.js viewer in production showed a downlink of 0 and the
563
+ * column read as a total outage. flv.js measures throughput itself
564
+ * (`statisticsInfo.speed`, KB/s), and the element counts decoded frames, so
565
+ * frame rate is the delta between two calls. Anything genuinely unavailable
566
+ * is left undefined rather than zeroed.
567
+ */
497
568
  async getStats() {
498
569
  if (!this.video) return null;
499
- return {
500
- bitrateKbps: 0,
501
- framesPerSecond: 0,
502
- latencyMs: void 0
503
- };
570
+ const speedKBs = this.player?.statisticsInfo?.speed;
571
+ const bitrateKbps = typeof speedKBs === "number" ? Math.round(speedKBs * 8) : void 0;
572
+ let framesPerSecond;
573
+ const q = this.video.getVideoPlaybackQuality?.();
574
+ const count = q?.totalVideoFrames;
575
+ const atMs = Date.now();
576
+ if (typeof count === "number") {
577
+ const prev = this.lastFrames;
578
+ if (prev && atMs > prev.atMs && count >= prev.count) {
579
+ framesPerSecond = Math.round((count - prev.count) * 1e3 / (atMs - prev.atMs));
580
+ }
581
+ this.lastFrames = { count, atMs };
582
+ }
583
+ return { bitrateKbps, framesPerSecond, latencyMs: void 0 };
504
584
  }
505
585
  };
506
586
 
@@ -536,7 +616,7 @@ function createViewCandidates(mode, signaling, deliveries = []) {
536
616
  }
537
617
 
538
618
  // src/internal/telemetry.ts
539
- var SDK_VERSION = "web/0.4.6";
619
+ var SDK_VERSION = "web/0.4.8";
540
620
  var FLUSH_INTERVAL_MS = 15e3;
541
621
  var MAX_BATCH = 64;
542
622
  function describeDevice() {
@@ -549,11 +629,12 @@ function describeNetwork() {
549
629
  return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
550
630
  }
551
631
  var QoeReporter = class {
552
- constructor(target, role, streamId, userId) {
632
+ constructor(target, role, streamId, userId, playerKind) {
553
633
  this.target = target;
554
634
  this.role = role;
555
635
  this.streamId = streamId;
556
636
  this.userId = userId;
637
+ this.playerKind = playerKind;
557
638
  this.sessionId = randomId();
558
639
  this.buffer = [];
559
640
  this.timer = null;
@@ -589,6 +670,7 @@ var QoeReporter = class {
589
670
  streamId: this.streamId,
590
671
  role: this.role,
591
672
  userId: this.userId,
673
+ playerKind: this.playerKind,
592
674
  samples,
593
675
  device: describeDevice(),
594
676
  network: describeNetwork()
@@ -713,7 +795,8 @@ var MebiusBroadcaster = class extends TypedEmitter {
713
795
  ts: Math.floor(Date.now() / 1e3),
714
796
  bitrateKbps: stats.bitrateKbps,
715
797
  fps: stats.framesPerSecond,
716
- rttMs: stats.rttMs
798
+ rttMs: stats.rttMs,
799
+ packetLossPct: stats.packetLossPct
717
800
  });
718
801
  }, STATS_INTERVAL_MS);
719
802
  }
@@ -727,6 +810,59 @@ function normalize(c, fallback) {
727
810
  return c;
728
811
  }
729
812
 
813
+ // src/internal/freeze-clock.ts
814
+ var FreezeClock = class {
815
+ constructor(now = Date.now) {
816
+ this.now = now;
817
+ /** When the current stall began, or null when playback is running. */
818
+ this.stalledSinceMs = null;
819
+ /** Stall time that has ended but has not yet been shipped with a sample. */
820
+ this.pendingMs = 0;
821
+ }
822
+ /** True while a stall is in progress. */
823
+ get stalled() {
824
+ return this.stalledSinceMs !== null;
825
+ }
826
+ /**
827
+ * Begin a stall. Re-entering while already stalled is ignored rather than
828
+ * restarting the clock: flv.js fires `waiting` repeatedly through a single
829
+ * long stall, and resetting the start on each would report a fraction of the
830
+ * freeze that actually happened.
831
+ */
832
+ beginStall() {
833
+ if (this.stalledSinceMs === null) this.stalledSinceMs = this.now();
834
+ }
835
+ /** End the current stall and bank its duration. No-op when not stalled. */
836
+ endStall() {
837
+ if (this.stalledSinceMs === null) return;
838
+ this.pendingMs += Math.max(0, this.now() - this.stalledSinceMs);
839
+ this.stalledSinceMs = null;
840
+ }
841
+ /**
842
+ * Freeze milliseconds to report on this tick, resetting the counter.
843
+ *
844
+ * A stall still in progress is counted up to now and its clock restarted, so
845
+ * a freeze longer than the sample interval is reported while it is happening
846
+ * rather than landing whole in whichever sample eventually follows it. Every
847
+ * millisecond is attributed exactly once — never dropped, never double-counted.
848
+ */
849
+ take() {
850
+ if (this.stalledSinceMs !== null) {
851
+ const now = this.now();
852
+ this.pendingMs += Math.max(0, now - this.stalledSinceMs);
853
+ this.stalledSinceMs = now;
854
+ }
855
+ const ms = this.pendingMs;
856
+ this.pendingMs = 0;
857
+ return ms;
858
+ }
859
+ /** Forget everything. Called when a session ends. */
860
+ reset() {
861
+ this.stalledSinceMs = null;
862
+ this.pendingMs = 0;
863
+ }
864
+ };
865
+
730
866
  // src/player.ts
731
867
  var STATS_INTERVAL_MS2 = 2e3;
732
868
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
@@ -744,6 +880,8 @@ var MebiusPlayer = class extends TypedEmitter {
744
880
  this.reporter = null;
745
881
  /** True between a `buffering` event and the element actually resuming. */
746
882
  this.stalled = false;
883
+ /** Measures how long playback was actually frozen; see FreezeClock. */
884
+ this.freeze = new FreezeClock();
747
885
  /** Cancels element listeners bound for the lifetime of one play(). */
748
886
  this.elementListeners = null;
749
887
  this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
@@ -762,6 +900,7 @@ var MebiusPlayer = class extends TypedEmitter {
762
900
  () => {
763
901
  if (!this.stalled || !this.playing) return;
764
902
  this.stalled = false;
903
+ this.freeze.endStall();
765
904
  this.emit("playing", { streamId });
766
905
  },
767
906
  { signal: this.elementListeners.signal }
@@ -777,7 +916,13 @@ var MebiusPlayer = class extends TypedEmitter {
777
916
  this.transport = candidate;
778
917
  this.playing = true;
779
918
  if (this.telemetry) {
780
- this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
919
+ this.reporter = new QoeReporter(
920
+ this.telemetry,
921
+ "play",
922
+ streamId,
923
+ this.userId,
924
+ candidate.kind
925
+ );
781
926
  this.reporter.start();
782
927
  this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
783
928
  }
@@ -805,6 +950,7 @@ var MebiusPlayer = class extends TypedEmitter {
805
950
  ELEMENT_OWNER.delete(this.video);
806
951
  }
807
952
  this.stalled = false;
953
+ this.freeze.reset();
808
954
  this.stopStats();
809
955
  await this.reporter?.stop();
810
956
  this.reporter = null;
@@ -844,6 +990,7 @@ var MebiusPlayer = class extends TypedEmitter {
844
990
  });
845
991
  transport.onBuffering(() => {
846
992
  if (this.transport !== transport) return;
993
+ this.freeze.beginStall();
847
994
  this.stalled = true;
848
995
  this.emit("buffering", void 0);
849
996
  });
@@ -851,12 +998,17 @@ var MebiusPlayer = class extends TypedEmitter {
851
998
  startStats() {
852
999
  this.statsTimer = setInterval(async () => {
853
1000
  const stats = await this.transport?.getStats();
854
- if (!stats) return;
1001
+ const freezeMs = this.freeze.take();
1002
+ if (!stats) {
1003
+ if (freezeMs > 0) this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs });
1004
+ return;
1005
+ }
855
1006
  this.emit("stats", stats);
856
1007
  this.reporter?.add({
857
1008
  ts: Math.floor(Date.now() / 1e3),
858
1009
  bitrateKbps: stats.bitrateKbps,
859
- fps: stats.framesPerSecond
1010
+ fps: stats.framesPerSecond,
1011
+ freezeMs
860
1012
  });
861
1013
  }, STATS_INTERVAL_MS2);
862
1014
  }