browsertime 24.9.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/lib/support/cli.js +1 -7
- package/lib/video/postprocessing/visualmetrics/visualMetrics.js +8 -23
- package/package.json +11 -12
- package/types/video/postprocessing/visualmetrics/visualMetrics.d.ts +1 -1
- package/types/video/postprocessing/visualmetrics/visualMetrics.d.ts.map +1 -1
- package/visualmetrics/visualmetrics.py +0 -2645
|
@@ -1,2645 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python
|
|
2
|
-
"""
|
|
3
|
-
Copyright (c) 2014, Google Inc.
|
|
4
|
-
All rights reserved.
|
|
5
|
-
|
|
6
|
-
Redistribution and use in source and binary forms, with or without modification,
|
|
7
|
-
are permitted provided that the following conditions are met:
|
|
8
|
-
|
|
9
|
-
* Redistributions of source code must retain the above copyright notice,
|
|
10
|
-
this list of conditions and the following disclaimer.
|
|
11
|
-
* Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
-
this list of conditions and the following disclaimer in the documentation
|
|
13
|
-
and/or other materials provided with the distribution.
|
|
14
|
-
* Neither the name of the company nor the names of its contributors may be
|
|
15
|
-
used to endorse or promote products derived from this software without
|
|
16
|
-
specific prior written permission.
|
|
17
|
-
|
|
18
|
-
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
19
|
-
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
20
|
-
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
21
|
-
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
|
22
|
-
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
23
|
-
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
24
|
-
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
25
|
-
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
26
|
-
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
27
|
-
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
28
|
-
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."""
|
|
29
|
-
#
|
|
30
|
-
# The original script from Google was heavily modified for the Browsertime
|
|
31
|
-
# project.
|
|
32
|
-
#
|
|
33
|
-
import gc
|
|
34
|
-
import glob
|
|
35
|
-
import gzip
|
|
36
|
-
import json
|
|
37
|
-
import logging
|
|
38
|
-
import math
|
|
39
|
-
import os
|
|
40
|
-
import platform
|
|
41
|
-
import re
|
|
42
|
-
import sys
|
|
43
|
-
import shutil
|
|
44
|
-
import subprocess
|
|
45
|
-
import tempfile
|
|
46
|
-
|
|
47
|
-
if sys.version_info > (3, 0):
|
|
48
|
-
GZIP_TEXT = "wt"
|
|
49
|
-
GZIP_READ_TEXT = "rt"
|
|
50
|
-
else:
|
|
51
|
-
GZIP_TEXT = "w"
|
|
52
|
-
GZIP_READ_TEXT = "r"
|
|
53
|
-
|
|
54
|
-
# Globals
|
|
55
|
-
options = None
|
|
56
|
-
client_viewport = None
|
|
57
|
-
image_magick = {"convert": "convert", "compare": "compare", "mogrify": "mogrify"}
|
|
58
|
-
frame_cache = {}
|
|
59
|
-
|
|
60
|
-
# #################################################################################################
|
|
61
|
-
# Frame Extraction and de-duplication
|
|
62
|
-
# #################################################################################################
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def video_to_frames(
|
|
66
|
-
video,
|
|
67
|
-
directory,
|
|
68
|
-
force,
|
|
69
|
-
orange_file,
|
|
70
|
-
white_file,
|
|
71
|
-
gray_file,
|
|
72
|
-
multiple,
|
|
73
|
-
find_viewport,
|
|
74
|
-
viewport_time,
|
|
75
|
-
viewport_retries,
|
|
76
|
-
viewport_min_height,
|
|
77
|
-
viewport_min_width,
|
|
78
|
-
full_resolution,
|
|
79
|
-
timeline_file,
|
|
80
|
-
trim_end,
|
|
81
|
-
):
|
|
82
|
-
""" Extract the video frames"""
|
|
83
|
-
global client_viewport
|
|
84
|
-
first_frame = os.path.join(directory, "ms_000000")
|
|
85
|
-
if (
|
|
86
|
-
not os.path.isfile(first_frame + ".png")
|
|
87
|
-
and not os.path.isfile(first_frame + ".jpg")
|
|
88
|
-
) or force:
|
|
89
|
-
if os.path.isfile(video):
|
|
90
|
-
video = os.path.realpath(video)
|
|
91
|
-
logging.info("Processing frames from video " + video + " to " + directory)
|
|
92
|
-
is_mobile = find_recording_platform(video)
|
|
93
|
-
if os.path.isdir(directory):
|
|
94
|
-
shutil.rmtree(directory, True)
|
|
95
|
-
if not os.path.isdir(directory):
|
|
96
|
-
os.mkdir(directory, 0o755)
|
|
97
|
-
if os.path.isdir(directory):
|
|
98
|
-
directory = os.path.realpath(directory)
|
|
99
|
-
viewport, cropped = find_video_viewport(
|
|
100
|
-
video,
|
|
101
|
-
directory,
|
|
102
|
-
find_viewport,
|
|
103
|
-
viewport_time,
|
|
104
|
-
viewport_retries,
|
|
105
|
-
viewport_min_height,
|
|
106
|
-
viewport_min_width,
|
|
107
|
-
is_mobile,
|
|
108
|
-
)
|
|
109
|
-
gc.collect()
|
|
110
|
-
if extract_frames(video, directory, full_resolution, viewport):
|
|
111
|
-
client_viewport = None
|
|
112
|
-
if find_viewport and options.notification:
|
|
113
|
-
client_viewport = find_image_viewport(
|
|
114
|
-
os.path.join(directory, "video-000000.png"),
|
|
115
|
-
is_mobile
|
|
116
|
-
)
|
|
117
|
-
if multiple and orange_file is not None:
|
|
118
|
-
directories = split_videos(directory, orange_file)
|
|
119
|
-
else:
|
|
120
|
-
directories = [directory]
|
|
121
|
-
for dir in directories:
|
|
122
|
-
trim_video_end(dir, trim_end)
|
|
123
|
-
if orange_file is not None:
|
|
124
|
-
remove_frames_before_orange(dir, orange_file)
|
|
125
|
-
remove_orange_frames(dir, orange_file)
|
|
126
|
-
find_first_frame(dir, white_file)
|
|
127
|
-
blank_first_frame(dir)
|
|
128
|
-
find_render_start(dir, orange_file, gray_file, cropped, is_mobile)
|
|
129
|
-
find_last_frame(dir, white_file)
|
|
130
|
-
adjust_frame_times(dir)
|
|
131
|
-
if timeline_file is not None and not multiple:
|
|
132
|
-
synchronize_to_timeline(dir, timeline_file)
|
|
133
|
-
eliminate_duplicate_frames(dir, cropped, is_mobile)
|
|
134
|
-
eliminate_similar_frames(dir)
|
|
135
|
-
# See if we are limiting the number of frames to keep
|
|
136
|
-
# (before processing them to save processing time)
|
|
137
|
-
if options.maxframes > 0:
|
|
138
|
-
cap_frame_count(dir, options.maxframes)
|
|
139
|
-
crop_viewport(dir)
|
|
140
|
-
gc.collect()
|
|
141
|
-
else:
|
|
142
|
-
logging.critical("Error extracting the video frames from %s", video)
|
|
143
|
-
else:
|
|
144
|
-
logging.critical("Error creating output directory: %s", directory)
|
|
145
|
-
else:
|
|
146
|
-
logging.critical("Input video file %s does not exist", video)
|
|
147
|
-
else:
|
|
148
|
-
logging.info("Extracted video already exists in %s", directory)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def extract_frames(video, directory, full_resolution, viewport):
|
|
152
|
-
"""Extract and number the video frames"""
|
|
153
|
-
ret = False
|
|
154
|
-
logging.info("Extracting frames from " + video + " to " + directory)
|
|
155
|
-
decimate = get_decimate_filter()
|
|
156
|
-
if decimate is not None:
|
|
157
|
-
crop = ""
|
|
158
|
-
if viewport is not None:
|
|
159
|
-
crop = "crop={0}:{1}:{2}:{3},".format(
|
|
160
|
-
viewport["width"], viewport["height"], viewport["x"], viewport["y"]
|
|
161
|
-
)
|
|
162
|
-
scale = "scale=iw*min({0:d}/iw\\,{0:d}/ih):ih*min({0:d}/iw\\,{0:d}/ih),".format(
|
|
163
|
-
options.thumbsize
|
|
164
|
-
)
|
|
165
|
-
if full_resolution:
|
|
166
|
-
scale = ""
|
|
167
|
-
# escape directory name
|
|
168
|
-
# see https://en.wikibooks.org/wiki/FFMPEG_An_Intermediate_Guide/image_sequence#Percent_in_filename
|
|
169
|
-
dir_escaped = directory.replace("%", "%%")
|
|
170
|
-
command = [
|
|
171
|
-
"ffmpeg",
|
|
172
|
-
"-v",
|
|
173
|
-
"debug",
|
|
174
|
-
"-i",
|
|
175
|
-
video,
|
|
176
|
-
"-vsync",
|
|
177
|
-
"0",
|
|
178
|
-
"-vf",
|
|
179
|
-
crop + scale + decimate + "=0:64:640:0.001",
|
|
180
|
-
os.path.join(dir_escaped, "img-%d.png"),
|
|
181
|
-
]
|
|
182
|
-
logging.debug(" ".join(command))
|
|
183
|
-
lines = []
|
|
184
|
-
if sys.version_info > (3, 0):
|
|
185
|
-
proc = subprocess.Popen(command, stderr=subprocess.PIPE, encoding="UTF-8")
|
|
186
|
-
else:
|
|
187
|
-
proc = subprocess.Popen(command, stderr=subprocess.PIPE)
|
|
188
|
-
while proc.poll() is None:
|
|
189
|
-
lines.extend(iter(proc.stderr.readline, ""))
|
|
190
|
-
|
|
191
|
-
pattern = re.compile(r"keep pts:[0-9]+ pts_time:(?P<timecode>[0-9\.]+)")
|
|
192
|
-
frame_count = 0
|
|
193
|
-
for line in lines:
|
|
194
|
-
match = re.search(pattern, line)
|
|
195
|
-
if match:
|
|
196
|
-
frame_count += 1
|
|
197
|
-
frame_time = int(
|
|
198
|
-
math.floor(float(match.groupdict().get("timecode")) * 1000)
|
|
199
|
-
)
|
|
200
|
-
src = os.path.join(directory, "img-{0:d}.png".format(frame_count))
|
|
201
|
-
dest = os.path.join(directory, "video-{0:06d}.png".format(frame_time))
|
|
202
|
-
logging.debug("Renaming " + src + " to " + dest)
|
|
203
|
-
os.rename(src, dest)
|
|
204
|
-
ret = True
|
|
205
|
-
return ret
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def find_recording_platform(video):
|
|
209
|
-
"""Find the platform that this video was recorded on.
|
|
210
|
-
|
|
211
|
-
We can make use of a field called `com.android.version` to
|
|
212
|
-
determine if we've recorded on mobile or not.
|
|
213
|
-
"""
|
|
214
|
-
command = ["ffprobe", video]
|
|
215
|
-
logging.debug(command)
|
|
216
|
-
|
|
217
|
-
lines = []
|
|
218
|
-
if sys.version_info > (3, 0):
|
|
219
|
-
proc = subprocess.Popen(command, stderr=subprocess.PIPE, encoding="UTF-8")
|
|
220
|
-
else:
|
|
221
|
-
proc = subprocess.Popen(command, stderr=subprocess.PIPE)
|
|
222
|
-
|
|
223
|
-
while proc.poll() is None:
|
|
224
|
-
lines.extend(iter(proc.stderr.readline, ""))
|
|
225
|
-
|
|
226
|
-
is_mobile = False
|
|
227
|
-
matcher = re.compile(".*com\.android\.version.*")
|
|
228
|
-
for line in lines:
|
|
229
|
-
if matcher.search(line):
|
|
230
|
-
is_mobile = True
|
|
231
|
-
|
|
232
|
-
return is_mobile
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def split_videos(directory, orange_file):
|
|
236
|
-
"""Split multiple videos on orange frame separators"""
|
|
237
|
-
logging.debug("Splitting video on orange frames (this may take a while)...")
|
|
238
|
-
directories = []
|
|
239
|
-
current = 0
|
|
240
|
-
found_orange = False
|
|
241
|
-
video_dir = None
|
|
242
|
-
frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
243
|
-
if len(frames):
|
|
244
|
-
for frame in frames:
|
|
245
|
-
if is_color_frame(frame, orange_file):
|
|
246
|
-
if not found_orange:
|
|
247
|
-
found_orange = True
|
|
248
|
-
# Make a copy of the orange frame for the end of the
|
|
249
|
-
# current video
|
|
250
|
-
if video_dir is not None:
|
|
251
|
-
dest = os.path.join(video_dir, os.path.basename(frame))
|
|
252
|
-
shutil.copyfile(frame, dest)
|
|
253
|
-
current += 1
|
|
254
|
-
video_dir = os.path.join(directory, str(current))
|
|
255
|
-
logging.debug(
|
|
256
|
-
"Orange frame found: %s, starting video directory %s",
|
|
257
|
-
frame,
|
|
258
|
-
video_dir,
|
|
259
|
-
)
|
|
260
|
-
if not os.path.isdir(video_dir):
|
|
261
|
-
os.mkdir(video_dir, 0o755)
|
|
262
|
-
if os.path.isdir(video_dir):
|
|
263
|
-
video_dir = os.path.realpath(video_dir)
|
|
264
|
-
clean_directory(video_dir)
|
|
265
|
-
directories.append(video_dir)
|
|
266
|
-
else:
|
|
267
|
-
video_dir = None
|
|
268
|
-
else:
|
|
269
|
-
found_orange = False
|
|
270
|
-
if video_dir is not None:
|
|
271
|
-
dest = os.path.join(video_dir, os.path.basename(frame))
|
|
272
|
-
os.rename(frame, dest)
|
|
273
|
-
else:
|
|
274
|
-
logging.debug("Removing spurious frame %s at the beginning", frame)
|
|
275
|
-
os.remove(frame)
|
|
276
|
-
return directories
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def remove_frames_before_orange(directory, orange_file):
|
|
280
|
-
"""Remove stray frames from the start of the video"""
|
|
281
|
-
frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
282
|
-
if len(frames):
|
|
283
|
-
# go through the first 20 frames and remove any that come before the first orange frame.
|
|
284
|
-
# iOS video capture starts with a blank white frame and then flips to
|
|
285
|
-
# orange before starting.
|
|
286
|
-
logging.debug("Scanning for non-orange frames...")
|
|
287
|
-
found_orange = False
|
|
288
|
-
remove_frames = []
|
|
289
|
-
frame_count = 0
|
|
290
|
-
for frame in frames:
|
|
291
|
-
frame_count += 1
|
|
292
|
-
if is_color_frame(frame, orange_file):
|
|
293
|
-
found_orange = True
|
|
294
|
-
break
|
|
295
|
-
if frame_count > 20:
|
|
296
|
-
break
|
|
297
|
-
remove_frames.append(frame)
|
|
298
|
-
|
|
299
|
-
if found_orange and len(remove_frames):
|
|
300
|
-
for frame in remove_frames:
|
|
301
|
-
logging.debug("Removing pre-orange frame %s", frame)
|
|
302
|
-
os.remove(frame)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def remove_orange_frames(directory, orange_file):
|
|
306
|
-
"""Remove orange frames from the beginning of the video"""
|
|
307
|
-
frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
308
|
-
if len(frames):
|
|
309
|
-
logging.debug("Scanning for orange frames...")
|
|
310
|
-
for frame in frames:
|
|
311
|
-
if is_color_frame(frame, orange_file):
|
|
312
|
-
logging.debug("Removing Orange frame: %s", frame)
|
|
313
|
-
os.remove(frame)
|
|
314
|
-
else:
|
|
315
|
-
break
|
|
316
|
-
for frame in reversed(frames):
|
|
317
|
-
if is_color_frame(frame, orange_file):
|
|
318
|
-
logging.debug("Removing orange frame %s from the end", frame)
|
|
319
|
-
os.remove(frame)
|
|
320
|
-
else:
|
|
321
|
-
break
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def find_image_viewport(file, is_mobile):
|
|
325
|
-
logging.debug("Finding the viewport for %s", file)
|
|
326
|
-
try:
|
|
327
|
-
from PIL import Image
|
|
328
|
-
|
|
329
|
-
im = Image.open(file)
|
|
330
|
-
width, height = im.size
|
|
331
|
-
x = int(math.floor(width / 2))
|
|
332
|
-
y = int(math.floor(height / 2))
|
|
333
|
-
pixels = im.load()
|
|
334
|
-
background = pixels[x, y]
|
|
335
|
-
|
|
336
|
-
# Find the left edge
|
|
337
|
-
left = None
|
|
338
|
-
while left is None and x >= 0:
|
|
339
|
-
if not colors_are_similar(background, pixels[x, y]):
|
|
340
|
-
left = x + 1
|
|
341
|
-
else:
|
|
342
|
-
x -= 1
|
|
343
|
-
if left is None:
|
|
344
|
-
left = 0
|
|
345
|
-
logging.debug("Viewport left edge is %d", left)
|
|
346
|
-
|
|
347
|
-
# Find the right edge
|
|
348
|
-
x = int(math.floor(width / 2))
|
|
349
|
-
right = None
|
|
350
|
-
while right is None and x < width:
|
|
351
|
-
if not colors_are_similar(background, pixels[x, y]):
|
|
352
|
-
right = x - 1
|
|
353
|
-
else:
|
|
354
|
-
x += 1
|
|
355
|
-
if right is None:
|
|
356
|
-
right = width
|
|
357
|
-
logging.debug("Viewport right edge is {0:d}".format(right))
|
|
358
|
-
|
|
359
|
-
# Find the top edge
|
|
360
|
-
x = int(math.floor(width / 2))
|
|
361
|
-
top = None
|
|
362
|
-
while top is None and y >= 0:
|
|
363
|
-
if not colors_are_similar(background, pixels[x, y]):
|
|
364
|
-
top = y + 1
|
|
365
|
-
else:
|
|
366
|
-
y -= 1
|
|
367
|
-
if top is None:
|
|
368
|
-
top = 0
|
|
369
|
-
logging.debug("Viewport top edge is {0:d}".format(top))
|
|
370
|
-
|
|
371
|
-
# Find the bottom edge
|
|
372
|
-
y = int(math.floor(height / 2))
|
|
373
|
-
bottom = None
|
|
374
|
-
while bottom is None and y < height:
|
|
375
|
-
if not colors_are_similar(background, pixels[x, y]):
|
|
376
|
-
bottom = y - 1
|
|
377
|
-
else:
|
|
378
|
-
y += 1
|
|
379
|
-
if bottom is None:
|
|
380
|
-
bottom = height
|
|
381
|
-
logging.debug("Viewport bottom edge is {0:d}".format(bottom))
|
|
382
|
-
|
|
383
|
-
viewport = {
|
|
384
|
-
"x": left,
|
|
385
|
-
"y": top,
|
|
386
|
-
"width": (right - left),
|
|
387
|
-
"height": (bottom - top),
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if is_mobile:
|
|
391
|
-
# On mobile we need to ignore the top ~10 pixels because
|
|
392
|
-
# there is a visible progress bar there on some browsers.
|
|
393
|
-
viewport["y"] += 10
|
|
394
|
-
viewport["height"] -= 10
|
|
395
|
-
|
|
396
|
-
except Exception:
|
|
397
|
-
viewport = None
|
|
398
|
-
|
|
399
|
-
return viewport
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def find_video_viewport(
|
|
403
|
-
video,
|
|
404
|
-
directory,
|
|
405
|
-
find_viewport,
|
|
406
|
-
viewport_time,
|
|
407
|
-
viewport_retries,
|
|
408
|
-
viewport_min_height,
|
|
409
|
-
viewport_min_width,
|
|
410
|
-
is_mobile,
|
|
411
|
-
):
|
|
412
|
-
logging.debug("Finding Video Viewport...")
|
|
413
|
-
viewport = None
|
|
414
|
-
|
|
415
|
-
# cropped will be True if the viewport setting changes
|
|
416
|
-
# the original frame
|
|
417
|
-
cropped = False
|
|
418
|
-
|
|
419
|
-
try:
|
|
420
|
-
from PIL import Image
|
|
421
|
-
|
|
422
|
-
retries = -1
|
|
423
|
-
|
|
424
|
-
while (
|
|
425
|
-
viewport is None
|
|
426
|
-
or viewport["height"] <= viewport_min_height
|
|
427
|
-
or viewport["width"] <= viewport_min_width
|
|
428
|
-
):
|
|
429
|
-
retries += 1
|
|
430
|
-
if retries >= 1:
|
|
431
|
-
# In some cases, the first frame is not an orange screen or a screen
|
|
432
|
-
# with a solid color. In this case, we need to try finding the viewport
|
|
433
|
-
# using the next frame. The `viewport_retries` dictates the maximum number
|
|
434
|
-
# of frames to check.
|
|
435
|
-
if retries >= viewport_retries:
|
|
436
|
-
logging.exception(
|
|
437
|
-
"Could not calculate a viewport after %s tries.",
|
|
438
|
-
viewport_retries,
|
|
439
|
-
)
|
|
440
|
-
break
|
|
441
|
-
logging.info("Failed to find a good viewport. Retrying...")
|
|
442
|
-
|
|
443
|
-
logging.debug("Using frame " + str(retries))
|
|
444
|
-
|
|
445
|
-
frame = os.path.join(directory, "viewport.png")
|
|
446
|
-
if os.path.isfile(frame):
|
|
447
|
-
os.remove(frame)
|
|
448
|
-
|
|
449
|
-
command = ["ffmpeg", "-i", video]
|
|
450
|
-
if viewport_time:
|
|
451
|
-
command.extend(["-ss", viewport_time])
|
|
452
|
-
|
|
453
|
-
# Pull one frame from the video starting with the frame at
|
|
454
|
-
# the `retries` index
|
|
455
|
-
command.extend(["-vf", "select=gte(n\\,%s)" % retries])
|
|
456
|
-
command.extend(["-frames:v", "1", frame])
|
|
457
|
-
subprocess.check_output(command)
|
|
458
|
-
|
|
459
|
-
if os.path.isfile(frame):
|
|
460
|
-
with Image.open(frame) as im:
|
|
461
|
-
width, height = im.size
|
|
462
|
-
logging.debug("%s is %dx%d", frame, width, height)
|
|
463
|
-
if options.notification:
|
|
464
|
-
im = Image.open(frame)
|
|
465
|
-
pixels = im.load()
|
|
466
|
-
middle = int(math.floor(height / 2))
|
|
467
|
-
# Find the top edge (at ~40% in to deal with browsers that
|
|
468
|
-
# color the notification area)
|
|
469
|
-
x = int(width * 0.4)
|
|
470
|
-
y = 0
|
|
471
|
-
background = pixels[x, y]
|
|
472
|
-
top = None
|
|
473
|
-
while top is None and y < middle:
|
|
474
|
-
if not colors_are_similar(background, pixels[x, y]):
|
|
475
|
-
top = y
|
|
476
|
-
else:
|
|
477
|
-
y += 1
|
|
478
|
-
if top is None:
|
|
479
|
-
top = 0
|
|
480
|
-
logging.debug("Window top edge is {0:d}".format(top))
|
|
481
|
-
|
|
482
|
-
# Find the bottom edge
|
|
483
|
-
x = 0
|
|
484
|
-
y = height - 1
|
|
485
|
-
bottom = None
|
|
486
|
-
while bottom is None and y > middle:
|
|
487
|
-
if not colors_are_similar(background, pixels[x, y]):
|
|
488
|
-
bottom = y
|
|
489
|
-
else:
|
|
490
|
-
y -= 1
|
|
491
|
-
if bottom is None:
|
|
492
|
-
bottom = height - 1
|
|
493
|
-
logging.debug("Window bottom edge is {0:d}".format(bottom))
|
|
494
|
-
|
|
495
|
-
viewport = {
|
|
496
|
-
"x": 0,
|
|
497
|
-
"y": top,
|
|
498
|
-
"width": width,
|
|
499
|
-
"height": (bottom - top),
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
elif find_viewport:
|
|
503
|
-
viewport = find_image_viewport(frame, is_mobile)
|
|
504
|
-
else:
|
|
505
|
-
viewport = {"x": 0, "y": 0, "width": width, "height": height}
|
|
506
|
-
|
|
507
|
-
os.remove(frame)
|
|
508
|
-
|
|
509
|
-
if viewport is not None and viewport != {
|
|
510
|
-
"x": 0,
|
|
511
|
-
"y": 0,
|
|
512
|
-
"width": width,
|
|
513
|
-
"height": height,
|
|
514
|
-
}:
|
|
515
|
-
cropped = True
|
|
516
|
-
|
|
517
|
-
except Exception as e:
|
|
518
|
-
viewport = None
|
|
519
|
-
|
|
520
|
-
return viewport, cropped
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def trim_video_end(directory, trim_time):
|
|
524
|
-
if trim_time > 0:
|
|
525
|
-
logging.debug(
|
|
526
|
-
"Trimming "
|
|
527
|
-
+ str(trim_time)
|
|
528
|
-
+ "ms from the end of the video in "
|
|
529
|
-
+ directory
|
|
530
|
-
)
|
|
531
|
-
frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
532
|
-
if len(frames):
|
|
533
|
-
match = re.compile(r"video-(?P<ms>[0-9]+)\.png")
|
|
534
|
-
m = re.search(match, frames[-1])
|
|
535
|
-
if m is not None:
|
|
536
|
-
frame_time = int(m.groupdict().get("ms"))
|
|
537
|
-
end_time = frame_time - trim_time
|
|
538
|
-
logging.debug("Trimming frames before " + str(end_time) + "ms")
|
|
539
|
-
for frame in frames:
|
|
540
|
-
m = re.search(match, frame)
|
|
541
|
-
if m is not None:
|
|
542
|
-
frame_time = int(m.groupdict().get("ms"))
|
|
543
|
-
if frame_time > end_time:
|
|
544
|
-
logging.debug("Trimming frame " + frame)
|
|
545
|
-
os.remove(frame)
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
def adjust_frame_times(directory):
|
|
549
|
-
offset = None
|
|
550
|
-
frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
551
|
-
# Special hack to the the video start
|
|
552
|
-
# Let us tune this in the future to skip using a global
|
|
553
|
-
global videoRecordingStart
|
|
554
|
-
match = re.compile(r"video-(?P<ms>[0-9]+)\.png")
|
|
555
|
-
if len(frames):
|
|
556
|
-
for frame in frames:
|
|
557
|
-
m = re.search(match, frame)
|
|
558
|
-
if m is not None:
|
|
559
|
-
frame_time = int(m.groupdict().get("ms"))
|
|
560
|
-
if offset is None:
|
|
561
|
-
# This is the first frame.
|
|
562
|
-
videoRecordingStart = frame_time
|
|
563
|
-
offset = frame_time
|
|
564
|
-
new_time = frame_time - offset
|
|
565
|
-
dest = os.path.join(directory, "ms_{0:06d}.png".format(new_time))
|
|
566
|
-
os.rename(frame, dest)
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
def find_first_frame(directory, white_file):
|
|
570
|
-
logging.debug("Finding First Frame...")
|
|
571
|
-
try:
|
|
572
|
-
if options.startwhite:
|
|
573
|
-
files = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
574
|
-
count = len(files)
|
|
575
|
-
if count > 1:
|
|
576
|
-
from PIL import Image
|
|
577
|
-
|
|
578
|
-
for i in range(count):
|
|
579
|
-
if is_white_frame(files[i], white_file):
|
|
580
|
-
break
|
|
581
|
-
else:
|
|
582
|
-
logging.debug(
|
|
583
|
-
"Removing non-white frame {0} from the beginning".format(
|
|
584
|
-
files[i]
|
|
585
|
-
)
|
|
586
|
-
)
|
|
587
|
-
os.remove(files[i])
|
|
588
|
-
elif options.findstart > 0 and options.findstart <= 100:
|
|
589
|
-
files = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
590
|
-
count = len(files)
|
|
591
|
-
if count > 1:
|
|
592
|
-
from PIL import Image
|
|
593
|
-
|
|
594
|
-
blank = files[0]
|
|
595
|
-
with Image.open(blank) as im:
|
|
596
|
-
width, height = im.size
|
|
597
|
-
match_height = int(math.ceil(height * options.findstart / 100.0))
|
|
598
|
-
crop = "{0:d}x{1:d}+{2:d}+{3:d}".format(width, match_height, 0, 0)
|
|
599
|
-
found_first_change = False
|
|
600
|
-
found_white_frame = False
|
|
601
|
-
found_non_white_frame = False
|
|
602
|
-
first_frame = None
|
|
603
|
-
if white_file is None:
|
|
604
|
-
found_white_frame = True
|
|
605
|
-
for i in range(count):
|
|
606
|
-
if not found_first_change:
|
|
607
|
-
different = not frames_match(
|
|
608
|
-
files[i], files[i + 1], 5, 100, crop, None
|
|
609
|
-
)
|
|
610
|
-
logging.debug(
|
|
611
|
-
"Removing early frame %s from the beginning", files[i]
|
|
612
|
-
)
|
|
613
|
-
os.remove(files[i])
|
|
614
|
-
if different:
|
|
615
|
-
first_frame = files[i + 1]
|
|
616
|
-
found_first_change = True
|
|
617
|
-
elif not found_white_frame:
|
|
618
|
-
if files[i] != first_frame:
|
|
619
|
-
if found_non_white_frame:
|
|
620
|
-
found_white_frame = is_white_frame(files[i], white_file)
|
|
621
|
-
if not found_white_frame:
|
|
622
|
-
logging.debug(
|
|
623
|
-
"Removing early non-white frame {0} from the beginning".format(
|
|
624
|
-
files[i]
|
|
625
|
-
)
|
|
626
|
-
)
|
|
627
|
-
os.remove(files[i])
|
|
628
|
-
else:
|
|
629
|
-
found_non_white_frame = not is_white_frame(
|
|
630
|
-
files[i], white_file
|
|
631
|
-
)
|
|
632
|
-
logging.debug(
|
|
633
|
-
"Removing early pre-non-white frame {0} from the beginning".format(
|
|
634
|
-
files[i]
|
|
635
|
-
)
|
|
636
|
-
)
|
|
637
|
-
os.remove(files[i])
|
|
638
|
-
if found_first_change and found_white_frame:
|
|
639
|
-
break
|
|
640
|
-
except BaseException:
|
|
641
|
-
logging.exception("Error finding first frame")
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
def find_last_frame(directory, white_file):
|
|
645
|
-
logging.debug("Finding Last Frame...")
|
|
646
|
-
try:
|
|
647
|
-
if options.endwhite:
|
|
648
|
-
files = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
649
|
-
count = len(files)
|
|
650
|
-
if count > 2:
|
|
651
|
-
found_end = False
|
|
652
|
-
|
|
653
|
-
for i in range(2, count):
|
|
654
|
-
if found_end:
|
|
655
|
-
logging.debug(
|
|
656
|
-
"Removing frame {0} from the end".format(files[i])
|
|
657
|
-
)
|
|
658
|
-
os.remove(files[i])
|
|
659
|
-
if is_white_frame(files[i], white_file):
|
|
660
|
-
found_end = True
|
|
661
|
-
logging.debug(
|
|
662
|
-
"Removing ending white frame {0}".format(files[i])
|
|
663
|
-
)
|
|
664
|
-
os.remove(files[i])
|
|
665
|
-
except BaseException:
|
|
666
|
-
logging.exception("Error finding last frame")
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
def find_render_start(directory, orange_file, gray_file, cropped, is_mobile):
|
|
670
|
-
logging.debug("Finding Render Start...")
|
|
671
|
-
try:
|
|
672
|
-
if (
|
|
673
|
-
client_viewport is not None
|
|
674
|
-
or options.viewport is not None
|
|
675
|
-
or (options.renderignore > 0 and options.renderignore <= 100)
|
|
676
|
-
):
|
|
677
|
-
files = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
678
|
-
count = len(files)
|
|
679
|
-
if count > 1:
|
|
680
|
-
from PIL import Image
|
|
681
|
-
|
|
682
|
-
first = files[0]
|
|
683
|
-
with Image.open(first) as im:
|
|
684
|
-
width, height = im.size
|
|
685
|
-
if options.renderignore > 0 and options.renderignore <= 100:
|
|
686
|
-
mask = {}
|
|
687
|
-
mask["width"] = int(math.floor(width * options.renderignore / 100))
|
|
688
|
-
mask["height"] = int(
|
|
689
|
-
math.floor(height * options.renderignore / 100)
|
|
690
|
-
)
|
|
691
|
-
mask["x"] = int(math.floor(width / 2 - mask["width"] / 2))
|
|
692
|
-
mask["y"] = int(math.floor(height / 2 - mask["height"] / 2))
|
|
693
|
-
else:
|
|
694
|
-
mask = None
|
|
695
|
-
|
|
696
|
-
im_width = width
|
|
697
|
-
im_height = height
|
|
698
|
-
|
|
699
|
-
top = 10
|
|
700
|
-
right_margin = 10
|
|
701
|
-
bottom_margin = 24
|
|
702
|
-
if height > 400 or width > 400:
|
|
703
|
-
top = max(top, int(math.ceil(float(height) * 0.03)))
|
|
704
|
-
right_margin = max(
|
|
705
|
-
right_margin, int(math.ceil(float(width) * 0.04))
|
|
706
|
-
)
|
|
707
|
-
bottom_margin = max(
|
|
708
|
-
bottom_margin, int(math.ceil(float(width) * 0.04))
|
|
709
|
-
)
|
|
710
|
-
height = max(height - top - bottom_margin, 1)
|
|
711
|
-
left = 0
|
|
712
|
-
width = max(width - right_margin, 1)
|
|
713
|
-
if client_viewport is not None:
|
|
714
|
-
height = max(client_viewport["height"] - top - bottom_margin, 1)
|
|
715
|
-
width = max(client_viewport["width"] - right_margin, 1)
|
|
716
|
-
left += client_viewport["x"]
|
|
717
|
-
top += client_viewport["y"]
|
|
718
|
-
elif cropped:
|
|
719
|
-
# The image was already cropped, so only cutout the bottom
|
|
720
|
-
# to get rid of the network request/etc. information for
|
|
721
|
-
# desktop videos, and nothing extra on mobile.
|
|
722
|
-
top = 0
|
|
723
|
-
left = 0
|
|
724
|
-
width = im_width
|
|
725
|
-
|
|
726
|
-
if is_mobile:
|
|
727
|
-
height = im_height
|
|
728
|
-
else:
|
|
729
|
-
height = im_height - bottom_margin
|
|
730
|
-
|
|
731
|
-
crop = "{0:d}x{1:d}+{2:d}+{3:d}".format(width, height, left, top)
|
|
732
|
-
|
|
733
|
-
for i in range(1, count):
|
|
734
|
-
if frames_match(first, files[i], 10, 0, crop, mask):
|
|
735
|
-
logging.debug("Removing pre-render frame %s", files[i])
|
|
736
|
-
os.remove(files[i])
|
|
737
|
-
elif orange_file is not None and is_color_frame(
|
|
738
|
-
files[i], orange_file
|
|
739
|
-
):
|
|
740
|
-
logging.debug("Removing orange frame %s", files[i])
|
|
741
|
-
os.remove(files[i])
|
|
742
|
-
elif gray_file is not None and is_color_frame(files[i], gray_file):
|
|
743
|
-
logging.debug("Removing gray frame %s", files[i])
|
|
744
|
-
os.remove(files[i])
|
|
745
|
-
else:
|
|
746
|
-
break
|
|
747
|
-
except BaseException:
|
|
748
|
-
logging.exception("Error getting render start")
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
def eliminate_duplicate_frames(directory, cropped, is_mobile):
|
|
752
|
-
logging.debug("Eliminating Duplicate Frames...")
|
|
753
|
-
global client_viewport
|
|
754
|
-
try:
|
|
755
|
-
files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
756
|
-
if len(files) > 1:
|
|
757
|
-
from PIL import Image
|
|
758
|
-
|
|
759
|
-
blank = files[0]
|
|
760
|
-
with Image.open(blank) as im:
|
|
761
|
-
width, height = im.size
|
|
762
|
-
if options.viewport and options.notification:
|
|
763
|
-
if (
|
|
764
|
-
client_viewport["width"] == width
|
|
765
|
-
and client_viewport["height"] == height
|
|
766
|
-
):
|
|
767
|
-
client_viewport = None
|
|
768
|
-
|
|
769
|
-
im_width = width
|
|
770
|
-
im_height = height
|
|
771
|
-
|
|
772
|
-
# Figure out the region of the image that we care about
|
|
773
|
-
top = 40
|
|
774
|
-
right_margin = 10
|
|
775
|
-
bottom_margin = 10
|
|
776
|
-
if height > 400 or width > 400:
|
|
777
|
-
top = int(math.ceil(float(height) * 0.04))
|
|
778
|
-
right_margin = int(math.ceil(float(width) * 0.04))
|
|
779
|
-
bottom_margin = int(math.ceil(float(width) * 0.06))
|
|
780
|
-
height = max(height - top - bottom_margin, 1)
|
|
781
|
-
left = 0
|
|
782
|
-
width = max(width - right_margin, 1)
|
|
783
|
-
|
|
784
|
-
if client_viewport is not None:
|
|
785
|
-
height = max(client_viewport["height"] - top - bottom_margin, 1)
|
|
786
|
-
width = max(client_viewport["width"] - right_margin, 1)
|
|
787
|
-
left += client_viewport["x"]
|
|
788
|
-
top += client_viewport["y"]
|
|
789
|
-
elif cropped:
|
|
790
|
-
# The image was already cropped, so only cutout the bottom
|
|
791
|
-
# to get rid of the network request/etc. information for
|
|
792
|
-
# desktop videos, and nothing extra on mobile.
|
|
793
|
-
top = 0
|
|
794
|
-
left = 0
|
|
795
|
-
width = im_width
|
|
796
|
-
|
|
797
|
-
if is_mobile:
|
|
798
|
-
height = im_height
|
|
799
|
-
else:
|
|
800
|
-
height = im_height - bottom_margin
|
|
801
|
-
|
|
802
|
-
crop = "{0:d}x{1:d}+{2:d}+{3:d}".format(width, height, left, top)
|
|
803
|
-
logging.debug("Viewport cropping set to " + crop)
|
|
804
|
-
|
|
805
|
-
# Do a pass looking for the first non-blank frame with an allowance
|
|
806
|
-
# for up to a 10% per-pixel difference for noise in the white
|
|
807
|
-
# field.
|
|
808
|
-
count = len(files)
|
|
809
|
-
for i in range(1, count):
|
|
810
|
-
if frames_match(blank, files[i], 10, 0, crop, None):
|
|
811
|
-
logging.debug(
|
|
812
|
-
"Removing duplicate frame {0} from the beginning".format(
|
|
813
|
-
files[i]
|
|
814
|
-
)
|
|
815
|
-
)
|
|
816
|
-
os.remove(files[i])
|
|
817
|
-
else:
|
|
818
|
-
break
|
|
819
|
-
|
|
820
|
-
# Do another pass looking for the last frame but with an allowance for up
|
|
821
|
-
# to a 15% difference in individual pixels to deal with noise
|
|
822
|
-
# around text.
|
|
823
|
-
files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
824
|
-
count = len(files)
|
|
825
|
-
duplicates = []
|
|
826
|
-
if count > 2:
|
|
827
|
-
files.reverse()
|
|
828
|
-
baseline = files[0]
|
|
829
|
-
previous_frame = baseline
|
|
830
|
-
for i in range(1, count):
|
|
831
|
-
if frames_match(baseline, files[i], 15, 0, crop, None):
|
|
832
|
-
if previous_frame is baseline:
|
|
833
|
-
duplicates.append(previous_frame)
|
|
834
|
-
else:
|
|
835
|
-
logging.debug(
|
|
836
|
-
"Removing duplicate frame {0} from the end".format(
|
|
837
|
-
previous_frame
|
|
838
|
-
)
|
|
839
|
-
)
|
|
840
|
-
os.remove(previous_frame)
|
|
841
|
-
previous_frame = files[i]
|
|
842
|
-
else:
|
|
843
|
-
break
|
|
844
|
-
for duplicate in duplicates:
|
|
845
|
-
logging.debug(
|
|
846
|
-
"Removing duplicate frame {0} from the end".format(duplicate)
|
|
847
|
-
)
|
|
848
|
-
os.remove(duplicate)
|
|
849
|
-
|
|
850
|
-
except BaseException:
|
|
851
|
-
logging.exception("Error processing frames for duplicates")
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
def eliminate_similar_frames(directory):
|
|
855
|
-
logging.debug("Removing Similar Frames...")
|
|
856
|
-
try:
|
|
857
|
-
# only do this when decimate couldn't be used to eliminate similar
|
|
858
|
-
# frames
|
|
859
|
-
if options.notification:
|
|
860
|
-
files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
861
|
-
count = len(files)
|
|
862
|
-
if count > 3:
|
|
863
|
-
crop = None
|
|
864
|
-
if client_viewport is not None:
|
|
865
|
-
crop = "{0:d}x{1:d}+{2:d}+{3:d}".format(
|
|
866
|
-
client_viewport["width"],
|
|
867
|
-
client_viewport["height"],
|
|
868
|
-
client_viewport["x"],
|
|
869
|
-
client_viewport["y"],
|
|
870
|
-
)
|
|
871
|
-
baseline = files[1]
|
|
872
|
-
for i in range(2, count - 1):
|
|
873
|
-
if frames_match(baseline, files[i], 1, 0, crop, None):
|
|
874
|
-
logging.debug("Removing similar frame {0}".format(files[i]))
|
|
875
|
-
os.remove(files[i])
|
|
876
|
-
else:
|
|
877
|
-
baseline = files[i]
|
|
878
|
-
except BaseException:
|
|
879
|
-
logging.exception("Error removing similar frames")
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
def blank_first_frame(directory):
|
|
883
|
-
try:
|
|
884
|
-
if options.forceblank:
|
|
885
|
-
files = sorted(glob.glob(os.path.join(directory, "video-*.png")))
|
|
886
|
-
count = len(files)
|
|
887
|
-
if count > 1:
|
|
888
|
-
from PIL import Image
|
|
889
|
-
|
|
890
|
-
with Image.open(files[0]) as im:
|
|
891
|
-
width, height = im.size
|
|
892
|
-
command = '{0} -size {1}x{2} xc:white PNG24:"{3}"'.format(
|
|
893
|
-
image_magick["convert"], width, height, files[0]
|
|
894
|
-
)
|
|
895
|
-
subprocess.call(command, shell=True)
|
|
896
|
-
except BaseException:
|
|
897
|
-
logging.exception("Error blanking first frame")
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
def crop_viewport(directory):
|
|
901
|
-
if client_viewport is not None:
|
|
902
|
-
try:
|
|
903
|
-
files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
904
|
-
count = len(files)
|
|
905
|
-
if count > 0:
|
|
906
|
-
crop = "{0:d}x{1:d}+{2:d}+{3:d}".format(
|
|
907
|
-
client_viewport["width"],
|
|
908
|
-
client_viewport["height"],
|
|
909
|
-
client_viewport["x"],
|
|
910
|
-
client_viewport["y"],
|
|
911
|
-
)
|
|
912
|
-
for i in range(count):
|
|
913
|
-
command = '{0} "{1}" -crop {2} "{1}"'.format(
|
|
914
|
-
image_magick["convert"], files[i], crop
|
|
915
|
-
)
|
|
916
|
-
subprocess.call(command, shell=True)
|
|
917
|
-
|
|
918
|
-
except BaseException:
|
|
919
|
-
logging.exception("Error cropping to viewport")
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
def get_decimate_filter():
|
|
923
|
-
decimate = None
|
|
924
|
-
try:
|
|
925
|
-
if sys.version_info > (3, 0):
|
|
926
|
-
filters = subprocess.check_output(
|
|
927
|
-
["ffmpeg", "-filters"], stderr=subprocess.STDOUT, encoding="UTF-8"
|
|
928
|
-
)
|
|
929
|
-
else:
|
|
930
|
-
filters = subprocess.check_output(
|
|
931
|
-
["ffmpeg", "-filters"], stderr=subprocess.STDOUT
|
|
932
|
-
)
|
|
933
|
-
lines = filters.split("\n")
|
|
934
|
-
match = re.compile(
|
|
935
|
-
r"(?P<filter>[\w]*decimate).*V->V.*Remove near-duplicate frames"
|
|
936
|
-
)
|
|
937
|
-
for line in lines:
|
|
938
|
-
m = re.search(match, line)
|
|
939
|
-
if m is not None:
|
|
940
|
-
decimate = m.groupdict().get("filter")
|
|
941
|
-
break
|
|
942
|
-
except BaseException:
|
|
943
|
-
logging.critical("Error checking ffmpeg filters for decimate")
|
|
944
|
-
decimate = None
|
|
945
|
-
return decimate
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
def clean_directory(directory):
|
|
949
|
-
files = glob.glob(os.path.join(directory, "*.png"))
|
|
950
|
-
for file in files:
|
|
951
|
-
os.remove(file)
|
|
952
|
-
files = glob.glob(os.path.join(directory, "*.jpg"))
|
|
953
|
-
for file in files:
|
|
954
|
-
os.remove(file)
|
|
955
|
-
files = glob.glob(os.path.join(directory, "*.json"))
|
|
956
|
-
for file in files:
|
|
957
|
-
os.remove(file)
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
def is_color_frame(file, color_file):
|
|
961
|
-
"""Check a section from the middle, top and bottom of the viewport to see if it matches"""
|
|
962
|
-
global frame_cache
|
|
963
|
-
if file in frame_cache and color_file in frame_cache[file]:
|
|
964
|
-
return bool(frame_cache[file][color_file])
|
|
965
|
-
match = False
|
|
966
|
-
if os.path.isfile(color_file):
|
|
967
|
-
try:
|
|
968
|
-
from PIL import Image
|
|
969
|
-
|
|
970
|
-
with Image.open(file) as img:
|
|
971
|
-
width, height = img.size
|
|
972
|
-
crops = []
|
|
973
|
-
# Middle
|
|
974
|
-
crops.append(
|
|
975
|
-
"{0:d}x{1:d}+{2:d}+{3:d}".format(
|
|
976
|
-
int(width / 2), int(height / 3), int(width / 4), int(height / 3)
|
|
977
|
-
)
|
|
978
|
-
)
|
|
979
|
-
# Top
|
|
980
|
-
crops.append(
|
|
981
|
-
"{0:d}x{1:d}+{2:d}+{3:d}".format(
|
|
982
|
-
int(width / 2), int(height / 5), int(width / 4), 50
|
|
983
|
-
)
|
|
984
|
-
)
|
|
985
|
-
# Bottom
|
|
986
|
-
crops.append(
|
|
987
|
-
"{0:d}x{1:d}+{2:d}+{3:d}".format(
|
|
988
|
-
int(width / 2),
|
|
989
|
-
int(height / 5),
|
|
990
|
-
int(width / 4),
|
|
991
|
-
height - int(height / 5),
|
|
992
|
-
)
|
|
993
|
-
)
|
|
994
|
-
for crop in crops:
|
|
995
|
-
command = (
|
|
996
|
-
'{0} "{1}" "(" "{2}" -crop {3} -resize 200x200! ")"'
|
|
997
|
-
" miff:- | {4} -metric AE - -fuzz 15% null:"
|
|
998
|
-
).format(
|
|
999
|
-
image_magick["convert"],
|
|
1000
|
-
color_file,
|
|
1001
|
-
file,
|
|
1002
|
-
crop,
|
|
1003
|
-
image_magick["compare"],
|
|
1004
|
-
)
|
|
1005
|
-
|
|
1006
|
-
if sys.version_info > (3, 0):
|
|
1007
|
-
compare = subprocess.Popen(
|
|
1008
|
-
command, stderr=subprocess.PIPE, shell=True, encoding="UTF-8"
|
|
1009
|
-
)
|
|
1010
|
-
else:
|
|
1011
|
-
compare = subprocess.Popen(
|
|
1012
|
-
command, stderr=subprocess.PIPE, shell=True
|
|
1013
|
-
)
|
|
1014
|
-
out, err = compare.communicate()
|
|
1015
|
-
if re.match("^[0-9]+$", err):
|
|
1016
|
-
different_pixels = int(err)
|
|
1017
|
-
if different_pixels < 10000:
|
|
1018
|
-
match = True
|
|
1019
|
-
break
|
|
1020
|
-
except Exception:
|
|
1021
|
-
pass
|
|
1022
|
-
if file not in frame_cache:
|
|
1023
|
-
frame_cache[file] = {}
|
|
1024
|
-
frame_cache[file][color_file] = bool(match)
|
|
1025
|
-
return match
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
def is_white_frame(file, white_file):
|
|
1029
|
-
white = False
|
|
1030
|
-
if os.path.isfile(white_file):
|
|
1031
|
-
if options.viewport:
|
|
1032
|
-
command = (
|
|
1033
|
-
'{0} "{1}" "(" "{2}" -resize 200x200! ")" miff:- | '
|
|
1034
|
-
"{3} -metric AE - -fuzz 10% null:"
|
|
1035
|
-
).format(image_magick["convert"], white_file, file, image_magick["compare"])
|
|
1036
|
-
else:
|
|
1037
|
-
command = (
|
|
1038
|
-
'{0} "{1}" "(" "{2}" -gravity Center -crop 50%x33%+0+0 -resize 200x200! ")" miff:- | '
|
|
1039
|
-
"{3} -metric AE - -fuzz 10% null:"
|
|
1040
|
-
).format(image_magick["convert"], white_file, file, image_magick["compare"])
|
|
1041
|
-
if client_viewport is not None:
|
|
1042
|
-
crop = "{0:d}x{1:d}+{2:d}+{3:d}".format(
|
|
1043
|
-
client_viewport["width"],
|
|
1044
|
-
client_viewport["height"],
|
|
1045
|
-
client_viewport["x"],
|
|
1046
|
-
client_viewport["y"],
|
|
1047
|
-
)
|
|
1048
|
-
command = (
|
|
1049
|
-
'{0} "{1}" "(" "{2}" -crop {3} -resize 200x200! ")" miff:- | '
|
|
1050
|
-
"{4} -metric AE - -fuzz 10% null:"
|
|
1051
|
-
).format(
|
|
1052
|
-
image_magick["convert"], white_file, file, crop, image_magick["compare"]
|
|
1053
|
-
)
|
|
1054
|
-
if sys.version_info > (3, 0):
|
|
1055
|
-
compare = subprocess.Popen(
|
|
1056
|
-
command, stderr=subprocess.PIPE, shell=True, encoding="UTF-8"
|
|
1057
|
-
)
|
|
1058
|
-
else:
|
|
1059
|
-
compare = subprocess.Popen(command, stderr=subprocess.PIPE, shell=True)
|
|
1060
|
-
out, err = compare.communicate()
|
|
1061
|
-
if re.match("^[0-9]+$", err):
|
|
1062
|
-
different_pixels = int(err)
|
|
1063
|
-
if different_pixels < 500:
|
|
1064
|
-
white = True
|
|
1065
|
-
|
|
1066
|
-
return white
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
def colors_are_similar(a, b, threshold=15):
|
|
1070
|
-
similar = True
|
|
1071
|
-
sum = 0
|
|
1072
|
-
for x in range(3):
|
|
1073
|
-
delta = abs(a[x] - b[x])
|
|
1074
|
-
sum += delta
|
|
1075
|
-
if delta > threshold:
|
|
1076
|
-
similar = False
|
|
1077
|
-
if sum > threshold:
|
|
1078
|
-
similar = False
|
|
1079
|
-
|
|
1080
|
-
return similar
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
def frames_match(image1, image2, fuzz_percent, max_differences, crop_region, mask_rect):
|
|
1084
|
-
match = False
|
|
1085
|
-
fuzz = ""
|
|
1086
|
-
if fuzz_percent > 0:
|
|
1087
|
-
fuzz = "-fuzz {0:d}% ".format(fuzz_percent)
|
|
1088
|
-
crop = ""
|
|
1089
|
-
if crop_region is not None:
|
|
1090
|
-
crop = "-crop {0} ".format(crop_region)
|
|
1091
|
-
if mask_rect is None:
|
|
1092
|
-
img1 = '"{0}"'.format(image1)
|
|
1093
|
-
img2 = '"{0}"'.format(image2)
|
|
1094
|
-
else:
|
|
1095
|
-
img1 = '( "{0}" -size {1}x{2} xc:white -geometry +{3}+{4} -compose over -composite )'.format(
|
|
1096
|
-
image1,
|
|
1097
|
-
mask_rect["width"],
|
|
1098
|
-
mask_rect["height"],
|
|
1099
|
-
mask_rect["x"],
|
|
1100
|
-
mask_rect["y"],
|
|
1101
|
-
)
|
|
1102
|
-
img2 = '( "{0}" -size {1}x{2} xc:white -geometry +{3}+{4} -compose over -composite )'.format(
|
|
1103
|
-
image2,
|
|
1104
|
-
mask_rect["width"],
|
|
1105
|
-
mask_rect["height"],
|
|
1106
|
-
mask_rect["x"],
|
|
1107
|
-
mask_rect["y"],
|
|
1108
|
-
)
|
|
1109
|
-
command = "{0} {1} {2} {3}miff:- | {4} -metric AE - {5}null:".format(
|
|
1110
|
-
image_magick["convert"], img1, img2, crop, image_magick["compare"], fuzz
|
|
1111
|
-
)
|
|
1112
|
-
if platform.system() != "Windows":
|
|
1113
|
-
command = command.replace("(", "\\(").replace(")", "\\)")
|
|
1114
|
-
if sys.version_info > (3, 0):
|
|
1115
|
-
compare = subprocess.Popen(
|
|
1116
|
-
command, stderr=subprocess.PIPE, shell=True, encoding="UTF-8"
|
|
1117
|
-
)
|
|
1118
|
-
else:
|
|
1119
|
-
compare = subprocess.Popen(command, stderr=subprocess.PIPE, shell=True)
|
|
1120
|
-
out, err = compare.communicate()
|
|
1121
|
-
if re.match("^[0-9]+$", err):
|
|
1122
|
-
different_pixels = int(err)
|
|
1123
|
-
if different_pixels <= max_differences:
|
|
1124
|
-
match = True
|
|
1125
|
-
else:
|
|
1126
|
-
logging.debug(
|
|
1127
|
-
'Unexpected compare result: out: "{0}", err: "{1}"'.format(out, err)
|
|
1128
|
-
)
|
|
1129
|
-
|
|
1130
|
-
return match
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
def generate_orange_png(orange_file):
|
|
1134
|
-
try:
|
|
1135
|
-
from PIL import Image, ImageDraw
|
|
1136
|
-
|
|
1137
|
-
im = Image.new("RGB", (200, 200))
|
|
1138
|
-
draw = ImageDraw.Draw(im)
|
|
1139
|
-
draw.rectangle([0, 0, 200, 200], fill=(222, 100, 13))
|
|
1140
|
-
del draw
|
|
1141
|
-
im.save(orange_file, "PNG")
|
|
1142
|
-
except BaseException:
|
|
1143
|
-
logging.exception("Error generating orange png " + orange_file)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
def generate_gray_png(gray_file):
|
|
1147
|
-
try:
|
|
1148
|
-
from PIL import Image, ImageDraw
|
|
1149
|
-
|
|
1150
|
-
im = Image.new("RGB", (200, 200))
|
|
1151
|
-
draw = ImageDraw.Draw(im)
|
|
1152
|
-
draw.rectangle([0, 0, 200, 200], fill=(128, 128, 128))
|
|
1153
|
-
del draw
|
|
1154
|
-
im.save(gray_file, "PNG")
|
|
1155
|
-
except BaseException:
|
|
1156
|
-
logging.exception("Error generating gray png " + gray_file)
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
def generate_white_png(white_file):
|
|
1160
|
-
try:
|
|
1161
|
-
from PIL import Image, ImageDraw
|
|
1162
|
-
|
|
1163
|
-
im = Image.new("RGB", (200, 200))
|
|
1164
|
-
draw = ImageDraw.Draw(im)
|
|
1165
|
-
draw.rectangle([0, 0, 200, 200], fill=(255, 255, 255))
|
|
1166
|
-
del draw
|
|
1167
|
-
im.save(white_file, "PNG")
|
|
1168
|
-
except BaseException:
|
|
1169
|
-
logging.exception("Error generating white png " + white_file)
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
def synchronize_to_timeline(directory, timeline_file):
|
|
1173
|
-
offset = get_timeline_offset(timeline_file)
|
|
1174
|
-
if offset > 0:
|
|
1175
|
-
frames = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
1176
|
-
match = re.compile(r"ms_(?P<ms>[0-9]+)\.png")
|
|
1177
|
-
for frame in frames:
|
|
1178
|
-
m = re.search(match, frame)
|
|
1179
|
-
if m is not None:
|
|
1180
|
-
frame_time = int(m.groupdict().get("ms"))
|
|
1181
|
-
new_time = max(frame_time - offset, 0)
|
|
1182
|
-
dest = os.path.join(directory, "ms_{0:06d}.png".format(new_time))
|
|
1183
|
-
if frame != dest:
|
|
1184
|
-
if os.path.isfile(dest):
|
|
1185
|
-
os.remove(dest)
|
|
1186
|
-
os.rename(frame, dest)
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
def get_timeline_offset(timeline_file):
|
|
1190
|
-
offset = 0
|
|
1191
|
-
try:
|
|
1192
|
-
file_name, ext = os.path.splitext(timeline_file)
|
|
1193
|
-
if ext.lower() == ".gz":
|
|
1194
|
-
f = gzip.open(timeline_file, GZIP_READ_TEXT)
|
|
1195
|
-
else:
|
|
1196
|
-
f = open(timeline_file, "r")
|
|
1197
|
-
timeline = json.load(f)
|
|
1198
|
-
f.close()
|
|
1199
|
-
last_paint = None
|
|
1200
|
-
first_navigate = None
|
|
1201
|
-
|
|
1202
|
-
# In the case of a trace instead of a timeline we want the list of
|
|
1203
|
-
# events
|
|
1204
|
-
if "traceEvents" in timeline:
|
|
1205
|
-
timeline = timeline["traceEvents"]
|
|
1206
|
-
|
|
1207
|
-
for timeline_event in timeline:
|
|
1208
|
-
paint_time = get_timeline_event_paint_time(timeline_event)
|
|
1209
|
-
if paint_time is not None:
|
|
1210
|
-
last_paint = paint_time
|
|
1211
|
-
first_navigate = get_timeline_event_navigate_time(timeline_event)
|
|
1212
|
-
if first_navigate is not None:
|
|
1213
|
-
break
|
|
1214
|
-
|
|
1215
|
-
if (
|
|
1216
|
-
last_paint is not None
|
|
1217
|
-
and first_navigate is not None
|
|
1218
|
-
and first_navigate > last_paint
|
|
1219
|
-
):
|
|
1220
|
-
offset = int(round(first_navigate - last_paint))
|
|
1221
|
-
logging.info(
|
|
1222
|
-
"Trimming {0:d}ms from the start of the video based on timeline synchronization".format(
|
|
1223
|
-
offset
|
|
1224
|
-
)
|
|
1225
|
-
)
|
|
1226
|
-
except BaseException:
|
|
1227
|
-
logging.critical("Error processing timeline file " + timeline_file)
|
|
1228
|
-
|
|
1229
|
-
return offset
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def get_timeline_event_paint_time(timeline_event):
|
|
1233
|
-
paint_time = None
|
|
1234
|
-
if "cat" in timeline_event:
|
|
1235
|
-
if (
|
|
1236
|
-
timeline_event["cat"].find("devtools.timeline") >= 0
|
|
1237
|
-
and "ts" in timeline_event
|
|
1238
|
-
and "name" in timeline_event
|
|
1239
|
-
and (
|
|
1240
|
-
timeline_event["name"].find("Paint") >= 0
|
|
1241
|
-
or timeline_event["name"].find("CompositeLayers") >= 0
|
|
1242
|
-
)
|
|
1243
|
-
):
|
|
1244
|
-
paint_time = float(timeline_event["ts"]) / 1000.0
|
|
1245
|
-
if "dur" in timeline_event:
|
|
1246
|
-
paint_time += float(timeline_event["dur"]) / 1000.0
|
|
1247
|
-
elif "method" in timeline_event:
|
|
1248
|
-
if (
|
|
1249
|
-
timeline_event["method"] == "Timeline.eventRecorded"
|
|
1250
|
-
and "params" in timeline_event
|
|
1251
|
-
and "record" in timeline_event["params"]
|
|
1252
|
-
):
|
|
1253
|
-
paint_time = get_timeline_event_paint_time(
|
|
1254
|
-
timeline_event["params"]["record"]
|
|
1255
|
-
)
|
|
1256
|
-
else:
|
|
1257
|
-
if "type" in timeline_event and (
|
|
1258
|
-
timeline_event["type"] == "Rasterize"
|
|
1259
|
-
or timeline_event["type"] == "CompositeLayers"
|
|
1260
|
-
or timeline_event["type"] == "Paint"
|
|
1261
|
-
):
|
|
1262
|
-
if "endTime" in timeline_event:
|
|
1263
|
-
paint_time = timeline_event["endTime"]
|
|
1264
|
-
elif "startTime" in timeline_event:
|
|
1265
|
-
paint_time = timeline_event["startTime"]
|
|
1266
|
-
|
|
1267
|
-
# Check for any child paint events
|
|
1268
|
-
if "children" in timeline_event:
|
|
1269
|
-
for child in timeline_event["children"]:
|
|
1270
|
-
child_paint_time = get_timeline_event_paint_time(child)
|
|
1271
|
-
if child_paint_time is not None and (
|
|
1272
|
-
paint_time is None or child_paint_time > paint_time
|
|
1273
|
-
):
|
|
1274
|
-
paint_time = child_paint_time
|
|
1275
|
-
|
|
1276
|
-
return paint_time
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
def get_timeline_event_navigate_time(timeline_event):
|
|
1280
|
-
navigate_time = None
|
|
1281
|
-
if "cat" in timeline_event:
|
|
1282
|
-
if (
|
|
1283
|
-
timeline_event["cat"].find("devtools.timeline") >= 0
|
|
1284
|
-
and "ts" in timeline_event
|
|
1285
|
-
and "name" in timeline_event
|
|
1286
|
-
and timeline_event["name"] == "ResourceSendRequest"
|
|
1287
|
-
):
|
|
1288
|
-
navigate_time = float(timeline_event["ts"]) / 1000.0
|
|
1289
|
-
elif "method" in timeline_event:
|
|
1290
|
-
if (
|
|
1291
|
-
timeline_event["method"] == "Timeline.eventRecorded"
|
|
1292
|
-
and "params" in timeline_event
|
|
1293
|
-
and "record" in timeline_event["params"]
|
|
1294
|
-
):
|
|
1295
|
-
navigate_time = get_timeline_event_navigate_time(
|
|
1296
|
-
timeline_event["params"]["record"]
|
|
1297
|
-
)
|
|
1298
|
-
else:
|
|
1299
|
-
if (
|
|
1300
|
-
"type" in timeline_event
|
|
1301
|
-
and timeline_event["type"] == "ResourceSendRequest"
|
|
1302
|
-
and "startTime" in timeline_event
|
|
1303
|
-
):
|
|
1304
|
-
navigate_time = timeline_event["startTime"]
|
|
1305
|
-
|
|
1306
|
-
# Check for any child paint events
|
|
1307
|
-
if "children" in timeline_event:
|
|
1308
|
-
for child in timeline_event["children"]:
|
|
1309
|
-
child_navigate_time = get_timeline_event_navigate_time(child)
|
|
1310
|
-
if child_navigate_time is not None and (
|
|
1311
|
-
navigate_time is None or child_navigate_time < navigate_time
|
|
1312
|
-
):
|
|
1313
|
-
navigate_time = child_navigate_time
|
|
1314
|
-
|
|
1315
|
-
return navigate_time
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
##########################################################################
|
|
1319
|
-
# Histogram calculations
|
|
1320
|
-
##########################################################################
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
def calculate_histograms(directory, histograms_file, force):
|
|
1324
|
-
logging.debug("Calculating image histograms")
|
|
1325
|
-
if not os.path.isfile(histograms_file) or force:
|
|
1326
|
-
try:
|
|
1327
|
-
extension = None
|
|
1328
|
-
directory = os.path.realpath(directory)
|
|
1329
|
-
first_frame = os.path.join(directory, "ms_000000")
|
|
1330
|
-
if os.path.isfile(first_frame + ".png"):
|
|
1331
|
-
extension = ".png"
|
|
1332
|
-
elif os.path.isfile(first_frame + ".jpg"):
|
|
1333
|
-
extension = ".jpg"
|
|
1334
|
-
if extension is not None:
|
|
1335
|
-
histograms = []
|
|
1336
|
-
frames = sorted(glob.glob(os.path.join(directory, "ms_*" + extension)))
|
|
1337
|
-
match = re.compile(r"ms_(?P<ms>[0-9]+)\.")
|
|
1338
|
-
for frame in frames:
|
|
1339
|
-
m = re.search(match, frame)
|
|
1340
|
-
if m is not None:
|
|
1341
|
-
frame_time = int(m.groupdict().get("ms"))
|
|
1342
|
-
histogram, total, dropped = calculate_image_histogram(frame)
|
|
1343
|
-
gc.collect()
|
|
1344
|
-
if histogram is not None:
|
|
1345
|
-
histograms.append(
|
|
1346
|
-
{
|
|
1347
|
-
"time": frame_time,
|
|
1348
|
-
"file": os.path.basename(frame),
|
|
1349
|
-
"histogram": histogram,
|
|
1350
|
-
"total_pixels": total,
|
|
1351
|
-
"dropped_pixels": dropped,
|
|
1352
|
-
}
|
|
1353
|
-
)
|
|
1354
|
-
if os.path.isfile(histograms_file):
|
|
1355
|
-
os.remove(histograms_file)
|
|
1356
|
-
f = gzip.open(histograms_file, GZIP_TEXT)
|
|
1357
|
-
json.dump(histograms, f)
|
|
1358
|
-
f.close()
|
|
1359
|
-
else:
|
|
1360
|
-
logging.critical("No video frames found in " + directory)
|
|
1361
|
-
except BaseException:
|
|
1362
|
-
logging.exception("Error calculating histograms")
|
|
1363
|
-
else:
|
|
1364
|
-
logging.debug("Histograms file {0} already exists".format(histograms_file))
|
|
1365
|
-
logging.debug("Done calculating histograms")
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
def calculate_image_histogram(file):
|
|
1369
|
-
logging.debug("Calculating histogram for " + file)
|
|
1370
|
-
dropped = 0
|
|
1371
|
-
try:
|
|
1372
|
-
from PIL import Image
|
|
1373
|
-
|
|
1374
|
-
im = Image.open(file)
|
|
1375
|
-
width, height = im.size
|
|
1376
|
-
total = width * height
|
|
1377
|
-
colors = im.getcolors(total)
|
|
1378
|
-
histogram = {
|
|
1379
|
-
"r": [0 for i in range(256)],
|
|
1380
|
-
"g": [0 for i in range(256)],
|
|
1381
|
-
"b": [0 for i in range(256)],
|
|
1382
|
-
}
|
|
1383
|
-
for entry in colors:
|
|
1384
|
-
try:
|
|
1385
|
-
count = entry[0]
|
|
1386
|
-
pixel = entry[1]
|
|
1387
|
-
# Don't include White pixels (with a tiny bit of slop for
|
|
1388
|
-
# compression artifacts)
|
|
1389
|
-
if pixel[0] < 250 or pixel[1] < 250 or pixel[2] < 250:
|
|
1390
|
-
histogram["r"][pixel[0]] += count
|
|
1391
|
-
histogram["g"][pixel[1]] += count
|
|
1392
|
-
histogram["b"][pixel[2]] += count
|
|
1393
|
-
else:
|
|
1394
|
-
dropped += 1
|
|
1395
|
-
except Exception:
|
|
1396
|
-
pass
|
|
1397
|
-
colors = None
|
|
1398
|
-
except Exception:
|
|
1399
|
-
total = 0
|
|
1400
|
-
histogram = None
|
|
1401
|
-
logging.exception("Error calculating histogram for " + file)
|
|
1402
|
-
return histogram, total, dropped
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
##########################################################################
|
|
1406
|
-
# Screen Shots
|
|
1407
|
-
##########################################################################
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
def save_screenshot(directory, dest, quality):
|
|
1411
|
-
directory = os.path.realpath(directory)
|
|
1412
|
-
files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
1413
|
-
if files is not None and len(files) >= 1:
|
|
1414
|
-
src = files[-1]
|
|
1415
|
-
if dest[-4:] == ".jpg":
|
|
1416
|
-
command = '{0} "{1}" -set colorspace sRGB -quality {2:d} "{3}"'.format(
|
|
1417
|
-
image_magick["convert"], src, quality, dest
|
|
1418
|
-
)
|
|
1419
|
-
subprocess.call(command, shell=True)
|
|
1420
|
-
else:
|
|
1421
|
-
shutil.copy(src, dest)
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
##########################################################################
|
|
1425
|
-
# JPEG conversion
|
|
1426
|
-
##########################################################################
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
def convert_to_jpeg(directory, quality):
|
|
1430
|
-
logging.debug("Converting video frames to JPEG")
|
|
1431
|
-
directory = os.path.realpath(directory)
|
|
1432
|
-
pattern = os.path.join(directory, "ms_*.png")
|
|
1433
|
-
command = '{0} -format jpg -set colorspace sRGB -quality {1:d} "{2}"'.format(
|
|
1434
|
-
image_magick["mogrify"], quality, pattern
|
|
1435
|
-
)
|
|
1436
|
-
logging.debug(command)
|
|
1437
|
-
subprocess.call(command, shell=True)
|
|
1438
|
-
files = sorted(glob.glob(pattern))
|
|
1439
|
-
match = re.compile(r"(?P<base>ms_[0-9]+\.)")
|
|
1440
|
-
for file in files:
|
|
1441
|
-
m = re.search(match, file)
|
|
1442
|
-
if m is not None:
|
|
1443
|
-
dest = os.path.join(directory, m.groupdict().get("base") + "jpg")
|
|
1444
|
-
if os.path.isfile(dest):
|
|
1445
|
-
os.remove(file)
|
|
1446
|
-
logging.debug("Done Converting video frames to JPEG")
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
##########################################################################
|
|
1450
|
-
# Video rendering
|
|
1451
|
-
##########################################################################
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
def render_video(directory, video_file):
|
|
1455
|
-
"""Render the frames to the given mp4 file"""
|
|
1456
|
-
directory = os.path.realpath(directory)
|
|
1457
|
-
files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
1458
|
-
if len(files) > 1:
|
|
1459
|
-
current_image = None
|
|
1460
|
-
with open(os.path.join(directory, files[0]), "rb") as f_in:
|
|
1461
|
-
current_image = f_in.read()
|
|
1462
|
-
if current_image is not None:
|
|
1463
|
-
command = [
|
|
1464
|
-
"ffmpeg",
|
|
1465
|
-
"-f",
|
|
1466
|
-
"image2pipe",
|
|
1467
|
-
"-vcodec",
|
|
1468
|
-
"png",
|
|
1469
|
-
"-r",
|
|
1470
|
-
"30",
|
|
1471
|
-
"-i",
|
|
1472
|
-
"-",
|
|
1473
|
-
"-vcodec",
|
|
1474
|
-
"libx264",
|
|
1475
|
-
"-r",
|
|
1476
|
-
"30",
|
|
1477
|
-
"-crf",
|
|
1478
|
-
"24",
|
|
1479
|
-
"-g",
|
|
1480
|
-
"15",
|
|
1481
|
-
"-preset",
|
|
1482
|
-
"superfast",
|
|
1483
|
-
"-y",
|
|
1484
|
-
video_file,
|
|
1485
|
-
]
|
|
1486
|
-
try:
|
|
1487
|
-
proc = subprocess.Popen(command, stdin=subprocess.PIPE)
|
|
1488
|
-
if proc:
|
|
1489
|
-
match = re.compile(r"ms_([0-9]+)\.")
|
|
1490
|
-
m = re.search(match, files[1])
|
|
1491
|
-
file_index = 0
|
|
1492
|
-
last_index = len(files) - 1
|
|
1493
|
-
if m is not None:
|
|
1494
|
-
next_image_time = int(m.group(1))
|
|
1495
|
-
done = False
|
|
1496
|
-
current_frame = 0
|
|
1497
|
-
while not done:
|
|
1498
|
-
current_frame_time = int(
|
|
1499
|
-
round(float(current_frame) * 1000.0 / 30.0)
|
|
1500
|
-
)
|
|
1501
|
-
if current_frame_time >= next_image_time:
|
|
1502
|
-
file_index += 1
|
|
1503
|
-
with open(
|
|
1504
|
-
os.path.join(directory, files[file_index]), "rb"
|
|
1505
|
-
) as f_in:
|
|
1506
|
-
current_image = f_in.read()
|
|
1507
|
-
if file_index < last_index:
|
|
1508
|
-
m = re.search(match, files[file_index + 1])
|
|
1509
|
-
if m:
|
|
1510
|
-
next_image_time = int(m.group(1))
|
|
1511
|
-
else:
|
|
1512
|
-
done = True
|
|
1513
|
-
proc.stdin.write(current_image)
|
|
1514
|
-
current_frame += 1
|
|
1515
|
-
# hold the end frame for one second so it's actually
|
|
1516
|
-
# visible
|
|
1517
|
-
for i in range(30):
|
|
1518
|
-
proc.stdin.write(current_image)
|
|
1519
|
-
proc.stdin.close()
|
|
1520
|
-
proc.communicate()
|
|
1521
|
-
except Exception:
|
|
1522
|
-
pass
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
##########################################################################
|
|
1526
|
-
# Reduce the number of saved video frames if necessary
|
|
1527
|
-
##########################################################################
|
|
1528
|
-
def cap_frame_count(directory, maxframes):
|
|
1529
|
-
directory = os.path.realpath(directory)
|
|
1530
|
-
frames = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
1531
|
-
frame_count = len(frames)
|
|
1532
|
-
if frame_count > maxframes:
|
|
1533
|
-
# First pass, sample all video frames at 10fps instead of 60fps,
|
|
1534
|
-
# keeping the first 20% of the target
|
|
1535
|
-
logging.debug(
|
|
1536
|
-
"Sampling 10fps: Reducing {0:d} frames to target of {1:d}...".format(
|
|
1537
|
-
frame_count, maxframes
|
|
1538
|
-
)
|
|
1539
|
-
)
|
|
1540
|
-
skip_frames = int(maxframes * 0.2)
|
|
1541
|
-
sample_frames(frames, 100, 0, skip_frames)
|
|
1542
|
-
|
|
1543
|
-
frames = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
1544
|
-
frame_count = len(frames)
|
|
1545
|
-
if frame_count > maxframes:
|
|
1546
|
-
# Second pass, sample all video frames after the first 5 seconds at
|
|
1547
|
-
# 2fps, keeping the first 40% of the target
|
|
1548
|
-
logging.debug(
|
|
1549
|
-
"Sampling 2fps: Reducing {0:d} frames to target of {1:d}...".format(
|
|
1550
|
-
frame_count, maxframes
|
|
1551
|
-
)
|
|
1552
|
-
)
|
|
1553
|
-
skip_frames = int(maxframes * 0.4)
|
|
1554
|
-
sample_frames(frames, 500, 5000, skip_frames)
|
|
1555
|
-
|
|
1556
|
-
frames = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
|
|
1557
|
-
frame_count = len(frames)
|
|
1558
|
-
if frame_count > maxframes:
|
|
1559
|
-
# Third pass, sample all video frames after the first 10
|
|
1560
|
-
# seconds at 1fps, keeping the first 60% of the target
|
|
1561
|
-
logging.debug(
|
|
1562
|
-
"Sampling 1fps: Reducing {0:d} frames to target of {1:d}...".format(
|
|
1563
|
-
frame_count, maxframes
|
|
1564
|
-
)
|
|
1565
|
-
)
|
|
1566
|
-
skip_frames = int(maxframes * 0.6)
|
|
1567
|
-
sample_frames(frames, 1000, 10000, skip_frames)
|
|
1568
|
-
|
|
1569
|
-
logging.debug(
|
|
1570
|
-
"{0:d} frames final count with a target max of {1:d} frames...".format(
|
|
1571
|
-
frame_count, maxframes
|
|
1572
|
-
)
|
|
1573
|
-
)
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
def sample_frames(frames, interval, start_ms, skip_frames):
|
|
1577
|
-
frame_count = len(frames)
|
|
1578
|
-
if frame_count > 3:
|
|
1579
|
-
# Always keep the first and last frames, only sample in the middle
|
|
1580
|
-
first_frame = frames[0]
|
|
1581
|
-
first_change = frames[1]
|
|
1582
|
-
last_frame = frames[-1]
|
|
1583
|
-
match = re.compile(r"ms_(?P<ms>[0-9]+)\.")
|
|
1584
|
-
m = re.search(match, first_change)
|
|
1585
|
-
first_change_time = 0
|
|
1586
|
-
if m is not None:
|
|
1587
|
-
first_change_time = int(m.groupdict().get("ms"))
|
|
1588
|
-
last_bucket = None
|
|
1589
|
-
logging.debug(
|
|
1590
|
-
"Sapling frames in {0:d}ms intervals after {1:d} ms, skipping {2:d} frames...".format(
|
|
1591
|
-
interval, first_change_time + start_ms, skip_frames
|
|
1592
|
-
)
|
|
1593
|
-
)
|
|
1594
|
-
frame_count = 0
|
|
1595
|
-
for frame in frames:
|
|
1596
|
-
m = re.search(match, frame)
|
|
1597
|
-
if m is not None:
|
|
1598
|
-
frame_count += 1
|
|
1599
|
-
frame_time = int(m.groupdict().get("ms"))
|
|
1600
|
-
frame_bucket = int(math.floor(frame_time / interval))
|
|
1601
|
-
if (
|
|
1602
|
-
frame_time > first_change_time + start_ms
|
|
1603
|
-
and frame_bucket == last_bucket
|
|
1604
|
-
and frame != first_frame
|
|
1605
|
-
and frame != first_change
|
|
1606
|
-
and frame != last_frame
|
|
1607
|
-
and frame_count > skip_frames
|
|
1608
|
-
):
|
|
1609
|
-
logging.debug("Removing sampled frame " + frame)
|
|
1610
|
-
os.remove(frame)
|
|
1611
|
-
last_bucket = frame_bucket
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
##########################################################################
|
|
1615
|
-
# Visual Metrics
|
|
1616
|
-
##########################################################################
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
def calculate_visual_metrics(
|
|
1620
|
-
histograms_file,
|
|
1621
|
-
start,
|
|
1622
|
-
end,
|
|
1623
|
-
perceptual,
|
|
1624
|
-
contentful,
|
|
1625
|
-
dirs,
|
|
1626
|
-
progress_file,
|
|
1627
|
-
hero_elements_file,
|
|
1628
|
-
key_colors,
|
|
1629
|
-
):
|
|
1630
|
-
metrics = None
|
|
1631
|
-
histograms = load_histograms(histograms_file, start, end)
|
|
1632
|
-
if histograms is not None and len(histograms) > 0:
|
|
1633
|
-
progress = calculate_visual_progress(histograms)
|
|
1634
|
-
if progress and progress_file is not None:
|
|
1635
|
-
file_name, ext = os.path.splitext(progress_file)
|
|
1636
|
-
if ext.lower() == ".gz":
|
|
1637
|
-
f = gzip.open(progress_file, GZIP_TEXT, 7)
|
|
1638
|
-
else:
|
|
1639
|
-
f = open(progress_file, "w")
|
|
1640
|
-
json.dump(progress, f)
|
|
1641
|
-
f.close()
|
|
1642
|
-
key_color_frames = calculate_key_color_frames(histograms, key_colors)
|
|
1643
|
-
if len(histograms) > 1:
|
|
1644
|
-
metrics = [
|
|
1645
|
-
{"name": "First Visual Change", "value": histograms[1]["time"]},
|
|
1646
|
-
{"name": "Last Visual Change", "value": histograms[-1]["time"]},
|
|
1647
|
-
{"name": "Speed Index", "value": calculate_speed_index(progress)},
|
|
1648
|
-
]
|
|
1649
|
-
if perceptual:
|
|
1650
|
-
value, value_progress = calculate_perceptual_speed_index(progress, dirs)
|
|
1651
|
-
metrics.extend(
|
|
1652
|
-
(
|
|
1653
|
-
{"name": "Perceptual Speed Index", "value": value},
|
|
1654
|
-
{
|
|
1655
|
-
"name": "Perceptual Speed Index Progress",
|
|
1656
|
-
"value": value_progress,
|
|
1657
|
-
},
|
|
1658
|
-
)
|
|
1659
|
-
)
|
|
1660
|
-
if contentful:
|
|
1661
|
-
value, value_progress = calculate_contentful_speed_index(progress, dirs)
|
|
1662
|
-
|
|
1663
|
-
metrics.extend(
|
|
1664
|
-
(
|
|
1665
|
-
{"name": "Contentful Speed Index", "value": value},
|
|
1666
|
-
{
|
|
1667
|
-
"name": "Contentful Speed Index Progress",
|
|
1668
|
-
"value": value_progress,
|
|
1669
|
-
},
|
|
1670
|
-
)
|
|
1671
|
-
)
|
|
1672
|
-
if hero_elements_file is not None and os.path.isfile(hero_elements_file):
|
|
1673
|
-
logging.debug("Calculating hero element times")
|
|
1674
|
-
hero_data = None
|
|
1675
|
-
hero_f_in = gzip.open(hero_elements_file, GZIP_READ_TEXT)
|
|
1676
|
-
try:
|
|
1677
|
-
hero_data = json.load(hero_f_in)
|
|
1678
|
-
except Exception as e:
|
|
1679
|
-
logging.exception("Could not load hero elements data")
|
|
1680
|
-
logging.exception(e)
|
|
1681
|
-
hero_f_in.close()
|
|
1682
|
-
|
|
1683
|
-
if (
|
|
1684
|
-
hero_data is not None
|
|
1685
|
-
and hero_data["heroes"] is not None
|
|
1686
|
-
and hero_data["viewport"] is not None
|
|
1687
|
-
and len(hero_data["heroes"]) > 0
|
|
1688
|
-
):
|
|
1689
|
-
viewport = hero_data["viewport"]
|
|
1690
|
-
hero_timings = []
|
|
1691
|
-
for hero in hero_data["heroes"]:
|
|
1692
|
-
hero_timings.append(
|
|
1693
|
-
{
|
|
1694
|
-
"name": hero["name"],
|
|
1695
|
-
"value": calculate_hero_time(
|
|
1696
|
-
progress, dirs, hero, viewport
|
|
1697
|
-
),
|
|
1698
|
-
}
|
|
1699
|
-
)
|
|
1700
|
-
hero_timings_sorted = sorted(
|
|
1701
|
-
hero_timings, key=lambda timing: timing["value"]
|
|
1702
|
-
)
|
|
1703
|
-
# hero_timings.append({'name': 'FirstPaintedHero',
|
|
1704
|
-
# 'value': hero_timings_sorted[0]['value']})
|
|
1705
|
-
hero_timings.append(
|
|
1706
|
-
{
|
|
1707
|
-
"name": "LastMeaningfulPaint",
|
|
1708
|
-
"value": hero_timings_sorted[-1]["value"],
|
|
1709
|
-
}
|
|
1710
|
-
)
|
|
1711
|
-
hero_data["timings"] = hero_timings
|
|
1712
|
-
metrics += hero_timings
|
|
1713
|
-
|
|
1714
|
-
hero_f_out = gzip.open(hero_elements_file, GZIP_TEXT, 7)
|
|
1715
|
-
json.dump(hero_data, hero_f_out)
|
|
1716
|
-
hero_f_out.close()
|
|
1717
|
-
else:
|
|
1718
|
-
logging.warning(
|
|
1719
|
-
"Hero elements file is not valid: " + str(hero_elements_file)
|
|
1720
|
-
)
|
|
1721
|
-
else:
|
|
1722
|
-
metrics = [
|
|
1723
|
-
{"name": "First Visual Change", "value": histograms[0]["time"]},
|
|
1724
|
-
{"name": "Last Visual Change", "value": histograms[0]["time"]},
|
|
1725
|
-
{"name": "Visually Complete", "value": histograms[0]["time"]},
|
|
1726
|
-
{"name": "Speed Index", "value": 0},
|
|
1727
|
-
]
|
|
1728
|
-
if perceptual:
|
|
1729
|
-
metrics.append({"name": "Perceptual Speed Index", "value": 0})
|
|
1730
|
-
if contentful:
|
|
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})
|
|
1746
|
-
prog = ""
|
|
1747
|
-
for p in progress:
|
|
1748
|
-
if len(prog):
|
|
1749
|
-
prog += ", "
|
|
1750
|
-
prog += "{0:d}={1:d}".format(p["time"], int(p["progress"]))
|
|
1751
|
-
metrics.append({"name": "Visual Progress", "value": prog})
|
|
1752
|
-
|
|
1753
|
-
return metrics
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
def load_histograms(histograms_file, start, end):
|
|
1757
|
-
histograms = None
|
|
1758
|
-
if os.path.isfile(histograms_file):
|
|
1759
|
-
f = gzip.open(histograms_file)
|
|
1760
|
-
original = json.load(f)
|
|
1761
|
-
f.close()
|
|
1762
|
-
if start != 0 or end != 0:
|
|
1763
|
-
histograms = []
|
|
1764
|
-
for histogram in original:
|
|
1765
|
-
if histogram["time"] <= start:
|
|
1766
|
-
histogram["time"] = start
|
|
1767
|
-
histograms = [histogram]
|
|
1768
|
-
elif histogram["time"] <= end:
|
|
1769
|
-
histograms.append(histogram)
|
|
1770
|
-
else:
|
|
1771
|
-
break
|
|
1772
|
-
else:
|
|
1773
|
-
histograms = original
|
|
1774
|
-
return histograms
|
|
1775
|
-
|
|
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
|
-
|
|
1850
|
-
def calculate_visual_progress(histograms):
|
|
1851
|
-
progress = []
|
|
1852
|
-
first = histograms[0]["histogram"]
|
|
1853
|
-
last = histograms[-1]["histogram"]
|
|
1854
|
-
for index, histogram in enumerate(histograms):
|
|
1855
|
-
p = calculate_frame_progress(histogram["histogram"], first, last)
|
|
1856
|
-
file_name, ext = os.path.splitext(histogram["file"])
|
|
1857
|
-
progress.append({"time": histogram["time"], "file": file_name, "progress": p})
|
|
1858
|
-
logging.debug("{0:d}ms - {1:d}% Complete".format(histogram["time"], int(p)))
|
|
1859
|
-
return progress
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
def calculate_frame_progress(histogram, start, final):
|
|
1863
|
-
total = 0
|
|
1864
|
-
matched = 0
|
|
1865
|
-
slop = 5 # allow for matching slight color variations
|
|
1866
|
-
channels = ["r", "g", "b"]
|
|
1867
|
-
for channel in channels:
|
|
1868
|
-
channel_total = 0
|
|
1869
|
-
channel_matched = 0
|
|
1870
|
-
buckets = 256
|
|
1871
|
-
available = [0 for i in range(buckets)]
|
|
1872
|
-
for i in range(buckets):
|
|
1873
|
-
available[i] = abs(histogram[channel][i] - start[channel][i])
|
|
1874
|
-
for i in range(buckets):
|
|
1875
|
-
target = abs(final[channel][i] - start[channel][i])
|
|
1876
|
-
if target:
|
|
1877
|
-
channel_total += target
|
|
1878
|
-
low = max(0, i - slop)
|
|
1879
|
-
high = min(buckets, i + slop)
|
|
1880
|
-
for j in range(low, high):
|
|
1881
|
-
this_match = min(target, available[j])
|
|
1882
|
-
available[j] -= this_match
|
|
1883
|
-
channel_matched += this_match
|
|
1884
|
-
target -= this_match
|
|
1885
|
-
total += channel_total
|
|
1886
|
-
matched += channel_matched
|
|
1887
|
-
progress = (float(matched) / float(total)) if total else 1
|
|
1888
|
-
return math.floor(progress * 100)
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
def find_visually_complete(progress):
|
|
1892
|
-
time = 0
|
|
1893
|
-
for p in progress:
|
|
1894
|
-
if int(p["progress"]) == 100:
|
|
1895
|
-
time = p["time"]
|
|
1896
|
-
break
|
|
1897
|
-
elif time == 0:
|
|
1898
|
-
time = p["time"]
|
|
1899
|
-
return time
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
def calculate_speed_index(progress):
|
|
1903
|
-
si = 0
|
|
1904
|
-
last_ms = progress[0]["time"]
|
|
1905
|
-
last_progress = progress[0]["progress"]
|
|
1906
|
-
for p in progress:
|
|
1907
|
-
elapsed = p["time"] - last_ms
|
|
1908
|
-
si += elapsed * (1.0 - last_progress)
|
|
1909
|
-
last_ms = p["time"]
|
|
1910
|
-
last_progress = p["progress"] / 100.0
|
|
1911
|
-
return int(si)
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
def calculate_contentful_speed_index(progress, directory):
|
|
1915
|
-
# convert output comes out with lines that have this format:
|
|
1916
|
-
# <pixel count>: <rgb color> #<hex color> <gray color>
|
|
1917
|
-
# This is CLI dependant and very fragile
|
|
1918
|
-
matcher = re.compile(r"(\d+?):")
|
|
1919
|
-
|
|
1920
|
-
try:
|
|
1921
|
-
dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory)
|
|
1922
|
-
content = []
|
|
1923
|
-
maxContent = 0
|
|
1924
|
-
for p in progress[1:]:
|
|
1925
|
-
# Full Path of the Current Frame
|
|
1926
|
-
current_frame = os.path.join(dir, "ms_{0:06d}.png".format(p["time"]))
|
|
1927
|
-
logging.debug("contentfulSpeedIndex: Current Image is %s" % current_frame)
|
|
1928
|
-
# Takes full path of PNG frames to compute contentfulness value
|
|
1929
|
-
command = "{0} {1} -canny 2x2+8%+8% -define histogram:unique-colors=true -format %c histogram:info:-".format(
|
|
1930
|
-
image_magick["convert"], current_frame
|
|
1931
|
-
)
|
|
1932
|
-
output = subprocess.check_output(command, shell=True).decode("utf-8")
|
|
1933
|
-
logging.debug("Output %s" % output)
|
|
1934
|
-
|
|
1935
|
-
# Take the last matching pixel count, which is the pixel count from the last
|
|
1936
|
-
# line
|
|
1937
|
-
pixel_count = matcher.findall(output)[-1]
|
|
1938
|
-
if not pixel_count:
|
|
1939
|
-
logging.debug("Could not find the contentfulness value")
|
|
1940
|
-
return None, None
|
|
1941
|
-
|
|
1942
|
-
value = int(pixel_count)
|
|
1943
|
-
if value > maxContent:
|
|
1944
|
-
maxContent = value
|
|
1945
|
-
content.append(value)
|
|
1946
|
-
|
|
1947
|
-
for i, value in enumerate(content):
|
|
1948
|
-
content[i] = (
|
|
1949
|
-
maxContent == 0 and 0.0 or float(content[i]) / float(maxContent)
|
|
1950
|
-
)
|
|
1951
|
-
|
|
1952
|
-
# Assume 0 content for first frame
|
|
1953
|
-
cont_si = 1 * (progress[1]["time"] - progress[0]["time"])
|
|
1954
|
-
completeness_value = [(progress[1]["time"], int(cont_si))]
|
|
1955
|
-
for i in range(1, len(progress) - 1):
|
|
1956
|
-
elapsed = progress[i + 1]["time"] - progress[i]["time"]
|
|
1957
|
-
# print i,' time =',p['time'],'elapsed =',elapsed,'content = ',content[i]
|
|
1958
|
-
cont_si += elapsed * (1.0 - content[i])
|
|
1959
|
-
completeness_value.append((progress[i + 1]["time"], int(cont_si)))
|
|
1960
|
-
|
|
1961
|
-
cont_si = int(cont_si)
|
|
1962
|
-
raw_progress_value = ["0=0"]
|
|
1963
|
-
for timestamp, percent in completeness_value:
|
|
1964
|
-
p = int(100 * float(percent) / float(cont_si))
|
|
1965
|
-
raw_progress_value.append("%d=%d" % (timestamp, p))
|
|
1966
|
-
|
|
1967
|
-
return cont_si, ", ".join(raw_progress_value)
|
|
1968
|
-
except Exception as e:
|
|
1969
|
-
logging.exception(e)
|
|
1970
|
-
return None, None
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
def calculate_perceptual_speed_index(progress, directory):
|
|
1974
|
-
from ssim import compute_ssim
|
|
1975
|
-
|
|
1976
|
-
x = len(progress)
|
|
1977
|
-
dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory)
|
|
1978
|
-
first_paint_frame = os.path.join(dir, "ms_{0:06d}.png".format(progress[1]["time"]))
|
|
1979
|
-
target_frame = os.path.join(dir, "ms_{0:06d}.png".format(progress[x - 1]["time"]))
|
|
1980
|
-
ssim_1 = compute_ssim(first_paint_frame, target_frame)
|
|
1981
|
-
per_si = float(progress[1]["time"])
|
|
1982
|
-
last_ms = progress[1]["time"]
|
|
1983
|
-
# Full Path of the Target Frame
|
|
1984
|
-
logging.debug("Target image for perSI is %s" % target_frame)
|
|
1985
|
-
ssim = ssim_1
|
|
1986
|
-
completeness_value = []
|
|
1987
|
-
for p in progress[1:]:
|
|
1988
|
-
elapsed = p["time"] - last_ms
|
|
1989
|
-
# print '*******elapsed %f'%elapsed
|
|
1990
|
-
# Full Path of the Current Frame
|
|
1991
|
-
current_frame = os.path.join(dir, "ms_{0:06d}.png".format(p["time"]))
|
|
1992
|
-
logging.debug("Current Image is %s" % current_frame)
|
|
1993
|
-
# Takes full path of PNG frames to compute SSIM value
|
|
1994
|
-
per_si += elapsed * (1.0 - ssim)
|
|
1995
|
-
ssim = compute_ssim(current_frame, target_frame)
|
|
1996
|
-
gc.collect()
|
|
1997
|
-
last_ms = p["time"]
|
|
1998
|
-
completeness_value.append((p["time"], int(per_si)))
|
|
1999
|
-
|
|
2000
|
-
per_si = int(per_si)
|
|
2001
|
-
raw_progress_value = ["0=0"]
|
|
2002
|
-
for timestamp, percent in completeness_value:
|
|
2003
|
-
p = int(100 * float(percent) / float(per_si))
|
|
2004
|
-
raw_progress_value.append("%d=%d" % (timestamp, p))
|
|
2005
|
-
|
|
2006
|
-
return per_si, ", ".join(raw_progress_value)
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
def calculate_hero_time(progress, directory, hero, viewport):
|
|
2010
|
-
try:
|
|
2011
|
-
dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory)
|
|
2012
|
-
n = len(progress)
|
|
2013
|
-
target_frame = os.path.join(dir, "ms_{0:06d}".format(progress[n - 1]["time"]))
|
|
2014
|
-
|
|
2015
|
-
extension = None
|
|
2016
|
-
if os.path.isfile(target_frame + ".png"):
|
|
2017
|
-
extension = ".png"
|
|
2018
|
-
elif os.path.isfile(target_frame + ".jpg"):
|
|
2019
|
-
extension = ".jpg"
|
|
2020
|
-
if extension is not None:
|
|
2021
|
-
hero_width = int(hero["width"])
|
|
2022
|
-
hero_height = int(hero["height"])
|
|
2023
|
-
hero_x = int(hero["x"])
|
|
2024
|
-
hero_y = int(hero["y"])
|
|
2025
|
-
target_frame = target_frame + extension
|
|
2026
|
-
logging.debug(
|
|
2027
|
-
"Target image for hero %s is %s" % (hero["name"], target_frame)
|
|
2028
|
-
)
|
|
2029
|
-
|
|
2030
|
-
from PIL import Image
|
|
2031
|
-
|
|
2032
|
-
with Image.open(target_frame) as im:
|
|
2033
|
-
width, height = im.size
|
|
2034
|
-
if width != viewport["width"]:
|
|
2035
|
-
scale = float(width) / float(viewport["width"])
|
|
2036
|
-
logging.debug(
|
|
2037
|
-
"Frames are %dpx wide but viewport was %dpx. Scaling by %f"
|
|
2038
|
-
% (width, viewport["width"], scale)
|
|
2039
|
-
)
|
|
2040
|
-
hero_width = int(hero["width"] * scale)
|
|
2041
|
-
hero_height = int(hero["height"] * scale)
|
|
2042
|
-
hero_x = int(hero["x"] * scale)
|
|
2043
|
-
hero_y = int(hero["y"] * scale)
|
|
2044
|
-
|
|
2045
|
-
logging.debug(
|
|
2046
|
-
'Calculating render time for hero element "%s" at position [%d, %d, %d, %d]'
|
|
2047
|
-
% (hero["name"], hero["x"], hero["y"], hero["width"], hero["height"])
|
|
2048
|
-
)
|
|
2049
|
-
|
|
2050
|
-
# Create a rectangular mask of the hero element position
|
|
2051
|
-
hero_mask = os.path.join(dir, "hero_{0}_mask.png".format(hero["name"]))
|
|
2052
|
-
command = '{0} -size {1}x{2} xc:black -fill white -draw "rectangle {3},{4} {5},{6}" PNG24:"{7}"'.format(
|
|
2053
|
-
image_magick["convert"],
|
|
2054
|
-
width,
|
|
2055
|
-
height,
|
|
2056
|
-
hero_x,
|
|
2057
|
-
hero_y,
|
|
2058
|
-
hero_x + hero_width,
|
|
2059
|
-
hero_y + hero_height,
|
|
2060
|
-
hero_mask,
|
|
2061
|
-
)
|
|
2062
|
-
subprocess.call(command, shell=True)
|
|
2063
|
-
|
|
2064
|
-
# Apply the mask to the target frame to create the reference frame
|
|
2065
|
-
target_mask = os.path.join(
|
|
2066
|
-
dir,
|
|
2067
|
-
"hero_{0}_ms_{1:06d}.png".format(hero["name"], progress[n - 1]["time"]),
|
|
2068
|
-
)
|
|
2069
|
-
command = "{0} {1} {2} -alpha Off -compose CopyOpacity -composite {3}".format(
|
|
2070
|
-
image_magick["convert"], target_frame, hero_mask, target_mask
|
|
2071
|
-
)
|
|
2072
|
-
subprocess.call(command, shell=True)
|
|
2073
|
-
|
|
2074
|
-
def cleanup():
|
|
2075
|
-
os.remove(hero_mask)
|
|
2076
|
-
if os.path.isfile(target_mask):
|
|
2077
|
-
os.remove(target_mask)
|
|
2078
|
-
|
|
2079
|
-
# Allow for small differences like scrollbars and overlaid UI elements
|
|
2080
|
-
# by applying a 10% fuzz and allowing for up to 2% of the pixels to be
|
|
2081
|
-
# different.
|
|
2082
|
-
fuzz = 10
|
|
2083
|
-
max_pixel_diff = math.ceil(hero_width * hero_height * 0.02)
|
|
2084
|
-
|
|
2085
|
-
for p in progress:
|
|
2086
|
-
current_frame = os.path.join(dir, "ms_{0:06d}".format(p["time"]))
|
|
2087
|
-
extension = None
|
|
2088
|
-
if os.path.isfile(current_frame + ".png"):
|
|
2089
|
-
extension = ".png"
|
|
2090
|
-
elif os.path.isfile(current_frame + ".jpg"):
|
|
2091
|
-
extension = ".jpg"
|
|
2092
|
-
if extension is not None:
|
|
2093
|
-
current_mask = os.path.join(
|
|
2094
|
-
dir, "hero_{0}_ms_{1:06d}.png".format(hero["name"], p["time"])
|
|
2095
|
-
)
|
|
2096
|
-
# Apply the mask to the current frame
|
|
2097
|
-
command = "{0} {1} {2} -alpha Off -compose CopyOpacity -composite {3}".format(
|
|
2098
|
-
image_magick["convert"],
|
|
2099
|
-
current_frame + extension,
|
|
2100
|
-
hero_mask,
|
|
2101
|
-
current_mask,
|
|
2102
|
-
)
|
|
2103
|
-
logging.debug(command)
|
|
2104
|
-
subprocess.call(command, shell=True)
|
|
2105
|
-
match = frames_match(
|
|
2106
|
-
target_mask, current_mask, fuzz, max_pixel_diff, None, None
|
|
2107
|
-
)
|
|
2108
|
-
# Remove each mask after using it
|
|
2109
|
-
os.remove(current_mask)
|
|
2110
|
-
|
|
2111
|
-
if match:
|
|
2112
|
-
# Clean up masks as soon as a match is found
|
|
2113
|
-
cleanup()
|
|
2114
|
-
return p["time"]
|
|
2115
|
-
|
|
2116
|
-
# No matches found; clean up masks
|
|
2117
|
-
cleanup()
|
|
2118
|
-
|
|
2119
|
-
return None
|
|
2120
|
-
except Exception as e:
|
|
2121
|
-
logging.exception(e)
|
|
2122
|
-
return None
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
##########################################################################
|
|
2126
|
-
# Check any dependencies
|
|
2127
|
-
##########################################################################
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
def check_config():
|
|
2131
|
-
ok = True
|
|
2132
|
-
|
|
2133
|
-
print("ffmpeg: ")
|
|
2134
|
-
if get_decimate_filter() is not None:
|
|
2135
|
-
print("OK")
|
|
2136
|
-
else:
|
|
2137
|
-
print("FAIL")
|
|
2138
|
-
ok = False
|
|
2139
|
-
|
|
2140
|
-
print("convert: ")
|
|
2141
|
-
if check_process("{0} -version".format(image_magick["convert"]), "ImageMagick"):
|
|
2142
|
-
print("OK")
|
|
2143
|
-
else:
|
|
2144
|
-
print("FAIL")
|
|
2145
|
-
ok = False
|
|
2146
|
-
|
|
2147
|
-
print("compare: ")
|
|
2148
|
-
if check_process("{0} -version".format(image_magick["compare"]), "ImageMagick"):
|
|
2149
|
-
print("OK")
|
|
2150
|
-
else:
|
|
2151
|
-
print("FAIL")
|
|
2152
|
-
ok = False
|
|
2153
|
-
|
|
2154
|
-
print("Pillow: ")
|
|
2155
|
-
try:
|
|
2156
|
-
from PIL import Image, ImageDraw # noqa
|
|
2157
|
-
|
|
2158
|
-
print("OK")
|
|
2159
|
-
except BaseException:
|
|
2160
|
-
print("FAIL")
|
|
2161
|
-
ok = False
|
|
2162
|
-
|
|
2163
|
-
print("SSIM: ")
|
|
2164
|
-
try:
|
|
2165
|
-
from ssim import compute_ssim # noqa
|
|
2166
|
-
|
|
2167
|
-
print("OK")
|
|
2168
|
-
except BaseException:
|
|
2169
|
-
print("FAIL")
|
|
2170
|
-
ok = False
|
|
2171
|
-
|
|
2172
|
-
return ok
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
def check_process(command, output):
|
|
2176
|
-
ok = False
|
|
2177
|
-
try:
|
|
2178
|
-
if sys.version_info > (3, 0):
|
|
2179
|
-
out = subprocess.check_output(
|
|
2180
|
-
command, stderr=subprocess.STDOUT, shell=True, encoding="UTF-8"
|
|
2181
|
-
)
|
|
2182
|
-
else:
|
|
2183
|
-
out = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True)
|
|
2184
|
-
if out.find(output) > -1:
|
|
2185
|
-
ok = True
|
|
2186
|
-
except BaseException:
|
|
2187
|
-
ok = False
|
|
2188
|
-
return ok
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
##########################################################################
|
|
2192
|
-
# Main Entry Point
|
|
2193
|
-
##########################################################################
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
def main():
|
|
2197
|
-
import argparse
|
|
2198
|
-
|
|
2199
|
-
global options
|
|
2200
|
-
global image_magick
|
|
2201
|
-
|
|
2202
|
-
parser = argparse.ArgumentParser(
|
|
2203
|
-
description="Calculate visual performance metrics from a video.",
|
|
2204
|
-
prog="visualmetrics",
|
|
2205
|
-
)
|
|
2206
|
-
parser.add_argument("--version", action="version", version="%(prog)s 0.1")
|
|
2207
|
-
parser.add_argument(
|
|
2208
|
-
"-c",
|
|
2209
|
-
"--check",
|
|
2210
|
-
action="store_true",
|
|
2211
|
-
default=False,
|
|
2212
|
-
help="Check dependencies (ffmpeg, imagemagick, PIL, SSIM).",
|
|
2213
|
-
)
|
|
2214
|
-
parser.add_argument(
|
|
2215
|
-
"-v",
|
|
2216
|
-
"--verbose",
|
|
2217
|
-
action="count",
|
|
2218
|
-
help="Increase verbosity (specify multiple times for more).",
|
|
2219
|
-
)
|
|
2220
|
-
parser.add_argument(
|
|
2221
|
-
"--logfile", help="Write log messages to given file instead of stdout"
|
|
2222
|
-
)
|
|
2223
|
-
parser.add_argument(
|
|
2224
|
-
"--logformat",
|
|
2225
|
-
help="Formatting for the log messages",
|
|
2226
|
-
default="%(asctime)s.%(msecs)03d - %(message)s",
|
|
2227
|
-
)
|
|
2228
|
-
parser.add_argument("-i", "--video", help="Input video file.")
|
|
2229
|
-
parser.add_argument(
|
|
2230
|
-
"-d",
|
|
2231
|
-
"--dir",
|
|
2232
|
-
help="Directory of video frames "
|
|
2233
|
-
"(as input if exists or as output if a video file is specified).",
|
|
2234
|
-
)
|
|
2235
|
-
parser.add_argument(
|
|
2236
|
-
"--render", help="Render the video frames to the given mp4 video file."
|
|
2237
|
-
)
|
|
2238
|
-
parser.add_argument(
|
|
2239
|
-
"--screenshot",
|
|
2240
|
-
help="Save the last frame of video as an image to the path provided.",
|
|
2241
|
-
)
|
|
2242
|
-
parser.add_argument(
|
|
2243
|
-
"-g",
|
|
2244
|
-
"--histogram",
|
|
2245
|
-
help="Histogram file (as input if exists or as output if "
|
|
2246
|
-
"histograms need to be calculated).",
|
|
2247
|
-
)
|
|
2248
|
-
parser.add_argument(
|
|
2249
|
-
"-m",
|
|
2250
|
-
"--timeline",
|
|
2251
|
-
help="Timeline capture from Chrome dev tools. Used to synchronize the video"
|
|
2252
|
-
" start time and only applies when orange frames are removed "
|
|
2253
|
-
"(see --orange). The timeline file can be gzipped if it ends in .gz",
|
|
2254
|
-
)
|
|
2255
|
-
parser.add_argument(
|
|
2256
|
-
"-q",
|
|
2257
|
-
"--quality",
|
|
2258
|
-
type=int,
|
|
2259
|
-
help="JPEG Quality " "(if specified, frames will be converted to JPEG).",
|
|
2260
|
-
)
|
|
2261
|
-
parser.add_argument(
|
|
2262
|
-
"-l",
|
|
2263
|
-
"--full",
|
|
2264
|
-
action="store_true",
|
|
2265
|
-
default=False,
|
|
2266
|
-
help="Keep full-resolution images instead of resizing to 400x400 pixels",
|
|
2267
|
-
)
|
|
2268
|
-
parser.add_argument(
|
|
2269
|
-
"--thumbsize", type=int, default=400, help="Thumbnail size (defaults to 400)."
|
|
2270
|
-
)
|
|
2271
|
-
parser.add_argument(
|
|
2272
|
-
"-f",
|
|
2273
|
-
"--force",
|
|
2274
|
-
action="store_true",
|
|
2275
|
-
default=False,
|
|
2276
|
-
help="Force processing of a video file (overwrite existing directory).",
|
|
2277
|
-
)
|
|
2278
|
-
parser.add_argument(
|
|
2279
|
-
"-o",
|
|
2280
|
-
"--orange",
|
|
2281
|
-
action="store_true",
|
|
2282
|
-
default=False,
|
|
2283
|
-
help="Remove orange-colored frames from the beginning of the video.",
|
|
2284
|
-
)
|
|
2285
|
-
parser.add_argument(
|
|
2286
|
-
"--gray",
|
|
2287
|
-
action="store_true",
|
|
2288
|
-
default=False,
|
|
2289
|
-
help="Remove gray-colored frames from the beginning of the video.",
|
|
2290
|
-
)
|
|
2291
|
-
parser.add_argument(
|
|
2292
|
-
"-w",
|
|
2293
|
-
"--white",
|
|
2294
|
-
action="store_true",
|
|
2295
|
-
default=False,
|
|
2296
|
-
help="Wait for a full white frame after a non-white frame "
|
|
2297
|
-
"at the beginning of the video.",
|
|
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
|
-
)
|
|
2317
|
-
parser.add_argument(
|
|
2318
|
-
"--multiple",
|
|
2319
|
-
action="store_true",
|
|
2320
|
-
default=False,
|
|
2321
|
-
help="Multiple videos are combined, separated by orange frames."
|
|
2322
|
-
"In this mode only the extraction is done and analysis "
|
|
2323
|
-
"needs to be run separately on each directory. Numbered "
|
|
2324
|
-
"directories will be created for each video under the output "
|
|
2325
|
-
"directory.",
|
|
2326
|
-
)
|
|
2327
|
-
parser.add_argument(
|
|
2328
|
-
"-n",
|
|
2329
|
-
"--notification",
|
|
2330
|
-
action="store_true",
|
|
2331
|
-
default=False,
|
|
2332
|
-
help="Trim the notification and home bars from the window.",
|
|
2333
|
-
)
|
|
2334
|
-
parser.add_argument(
|
|
2335
|
-
"-p",
|
|
2336
|
-
"--viewport",
|
|
2337
|
-
action="store_true",
|
|
2338
|
-
default=False,
|
|
2339
|
-
help="Locate and use the viewport from the first video frame.",
|
|
2340
|
-
)
|
|
2341
|
-
parser.add_argument(
|
|
2342
|
-
"-t",
|
|
2343
|
-
"--viewporttime",
|
|
2344
|
-
help="Time of the video frame to use for identifying the viewport "
|
|
2345
|
-
"(in HH:MM:SS.xx format).",
|
|
2346
|
-
)
|
|
2347
|
-
parser.add_argument(
|
|
2348
|
-
"--viewportretries",
|
|
2349
|
-
type=int,
|
|
2350
|
-
default=5,
|
|
2351
|
-
help="Number of times to attempt to obtain a viewport. Analogous to the "
|
|
2352
|
-
"number of frames to try to find a viewport with. By default, up to the "
|
|
2353
|
-
"first 5 frames are used.",
|
|
2354
|
-
)
|
|
2355
|
-
parser.add_argument(
|
|
2356
|
-
"--viewportminheight",
|
|
2357
|
-
type=int,
|
|
2358
|
-
default=0,
|
|
2359
|
-
help="The minimum possible height (in pixels) for the viewport. Used when "
|
|
2360
|
-
"attempting to find the viewport size. Defaults to 0.",
|
|
2361
|
-
)
|
|
2362
|
-
parser.add_argument(
|
|
2363
|
-
"--viewportminwidth",
|
|
2364
|
-
type=int,
|
|
2365
|
-
default=0,
|
|
2366
|
-
help="The minimum possible width (in pixels) for the viewport. Used when "
|
|
2367
|
-
"attempting to find the viewport size. Defaults to 0.",
|
|
2368
|
-
)
|
|
2369
|
-
parser.add_argument(
|
|
2370
|
-
"-s",
|
|
2371
|
-
"--start",
|
|
2372
|
-
type=int,
|
|
2373
|
-
default=0,
|
|
2374
|
-
help="Start time (in milliseconds) for calculating visual metrics.",
|
|
2375
|
-
)
|
|
2376
|
-
parser.add_argument(
|
|
2377
|
-
"-e",
|
|
2378
|
-
"--end",
|
|
2379
|
-
type=int,
|
|
2380
|
-
default=0,
|
|
2381
|
-
help="End time (in milliseconds) for calculating visual metrics.",
|
|
2382
|
-
)
|
|
2383
|
-
parser.add_argument(
|
|
2384
|
-
"--findstart",
|
|
2385
|
-
type=int,
|
|
2386
|
-
default=0,
|
|
2387
|
-
help="Find the start of activity by looking at the top X%% "
|
|
2388
|
-
"of the video (like a browser address bar).",
|
|
2389
|
-
)
|
|
2390
|
-
parser.add_argument(
|
|
2391
|
-
"--renderignore",
|
|
2392
|
-
type=int,
|
|
2393
|
-
default=0,
|
|
2394
|
-
help="Ignore the center X%% of the frame when looking for "
|
|
2395
|
-
"the first rendered frame (useful for Opera mini).",
|
|
2396
|
-
)
|
|
2397
|
-
parser.add_argument(
|
|
2398
|
-
"--startwhite",
|
|
2399
|
-
action="store_true",
|
|
2400
|
-
default=False,
|
|
2401
|
-
help="Find the first fully white frame as the start of the video.",
|
|
2402
|
-
)
|
|
2403
|
-
parser.add_argument(
|
|
2404
|
-
"--endwhite",
|
|
2405
|
-
action="store_true",
|
|
2406
|
-
default=False,
|
|
2407
|
-
help="Find the first fully white frame after render start as the "
|
|
2408
|
-
"end of the video.",
|
|
2409
|
-
)
|
|
2410
|
-
parser.add_argument(
|
|
2411
|
-
"--forceblank",
|
|
2412
|
-
action="store_true",
|
|
2413
|
-
default=False,
|
|
2414
|
-
help="Force the first frame to be blank white.",
|
|
2415
|
-
)
|
|
2416
|
-
parser.add_argument(
|
|
2417
|
-
"--trimend",
|
|
2418
|
-
type=int,
|
|
2419
|
-
default=0,
|
|
2420
|
-
help="Time to trim from the end of the video (in milliseconds).",
|
|
2421
|
-
)
|
|
2422
|
-
parser.add_argument(
|
|
2423
|
-
"--maxframes",
|
|
2424
|
-
type=int,
|
|
2425
|
-
default=0,
|
|
2426
|
-
help="Maximum number of video frames before reducing by "
|
|
2427
|
-
"sampling (to 10fps, 1fps, etc).",
|
|
2428
|
-
)
|
|
2429
|
-
parser.add_argument(
|
|
2430
|
-
"-k",
|
|
2431
|
-
"--perceptual",
|
|
2432
|
-
action="store_true",
|
|
2433
|
-
default=False,
|
|
2434
|
-
help="Calculate perceptual Speed Index",
|
|
2435
|
-
)
|
|
2436
|
-
parser.add_argument(
|
|
2437
|
-
"--contentful",
|
|
2438
|
-
action="store_true",
|
|
2439
|
-
default=False,
|
|
2440
|
-
help="Calculate contentful Speed Index",
|
|
2441
|
-
)
|
|
2442
|
-
parser.add_argument(
|
|
2443
|
-
"-j",
|
|
2444
|
-
"--json",
|
|
2445
|
-
action="store_true",
|
|
2446
|
-
default=False,
|
|
2447
|
-
help="Set output format to JSON",
|
|
2448
|
-
)
|
|
2449
|
-
parser.add_argument("--progress", help="Visual progress output file.")
|
|
2450
|
-
parser.add_argument("--herodata", help="Hero elements data file.")
|
|
2451
|
-
|
|
2452
|
-
options = parser.parse_args()
|
|
2453
|
-
|
|
2454
|
-
if (
|
|
2455
|
-
not options.check
|
|
2456
|
-
and not options.dir
|
|
2457
|
-
and not options.video
|
|
2458
|
-
and not options.histogram
|
|
2459
|
-
):
|
|
2460
|
-
parser.error(
|
|
2461
|
-
"A video, Directory of images or histograms file needs to be provided.\n\n"
|
|
2462
|
-
"Use -h to see available options"
|
|
2463
|
-
)
|
|
2464
|
-
|
|
2465
|
-
if options.perceptual or options.contentful:
|
|
2466
|
-
if not options.video:
|
|
2467
|
-
parser.error(
|
|
2468
|
-
"A video file needs to be provided.\n\n"
|
|
2469
|
-
"Use -h to see available options"
|
|
2470
|
-
)
|
|
2471
|
-
|
|
2472
|
-
temp_dir = tempfile.mkdtemp(prefix="vis-")
|
|
2473
|
-
colors_temp_dir = tempfile.mkdtemp(prefix="vis-color-")
|
|
2474
|
-
directory = temp_dir
|
|
2475
|
-
if options.dir is not None:
|
|
2476
|
-
directory = options.dir
|
|
2477
|
-
if options.histogram is not None:
|
|
2478
|
-
histogram_file = options.histogram
|
|
2479
|
-
else:
|
|
2480
|
-
histogram_file = os.path.join(temp_dir, "histograms.json.gz")
|
|
2481
|
-
|
|
2482
|
-
# Set up logging
|
|
2483
|
-
log_level = logging.CRITICAL
|
|
2484
|
-
if options.verbose == 1:
|
|
2485
|
-
log_level = logging.ERROR
|
|
2486
|
-
elif options.verbose == 2:
|
|
2487
|
-
log_level = logging.WARNING
|
|
2488
|
-
elif options.verbose == 3:
|
|
2489
|
-
log_level = logging.INFO
|
|
2490
|
-
elif options.verbose == 4:
|
|
2491
|
-
log_level = logging.DEBUG
|
|
2492
|
-
if options.logfile is not None:
|
|
2493
|
-
logging.basicConfig(
|
|
2494
|
-
filename=options.logfile,
|
|
2495
|
-
level=log_level,
|
|
2496
|
-
format=options.logformat,
|
|
2497
|
-
datefmt="%H:%M:%S",
|
|
2498
|
-
)
|
|
2499
|
-
else:
|
|
2500
|
-
logging.basicConfig(
|
|
2501
|
-
level=log_level, format=options.logformat, datefmt="%H:%M:%S"
|
|
2502
|
-
)
|
|
2503
|
-
|
|
2504
|
-
if options.multiple:
|
|
2505
|
-
options.orange = True
|
|
2506
|
-
|
|
2507
|
-
if platform.system() == "Windows":
|
|
2508
|
-
paths = [os.getenv("ProgramFiles"), os.getenv("ProgramFiles(x86)")]
|
|
2509
|
-
for path in paths:
|
|
2510
|
-
if path is not None and os.path.isdir(path):
|
|
2511
|
-
dirs = sorted(os.listdir(path), reverse=True)
|
|
2512
|
-
for subdir in dirs:
|
|
2513
|
-
if subdir.lower().startswith("imagemagick"):
|
|
2514
|
-
convert = os.path.join(path, subdir, "convert.exe")
|
|
2515
|
-
compare = os.path.join(path, subdir, "compare.exe")
|
|
2516
|
-
mogrify = os.path.join(path, subdir, "mogrify.exe")
|
|
2517
|
-
if (
|
|
2518
|
-
os.path.isfile(convert)
|
|
2519
|
-
and os.path.isfile(compare)
|
|
2520
|
-
and os.path.isfile(mogrify)
|
|
2521
|
-
):
|
|
2522
|
-
if convert.find(" ") >= 0:
|
|
2523
|
-
convert = '"{0}"'.format(convert)
|
|
2524
|
-
if compare.find(" ") >= 0:
|
|
2525
|
-
compare = '"{0}"'.format(compare)
|
|
2526
|
-
if mogrify.find(" ") >= 0:
|
|
2527
|
-
mogrify = '"{0}"'.format(mogrify)
|
|
2528
|
-
image_magick["convert"] = convert
|
|
2529
|
-
image_magick["compare"] = compare
|
|
2530
|
-
image_magick["mogrify"] = mogrify
|
|
2531
|
-
break
|
|
2532
|
-
|
|
2533
|
-
ok = False
|
|
2534
|
-
try:
|
|
2535
|
-
if not options.check:
|
|
2536
|
-
if options.video:
|
|
2537
|
-
orange_file = None
|
|
2538
|
-
if options.orange:
|
|
2539
|
-
orange_file = os.path.join(
|
|
2540
|
-
os.path.dirname(os.path.realpath(__file__)), "orange.png"
|
|
2541
|
-
)
|
|
2542
|
-
if not os.path.isfile(orange_file):
|
|
2543
|
-
orange_file = os.path.join(colors_temp_dir, "orange.png")
|
|
2544
|
-
generate_orange_png(orange_file)
|
|
2545
|
-
white_file = None
|
|
2546
|
-
if options.white or options.startwhite or options.endwhite:
|
|
2547
|
-
white_file = os.path.join(
|
|
2548
|
-
os.path.dirname(os.path.realpath(__file__)), "white.png"
|
|
2549
|
-
)
|
|
2550
|
-
if not os.path.isfile(white_file):
|
|
2551
|
-
white_file = os.path.join(colors_temp_dir, "white.png")
|
|
2552
|
-
generate_white_png(white_file)
|
|
2553
|
-
gray_file = None
|
|
2554
|
-
if options.gray:
|
|
2555
|
-
gray_file = os.path.join(
|
|
2556
|
-
os.path.dirname(os.path.realpath(__file__)), "gray.png"
|
|
2557
|
-
)
|
|
2558
|
-
if not os.path.isfile(gray_file):
|
|
2559
|
-
gray_file = os.path.join(colors_temp_dir, "gray.png")
|
|
2560
|
-
generate_gray_png(gray_file)
|
|
2561
|
-
video_to_frames(
|
|
2562
|
-
options.video,
|
|
2563
|
-
directory,
|
|
2564
|
-
options.force,
|
|
2565
|
-
orange_file,
|
|
2566
|
-
white_file,
|
|
2567
|
-
gray_file,
|
|
2568
|
-
options.multiple,
|
|
2569
|
-
options.viewport,
|
|
2570
|
-
options.viewporttime,
|
|
2571
|
-
options.viewportretries,
|
|
2572
|
-
options.viewportminheight,
|
|
2573
|
-
options.viewportminwidth,
|
|
2574
|
-
options.full,
|
|
2575
|
-
options.timeline,
|
|
2576
|
-
options.trimend,
|
|
2577
|
-
)
|
|
2578
|
-
if not options.multiple:
|
|
2579
|
-
if options.render is not None:
|
|
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
|
-
}
|
|
2593
|
-
|
|
2594
|
-
# Calculate the histograms and visual metrics
|
|
2595
|
-
calculate_histograms(directory, histogram_file, options.force)
|
|
2596
|
-
metrics = calculate_visual_metrics(
|
|
2597
|
-
histogram_file,
|
|
2598
|
-
options.start,
|
|
2599
|
-
options.end,
|
|
2600
|
-
options.perceptual,
|
|
2601
|
-
options.contentful,
|
|
2602
|
-
directory,
|
|
2603
|
-
options.progress,
|
|
2604
|
-
options.herodata,
|
|
2605
|
-
key_colors,
|
|
2606
|
-
)
|
|
2607
|
-
|
|
2608
|
-
if options.screenshot is not None:
|
|
2609
|
-
quality = 30
|
|
2610
|
-
if options.quality is not None:
|
|
2611
|
-
quality = options.quality
|
|
2612
|
-
save_screenshot(directory, options.screenshot, quality)
|
|
2613
|
-
# JPEG conversion
|
|
2614
|
-
if options.dir is not None and options.quality is not None:
|
|
2615
|
-
convert_to_jpeg(directory, options.quality)
|
|
2616
|
-
|
|
2617
|
-
if metrics is not None:
|
|
2618
|
-
ok = True
|
|
2619
|
-
if options.json:
|
|
2620
|
-
data = dict()
|
|
2621
|
-
for metric in metrics:
|
|
2622
|
-
data[metric["name"].replace(" ", "")] = metric["value"]
|
|
2623
|
-
if "videoRecordingStart" in globals():
|
|
2624
|
-
data["videoRecordingStart"] = videoRecordingStart
|
|
2625
|
-
print(json.dumps(data))
|
|
2626
|
-
else:
|
|
2627
|
-
for metric in metrics:
|
|
2628
|
-
print("{0}: {1}".format(metric["name"], metric["value"]))
|
|
2629
|
-
else:
|
|
2630
|
-
ok = check_config()
|
|
2631
|
-
except Exception as e:
|
|
2632
|
-
logging.exception(e)
|
|
2633
|
-
ok = False
|
|
2634
|
-
|
|
2635
|
-
# Clean up
|
|
2636
|
-
shutil.rmtree(temp_dir)
|
|
2637
|
-
shutil.rmtree(colors_temp_dir)
|
|
2638
|
-
if ok:
|
|
2639
|
-
exit(0)
|
|
2640
|
-
else:
|
|
2641
|
-
exit(1)
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
if "__main__" == __name__:
|
|
2645
|
-
main()
|