135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
#!/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 <path> --concept <path> --capture <path> \
|
|
--name <board-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"|  | {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())
|