#!/usr/bin/env python3 """Compose three-up evidence boards: real photo | generated concept | capture. Each board row puts the three image sources for one venue state side by side so the evidence chain stays visible: the licensed photo is the *evidence*, the generated frame is the *proposal*, the game capture is the *proof*. Boards are written to `docs/screens/boards/` as JPEG strips with column labels; the attribution for the photo panel lives in the folder README (the composite keeps only the labels, so licence text never gets cropped by the art). python3 tools/three_up.py compose \ --photo --concept --capture \ --name [--caption "..."] `boards.json` (see --manifest) drives batch composition; `rows` emits the README table rows for the generated boards. """ import argparse import json import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parent.parent BOARDS_DIR = ROOT / "docs" / "screens" / "boards" PANEL_H = 360 LABEL_H = 34 GUTTER = 8 LABELS = ("Real photo (evidence)", "Generated concept (proposal)", "Game capture (proof)") def _load(path_str: str) -> Image.Image: path = Path(path_str) if not path.is_absolute(): path = ROOT / path if not path.is_file(): sys.exit(f"error: image not found: {path}") with Image.open(path) as img: img.load() return img.convert("RGB") def _label_font(size: int = 18): try: return ImageFont.truetype( "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size) except OSError: try: return ImageFont.truetype("DejaVuSans-Bold", size) except OSError: return ImageFont.load_default() def compose_board(photo: Image.Image, concept: Image.Image, capture: Image.Image) -> Image.Image: """Return one labelled three-panel strip at a shared panel height.""" panels = [] for image in (photo, concept, capture): scale = PANEL_H / image.height panels.append(image.resize((max(1, round(image.width * scale)), PANEL_H), Image.Resampling.LANCZOS)) width = sum(p.width for p in panels) + GUTTER * 4 board = Image.new("RGB", (width, PANEL_H + LABEL_H), (24, 24, 24)) draw = ImageDraw.Draw(board) font = _label_font(16) x = GUTTER for panel, label in zip(panels, LABELS): board.paste(panel, (x, LABEL_H)) draw.text((x + 4, 8), label, fill=(232, 232, 232), font=font) x += panel.width + GUTTER return board def _board_path(name: str) -> Path: if not name or any(c in name for c in "/\\ ") or not name.endswith("-board"): sys.exit("error: board name must be a slug ending in '-board' " "(e.g. 'elcapitan-day-board')") return BOARDS_DIR / f"{name}.jpg" def cmd_compose(args) -> int: board = compose_board(_load(args.photo), _load(args.concept), _load(args.capture)) BOARDS_DIR.mkdir(parents=True, exist_ok=True) out = _board_path(args.name) board.save(out, quality=85) print(f"wrote {out.relative_to(ROOT)} {board.size[0]}x{board.size[1]}") if args.caption: sidecar = out.with_suffix(".json") sidecar.write_text(json.dumps({ "board": out.name, "caption": args.caption, "photo": args.photo, "concept": args.concept, "capture": args.capture}, indent=2) + "\n") print(f"wrote {sidecar.relative_to(ROOT)}") return 0 def cmd_rows(_args) -> int: for board in sorted(BOARDS_DIR.glob("*-board.jpg")): sidecar = board.with_suffix(".json") caption = "" if sidecar.is_file(): caption = json.loads(sidecar.read_text()).get("caption", "") name = board.stem.removesuffix("-board").replace("-", " ") print(f"| ![three-up board: {name}]({board.name}) | {caption} |") return 0 def build_parser(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="cmd", required=True) compose = sub.add_parser("compose", help="compose one three-up board") compose.add_argument("--photo", required=True) compose.add_argument("--concept", required=True) compose.add_argument("--capture", required=True) compose.add_argument("--name", required=True) compose.add_argument("--caption", default="") compose.set_defaults(handler=cmd_compose) sub.add_parser("rows", help="emit README table rows for all boards" ).set_defaults(handler=cmd_rows) return parser def main(argv=None) -> int: args = build_parser().parse_args(argv) return args.handler(args) if __name__ == "__main__": sys.exit(main())