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>
197 lines
11 KiB
Python
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()
|