diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index c2edae2..db1c9ea 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -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. diff --git a/public/props/ashUrn.png b/public/props/ashUrn.png index 23344d5..54f4b28 100644 Binary files a/public/props/ashUrn.png and b/public/props/ashUrn.png differ diff --git a/public/props/ashtray.png b/public/props/ashtray.png index 6207161..1758d15 100644 Binary files a/public/props/ashtray.png and b/public/props/ashtray.png differ diff --git a/public/props/bainMarie.png b/public/props/bainMarie.png index 047bb85..2d59396 100644 Binary files a/public/props/bainMarie.png and b/public/props/bainMarie.png differ diff --git a/public/props/balustrade.png b/public/props/balustrade.png index 967ea26..00facbc 100644 Binary files a/public/props/balustrade.png and b/public/props/balustrade.png differ diff --git a/public/props/barrier.png b/public/props/barrier.png index 1d54a98..8f98161 100644 Binary files a/public/props/barrier.png and b/public/props/barrier.png differ diff --git a/public/props/beerUmbrella.png b/public/props/beerUmbrella.png index ee7f179..bcdce22 100644 Binary files a/public/props/beerUmbrella.png and b/public/props/beerUmbrella.png differ diff --git a/public/props/binRound.png b/public/props/binRound.png index 70cde68..fe19c2d 100644 Binary files a/public/props/binRound.png and b/public/props/binRound.png differ diff --git a/public/props/bistroTable.png b/public/props/bistroTable.png index 2ea852a..0bb2f55 100644 Binary files a/public/props/bistroTable.png and b/public/props/bistroTable.png differ diff --git a/public/props/brokenBasin.png b/public/props/brokenBasin.png index 7d283d5..e258fb0 100644 Binary files a/public/props/brokenBasin.png and b/public/props/brokenBasin.png differ diff --git a/public/props/canStack.png b/public/props/canStack.png index b1e20ac..c22ff13 100644 Binary files a/public/props/canStack.png and b/public/props/canStack.png differ diff --git a/public/props/champBucket.png b/public/props/champBucket.png index a2b0ee3..d6a2017 100644 Binary files a/public/props/champBucket.png and b/public/props/champBucket.png differ diff --git a/public/props/cocktailBar.png b/public/props/cocktailBar.png index 8606e83..deb0457 100644 Binary files a/public/props/cocktailBar.png and b/public/props/cocktailBar.png differ diff --git a/public/props/distroBox.png b/public/props/distroBox.png index 7352531..c5d273c 100644 Binary files a/public/props/distroBox.png and b/public/props/distroBox.png differ diff --git a/public/props/djPlinth.png b/public/props/djPlinth.png index 565ad6c..7388aaa 100644 Binary files a/public/props/djPlinth.png and b/public/props/djPlinth.png differ diff --git a/public/props/jukebox.png b/public/props/jukebox.png index 954ed81..383c540 100644 Binary files a/public/props/jukebox.png and b/public/props/jukebox.png differ diff --git a/public/props/loungeChair.png b/public/props/loungeChair.png index 3ffa713..c354052 100644 Binary files a/public/props/loungeChair.png and b/public/props/loungeChair.png differ diff --git a/public/props/marbleSink.png b/public/props/marbleSink.png index 20897f0..b61b06f 100644 Binary files a/public/props/marbleSink.png and b/public/props/marbleSink.png differ diff --git a/public/props/oldCouch.png b/public/props/oldCouch.png index 2a91b8c..51891c9 100644 Binary files a/public/props/oldCouch.png and b/public/props/oldCouch.png differ diff --git a/public/props/pallet.png b/public/props/pallet.png index 928955e..82fe35c 100644 Binary files a/public/props/pallet.png and b/public/props/pallet.png differ diff --git a/public/props/picnicTable.png b/public/props/picnicTable.png index 92e1e0a..f42fb9f 100644 Binary files a/public/props/picnicTable.png and b/public/props/picnicTable.png differ diff --git a/public/props/pieWarmer.png b/public/props/pieWarmer.png index c6060ad..9ff6c73 100644 Binary files a/public/props/pieWarmer.png and b/public/props/pieWarmer.png differ diff --git a/public/props/pillar.png b/public/props/pillar.png index c380f08..836127b 100644 Binary files a/public/props/pillar.png and b/public/props/pillar.png differ diff --git a/public/props/planter.png b/public/props/planter.png index f1350ce..caea0a2 100644 Binary files a/public/props/planter.png and b/public/props/planter.png differ diff --git a/public/props/plywoodBar.png b/public/props/plywoodBar.png index a49edb5..6339a9e 100644 Binary files a/public/props/plywoodBar.png and b/public/props/plywoodBar.png differ diff --git a/public/props/pokie.png b/public/props/pokie.png index d2daf55..cc20db6 100644 Binary files a/public/props/pokie.png and b/public/props/pokie.png differ diff --git a/public/props/poster4.png b/public/props/poster4.png index 15cf229..681644d 100644 Binary files a/public/props/poster4.png and b/public/props/poster4.png differ diff --git a/public/props/poster5.png b/public/props/poster5.png index 566ad34..d553205 100644 Binary files a/public/props/poster5.png and b/public/props/poster5.png differ diff --git a/public/props/poster6.png b/public/props/poster6.png index e631e33..c24d7e7 100644 Binary files a/public/props/poster6.png and b/public/props/poster6.png differ diff --git a/public/props/poster7.png b/public/props/poster7.png index 7ccdd4c..3f91ec1 100644 Binary files a/public/props/poster7.png and b/public/props/poster7.png differ diff --git a/public/props/poster8.png b/public/props/poster8.png index 19f7db6..0fcb8c4 100644 Binary files a/public/props/poster8.png and b/public/props/poster8.png differ diff --git a/public/props/poster9.png b/public/props/poster9.png index 139ed47..883b08b 100644 Binary files a/public/props/poster9.png and b/public/props/poster9.png differ diff --git a/public/props/raffleDrum.png b/public/props/raffleDrum.png index 1cfdd1e..c65fbf9 100644 Binary files a/public/props/raffleDrum.png and b/public/props/raffleDrum.png differ diff --git a/public/props/ropePost.png b/public/props/ropePost.png index ad8540c..62fe36d 100644 Binary files a/public/props/ropePost.png and b/public/props/ropePost.png differ diff --git a/public/props/staffChef.png b/public/props/staffChef.png index 1e00d57..b67e880 100644 Binary files a/public/props/staffChef.png and b/public/props/staffChef.png differ diff --git a/public/props/staffCleaner.png b/public/props/staffCleaner.png index 47fa5a3..96c8137 100644 Binary files a/public/props/staffCleaner.png and b/public/props/staffCleaner.png differ diff --git a/public/props/staffGlassie.png b/public/props/staffGlassie.png index 78b0411..9fe94ee 100644 Binary files a/public/props/staffGlassie.png and b/public/props/staffGlassie.png differ diff --git a/public/props/wheelieBin.png b/public/props/wheelieBin.png index ea87b27..bf9ade6 100644 Binary files a/public/props/wheelieBin.png and b/public/props/wheelieBin.png differ diff --git a/tools/gen/assets.py b/tools/gen/assets.py index a16b746..0dce151 100644 --- a/tools/gen/assets.py +++ b/tools/gen/assets.py @@ -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) ], ] diff --git a/tools/gen/cf_flux.py b/tools/gen/cf_flux.py index ca61b63..c6bf22a 100644 --- a/tools/gen/cf_flux.py +++ b/tools/gen/cf_flux.py @@ -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: diff --git a/tools/gen/contact_sheet.py b/tools/gen/contact_sheet.py new file mode 100644 index 0000000..2208a88 --- /dev/null +++ b/tools/gen/contact_sheet.py @@ -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() diff --git a/tools/gen/run_batch.py b/tools/gen/run_batch.py index 6daf234..a06661c 100644 --- a/tools/gen/run_batch.py +++ b/tools/gen/run_batch.py @@ -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, ) diff --git a/tools/glb_to_sprite.py b/tools/glb_to_sprite.py index a5d864d..6792977 100644 --- a/tools/glb_to_sprite.py +++ b/tools/glb_to_sprite.py @@ -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__": diff --git a/tools/props_import.py b/tools/props_import.py index 107a07f..2beac2d 100644 --- a/tools/props_import.py +++ b/tools/props_import.py @@ -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:"):