regress.py 28 KB

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