[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>
This commit is contained in:
jing 2026-07-16 01:45:12 +10:00
parent 34f84ab162
commit dade036f69
6 changed files with 1195 additions and 0 deletions

View File

@ -0,0 +1,86 @@
#!/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}")

232
pipeline/derive_maps.py Normal file
View File

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""GUTS texture post: tileable -> grayscale -> Sobel normals -> WebP (Lane D).
Pure numpy + PIL. NO model, NO network, NO spend this is arithmetic (PIPELINE.md §Textures.3).
Why post-processing exists: FLUX is *told* "seamless tiling texture" and does not obey its
output has hard edge seams. Two exact fixes are implemented; `--tile-mode` picks:
blend (default) 4-way cosine partition-of-unity of the image's four half-rolls.
w00=sx*sy, w10=cx*sy, w01=sx*cy, w11=cx*cy with sx=sin²(πx/N), cx=1-sx.
Weights sum to 1 everywhere and are N-periodic, so the result tiles
EXACTLY; each copy's seam sits where its own weight is 0, so no seam
survives. Cost: quadrant centres blend all four copies -> softer detail.
mirror 2N mirror-quad downscaled to N. Crisper, but visibly symmetric
("butterfly"). PIPELINE.md sanctions this as the fallback fix.
none passthrough (for already-tiling sources / debugging).
<mflux-py> pipeline/derive_maps.py --contact # contact sheets only, no harvest
<mflux-py> pipeline/derive_maps.py --harvest # .genraw/*.png -> web/assets/gen/*.webp
<mflux-py> pipeline/derive_maps.py --harvest --tile-mode mirror --only esophagus
"""
import json, math, os, sys
import numpy as np
from PIL import Image, ImageFilter
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "pipeline", ".genraw")
GEN = os.path.join(ROOT, "web", "assets", "gen")
SHOTS = os.path.join(ROOT, "docs", "shots", "laneD")
BATCH = os.path.join(ROOT, "pipeline", "batch_textures.json")
os.makedirs(GEN, exist_ok=True)
os.makedirs(SHOTS, exist_ok=True)
WEBP_Q = 82 # walls: lossy is fine, they are luminance/AO detail
WEBP_Q_N = 90 # normals: lossy banding shows in the lighting, so spend a little more
NORMAL_STRENGTH = 2.4
OUT_SIZE = 1024 # house law: WebP <= 1024 (TECH.md)
MATCAP_SIZE = 512
# ── image <-> array ─────────────────────────────────────────────────────────────────────
def load_gray(path):
"""Load as float32 luminance 0..1. Textures are authored GRAYSCALE — the biome tint is
Lane A's shader's job (ART_BIBLE), so colour here would fight the tint."""
im = Image.open(path).convert("L")
return np.asarray(im, dtype=np.float32) / 255.0
def save_gray(a, path, q=WEBP_Q):
im = Image.fromarray(np.clip(a * 255.0, 0, 255).astype(np.uint8), mode="L")
im.save(path, "WEBP", quality=q, method=6)
return os.path.getsize(path)
def save_rgb(a, path, q=WEBP_Q_N):
im = Image.fromarray(np.clip(a * 255.0, 0, 255).astype(np.uint8), mode="RGB")
im.save(path, "WEBP", quality=q, method=6)
return os.path.getsize(path)
def resize(a, n):
im = Image.fromarray(np.clip(a * 255.0, 0, 255).astype(np.uint8), mode="L")
return np.asarray(im.resize((n, n), Image.LANCZOS), dtype=np.float32) / 255.0
# ── tiling ──────────────────────────────────────────────────────────────────────────────
def tile_blend(a):
"""4-way cosine partition-of-unity blend. Exactly tileable (see module docstring)."""
h, w = a.shape
x = np.arange(w, dtype=np.float32)
y = np.arange(h, dtype=np.float32)
sx = np.sin(np.pi * x / w) ** 2 # 0 at x=0 and x=w, 1 at x=w/2
sy = np.sin(np.pi * y / h) ** 2
cx, cy = 1.0 - sx, 1.0 - sy
r_x = np.roll(a, w // 2, axis=1)
r_y = np.roll(a, h // 2, axis=0)
r_xy = np.roll(r_x, h // 2, axis=0)
out = (a * np.outer(sy, sx) + r_x * np.outer(sy, cx)
+ r_y * np.outer(cy, sx) + r_xy * np.outer(cy, cx))
# the 4-way average in the quadrant centres flattens contrast; pull it back to the
# source's spread so the pack stays consistent under the shader's tint.
return match_stats(out, a)
def tile_mirror(a):
"""Mirror quad -> downscale. Exactly tileable, but symmetric."""
top = np.concatenate([a, a[:, ::-1]], axis=1)
full = np.concatenate([top, top[::-1, :]], axis=0)
return resize(full, a.shape[0])
def match_stats(x, ref):
"""Rescale x to ref's mean/std (contrast restore), then clip."""
sx = x.std() or 1.0
return np.clip((x - x.mean()) * (ref.std() / sx) + ref.mean(), 0.0, 1.0)
def seam_error(a):
"""Mean |delta| across the wrap seams vs mean |delta| inside the image. ~1.0 = tiles
perfectly (the seam is as smooth as ordinary interior detail); >>1 = visible seam.
This is the NUMBER that decides tiling not vibes."""
inner_x = np.abs(np.diff(a, axis=1)).mean()
inner_y = np.abs(np.diff(a, axis=0)).mean()
seam_x = np.abs(a[:, 0] - a[:, -1]).mean()
seam_y = np.abs(a[0, :] - a[-1, :]).mean()
return (seam_x / (inner_x or 1e-6) + seam_y / (inner_y or 1e-6)) / 2
# ── normals ─────────────────────────────────────────────────────────────────────────────
def height_of(a):
"""Grayscale -> height via a small blur pyramid: keeps the big folds, drops the
per-pixel SEM speckle that would otherwise become normal-map noise."""
im = Image.fromarray((a * 255).astype(np.uint8), mode="L")
b1 = np.asarray(im.filter(ImageFilter.GaussianBlur(1.0)), dtype=np.float32) / 255.0
b2 = np.asarray(im.filter(ImageFilter.GaussianBlur(4.0)), dtype=np.float32) / 255.0
return 0.65 * b1 + 0.35 * b2
def normal_of(a, strength=NORMAL_STRENGTH):
"""Sobel over a WRAPPED height field -> tangent-space normal map. np.roll wrapping is
what keeps the normal map tileable alongside its albedo."""
h = height_of(a)
def sh(dy, dx):
return np.roll(np.roll(h, dy, axis=0), dx, axis=1)
gx = ((sh(-1, -1) + 2 * sh(0, -1) + sh(1, -1)) - (sh(-1, 1) + 2 * sh(0, 1) + sh(1, 1)))
gy = ((sh(-1, -1) + 2 * sh(-1, 0) + sh(-1, 1)) - (sh(1, -1) + 2 * sh(1, 0) + sh(1, 1)))
nx, ny, nz = -gx * strength, -gy * strength, np.ones_like(gx)
ln = np.sqrt(nx * nx + ny * ny + nz * nz)
return np.stack([(nx / ln) * 0.5 + 0.5, (ny / ln) * 0.5 + 0.5, (nz / ln) * 0.5 + 0.5], axis=-1)
# ── matcap ──────────────────────────────────────────────────────────────────────────────
def crop_matcap(a):
"""Find the lit ball on the black studio background and crop it square. A matcap must be
the sphere touching all four edges or the lighting lookup is wrong."""
m = a > (a.max() * 0.10)
ys, xs = np.where(m)
if len(xs) < 100:
return resize(a, MATCAP_SIZE)
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
r = max(x1 - x0, y1 - y0) / 2
h, w = a.shape
x0 = int(max(0, cx - r)); x1 = int(min(w, cx + r))
y0 = int(max(0, cy - r)); y1 = int(min(h, cy + r))
return resize(a[y0:y1, x0:x1], MATCAP_SIZE)
# ── contact sheets (the eyeball law) ────────────────────────────────────────────────────
def contact_sheet(entries, path, cell=256):
"""One row per texture: [source] [tiled 2x2] [normal]. The 2x2 is the tile check — a seam
shows up instantly as a cross through the middle."""
if not entries:
return None
rows = len(entries)
sheet = Image.new("RGB", (cell * 3 + 40, rows * cell + 20), (10, 12, 14))
for i, (name, src, tiled, nrm) in enumerate(entries):
y = 10 + i * cell
def put(a, x, mode="L"):
im = Image.fromarray(np.clip(a * 255, 0, 255).astype(np.uint8), mode=mode)
sheet.paste(im.resize((cell, cell), Image.LANCZOS).convert("RGB"), (x, y))
put(src, 10)
two = np.concatenate([np.concatenate([tiled, tiled], axis=1)] * 2, axis=0)
put(two, 20 + cell)
put(nrm, 30 + cell * 2, mode="RGB")
sheet.save(path, "PNG")
return path
# ── harvest ─────────────────────────────────────────────────────────────────────────────
def harvest(only="", tile_mode="blend", contact_only=False):
batch = json.load(open(BATCH)) if os.path.exists(BATCH) else {}
srcs = sorted(f for f in os.listdir(RAW) if f.endswith(".png"))
if only:
srcs = [f for f in srcs if only in f]
if not srcs:
print(f"nothing in {RAW} matching *{only}* — run --local first"); return
entries, results, wall_rows, cap_rows = [], {}, [], []
for f in srcs:
slug = os.path.splitext(f)[0]
a = load_gray(os.path.join(RAW, f))
is_cap = batch.get(slug, {}).get("kind") == "matcap" or slug.startswith("matcap")
if is_cap:
out = crop_matcap(a)
kb_a = save_gray(out, os.path.join(GEN, f"{slug}.webp")) // 1024 if not contact_only else 0
nrm = np.stack([out] * 3, axis=-1) # sheet placeholder: matcaps get no normal
cap_rows.append((slug, a, out, nrm))
results[slug] = dict(kind="matcap", seam=None, kb=kb_a, size=MATCAP_SIZE)
continue
before = seam_error(a)
tiled = {"blend": tile_blend, "mirror": tile_mirror, "none": lambda z: z}[tile_mode](a)
if tiled.shape[0] != OUT_SIZE:
tiled = resize(tiled, OUT_SIZE)
after = seam_error(tiled)
nrm = normal_of(tiled)
kb_a = kb_n = 0
if not contact_only:
kb_a = save_gray(tiled, os.path.join(GEN, f"{slug}.webp")) // 1024
kb_n = save_rgb(nrm, os.path.join(GEN, f"{slug}_n.webp")) // 1024
wall_rows.append((slug, a, tiled, nrm))
results[slug] = dict(kind="wall", seam_before=round(float(before), 2),
seam_after=round(float(after), 2), kb=kb_a, kb_n=kb_n,
size=OUT_SIZE, tile_mode=tile_mode)
print(f" {slug:22s} seam {before:5.2f} -> {after:4.2f} "
f"{kb_a:>4}KB +{kb_n:>4}KB normal [{tile_mode}]")
for slug, r in results.items():
if slug in batch:
batch[slug].update({k: v for k, v in r.items() if k != "kind"})
if batch:
json.dump(batch, open(BATCH, "w"), indent=2, sort_keys=True)
if wall_rows:
p = contact_sheet(wall_rows, os.path.join(SHOTS, "contact_walls.png"))
print(f" contact sheet -> {p}")
if cap_rows:
p = contact_sheet(cap_rows, os.path.join(SHOTS, "contact_matcaps.png"))
print(f" contact sheet -> {p}")
if not contact_only:
tot = sum(r.get("kb", 0) + r.get("kb_n", 0) for r in results.values())
print(f"harvested {len(results)} -> {GEN} ({tot/1024:.2f}MB) "
f"(now run build_manifest.py)")
return results
if __name__ == "__main__":
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
mode = sys.argv[sys.argv.index("--tile-mode") + 1] if "--tile-mode" in sys.argv else "blend"
harvest(only=only, tile_mode=mode, contact_only="--contact" in sys.argv)

430
pipeline/gen_audio.py Normal file
View File

@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""GUTS audio pack — 100% procedural synthesis in numpy, on-device, $0 (Lane D).
Ported from PROCITY's gen_audio.py (round-11 audio round) — same DSP toolkit, same reasons:
* $0, no model download, runs in the mflux venv (numpy only).
* Deterministic + seeded same name, same sound, forever.
* Perfect SEAMLESS LOOPS (the tail is crossfaded over the head) neural gens can't loop.
* Parody-safe by construction: every sample is an original oscillator/noise render.
* Precise budget control (<=10 MB shipped, TECH.md).
Pipeline: synth (numpy float32) -> WAV (stdlib wave, int16) -> ffmpeg -> OGG/Opus + M4A/AAC.
Loudness: beds -> ffmpeg loudnorm (EBU R128, -16 LUFS); sfx -> peak-normalized in-synth
(loudnorm is unreliable under ~3 s).
Evidence: --evidence writes a spectrogram sheet + a NUMERIC loop-seam check to
docs/shots/laneD/ (PROCESS.md: audio evidence = spectrogram + loop-seam check).
<mflux-py> pipeline/gen_audio.py --dry-run # list the pack + budget, no render
<mflux-py> pipeline/gen_audio.py # render -> web/assets/audio/
<mflux-py> pipeline/gen_audio.py --only sfx # one category
<mflux-py> pipeline/gen_audio.py --wav-only # keep .genaudio/*.wav, skip ffmpeg (debug)
<mflux-py> = ~/Documents/MODELBEAST/venvs/mflux/bin/python
House law: the game runs silent-and-happy with zero assets none of this blocks anyone.
"""
import json, math, os, shutil, subprocess, sys, wave, zlib
import numpy as np
SR = 44100
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "pipeline", ".genaudio") # WAV scratch (git-ignored)
OUT = os.path.join(ROOT, "web", "assets", "audio") # shipped OGG/M4A
SHOTS = os.path.join(ROOT, "docs", "shots", "laneD")
BATCH = os.path.join(ROOT, "pipeline", "batch_audio.json")
for d in (RAW, OUT, SHOTS):
os.makedirs(d, exist_ok=True)
# m3ultra's ffmpeg is homebrew and NOT on the non-interactive ssh PATH — find it properly.
FF = (os.environ.get("GUTS_FFMPEG") or shutil.which("ffmpeg")
or ("/opt/homebrew/bin/ffmpeg" if os.path.exists("/opt/homebrew/bin/ffmpeg") else "ffmpeg"))
XF = 0.75 # loop crossfade seconds
# ─────────────────────────────────────────────────────────────────────────────
# DSP toolkit (pure numpy) — ported from PROCITY pipeline/gen_audio.py
# ─────────────────────────────────────────────────────────────────────────────
def seed_of(name):
return zlib.crc32(name.encode()) & 0xFFFFFFFF
def rng(name):
return np.random.default_rng(seed_of(name))
def tarr(dur):
return np.arange(int(dur * SR)) / SR
def _phase(f, dur):
n = int(dur * SR)
f = np.full(n, f, dtype=np.float64) if np.isscalar(f) else np.resize(f, n)
return np.cumsum(2 * np.pi * f / SR)
def osc(f, dur, kind="sine"):
ph = _phase(f, dur)
if kind == "sine":
return np.sin(ph)
if kind == "tri":
return 2 / np.pi * np.arcsin(np.sin(ph))
if kind == "saw":
return 2 * ((ph / (2 * np.pi)) % 1.0) - 1
if kind == "square":
return np.sign(np.sin(ph))
raise ValueError(kind)
def noise(dur, r=None, kind="white"):
n = int(dur * SR)
r = r if r is not None else np.random.default_rng(0)
x = r.standard_normal(n)
if kind == "pink": # ~-3 dB/oct, vectorised one-pole cascade
out = np.zeros(n)
for a, g in ((0.99765, 0.0990460), (0.96300, 0.2965164), (0.57000, 1.0526913)):
out += lp_pole(x * g, a)
return (out + x * 0.1848) / 4
return x
def lp_pole(x, a):
"""y[n] = a*y[n-1] + x[n] — via lfilter-free cumulative trick (scipy is not installed)."""
y = np.empty_like(x)
acc = 0.0
for i in range(len(x)): # honest loop; pack is small, runs in ~1 s
acc = a * acc + x[i]
y[i] = acc
return y
def lp1(x, cutoff):
a = math.exp(-2 * math.pi * cutoff / SR)
return lp_pole(x * (1 - a), a)
def hp1(x, cutoff):
return x - lp1(x, cutoff)
def bandpass(x, lo, hi):
return hp1(lp1(x, hi), lo)
def adsr(dur, a=0.01, d=0.1, s=0.7, r=0.1):
n = int(dur * SR)
ai, di = int(a * SR), int(d * SR)
ri = min(int(r * SR), max(n - ai - di, 0))
si = max(n - ai - di - ri, 0)
return np.concatenate([
np.linspace(0, 1, ai), np.linspace(1, s, di),
np.full(si, s), np.linspace(s, 0, ri)])[:n]
def fade(x, fin=0.01, fout=0.01):
n = len(x); ai = int(fin * SR); ao = int(fout * SR)
if ai: x[:ai] *= np.linspace(0, 1, ai)
if ao: x[-ao:] *= np.linspace(1, 0, ao)
return x
def peaknorm(x, target_db=-3.0):
p = np.max(np.abs(x)) or 1.0
return x * (10 ** (target_db / 20) / p)
def softclip(x, drive=1.0):
return np.tanh(x * drive)
def seamless(x, xf=XF):
"""Loop of length len(x)-xf*SR that plays end->start seamlessly, by crossfading the tail
(xf s) over the head. Render xf LONGER than the loop you want."""
k = int(xf * SR)
if k <= 0 or len(x) <= k:
return x
body, tail = x[:-k].copy(), x[-k:]
w = np.linspace(0, 1, k)
if body.ndim == 2:
w = w[:, None]
body[:k] = body[:k] * w + tail * (1 - w)
return body
def stereo(l, r=None):
r = l if r is None else r
n = max(len(l), len(r))
out = np.zeros((n, 2))
out[:len(l), 0] = l; out[:len(r), 1] = r
return out
def pan(x, p):
p = (p + 1) / 2
return stereo(x * math.cos(p * math.pi / 2), x * math.sin(p * math.pi / 2))
def mix(*layers):
n = max(len(a) for a in layers)
ch = 2 if any(a.ndim == 2 for a in layers) else 1
out = np.zeros((n, 2) if ch == 2 else n)
for a in layers:
if ch == 2 and a.ndim == 1:
a = stereo(a)
out[:len(a)] += a
return out
def schroeder_reverb(x, mix_amt=0.2, decay=0.5):
"""4 combs + 2 allpasses. The gut is a wet cave; everything gets some of this."""
mono = x.mean(axis=1) if x.ndim == 2 else x
out = np.zeros(len(mono))
for ms, g in ((29.7, 0.805), (37.1, 0.827), (41.1, 0.783), (43.7, 0.764)):
d = int(ms * SR / 1000)
buf = np.zeros(len(mono) + d)
buf[:len(mono)] = mono
for i in range(d, len(buf)):
buf[i] += buf[i - d] * g * decay
out += buf[:len(mono)]
out /= 4
for ms, g in ((5.0, 0.7), (1.7, 0.7)):
d = int(ms * SR / 1000)
y = np.zeros(len(out) + d)
y[:len(out)] = out
for i in range(d, len(y)):
y[i] += y[i - d] * -g
out = y[:len(out)]
wet = out / (np.max(np.abs(out)) or 1)
if x.ndim == 2:
return x * (1 - mix_amt) + stereo(wet) * mix_amt
return x * (1 - mix_amt) + wet * mix_amt
# ─────────────────────────────────────────────────────────────────────────────
# the GUTS pack (round 1 = proof: 1 bed + 4 sfx; full pack is round 2)
# ─────────────────────────────────────────────────────────────────────────────
def bed_esophagus(name):
"""Wet cavernous drone + muffled resting heartbeat + peristalsis whooshes.
Loop = 24 s exactly, so the 1.2 s heartbeat (50 bpm) and 4 s whoosh both divide it and
survive the crossfade without a stutter."""
r = rng(name)
LOOP = 24.0
dur = LOOP + XF
# low wet drone — two detuned saws, heavily lowpassed, slow swell
d1 = osc(55.0, dur, "saw") + osc(55.0 * 1.006, dur, "saw") * 0.8
drone = lp1(d1 * 0.25, 190) * (0.75 + 0.25 * np.sin(2 * np.pi * tarr(dur) / 12.0))
# cavern air — pink noise in the 200900 band, breathing at 0.25 Hz
air = bandpass(noise(dur, r, "pink"), 200, 900) * 0.30
air *= 0.6 + 0.4 * np.sin(2 * np.pi * tarr(dur) / 8.0)
# muffled heartbeat (lub-dub), 50 bpm
hb = np.zeros(int(dur * SR))
period = 1.2
for i in range(int(dur / period) + 1):
for off, amp in ((0.0, 1.0), (0.16, 0.62)): # lub, dub
st = int((i * period + off) * SR)
if st >= len(hb):
continue
th = 0.22
body = osc(np.linspace(58, 34, int(th * SR)), th, "sine") * adsr(th, 0.004, 0.05, 0.25, 0.14)
seg = body * amp
n = min(len(seg), len(hb) - st)
hb[st:st + n] += seg[:n]
hb = lp1(hb, 120) * 0.55
# peristalsis whoosh every 4 s — filtered noise sweeping past
wh = np.zeros(int(dur * SR))
for i in range(int(dur / 4.0) + 1):
st = int(i * 4.0 * SR)
wd = 1.8
seg = noise(wd, np.random.default_rng(seed_of(name) + i))
sweep = np.linspace(300, 1600, int(wd * SR))
seg = bandpass(seg, 200, 2000) * np.sin(np.pi * np.linspace(0, 1, len(seg))) ** 2
seg *= 0.18 * (0.7 + 0.3 * math.sin(i))
n = min(len(seg), len(wh) - st)
if n > 0:
wh[st:st + n] += seg[:n]
body = mix(pan(drone, -0.15), pan(air, 0.2), pan(hb, 0.0), pan(wh, 0.35))
body = schroeder_reverb(body, mix_amt=0.28, decay=0.55)
body = softclip(body * 0.8, 1.1)
return seamless(body, XF)
def sfx_pellet(name):
"""Lysozyme cannon — a short wet bright zap, not a laser pew."""
dur = 0.20
r = rng(name)
body = osc(np.linspace(1500, 380, int(dur * SR)), dur, "saw") * adsr(dur, 0.001, 0.04, 0.25, 0.12)
wet = bandpass(noise(dur, r) * adsr(dur, 0.001, 0.03, 0.1, 0.15), 800, 5200) * 0.5
x = lp1(body * 0.6 + wet, 6000)
return peaknorm(fade(x, 0.001, 0.02), -4.0)
def sfx_hit_squelch(name):
"""Wet impact into tissue: a low slap + a burst of high spatter."""
dur = 0.34
r = rng(name)
thud = osc(np.linspace(180, 55, int(dur * SR)), dur, "sine") * adsr(dur, 0.002, 0.09, 0.18, 0.2)
spat = bandpass(noise(dur, r), 700, 4500) * adsr(dur, 0.001, 0.05, 0.06, 0.25)
x = softclip(thud * 0.9 + spat * 0.55, 1.4)
x = schroeder_reverb(x, mix_amt=0.16, decay=0.4)
return peaknorm(fade(x, 0.001, 0.03), -3.5)
def sfx_boost(name):
"""ENDO-1 boost — rising filtered whoosh with a pressure thump underneath."""
dur = 0.85
r = rng(name)
air = noise(dur, r)
env = np.linspace(0, 1, int(dur * SR)) ** 1.6
air = bandpass(air, 220, 5200) * env * np.sin(np.pi * np.linspace(0, 1, int(dur * SR))) ** 0.7
sub = osc(np.linspace(38, 120, int(dur * SR)), dur, "sine") * adsr(dur, 0.05, 0.2, 0.6, 0.3) * 0.6
x = softclip(air * 0.7 + sub, 1.2)
return peaknorm(fade(x, 0.01, 0.06), -3.5)
def sfx_pickup(name):
"""Pickup chime — soft, green, organic (ART_BIBLE pickups #7dffb0). Two-note bell."""
dur = 0.5
x = np.zeros(int(dur * SR))
for f, amp, delay in ((784.0, 1.0, 0.0), (1174.7, 0.7, 0.07)):
st = int(delay * SR)
seg = (osc(f, dur - delay, "sine") + 0.35 * osc(f * 2.01, dur - delay, "sine")) \
* adsr(dur - delay, 0.004, 0.18, 0.22, 0.28) * amp
x[st:st + len(seg)] += seg
x = schroeder_reverb(lp1(x, 7000), mix_amt=0.22, decay=0.45)
return peaknorm(fade(x, 0.002, 0.05), -4.0)
PACK = {
"bed-esophagus": ("bed", bed_esophagus, dict(loop=True, gain=0.5, key="esophagus")),
"sfx-pellet": ("sfx", sfx_pellet, dict(loop=False, gain=0.5, key="pellet")),
"sfx-hit-squelch": ("sfx", sfx_hit_squelch, dict(loop=False, gain=0.7, key="hit_squelch")),
"sfx-boost": ("sfx", sfx_boost, dict(loop=False, gain=0.6, key="boost")),
"sfx-pickup": ("sfx", sfx_pickup, dict(loop=False, gain=0.5, key="pickup")),
}
# ─────────────────────────────────────────────────────────────────────────────
# io + evidence
# ─────────────────────────────────────────────────────────────────────────────
def write_wav(path, x):
if x.ndim == 1:
x = stereo(x)
peak = np.max(np.abs(x))
if peak > 0.95:
x = x * (0.95 / peak)
pcm = (np.clip(x, -1, 1) * 32767).astype("<i2")
with wave.open(path, "wb") as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
w.writeframes(pcm.tobytes())
def encode(wav, base, category):
ogg = os.path.join(OUT, base + ".ogg")
m4a = os.path.join(OUT, base + ".m4a")
af = ["-af", "loudnorm=I=-16:TP=-1.5:LRA=11"] if category == "bed" else []
br = "80k" if category == "bed" else "64k"
subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "libopus", "-b:a", br, "-ar", "48000", ogg],
check=True, capture_output=True)
subprocess.run([FF, "-y", "-i", wav, *af, "-c:a", "aac", "-b:a", "128k", "-ar", "44100", m4a],
check=True, capture_output=True)
return os.path.getsize(ogg), os.path.getsize(m4a)
def loop_seam(x):
"""THE loop-seam number. Ratio of the wrap-around step (last sample -> first sample) to
the RMS step inside the signal. ~1 = the seam is indistinguishable from ordinary motion;
>>1 = an audible click. Reported for every looping bed; vibes are not evidence."""
m = x.mean(axis=1) if x.ndim == 2 else x
inner = np.sqrt(np.mean(np.diff(m) ** 2)) or 1e-9
wrap = abs(m[0] - m[-1])
return float(wrap / inner)
def spectrogram(x, w=420, h=180):
"""|STFT| -> log-magnitude image column-per-frame. numpy only (no matplotlib on the box)."""
m = x.mean(axis=1) if x.ndim == 2 else x
n = 1024
hop = max(1, (len(m) - n) // w)
win = np.hanning(n)
cols = []
for i in range(w):
st = i * hop
seg = m[st:st + n]
if len(seg) < n:
seg = np.pad(seg, (0, n - len(seg)))
S = np.abs(np.fft.rfft(seg * win))
cols.append(20 * np.log10(S + 1e-6))
img = np.array(cols).T[:h * 2:2][::-1] # low freqs at the bottom
img = np.clip((img + 60) / 70, 0, 1)
return img
def evidence(rendered):
"""Spectrogram sheet + loop-seam table -> docs/shots/laneD/. Import PIL late: the render
path itself must not depend on it."""
from PIL import Image, ImageDraw
rows = len(rendered)
W, H, PADL = 420, 180, 150
sheet = Image.new("RGB", (W + PADL + 20, rows * (H + 14) + 14), (10, 12, 14))
d = ImageDraw.Draw(sheet)
for i, (name, x, meta) in enumerate(rendered):
y = 8 + i * (H + 14)
img = spectrogram(x, W, H)
g = (img * 255).astype(np.uint8)
rgb = np.stack([g, (g * 0.85).astype(np.uint8), (g * 0.55).astype(np.uint8)], axis=-1)
sheet.paste(Image.fromarray(rgb, "RGB"), (PADL, y))
d.text((8, y + 6), name, fill=(90, 255, 210))
d.text((8, y + 22), f"{meta['seconds']:.2f}s", fill=(150, 170, 180))
if meta.get("loop"):
d.text((8, y + 38), f"seam {meta['loop_seam']:.2f}", fill=(150, 170, 180))
d.text((8, y + 54 if meta.get("loop") else y + 38), f"{meta['ogg_kb']}KB ogg", fill=(150, 170, 180))
p = os.path.join(SHOTS, "audio_spectrograms.png")
sheet.save(p, "PNG")
return p
if __name__ == "__main__":
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
wav_only = "--wav-only" in sys.argv
todo = {k: v for k, v in PACK.items() if not only or only in k or only == v[0]}
if "--dry-run" in sys.argv:
print(f"{len(todo)} audio assets (procedural numpy synth, $0):")
for k, (cat, _, meta) in sorted(todo.items()):
print(f" [{cat}] {k:18s} loop={str(meta['loop']):5s} key={meta['key']}")
sys.exit(0)
print(f"rendering {len(todo)} assets @ {SR}Hz (numpy synth -> {os.path.basename(FF)} opus+aac)")
log = json.load(open(BATCH)) if os.path.exists(BATCH) else {}
rendered, tot_ogg, tot_m4a = [], 0, 0
for k, (cat, gen, meta) in todo.items():
x = gen(k)
wav = os.path.join(RAW, k + ".wav")
write_wav(wav, x)
secs = len(x) / SR
seam = loop_seam(x) if meta["loop"] else None
if wav_only:
print(f" [{cat}] {k:18s} {secs:5.2f}s (wav only)"
+ (f" seam={seam:.2f}" if seam is not None else ""))
continue
og, m4 = encode(wav, k, cat)
tot_ogg += og; tot_m4a += m4
info = dict(category=cat, key=meta["key"], loop=meta["loop"], gain=meta["gain"],
seconds=round(secs, 2), ogg_kb=og // 1024, m4a_kb=m4 // 1024)
if seam is not None:
info["loop_seam"] = round(seam, 3)
log[k] = info
rendered.append((k, x, info))
print(f" [{cat}] {k:18s} {secs:5.2f}s ogg={og//1024:>4}KB m4a={m4//1024:>4}KB"
+ (f" seam={seam:.2f}" if seam is not None else ""))
if wav_only:
sys.exit(0)
json.dump(log, open(BATCH, "w"), indent=2, sort_keys=True)
total = (tot_ogg + tot_m4a) / 1e6
print(f"\ntotal shipped: ogg {tot_ogg/1e6:.2f}MB + m4a {tot_m4a/1e6:.2f}MB = {total:.2f}MB "
f"(budget 10MB)")
if "--evidence" in sys.argv and rendered:
print(f"evidence -> {evidence(rendered)}")

154
pipeline/gen_textures.py Normal file
View File

@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""GUTS wall/matcap texture pack — MODELBEAST flux_local, on-device, $0 (Lane D).
Ported from PROCITY's gen_skins.py (same operator, same discipline: dry-run first, idempotent,
resumable, review-then-harvest). Differences from PROCITY: square 1024s, grayscale-authored
(the biome tint lives in Lane A's shader — ART_BIBLE), and every result must TILE, so harvest
runs through derive_maps.py (seam-blend + Sobel normals + WebP).
<mflux-py> pipeline/gen_textures.py --dry-run # list the pack, no GPU, no spend
<mflux-py> pipeline/gen_textures.py --local # generate -> pipeline/.genraw/*.png
<mflux-py> pipeline/gen_textures.py --local --only esophagus
<mflux-py> pipeline/gen_textures.py --harvest # .genraw -> web/assets/gen/*.webp (+ _n)
<mflux-py> = ~/Documents/MODELBEAST/venvs/mflux/bin/python (numpy + PIL live there)
Review .genraw/ by eye and DELETE REJECTS before --harvest committed junk is forever.
Provenance (prompt/seed/model/time per asset) is written to pipeline/batch_textures.json and
committed: regeneration must always be possible.
"""
import json, os, subprocess, sys, time
import shutil as _sh
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "pipeline", ".genraw") # git-ignored scratch
BATCH = os.path.join(ROOT, "pipeline", "batch_textures.json")
os.makedirs(RAW, exist_ok=True)
MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST"))
FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python")
FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py")
# ── the style stem — verbatim from ART_BIBLE §FLUX prompt kit ────────────────────────────
STEM = ("scanning electron microscope micrograph, monochrome, high detail organic tissue, "
"dark background, dramatic rim lighting, seamless tiling texture")
# ── the pack. tile = [repeats_around_theta, units_of_s_per_repeat] — see NOTES §tiling ────
# Two per biome (ART_BIBLE: "generate ~2 per biome, not ten"). One texture can serve two
# biomes at different tints, so these are shapes-of-tissue, not colours.
WALLS = {
"wall_esophagus_a": dict(
p="human esophageal mucosa, longitudinal ribbed folds, wet ridges",
seed=1101, tile=[4, 16]),
"wall_esophagus_b": dict(
p="human esophagus inner lining, tight circular muscular rings, contracted sphincter folds",
seed=1102, tile=[3, 12]),
"wall_stomach_a": dict(
p="gastric mucosa, pitted craters, gastric pits, glistening mucus",
seed=1201, tile=[4, 18]),
"wall_stomach_b": dict(
p="stomach rugae, thick heavy folded ridges, deep valleys between folds",
seed=1202, tile=[3, 22]),
"wall_smallint_a": dict(
p="intestinal villi, dense finger-like projections, forest of fronds",
seed=1301, tile=[5, 14]),
"wall_smallint_b": dict(
p="intestinal crypts of Lieberkuhn, honeycomb of round pits, packed glandular openings",
seed=1302, tile=[6, 10]),
}
# matcap: NOT tileable, NOT seam-blended — a lit sphere cropped to a square (see derive_maps)
MATCAPS = {
"matcap_tissue_wet": dict(
p=("spherical material study ball, wet translucent organic tissue, subsurface glow, "
"studio black background"),
seed=1401, stem_override=True),
}
PROMPTS = {**{k: v for k, v in WALLS.items()}, **MATCAPS}
STEPS = 4 # flux2-klein-4b is a 4-step distilled model
GUIDANCE = 3.5
SIZE = 1024
def local_available():
return os.path.exists(FLUX_PY) and os.path.exists(FLUX_RUN)
def prompt_of(slug):
spec = PROMPTS[slug]
if spec.get("stem_override"): # matcaps want a lit sphere, not an SEM micrograph
return spec["p"]
return STEM + ", " + spec["p"]
def have(slug):
return os.path.exists(os.path.join(RAW, f"{slug}.png"))
def gen_local(slug):
"""One GPU job at a time — MPS does not share cleanly (PROCITY scar)."""
spec = PROMPTS[slug]
outdir = os.path.join(RAW, "_job_" + slug)
os.makedirs(outdir, exist_ok=True)
params = json.dumps({"prompt": prompt_of(slug), "model": "flux2-klein-4b",
"steps": STEPS, "width": SIZE, "height": SIZE,
"seed": spec["seed"], "quantize": "8", "guidance": GUIDANCE})
t0 = time.time()
r = subprocess.run([FLUX_PY, FLUX_RUN, "--outdir", outdir, "--params", params],
capture_output=True, text=True, timeout=900)
pngs = [p for p in os.listdir(outdir) if p.endswith(".png")]
if not pngs:
_sh.rmtree(outdir, ignore_errors=True)
raise RuntimeError((r.stderr or r.stdout or "no png")[-200:])
fn = os.path.join(RAW, f"{slug}.png")
_sh.move(os.path.join(outdir, pngs[0]), fn)
_sh.rmtree(outdir, ignore_errors=True)
return fn, time.time() - t0
def record(slug, fn, secs):
"""Append/refresh provenance so any asset can be regenerated byte-for-byte."""
log = {}
if os.path.exists(BATCH):
log = json.load(open(BATCH))
spec = PROMPTS[slug]
log[slug] = {"prompt": prompt_of(slug), "seed": spec["seed"], "model": "flux2-klein-4b",
"steps": STEPS, "guidance": GUIDANCE, "size": SIZE,
"gen_seconds": round(secs, 1), "kb": os.path.getsize(fn) // 1024,
"tile": spec.get("tile"), "kind": "matcap" if slug in MATCAPS else "wall"}
json.dump(log, open(BATCH, "w"), indent=2, sort_keys=True)
if __name__ == "__main__":
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
todo = {s: prompt_of(s) for s in PROMPTS if (not only or only in s)}
if "--harvest" in sys.argv:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import derive_maps
derive_maps.harvest(only=only)
sys.exit(0)
pending = {s: p for s, p in todo.items() if not have(s)}
print(f"{len(pending)}/{len(todo)} textures to generate "
f"(MODELBEAST flux2-klein-4b, local·free·$0)" + (f" filter:*{only}*" if only else ""))
if "--dry-run" in sys.argv:
for s in sorted(todo):
mark = "have" if have(s) else " gen"
print(f" [{mark}] {s:22s} seed={PROMPTS[s]['seed']} {prompt_of(s)[:78]}")
sys.exit(0)
if not local_available():
print(f"ERROR: MODELBEAST flux_local missing ({FLUX_RUN}).\n"
"Set MB_HOME, or run this on the m3ultra box (PIPELINE.md).")
sys.exit(2)
fails = 0
for i, slug in enumerate(sorted(pending), 1):
try:
fn, secs = gen_local(slug)
record(slug, fn, secs)
print(f"[{i}/{len(pending)}] {slug:22s} {secs:5.1f}s {os.path.getsize(fn)//1024}KB")
except Exception as e:
fails += 1
print(f"[{i}/{len(pending)}] {slug:22s} FAILED: {str(e)[:120]}")
print(f"done, {fails} failures. Review {RAW}/, delete rejects, then --harvest.")

View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""GUTS manifest validator (Lane D) — the qa.sh hook. Exit 0 = green, 1 = errors.
Enforces the house laws that a manifest can violate (TECH.md):
* every referenced url actually exists on disk (a manifest that lies is worse than none)
* WebP dimensions <= 1024, matcaps square
* every wall texture carries a sane `tile` hint (Lane A reads it)
* audio ships OGG **and** M4A (Safari has no Opus-in-ogg) and stays under the 10 MB budget
* no orphan files in assets/gen (harvested but uncatalogued => invisible to the game)
Stdlib ONLY no numpy, no PIL. qa.sh runs on any box, including ones with a bare python3,
so the WebP header is parsed by hand below.
python3 pipeline/validate_manifest.py [--quiet]
"""
import json, os, struct, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(ROOT, "web", "assets")
MANIFEST = os.path.join(ASSETS, "manifest.json")
AUDIO_BUDGET_MB = 10.0
MAX_DIM = 1024
errs, warns = [], []
def webp_size(path):
"""(w, h) from a WebP header — VP8 (lossy) / VP8L (lossless) / VP8X (extended).
Returns None if the fourcc is unrecognised rather than guessing."""
with open(path, "rb") as f:
b = f.read(32)
if len(b) < 30 or b[:4] != b"RIFF" or b[8:12] != b"WEBP":
return None
fourcc = b[12:16]
if fourcc == b"VP8 ":
# frame tag (3B) + start code 9d 01 2a (3B) then 16-bit w,h with 2 scale bits on top
w, h = struct.unpack("<HH", b[26:30])
return w & 0x3FFF, h & 0x3FFF
if fourcc == b"VP8L":
n = struct.unpack("<I", b[21:25])[0]
return (n & 0x3FFF) + 1, ((n >> 14) & 0x3FFF) + 1
if fourcc == b"VP8X":
w = b[24] | (b[25] << 8) | (b[26] << 16)
h = b[27] | (b[28] << 8) | (b[29] << 16)
return w + 1, h + 1
return None
def check_file(rel, label):
p = os.path.join(ASSETS, rel)
if not os.path.exists(p):
errs.append(f"{label}: missing file {rel}")
return None
return p
def main():
quiet = "--quiet" in sys.argv
if not os.path.exists(MANIFEST):
# An absent manifest is LEGAL: the game must boot asset-free. Say so and pass.
print("validate_manifest: no manifest.json — asset-free boot (legal, TECH.md §assets)")
return 0
try:
m = json.load(open(MANIFEST))
except Exception as e:
print(f"validate_manifest: manifest.json does not parse: {e}")
return 1
seen = set()
for name, e in (m.get("textures") or {}).items():
p = check_file(e.get("url", ""), f"texture {name}")
if p:
seen.add(os.path.basename(p))
wh = webp_size(p)
if wh and max(wh) > MAX_DIM:
errs.append(f"texture {name}: {wh[0]}x{wh[1]} exceeds {MAX_DIM} (house law)")
if "normal" in e:
np_ = check_file(e["normal"], f"texture {name} normal")
if np_:
seen.add(os.path.basename(np_))
t = e.get("tile")
if not (isinstance(t, list) and len(t) == 2 and all(isinstance(v, (int, float)) and v > 0 for v in t)):
errs.append(f"texture {name}: bad tile {t!r} — want [repeats_theta, units_per_s]")
for name, e in (m.get("matcaps") or {}).items():
p = check_file(e.get("url", ""), f"matcap {name}")
if p:
seen.add(os.path.basename(p))
wh = webp_size(p)
if wh and wh[0] != wh[1]:
errs.append(f"matcap {name}: {wh[0]}x{wh[1]} is not square")
if wh and max(wh) > MAX_DIM:
errs.append(f"matcap {name}: {wh[0]}x{wh[1]} exceeds {MAX_DIM}")
for name, e in (m.get("models") or {}).items():
check_file(e.get("url", ""), f"model {name}")
tris = e.get("tris")
if tris and tris > 5000:
errs.append(f"model {name}: {tris} tris exceeds house law 5000 (decimate)")
if not tris:
warns.append(f"model {name}: no tris count recorded (run glb_stat)")
audio_bytes = 0
for cat in ("beds", "sfx"):
for name, e in ((m.get("audio") or {}).get(cat) or {}).items():
for fmt in ("ogg", "m4a"):
if fmt not in e:
errs.append(f"audio {cat}/{name}: no {fmt} (dual-ship law: Safari needs m4a)")
continue
p = check_file(e[fmt], f"audio {cat}/{name}")
if p:
audio_bytes += os.path.getsize(p)
if audio_bytes / 1e6 > AUDIO_BUDGET_MB:
errs.append(f"audio total {audio_bytes/1e6:.1f}MB exceeds {AUDIO_BUDGET_MB}MB budget")
gendir = os.path.join(ASSETS, "gen")
if os.path.isdir(gendir):
for f in sorted(os.listdir(gendir)):
if f.endswith(".webp") and f not in seen:
warns.append(f"orphan {f} in gen/ — harvested but not in manifest "
f"(re-run build_manifest.py)")
if not quiet:
n = m.get("textures", {}), m.get("matcaps", {}), (m.get("audio") or {})
print(f"validate_manifest: {len(n[0])} textures, {len(n[1])} matcaps, "
f"{len(n[2].get('beds', {}))} beds, {len(n[2].get('sfx', {}))} sfx, "
f"audio {audio_bytes/1e6:.2f}/{AUDIO_BUDGET_MB}MB")
for w in warns:
print(f" warn: {w}")
for e in errs:
print(f" ERROR: {e}")
return 1 if errs else 0
if __name__ == "__main__":
sys.exit(main())

157
web/js/core/assets.js Normal file
View File

@ -0,0 +1,157 @@
// core/assets.js (Lane D) — the manifest loader. Implements the asset contract in docs/TECH.md.
//
// THE LAW THIS FILE EXISTS TO ENFORCE: every asset is optional at runtime. Nothing in here
// throws, ever. A missing manifest, a 404, a corrupt WebP, a box with no network — all resolve
// to `null`, and the consumer draws its procedural fallback. GUTS must be fully playable with
// an empty assets/gen/, and `?localassets=0` exists so you can prove it on demand.
//
// Usage (boot constructs it once and passes it to every lane factory):
// const assets = await createAssets({ flags, renderer });
// const e = assets.get('textures', 'wall_esophagus_a'); // raw manifest entry | null
// const t = assets.texture('wall_esophagus_a'); // {map, normalMap, tile} | null
// if (t) mat.uniforms.uDetail.value = t.map; // else: keep the procedural path
import * as THREE from 'three';
const MANIFEST_URL = new URL('../../assets/manifest.json', import.meta.url);
const ASSETS_BASE = new URL('../../assets/', import.meta.url);
// The 3GOD depot (house shared mesh store). `depot:foo.glb` in a manifest resolves here.
// Nothing SHIPPED may use it — the game must run offline (README law) — but it lets Lane D
// point at depot meshes while prototyping without copying them into the repo.
const DEPOT_BASE = 'https://digalot.fyi/3god/';
const EMPTY = { textures: {}, matcaps: {}, models: {}, audio: { beds: {}, sfx: {} } };
/** `depot:x` -> depot URL; anything else -> resolved against web/assets/. */
function resolve(url) {
if (typeof url !== 'string' || !url) return null;
if (url.startsWith('depot:')) return DEPOT_BASE + url.slice(6);
if (/^https?:\/\//.test(url)) return url;
return new URL(url, ASSETS_BASE).href;
}
export async function createAssets({ flags = {}, renderer = null } = {}) {
const loader = new THREE.TextureLoader();
const cache = new Map(); // url -> THREE.Texture (one GPU upload per file)
const maxAniso = renderer?.capabilities?.getMaxAnisotropy?.() ?? 1;
let manifest = EMPTY;
// ?localassets=0 => pretend the manifest is empty. This is the fallback-path test switch:
// if the game looks broken with it on, someone has violated the optional-asset law.
if (flags.localassets === false) {
console.info('[assets] ?localassets=0 — empty manifest, procedural fallbacks only');
} else {
try {
const r = await fetch(MANIFEST_URL, { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
manifest = { ...EMPTY, ...(await r.json()) };
} catch (e) {
// Not an error condition — an asset-free checkout is a supported way to run GUTS.
console.info('[assets] no manifest, running asset-free —', e.message);
manifest = EMPTY;
}
}
function loadTexture(url, { srgb = true, repeat = null } = {}) {
const abs = resolve(url);
if (!abs) return null;
if (cache.has(abs)) return cache.get(abs);
let tex;
try {
// TextureLoader is async under the hood; a decode failure lands in onError, where we
// just mark the texture dead rather than letting it reject into nothing.
tex = loader.load(abs, undefined, undefined, () => {
console.info(`[assets] failed to decode ${url} — consumer should fall back`);
tex.userData.failed = true;
});
} catch (e) {
console.info(`[assets] failed to load ${url}`, e.message);
return null;
}
tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.anisotropy = maxAniso;
if (repeat) tex.repeat.set(repeat[0], repeat[1]);
cache.set(abs, tex);
return tex;
}
const api = {
manifest,
/** THE contract (TECH.md): raw manifest entry, or null on any miss. Never throws. */
get(category, name) {
const cat = manifest?.[category];
if (!cat) return null;
if (category === 'audio') return null; // audio is nested; use audioUrl()
return cat[name] ?? null;
},
/**
* Wall texture ready to plug into a material, or null.
* `tile` = [repeats around theta, units of s per repeat] (LANE_D_NOTES §tiling).
* The stub/world UV is (theta/2pi, s-in-units), so repeat = (tile[0], 1/tile[1])
* is pre-applied here Lane A shouldn't have to rediscover the convention.
*/
texture(name) {
const e = api.get('textures', name);
if (!e) return null;
const tile = Array.isArray(e.tile) && e.tile.length === 2 ? e.tile : [4, 16];
const repeat = [tile[0], 1 / tile[1]];
const map = loadTexture(e.url, { srgb: true, repeat });
if (!map) return null;
return {
map,
normalMap: e.normal ? loadTexture(e.normal, { srgb: false, repeat }) : null,
tile,
repeat,
};
},
/** Matcap texture (clamped, non-tiling), or null. */
matcap(name) {
const e = api.get('matcaps', name);
if (!e) return null;
const t = loadTexture(e.url, { srgb: true });
if (t) t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
return t;
},
/** Model manifest entry (url resolved), or null. Lane D ships GLBs from round 2. */
model(name) {
const e = api.get('models', name);
if (!e) return null;
return { ...e, url: resolve(e.url) };
},
/**
* Best playable audio URL for this browser, or null.
* cat: 'beds' | 'sfx'. Safari can't do Opus-in-Ogg, so we dual-ship and probe.
*/
audioUrl(cat, name) {
const e = manifest?.audio?.[cat]?.[name];
if (!e) return null;
const probe = document.createElement('audio');
const canOgg = !!probe.canPlayType('audio/ogg; codecs="opus"');
const pick = (canOgg && e.ogg) ? e.ogg : (e.m4a || e.ogg);
return pick ? resolve(pick) : null;
},
/** Raw audio entry ({ogg, m4a, loop, gain, seconds}) or null — for Lane E's engine. */
audio(cat, name) {
return manifest?.audio?.[cat]?.[name] ?? null;
},
/** Names present in a category — lets a lane iterate what actually shipped. */
list(category) {
if (category === 'audio') return Object.keys(manifest?.audio ?? {});
return Object.keys(manifest?.[category] ?? {});
},
dispose() {
for (const t of cache.values()) t.dispose();
cache.clear();
},
};
return api;
}