Compare commits

...

5 Commits

Author SHA1 Message Date
type-two
04e15d3e39 [laneA] M3-polish: PiP cap+swap, spec-gloss GLB fix (lady renders textured)
- PiP: sized in _resize (~24% w, 16:9, bottom-right) — CSS aspect-ratio is
  unreliable on <canvas> and had ballooned it. Click-to-swap main<->active cam;
  _render(cam,canvas) restores aspect after a pip blit so swap never distorts.
  renderActiveCamera/pause/resume contract unchanged (M3 finalRender safe).
- room3d: SpecGlossPlugin on GLTFLoader — CC/Reallusion GLBs use
  KHR_materials_pbrSpecularGlossiness (dropped in three r160) and rendered
  flat white; map spec-gloss -> MeshStandard. lady.glb now withMap=26/26.

Verified live at :8020, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:49:22 +10:00
type-two
c602ce8dea [laneC] log: M4 done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:47:52 +10:00
type-two
be65355f6b [laneC] M4: audio mux, graft_limb.py, MODELBEAST proxy
- render/end muxes scene audio[] (adelay+volume+amix, aac, -shortest);
  audio paths path-guarded; render.js finalRender passes scene audio
- scripts/graft_limb.py: headless Blender limb graft (align root->target bone,
  join armatures, parent-keep-offset, join meshes; refusal exit 2 on mitten
  overlap; drops phantom importer icosphere; no finish/decimate). --selftest
  builds synthetic fixtures + verifies both paths under Blender
- /mb/submit MODELBEAST proxy (SCENEGOD_MB=1 gated, token from ~/Documents/
  backnforth/.env at request time never logged, /api/assets->/api/jobs)
- test_server.py: +audio-stream, +MB gating & two-step contract (mock MB);
  8 groups green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:47:31 +10:00
type-two
65b7757fd2 [laneB] log: M2 session 3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:41:59 +10:00
type-two
6200f945b0 [laneB] M2 session 3: absorb _mirror + tests, setDuration, snap-step, param capture
- stagestub.js: removeEntity now fires onChange WITH the removed entity (real
  Stage contract) so _mirror's getEntity(id)===null removal path is testable.
