browsertime 15.3.0 → 16.0.1

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