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>
87 lines
3.3 KiB
Python
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}")
|