Compare commits

...

5 Commits

Author SHA1 Message Date
type-two
de3416869f [laneA] M1: Stage viewport, retarget port, dock+inspector
- room3d.js: pure-module port of rigroom retarget, target parameterized;
  _selftest bakes across two mismatched-rest skeletons (maxErr 6.4e-12)
- stage.js: Stage class (IBL, gizmo, entities, clip bake cache) per PLAN §4.2
- dock.js: asset dock + inspector, offline file-drop + clip retarget
- index.html/style.css: grid layout, guarded timeline import (page survives B absent)

Verified in-browser end-to-end: Mixamo char loaded+normalized, backdrop plane,
gizmo move, running clip retargeted (66 tracks) and animating live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:06:22 +10:00
type-two
b20354c53f [laneB] log: M1 session 1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:57:52 +10:00
type-two
45debd3c10 [laneB] M1: timeline model+clock, panel UI, stub, self-check
- timeline.js: headless clock/tracks/evaluate (transform slerp, param lerp,
  clip blocks with fade-out, camera cuts, undo stack). No three/DOM import.
- tlui.js: canvas panel — ruler scrub, playhead, key add/drag/delete, clip
  blocks, camera cuts, save/load with localStorage fallback. Injects own CSS.
- stagestub.js: dev Stage per PLAN §4.2 (deleted at SYNC 1).
- timeline_test.mjs: node self-check, all assertions pass.
- tldev.html: dev-only browser harness (deleted at SYNC 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:57:18 +10:00
type-two
f6292f2c84 [laneC] log: M1 done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:54:31 +10:00
type-two
bb85eef040 [laneC] M1 server: statics, assets tree/file, scenes CRUD+validation, render stubs, tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:53:52 +10:00
18 changed files with 2207 additions and 2 deletions

1
.gitignore vendored
View File

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

View File

@ -9,3 +9,50 @@ Format per session:
NEXT: …
(no sessions yet)
## 2026-07-18 session 1
DONE: M1 shipped and verified end-to-end in a browser.
- `web/room3d.js` — ported the rigroom retarget as a pure ES module (no DOM/globals).
Exports parseAny/canon/boneMap/captureRest/bakeRetarget/disposeRoot/stats + `_selftest`.
bakeRetarget is now `(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts?)` — target is a
parameter instead of the closed-over `char`. Algorithm kept byte-for-byte (30fps world-delta
bake, hips-height ratio, quat sign continuity, dup-hierarchy dedupe). Added `.bvh` to parseAny.
- `web/stage.js``class Stage` per PLAN §4.2. IBL (RoomEnvironment+PMREM), grid+circle floor,
OrbitControls director cam, TransformControls gizmo (W/E/R), raycast select, shadow-casting.
addEntity handles character/prop/backdrop(plane|corner|dome)/camera/light; wrapper-Group holds
all transforms, loaded root normalized+grounded underneath. prepareClip caches parse-per-path and
bake-per-(entity,path,index). captureState/applyState round-trip entities.
- `web/dock.js` — asset dock (Characters/Props/Backdrops/Animations tabs from `/assets/tree`,
offline→file-drop) + inspector (transform/params editors, ⏺key dispatches `scenegod:capturekey`,
delete). Local drop: image→backdrop, mesh→character, clip-onto-selected-character→bake+play.
- `web/index.html` + `web/style.css` — CSS-grid layout (dock/view/inspector/timeline), rigroom
importmap (three 0.160.0), `?selftest=1` badge. Timeline loaded via guarded dynamic import so the
page never breaks when Lane B's file is absent.
VERIFICATION (browser, in-app):
- `?selftest=1`: PASS — two 3-bone skeletons, different rests+namespaces, baked 1s clip,
world-delta match maxErr=6.4e-12, srcMoved=0.224rad (guard against a silently-frozen source).
- Real end-to-end (Female_mixamo.fbx char + "Running.fbx" clip + a jpg backdrop, served from a
throwaway scratchpad harness): char loaded (65 bones, 18.6k tris, normalized), gizmo-moved,
clip retargeted (66 tracks) and animating live. Screenshot: `logs/shots/m1-lady-running.png`.
- Page boots with zero console errors with BOTH timeline.js and the server absent.
DECISIONS:
- selftest uses underscore namespaces (`mixamorig1_`/`mixamorig4_`), not colon — a colon in a bone
name breaks three's PropertyBinding so the source clip wouldn't bind (silent pass). canon() still
collapses both to the same key, which is the thing under test.
- Added `Stage.prepareClipUpload(id, buf, name, idx)` (bake from in-memory bytes) so the offline
file-drop path can retarget without a server. `Stage.setMixerAuto(false)` lets Lane B take over
mixer stepping at SYNC 1 (default true so dropped clips preview standalone now).
- backdrop param edits (mode/image/width) don't rebuild the mesh yet — marked M2. fov/light
color/intensity are live.
BLOCKED/REQUESTS:
- @Lane C: please confirm the item shape inside `/assets/tree` arrays. dock.js tolerates both a
bare path string and `{path,name,files:[{path,ext}]}` (rigroom-style) for now — tell me the real
one and I'll drop the fallback. Also confirm the page is served such that absolute `/web/*.js`
imports resolve (I used `/web/...` to be URL-independent).
NEXT (M2, do not start until orchestrator flips the milestone line):
- multiple cameras + PiP overlay via renderActiveCamera, light entity param plumbing polish,
backdrop rebuild-on-param-change, drag-from-dock onto viewport (currently click-to-add).

View File

@ -8,4 +8,54 @@ Format per session:
BLOCKED/REQUESTS: …
NEXT: …
(no sessions yet)
## 2026-07-18 session 1
DONE: M1 complete. All files under `scenegod/web/`.
- `timeline.js` — model + clock, no DOM, no three import (self-contained
quat/euler/slerp math so `node` can run it). `load/toJSON` lossless
round-trip (unknown entity fields preserved via structuredClone + a
WeakMap for cached quats). `evaluate(t)`: transform (pos/scale lerp, rot
slerp→euler, easing incl. step-hold), params (num lerp, color sRGB lerp),
clip blocks (set-time exact scrubbing, fade-out ramp), camera cuts (fires
only on change). Clock: `play()` rAF deltas, `seek` clamp, `step(frame)`
pure/deterministic for Lane C render. Mutators addKey/moveKey/deleteKey/
addClipBlock/moveClipBlock/trimClipBlock/addCut + undo stack (`undo()`
wired; Ctrl+Z hooked in tlui). `capturekey` window listener present.
Clips need `await tl.preload()` before evaluate (prepareClip is async).
- `tlui.js` — canvas panel into `#timeline`. Ruler scrub, drag playhead,
space=play/pause, dblclick lane=add key from stage state, drag key (snap
to frame, Alt=free), right-click=delete, drag clip block/edge, Home/End,
Ctrl+Z. Scene bar name/dur + Save/Load (POST/GET `/scenes/{name}`,
localStorage fallback). Names column auto-syncs to `stage.entities()` via
`onChange`.
- `stagestub.js` — full §4.2 Stage as plain objects; records effects into
`stage.applied[]`. Dev-only, delete at SYNC 1.
- `timeline_test.mjs``node scenegod/web/timeline_test.mjs` → green
(midpoint interp, step-ease hold, clip local time @3 frames, camera flip,
lossless round-trip, unknown-field preservation).
- `tldev.html` — dev-only browser harness. Verified in browser: scrub moves
entities (interp live in log), play runs to duration + auto-stops, camera
cut flips camA→camB at t=3, keys/clips/cuts render. Delete at SYNC 1.
DECISIONS:
- Timeline imports NO three.js — inlined euler↔quat + slerp (~30 lines) so
the node self-check needs zero deps (there is no node_modules/three in
this stack). setTransform rot is euler (matches entityTransform format).
- One `<canvas>` for all lanes (not canvas-per-lane); hit-testing via a
hitbox list rebuilt each draw. Simpler, fast enough at this scale.
- CSS injected from tlui.js via a `<style id=laneB-tl-css>` rather than
creating `web/style.css` — that file is Lane A's and didn't exist yet.
Move into a `/* === LANE B === */` block at SYNC if John wants it
centralized. (See REQUEST below.)
- M1 crossfade = fade-OUT only; cross-block fade-IN is M2 "crossfade polish"
per the milestone list. Marked with a ponytail comment in timeline.js.
BLOCKED/REQUESTS:
- None blocking. Note for orchestrator/Lane A: my timeline styles live in
an injected `<style>` in tlui.js. If you'd rather they sit in style.css,
say so and I'll emit a `/* === LANE B === */` block for Lane A to paste
(I won't edit style.css directly — ownership).
- Lane A's files (index.html, stage.js, room3d.js, dock.js, style.css) are
present untracked in the shared working dir but uncommitted — I staged
ONLY my 5 files, left theirs alone.
NEXT: Hold at M1 boundary. At SYNC 1 the stub swaps for the real Stage
(one-line change in the page import) and tldev.html/stagestub.js get
deleted. M2 (locked): clip crossfade polish, params/cut lane UI, snapping
config, undo Ctrl+Z already stubbed, box-select multi-drag.

View File

@ -8,4 +8,30 @@ Format per session:
BLOCKED/REQUESTS: …
NEXT: …
(no sessions yet)
## 2026-07-18 session 1
DONE: M1 code-complete. Commit bb85eef.
- `requirements.txt` (fastapi, uvicorn only); `.gitignore` += `.venv/`.
- `scenegod/server.py` single file: statics (`/` no-store placeholder,
`/web/{path}` short-cache, path-guarded), `/assets/tree` (5s cache,
per-category grouping), `/assets/file` (traversal-guarded), `/scenes`
CRUD with atomic write + slugify + `validate_scene`, render endpoints
(begin/frame accept + save; end/status/out 501 until M3).
- `scripts/test_server.py`: 5 groups green (tree grouping, file stream,
4 traversal attempts rejected, scene round-trip w/ slugify, 5 validation
failures + 1 fade-overlap-allowed). `OK: 5 test groups passed`.
DECISIONS:
- System python3 is 3.14 (fastapi present but pydantic missing); made a
`.venv` on python3.11 per CLAUDE.md stack rule. Tests + server use
`.venv/bin/python`. `.venv/` gitignored.
- `web/` is under `scenegod/web/` per PLAN §2 (not repo root) — `WEB`
points there; Lane A/B statics resolve via `/web/{path}`.
- Asset grouping: per-category primary ext set (models for chars/anims/
props, images for backdrops, audio for audio). Non-primary image sharing
a stem → `thumb`, not a `format`. `path` prefers glb else first model.
Entries with no primary format dropped (thumb-only stems aren't assets).
- Clip-overlap check: violation if `next.start < a.end - a.fade`, where
`a.end = start + (out-in)*loop`. Fade-region overlap allowed.
BLOCKED/REQUESTS: none.
NEXT: M2 (when unlocked): scene-validation hardening + asset thumbnails
endpoint. M3 render pipeline (ffmpeg encode + web/render.js) is designed in
C-server.md §C5, endpoints already stubbed — implement at SYNC-2/M3.

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

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)))

187
scenegod/web/dock.js Normal file
View File

