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>
145 lines
6.2 KiB
Python
145 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""props_import.py — turn raw generated art into game-ready prop sprites.
|
|
|
|
python3 tools/props_import.py <raw-image> <kind> [--no-quantise] [--keep-bg]
|
|
|
|
Pipeline per docs/SPACES.md §4: crop to content, remove near-black/flat
|
|
background to alpha, nearest-neighbour downscale to the kind's target size,
|
|
optional 16-colour quantise (keeps the pixel-art register), write
|
|
public/props/<kind>.png and add <kind> to public/props/manifest.json.
|
|
|
|
`--keep-bg` skips the background-to-alpha pass. Backdrop and texture plates need
|
|
it: they are meant to be fully opaque, and the near-black cut punches holes
|
|
through exactly the parts that make a night street look like night.
|
|
|
|
Kinds and target sizes mirror PROP_SIZES in src/scenes/floor/FloorView.ts —
|
|
unknown kinds are refused so a typo cannot ship an orphan sprite. Sizes for the
|
|
venue expansion come from tools/gen/assets.py, which is the manifest the
|
|
generator itself walks, so the two cannot drift apart.
|
|
"""
|
|
import fcntl
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
TARGETS = {
|
|
"deck": (32, 32), "mixer": (32, 16), "monitor": (16, 16), "boothLamp": (16, 16),
|
|
"speaker": (16, 32), "discoball": (32, 32),
|
|
"barShelf": (496, 16), "taps": (48, 16), "till": (32, 16), "glassStack": (32, 16),
|
|
"glassRack": (32, 16), "boothTable": (32, 16), "sinkRow": (96, 16),
|
|
"buttBin": (16, 16), "crate": (16, 16),
|
|
"poster1": (32, 16), "poster2": (32, 16), "poster3": (32, 16),
|
|
"cloak": (16, 48), "stampPodium": (16, 16), "arcade": (32, 32),
|
|
"highTable": (16, 16), "plant": (16, 16), "wetFloor": (16, 16), "poolTable": (48, 32),
|
|
"dishwasher": (32, 16),
|
|
"urinal": (48, 16), "handDryer": (16, 16), "cubicleDoor": (16, 16), "mopBucket": (16, 16), "cueRack": (16, 16), "barFridge": (32, 32), "iceWell": (32, 16), "patioHeater": (16, 32), "truss": (48, 16),
|
|
# the door-scene backdrop plate is special-cased: full size, no quantise
|
|
"street:backdrop": (640, 176),
|
|
# door-scene red carpet plate (front-on, not a floor prop)
|
|
"carpet": (128, 32),
|
|
}
|
|
|
|
# The venue expansion's kinds live in the generator manifest so one edit feeds
|
|
# both the farm and this importer. Absent (a bare checkout, an older tools dir),
|
|
# the base table above still works exactly as it did.
|
|
try:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent / "gen"))
|
|
import assets as _assets # type: ignore[import-not-found]
|
|
TARGETS.update({a.kind: a.size for a in _assets.ALL})
|
|
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__)
|
|
raw, kind = Path(sys.argv[1]), sys.argv[2]
|
|
quantise = "--no-quantise" not in sys.argv
|
|
keep_bg = "--keep-bg" in sys.argv
|
|
if kind not in TARGETS:
|
|
sys.exit(f"unknown kind '{kind}' — add it to PROP_SIZES + PROPS first, then here")
|
|
|
|
img = Image.open(raw).convert("RGBA")
|
|
if not keep_bg:
|
|
# near-black background -> transparent (generated props sit on dark bg by prompt)
|
|
px = img.load()
|
|
for y in range(img.height):
|
|
for x in range(img.width):
|
|
r, g, b, a = px[x, y]
|
|
if r < 18 and g < 18 and b < 22:
|
|
px[x, y] = (0, 0, 0, 0)
|
|
bbox = img.getbbox()
|
|
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:"):
|
|
alpha = img.getchannel("A")
|
|
img = img.convert("RGB").quantize(16).convert("RGBA")
|
|
img.putalpha(alpha)
|
|
|
|
out_name = kind.replace(":", "_")
|
|
root = Path(__file__).resolve().parent.parent
|
|
out = root / "public" / "props" / f"{out_name}.png"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(out)
|
|
|
|
# The manifest is a read-modify-write, and the batch generator runs several
|
|
# imports at once — unlocked, two finishing together each read the same
|
|
# list, and the second write silently drops the first one's entry. The prop
|
|
# is then on disk but not in the manifest, so the game never loads it and
|
|
# renders a placeholder over perfectly good art. An exclusive lock for the
|
|
# few milliseconds this takes is the whole fix.
|
|
man_path = root / "public" / "props" / "manifest.json"
|
|
lock_path = man_path.with_suffix(".lock")
|
|
with open(lock_path, "w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
try:
|
|
man = json.loads(man_path.read_text()) if man_path.exists() else {"props": []}
|
|
if out_name not in man["props"]:
|
|
man["props"].append(out_name)
|
|
man["props"].sort()
|
|
man_path.write_text(json.dumps(man, indent=2) + "\n")
|
|
finally:
|
|
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
print(f"wrote {out} and updated manifest ({len(man['props'])} generated props live)")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|