dev/: test asset files, a fake Libreflow IPC server and an end-to-end Scene Manager test (excluded from the extension build). CLAUDE.md: notes for working on the add-on (conventions, testing, design findings, open points). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
9.0 KiB
Python
163 lines
9.0 KiB
Python
"""Fake Libreflow IPC server (kabaret.ipc + wtdcmds commands) to test the Scene Manager.
|
|
|
|
python dev/fake_libreflow.py <workdir>
|
|
|
|
Serves a project with one shot casting bob (chars, x2), tree (sets), rock (props),
|
|
lamp (props, nothing published) and ghost (not in the flow). The asset files come
|
|
from dev/make_test_assets.py (<workdir>/lib). Writes <workdir>/kabaret/server.json:
|
|
point config LIBREFLOW_SERVER_FILE to it (see dev/test_scene_manager.py).
|
|
|
|
Test-only endpoints: POST /_test/publish {file_oid, name, available, path},
|
|
POST /_test/sessions {uid: session}, POST /_test/calls (commands received).
|
|
State lives in memory: restart the server for a clean state.
|
|
"""
|
|
import json, os, shutil, sys, time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
ROOT = sys.argv[1]
|
|
TOKEN = "secret-token"
|
|
PROJECT = "/melvile"
|
|
SHOT = "/melvile/films/f01/sequences/s010/shots/p010"
|
|
SESSIONS = {"uid-1": {"session_name": "libreflow_dev", "cluster_name": "melvile", "is_gui": True, "current_oid": SHOT}}
|
|
|
|
def rev(name, path, available=True, comment=""):
|
|
return {"name": name, "oid": f"x/history/revisions/{name}", "path": path, "sync_status": "Available" if available else "NotAvailable",
|
|
"available": available, "date": time.time(), "user": "nero", "comment": comment}
|
|
|
|
ASSETS = { # asset oid -> (type, {file_oid: (label, [revisions oldest first])})
|
|
f"{PROJECT}/asset_types/chars/assets/bob": ("chars", {f"{PROJECT}/asset_types/chars/assets/bob/tasks/rigging/files/rig_blend": ("rigging/rig.blend", [
|
|
rev("v001", f"{ROOT}/lib/bob/v001/rig.blend"), rev("v002", f"{ROOT}/lib/bob/v002/rig.blend", comment="two meshes")])}),
|
|
f"{PROJECT}/asset_types/sets/assets/tree": ("sets", {f"{PROJECT}/asset_types/sets/assets/tree/tasks/modeling/files/model_blend": ("modeling/model.blend", [
|
|
rev("v001", f"{ROOT}/lib/tree/v001/model.blend")])}),
|
|
f"{PROJECT}/asset_types/props/assets/rock": ("props", {f"{PROJECT}/asset_types/props/assets/rock/tasks/modeling/files/rock_blend": ("modeling/rock.blend", [
|
|
rev("v001", f"{ROOT}/lib/rock/v001/rock.blend")])}),
|
|
f"{PROJECT}/asset_types/props/assets/lamp": ("props", {}),
|
|
}
|
|
CASTING = [("bob", "chars", 2), ("tree", "sets", 1), ("rock", "props", 1), ("lamp", "props", 1), ("ghost", "chars", 1)]
|
|
CALLS = []
|
|
|
|
def file_data(file_oid):
|
|
for asset_oid, (atype, files) in ASSETS.items():
|
|
if file_oid in files:
|
|
label, revs = files[file_oid]
|
|
avail = [r for r in revs if r["available"]]
|
|
return {"label": label, "file_oid": file_oid, "latest": revs[-1] if revs else None, "latest_available": avail[-1] if avail else None}
|
|
raise ValueError(f"No file {file_oid}")
|
|
|
|
def context(oid):
|
|
parts = oid.strip("/").split("/")
|
|
if oid.startswith(PROJECT + "/films"):
|
|
return {"oid": oid, "kind": "shot", "project": "melvile", "film": parts[2], "film_oid": "/".join([""] + parts[:3]),
|
|
"sequence": parts[4], "sequence_oid": "/".join([""] + parts[:5]), "shot": parts[6], "shot_oid": SHOT}
|
|
if "/assets/" in oid:
|
|
return {"oid": oid, "kind": "asset", "project": "melvile", "asset_type": parts[2], "asset": parts[4]}
|
|
if oid == PROJECT:
|
|
return {"oid": oid, "kind": "project", "project": "melvile"}
|
|
raise ValueError(f"Oid {oid} does not exist")
|
|
|
|
def cmd(name, kw):
|
|
import fnmatch
|
|
if name == "wtd_ping":
|
|
return {"version": "0.1.0", **({"project": "melvile", "user": "nero", "site": "lyon", "root": ROOT} if kw.get("oid") else {})}
|
|
if name == "wtd_get_context":
|
|
return context(kw["oid"])
|
|
if name == "wtd_list_shots":
|
|
return [context(SHOT), context(SHOT.replace("p010", "p020"))]
|
|
if name == "wtd_list_assets":
|
|
return [context(oid) for oid in ASSETS]
|
|
if name == "wtd_get_casting_publishes":
|
|
if not kw["oid"].startswith(SHOT):
|
|
raise ValueError("Not a shot")
|
|
patterns = kw.get("file_patterns") or ["*/*.blend"]
|
|
out = []
|
|
for aname, atype, nb in CASTING:
|
|
oid = next((o for o in ASSETS if o.endswith("/assets/" + aname)), None)
|
|
a = {"name": aname, "kitsu_id": "k-" + aname, "asset_type": atype, "nb_occurences": nb, "label": "animate", "oid": oid, "candidates": [oid] if oid else [], "file": None, "error": None}
|
|
if oid is None:
|
|
a["error"] = "Asset not found in the flow"
|
|
else:
|
|
files = ASSETS[oid][1]
|
|
match = next((f for p in patterns for f, (label, _r) in files.items() if fnmatch.fnmatchcase(label, p)), None)
|
|
if match is None:
|
|
a["error"] = "No file matching " + ", ".join(patterns)
|
|
else:
|
|
a["file"] = file_data(match)
|
|
if a["file"]["latest_available"] is None:
|
|
a["error"] = "No published revision available on this site"
|
|
out.append(a)
|
|
return {"shot_oid": SHOT, "assets": out}
|
|
if name == "wtd_get_latest_revisions":
|
|
res = {}
|
|
for f in kw["file_oids"]:
|
|
try: res[f] = file_data(f)
|
|
except ValueError as exc: res[f] = {"file_oid": f, "error": str(exc)}
|
|
return res
|
|
if name == "wtd_get_revisions":
|
|
for _a, (_t, files) in ASSETS.items():
|
|
if kw["file_oid"] in files:
|
|
return list(reversed(files[kw["file_oid"]][1]))
|
|
raise ValueError("No file")
|
|
if name == "wtd_assetize":
|
|
src = kw["source_path"]
|
|
if not os.path.isfile(src): raise ValueError(f"Source file {src} does not exist")
|
|
atype, aname, task = kw["asset_type"], kw["asset_name"], kw["task"]
|
|
asset_oid = f"{PROJECT}/asset_types/{atype}/assets/{aname}"
|
|
created = asset_oid not in ASSETS
|
|
if created and not kw.get("create_asset"):
|
|
raise ValueError(f"Asset {aname} does not exist (pass create_asset=true to create it)")
|
|
if task not in ("modeling", "rigging"):
|
|
raise ValueError(f"Task {task} does not exist on {asset_oid} (available: modeling, rigging)")
|
|
fname = kw.get("file_name") or aname
|
|
ASSETS.setdefault(asset_oid, (atype, {}))
|
|
files = ASSETS[asset_oid][1]
|
|
file_oid = f"{asset_oid}/tasks/{task}/files/{fname}_blend"
|
|
label, revs = files.setdefault(file_oid, (f"{task}/{fname}.blend", []))
|
|
name = f"v{len(revs) + 1:03d}"
|
|
dest = f"{ROOT}/lib/{aname}/{name}/{fname}.blend"; os.makedirs(os.path.dirname(dest), exist_ok=True); shutil.copy(src, dest)
|
|
revs.append(rev(name, dest, comment=kw.get("comment", "")))
|
|
return {"asset_oid": asset_oid, "asset_created": created, **file_data(file_oid)}
|
|
raise KeyError(name)
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *a): pass
|
|
def reply(self, code, body):
|
|
data = json.dumps(body).encode(); self.send_response(code); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
|
|
def auth(self):
|
|
if self.headers.get("Authorization") != f"Bearer {TOKEN}":
|
|
self.reply(401, {"detail": "Invalid token"}); return False
|
|
return True
|
|
def do_GET(self):
|
|
if not self.auth(): return
|
|
if self.path == "/sessions": return self.reply(200, SESSIONS)
|
|
self.reply(404, {"detail": "Not found"})
|
|
def do_POST(self):
|
|
if not self.auth(): return
|
|
url = urlparse(self.path); qs = parse_qs(url.query)
|
|
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
|
if url.path == "/_test/publish": # simulate a new publish
|
|
f = body["file_oid"]
|
|
for _a, (_t, files) in ASSETS.items():
|
|
if f in files: files[f][1].append(rev(body["name"], body.get("path", "/nowhere/" + body["name"]), body.get("available", True)))
|
|
return self.reply(200, {"result": True})
|
|
if url.path == "/_test/sessions":
|
|
SESSIONS.clear(); SESSIONS.update(body); return self.reply(200, {"result": True})
|
|
if url.path == "/_test/calls":
|
|
return self.reply(200, {"result": CALLS})
|
|
name = url.path.rsplit("/", 1)[-1]
|
|
uid = qs.get("session_uid", [None])[0]
|
|
if uid is None and len(SESSIONS) > 1: return self.reply(400, {"detail": "Several sessions connected: session_uid required"})
|
|
if uid is not None and uid not in SESSIONS: return self.reply(404, {"detail": f"Session {uid} not found"})
|
|
CALLS.append([name, body.get("kwargs", {})])
|
|
try:
|
|
return self.reply(200, {"result": cmd(name, body.get("kwargs", {}))})
|
|
except KeyError as exc: return self.reply(404, {"detail": f"Unknown command {exc}"})
|
|
except Exception as exc: return self.reply(500, {"detail": str(exc)})
|
|
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
os.makedirs(f"{ROOT}/kabaret", exist_ok=True)
|
|
with open(f"{ROOT}/kabaret/server.json", "w") as fh:
|
|
json.dump({"host": "127.0.0.1", "port": server.server_address[1], "token": TOKEN, "pid": os.getpid()}, fh)
|
|
print("listening", server.server_address[1], flush=True)
|
|
server.serve_forever()
|