@ -0,0 +1,187 @@
// dock.js — asset dock (left) + inspector (right). Talks to Stage's public API only.
// M1: click a card to add; click an animation to bake onto the selected character; drop local
// files onto the view for offline/upload work. M2: Lane B takes over clip placement.
import { stats } from './room3d.js';
const TABS = ['characters', 'props', 'backdrops', 'animations'];
export class Dock {
constructor({ stage, dockEl, inspectorEl, viewEl }){
this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl;
this.tree = { characters:[], props:[], backdrops:[], animations:[] };
this.tab = 'characters';
stage.onSelect(en => this.renderInspector(en));
stage.onChange(en => { if(en && en.id === this._selId) this.renderInspector(en); });
this._selId = null;
this._buildTabs();
this._dropZone();
this.load();
}
async load(){
try {
const r = await fetch('/assets/tree');
if(!r.ok) throw new Error(r.status);
this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) };
} catch(e){
this._offline = true;
this.tree = { characters:[], props:[], backdrops:[], animations:[] };
}
this.renderBrowser();
}
_buildTabs(){
const bar = document.createElement('div'); bar.className = 'tabs';
for(const t of TABS){
const b = document.createElement('button'); b.textContent = t.toUpperCase();
b.className = t === this.tab ? 'on' : '';
b.onclick = () => { this.tab = t; [...bar.children].forEach(c => c.classList.toggle('on', c === b)); this.renderBrowser(); };
bar.appendChild(b);
}
this.dock.appendChild(bar);
this.list = document.createElement('div'); this.list.className = 'cards';
this.dock.appendChild(this.list);
}
// tolerate string paths or {path,name,files:[{path,ext}]} until Lane C nails the shape (logged REQUEST)
_path(it){ return typeof it === 'string' ? it
: (it.path || (it.files && it.files[0] && it.files[0].path) || it.file || ''); }
_name(it){ return typeof it === 'string' ? it.split('/').pop()
: (it.name || this._path(it).split('/').pop()); }
renderBrowser(){
this.list.innerHTML = '';
const items = this.tree[this.tab] || [];
if(!items.length){
this.list.innerHTML = `<div class="empty">${this._offline
? 'server offline — drag a .glb / .fbx / image onto the view'
: 'nothing here — drop banks into assets/' + this.tab + '/'}</div>`;
return;
}
const icon = { characters:'🕺', props:'🪑', backdrops:'🖼', animations:'🏃' }[this.tab];
for(const it of items){
const c = document.createElement('div'); c.className = 'card';
c.innerHTML = `<div class="th">${icon}</div><div class="nm">${this._name(it)}</div>`;
c.onclick = () => this._addFromLibrary(it);
this.list.appendChild(c);
}
}
async _addFromLibrary(it){
const path = this._path(it);
try {
if(this.tab === 'animations'){
const en = this.stage.getEntity(this._selId);
if(!en || en.kind !== 'character') return this._toast('select a character first');
this._toast('baking clip…');
const clip = await this.stage.prepareClip(en.id, path, 0);
this.stage.playClip(en.id, clip);
this._toast('▶ ' + this._name(it));
return;
}
const kind = this.tab === 'characters' ? 'character' : this.tab === 'props' ? 'prop' : 'backdrop';
const desc = { kind, label: this._name(it), source: { type:'assets', path } };
if(kind === 'backdrop') desc.params = { mode:'plane', image:path, width:10 };
this._toast('loading ' + this._name(it) + '…');
const en = await this.stage.addEntity(desc);
this.stage.select(en.id);
this._toast('');
} catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); }
}
// ---- inspector ----
renderInspector(en){
this._selId = en ? en.id : null;
const el = this.insp; el.innerHTML = '';
if(!en){ el.innerHTML = '<div class="empty">nothing selected</div>'; return; }
const t = this.stage.entityTransform(en.id);
const h = document.createElement('div'); h.className = 'ihd';
h.innerHTML = `<b>${en.label}</b><span class="kind">${en.kind}${
en.source && en.source.type === 'upload' ? ' · <span class="warn">upload</span>' : ''}</span>`;
el.appendChild(h);
el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v })));
el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v })));
el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v })));
if(en.kind === 'camera')
el.appendChild(this._num('fov', en.params.fov ?? 45, v => this.stage.setParam(en.id, 'fov', v)));
if(en.kind === 'light'){
el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this.stage.setParam(en.id, 'intensity', v)));
el.appendChild(this._color('color', en.params.color || '#ffffff', v => this.stage.setParam(en.id, 'color', v)));
}
if(en.kind === 'backdrop')
el.appendChild(this._sel('mode', ['plane','corner','dome'], en.params.mode || 'plane',
v => this.stage.setParam(en.id, 'mode', v))); // rebuild is M2
const row = document.createElement('div'); row.className = 'ibtns';
const key = document.createElement('button'); key.textContent = '⏺ key'; key.title = 'capture keyframe';
key.onclick = () => dispatchEvent(new CustomEvent('scenegod:capturekey', { detail:{ id: en.id } }));
const del = document.createElement('button'); del.textContent = '🗑 delete'; del.className = 'danger';
del.onclick = () => { this.stage.removeEntity(en.id); this.renderInspector(null); };
row.append(key, del); el.appendChild(row);
}
_field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld';
w.innerHTML = `<span>${label}</span>`; w.appendChild(inputEl); return w; }
_num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val;
i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); }
_color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val;
i.oninput = () => cb(i.value); return this._field(label, i); }
_sel(label, opts, val, cb){ const s = document.createElement('select');
for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); }
s.onchange = () => cb(s.value); return this._field(label, s); }
_vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec';
w.innerHTML = `<span>${label}</span>`;
const cur = [...arr];
['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3);
i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); });
return w; }
// ---- local file drop (upload / offline path) ----
_dropZone(){
addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('hot'); });
addEventListener('dragleave', e => { if(e.target === document.body) document.body.classList.remove('hot'); });
addEventListener('drop', async e => {
e.preventDefault(); document.body.classList.remove('hot');
for(const f of e.dataTransfer.files){
const nm = f.name.toLowerCase();
try {
if(/\.(png|jpe?g|webp)$/.test(nm)){
const url = URL.createObjectURL(f);
const en = await this.stage.addEntity({ kind:'backdrop', label:f.name,
source:{ type:'upload', path:f.name, _url:url }, params:{ mode:'plane' } });
this.stage.select(en.id);
} else if(/\.(glb|gltf|fbx|obj|bvh)$/.test(nm)){
const buf = await f.arrayBuffer();
// clip drop onto selected character → bake; else load as a character
const sel = this.stage.getEntity(this._selId);
const probe = await this._probe(buf, f.name);
if(sel && sel.kind === 'character' && probe.anims && !probe.meshes){
this._toast('baking ' + f.name + '…');
const clip = await this.stage.prepareClipUpload(sel.id, buf, f.name, 0);
this.stage.playClip(sel.id, clip); this._toast('▶ ' + f.name);
} else {
const en = await this.stage.addEntity({ kind:'character', label:f.name,
source:{ type:'upload', path:f.name }, _buf:buf });
this.stage.select(en.id); this._toast('');
}
}
} catch(err){ this._toast('drop failed: ' + String(err).slice(0, 100)); }
}
});
}
// cheap probe: is this file animation-only (clip) or a mesh (character)?
async _probe(buf, name){
const { parseAny } = await import('./room3d.js');
const { root, anims } = await parseAny(buf.slice(0), name);
const st = stats(root);
return { anims: anims.length > 0, meshes: st.meshes };
}
_toast(m){ let t = document.getElementById('toast');
if(!t){ t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
t.textContent = m; t.style.display = m ? 'block' : 'none';
clearTimeout(this._th); if(m) this._th = setTimeout(() => t.style.display = 'none', 3500);
}
}

57
scenegod/web/index.html Normal file
View File

@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SCENEGOD — machinima stage</title>
<link rel="stylesheet" href="/web/style.css">
</head>
<body>
<header>
<h1>🎬 SCENEGOD</h1><span class="tag">machinima stage</span>
<span id="selftest"></span>
</header>
<div id="app">
<aside id="dock"></aside>
<div id="view"></div>
<aside id="inspector"><div class="empty">nothing selected</div></aside>
<div id="timeline"><div class="empty">timeline — Lane B mounts here</div></div>
</div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import { Stage } from '/web/stage.js';
import { Dock } from '/web/dock.js';
// retarget self-check (?selftest=1) — proves the crown-jewel bake before anything else
if(new URLSearchParams(location.search).has('selftest')){
const { _selftest } = await import('/web/room3d.js');
const r = _selftest();
const el = document.getElementById('selftest');
el.textContent = `selftest: ${r.ok ? 'PASS' : 'FAIL'} (maxErr=${r.maxErr.toExponential(2)})`;
el.className = r.ok ? 'ok' : 'bad';
console.log('[room3d selftest]', r);
}
const stage = new Stage(document.getElementById('view'));
const dock = new Dock({
stage,
dockEl: document.getElementById('dock'),
inspectorEl: document.getElementById('inspector'),
viewEl: document.getElementById('view'),
});
// Timeline is optional — the page must never break when Lane B's file is absent
try {
const { Timeline } = await import('/web/timeline.js');
window.timeline = new Timeline(stage);
} catch(e){ /* timeline.js not present yet */ }
window.stage = stage; window.dock = dock;
</script>
</body>
</html>

171
scenegod/web/room3d.js Normal file
View File

