Add parity contact sheet renderer
This commit is contained in:
parent
be84cd539f
commit
9deed263d3
2 changed files with 296 additions and 0 deletions
|
|
@ -56,6 +56,25 @@ function Invoke-Capture {
|
|||
}
|
||||
}
|
||||
|
||||
function Resolve-ContactSheetPython {
|
||||
$candidates = @()
|
||||
if ($env:EVE_PARITY_PYTHON) {
|
||||
$candidates += $env:EVE_PARITY_PYTHON
|
||||
}
|
||||
$candidates += @("python", "python3")
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
try {
|
||||
& $candidate -c "import PIL" *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $candidate
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\run-parity-harness.ps1 -OutputDirectory (Join-Path $runRoot "semantic") | Out-Host
|
||||
|
|
@ -148,6 +167,13 @@ try {
|
|||
}
|
||||
$lines -join "`n" | Set-Content -LiteralPath $mdPath -Encoding UTF8
|
||||
|
||||
$contactSheetPython = Resolve-ContactSheetPython
|
||||
if ($contactSheetPython) {
|
||||
& $contactSheetPython .\tools\parity\render-contact-sheet.py $jsonPath | Out-Host
|
||||
} else {
|
||||
Write-Host "Parity contact sheet skipped: set EVE_PARITY_PYTHON to a Python with Pillow."
|
||||
}
|
||||
|
||||
Write-Host "Parity smoke report: $mdPath"
|
||||
$failed = @($results.targets | Where-Object { $_.status -ne "pass" })
|
||||
if ($failed.Count -gt 0) {
|
||||
|
|
|
|||
270
tools/parity/render-contact-sheet.py
Normal file
270
tools/parity/render-contact-sheet.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
#!/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 / Kotlin",
|
||||
"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-native-"):
|
||||
runtime = "Android / Kotlin"
|
||||
column = f"native {target_id.removeprefix('android-native-')}"
|
||||
elif target_id.startswith("android-"):
|
||||
runtime = "Android / Kotlin"
|
||||
column = target_id.removeprefix("android-")
|
||||
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())
|
||||
Loading…
Reference in a new issue