A contact sheet, and the luminance ceiling the pipeline never had
127 sprites had been generated and about eight had ever been looked at. The pipeline will happily render the wrong object, confidently and well-lit, and no assertion can tell — barrier and flightCase are both "a grey box with edges" as far as any test goes. tools/gen/contact_sheet.py puts every shipped sprite on one page at integer upscale on the venue's own floor colour. It immediately showed about twenty props reading as pale blobs. First instinct was that the vertex baker had lost the albedo. That was wrong: sat=0 on ashUrn, marbleSink and pillar is honest, because stainless and marble and concrete really are grey. The fault was BRIGHTNESS. Props that read sit at median luminance 23-123 (patioHeater 23, arcade 45, poolTable 53, cdj 68); the blobs were 150-190. The venue is painted at ~35 under a 50% black sheet and props are lit by the room's own LIGHTS, so a prop arriving at 180 doesn't read as a bright object — it reads as self-illuminated. Every prop that worked before worked by accident of subject matter; the first pale subjects exposed the gap. props_import --dim scales RGB until median opaque luminance is <=110. Only ever darkens, so anything already in band passes through untouched. Fixed 48 sprites with no regeneration at all — the 512px Blender renders were still in art_incoming, which is exactly why that directory is kept. Also: poster7 shipped with 29% of its pixels and poster9 with 42%, because the near-black-to-alpha pass was eating the dark half of a dark poster. Posters are rectangular plates on a wall, so 4-9 now keep their background and fill the frame. And cf_flux --probe was reporting EXHAUSTED on an account with a full 10,000 neurons: the probe prompt was "a grey square", Cloudflare's safety filter refused it as NSFW, and every non-429 fell through to SystemExit. A health check that can't tell "refused" from "empty" will eventually call a healthy system dead. Gate: lint clean, build clean, 821 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ -2336,3 +2336,61 @@ is 10:00 AEST — so an evening session in Australia will essentially always fin
|
||||
the allocation gone and run local. That is fine (the farm is free and unlimited),
|
||||
but it means the CF-first tier only ever pays off for a morning batch. Every
|
||||
sprite this session is MODELBEAST-rendered.
|
||||
|
||||
## SESSION — FABLE-SOLO-26d · 2026-07-30 (wave 4: the audit)
|
||||
|
||||
**Gate:** lint ✓ build ✓ test ✓ (821 tests) · **Deployed** + verified.
|
||||
John: "flux creds will be reset right now have a look." They had — 00:10 UTC.
|
||||
|
||||
### `tools/gen/contact_sheet.py` — and why it should have existed on day one
|
||||
127 sprites had been generated and maybe eight had ever been LOOKED at. The
|
||||
pipeline will happily produce a confident, well-lit render of the wrong object
|
||||
and no assertion can tell: `barrier` and `flightCase` are both "a grey box with
|
||||
edges" as far as any test is concerned. One page, every sprite, integer upscale,
|
||||
on the venue's own floor colour. It immediately showed ~20 props reading as pale
|
||||
blobs.
|
||||
|
||||
### THE PIPELINE WAS MISSING A LUMINANCE CEILING (the real find)
|
||||
First instinct was "the vertex baker lost the albedo". Wrong — measured, and
|
||||
`sat=0` on `ashUrn`/`marbleSink`/`pillar` is HONEST: stainless, marble and
|
||||
concrete really are grey. The actual fault was **brightness**:
|
||||
|
||||
| reads well | medLum | reads as a blob | medLum |
|
||||
|---|---|---|---|
|
||||
| patioHeater | 23 | ashUrn | 152 |
|
||||
| taps | 35 | oldCouch | 157 |
|
||||
| arcade | 45 | binRound | 186 |
|
||||
| poolTable | 53 | marbleSink | 186 |
|
||||
| cdj | 68 | djPlinth | 189 |
|
||||
|
||||
The venue is painted at luminance ~35 under a 50% black sheet, and props are lit
|
||||
by the room's own LIGHTS (SPACES.md §1) — so a prop that ARRIVES at 180 does not
|
||||
read as a bright object, it reads as self-illuminated. Every prop that worked
|
||||
worked by accident of subject matter (dark cabinets, dark green felt); the first
|
||||
pale subjects exposed the gap. **`props_import --dim`** now scales RGB until
|
||||
median opaque luminance is ≤110, only ever darkening, so props already in band
|
||||
pass through untouched. `glb_to_sprite` forwards the flag; `run_batch` passes it
|
||||
for every mesh prop.
|
||||
|
||||
**Fixed 48 sprites with zero regeneration** — the 512px Blender renders were all
|
||||
still in `art_incoming/`, which is exactly why that directory is kept.
|
||||
|
||||
### Posters were being eaten by their own alpha cut
|
||||
`poster7` shipped with 29% of its pixels opaque and `poster9` with 42%: the
|
||||
near-black-to-alpha pass was removing the dark half of a dark poster. A poster is
|
||||
a rectangular PLATE on a wall, so poster4–9 are now `keep_bg` and prompted to
|
||||
fill the frame edge to edge. Regenerated on Cloudflare in ~3s each.
|
||||
|
||||
### `cf_flux --probe` was lying
|
||||
It reported EXHAUSTED on an account with a full 10,000 neurons. Cause: the probe
|
||||
prompt was the literal string "a grey square", Cloudflare's safety filter
|
||||
rejected it as NSFW (HTTP 400, code 3030), and every non-429 fell through to
|
||||
SystemExit. The probe now asks only the question it is for — "has the daily
|
||||
allocation run out" — and treats any other API response as budget available.
|
||||
Worth remembering: **a health check that cannot distinguish "refused" from
|
||||
"empty" will eventually report a healthy system as dead.**
|
||||
|
||||
### Cloudflare timing, settled
|
||||
Reset is **00:00 UTC = 10:00 AEST**. An evening session in Australia will always
|
||||
find it spent; a morning one gets ~150 free images first. Today's poster re-rolls
|
||||
were the first CF-generated sprites in the project.
|
||||
|
||||
|
Before Width: | Height: | Size: 609 B After Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 608 B After Width: | Height: | Size: 596 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 990 B |
|
Before Width: | Height: | Size: 628 B After Width: | Height: | Size: 575 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 457 B After Width: | Height: | Size: 422 B |
|
Before Width: | Height: | Size: 417 B After Width: | Height: | Size: 404 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 988 B |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 670 B After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 354 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 589 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1015 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 888 B After Width: | Height: | Size: 771 B |
|
Before Width: | Height: | Size: 948 B After Width: | Height: | Size: 912 B |
|
Before Width: | Height: | Size: 778 B After Width: | Height: | Size: 694 B |
|
Before Width: | Height: | Size: 486 B After Width: | Height: | Size: 479 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1009 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 924 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 701 B After Width: | Height: | Size: 673 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 619 B |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 554 B |
|
Before Width: | Height: | Size: 1007 B After Width: | Height: | Size: 907 B |
|
Before Width: | Height: | Size: 926 B After Width: | Height: | Size: 919 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 632 B After Width: | Height: | Size: 618 B |
@ -327,15 +327,21 @@ DOORSCENE = [
|
||||
# Six more gig posters. Mangled text is CORRECT here and always has been —
|
||||
# docs/SPACES.md keeps "no text" out of the poster prompts on purpose,
|
||||
# because unreadable print is what a real pub poster wall looks like.
|
||||
# keep_bg + "fills the frame": a poster IS a rectangular plate on a wall, and
|
||||
# the importer's near-black-to-alpha pass was eating the dark half of the
|
||||
# darker ones — poster7 shipped with 29% of its pixels and poster9 with 42%.
|
||||
*[
|
||||
Asset(f"poster{i}", "flat", (32, 16), p, 4530 + i, gen=(512, 768), quantise=True)
|
||||
Asset(f"poster{i}", "flat", (32, 16),
|
||||
f"{p}, fills the entire frame edge to edge, flat to camera, "
|
||||
"no border, no background visible, video game texture art",
|
||||
4530 + i, gen=(512, 768), quantise=True, keep_bg=True)
|
||||
for i, p in enumerate([
|
||||
"a grimy 90s Australian pub rock gig poster, portrait, torn edges",
|
||||
"a rave flyer poster, acid green and magenta, portrait, torn edges",
|
||||
"a missing cat poster stapled to a pole, portrait, weathered",
|
||||
"a techno club night poster, stark black and white, portrait",
|
||||
"a meat raffle and poker night notice, portrait, photocopied",
|
||||
"a covers band poster with a bad photo of the band, portrait",
|
||||
"a grimy 90s Australian pub rock gig poster, torn and faded",
|
||||
"a rave flyer poster, acid green and magenta",
|
||||
"a missing cat poster, weathered and rain damaged",
|
||||
"a techno club night poster, stark black and white",
|
||||
"a meat raffle and poker night notice, photocopied",
|
||||
"a covers band poster with a bad photo of the band",
|
||||
], start=4)
|
||||
],
|
||||
]
|
||||
|
||||
@ -92,15 +92,28 @@ def generate(out: Path, prompt: str, steps: int = DEFAULT_STEPS, seed: int = 0,
|
||||
|
||||
|
||||
def probe() -> bool:
|
||||
"""Cheapest possible call — is there budget left right now?"""
|
||||
"""Cheapest possible call — is there budget left right now?
|
||||
|
||||
The question is ONLY "has the daily allocation run out". Anything else the
|
||||
API says means the account is alive and spending is possible, so it counts as
|
||||
budget available — including a rejected prompt. That distinction is not
|
||||
hypothetical: Cloudflare's safety filter rejected the literal prompt "a grey
|
||||
square" as NSFW (HTTP 400, code 3030), and because the old probe let every
|
||||
non-429 fall through to SystemExit, a perfectly healthy account with a full
|
||||
10,000 neurons reported as unusable.
|
||||
"""
|
||||
try:
|
||||
_post("a grey square", 1, 1)
|
||||
_post("a plain wooden chair, product photo", 4, 1)
|
||||
return True
|
||||
except Exhausted:
|
||||
return False
|
||||
except RateLimited:
|
||||
# Burst-limited means the account is alive and has budget.
|
||||
return True
|
||||
except SystemExit as exc:
|
||||
# A 4xx that is not the daily cap: the account works, this prompt did not.
|
||||
print(f"cloudflare reachable, prompt refused: {exc}", file=sys.stderr)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
70
tools/gen/contact_sheet.py
Normal file
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""contact_sheet.py — every shipped sprite on one page, for judging.
|
||||
|
||||
python3 tools/gen/contact_sheet.py [out.png] [--filter substr]
|
||||
|
||||
Built because 127 sprites had been generated and only a handful had ever been
|
||||
LOOKED at. The pipeline is happy to produce a confident, well-lit render of the
|
||||
wrong object, and no test can tell — `barrier` and `flightCase` are both "a grey
|
||||
box with edges" as far as any assertion goes.
|
||||
|
||||
Sprites are shown at an integer upscale on the game's own dark floor colour,
|
||||
which is the honest way to see them: SPACES.md's rule is to judge at ship size
|
||||
rather than at render size, so this deliberately does NOT show the 512px render.
|
||||
The point is to catch sprites that are unrecognisable or wrongly cut out, not to
|
||||
pixel-peep detail that never reaches the screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
PROPS = Path(__file__).resolve().parent.parent.parent / "public" / "props"
|
||||
CELL_W, CELL_H = 108, 124
|
||||
COLS = 12
|
||||
LABEL_H = 14
|
||||
# The venue floor colour from venueMap COLOURS.floor — sprites are judged against
|
||||
# what they actually sit on, not against white.
|
||||
BG = (34, 30, 43)
|
||||
CELL_BG = (24, 21, 30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out = Path(sys.argv[1]) if len(sys.argv) > 1 and not sys.argv[1].startswith("--") \
|
||||
else Path("art_incoming/contact_sheet.png")
|
||||
needle = sys.argv[sys.argv.index("--filter") + 1] if "--filter" in sys.argv else ""
|
||||
|
||||
names = sorted(p.stem for p in PROPS.glob("*.png") if needle in p.stem)
|
||||
if not names:
|
||||
sys.exit("no sprites matched")
|
||||
rows = (len(names) + COLS - 1) // COLS
|
||||
sheet = Image.new("RGB", (COLS * CELL_W, rows * CELL_H), BG)
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
|
||||
for i, name in enumerate(names):
|
||||
cx = (i % COLS) * CELL_W
|
||||
cy = (i // COLS) * CELL_H
|
||||
draw.rectangle([cx + 1, cy + 1, cx + CELL_W - 2, cy + CELL_H - LABEL_H - 2], fill=CELL_BG)
|
||||
|
||||
img = Image.open(PROPS / f"{name}.png").convert("RGBA")
|
||||
room_w, room_h = CELL_W - 8, CELL_H - LABEL_H - 8
|
||||
# Integer upscale only — a fractional resize would invent detail the game
|
||||
# never shows and blur the pixel register these were quantised into.
|
||||
scale = max(1, min(room_w // max(1, img.width), room_h // max(1, img.height)))
|
||||
shown = img.resize((img.width * scale, img.height * scale), Image.NEAREST)
|
||||
px = cx + (CELL_W - shown.width) // 2
|
||||
py = cy + (CELL_H - LABEL_H - shown.height) // 2
|
||||
sheet.paste(shown, (px, py), shown)
|
||||
|
||||
draw.text((cx + 3, cy + CELL_H - LABEL_H), f"{name} {img.width}x{img.height}",
|
||||
fill=(150, 150, 165))
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(out)
|
||||
print(f"{len(names)} sprites -> {out} ({sheet.width}x{sheet.height})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -117,7 +117,7 @@ def do_mesh(a: manifest.Asset) -> None:
|
||||
# Blender ortho render at the house rake, then the normal sprite import.
|
||||
subprocess.run(
|
||||
[sys.executable, str(REPO / "tools" / "glb_to_sprite.py"), str(glb), a.kind,
|
||||
"--rake", str(a.rake), "--px", "512"],
|
||||
"--rake", str(a.rake), "--px", "512", "--dim"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@ -136,7 +136,14 @@ def main() -> None:
|
||||
print(f"rendered {raw}")
|
||||
|
||||
# Straight into the existing sprite pipeline — same as any flux raw.
|
||||
subprocess.run([sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), kind], check=True)
|
||||
# Forward the flags props_import owns. `--dim` matters for every mesh prop:
|
||||
# a Blender render of a stainless bin is honestly pale, and honestly pale is
|
||||
# wrong for a room painted at luminance 35 under a black sheet.
|
||||
passthrough = [f for f in ("--dim", "--no-quantise", "--keep-bg") if f in sys.argv]
|
||||
subprocess.run(
|
||||
[sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), kind, *passthrough],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -52,6 +52,36 @@ except Exception: # pragma: no cover - optional dependency by design
|
||||
pass
|
||||
|
||||
|
||||
# The luminance band the props that READ sit in. Measured off the tileset that
|
||||
# was already working: arcade 45, poolTable 53, speaker 59, mixer 61, cdj 68,
|
||||
# discoball 70. The ones that failed were 150-190 — stainless bins, marble, a
|
||||
# concrete pillar, a white plinth: all objects that really ARE pale, rendered
|
||||
# honestly, and therefore wrong for this game. The venue is painted at luminance
|
||||
# ~35 and buried under a 50% black sheet, so a prop at 180 does not read as a
|
||||
# bright object, it reads as a self-illuminated blob. Props are lit by the room's
|
||||
# own LIGHTS (docs/SPACES.md §1) — they have to arrive dark enough to be lit.
|
||||
LUM_CEILING = 110
|
||||
|
||||
|
||||
def dim_to_room(img: Image.Image) -> Image.Image:
|
||||
"""Scale RGB down until the median opaque luminance sits under the ceiling.
|
||||
|
||||
Only ever darkens, and only when needed, so a prop that already reads (the
|
||||
dark cabinets, the pool table) passes through untouched.
|
||||
"""
|
||||
px = [p for p in img.getdata() if p[3] > 8]
|
||||
if not px:
|
||||
return img
|
||||
lums = sorted((p[0] + p[1] + p[2]) // 3 for p in px)
|
||||
median = lums[len(lums) // 2]
|
||||
if median <= LUM_CEILING:
|
||||
return img
|
||||
factor = LUM_CEILING / median
|
||||
r, g, b, a = img.split()
|
||||
lut = [min(255, int(v * factor)) for v in range(256)]
|
||||
return Image.merge("RGBA", (r.point(lut), g.point(lut), b.point(lut), a))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
@ -74,6 +104,9 @@ def main() -> None:
|
||||
if bbox:
|
||||
img = img.crop(bbox)
|
||||
|
||||
if "--dim" in sys.argv:
|
||||
img = dim_to_room(img)
|
||||
|
||||
w, h = TARGETS[kind]
|
||||
img = img.resize((w, h), Image.NEAREST)
|
||||
if quantise and not kind.startswith("street:"):
|
||||
|
||||