browsertime 22.2.0 → 22.4.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,17 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 22.3.0 - 2024-06-06
4
+ ### Added
5
+ * Use `--enableVideoRun` to get one extra run with a video and visual metrics [#2139](https://github.com/sitespeedio/browsertime/pull/2139)
6
+
7
+ ## 22.3.0 - 2024-06-04
8
+ ### Added
9
+ * Add the ability to gather power usage measurements on Android from USB power meters, thank you [Gregory Mierzwinski](https://github.com/gmierz) for PR [#2134](https://github.com/sitespeedio/browsertime/pull/2134).
10
+ * Add support to visualmetrics to identify key frames matching the given colors, thank you [aosmond](https://github.com/aosmond) for PR [#2119](https://github.com/sitespeedio/browsertime/pull/2119).
11
+
12
+ ### Fixed
13
+ * Removed DOMContentFlushed for Firefox thank you [florinbilt](https://github.com/florinbilt) for PR [#2138](https://github.com/sitespeedio/browsertime/pull/2138).
14
+
3
15
  ## 22.2.0 - 2024-05-24
4
16
  ### Added
5
17
  * New command: Mouse single click on a element with a specific id `commands.mouse.singleClick.byId(id)` and `commands.mouse.singleClick.byIdAndWait(id)` [#2135](https://github.com/sitespeedio/browsertime/pull/2135).
@@ -123,21 +123,34 @@ async function run(urls, options) {
123
123
  );
124
124
  }
125
125
 
126
- if (options.enableProfileRun) {
127
- log.info('Make one extra run to collect trace information');
126
+ if (options.enableProfileRun || options.enableVideoRun) {
127
+ log.info('Make one extra run to collect trace/video information');
128
128
  options.iterations = 1;
129
- if (options.browser === 'firefox') {
130
- options.firefox.geckoProfiler = true;
131
- } else if (options.browser === 'chrome') {
132
- options.chrome.timeline = true;
133
- options.cpu = true;
134
- options.chrome.enableTraceScreenshots = true;
135
- options.chrome.traceCategory = [
136
- 'disabled-by-default-v8.cpu_profiler'
137
- ];
129
+ if (options.enableProfileRun) {
130
+ if (options.browser === 'firefox') {
131
+ options.firefox.geckoProfiler = true;
132
+ } else if (options.browser === 'chrome') {
133
+ options.chrome.timeline = true;
134
+ options.cpu = true;
135
+ options.chrome.enableTraceScreenshots = true;
136
+ options.chrome.traceCategory = [
137
+ 'disabled-by-default-v8.cpu_profiler'
138
+ ];
139
+ }
140
+ }
141
+ if (options.enableVideoRun) {
142
+ if (options.video === true) {
143
+ log.error(
144
+ 'You can only configure video run if you do not collect any video'
145
+ );
146
+ // This is a hack to not get an error
147
+ options.video = false;
148
+ options.visualMetrics = false;
149
+ } else {
150
+ options.video = true;
151
+ options.visualMetrics = true;
152
+ }
138
153
  }
139
- options.video = false;
140
- options.visualMetrics = false;
141
154
  const traceEngine = new Engine(options);
142
155
  await traceEngine.start();
143
156
  await traceEngine.runMultiple(urls, scriptsByCategory);
@@ -5,8 +5,10 @@ import { EOL as endOfLine } from 'node:os';
5
5
  import { execa } from 'execa';
6
6
  import intel from 'intel';
7
7
  import pkg from '@devicefarmer/adbkit';
8
+ import usbPowerProfiler from 'usb-power-profiling/usb-power-profiling.js';
8
9
  const { Adb } = pkg;
9
10
  import get from 'lodash.get';
11
+ import { pathToFolder } from '../support/pathToFolder.js';
10
12
  const log = intel.getLogger('browsertime.android');
11
13
  const mkdir = promisify(_mkdir);
12
14
  const delay = ms => new Promise(res => setTimeout(res, ms));
@@ -466,6 +468,41 @@ export class Android {
466
468
  const batterystats = await this._runCommandAndGet('dumpsys batterystats');
467
469
  return parsePowerMetrics(batterystats, packageName);
468
470
  }
471
+
472
+ async measureUsbPowerUsage(startTime, endTime) {
473
+ return getUsbPowerUsage(startTime, endTime);
474
+ }
475
+
476
+ async getUsbPowerUsageProfile(index, url, result, options, storageManager) {
477
+ let profileData = await usbPowerProfiler.profileFromData();
478
+ let destinationFilename = join(
479
+ await pathToFolder(url, options),
480
+ `powerProfile-${index}.json`
481
+ );
482
+
483
+ await storageManager.writeJson(destinationFilename, profileData);
484
+ }
485
+ }
486
+
487
+ async function getUsbPowerUsage(startTime, endTime) {
488
+ let baselineUsageData = await usbPowerProfiler.getPowerData(
489
+ startTime - 2,
490
+ endTime - 1
491
+ );
492
+ let baselineUsageTotal = baselineUsageData[0]['samples']['data'].reduce(
493
+ (currSum, currVal) => currSum + Number.parseInt(currVal[1]),
494
+ 0
495
+ );
496
+ let baselineUsage =
497
+ baselineUsageTotal / baselineUsageData[0]['samples']['data'].length;
498
+
499
+ let powerUsageData = await usbPowerProfiler.getPowerData(startTime, endTime);
500
+ let powerUsage = powerUsageData[0]['samples']['data'].reduce(
501
+ (currSum, currVal) => currSum + Number.parseInt(currVal[1]),
502
+ 0
503
+ );
504
+
505
+ return { powerUsage, baselineUsage };
469
506
  }
470
507
 
471
508
  async function parsePowerMetrics(batterystats, packageName) {
@@ -3,6 +3,7 @@ import { unlink as _unlink, rm as _rm } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { logging } from 'selenium-webdriver';
5
5
  import intel from 'intel';
6
+ import usbPowerProfiler from 'usb-power-profiling/usb-power-profiling.js';
6
7
  const log = intel.getLogger('browsertime.chrome');
7
8
  const { Type } = logging;
8
9
  import { longTaskMetrics } from '../longTaskMetrics.js';
@@ -36,6 +37,7 @@ export class Chromium {
36
37
  // Keep the HAR file for all runs
37
38
  this.hars = [];
38
39
  this.androidTmpDir = '/data/local/tmp/';
40
+ this.testStartTime = undefined;
39
41
  }
40
42
 
41
43
  /**
@@ -68,6 +70,10 @@ export class Chromium {
68
70
  // https://github.com/cyrus-and/chrome-remote-interface/issues/332
69
71
  if (isAndroidConfigured(this.options)) {
70
72
  await this.android.addDevtoolsFw();
73
+
74
+ if (this.options.androidUsbPower) {
75
+ usbPowerProfiler.startSampling();
76
+ }
71
77
  }
72
78
 
73
79
  this.cdpClient = new ChromeDevtoolsProtocol(this.options);
@@ -153,8 +159,12 @@ export class Chromium {
153
159
  log.info('Could not set cookie because the URL is unknown');
154
160
  }
155
161
 
156
- if (this.android && this.options.androidPower) {
157
- await this.android.resetPowerUsage();
162
+ if (this.android) {
163
+ if (this.options.androidPower) {
164
+ await this.android.resetPowerUsage();
165
+ } else if (this.options.androidUsbPower) {
166
+ await usbPowerProfiler.resetPowerData();
167
+ }
158
168
  }
159
169
 
160
170
  if (
@@ -165,6 +175,8 @@ export class Chromium {
165
175
  this.isTracing = true;
166
176
  return this.cdpClient.startTrace();
167
177
  }
178
+
179
+ this.testStartTime = Date.now();
168
180
  }
169
181
 
170
182
  /**
@@ -218,10 +230,25 @@ export class Chromium {
218
230
  'GET_LONG_TASKS'
219
231
  );
220
232
 
221
- if (this.android && this.options.androidPower) {
222
- result.power = await this.android.measurePowerUsage(
223
- this.chrome.android.package
224
- );
233
+ if (this.android) {
234
+ if (this.options.androidPower) {
235
+ result.power = await this.android.measurePowerUsage(
236
+ this.chrome.android.package
237
+ );
238
+ } else if (this.options.androidUsbPower) {
239
+ result.power = await this.android.measureUsbPowerUsage(
240
+ this.testStartTime,
241
+ Date.now()
242
+ );
243
+
244
+ await this.android.getUsbPowerUsageProfile(
245
+ index,
246
+ url,
247
+ result,
248
+ this.options,
249
+ this.storageManager
250
+ );
251
+ }
225
252
  }
226
253
 
227
254
  if (this.options.verbose >= 2 || this.chrome.enableChromeDriverLog) {
@@ -172,6 +172,10 @@ export function setupChromiumOptions(
172
172
  }
173
173
  }
174
174
 
175
+ if (browserOptions.enableVideoAutoplay) {
176
+ seleniumOptions.addArguments('--autoplay-policy=no-user-gesture-required');
177
+ }
178
+
175
179
  // It's a new splash screen introduced in Chrome 98
176
180
  // for new profiles
177
181
  // disable it with ChromeWhatsNewUI
@@ -15,7 +15,10 @@ import {
15
15
  addConnectivity,
16
16
  removeConnectivity
17
17
  } from '../../connectivity/index.js';
18
- import { jsonifyVisualProgress } from '../../support/util.js';
18
+ import {
19
+ jsonifyVisualProgress,
20
+ jsonifyKeyColorFrames
21
+ } from '../../support/util.js';
19
22
  import { flushDNS } from '../../support/dns.js';
20
23
 
21
24
  import { getNumberOfRunningProcesses } from '../../support/processes.js';
@@ -232,6 +235,12 @@ export class Iteration {
232
235
  );
233
236
  }
234
237
  }
238
+ if (videoMetrics.visualMetrics['KeyColorFrames']) {
239
+ videoMetrics.visualMetrics['KeyColorFrames'] =
240
+ jsonifyKeyColorFrames(
241
+ videoMetrics.visualMetrics['KeyColorFrames']
242
+ );
243
+ }
235
244
  result[index_].videoRecordingStart =
236
245
  videoMetrics.videoRecordingStart;
237
246
  result[index_].visualMetrics = videoMetrics.visualMetrics;
@@ -151,7 +151,7 @@ export const defaultFirefoxPreferences = {
151
151
  // Preferences file used by the raptor harness
152
152
  'dom.performance.time_to_non_blank_paint.enabled': true,
153
153
  'dom.performance.time_to_contentful_paint.enabled': true,
154
- 'dom.performance.time_to_dom_content_flushed.enabled': true,
154
+ 'dom.performance.time_to_dom_content_flushed.enabled': false,
155
155
  'dom.performance.time_to_first_interactive.enabled': true,
156
156
 
157
157
  // required for geckoview logging
@@ -3,6 +3,7 @@ import { promisify } from 'node:util';
3
3
  import { join } from 'node:path';
4
4
  import intel from 'intel';
5
5
  import get from 'lodash.get';
6
+ import usbPowerProfiler from 'usb-power-profiling/usb-power-profiling.js';
6
7
  import { adapters } from 'ff-test-bidi-har-export';
7
8
  import { getEmptyHAR, mergeHars } from '../../support/har/index.js';
8
9
  import { pathToFolder } from '../../support/pathToFolder.js';
@@ -32,6 +33,7 @@ export class Firefox {
32
33
  this.aliasAndUrl = {};
33
34
  // This keep the HAR files for all runs
34
35
  this.hars = [];
36
+ this.testStartTime = undefined;
35
37
  }
36
38
 
37
39
  /**
@@ -71,6 +73,10 @@ export class Firefox {
71
73
  runner.getDriver(),
72
74
  this.options
73
75
  );
76
+
77
+ if (isAndroidConfigured(this.options) && this.options.androidUsbPower) {
78
+ usbPowerProfiler.startSampling();
79
+ }
74
80
  }
75
81
 
76
82
  /**
@@ -131,8 +137,12 @@ export class Firefox {
131
137
  await this.har.startRecording();
132
138
  }
133
139
 
134
- if (isAndroidConfigured(this.options) && this.options.androidPower) {
135
- await this.android.resetPowerUsage();
140
+ if (isAndroidConfigured(this.options)) {
141
+ if (this.options.androidPower) {
142
+ await this.android.resetPowerUsage();
143
+ } else if (this.options.androidUsbPower) {
144
+ await usbPowerProfiler.resetPowerData();
145
+ }
136
146
  }
137
147
 
138
148
  if (
@@ -158,6 +168,8 @@ export class Firefox {
158
168
  this.perfStats = new PerfStats(runner, this.firefoxConfig);
159
169
  return this.perfStats.start();
160
170
  }
171
+
172
+ this.testStartTime = Date.now();
161
173
  }
162
174
 
163
175
  /**
@@ -169,10 +181,25 @@ export class Firefox {
169
181
  async afterPageCompleteCheck(runner, index, url, alias) {
170
182
  const result = { url, alias };
171
183
 
172
- if (isAndroidConfigured(this.options) && this.options.androidPower) {
173
- result.power = await this.android.measurePowerUsage(
174
- this.firefoxConfig.android.package
175
- );
184
+ if (isAndroidConfigured(this.options)) {
185
+ if (this.options.androidPower) {
186
+ result.power = await this.android.measurePowerUsage(
187
+ this.firefoxConfig.android.package
188
+ );
189
+ } else if (this.options.androidUsbPower) {
190
+ result.power = await this.android.measureUsbPowerUsage(
191
+ this.testStartTime,
192
+ Date.now()
193
+ );
194
+
195
+ await this.android.getUsbPowerUsageProfile(
196
+ index,
197
+ url,
198
+ result,
199
+ this.options,
200
+ this.storageManager
201
+ );
202
+ }
176
203
  }
177
204
 
178
205
  if (
@@ -274,6 +274,11 @@ export function parseCommandLine() {
274
274
  type: 'boolean',
275
275
  group: 'chrome'
276
276
  })
277
+ .option('chrome.enableVideoAutoplay', {
278
+ describe: 'Allow videos to autoplay.',
279
+ type: 'boolean',
280
+ group: 'chrome'
281
+ })
277
282
  .option('chrome.timeline', {
278
283
  alias: 'chrome.trace',
279
284
  describe:
@@ -341,6 +346,15 @@ export function parseCommandLine() {
341
346
  '(You have to disable charging yourself for this - it depends on the phone model).',
342
347
  group: 'android'
343
348
  })
349
+ .option('android.usbPowerTesting', {
350
+ alias: 'androidUsbPower',
351
+ type: 'boolean',
352
+ describe:
353
+ 'Enables android power testing using usb-power-profiling. Assumes that ' +
354
+ 'a valid device is attached to the phone. See here for supported devices: ' +
355
+ 'https://github.com/fqueze/usb-power-profiling?tab=readme-ov-file#supported-devices',
356
+ group: 'android'
357
+ })
344
358
  .option('chrome.CPUThrottlingRate', {
345
359
  type: 'number',
346
360
  describe:
@@ -600,6 +614,11 @@ export function parseCommandLine() {
600
614
  describe:
601
615
  'Make one extra run that collects the profiling trace log (no other metrics is collected). For Chrome it will collect the timeline trace, for Firefox it will get the Geckoprofiler trace. This means you do not need to get the trace for all runs and can skip the overhead it produces.'
602
616
  })
617
+ .option('enableVideoRun', {
618
+ type: 'boolean',
619
+ describe:
620
+ 'Make one extra run that collects video and visual metrics. This means you can do your runs with --visualMetrics true --video false --enableVideoRun true to collect visual metrics from all runs and save a video from the profile/video run. If you run it together with --enableProfileRun it will also collect profiling trace.'
621
+ })
603
622
  .option('video', {
604
623
  type: 'boolean',
605
624
  describe:
@@ -709,6 +728,12 @@ export function parseCommandLine() {
709
728
  describe:
710
729
  'Use the portable visual-metrics processing script (no ImageMagick dependencies).'
711
730
  })
731
+ .option('visualMetricsKeyColor', {
732
+ type: 'array',
733
+ nargs: 8,
734
+ describe:
735
+ 'Collect Key Color frame metrics when you run --visualMetrics. Each --visualMetricsKeyColor supplied must have 8 arguments: key name, red channel (0-255) low and high, green channel (0-255) low and high, blue channel (0-255) low and high, fraction (0.0-1.0) of pixels that must match each channel.'
736
+ })
712
737
  .option('scriptInput.visualElements', {
713
738
  describe:
714
739
  'Include specific elements in visual elements. Give the element a name and select it with document.body.querySelector. Use like this: --scriptInput.visualElements name:domSelector see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors. Add multiple instances to measure multiple elements. Visual Metrics will use these elements and calculate when they are visible and fully rendered.'
@@ -230,6 +230,29 @@ export function jsonifyVisualProgress(visualProgress) {
230
230
  }
231
231
  return visualProgress;
232
232
  }
233
+ export function jsonifyKeyColorFrames(keyColorFrames) {
234
+ // Original data looks like
235
+ // "FrameName1=[0-133 255-300], FrameName2=[133-255] FrameName3=[]"
236
+ if (typeof keyColorFrames === 'string') {
237
+ const keyColorFramesObject = {};
238
+ for (const keyColorPair of keyColorFrames.split(', ')) {
239
+ const [name, values] = keyColorPair.split('=');
240
+ keyColorFramesObject[name] = [];
241
+ const rangePairs = values.replace('[', '').replace(']', '');
242
+ if (rangePairs) {
243
+ for (const rangePair of rangePairs.split(' ')) {
244
+ const [start, end] = rangePair.split('-');
245
+ keyColorFramesObject[name].push({
246
+ startTimestamp: Number.parseInt(start, 10),
247
+ endTimestamp: Number.parseInt(end, 10)
248
+ });
249
+ }
250
+ }
251
+ }
252
+ return keyColorFramesObject;
253
+ }
254
+ return keyColorFrames;
255
+ }
233
256
  export function adjustVisualProgressTimestamps(
234
257
  visualProgress,
235
258
  profilerStartTime,
@@ -84,6 +84,15 @@ export async function run(
84
84
  scriptArguments.push('--contentful');
85
85
  }
86
86
 
87
+ if (options.visualMetricsKeyColor) {
88
+ for (let i = 0; i < options.visualMetricsKeyColor.length; ++i) {
89
+ if (i % 8 == 0) {
90
+ scriptArguments.push('--keycolor');
91
+ }
92
+ scriptArguments.push(options.visualMetricsKeyColor[i]);
93
+ }
94
+ }
95
+
87
96
  // There seems to be a bug with --startwhite that makes VM bail out
88
97
  // 11:20:14.950 - Calculating image histograms
89
98
  // 11:20:14.951 - No video frames found in /private/var/folders/27/xpnvcsbs0nlfbb4qq397z3rh0000gn/T/vis-cn_JMf
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": "22.2.0",
4
+ "version": "22.4.0",
5
5
  "bin": "./bin/browsertime.js",
6
6
  "type": "module",
7
7
  "types": "./types/scripting.d.ts",
@@ -31,6 +31,7 @@
31
31
  "lodash.pick": "4.4.0",
32
32
  "lodash.set": "4.3.2",
33
33
  "selenium-webdriver": "4.21.0",
34
+ "usb-power-profiling": "^1.2.0",
34
35
  "yargs": "17.7.2"
35
36
  },
36
37
  "optionalDependencies": {
@@ -66,5 +66,10 @@ export class Android {
66
66
  'full-wifi': number;
67
67
  total: number;
68
68
  }>;
69
+ measureUsbPowerUsage(startTime: any, endTime: any): Promise<{
70
+ powerUsage: any;
71
+ baselineUsage: number;
72
+ }>;
73
+ getUsbPowerUsageProfile(index: any, url: any, result: any, options: any, storageManager: any): Promise<void>;
69
74
  }
70
75
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../lib/android/index.js"],"names":[],"mappings":"AAiiBA,2DAWC;AA/hBD;IACE,0BAwBC;IAfC,YAAgC;IAEhC,QAIC;IACD,UAAgC;IAGhC,6BAA6B;IAC7B,yBAA2B;IAC3B,eAAgC;IAKlC,uBAgBC;IALC,YAA4C;IAG1C,YAAoE;IAIxE,wCAEC;IAED,8CAIC;IAED,6CAIC;IAED,2CAUC;IAED,mEAYC;IAED,mEAcC;IAED,uCAEC;IAED,0CAGC;IAED,4CAMC;IAED,4CAMC;IAED,uBAGC;IAED,kCAOC;IAED;;;;;;;OAuBC;IAED,2CAKC;IAED,8BAKC;IAED;;;oDAuBC;IAED,2BAIC;IAED,qCAGC;IAED,iCAGC;IAED,wBASC;IAED,2CAOC;IAED,gCAEC;IAED,0BAIC;IAED,iCAGC;IAED,8CAIC;IAED,4BAEC;IAED,sCAYC;IAED,mDAsBC;IAED,gDA+CC;IAED;;;;;;;OAqDC;IAED,4DAQC;IAED,mCAcC;IAED,kCAQC;IAED,iCAIC;IAED;;;;OAGC;CACF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../lib/android/index.js"],"names":[],"mappings":"AAskBA,2DAWC;AAlkBD;IACE,0BAwBC;IAfC,YAAgC;IAEhC,QAIC;IACD,UAAgC;IAGhC,6BAA6B;IAC7B,yBAA2B;IAC3B,eAAgC;IAKlC,uBAgBC;IALC,YAA4C;IAG1C,YAAoE;IAIxE,wCAEC;IAED,8CAIC;IAED,6CAIC;IAED,2CAUC;IAED,mEAYC;IAED,mEAcC;IAED,uCAEC;IAED,0CAGC;IAED,4CAMC;IAED,4CAMC;IAED,uBAGC;IAED,kCAOC;IAED;;;;;;;OAuBC;IAED,2CAKC;IAED,8BAKC;IAED;;;oDAuBC;IAED,2BAIC;IAED,qCAGC;IAED,iCAGC;IAED,wBASC;IAED,2CAOC;IAED,gCAEC;IAED,0BAIC;IAED,iCAGC;IAED,8CAIC;IAED,4BAEC;IAED,sCAYC;IAED,mDAsBC;IAED,gDA+CC;IAED;;;;;;;OAqDC;IAED,4DAQC;IAED,mCAcC;IAED,kCAQC;IAED,iCAIC;IAED;;;;OAGC;IAED;;;OAEC;IAED,6GAQC;CACF"}
@@ -2,5 +2,6 @@ export function formatMetric(name: any, metric: any, multiple: any, inMs: any, e
2
2
  export function logResultLogLine(results: any): void;
3
3
  export function toArray(arrayLike: any): any[];
4
4
  export function jsonifyVisualProgress(visualProgress: any): any;
5
+ export function jsonifyKeyColorFrames(keyColorFrames: any): any;
5
6
  export function adjustVisualProgressTimestamps(visualProgress: any, profilerStartTime: any, recordingStartTime: any): any;
6
7
  //# sourceMappingURL=util.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../../lib/support/util.js"],"names":[],"mappings":"AAIA,oGAeC;AACD,qDA0LC;AACD,+CAQC;AACD,gEAeC;AACD,0HAWC"}
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../../lib/support/util.js"],"names":[],"mappings":"AAIA,oGAeC;AACD,qDA0LC;AACD,+CAQC;AACD,gEAeC;AACD,gEAsBC;AACD,0HAWC"}
@@ -1 +1 @@
1
- {"version":3,"file":"visualMetrics.d.ts","sourceRoot":"","sources":["../../../../lib/video/postprocessing/visualmetrics/visualMetrics.js"],"names":[],"mappings":"AAsCA,mGAEC;AACD,0KAiHC"}
1
+ {"version":3,"file":"visualMetrics.d.ts","sourceRoot":"","sources":["../../../../lib/video/postprocessing/visualmetrics/visualMetrics.js"],"names":[],"mappings":"AAsCA,mGAEC;AACD,0KA0HC"}
@@ -1104,7 +1104,7 @@ def calculate_histograms(directory, histograms_file, force):
1104
1104
  m = re.search(match, frame)
1105
1105
  if m is not None:
1106
1106
  frame_time = int(m.groupdict().get("ms"))
1107
- histogram = calculate_image_histogram(frame)
1107
+ histogram, total, dropped = calculate_image_histogram(frame)
1108
1108
  gc.collect()
1109
1109
  if histogram is not None:
1110
1110
  histograms.append(
@@ -1112,6 +1112,8 @@ def calculate_histograms(directory, histograms_file, force):
1112
1112
  "time": frame_time,
1113
1113
  "file": os.path.basename(frame),
1114
1114
  "histogram": histogram,
1115
+ "total_pixels": total,
1116
+ "dropped_pixels": dropped,
1115
1117
  }
1116
1118
  )
1117
1119
  if os.path.isfile(histograms_file):
@@ -1130,12 +1132,14 @@ def calculate_histograms(directory, histograms_file, force):
1130
1132
 
1131
1133
  def calculate_image_histogram(file):
1132
1134
  logging.debug("Calculating histogram for " + file)
1135
+ dropped = 0
1133
1136
  try:
1134
1137
  from PIL import Image
1135
1138
 
1136
1139
  im = Image.open(file)
1137
1140
  width, height = im.size
1138
- colors = im.getcolors(width * height)
1141
+ total = width * height
1142
+ colors = im.getcolors(total)
1139
1143
  histogram = {
1140
1144
  "r": [0 for i in range(256)],
1141
1145
  "g": [0 for i in range(256)],
@@ -1151,13 +1155,16 @@ def calculate_image_histogram(file):
1151
1155
  histogram["r"][pixel[0]] += count
1152
1156
  histogram["g"][pixel[1]] += count
1153
1157
  histogram["b"][pixel[2]] += count
1158
+ else:
1159
+ dropped += 1
1154
1160
  except Exception:
1155
1161
  pass
1156
1162
  colors = None
1157
1163
  except Exception:
1164
+ total = 0
1158
1165
  histogram = None
1159
1166
  logging.exception("Error calculating histogram for " + file)
1160
- return histogram
1167
+ return histogram, total, dropped
1161
1168
 
1162
1169
 
1163
1170
  ##########################################################################
@@ -1212,6 +1219,7 @@ def calculate_visual_metrics(
1212
1219
  dirs,
1213
1220
  progress_file,
1214
1221
  hero_elements_file,
1222
+ key_colors,
1215
1223
  ):
1216
1224
  metrics = None
1217
1225
  histograms = load_histograms(histograms_file, start, end)
@@ -1225,6 +1233,7 @@ def calculate_visual_metrics(
1225
1233
  f = open(progress_file, "w")
1226
1234
  json.dump(progress, f)
1227
1235
  f.close()
1236
+ key_color_frames = calculate_key_color_frames(histograms, key_colors)
1228
1237
  if len(histograms) > 1:
1229
1238
  metrics = [
1230
1239
  {"name": "First Visual Change", "value": histograms[1]["time"]},
@@ -1315,6 +1324,20 @@ def calculate_visual_metrics(
1315
1324
  metrics.append({"name": "Perceptual Speed Index", "value": 0})
1316
1325
  if contentful:
1317
1326
  metrics.append({"name": "Contentful Speed Index", "value": 0})
1327
+ if key_color_frames:
1328
+ keysum = ""
1329
+ for key in key_color_frames:
1330
+ if len(keysum):
1331
+ keysum += ", "
1332
+ framesum = ""
1333
+ for frame in key_color_frames[key]:
1334
+ if len(framesum):
1335
+ framesum += " "
1336
+ framesum += "{0:d}-{1:d}".format(
1337
+ frame["start_time"], frame["end_time"]
1338
+ )
1339
+ keysum += "{0}=[{1}]".format(key, framesum)
1340
+ metrics.append({"name": "Key Color Frames", "value": keysum})
1318
1341
  prog = ""
1319
1342
  for p in progress:
1320
1343
  if len(prog):
@@ -1346,6 +1369,79 @@ def load_histograms(histograms_file, start, end):
1346
1369
  return histograms
1347
1370
 
1348
1371
 
1372
+ def is_key_color_frame(histogram, key_color):
1373
+ # The fraction is measured against the entire image, not just the sampled
1374
+ # pixels. This helps avoid matching frames with only a few pixels that
1375
+ # happen to be in the acceptable range.
1376
+ total_fraction = histogram["total_pixels"] * key_color["fraction"]
1377
+ if total_fraction < histogram["total_pixels"] - histogram["dropped_pixels"]:
1378
+ for channel in ["r", "g", "b"]:
1379
+ # Find the acceptable range around the target channel value
1380
+ max_channel = len(histogram["histogram"][channel])
1381
+ low = min(max_channel - 1, max(0, key_color[channel + "_low"]))
1382
+ high = min(max_channel, max(1, key_color[channel + "_high"] + 1))
1383
+ target_total = 0
1384
+ for i in histogram["histogram"][channel][low:high]:
1385
+ target_total += i
1386
+ if target_total < total_fraction:
1387
+ return False
1388
+ return True
1389
+
1390
+
1391
+ def calculate_key_color_frames(histograms, key_colors):
1392
+ if not key_colors:
1393
+ return {}
1394
+
1395
+ key_color_frames = {}
1396
+ for key in key_colors:
1397
+ key_color_frames[key] = []
1398
+
1399
+ current = None
1400
+ current_key = None
1401
+ total = 0
1402
+ matched = 0
1403
+ buckets = 256
1404
+ channels = ["r", "g", "b"]
1405
+ histograms = histograms.copy()
1406
+
1407
+ while len(histograms) > 0:
1408
+ histogram = histograms.pop(0)
1409
+ matching_key = None
1410
+ for key in key_colors:
1411
+ if is_key_color_frame(histogram, key_colors[key]):
1412
+ matching_key = key
1413
+ break
1414
+
1415
+ if matching_key is None:
1416
+ continue
1417
+
1418
+ last_histogram = histogram
1419
+ frame_count = 1
1420
+ while len(histograms) > 0:
1421
+ last_histogram = histograms[0]
1422
+ if is_key_color_frame(last_histogram, key_colors[matching_key]):
1423
+ frame_count += 1
1424
+ histograms.pop(0)
1425
+ else:
1426
+ break
1427
+
1428
+ logging.debug(
1429
+ "{0:d}ms to {1:d}ms - Matched key color frame {2}".format(
1430
+ histogram["time"], last_histogram["time"], matching_key
1431
+ )
1432
+ )
1433
+
1434
+ key_color_frames[matching_key].append(
1435
+ {
1436
+ "frame_count": frame_count,
1437
+ "start_time": histogram["time"],
1438
+ "end_time": last_histogram["time"],
1439
+ }
1440
+ )
1441
+
1442
+ return key_color_frames
1443
+
1444
+
1349
1445
  def calculate_visual_progress(histograms):
1350
1446
  progress = []
1351
1447
  first = histograms[0]["histogram"]
@@ -1760,6 +1856,24 @@ def main():
1760
1856
  default=False,
1761
1857
  help="Remove orange-colored frames from the beginning of the video.",
1762
1858
  )
1859
+ parser.add_argument(
1860
+ "--keycolor",
1861
+ action="append",
1862
+ nargs=8,
1863
+ metavar=(
1864
+ "key",
1865
+ "red_low",
1866
+ "red_high",
1867
+ "green_low",
1868
+ "green_high",
1869
+ "blue_low",
1870
+ "blue_high",
1871
+ "fraction",
1872
+ ),
1873
+ help="Identify frames that match the given channel (0-255) low and "
1874
+ "high. Fraction is the percentage of the pixels per channel that "
1875
+ "must be in the given range (0-1).",
1876
+ )
1763
1877
  parser.add_argument(
1764
1878
  "-p",
1765
1879
  "--viewport",
@@ -1916,6 +2030,19 @@ def main():
1916
2030
  options.full,
1917
2031
  )
1918
2032
 
2033
+ key_colors = {}
2034
+ if options.keycolor:
2035
+ for key_params in options.keycolor:
2036
+ key_colors[key_params[0]] = {
2037
+ "r_low": int(key_params[1]),
2038
+ "r_high": int(key_params[2]),
2039
+ "g_low": int(key_params[3]),
2040
+ "g_high": int(key_params[4]),
2041
+ "b_low": int(key_params[5]),
2042
+ "b_high": int(key_params[6]),
2043
+ "fraction": float(key_params[7]),
2044
+ }
2045
+
1919
2046
  # Calculate the histograms and visual metrics
1920
2047
  calculate_histograms(directory, histogram_file, options.force)
1921
2048
  metrics = calculate_visual_metrics(
@@ -1927,6 +2054,7 @@ def main():
1927
2054
  directory,
1928
2055
  options.progress,
1929
2056
  options.herodata,
2057
+ key_colors,
1930
2058
  )
1931
2059
 
1932
2060
  if options.screenshot is not None:
@@ -1339,7 +1339,7 @@ def calculate_histograms(directory, histograms_file, force):
1339
1339
  m = re.search(match, frame)
1340
1340
  if m is not None:
1341
1341
  frame_time = int(m.groupdict().get("ms"))
1342
- histogram = calculate_image_histogram(frame)
1342
+ histogram, total, dropped = calculate_image_histogram(frame)
1343
1343
  gc.collect()
1344
1344
  if histogram is not None:
1345
1345
  histograms.append(
@@ -1347,6 +1347,8 @@ def calculate_histograms(directory, histograms_file, force):
1347
1347
  "time": frame_time,
1348
1348
  "file": os.path.basename(frame),
1349
1349
  "histogram": histogram,
1350
+ "total_pixels": total,
1351
+ "dropped_pixels": dropped,
1350
1352
  }
1351
1353
  )
1352
1354
  if os.path.isfile(histograms_file):
@@ -1365,12 +1367,14 @@ def calculate_histograms(directory, histograms_file, force):
1365
1367
 
1366
1368
  def calculate_image_histogram(file):
1367
1369
  logging.debug("Calculating histogram for " + file)
1370
+ dropped = 0
1368
1371
  try:
1369
1372
  from PIL import Image
1370
1373
 
1371
1374
  im = Image.open(file)
1372
1375
  width, height = im.size
1373
- colors = im.getcolors(width * height)
1376
+ total = width * height
1377
+ colors = im.getcolors(total)
1374
1378
  histogram = {
1375
1379
  "r": [0 for i in range(256)],
1376
1380
  "g": [0 for i in range(256)],
@@ -1386,13 +1390,16 @@ def calculate_image_histogram(file):
1386
1390
  histogram["r"][pixel[0]] += count
1387
1391
  histogram["g"][pixel[1]] += count
1388
1392
  histogram["b"][pixel[2]] += count
1393
+ else:
1394
+ dropped += 1
1389
1395
  except Exception:
1390
1396
  pass
1391
1397
  colors = None
1392
1398
  except Exception:
1399
+ total = 0
1393
1400
  histogram = None
1394
1401
  logging.exception("Error calculating histogram for " + file)
1395
- return histogram
1402
+ return histogram, total, dropped
1396
1403
 
1397
1404
 
1398
1405
  ##########################################################################
@@ -1618,6 +1625,7 @@ def calculate_visual_metrics(
1618
1625
  dirs,
1619
1626
  progress_file,
1620
1627
  hero_elements_file,
1628
+ key_colors,
1621
1629
  ):
1622
1630
  metrics = None
1623
1631
  histograms = load_histograms(histograms_file, start, end)
@@ -1631,6 +1639,7 @@ def calculate_visual_metrics(
1631
1639
  f = open(progress_file, "w")
1632
1640
  json.dump(progress, f)
1633
1641
  f.close()
1642
+ key_color_frames = calculate_key_color_frames(histograms, key_colors)
1634
1643
  if len(histograms) > 1:
1635
1644
  metrics = [
1636
1645
  {"name": "First Visual Change", "value": histograms[1]["time"]},
@@ -1720,6 +1729,20 @@ def calculate_visual_metrics(
1720
1729
  metrics.append({"name": "Perceptual Speed Index", "value": 0})
1721
1730
  if contentful:
1722
1731
  metrics.append({"name": "Contentful Speed Index", "value": 0})
1732
+ if key_color_frames:
1733
+ keysum = ""
1734
+ for key in key_color_frames:
1735
+ if len(keysum):
1736
+ keysum += ", "
1737
+ framesum = ""
1738
+ for frame in key_color_frames[key]:
1739
+ if len(framesum):
1740
+ framesum += " "
1741
+ framesum += "{0:d}-{1:d}".format(
1742
+ frame["start_time"], frame["end_time"]
1743
+ )
1744
+ keysum += "{0}=[{1}]".format(key, framesum)
1745
+ metrics.append({"name": "Key Color Frames", "value": keysum})
1723
1746
  prog = ""
1724
1747
  for p in progress:
1725
1748
  if len(prog):
@@ -1751,6 +1774,79 @@ def load_histograms(histograms_file, start, end):
1751
1774
  return histograms
1752
1775
 
1753
1776
 
1777
+ def is_key_color_frame(histogram, key_color):
1778
+ # The fraction is measured against the entire image, not just the sampled
1779
+ # pixels. This helps avoid matching frames with only a few pixels that
1780
+ # happen to be in the acceptable range.
1781
+ total_fraction = histogram["total_pixels"] * key_color["fraction"]
1782
+ if total_fraction < histogram["total_pixels"] - histogram["dropped_pixels"]:
1783
+ for channel in ["r", "g", "b"]:
1784
+ # Find the acceptable range around the target channel value
1785
+ max_channel = len(histogram["histogram"][channel])
1786
+ low = min(max_channel - 1, max(0, key_color[channel + "_low"]))
1787
+ high = min(max_channel, max(1, key_color[channel + "_high"] + 1))
1788
+ target_total = 0
1789
+ for i in histogram["histogram"][channel][low:high]:
1790
+ target_total += i
1791
+ if target_total < total_fraction:
1792
+ return False
1793
+ return True
1794
+
1795
+
1796
+ def calculate_key_color_frames(histograms, key_colors):
1797
+ if not key_colors:
1798
+ return {}
1799
+
1800
+ key_color_frames = {}
1801
+ for key in key_colors:
1802
+ key_color_frames[key] = []
1803
+
1804
+ current = None
1805
+ current_key = None
1806
+ total = 0
1807
+ matched = 0
1808
+ buckets = 256
1809
+ channels = ["r", "g", "b"]
1810
+ histograms = histograms.copy()
1811
+
1812
+ while len(histograms) > 0:
1813
+ histogram = histograms.pop(0)
1814
+ matching_key = None
1815
+ for key in key_colors:
1816
+ if is_key_color_frame(histogram, key_colors[key]):
1817
+ matching_key = key
1818
+ break
1819
+
1820
+ if matching_key is None:
1821
+ continue
1822
+
1823
+ last_histogram = histogram
1824
+ frame_count = 1
1825
+ while len(histograms) > 0:
1826
+ last_histogram = histograms[0]
1827
+ if is_key_color_frame(last_histogram, key_colors[matching_key]):
1828
+ frame_count += 1
1829
+ histograms.pop(0)
1830
+ else:
1831
+ break
1832
+
1833
+ logging.debug(
1834
+ "{0:d}ms to {1:d}ms - Matched key color frame {2}".format(
1835
+ histogram["time"], last_histogram["time"], matching_key
1836
+ )
1837
+ )
1838
+
1839
+ key_color_frames[matching_key].append(
1840
+ {
1841
+ "frame_count": frame_count,
1842
+ "start_time": histogram["time"],
1843
+ "end_time": last_histogram["time"],
1844
+ }
1845
+ )
1846
+
1847
+ return key_color_frames
1848
+
1849
+
1754
1850
  def calculate_visual_progress(histograms):
1755
1851
  progress = []
1756
1852
  first = histograms[0]["histogram"]
@@ -2200,6 +2296,24 @@ def main():
2200
2296
  help="Wait for a full white frame after a non-white frame "
2201
2297
  "at the beginning of the video.",
2202
2298
  )
2299
+ parser.add_argument(
2300
+ "--keycolor",
2301
+ action="append",
2302
+ nargs=8,
2303
+ metavar=(
2304
+ "key",
2305
+ "red_low",
2306
+ "red_high",
2307
+ "green_low",
2308
+ "green_high",
2309
+ "blue_low",
2310
+ "blue_high",
2311
+ "fraction",
2312
+ ),
2313
+ help="Identify frames that match the given channel (0-255) low and "
2314
+ "high. Fraction is the percentage of the pixels per channel that "
2315
+ "must be in the given range (0-1).",
2316
+ )
2203
2317
  parser.add_argument(
2204
2318
  "--multiple",
2205
2319
  action="store_true",
@@ -2464,6 +2578,18 @@ def main():
2464
2578
  if not options.multiple:
2465
2579
  if options.render is not None:
2466
2580
  render_video(directory, options.render)
2581
+ key_colors = {}
2582
+ if options.keycolor:
2583
+ for key_params in options.keycolor:
2584
+ key_colors[key_params[0]] = {
2585
+ "r_low": int(key_params[1]),
2586
+ "r_high": int(key_params[2]),
2587
+ "g_low": int(key_params[3]),
2588
+ "g_high": int(key_params[4]),
2589
+ "b_low": int(key_params[5]),
2590
+ "b_high": int(key_params[6]),
2591
+ "fraction": float(key_params[7]),
2592
+ }
2467
2593
 
2468
2594
  # Calculate the histograms and visual metrics
2469
2595
  calculate_histograms(directory, histogram_file, options.force)
@@ -2476,6 +2602,7 @@ def main():
2476
2602
  directory,
2477
2603
  options.progress,
2478
2604
  options.herodata,
2605
+ key_colors,
2479
2606
  )
2480
2607
 
2481
2608
  if options.screenshot is not None: