browsertime 27.5.0 → 28.1.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/bin/browsertime.js +28 -4
  3. package/browserscripts/pageinfo/loaf.js +15 -0
  4. package/browserscripts/pageinfo/loafSummary.js +24 -0
  5. package/lib/chrome/coverage.js +89 -19
  6. package/lib/chrome/mediawikiResourceLoader.js +225 -0
  7. package/lib/chrome/parseCpuTrace.js +87 -2
  8. package/lib/chrome/trace/blocking-time.js +189 -0
  9. package/lib/chrome/trace/domain-breakdown.js +86 -0
  10. package/lib/chrome/trace/frame-stability.js +135 -0
  11. package/lib/chrome/trace/function-costs.js +281 -0
  12. package/lib/chrome/trace/index.js +7 -0
  13. package/lib/chrome/trace/module-costs.js +137 -0
  14. package/lib/chrome/trace/non-composited-animations.js +50 -5
  15. package/lib/chrome/trace/selector-stats.js +110 -0
  16. package/lib/chrome/trace/style-invalidations.js +162 -0
  17. package/lib/chrome/trace/task-groups.js +28 -2
  18. package/lib/chrome/trace/timer-costs.js +183 -0
  19. package/lib/chrome/webdriver/chromium.js +128 -0
  20. package/lib/chrome/webdriver/traceUtilities.js +11 -1
  21. package/lib/core/engine/collector.js +12 -0
  22. package/lib/core/engine/index.js +6 -0
  23. package/lib/support/cli.js +1 -1
  24. package/lib/support/har/index.js +44 -1
  25. package/package.json +4 -4
  26. package/types/chrome/mediawikiResourceLoader.d.ts +11 -0
  27. package/types/chrome/mediawikiResourceLoader.d.ts.map +1 -0
  28. package/types/chrome/parseCpuTrace.d.ts +1 -26
  29. package/types/chrome/parseCpuTrace.d.ts.map +1 -1
  30. package/types/chrome/trace/blocking-time.d.ts +7 -0
  31. package/types/chrome/trace/blocking-time.d.ts.map +1 -0
  32. package/types/chrome/trace/domain-breakdown.d.ts +11 -0
  33. package/types/chrome/trace/domain-breakdown.d.ts.map +1 -0
  34. package/types/chrome/trace/frame-stability.d.ts +11 -0
  35. package/types/chrome/trace/frame-stability.d.ts.map +1 -0
  36. package/types/chrome/trace/function-costs.d.ts +6 -0
  37. package/types/chrome/trace/function-costs.d.ts.map +1 -0
  38. package/types/chrome/trace/index.d.ts +7 -0
  39. package/types/chrome/trace/index.d.ts.map +1 -1
  40. package/types/chrome/trace/module-costs.d.ts +20 -0
  41. package/types/chrome/trace/module-costs.d.ts.map +1 -0
  42. package/types/chrome/trace/non-composited-animations.d.ts +1 -25
  43. package/types/chrome/trace/non-composited-animations.d.ts.map +1 -1
  44. package/types/chrome/trace/selector-stats.d.ts +8 -0
  45. package/types/chrome/trace/selector-stats.d.ts.map +1 -0
  46. package/types/chrome/trace/style-invalidations.d.ts +9 -0
  47. package/types/chrome/trace/style-invalidations.d.ts.map +1 -0
  48. package/types/chrome/trace/task-groups.d.ts.map +1 -1
  49. package/types/chrome/trace/timer-costs.d.ts +10 -0
  50. package/types/chrome/trace/timer-costs.d.ts.map +1 -0
  51. package/types/chrome/webdriver/traceUtilities.d.ts.map +1 -1
@@ -3,10 +3,28 @@ import {
3
3
  computeMainThreadTasks,
4
4
  computeScriptCosts,
5
5
  computeForcedReflows,
6
- computeNonCompositedAnimations
6
+ computeNonCompositedAnimations,
7
+ computeBlockingTime,
8
+ computeDomainBreakdown,
9
+ computeFrameStability,
10
+ computeStyleInvalidations,
11
+ computeSelectorStats,
12
+ computeTimerCosts
7
13
  } from './trace/index.js';
14
+ import { labelForUrl } from './mediawikiResourceLoader.js';
8
15
  const log = getLogger('browsertime.chrome.cpu');
9
16
 
