browsertime 24.8.2 → 25.0.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.
@@ -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()