Compare commits
5 Commits
e29edc808b
...
de3416869f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de3416869f | ||
|
|
b20354c53f | ||
|
|
45debd3c10 | ||
|
|
f6292f2c84 | ||
|
|
bb85eef040 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.env
|
||||
.venv/
|
||||
assets/
|
||||
scenes/
|
||||
renders/
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
BIN
logs/shots/m1-lady-running.png
Normal file
BIN
logs/shots/m1-lady-running.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
246
scenegod/server.py
Normal file
246
scenegod/server.py
Normal file
@ -0,0 +1,246 @@
|
||||
"""SCENEGOD server — page + assets + scenes + render pipeline.
|
||||
|
||||
Single-file FastAPI app (MESHGOD convention). A plain folder tree is the asset
|
||||
database (no index file to drift): assets/{characters,animations,props,
|
||||
backdrops,audio}/<pack>/name.ext. Files sharing dir+stem = one entry, many
|
||||
formats. See PLAN.md §4.3 for the HTTP contract.
|
||||
|
||||
uvicorn scenegod.server:app --port 8020 # binds 127.0.0.1
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WEB = Path(__file__).resolve().parent / "web" # scenegod/web (PLAN §2)
|
||||
ASSETS = Path(os.environ.get("SCENEGOD_ASSETS", ROOT / "assets")).resolve()
|
||||
SCENES = Path(os.environ.get("SCENEGOD_SCENES", ROOT / "scenes")).resolve()
|
||||
RENDERS = Path(os.environ.get("SCENEGOD_RENDERS", ROOT / "renders")).resolve()
|
||||
|
||||
MODEL_EXTS = {"glb", "gltf", "fbx", "obj", "bvh"}
|
||||
IMG_EXTS = {"jpg", "jpeg", "png", "webp"}
|
||||
AUDIO_EXTS = {"wav", "mp3"}
|
||||
ASSET_EXTS = MODEL_EXTS | IMG_EXTS | AUDIO_EXTS
|
||||
# primary ext per category (others sharing a stem are thumbnails)
|
||||
CATEGORIES = {"characters": MODEL_EXTS, "animations": MODEL_EXTS, "props": MODEL_EXTS,
|
||||
"backdrops": IMG_EXTS, "audio": AUDIO_EXTS}
|
||||
MEDIA_TYPES = {
|
||||
"glb": "model/gltf-binary", "gltf": "model/gltf+json", "fbx": "application/octet-stream",
|
||||
"obj": "text/plain", "bvh": "text/plain", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"png": "image/png", "webp": "image/webp", "wav": "audio/wav", "mp3": "audio/mpeg",
|
||||
}
|
||||
|
||||
SCENES.mkdir(parents=True, exist_ok=True)
|
||||
RENDERS.mkdir(parents=True, exist_ok=True)
|
||||
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
|
||||
|
||||
app = FastAPI(title="SCENEGOD")
|
||||
|
||||
|
||||
# ---- path guard (ship-check rule: every path-taking endpoint uses this) ----
|
||||
def safe_under(root: Path, relpath: str) -> Path:
|
||||
"""Resolve relpath under root; reject traversal, absolute, symlink escape."""
|
||||
full = (root / relpath).resolve()
|
||||
if full != root and root not in full.parents:
|
||||
raise HTTPException(400, "bad path")
|
||||
return full
|
||||
|
||||
|
||||
# ---- assets ----
|
||||
_tree_cache = {"t": 0.0, "val": None}
|
||||
|
||||
|
||||
def scan_assets() -> dict:
|
||||
"""Walk each category dir, group files by (dir, stem) → one entry w/ formats.
|
||||
Live scan (hundreds of files); cached 5s in /assets/tree."""
|
||||
out = {c: [] for c in CATEGORIES}
|
||||
for cat, primary in CATEGORIES.items():
|
||||
base = ASSETS / cat
|
||||
if not base.is_dir():
|
||||
continue
|
||||
groups: dict[str, dict] = {}
|
||||
for dirpath, dirs, files in os.walk(base):
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
for f in sorted(files):
|
||||
stem, dot, ext = f.rpartition(".")
|
||||
ext = ext.lower()
|
||||
if f.startswith(".") or not dot or ext not in ASSET_EXTS:
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, f), ASSETS).replace(os.sep, "/")
|
||||
key = os.path.relpath(os.path.join(dirpath, stem), ASSETS) # dir+stem
|
||||
g = groups.setdefault(key, {"name": stem, "path": None, "formats": [], "thumb": None})
|
||||
if ext not in primary:
|
||||
if ext in IMG_EXTS:
|
||||
g["thumb"] = rel
|
||||
continue
|
||||
g["formats"].append(ext)
|
||||
if g["path"] is None or ext == "glb": # prefer glb, else first
|
||||
g["path"] = rel
|
||||
entries = [g for g in groups.values() if g["formats"]] # thumb-only stems aren't assets
|
||||
out[cat] = sorted(entries, key=lambda g: g["name"].lower())
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/assets/tree")
|
||||
def assets_tree():
|
||||
if not ASSETS.is_dir():
|
||||
raise HTTPException(500, f"asset root missing: {ASSETS} (set SCENEGOD_ASSETS)")
|
||||
now = time.monotonic()
|
||||
if _tree_cache["val"] is None or now - _tree_cache["t"] > 5.0:
|
||||
_tree_cache["val"] = scan_assets()
|
||||
_tree_cache["t"] = now
|
||||
return _tree_cache["val"]
|
||||
|
||||
|
||||
@app.get("/assets/file")
|
||||
def assets_file(path: str):
|
||||
full = safe_under(ASSETS, path)
|
||||
if not full.is_file():
|
||||
raise HTTPException(404, "not found")
|
||||
ext = full.suffix.lstrip(".").lower()
|
||||
return FileResponse(full, media_type=MEDIA_TYPES.get(ext, "application/octet-stream"))
|
||||
|
||||
|
||||
# ---- scenes ----
|
||||
SLUG = re.compile(r"[^a-z0-9_-]")
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
s = SLUG.sub("-", name.lower()).strip("-")
|
||||
if not s:
|
||||
raise HTTPException(422, "empty scene name after slugify")
|
||||
return s
|
||||
|
||||
|
||||
def validate_scene(s: dict) -> list[str]:
|
||||
"""Return list of violations (empty = valid). Stdlib only, no jsonschema."""
|
||||
errs = []
|
||||
if s.get("version") != 1:
|
||||
errs.append(f"version must be 1, got {s.get('version')!r}")
|
||||
ents = s.get("entities")
|
||||
if not isinstance(ents, list):
|
||||
return errs + ["entities must be a list"]
|
||||
ids, cam_ids = set(), set()
|
||||
for i, e in enumerate(ents):
|
||||
eid = e.get("id")
|
||||
if eid in ids:
|
||||
errs.append(f"entity[{i}]: duplicate id {eid!r}")
|
||||
ids.add(eid)
|
||||
if e.get("kind") == "camera":
|
||||
cam_ids.add(eid)
|
||||
tracks = e.get("tracks") or {}
|
||||
for tk in ("transform", "params"):
|
||||
keys = tracks.get(tk) or []
|
||||
ts = [k.get("t") for k in keys]
|
||||
if ts != sorted(ts):
|
||||
errs.append(f"entity {eid!r}: track {tk} not sorted by t")
|
||||
clips = sorted(tracks.get("clips") or [], key=lambda c: c.get("start", 0))
|
||||
for a, b in zip(clips, clips[1:]):
|
||||
end = a.get("start", 0) + (a.get("out", 0) - a.get("in", 0)) * a.get("loop", 1)
|
||||
if b.get("start", 0) < end - a.get("fade", 0) - 1e-6:
|
||||
errs.append(f"entity {eid!r}: clips overlap beyond fade "
|
||||
f"(block ends {end:.3f}, next starts {b.get('start')})")
|
||||
for cut in s.get("cameraCuts") or []:
|
||||
if cut.get("camera") not in cam_ids:
|
||||
errs.append(f"cameraCut references non-camera entity {cut.get('camera')!r}")
|
||||
return errs
|
||||
|
||||
|
||||
@app.get("/scenes")
|
||||
def scenes_list():
|
||||
out = []
|
||||
for p in sorted(SCENES.glob("*.json")):
|
||||
try:
|
||||
dur = json.loads(p.read_text()).get("duration")
|
||||
except Exception:
|
||||
dur = None
|
||||
out.append({"name": p.stem, "mtime": int(p.stat().st_mtime), "duration": dur})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/scenes/{name}")
|
||||
def scene_get(name: str):
|
||||
p = safe_under(SCENES, slugify(name) + ".json")
|
||||
if not p.is_file():
|
||||
raise HTTPException(404, "not found")
|
||||
return json.loads(p.read_text())
|
||||
|
||||
|
||||
@app.post("/scenes/{name}")
|
||||
async def scene_save(name: str, request: Request):
|
||||
body = await request.body()
|
||||
try:
|
||||
scene = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(400, f"invalid JSON: {e}")
|
||||
errs = validate_scene(scene)
|
||||
if errs:
|
||||
return JSONResponse({"errors": errs}, status_code=422)
|
||||
p = safe_under(SCENES, slugify(name) + ".json")
|
||||
tmp = p.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(scene, indent=2))
|
||||
tmp.replace(p) # atomic
|
||||
return {"ok": True, "name": p.stem}
|
||||
|
||||
|
||||
# ---- render (M3 — design landed, encode stubbed) ----
|
||||
@app.post("/render/begin")
|
||||
async def render_begin(request: Request):
|
||||
if not HAVE_FFMPEG:
|
||||
raise HTTPException(503, "ffmpeg not found on server")
|
||||
rid = uuid.uuid4().hex
|
||||
(RENDERS / rid / "frames").mkdir(parents=True)
|
||||
(RENDERS / rid / "meta.json").write_text((await request.body()).decode() or "{}")
|
||||
return {"renderId": rid}
|
||||
|
||||
|
||||
@app.post("/render/{rid}/frame/{n}")
|
||||
async def render_frame(rid: str, n: int, request: Request):
|
||||
frames = safe_under(RENDERS, rid) / "frames"
|
||||
if not frames.is_dir():
|
||||
raise HTTPException(404, "unknown renderId")
|
||||
(frames / f"{n:06d}.png").write_bytes(await request.body())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/render/{rid}/end")
|
||||
async def render_end(rid: str):
|
||||
raise HTTPException(501, "encode not implemented until M3")
|
||||
|
||||
|
||||
@app.get("/render/{rid}/status")
|
||||
def render_status(rid: str):
|
||||
raise HTTPException(501, "not implemented until M3")
|
||||
|
||||
|
||||
@app.get("/render/{rid}/out.mp4")
|
||||
def render_out(rid: str):
|
||||
raise HTTPException(501, "not implemented until M3")
|
||||
|
||||
|
||||
# ---- statics (last: catch-all /web is defined after API routes) ----
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index():
|
||||
f = WEB / "index.html"
|
||||
html = f.read_text() if f.is_file() else "<h1>SCENEGOD</h1><p>Lane A page not landed yet.</p>"
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-store"}) # MESHGOD stale-cache lesson
|
||||
|
||||
|
||||
@app.get("/web/{path:path}")
|
||||
def web_static(path: str):
|
||||
full = safe_under(WEB, path)
|
||||
if not full.is_file():
|
||||
raise HTTPException(404, "not found")
|
||||
return FileResponse(full, headers={"Cache-Control": "max-age=60"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("SCENEGOD_PORT", 8020)))
|
||||
187
scenegod/web/dock.js
Normal file
187
scenegod/web/dock.js
Normal 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
57
scenegod/web/index.html
Normal 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
171
scenegod/web/room3d.js
Normal 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
327
scenegod/web/stage.js
Normal 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
103
scenegod/web/stagestub.js
Normal 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
67
scenegod/web/style.css
Normal 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
314
scenegod/web/timeline.js
Normal 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;
|
||||
102
scenegod/web/timeline_test.mjs
Normal file
102
scenegod/web/timeline_test.mjs
Normal 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
59
scenegod/web/tldev.html
Normal 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
313
scenegod/web/tlui.js
Normal 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
133
scripts/test_server.py
Normal file
@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""M1 smoke test for scenegod.server — stdlib only, real uvicorn on a test port.
|
||||
|
||||
python3 scripts/test_server.py # green = M1 code-done
|
||||
|
||||
Spins up the server against throwaway asset/scene dirs, then asserts tree
|
||||
grouping, file streaming, path-traversal rejection, scene round-trip, and
|
||||
validation failures.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PY = ROOT / ".venv" / "bin" / "python"
|
||||
PORT = 8099
|
||||
BASE = f"http://127.0.0.1:{PORT}"
|
||||
|
||||
|
||||
def req(method, path, body=None):
|
||||
data = body.encode() if isinstance(body, str) else body
|
||||
r = urllib.request.Request(BASE + path, data=data, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(r) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
|
||||
def get_json(path):
|
||||
st, b = req("GET", path)
|
||||
assert st == 200, f"{path} -> {st}"
|
||||
return json.loads(b)
|
||||
|
||||
|
||||
def main():
|
||||
tmp = Path(tempfile.mkdtemp(prefix="scenegod_test_"))
|
||||
assets, scenes = tmp / "assets", tmp / "scenes"
|
||||
# lady.glb + lady.fbx + lady.jpg share a stem -> one entry, thumb split off
|
||||
(assets / "characters" / "pack01").mkdir(parents=True)
|
||||
for f in ("lady.glb", "lady.fbx", "lady.jpg"):
|
||||
(assets / "characters" / "pack01" / f).write_bytes(b"x")
|
||||
(assets / "backdrops").mkdir(parents=True)
|
||||
(assets / "backdrops" / "street.jpg").write_bytes(b"img")
|
||||
for d in ("animations", "props", "audio"):
|
||||
(assets / d).mkdir()
|
||||
|
||||
env = {**os.environ, "SCENEGOD_ASSETS": str(assets), "SCENEGOD_SCENES": str(scenes)}
|
||||
proc = subprocess.Popen(
|
||||
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(PORT)],
|
||||
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
for _ in range(50): # wait for boot
|
||||
try:
|
||||
req("GET", "/scenes")
|
||||
break
|
||||
except urllib.error.URLError:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise SystemExit("server did not start")
|
||||
|
||||
n = 0
|
||||
# --- tree grouping ---
|
||||
tree = get_json("/assets/tree")
|
||||
chars = tree["characters"]
|
||||
assert len(chars) == 1 and chars[0]["name"] == "lady", chars
|
||||
assert chars[0]["path"] == "characters/pack01/lady.glb", chars # glb preferred
|
||||
assert set(chars[0]["formats"]) == {"glb", "fbx"}, chars # jpg not a format
|
||||
assert chars[0]["thumb"] == "characters/pack01/lady.jpg", chars
|
||||
assert tree["backdrops"][0]["path"] == "backdrops/street.jpg", tree["backdrops"]
|
||||
n += 1
|
||||
|
||||
# --- file streaming ---
|
||||
st, b = req("GET", "/assets/file?path=backdrops/street.jpg")
|
||||
assert st == 200 and b == b"img", (st, b)
|
||||
n += 1
|
||||
|
||||
# --- path traversal rejected ---
|
||||
for bad in ("../../etc/hosts", "/etc/hosts", "..%2f..%2fetc%2fhosts",
|
||||
"characters/../../../etc/hosts"):
|
||||
st, _ = req("GET", "/assets/file?path=" + bad)
|
||||
assert st >= 400, f"traversal {bad!r} not rejected: {st}"
|
||||
n += 1
|
||||
|
||||
# --- scene round-trip ---
|
||||
scene = {"version": 1, "name": "s1", "fps": 30, "duration": 5.0,
|
||||
"entities": [{"id": "e1", "kind": "camera", "label": "cam",
|
||||
"tracks": {"transform": [{"t": 0}, {"t": 1}]}}],
|
||||
"cameraCuts": [{"t": 0, "camera": "e1"}]}
|
||||
st, _ = req("POST", "/scenes/" + urllib.parse.quote("My Scene!"), json.dumps(scene))
|
||||
assert st == 200, st
|
||||
assert get_json("/scenes/my-scene")["duration"] == 5.0 # slugified
|
||||
assert any(s["name"] == "my-scene" for s in get_json("/scenes"))
|
||||
n += 1
|
||||
|
||||
# --- validation failures ---
|
||||
bad_scenes = [
|
||||
{"version": 2, "entities": []}, # bad version
|
||||
{"version": 1, "entities": [{"id": "a"}, {"id": "a"}]}, # dup id
|
||||
{"version": 1, "entities": [{"id": "a", "tracks": # unsorted
|
||||
{"transform": [{"t": 1}, {"t": 0}]}}]},
|
||||
{"version": 1, "entities": [], "cameraCuts": [{"camera": "nope"}]}, # bad cut
|
||||
{"version": 1, "entities": [{"id": "a", "tracks": {"clips": [ # overlap
|
||||
{"start": 0, "in": 0, "out": 2, "loop": 1, "fade": 0},
|
||||
{"start": 1, "in": 0, "out": 2}]}}]},
|
||||
]
|
||||
for bs in bad_scenes:
|
||||
st, b = req("POST", "/scenes/bad", json.dumps(bs))
|
||||
assert st == 422, f"expected 422 for {bs}, got {st}"
|
||||
assert json.loads(b)["errors"], b
|
||||
# a fade-covered overlap is allowed
|
||||
ok = {"version": 1, "entities": [{"id": "a", "tracks": {"clips": [
|
||||
{"start": 0, "in": 0, "out": 2, "loop": 1, "fade": 0.5},
|
||||
{"start": 1.6, "in": 0, "out": 2}]}}]}
|
||||
st, _ = req("POST", "/scenes/ok-fade", json.dumps(ok))
|
||||
assert st == 200, st
|
||||
n += 1
|
||||
|
||||
print(f"OK: {n} test groups passed")
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user