[laneC] M1 server: statics, assets tree/file, scenes CRUD+validation, render stubs, tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 17:53:52 +10:00
parent e29edc808b
commit bb85eef040
4 changed files with 382 additions and 0 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.env .env
.venv/
assets/ assets/
scenes/ scenes/
renders/ renders/

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
fastapi
uvicorn

246
scenegod/server.py Normal file
View File

@ -0,0 +1,246 @@
"""SCENEGOD server — page + assets + scenes + render pipeline.
Single-file FastAPI app (MESHGOD convention). A plain folder tree is the asset
database (no index file to drift): assets/{characters,animations,props,
backdrops,audio}/<pack>/name.ext. Files sharing dir+stem = one entry, many
formats. See PLAN.md §4.3 for the HTTP contract.
uvicorn scenegod.server:app --port 8020 # binds 127.0.0.1
"""
import json
import os
import re
import shutil
import time
import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
ROOT = Path(__file__).resolve().parent.parent
WEB = Path(__file__).resolve().parent / "web" # scenegod/web (PLAN §2)
ASSETS = Path(os.environ.get("SCENEGOD_ASSETS", ROOT / "assets")).resolve()
SCENES = Path(os.environ.get("SCENEGOD_SCENES", ROOT / "scenes")).resolve()
RENDERS = Path(os.environ.get("SCENEGOD_RENDERS", ROOT / "renders")).resolve()
MODEL_EXTS = {"glb", "gltf", "fbx", "obj", "bvh"}
IMG_EXTS = {"jpg", "jpeg", "png", "webp"}
AUDIO_EXTS = {"wav", "mp3"}
ASSET_EXTS = MODEL_EXTS | IMG_EXTS | AUDIO_EXTS
# primary ext per category (others sharing a stem are thumbnails)
CATEGORIES = {"characters": MODEL_EXTS, "animations": MODEL_EXTS, "props": MODEL_EXTS,
"backdrops": IMG_EXTS, "audio": AUDIO_EXTS}
MEDIA_TYPES = {
"glb": "model/gltf-binary", "gltf": "model/gltf+json", "fbx": "application/octet-stream",
"obj": "text/plain", "bvh": "text/plain", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"png": "image/png", "webp": "image/webp", "wav": "audio/wav", "mp3": "audio/mpeg",
}
SCENES.mkdir(parents=True, exist_ok=True)
RENDERS.mkdir(parents=True, exist_ok=True)
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
app = FastAPI(title="SCENEGOD")
# ---- path guard (ship-check rule: every path-taking endpoint uses this) ----
def safe_under(root: Path, relpath: str) -> Path:
"""Resolve relpath under root; reject traversal, absolute, symlink escape."""
full = (root / relpath).resolve()
if full != root and root not in full.parents:
raise HTTPException(400, "bad path")
return full
# ---- assets ----
_tree_cache = {"t": 0.0, "val": None}
def scan_assets() -> dict:
"""Walk each category dir, group files by (dir, stem) → one entry w/ formats.
Live scan (hundreds of files); cached 5s in /assets/tree."""
out = {c: [] for c in CATEGORIES}
for cat, primary in CATEGORIES.items():
base = ASSETS / cat
if not base.is_dir():
continue
groups: dict[str, dict] = {}
for dirpath, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if not d.startswith(".")]
for f in sorted(files):
stem, dot, ext = f.rpartition(".")
ext = ext.lower()
if f.startswith(".") or not dot or ext not in ASSET_EXTS:
continue
rel = os.path.relpath(os.path.join(dirpath, f), ASSETS).replace(os.sep, "/")
key = os.path.relpath(os.path.join(dirpath, stem), ASSETS) # dir+stem
g = groups.setdefault(key, {"name": stem, "path": None, "formats": [], "thumb": None})
if ext not in primary:
if ext in IMG_EXTS:
g["thumb"] = rel
continue
g["formats"].append(ext)
if g["path"] is None or ext == "glb": # prefer glb, else first
g["path"] = rel
entries = [g for g in groups.values() if g["formats"]] # thumb-only stems aren't assets
out[cat] = sorted(entries, key=lambda g: g["name"].lower())
return out
@app.get("/assets/tree")
def assets_tree():
if not ASSETS.is_dir():
raise HTTPException(500, f"asset root missing: {ASSETS} (set SCENEGOD_ASSETS)")
now = time.monotonic()
if _tree_cache["val"] is None or now - _tree_cache["t"] > 5.0:
_tree_cache["val"] = scan_assets()
_tree_cache["t"] = now
return _tree_cache["val"]
@app.get("/assets/file")
def assets_file(path: str):
full = safe_under(ASSETS, path)
if not full.is_file():
raise HTTPException(404, "not found")
ext = full.suffix.lstrip(".").lower()
return FileResponse(full, media_type=MEDIA_TYPES.get(ext, "application/octet-stream"))
# ---- scenes ----
SLUG = re.compile(r"[^a-z0-9_-]")
def slugify(name: str) -> str:
s = SLUG.sub("-", name.lower()).strip("-")
if not s:
raise HTTPException(422, "empty scene name after slugify")
return s
def validate_scene(s: dict) -> list[str]:
"""Return list of violations (empty = valid). Stdlib only, no jsonschema."""
errs = []
if s.get("version") != 1:
errs.append(f"version must be 1, got {s.get('version')!r}")
ents = s.get("entities")
if not isinstance(ents, list):
return errs + ["entities must be a list"]
ids, cam_ids = set(), set()
for i, e in enumerate(ents):
eid = e.get("id")
if eid in ids:
errs.append(f"entity[{i}]: duplicate id {eid!r}")
ids.add(eid)
if e.get("kind") == "camera":
cam_ids.add(eid)
tracks = e.get("tracks") or {}
for tk in ("transform", "params"):
keys = tracks.get(tk) or []
ts = [k.get("t") for k in keys]
if ts != sorted(ts):
errs.append(f"entity {eid!r}: track {tk} not sorted by t")
clips = sorted(tracks.get("clips") or [], key=lambda c: c.get("start", 0))
for a, b in zip(clips, clips[1:]):
end = a.get("start", 0) + (a.get("out", 0) - a.get("in", 0)) * a.get("loop", 1)
if b.get("start", 0) < end - a.get("fade", 0) - 1e-6:
errs.append(f"entity {eid!r}: clips overlap beyond fade "
f"(block ends {end:.3f}, next starts {b.get('start')})")
for cut in s.get("cameraCuts") or []:
if cut.get("camera") not in cam_ids:
errs.append(f"cameraCut references non-camera entity {cut.get('camera')!r}")
return errs
@app.get("/scenes")
def scenes_list():
out = []
for p in sorted(SCENES.glob("*.json")):
try:
dur = json.loads(p.read_text()).get("duration")
except Exception:
dur = None
out.append({"name": p.stem, "mtime": int(p.stat().st_mtime), "duration": dur})
return out
@app.get("/scenes/{name}")
def scene_get(name: str):
p = safe_under(SCENES, slugify(name) + ".json")
if not p.is_file():
raise HTTPException(404, "not found")
return json.loads(p.read_text())
@app.post("/scenes/{name}")
async def scene_save(name: str, request: Request):
body = await request.body()
try:
scene = json.loads(body)
except json.JSONDecodeError as e:
raise HTTPException(400, f"invalid JSON: {e}")
errs = validate_scene(scene)
if errs:
return JSONResponse({"errors": errs}, status_code=422)
p = safe_under(SCENES, slugify(name) + ".json")
tmp = p.with_suffix(".json.tmp")
tmp.write_text(json.dumps(scene, indent=2))
tmp.replace(p) # atomic
return {"ok": True, "name": p.stem}
# ---- render (M3 — design landed, encode stubbed) ----
@app.post("/render/begin")
async def render_begin(request: Request):
if not HAVE_FFMPEG:
raise HTTPException(503, "ffmpeg not found on server")
rid = uuid.uuid4().hex
(RENDERS / rid / "frames").mkdir(parents=True)
(RENDERS / rid / "meta.json").write_text((await request.body()).decode() or "{}")
return {"renderId": rid}
@app.post("/render/{rid}/frame/{n}")
async def render_frame(rid: str, n: int, request: Request):
frames = safe_under(RENDERS, rid) / "frames"
if not frames.is_dir():
raise HTTPException(404, "unknown renderId")
(frames / f"{n:06d}.png").write_bytes(await request.body())
return {"ok": True}
@app.post("/render/{rid}/end")
async def render_end(rid: str):
raise HTTPException(501, "encode not implemented until M3")
@app.get("/render/{rid}/status")
def render_status(rid: str):
raise HTTPException(501, "not implemented until M3")
@app.get("/render/{rid}/out.mp4")
def render_out(rid: str):
raise HTTPException(501, "not implemented until M3")
# ---- statics (last: catch-all /web is defined after API routes) ----
@app.get("/", response_class=HTMLResponse)
def index():
f = WEB / "index.html"
html = f.read_text() if f.is_file() else "<h1>SCENEGOD</h1><p>Lane A page not landed yet.</p>"
return HTMLResponse(html, headers={"Cache-Control": "no-store"}) # MESHGOD stale-cache lesson
@app.get("/web/{path:path}")
def web_static(path: str):
full = safe_under(WEB, path)
if not full.is_file():
raise HTTPException(404, "not found")
return FileResponse(full, headers={"Cache-Control": "max-age=60"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("SCENEGOD_PORT", 8020)))

