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