[m7-C] starter templates: read-only API + three working scenes

templates/ ships with the repo (two-hander-golden-hour, record-store,
empty-stage), GET /templates + /templates/{name} behind the same slug and
path-traversal guards as /scenes; presentation metadata rides in a template
key that validate_scene ignores and toJSON drops, so loading then saving
yields plain scene JSON.

Test group asserts the three list in order, round-trip, traversal/unknown
rejected, write verbs refused, and every template passes validate_scene with
every referenced asset present on disk - the check that stops a rotten
template shipping. 10 groups green.

Verified live: two-hander loads 7 entities, 8s, both performers animating,
cut fires cam1 to cam2 at 4.5s; other two load clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 22:34:53 +10:00
parent 7fea880286
commit 586a3ecbd2
9 changed files with 450 additions and 0 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
assets/
scenes/
renders/
*.log
__pycache__/
*.pyc
.DS_Store

View File

@ -172,3 +172,14 @@ videoTex.needsUpdate — r160 VideoTexture rides requestVideoFrameCallback which
never fires in hidden tabs → black plates in renders. Backlog carried: honor
audio in/out at mux (C), walk-to-mark prevPos pre-apply (A), live viseme asset
still owed by John.
### 2026-07-25 — M7-C ACCEPTED at SYNC 8.
Verified live after a server restart: /templates lists all three in order with thumbs;
two-hander-golden-hour loads 7 entities (plate, man, lady, cam1, cam2, sun, amb), 8s,
both performers carrying clips, and the cut fires cam1 to cam2 across t=4.5; record-store
(5 ents, 6s) and empty-stage (3 ents, 10s) both clean. 10 test groups green. The
anti-rot check (every referenced asset must exist) is exactly the right instinct — keep
that pattern for anything else that ships data alongside code.
NEXT for Lane C when you want it: the post-M7 backlog in PLAN §5 is yours to start —
multi-shot SEQUENCING (ordered shot list of scenes, rendered and ffmpeg-concatenated
into one film) is the biggest single unlock left in the project.

View File