133
scripts/test_server.py Normal file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""M1 smoke test for scenegod.server — stdlib only, real uvicorn on a test port.
python3 scripts/test_server.py # green = M1 code-done
Spins up the server against throwaway asset/scene dirs, then asserts tree
grouping, file streaming, path-traversal rejection, scene round-trip, and
validation failures.
"""
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PY = ROOT / ".venv" / "bin" / "python"
PORT = 8099
BASE = f"http://127.0.0.1:{PORT}"
def req(method, path, body=None):
data = body.encode() if isinstance(body, str) else body
r = urllib.request.Request(BASE + path, data=data, method=method)
try:
with urllib.request.urlopen(r) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def get_json(path):
st, b = req("GET", path)
assert st == 200, f"{path} -> {st}"
return json.loads(b)
def main():
tmp = Path(tempfile.mkdtemp(prefix="scenegod_test_"))
assets, scenes = tmp / "assets", tmp / "scenes"
# lady.glb + lady.fbx + lady.jpg share a stem -> one entry, thumb split off
(assets / "characters" / "pack01").mkdir(parents=True)
for f in ("lady.glb", "lady.fbx", "lady.jpg"):
(assets / "characters" / "pack01" / f).write_bytes(b"x")
(assets / "backdrops").mkdir(parents=True)
(assets / "backdrops" / "street.jpg").write_bytes(b"img")
for d in ("animations", "props", "audio"):
(assets / d).mkdir()
env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes)}
proc = subprocess.Popen(
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(PORT)],
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(50): # wait for boot
try:
req("GET", "/scenes")
break
except urllib.error.URLError:
time.sleep(0.2)
else:
raise SystemExit("server did not start")
n = 0
# --- tree grouping ---
tree = get_json("/assets/tree")
chars = tree["characters"]
assert len(chars) == 1 and chars[0]["name"] == "lady", chars
assert chars[0]["path"] == "characters/pack01/lady.glb", chars # glb preferred
assert set(chars[0]["formats"]) == {"glb", "fbx"}, chars # jpg not a format
assert chars[0]["thumb"] == "characters/pack01/lady.jpg", chars
assert tree["backdrops"][0]["path"] == "backdrops/street.jpg", tree["backdrops"]
n += 1
# --- file streaming ---
st, b = req("GET", "/assets/file?path=backdrops/street.jpg")
assert st == 200 and b == b"img", (st, b)
n += 1
# --- path traversal rejected ---
for bad in ("../../etc/hosts", "/etc/hosts", "..%2f..%2fetc%2fhosts",
"characters/../../../etc/hosts"):
st, _ = req("GET", "/assets/file?path=" + bad)
assert st >= 400, f"traversal {bad!r} not rejected: {st}"
n += 1
# --- scene round-trip ---
scene = {"version": 1, "name": "s1", "fps": 30, "duration": 5.0,
"entities": [{"id": "e1", "kind": "camera", "label": "cam",
"tracks": {"transform": [{"t": 0}, {"t": 1}]}}],
"cameraCuts": [{"t": 0, "camera": "e1"}]}
st, _ = req("POST", "/scenes/" + urllib.parse.quote("My Scene!"), json.dumps(scene))
assert st == 200, st
assert get_json("/scenes/my-scene")["duration"] == 5.0 # slugified
assert any(s["name"] == "my-scene" for s in get_json("/scenes"))
n += 1
# --- validation failures ---
bad_scenes = [
{"version": 2, "entities": []}, # bad version
{"version": 1, "entities": [{"id": "a"}, {"id": "a"}]}, # dup id
{"version": 1, "entities": [{"id": "a", "tracks": # unsorted
{"transform": [{"t": 1}, {"t": 0}]}}]},
{"version": 1, "entities": [], "cameraCuts": [{"camera": "nope"}]}, # bad cut
{"version": 1, "entities": [{"id": "a", "tracks": {"clips": [ # overlap
{"start": 0, "in": 0, "out": 2, "loop": 1, "fade": 0},
{"start": 1, "in": 0, "out": 2}]}}]},
]
for bs in bad_scenes:
st, b = req("POST", "/scenes/bad", json.dumps(bs))
assert st == 422, f"expected 422 for {bs}, got {st}"
assert json.loads(b)["errors"], b
# a fade-covered overlap is allowed
ok = {"version": 1, "entities": [{"id": "a", "tracks": {"clips": [
{"start": 0, "in": 0, "out": 2, "loop": 1, "fade": 0.5},
{"start": 1.6, "in": 0, "out": 2}]}}]}
st, _ = req("POST", "/scenes/ok-fade", json.dumps(ok))
assert st == 200, st
n += 1
print(f"OK: {n} test groups passed")
finally:
proc.terminate()
proc.wait()
if __name__ == "__main__":
main()