Add three-up evidence boards for El Capitan and Joke Shop
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-03 15:09:18 +01:00
1 parent ad91e71eeb
commit 343b3348c2
10 files changed
+254 -4

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
"""Tests for the three-up board composer (no network, no fixture images)."""
import json
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
from PIL import Image
import three_up as tu
def _img(w, h, colour=(200, 100, 50)):
return Image.new("RGB", (w, h), colour)
class ComposeTests(unittest.TestCase):
def test_panels_share_the_height_and_labels_are_baked_in(self):
board = tu.compose_board(_img(1600, 800), _img(1280, 720), _img(640, 480))
self.assertEqual(board.height, tu.PANEL_H + tu.LABEL_H)
widths = [p.width for p in
[board.crop((tu.GUTTER, tu.LABEL_H,
tu.GUTTER + 720, board.height))]]
# three panels + four gutters span the full width
expected = (720 + 640 + 480) + tu.GUTTER * 4
self.assertEqual(board.width, expected)
def test_board_is_never_darker_than_the_frame_colour(self):
board = tu.compose_board(_img(100, 100, (0, 0, 0)), _img(100, 100),
_img(100, 100))
# the label strip keeps the frame colour even with black panels
strip = board.crop((0, 0, board.width, tu.LABEL_H))
colours = set(strip.getdata())
self.assertIn((24, 24, 24), colours)
class NamingTests(unittest.TestCase):
def test_board_name_must_be_a_slug_ending_in_board(self):
for bad in ("", "with space-board", "slash/board", "no-suffix", "x.jpg"):
with self.assertRaises(SystemExit, msg=bad):
tu._board_path(bad)
def test_board_path_lands_in_the_boards_dir(self):
self.assertEqual(tu._board_path("elcapitan-day-board"),
tu.BOARDS_DIR / "elcapitan-day-board.jpg")
class MissingInputTests(unittest.TestCase):
def test_compose_exits_on_a_missing_image(self):
with self.assertRaises(SystemExit):
tu._load("definitely/not/here.png")
class RowsTests(unittest.TestCase):
def test_rows_emit_captions_from_the_sidecars(self):
with mock.patch.object(tu, "BOARDS_DIR",
Path(self.enterContext(TemporaryDirectory()))):
board = tu.BOARDS_DIR / "x-board.jpg"
board.write_bytes(b"jpg")
board.with_suffix(".json").write_text(json.dumps(
{"caption": "A venue, day."}))
with mock.patch("builtins.print") as printed:
self.assertEqual(tu.cmd_rows(None), 0)
out = "\n".join(str(c[0][0]) for c in printed.call_args_list)
self.assertIn("![three-up board: x](x-board.jpg)", out)
self.assertIn("A venue, day.", out)
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
#!/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"| ![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())