browsertime 22.1.0 → 22.3.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,21 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 22.3.0 - 2024-06-04
4
+ ### Added
5
+ * 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).
6
+ * 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).
7
+
8
+ ### Fixed
9
+ * Removed DOMContentFlushed for Firefox thank you [florinbilt](https://github.com/florinbilt) for PR [#2138](https://github.com/sitespeedio/browsertime/pull/2138).
10
+
11
+ ## 22.2.0 - 2024-05-24
12
+ ### Added
13
+ * 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).
14
+ * New command: `commands.wait.byIdAndVisible(id, maxTime)` - wait for an element with id to become visible [#2137](https://github.com/sitespeedio/browsertime/pull/2137).
15
+
16
+ ### Fixed
17
+ * Make sure errors are correctly logged if you cannot add text to element [#2136](https://github.com/sitespeedio/browsertime/pull/2136).
18
+
3
19
  ## 22.1.0 - 2024-05-20
4
20
  ### Added
5
21
  * Updated to Edge and Edgedriver 125 [#2132](https://github.com/sitespeedio/browsertime/pull/2132).
@@ -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
@@ -33,7 +33,12 @@ export class AddText {
33
33
  await element.clear();
34
34
  return element.sendKeys(text);
35
35
  } catch (error) {
36
- log.error('Could not add text %s to id %s', text, id);
36
+ log.error(
37
+ 'Could not add text %s to id %s error:%s',
38
+ text,
39
+ id,
40
+ error.message
41
+ );
37
42
  log.verbose(error);
38
43
  throw new Error(`Could not add text ${text} to id ${id}`);
39
44
  }
@@ -57,7 +62,12 @@ export class AddText {
57
62
  await element.clear();
58
63
  return element.sendKeys(text);
59
64
  } catch (error) {
60
- log.error('Could not add text %s to xpath %s', text, xpath);
65
+ log.error(
66
+ 'Could not add text %s to xpath %s error: %s',
67
+ text,
68
+ xpath,
69
+ error.message
70
+ );
61
71
  log.verbose(error);
62
72
  throw new Error(`Could not add text ${text} to xpath ${xpath}`);
63
73
  }
@@ -81,7 +91,12 @@ export class AddText {
81
91
  await element.clear();
82
92
  return element.sendKeys(text);
83
93
  } catch (error) {
84
- log.error('Could not add text %s to selector %s', text, selector);
94
+ log.error(
95
+ 'Could not add text %s to selector %s error: %s',
96
+ text,
97
+ selector,
98
+ error.message
99
+ );
85
100
  log.verbose(error);
86
101
  throw new Error(`Could not add text ${text} to selector ${selector}`);
87
102
  }
@@ -105,7 +120,12 @@ export class AddText {
105
120
  await element.clear();
106
121
  return element.sendKeys(text);
107
122
  } catch (error) {
108
- log.error('Could not add text %s to class name %s', text, className);
123
+ log.error(
124
+ 'Could not add text %s to class name %s error: %s',
125
+ text,
126
+ className,
127
+ error.message
128
+ );
109
129
  log.verbose(error);
110
130
  throw new Error(`Could not add text ${text} to class name ${className}`);
111
131
  }
@@ -129,7 +149,12 @@ export class AddText {
129
149
  await element.clear();
130
150
  return element.sendKeys(text);
131
151
  } catch (error) {
132
- log.error('Could not add text %s to name attribute %s', text, name);
152
+ log.error(
153
+ 'Could not add text %s to name attribute %s error: %s',
154
+ text,
155
+ name,
156
+ error.message
157
+ );
133
158
  log.verbose(error);
134
159
  throw new Error(`Could not add text ${text} to name attribute ${name}`);
135
160
  }
@@ -230,7 +230,7 @@ export class SingleClick {
230
230
 
231
231
  /**
232
232
  * Clicks on a link whose visible text contains the given substring and waits on the
233
- * page complete checl.
233
+ * page complete check.
234
234
  *
235
235
  * @async
236
236
  * @param {string} text - The substring of the visible text of the link to click.
@@ -247,4 +247,43 @@ export class SingleClick {
247
247
  throw new Error('Could not find link by partial text ' + text);
248
248
  }
249
249
  }
250
+
251
+ /**
252
+ * Clicks on a element with a specific id.
253
+ *
254
+ * @async
255
+ * @param {string} id - The id of the link to click.
256
+ * @returns {Promise<void>} A promise that resolves when the click action is performed.
257
+ * @throws {Error} Throws an error if the id is not found.
258
+ */
259
+ async byId(id) {
260
+ try {
261
+ const element = await this.browser.getDriver().findElement(By.id(id));
262
+ return this.actions.click(element).perform();
263
+ } catch (error) {
264
+ log.error('Could not find the element with id %s', id);
265
+ log.verbose(error);
266
+ throw new Error('Could not the element with id ' + id);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Clicks on a element with a specific id and wait on the page complete check
272
+ *
273
+ * @async
274
+ * @param {string} id - The id of the link to click.
275
+ * @returns {Promise<void>} A promise that resolves when the page has completed.
276
+ * @throws {Error} Throws an error if the id is not found.
277
+ */
278
+ async byIdAndWait(id) {
279
+ try {
280
+ const element = await this.browser.getDriver().findElement(By.id(id));
281
+ await this.actions.click(element).perform();
282
+ return this.browser.extraWait(this.pageCompleteCheck);
283
+ } catch (error) {
284
+ log.error('Could not find the element with id %s', id);
285
+ log.verbose(error);
286
+ throw new Error('Could not the element with id ' + id);
287
+ }
288
+ }
250
289
  }
@@ -22,7 +22,7 @@ export class Wait {
22
22
  }
23
23
 
24
24
  /**
25
- * Waits for an element with a specific ID to appear within a maximum time.
25
+ * Waits for an element with a specific ID to be located within a maximum time.
26
26
  *
27
27
  * @async
28
28
  * @param {string} id - The ID of the element to wait for.
@@ -46,6 +46,30 @@ export class Wait {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Waits for an element with a specific ID to be located and visible within a maximum time.
51
+ *
52
+ * @async
53
+ * @param {string} id - The ID of the element to wait for.
54
+ * @param {number} maxTime - Maximum time to wait in milliseconds.
55
+ * @returns {Promise<void>} A promise that resolves when the element is found or the time times out.
56
+ * @throws {Error} Throws an error if the element is not found within the specified time.
57
+ */
58
+ async byIdAndVisible(id, maxTime) {
59
+ const driver = this.browser.getDriver();
60
+ await this.byId(id, maxTime);
61
+ try {
62
+ await driver.wait(
63
+ webdriver.until.elementIsVisible(webdriver.By.id(id)),
64
+ maxTime
65
+ );
66
+ } catch (error) {
67
+ log.error('Element by id %s was not visible in %s ms', id, maxTime);
68
+ log.verbose(error);
69
+ throw new Error(`Element by id ${id} was not located in ${maxTime} ms`);
70
+ }
71
+ }
72
+
49
73
  /**
50
74
  * Waits for an element located by XPath to appear within a maximum time.
51
75
  *
@@ -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:
@@ -709,6 +723,12 @@ export function parseCommandLine() {
709
723
  describe:
710
724
  'Use the portable visual-metrics processing script (no ImageMagick dependencies).'
711
725
  })
726
+ .option('visualMetricsKeyColor', {
727
+ type: 'array',
728
+ nargs: 8,
729
+ describe:
730
+ '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.'
731
+ })
712
732
  .option('scriptInput.visualElements', {
713
733
  describe:
714
734
  '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.1.0",
4
+ "version": "22.3.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"}
@@ -1 +1 @@
1
- {"version":3,"file":"addText.d.ts","sourceRoot":"","sources":["../../../../lib/core/engine/command/addText.js"],"names":[],"mappings":"AAIA;;;;GAIG;AACH;IACE,0BAKC;IAJC;;OAEG;IACH,gBAAsB;IAGxB;;;;;;;;;OASG;IACH,WALW,MAAM,MACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;IAED;;;;;;;;;OASG;IACH,cALW,MAAM,SACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;IAED;;;;;;;;;OASG;IACH,iBALW,MAAM,YACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;IAED;;;;;;;;;OASG;IACH,kBALW,MAAM,aACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;IAED;;;;;;;;;OASG;IACH,aALW,MAAM,QACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;CACF"}
1
+ {"version":3,"file":"addText.d.ts","sourceRoot":"","sources":["../../../../lib/core/engine/command/addText.js"],"names":[],"mappings":"AAIA;;;;GAIG;AACH;IACE,0BAKC;IAJC;;OAEG;IACH,gBAAsB;IAGxB;;;;;;;;;OASG;IACH,WALW,MAAM,MACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAoBzB;IAED;;;;;;;;;OASG;IACH,cALW,MAAM,SACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAoBzB;IAED;;;;;;;;;OASG;IACH,iBALW,MAAM,YACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAoBzB;IAED;;;;;;;;;OASG;IACH,kBALW,MAAM,aACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAoBzB;IAED;;;;;;;;;OASG;IACH,aALW,MAAM,QACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAoBzB;CACF"}
@@ -103,7 +103,7 @@ export class SingleClick {
103
103
  byPartialLinkText(text: string): Promise<void>;
104
104
  /**
105
105
  * Clicks on a link whose visible text contains the given substring and waits on the
106
- * page complete checl.
106
+ * page complete check.
107
107
  *
108
108
  * @async
109
109
  * @param {string} text - The substring of the visible text of the link to click.
@@ -111,5 +111,23 @@ export class SingleClick {
111
111
  * @throws {Error} Throws an error if the link is not found.
112
112
  */
113
113
  byPartialLinkTextAndWait(text: string): Promise<void>;
114
+ /**
115
+ * Clicks on a element with a specific id.
116
+ *
117
+ * @async
118
+ * @param {string} id - The id of the link to click.
119
+ * @returns {Promise<void>} A promise that resolves when the click action is performed.
120
+ * @throws {Error} Throws an error if the id is not found.
121
+ */
122
+ byId(id: string): Promise<void>;
123
+ /**
124
+ * Clicks on a element with a specific id and wait on the page complete check
125
+ *
126
+ * @async
127
+ * @param {string} id - The id of the link to click.
128
+ * @returns {Promise<void>} A promise that resolves when the page has completed.
129
+ * @throws {Error} Throws an error if the id is not found.
130
+ */
131
+ byIdAndWait(id: string): Promise<void>;
114
132
  }
115
133
  //# sourceMappingURL=singleClick.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"singleClick.d.ts","sourceRoot":"","sources":["../../../../../lib/core/engine/command/mouse/singleClick.js"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH;IACE,kDAaC;IAZC;;OAEG;IACH,gBAAsB;IACtB;;OAEG;IACH,gBAAgE;IAChE;;OAEG;IACH,0BAA0C;IAG5C;;;;;;;;OAQG;IACH,eALW,MAAM,kBAEJ,QAAQ,IAAI,CAAC,CAoBzB;IAED;;;;;;;OAOG;IACH,sBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;IAED;;;;;;;;OAQG;IACH,qBALW,MAAM,kBAEJ,QAAQ,IAAI,CAAC,CAsBzB;IAED;;;;;;;OAOG;IACH,4BAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAiBzB;IAED;;;;;;;OAOG;IACH,yBAHa,QAAQ,IAAI,CAAC,CAiBzB;IAED;;;;;;;OAOG;IACH,mBAHa,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,iBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,wBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,wBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;;OAQG;IACH,+BAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;CACF"}
1
+ {"version":3,"file":"singleClick.d.ts","sourceRoot":"","sources":["../../../../../lib/core/engine/command/mouse/singleClick.js"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH;IACE,kDAaC;IAZC;;OAEG;IACH,gBAAsB;IACtB;;OAEG;IACH,gBAAgE;IAChE;;OAEG;IACH,0BAA0C;IAG5C;;;;;;;;OAQG;IACH,eALW,MAAM,kBAEJ,QAAQ,IAAI,CAAC,CAoBzB;IAED;;;;;;;OAOG;IACH,sBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAezB;IAED;;;;;;;;OAQG;IACH,qBALW,MAAM,kBAEJ,QAAQ,IAAI,CAAC,CAsBzB;IAED;;;;;;;OAOG;IACH,4BAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAiBzB;IAED;;;;;;;OAOG;IACH,yBAHa,QAAQ,IAAI,CAAC,CAiBzB;IAED;;;;;;;OAOG;IACH,mBAHa,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,iBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,wBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,wBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;;OAQG;IACH,+BAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,SAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAYzB;IAED;;;;;;;OAOG;IACH,gBAJW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAazB;CACF"}
@@ -15,7 +15,7 @@ export class Wait {
15
15
  */
16
16
  private pageCompleteCheck;
17
17
  /**
18
- * Waits for an element with a specific ID to appear within a maximum time.
18
+ * Waits for an element with a specific ID to be located within a maximum time.
19
19
  *
20
20
  * @async
21
21
  * @param {string} id - The ID of the element to wait for.
@@ -24,6 +24,16 @@ export class Wait {
24
24
  * @throws {Error} Throws an error if the element is not found within the specified time.
25
25
  */
26
26
  byId(id: string, maxTime: number): Promise<void>;
27
+ /**
28
+ * Waits for an element with a specific ID to be located and visible within a maximum time.
29
+ *
30
+ * @async
31
+ * @param {string} id - The ID of the element to wait for.
32
+ * @param {number} maxTime - Maximum time to wait in milliseconds.
33
+ * @returns {Promise<void>} A promise that resolves when the element is found or the time times out.
34
+ * @throws {Error} Throws an error if the element is not found within the specified time.
35
+ */
36
+ byIdAndVisible(id: string, maxTime: number): Promise<void>;
27
37
  /**
28
38
  * Waits for an element located by XPath to appear within a maximum time.
29
39
  *
@@ -1 +1 @@
1
- {"version":3,"file":"wait.d.ts","sourceRoot":"","sources":["../../../../lib/core/engine/command/wait.js"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH;IACE,kDASC;IARC;;OAEG;IACH,gBAAsB;IACtB;;OAEG;IACH,0BAA0C;IAG5C;;;;;;;;OAQG;IACH,SALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAiBzB;IAED;;;;;;;;OAQG;IACH,eALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAmBzB;IAED;;;;;;;;OAQG;IACH,qBALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAuBzB;IAED;;;;;;;OAOG;IACH,WAHW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAIzB;IAED;;;;;OAKG;IACH,oBAFa,QAAQ,IAAI,CAAC,CAIzB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAiBzB;CACF"}
1
+ {"version":3,"file":"wait.d.ts","sourceRoot":"","sources":["../../../../lib/core/engine/command/wait.js"],"names":[],"mappings":"AAKA;;;;;GAKG;AACH;IACE,kDASC;IARC;;OAEG;IACH,gBAAsB;IACtB;;OAEG;IACH,0BAA0C;IAG5C;;;;;;;;OAQG;IACH,SALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAiBzB;IAED;;;;;;;;OAQG;IACH,mBALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAgBzB;IAED;;;;;;;;OAQG;IACH,eALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAmBzB;IAED;;;;;;;;OAQG;IACH,qBALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAuBzB;IAED;;;;;;;OAOG;IACH,WAHW,MAAM,GACJ,QAAQ,IAAI,CAAC,CAIzB;IAED;;;;;OAKG;IACH,oBAFa,QAAQ,IAAI,CAAC,CAIzB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,WACN,MAAM,GACJ,QAAQ,IAAI,CAAC,CAiBzB;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: