guts/pipeline/build_manifest.py
jing dade036f69 [lane D] Round 1: pipeline scripts + assets.js loader
Ports PROCITY's pipeline shapes to GUTS (dry-run first, idempotent, resumable):
- gen_textures.py: flux_local wall/matcap pack, ART_BIBLE prompts, provenance JSON
- derive_maps.py: exact-tiling (4-way cosine partition-of-unity) + Sobel normals + WebP
- build_manifest.py / validate_manifest.py: catalogue-what-exists + qa hook
- gen_audio.py: procedural numpy synth, seamless loops, ogg+m4a dual-ship
- js/core/assets.js: manifest loader, null-on-miss, ?localassets=0 empty boot

Generation runs next on the m3ultra box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:45:12 +10:00

87 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""GUTS asset manifest builder (Lane D) — catalogues web/assets/** into manifest.json.
The manifest is a CATALOGUE OF WHAT EXISTS, never a wishlist: it is generated by scanning the
shipped files, so it can't drift from reality and can't promise a texture that isn't there.
That is what makes the runtime law safe (TECH.md): every entry is optional, `assets.get()`
returns null on miss, and the game boots fine with an empty assets/gen/.
python3 pipeline/build_manifest.py # scan -> web/assets/manifest.json
python3 pipeline/build_manifest.py --dry-run # print what it would write
Stdlib only — runs anywhere, including in qa.sh.
"""
import json, os, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(ROOT, "web", "assets")
GEN = os.path.join(ASSETS, "gen")
AUDIO = os.path.join(ASSETS, "audio")
MODELS = os.path.join(ASSETS, "models")
OUT = os.path.join(ASSETS, "manifest.json")
BATCH_TEX = os.path.join(ROOT, "pipeline", "batch_textures.json")
BATCH_AUD = os.path.join(ROOT, "pipeline", "batch_audio.json")
DEFAULT_TILE = [4, 16] # [repeats around theta, units of s per repeat] — see LANE_D_NOTES
def ls(d, ext):
if not os.path.isdir(d):
return []
return sorted(f for f in os.listdir(d) if f.endswith(ext))
def build():
tex_meta = json.load(open(BATCH_TEX)) if os.path.exists(BATCH_TEX) else {}
aud_meta = json.load(open(BATCH_AUD)) if os.path.exists(BATCH_AUD) else {}
m = {"textures": {}, "matcaps": {}, "models": {}, "audio": {"beds": {}, "sfx": {}}}
for f in ls(GEN, ".webp"):
slug = f[:-5]
if slug.endswith("_n"):
continue # normals are attached to their albedo
if slug.startswith("matcap_"):
m["matcaps"][slug[len("matcap_"):]] = {"url": f"gen/{f}"}
continue
e = {"url": f"gen/{f}"}
if os.path.exists(os.path.join(GEN, f"{slug}_n.webp")):
e["normal"] = f"gen/{slug}_n.webp"
e["tile"] = (tex_meta.get(slug) or {}).get("tile") or DEFAULT_TILE
m["textures"][slug] = e
for f in ls(MODELS, ".glb"):
slug = f[:-4]
e = {"url": f"models/{f}"}
tris = (tex_meta.get(slug) or {}).get("tris")
if tris:
e["tris"] = tris
m["models"][slug] = e
for f in ls(AUDIO, ".ogg"):
slug = f[:-4]
meta = aud_meta.get(slug) or {}
cat = "beds" if meta.get("category", "sfx") in ("ambience", "music", "bed") else "sfx"
key = meta.get("key") or slug.split("-", 1)[-1]
e = {"ogg": f"audio/{f}"}
if os.path.exists(os.path.join(AUDIO, f"{slug}.m4a")):
e["m4a"] = f"audio/{slug}.m4a"
for k in ("loop", "gain", "seconds"):
if k in meta:
e[k] = meta[k]
m["audio"][cat][key] = e
return m
if __name__ == "__main__":
m = build()
n = (len(m["textures"]), len(m["matcaps"]), len(m["models"]),
len(m["audio"]["beds"]), len(m["audio"]["sfx"]))
print(f"manifest: {n[0]} textures, {n[1]} matcaps, {n[2]} models, "
f"{n[3]} beds, {n[4]} sfx")
if "--dry-run" in sys.argv:
print(json.dumps(m, indent=2, sort_keys=True))
sys.exit(0)
os.makedirs(ASSETS, exist_ok=True)
json.dump(m, open(OUT, "w"), indent=2, sort_keys=True)
print(f"wrote {OUT}")