Thing/tools/parity/render-contact-sheet.py
2026-06-07 05:24:35 +01:00

270 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Render a parity smoke contact sheet from captured runtime screenshots."""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from PIL import Image, ImageDraw, ImageFont, ImageOps
BACKGROUND = "#050b0d"
PANEL = "#0b1f2d"
PANEL_ALT = "#071722"
BORDER = "#1f5a65"
TEXT = "#f3ead2"
MUTED = "#9ab1bc"
ACCENT = "#e6c66d"
PASS = "#77d38b"
FAIL = "#ff6b6b"
RUNTIME_ORDER = [
"Web reference",
"Windows / Flutter",
"Linux / Flutter",
"Android / Flutter",
"iOS / UIKit",
]
COLUMN_ORDER = [
"phone",
"tablet",
"desktop",
"native portrait",
"native landscape",
"device",
]
@dataclass(frozen=True)
class TargetShot:
target_id: str
runtime: str
column: str
status: str
screenshot: Path | None
error: str
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("smoke", type=Path, help="Parity smoke JSON file or smoke output directory.")
parser.add_argument("--out", type=Path, help="Output PNG path. Defaults to contact-sheet.png beside the smoke JSON.")
parser.add_argument("--tile-width", type=int, default=360)
parser.add_argument("--tile-height", type=int, default=240)
args = parser.parse_args()
smoke_path = args.smoke
if smoke_path.is_dir():
smoke_path = smoke_path / "parity-smoke.json"
if not smoke_path.exists():
raise SystemExit(f"Smoke report not found: {smoke_path}")
with smoke_path.open("r", encoding="utf-8-sig") as handle:
report = json.load(handle)
output = args.out or smoke_path.with_name("contact-sheet.png")
output.parent.mkdir(parents=True, exist_ok=True)
shots = [classify_target(target) for target in report.get("targets", [])]
sheet = render_sheet(report, shots, args.tile_width, args.tile_height)
sheet.save(output)
print(output)
return 0
def classify_target(target: dict) -> TargetShot:
target_id = str(target.get("id", "unknown"))
runtime = "Unknown"
column = "device"
if target_id.startswith("web-"):
runtime = "Web reference"
column = target_id.removeprefix("web-")
elif target_id.startswith("windows-"):
runtime = "Windows / Flutter"
column = target_id.removeprefix("windows-")
elif target_id.startswith("linux-"):
runtime = "Linux / Flutter"
column = target_id.removeprefix("linux-")
elif target_id.startswith("android-flutter-native-"):
runtime = "Android / Flutter"
column = f"native {target_id.removeprefix('android-flutter-native-')}"
elif target_id.startswith("android-flutter-"):
runtime = "Android / Flutter"
column = target_id.removeprefix("android-flutter-")
elif target_id == "ios" or target_id.startswith("ios-"):
runtime = "iOS / UIKit"
column = "device"
screenshot = target.get("screenshot")
return TargetShot(
target_id=target_id,
runtime=runtime,
column=column,
status=str(target.get("status", "unknown")),
screenshot=Path(screenshot) if screenshot else None,
error=str(target.get("error", "")),
)
def render_sheet(report: dict, shots: list[TargetShot], tile_w: int, tile_h: int) -> Image.Image:
fonts = load_fonts()
rows = ordered_unique([shot.runtime for shot in shots], RUNTIME_ORDER)
columns = ordered_unique([shot.column for shot in shots], COLUMN_ORDER)
by_cell = {(shot.runtime, shot.column): shot for shot in shots}
margin = 28
gutter = 14
row_label_w = 190
header_h = 110
column_header_h = 42
row_h = tile_h + 66
width = margin * 2 + row_label_w + gutter + len(columns) * tile_w + (len(columns) - 1) * gutter
height = margin * 2 + header_h + column_header_h + len(rows) * row_h + (len(rows) - 1) * gutter
image = Image.new("RGB", (width, height), BACKGROUND)
draw = ImageDraw.Draw(image)
title = f"Eve Parity Reference Contact Sheet - {report.get('providerId', 'unknown')}"
generated = str(report.get("generatedAt", ""))
draw.text((margin, margin), title, fill=TEXT, font=fonts["title"])
draw.text((margin, margin + 44), f"Smoke: {generated}", fill=MUTED, font=fonts["body"])
draw.text((margin, margin + 70), f"Targets: {len(shots)} captured", fill=MUTED, font=fonts["body"])
top = margin + header_h
left = margin + row_label_w + gutter
for index, column in enumerate(columns):
x = left + index * (tile_w + gutter)
draw.text((x, top + 10), column.upper(), fill=ACCENT, font=fonts["label"])
y = top + column_header_h
for row in rows:
draw.rounded_rectangle(
(margin, y, margin + row_label_w, y + row_h),
radius=8,
fill=PANEL_ALT,
outline=BORDER,
width=1,
)
draw.text((margin + 14, y + 18), row, fill=TEXT, font=fonts["label"])
passed = sum(1 for shot in shots if shot.runtime == row and shot.status == "pass")
total = sum(1 for shot in shots if shot.runtime == row)
draw.text((margin + 14, y + 46), f"{passed}/{total} pass", fill=PASS if passed == total else FAIL, font=fonts["body"])
for col_index, column in enumerate(columns):
x = left + col_index * (tile_w + gutter)
shot = by_cell.get((row, column))
draw_cell(image, draw, shot, x, y, tile_w, tile_h, fonts)
y += row_h + gutter
return image
def draw_cell(
sheet: Image.Image,
draw: ImageDraw.ImageDraw,
shot: TargetShot | None,
x: int,
y: int,
tile_w: int,
tile_h: int,
fonts: dict[str, ImageFont.ImageFont],
) -> None:
cell_h = tile_h + 66
draw.rounded_rectangle((x, y, x + tile_w, y + cell_h), radius=8, fill=PANEL, outline=BORDER, width=1)
label_y = y + 10
preview_y = y + 42
if shot is None:
draw.text((x + 14, label_y), "not captured", fill=MUTED, font=fonts["body"])
return
status_color = PASS if shot.status == "pass" else FAIL
draw.text((x + 14, label_y), shot.target_id, fill=TEXT, font=fonts["body_bold"])
draw.text((x + tile_w - 72, label_y), shot.status.upper(), fill=status_color, font=fonts["body_bold"])
preview_box = (x + 10, preview_y, x + tile_w - 10, preview_y + tile_h)
draw.rectangle(preview_box, fill="#02080c", outline="#153642", width=1)
if shot.status != "pass":
draw_wrapped(draw, shot.error or "capture failed", (x + 18, preview_y + 18), tile_w - 36, fonts["body"], FAIL)
return
if shot.screenshot is None or not shot.screenshot.exists():
draw_wrapped(draw, "missing screenshot", (x + 18, preview_y + 18), tile_w - 36, fonts["body"], FAIL)
return
with Image.open(shot.screenshot) as source:
source = ImageOps.exif_transpose(source).convert("RGB")
source.thumbnail((tile_w - 20, tile_h), Image.Resampling.LANCZOS)
px = x + 10 + ((tile_w - 20) - source.width) // 2
py = preview_y + (tile_h - source.height) // 2
sheet.paste(source, (px, py))
def draw_wrapped(
draw: ImageDraw.ImageDraw,
text: str,
xy: tuple[int, int],
max_width: int,
font: ImageFont.ImageFont,
fill: str,
) -> None:
words = text.split()
lines: list[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip()
if draw.textlength(candidate, font=font) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
y = xy[1]
for line in lines[:8]:
draw.text((xy[0], y), line, fill=fill, font=font)
y += 22
def ordered_unique(values: Iterable[str], preferred: list[str]) -> list[str]:
seen = set(values)
ordered = [value for value in preferred if value in seen]
ordered.extend(sorted(value for value in seen if value not in set(preferred)))
return ordered
def load_fonts() -> dict[str, ImageFont.ImageFont]:
candidates = [
Path("C:/Windows/Fonts/segoeui.ttf"),
Path("C:/Windows/Fonts/arial.ttf"),
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
]
bold_candidates = [
Path("C:/Windows/Fonts/segoeuib.ttf"),
Path("C:/Windows/Fonts/arialbd.ttf"),
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
]
regular = next((path for path in candidates if path.exists()), None)
bold = next((path for path in bold_candidates if path.exists()), regular)
if regular is None or bold is None:
default = ImageFont.load_default()
return {"title": default, "label": default, "body_bold": default, "body": default}
return {
"title": ImageFont.truetype(str(bold), 30),
"label": ImageFont.truetype(str(bold), 17),
"body_bold": ImageFont.truetype(str(bold), 14),
"body": ImageFont.truetype(str(regular), 14),
}
if __name__ == "__main__":
raise SystemExit(main())