regress.py 30 KB

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