17
+ // Additive label field for MediaWiki ResourceLoader URLs so per-bundle
18
+ // time series survive the weekly version= churn. The url field is
19
+ // untouched (browsertime.json shape is a public contract); non-MediaWiki
20
+ // URLs get no label and the output stays byte-identical to before.
21
+ function addResourceLoaderLabels(entries, urlField = 'url', field = 'label') {
22
+ for (const entry of entries) {
23
+ const label = labelForUrl(entry[urlField]);
24
+ if (label) entry[field] = label;
25
+ }
26
+ }
27
+
10
28
  function round(number_, decimals = 3) {
11
29
  const pow = Math.pow(10, decimals);
12
30
  return Math.round(number_ * pow) / pow;
@@ -105,6 +123,67 @@ export async function parseCPUTrace(tracelog, url) {
105
123
  } catch (error) {
106
124
  log.debug('computeNonCompositedAnimations failed: %s', error.message);
107
125
  }
126
+ // These return objects with summary totals, so on failure the
127
+ // key is omitted instead of publishing a misleading zero total.
128
+ let blocking;
129
+ try {
130
+ blocking = computeBlockingTime(tracelog);
131
+ } catch (error) {
132
+ log.debug('computeBlockingTime failed: %s', error.message);
133
+ }
134
+ let domains;
135
+ try {
136
+ domains = computeDomainBreakdown(tracelog, url);
137
+ } catch (error) {
138
+ log.debug('computeDomainBreakdown failed: %s', error.message);
139
+ }
140
+ let frames;
141
+ try {
142
+ frames = computeFrameStability(tracelog);
143
+ } catch (error) {
144
+ log.debug('computeFrameStability failed: %s', error.message);
145
+ }
146
+ let styleInvalidations;
147
+ try {
148
+ styleInvalidations = computeStyleInvalidations(tracelog);
149
+ } catch (error) {
150
+ log.debug('computeStyleInvalidations failed: %s', error.message);
151
+ }
152
+ // Only produces output when the SelectorStats trace category is
153
+ // on (--enableProfileRun); undefined omits the key.
154
+ let selectorStats;
155
+ try {
156
+ selectorStats = computeSelectorStats(tracelog);
157
+ } catch (error) {
158
+ log.debug('computeSelectorStats failed: %s', error.message);
159
+ }
160
+ let timers;
161
+ try {
162
+ timers = computeTimerCosts(tracelog);
163
+ } catch (error) {
164
+ log.debug('computeTimerCosts failed: %s', error.message);
165
+ }
166
+
167
+ addResourceLoaderLabels(cleanedUrls);
168
+ addResourceLoaderLabels(scriptCosts);
169
+ addResourceLoaderLabels(
170
+ forcedReflows,
171
+ 'triggeredByUrl',
172
+ 'triggeredByUrlLabel'
173
+ );
174
+ if (blocking) {
175
+ addResourceLoaderLabels(blocking.urls);
176
+ if (blocking.beforeLargestContentfulPaint) {
177
+ addResourceLoaderLabels(blocking.beforeLargestContentfulPaint.urls);
178
+ }
179
+ }
180
+ if (styleInvalidations) {
181
+ addResourceLoaderLabels(styleInvalidations.sources);
182
+ }
183
+ if (timers) {
184
+ addResourceLoaderLabels(timers.byUrl);
185
+ addResourceLoaderLabels(timers.sites);
186
+ }
108
187
 
109
188
  log.debug('Chrome trace log finished parsed and sorted.');
110
189
  return {
@@ -113,7 +192,13 @@ export async function parseCPUTrace(tracelog, url) {
113
192
  urls: cleanedUrls,
114
193
  scriptCosts,
115
194
  forcedReflows,
116
- nonCompositedAnimations
195
+ nonCompositedAnimations,
196
+ ...(blocking ? { blocking } : {}),
197
+ ...(domains ? { domains } : {}),
198
+ ...(frames ? { frames } : {}),
199
+ ...(styleInvalidations ? { styleInvalidations } : {}),
200
+ ...(selectorStats ? { selectorStats } : {}),
201
+ ...(timers ? { timers } : {})
117
202
  };
118
203
  } catch (error) {
119
204
  log.error(
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Blocking-time attribution per URL and per kind of work.
3
+ *
4
+ * A top-level main-thread task longer than 50 ms blocks input for
5
+ * (duration - 50) ms — the same definition Lighthouse's Total
6
+ * Blocking Time uses. Knowing the page's TBT is one thing; knowing
7
+ * WHICH script produced it is what tells you what to fix first, so
8
+ * each blocking task is attributed to a URL:
9
+ *
10
+ * Top-level tasks are often anonymous wrappers (RunTask et al.) whose
11
+ * own attributableURLs is empty while the real work sits in child
12
+ * tasks. So instead of only looking at the top-level task's own URL
13
+ * chain, the whole subtree is walked and the task is attributed to
14
+ * the URL that contributed the most self-time (each node credited to
15
+ * its most-specific URL, attributableURLs.at(-1), the same choice
16
+ * script-costs.js makes). ParseHTML nodes carry no attributable URL
17
+ * (main-thread-tasks.js only reads URLs from script events and stack
18
+ * frames, and stays byte-equivalent for cpu.urls consumers), so here
19
+ * they fall back to the document URL in args.beginData.url — a page
20
+ * blocked by parsing its own big HTML gets attributed to that
21
+ * document instead of disappearing. Tasks where no node carries any
22
+ * URL go to the 'unknown' bucket — browser-internal work (GC, style,
23
+ * layout without a JS trigger) no script owns.
24
+ *
25
+ * Blocking time is also split by KIND of work: each blocking task's
26
+ * subtree self-time per task group (parseHTML, styleLayout,
27
+ * scriptEvaluation, …) is scaled to the task's blocking share and
28
+ * summed per window into `kinds`. The per-URL list says who to blame;
29
+ * the kinds split says what kind of fix applies — script blocking
30
+ * needs code changes, parse/style blocking needs a smaller document
31
+ * or cheaper CSS.
32
+ *
33
+ * Two windows are reported: the whole trace, and navigationStart →
34
+ * the last largestContentfulPaint::Candidate event (blocking time in
35
+ * that window is what delayed LCP). Task times from
36
+ * main-thread-tasks.js are ms rebased to the first task, while the
37
+ * navigationStart / LCP timestamps are raw trace µs — the window
38
+ * check therefore uses the task's raw event.ts instead of converting
39
+ * back and forth. When the trace has no LCP candidate the LCP window
40
+ * is omitted rather than guessed.
41
+ *
42
+ * Returns:
43
+ * { totalBlockingTime, tasks, urls: [{ url, value, tasks }, …],
44
+ * kinds: { styleLayout: ms, … },
45
+ * beforeLargestContentfulPaint?: { totalBlockingTime, tasks,
46
+ * urls: [...], kinds: {...} } }
47
+ * Times in ms rounded to one decimal; url lists sorted by value desc
48
+ * and noise-filtered to entries above 10 ms, matching cpu.urls.
49
+ * Kinds that round to 0 ms are omitted.
50
+ */
51
+
52
+ import { getMainThreadTasks } from './main-thread-tasks.js';
53
+ import { computeTraceOfTab } from './trace-of-tab.js';
54
+
55
+ const BLOCKING_THRESHOLD_MS = 50;
56
+ const REPORT_LIMIT_MS = 10;
57
+ const UNKNOWN = 'unknown';
58
+
59
+ function round(ms) {
60
+ return Math.round(ms * 10) / 10;
61
+ }
62
+
63
+ function nodeUrl(node) {
64
+ const url = node.attributableURLs.at(-1);
65
+ if (url) return url;
66
+ if (node.event.name === 'ParseHTML') {
67
+ const beginData = node.event.args && node.event.args.beginData;
68
+ return beginData && beginData.url;
69
+ }
70
+ }
71
+
72
+ // One subtree walk computing both attributions for a blocking task:
73
+ // the dominant URL (most self-time) and the self-time per task kind.
74
+ function analyzeTask(task) {
75
+ const selfTimeByUrl = new Map();
76
+ const selfTimeByKind = new Map();
77
+ const stack = [task];
78
+ while (stack.length > 0) {
79
+ const node = stack.pop();
80
+ const url = nodeUrl(node);
81
+ if (url) {
82
+ selfTimeByUrl.set(url, (selfTimeByUrl.get(url) || 0) + node.selfTime);
83
+ }
84
+ const kind = node.group.id;
85
+ selfTimeByKind.set(kind, (selfTimeByKind.get(kind) || 0) + node.selfTime);
86
+ stack.push(...node.children);
87
+ }
88
+ let bestUrl;
89
+ let bestTime = 0;
90
+ for (const [url, time] of selfTimeByUrl) {
91
+ if (time > bestTime) {
92
+ bestUrl = url;
93
+ bestTime = time;
94
+ }
95
+ }
96
+ return { url: bestUrl || UNKNOWN, selfTimeByKind };
97
+ }
98
+
99
+ function newWindow() {
100
+ return {
101
+ totalBlockingTime: 0,
102
+ tasks: 0,
103
+ byUrl: new Map(),
104
+ byKind: new Map()
105
+ };
106
+ }
107
+
108
+ function addToWindow(window, task, blocking, analysis) {
109
+ window.totalBlockingTime += blocking;
110
+ window.tasks += 1;
111
+ let entry = window.byUrl.get(analysis.url);
112
+ if (!entry) {
113
+ entry = { url: analysis.url, value: 0, tasks: 0 };
114
+ window.byUrl.set(analysis.url, entry);
115
+ }
116
+ entry.value += blocking;
117
+ entry.tasks += 1;
118
+ // Scale each kind's subtree self-time to the task's blocking share,
119
+ // so the kinds sum to totalBlockingTime instead of raw durations.
120
+ for (const [kind, selfTime] of analysis.selfTimeByKind) {
121
+ const share = (selfTime / task.duration) * blocking;
122
+ window.byKind.set(kind, (window.byKind.get(kind) || 0) + share);
123
+ }
124
+ }
125
+
126
+ function finishWindow(window) {
127
+ const urls = [];
128
+ for (const entry of window.byUrl.values()) {
129
+ if (entry.value > REPORT_LIMIT_MS) {
130
+ entry.value = round(entry.value);
131
+ urls.push(entry);
132
+ }
133
+ }
134
+ urls.sort((a, b) => b.value - a.value);
135
+ const kinds = {};
136
+ for (const [kind, value] of [...window.byKind.entries()].toSorted(
137
+ (a, b) => b[1] - a[1]
138
+ )) {
139
+ const rounded = round(value);
140
+ if (rounded > 0) kinds[kind] = rounded;
141
+ }
142
+ return {
143
+ totalBlockingTime: round(window.totalBlockingTime),
144
+ tasks: window.tasks,
145
+ urls,
146
+ kinds
147
+ };
148
+ }
149
+
150
+ export function computeBlockingTime(trace) {
151
+ const { mainThreadEvents, timestamps } = computeTraceOfTab(trace);
152
+ const tasks = getMainThreadTasks(mainThreadEvents, timestamps.traceEnd);
153
+
154
+ let lcpEvent;
155
+ for (const event of trace.traceEvents) {
156
+ if (
157
+ event.name === 'largestContentfulPaint::Candidate' &&
158
+ (!lcpEvent || event.ts > lcpEvent.ts)
159
+ ) {
160
+ lcpEvent = event;
161
+ }
162
+ }
163
+
164
+ const total = newWindow();
165
+ const beforeLcp = newWindow();
166
+
167
+ for (const task of tasks) {
168
+ if (task.parent) continue;
169
+ if (task.duration <= BLOCKING_THRESHOLD_MS) continue;
170
+
171
+ const blocking = task.duration - BLOCKING_THRESHOLD_MS;
172
+ const analysis = analyzeTask(task);
173
+ addToWindow(total, task, blocking, analysis);
174
+
175
+ if (
176
+ lcpEvent &&
177
+ task.event.ts >= timestamps.navigationStart &&
178
+ task.event.ts < lcpEvent.ts
179
+ ) {
180
+ addToWindow(beforeLcp, task, blocking, analysis);
181
+ }
182
+ }
183
+
184
+ const result = finishWindow(total);
185
+ if (lcpEvent) {
186
+ result.beforeLargestContentfulPaint = finishWindow(beforeLcp);
187
+ }
188
+ return result;
189
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * First-party vs third-party main-thread rollup.
3
+ *
4
+ * cpu.urls answers "which URL costs the most main-thread time";
5
+ * this answers the level above it — how much of the main thread the
6
+ * site's own code uses versus embedded third parties, and which
7
+ * domains those third parties are. Each task's self-time is credited
8
+ * to its most-specific attributable URL (attributableURLs.at(-1),
9
+ * same choice as script-costs.js) so a task counts once — cpu.urls
10
+ * deliberately credits the full URL chain, which would double-count
11
+ * across domains here. Tasks with no attributable URL are
12
+ * browser-internal work no domain owns and are skipped.
13
+ *
14
+ * First-party is decided by registrable domain: the last two labels
15
+ * of the hostname, so every subdomain of the tested page's domain is
16
+ * first-party (en.wikipedia.org and upload.wikimedia.org roll up to
17
+ * wikipedia.org / wikimedia.org). Deliberately NOT a full public
18
+ * suffix list — two-level public suffixes (co.uk, com.au, …) group
19
+ * one label too high, which is acceptable for a rollup and avoids
20
+ * carrying an eTLD+1 dependency.
21
+ *
22
+ * Returns:
23
+ * { firstPartyDomain, firstParty, thirdParty,
24
+ * domains: [{ domain, value, firstParty }, …] }
25
+ * Times in ms rounded to one decimal; domains sorted by value desc
26
+ * and noise-filtered to entries above 10 ms, matching cpu.urls. The
27
+ * firstParty / thirdParty totals include the filtered-out remainder.
28
+ */
29
+
30
+ import { compute } from './main-thread-tasks.js';
31
+
32
+ const REPORT_LIMIT_MS = 10;
33
+
34
+ function round(ms) {
35
+ return Math.round(ms * 10) / 10;
36
+ }
37
+
38
+ function registrableDomain(hostname) {
39
+ const labels = hostname.split('.');
40
+ return labels.length <= 2 ? hostname : labels.slice(-2).join('.');
41
+ }
42
+
43
+ function domainOf(url) {
44
+ try {
45
+ const { hostname } = new URL(url);
46
+ return hostname ? registrableDomain(hostname) : undefined;
47
+ } catch {
48
+ return;
49
+ }
50
+ }
51
+
52
+ export function computeDomainBreakdown(trace, pageUrl) {
53
+ const firstPartyDomain = registrableDomain(new URL(pageUrl).hostname);
54
+ const byDomain = new Map();
55
+
56
+ for (const task of compute(trace)) {
57
+ const url = task.attributableURLs.at(-1);
58
+ if (!url) continue;
59
+ const domain = domainOf(url);
60
+ if (!domain) continue;
61
+ byDomain.set(domain, (byDomain.get(domain) || 0) + task.selfTime);
62
+ }
63
+
64
+ let firstParty = 0;
65
+ let thirdParty = 0;
66
+ const domains = [];
67
+ for (const [domain, value] of byDomain) {
68
+ const isFirstParty = domain === firstPartyDomain;
69
+ if (isFirstParty) {
70
+ firstParty += value;
71
+ } else {
72
+ thirdParty += value;
73
+ }
74
+ if (value > REPORT_LIMIT_MS) {
75
+ domains.push({ domain, value: round(value), firstParty: isFirstParty });
76
+ }
77
+ }
78
+ domains.sort((a, b) => b.value - a.value);
79
+
80
+ return {
81
+ firstPartyDomain,
82
+ firstParty: round(firstParty),
83
+ thirdParty: round(thirdParty),
84
+ domains
85
+ };
86
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Frame stability — how smoothly the compositor produced frames
3
+ * during the traced window. Jank (dropped frames, long gaps between
4
+ * visual updates) is invisible in load metrics; this surfaces it
5
+ * from the trace browsertime already records.
6
+ *
7
+ * Event shapes sampled from Chrome 150.0.7871.115 with the default
8
+ * browsertime trace categories. The frame pipeline shows up as
9
+ * `PipelineReporter` async events (cat
10
+ * `cc,benchmark,disabled-by-default-devtools.timeline.frame`), one
11
+ * b/e pair per frame attempt, paired via `id2.local` within a
12
+ * process. The `b` event carries `args.frame_reporter` with the
13
+ * final outcome: `state` (STATE_PRESENTED_ALL /
14
+ * STATE_PRESENTED_PARTIAL / STATE_DROPPED / STATE_NO_UPDATE_DESIRED),
15
+ * `affects_smoothness`, and `frame_sequence`. The `e` timestamp is
16
+ * when the frame reached the screen (presentation) for presented
17
+ * frames. The same frame_sequence can have several reporters
18
+ * (forked / main + impl), so outcomes are merged per sequence. The
19
+ * legacy instant events (BeginFrame, DrawFrame, DroppedFrame) are
20
+ * still emitted and were used to cross-check: DroppedFrame instants
21
+ * match STATE_DROPPED reporters with affects_smoothness=true.
22
+ *
23
+ * Only STATE_DROPPED frames flagged `affects_smoothness` are counted
24
+ * as dropped — Chrome also marks no-damage idle frames STATE_DROPPED
25
+ * and counting those would alarm on every static page.
26
+ *
27
+ * Returns: { presented, partial, dropped, effectiveFps, longestGap }
28
+ * presented — frames that reached the screen after
29
+ * navigationStart
30
+ * partial — subset of presented where the compositor
31
+ * presented without the main-thread update
32
+ * (STATE_PRESENTED_PARTIAL only)
33
+ * dropped — frames Chrome flags as dropped AND affecting
34
+ * smoothness (its own jank signal)
35
+ * effectiveFps — presented frames over the first→last
36
+ * presentation span; omitted with <2 presentations
37
+ * longestGap — { duration, startTime } in ms relative to
38
+ * navigationStart, the longest time between two
39
+ * consecutive presented frames. On mostly-static
40
+ * pages this includes idle time (nothing to draw),
41
+ * so read it as "longest time between visual
42
+ * updates", not proof of jank; omitted with <2
43
+ * presentations
44
+ */
45
+
46
+ import { computeTraceOfTab } from './trace-of-tab.js';
47
+
48
+ const PRESENTED_STATES = new Set([
49
+ 'STATE_PRESENTED_ALL',
50
+ 'STATE_PRESENTED_PARTIAL'
51
+ ]);
52
+
53
+ function round1(number_) {
54
+ return Math.round(number_ * 10) / 10;
55
+ }
56
+
57
+ export function computeFrameStability(trace) {
58
+ const { mainFrameIds, timestamps } = computeTraceOfTab(trace);
59
+ const navStart = timestamps.navigationStart;
60
+
61
+ const open = new Map();
62
+ const bySequence = new Map();
63
+ for (const event of trace.traceEvents) {
64
+ if (event.name !== 'PipelineReporter' || event.pid !== mainFrameIds.pid)
65
+ continue;
66
+ const id = (event.id2 && event.id2.local) || event.id;
67
+ if (event.ph === 'b') {
68
+ open.set(id, event);
69
+ } else if (event.ph === 'e') {
70
+ const begin = open.get(id);
71
+ if (!begin) continue;
72
+ open.delete(id);
73
+ if (event.ts < navStart) continue;
74
+ const reporter = (begin.args && begin.args.frame_reporter) || {};
75
+ if (reporter.frame_sequence === undefined) continue;
76
+ let frame = bySequence.get(reporter.frame_sequence);
77
+ if (!frame) {
78
+ frame = { presentedAll: false, presentedPartial: false };
79
+ bySequence.set(reporter.frame_sequence, frame);
80
+ }
81
+ if (PRESENTED_STATES.has(reporter.state)) {
82
+ if (reporter.state === 'STATE_PRESENTED_ALL') frame.presentedAll = true;
83
+ else frame.presentedPartial = true;
84
+ frame.presentationTs = Math.min(
85
+ frame.presentationTs ?? Number.POSITIVE_INFINITY,
86
+ event.ts
87
+ );
88
+ } else if (
89
+ reporter.state === 'STATE_DROPPED' &&
90
+ reporter.affects_smoothness === true
91
+ ) {
92
+ frame.dropped = true;
93
+ }
94
+ }
95
+ }
96
+
97
+ let presented = 0;
98
+ let partial = 0;
99
+ let dropped = 0;
100
+ const presentationTimes = [];
101
+ for (const frame of bySequence.values()) {
102
+ if (frame.presentedAll || frame.presentedPartial) {
103
+ presented++;
104
+ if (!frame.presentedAll) partial++;
105
+ presentationTimes.push(frame.presentationTs);
106
+ } else if (frame.dropped) {
107
+ dropped++;
108
+ }
109
+ }
110
+ presentationTimes.sort((a, b) => a - b);
111
+
112
+ let effectiveFps;
113
+ let longestGap;
114
+ if (presentationTimes.length >= 2) {
115
+ const spanSeconds = (presentationTimes.at(-1) - presentationTimes[0]) / 1e6;
116
+ if (spanSeconds > 0) {
117
+ effectiveFps = round1((presentationTimes.length - 1) / spanSeconds);
118
+ }
119
+ let worst = 0;
120
+ let worstStart = presentationTimes[0];
121
+ for (let index = 1; index < presentationTimes.length; index++) {
122
+ const gap = presentationTimes[index] - presentationTimes[index - 1];
123
+ if (gap > worst) {
124
+ worst = gap;
125
+ worstStart = presentationTimes[index - 1];
126
+ }
127
+ }
128
+ longestGap = {
129
+ duration: round1(worst / 1000),
130
+ startTime: round1((worstStart - navStart) / 1000)
131
+ };
132
+ }
133
+
134
+ return { presented, partial, dropped, effectiveFps, longestGap };
135
+ }