from __future__ import annotations import json from pathlib import Path from typing import Any from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[1] DOCS_DIR = ROOT / "docs" MANIFEST_PATH = DOCS_DIR / "unit-sprite-rework-manifest.json" AUDIT_PATH = DOCS_DIR / "unit-sprite-rework-audit.json" OUTPUT_PATH = DOCS_DIR / "unit-sprite-current-approval-board.png" def font(size: int = 14): for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"): try: return ImageFont.truetype(candidate, size) except OSError: continue return ImageFont.load_default() def load_json(path: Path) -> dict[str, Any]: if not path.exists(): raise FileNotFoundError(f"missing required file: {path.relative_to(ROOT)}") return json.loads(path.read_text(encoding="utf-8")) def audit_row(audit: dict[str, Any], stem: str) -> dict[str, Any] | None: return next((row for row in audit.get("rows", []) if row.get("stem") == stem), None) def infer_prefix(candidate: dict[str, Any]) -> str: artifact_prefix = str(candidate.get("artifactPrefix", "")).strip() if artifact_prefix: return artifact_prefix source = Path(candidate.get("source", "")).name version = candidate.get("version") if source and version and source.endswith(f"-source-{version}.png"): return source.removesuffix(f"-source-{version}.png") + f"-{version}" if source and source.endswith(".png"): return source.removesuffix(".png") raise ValueError("cannot infer artifact prefix from currentApprovalCandidate source") def fit_image(path: Path, box: tuple[int, int], title: str) -> Image.Image: width, height = box panel = Image.new("RGBA", (width, height), (24, 30, 27, 255)) draw = ImageDraw.Draw(panel) draw.rectangle((0, 0, width - 1, height - 1), outline=(105, 120, 94, 255), width=1) draw.text((12, 10), title, fill=(240, 226, 178, 255), font=font(14)) if not path.exists(): draw.text((12, 44), f"Missing: {path.name}", fill=(230, 128, 104, 255), font=font(12)) return panel image = Image.open(path).convert("RGBA") image.thumbnail((width - 24, height - 56), Image.Resampling.LANCZOS) x = (width - image.width) // 2 y = 44 + (height - 56 - image.height) // 2 panel.alpha_composite(image, (x, y)) return panel def write_text_block(draw: ImageDraw.ImageDraw, x: int, y: int, lines: list[str], fill=(222, 214, 174, 255)) -> None: small = font(12) for index, line in enumerate(lines): draw.text((x, y + index * 21), line, fill=fill, font=small) def render_board() -> Path: manifest = load_json(MANIFEST_PATH) audit = load_json(AUDIT_PATH) pending = manifest.get("pendingApproval", []) if not pending: raise ValueError("no pendingApproval unit to render") stem = pending[0] candidates = manifest.get("currentApprovalCandidate", {}) candidate = candidates.get(stem) if not candidate: raise ValueError(f"{stem}: missing currentApprovalCandidate entry") prefix = infer_prefix(candidate) row = audit_row(audit, stem) base = row.get("base", {}) if row else {} paths = { "Contact Sheet": DOCS_DIR / f"{prefix}-contact.png", "50px / 72px Scale": DOCS_DIR / f"{prefix}-scale-compare.png", "Debug Battle Preview": DOCS_DIR / f"{prefix}-debug-preview-battle-check.png", "Approved + Pending Board": DOCS_DIR / "unit-sprite-review-approved-and-pending.png", } width = 1280 height = 1320 pad = 24 out = Image.new("RGBA", (width, height), (18, 22, 20, 255)) draw = ImageDraw.Draw(out) title_font = font(24) body_font = font(13) draw.text((pad, 18), f"Current Unit Sprite Approval Board: {stem} {candidate.get('version', '')}", fill=(245, 232, 183, 255), font=title_font) draw.text( (pad, 52), "Generated from the current manifest. This board is for visual approval only; it does not promote, commit, push, or deploy.", fill=(178, 166, 127, 255), font=body_font, ) info_lines = [ f"status: pending-approval", f"source: {Path(candidate.get('source', '')).name}", f"approval packet: {Path(candidate.get('approvalPacket', '')).name}", "art quality gate: unit-sprite-art-quality-gate.md", f"base status: {base.get('status', 'unknown')}", f"battle bbox: {base.get('battle_min_width_px')}..{base.get('battle_max_width_px')} x {base.get('battle_min_height_px')}..{base.get('battle_max_height_px')} px", "release gate: blocked until explicit approval", ] write_text_block(draw, pad, 84, info_lines) top_y = 230 left_w = 888 right_w = width - pad * 3 - left_w out.alpha_composite(fit_image(paths["Contact Sheet"], (left_w, 450), "Contact Sheet"), (pad, top_y)) out.alpha_composite(fit_image(paths["50px / 72px Scale"], (right_w, 216), "50px / 72px Scale"), (pad * 2 + left_w, top_y)) out.alpha_composite(fit_image(paths["Approved + Pending Board"], (right_w, 216), "Approved + Pending"), (pad * 2 + left_w, top_y + 234)) bottom_y = top_y + 474 out.alpha_composite(fit_image(paths["Debug Battle Preview"], (width - pad * 2, 430), "Real Battle Debug Preview"), (pad, bottom_y)) command_y = bottom_y + 446 draw.text((pad, command_y), "After explicit approval:", fill=(240, 226, 178, 255), font=font(15)) commands = [ f"python scripts\\promote-unit-sprite-approval.py --unit {stem} --expected-version {candidate.get('version')}", "python scripts\\audit-unit-sprite-sheets.py", "python scripts\\render-unit-sprite-review-boards.py", "python scripts\\check-unit-sprite-art-quality.py", "python scripts\\check-unit-sprite-approval-gate.py --mode release", ] write_text_block(draw, pad, command_y + 28, commands, fill=(203, 214, 198, 255)) OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) out.convert("RGB").save(OUTPUT_PATH, optimize=True) return OUTPUT_PATH def main() -> None: path = render_board() print(f"Wrote {path}") if __name__ == "__main__": main()