@ -0,0 +1,171 @@
// room3d.js — loaders + Mixamo retarget, ported from MESHGOD rigroom.html.
// Pure module: no DOM, no globals, no toast — throw Errors, callers handle UI.
// The bake algorithm is byte-for-byte rigroom's; only the target is now a
// parameter instead of a closed-over global.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
export async function parseAny(buf, name){
name = (name||'').toLowerCase();
// extensionless routes (gallery/<id>/glb) + magic-byte fallback: gltf binary starts "glTF"
if(!/\.(glb|gltf|fbx|obj|bvh)$/.test(name)){
const head = new TextDecoder().decode(new Uint8Array(buf, 0, Math.min(16, buf.byteLength)));
if(head.startsWith('glTF')) name += '.glb';
else if(head.includes('Kaydara')) name += '.fbx';
else if(head.includes('HIERARCHY')) name += '.bvh';
else name += '.glb'; // worst case the loader throws a clear error
}
if(name.endsWith('.glb') || name.endsWith('.gltf')){
const g = await new GLTFLoader().parseAsync(buf, ''); return {root:g.scene, anims:g.animations||[]}; }
if(name.endsWith('.fbx')){ const r = new FBXLoader().parse(buf, ''); return {root:r, anims:r.animations||[]}; }
if(name.endsWith('.obj')){ const r = new OBJLoader().parse(new TextDecoder().decode(buf));
r.traverse(o=>{ if(o.isMesh && !o.material.map) o.material = new THREE.MeshStandardMaterial({color:0x9aa4af, roughness:.7}); });
return {root:r, anims:[]}; }
if(name.endsWith('.bvh')){ const r = new BVHLoader().parse(new TextDecoder().decode(buf));
const root = new THREE.Object3D(); root.add(r.skeleton.bones[0]);
return {root, anims:[r.clip]}; }
throw new Error('unsupported type: '+name);
}
export function stats(root){
let tris=0, meshes=0, bones=0;
root.traverse(o=>{ if(o.isMesh){ meshes++; const g=o.geometry;
tris += (g.index ? g.index.count : g.attributes.position.count)/3; } if(o.isBone) bones++; });
return {tris:Math.round(tris), meshes, bones};
}
export function disposeRoot(root){
root.traverse(o=>{ if(o.geometry) o.geometry.dispose();
const m=o.material; if(m)(Array.isArray(m)?m:[m]).forEach(mt=>{ for(const k in mt){ const v=mt[k];
if(v && v.isTexture) v.dispose(); } mt.dispose(); }); });
}
// canonical bone key: "mixamorig:LeftUpLeg" / "mixamorig_LeftUpLeg" / "mixamorig4LeftUpLeg" → "leftupleg"
// (mixamo numbers the namespace per-download — strip digits too). Encodes real Mixamo quirks; keep as-is.
export const canon = n => n.replace(/^.*?mixamorig\d*[:_]?/i,'').replace(/[^a-z0-9]/gi,'').toLowerCase();
export function boneMap(root){
const m={}; root.traverse(o=>{ if(o.isBone && !(canon(o.name) in m)) m[canon(o.name)] = o; }); return m;
}
const hipsOf = map => map['hips'] || null;
// bind-pose snapshot — retargets are world-space deltas against THIS. Capture BEFORE any clip poses it.
export function captureRest(root){
root.updateMatrixWorld(true);
const rest = new Map(), order = [];
root.traverse(o=>{ if(o.isBone){ order.push(o);
rest.set(o, { wq:o.getWorldQuaternion(new THREE.Quaternion()),
lq:o.quaternion.clone(), lp:o.position.clone() }); } });
return {rest, order};
}
// WORLD-SPACE rotation-delta transfer, baked at 30fps. Raw quaternion copying only works when both
// rests share bone axes (fbx→fbx). Blender-exported GLB rigs orient bones differently, so we sample
// the SOURCE through the clip and move each target bone by the source bone's world-space delta.
export function bakeRetarget(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts={}){
const tgt = boneMap(tgtRoot), src = boneMap(srcRoot);
const tHips = hipsOf(tgt), sHips = hipsOf(src);
if(!tHips || !sHips) throw new Error('retarget: missing hips (mixamorig skeleton?)');
const sRest = srcRest.rest, tRest = tgtRest.rest, tOrder = tgtRest.order;
const pairs = new Map(); // target bone → source bone. Dedupe by canon key: fbx files often
const used = new Set(); // carry a DUPLICATE hierarchy — bind only the first (what boneMap picks)
for(const tb of tOrder){ const k=canon(tb.name), sb=src[k];
if(sb && sRest.has(sb) && !used.has(k)){ pairs.set(tb, sb); used.add(k); } }
if(!pairs.size) throw new Error('retarget: no matching bones');
let ratio=1; // hips travel: local hip-height ratio (cm fbx vs m glb sorts itself out)
if(sHips.position.y > 1e-6) ratio = tHips.position.y / sHips.position.y;
const fps=30, n=Math.max(2, Math.ceil(clip.duration*fps)+1);
const times = new Float32Array(n);
const qData = new Map([...pairs.keys()].map(tb=>[tb, new Float32Array(n*4)]));
const pData = new Float32Array(n*3);
const mixerS = new THREE.AnimationMixer(srcRoot);
mixerS.clipAction(clip).play();
const q1=new THREE.Quaternion(), q2=new THREE.Quaternion(), q3=new THREE.Quaternion();
for(let i=0;i<n;i++){
const t = Math.min(clip.duration, i/fps); times[i]=t;
mixerS.setTime(t); srcRoot.updateMatrixWorld(true);
const world = new Map(); // this frame's target world quats, parent-first (traverse order)
for(const tb of tOrder){
const parent = tb.parent;
const pw = world.get(parent) || parent.getWorldQuaternion(q3); // non-bone parents are static
let lq;
const sb = pairs.get(tb);
if(sb){
const sw = sb.getWorldQuaternion(q1); // source now
const delta = sw.multiply(q2.copy(sRest.get(sb).wq).invert()); // world delta from source rest
const tw = delta.multiply(tRest.get(tb).wq); // applied to target rest
lq = q2.copy(pw).invert().multiply(tw); // back to local
// hemisphere continuity: keep each bone on the same side as its last frame, else slerp garbage
const qd = qData.get(tb);
if(i && qd[(i-1)*4]*lq.x + qd[(i-1)*4+1]*lq.y + qd[(i-1)*4+2]*lq.z + qd[(i-1)*4+3]*lq.w < 0)
lq.set(-lq.x,-lq.y,-lq.z,-lq.w);
qd.set([lq.x,lq.y,lq.z,lq.w], i*4);
lq = new THREE.Quaternion(lq.x,lq.y,lq.z,lq.w);
} else lq = tRest.get(tb).lq;
world.set(tb, new THREE.Quaternion().multiplyQuaternions(pw, lq));
}
const hl = tRest.get(tHips).lp, sl = sRest.get(sHips).lp;
pData.set([ hl.x + (sHips.position.x - sl.x)*ratio, hl.y + (sHips.position.y - sl.y)*ratio,
hl.z + (sHips.position.z - sl.z)*ratio ], i*3);
}
mixerS.stopAllAction();
for(const [sb,r] of sRest){ sb.quaternion.copy(r.lq); sb.position.copy(r.lp); } // un-pose the source
if(opts.inPlace) for(let i=3;i<pData.length;i+=3){ pData[i]=pData[0]; pData[i+2]=pData[2]; }
const tracks = [...pairs.keys()].map(tb =>
new THREE.QuaternionKeyframeTrack(`${tb.name}.quaternion`, times, qData.get(tb)));
tracks.push(new THREE.VectorKeyframeTrack(`${tHips.name}.position`, times, pData));
return new THREE.AnimationClip(clip.name, clip.duration, tracks);
}
// ---- self-check: two 3-bone skeletons with DIFFERENT rests, bake a 1s clip across, assert the
// world-space rotation DELTA the target reproduces matches the source's, within 1e-3. --------------
function _mkSkel(prefix, restEuler){
// hips -> spine -> head, each 1 unit up its parent. restEuler tilts each bone's rest differently.
const names = ['Hips','Spine','Head'];
let parent=null, root=null; const bones=[];
names.forEach((nm,i)=>{
const b = new THREE.Bone(); b.name = prefix+nm;
b.position.set(0, i===0 ? 1.0 : 1.0, 0); // hips at y=1 so ratio is well-defined
b.rotation.set(restEuler[i][0], restEuler[i][1], restEuler[i][2]);
if(parent) parent.add(b); else root=b;
parent=b; bones.push(b);
});
const wrap = new THREE.Object3D(); wrap.add(root); wrap.updateMatrixWorld(true);
return {wrap, bones};
}
export function _selftest(){
// underscore namespaces (not colon) so three's PropertyBinding binds the clips; canon() still
// strips "mixamorig1_" / "mixamorig4_" to the same key, which is what we're actually testing.
const src = _mkSkel('mixamorig1_', [[0,0,0],[0.1,0,0.2],[0,0.15,0]]);
const tgt = _mkSkel('mixamorig4_', [[0,0.3,0],[-0.2,0,0.1],[0.05,0,-0.1]]); // different namespace + rests
const srcRest = captureRest(src.wrap), tgtRest = captureRest(tgt.wrap);
// source clip: rotate the spine over 1s
const times = new Float32Array([0, 0.5, 1.0]);
const q0=new THREE.Quaternion(), q1=new THREE.Quaternion().setFromEuler(new THREE.Euler(0.6,0,0.3));
const qm=q0.clone().slerp(q1,0.5);
const vals=new Float32Array([q0.x,q0.y,q0.z,q0.w, qm.x,qm.y,qm.z,qm.w, q1.x,q1.y,q1.z,q1.w]);
const clip = new THREE.AnimationClip('t', 1.0,
[new THREE.QuaternionKeyframeTrack(src.bones[1].name+'.quaternion', times, vals)]);
const baked = bakeRetarget(clip, src.wrap, srcRest, tgt.wrap, tgtRest);
const srcMix = new THREE.AnimationMixer(src.wrap); srcMix.clipAction(clip).play();
const tgtMix = new THREE.AnimationMixer(tgt.wrap); tgtMix.clipAction(baked).play();
const delta=(bone,rest)=>bone.getWorldQuaternion(new THREE.Quaternion())
.multiply(rest.wq.clone().invert());
let maxErr=0, srcMoved=0;
for(const t of [0, 0.5, 1.0]){
srcMix.setTime(t); src.wrap.updateMatrixWorld(true);
tgtMix.setTime(t); tgt.wrap.updateMatrixWorld(true);
for(let i=0;i<3;i++){
const ds=delta(src.bones[i], srcRest.rest.get(src.bones[i]));
const dt=delta(tgt.bones[i], tgtRest.rest.get(tgt.bones[i]));
let d = Math.abs(ds.x*dt.x + ds.y*dt.y + ds.z*dt.z + ds.w*dt.w); // |dot|→1 when equal (mod sign)
maxErr = Math.max(maxErr, 1 - d);
srcMoved = Math.max(srcMoved, 2*Math.acos(Math.min(1, Math.abs(ds.w)))); // world-delta angle (rad)
}
}
const ok = maxErr < 1e-3 && srcMoved > 0.05; // guard: a non-posing source (e.g. unbound clip) would pass trivially
return {ok, maxErr, srcMoved};
}

327
scenegod/web/stage.js Normal file
View File