@ -160,3 +160,68 @@ NEXT: M6 FLOW VIDEO PLATES server side (unlocked on this acceptance):
flow_intake.py poster-jpg intake is already in scripts/; verify video exts
in /assets/tree (mp4 not in ASSET_EXTS yet — backdrops video category will
need it) + deterministic render check once Lane A's stage.syncVideos lands.
## 2026-07-25 session 5
DONE: M7-C starter templates — server side complete, 10 test groups green.
- `templates/` at repo root (NOT gitignored — ships with the code, unlike
assets/scenes/renders). Three files, all valid against the live
`validate_scene`, every referenced path verified present in `assets/`:
- `templates/two-hander-golden-hour.json` — lady + man facing each other
(yaw +0.55 / -0.6; characters face +Z at yaw 0 per presets.js), Flow
golden-hour street plate (`mode:"video"`, `fit:"frustum"`, `groundTint`),
grammar.js `golden_hour` key+ambient verbatim (#ffb469 @2.6 / #ffd9b0 @0.55,
bg #d98a52, fog 0.02), cam1 "wide" fov40 @5m + cam2 "closer" fov32 @3.4m,
cut at t=4.5. 8s. Both carry `carrying.fbx`; lady's block is trimmed
`in:2,out:10` so the two performances don't move in lockstep.
- `templates/record-store.json` — record-store interior plate, man +
carrying.fbx, one camera fov38, warm key + ambient (fog 0.012), 6s.
- `templates/empty-stage.json` — one camera, `day` key+ambient, 10s, no
characters, no plate. The clean start.
- `scenegod/server.py:31` TEMPLATES root (`SCENEGOD_TEMPLATES`, default
`./templates`); `:261-293` the read-only endpoints: `GET /templates` ->
`[{name,title,description,thumb?}]`, `GET /templates/{name}` -> scene JSON,
both through `safe_under` + `slugify` (same guard discipline as /scenes).
No POST/DELETE by design — load a template, then `POST /scenes/{name}`.
- `scripts/test_server.py:137` templates group: lists exactly the three,
order-leads with the two-hander, round-trips each one, rejects
`../../etc/hosts` / encoded `..` / absolute / unknown (all >=400), write
verbs 404/405, and — the check that stops a rotten template shipping —
POSTs every template through the real `validate_scene` and asserts every
`source.path` / `params.video|image` / `clips[].path` / `audio[].path` /
thumb exists under `assets/`, plus camera+light `source.type == "none"`.
`OK: 10 test groups passed`.
- `.gitignore`: `*.log` (a 65KB `scenegod-8020.log` was sitting untracked at
repo root, one `git add` away from the repo).
DECISIONS:
- Presentation metadata rides INSIDE the scene JSON under a `template` key
({title, description, thumb, order}) rather than a sidecar or a filename
convention: `validate_scene` ignores unknown top-level keys and
`Timeline.load/toJSON` doesn't carry it, so a saved scene comes out as plain
scene JSON with no template residue. `order` is server-side sort only (not in
the response) so the picker leads with the wow template, not alphabetically.
- Verified before shipping, not assumed: lady.glb is a **CC_Base** rig (103
`CC_Base` strings, 0 mixamo) — safe only because Lane A's M7-A alias layer
landed; `_normalize` scales both characters to 1.7m with feet at y=0, so the
camera heights/fovs above frame them for real (wide vspan 3.65m, closer
1.97m). Both clips probed under headless Blender: carrying.fbx = 10.0s and
hiphop.fbx = 4.467s, and **both are in-place** (hips travel 10cm / 2cm), so
no template performer can wander out of frame.
- Video plates follow the orchestrator shape (`params.video`, no `image`):
`_buildBackdrop` reads `p.image || en.source.path`, and `source.path` is the
same mp4, so the fallback carries it. If Lane A ever drops that fallback,
add `image` alongside `video`.
- Cameras/lights get `source:{type:"none"}` (sync2-sunset's `"upload"` was the
bug). Light `rot` is not decorative — `_buildLight` aims a directional down
the wrapper's -Z, so every key light's euler is `lookAtEuler(pos,[0,1,0])`
computed with presets.js's own algebra.
BLOCKED/REQUESTS:
- @orchestrator: the uvicorn on :8020 is NOT `--reload` — it still 404s
`/templates`. Restart it before browser-checking.
- @Lane B (M7-B): the picker contract is `GET /templates` ->
`[{name,title,description,thumb?}]` (already ordered; `thumb` is
asset-relative -> `assets/file?path=` or `assets/thumb?path=`), then
`GET /templates/{name}` -> feed straight to `timeline.load()` /
`stage.applyState()`, and save with the existing `POST /scenes/{name}`.
NEXT: nothing outstanding in Lane C. Backlog if wanted: a fourth template
once a prop/screen asset bank exists, and the still-unverified MediaRecorder
draft path (carried from session 3).

View File

@ -696,3 +696,34 @@ INFO: 127.0.0.1:61602 - "POST /render/b727c44ecdd846aba6da356db2b7afa2/frame
INFO: 127.0.0.1:61602 - "POST /render/b727c44ecdd846aba6da356db2b7afa2/frame/89 HTTP/1.1" 200 OK
INFO: 127.0.0.1:61602 - "POST /render/b727c44ecdd846aba6da356db2b7afa2/end HTTP/1.1" 200 OK
INFO: 127.0.0.1:61602 - "GET /render/b727c44ecdd846aba6da356db2b7afa2/status HTTP/1.1" 200 OK
INFO: 127.0.0.1:64658 - "GET /templates HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:64659 - "GET /templates HTTP/1.1" 404 Not Found
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [54956]
INFO: Started server process [88415]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit)
INFO: 127.0.0.1:65075 - "GET /templates HTTP/1.1" 200 OK
INFO: 127.0.0.1:65153 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:65153 - "GET /web/style.css HTTP/1.1" 200 OK
INFO: 127.0.0.1:65156 - "GET /web/stage.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65157 - "GET /web/dock.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65157 - "GET /web/room3d.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65153 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:65156 - "GET /web/presets.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65157 - "GET /assets/tree HTTP/1.1" 200 OK
INFO: 127.0.0.1:65156 - "GET /web/grammar.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65156 - "GET /director HTTP/1.1" 405 Method Not Allowed
INFO: 127.0.0.1:65157 - "GET /web/timeline.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65157 - "GET /web/tlui.js HTTP/1.1" 200 OK
INFO: 127.0.0.1:65228 - "GET /templates/two-hander-golden-hour HTTP/1.1" 200 OK
INFO: 127.0.0.1:65228 - "GET /assets/file?path=characters%2Ftest%2Flady.glb HTTP/1.1" 200 OK
INFO: 127.0.0.1:49198 - "GET /templates/record-store HTTP/1.1" 200 OK
INFO: 127.0.0.1:49198 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:49200 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:49200 - "GET /assets/file?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.mp4 HTTP/1.1" 206 Partial Content
INFO: 127.0.0.1:49201 - "GET /assets/thumb?path=backdrops%2Fvideo%2Fscenegod_plates%2Frecord_store_interior.mp4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:49200 - "GET /templates/empty-stage HTTP/1.1" 200 OK