- timeline.js: setDuration(x) — clamps the playhead, keeps out-of-range keys
  (lossless), notifies UI via onLoad. (Reviewed orchestrator's _mirror seam.)
- tlui.js: dur field → setDuration; snap-increment select (frame/0.1/0.25/1s);
  keyable-param add via dblclick on a params lane (captures current param
  values, skips 'type'/bool descriptors); params lane only when keys exist or
  kind is camera/light (no empty lane for characters).
- timeline_test.mjs: _mirror lifecycle (add→keyframe→remove) + setDuration.

Verified in browser (throwaway harness, not committed): dock-adds mirror into
rows, param capture, setDuration clamp+bar refresh, snap-step math.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:41:39 +10:00
15 changed files with 601 additions and 34 deletions

View File

@ -115,3 +115,35 @@ BLOCKED/REQUESTS — INTEGRATION GAP (orchestrator, please route):
NEXT: once the entity-mirror seam is decided, the "lady + man + street + 2 cameras + sunset" SYNC-2
demo should play. Holding at the M2 boundary until the orchestrator flips the milestone line.
## 2026-07-18 session 3 (M3-polish)
DONE: both M3-polish items the orchestrator flagged, verified live at :8020.
- **PiP cap + swap.** Was fixed 256×144 and (with the CSS `aspect-ratio`/`%` combo) blew up to
340×413 portrait on a `<canvas>` — CSS aspect-ratio is unreliable on replaced elements. Now sized
in `_resize()`: `clamp(180, view.width*0.24, 340)` wide × 16:9, bottom-right. Click the inset to
swap main↔active-cam (`_pipSwap`). Refactored `renderActiveCamera`→ private `_render(cam,canvas)`
that restores the cam's aspect after a pip blit, so swapping never distorts either camera
(verified: dirCam.aspect stays 1.79 while it's shown in the inset). `renderActiveCamera(canvas)`
is now a one-line wrapper over `_render(this._activeCam(), canvas)` — M3 contract unchanged;
`pause()/resume()` untouched. Verified 180×101 @1.78 ratio, swap flips the views cleanly.
- **lady.glb flat-white diagnosed + fixed.** It's the ASSET: a Reallusion/Character-Creator export
with `extensionsRequired:["KHR_materials_pbrSpecularGlossiness"]` — diffuse lives in that
extension, which three r160 dropped, so all 26 materials fell back to white MeshStandard. (rigroom
would show the same — no port regression.) Since SCENEGOD must eat any dropped character, added a
minimal `SpecGlossPlugin` registered on GLTFLoader in room3d.js: spec-gloss → MeshStandard
(diffuse→map/color, roughness≈1glossiness, metalness 0). Only touches materials carrying the
extension; metallic-rough GLBs and FBX untouched. Verified: lady loads with withMap=26/26 and
renders fully textured (skin/clothes/face). Shot: `logs/shots/m3-pip-swap.png`.
DECISIONS:
- Runtime spec-gloss shim over an offline gltf-transform convert: keeps CC characters "just work"
in the drop-on-stage flow and in the render pipeline, no per-asset conversion step. Approximation
(ignores the specular map) is fine for machinima lighting; upgrade path = full spec→metal convert
if a hero asset needs it.
- PiP size lives in JS, not CSS — `<canvas>` + `aspect-ratio` is the trap that produced the giant
inset. CSS keeps only position/border.
BLOCKED/REQUESTS: none.
NEXT: hold at M3-polish boundary. Available for SYNC-3 findings (finalRender drives
`pause`/`resume`/`renderActiveCamera` — kept stable per the hard contract).

View File

@ -103,3 +103,34 @@ BLOCKED/REQUESTS:
NEXT: Hold at M2. Remaining M2 polish if a session 3 is granted: undo already
wired to Ctrl+Z; could add snapping increment config + keyable-param add UI
(currently params keys come from Lane A capture/inspector). Await SYNC 2.
## 2026-07-18 session 3 (M2 polish)
DONE: All three orchestrator session-3 items.
1. Absorbed `_mirror` (orchestrator's seam) — reviewed; it upserts dock-added
stage entities into `scene.entities` with empty tracks and drops them when
`stage.getEntity(id)===null`. Fixed the STUB to make removal testable:
`removeEntity` now fires `onChange` WITH the removed entity object (was
firing `null`, so `_mirror(null)` early-returned and never removed).
Added timeline_test.mjs lifecycle case: dock-add → keyframe → remove,
asserting entity+empty-tracks appear then vanish.
2. `setDuration(x)` mutator: clamps the playhead into range, KEEPS keys past
the new end (lossless), notifies the UI (reuses onLoad → syncRows +
refreshBar). Wired the dur field to it — plain `scene.duration = x` skipped
the bar refresh. Test asserts clamp + key retention + notify.
3. Snap-increment select (frame / 0.1 / 0.25 / 1s) in the scene bar; `_snap`
rounds to the chosen step (Alt still = free). Keyable-param add: dblclick a
params lane captures the entity's current param values as keys (skips
`type`/boolean descriptors) — mirrors the transform-capture dblclick.
DECISIONS:
- params lane now shows only when the entity has ≥1 param key OR kind is
camera/light. `_mirror` seeds `tracks.params=[]` (truthy), which was
giving every mirrored character an empty params lane; tightened to
`tracks?.params?.length > 0 || camera|light`.
- setDuration reuses the onLoad callback list as its "refresh UI chrome" hook
rather than adding a second callback channel (lazy; onLoad handler already
does syncRows+refreshBar+draw).
BLOCKED/REQUESTS: none. Verified with a throwaway harness (`_check.html`,
served :8099, deleted, NOT committed). timeline_test.mjs green.
NEXT: M2 polish complete. Undo already wired (Ctrl+Z). Open for M4 timeline
work when unlocked (audio tracks on the timeline UI + Web Audio sync, per
PLAN §5 M4) or any SYNC 3 fixes. Holding until orchestrator flips M4.

View File

@ -64,3 +64,37 @@ BLOCKED/REQUESTS:
NEXT: M4 is locked. When unlocked: ffmpeg audio mux (scene audio[]),
/mb/submit MODELBEAST proxy, scripts/graft_limb.py. Meanwhile available to
integration-test finalRender once Lane A's renderActiveCamera lands.
## 2026-07-18 session 3
DONE: M4 complete (unlocked after SYNC 3). Commit be65355.
- Audio mux: `/render/end` now accepts `{audio:[{path,start,gain}]}`, muxes via
ffmpeg adelay+volume+amix -> aac -shortest; audio paths path-guarded (400 on
missing). `render.js finalRender` passes `opts.audio` through.
- `scripts/graft_limb.py`: headless Blender (`blender -b -P ... -- --body B
--limb L --bone NAME --out O`). Align limb root head->target bone world head,
join armatures, parent limb root under target (keep offset), join meshes
(vgroup weights survive). Refusal: overlapping bones -> GraftRefused ->
exit 2 "re-skin, don't graft". No finish/decimate. `--selftest` builds
synthetic body/limb GLBs and verifies refusal + graft paths under Blender.
- `/mb/submit`: MODELBEAST proxy, SCENEGOD_MB=1 gated (503 otherwise), token
from ~/Documents/backnforth/.env at request time (never logged; errors
scrub it), /api/assets multipart -> /api/jobs, mirrors meshgod/ops.py.
- `test_server.py`: 8 groups green (added audio-stream assert, MB gating,
MB two-step contract via in-process mock — no farm jobs).
DECISIONS:
- Blender 5 glTF importer injects a stray unparented 42-vert icosphere on
every import; `_import` now keeps only rig-connected meshes (parented to
armature / has vgroups / armature modifier) and deletes strays. Right filter
for a graft tool regardless.
- graft refusal check: provides = limb bones - {limb root}; refuse if
provides ∩ body bones. Attach/root bone sharing the target name is expected.
- MB test uses a stdlib http.server mock + a 2nd scenegod instance; asserts
Bearer token forwarded, multipart upload, asset_id chained into job payload.
BLOCKED/REQUESTS: none.
BACKLOG (from orchestrator, not urgent): draft webm via MediaRecorder is
untested in the embedded preview (rAF suspension). Needs one verification in a
real Chrome window before calling draftRecord done — client-only, needs Lane A
stage running live; deferred.
NEXT: Lane C M1-M4 all shipped. Available for polish / integration support.
Blender binary here is /Applications/Blender.app/Contents/MacOS/Blender (not
on PATH as `blender` in this session, contrary to orchestrator note).

BIN
logs/shots/m3-pip-swap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -7,6 +7,7 @@ formats. See PLAN.md §4.3 for the HTTP contract.
uvicorn scenegod.server:app --port 8020 # binds 127.0.0.1
"""
import base64
import json
import os
import re
@ -14,6 +15,8 @@ import shutil
import subprocess
import threading
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
@ -43,6 +46,23 @@ SCENES.mkdir(parents=True, exist_ok=True)
RENDERS.mkdir(parents=True, exist_ok=True)
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
# MODELBEAST proxy (M4) — off unless SCENEGOD_MB=1. Token read from disk at
# request time, never logged. Contract mirrors MESHGOD meshgod/ops.py.
MB_HOST = os.environ.get("MB_HOST", "http://100.89.131.57:8777")
MB_ENV = Path(os.environ.get("MB_ENV", "~/Documents/backnforth/.env")).expanduser()
def _mb_token() -> str:
if os.environ.get("MB_TOKEN"):
return os.environ["MB_TOKEN"]
try:
for line in MB_ENV.read_text().splitlines():
if line.startswith("MB_TOKEN"):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
raise HTTPException(503, f"no MB token (set MB_TOKEN or provide {MB_ENV})")
def reap_renders(max_age_s=48 * 3600):
"""Delete render dirs older than max_age_s. Called at boot (C5)."""
@ -229,11 +249,22 @@ def _render_dir(rid: str) -> Path:
return safe_under(RENDERS, rid) # rid is a hex uuid; guard anyway
def _encode(rid: str, fps: int):
def _encode(rid: str, fps: int, audio: list[dict]):
"""audio = [{file: abs path, start: sec, gain: float}] (paths pre-resolved)."""
d = _render_dir(rid)
_renders[rid] = {"state": "encoding", "log": ""}
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", str(d / "out.mp4")]
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png")]
for a in audio:
cmd += ["-i", a["file"]]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"]
if audio: # delay each track to its start, apply gain, mix down to one stereo stream
parts = [f"[{i}:a]adelay={int(a['start']*1000)}:all=1,volume={a['gain']}[a{i}]"
for i, a in enumerate(audio, start=1)]
parts.append("".join(f"[a{i}]" for i in range(1, len(audio) + 1))
+ f"amix=inputs={len(audio)}:normalize=0[aout]")
cmd += ["-filter_complex", ";".join(parts),
"-map", "0:v", "-map", "[aout]", "-c:a", "aac", "-shortest"]
cmd += [str(d / "out.mp4")]
try:
p = subprocess.run(cmd, capture_output=True, text=True)
ok = p.returncode == 0 and (d / "out.mp4").is_file()
@ -270,7 +301,7 @@ async def render_frame(rid: str, n: int, request: Request):
@app.post("/render/{rid}/end")
async def render_end(rid: str):
async def render_end(rid: str, request: Request):
d = _render_dir(rid)
if not (d / "frames").is_dir():
raise HTTPException(404, "unknown renderId")
@ -280,7 +311,18 @@ async def render_end(rid: str):
fps = int(json.loads((d / "meta.json").read_text()).get("fps", 30))
except (OSError, json.JSONDecodeError):
fps = 30
threading.Thread(target=_encode, args=(rid, fps), daemon=True).start()
try:
body = json.loads(await request.body() or b"{}")
except json.JSONDecodeError:
body = {}
audio = []
for a in body.get("audio") or []: # scene JSON audio[] (M4)
f = safe_under(ASSETS, a["path"]) # path-guard every audio input
if not f.is_file():
raise HTTPException(400, f"audio not found: {a['path']}")
audio.append({"file": str(f), "start": float(a.get("start", 0)),
"gain": float(a.get("gain", 1.0))})
threading.Thread(target=_encode, args=(rid, fps, audio), daemon=True).start()
return {"ok": True}
@ -305,6 +347,46 @@ def render_out(rid: str):
return FileResponse(out, media_type="video/mp4")
# ---- MODELBEAST proxy (M4, gated) ----
def _mb_post(path: str, data: bytes, content_type: str) -> dict:
req = urllib.request.Request(MB_HOST + path, data=data, method="POST",
headers={"Authorization": "Bearer " + _mb_token(), "Content-Type": content_type})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
raise HTTPException(502, f"MB {path}: {e.code} {e.read()[:200].decode('utf-8', 'replace')}")
except OSError as e: # never surface the token in the error
raise HTTPException(502, f"MB {path} unreachable: {e.__class__.__name__}")
@app.post("/mb/submit")
async def mb_submit(request: Request):
"""Submit a MODELBEAST job (tts/voice-clone/lipsync). Optional input file →
/api/assets first, then /api/jobs the meshgod/ops.py contract."""
if os.environ.get("SCENEGOD_MB") != "1":
raise HTTPException(503, "MODELBEAST proxy disabled (set SCENEGOD_MB=1)")
body = await request.json()
op = (body.get("operator") or "").strip()
if not op:
raise HTTPException(400, "operator required")
asset_id = None
if body.get("file_b64"):
raw = base64.b64decode(body["file_b64"].split(",")[-1])
name = os.path.basename(body.get("file_name") or "input.bin") or "input.bin"
bnd = uuid.uuid4().hex
mp = ((f'--{bnd}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode()
+ raw + f"\r\n--{bnd}--\r\n".encode())
a = _mb_post("/api/assets", mp, f"multipart/form-data; boundary={bnd}")
asset_id = a.get("id") or (a.get("items") or [a])[0].get("id")
payload = {"operator": op, "params": body.get("params") or {}}
if asset_id:
payload["asset_id"] = asset_id
j = _mb_post("/api/jobs", json.dumps(payload).encode(), "application/json")
return {"job_id": j.get("id"), "asset_id": asset_id}
# ---- statics (last: catch-all /web is defined after API routes) ----
@app.get("/", response_class=HTMLResponse)
def index():

View File

@ -26,7 +26,7 @@ export function draftRecord(stage, timeline, { fps = 30, mimeType = 'video/webm'
// --- final: step frame-by-frame (deterministic), upload PNGs, ffmpeg encode ---
export async function finalRender(stage, timeline, opts = {}) {
const { fps = 30, width = 1920, height = 1080, name = 'scene', onProgress } = opts;
const { fps = 30, width = 1920, height = 1080, name = 'scene', audio = [], onProgress } = opts;
const canvas = stage.renderer.domElement;
const prev = { w: canvas.width, h: canvas.height, dpr: stage.renderer.getPixelRatio() };
stage.renderer.setPixelRatio(1);
@ -50,7 +50,7 @@ export async function finalRender(stage, timeline, opts = {}) {
}
await Promise.all(inflight);
await postJSON(`/render/${renderId}/end`, {});
await postJSON(`/render/${renderId}/end`, { audio }); // scene audio[] muxed by ffmpeg
let st;
do { await sleep(500); st = await (await fetch(`/render/${renderId}/status`)).json(); }
while (st.state === 'queued' || st.state === 'encoding');

View File

@ -8,6 +8,26 @@ import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
// three r160 removed built-in KHR_materials_pbrSpecularGlossiness support, so Character Creator /
// Reallusion GLBs render flat white (diffuse lives in the extension). Minimal plugin: approximate
// spec-gloss as MeshStandard (diffuse→map/color, roughness≈1-glossiness, metalness 0). Only touches
// materials carrying the extension — metallic-roughness GLBs are unaffected.
const SPEC_GLOSS = 'KHR_materials_pbrSpecularGlossiness';
class SpecGlossPlugin {
constructor(parser){ this.parser = parser; this.name = SPEC_GLOSS; }
getMaterialType(i){ return this.parser.json.materials[i]?.extensions?.[SPEC_GLOSS] ? THREE.MeshStandardMaterial : null; }
extendMaterialParams(i, params){
const ext = this.parser.json.materials[i]?.extensions?.[SPEC_GLOSS];
if(!ext) return Promise.resolve();
const pending = [];
params.color = new THREE.Color(1,1,1); params.metalness = 0;
if(Array.isArray(ext.diffuseFactor)){ params.color.fromArray(ext.diffuseFactor); params.opacity = ext.diffuseFactor[3]; }
params.roughness = ext.glossinessFactor != null ? 1 - ext.glossinessFactor : 0.5;
if(ext.diffuseTexture) pending.push(this.parser.assignTexture(params, 'map', ext.diffuseTexture, THREE.SRGBColorSpace));
return Promise.all(pending);
}
}
export async function parseAny(buf, name){
name = (name||'').toLowerCase();
// extensionless routes (gallery/<id>/glb) + magic-byte fallback: gltf binary starts "glTF"
@ -19,7 +39,9 @@ export async function parseAny(buf, name){
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||[]}; }
const loader = new GLTFLoader();
loader.register(p => new SpecGlossPlugin(p)); // Reallusion/CC exports need this (r160 dropped it)
const g = await loader.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}); });

View File

@ -26,9 +26,13 @@ export class Stage {
view.appendChild(r.domElement);
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
// PiP overlay — the active camera's view, shown top-right when a camera is active
// PiP overlay — the active camera's view (bottom-right). CSS sizes it (~24% w); the buffer
// stays small. Click to swap which view is main vs inset.
const pip = this._pip = document.createElement('canvas');
pip.width = 256; pip.height = 144; pip.className = 'pip'; pip.style.display = 'none';
pip.width = 320; pip.height = 180; pip.className = 'pip'; pip.style.display = 'none';
pip.title = 'click to swap with the main view';
this._pipSwap = false;
pip.addEventListener('click', () => { this._pipSwap = !this._pipSwap; });
view.appendChild(pip);
const scene = this.scene = new THREE.Scene();
@ -68,15 +72,19 @@ export class Stage {
if(this._paused) return; // M3: caller drives rendering deterministically
ctr.update();
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
// PiP first (it scribbles the active cam onto the main canvas), then the director view last
if(this._activeCamId && pip.style.display !== 'none') this.renderActiveCamera(pip);
r.render(scene, this._dirCam);
// inset first (it scribbles onto the main canvas), then the main view last so it wins
if(this._activeCamId && pip.style.display !== 'none')
this._render(this._pipSwap ? this._dirCam : this._activeCam(), pip);
this._render((this._pipSwap && this._activeCamId) ? this._activeCam() : this._dirCam, null);
};
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(); }
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix();
// size the inset here — CSS aspect-ratio/% is unreliable on a <canvas>
const pw = Math.max(180, Math.min(340, w*0.24));
this._pip.style.width = Math.round(pw)+'px'; this._pip.style.height = Math.round(pw*9/16)+'px'; }
// ---- callbacks ----
onSelect(cb){ this._selCbs.push(cb); }
@ -322,17 +330,20 @@ export class Stage {
}
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
// render `cam` to the main WebGL canvas at the target's aspect; if `canvas` given, blit there and
// restore the cam's aspect (so using a cam for the inset never distorts it in the main view).
_render(cam, canvas){
const w = canvas ? canvas.width : this.renderer.domElement.width;
const h = canvas ? canvas.height : this.renderer.domElement.height;
const prev = cam.aspect;
if(cam.isPerspectiveCamera){ cam.aspect = w/h; cam.updateProjectionMatrix(); }
this.renderer.render(this.scene, cam);
if(canvas){ canvas.getContext('2d').drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height);
if(cam.isPerspectiveCamera && cam.aspect !== prev){ cam.aspect = prev; cam.updateProjectionMatrix(); } }
}
// PiP: pass the small overlay canvas. Final render (M3): pass null after pause() — the active cam
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
renderActiveCamera(canvas){
const cam = this._activeCam();
const aspect = canvas ? canvas.width/canvas.height
: this.renderer.domElement.width/this.renderer.domElement.height;
if(cam.isPerspectiveCamera){ cam.aspect = aspect; 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); }
}
renderActiveCamera(canvas){ this._render(this._activeCam(), canvas); }
pause(){ this._paused = true; }
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump

View File

@ -54,7 +54,11 @@ export class StageStub {
this._fireChange(e);
return e;
}
removeEntity(id) { this._entities.delete(id); this._mixers.delete(id); this._fireChange(null); }
removeEntity(id) {
const e = this._entities.get(id);
this._entities.delete(id); this._mixers.delete(id);
this._fireChange(e || { id }); // fire WITH the removed entity; getEntity(id) now null (real Stage contract)
}
getEntity(id) { return this._entities.get(id) || null; }
entities() { return [...this._entities.values()]; }

View File

@ -66,10 +66,10 @@ body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align
#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}
/* PiP — active camera preview overlay */
.pip{position:absolute;top:12px;right:12px;width:256px;height:144px;border:1px solid var(--teal);
border-radius:6px;background:#000;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
.pip::after{content:'CAM'}
/* PiP — active camera preview inset (bottom-right, ~24% wide, click to swap). Size set in JS. */
.pip{position:absolute;right:12px;bottom:12px;border:1px solid var(--teal);border-radius:6px;
background:#000;cursor:pointer;box-shadow:0 4px 18px rgba(0,0,0,.55);z-index:5}
.pip:hover{border-color:var(--ink)}
/* dock "add rig" toolbar (cameras + lights have no asset file) */
.addbar{display:flex;gap:6px;margin-bottom:10px}

View File

@ -145,6 +145,18 @@ export class Timeline {
onLoad(cb) { this._loadCbs.push(cb); }
toJSON() { return structuredClone(this.scene); }
// Programmatic duration change (dur field or code). Keys past the new end are
// kept (lossless) but unreachable; clamp the playhead and refresh the UI —
// plain `scene.duration = x` skips both. Reuses the onLoad hook to redraw +
// resync the scene bar.
setDuration(x) {
this.scene.duration = Math.max(0.1, +x || 0.1);
if (this.time > this.scene.duration) this.time = this.scene.duration;
for (const cb of this._loadCbs) cb(this.scene);
this.seek(this.time);
return this.scene.duration;
}
// Lane A clip-drop: fetch the clip (for its duration), drop a block at the
// playhead, add a default crossfade where it abuts a neighbour, preload.
async addClipDrop({ id, path, clipIndex = 0 }) {

View File

@ -137,4 +137,30 @@ assert.ok(wa && near(wa.weight, 0.5, 1e-6), `A fade-out weight ~0.5, got ${wa &&
assert.ok(wb && near(wb.weight, 0.5, 1e-6), `B fade-in weight ~0.5, got ${wb && wb.weight}`);
assert.ok(near(wa.weight + wb.weight, 1, 1e-6), 'crossfade weights sum to 1');
// ---- SYNC2: _mirror entity lifecycle (stub add → mutate → remove) ----
const mStage = new StageStub();
const tlM = new Timeline(mStage); // ctor subscribes _mirror to stage.onChange
await mStage.addEntity({ id: 'p1', kind: 'prop', label: 'crate' }); // fires onChange → mirrored
let me = tlM.scene.entities.find((x) => x.id === 'p1');
assert.ok(me, 'dock-added stage entity must be mirrored into the timeline');
assert.deepEqual(me.tracks, { transform: [], params: [], clips: [] }, 'mirrored entity starts with empty tracks');
tlM.addKey('p1', 'transform', { t: 0, pos: [1, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' });
assert.equal(tlM.scene.entities.find((x) => x.id === 'p1').tracks.transform.length, 1, 'can keyframe a mirrored entity');
mStage.removeEntity('p1'); // fires onChange w/ removed entity; getEntity → null
assert.ok(!tlM.scene.entities.find((x) => x.id === 'p1'), 'removing from stage drops the entity + its tracks');
// ---- SYNC2: setDuration clamps the playhead + keeps keys, fires onLoad ----
let durNotified = 0;
const tlDur = new Timeline(new StageStub());
tlDur.load({ version: 1, fps: 30, duration: 10, entities: [
{ id: 'e', kind: 'prop', tracks: { transform: [{ t: 8, pos: [0, 0, 0], rot: [0, 0, 0], scale: 1, ease: 'linear' }] } },
], cameraCuts: [] });
tlDur.onLoad(() => durNotified++);
tlDur.seek(9);
tlDur.setDuration(5);
assert.equal(tlDur.duration, 5, 'setDuration updates duration');
assert.equal(tlDur.time, 5, 'setDuration clamps the playhead into range');
assert.equal(tlDur.scene.entities[0].tracks.transform.length, 1, 'keys past the new end are kept (lossless)');
assert.ok(durNotified >= 1, 'setDuration notifies the UI via onLoad');
console.log('OK — timeline_test.mjs: all assertions passed');

View File

@ -17,6 +17,7 @@ const CSS = `
#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 select{background:#0d1117;border:1px solid #2b323c;color:#c9d1d9;padding:2px 4px;border-radius:3px;font:inherit}
#timeline .tl-bar label{display:flex;align-items:center;gap:3px;color:#8b949e;cursor:pointer}
#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}
@ -41,6 +42,7 @@ export class TimelineUI {
this._drag = null;
this._selKeys = []; // box-selected keys: [{id, track, key}]
this._snapOn = true;
this._snapStep = 'frame'; // 'frame' | seconds string
this._injectCSS();
this._build();
this.tl.onTick(() => this.draw());
@ -73,6 +75,9 @@ export class TimelineUI {
<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}">
<label><input type="checkbox" class="snap" checked> snap</label>
<select class="snapstep" title="snap increment">
<option value="frame">frame</option><option value="0.1">0.1s</option>
<option value="0.25">0.25s</option><option value="1">1s</option></select>
<span class="spring"></span>
<button data-act="save">Save</button>
<button data-act="load">Load</button>`;
@ -82,12 +87,14 @@ export class TimelineUI {
this.$name = bar.querySelector('.name');
this.$dur = bar.querySelector('.dur');
this.$snap = bar.querySelector('.snap');
this.$snapStep = bar.querySelector('.snapstep');
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.$dur.onchange = () => this.tl.setDuration(this.$dur.value); // clamps + refreshes bar
this.$name.onchange = () => { this.tl.scene.name = this.$name.value; };
this.$snap.onchange = () => { this._snapOn = this.$snap.checked; };
this.$snapStep.onchange = () => { this._snapStep = this.$snapStep.value; };
const body = document.createElement('div');
body.className = 'tl-body';
@ -118,7 +125,7 @@ export class TimelineUI {
for (const e of list) {
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' });
const paramable = (e.tracks && e.tracks.params) || e.kind === 'camera' || e.kind === 'light';
const paramable = (e.tracks?.params?.length > 0) || e.kind === 'camera' || e.kind === 'light';
if (paramable) rows.push({ type: 'params', id: e.id, label: '↳ params' });
}
rows.push({ type: 'cameras', label: 'Cameras' });
@ -268,7 +275,11 @@ export class TimelineUI {
// ---- 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) || !this._snapOn ? t : Math.round(t * this.tl.fps) / this.tl.fps; }
_snap(t, e) {
if ((e && e.altKey) || !this._snapOn) return t;
const step = this._snapStep === 'frame' ? 1 / this.tl.fps : (+this._snapStep || 1 / this.tl.fps);
return Math.round(t / step) * step;
}
_hitAt(x, y) {
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; }
@ -327,6 +338,14 @@ export class TimelineUI {
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();
} else if (r.type === 'params') {
// capture every keyable param at its current value (mirrors transform capture)
const params = (this._entity(r.id)?.params) || {};
for (const [key, value] of Object.entries(params)) {
if (key === 'type' || typeof value === 'boolean') continue; // non-keyable descriptors
this.tl.addKey(r.id, 'params', { t, key, value, ease: 'inout' });
}
this.draw();
} else if (r.type === 'cameras') {
const cams = this.stage.entities().filter((en) => en.kind === 'camera');
const cam = this.tl._activeCam || (cams[0] && cams[0].id);

223
scripts/graft_limb.py Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""graft_limb.py — graft a rigged limb GLB onto a rigged body GLB (headless Blender).
blender -b -P scripts/graft_limb.py -- \
--body body.glb --limb hand.glb --bone mixamorig:RightHand --out grafted.glb
blender -b -P scripts/graft_limb.py -- --selftest # builds synthetic fixtures
What it does (C-server.md §C6): import both; align the limb's root bone head to
the target bone's world head; join the two armatures; parent the limb root under
the target bone keeping its offset; join the meshes (vertex-group weights survive
because the limb's bone names now live in the merged armature); export GLB.
Refusal: if the body armature ALREADY has the bones the limb provides, this is a
mitten (the body is skinned there already) exit 2 with a re-skin message
instead of duplicating bones.
Rig contract: the grafted output NEVER goes through a join/decimate "finish"
path that spawns phantom joint meshes and mauls the armature. Export as-is.
"""
import sys
import bpy
from mathutils import Vector
class GraftRefused(Exception):
"""Body already carries the limb's bones — re-skin, don't graft."""
def _clear():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
for coll in (bpy.data.meshes, bpy.data.armatures):
for d in list(coll):
if d.users == 0:
coll.remove(d)
def _import(path):
"""Import a GLB; return (armature_obj, [skinned mesh_objs]). Meshes not bound
to the armature are dropped the Blender 5 glTF importer injects a stray
unparented icosphere, and a graft only cares about rig-deformed geometry."""
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
new = [o for o in bpy.data.objects if o not in before]
arms = [o for o in new if o.type == "ARMATURE"]
if not arms:
sys.exit(f"[graft] no armature in {path}")
arm = arms[0]
meshes = [o for o in new if o.type == "MESH" and (
o.parent is arm or o.vertex_groups or any(m.type == "ARMATURE" for m in o.modifiers))]
for o in new: # remove strays (phantom icosphere, lights/cameras aren't MESH)
if o.type == "MESH" and o not in meshes:
bpy.data.objects.remove(o, do_unlink=True)
return arm, meshes
def _root_bone(arm):
for b in arm.data.bones:
if b.parent is None:
return b.name
return arm.data.bones[0].name
def _world_head(arm, bone_name):
return arm.matrix_world @ arm.data.bones[bone_name].head_local
def graft(body_path, limb_path, target_bone, out_path):
_clear()
body_arm, body_meshes = _import(body_path)
limb_arm, limb_meshes = _import(limb_path)
if target_bone not in body_arm.data.bones:
sys.exit(f"[graft] target bone {target_bone!r} not in body armature "
f"(have: {[b.name for b in body_arm.data.bones][:20]})")
limb_root = _root_bone(limb_arm)
body_bones = {b.name for b in body_arm.data.bones}
provides = {b.name for b in limb_arm.data.bones} - {limb_root}
clash = provides & body_bones
if clash:
raise GraftRefused(f"bones exist; mitten weights — re-skin, don't graft "
f"(overlap: {sorted(clash)})")
# 1. align limb root head -> target bone head (world space)
delta = _world_head(body_arm, target_bone) - _world_head(limb_arm, limb_root)
limb_arm.matrix_world.translation += delta
bpy.context.view_layer.update()
# 2. remember which meshes the limb armature deforms, then join armatures
for m in limb_meshes:
for mod in m.modifiers:
if mod.type == "ARMATURE":
mod.object = None # detach before join deletes limb_arm; re-point below
bpy.ops.object.select_all(action="DESELECT")
limb_arm.select_set(True)
body_arm.select_set(True)
bpy.context.view_layer.objects.active = body_arm
bpy.ops.object.join() # limb bones now live inside body_arm; limb_arm object gone
# 3. re-point limb meshes at the merged armature + parent limb root under target
for m in limb_meshes:
had = any(mod.type == "ARMATURE" for mod in m.modifiers)
for mod in m.modifiers:
if mod.type == "ARMATURE":
mod.object = body_arm
if not had:
mod = m.modifiers.new("Armature", "ARMATURE")
mod.object = body_arm
m.parent = body_arm
bpy.context.view_layer.objects.active = body_arm
bpy.ops.object.mode_set(mode="EDIT")
eb = body_arm.data.edit_bones
eb[limb_root].parent = eb[target_bone]
eb[limb_root].use_connect = False # keep offset
bpy.ops.object.mode_set(mode="OBJECT")
# 4. join meshes (vertex groups merge by bone name -> weights survive)
all_meshes = body_meshes + limb_meshes
if len(all_meshes) > 1:
bpy.ops.object.select_all(action="DESELECT")
for m in all_meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = all_meshes[0]
bpy.ops.object.join()
# 5. export as-is — NO finish/decimate (rig contract)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(filepath=out_path, export_format="GLB", use_selection=True)
print(f"[graft] wrote {out_path}")
# --- selftest: build synthetic fixtures and exercise both paths -------------
def _make_rig(name, bones, out):
"""bones = [(name, head, tail, parent_or_None)]; builds a skinned cube, exports GLB."""
_clear()
bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0))
arm = bpy.context.object
arm.name = name
eb = arm.data.edit_bones
eb.remove(eb[0]) # drop the default bone
for bn, head, tail, parent in bones:
b = eb.new(bn)
b.head, b.tail = Vector(head), Vector(tail)
if parent:
b.parent = eb[parent]
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.mesh.primitive_cube_add(size=0.4, location=tuple(bones[-1][1]))
cube = bpy.context.object
bpy.ops.object.select_all(action="DESELECT")
cube.select_set(True)
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(filepath=out, export_format="GLB", use_selection=True)
def selftest():
import os
import tempfile
d = tempfile.mkdtemp(prefix="graft_selftest_")
body = os.path.join(d, "body.glb")
limb_ok = os.path.join(d, "limb_ok.glb")
limb_bad = os.path.join(d, "limb_bad.glb")
out = os.path.join(d, "grafted.glb")
# full-rig body: root -> RightHand -> RightHandIndex1
_make_rig("Body", [
("root", (0, 0, 0), (0, 0, 1), None),
("mixamorig:RightHand", (0, 0, 1), (0, 0, 1.3), "root"),
("mixamorig:RightHandIndex1", (0, 0, 1.3), (0, 0, 1.5), "mixamorig:RightHand"),
], body)
# good limb: custom bone names, no overlap with body
_make_rig("LimbOK", [
("nub_root", (0, 0, 0), (0, 0, 0.3), None),
("nub_tip", (0, 0, 0.3), (0, 0, 0.5), "nub_root"),
], limb_ok)
# bad limb: provides a bone the body already has -> mitten
_make_rig("LimbBad", [
("bad_root", (0, 0, 0), (0, 0, 0.2), None),
("mixamorig:RightHandIndex1", (0, 0, 0.2), (0, 0, 0.4), "bad_root"),
], limb_bad)
# refusal path
try:
graft(body, limb_bad, "mixamorig:RightHand", out)
sys.exit("[selftest] FAIL: expected GraftRefused for overlapping bones")
except GraftRefused as e:
print(f"[selftest] refusal OK: {e}")
# graft path
graft(body, limb_ok, "mixamorig:RightHand", out)
assert os.path.isfile(out) and os.path.getsize(out) > 0, "output GLB missing"
_clear()
arm, meshes = _import(out)
names = {b.name for b in arm.data.bones}
assert {"mixamorig:RightHand", "nub_root", "nub_tip"} <= names, f"merged bones wrong: {names}"
assert arm.data.bones["nub_root"].parent.name == "mixamorig:RightHand", "root not parented"
assert len(meshes) == 1, f"meshes not joined: {len(meshes)}"
print("[selftest] graft OK: merged armature + single skinned mesh")
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if "--selftest" in argv:
selftest()
return
def opt(flag):
return argv[argv.index(flag) + 1] if flag in argv else None
body, limb, bone, out = opt("--body"), opt("--limb"), opt("--bone"), opt("--out")
if not all((body, limb, bone, out)):
sys.exit("usage: -- --body B.glb --limb L.glb --bone NAME --out O.glb")
try:
graft(body, limb, bone, out)
except GraftRefused as e:
print(f"[graft] REFUSED: {e}", file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()

View File

@ -7,16 +7,19 @@ 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 base64
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@ -142,7 +145,13 @@ def main():
for i, png in enumerate(sorted(fr.glob("*.png"))):
st, _ = req("POST", f"/render/{rid}/frame/{i}", png.read_bytes())
assert st == 200, st
assert req("POST", f"/render/{rid}/end", "{}")[0] == 200
# M4: mux a tone from assets/audio/test/ via the scene audio[] contract
(assets / "audio" / "test").mkdir(parents=True)
subprocess.run(["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
str(assets / "audio" / "test" / "tone.wav")],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
end_body = json.dumps({"audio": [{"path": "audio/test/tone.wav", "start": 0, "gain": 1.0}]})
assert req("POST", f"/render/{rid}/end", end_body)[0] == 200
for _ in range(100): # poll up to ~20s
state = json.loads(req("GET", f"/render/{rid}/status")[1])["state"]
if state in ("done", "error"):
@ -151,15 +160,77 @@ def main():
assert state == "done", state
st, mp4 = req("GET", f"/render/{rid}/out.mp4")
assert st == 200 and mp4[:4] != b"", st
out = str(renders / rid / "out.mp4")
dur = subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nokey=1", str(renders / rid / "out.mp4")],
text=True).strip()
"-of", "default=nw=1:nokey=1", out], text=True).strip()
assert abs(float(dur) - 1.0) < 0.2, f"mp4 duration {dur}s, expected ~1s"
codecs = subprocess.check_output(
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
"stream=codec_type", "-of", "default=nw=1:nokey=1", out], text=True).strip()
assert "audio" in codecs, f"no audio stream muxed: {codecs!r}"
# bad audio path rejected before encode
assert req("POST", f"/render/{rid}/end",
json.dumps({"audio": [{"path": "audio/nope.wav"}]}))[0] == 400
n += 1
else:
print("(render test skipped — ffmpeg/ffprobe not found)")
# --- MODELBEAST proxy: gating + two-step contract (mock MB, no farm jobs) ---
assert req("POST", "/mb/submit", json.dumps({"operator": "tts"}))[0] == 503 # gated off
n += 1
captured = []
class MB(BaseHTTPRequestHandler):
def do_POST(self):
data = self.rfile.read(int(self.headers.get("Content-Length", 0)))
captured.append({"path": self.path, "auth": self.headers.get("Authorization"),
"ctype": self.headers.get("Content-Type", ""), "body": data})
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(
{"id": "asset123" if self.path == "/api/assets" else "job456"}).encode())
def log_message(self, *a):
pass
mbport, port2 = PORT + 1, PORT + 2
srv = HTTPServer(("127.0.0.1", mbport), MB)
threading.Thread(target=srv.serve_forever, daemon=True).start()
env2 = {**env, "SCENEGOD_MB": "1", "MB_HOST": f"http://127.0.0.1:{mbport}",
"MB_TOKEN": "secret"}
p2 = subprocess.Popen(
[str(PY), "-m", "uvicorn", "scenegod.server:app", "--port", str(port2)],
cwd=ROOT, env=env2, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(50):
try:
urllib.request.urlopen(f"http://127.0.0.1:{port2}/scenes")
break
except urllib.error.URLError:
time.sleep(0.2)
sub = {"operator": "voice-clone", "params": {"text": "hi"},
"file_b64": base64.b64encode(b"WAVDATA").decode(), "file_name": "in.wav"}
r = urllib.request.urlopen(urllib.request.Request(
f"http://127.0.0.1:{port2}/mb/submit",
data=json.dumps(sub).encode(), method="POST",
headers={"Content-Type": "application/json"}))
out = json.loads(r.read())
assert out == {"job_id": "job456", "asset_id": "asset123"}, out
assert [c["path"] for c in captured] == ["/api/assets", "/api/jobs"], captured
assert all(c["auth"] == "Bearer secret" for c in captured), "token not forwarded"
assert captured[0]["ctype"].startswith("multipart/form-data"), captured[0]["ctype"]
assert b"WAVDATA" in captured[0]["body"], "file bytes not uploaded"
job = json.loads(captured[1]["body"])
assert job["asset_id"] == "asset123" and job["operator"] == "voice-clone", job
n += 1
finally:
p2.terminate()
p2.wait()
srv.shutdown()
print(f"OK: {n} test groups passed")
finally:
proc.terminate()