diff --git a/.gitignore b/.gitignore index 472d5f3..57e08e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +.venv/ assets/ scenes/ renders/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..97dc7cd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn diff --git a/scenegod/server.py b/scenegod/server.py new file mode 100644 index 0000000..6b2ecc2 --- /dev/null +++ b/scenegod/server.py @@ -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}//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 "

SCENEGOD

Lane A page not landed yet.

" + 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))) diff --git a/scripts/test_server.py b/scripts/test_server.py new file mode 100644 index 0000000..3afa6c6 --- /dev/null +++ b/scripts/test_server.py @@ -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()