@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.d.ts CHANGED
@@ -90,21 +90,36 @@ interface PlayerOptions {
90
90
  * resolves to one.
91
91
  */
92
92
  type ViewTarget = HTMLVideoElement | string;
93
- /** Live broadcast statistics, emitted periodically on the `"stats"` event. */
93
+ /**
94
+ * Live broadcast statistics, emitted periodically on the `"stats"` event.
95
+ *
96
+ * Every field is optional: the first tick of a session has no previous sample
97
+ * to difference against, and a value that was never measured must stay absent
98
+ * rather than be reported as a confident zero.
99
+ */
94
100
  interface BroadcastStats {
95
- /** Outbound bitrate in kilobits per second. */
96
- bitrateKbps: number;
101
+ /** Outbound bitrate in kilobits per second — what is actually being sent. */
102
+ bitrateKbps?: number;
97
103
  /** Frames per second currently being sent. */
98
- framesPerSecond: number;
104
+ framesPerSecond?: number;
99
105
  /** Round-trip time to the gateway in milliseconds, if known. */
100
106
  rttMs?: number;
107
+ /** Percentage of sent packets the receiver reported missing, if known. */
108
+ packetLossPct?: number;
101
109
  }
102
- /** Live playback statistics, emitted periodically on the `"stats"` event. */
110
+ /**
111
+ * Live playback statistics, emitted periodically on the `"stats"` event.
112
+ *
113
+ * Fields are optional because not every route can measure every one, and a
114
+ * transport that reports 0 for something it never measured is indistinguishable
115
+ * from a stream that is genuinely delivering nothing — which is exactly how
116
+ * "0 kbps downlink" ended up on every viewer in the dashboard.
117
+ */
103
118
  interface PlaybackStats {
104
- /** Inbound bitrate in kilobits per second. */
105
- bitrateKbps: number;
106
- /** Frames per second currently being rendered. */
107
- framesPerSecond: number;
119
+ /** Inbound bitrate in kilobits per second, when the route can measure it. */
120
+ bitrateKbps?: number;
121
+ /** Frames per second currently being rendered, when known. */
122
+ framesPerSecond?: number;
108
123
  /** Estimated end-to-end latency in milliseconds, if known. */
109
124
  latencyMs?: number;
110
125
  }
@@ -275,6 +290,8 @@ declare class MebiusPlayer extends TypedEmitter<PlayerEventMap> {
275
290
  private reporter;
276
291
  /** True between a `buffering` event and the element actually resuming. */
277
292
  private stalled;
293
+ /** Measures how long playback was actually frozen; see FreezeClock. */
294
+ private readonly freeze;
278
295
  /** Cancels element listeners bound for the lifetime of one play(). */
279
296
  private elementListeners;
280
297
  /** @internal */
@@ -42724,6 +42724,8 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42724
42724
  this.signaling = signaling;
42725
42725
  this.pc = null;
42726
42726
  this.resourceUrl = null;
42727
+ /** Bytes sent and packet counters at the previous getStats() call. */
42728
+ this.lastOutbound = null;
42727
42729
  }
42728
42730
  async start(streamId, stream) {
42729
42731
  const pc = new RTCPeerConnection(DEFAULT_RTC_CONFIG);
@@ -42756,26 +42758,61 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42756
42758
  this.pc?.close();
42757
42759
  this.pc = null;
42758
42760
  }
42761
+ /**
42762
+ * Live broadcast statistics.
42763
+ *
42764
+ * Two corrections over the obvious reading of RTCStats:
42765
+ *
42766
+ * `bitrateKbps` is the delta of `outbound-rtp.bytesSent`, not
42767
+ * `availableOutgoingBitrate`. The latter is the congestion controller's
42768
+ * ESTIMATE of headroom, so a broadcaster on a fast link reported several
42769
+ * megabits while actually sending a fraction of that — the dashboard's
42770
+ * "bitrate adherence" score was measuring the network, not the encoder.
42771
+ *
42772
+ * `packetLossPct` comes from the receiver's report (`remote-inbound-rtp`),
42773
+ * which is the only place that knows what did not arrive. It was never
42774
+ * reported at all, and publishQualityScore treats a missing value as zero
42775
+ * loss — so every publisher scored full marks on a fifth of the rubric no
42776
+ * matter how bad the uplink was.
42777
+ */
42759
42778
  async getStats() {
42760
42779
  if (!this.pc) return null;
42761
42780
  const report = await this.pc.getStats();
42762
- let bitrateKbps = 0;
42763
- let framesPerSecond = 0;
42781
+ let framesPerSecond;
42764
42782
  let rttMs;
42783
+ let packetLossPct;
42784
+ let bytesSent;
42785
+ let packetsSent;
42786
+ let packetsLost;
42765
42787
  report.forEach((stat) => {
42766
42788
  if (stat.type === "outbound-rtp" && !stat.isRemote) {
42767
42789
  if (typeof stat.framesPerSecond === "number") framesPerSecond = stat.framesPerSecond;
42790
+ if (typeof stat.bytesSent === "number") bytesSent = (bytesSent ?? 0) + stat.bytesSent;
42791
+ if (typeof stat.packetsSent === "number") packetsSent = (packetsSent ?? 0) + stat.packetsSent;
42792
+ }
42793
+ if (stat.type === "remote-inbound-rtp") {
42794
+ if (typeof stat.packetsLost === "number") packetsLost = (packetsLost ?? 0) + stat.packetsLost;
42795
+ if (typeof stat.roundTripTime === "number") rttMs = Math.round(stat.roundTripTime * 1e3);
42768
42796
  }
42769
42797
  if (stat.type === "candidate-pair" && stat.state === "succeeded") {
42770
- if (typeof stat.availableOutgoingBitrate === "number") {
42771
- bitrateKbps = Math.round(stat.availableOutgoingBitrate / 1e3);
42772
- }
42773
- if (typeof stat.currentRoundTripTime === "number") {
42798
+ if (rttMs == null && typeof stat.currentRoundTripTime === "number") {
42774
42799
  rttMs = Math.round(stat.currentRoundTripTime * 1e3);
42775
42800
  }
42776
42801
  }
42777
42802
  });
42778
- return { bitrateKbps, framesPerSecond, rttMs };
42803
+ let bitrateKbps;
42804
+ const atMs = Date.now();
42805
+ if (bytesSent != null) {
42806
+ const prev = this.lastOutbound;
42807
+ if (prev && atMs > prev.atMs && bytesSent >= prev.bytes) {
42808
+ bitrateKbps = Math.round((bytesSent - prev.bytes) * 8 / 1e3 / ((atMs - prev.atMs) / 1e3));
42809
+ }
42810
+ this.lastOutbound = { bytes: bytesSent, atMs };
42811
+ }
42812
+ if (packetsLost != null && packetsSent != null && packetsSent > 0) {
42813
+ packetLossPct = Math.max(0, Math.min(100, packetsLost / packetsSent * 100));
42814
+ }
42815
+ return { bitrateKbps, framesPerSecond, rttMs, packetLossPct };
42779
42816
  }
42780
42817
  };
42781
42818
 
@@ -42805,6 +42842,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42805
42842
  var WhepViewTransport = class {
42806
42843
  constructor(signaling) {
42807
42844
  this.signaling = signaling;
42845
+ this.kind = "whep";
42808
42846
  this.pc = null;
42809
42847
  this.resourceUrl = null;
42810
42848
  this.endedCb = null;
@@ -42894,6 +42932,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42894
42932
  constructor(signaling, deliveryPath) {
42895
42933
  this.signaling = signaling;
42896
42934
  this.deliveryPath = deliveryPath;
42935
+ this.kind = "hls";
42897
42936
  this.hls = null;
42898
42937
  this.video = null;
42899
42938
  this.endedCb = null;
@@ -42999,6 +43038,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42999
43038
  constructor(signaling, deliveryPath) {
43000
43039
  this.signaling = signaling;
43001
43040
  this.deliveryPath = deliveryPath;
43041
+ this.kind = "flv_js";
43002
43042
  this.player = null;
43003
43043
  this.video = null;
43004
43044
  this.endedCb = null;
@@ -43007,6 +43047,8 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43007
43047
  this.listeners = null;
43008
43048
  /** True when playback only started because the element had to be muted. */
43009
43049
  this.mutedByPolicy = false;
43050
+ /** Decoded-frame count and timestamp of the previous getStats() call. */
43051
+ this.lastFrames = null;
43010
43052
  }
43011
43053
  onEnded(cb) {
43012
43054
  this.endedCb = cb;
@@ -43070,13 +43112,33 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43070
43112
  }
43071
43113
  this.video = null;
43072
43114
  }
43115
+ /**
43116
+ * Real playback statistics for this route.
43117
+ *
43118
+ * Both numbers used to be hardcoded zeros, which is worse than reporting
43119
+ * nothing: the dashboard cannot tell a measured 0 kbps from an unmeasured
43120
+ * one, so every flv.js viewer in production showed a downlink of 0 and the
43121
+ * column read as a total outage. flv.js measures throughput itself
43122
+ * (`statisticsInfo.speed`, KB/s), and the element counts decoded frames, so
43123
+ * frame rate is the delta between two calls. Anything genuinely unavailable
43124
+ * is left undefined rather than zeroed.
43125
+ */
43073
43126
  async getStats() {
43074
43127
  if (!this.video) return null;
43075
- return {
43076
- bitrateKbps: 0,
43077
- framesPerSecond: 0,
43078
- latencyMs: void 0
43079
- };
43128
+ const speedKBs = this.player?.statisticsInfo?.speed;
43129
+ const bitrateKbps = typeof speedKBs === "number" ? Math.round(speedKBs * 8) : void 0;
43130
+ let framesPerSecond;
43131
+ const q = this.video.getVideoPlaybackQuality?.();
43132
+ const count = q?.totalVideoFrames;
43133
+ const atMs = Date.now();
43134
+ if (typeof count === "number") {
43135
+ const prev = this.lastFrames;
43136
+ if (prev && atMs > prev.atMs && count >= prev.count) {
43137
+ framesPerSecond = Math.round((count - prev.count) * 1e3 / (atMs - prev.atMs));
43138
+ }
43139
+ this.lastFrames = { count, atMs };
43140
+ }
43141
+ return { bitrateKbps, framesPerSecond, latencyMs: void 0 };
43080
43142
  }
43081
43143
  };
43082
43144
 
@@ -43112,7 +43174,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43112
43174
  }
43113
43175
 
43114
43176
  // src/internal/telemetry.ts
43115
- var SDK_VERSION = "web/0.4.6";
43177
+ var SDK_VERSION = "web/0.4.8";
43116
43178
  var FLUSH_INTERVAL_MS = 15e3;
43117
43179
  var MAX_BATCH = 64;
43118
43180
  function describeDevice() {
@@ -43125,11 +43187,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43125
43187
  return conn?.effectiveType ? { type: conn.effectiveType } : void 0;
43126
43188
  }
43127
43189
  var QoeReporter = class {
43128
- constructor(target, role, streamId, userId) {
43190
+ constructor(target, role, streamId, userId, playerKind) {
43129
43191
  this.target = target;
43130
43192
  this.role = role;
43131
43193
  this.streamId = streamId;
43132
43194
  this.userId = userId;
43195
+ this.playerKind = playerKind;
43133
43196
  this.sessionId = randomId();
43134
43197
  this.buffer = [];
43135
43198
  this.timer = null;
@@ -43165,6 +43228,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43165
43228
  streamId: this.streamId,
43166
43229
  role: this.role,
43167
43230
  userId: this.userId,
43231
+ playerKind: this.playerKind,
43168
43232
  samples,
43169
43233
  device: describeDevice(),
43170
43234
  network: describeNetwork()
@@ -43289,7 +43353,8 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43289
43353
  ts: Math.floor(Date.now() / 1e3),
43290
43354
  bitrateKbps: stats.bitrateKbps,
43291
43355
  fps: stats.framesPerSecond,
43292
- rttMs: stats.rttMs
43356
+ rttMs: stats.rttMs,
43357
+ packetLossPct: stats.packetLossPct
43293
43358
  });
43294
43359
  }, STATS_INTERVAL_MS);