View File

@ -28,6 +28,7 @@ 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()
TEMPLATES = Path(os.environ.get("SCENEGOD_TEMPLATES", ROOT / "templates")).resolve()
MODEL_EXTS = {"glb", "gltf", "fbx", "obj", "bvh"}
IMG_EXTS = {"jpg", "jpeg", "png", "webp"}
@ -257,6 +258,42 @@ async def scene_save(name: str, request: Request):
return {"ok": True, "name": p.stem}
# ---- starter templates (M7-C) ----
# `templates/` ships WITH the repo (not gitignored like scenes/), so the app has
# something to open on instead of an empty grid. READ-ONLY on purpose: there is no
# POST/DELETE here — "new from template" is GET /templates/{name} followed by an
# ordinary POST /scenes/{name}. Presentation metadata rides in a `template` key
# (title/description/thumb/order) that validate_scene ignores and that never
# survives a Timeline round-trip, so saved scenes stay plain scene JSON.
def _template_meta(p: Path) -> dict | None:
try:
t = (json.loads(p.read_text()).get("template") or {})
except (OSError, json.JSONDecodeError):
return None # a broken file is not a template
m = {"name": p.stem, "title": t.get("title") or p.stem,
"description": t.get("description") or "", "_order": t.get("order", 100)}
if t.get("thumb"):
m["thumb"] = t["thumb"] # asset-relative → GET /assets/file?path=
return m
@app.get("/templates")
def templates_list():
if not TEMPLATES.is_dir():
return []
metas = [m for m in (_template_meta(p) for p in TEMPLATES.glob("*.json")) if m]
metas.sort(key=lambda m: (m["_order"], m["name"])) # the wow one first, not alphabetical
return [{k: v for k, v in m.items() if k != "_order"} for m in metas]
@app.get("/templates/{name}")
def template_get(name: str):
p = safe_under(TEMPLATES, slugify(name) + ".json") # same guard discipline as /scenes
if not p.is_file():
raise HTTPException(404, "not found")
return json.loads(p.read_text())
# ---- render (C5) ----
# ponytail: in-memory state, single-process local tool. A server restart
# mid-encode loses live status; status() falls back to on-disk out.mp4.

View File

