Compare commits
5 Commits
41f2c9fa8d
...
04e15d3e39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e15d3e39 | ||
|
|
c602ce8dea | ||
|
|
be65355f6b | ||
|
|
65b7757fd2 | ||
|
|
6200f945b0 |
@ -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≈1−glossiness, 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).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
BIN
logs/shots/m3-pip-swap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@ -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():
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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}); });
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()]; }
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
223
scripts/graft_limb.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user