43295
43360
  }
@@ -43303,6 +43368,59 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43303
43368
  return c;
43304
43369
  }
43305
43370
 
43371
+ // src/internal/freeze-clock.ts
43372
+ var FreezeClock = class {
43373
+ constructor(now2 = Date.now) {
43374
+ this.now = now2;
43375
+ /** When the current stall began, or null when playback is running. */
43376
+ this.stalledSinceMs = null;
43377
+ /** Stall time that has ended but has not yet been shipped with a sample. */
43378
+ this.pendingMs = 0;
43379
+ }
43380
+ /** True while a stall is in progress. */
43381
+ get stalled() {
43382
+ return this.stalledSinceMs !== null;
43383
+ }
43384
+ /**
43385
+ * Begin a stall. Re-entering while already stalled is ignored rather than
43386
+ * restarting the clock: flv.js fires `waiting` repeatedly through a single
43387
+ * long stall, and resetting the start on each would report a fraction of the
43388
+ * freeze that actually happened.
43389
+ */
43390
+ beginStall() {
43391
+ if (this.stalledSinceMs === null) this.stalledSinceMs = this.now();
43392
+ }
43393
+ /** End the current stall and bank its duration. No-op when not stalled. */
43394
+ endStall() {
43395
+ if (this.stalledSinceMs === null) return;
43396
+ this.pendingMs += Math.max(0, this.now() - this.stalledSinceMs);
43397
+ this.stalledSinceMs = null;
43398
+ }
43399
+ /**
43400
+ * Freeze milliseconds to report on this tick, resetting the counter.
43401
+ *
43402
+ * A stall still in progress is counted up to now and its clock restarted, so
43403
+ * a freeze longer than the sample interval is reported while it is happening
43404
+ * rather than landing whole in whichever sample eventually follows it. Every
43405
+ * millisecond is attributed exactly once — never dropped, never double-counted.
43406
+ */
43407
+ take() {
43408
+ if (this.stalledSinceMs !== null) {
43409
+ const now2 = this.now();
43410
+ this.pendingMs += Math.max(0, now2 - this.stalledSinceMs);
43411
+ this.stalledSinceMs = now2;
43412
+ }
43413
+ const ms = this.pendingMs;
43414
+ this.pendingMs = 0;
43415
+ return ms;
43416
+ }
43417
+ /** Forget everything. Called when a session ends. */
43418
+ reset() {
43419
+ this.stalledSinceMs = null;
43420
+ this.pendingMs = 0;
43421
+ }
43422
+ };
43423
+
43306
43424
  // src/player.ts