@ -134,6 +134,50 @@ def main():
assert st == 200, st
n += 1
# --- starter templates (M7-C): listed, read-only, guarded, and NOT ROTTEN ---
tpl = get_json("/templates")
assert sorted(t["name"] for t in tpl) == [
"empty-stage", "record-store", "two-hander-golden-hour"], tpl
assert tpl[0]["name"] == "two-hander-golden-hour", "order: lead with the wow one"
for t in tpl:
assert t["title"] and t["description"], t
# traversal / absolute / URL-encoded .. / unknown name
for bad in ("../../etc/hosts", "..%2f..%2fetc%2fhosts", "%2e%2e%2f%2e%2e%2fetc%2fhosts",
urllib.parse.quote("/etc/hosts", safe=""), "nope"):
st, _ = req("GET", "/templates/" + bad)
assert st >= 400, f"template path {bad!r} not rejected: {st}"
# read-only: no write verbs
assert req("POST", "/templates/empty-stage", "{}")[0] in (404, 405)
assert req("DELETE", "/templates/empty-stage")[0] in (404, 405)
real_assets = ROOT / "assets"
for t in tpl:
js = get_json("/templates/" + t["name"])
assert js["version"] == 1 and js["name"] == t["name"], js["name"]
# round-trips through the REAL validator: save it as a scene like the UI does
st, b = req("POST", "/scenes/tplcheck-" + t["name"], json.dumps(js))
assert st == 200, f"template {t['name']} fails validate_scene: {b}"
refs = [t["thumb"]] if t.get("thumb") else []
for e in js["entities"]:
src = e.get("source") or {}
if e["kind"] in ("camera", "light"):
assert src.get("type") == "none", \
f"{t['name']}/{e['id']}: {e['kind']} must have source type 'none'"
if src.get("type") == "assets":
refs.append(src["path"])
p = e.get("params") or {}
refs += [p[k] for k in ("video", "image") if p.get(k)]
refs += [c["path"] for c in (e.get("tracks") or {}).get("clips") or []]
refs += [a["path"] for a in js.get("audio") or []]
if real_assets.is_dir(): # the check that stops a rotten template shipping
for r in refs:
assert (real_assets / r).is_file(), \
f"template {t['name']}: missing asset {r!r}"
assert refs or t["name"] == "empty-stage", f"{t['name']} references nothing"
if not real_assets.is_dir():
print("(template asset-existence check skipped — no assets/ dir)")
n += 1
# --- render pipeline: begin -> post 30 PNGs -> end -> poll -> mp4 ---
if shutil.which("ffmpeg") and shutil.which("ffprobe"):
fr = tmp / "genframes"

View File

