browsertime 28.0.0 → 28.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 28.2.0 - 2026-07-20
4
+
5
+ ### Added
6
+ * New `videoParams.noiseTolerance`: a percentage of a frame's pixels that is allowed to differ before duplicate-frame elimination still treats the frame as unchanged. Browsers re-rasterize a visually-done page long after it settles, leaving invisible sub-pixel noise on glyph edges that the default handful-of-pixels tolerance counts as real change, inflating Last Visual Change, VisualComplete95/99 and Speed Index. Off by default [#2528](https://github.com/sitespeedio/browsertime/pull/2528).
7
+ * New `videoParams.filmstripDiff` frame diffs: writes `diff_<ms>.png` next to each filmstrip frame (the frame dimmed with every changed pixel painted red, using the same per-pixel threshold report UIs use at runtime) plus a Frame Diffs metric. Report UIs cannot read frame pixels from `file://` results, so an exact "what changed in this frame" view was impossible exactly where people debug local results. Off by default [#2531](https://github.com/sitespeedio/browsertime/pull/2531).
8
+ * Visual instability heatmap: the same `videoParams.filmstripDiff` run now also writes one `instability.png` showing how many times each pixel changed across the video (amber for changed once, deep red for ten or more), plus a Frame Instability summary in the JSON. Answers "which parts of the page keep changing", so invisible re-rasterization noise can be told apart from a genuinely restless element [#2533](https://github.com/sitespeedio/browsertime/pull/2533).
9
+ * `cpu.styleInvalidations` (Chrome only) now splits every reason, trigger and source at first paint: new `afterFirstPaint` counts and `styleRecalcsAfterFirstPaint` / `layoutInvalidationsAfterFirstPaint` totals separate building the page from churn on a page the user is already looking at. The fields only exist when the trace carried the FCP event, so absent means unknown rather than zero [#2530](https://github.com/sitespeedio/browsertime/pull/2530).
10
+
11
+ ### Fixed
12
+ * Visual metrics no longer require OpenCV and pyssim unless the metrics that need them are requested: OpenCV is only used by `--contentful` and pyssim only by `--perceptual`, so a default install now runs on just ffmpeg, Numpy and Pillow. The Docker image keeps installing everything so both metrics keep working there [#2535](https://github.com/sitespeedio/browsertime/pull/2535).
13
+ * `VisualComplete99` is now reset when the page repaints backward. Two copy-paste typos reset `VisualComplete95` twice and never reset `VisualComplete99`, so on a page that reaches 100%, repaints backward and then recovers, `VisualComplete99` could report a time earlier than `VisualComplete95` (99% complete before 95%, which cannot happen) [#2537](https://github.com/sitespeedio/browsertime/pull/2537).
14
+
15
+ ### Tech
16
+ * `compare()`, the hot path of frame processing, now counts differing pixels with `count_nonzero` on the boolean mask instead of materializing an index array of every matching pixel on every call. Byte-identical metrics on a real recording [#2536](https://github.com/sitespeedio/browsertime/pull/2536).
17
+ * Removed dead code from visual metrics: the unreachable ImageMagick-based `visualmetrics.py`, an advertised-but-unimplemented `--render` option, uncalled functions and leftover regexes in the portable script, plus a regex escape that printed a `SyntaxWarning` on Python 3.12+. No behaviour change [#2534](https://github.com/sitespeedio/browsertime/pull/2534).
18
+ * Added CI test coverage for the Speed Index, visual progress and visual change math in `visualmetrics-portable.py`, running the shipped script against the repo's test frames with the computed values pinned. A dedicated GitHub Action runs the suite in under a second [#2532](https://github.com/sitespeedio/browsertime/pull/2532).
19
+
20
+ ## 28.1.0 - 2026-07-17
21
+
22
+ ### Added
23
+ * Long Animation Frames (`pageinfo.loaf`) now ship every per-script field the LoAF API exposes (`startTime`, `duration`, `executionStart`, `pauseDuration`) plus `startTime` and `firstUIEventTimestamp` per frame — enough to attribute blocking time to individual scripts, place each frame on the page timeline and spot frames where an input was waiting [#2522](https://github.com/sitespeedio/browsertime/pull/2522) [#2523](https://github.com/sitespeedio/browsertime/pull/2523) [#2526](https://github.com/sitespeedio/browsertime/pull/2526).
24
+ * New `pageinfo.loafSummary` key (`totalFrames`, `totalBlockingDuration`, `totalDuration`): `pageinfo.loaf` only ships the 10 frames with the most blocking, so totals computed from it understate busy pages [#2524](https://github.com/sitespeedio/browsertime/pull/2524).
25
+ * LoAF script URLs get the same ResourceLoader `label`s as `cpu.urls` (`load.php[startup]` and friends) [#2525](https://github.com/sitespeedio/browsertime/pull/2525).
26
+ * Multi-module `load.php` batches are labeled with their first module name — pages lazy-load several general batches, so the bare `load.php[scripts]` label collided as soon as there was more than one [#2527](https://github.com/sitespeedio/browsertime/pull/2527).
27
+
3
28
  ## 28.0.0 - 2026-07-17
4
29
 
5
30
  ### Breaking
@@ -17,6 +17,11 @@
17
17
  // re-package
18
18
  for (let entry of longestBlockingLoAFs) {
19
19
  const info = {};
20
+ // startTime places the frame on the page timeline (before or
21
+ // after FCP/LCP/load); firstUIEventTimestamp > 0 means an input
22
+ // was waiting while this frame blocked — the INP connection.
23
+ info.startTime = entry.startTime;
24
+ info.firstUIEventTimestamp = entry.firstUIEventTimestamp;
20
25
  info.blockingDuration = entry.blockingDuration;
21
26
  info.duration = entry.duration;
22
27
  info.styleAndLayoutStart = entry.styleAndLayoutStart;
@@ -29,6 +34,16 @@
29
34
  info.scripts = [];
30
35
  for (let script of entry.scripts) {
31
36
  const s = {};
37
+ // startTime + duration make per-script attribution possible:
38
+ // without them a frame's blocking can only be credited to
39
+ // every script that ran in it.
40
+ s.startTime = script.startTime;
41
+ s.duration = script.duration;
42
+ // executionStart - startTime is queue/compile delay before the
43
+ // script ran; pauseDuration separates "computed the whole time"
44
+ // from "sat in a sync pause" (alert, sync XHR).
45
+ s.executionStart = script.executionStart;
46
+ s.pauseDuration = script.pauseDuration;
32
47
  s.forcedStyleAndLayoutDuration = script.forcedStyleAndLayoutDuration;
33
48
  s.invoker = script.invoker;
34
49
  s.invokerType = script.invokerType;
@@ -0,0 +1,24 @@
1
+ (function () {
2
+ // The loaf script ships only the 10 frames with the most blocking
3
+ // time, so its numbers understate busy pages. These totals are
4
+ // computed over every long animation frame before the cut.
5
+ if (
6
+ PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')
7
+ ) {
8
+ const observer = new PerformanceObserver(list => {});
9
+ observer.observe({type: 'long-animation-frame', buffered: true});
10
+ const entries = observer.takeRecords();
11
+
12
+ let totalBlockingDuration = 0;
13
+ let totalDuration = 0;
14
+ for (let entry of entries) {
15
+ totalBlockingDuration += entry.blockingDuration;
16
+ totalDuration += entry.duration;
17
+ }
18
+ return {
19
+ totalFrames: entries.length,
20
+ totalBlockingDuration,
21
+ totalDuration
22
+ };
23
+ }
24
+ })();
@@ -96,12 +96,14 @@ export function labelForUrl(url) {
96
96
  return 'load.php[startup]';
97
97
  }
98
98
  const kind = only === 'styles' ? 'styles' : 'scripts';
99
- if (modules.length === 1) {
99
+ // Every multi-module batch carries its (alphabetically) first module
100
+ // name. Real pages lazy-load several general batches (no `only=`)
101
+ // over time, so a bare load.php[scripts] label collides as soon as
102
+ // there is more than one — and the single-module case already names
103
+ // general batches like raw ones.
104
+ if (modules.length > 0) {
100
105
  return `load.php[${kind}:${modules[0]}]`;
101
106
  }
102
- if (modules.length > 1 && only === 'scripts') {
103
- return `load.php[scripts:${modules[0]}]`;
104
- }
105
107
  return `load.php[${kind}]`;
106
108
  }
107
109
 
@@ -26,6 +26,22 @@
26
26
  * they cause is already in cpu.categories.styleLayout and the
27
27
  * recalculateStyle summaries.
28
28
  *
29
+ * When the trace carries a firstContentfulPaint event, every reason,
30
+ * trigger and source also gets an afterFirstPaint count and the
31
+ * result gets styleRecalcsAfterFirstPaint /
32
+ * layoutInvalidationsAfterFirstPaint totals; with a
33
+ * largestContentfulPaint::Candidate event the same happens for
34
+ * afterLargestContentfulPaint (final candidate, mirroring how
35
+ * getRenderBlocking windows the recalculate-style work to both
36
+ * paints). Invalidations before first paint are mostly the page being
37
+ * built; between the paints they are what delayed the largest paint;
38
+ * after it they are churn on a page the user is already looking at.
39
+ * Absent fields mean the paint event was missing from the trace —
40
+ * consumers can fall back to guessing from the reason categories, and
41
+ * should mind that LCP can land before FCP in real traces (the counts
42
+ * stay truthful per boundary, an "in between" window is then simply
43
+ * not derivable by subtraction).
44
+ *
29
45
  * When the optional `bundles` map (url → location resolver from the
30
46
  * coverage path) is passed, source frames inside concatenated
31
47
  * bundles resolve to the owning module — thousands of invalidations
@@ -44,9 +60,13 @@ const MAX_REASONS = 10;
44
60
  const MAX_TRIGGERS = 15;
45
61
  const MAX_SOURCES = 10;
46
62
 
47
- function increment(map, key) {
63
+ function increment(map, key, afterFirstPaint, afterLargestPaint) {
48
64
  if (!key) return;
49
- map.set(key, (map.get(key) || 0) + 1);
65
+ const entry = map.get(key) || { count: 0, after: 0, afterLcp: 0 };
66
+ entry.count++;
67
+ if (afterFirstPaint) entry.after++;
68
+ if (afterLargestPaint) entry.afterLcp++;
69
+ map.set(key, entry);
50
70
  }
51
71
 
52
72
  // Source identity for an invalidation: the innermost stack frame
@@ -54,7 +74,13 @@ function increment(map, key) {
54
74
  // a known concatenated bundle (trace stack-frame positions are
55
75
  // 1-based, matching the resolvers). Without bundles the identity is
56
76
  // just the URL — the pre-existing behaviour.
57
- function sourceEntryFor(sources, data, bundles) {
77
+ function sourceEntryFor(
78
+ sources,
79
+ data,
80
+ bundles,
81
+ afterFirstPaint,
82
+ afterLargestPaint
83
+ ) {
58
84
  for (const frame of data.stackTrace || []) {
59
85
  if (!frame.url) continue;
60
86
  let module_;
@@ -67,18 +93,20 @@ function sourceEntryFor(sources, data, bundles) {
67
93
  const key = module_ ? `${frame.url}|${module_.name}` : frame.url;
68
94
  let entry = sources.get(key);
69
95
  if (!entry) {
70
- entry = { url: frame.url, count: 0 };
96
+ entry = { url: frame.url, count: 0, after: 0, afterLcp: 0 };
71
97
  if (module_) entry.module = module_.name;
72
98
  sources.set(key, entry);
73
99
  }
74
100
  entry.count++;
101
+ if (afterFirstPaint) entry.after++;
102
+ if (afterLargestPaint) entry.afterLcp++;
75
103
  return;
76
104
  }
77
105
  }
78
106
 
79
107
  function topEntries(map, limit, publish) {
80
108
  return [...map.entries()]
81
- .toSorted((a, b) => b[1] - a[1])
109
+ .toSorted((a, b) => b[1].count - a[1].count)
82
110
  .slice(0, limit)
83
111
  .map(entry => publish(entry));
84
112
  }
@@ -90,38 +118,108 @@ export function computeStyleInvalidations(trace, bundles) {
90
118
  const sources = new Map();
91
119
  let styleRecalcs = 0;
92
120
  let layoutInvalidations = 0;
121
+ let styleRecalcsAfter = 0;
122
+ let layoutInvalidationsAfter = 0;
123
+ let styleRecalcsAfterLcp = 0;
124
+ let layoutInvalidationsAfterLcp = 0;
93
125
  let sawInvalidations = false;
94
126
 
127
+ // Same events getRenderBlocking uses to window the recalculate-style
128
+ // work — one boundary per paint, so "after first paint" and "after
129
+ // the largest paint" mean the same thing in both places. LCP is the
130
+ // final candidate, like getLargestContentfulPaintEvent picks it.
131
+ const fcpEvent = trace.traceEvents.find(
132
+ task => task.name === 'firstContentfulPaint'
133
+ );
134
+ const fcpTs = fcpEvent ? fcpEvent.ts : undefined;
135
+ let lcpTs;
136
+ for (const task of trace.traceEvents) {
137
+ if (
138
+ task.name === 'largestContentfulPaint::Candidate' &&
139
+ (lcpTs === undefined || task.ts > lcpTs)
140
+ ) {
141
+ lcpTs = task.ts;
142
+ }
143
+ }
144
+
95
145
  for (const event of trace.traceEvents) {
96
146
  const data = (event.args && event.args.data) || {};
147
+ const afterFirstPaint = fcpTs !== undefined && event.ts > fcpTs;
148
+ const afterLargestPaint = lcpTs !== undefined && event.ts > lcpTs;
97
149
  switch (event.name) {
98
150
  case 'StyleRecalcInvalidationTracking': {
99
151
  sawInvalidations = true;
100
152
  styleRecalcs++;
101
- increment(recalcReasons, data.reason);
102
- sourceEntryFor(sources, data, bundles);
153
+ if (afterFirstPaint) styleRecalcsAfter++;
154
+ if (afterLargestPaint) styleRecalcsAfterLcp++;
155
+ increment(
156
+ recalcReasons,
157
+ data.reason,
158
+ afterFirstPaint,
159
+ afterLargestPaint
160
+ );
161
+ sourceEntryFor(
162
+ sources,
163
+ data,
164
+ bundles,
165
+ afterFirstPaint,
166
+ afterLargestPaint
167
+ );
103
168
  break;
104
169
  }
105
170
  case 'LayoutInvalidationTracking': {
106
171
  sawInvalidations = true;
107
172
  layoutInvalidations++;
108
- increment(layoutReasons, data.reason);
109
- sourceEntryFor(sources, data, bundles);
173
+ if (afterFirstPaint) layoutInvalidationsAfter++;
174
+ if (afterLargestPaint) layoutInvalidationsAfterLcp++;
175
+ increment(
176
+ layoutReasons,
177
+ data.reason,
178
+ afterFirstPaint,
179
+ afterLargestPaint
180
+ );
181
+ sourceEntryFor(
182
+ sources,
183
+ data,
184
+ bundles,
185
+ afterFirstPaint,
186
+ afterLargestPaint
187
+ );
110
188
  break;
111
189
  }
112
190
  case 'ScheduleStyleInvalidationTracking': {
113
191
  sawInvalidations = true;
114
192
  if (data.changedClass) {
115
- increment(triggers, `class|${data.changedClass}`);
193
+ increment(
194
+ triggers,
195
+ `class|${data.changedClass}`,
196
+ afterFirstPaint,
197
+ afterLargestPaint
198
+ );
116
199
  }
117
200
  if (data.changedAttribute) {
118
- increment(triggers, `attribute|${data.changedAttribute}`);
201
+ increment(
202
+ triggers,
203
+ `attribute|${data.changedAttribute}`,
204
+ afterFirstPaint,
205
+ afterLargestPaint
206
+ );
119
207
  }
120
208
  if (data.changedId) {
121
- increment(triggers, `id|${data.changedId}`);
209
+ increment(
210
+ triggers,
211
+ `id|${data.changedId}`,
212
+ afterFirstPaint,
213
+ afterLargestPaint
214
+ );
122
215
  }
123
216
  if (data.changedPseudo) {
124
- increment(triggers, `pseudo|${data.changedPseudo}`);
217
+ increment(
218
+ triggers,
219
+ `pseudo|${data.changedPseudo}`,
220
+ afterFirstPaint,
221
+ afterLargestPaint
222
+ );
125
223
  }
126
224
  break;
127
225
  }
@@ -132,31 +230,49 @@ export function computeStyleInvalidations(trace, bundles) {
132
230
  }
133
231
  if (!sawInvalidations) return;
134
232
 
135
- return {
233
+ // The after fields only exist when the trace had the matching paint
234
+ // event — an absent field means "unknown", never "zero".
235
+ const withAfter = (published, entry) => {
236
+ if (fcpTs !== undefined) published.afterFirstPaint = entry.after;
237
+ if (lcpTs !== undefined) {
238
+ published.afterLargestContentfulPaint = entry.afterLcp;
239
+ }
240
+ return published;
241
+ };
242
+
243
+ const result = {
136
244
  styleRecalcs,
137
245
  layoutInvalidations,
138
- recalcReasons: topEntries(
139
- recalcReasons,
140
- MAX_REASONS,
141
- ([reason, count]) => ({
142
- reason,
143
- count
144
- })
246
+ recalcReasons: topEntries(recalcReasons, MAX_REASONS, ([reason, entry]) =>
247
+ withAfter({ reason, count: entry.count }, entry)
145
248
  ),
146
- layoutReasons: topEntries(
147
- layoutReasons,
148
- MAX_REASONS,
149
- ([reason, count]) => ({
150
- reason,
151
- count
152
- })
249
+ layoutReasons: topEntries(layoutReasons, MAX_REASONS, ([reason, entry]) =>
250
+ withAfter({ reason, count: entry.count }, entry)
153
251
  ),
154
- triggers: topEntries(triggers, MAX_TRIGGERS, ([key, count]) => {
252
+ triggers: topEntries(triggers, MAX_TRIGGERS, ([key, entry]) => {
155
253
  const [kind, ...name] = key.split('|');
156
- return { kind, name: name.join('|'), count };
254
+ return withAfter(
255
+ { kind, name: name.join('|'), count: entry.count },
256
+ entry
257
+ );
157
258
  }),
158
259
  sources: [...sources.values()]
159
260
  .toSorted((a, b) => b.count - a.count)
160
261
  .slice(0, MAX_SOURCES)
262
+ .map(entry => {
263
+ const published = { url: entry.url, count: entry.count };
264
+ if (entry.module) published.module = entry.module;
265
+ return withAfter(published, entry);
266
+ })
161
267
  };
268
+ if (fcpTs !== undefined) {
269
+ result.styleRecalcsAfterFirstPaint = styleRecalcsAfter;
270
+ result.layoutInvalidationsAfterFirstPaint = layoutInvalidationsAfter;
271
+ }
272
+ if (lcpTs !== undefined) {
273
+ result.styleRecalcsAfterLargestContentfulPaint = styleRecalcsAfterLcp;
274
+ result.layoutInvalidationsAfterLargestContentfulPaint =
275
+ layoutInvalidationsAfterLcp;
276
+ }
277
+ return result;
162
278
  }
@@ -395,6 +395,20 @@ export class Chromium {
395
395
  // this hack fixes that
396
396
  if (result.browserScripts && result.browserScripts.pageinfo) {
397
397
  result.browserScripts.pageinfo.longTask = this.longTaskInfo;
398
+
399
+ // Resolve LoAF script URLs to ResourceLoader module labels the
400
+ // same way cpu.urls gets them — several frames all blaming one
401
+ // giant load.php URL say nothing without the module name.
402
+ if (Array.isArray(result.browserScripts.pageinfo.loaf)) {
403
+ for (const frame of result.browserScripts.pageinfo.loaf) {
404
+ for (const script of frame.scripts || []) {
405
+ const label = labelForUrl(script.sourceURL);
406
+ if (label) {
407
+ script.label = label;
408
+ }
409
+ }
410
+ }
411
+ }
398
412
  }
399
413
 
400
414
  if (this.chrome.collectNetLog && this.chrome.android) {
@@ -212,8 +212,14 @@ export class Collector {
212
212
  this._logVisualMetrics(data);
213
213
  }
214
214
  for (let key of Object.keys(data.visualMetrics)) {
215
- // Skip VisualProgress/ContentfulProgress etc
216
- if (!key.includes('Progress')) {
215
+ // Skip VisualProgress/ContentfulProgress etc, FrameDiffs (an
216
+ // array of per-frame objects) and FrameInstability (a summary
217
+ // object) — nothing to summarize.
218
+ if (
219
+ !key.includes('Progress') &&
220
+ key !== 'FrameDiffs' &&
221
+ key !== 'FrameInstability'
222
+ ) {
217
223
  const d = { visualMetrics: {} };
218
224
  d['visualMetrics'][key] = data.visualMetrics[key];
219
225
  statistics.addDeep(d);
@@ -732,6 +732,13 @@ export function parseCommandLine() {
732
732
  describe: 'Create filmstrip screenshots.',
733
733
  group: 'video'
734
734
  })
735
+ .option('videoParams.filmstripDiff', {
736
+ type: 'boolean',
737
+ default: false,
738
+ describe:
739
+ 'Also write a diff image per filmstrip frame (the frame dimmed with every pixel changed since the previous frame painted red) plus changed-pixel counts. Lets report UIs show exact frame diffs without pixel access, for example when a report is opened from file://.',
740
+ group: 'video'
741
+ })
735
742
  .option('videoParams.nice', {
736
743
  default: 0,
737
744
  describe:
@@ -750,6 +757,13 @@ export function parseCommandLine() {
750
757
  'Convert the original video to a viewable format (for most video players). Turn that off to make a faster run.',
751
758
  group: 'video'
752
759
  })
760
+ .option('videoParams.noiseTolerance', {
761
+ type: 'number',
762
+ default: 0,
763
+ describe:
764
+ 'Percentage (0-100) of pixels that are allowed to differ between video frames when Visual Metrics looks for visual changes. Browsers can re-rasterize the page long after it is visually done, leaving invisible sub-pixel noise that inflates Last Visual Change, Speed Index and the visual progress curve. Try 0.05 if you see a too late Last Visual Change with no visible change in the filmstrip. 0 (default) keeps the old behavior of allowing 5 pixels.',
765
+ group: 'video'
766
+ })
753
767
  .option('videoParams.threads', {
754
768
  default: threads,
755
769
  describe:
@@ -21,16 +21,19 @@ export function extraMetrics(metrics) {
21
21
 
22
22
  // Oh noo the painting on the screen goes backward
23
23
  // see https://github.com/sitespeedio/sitespeed.io/issues/2259#issuecomment-456878707
24
+ // Dropping below a threshold invalidates that milestone and every
25
+ // higher one, so they are re-found at the later timestamp where the
26
+ // page climbs back up.
24
27
  if (metrics.VisualComplete85 && Number.parseInt(percent, 10) < 85) {
25
28
  metrics.VisualComplete85 = undefined;
26
29
  metrics.VisualComplete95 = undefined;
27
- metrics.VisualComplete95 = undefined;
30
+ metrics.VisualComplete99 = undefined;
28
31
  } else if (
29
32
  metrics.VisualComplete95 &&
30
33
  Number.parseInt(percent, 10) < 95
31
34
  ) {
32
35
  metrics.VisualComplete95 = undefined;
33
- metrics.VisualComplete95 = undefined;
36
+ metrics.VisualComplete99 = undefined;
34
37
  } else if (
35
38
  metrics.VisualComplete99 &&
36
39
  Number.parseInt(percent, 10) < 99
@@ -52,6 +52,8 @@ export async function run(
52
52
 
53
53
  const thumbsize = getProperty(options, 'videoParams.thumbsize', 400);
54
54
 
55
+ const noiseTolerance = getProperty(options, 'videoParams.noiseTolerance', 0);
56
+
55
57
  const scriptArguments = [
56
58
  '--video',
57
59
  videoPath,
@@ -69,6 +71,10 @@ export async function run(
69
71
  100
70
72
  ];
71
73
 
74
+ if (noiseTolerance > 0) {
75
+ scriptArguments.push('--noisetolerance', noiseTolerance);
76
+ }
77
+
72
78
  if (options.visualMetricsPerceptual) {
73
79
  scriptArguments.push('--perceptual');
74
80
  }
@@ -111,6 +117,9 @@ export async function run(
111
117
  } else if (thumbsize !== 400) {
112
118
  scriptArguments.push('--thumbsize', thumbsize);
113
119
  }
120
+ if (getProperty(options, 'videoParams.filmstripDiff', false)) {
121
+ scriptArguments.push('--framediff');
122
+ }
114
123
  }
115
124
 
116
125
  const visualMetricsLogFile = path.join(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "browsertime",
3
3
  "description": "Get performance metrics from your web page using Browsertime.",
4
- "version": "28.0.0",
4
+ "version": "28.2.0",
5
5
  "bin": "./bin/browsertime.js",
6
6
  "type": "module",
7
7
  "types": "./scripting.d.ts",
@@ -1 +1 @@
1
- {"version":3,"file":"mediawikiResourceLoader.d.ts","sourceRoot":"","sources":["../../lib/chrome/mediawikiResourceLoader.js"],"names":[],"mappings":"AAmBA,mEAEC;AA6DD,8CAuBC;AAcD,8DAUC;AAwBD;;;;;EAkCC;AAOD,6EA4BC"}
1
+ {"version":3,"file":"mediawikiResourceLoader.d.ts","sourceRoot":"","sources":["../../lib/chrome/mediawikiResourceLoader.js"],"names":[],"mappings":"AAmBA,mEAEC;AA6DD,8CAyBC;AAcD,8DAUC;AAwBD;;;;;EAkCC;AAOD,6EA4BC"}
@@ -1 +1 @@
1
- {"version":3,"file":"style-invalidations.d.ts","sourceRoot":"","sources":["../../../lib/chrome/trace/style-invalidations.js"],"names":[],"mappings":"AAqFA;;;;;;;EA4EC"}
1
+ {"version":3,"file":"style-invalidations.d.ts","sourceRoot":"","sources":["../../../lib/chrome/trace/style-invalidations.js"],"names":[],"mappings":"AAiHA;;;;;;;EAoKC"}
@@ -1 +1 @@
1
- {"version":3,"file":"extraMetrics.d.ts","sourceRoot":"","sources":["../../../../lib/video/postprocessing/visualmetrics/extraMetrics.js"],"names":[],"mappings":"AAAA;;;EA8CC"}
1
+ {"version":3,"file":"extraMetrics.d.ts","sourceRoot":"","sources":["../../../../lib/video/postprocessing/visualmetrics/extraMetrics.js"],"names":[],"mappings":"AAAA;;;EAiDC"}
@@ -1 +1 @@
1
- {"version":3,"file":"visualMetrics.d.ts","sourceRoot":"","sources":["../../../../lib/video/postprocessing/visualmetrics/visualMetrics.js"],"names":[],"mappings":"AAoBA,kDAKC;AACD,0KAqIC"}
1
+ {"version":3,"file":"visualMetrics.d.ts","sourceRoot":"","sources":["../../../../lib/video/postprocessing/visualmetrics/visualMetrics.js"],"names":[],"mappings":"AAoBA,kDAKC;AACD,0KA8IC"}
@@ -66,13 +66,17 @@ def compare(img1, img2, fuzz=0.10):
66
66
  img1_data = np.array(img1)
67
67
  img2_data = np.array(img2)
68
68
 
69
- inds = np.argwhere(
69
+ # count_nonzero instead of len(argwhere(...)): the count is all
70
+ # we need and argwhere materializes an index array of every
71
+ # matching pixel, which is most of the frame on every call in
72
+ # the duplicate-elimination hot path.
73
+ close = (
70
74
  np.isclose(img1_data[:, :, 0], img2_data[:, :, 0], atol=fuzz * 255)
71
75
  & np.isclose(img1_data[:, :, 1], img2_data[:, :, 1], atol=fuzz * 255)
72
76
  & np.isclose(img1_data[:, :, 2], img2_data[:, :, 2], atol=fuzz * 255)
73
77
  )
74
78
 
75
- return (img1_data.shape[0] * img1_data.shape[1]) - len(inds)
79
+ return (img1_data.shape[0] * img1_data.shape[1]) - int(np.count_nonzero(close))
76
80
  except BaseException as e:
77
81
  logging.exception(e)
78
82
  return None
@@ -409,16 +413,14 @@ def video_to_frames(
409
413
  gc.collect()
410
414
  if extract_frames(video, directory, full_resolution, viewport):
411
415
  client_viewport = None
412
- directories = [directory]
413
- for dir in directories:
414
- if orange_file is not None:
415
- remove_frames_before_orange(dir, orange_file)
416
- remove_orange_frames(dir, orange_file)
417
- find_render_start(dir, orange_file, cropped, is_mobile)
418
- adjust_frame_times(dir)
419
- eliminate_duplicate_frames(dir, cropped, is_mobile)
420
- crop_viewport(dir)
421
- gc.collect()
416
+ if orange_file is not None:
417
+ remove_frames_before_orange(directory, orange_file)
418
+ remove_orange_frames(directory, orange_file)
419
+ find_render_start(directory, orange_file, cropped, is_mobile)
420
+ adjust_frame_times(directory)
421
+ eliminate_duplicate_frames(directory, cropped, is_mobile)
422
+ crop_viewport(directory)
423
+ gc.collect()
422
424
  else:
423
425
  logging.critical("Error extracting the video frames from %s", video)
424
426
  else:
@@ -500,7 +502,7 @@ def find_recording_platform(video):
500
502
  lines = result.stderr.splitlines()
501
503
 
502
504
  is_mobile = False
503
- matcher = re.compile(".*com\.android\.version.*")
505
+ matcher = re.compile(r".*com\.android\.version.*")
504
506
  for line in lines:
505
507
  if matcher.search(line):
506
508
  is_mobile = True
@@ -902,6 +904,33 @@ def eliminate_duplicate_frames(directory, cropped, is_mobile):
902
904
  )
903
905
  Path(duplicate).unlink()
904
906
 
907
+ # With a noise tolerance set, do a last pass over the remaining
908
+ # frames and drop every frame that differs from the previously
909
+ # kept frame by no more than the allowance. Browsers can
910
+ # re-rasterize the whole page long after it is visually done,
911
+ # leaving invisible sub-pixel noise on glyph edges spread over
912
+ # hundreds of pixels on a page-sized frame; the passes above
913
+ # allow only a handful of differing pixels, so that noise counts
914
+ # as a visual change and pollutes Last Visual Change, the visual
915
+ # progress curve, Speed Index and VisualComplete85/95/99.
916
+ # Comparing against the last KEPT frame (not the deleted
917
+ # neighbour) keeps slow real changes: once the accumulated
918
+ # difference crosses the allowance the frame is kept and becomes
919
+ # the new reference.
920
+ if options.noisetolerance > 0:
921
+ max_differences = max(
922
+ 5, int(width * height * options.noisetolerance / 100)
923
+ )
924
+ files = sorted(glob.glob(str(Path(directory) / "ms_*.png")))
925
+ if len(files) > 2:
926
+ kept = files[0]
927
+ for file in files[1:]:
928
+ if frames_match(kept, file, 15, max_differences, crop, None):
929
+ logging.debug("Removing noise frame {0}".format(file))
930
+ Path(file).unlink()
931
+ else:
932
+ kept = file
933
+
905
934
  except BaseException:
906
935
  logging.exception("Error processing frames for duplicates")
907
936
 
@@ -950,18 +979,6 @@ def get_decimate_filter():
950
979
  return decimate
951
980
 
952
981
 
953
- def clean_directory(directory):
954
- files = glob.glob(str(Path(directory) / "*.png"))
955
- for file in files:
956
- Path(file).unlink()
957
- files = glob.glob(str(Path(directory) / "*.jpg"))
958
- for file in files:
959
- Path(file).unlink()
960
- files = glob.glob(str(Path(directory) / "*.json"))
961
- for file in files:
962
- Path(file).unlink()
963
-
964
-
965
982
  def is_color_frame(file, color_file):
966
983
  """Check a section from the middle, top and bottom of the viewport to see if it matches"""
967
984
  global frame_cache
@@ -1189,6 +1206,101 @@ def save_screenshot(directory, dest, quality):
1189
1206
  ##########################################################################
1190
1207
 
1191
1208
 
1209
+ def create_frame_diffs(directory):
1210
+ """Write a diff image next to every filmstrip frame and return the
1211
+ changed-pixel counts.
1212
+
1213
+ For each adjacent pair of kept frames, diff_<ms>.png shows the
1214
+ current frame dimmed toward white with every changed pixel painted
1215
+ red — precomputed here because the report's lightbox cannot read
1216
+ pixels when opened from file:// (tainted canvas), and this is where
1217
+ the frames and Pillow already are. The per-pixel threshold (sum of
1218
+ channel deltas > 30) matches the report's canvas fallback, so both
1219
+ paths show the same picture. PNG on purpose: mostly-white plus
1220
+ sparse red compresses better than JPEG and keeps the marks crisp.
1221
+
1222
+ Runs before convert_to_jpeg (which only globs ms_*.png) while the
1223
+ frames are still lossless PNG.
1224
+
1225
+ The same loop also counts how many times each pixel changed across
1226
+ the whole run and writes instability.png: the final frame ghosted
1227
+ toward white with every changed pixel tinted from amber (changed
1228
+ once) to deep red (changed 10 or more times). The color scale is
1229
+ fixed, not normalized per run, so heatmaps from two runs are
1230
+ comparable side by side. A page that paints once and stays put is
1231
+ all amber; text that re-rasterizes over and over shows up as red
1232
+ speckle. The summary counts are returned next to the per-frame
1233
+ diffs, with "repeatedly" meaning 5 or more changes.
1234
+ """
1235
+ diffs = []
1236
+ instability = None
1237
+ try:
1238
+ import numpy as np
1239
+ from PIL import Image
1240
+
1241
+ files = sorted(glob.glob(str(Path(directory) / "ms_*.png")))
1242
+ counts = None
1243
+ final = None
1244
+ for previous_file, current_file in zip(files, files[1:]):
1245
+ with Image.open(previous_file) as previous_image, Image.open(
1246
+ current_file
1247
+ ) as current_image:
1248
+ previous = np.array(previous_image.convert("RGB"), dtype=int)
1249
+ current = np.array(current_image.convert("RGB"), dtype=int)
1250
+ if previous.shape != current.shape:
1251
+ continue
1252
+ delta = np.abs(previous - current).sum(axis=2)
1253
+ changed = delta > 30
1254
+ if counts is None:
1255
+ counts = np.zeros(changed.shape, dtype=int)
1256
+ if counts.shape == changed.shape:
1257
+ counts += changed
1258
+ final = current
1259
+ out = 255 - (255 - current) * 0.2
1260
+ out[changed] = [224, 28, 60]
1261
+ stem = Path(current_file).stem
1262
+ diff_name = "diff_" + stem[3:] + ".png"
1263
+ Image.fromarray(out.astype(np.uint8)).save(
1264
+ str(Path(directory) / diff_name)
1265
+ )
1266
+ changed_pixels = int(changed.sum())
1267
+ diffs.append(
1268
+ {
1269
+ "time": int(stem[3:]),
1270
+ "changedPixels": changed_pixels,
1271
+ "changedShare": round(
1272
+ changed_pixels / changed.size * 100, 2
1273
+ ),
1274
+ }
1275
+ )
1276
+ if counts is not None and final is not None:
1277
+ cap = 10
1278
+ heat = np.sqrt(np.minimum(counts, cap) / cap)
1279
+ amber = np.array([255.0, 196.0, 60.0])
1280
+ red = np.array([201.0, 16.0, 46.0])
1281
+ color = amber + (red - amber) * heat[:, :, None]
1282
+ alpha = np.where(counts > 0, 0.35 + 0.65 * heat, 0)[:, :, None]
1283
+ ghost = 255 - (255 - final) * 0.15
1284
+ heatmap = ghost * (1 - alpha) + color * alpha
1285
+ Image.fromarray(heatmap.astype(np.uint8)).save(
1286
+ str(Path(directory) / "instability.png")
1287
+ )
1288
+ changed_ever = int((counts > 0).sum())
1289
+ changed_repeatedly = int((counts >= 5).sum())
1290
+ instability = {
1291
+ "changedPixels": changed_ever,
1292
+ "changedShare": round(changed_ever / counts.size * 100, 2),
1293
+ "repeatedlyChangedPixels": changed_repeatedly,
1294
+ "repeatedlyChangedShare": round(
1295
+ changed_repeatedly / counts.size * 100, 2
1296
+ ),
1297
+ "maxChanges": int(counts.max()),
1298
+ }
1299
+ except BaseException:
1300
+ logging.exception("Error creating frame diffs")
1301
+ return diffs, instability
1302
+
1303
+
1192
1304
  def convert_to_jpeg(directory, quality):
1193
1305
  logging.debug("Converting video frames to JPEG")
1194
1306
  directory = str(Path(directory).resolve())
@@ -1395,12 +1507,6 @@ def calculate_key_color_frames(histograms, key_colors):
1395
1507
  for key in key_colors:
1396
1508
  key_color_frames[key] = []
1397
1509
 
1398
- current = None
1399
- current_key = None
1400
- total = 0
1401
- matched = 0
1402
- buckets = 256
1403
- channels = ["r", "g", "b"]
1404
1510
  histograms = histograms.copy()
1405
1511
 
1406
1512
  while len(histograms) > 0:
@@ -1486,17 +1592,6 @@ def calculate_frame_progress(histogram, start, final):
1486
1592
  return math.floor(progress * 100)
1487
1593
 
1488
1594
 
1489
- def find_visually_complete(progress):
1490
- time = 0
1491
- for p in progress:
1492
- if int(p["progress"]) == 100:
1493
- time = p["time"]
1494
- break
1495
- elif time == 0:
1496
- time = p["time"]
1497
- return time
1498
-
1499
-
1500
1595
  def calculate_speed_index(progress):
1501
1596
  si = 0
1502
1597
  last_ms = progress[0]["time"]
@@ -1510,11 +1605,6 @@ def calculate_speed_index(progress):
1510
1605
 
1511
1606
 
1512
1607
  def calculate_contentful_speed_index(progress, directory):
1513
- # convert output comes out with lines that have this format:
1514
- # <pixel count>: <rgb color> #<hex color> <gray color>
1515
- # This is CLI dependant and very fragile
1516
- matcher = re.compile(r"(\d+?):")
1517
-
1518
1608
  try:
1519
1609
  from PIL import Image
1520
1610
 
@@ -1715,7 +1805,14 @@ def calculate_hero_time(progress, directory, hero, viewport):
1715
1805
  ##########################################################################
1716
1806
 
1717
1807
 
1718
- def check_config():
1808
+ def check_config(need_opencv=False, need_ssim=False):
1809
+ """Check dependencies.
1810
+
1811
+ OpenCV is only used by --contentful/--contentful-video and pyssim
1812
+ only by --perceptual, so their absence is fatal only when those
1813
+ options are in play — everything else needs just ffmpeg, Numpy and
1814
+ Pillow.
1815
+ """
1719
1816
  ok = True
1720
1817
 
1721
1818
  if get_decimate_filter() is not None:
@@ -1739,45 +1836,44 @@ def check_config():
1739
1836
  ok = False
1740
1837
 
1741
1838
  try:
1742
- import cv2
1839
+ from PIL import Image, ImageCms, ImageDraw, ImageOps # noqa
1743
1840
 
1744
- logging.debug("OpenCV-Python found")
1841
+ logging.debug("Pillow found")
1745
1842
  except BaseException:
1746
- print("OpenCV-Python: FAIL")
1843
+ print("Pillow: FAIL")
1747
1844
  ok = False
1748
1845
 
1749
1846
  try:
1750
- from PIL import Image, ImageCms, ImageDraw, ImageOps # noqa
1847
+ import cv2
1751
1848
 
1752
- logging.debug("Pillow found")
1849
+ logging.debug("OpenCV-Python found")
1753
1850
  except BaseException:
1754
- print("Pillow: FAIL")
1755
- ok = False
1851
+ if need_opencv:
1852
+ print("OpenCV-Python: FAIL")
1853
+ ok = False
1854
+ else:
1855
+ # Not print: in --json runs stdout must stay pure JSON.
1856
+ logging.info(
1857
+ "OpenCV-Python is not installed (only needed for --contentful)"
1858
+ )
1756
1859
 
1757
1860
  try:
1758
1861
  from ssim import compute_ssim # noqa
1759
1862
 
1760
1863
  logging.debug("SSIM found")
1761
1864
  except BaseException:
1762
- print("SSIM: FAIL")
1763
- ok = False
1865
+ if need_ssim:
1866
+ print("SSIM: FAIL")
1867
+ ok = False
1868
+ else:
1869
+ # Not print: in --json runs stdout must stay pure JSON.
1870
+ logging.info(
1871
+ "SSIM is not installed (only needed for --perceptual)"
1872
+ )
1764
1873
 
1765
1874
  return ok
1766
1875
 
1767
1876
 
1768
- def check_process(command, output):
1769
- ok = False
1770
- try:
1771
- out = subprocess.check_output(
1772
- command, stderr=subprocess.STDOUT, shell=True, encoding="UTF-8"
1773
- )
1774
- if out.find(output) > -1:
1775
- ok = True
1776
- except BaseException:
1777
- ok = False
1778
- return ok
1779
-
1780
-
1781
1877
  ##########################################################################
1782
1878
  # Main Entry Point
1783
1879
  ##########################################################################
@@ -1821,9 +1917,6 @@ def main():
1821
1917
  help="Directory of video frames "
1822
1918
  "(as input if exists or as output if a video file is specified).",
1823
1919
  )
1824
- parser.add_argument(
1825
- "--render", help="Render the video frames to the given mp4 video file."
1826
- )
1827
1920
  parser.add_argument(
1828
1921
  "--screenshot",
1829
1922
  help="Save the last frame of video as an image to the path provided.",
@@ -1926,6 +2019,27 @@ def main():
1926
2019
  help="Ignore the center X%% of the frame when looking for "
1927
2020
  "the first rendered frame (useful for Opera mini).",
1928
2021
  )
2022
+ parser.add_argument(
2023
+ "--framediff",
2024
+ action="store_true",
2025
+ default=False,
2026
+ help="Write a diff image next to every filmstrip frame (the "
2027
+ "frame dimmed with every pixel changed since the previous frame "
2028
+ "painted red) and report the changed-pixel counts as a Frame "
2029
+ "Diffs metric. Lets report UIs show exact diffs without pixel "
2030
+ "access.",
2031
+ )
2032
+ parser.add_argument(
2033
+ "--noisetolerance",
2034
+ type=float,
2035
+ default=0,
2036
+ help="Percentage (0-100) of the compared area that is allowed to "
2037
+ "differ between frames when eliminating duplicate frames. Use it "
2038
+ "to keep invisible sub-pixel browser rendering noise from "
2039
+ "inflating Last Visual Change, Speed Index and the visual "
2040
+ "progress curve. 0 (default) keeps the fixed allowance of 5 "
2041
+ "pixels.",
2042
+ )
1929
2043
  parser.add_argument(
1930
2044
  "-k",
1931
2045
  "--perceptual",
@@ -2007,7 +2121,10 @@ def main():
2007
2121
  # Run a quick check to make sure all requirements exist,
2008
2122
  # otherwise failures might be silent due to how this code is
2009
2123
  # structured.
2010
- ok = check_config()
2124
+ ok = check_config(
2125
+ need_opencv=options.contentful or options.contentful_video,
2126
+ need_ssim=options.perceptual,
2127
+ )
2011
2128
  if not ok:
2012
2129
  raise Exception("Please install requirements before running.")
2013
2130
 
@@ -2057,6 +2174,15 @@ def main():
2057
2174
  key_colors,
2058
2175
  )
2059
2176
 
2177
+ if options.framediff and options.dir is not None and metrics is not None:
2178
+ framediffs, instability = create_frame_diffs(directory)
2179
+ if framediffs:
2180
+ metrics.append({"name": "Frame Diffs", "value": framediffs})
2181
+ if instability:
2182
+ metrics.append(
2183
+ {"name": "Frame Instability", "value": instability}
2184
+ )
2185
+
2060
2186
  if options.screenshot is not None:
2061
2187
  quality = 30
2062
2188
  if options.quality is not None: