Files
wtd-libreflow-addon/dev/fake_libreflow.py
T
NDemaerschalkandClaude Opus 5.5 fbc7ac18e4 Scene Manager: Load Publish and numbered instances
Load Publish (Loaded panel) searches every asset of the project, then
loads any of its published .blend files at any version, cast in the shot
or not. The file the casting would load and the latest version on this
site are preselected; a version not on this site is requested instead.
Built on the existing wtd_list_assets / wtd_get_asset_files /
wtd_get_revisions commands.

New instances are numbered (COL_bob_01, tree_01...), counting on from the
highest number in the file (SCENE_MANAGER_INSTANCE_NAME).

README: Pick Shot only lists shots that exist in Libreflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:47:05 +01:00

197 lines
11 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 (main_character, x2), tree (bg_set), rock (main_props),
lamp (main_props, nothing published), car (vehicles, v001 not on this site) and ghost (not in the flow);
chair (set_props) is not cast, for Load Publish. 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/download {revision_oid} (a requested revision reaches this site),
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/main_character/assets/bob": ("main_character", {f"{PROJECT}/asset_types/main_character/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/bg_set/assets/tree": ("bg_set", {f"{PROJECT}/asset_types/bg_set/assets/tree/tasks/modeling/files/model_blend": ("modeling/model.blend", [
rev("v001", f"{ROOT}/lib/tree/v001/model.blend")])}),
f"{PROJECT}/asset_types/main_props/assets/rock": ("main_props", {f"{PROJECT}/asset_types/main_props/assets/rock/tasks/modeling/files/rock_blend": ("modeling/rock.blend", [
rev("v001", f"{ROOT}/lib/rock/v001/rock.blend")])}),
f"{PROJECT}/asset_types/main_props/assets/lamp": ("main_props", {}),
f"{PROJECT}/asset_types/vehicles/assets/car": ("vehicles", {f"{PROJECT}/asset_types/vehicles/assets/car/tasks/rigging/files/car_blend": ("rigging/car.blend", [
rev("v001", f"{ROOT}/lib/car/v001/car.blend", available=False)])}),
f"{PROJECT}/asset_types/set_props/assets/chair": ("set_props", {
f"{PROJECT}/asset_types/set_props/assets/chair/tasks/shading/files/chair_shading_blend": ("shading/chair_shading.blend", [
rev("v001", f"{ROOT}/lib/chair/v001/chair.blend")]),
f"{PROJECT}/asset_types/set_props/assets/chair/tasks/modeling/files/chair_blend": ("modeling/chair.blend", [
rev("v001", f"{ROOT}/lib/chair/v001/chair.blend"), rev("v002", f"{ROOT}/lib/chair/v001/chair.blend", available=False)])}),
}
CASTING = [("bob", "main_character", 2), ("tree", "bg_set", 1), ("rock", "main_props", 1), ("lamp", "main_props", 1), ("car", "vehicles", 1), ("ghost", "main_character", 1)]
CALLS = []
def set_oids():
for _a, (_t, files) in ASSETS.items():
for f, (_l, revs) in files.items():
for r in revs: r["oid"] = f"{f}/history/revisions/{r['name']}"
def find_rev(oid):
return next((r for _a, (_t, files) in ASSETS.items() for _l, revs in files.values() for r in revs if r["oid"] == oid), None)
set_oids()
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_asset_files":
return [file_data(f) for f in ASSETS[kw["oid"]][1]]
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_request_revisions":
res = {}
for oid in kw["revision_oids"]:
r = find_rev(oid)
if r is None:
res[oid] = {"oid": oid, "error": f"Could not find {oid}"}; continue
if not r["available"]: r["sync_status"] = "Requested"
res[oid] = r
return res
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", ""))); set_oids()
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)))
set_oids()
return self.reply(200, {"result": True})
if url.path == "/_test/download":
r = find_rev(body["revision_oid"]); r["available"] = True; r["sync_status"] = "Available"
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()