@ -0,0 +1,327 @@
// stage.js — the 3D stage: renderer, IBL scene, entities on wrappers, gizmo, retarget cache.
// Timeline (Lane B) talks to the stage ONLY through this API (PLAN §4.2).
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3d.js';
const ASSET_URL = p => '/assets/file?path=' + encodeURIComponent(p);
export class Stage {
constructor(viewEl){
this.view = viewEl;
this._entities = new Map();
this._selCbs = []; this._changeCbs = [];
this._selected = null;
this._activeCamId = null;
this._mixerAuto = true; // Stage advances mixers each frame until Timeline takes over
this._idc = 0;
this._clipSrc = new Map(); // path -> {root, rest, anims} (parse+rest cached)
this._baked = new Map(); // id|path|index -> AnimationClip
const r = this.renderer = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
r.setPixelRatio(devicePixelRatio);
r.shadowMap.enabled = true;
view.appendChild(r.domElement);
const scene = this.scene = new THREE.Scene();
const cam = this._dirCam = new THREE.PerspectiveCamera(45, 1, 0.01, 3000);
cam.position.set(0, 1.6, 5);
const ctr = this.controls = new OrbitControls(cam, r.domElement);
ctr.enableDamping = true; ctr.target.set(0, 1.0, 0);
const pmrem = new THREE.PMREMGenerator(r);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose();
// default lights — replaced/dimmed once the scene carries light entities
this._defHemi = new THREE.HemisphereLight(0xffffff, 0x555a66, 2.2); scene.add(this._defHemi);
this._defKey = new THREE.DirectionalLight(0xffffff, 1.8); this._defKey.position.set(3, 5, 2); scene.add(this._defKey);
const floor = new THREE.Mesh(new THREE.CircleGeometry(30, 64).rotateX(-Math.PI/2),
new THREE.MeshStandardMaterial({color:0x141a20, roughness:.97}));
floor.receiveShadow = true; scene.add(floor);
scene.add(new THREE.GridHelper(60, 60, 0x2a323b, 0x1c232b));
// gizmo
const tc = this._tc = new TransformControls(cam, r.domElement);
tc.addEventListener('dragging-changed', e => { ctr.enabled = !e.value; });
tc.addEventListener('mouseUp', () => { if(this._selected) this._fireChange(this._selected); });
scene.add(tc);
addEventListener('keydown', e => {
if(!this._selected) return;
if(e.key==='w') tc.setMode('translate');
else if(e.key==='e') tc.setMode('rotate');
else if(e.key==='r') tc.setMode('scale');
});
r.domElement.addEventListener('click', e => this._pick(e));
this.clock = new THREE.Clock();
this._resize(); addEventListener('resize', () => this._resize());
const loop = () => { requestAnimationFrame(loop);
ctr.update();
const dt = this.clock.getDelta();
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
r.render(scene, this._dirCam);
};
loop();
}
_resize(){ const w=this.view.clientWidth, h=this.view.clientHeight;
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix(); }
// ---- callbacks ----
onSelect(cb){ this._selCbs.push(cb); }
onChange(cb){ this._changeCbs.push(cb); }
_fireChange(en){ for(const cb of this._changeCbs) cb(en); }
// ---- entity lifecycle ----
entities(){ return [...this._entities.values()]; }
getEntity(id){ return this._entities.get(id); }
async addEntity(desc){
const id = desc.id || ('e' + (++this._idc));
if(this._idc <= (parseInt(String(id).slice(1)) || 0)) this._idc = parseInt(String(id).slice(1)) || this._idc;
const wrapper = new THREE.Group();
wrapper.userData.entityId = id;
const en = { id, kind: desc.kind, label: desc.label || desc.kind,
source: desc.source || null, params: {...(desc.params||{})},
wrapper, root:null, mixer:null, rest:null, cam:null, light:null };
if(desc.kind === 'character' || desc.kind === 'prop'){
const { root } = await this._loadAsset(desc);
this._normalize(root, desc.kind === 'character');
root.traverse(o=>{ if(o.isMesh){ o.castShadow = true; o.receiveShadow = true; } if(o.isSkinnedMesh) o.frustumCulled=false; });
wrapper.add(root); en.root = root;
if(desc.kind === 'character'){ en.rest = captureRest(root); en.mixer = new THREE.AnimationMixer(root); }
} else if(desc.kind === 'backdrop'){
en.root = await this._buildBackdrop(en); wrapper.add(en.root);
} else if(desc.kind === 'camera'){
const fov = en.params.fov || 45;
en.cam = new THREE.PerspectiveCamera(fov, 16/9, 0.05, 3000);
wrapper.add(en.cam);
const proxy = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.35, 4).rotateX(-Math.PI/2),
new THREE.MeshBasicMaterial({color:0x43c0cf, wireframe:true}));
proxy.position.z = 0.18; wrapper.add(proxy);
} else if(desc.kind === 'light'){
this._buildLight(en);
} else throw new Error('unknown kind: ' + desc.kind);
this.scene.add(wrapper);
this._entities.set(id, en);
if(desc.transform) this.setTransform(id, desc.transform);
return en;
}
removeEntity(id){
const en = this._entities.get(id); if(!en) return;
if(this._selected === id) this.select(null);
if(en.root) disposeRoot(en.root);
if(en.light) en.light.parent && en.light.parent.remove(en.light);
this.scene.remove(en.wrapper);
this._entities.delete(id);
for(const k of [...this._baked.keys()]) if(k.startsWith(id+'|')) this._baked.delete(k);
if(en.kind === 'light') this._refreshDefaults();
}
async _loadAsset(desc){
let buf, name;
if(desc.source && desc.source.type === 'upload'){
if(!desc._buf) throw new Error('upload entity has no buffer');
buf = desc._buf; name = desc.source.path;
} else {
const path = desc.source.path;
const res = await fetch(ASSET_URL(path));
if(!res.ok) throw new Error('asset fetch ' + res.status + ': ' + path);
buf = await res.arrayBuffer(); name = path;
}
return parseAny(buf, name);
}
_normalize(root, isChar){
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
if(box.isEmpty() || !isFinite(box.min.x)) return;
if(isChar){
const size = box.getSize(new THREE.Vector3());
const s = 1.7 / (Math.max(size.x, size.y, size.z) || 1);
root.scale.setScalar(s); root.updateMatrixWorld(true);
}
const b = new THREE.Box3().setFromObject(root);
root.position.x -= (b.min.x + b.max.x)/2;
root.position.z -= (b.min.z + b.max.z)/2;
root.position.y -= b.min.y; // feet on the floor
}
async _buildBackdrop(en){
const p = en.params;
const url = en.source
? (en.source.type === 'upload' ? en.source._url : ASSET_URL(p.image || en.source.path))
: ASSET_URL(p.image);
const tex = await new THREE.TextureLoader().loadAsync(url);
tex.colorSpace = THREE.SRGBColorSpace;
const aspect = (tex.image.width / tex.image.height) || 1;
const w = p.width || 10, h = w / aspect;
const mat = new THREE.MeshStandardMaterial({map:tex, roughness:1, metalness:0});
const grp = new THREE.Group();
if(p.mode === 'dome'){
const dome = new THREE.Mesh(new THREE.SphereGeometry(40, 40, 24),
new THREE.MeshBasicMaterial({map:tex, side:THREE.BackSide}));
grp.add(dome);
} else {
const wall = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
wall.position.y = h/2; wall.receiveShadow = true; grp.add(wall);
if(p.mode === 'corner'){ // cheap "L": duplicate lower third laid flat as ground
const gh = h/3;
const gm = new THREE.Mesh(new THREE.PlaneGeometry(w, gh), mat.clone());
gm.rotation.x = -Math.PI/2; gm.position.z = gh/2; gm.receiveShadow = true; grp.add(gm);
wall.position.z = -gh/2;
}
}
return grp;
}
_buildLight(en){
const p = en.params;
if(p.type === 'ambient'){
en.light = new THREE.HemisphereLight(new THREE.Color(p.color||'#ffffff'), 0x444450, p.intensity ?? 1.0);
en.wrapper.add(en.light);
} else {
const dir = en.light = new THREE.DirectionalLight(new THREE.Color(p.color||'#ffffff'), p.intensity ?? 1.5);
dir.castShadow = p.castShadow ?? true;
dir.shadow.mapSize.set(1024,1024); dir.shadow.camera.far = 60;
dir.position.set(0,0,0);
const tgt = new THREE.Object3D(); tgt.position.set(0,0,-1); en.wrapper.add(tgt); dir.target = tgt;
en.wrapper.add(dir);
const proxy = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 8),
new THREE.MeshBasicMaterial({color:0xffe08a}));
en.wrapper.add(proxy);
}
this._refreshDefaults();
}
// dim built-in lights once scene lights exist
_refreshDefaults(){
const hasKey = this.entities().some(e => e.kind==='light' && e.params.type!=='ambient');
const hasAmb = this.entities().some(e => e.kind==='light' && e.params.type==='ambient');
this._defKey.visible = !hasKey;
this._defHemi.intensity = hasAmb ? 0 : 2.2;
}
// ---- selection + gizmo ----
select(id){
this._selected = id;
if(id == null){ this._tc.detach(); }
else { const en = this._entities.get(id); if(en) this._tc.attach(en.wrapper); }
for(const cb of this._selCbs) cb(id ? this._entities.get(id) : null);
}
_pick(e){
if(this._tc.dragging) return;
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((e.clientX - rect.left)/rect.width)*2 - 1,
-((e.clientY - rect.top)/rect.height)*2 + 1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const wraps = this.entities().map(en => en.wrapper);
const hits = rc.intersectObjects(wraps, true);
for(const h of hits){
let o = h.object; while(o && o.userData.entityId == null) o = o.parent;
if(o){ this.select(o.userData.entityId); return; }
}
this.select(null);
}
// ---- transforms / params (Timeline drives these every tick) ----
entityTransform(id){
const w = this._entities.get(id)?.wrapper; if(!w) return null;
return { pos:[w.position.x, w.position.y, w.position.z],
rot:[w.rotation.x, w.rotation.y, w.rotation.z],
scale: w.scale.x };
}
setTransform(id, {pos, rot, scale}){
const w = this._entities.get(id)?.wrapper; if(!w) return;
if(pos) w.position.set(pos[0], pos[1], pos[2]);
if(rot) w.rotation.set(rot[0], rot[1], rot[2]);
if(scale != null) w.scale.setScalar(scale);
}
setParam(id, key, value){
const en = this._entities.get(id); if(!en) return;
en.params[key] = value;
if(en.cam && key === 'fov'){ en.cam.fov = value; en.cam.updateProjectionMatrix(); }
if(en.light){
if(key === 'intensity') en.light.intensity = value;
if(key === 'color') en.light.color.set(value);
}
// backdrop mode/image/width changes rebuild lazily — M2; ponytail: rebuild-on-change, add when keyable.
}
// ---- clips (bake is expensive — cache per path parse and per (entity,path,index) bake) ----
async prepareClip(id, path, clipIndex=0){
const en = this._entities.get(id);
if(!en || en.kind !== 'character') throw new Error('prepareClip: not a character: ' + id);
const bkey = `${id}|${path}|${clipIndex}`;
if(this._baked.has(bkey)) return this._baked.get(bkey);
let src = this._clipSrc.get(path);
if(!src){
const res = await fetch(ASSET_URL(path));
if(!res.ok) throw new Error('clip fetch ' + res.status + ': ' + path);
const { root, anims } = await parseAny(await res.arrayBuffer(), path);
src = { root, anims, rest: captureRest(root) };
this._clipSrc.set(path, src);
}
const clip = src.anims[clipIndex];
if(!clip) throw new Error('no clip index ' + clipIndex + ' in ' + path);
const baked = bakeRetarget(clip, src.root, src.rest, en.root, en.rest);
this._baked.set(bkey, baked);
return baked;
}
// same bake, from in-memory bytes (dropped clip file) instead of a server path
async prepareClipUpload(id, buf, name, clipIndex=0){
const en = this._entities.get(id);
if(!en || en.kind !== 'character') throw new Error('prepareClipUpload: not a character: ' + id);
const { root, anims } = await parseAny(buf, name);
const clip = anims[clipIndex];
if(!clip) throw new Error('no animation tracks in ' + name);
return bakeRetarget(clip, root, captureRest(root), en.root, en.rest);
}
entityMixer(id){ return this._entities.get(id)?.mixer || null; }
// M1 temp helper: play a prepared clip on a character (Timeline replaces this in M2).
playClip(id, clip, {loop=true} = {}){
const en = this._entities.get(id); if(!en || !en.mixer) return;
en.mixer.stopAllAction();
const a = en.mixer.clipAction(clip);
a.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
a.clampWhenFinished = !loop; a.reset().play();
}
setMixerAuto(on){ this._mixerAuto = on; } // Timeline calls setMixerAuto(false) at integration
// ---- cameras ----
setActiveCamera(id){ this._activeCamId = id; }
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
renderActiveCamera(canvas){
const cam = this._activeCam();
if(cam.isPerspectiveCamera && canvas){ cam.aspect = canvas.width/canvas.height; cam.updateProjectionMatrix(); }
this.renderer.render(this.scene, cam);
if(canvas){ const ctx = canvas.getContext('2d');
ctx.drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height); }
}
// ---- scene JSON round-trip (entities only; Timeline owns tracks/duration/cuts) ----
captureState(){
return this.entities().map(en => ({
id: en.id, kind: en.kind, label: en.label,
source: en.source, params: {...en.params},
transform: this.entityTransform(en.id),
upload: en.source && en.source.type === 'upload' || undefined,
}));
}
async applyState(sceneJson){
for(const en of this.entities()) this.removeEntity(en.id);
for(const d of (sceneJson.entities || [])){
if(d.source && d.source.type === 'upload'){ console.warn('skipping upload entity (no bytes):', d.id); continue; }
try { await this.addEntity(d); } catch(err){ console.error('addEntity failed', d.id, err); }
}
}
}

