regress.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718
  1. #!/usr/bin/env python3
  2. """Wallpaper regression runs: render every installed scene wallpaper headlessly with a build,
  3. then compare two runs (usually the last good build against the one being tested).
  4. tools/regression/regress.py run /path/to/old/linux-wallpaperengine runs/base --repeat 2
  5. tools/regression/regress.py run build/output/linux-wallpaperengine runs/new
  6. tools/regression/regress.py compare runs/base runs/new --report runs/report
  7. Runs are reproducible: animation time advances by a fixed step per frame (LWE_FIXED_TIMESTEP),
  8. particles are seeded per object, the wall clock is pinned (fixed_clock.c) and audio input is
  9. off. Each worker gets its own Xvfb display, nothing touches the real screen.
  10. --gpu renders on the GPU instead (the engine's headless EGL driver, XDG_SESSION_TYPE=headless),
  11. no Xvfb and many times faster than llvmpipe. GPU and llvmpipe pixels differ slightly, so compare
  12. runs made the same way; the binary needs the headless driver for this.
  13. compare looks at four things per wallpaper: whether it still renders (crash/timeout), error
  14. lines that appear or disappear in the log, how much of the screenshot changed, and whether gif
  15. and video textures still move (wallpapers shipping them get a second render a few frames later,
  16. a hidden or frozen one shows up as less motion). Video textures play in real time and never
  17. render the same twice, --repeat 2 on the base run marks those pixels so compare ignores them.
  18. It writes an HTML report with side-by-sides of everything flagged and exits 1 if anything
  19. regressed. Both builds need LWE_FIXED_TIMESTEP support, older ones render every run differently.
  20. Needs Xvfb, gcc and Pillow (tools/requirements.txt).
  21. """
  22. import argparse
  23. import concurrent.futures
  24. import html
  25. import json
  26. import os
  27. import queue
  28. import re
  29. import shutil
  30. import signal
  31. import subprocess
  32. import sys
  33. import time
  34. from pathlib import Path
  35. HERE = Path (__file__).resolve ().parent
  36. TOOLS = HERE.parent
  37. DEFAULT_WORKSHOP = Path.home () / '.local/share/Steam/steamapps/workshop/content/431960'
  38. DEFAULT_ASSETS = Path.home () / '.local/share/Steam/steamapps/common/wallpaper_engine/assets'
  39. # lines worth tracking between builds; everything else in the log is chatter
  40. ERROR_LINE = re.compile (
  41. r'error|exception|failed|cannot|can\'t|unsupported|not supported|typeerror|referenceerror|'
  42. r'syntaxerror|abort|segmentation|falling back',
  43. re.IGNORECASE
  44. )
  45. # driver/X noise that shows up on some machines and says nothing about the engine
  46. IGNORED_LINE = re.compile (r'amdgpu|_XSERVTrans|MESA-LOADER|libEGL warning|ALSA lib|Puppet draw result')
  47. # mpv announcing a video stream, those textures play in real time and never render the same twice
  48. VIDEO_LINE = re.compile (r'^\W*Video\s+--vid=', re.MULTILINE)
  49. def env_path (name, fallback):
  50. value = os.environ.get (name)
  51. return Path (value) if value else fallback
  52. def read_project (folder):
  53. try:
  54. with open (folder / 'project.json', encoding = 'utf-8-sig') as f:
  55. return json.load (f)
  56. except (OSError, ValueError):
  57. return None
  58. def read_sized (f):
  59. size = int.from_bytes (f.read (4), 'little')
  60. return f.read (size).decode ('utf-8', 'replace')
  61. def texture_kinds (header):
  62. """'gif' for animated (sprite sheet) textures, 'video' for mp4 ones, from a .tex header."""
  63. if header[:8] != b'TEXV0005' or len (header) < 26:
  64. return set ()
  65. flags = int.from_bytes (header[22:26], 'little')
  66. return ({'gif'} if flags & 4 else set ()) | ({'video'} if flags & 32 else set ())
  67. def animated_textures (folder):
  68. """What kind of animated textures a wallpaper ships, loose or inside its .pkg files."""
  69. kinds = set ()
  70. for path in folder.rglob ('*'):
  71. if path.suffix == '.mp4':
  72. kinds.add ('video')
  73. elif path.suffix == '.tex':
  74. with open (path, 'rb') as f:
  75. kinds |= texture_kinds (f.read (64))
  76. elif path.suffix == '.pkg':
  77. try:
  78. with open (path, 'rb') as f:
  79. read_sized (f)
  80. entries = []
  81. for _ in range (int.from_bytes (f.read (4), 'little')):
  82. name = read_sized (f)
  83. offset = int.from_bytes (f.read (4), 'little')
  84. f.read (4)
  85. entries.append ((name, offset))
  86. base = f.tell ()
  87. for name, offset in entries:
  88. if name.endswith ('.mp4'):
  89. kinds.add ('video')
  90. elif name.endswith ('.tex'):
  91. f.seek (base + offset)
  92. kinds |= texture_kinds (f.read (64))
  93. except (OSError, ValueError):
  94. pass
  95. return kinds
  96. def find_wallpapers (workshop, types, ids, variants):
  97. """(name, folder, title, properties) per render; variants add renders of the same wallpaper
  98. with user properties set, named <id>@<variant>."""
  99. found = []
  100. for folder in sorted (workshop.iterdir ()):
  101. if not folder.is_dir () or (ids and folder.name not in ids):
  102. continue
  103. project = read_project (folder)
  104. kind = str ((project or {}).get ('type', '')).lower ()
  105. if kind in types:
  106. title = (project or {}).get ('title', '')
  107. found.append ((folder.name, folder, title, []))
  108. for variant, properties in variants.get (folder.name, {}).items ():
  109. found.append ((f'{folder.name}@{variant}', folder, f'{title} [{variant}]', properties))
  110. return found
  111. def build_shims (out):
  112. shims = out / '.shims'
  113. shims.mkdir (parents = True, exist_ok = True)
  114. built = []
  115. for source in (TOOLS / 'dbus_noop_shim.c', HERE / 'fixed_clock.c'):
  116. target = shims / (source.stem + '.so')
  117. if not target.exists () or source.stat ().st_mtime > target.stat ().st_mtime:
  118. subprocess.run (['gcc', '-shared', '-fPIC', '-O2', '-o', str (target), str (source), '-ldl'], check = True)
  119. built.append (str (target))
  120. return ':'.join (built)
  121. def x_sockets ():
  122. """Display numbers with a listening X socket, abstract ones included (some sandboxes can't
  123. write /tmp/.X11-unix, Xvfb then only has the abstract socket)."""
  124. numbers = set ()
  125. with open ('/proc/net/unix') as f:
  126. for line in f:
  127. match = re.search (r'/tmp/\.X11-unix/X(\d+)$', line.strip ())
  128. if match:
  129. numbers.add (int (match.group (1)))
  130. return numbers
  131. class Display:
  132. """One Xvfb server per worker so renders can run side by side."""
  133. process = None
  134. def __init__ (self, first, width, height):
  135. number = first
  136. while True:
  137. while number in x_sockets () or Path (f'/tmp/.X11-unix/X{number}').exists ():
  138. number += 1
  139. self.name = f':{number}'
  140. self.number = number
  141. self.process = subprocess.Popen (
  142. ['Xvfb', self.name, '-screen', '0', f'{width}x{height}x24', '-nolisten', 'tcp'],
  143. stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL
  144. )
  145. for _ in range (100):
  146. if number in x_sockets ():
  147. return
  148. if self.process.poll () is not None:
  149. break
  150. time.sleep (0.05)
  151. self.close ()
  152. number += 1
  153. if number > first + 50:
  154. raise RuntimeError ('could not start Xvfb')
  155. def close (self):
  156. if self.process is None:
  157. return
  158. self.process.terminate ()
  159. try:
  160. self.process.wait (5)
  161. except subprocess.TimeoutExpired:
  162. self.process.kill ()
  163. def stop (process):
  164. if process.poll () is not None:
  165. return
  166. os.killpg (process.pid, signal.SIGTERM)
  167. try:
  168. process.wait (5)
  169. except subprocess.TimeoutExpired:
  170. os.killpg (process.pid, signal.SIGKILL)
  171. process.wait ()
  172. def render_once (args, env, folder, shot, log_path, properties):
  173. command = [
  174. str (args.binary), '--window', f'0x0x{args.width}x{args.height}', '--fps', '1000',
  175. '--silent', '--no-audio-processing', '--screenshot', str (shot), '--screenshot-delay', str (args.frame),
  176. '--assets-dir', str (args.assets),
  177. ]
  178. for value in properties:
  179. command += ['--set-property', value]
  180. command.append (str (folder))
  181. started = time.monotonic ()
  182. status = 'timeout'
  183. with open (log_path, 'wb') as log:
  184. process = subprocess.Popen (
  185. command, env = env, stdin = subprocess.DEVNULL, stdout = log, stderr = subprocess.STDOUT, start_new_session = True
  186. )
  187. last_size = -1
  188. while time.monotonic () - started < args.timeout:
  189. code = process.poll ()
  190. size = shot.stat ().st_size if shot.exists () else 0
  191. # the engine keeps running after the screenshot, stop it once the file is complete
  192. if size > 0 and size == last_size:
  193. status = 'ok'
  194. break
  195. if code is not None:
  196. status = 'ok' if size > 0 else ('crash' if code < 0 else 'noshot')
  197. break
  198. last_size = size
  199. time.sleep (0.25)
  200. stop (process)
  201. return status, process.returncode, time.monotonic () - started
  202. def render (args, preload, display, wallpaper):
  203. wallpaper_id, folder, title, properties = wallpaper
  204. target = args.out / wallpaper_id
  205. shutil.rmtree (target, ignore_errors = True)
  206. target.mkdir (parents = True)
  207. env = dict (os.environ)
  208. env.pop ('WAYLAND_DISPLAY', None)
  209. env.pop ('WAYLAND_SOCKET', None)
  210. env.pop ('DISPLAY', None)
  211. env.update ({
  212. 'XDG_SESSION_TYPE': 'x11',
  213. 'LD_LIBRARY_PATH': ':'.join (filter (None, [str (args.binary.parent), os.environ.get ('LD_LIBRARY_PATH')])),
  214. 'LD_PRELOAD': preload,
  215. 'LWE_FIXED_TIMESTEP': str (args.step),
  216. 'LWE_FIXED_CLOCK': str (args.clock),
  217. 'TZ': 'UTC',
  218. })
  219. if args.gpu:
  220. env['XDG_SESSION_TYPE'] = 'headless'
  221. if args.gpu != 'auto':
  222. env['LWE_HEADLESS_DEVICE'] = args.gpu
  223. else:
  224. env['DISPLAY'] = display.name
  225. # software rendering (llvmpipe) starts a thread per core in every instance, which just
  226. # thrashes when several renders run at once
  227. env.setdefault ('LP_NUM_THREADS', str (max (1, (os.cpu_count () or 1) // args.jobs)))
  228. status, code, seconds = render_once (args, env, folder, target / 'shot.png', target / 'log.txt', properties)
  229. # extra renders of the same build show what isn't reproducible (video textures play in real
  230. # time), compare ignores those pixels
  231. if status == 'ok':
  232. for repeat in range (2, args.repeat + 1):
  233. render_once (args, env, folder, target / f'shot{repeat}.png', target / f'log{repeat}.txt', properties)
  234. # animated textures have to keep moving: a second, later frame shows whether a gif or video
  235. # is still drawn and still playing (a hidden or frozen one leaves its area static)
  236. animated = sorted (animated_textures (folder))
  237. motion = None
  238. if status == 'ok' and animated and args.motion_gap > 0:
  239. later = target / 'shot_motion.png'
  240. args_later = argparse.Namespace (**{**vars (args), 'frame': args.frame + args.motion_gap})
  241. if render_once (args_later, env, folder, later, target / 'log_motion.txt', properties)[0] == 'ok':
  242. motion = changed_fraction (target / 'shot.png', later, 16)
  243. log = (target / 'log.txt').read_text (errors = 'replace')
  244. result = {
  245. 'id': wallpaper_id,
  246. 'title': title,
  247. 'status': status,
  248. 'exit': code,
  249. 'seconds': round (seconds, 1),
  250. 'video': bool (VIDEO_LINE.search (log)),
  251. 'animated': animated,
  252. 'motion': motion,
  253. }
  254. (target / 'result.json').write_text (json.dumps (result, ensure_ascii = False, indent = 1))
  255. return result
  256. def cmd_run (args):
  257. if args.jobs is None:
  258. cores = os.cpu_count () or 2
  259. args.jobs = max (1, cores // 2 if args.gpu else min (8, cores // 4))
  260. args.binary = args.binary.resolve ()
  261. args.out = args.out.resolve ()
  262. args.out.mkdir (parents = True, exist_ok = True)
  263. variants = {}
  264. if args.variants.exists ():
  265. variants = {k: v for k, v in json.loads (args.variants.read_text ()).items () if not k.startswith ('_')}
  266. wallpapers = find_wallpapers (args.workshop, set (args.types.split (',')), set (args.ids or []), variants)
  267. if not wallpapers:
  268. sys.exit (f'no wallpapers found in {args.workshop}')
  269. preload = build_shims (args.out)
  270. displays = queue.Queue ()
  271. opened = []
  272. try:
  273. for _ in range (args.jobs):
  274. if args.gpu:
  275. displays.put (None)
  276. continue
  277. display = Display (opened[-1].number + 1 if opened else 90, args.width, args.height)
  278. opened.append (display)
  279. displays.put (display)
  280. def job (wallpaper):
  281. display = displays.get ()
  282. try:
  283. return render (args, preload, display, wallpaper)
  284. finally:
  285. displays.put (display)
  286. (args.out / 'run.json').write_text (json.dumps ({
  287. 'binary': str (args.binary),
  288. 'frame': args.frame,
  289. 'step': args.step,
  290. 'clock': args.clock,
  291. 'size': [args.width, args.height],
  292. 'repeat': args.repeat,
  293. 'renderer': 'gpu' if args.gpu else 'llvmpipe',
  294. 'started': time.strftime ('%Y-%m-%d %H:%M:%S'),
  295. }, indent = 1))
  296. done = 0
  297. with concurrent.futures.ThreadPoolExecutor (args.jobs) as pool:
  298. for result in pool.map (job, wallpapers):
  299. done += 1
  300. print (f'[{done}/{len (wallpapers)}] {result["id"]} {result["status"]} {result["seconds"]}s', flush = True)
  301. finally:
  302. for display in opened:
  303. display.close ()
  304. def error_lines (log):
  305. if not log.exists ():
  306. return set ()
  307. lines = set ()
  308. for line in log.read_text (errors = 'replace').splitlines ():
  309. if ERROR_LINE.search (line) and not IGNORED_LINE.search (line):
  310. # numbers, pointers and temp paths change between runs, the message doesn't
  311. line = re.sub (r'0x[0-9a-fA-F]+', 'X', line)
  312. line = re.sub (r'\d+', 'N', line)
  313. lines.add (line.strip ()[:300])
  314. return lines
  315. def strongest_difference (a, b):
  316. from PIL import ImageChops
  317. if a.size != b.size:
  318. b = b.resize (a.size)
  319. # per pixel, the largest channel difference
  320. channels = ImageChops.difference (a, b).split ()
  321. return ImageChops.lighter (ImageChops.lighter (channels[0], channels[1]), channels[2])
  322. def changed_fraction (first, second, threshold):
  323. from PIL import Image
  324. a = Image.open (first).convert ('RGB')
  325. histogram = strongest_difference (a, Image.open (second).convert ('RGB')).histogram ()
  326. return sum (histogram[threshold + 1:]) / (a.size[0] * a.size[1])
  327. def noise_mask (shots, threshold):
  328. """Pixels that already differ between repeated renders of the same build."""
  329. from PIL import Image, ImageChops
  330. images = [Image.open (shot).convert ('RGB') for shot in shots]
  331. mask = None
  332. for other in images[1:]:
  333. changed = strongest_difference (images[0], other).point (lambda v: 255 if v > threshold else 0)
  334. mask = changed if mask is None else ImageChops.lighter (mask, changed)
  335. return mask
  336. def image_diff (base, new, threshold, heatmap, noise):
  337. from PIL import Image, ImageChops
  338. a = Image.open (base).convert ('RGB')
  339. b = Image.open (new).convert ('RGB')
  340. changed_mask = strongest_difference (a, b).point (lambda v: 255 if v > threshold else 0)
  341. total = a.size[0] * a.size[1]
  342. ignored = 0
  343. if noise is not None:
  344. noise = noise.resize (a.size)
  345. ignored = noise.histogram ()[255] / total
  346. changed_mask = ImageChops.subtract (changed_mask, noise)
  347. changed = changed_mask.histogram ()[255] / total
  348. if changed > 0 or ignored > 0:
  349. dim = a.convert ('L').convert ('RGB').point (lambda v: v // 3)
  350. if noise is not None:
  351. dim = Image.composite (Image.new ('RGB', a.size, (60, 60, 110)), dim, noise)
  352. Image.composite (Image.new ('RGB', a.size, (255, 40, 40)), dim, changed_mask).save (heatmap, quality = 85)
  353. return changed, ignored
  354. def thumbnail (source, target, width = 640):
  355. from PIL import Image
  356. image = Image.open (source).convert ('RGB')
  357. image.thumbnail ((width, width))
  358. image.save (target, quality = 85)
  359. def load_result (folder):
  360. try:
  361. return json.loads ((folder / 'result.json').read_text ())
  362. except (OSError, ValueError):
  363. return None
  364. def renderer (folder):
  365. try:
  366. return json.loads ((folder / 'run.json').read_text ()).get ('renderer', 'llvmpipe')
  367. except (OSError, ValueError):
  368. return '?'
  369. def cmd_compare (args):
  370. if renderer (args.base) != renderer (args.new):
  371. print (f'warning: base was rendered with {renderer (args.base)} and new with {renderer (args.new)}, '
  372. 'expect small differences everywhere', file = sys.stderr)
  373. report = args.report.resolve ()
  374. shutil.rmtree (report, ignore_errors = True)
  375. (report / 'img').mkdir (parents = True)
  376. ids = sorted ({p.name for p in args.base.iterdir () if (p / 'result.json').exists ()}
  377. | {p.name for p in args.new.iterdir () if (p / 'result.json').exists ()})
  378. rows = []
  379. for wallpaper_id in ids:
  380. base = load_result (args.base / wallpaper_id)
  381. new = load_result (args.new / wallpaper_id)
  382. row = {
  383. 'id': wallpaper_id,
  384. 'title': (new or base or {}).get ('title', ''),
  385. 'base': base['status'] if base else 'missing',
  386. 'new': new['status'] if new else 'missing',
  387. 'added_errors': [],
  388. 'fixed_errors': [],
  389. 'changed': None,
  390. 'verdict': 'same',
  391. }
  392. if base and new:
  393. base_errors = error_lines (args.base / wallpaper_id / 'log.txt')
  394. new_errors = error_lines (args.new / wallpaper_id / 'log.txt')
  395. row['added_errors'] = sorted (new_errors - base_errors)
  396. row['fixed_errors'] = sorted (base_errors - new_errors)
  397. base_shot = args.base / wallpaper_id / 'shot.png'
  398. new_shot = args.new / wallpaper_id / 'shot.png'
  399. row['video'] = bool ((base or {}).get ('video') or (new or {}).get ('video'))
  400. row['ignored'] = 0
  401. if base and new and base_shot.exists () and new_shot.exists ():
  402. heatmap = report / 'img' / f'{wallpaper_id}_diff.jpg'
  403. repeats = sorted ((args.base / wallpaper_id).glob ('shot[0-9]*.png'))
  404. repeats += sorted ((args.new / wallpaper_id).glob ('shot[0-9]*.png'))
  405. noise = None
  406. if repeats:
  407. noise = noise_mask ([base_shot] + [r for r in repeats if r.parent == base_shot.parent], args.threshold)
  408. new_repeats = [r for r in repeats if r.parent == new_shot.parent]
  409. if new_repeats:
  410. from PIL import ImageChops
  411. extra = noise_mask ([new_shot] + new_repeats, args.threshold)
  412. noise = extra if noise is None else ImageChops.lighter (noise, extra.resize (noise.size))
  413. row['changed'], row['ignored'] = image_diff (base_shot, new_shot, args.threshold, heatmap, noise)
  414. row['animated'] = (new or base or {}).get ('animated', [])
  415. row['motion'] = [(base or {}).get ('motion'), (new or {}).get ('motion')]
  416. base_motion, new_motion = row['motion']
  417. # the second frame moved noticeably less than with the base build
  418. # runs made before the motion check have no "motion" key at all, None means the later render failed
  419. less_motion = ('motion' in (new or {}) and base_motion is not None and base_motion * 100 >= args.motion_min
  420. and (new_motion or 0) < base_motion * 0.5)
  421. if row['new'] == 'missing':
  422. row['verdict'] = 'not run'
  423. elif row['base'] == 'missing':
  424. row['verdict'] = 'new wallpaper'
  425. elif row['new'] != 'ok' and row['base'] == 'ok':
  426. row['verdict'] = 'broken'
  427. elif row['new'] == 'ok' and row['base'] not in ('ok', 'missing'):
  428. row['verdict'] = 'fixed'
  429. elif row['added_errors']:
  430. row['verdict'] = 'new errors'
  431. elif less_motion:
  432. row['verdict'] = 'less motion'
  433. elif row['changed'] is not None and row['changed'] * 100 > (args.video_tolerance if row['video'] else args.tolerance):
  434. row['verdict'] = 'changed'
  435. elif row['fixed_errors']:
  436. row['verdict'] = 'fewer errors'
  437. if row['verdict'] not in ('same', 'not run'):
  438. for side, shot in (('base', base_shot), ('new', new_shot),
  439. ('base_motion', args.base / wallpaper_id / 'shot_motion.png'),
  440. ('new_motion', args.new / wallpaper_id / 'shot_motion.png')):
  441. if shot.exists ():
  442. thumbnail (shot, report / 'img' / f'{wallpaper_id}_{side}.jpg')
  443. rows.append (row)
  444. order = {'broken': 0, 'new errors': 1, 'less motion': 2, 'changed': 3, 'fixed': 4, 'fewer errors': 5,
  445. 'new wallpaper': 6, 'same': 7, 'not run': 8}
  446. rows.sort (key = lambda r: (order[r['verdict']], -(r['changed'] or 0)))
  447. (report / 'summary.json').write_text (json.dumps (rows, ensure_ascii = False, indent = 1))
  448. write_html (report, args, rows)
  449. counts = {}
  450. for row in rows:
  451. counts[row['verdict']] = counts.get (row['verdict'], 0) + 1
  452. print (', '.join (f'{count} {verdict}' for verdict, count in sorted (counts.items (), key = lambda c: order[c[0]])))
  453. for row in rows:
  454. if row['verdict'] not in ('same', 'not run'):
  455. changed = f' {row["changed"] * 100:.2f}% of pixels' if row['changed'] else ''
  456. print (f' {row["verdict"]:12} {row["id"]} {row["title"][:50]}{changed}')
  457. print (f'report: {report / "index.html"}')
  458. regressed = any (r['verdict'] in ('broken', 'new errors', 'less motion', 'changed') for r in rows)
  459. sys.exit (1 if regressed else 0)
  460. def write_html (report, args, rows):
  461. def esc (value):
  462. return html.escape (str (value))
  463. def run_info (folder):
  464. try:
  465. return json.loads ((folder / 'run.json').read_text ())
  466. except (OSError, ValueError):
  467. return {}
  468. base_info = run_info (args.base)
  469. new_info = run_info (args.new)
  470. parts = [f'''<!doctype html><html><head><meta charset="utf-8"><title>Wallpaper regressions</title>
  471. <style>
  472. body {{ font: 14px system-ui, sans-serif; margin: 24px; background: #16161a; color: #ddd; }}
  473. a {{ color: #8ab4f8; }} h2 {{ margin-top: 40px; }}
  474. table {{ border-collapse: collapse; }} td, th {{ padding: 4px 10px; border-bottom: 1px solid #333; text-align: left; }}
  475. .broken, .new-errors {{ color: #ff6b6b; }} .changed {{ color: #ffb86b; }} .fixed, .fewer-errors {{ color: #7ee787; }}
  476. .pair {{ display: flex; gap: 8px; flex-wrap: wrap; }} .pair figure {{ margin: 0; }} .pair img {{ max-width: 420px; display: block; }}
  477. figcaption {{ color: #999; font-size: 12px; }} pre {{ background: #222; padding: 8px; overflow-x: auto; }}
  478. </style></head><body>
  479. <h1>Wallpaper regressions</h1>
  480. <p>base: {esc (args.base)} ({esc (base_info.get ('binary', '?'))}, {esc (base_info.get ('started', ''))})<br>
  481. new: {esc (args.new)} ({esc (new_info.get ('binary', '?'))}, {esc (new_info.get ('started', ''))})<br>
  482. frame {esc (new_info.get ('frame', '?'))} at {esc (new_info.get ('step', '?'))}s per frame, pixel threshold {args.threshold},
  483. changed above {args.tolerance}% of pixels ({args.video_tolerance}% with video textures)</p>
  484. <p>Heatmaps: red is changed, blue is ignored because it already differs between repeated renders of one build
  485. (<code>run --repeat 2</code>), grey is unchanged.</p>
  486. <table><tr><th>verdict</th><th>id</th><th>title</th><th>base</th><th>new</th><th>pixels changed</th><th>ignored</th><th>motion base / new</th></tr>''']
  487. for row in rows:
  488. css = row['verdict'].replace (' ', '-')
  489. changed = f'{row["changed"] * 100:.2f}%' if row['changed'] is not None else '-'
  490. ignored = f'{row["ignored"] * 100:.1f}%' if row['ignored'] else ''
  491. title = esc (row['title']) + (' <small>(video texture)</small>' if row['video'] else '')
  492. detailed = row['verdict'] not in ('same', 'not run')
  493. link = f'<a href="#w{esc (row["id"])}">{esc (row["id"])}</a>' if detailed else esc (row['id'])
  494. motion = ' / '.join ('-' if m is None else f'{m * 100:.2f}%' for m in row['motion']) if row['animated'] else ''
  495. if row['animated']:
  496. title += f' <small>({", ".join (row["animated"])})</small>'
  497. parts.append (f'<tr><td class="{css}">{esc (row["verdict"])}</td><td>{link}</td><td>{title}</td>'
  498. f'<td>{esc (row["base"])}</td><td>{esc (row["new"])}</td><td>{changed}</td><td>{ignored}</td><td>{motion}</td></tr>')
  499. parts.append ('</table>')
  500. for row in rows:
  501. if row['verdict'] in ('same', 'not run'):
  502. continue
  503. parts.append (f'<h2 id="w{esc (row["id"])}" class="{row["verdict"].replace (" ", "-")}">'
  504. f'{esc (row["id"])} - {esc (row["title"])} ({esc (row["verdict"])})</h2><div class="pair">')
  505. for name, caption in (('base', 'base'), ('new', 'new'), ('diff', 'changed pixels'),
  506. ('base_motion', 'base, a few frames later'), ('new_motion', 'new, a few frames later')):
  507. image = report / 'img' / f'{row["id"]}_{name}.jpg'
  508. if image.exists ():
  509. parts.append (f'<figure><img src="img/{image.name}"><figcaption>{caption}</figcaption></figure>')
  510. parts.append ('</div>')
  511. if row['added_errors']:
  512. parts.append ('<p>new in the log:</p><pre>' + esc ('\n'.join (row['added_errors'])) + '</pre>')
  513. if row['fixed_errors']:
  514. parts.append ('<p>gone from the log:</p><pre>' + esc ('\n'.join (row['fixed_errors'])) + '</pre>')
  515. parts.append ('</body></html>')
  516. (report / 'index.html').write_text ('\n'.join (parts))
  517. def main ():
  518. parser = argparse.ArgumentParser (description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter)
  519. commands = parser.add_subparsers (dest = 'command', required = True)
  520. run = commands.add_parser ('run', help = 'render every wallpaper with one build')
  521. run.add_argument ('binary', type = Path, help = 'the linux-wallpaperengine executable to test')
  522. run.add_argument ('out', type = Path, help = 'folder for the screenshots and logs')
  523. run.add_argument ('--workshop', type = Path, default = env_path ('LWE_WORKSHOP_DIR', DEFAULT_WORKSHOP))
  524. run.add_argument ('--assets', type = Path, default = env_path ('LWE_ASSETS_DIR', DEFAULT_ASSETS))
  525. run.add_argument ('--ids', nargs = '*', help = 'only these workshop ids')
  526. run.add_argument ('--variants', type = Path, default = HERE / 'variants.json',
  527. help = 'extra renders with user properties set (default: variants.json next to this script)')
  528. run.add_argument ('--types', default = 'scene', help = 'project types to include, comma separated (default: scene)')
  529. run.add_argument ('--jobs', type = int, help = 'renders at once (default: cores/4 up to 8, cores/2 with --gpu)')
  530. run.add_argument ('--gpu', nargs = '?', const = 'auto', metavar = 'RENDER_NODE',
  531. help = 'render on the GPU without Xvfb, optionally on this /dev/dri/renderD* node')
  532. run.add_argument ('--frame', type = int, default = 30, help = 'frame to screenshot (default: 30)')
  533. run.add_argument ('--step', type = float, default = 0.1, help = 'animation seconds per frame (default: 0.1)')
  534. run.add_argument ('--clock', type = int, default = 1767268800, help = 'pinned wall clock, unix seconds')
  535. run.add_argument ('--width', type = int, default = 1280)
  536. run.add_argument ('--height', type = int, default = 720)
  537. run.add_argument ('--repeat', type = int, default = 1,
  538. help = 'renders per wallpaper; use 2+ for the base run so compare can ignore noise')
  539. run.add_argument ('--motion-gap', type = int, default = 5,
  540. help = 'frames between the two renders of wallpapers with gif/video textures (0 disables)')
  541. run.add_argument ('--timeout', type = float, default = 300, help = 'seconds per wallpaper')
  542. run.set_defaults (func = cmd_run)
  543. compare = commands.add_parser ('compare', help = 'compare two runs and write a report')
  544. compare.add_argument ('base', type = Path)
  545. compare.add_argument ('new', type = Path)
  546. compare.add_argument ('--report', type = Path, default = Path ('regression-report'))
  547. compare.add_argument ('--threshold', type = int, default = 16, help = 'per-channel difference that counts as changed')
  548. compare.add_argument ('--video-tolerance', type = float, default = 5,
  549. help = 'percent of changed pixels allowed with video textures, the motion check covers those')
  550. compare.add_argument ('--motion-min', type = float, default = 0.05,
  551. help = 'percent of pixels that must move in the base run for the motion check to apply')
  552. compare.add_argument ('--tolerance', type = float, default = 0.1, help = 'percent of changed pixels allowed')
  553. compare.set_defaults (func = cmd_compare)
  554. args = parser.parse_args ()
  555. args.func (args)
  556. if __name__ == '__main__':
  557. main ()