Added the Scene Manager (Libreflow casting, load, update, assetize)
New Scene Manager panel (WTD tab) talking to a running Libreflow through its IPC server and the wtdcmds commands: pick the shot context, read the casting, link the latest available publishes (overrides for chars and props, collection instances otherwise), flag out-of-date versions in red, update / roll back / remove loaded assets, and publish local collections as assets. Requests run in the background. Settings in config.py (SCENE_MANAGER_*, LIBREFLOW_*). The Shot Viewer groups rigs loaded by the Scene Manager by asset type. The manifest declares the network permission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10fe71e79e
commit
c238f4db7c
10 files changed
+1630
-11
No files matched your search
@@ -0,0 +1,149 @@
|
||||
"""Client of the Libreflow IPC server (kabaret.ipc) and its wtdcmds commands.
|
||||
|
||||
The server writes its address and token in ``config.LIBREFLOW_SERVER_FILE``.
|
||||
The file is read again on every request: the port changes each time the
|
||||
server restarts.
|
||||
|
||||
``run`` executes a request in a worker thread and hands the result back on
|
||||
Blender's main thread, so the UI never waits on the network.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import config
|
||||
|
||||
# Hints added to the server's message for the statuses the user can act on.
|
||||
_STATUS_HINTS = {
|
||||
401: "the server refused the token, try again",
|
||||
504: "no answer within 10 s, the command may still complete in Libreflow",
|
||||
}
|
||||
# Statuses whose message replaces the server's.
|
||||
_STATUS_MESSAGES = {400: "Several Libreflow sessions are connected: pick one in the Scene Manager panel"}
|
||||
|
||||
|
||||
class LibreflowError(Exception):
|
||||
"""A request failed. ``status`` is the HTTP status, None when the server was not reached."""
|
||||
|
||||
def __init__(self, message, status=None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class NotRunning(LibreflowError):
|
||||
"""No IPC server: Libreflow is not running."""
|
||||
|
||||
|
||||
def server_info():
|
||||
path = os.path.expanduser(config.LIBREFLOW_SERVER_FILE)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except FileNotFoundError:
|
||||
raise NotRunning("Libreflow is not running") from None
|
||||
except (OSError, ValueError) as exc:
|
||||
raise LibreflowError(f"Cannot read {path}: {exc}") from None
|
||||
|
||||
|
||||
def _request(method, path, body=None, params=None):
|
||||
info = server_info()
|
||||
url = f"http://{info['host']}:{info['port']}{path}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={"Authorization": f"Bearer {info['token']}", "Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=config.LIBREFLOW_TIMEOUT) as response:
|
||||
return json.loads(response.read() or b"null")
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
detail = json.loads(exc.read() or b"{}").get("detail", exc.reason)
|
||||
except ValueError:
|
||||
detail = exc.reason
|
||||
hint = _STATUS_HINTS.get(exc.code)
|
||||
message = _STATUS_MESSAGES.get(exc.code) or (f"{detail} ({hint})" if hint else str(detail))
|
||||
raise LibreflowError(message, exc.code) from None
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise NotRunning(f"Libreflow is not reachable ({getattr(exc, 'reason', exc)})") from None
|
||||
|
||||
|
||||
def sessions():
|
||||
"""{session_uid: {session_name, cluster_name, is_gui, current_oid}} of the connected sessions."""
|
||||
return _request("GET", "/sessions") or {}
|
||||
|
||||
|
||||
def call(cmd_name, session_uid=None, **kwargs):
|
||||
"""Run a Flow command and return its result."""
|
||||
params = {"session_uid": session_uid} if session_uid else None
|
||||
return _request("POST", f"/cmds/Flow/{cmd_name}", {"kwargs": kwargs}, params)["result"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Background jobs
|
||||
# --------------------------------------------------------------------------- #
|
||||
_results = queue.Queue()
|
||||
_jobs = {} # job id -> label, for the jobs not finished yet
|
||||
_generation = 0 # bumped on file load: results of older jobs are dropped
|
||||
_next_id = 0
|
||||
|
||||
|
||||
def run(label, func, on_success, on_error):
|
||||
"""Run ``func()`` in a thread, then ``on_success(result)`` or ``on_error(exception)`` on the main thread."""
|
||||
global _next_id
|
||||
_next_id += 1
|
||||
job_id, generation = _next_id, _generation
|
||||
_jobs[job_id] = label
|
||||
|
||||
def worker():
|
||||
try:
|
||||
_results.put((job_id, generation, on_success, func()))
|
||||
except Exception as exc: # noqa: BLE001 - handed to on_error on the main thread
|
||||
_results.put((job_id, generation, on_error, exc))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
if not bpy.app.timers.is_registered(_poll):
|
||||
bpy.app.timers.register(_poll, first_interval=0.05)
|
||||
|
||||
|
||||
def busy_label():
|
||||
"""Label of a running job, or ""."""
|
||||
return next(iter(_jobs.values()), "")
|
||||
|
||||
|
||||
def reset():
|
||||
"""Forget the running jobs (their results will be dropped), e.g. when another file is loaded."""
|
||||
global _generation
|
||||
_generation += 1
|
||||
_jobs.clear()
|
||||
|
||||
|
||||
def _poll():
|
||||
while True:
|
||||
try:
|
||||
job_id, generation, callback, value = _results.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if generation != _generation:
|
||||
continue
|
||||
_jobs.pop(job_id, None)
|
||||
try:
|
||||
callback(value)
|
||||
except Exception: # noqa: BLE001 - keep the poller alive
|
||||
traceback.print_exc()
|
||||
for window in bpy.context.window_manager.windows:
|
||||
for area in window.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
return 0.1 if _jobs else None
|
||||
Reference in new issue
Block a user