103
scenegod/web/stagestub.js Normal file
View File

@ -0,0 +1,103 @@
// stagestub.js — dev-only fake Stage implementing the PLAN §4.2 API with
// plain objects, so Lane B's timeline can be built + tested headless before
// Lane A's real Stage lands. DELETED at SYNC 1 (one-line swap in the page).
// ponytail: no three.js, no DOM — runs under plain node for timeline_test.mjs.
const clone = (x) => (x == null ? x : JSON.parse(JSON.stringify(x)));
class StubAction {
constructor(clip) {
this._clip = clip;
this.time = 0;
this.weight = 0;
this.enabled = false;
this.paused = false;
}
play() { this._playing = true; return this; }
reset() { this.time = 0; return this; }
}
class StubMixer {
constructor(id, stage) { this.id = id; this.stage = stage; this._actions = new Map(); }
clipAction(clip) {
if (!this._actions.has(clip)) this._actions.set(clip, new StubAction(clip));
return this._actions.get(clip);
}
update(_dt) {
// record every audibly-active action so tests can assert clip local time
for (const a of this._actions.values()) {
if (a.enabled && a.weight > 0) {
this.stage.applied.push({ type: 'clip', id: this.id, clip: a._clip.name, time: a.time, weight: a.weight });
}
}
}
}
export class StageStub {
constructor(viewportEl) {
this.viewportEl = viewportEl || null;
this._entities = new Map();
this._mixers = new Map();
this._clipCache = new Map();
this._changeCbs = [];
this._selectCbs = [];
this._active = null;
this._selected = null;
this.applied = []; // timeline effects land here for assertions
}
async addEntity(desc) {
const e = clone(desc);
if (!e.transform) e.transform = { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 };
if (!e.params) e.params = {};
this._entities.set(e.id, e);
this._fireChange(e);
return e;
}
removeEntity(id) { this._entities.delete(id); this._mixers.delete(id); this._fireChange(null); }
getEntity(id) { return this._entities.get(id) || null; }
entities() { return [...this._entities.values()]; }
select(id) { this._selected = id; this._selectCbs.forEach((cb) => cb(id)); }
onSelect(cb) { this._selectCbs.push(cb); }
onChange(cb) { this._changeCbs.push(cb); }
_fireChange(e) { this._changeCbs.forEach((cb) => cb(e)); }
entityTransform(id) {
const e = this._entities.get(id);
return e ? clone(e.transform) : { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 };
}
setTransform(id, t) {
const e = this._entities.get(id);
if (e) e.transform = clone(t);
this.applied.push({ type: 'transform', id, ...clone(t) });
console.debug('stub.setTransform', id, t);
}
setParam(id, key, value) {
const e = this._entities.get(id);
if (e) { e.params = e.params || {}; e.params[key] = value; }
this.applied.push({ type: 'param', id, key, value });
console.debug('stub.setParam', id, key, value);
}
async prepareClip(id, path, clipIndex) {
const k = `${path}#${clipIndex}`; // CACHED per §4.2
if (!this._clipCache.has(k)) this._clipCache.set(k, { name: k, duration: 2.4 });
return this._clipCache.get(k);
}
entityMixer(id) {
if (!this._mixers.has(id)) this._mixers.set(id, new StubMixer(id, this));
return this._mixers.get(id);
}
setActiveCamera(id) { this._active = id; this.applied.push({ type: 'camera', id }); console.debug('stub.setActiveCamera', id); }
renderActiveCamera(_canvas) { /* noop in stub */ }
captureState() { return this.entities().map(clone); }
async applyState(scene) {
this._entities.clear();
for (const e of (scene.entities || [])) await this.addEntity(e);
}
}
export default StageStub;

67
scenegod/web/style.css Normal file
View File

