From dade036f69c79aca9d6c102ae53d13bffcba07f9 Mon Sep 17 00:00:00 2001 From: jing Date: Thu, 16 Jul 2026 01:45:12 +1000 Subject: [PATCH] [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 --- pipeline/build_manifest.py | 86 +++++++ pipeline/derive_maps.py | 232 ++++++++++++++++++ pipeline/gen_audio.py | 430 ++++++++++++++++++++++++++++++++++ pipeline/gen_textures.py | 154 ++++++++++++ pipeline/validate_manifest.py | 136 +++++++++++ web/js/core/assets.js | 157 +++++++++++++ 6 files changed, 1195 insertions(+) create mode 100644 pipeline/build_manifest.py create mode 100644 pipeline/derive_maps.py create mode 100644 pipeline/gen_audio.py create mode 100644 pipeline/gen_textures.py create mode 100644 pipeline/validate_manifest.py create mode 100644 web/js/core/assets.js diff --git a/pipeline/build_manifest.py b/pipeline/build_manifest.py new file mode 100644 index 0000000..3121523 --- /dev/null +++ b/pipeline/build_manifest.py @@ -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}") diff --git a/pipeline/derive_maps.py b/pipeline/derive_maps.py new file mode 100644 index 0000000..ba806be --- /dev/null +++ b/pipeline/derive_maps.py @@ -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). + + pipeline/derive_maps.py --contact # contact sheets only, no harvest + pipeline/derive_maps.py --harvest # .genraw/*.png -> web/assets/gen/*.webp + 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) diff --git a/pipeline/gen_audio.py b/pipeline/gen_audio.py new file mode 100644 index 0000000..61a8345 --- /dev/null +++ b/pipeline/gen_audio.py @@ -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). + + pipeline/gen_audio.py --dry-run # list the pack + budget, no render + pipeline/gen_audio.py # render -> web/assets/audio/ + pipeline/gen_audio.py --only sfx # one category + pipeline/gen_audio.py --wav-only # keep .genaudio/*.wav, skip ffmpeg (debug) + + = ~/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 200–900 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(" 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)}") diff --git a/pipeline/gen_textures.py b/pipeline/gen_textures.py new file mode 100644 index 0000000..ee69a41 --- /dev/null +++ b/pipeline/gen_textures.py @@ -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). + + pipeline/gen_textures.py --dry-run # list the pack, no GPU, no spend + pipeline/gen_textures.py --local # generate -> pipeline/.genraw/*.png + pipeline/gen_textures.py --local --only esophagus + pipeline/gen_textures.py --harvest # .genraw -> web/assets/gen/*.webp (+ _n) + + = ~/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.") diff --git a/pipeline/validate_manifest.py b/pipeline/validate_manifest.py new file mode 100644 index 0000000..154fe78 --- /dev/null +++ b/pipeline/validate_manifest.py @@ -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("> 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()) diff --git a/web/js/core/assets.js b/web/js/core/assets.js new file mode 100644 index 0000000..aaf41c7 --- /dev/null +++ b/web/js/core/assets.js @@ -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; +}