[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:
parent
e29edc808b
commit
bb85eef040
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.env
|
||||
.venv/
|
||||
assets/
|
||||
scenes/
|
||||
renders/
|
||||
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
246
scenegod/server.py
Normal file
246
scenegod/server.py
Normal 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
133
scripts/test_server.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user