@ -0,0 +1,67 @@
/* SCENEGOD — dark director UI, visual language borrowed from MESHGOD rigroom */
:root{
--bg:#101317; --bg2:#161b21; --panel:#1a2027; --line:#2a323b; --line2:#39434e;
--ink:#e8ecf1; --mut:#8a95a2; --teal:#43c0cf; --teal-d:#2a8b98; --bad:#e0604a; --good:#5bc98b;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);height:100vh;
display:flex;flex-direction:column;overflow:hidden}
header{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex:0 0 auto}
header h1{margin:0;font-size:16px;letter-spacing:-.01em}
header .tag{font-family:var(--mono);font-size:10px;color:var(--teal);letter-spacing:.12em;text-transform:uppercase}
#selftest{margin-left:auto;font-family:var(--mono);font-size:11px}
#selftest.ok{color:var(--good)} #selftest.bad{color:var(--bad)}
#app{flex:1;min-height:0;display:grid;
grid-template-columns:280px 1fr 260px;
grid-template-rows:1fr 260px;
grid-template-areas:"dock view inspector" "dock timeline inspector";}
#dock{grid-area:dock;border-right:1px solid var(--line);overflow-y:auto;padding:10px}
#view{grid-area:view;position:relative;min-width:0;
background:radial-gradient(120% 100% at 50% 0%,#20272f,#0d1013)}
#view canvas{display:block;width:100%;height:100%}
#inspector{grid-area:inspector;border-left:1px solid var(--line);overflow-y:auto;padding:12px}
#timeline{grid-area:timeline;border-top:1px solid var(--line);background:var(--bg2);overflow:auto}
.empty{color:var(--mut);font-family:var(--mono);font-size:11.5px;padding:14px;line-height:1.6}
/* dock */
.tabs{display:flex;background:var(--bg2);border:1px solid var(--line2);border-radius:7px;overflow:hidden;margin-bottom:10px}
.tabs button{flex:1;background:transparent;border:0;color:var(--mut);padding:7px 6px;font-family:var(--mono);
font-size:10.5px;cursor:pointer;letter-spacing:.04em}
.tabs button.on{background:var(--teal);color:#0c0f12;font-weight:700}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px}
.card{background:var(--bg2);border:1px solid var(--line);border-radius:9px;overflow:hidden;cursor:pointer}
.card:hover{border-color:var(--teal)}
.card .th{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:30px;
background:radial-gradient(120% 100% at 50% 0%,#20272f,#0d1013);color:var(--mut)}
.card .nm{padding:5px 7px;font-size:10.5px;line-height:1.3;color:var(--ink);
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:30px}
/* inspector */
.ihd{margin-bottom:10px} .ihd b{font-size:14px}
.ihd .kind{display:block;font-family:var(--mono);font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.ihd .warn{color:var(--bad)}
.fld{display:flex;align-items:center;gap:8px;margin:7px 0;font-family:var(--mono);font-size:11px;color:var(--mut)}
.fld>span{width:52px;flex:0 0 auto}
.fld input,.fld select{flex:1;min-width:0;background:var(--panel);border:1px solid var(--line2);color:var(--ink);
border-radius:6px;padding:5px 7px;font-family:var(--mono);font-size:11px}
.fld input[type=color]{padding:2px;height:26px}
.fld.vec input{width:100%}
.fld.vec{align-items:center}
.ibtns{display:flex;gap:8px;margin-top:14px}
.ibtns button{flex:1;background:var(--panel);border:1px solid var(--line2);color:var(--ink);border-radius:7px;
padding:8px;font-family:var(--mono);font-size:12px;cursor:pointer}
.ibtns button:hover{border-color:var(--teal)}
.ibtns button.danger:hover{border-color:var(--bad);color:var(--bad)}
/* drop + toast */
body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
font-size:24px;color:var(--teal);background:rgba(16,19,23,.85);pointer-events:none;z-index:99}
#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--panel);
border:1px solid var(--teal);border-radius:9px;padding:9px 16px;font-size:13px;display:none;z-index:50;max-width:80vw}
/* === LANE B === */
/* Lane B appends timeline styling below this marker */

314
scenegod/web/timeline.js Normal file
View File

@ -0,0 +1,314 @@
// timeline.js — SCENEGOD master clock, tracks, keyframe evaluation (Lane B).
// Talks to Stage ONLY through the PLAN §4.2 API. NO DOM, NO three.js import,
// so it runs headless under `node` for timeline_test.mjs. All the vector /
// quaternion math it needs is inlined below (a few lines each).
// ---- tiny math (self-contained; euler XYZ radians) --------------------------
const lerp = (a, b, u) => a + (b - a) * u;
const lerpArr = (a, b, u) => a.map((v, i) => lerp(v, b[i], u));
function eulerToQuat([x, y, z]) { // XYZ order, matches three
const c1 = Math.cos(x / 2), s1 = Math.sin(x / 2);
const c2 = Math.cos(y / 2), s2 = Math.sin(y / 2);
const c3 = Math.cos(z / 2), s3 = Math.sin(z / 2);
return [
s1 * c2 * c3 + c1 * s2 * s3,
c1 * s2 * c3 - s1 * c2 * s3,
c1 * c2 * s3 + s1 * s2 * c3,
c1 * c2 * c3 - s1 * s2 * s3,
];
}
function quatToEuler([x, y, z, w]) { // XYZ order
const m11 = 1 - 2 * (y * y + z * z), m12 = 2 * (x * y - z * w), m13 = 2 * (x * z + y * w);
const m22 = 1 - 2 * (x * x + z * z), m23 = 2 * (y * z - x * w);
const m33 = 1 - 2 * (x * x + y * y);
const ey = Math.asin(Math.max(-1, Math.min(1, m13)));
let ex, ez;
if (Math.abs(m13) < 0.9999999) { ex = Math.atan2(-m23, m33); ez = Math.atan2(-m12, m11); }
else { ex = Math.atan2(m22 !== undefined ? 2 * (y * z + x * w) : 0, m22); ez = 0; }
return [ex, ey, ez];
}
function slerp(a, b, u) { // quaternion slerp, sign-safe
let [ax, ay, az, aw] = a, [bx, by, bz, bw] = b;
let cos = ax * bx + ay * by + az * bz + aw * bw;
if (cos < 0) { bx = -bx; by = -by; bz = -bz; bw = -bw; cos = -cos; }
if (cos > 0.9995) { // nearly parallel → nlerp
const q = [lerp(ax, bx, u), lerp(ay, by, u), lerp(az, bz, u), lerp(aw, bw, u)];
const n = Math.hypot(...q) || 1;
return q.map((v) => v / n);
}
const t = Math.acos(cos), s = Math.sin(t);
const wa = Math.sin((1 - u) * t) / s, wb = Math.sin(u * t) / s;
return [ax * wa + bx * wb, ay * wa + by * wb, az * wa + bz * wb, aw * wa + bw * wb];
}
function ease(u, kind) {
switch (kind) {
case 'step': return 0; // hold start value across segment
case 'in': return u * u * u;
case 'out': return 1 - Math.pow(1 - u, 3);
case 'inout': return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
case 'linear': default: return u;
}
}
// color helpers for param track (sRGB lerp is fine for v1 per B2)
const isColor = (v) => typeof v === 'string' && v[0] === '#';
function hexToRgb(h) { const n = parseInt(h.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function rgbToHex([r, g, b]) { return '#' + [r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join(''); }
// find bracketing keys in a t-sorted array. ponytail: linear scan — key arrays
// are tiny (handfuls); swap for binary search only if a lane ever profiles hot.
function bracket(keys, t) {
if (keys.length === 0) return null;
if (t <= keys[0].t) return [keys[0], keys[0], 0];
const last = keys[keys.length - 1];
if (t >= last.t) return [last, last, 0];
for (let i = 0; i < keys.length - 1; i++) {
if (t >= keys[i].t && t < keys[i + 1].t) {
const span = keys[i + 1].t - keys[i].t;
return [keys[i], keys[i + 1], span > 0 ? (t - keys[i].t) / span : 0];
}
}
return [last, last, 0];
}
// ---- Timeline ---------------------------------------------------------------
export class Timeline {
constructor(stage) {
this.stage = stage;
this.scene = { version: 1, name: 'untitled', fps: 30, duration: 10, entities: [], cameraCuts: [], audio: [] };
this.time = 0;
this.playing = false;
this._tickCbs = [];
this._raf = null;
this._last = 0;
this._qcache = new WeakMap(); // key obj -> cached quat (keeps keys pure for round-trip)
this._clipActions = new Map(); // block obj -> {action, clip}
this._activeCam = undefined; // last camera pushed (avoid re-spamming setActiveCamera)
this.undoStack = [];
if (typeof window !== 'undefined') {
window.addEventListener('scenegod:capturekey', (e) => {
const id = e.detail && e.detail.id;
if (id) this.addKey(id, 'transform', { t: this.time, ...this.stage.entityTransform(id), ease: 'inout' });
});
}
}
get fps() { return this.scene.fps; }
get duration() { return this.scene.duration; }
get entities() { return this.scene.entities; }
get cameraCuts() { return this.scene.cameraCuts; }
// ---- load / save (lossless round-trip) ----
load(sceneJson) {
this.scene = structuredClone(sceneJson);
if (this.scene.fps == null) this.scene.fps = 30;
if (this.scene.duration == null) this.scene.duration = 10;
this.scene.entities = this.scene.entities || [];
this.scene.cameraCuts = this.scene.cameraCuts || [];
for (const e of this.scene.entities) this._sortTracks(e);
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
this._clipActions.clear();
this._activeCam = undefined;
this.seek(0);
return this;
}
toJSON() { return structuredClone(this.scene); }
_sortTracks(e) {
const tr = e.tracks;
if (!tr) return;
if (tr.transform) tr.transform.sort((a, b) => a.t - b.t);
if (tr.params) tr.params.sort((a, b) => a.t - b.t);
if (tr.clips) tr.clips.sort((a, b) => a.start - b.start);
}
_quat(key) {
let q = this._qcache.get(key);
if (!q) { q = eulerToQuat(key.rot || [0, 0, 0]); this._qcache.set(key, q); }
return q;
}
// ---- clock ----
onTick(cb) { this._tickCbs.push(cb); }
play() {
if (this.playing || typeof requestAnimationFrame === 'undefined') return;
this.playing = true;
this._last = performance.now();
const loop = (now) => {
if (!this.playing) return;
const dt = (now - this._last) / 1000;
this._last = now;
let t = this.time + dt;
if (t >= this.duration) { t = this.duration; this.playing = false; }
this.seek(t);
if (this.playing) this._raf = requestAnimationFrame(loop);
};
this._raf = requestAnimationFrame(loop);
}
pause() { this.playing = false; if (this._raf) cancelAnimationFrame(this._raf); this._raf = null; }
seek(t) {
this.time = Math.max(0, Math.min(this.duration, t));
this.evaluate(this.time);
for (const cb of this._tickCbs) cb(this.time);
}
step(frame) { this.time = Math.max(0, Math.min(this.duration, frame / this.fps)); this.evaluate(this.time); return this.time; }
// ---- clip preload (async fetch/retarget once; evaluate stays sync) ----
async preload() {
for (const e of this.entities) {
const clips = e.tracks && e.tracks.clips;
if (!clips) continue;
const mixer = this.stage.entityMixer(e.id);
for (const b of clips) {
if (this._clipActions.has(b)) continue;
const clip = await this.stage.prepareClip(e.id, b.path, b.clipIndex || 0);
const action = mixer.clipAction(clip);
action.play(); action.paused = true; action.enabled = false; action.weight = 0;
this._clipActions.set(b, { action, clip });
}
}
}
// ---- evaluation ----
evaluate(t) {
for (const e of this.entities) {
if (e.kind === 'camera' || e.tracks) this._evalTransform(e, t);
this._evalParams(e, t);
if (e.tracks && e.tracks.clips) this._evalClips(e, t);
}
this._evalCameraCuts(t);
}
_evalTransform(e, t) {
const keys = e.tracks && e.tracks.transform;
if (!keys || keys.length === 0) return; // 0 keys → leave rest pose alone
const br = bracket(keys, t);
const [k0, k1, u] = br;
if (k0 === k1) { this.stage.setTransform(e.id, { pos: k0.pos, rot: k0.rot, scale: k0.scale }); return; }
const eu = ease(u, k0.ease);
const pos = lerpArr(k0.pos, k1.pos, eu);
const scale = typeof k0.scale === 'number'
? lerp(k0.scale, k1.scale, eu)
: lerpArr(k0.scale, k1.scale, eu);
const rot = k0.ease === 'step' ? k0.rot.slice() : quatToEuler(slerp(this._quat(k0), this._quat(k1), eu));
this.stage.setTransform(e.id, { pos, rot, scale });
}
_evalParams(e, t) {
const keys = e.tracks && e.tracks.params;
if (!keys || keys.length === 0) return;
const byKey = new Map();
for (const k of keys) { if (!byKey.has(k.key)) byKey.set(k.key, []); byKey.get(k.key).push(k); }
for (const [name, ks] of byKey) {
const br = bracket(ks, t);
const [k0, k1, u] = br;
let val;
if (k0 === k1) val = k0.value;
else {
const eu = ease(u, k0.ease);
val = isColor(k0.value)
? rgbToHex(lerpArr(hexToRgb(k0.value), hexToRgb(k1.value), eu))
: lerp(k0.value, k1.value, eu);
}
this.stage.setParam(e.id, name, val);
}
}
_evalClips(e, t) {
const blocks = e.tracks.clips;
const mixer = this.stage.entityMixer(e.id);
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const rec = this._clipActions.get(b);
if (!rec) continue; // not preloaded yet → silent
const span = (b.out - b.in) || 0.0001;
const loops = b.loop || 1;
const blockEnd = b.start + span * loops;
let w = 0, local = b.in;
if (t >= b.start && t < blockEnd) {
local = b.in + ((t - b.start) % span);
w = 1;
// ponytail: fade-OUT ramp only for M1; cross-block fade-IN is M2 "crossfade polish".
const succ = blocks[i + 1];
if (succ && b.fade > 0 && t >= blockEnd - b.fade) w = Math.max(0, (blockEnd - t) / b.fade);
}
rec.action.time = local;
rec.action.weight = w;
rec.action.enabled = w > 0;
}
mixer.update(0); // set-time style — exact when scrubbing
}
_evalCameraCuts(t) {
const cuts = this.cameraCuts;
let cam = null;
for (const c of cuts) { if (c.t <= t) cam = c.camera; else break; }
if (cam !== this._activeCam) { this._activeCam = cam; this.stage.setActiveCamera(cam); }
}
// ---- keyframe / block mutators (keep sorted, push inverse for undo) ----
_tracks(id) {
const e = this.scene.entities.find((x) => x.id === id);
if (!e) throw new Error(`no entity ${id}`);
e.tracks = e.tracks || {};
return e.tracks;
}
addKey(id, track, key) {
const tr = this._tracks(id);
const arr = (tr[track] = tr[track] || []);
// for transform/param a "key" replaces any existing at same t (+key name)
const same = (k) => k.t === key.t && (track !== 'params' || k.key === key.key);
const prevIdx = arr.findIndex(same);
const prev = prevIdx >= 0 ? arr[prevIdx] : null;
if (prevIdx >= 0) arr.splice(prevIdx, 1);
arr.push(key);
arr.sort((a, b) => a.t - b.t);
this._qcache.delete(key);
this.undoStack.push({ undo: () => { const i = arr.indexOf(key); if (i >= 0) arr.splice(i, 1); if (prev) { arr.push(prev); arr.sort((a, b) => a.t - b.t); } } });
return key;
}
moveKey(id, track, key, newT) {
const oldT = key.t; key.t = newT;
this._tracks(id)[track].sort((a, b) => a.t - b.t);
this.undoStack.push({ undo: () => { key.t = oldT; this._tracks(id)[track].sort((a, b) => a.t - b.t); } });
}
deleteKey(id, track, key) {
const arr = this._tracks(id)[track] || [];
const i = arr.indexOf(key);
if (i < 0) return;
arr.splice(i, 1);
this.undoStack.push({ undo: () => { arr.push(key); arr.sort((a, b) => a.t - b.t); } });
}
addClipBlock(id, block) {
const tr = this._tracks(id);
const arr = (tr.clips = tr.clips || []);
arr.push(block); arr.sort((a, b) => a.start - b.start);
this._clipActions.delete(block);
this.undoStack.push({ undo: () => { const i = arr.indexOf(block); if (i >= 0) arr.splice(i, 1); } });
return block;
}
moveClipBlock(id, block, newStart) {
const old = block.start; block.start = newStart;
this._tracks(id).clips.sort((a, b) => a.start - b.start);
this.undoStack.push({ undo: () => { block.start = old; this._tracks(id).clips.sort((a, b) => a.start - b.start); } });
}
trimClipBlock(id, block, { in: inV, out: outV } = {}) {
const oldIn = block.in, oldOut = block.out;
if (inV != null) block.in = inV;
if (outV != null) block.out = outV;
this.undoStack.push({ undo: () => { block.in = oldIn; block.out = oldOut; } });
}
addCut(t, cameraId) {
const cut = { t, camera: cameraId };
this.scene.cameraCuts.push(cut);
this.scene.cameraCuts.sort((a, b) => a.t - b.t);
this._activeCam = undefined; // force re-eval of active cam
this.undoStack.push({ undo: () => { const i = this.scene.cameraCuts.indexOf(cut); if (i >= 0) this.scene.cameraCuts.splice(i, 1); } });
return cut;
}
undo() { const op = this.undoStack.pop(); if (op) op.undo(); this.evaluate(this.time); }
}
export default Timeline;

View File

@ -0,0 +1,102 @@
// timeline_test.mjs — Lane B M1 self-check. Runs under plain `node`, no deps.
// node scenegod/web/timeline_test.mjs
import assert from 'node:assert/strict';
import { StageStub } from './stagestub.js';
import { Timeline } from './timeline.js';
const near = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const scene = {
version: 1, name: 'test', fps: 30, duration: 3,
entities: [
{
id: 'e1', kind: 'character', label: 'lady',
source: { type: 'assets', path: 'characters/lady.glb' },
params: {}, transform: { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 },
tracks: {
transform: [
{ t: 0, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' },
{ t: 2, pos: [10, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' },
],
clips: [
{ path: 'animations/walk.fbx', clipIndex: 0, start: 1.0, in: 0.0, out: 2.4, loop: 1, fade: 0 },
],
},
},
{
id: 'e2', kind: 'light', label: 'sun',
params: { type: 'key', color: '#000000', intensity: 0 },
transform: { pos: [0, 5, 0], rot: [0, 0, 0], scale: 1 },
tracks: {
params: [
{ t: 0, key: 'intensity', value: 0, ease: 'step' },
{ t: 2, key: 'intensity', value: 10, ease: 'step' },
],
},
},
{ id: 'cam1', kind: 'camera', params: { fov: 45 }, transform: { pos: [0, 1, 5], rot: [0, 0, 0], scale: 1 }, tracks: {} },
{ id: 'cam2', kind: 'camera', params: { fov: 60 }, transform: { pos: [5, 1, 5], rot: [0, 0, 0], scale: 1 }, tracks: {} },
],
cameraCuts: [{ t: 0, camera: 'cam1' }, { t: 1.5, camera: 'cam2' }],
audio: [],
};
const stage = new StageStub();
await stage.applyState(scene);
const tl = new Timeline(stage);
tl.load(scene);
await tl.preload();
const last = (pred) => { for (let i = stage.applied.length - 1; i >= 0; i--) if (pred(stage.applied[i])) return stage.applied[i]; return null; };
// step through 90 frames (3s @ 30fps)
for (let f = 0; f <= 90; f++) {
stage.applied.length = 0;
tl.step(f);
if (f === 30) { // t=1.0 → midpoint of a 2s linear move
const tr = last((a) => a.type === 'transform' && a.id === 'e1');
assert.ok(tr && near(tr.pos[0], 5, 1e-4), `midpoint x should be ~5, got ${tr && tr.pos[0]}`);
}
if (f === 15) { // t=0.5 → ease:step holds start (0)
const p = last((a) => a.type === 'param' && a.id === 'e2' && a.key === 'intensity');
assert.ok(p && near(p.value, 0), `step-ease should hold 0, got ${p && p.value}`);
}
if (f === 45) { // t=1.5 → clip active, local = 0.5 into walk
const c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.ok(c && near(c.time, 0.5, 1e-6), `clip local time should be 0.5, got ${c && c.time}`);
const cam = last((a) => a.type === 'camera'); // camera flips to cam2 at t>=1.5
// cut fires only on change; check stage state instead
assert.equal(stage._active, 'cam2', `active cam should be cam2 at t=1.5, is ${stage._active}`);
}
if (f === 20) { // t=0.667 → before the 1.5s cut, still cam1
assert.equal(stage._active, 'cam1', `active cam should be cam1 before cut, is ${stage._active}`);
}
if (f === 0) { // clip not started at t=0
const c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.equal(c, null, 'clip should be inactive at t=0');
}
}
// clip sample frames: at t=1.0 (start) local=0, at t=2.4 local≈1.4
stage.applied.length = 0; tl.step(30); // t=1.0
let c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.ok(c && near(c.time, 0, 1e-6), `clip local at start should be 0, got ${c && c.time}`);
stage.applied.length = 0; tl.step(72); // t=2.4
c = last((a) => a.type === 'clip' && a.id === 'e1');
assert.ok(c && near(c.time, 1.4, 1e-6), `clip local at 2.4 should be 1.4, got ${c && c.time}`);
// toJSON deep-equals a re-loaded copy (lossless round-trip)
const out = tl.toJSON();
const tl2 = new Timeline(new StageStub());
tl2.load(out);
assert.deepEqual(tl2.toJSON(), out, 'round-trip must be lossless');
// unknown entity fields preserved (Lane A owns them)
const withExtra = structuredClone(scene);
withExtra.entities[0].laneAOnly = { gizmo: 'move', foo: [1, 2, 3] };
const tl3 = new Timeline(new StageStub());
tl3.load(withExtra);
assert.deepEqual(tl3.toJSON().entities[0].laneAOnly, { gizmo: 'move', foo: [1, 2, 3] }, 'unknown fields must survive');
console.log('OK — timeline_test.mjs: all assertions passed');

59
scenegod/web/tldev.html Normal file
View File

@ -0,0 +1,59 @@
<!doctype html>
<!-- tldev.html — Lane B DEV-ONLY harness to exercise the timeline UI against
the stub before Lane A's index.html exists. Deleted at SYNC 1. -->
<meta charset="utf-8">
<title>SCENEGOD · timeline dev harness</title>
<style>
html,body{margin:0;height:100%;background:#0d1117;color:#c9d1d9;font:13px ui-monospace,monospace}
#top{padding:8px 12px;border-bottom:1px solid #2b323c}
#log{height:120px;overflow:auto;padding:6px 12px;color:#565f6b;font-size:11px;white-space:pre-wrap;border-bottom:1px solid #2b323c}
#timeline{position:fixed;left:0;right:0;bottom:0}
</style>
<div id="top">Lane B dev harness — stub stage. Double-click a lane to add a key,
drag keys, ▶ to play, drag the ruler to scrub. setTransform/param calls log below.</div>
<div id="log"></div>
<div id="timeline"></div>
<script type="module">
import { StageStub } from './stagestub.js';
import { Timeline } from './timeline.js';
import { TimelineUI } from './tlui.js';
// mirror stub effects into the on-page log
const logEl = document.getElementById('log');
const stage = new StageStub();
const _st = stage.setTransform.bind(stage), _sp = stage.setParam.bind(stage), _sc = stage.setActiveCamera.bind(stage);
const line = (s) => { logEl.textContent = s + '\n' + logEl.textContent.slice(0, 4000); };
stage.setTransform = (id, t) => { _st(id, t); line(`setTransform ${id} pos=[${t.pos.map(n=>n.toFixed(2))}]`); };
stage.setParam = (id, k, v) => { _sp(id, k, v); line(`setParam ${id} ${k}=${v}`); };
stage.setActiveCamera = (id) => { _sc(id); line(`activeCamera ${id}`); };
const scene = {
version: 1, name: 'dev', fps: 30, duration: 6,
entities: [
{ id: 'lady', kind: 'character', label: 'lady',
source: { type: 'assets', path: 'characters/lady.glb' },
transform: { pos: [0,0,0], rot: [0,0,0], scale: 1 },
tracks: { transform: [
{ t: 0, pos: [-3,0,0], rot: [0,0,0], scale: 1, ease: 'linear' },
{ t: 4, pos: [3,0,0], rot: [0,1.57,0], scale: 1, ease: 'inout' } ],
clips: [ { path: 'animations/walk.fbx', clipIndex: 0, start: 0.5, in: 0, out: 2.4, loop: 1, fade: 0 } ] } },
{ id: 'sun', kind: 'light', label: 'sun',
params: { type: 'key', color: '#ffcc88', intensity: 1 },
transform: { pos: [2,5,2], rot: [0,0,0], scale: 1 },
tracks: { params: [ { t: 0, key: 'intensity', value: 0.2, ease: 'inout' }, { t: 5, key: 'intensity', value: 3, ease: 'inout' } ] } },
{ id: 'camA', kind: 'camera', label: 'wide', params: { fov: 40 }, transform: { pos: [0,1.5,6], rot: [0,0,0], scale: 1 }, tracks: {} },
{ id: 'camB', kind: 'camera', label: 'close', params: { fov: 60 }, transform: { pos: [2,1.6,2], rot: [0,0,0], scale: 1 }, tracks: {} },
],
cameraCuts: [ { t: 0, camera: 'camA' }, { t: 3, camera: 'camB' } ],
audio: [],
};
await stage.applyState(scene);
const tl = new Timeline(stage);
tl.load(scene);
await tl.preload();
const ui = new TimelineUI(tl, '#timeline');
window._tl = tl; window._ui = ui; window._stage = stage; // console poking
line('ready — scene loaded');
</script>

313
scenegod/web/tlui.js Normal file
View File

@ -0,0 +1,313 @@
// tlui.js — SCENEGOD timeline panel (Lane B). ALL DOM/canvas lives here;
// timeline.js stays headless. Mounts into #timeline, drives a Timeline.
//
// Layout: [scene bar] / [names column | ruler+canvas lanes]. One <canvas> for
// all lanes (decision: simpler than canvas-per-lane; hit-testing via a rebuilt
// hitbox list each draw). Fit-to-width time axis; zoom/pan is M2.
const ROW_H = 28;
const RULER_H = 22;
const KEY_R = 5;
// ponytail: styles injected here, not written into Lane A's web/style.css —
// keeps Lane B inside its own files. Move into a /* === LANE B === */ block at
// SYNC if John wants them centralized.
const CSS = `
#timeline{display:flex;flex-direction:column;background:#161a20;color:#c9d1d9;font:12px/1.4 ui-monospace,Menlo,monospace;border-top:1px solid #2b323c;user-select:none}
#timeline .tl-bar{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid #2b323c}
#timeline .tl-bar input{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 6px;border-radius:3px;font:inherit}
#timeline .tl-bar input.name{width:150px}
#timeline .tl-bar input.num{width:56px}
#timeline .tl-bar button{background:#21262d;border:1px solid #2b323c;color:#c9d1d9;padding:3px 10px;border-radius:3px;cursor:pointer;font:inherit}
#timeline .tl-bar button:hover{background:#2b323c}
#timeline .tl-bar .spring{flex:1}
#timeline .tl-bar .clock{color:#7aa2f7;min-width:64px;text-align:right}
#timeline .tl-body{display:flex;height:200px}
#timeline .tl-names{width:150px;flex:none;overflow:hidden;border-right:1px solid #2b323c;background:#12161c}
#timeline .tl-names .row{height:${ROW_H}px;display:flex;align-items:center;padding:0 8px;box-sizing:border-box;border-bottom:1px solid #1b2027;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#timeline .tl-names .row.head{margin-top:${RULER_H}px}
#timeline .tl-names .row .k{color:#565f6b;margin-right:6px;font-size:10px}
#timeline .tl-names .row.clip{color:#8b949e;padding-left:20px;font-size:11px}
#timeline .tl-lanes{flex:1;position:relative;overflow:hidden}
#timeline canvas{display:block;width:100%;height:100%}
`;
export class TimelineUI {
constructor(timeline, mountEl) {
this.tl = timeline;
this.stage = timeline.stage;
this.el = typeof mountEl === 'string' ? document.querySelector(mountEl) : mountEl;
this._hits = [];
this._drag = null;
this._injectCSS();
this._build();
this.tl.onTick(() => this.draw());
if (this.stage.onChange) this.stage.onChange(() => this._syncRows());
this._syncRows();
this.draw();
}
_injectCSS() {
if (document.getElementById('laneB-tl-css')) return;
const s = document.createElement('style');
s.id = 'laneB-tl-css';
s.textContent = CSS;
document.head.appendChild(s);
}
_build() {
this.el.innerHTML = '';
// scene bar
const bar = document.createElement('div');
bar.className = 'tl-bar';
bar.innerHTML = `
<button data-act="play"></button>
<span class="clock">0.00s</span>
<input class="name" placeholder="scene name" value="${this.tl.scene.name || ''}">
<span>dur</span><input class="num dur" type="number" min="0" step="0.5" value="${this.tl.duration}">
<span class="spring"></span>
<button data-act="save">Save</button>
<button data-act="load">Load</button>`;
this.el.appendChild(bar);
this.$play = bar.querySelector('[data-act="play"]');
this.$clock = bar.querySelector('.clock');
this.$name = bar.querySelector('.name');
this.$dur = bar.querySelector('.dur');
this.$play.onclick = () => this._toggle();
bar.querySelector('[data-act="save"]').onclick = () => this.save();
bar.querySelector('[data-act="load"]').onclick = () => this.load(this.$name.value);
this.$dur.onchange = () => { this.tl.scene.duration = Math.max(0.1, +this.$dur.value || 1); this.draw(); };
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
// body
const body = document.createElement('div');
body.className = 'tl-body';
this.$names = document.createElement('div'); this.$names.className = 'tl-names';
const lanes = document.createElement('div'); lanes.className = 'tl-lanes';
this.canvas = document.createElement('canvas');
lanes.appendChild(this.canvas);
body.appendChild(this.$names); body.appendChild(lanes);
this.el.appendChild(body);
this.lanesEl = lanes;
// canvas events
this.canvas.addEventListener('mousedown', (e) => this._down(e));
window.addEventListener('mousemove', (e) => this._move(e));
window.addEventListener('mouseup', () => this._up());
this.canvas.addEventListener('dblclick', (e) => this._dbl(e));
this.canvas.addEventListener('contextmenu', (e) => this._ctx(e));
window.addEventListener('keydown', (e) => this._key(e));
window.addEventListener('resize', () => this.draw());
}
// rows: [{type:'transform'|'clip'|'cameras', id?, label}]
_rows() {
const rows = [];
for (const e of this.stage.entities()) {
rows.push({ type: 'transform', id: e.id, kind: e.kind, label: e.label || e.id });
if (e.kind === 'character') rows.push({ type: 'clip', id: e.id, label: '↳ clips' });
}
rows.push({ type: 'cameras', label: 'Cameras' });
return rows;
}
_syncRows() {
this.rows = this._rows();
// rebuild names column
this.$names.innerHTML = '';
this.rows.forEach((r, i) => {
const d = document.createElement('div');
d.className = 'row' + (i === 0 ? ' head' : '') + (r.type === 'clip' ? ' clip' : '');
if (r.type === 'transform') d.innerHTML = `<span class="k">${(r.kind || '?')[0].toUpperCase()}</span>${r.label}`;
else d.textContent = r.label;
this.$names.appendChild(d);
});
this.draw();
}
// ---- geometry ----
_dims() {
const w = this.canvas.clientWidth, h = this.canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
if (this.canvas.width !== w * dpr || this.canvas.height !== h * dpr) {
this.canvas.width = w * dpr; this.canvas.height = h * dpr;
}
return { w, h, dpr, pps: w / (this.tl.duration || 1) };
}
_x2t(x) { const { pps } = this._dims(); return Math.max(0, Math.min(this.tl.duration, x / pps)); }
_t2x(t) { const { pps } = this._dims(); return t * pps; }
_rowAtY(y) { const i = Math.floor((y - RULER_H) / ROW_H); return (i >= 0 && i < this.rows.length) ? i : -1; }
// ---- draw ----
draw() {
if (!this.rows) this.rows = this._rows();
const { w, h, dpr } = this._dims();
const c = this.canvas.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
c.clearRect(0, 0, w, h);
this._hits = [];
// ruler
c.fillStyle = '#12161c'; c.fillRect(0, 0, w, RULER_H);
c.strokeStyle = '#2b323c'; c.fillStyle = '#565f6b'; c.font = '10px ui-monospace';
const dur = this.tl.duration || 1;
const stepSec = dur <= 5 ? 0.5 : dur <= 20 ? 1 : 5;
for (let t = 0; t <= dur + 1e-6; t += stepSec) {
const x = this._t2x(t);
c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.strokeStyle = '#1b2027'; c.stroke();
c.fillText(t.toFixed(stepSec < 1 ? 1 : 0) + 's', x + 2, 12);
}
// lanes
this.rows.forEach((r, i) => {
const y = RULER_H + i * ROW_H;
c.fillStyle = i % 2 ? '#161a20' : '#13171d';
c.fillRect(0, y, w, ROW_H);
c.strokeStyle = '#1b2027'; c.beginPath(); c.moveTo(0, y + ROW_H); c.lineTo(w, y + ROW_H); c.stroke();
if (r.type === 'transform') this._drawKeys(c, r, y);
else if (r.type === 'clip') this._drawClips(c, r, y);
else if (r.type === 'cameras') this._drawCuts(c, y);
});
// playhead
const px = this._t2x(this.tl.time);
c.strokeStyle = '#f7768e'; c.lineWidth = 1.5;
c.beginPath(); c.moveTo(px, 0); c.lineTo(px, h); c.stroke(); c.lineWidth = 1;
c.fillStyle = '#f7768e'; c.beginPath(); c.moveTo(px - 4, 0); c.lineTo(px + 4, 0); c.lineTo(px, 6); c.fill();
this.$clock.textContent = this.tl.time.toFixed(2) + 's';
this.$play.textContent = this.tl.playing ? '❚❚' : '▶';
}
_entity(id) { return this.stage.getEntity(id); }
_keysOf(id) { const e = this._entity(id); return (e && e.tracks && e.tracks.transform) || []; }
_drawKeys(c, r, y) {
const cy = y + ROW_H / 2;
for (const k of this._keysOf(r.id)) {
const x = this._t2x(k.t);
c.fillStyle = '#7aa2f7';
c.beginPath(); c.moveTo(x, cy - KEY_R); c.lineTo(x + KEY_R, cy); c.lineTo(x, cy + KEY_R); c.lineTo(x - KEY_R, cy); c.fill();
this._hits.push({ kind: 'key', id: r.id, track: 'transform', key: k, x, y: cy });
}
}
_drawClips(c, r, y) {
const e = this._entity(r.id);
const blocks = (e && e.tracks && e.tracks.clips) || [];
for (const b of blocks) {
const span = (b.out - b.in) || 0.0001, end = b.start + span * (b.loop || 1);
const x0 = this._t2x(b.start), x1 = this._t2x(end);
c.fillStyle = '#3d5a80'; c.fillRect(x0, y + 5, Math.max(2, x1 - x0), ROW_H - 10);
c.strokeStyle = '#98c1d9'; c.strokeRect(x0 + 0.5, y + 5.5, Math.max(2, x1 - x0) - 1, ROW_H - 11);
c.fillStyle = '#e0e6ed'; c.font = '10px ui-monospace';
const name = (b.path || '').split('/').pop();
c.save(); c.beginPath(); c.rect(x0, y, x1 - x0, ROW_H); c.clip();
c.fillText(name, x0 + 4, y + ROW_H / 2 + 3); c.restore();
this._hits.push({ kind: 'block', id: r.id, block: b, x0, x1, y });
this._hits.push({ kind: 'edge', id: r.id, block: b, side: 'out', x: x1, y });
}
}
_drawCuts(c, y) {
const cy = y + ROW_H / 2;
for (const cut of this.tl.cameraCuts) {
const x = this._t2x(cut.t);
c.fillStyle = '#e0af68';
c.fillRect(x - 1, y + 4, 2, ROW_H - 8);
c.beginPath(); c.arc(x, cy, 4, 0, Math.PI * 2); c.fill();
c.fillStyle = '#161a20'; c.font = '9px ui-monospace'; c.fillText(cut.camera || '?', x + 6, cy + 3);
this._hits.push({ kind: 'cut', cut, x, y: cy });
}
}
// ---- interaction ----
_local(e) { const r = this.canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
_snap(t, e) { return e && e.altKey ? t : Math.round(t * this.tl.fps) / this.tl.fps; }
_hitAt(x, y) {
// prefer key/edge handles (small), then blocks
for (const h of this._hits) {
if (h.kind === 'key' || h.kind === 'cut') { if (Math.abs(h.x - x) < 7 && Math.abs(h.y - y) < 9) return h; }
else if (h.kind === 'edge') { if (Math.abs(h.x - x) < 5 && y > h.y && y < h.y + ROW_H) return h; }
}
for (const h of this._hits) if (h.kind === 'block' && x >= h.x0 && x <= h.x1 && y > h.y && y < h.y + ROW_H) return h;
return null;
}
_down(e) {
const { x, y } = this._local(e);
if (y < RULER_H) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; }
const h = this._hitAt(x, y);
if (!h) { this._drag = { kind: 'seek' }; this.tl.seek(this._x2t(x)); return; }
if (h.kind === 'key') this._drag = { kind: 'key', h };
else if (h.kind === 'edge') this._drag = { kind: 'trim', h };
else if (h.kind === 'block') this._drag = { kind: 'block', h, grabT: this._x2t(x) - h.block.start };
else if (h.kind === 'cut') this._drag = { kind: 'cut', h };
}
_move(e) {
if (!this._drag) return;
const { x } = this._local(e);
const t = this._x2t(x), d = this._drag;
if (d.kind === 'seek') this.tl.seek(t);
else if (d.kind === 'key') { this.tl.moveKey(d.h.id, 'transform', d.h.key, this._snap(t, e)); this.draw(); }
else if (d.kind === 'cut') { d.h.cut.t = this._snap(t, e); this.tl.cameraCuts.sort((a, b) => a.t - b.t); this.tl._activeCam = undefined; this.draw(); }
else if (d.kind === 'block') { this.tl.moveClipBlock(d.h.id, d.h.block, Math.max(0, this._snap(t - d.grabT, e))); this.draw(); }
else if (d.kind === 'trim') { const b = d.h.block; this.tl.trimClipBlock(d.h.id, b, { out: Math.max(b.in + 0.05, b.in + (t - b.start)) }); this.draw(); }
}
_up() { this._drag = null; }
_dbl(e) {
const { x, y } = this._local(e);
const i = this._rowAtY(y); if (i < 0) return;
const r = this.rows[i];
if (r.type !== 'transform') return;
const t = this._snap(this._x2t(x), e);
const cur = this.stage.entityTransform(r.id);
this.tl.addKey(r.id, 'transform', { t, pos: cur.pos, rot: cur.rot, scale: cur.scale, ease: 'inout' });
this.draw();
}
_ctx(e) {
e.preventDefault();
const { x, y } = this._local(e);
const h = this._hitAt(x, y);
if (h && h.kind === 'key') { this.tl.deleteKey(h.id, 'transform', h.key); this.draw(); }
else if (h && h.kind === 'cut') { const i = this.tl.cameraCuts.indexOf(h.cut); if (i >= 0) this.tl.cameraCuts.splice(i, 1); this.tl._activeCam = undefined; this.draw(); }
}
_key(e) {
if (e.target && /INPUT|TEXTAREA/.test(e.target.tagName)) return;
if (e.code === 'Space') { e.preventDefault(); this._toggle(); }
else if (e.key === 'Home') this.tl.seek(0);
else if (e.key === 'End') this.tl.seek(this.tl.duration);
else if ((e.ctrlKey || e.metaKey) && e.key === 'z') { e.preventDefault(); this.tl.undo(); this.draw(); }
}
_toggle() { this.tl.playing ? this.tl.pause() : this.tl.play(); this.draw(); }
// ---- persistence (Lane C endpoints; localStorage fallback) ----
async save() {
const name = (this.$name.value || 'untitled').trim();
this.tl.scene.name = name;
const json = this.tl.toJSON();
try {
const res = await fetch(`/scenes/${encodeURIComponent(name)}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(json),
});
if (!res.ok) throw new Error(res.status);
} catch {
localStorage.setItem('scenegod:scene:' + name, JSON.stringify(json)); // fallback until Lane C lands
}
}
async load(name) {
name = (name || '').trim(); if (!name) return;
let json = null;
try { const res = await fetch(`/scenes/${encodeURIComponent(name)}`); if (res.ok) json = await res.json(); } catch { /* fall through */ }
if (!json) { const raw = localStorage.getItem('scenegod:scene:' + name); if (raw) json = JSON.parse(raw); }
if (!json) return;
if (this.stage.applyState) await this.stage.applyState(json);
this.tl.load(json);
await this.tl.preload();
this.$name.value = json.name || name;
this.$dur.value = this.tl.duration;
this._syncRows();
this.draw();
}
}
export default TimelineUI;

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()