diff --git a/TODO.md b/TODO.md index 84126e9..e6303cc 100644 --- a/TODO.md +++ b/TODO.md @@ -125,8 +125,12 @@ except the rebuilt assets themselves. navigation obstacles and readable storytelling clusters. - [ ] Camera height: decide 1.7 m gameplay framing vs the ~1.55 m concept frames using `camera-cinematography`, and record the decision instead of leaving the mismatch open. -- [ ] Build the venue three-up boards (real photo | generated concept | current capture) and - attach them to `docs/hollywood-boulevard-map.md` and `docs/joke-shop-map.md`. +- [x] Build the venue three-up boards (real photo | generated concept | current capture): + `tools/three_up.py compose` renders labelled strips into `docs/screens/boards/` (caption + sidecar per board, `rows` emits README rows). Boards committed: El Capitan day, El Capitan + night, Joke Shop day. Tests in `tools/test_three_up.py`. +- [ ] Link each committed board from `docs/hollywood-boulevard-map.md` and + `docs/joke-shop-map.md`. - [ ] Use the generated concept lighting and palette as the target for the `lighting`/`lookdev` pass, and record the deviations that stay accepted. - [ ] After the pass, regenerate the venue captures and update README plus both venue documents. @@ -173,8 +177,10 @@ and never ship a generated or downloaded image without provenance. - [ ] Generate one concept board per venue with `tools/scene_concepts.py generate` (palette, facade rhythm, lighting), keep raw output gitignored, commit only the selected board, and record the model, prompt and spend in the sidecar. -- [ ] Build a three-up sheet per venue (real photo | generated concept | current build capture) and - use it as the reference for the character, prop and venue verdicts. +- [x] Build a three-up sheet per venue (real photo | generated concept | current build capture) and + use it as the reference for the character, prop and venue verdicts. (Done for the venue + exteriors via `docs/screens/boards/`; character/prop verdicts still use the Blender + preview sheets.) - [ ] Apply the screenshot rule to every workstream: each accepted fix updates the relevant row in `README.md` plus the matching docs page (`docs/blender_previews/README.md`, `docs/screenshots/README.md`, `docs/3d-model-review.md` or the venue map doc). diff --git a/docs/screens/boards/README.md b/docs/screens/boards/README.md new file mode 100644 index 0000000..183abc2 --- /dev/null +++ b/docs/screens/boards/README.md @@ -0,0 +1,18 @@ +# Three-up boards: photo | concept | capture + +Each strip lines up the three image sources for one venue state so the evidence +chain stays visible: the licensed real photo is the **evidence**, the generated +frame is the **proposal** (geometry anchored on the photo; see the +[concepts board](../concepts/README.md) and each frame's `.refgen.json`), and +the game capture is the **proof** of the current build. Where the concept and +the photo disagree, the photo wins for shape; the capture shows the gap the +facade-realism work has to close. + +Composed with `tools/three_up.py compose` (labels baked in; the photo panel's +attribution lives in the caption and the [refs sidecars](../refs/README.md)). + +| Board | Caption | +| --- | --- | +| ![three-up board: elcapitan day](elcapitan-day-board.jpg) | El Capitan facade, day. Photo: [CC BY 2.0, woo, 2009](https://commons.wikimedia.org/wiki/File:El_Capitan_Theater_frontal_fachade-The_Masonic_Temple.jpg) via `refs/hollywood`. Concept: photo-anchored generation. Capture: current build. | +| ![three-up board: elcapitan night](elcapitan-night-board.jpg) | El Capitan block, night. Photo: CC BY-SA 3.0 (reviewed; redistribution only, no derived assets) via `refs/hollywood`. Concept: photo-anchored night restage. Capture: current build. | +| ![three-up board: jokeshop day](jokeshop-day-board.jpg) | Joke Shop frontage, day. Photo: public domain (2006) via `refs/joke-shop`. Concept: generated. Capture: current build. | diff --git a/docs/screens/boards/elcapitan-day-board.jpg b/docs/screens/boards/elcapitan-day-board.jpg new file mode 100644 index 0000000..a6bfd19 Binary files /dev/null and b/docs/screens/boards/elcapitan-day-board.jpg differ diff --git a/docs/screens/boards/elcapitan-day-board.json b/docs/screens/boards/elcapitan-day-board.json new file mode 100644 index 0000000..f438643 --- /dev/null +++ b/docs/screens/boards/elcapitan-day-board.json @@ -0,0 +1,7 @@ +{ + "board": "elcapitan-day-board.jpg", + "caption": "El Capitan facade, day: 2009 CC BY photo | photo-anchored concept | current capture", + "photo": "docs/screens/refs/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg", + "concept": "docs/screens/concepts/hollywood_boulevard/hollywood_boulevard_entrance_day-v2.png", + "capture": "docs/screenshots/01-venue-overview.png" +} diff --git a/docs/screens/boards/elcapitan-night-board.jpg b/docs/screens/boards/elcapitan-night-board.jpg new file mode 100644 index 0000000..9367346 Binary files /dev/null and b/docs/screens/boards/elcapitan-night-board.jpg differ diff --git a/docs/screens/boards/elcapitan-night-board.json b/docs/screens/boards/elcapitan-night-board.json new file mode 100644 index 0000000..c15a56f --- /dev/null +++ b/docs/screens/boards/elcapitan-night-board.json @@ -0,0 +1,7 @@ +{ + "board": "elcapitan-night-board.jpg", + "caption": "El Capitan block, night: CC BY-SA photo (redistribution only) | photo-anchored concept | current capture", + "photo": "docs/screens/refs/hollywood/hollywood-boulevard-night-context-ccbysa.jpg", + "concept": "docs/screens/concepts/hollywood_boulevard/el_capitan_facade_night.png", + "capture": "docs/screenshots/05-late-night-avenue.png" +} diff --git a/docs/screens/boards/jokeshop-day-board.jpg b/docs/screens/boards/jokeshop-day-board.jpg new file mode 100644 index 0000000..b8bc1b4 Binary files /dev/null and b/docs/screens/boards/jokeshop-day-board.jpg differ diff --git a/docs/screens/boards/jokeshop-day-board.json b/docs/screens/boards/jokeshop-day-board.json new file mode 100644 index 0000000..f56b97a --- /dev/null +++ b/docs/screens/boards/jokeshop-day-board.json @@ -0,0 +1,7 @@ +{ + "board": "jokeshop-day-board.jpg", + "caption": "Joke Shop, day: licensed photo | generated concept | current capture", + "photo": "docs/screens/refs/joke-shop/comedy-store-frontage-palms-day-pd-2006-12-29.jpg", + "concept": "docs/screens/concepts/joke_shop/joke_shop_entrance_day.png", + "capture": "docs/screenshots/12-joke-shop.png" +} diff --git a/tools/test_three_up.py b/tools/test_three_up.py new file mode 100644 index 0000000..89eb37c --- /dev/null +++ b/tools/test_three_up.py @@ -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() diff --git a/tools/three_up.py b/tools/three_up.py new file mode 100644 index 0000000..6c5f03e --- /dev/null +++ b/tools/three_up.py @@ -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 --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())