43307
43425
  var STATS_INTERVAL_MS2 = 2e3;
43308
43426
  var FIRST_FRAME_TIMEOUT_MS = 8e3;
@@ -43320,6 +43438,8 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43320
43438
  this.reporter = null;
43321
43439
  /** True between a `buffering` event and the element actually resuming. */
43322
43440
  this.stalled = false;
43441
+ /** Measures how long playback was actually frozen; see FreezeClock. */
43442
+ this.freeze = new FreezeClock();
43323
43443
  /** Cancels element listeners bound for the lifetime of one play(). */
43324
43444
  this.elementListeners = null;
43325
43445
  this.candidates = createViewCandidates(options.mode ?? "auto", signaling, deliveries);
@@ -43338,6 +43458,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43338
43458
  () => {
43339
43459
  if (!this.stalled || !this.playing) return;
43340
43460
  this.stalled = false;
43461
+ this.freeze.endStall();
43341
43462
  this.emit("playing", { streamId });
43342
43463
  },
43343
43464
  { signal: this.elementListeners.signal }
@@ -43353,7 +43474,13 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43353
43474
  this.transport = candidate;
43354
43475
  this.playing = true;
43355
43476
  if (this.telemetry) {
43356
- this.reporter = new QoeReporter(this.telemetry, "play", streamId, this.userId);
43477
+ this.reporter = new QoeReporter(
43478
+ this.telemetry,
43479
+ "play",
43480
+ streamId,
43481
+ this.userId,
43482
+ candidate.kind
43483
+ );
43357
43484
  this.reporter.start();
43358
43485
  this.reporter.add({ ts: Math.floor(Date.now() / 1e3), firstFrameMs: Date.now() - startedAtMs });
43359
43486
  }
