@mattebox/player-diagnostics 0.4.0 → 0.5.0

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/charts.js CHANGED
@@ -1,401 +1,365 @@
1
- import { bitrate, clock } from './format.js';
2
- export const CHART_TABS = [
3
- 'buffer',
4
- 'throughput',
5
- 'stalls',
6
- 'frames',
7
- 'switches',
1
+ import { setChildren } from "./dom.js";
2
+ import { bitrate, clock } from "./format.js";
3
+ //#region src/charts.ts
4
+ const CHART_TABS = [
5
+ "buffer",
6
+ "throughput",
7
+ "stalls",
8
+ "frames",
9
+ "switches"
8
10
  ];
9
11
  /** A round step for a range: 1, 2, 5 times a power of ten. */
10
12
  function niceStep(range, target) {
11
- const raw = range / Math.max(target, 1);
12
- const pow = 10 ** Math.floor(Math.log10(Math.max(raw, 1e-9)));
13
- for (const m of [1, 2, 5, 10])
14
- if (raw <= m * pow)
15
- return m * pow;
16
- return 10 * pow;
13
+ const raw = range / Math.max(target, 1);
14
+ const pow = 10 ** Math.floor(Math.log10(Math.max(raw, 1e-9)));
15
+ for (const m of [
16
+ 1,
17
+ 2,
18
+ 5,
19
+ 10
20
+ ]) if (raw <= m * pow) return m * pow;
21
+ return 10 * pow;
17
22
  }
18
23
  /** Greys with the alpha the grid and the bands need, from any colour the tokens give. */
19
24
  function alpha(color, value) {
20
- const probe = document.createElement('canvas').getContext('2d');
21
- if (probe === null)
22
- return color;
23
- probe.fillStyle = color;
24
- const hex = probe.fillStyle;
25
- if (!/^#[0-9a-f]{6}$/i.test(hex))
26
- return color;
27
- const r = Number.parseInt(hex.slice(1, 3), 16);
28
- const g = Number.parseInt(hex.slice(3, 5), 16);
29
- const b = Number.parseInt(hex.slice(5, 7), 16);
30
- return `rgba(${r}, ${g}, ${b}, ${value})`;
25
+ const probe = document.createElement("canvas").getContext("2d");
26
+ if (probe === null) return color;
27
+ probe.fillStyle = color;
28
+ const hex = probe.fillStyle;
29
+ if (!/^#[0-9a-f]{6}$/i.test(hex)) return color;
30
+ return `rgba(${Number.parseInt(hex.slice(1, 3), 16)}, ${Number.parseInt(hex.slice(3, 5), 16)}, ${Number.parseInt(hex.slice(5, 7), 16)}, ${value})`;
31
31
  }
32
- export function createCharts(canvas, legend, readout) {
33
- function frame() {
34
- const ctx = canvas.getContext('2d');
35
- if (ctx === null)
36
- return null;
37
- const dpr = window.devicePixelRatio || 1;
38
- const w = canvas.clientWidth;
39
- const h = canvas.clientHeight;
40
- if (w === 0 || h === 0)
41
- return null;
42
- const width = Math.round(w * dpr);
43
- const height = Math.round(h * dpr);
44
- if (canvas.width !== width || canvas.height !== height) {
45
- canvas.width = width;
46
- canvas.height = height;
47
- }
48
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
49
- ctx.clearRect(0, 0, w, h);
50
- ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
51
- return { ctx, w, h, left: 44, right: w - 8, top: 8, bottom: h - 18 };
52
- }
53
- /** The wall-time axis, now at the right edge, labelled back from it. Returns the mapping. */
54
- function wallAxis(f, t0, t1, p) {
55
- const x = (t) => f.left + ((t - t0) / (t1 - t0)) * (f.right - f.left);
56
- const span = (t1 - t0) / 1000;
57
- const step = niceStep(span, 6);
58
- f.ctx.strokeStyle = alpha(p.muted, 0.25);
59
- f.ctx.fillStyle = p.muted;
60
- f.ctx.textAlign = 'center';
61
- f.ctx.textBaseline = 'top';
62
- for (let back = 0; back <= span; back += step) {
63
- const px = x(t1 - back * 1000);
64
- f.ctx.beginPath();
65
- f.ctx.moveTo(px, f.top);
66
- f.ctx.lineTo(px, f.bottom);
67
- f.ctx.stroke();
68
- const text = back === 0 ? 'now' : back < 60 ? `-${back}s` : `-${clock(back)}`;
69
- f.ctx.fillText(text, px, f.bottom + 3);
70
- }
71
- return x;
72
- }
73
- /** The value axis from zero, four gridlines, labelled by `unit`. Returns the mapping. */
74
- function valueAxis(f, max, unit, p) {
75
- const y = (v) => f.bottom - (v / max) * (f.bottom - f.top);
76
- const step = niceStep(max, 4);
77
- f.ctx.strokeStyle = alpha(p.muted, 0.25);
78
- f.ctx.fillStyle = p.muted;
79
- f.ctx.textAlign = 'right';
80
- f.ctx.textBaseline = 'middle';
81
- for (let v = 0; v <= max; v += step) {
82
- const py = y(v);
83
- f.ctx.beginPath();
84
- f.ctx.moveTo(f.left, py);
85
- f.ctx.lineTo(f.right, py);
86
- f.ctx.stroke();
87
- f.ctx.fillText(unit(v), f.left - 4, py);
88
- }
89
- return y;
90
- }
91
- function line(f, points, color, options = {}) {
92
- if (points.length === 0)
93
- return;
94
- f.ctx.save();
95
- f.ctx.strokeStyle = color;
96
- f.ctx.lineWidth = options.width ?? 2;
97
- f.ctx.setLineDash(options.dashed === true ? [4, 4] : []);
98
- f.ctx.beginPath();
99
- let previous;
100
- for (const point of points) {
101
- if (previous === undefined)
102
- f.ctx.moveTo(point[0], point[1]);
103
- else if (options.step === true) {
104
- f.ctx.lineTo(point[0], previous[1]);
105
- f.ctx.lineTo(point[0], point[1]);
106
- }
107
- else
108
- f.ctx.lineTo(point[0], point[1]);
109
- previous = point;
110
- }
111
- f.ctx.stroke();
112
- f.ctx.restore();
113
- }
114
- function dot(f, px, py, color, r = 2.5) {
115
- f.ctx.fillStyle = color;
116
- f.ctx.beginPath();
117
- f.ctx.arc(px, py, r, 0, Math.PI * 2);
118
- f.ctx.fill();
119
- }
120
- /** A dashed vertical rule with a glyph at its top. */
121
- function marker(f, px, color, glyph) {
122
- f.ctx.save();
123
- f.ctx.strokeStyle = color;
124
- f.ctx.lineWidth = 1;
125
- f.ctx.setLineDash([2, 3]);
126
- f.ctx.beginPath();
127
- f.ctx.moveTo(px, f.top);
128
- f.ctx.lineTo(px, f.bottom);
129
- f.ctx.stroke();
130
- f.ctx.restore();
131
- f.ctx.fillStyle = color;
132
- f.ctx.textAlign = 'center';
133
- f.ctx.textBaseline = 'top';
134
- f.ctx.fillText(glyph, px, f.top);
135
- }
136
- function setLegend(items) {
137
- legend.replaceChildren();
138
- for (const [text, color] of items) {
139
- const item = document.createElement('span');
140
- item.setAttribute('part', 'legend-item');
141
- const swatch = document.createElement('span');
142
- swatch.setAttribute('part', 'swatch');
143
- swatch.style.background = color;
144
- item.append(swatch, text);
145
- legend.append(item);
146
- }
147
- }
148
- /** The samples in the window, plus the one before it so the line enters from the left. */
149
- function visible(samples, t0) {
150
- const from = samples.findIndex((s) => s.t >= t0);
151
- if (from < 0)
152
- return samples.length > 0 ? [samples[samples.length - 1]] : [];
153
- return samples.slice(Math.max(0, from - 1));
154
- }
155
- function renditions(engine) {
156
- return engine?.quality.renditions ?? [];
157
- }
158
- function drawBuffer(f, input) {
159
- const { video, engine, palette: p } = input;
160
- const state = engine?.stats.snapshot() ?? null;
161
- const duration = state?.presentation?.duration ?? video.duration;
162
- const live = state?.live ?? null;
163
- const now = video.currentTime;
164
- let d0 = 0;
165
- let d1 = Number.isFinite(duration) && duration > 0 ? duration : Math.max(now + 10, 10);
166
- if (live !== null) {
167
- d0 = live.span.start;
168
- d1 = Math.max(live.span.end, now + 10);
169
- }
170
- else if (d1 > 600) {
171
- d0 = Math.max(0, now - 60);
172
- d1 = now + 180;
173
- }
174
- const x = (t) => f.left + ((t - d0) / (d1 - d0)) * (f.right - f.left);
175
- // One row per source buffer, or the video's own ranges for a native session.
176
- const rows = state === null
177
- ? [
178
- {
179
- label: 'video',
180
- ranges: Array.from({ length: video.buffered.length }, (_, i) => ({
181
- start: video.buffered.start(i),
182
- end: video.buffered.end(i),
183
- })),
184
- },
185
- ]
186
- : [...state.buffers.entries()].map(([id, buffer]) => ({
187
- label: id.replace('sb:', ''),
188
- ranges: buffer.ranges,
189
- }));
190
- if (rows.length === 0)
191
- rows.push({ label: 'video', ranges: [] });
192
- const ladder = renditions(engine);
193
- const rowGap = 8;
194
- const rowH = Math.min(34, (f.bottom - f.top - rowGap * (rows.length - 1)) / rows.length);
195
- f.ctx.textAlign = 'left';
196
- f.ctx.textBaseline = 'middle';
197
- rows.forEach((row, i) => {
198
- const y = f.top + i * (rowH + rowGap);
199
- f.ctx.fillStyle = alpha(p.muted, 0.15);
200
- f.ctx.fillRect(f.left, y, f.right - f.left, rowH);
201
- if (state !== null) {
202
- for (const [range, id] of state.quality.appendLog) {
203
- const index = ladder.findIndex((r) => r.id === id);
204
- f.ctx.fillStyle = alpha(p.series[Math.max(0, index) % p.series.length] ?? p.accent, 0.3);
205
- f.ctx.fillRect(x(range.start), y, Math.max(1, x(range.end) - x(range.start)), rowH);
206
- }
207
- }
208
- f.ctx.fillStyle = p.ink;
209
- for (const range of row.ranges) {
210
- f.ctx.fillRect(x(range.start), y + rowH - 4, Math.max(1, x(range.end) - x(range.start)), 3);
211
- }
212
- f.ctx.fillStyle = p.muted;
213
- f.ctx.fillText(row.label, f.left + 4, y + rowH / 2);
214
- });
215
- if (live !== null)
216
- marker(f, x(live.edge), p.warn, 'edge');
217
- f.ctx.fillStyle = p.accent;
218
- f.ctx.fillRect(x(now) - 1, f.top, 2, f.bottom - f.top);
219
- f.ctx.fillStyle = p.muted;
220
- f.ctx.textAlign = 'left';
221
- f.ctx.textBaseline = 'top';
222
- f.ctx.fillText(clock(d0), f.left, f.bottom + 3);
223
- f.ctx.textAlign = 'right';
224
- f.ctx.fillText(clock(d1), f.right, f.bottom + 3);
225
- setLegend([
226
- ...ladder.map((r, i) => [
227
- r.height !== undefined ? `${r.height}p` : bitrate(r.bitrate),
228
- p.series[i % p.series.length] ?? p.accent,
229
- ]),
230
- ['buffered', p.ink],
231
- ['playhead', p.accent],
232
- ]);
233
- const ahead = input.samples[input.samples.length - 1]?.ahead ?? 0;
234
- readout.textContent = `${clock(now)} of ${clock(d1)}, ${ahead.toFixed(1)}s buffered ahead${live !== null ? `, window ${clock(live.span.start)} to ${clock(live.span.end)}` : ''}`;
235
- }
236
- function drawThroughput(f, input, t0, t1) {
237
- const p = input.palette;
238
- const view = visible(input.samples, t0);
239
- const segments = input.marks.filter((m) => m.kind === 'segment' && m.t >= t0);
240
- const top = Math.max(1_000_000, ...view.map((s) => Math.max(s.slow, s.fast, s.playingBitrate)), ...segments.map((m) => m.value)) * 1.15;
241
- const y = valueAxis(f, top, (v) => (v / 1e6).toFixed(v >= 1e7 ? 0 : 1), p);
242
- const x = wallAxis(f, t0, t1, p);
243
- for (const m of segments) {
244
- const audio = m.trackId?.startsWith('aud') === true || m.trackId?.includes('audio') === true;
245
- dot(f, x(m.t), y(m.value), (audio ? p.series[4] : p.series[0]) ?? p.accent);
246
- }
247
- line(f, view.map((s) => [x(s.t), y(s.slow)]), p.series[1] ?? p.accent);
248
- line(f, view.map((s) => [x(s.t), y(s.fast)]), p.series[2] ?? p.accent, { dashed: true });
249
- line(f, view.map((s) => [x(s.t), y(s.playingBitrate)]), p.muted, { step: true, width: 1.5 });
250
- setLegend([
251
- ['video segment', p.series[0] ?? p.accent],
252
- ['audio segment', p.series[4] ?? p.accent],
253
- ['slow estimate', p.series[1] ?? p.accent],
254
- ['fast estimate', p.series[2] ?? p.accent],
255
- ['playing bitrate', p.muted],
256
- ]);
257
- const last = view[view.length - 1];
258
- readout.textContent =
259
- last === undefined
260
- ? 'Mbps'
261
- : `Mbps. Now: slow ${bitrate(last.slow)}, fast ${bitrate(last.fast)}, playing ${bitrate(last.playingBitrate)}`;
262
- }
263
- function drawStalls(f, input, t0, t1) {
264
- const p = input.palette;
265
- const view = visible(input.samples, t0);
266
- const top = Math.max(5, ...view.map((s) => s.ahead)) * 1.15;
267
- const y = valueAxis(f, top, (v) => `${v.toFixed(0)}s`, p);
268
- const x = wallAxis(f, t0, t1, p);
269
- f.ctx.fillStyle = alpha(p.series[3] ?? p.accent, 0.25);
270
- let start = null;
271
- for (const [i, s] of view.entries()) {
272
- if (s.stalled && start === null)
273
- start = s.t;
274
- const last = i === view.length - 1;
275
- if ((!s.stalled || last) && start !== null) {
276
- f.ctx.fillRect(x(start), f.top, Math.max(2, x(s.t) - x(start)), f.bottom - f.top);
277
- start = null;
278
- }
279
- }
280
- line(f, view.map((s) => [x(s.t), y(s.ahead)]), p.series[0] ?? p.accent);
281
- for (const m of input.marks) {
282
- if (m.t < t0 || m.kind === 'segment' || m.kind === 'switch')
283
- continue;
284
- if (m.kind === 'stall')
285
- marker(f, x(m.t), p.accent, '!');
286
- else if (m.kind === 'seek')
287
- marker(f, x(m.t), p.muted, '>');
288
- else
289
- marker(f, x(m.t), p.warn, m.kind.charAt(0).toUpperCase());
290
- }
291
- setLegend([
292
- ['buffered ahead', p.series[0] ?? p.accent],
293
- ['stalled', p.series[3] ?? p.accent],
294
- ['! stall', p.accent],
295
- ['> seek', p.muted],
296
- ['N F S G recovery', p.warn],
297
- ]);
298
- const c = input.counters;
299
- const now = view[view.length - 1]?.stalled === true ? ', stalled now' : '';
300
- readout.textContent = `${c.stalls} stalls, ${c.stalledSeconds.toFixed(1)}s stalled in total${now}`;
301
- }
302
- function drawFrames(f, input, t0, t1) {
303
- const p = input.palette;
304
- const view = visible(input.samples, t0);
305
- const fps = view.map((s, i) => {
306
- const prev = view[i - 1];
307
- const dt = prev === undefined ? 0.5 : Math.max((s.t - prev.t) / 1000, 0.05);
308
- return s.decoded / dt;
309
- });
310
- const top = Math.max(30, ...fps) * 1.15;
311
- const y = valueAxis(f, top, (v) => v.toFixed(0), p);
312
- const x = wallAxis(f, t0, t1, p);
313
- const maxDrop = Math.max(1, ...view.map((s) => s.dropped));
314
- f.ctx.fillStyle = p.accent;
315
- for (const s of view) {
316
- if (s.dropped === 0)
317
- continue;
318
- const height = (s.dropped / maxDrop) * (f.bottom - f.top) * 0.6;
319
- f.ctx.fillRect(x(s.t) - 1.5, f.bottom - height, 3, height);
320
- }
321
- line(f, view.map((s, i) => [x(s.t), y(fps[i] ?? 0)]), p.series[0] ?? p.accent);
322
- setLegend([
323
- ['decoded per second', p.series[0] ?? p.accent],
324
- ['dropped, relative', p.accent],
325
- ]);
326
- const c = input.counters;
327
- const rate = c.decoded > 0 ? ((c.dropped / c.decoded) * 100).toFixed(2) : '0.00';
328
- readout.textContent = `${c.decoded} decoded, ${c.dropped} dropped (${rate}%) this session`;
329
- }
330
- function drawSwitches(f, input, t0, t1) {
331
- const p = input.palette;
332
- const view = visible(input.samples, t0);
333
- const ladder = renditions(input.engine);
334
- const useHeight = ladder.some((r) => r.height !== undefined);
335
- const levels = ladder.map((r) => (useHeight ? (r.height ?? 0) : r.bitrate));
336
- const top = Math.max(1, ...levels, ...view.map((s) => (useHeight ? s.playingHeight : s.playingBitrate))) *
337
- 1.15;
338
- const y = (v) => f.bottom - (v / top) * (f.bottom - f.top);
339
- // The ladder is the axis: one line per rendition.
340
- f.ctx.strokeStyle = alpha(p.muted, 0.25);
341
- f.ctx.fillStyle = p.muted;
342
- f.ctx.textAlign = 'right';
343
- f.ctx.textBaseline = 'middle';
344
- for (const level of levels) {
345
- const py = y(level);
346
- f.ctx.beginPath();
347
- f.ctx.moveTo(f.left, py);
348
- f.ctx.lineTo(f.right, py);
349
- f.ctx.stroke();
350
- f.ctx.fillText(useHeight ? `${level}p` : bitrate(level), f.left - 4, py);
351
- }
352
- const x = wallAxis(f, t0, t1, p);
353
- line(f, view.map((s) => [x(s.t), y(useHeight ? s.playingHeight : s.playingBitrate)]), p.series[0] ?? p.accent, { step: true });
354
- for (const m of input.marks)
355
- if (m.kind === 'switch' && m.t >= t0)
356
- marker(f, x(m.t), p.warn, 'S');
357
- setLegend([
358
- ['playing', p.series[0] ?? p.accent],
359
- ['S switch', p.warn],
360
- ]);
361
- const last = view[view.length - 1];
362
- const now = last === undefined || last.playingId === null
363
- ? '–'
364
- : useHeight
365
- ? `${last.playingHeight}p`
366
- : bitrate(last.playingBitrate);
367
- readout.textContent = `${input.counters.switches} switches this session, now ${now}`;
368
- }
369
- return {
370
- draw(input) {
371
- const f = frame();
372
- if (f === null)
373
- return;
374
- if (input.engine === null && (input.tab === 'throughput' || input.tab === 'switches')) {
375
- setLegend([]);
376
- readout.textContent = 'A native session: the browser holds this, and says nothing.';
377
- return;
378
- }
379
- const t1 = performance.now();
380
- const t0 = t1 - input.window * 1000;
381
- switch (input.tab) {
382
- case 'buffer':
383
- drawBuffer(f, input);
384
- break;
385
- case 'throughput':
386
- drawThroughput(f, input, t0, t1);
387
- break;
388
- case 'stalls':
389
- drawStalls(f, input, t0, t1);
390
- break;
391
- case 'frames':
392
- drawFrames(f, input, t0, t1);
393
- break;
394
- case 'switches':
395
- drawSwitches(f, input, t0, t1);
396
- break;
397
- }
398
- },
399
- };
32
+ function createCharts(canvas, legend, readout) {
33
+ function frame() {
34
+ const ctx = canvas.getContext("2d");
35
+ if (ctx === null) return null;
36
+ const dpr = window.devicePixelRatio || 1;
37
+ const w = canvas.clientWidth;
38
+ const h = canvas.clientHeight;
39
+ if (w === 0 || h === 0) return null;
40
+ const width = Math.round(w * dpr);
41
+ const height = Math.round(h * dpr);
42
+ if (canvas.width !== width || canvas.height !== height) {
43
+ canvas.width = width;
44
+ canvas.height = height;
45
+ }
46
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
47
+ ctx.clearRect(0, 0, w, h);
48
+ ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
49
+ return {
50
+ ctx,
51
+ w,
52
+ h,
53
+ left: 44,
54
+ right: w - 8,
55
+ top: 8,
56
+ bottom: h - 18
57
+ };
58
+ }
59
+ /** The wall-time axis, now at the right edge, labelled back from it. Returns the mapping. */
60
+ function wallAxis(f, t0, t1, p) {
61
+ const x = (t) => f.left + (t - t0) / (t1 - t0) * (f.right - f.left);
62
+ const span = (t1 - t0) / 1e3;
63
+ const step = niceStep(span, 6);
64
+ f.ctx.strokeStyle = alpha(p.muted, .25);
65
+ f.ctx.fillStyle = p.muted;
66
+ f.ctx.textAlign = "center";
67
+ f.ctx.textBaseline = "top";
68
+ for (let back = 0; back <= span; back += step) {
69
+ const px = x(t1 - back * 1e3);
70
+ f.ctx.beginPath();
71
+ f.ctx.moveTo(px, f.top);
72
+ f.ctx.lineTo(px, f.bottom);
73
+ f.ctx.stroke();
74
+ const text = back === 0 ? "now" : back < 60 ? `-${back}s` : `-${clock(back)}`;
75
+ f.ctx.fillText(text, px, f.bottom + 3);
76
+ }
77
+ return x;
78
+ }
79
+ /** The value axis from zero, four gridlines, labelled by `unit`. Returns the mapping. */
80
+ function valueAxis(f, max, unit, p) {
81
+ const y = (v) => f.bottom - v / max * (f.bottom - f.top);
82
+ const step = niceStep(max, 4);
83
+ f.ctx.strokeStyle = alpha(p.muted, .25);
84
+ f.ctx.fillStyle = p.muted;
85
+ f.ctx.textAlign = "right";
86
+ f.ctx.textBaseline = "middle";
87
+ for (let v = 0; v <= max; v += step) {
88
+ const py = y(v);
89
+ f.ctx.beginPath();
90
+ f.ctx.moveTo(f.left, py);
91
+ f.ctx.lineTo(f.right, py);
92
+ f.ctx.stroke();
93
+ f.ctx.fillText(unit(v), f.left - 4, py);
94
+ }
95
+ return y;
96
+ }
97
+ function line(f, points, color, options = {}) {
98
+ if (points.length === 0) return;
99
+ f.ctx.save();
100
+ f.ctx.strokeStyle = color;
101
+ f.ctx.lineWidth = options.width ?? 2;
102
+ f.ctx.setLineDash(options.dashed === true ? [4, 4] : []);
103
+ f.ctx.beginPath();
104
+ let previous;
105
+ for (const point of points) {
106
+ if (previous === void 0) f.ctx.moveTo(point[0], point[1]);
107
+ else if (options.step === true) {
108
+ f.ctx.lineTo(point[0], previous[1]);
109
+ f.ctx.lineTo(point[0], point[1]);
110
+ } else f.ctx.lineTo(point[0], point[1]);
111
+ previous = point;
112
+ }
113
+ f.ctx.stroke();
114
+ f.ctx.restore();
115
+ }
116
+ function dot(f, px, py, color, r = 2.5) {
117
+ f.ctx.fillStyle = color;
118
+ f.ctx.beginPath();
119
+ f.ctx.arc(px, py, r, 0, Math.PI * 2);
120
+ f.ctx.fill();
121
+ }
122
+ /** A dashed vertical rule with a glyph at its top. */
123
+ function marker(f, px, color, glyph) {
124
+ f.ctx.save();
125
+ f.ctx.strokeStyle = color;
126
+ f.ctx.lineWidth = 1;
127
+ f.ctx.setLineDash([2, 3]);
128
+ f.ctx.beginPath();
129
+ f.ctx.moveTo(px, f.top);
130
+ f.ctx.lineTo(px, f.bottom);
131
+ f.ctx.stroke();
132
+ f.ctx.restore();
133
+ f.ctx.fillStyle = color;
134
+ f.ctx.textAlign = "center";
135
+ f.ctx.textBaseline = "top";
136
+ f.ctx.fillText(glyph, px, f.top);
137
+ }
138
+ function setLegend(items) {
139
+ setChildren(legend);
140
+ for (const [text, color] of items) {
141
+ const item = document.createElement("span");
142
+ item.setAttribute("part", "legend-item");
143
+ const swatch = document.createElement("span");
144
+ swatch.setAttribute("part", "swatch");
145
+ swatch.style.background = color;
146
+ item.append(swatch, text);
147
+ legend.append(item);
148
+ }
149
+ }
150
+ /** The samples in the window, plus the one before it so the line enters from the left. */
151
+ function visible(samples, t0) {
152
+ const from = samples.findIndex((s) => s.t >= t0);
153
+ if (from < 0) return samples.length > 0 ? [samples[samples.length - 1]] : [];
154
+ return samples.slice(Math.max(0, from - 1));
155
+ }
156
+ function renditions(engine) {
157
+ return engine?.quality.renditions ?? [];
158
+ }
159
+ function drawBuffer(f, input) {
160
+ const { video, engine, palette: p } = input;
161
+ const state = engine?.stats.snapshot() ?? null;
162
+ const duration = state?.presentation?.duration ?? video.duration;
163
+ const live = state?.live ?? null;
164
+ const now = video.currentTime;
165
+ let d0 = 0;
166
+ let d1 = Number.isFinite(duration) && duration > 0 ? duration : Math.max(now + 10, 10);
167
+ if (live !== null) {
168
+ d0 = live.span.start;
169
+ d1 = Math.max(live.span.end, now + 10);
170
+ } else if (d1 > 600) {
171
+ d0 = Math.max(0, now - 60);
172
+ d1 = now + 180;
173
+ }
174
+ const x = (t) => f.left + (t - d0) / (d1 - d0) * (f.right - f.left);
175
+ const rows = state === null ? [{
176
+ label: "video",
177
+ ranges: Array.from({ length: video.buffered.length }, (_, i) => ({
178
+ start: video.buffered.start(i),
179
+ end: video.buffered.end(i)
180
+ }))
181
+ }] : [...state.buffers.entries()].map(([id, buffer]) => ({
182
+ label: id.replace("sb:", ""),
183
+ ranges: buffer.ranges
184
+ }));
185
+ if (rows.length === 0) rows.push({
186
+ label: "video",
187
+ ranges: []
188
+ });
189
+ const ladder = renditions(engine);
190
+ const rowGap = 8;
191
+ const rowH = Math.min(34, (f.bottom - f.top - rowGap * (rows.length - 1)) / rows.length);
192
+ f.ctx.textAlign = "left";
193
+ f.ctx.textBaseline = "middle";
194
+ rows.forEach((row, i) => {
195
+ const y = f.top + i * (rowH + rowGap);
196
+ f.ctx.fillStyle = alpha(p.muted, .15);
197
+ f.ctx.fillRect(f.left, y, f.right - f.left, rowH);
198
+ if (state !== null) for (const [range, id] of state.quality.appendLog) {
199
+ const index = ladder.findIndex((r) => r.id === id);
200
+ f.ctx.fillStyle = alpha(p.series[Math.max(0, index) % p.series.length] ?? p.accent, .3);
201
+ f.ctx.fillRect(x(range.start), y, Math.max(1, x(range.end) - x(range.start)), rowH);
202
+ }
203
+ f.ctx.fillStyle = p.ink;
204
+ for (const range of row.ranges) f.ctx.fillRect(x(range.start), y + rowH - 4, Math.max(1, x(range.end) - x(range.start)), 3);
205
+ f.ctx.fillStyle = p.muted;
206
+ f.ctx.fillText(row.label, f.left + 4, y + rowH / 2);
207
+ });
208
+ if (live !== null) marker(f, x(live.edge), p.warn, "edge");
209
+ f.ctx.fillStyle = p.accent;
210
+ f.ctx.fillRect(x(now) - 1, f.top, 2, f.bottom - f.top);
211
+ f.ctx.fillStyle = p.muted;
212
+ f.ctx.textAlign = "left";
213
+ f.ctx.textBaseline = "top";
214
+ f.ctx.fillText(clock(d0), f.left, f.bottom + 3);
215
+ f.ctx.textAlign = "right";
216
+ f.ctx.fillText(clock(d1), f.right, f.bottom + 3);
217
+ setLegend([
218
+ ...ladder.map((r, i) => [r.height !== void 0 ? `${r.height}p` : bitrate(r.bitrate), p.series[i % p.series.length] ?? p.accent]),
219
+ ["buffered", p.ink],
220
+ ["playhead", p.accent]
221
+ ]);
222
+ const ahead = input.samples[input.samples.length - 1]?.ahead ?? 0;
223
+ readout.textContent = `${clock(now)} of ${clock(d1)}, ${ahead.toFixed(1)}s buffered ahead${live !== null ? `, window ${clock(live.span.start)} to ${clock(live.span.end)}` : ""}`;
224
+ }
225
+ function drawThroughput(f, input, t0, t1) {
226
+ const p = input.palette;
227
+ const view = visible(input.samples, t0);
228
+ const segments = input.marks.filter((m) => m.kind === "segment" && m.t >= t0);
229
+ const y = valueAxis(f, Math.max(1e6, ...view.map((s) => Math.max(s.slow, s.fast, s.playingBitrate)), ...segments.map((m) => m.value)) * 1.15, (v) => (v / 1e6).toFixed(v >= 1e7 ? 0 : 1), p);
230
+ const x = wallAxis(f, t0, t1, p);
231
+ for (const m of segments) {
232
+ const audio = m.trackId?.startsWith("aud") === true || m.trackId?.includes("audio") === true;
233
+ dot(f, x(m.t), y(m.value), (audio ? p.series[4] : p.series[0]) ?? p.accent);
234
+ }
235
+ line(f, view.map((s) => [x(s.t), y(s.slow)]), p.series[1] ?? p.accent);
236
+ line(f, view.map((s) => [x(s.t), y(s.fast)]), p.series[2] ?? p.accent, { dashed: true });
237
+ line(f, view.map((s) => [x(s.t), y(s.playingBitrate)]), p.muted, {
238
+ step: true,
239
+ width: 1.5
240
+ });
241
+ setLegend([
242
+ ["video segment", p.series[0] ?? p.accent],
243
+ ["audio segment", p.series[4] ?? p.accent],
244
+ ["slow estimate", p.series[1] ?? p.accent],
245
+ ["fast estimate", p.series[2] ?? p.accent],
246
+ ["playing bitrate", p.muted]
247
+ ]);
248
+ const last = view[view.length - 1];
249
+ readout.textContent = last === void 0 ? "Mbps" : `Mbps. Now: slow ${bitrate(last.slow)}, fast ${bitrate(last.fast)}, playing ${bitrate(last.playingBitrate)}`;
250
+ }
251
+ function drawStalls(f, input, t0, t1) {
252
+ const p = input.palette;
253
+ const view = visible(input.samples, t0);
254
+ const y = valueAxis(f, Math.max(5, ...view.map((s) => s.ahead)) * 1.15, (v) => `${v.toFixed(0)}s`, p);
255
+ const x = wallAxis(f, t0, t1, p);
256
+ f.ctx.fillStyle = alpha(p.series[3] ?? p.accent, .25);
257
+ let start = null;
258
+ for (const [i, s] of view.entries()) {
259
+ if (s.stalled && start === null) start = s.t;
260
+ const last = i === view.length - 1;
261
+ if ((!s.stalled || last) && start !== null) {
262
+ f.ctx.fillRect(x(start), f.top, Math.max(2, x(s.t) - x(start)), f.bottom - f.top);
263
+ start = null;
264
+ }
265
+ }
266
+ line(f, view.map((s) => [x(s.t), y(s.ahead)]), p.series[0] ?? p.accent);
267
+ for (const m of input.marks) {
268
+ if (m.t < t0 || m.kind === "segment" || m.kind === "switch") continue;
269
+ if (m.kind === "stall") marker(f, x(m.t), p.accent, "!");
270
+ else if (m.kind === "seek") marker(f, x(m.t), p.muted, ">");
271
+ else marker(f, x(m.t), p.warn, m.kind.charAt(0).toUpperCase());
272
+ }
273
+ setLegend([
274
+ ["buffered ahead", p.series[0] ?? p.accent],
275
+ ["stalled", p.series[3] ?? p.accent],
276
+ ["! stall", p.accent],
277
+ ["> seek", p.muted],
278
+ ["N F S G recovery", p.warn]
279
+ ]);
280
+ const c = input.counters;
281
+ const now = view[view.length - 1]?.stalled === true ? ", stalled now" : "";
282
+ readout.textContent = `${c.stalls} stalls, ${c.stalledSeconds.toFixed(1)}s stalled in total${now}`;
283
+ }
284
+ function drawFrames(f, input, t0, t1) {
285
+ const p = input.palette;
286
+ const view = visible(input.samples, t0);
287
+ const fps = view.map((s, i) => {
288
+ const prev = view[i - 1];
289
+ const dt = prev === void 0 ? .5 : Math.max((s.t - prev.t) / 1e3, .05);
290
+ return s.decoded / dt;
291
+ });
292
+ const y = valueAxis(f, Math.max(30, ...fps) * 1.15, (v) => v.toFixed(0), p);
293
+ const x = wallAxis(f, t0, t1, p);
294
+ const maxDrop = Math.max(1, ...view.map((s) => s.dropped));
295
+ f.ctx.fillStyle = p.accent;
296
+ for (const s of view) {
297
+ if (s.dropped === 0) continue;
298
+ const height = s.dropped / maxDrop * (f.bottom - f.top) * .6;
299
+ f.ctx.fillRect(x(s.t) - 1.5, f.bottom - height, 3, height);
300
+ }
301
+ line(f, view.map((s, i) => [x(s.t), y(fps[i] ?? 0)]), p.series[0] ?? p.accent);
302
+ setLegend([["decoded per second", p.series[0] ?? p.accent], ["dropped, relative", p.accent]]);
303
+ const c = input.counters;
304
+ const rate = c.decoded > 0 ? (c.dropped / c.decoded * 100).toFixed(2) : "0.00";
305
+ readout.textContent = `${c.decoded} decoded, ${c.dropped} dropped (${rate}%) this session`;
306
+ }
307
+ function drawSwitches(f, input, t0, t1) {
308
+ const p = input.palette;
309
+ const view = visible(input.samples, t0);
310
+ const ladder = renditions(input.engine);
311
+ const useHeight = ladder.some((r) => r.height !== void 0);
312
+ const levels = ladder.map((r) => useHeight ? r.height ?? 0 : r.bitrate);
313
+ const top = Math.max(1, ...levels, ...view.map((s) => useHeight ? s.playingHeight : s.playingBitrate)) * 1.15;
314
+ const y = (v) => f.bottom - v / top * (f.bottom - f.top);
315
+ f.ctx.strokeStyle = alpha(p.muted, .25);
316
+ f.ctx.fillStyle = p.muted;
317
+ f.ctx.textAlign = "right";
318
+ f.ctx.textBaseline = "middle";
319
+ for (const level of levels) {
320
+ const py = y(level);
321
+ f.ctx.beginPath();
322
+ f.ctx.moveTo(f.left, py);
323
+ f.ctx.lineTo(f.right, py);
324
+ f.ctx.stroke();
325
+ f.ctx.fillText(useHeight ? `${level}p` : bitrate(level), f.left - 4, py);
326
+ }
327
+ const x = wallAxis(f, t0, t1, p);
328
+ line(f, view.map((s) => [x(s.t), y(useHeight ? s.playingHeight : s.playingBitrate)]), p.series[0] ?? p.accent, { step: true });
329
+ for (const m of input.marks) if (m.kind === "switch" && m.t >= t0) marker(f, x(m.t), p.warn, "S");
330
+ setLegend([["playing", p.series[0] ?? p.accent], ["S switch", p.warn]]);
331
+ const last = view[view.length - 1];
332
+ const now = last === void 0 || last.playingId === null ? "–" : useHeight ? `${last.playingHeight}p` : bitrate(last.playingBitrate);
333
+ readout.textContent = `${input.counters.switches} switches this session, now ${now}`;
334
+ }
335
+ return { draw(input) {
336
+ const f = frame();
337
+ if (f === null) return;
338
+ if (input.engine === null && (input.tab === "throughput" || input.tab === "switches")) {
339
+ setLegend([]);
340
+ readout.textContent = "A native session: the browser holds this, and says nothing.";
341
+ return;
342
+ }
343
+ const t1 = performance.now();
344
+ const t0 = t1 - input.window * 1e3;
345
+ switch (input.tab) {
346
+ case "buffer":
347
+ drawBuffer(f, input);
348
+ break;
349
+ case "throughput":
350
+ drawThroughput(f, input, t0, t1);
351
+ break;
352
+ case "stalls":
353
+ drawStalls(f, input, t0, t1);
354
+ break;
355
+ case "frames":
356
+ drawFrames(f, input, t0, t1);
357
+ break;
358
+ case "switches": drawSwitches(f, input, t0, t1);
359
+ }
360
+ } };
400
361
  }
362
+ //#endregion
363
+ export { CHART_TABS, createCharts };
364
+
401
365
  //# sourceMappingURL=charts.js.map