browsertime 28.1.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 +17 -0
- package/lib/chrome/trace/style-invalidations.js +146 -30
- package/lib/core/engine/collector.js +8 -2
- package/lib/support/cli.js +14 -0
- package/lib/video/postprocessing/visualmetrics/extraMetrics.js +5 -2
- package/lib/video/postprocessing/visualmetrics/visualMetrics.js +9 -0
- package/package.json +1 -1
- package/types/chrome/trace/style-invalidations.d.ts.map +1 -1
- package/types/video/postprocessing/visualmetrics/extraMetrics.d.ts.map +1 -1
- package/types/video/postprocessing/visualmetrics/visualMetrics.d.ts.map +1 -1
- package/visualmetrics/visualmetrics-portable.py +200 -74
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 28.1.0 - 2026-07-17
|
|
4
21
|
|
|
5
22
|
### Added
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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(
|
|
193
|
+
increment(
|
|
194
|
+
triggers,
|
|
195
|
+
`class|${data.changedClass}`,
|
|
196
|
+
afterFirstPaint,
|
|
197
|
+
afterLargestPaint
|
|
198
|
+
);
|
|
116
199
|
}
|
|
117
200
|
if (data.changedAttribute) {
|
|
118
|
-
increment(
|
|
201
|
+
increment(
|
|
202
|
+
triggers,
|
|
203
|
+
`attribute|${data.changedAttribute}`,
|
|
204
|
+
afterFirstPaint,
|
|
205
|
+
afterLargestPaint
|
|
206
|
+
);
|
|
119
207
|
}
|
|
120
208
|
if (data.changedId) {
|
|
121
|
-
increment(
|
|
209
|
+
increment(
|
|
210
|
+
triggers,
|
|
211
|
+
`id|${data.changedId}`,
|
|
212
|
+
afterFirstPaint,
|
|
213
|
+
afterLargestPaint
|
|
214
|
+
);
|
|
122
215
|
}
|
|
123
216
|
if (data.changedPseudo) {
|
|
124
|
-
increment(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
252
|
+
triggers: topEntries(triggers, MAX_TRIGGERS, ([key, entry]) => {
|
|
155
253
|
const [kind, ...name] = key.split('|');
|
|
156
|
-
return
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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);
|
package/lib/support/cli.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"style-invalidations.d.ts","sourceRoot":"","sources":["../../../lib/chrome/trace/style-invalidations.js"],"names":[],"mappings":"
|
|
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;;;
|
|
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,
|
|
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
|
-
|
|
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]) -
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
1839
|
+
from PIL import Image, ImageCms, ImageDraw, ImageOps # noqa
|
|
1743
1840
|
|
|
1744
|
-
logging.debug("
|
|
1841
|
+
logging.debug("Pillow found")
|
|
1745
1842
|
except BaseException:
|
|
1746
|
-
print("
|
|
1843
|
+
print("Pillow: FAIL")
|
|
1747
1844
|
ok = False
|
|
1748
1845
|
|
|
1749
1846
|
try:
|
|
1750
|
-
|
|
1847
|
+
import cv2
|
|
1751
1848
|
|
|
1752
|
-
logging.debug("
|
|
1849
|
+
logging.debug("OpenCV-Python found")
|
|
1753
1850
|
except BaseException:
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
1763
|
-
|
|
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:
|