@@ -43381,6 +43508,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43381
43508
  ELEMENT_OWNER.delete(this.video);
43382
43509
  }
43383
43510
  this.stalled = false;
43511
+ this.freeze.reset();
43384
43512
  this.stopStats();
43385
43513
  await this.reporter?.stop();
43386
43514
  this.reporter = null;
@@ -43420,6 +43548,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43420
43548
  });
43421
43549
  transport.onBuffering(() => {
43422
43550
  if (this.transport !== transport) return;
43551
+ this.freeze.beginStall();
43423
43552
  this.stalled = true;
43424
43553
  this.emit("buffering", void 0);
43425
43554
  });
@@ -43427,12 +43556,17 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43427
43556
  startStats() {
43428
43557
  this.statsTimer = setInterval(async () => {
43429
43558
  const stats = await this.transport?.getStats();
43430
- if (!stats) return;
43559
+ const freezeMs = this.freeze.take();
43560
+ if (!stats) {
43561
+ if (freezeMs > 0) this.reporter?.add({ ts: Math.floor(Date.now() / 1e3), freezeMs });
43562
+ return;
43563
+ }
43431
43564
  this.emit("stats", stats);
43432
43565
  this.reporter?.add({
43433
43566
  ts: Math.floor(Date.now() / 1e3),
43434
43567
  bitrateKbps: stats.bitrateKbps,
43435
- fps: stats.framesPerSecond
43568
+ fps: stats.framesPerSecond,
43569
+ freezeMs
43436
43570
  });
43437
43571
  }, STATS_INTERVAL_MS2);
43438
43572
  }