|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Copyright (c) 2026 BELNEM s.r.o. html2wp Source-Available Licence — see LICENSE. |
| 3 | +"""The owner's look: every source page beside its WordPress page, on demand. |
| 4 | +
|
| 5 | + visual-compare.py {workspace} [--desktop-only] [--jobs N] [--wp URL] |
| 6 | +
|
| 7 | +A UI's "Compare" button runs this directly in the project container — no AI |
| 8 | +turn. It is compare-pages.py (stage 5.5's side-by-side composites) run for the |
| 9 | +preview WordPress the conversion installed, at 1440 and at 390, with an index |
| 10 | +a UI can list and a status file it can poll: |
| 11 | +
|
| 12 | + {workspace}/visual-review/<key>.side-by-side.png desktop (1440) |
| 13 | + {workspace}/visual-review/mobile/<key>.side-by-side.png mobile (390) |
| 14 | + {workspace}/visual-review/visual-compare.json index: key, title, page, |
| 15 | + route, images, diff %, heights |
| 16 | + {workspace}/visual-review/status.json running | done | failed |
| 17 | +
|
| 18 | +The diff % is read off each composite (the share of pixels that differ, the |
| 19 | +height difference counted as differing): a hint for which page to open first, |
| 20 | +never a gate — the gates are the verdict, and this changes nothing of the |
| 21 | +run: not progress.json, not result.json, not a report. |
| 22 | +
|
| 23 | +The preview is the one test-env.sh started for this workspace (its state file |
| 24 | +names it). Down — a stopped container, a restarted project container — it is |
| 25 | +brought back with `test-env.sh up <slug>`, which reuses the same WordPress and |
| 26 | +its content. With no preview at all (the theme was never installed) there is |
| 27 | +nothing to compare, and the status says so. Visual Edit Lite loads public |
| 28 | +scripts, so it is switched off for the capture and back on after, as every |
| 29 | +gate does. --wp compares against another address (and skips test-env). |
| 30 | +
|
| 31 | +Exit 0 = done; 1 = failed (status.json says why); 2 = usage. A second run while |
| 32 | +one is going is refused (exit 1) rather than raced. |
| 33 | +""" |
| 34 | +import argparse |
| 35 | +import json |
| 36 | +import os |
| 37 | +import shlex |
| 38 | +import subprocess |
| 39 | +import sys |
| 40 | +import time |
| 41 | +import urllib.error |
| 42 | +import urllib.request |
| 43 | +from pathlib import Path |
| 44 | + |
| 45 | +HERE = Path(__file__).resolve().parent |
| 46 | +SCHEMA = "h2wp-visual-compare/1" |
| 47 | +CAPTION, GAP = 44, 24 # compare-pages.py compose(): caption band, gutter |
| 48 | +WIDTHS = (("desktop", 1440, ""), ("mobile", 390, "mobile")) |
| 49 | + |
| 50 | + |
| 51 | +def now(): |
| 52 | + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) |
| 53 | + |
| 54 | + |
| 55 | +def write_json(path, doc): |
| 56 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 57 | + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") |
| 58 | + tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n") |
| 59 | + os.replace(tmp, path) |
| 60 | + |
| 61 | + |
| 62 | +def diff_percent(composite, width, heights): |
| 63 | + """The share of the page that differs between the two halves of a |
| 64 | + compare-pages.py composite (original left, WordPress right, each |
| 65 | + `heights` tall): pixels whose colour moved by more than a little, plus |
| 66 | + the rows one side has and the other does not.""" |
| 67 | + from PIL import Image, ImageChops |
| 68 | + board = Image.open(composite).convert("RGB") |
| 69 | + left_h, right_h = heights |
| 70 | + common, tall = min(left_h, right_h), max(left_h, right_h) or 1 |
| 71 | + if common <= 0: |
| 72 | + return 100.0 |
| 73 | + a = board.crop((0, CAPTION, width, CAPTION + common)) |
| 74 | + b = board.crop((width + GAP, CAPTION, 2 * width + GAP, CAPTION + common)) |
| 75 | + moved = ImageChops.difference(a, b).convert("L").point(lambda v: 255 if v > 24 else 0) |
| 76 | + changed = moved.histogram()[255] + (tall - common) * width |
| 77 | + return round(100.0 * changed / (tall * width), 2) |
| 78 | + |
| 79 | + |
| 80 | +def answers(url): |
| 81 | + try: |
| 82 | + with urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=10) as r: |
| 83 | + return r.status < 500 |
| 84 | + except urllib.error.HTTPError as e: |
| 85 | + return e.code < 500 |
| 86 | + except OSError: |
| 87 | + return False |
| 88 | + |
| 89 | + |
| 90 | +def preview(ws, slug, status): |
| 91 | + """(url, wp-cli prefix) of the workspace's preview, up.""" |
| 92 | + state_path = ws / f".test-env-{slug}.json" |
| 93 | + if not state_path.is_file(): |
| 94 | + raise RuntimeError("no preview WordPress for this project yet: the theme was never installed " |
| 95 | + "(the conversion starts one at stage 3)") |
| 96 | + state = json.loads(state_path.read_text()) |
| 97 | + if not answers(state["url"] + "/"): |
| 98 | + status("bringing the preview WordPress back up") |
| 99 | + up = subprocess.run(["bash", str(HERE / "test-env.sh"), "up", slug], cwd=ws, |
| 100 | + capture_output=True, text=True, timeout=900) |
| 101 | + state = json.loads(state_path.read_text()) |
| 102 | + if up.returncode != 0 or not answers(state["url"] + "/"): |
| 103 | + tail = (up.stdout + up.stderr).strip().splitlines()[-3:] |
| 104 | + raise RuntimeError("the preview WordPress did not come back: " + " | ".join(tail)) |
| 105 | + return state["url"], state.get("wpCli") or "" |
| 106 | + |
| 107 | + |
| 108 | +def lite(wp_cli, action): |
| 109 | + """Visual Edit Lite's state before, and switch it (best effort).""" |
| 110 | + if not wp_cli: |
| 111 | + return None |
| 112 | + base = shlex.split(wp_cli) |
| 113 | + got = subprocess.run(base + ["plugin", "get", "visual-edit-lite", "--field=status"], |
| 114 | + capture_output=True, text=True, timeout=60) |
| 115 | + before = got.stdout.strip() if got.returncode == 0 else None |
| 116 | + if action and before: |
| 117 | + subprocess.run(base + ["plugin", action, "visual-edit-lite"], capture_output=True, text=True, timeout=60) |
| 118 | + return before |
| 119 | + |
| 120 | + |
| 121 | +def main(argv=None): |
| 122 | + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) |
| 123 | + ap.add_argument("workspace") |
| 124 | + ap.add_argument("--desktop-only", action="store_true") |
| 125 | + ap.add_argument("--jobs", type=int, default=2) |
| 126 | + ap.add_argument("--wp", default="", help="compare against this address instead of the test-env preview") |
| 127 | + args = ap.parse_args(argv) |
| 128 | + ws = Path(args.workspace).resolve() |
| 129 | + out = ws / "visual-review" |
| 130 | + status_path = out / "status.json" |
| 131 | + manifest_path = ws / "conversion-manifest.json" |
| 132 | + |
| 133 | + doc = {"schema": SCHEMA + "-status", "state": "running", "startedAt": now(), "updatedAt": now(), |
| 134 | + "note": "", "pid": os.getpid()} |
| 135 | + try: |
| 136 | + old = json.loads(status_path.read_text()) |
| 137 | + if old.get("state") == "running" and old.get("pid") and old["pid"] != os.getpid(): |
| 138 | + os.kill(int(old["pid"]), 0) |
| 139 | + print(f"visual-compare: a comparison is already running (pid {old['pid']})", file=sys.stderr) |
| 140 | + return 1 |
| 141 | + except (OSError, ValueError, ProcessLookupError): |
| 142 | + pass |
| 143 | + |
| 144 | + def status(note, state="running", **extra): |
| 145 | + doc.update(state=state, note=note, updatedAt=now(), **extra) |
| 146 | + write_json(status_path, doc) |
| 147 | + print(f"visual-compare: {note}") |
| 148 | + |
| 149 | + status("starting") |
| 150 | + if not manifest_path.is_file(): |
| 151 | + status("no conversion-manifest.json: nothing converted to compare yet", "failed") |
| 152 | + return 1 |
| 153 | + manifest = json.loads(manifest_path.read_text()) |
| 154 | + slug = (manifest.get("site") or {}).get("slug") or "" |
| 155 | + original = (manifest.get("input") or {}).get("dir") or str(ws / "static-src") |
| 156 | + lite_before = None |
| 157 | + wp_cli = "" |
| 158 | + try: |
| 159 | + wp, wp_cli = (args.wp.rstrip("/"), "") if args.wp else preview(ws, slug, status) |
| 160 | + lite_before = lite(wp_cli, "deactivate") |
| 161 | + index = {"schema": SCHEMA, "capturedAt": None, "preview": wp, "original": original, "pages": {}} |
| 162 | + widths = WIDTHS[:1] if args.desktop_only else WIDTHS |
| 163 | + for name, width, sub in widths: |
| 164 | + status(f"capturing {name} ({width}px)") |
| 165 | + target = out / sub if sub else out |
| 166 | + run = subprocess.run([sys.executable, "-W", "ignore::SyntaxWarning", str(HERE / "compare-pages.py"), |
| 167 | + f"--manifest={manifest_path}", "--wp", wp, "--original", original, |
| 168 | + "--out", str(target), "--width", str(width), "--jobs", str(max(1, args.jobs))], |
| 169 | + capture_output=True, text=True, timeout=3600) |
| 170 | + review = target / "review-manifest.json" |
| 171 | + if run.returncode != 0 or not review.is_file(): |
| 172 | + tail = (run.stdout + run.stderr).strip().splitlines()[-3:] |
| 173 | + raise RuntimeError(f"the {name} capture failed: " + " | ".join(tail)) |
| 174 | + titles = {p.get("file"): p.get("title") for p in manifest.get("pages") or []} |
| 175 | + for pair in json.loads(review.read_text()).get("pairs") or []: |
| 176 | + key = pair.get("key") or Path(pair.get("page", "")).stem |
| 177 | + row = index["pages"].setdefault(key, {"key": key, "page": pair.get("page"), |
| 178 | + "title": titles.get(pair.get("page")), "route": pair.get("wpUrl")}) |
| 179 | + if "composite" in pair: |
| 180 | + image = target / pair["composite"] |
| 181 | + diff = diff_percent(image, width, (pair.get("origHeight") or 0, pair.get("wpHeight") or 0)) |
| 182 | + row[name] = {"image": str(image.relative_to(ws)), "diffPercent": diff, |
| 183 | + "origHeight": pair.get("origHeight"), "wpHeight": pair.get("wpHeight")} |
| 184 | + else: |
| 185 | + row[name] = {"error": pair.get("error") or "not captured"} |
| 186 | + index["capturedAt"] = now() |
| 187 | + index["pages"] = list(index["pages"].values()) |
| 188 | + write_json(out / "visual-compare.json", index) |
| 189 | + made = sum(1 for p in index["pages"] for w in ("desktop", "mobile") if (p.get(w) or {}).get("image")) |
| 190 | + status(f"{made} side-by-side composite(s) of {len(index['pages'])} page(s)", "done", |
| 191 | + index="visual-review/visual-compare.json") |
| 192 | + return 0 |
| 193 | + except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error: |
| 194 | + status(str(error)[:600], "failed") |
| 195 | + return 1 |
| 196 | + finally: |
| 197 | + if lite_before == "active": |
| 198 | + lite(wp_cli, "activate") |
| 199 | + |
| 200 | + |
| 201 | +if __name__ == "__main__": |
| 202 | + sys.exit(main()) |
0 commit comments