@ -0,0 +1,42 @@
{
"version": 1,
"name": "empty-stage",
"template": {
"title": "Empty stage",
"description": "A clean 10-second stage: one camera at eye height, a daylight key and ambient, nothing else. Drag your own characters and plates in.",
"order": 3
},
"fps": 30,
"duration": 10,
"entities": [
{
"id": "cam1",
"kind": "camera",
"label": "cam",
"source": { "type": "none" },
"params": { "fov": 40 },
"transform": { "pos": [0, 1.6, 6], "rot": [-0.0997, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "sun",
"kind": "light",
"label": "key",
"source": { "type": "none" },
"params": { "type": "key", "color": "#fff6e4", "intensity": 3, "castShadow": true },
"transform": { "pos": [6.58, 16.38, 9.4], "rot": [-1.0222, 0.35, 0.5113], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "amb",
"kind": "light",
"label": "ambient",
"source": { "type": "none" },
"params": { "type": "ambient", "color": "#dfe9ff", "intensity": 1.15, "bg": "#8fb0d6" },
"transform": { "pos": [0, 3, 0], "rot": [0, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
}
],
"cameraCuts": [{ "t": 0, "camera": "cam1" }],
"audio": []
}

View File

@ -0,0 +1,92 @@
{
"version": 1,
"name": "record-store",
"template": {
"title": "Record store",
"description": "One character in a warm record-shop interior plate, single camera, 6 seconds. The simplest one-shot to build a scene on top of.",
"thumb": "backdrops/video/scenegod_plates/record_store_interior.jpg",
"order": 2
},
"fps": 30,
"duration": 6,
"entities": [
{
"id": "plate",
"kind": "backdrop",
"label": "record store interior",
"source": {
"type": "assets",
"path": "backdrops/video/scenegod_plates/record_store_interior.mp4"
},
"params": {
"mode": "video",
"video": "backdrops/video/scenegod_plates/record_store_interior.mp4",
"width": 12,
"fit": "frustum",
"fitDist": 12,
"groundTint": true,
"videoStart": 0
},
"transform": { "pos": [0, 0, -7], "rot": [0, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "man",
"kind": "character",
"label": "man",
"source": { "type": "assets", "path": "characters/test/man.fbx" },
"params": {},
"transform": { "pos": [0, 0, 0], "rot": [0, 0.18, 0], "scale": 1 },
"tracks": {
"transform": [],
"params": [],
"clips": [
{
"path": "animations/test/carrying.fbx",
"clipIndex": 0,
"start": 0,
"in": 0,
"out": 6,
"loop": 1,
"fade": 0
}
]
}
},
{
"id": "cam1",
"kind": "camera",
"label": "shot",
"source": { "type": "none" },
"params": { "fov": 38 },
"transform": { "pos": [0.6, 1.45, 3.4], "rot": [-0.1171, 0.1735, 0.0203], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "sun",
"kind": "light",
"label": "key",
"source": { "type": "none" },
"params": { "type": "key", "color": "#ffcf9a", "intensity": 2, "castShadow": true },
"transform": { "pos": [-4.85, 8.03, 10.39], "rot": [-0.5949, -0.3689, -0.2393], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "amb",
"kind": "light",
"label": "ambient",
"source": { "type": "none" },
"params": {
"type": "ambient",
"color": "#ffdcb4",
"intensity": 0.75,
"bg": "#2a1d14",
"fog": 0.012
},
"transform": { "pos": [0, 3, 0], "rot": [0, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
}
],
"cameraCuts": [{ "t": 0, "camera": "cam1" }],
"audio": []
}

View File

@ -0,0 +1,127 @@
{
"version": 1,
"name": "two-hander-golden-hour",
"template": {
"title": "Two-hander — golden hour",
"description": "The lady and the man face each other on a golden-hour street plate. Wide master, cut at 4.5s to a tighter angled two-shot. 8 seconds, both performers already carrying a clip.",
"thumb": "backdrops/video/scenegod_plates/suburb_strip_goldenhour.jpg",
"order": 1
},
"fps": 30,
"duration": 8,
"entities": [
{
"id": "plate",
"kind": "backdrop",
"label": "golden hour street",
"source": {
"type": "assets",
"path": "backdrops/video/scenegod_plates/suburb_strip_goldenhour.mp4"
},
"params": {
"mode": "video",
"video": "backdrops/video/scenegod_plates/suburb_strip_goldenhour.mp4",
"width": 12,
"fit": "frustum",
"fitDist": 14,
"groundTint": true,
"videoStart": 0
},
"transform": { "pos": [0, 0, -8], "rot": [0, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "man",
"kind": "character",
"label": "man",
"source": { "type": "assets", "path": "characters/test/man.fbx" },
"params": {},
"transform": { "pos": [-1, 0, 0], "rot": [0, 0.55, 0], "scale": 1 },
"tracks": {
"transform": [],
"params": [],
"clips": [
{
"path": "animations/test/carrying.fbx",
"clipIndex": 0,
"start": 0,
"in": 0,
"out": 8,
"loop": 1,
"fade": 0
}
]
}
},
{
"id": "lady",
"kind": "character",
"label": "lady",
"source": { "type": "assets", "path": "characters/test/lady.glb" },
"params": {},
"transform": { "pos": [1, 0, 0.25], "rot": [0, -0.6, 0], "scale": 1 },
"tracks": {
"transform": [],
"params": [],
"clips": [
{
"path": "animations/test/carrying.fbx",
"clipIndex": 0,
"start": 0,
"in": 2,
"out": 10,
"loop": 1,
"fade": 0
}
]
}
},
{
"id": "cam1",
"kind": "camera",
"label": "wide",
"source": { "type": "none" },
"params": { "fov": 40 },
"transform": { "pos": [0, 1.5, 5], "rot": [-0.0898, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "cam2",
"kind": "camera",
"label": "closer",
"source": { "type": "none" },
"params": { "fov": 32 },
"transform": { "pos": [1.9, 1.45, 3], "rot": [-0.0875, 0.5862, 0.0485], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "sun",
"kind": "light",
"label": "sun",
"source": { "type": "none" },
"params": { "type": "key", "color": "#ffb469", "intensity": 2.6, "castShadow": true },
"transform": { "pos": [-16.18, 3.13, 11.33], "rot": [-0.1858, -0.9517, -0.1519], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
},
{
"id": "amb",
"kind": "light",
"label": "ambient",
"source": { "type": "none" },
"params": {
"type": "ambient",
"color": "#ffd9b0",
"intensity": 0.55,
"bg": "#d98a52",
"fog": 0.02
},
"transform": { "pos": [0, 3, 0], "rot": [0, 0, 0], "scale": 1 },
"tracks": { "transform": [], "params": [], "clips": [] }
}
],
"cameraCuts": [
{ "t": 0, "camera": "cam1" },
{ "t": 4.5, "camera": "cam2" }
],
"audio": []
}