[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>
This commit is contained in:
parent
65b7757fd2
commit
be65355f6b
@ -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
|
uvicorn scenegod.server:app --port 8020 # binds 127.0.0.1
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -14,6 +15,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -43,6 +46,23 @@ SCENES.mkdir(parents=True, exist_ok=True)
|
|||||||
RENDERS.mkdir(parents=True, exist_ok=True)
|
RENDERS.mkdir(parents=True, exist_ok=True)
|
||||||
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
|
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):
|
def reap_renders(max_age_s=48 * 3600):
|
||||||
"""Delete render dirs older than max_age_s. Called at boot (C5)."""
|
"""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
|
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)
|
d = _render_dir(rid)
|
||||||
_renders[rid] = {"state": "encoding", "log": ""}
|
_renders[rid] = {"state": "encoding", "log": ""}
|
||||||
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png"),
|
cmd = ["ffmpeg", "-y", "-framerate", str(fps), "-i", str(d / "frames" / "%06d.png")]
|
||||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", str(d / "out.mp4")]
|
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:
|
try:
|
||||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
ok = p.returncode == 0 and (d / "out.mp4").is_file()
|
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")
|
@app.post("/render/{rid}/end")
|
||||||
async def render_end(rid: str):
|
async def render_end(rid: str, request: Request):
|
||||||
d = _render_dir(rid)
|
d = _render_dir(rid)
|
||||||
if not (d / "frames").is_dir():
|
if not (d / "frames").is_dir():
|
||||||
raise HTTPException(404, "unknown renderId")
|
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))
|
fps = int(json.loads((d / "meta.json").read_text()).get("fps", 30))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
fps = 30
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@ -305,6 +347,46 @@ def render_out(rid: str):
|
|||||||
return FileResponse(out, media_type="video/mp4")
|
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) ----
|
# ---- statics (last: catch-all /web is defined after API routes) ----
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index():
|
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 ---
|
// --- final: step frame-by-frame (deterministic), upload PNGs, ffmpeg encode ---
|
||||||
export async function finalRender(stage, timeline, opts = {}) {
|
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 canvas = stage.renderer.domElement;
|
||||||
const prev = { w: canvas.width, h: canvas.height, dpr: stage.renderer.getPixelRatio() };
|
const prev = { w: canvas.width, h: canvas.height, dpr: stage.renderer.getPixelRatio() };
|
||||||
stage.renderer.setPixelRatio(1);
|
stage.renderer.setPixelRatio(1);
|
||||||
@ -50,7 +50,7 @@ export async function finalRender(stage, timeline, opts = {}) {
|
|||||||
}
|
}
|
||||||
await Promise.all(inflight);
|
await Promise.all(inflight);
|
||||||
|
|
||||||
await postJSON(`/render/${renderId}/end`, {});
|
await postJSON(`/render/${renderId}/end`, { audio }); // scene audio[] muxed by ffmpeg
|
||||||
let st;
|
let st;
|
||||||
do { await sleep(500); st = await (await fetch(`/render/${renderId}/status`)).json(); }
|
do { await sleep(500); st = await (await fetch(`/render/${renderId}/status`)).json(); }
|
||||||
while (st.state === 'queued' || st.state === 'encoding');
|
while (st.state === 'queued' || st.state === 'encoding');
|
||||||
|
|||||||
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
|
grouping, file streaming, path-traversal rejection, scene round-trip, and
|
||||||
validation failures.
|
validation failures.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
@ -142,7 +145,13 @@ def main():
|
|||||||
for i, png in enumerate(sorted(fr.glob("*.png"))):
|
for i, png in enumerate(sorted(fr.glob("*.png"))):
|
||||||
st, _ = req("POST", f"/render/{rid}/frame/{i}", png.read_bytes())
|
st, _ = req("POST", f"/render/{rid}/frame/{i}", png.read_bytes())
|
||||||
assert st == 200, st
|
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
|
for _ in range(100): # poll up to ~20s
|
||||||
state = json.loads(req("GET", f"/render/{rid}/status")[1])["state"]
|
state = json.loads(req("GET", f"/render/{rid}/status")[1])["state"]
|
||||||
if state in ("done", "error"):
|
if state in ("done", "error"):
|
||||||
@ -151,15 +160,77 @@ def main():
|
|||||||
assert state == "done", state
|
assert state == "done", state
|
||||||
st, mp4 = req("GET", f"/render/{rid}/out.mp4")
|
st, mp4 = req("GET", f"/render/{rid}/out.mp4")
|
||||||
assert st == 200 and mp4[:4] != b"", st
|
assert st == 200 and mp4[:4] != b"", st
|
||||||
|
out = str(renders / rid / "out.mp4")
|
||||||
dur = subprocess.check_output(
|
dur = subprocess.check_output(
|
||||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
"-of", "default=nw=1:nokey=1", str(renders / rid / "out.mp4")],
|
"-of", "default=nw=1:nokey=1", out], text=True).strip()
|
||||||
text=True).strip()
|
|
||||||
assert abs(float(dur) - 1.0) < 0.2, f"mp4 duration {dur}s, expected ~1s"
|
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
|
n += 1
|
||||||
else:
|
else:
|
||||||
print("(render test skipped — ffmpeg/ffprobe not found)")
|
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")
|
print(f"OK: {n} test groups passed")
|
||||||
finally:
|
finally:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user