From 586a3ecbd22f477c5ceb8e2a39b3ecce155dc4f3 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 22:34:53 +1000 Subject: [PATCH] [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 --- .gitignore | 1 + lanes/C-server.md | 11 +++ logs/laneC.md | 65 +++++++++++++ scenegod-8020.log | 31 +++++++ scenegod/server.py | 37 ++++++++ scripts/test_server.py | 44 +++++++++ templates/empty-stage.json | 42 +++++++++ templates/record-store.json | 92 +++++++++++++++++++ templates/two-hander-golden-hour.json | 127 ++++++++++++++++++++++++++ 9 files changed, 450 insertions(+) create mode 100644 templates/empty-stage.json create mode 100644 templates/record-store.json create mode 100644 templates/two-hander-golden-hour.json diff --git a/.gitignore b/.gitignore index 57e08e8..9b9193b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ assets/ scenes/ renders/ +*.log __pycache__/ *.pyc .DS_Store diff --git a/lanes/C-server.md b/lanes/C-server.md index 470c478..ab42c80 100644 --- a/lanes/C-server.md +++ b/lanes/C-server.md @@ -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. diff --git a/logs/laneC.md b/logs/laneC.md index 3b713ed..e24a29b 100644 --- a/logs/laneC.md +++ b/logs/laneC.md @@ -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). diff --git a/scenegod-8020.log b/scenegod-8020.log index c747910..ae665f1 100644 --- a/scenegod-8020.log +++ b/scenegod-8020.log @@ -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 diff --git a/scenegod/server.py b/scenegod/server.py index f6b0ae0..78d413c 100644 --- a/scenegod/server.py +++ b/scenegod/server.py @@ -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. diff --git a/scripts/test_server.py b/scripts/test_server.py index 6345c5e..fc4385c 100644 --- a/scripts/test_server.py +++ b/scripts/test_server.py @@ -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" diff --git a/templates/empty-stage.json b/templates/empty-stage.json new file mode 100644 index 0000000..374661d --- /dev/null +++ b/templates/empty-stage.json @@ -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": [] +} diff --git a/templates/record-store.json b/templates/record-store.json new file mode 100644 index 0000000..02f74f4 --- /dev/null +++ b/templates/record-store.json @@ -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": [] +} diff --git a/templates/two-hander-golden-hour.json b/templates/two-hander-golden-hour.json new file mode 100644 index 0000000..6f49ec6 --- /dev/null +++ b/templates/two-hander-golden-hour.json @@ -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": [] +}