[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:
parent
34f84ab162
commit
dade036f69
86
pipeline/build_manifest.py
Normal file
86
pipeline/build_manifest.py
Normal 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
232
pipeline/derive_maps.py
Normal 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
430
pipeline/gen_audio.py
Normal 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 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("<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
154
pipeline/gen_textures.py
Normal 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.")
|
||||||
136
pipeline/validate_manifest.py
Normal file
136
pipeline/validate_manifest.py
Normal 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
157
web/js/core/assets.js
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user