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

+10 -4
View File
@@ -125,8 +125,12 @@ except the rebuilt assets themselves.
navigation obstacles and readable storytelling clusters. navigation obstacles and readable storytelling clusters.
- [ ] Camera height: decide 1.7 m gameplay framing vs the ~1.55 m concept frames using - [ ] 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. `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 - [x] Build the venue three-up boards (real photo | generated concept | current capture):
attach them to `docs/hollywood-boulevard-map.md` and `docs/joke-shop-map.md`. `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` - [ ] Use the generated concept lighting and palette as the target for the `lighting`/`lookdev`
pass, and record the deviations that stay accepted. pass, and record the deviations that stay accepted.
- [ ] After the pass, regenerate the venue captures and update README plus both venue documents. - [ ] 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 - [ ] 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 rhythm, lighting), keep raw output gitignored, commit only the selected board, and record the
model, prompt and spend in the sidecar. model, prompt and spend in the sidecar.
- [ ] Build a three-up sheet per venue (real photo | generated concept | current build capture) and - [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. 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 - [ ] 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`, `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). `docs/screenshots/README.md`, `docs/3d-model-review.md` or the venue map doc).
+18
View File
@@ -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. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -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"
}
+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())