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:
Nero Frame 13andClaude Opus 5.5 committed 2026-10-27 11:15:02 +01:00
1 parent 10fe71e79e
commit c238f4db7c
10 files changed
+1630 -11

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
"""Scene Manager: load, update and publish the assets of a shot through Libreflow.
Libreflow is reached through its IPC server (``ipc``), the linked assets are
handled in ``library`` and the operators in ``operators``.
"""
from . import operators
def register():
operators.register()
def unregister():
operators.unregister()
+149
View File
@@ -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
+243
View File
@@ -0,0 +1,243 @@
"""Blender side of the Scene Manager: the assets linked from Libreflow publishes.
Every loaded publish is one ``bpy.types.Library``, tagged with custom
properties (``wtd_asset_oid``, ``wtd_file_oid``, ``wtd_revision``...). The tags
live on the library only: library overrides lose custom properties when they
resync. Instances are found back from their library: a collection instance
(empty) through its instanced collection, a library override through the
linked collection it overrides.
"""
import os
import re
import bpy
from .. import config
ASSET_OID = "wtd_asset_oid"
class LoadError(Exception):
"""An asset cannot be loaded or updated. The message is shown to the user."""
# --------------------------------------------------------------------------- #
# Loaded assets
# --------------------------------------------------------------------------- #
def managed_libraries():
"""Libraries loaded by the Scene Manager, sorted by asset name."""
libraries = [lib for lib in bpy.data.libraries if lib.get(ASSET_OID)]
return sorted(libraries, key=lambda lib: (lib.get("wtd_asset_name", ""), lib.name))
def find_library(file_oid):
return next((lib for lib in managed_libraries() if lib.get("wtd_file_oid") == file_oid), None)
def linked_collection(library):
"""The asset collection linked from the library."""
name = library.get("wtd_collection", "")
return next((c for c in bpy.data.collections if c.library == library and c.name == name), None)
def all_instances():
"""{library name: [instances]}: collection instances (empties) and override hierarchy roots,
found in one pass over the file."""
found = {}
for ob in bpy.data.objects:
collection = ob.instance_collection if ob.instance_type == "COLLECTION" else None
if ob.library is None and collection is not None and collection.library is not None:
found.setdefault(collection.library.name, []).append(ob)
for collection in bpy.data.collections:
override = collection.override_library
if (
collection.library is None
and override is not None
and override.reference is not None
and override.reference.library is not None
and override.hierarchy_root == collection
):
found.setdefault(override.reference.library.name, []).append(collection)
return found
def instances(library):
return all_instances().get(library.name, [])
def instance_objects(instance):
return [instance] if isinstance(instance, bpy.types.Object) else list(instance.all_objects)
def selected_instances(view_layer):
"""(library, instance) pairs with at least one selected object."""
selected = {ob.as_pointer() for ob in view_layer.objects if ob.select_get(view_layer=view_layer)}
found = all_instances()
return [
(library, instance)
for library in managed_libraries()
for instance in found.get(library.name, [])
if any(ob.as_pointer() in selected for ob in instance_objects(instance))
]
# --------------------------------------------------------------------------- #
# Load
# --------------------------------------------------------------------------- #
def link_mode(asset_type):
modes = config.SCENE_MANAGER_LINK_MODES
return modes.get(asset_type, modes.get("*", "INSTANCE"))
def _type_collection(scene, asset_type):
"""Collection gathering the instances of an asset type, under the scene collection."""
name = config.SCENE_MANAGER_TYPE_COLLECTION.format(asset_type=asset_type)
collection = bpy.data.collections.get(name)
if collection is None or collection.library is not None:
collection = bpy.data.collections.new(name)
if collection.name not in scene.collection.children:
scene.collection.children.link(collection)
return collection
def _revision_path(revision, what):
path = (revision or {}).get("path")
if not path:
raise LoadError(f"{what}: Libreflow gives no path for {revision and revision.get('name')}")
if not os.path.isfile(path):
raise LoadError(f"{what}: {revision['name']} not found on disk ({path})")
return path
def link_asset(asset, revision, context_oid):
"""Link the asset collection of a published revision. Returns its library.
The collection is the first of config SCENE_MANAGER_COLLECTION_NAMES found in
the file, else its single top-level collection.
"""
name = asset["name"]
path = _revision_path(revision, name)
candidates = [pattern.format(asset=name) for pattern in config.SCENE_MANAGER_COLLECTION_NAMES]
with bpy.data.libraries.load(path, link=True) as (data_from, data_to):
chosen = next((c for c in candidates if c in data_from.collections), None)
data_to.collections = [chosen] if chosen else list(data_from.collections)
linked = [c for c in data_to.collections if c is not None]
if not linked:
raise LoadError(f"{name}: no collection in {os.path.basename(path)}")
library = linked[0].library
if chosen is None:
roots = [c for c in linked if not any(c in parent.children.values() for parent in linked)]
if len(roots) != 1:
message = (
f"{name}: no collection named {' or '.join(candidates)}, and "
f"{len(roots)} top-level collections in the file ({', '.join(c.name for c in roots)})"
)
bpy.data.libraries.remove(library)
raise LoadError(message)
chosen = roots[0].name
library.name = name
library[ASSET_OID] = asset["oid"]
library["wtd_asset_name"] = name
library["wtd_asset_type"] = asset.get("asset_type") or ""
library["wtd_file_oid"] = asset["file"]["file_oid"]
library["wtd_revision"] = revision["name"]
library["wtd_collection"] = chosen
library["wtd_context_oid"] = context_oid
return library
def add_instance(scene, view_layer, library, parent=None):
"""Bring the library's asset in the scene: a library override or a collection instance
(config SCENE_MANAGER_LINK_MODES), in ``parent`` or in the collection of its asset type."""
collection = linked_collection(library)
if collection is None:
raise LoadError(f"{library.name}: collection {library.get('wtd_collection')} missing from the file")
parent = parent or _type_collection(scene, library.get("wtd_asset_type", ""))
if link_mode(library.get("wtd_asset_type", "")) == "OVERRIDE":
override = collection.override_hierarchy_create(scene, view_layer)
if scene.collection.children.get(override.name) == override and parent != scene.collection:
scene.collection.children.unlink(override)
if parent.children.get(override.name) != override:
parent.children.link(override)
return override
empty = bpy.data.objects.new(library.get("wtd_asset_name", collection.name), None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
parent.objects.link(empty)
return empty
# --------------------------------------------------------------------------- #
# Update / remove
# --------------------------------------------------------------------------- #
def update(library_name, revision):
"""Point a library to another revision of its file and reload it."""
library = bpy.data.libraries[library_name]
path = _revision_path(revision, library.get("wtd_asset_name", library_name))
library.filepath = path
library.reload()
# The reload replaces the linked data: look the library up again.
library = bpy.data.libraries[library_name]
library["wtd_revision"] = revision["name"]
return library
def remove_instance(instance):
if isinstance(instance, bpy.types.Object):
bpy.data.objects.remove(instance)
return
ids = {instance}
ids.update(c for c in instance.children_recursive if c.override_library and c.library is None)
for ob in instance.all_objects:
if ob.override_library and ob.library is None:
ids.add(ob)
if ob.data is not None and ob.data.override_library and ob.data.library is None:
ids.add(ob.data)
bpy.data.batch_remove(ids)
def remove_library(library):
"""Remove every instance of the asset, then the library and its linked data."""
for instance in instances(library):
remove_instance(instance)
bpy.data.libraries.remove(library)
# --------------------------------------------------------------------------- #
# Assetize
# --------------------------------------------------------------------------- #
def _holds_linked_data(collection):
return any(c.override_library or c.library for c in collection.children_recursive) or any(
ob.override_library or ob.library or (ob.instance_collection and ob.instance_collection.library)
for ob in collection.all_objects
)
def assetize_candidates():
"""Local collections that can be published: not linked, not an override, holding no loaded asset."""
return [
c
for c in bpy.data.collections
if c.library is None and c.override_library is None and not c.get(ASSET_OID) and not _holds_linked_data(c)
]
def asset_name_from(collection_name):
"""'COL_street lamp.001' -> 'street_lamp': the prefix and duplicate suffix removed, an identifier."""
name = re.sub(r"\.\d{3,}$", "", collection_name)
prefix = config.DATABLOCK_PREFIXES.get("collections", "")
if prefix and name.upper().startswith(prefix.upper()):
name = name[len(prefix):]
return re.sub(r"[^A-Za-z0-9_]+", "_", name).strip("_")
def write_collection(collection, path):
"""Write the collection and what it uses to a .blend, for publishing."""
bpy.data.libraries.write(path, {collection}, path_remap="RELATIVE_ALL", fake_user=True)
def parents_of(collection, scene):
return [p for p in [scene.collection, *bpy.data.collections] if p.children.get(collection.name) == collection]
+873
View File
@@ -0,0 +1,873 @@
"""Scene Manager operators: connection, shot context, casting, load, update, remove, assetize.
Libreflow is only reached through ``ipc``; every request but the ones filling a
dialog runs in the background and applies its result on the main thread.
What comes back from Libreflow is cached in ``STATE`` (not saved with the file).
"""
import os
import re
import shutil
import tempfile
import time
import bpy
from bpy.app.handlers import persistent
from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty
from .. import config
from . import ipc
from . import library as lib
class _State:
def __init__(self):
self.clear()
def clear(self):
self.sessions = {} # session uid -> {session_name, cluster_name, is_gui, current_oid}
self.connection_error = "Not connected"
self.ping = {} # wtd_ping: version, project, user, site
self.contexts = {} # oid -> context, or {"error": message}
self.casting = {} # context oid -> wtd_get_casting_publishes result
self.files = {} # file oid -> {latest, latest_available} or {error}
self.revisions = {} # file oid -> wtd_get_revisions result
self.shots = [] # wtd_list_shots, for the shot picker
self.assets = [] # wtd_list_assets, for the assetize dialog
self.message = ("", "INFO") # last outcome, shown in the panel
STATE = _State()
# Loaded assets unfolded in the panel (library names), UI state only.
EXPANDED: set[str] = set()
def report(message, level="INFO"):
STATE.message = (message, level)
print(f"[WTD Scene Manager] {message}")
def _undo_push(message):
try:
bpy.ops.ed.undo_push(message=message)
except RuntimeError:
pass
def _on_error(what, quiet=False):
def callback(exc):
if isinstance(exc, ipc.NotRunning):
STATE.sessions = {}
STATE.connection_error = str(exc)
if not quiet:
report(f"{what}: {exc}", "ERROR")
return callback
def session_uid():
"""The chosen session, None when Libreflow picks it (a single session connected)."""
uid = bpy.context.window_manager.wtd_libreflow.session_uid
return uid if uid in STATE.sessions else None
def current_session():
return STATE.sessions.get(session_uid() or "") or next(iter(STATE.sessions.values()), {})
def connected():
return bool(STATE.sessions)
def context_oid(scene):
return scene.wtd_scene_manager.context_oid.strip()
def main_scene():
"""Scene of the main window (bpy.context.scene is not reliable in timers)."""
windows = bpy.context.window_manager.windows
return windows[0].scene if windows else bpy.context.scene
def context_label(oid):
"""'f01 / s010 / p010' for a validated oid, or the reason it is not valid."""
ctx = STATE.contexts.get(oid)
if ctx is None:
return "", False
if "error" in ctx:
return ctx["error"], False
keys = ("film", "sequence", "shot") if ctx["kind"].startswith("shot") else ("asset_type", "asset")
parts = [ctx[key] for key in keys if ctx.get(key)]
return f"{ctx['kind']}: {' / '.join(parts) or ctx.get('project', '')}", True
def is_shot_context(oid):
ctx = STATE.contexts.get(oid) or {}
return ctx.get("kind", "").startswith("shot")
def version_status(library):
"""(state, text) of a loaded asset's revision: state is "LATEST", "UPDATE" (a newer one
is available), "PENDING" (newer one published, not on this site yet) or "UNKNOWN"."""
data = STATE.files.get(library.get("wtd_file_oid"))
if data is None:
return "UNKNOWN", "not checked"
if data.get("error"):
return "UNKNOWN", data["error"]
current = library.get("wtd_revision")
latest, available = data.get("latest"), data.get("latest_available")
if latest is None or latest["name"] == current:
return "LATEST", "latest"
if available is not None and available["name"] != current:
return "UPDATE", f"{available['name']} available"
return "PENDING", f"{latest['name']} published, not available on this site yet"
# --------------------------------------------------------------------------- #
# Background requests
# --------------------------------------------------------------------------- #
def connect(scene, quiet=False):
"""Read the sessions (and who / where we are) in the background, then check the scene's context."""
wanted = bpy.context.window_manager.wtd_libreflow.session_uid
oid, scene_name = context_oid(scene), scene.name
def fetch():
sessions = ipc.sessions()
uid = wanted if wanted in sessions else (next(iter(sessions)) if len(sessions) == 1 else "")
ping = {}
if uid:
target = oid or sessions[uid].get("current_oid")
try:
ping = ipc.call("wtd_ping", uid, oid=target) if target else ipc.call("wtd_ping", uid)
except ipc.LibreflowError:
ping = ipc.call("wtd_ping", uid)
return sessions, uid, ping
def done(result):
sessions, uid, ping = result
STATE.sessions, STATE.ping = sessions, ping
STATE.connection_error = "" if sessions else "No Libreflow session connected"
bpy.context.window_manager.wtd_libreflow.session_uid = uid
scene = bpy.data.scenes.get(scene_name)
if scene is not None and context_oid(scene):
validate_context(scene, quiet=quiet)
ipc.run("Connecting to Libreflow", fetch, done, _on_error("Connection", quiet))
def validate_context(scene, quiet=False):
"""Check the context oid; for a shot, then fetch its casting and the loaded versions."""
oid, uid, scene_name = context_oid(scene), session_uid(), scene.name
if not oid:
return
def done(ctx):
STATE.contexts[oid] = ctx
scene = bpy.data.scenes.get(scene_name)
if scene is not None and ctx["kind"].startswith("shot"):
fetch_casting(scene, quiet=quiet)
refresh_versions(quiet=quiet)
def failed(exc):
if not isinstance(exc, ipc.NotRunning):
STATE.contexts[oid] = {"error": str(exc)}
_on_error("Context", quiet)(exc)
ipc.run("Checking the context", lambda: ipc.call("wtd_get_context", uid, oid=oid), done, failed)
def _casting_publishes(uid, oid):
"""Casting with a file per asset, the file chosen with the patterns of its asset type
(config SCENE_MANAGER_FILE_PATTERNS)."""
patterns = config.SCENE_MANAGER_FILE_PATTERNS
default = patterns.get("*", ["*/*.blend"])
casting = ipc.call("wtd_get_casting_publishes", uid, oid=oid, file_patterns=default)
specific = {asset_type: p for asset_type, p in patterns.items() if asset_type != "*" and p != default}
for asset_patterns in {tuple(p) for p in specific.values()}:
types = {t for t, p in specific.items() if tuple(p) == asset_patterns}
if not any(asset["asset_type"] in types for asset in casting["assets"]):
continue
other = ipc.call("wtd_get_casting_publishes", uid, oid=oid, file_patterns=list(asset_patterns))
by_key = {(a["asset_type"], a["name"]): a for a in other["assets"]}
casting["assets"] = [
by_key.get((a["asset_type"], a["name"]), a) if a["asset_type"] in types else a
for a in casting["assets"]
]
return casting
def _store_casting(oid, casting):
STATE.casting[oid] = casting
STATE.files.update({a["file"]["file_oid"]: a["file"] for a in casting["assets"] if a.get("file")})
def fetch_casting(scene, then=None, quiet=False):
oid, uid = context_oid(scene), session_uid()
def done(casting):
_store_casting(oid, casting)
if then is not None:
then(casting)
ipc.run("Reading the casting", lambda: _casting_publishes(uid, oid), done, _on_error("Casting", quiet))
def refresh_versions(quiet=False):
file_oids = [library["wtd_file_oid"] for library in lib.managed_libraries()]
if not file_oids:
return
uid = session_uid()
ipc.run(
"Checking versions",
lambda: ipc.call("wtd_get_latest_revisions", uid, file_oids=file_oids),
STATE.files.update,
_on_error("Versions", quiet),
)
# --------------------------------------------------------------------------- #
# Settings
# --------------------------------------------------------------------------- #
def _context_oid_update(self, context):
if connected():
validate_context(self.id_data)
class WTD_SceneManagerSettings(bpy.types.PropertyGroup):
"""Per-file Scene Manager settings, saved in the .blend."""
context_oid: StringProperty(
name="Context",
description="Libreflow oid of the shot this scene belongs to",
update=_context_oid_update,
)
class WTD_LibreflowSettings(bpy.types.PropertyGroup):
"""Libreflow connection, for this Blender session."""
session_uid: StringProperty(name="Session", description="Libreflow session the commands are sent to")
# --------------------------------------------------------------------------- #
# Connection / context
# --------------------------------------------------------------------------- #
class WTD_OT_scene_connect(bpy.types.Operator):
bl_idname = "wtd_scene.connect"
bl_label = "Connect"
bl_description = "Connect to the running Libreflow (or refresh the sessions, context, casting and versions)"
def execute(self, context):
connect(context.scene)
return {"FINISHED"}
class WTD_OT_scene_select_session(bpy.types.Operator):
bl_idname = "wtd_scene.select_session"
bl_label = "Select Session"
bl_description = "Send the commands to this Libreflow session"
uid: StringProperty()
def execute(self, context):
context.window_manager.wtd_libreflow.session_uid = self.uid
connect(context.scene)
return {"FINISHED"}
class WTD_OT_scene_use_current_oid(bpy.types.Operator):
bl_idname = "wtd_scene.use_current_oid"
bl_label = "Use Libreflow Current Oid"
bl_description = "Set the context to the oid shown in the Libreflow window"
@classmethod
def poll(cls, context):
return connected()
def execute(self, context):
oid = current_session().get("current_oid")
if not oid:
self.report({"WARNING"}, "Libreflow shows no oid")
return {"CANCELLED"}
context.scene.wtd_scene_manager.context_oid = oid
return {"FINISHED"}
def _shot_items(self, context):
return _SHOT_ITEMS
_SHOT_ITEMS = []
class WTD_OT_scene_pick_shot(bpy.types.Operator):
bl_idname = "wtd_scene.pick_shot"
bl_label = "Pick Shot"
bl_description = "Set the context to a shot of the project"
bl_property = "shot"
shot: EnumProperty(items=_shot_items)
@classmethod
def poll(cls, context):
return connected()
def invoke(self, context, event):
uid = session_uid()
oid = context_oid(context.scene) or current_session().get("current_oid")
if not oid:
self.report({"WARNING"}, "Set a context oid (or open the project in Libreflow) first")
return {"CANCELLED"}
context.window.cursor_set("WAIT")
try:
STATE.shots = ipc.call("wtd_list_shots", uid, oid=oid)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Shots: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
_SHOT_ITEMS[:] = [
(s["oid"], f"{s.get('film', '')} / {s.get('sequence', '')} / {s.get('shot', '')}", s["oid"])
for s in STATE.shots
]
if not _SHOT_ITEMS:
self.report({"WARNING"}, "No shot in the project")
return {"CANCELLED"}
context.window_manager.invoke_search_popup(self)
return {"RUNNING_MODAL"}
def execute(self, context):
context.scene.wtd_scene_manager.context_oid = self.shot
return {"FINISHED"}
class WTD_OT_scene_refresh(bpy.types.Operator):
bl_idname = "wtd_scene.refresh"
bl_label = "Refresh"
bl_description = "Read the casting and the latest versions of the loaded assets again"
@classmethod
def poll(cls, context):
return connected()
def execute(self, context):
if is_shot_context(context_oid(context.scene)):
fetch_casting(context.scene)
refresh_versions()
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Load
# --------------------------------------------------------------------------- #
def load_casting(scene, view_layer, casting, context_oid_value, only=None):
"""Link the missing assets of the casting and create their missing instances.
Returns (number of instances added, error messages)."""
added, errors = 0, []
for asset in casting["assets"]:
if only and asset["oid"] != only:
continue
if asset.get("error") or not asset.get("file"):
errors.append(f"{asset['name']}: {asset.get('error') or 'nothing to load'}")
continue
try:
library = lib.find_library(asset["file"]["file_oid"]) or lib.link_asset(
asset, asset["file"]["latest_available"], context_oid_value
)
wanted = asset.get("nb_occurences") or 1
for _ in range(wanted - len(lib.instances(library))):
lib.add_instance(scene, view_layer, library)
added += 1
except (lib.LoadError, RuntimeError) as exc:
errors.append(str(exc) if isinstance(exc, lib.LoadError) else f"{asset['name']}: {exc}")
return added, errors
class WTD_OT_scene_load(bpy.types.Operator):
bl_idname = "wtd_scene.load"
bl_label = "Load"
bl_description = "Link the latest available publish of the cast assets that are not loaded yet"
asset_oid: StringProperty(description="Only this asset", options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
return connected() and is_shot_context(context_oid(context.scene))
def execute(self, context):
oid = context_oid(context.scene)
scene_name, view_layer_name, only = context.scene.name, context.view_layer.name, self.asset_oid
def apply(casting):
scene = bpy.data.scenes.get(scene_name)
if scene is None:
return
added, errors = load_casting(scene, scene.view_layers[view_layer_name], casting, oid, only)
_undo_push("Load casting")
text = f"{added} instance(s) added" if added else "Nothing new to load"
if errors:
report(f"{text}, {len(errors)} asset(s) cannot be loaded: " + "; ".join(errors), "WARNING")
else:
report(text)
cached = STATE.casting.get(oid)
if only and cached is not None:
apply(cached)
else:
fetch_casting(context.scene, then=apply)
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Update / versions
# --------------------------------------------------------------------------- #
def _update_libraries(names, target=None):
"""Update libraries to their latest available revision (or to ``target``). Returns messages."""
updated, messages = [], []
for name in names:
library = bpy.data.libraries.get(name)
if library is None:
continue
revision = target
if revision is None:
state, text = version_status(library)
if state != "UPDATE":
if state == "PENDING":
messages.append(f"{name}: {text}")
continue
revision = STATE.files[library["wtd_file_oid"]]["latest_available"]
try:
lib.update(name, revision)
updated.append(f"{name} {revision['name']}")
except (lib.LoadError, RuntimeError) as exc:
messages.append(str(exc))
return updated, messages
class WTD_OT_scene_update(bpy.types.Operator):
bl_idname = "wtd_scene.update"
bl_label = "Update"
bl_description = "Switch the loaded assets to their latest available publish"
bl_options = {"REGISTER", "UNDO"}
library: StringProperty(description="Only this loaded asset", options={"SKIP_SAVE"})
selected: BoolProperty(description="Only the assets of the selected objects", options={"SKIP_SAVE"})
@classmethod
def poll(cls, context):
return connected() and bool(lib.managed_libraries())
def execute(self, context):
if self.library:
names = [self.library]
elif self.selected:
names = sorted({library.name for library, _ in lib.selected_instances(context.view_layer)})
if not names:
self.report({"WARNING"}, "No loaded asset selected")
return {"CANCELLED"}
else:
names = [library.name for library in lib.managed_libraries()]
file_oids = [bpy.data.libraries[name]["wtd_file_oid"] for name in names]
def apply(latest):
STATE.files.update(latest)
updated, messages = _update_libraries(names)
_undo_push("Update assets")
text = f"Updated {', '.join(updated)}" if updated else "Nothing to update"
report(text + ("; " + "; ".join(messages) if messages else ""), "WARNING" if messages else "INFO")
uid = session_uid()
ipc.run(
"Updating",
lambda: ipc.call("wtd_get_latest_revisions", uid, file_oids=file_oids),
apply,
_on_error("Update"),
)
return {"FINISHED"}
def _revision_items(self, context):
return _REVISION_ITEMS
_REVISION_ITEMS = []
class WTD_OT_scene_pick_revision(bpy.types.Operator):
bl_idname = "wtd_scene.pick_revision"
bl_label = "Pick Version"
bl_description = "Switch this asset to another published version (e.g. roll back)"
bl_options = {"REGISTER", "UNDO"}
library: StringProperty()
revision: EnumProperty(name="Version", items=_revision_items)
@classmethod
def poll(cls, context):
return connected()
def invoke(self, context, event):
library = bpy.data.libraries.get(self.library)
if library is None:
return {"CANCELLED"}
file_oid = library["wtd_file_oid"]
context.window.cursor_set("WAIT")
try:
STATE.revisions[file_oid] = ipc.call("wtd_get_revisions", session_uid(), file_oid=file_oid)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Versions: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
current = library.get("wtd_revision")
_REVISION_ITEMS[:] = [
(
r["name"],
f"{r['name']} {time.strftime('%Y-%m-%d', time.localtime(r['date'])) if r.get('date') else ''}"
f" {r.get('user') or ''}" + ("" if r.get("available") else " (not on this site)"),
r.get("comment") or "",
"CHECKMARK" if r["name"] == current else "NONE",
index,
)
for index, r in enumerate(STATE.revisions[file_oid])
]
if not _REVISION_ITEMS:
self.report({"WARNING"}, "No published version")
return {"CANCELLED"}
if current in {item[0] for item in _REVISION_ITEMS}:
self.revision = current
return context.window_manager.invoke_props_dialog(self, title=f"{self.library}: version")
def execute(self, context):
library = bpy.data.libraries.get(self.library)
if library is None:
return {"CANCELLED"}
revision = next(r for r in STATE.revisions[library["wtd_file_oid"]] if r["name"] == self.revision)
if not revision.get("available"):
self.report({"WARNING"}, f"{revision['name']} is not available on this site")
return {"CANCELLED"}
updated, messages = _update_libraries([self.library], target=revision)
if messages:
self.report({"WARNING"}, "; ".join(messages))
return {"CANCELLED"}
self.report({"INFO"}, f"{self.library} switched to {revision['name']}")
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Remove / select
# --------------------------------------------------------------------------- #
class WTD_OT_scene_remove(bpy.types.Operator):
bl_idname = "wtd_scene.remove"
bl_label = "Remove"
bl_description = "Remove loaded assets from the scene"
bl_options = {"REGISTER", "UNDO"}
library: StringProperty(description="Remove this whole asset (all its instances and the link)", options={"SKIP_SAVE"})
instance: StringProperty(description="Remove only this instance (object or collection name)", options={"SKIP_SAVE"})
selected: BoolProperty(description="Remove the instances of the selected objects", options={"SKIP_SAVE"})
def invoke(self, context, event):
if self.library:
return context.window_manager.invoke_confirm(
self, event, title=f"Remove {self.library}?", message="All its instances and the link to its file"
)
return self.execute(context)
def execute(self, context):
if context.mode != "OBJECT" and context.view_layer.objects.active is not None:
bpy.ops.object.mode_set(mode="OBJECT")
if self.library:
library = bpy.data.libraries.get(self.library)
if library is None:
return {"CANCELLED"}
lib.remove_library(library)
self.report({"INFO"}, f"Removed {self.library}")
return {"FINISHED"}
if self.selected:
targets = [instance for _library, instance in lib.selected_instances(context.view_layer)]
else:
targets = [
instance
for library in lib.managed_libraries()
for instance in lib.instances(library)
if instance.name == self.instance
]
if not targets:
self.report({"WARNING"}, "No loaded instance selected")
return {"CANCELLED"}
for instance in targets:
lib.remove_instance(instance)
self.report({"INFO"}, f"Removed {len(targets)} instance(s)")
return {"FINISHED"}
class WTD_OT_scene_select_instance(bpy.types.Operator):
bl_idname = "wtd_scene.select_instance"
bl_label = "Select"
bl_description = "Select the objects of this instance"
bl_options = {"REGISTER", "UNDO"}
library: StringProperty()
instance: StringProperty()
def execute(self, context):
library = bpy.data.libraries.get(self.library)
instance = next((i for i in lib.instances(library) if i.name == self.instance), None) if library else None
if instance is None:
return {"CANCELLED"}
if context.mode != "OBJECT" and context.view_layer.objects.active is not None:
bpy.ops.object.mode_set(mode="OBJECT")
view_layer = context.view_layer
objects = [ob for ob in lib.instance_objects(instance) if ob.name in view_layer.objects and ob.visible_get()]
if not objects:
self.report({"WARNING"}, "Nothing selectable (hidden or excluded)")
return {"CANCELLED"}
for ob in context.selected_objects:
ob.select_set(False)
for ob in objects:
ob.select_set(True)
view_layer.objects.active = next((ob for ob in objects if ob.type == "ARMATURE"), objects[0])
return {"FINISHED"}
class WTD_OT_scene_toggle_expand(bpy.types.Operator):
bl_idname = "wtd_scene.toggle_expand"
bl_label = "Show Instances"
bl_description = "Show or hide the instances of this asset"
library: StringProperty()
def execute(self, context):
EXPANDED.symmetric_difference_update({self.library})
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Assetize
# --------------------------------------------------------------------------- #
def _collection_items(self, context):
_COLLECTION_ITEMS[:] = [(c.name, c.name, "") for c in lib.assetize_candidates()]
return _COLLECTION_ITEMS
def _asset_type_items(self, context):
types = sorted({a["asset_type"] for a in STATE.assets if a.get("asset_type")})
_ASSET_TYPE_ITEMS[:] = [(t, t, "") for t in types]
return _ASSET_TYPE_ITEMS
_COLLECTION_ITEMS = []
_ASSET_TYPE_ITEMS = []
def _existing_asset(asset_type, asset_name, asset_lib):
return any(
a.get("asset_type") == asset_type
and a.get("asset") == asset_name
and (a.get("asset_lib") or "") == (asset_lib or "")
for a in STATE.assets
)
def replace_with_publish(scene, view_layer, collection_name, asset, context_oid_value):
"""Link the published revision in place of the local collection (kept in the file, unlinked)."""
local = bpy.data.collections.get(collection_name)
revision = asset["file"].get("latest_available")
if local is None or revision is None:
raise lib.LoadError(f"{asset['name']}: published, but not available on this site to replace it")
library = lib.find_library(asset["file"]["file_oid"])
if library is not None:
library = lib.update(library.name, revision)
else:
library = lib.link_asset(asset, revision, context_oid_value)
parents = lib.parents_of(local, scene)
for parent in parents:
parent.children.unlink(local)
# Kept in the file, out of the way: the published asset takes its name.
local.use_fake_user = True
local.name = f"{collection_name}_local"
lib.add_instance(scene, view_layer, library, parent=parents[0] if parents else None)
class WTD_OT_scene_assetize(bpy.types.Operator):
bl_idname = "wtd_scene.assetize"
bl_label = "Assetize"
bl_description = "Publish a collection of this scene to Libreflow as an asset (new asset or new version)"
collection: EnumProperty(name="Collection", items=_collection_items)
asset_type: EnumProperty(name="Asset Type", items=_asset_type_items)
asset_name: StringProperty(name="Asset Name", description="Letters, digits and underscores")
asset_lib: StringProperty(name="Asset Library", description="Publish under this asset library (optional)")
task: StringProperty(name="Task", description="Asset task to publish in")
file_name: StringProperty(name="File Name", description="Name of the .blend (defaults to the asset name)")
comment: StringProperty(name="Comment")
upload: BoolProperty(name="Upload", description="Also upload the revision to the exchange site")
replace: BoolProperty(
name="Replace with the Publish",
description="Link the published asset in place of the local collection (which stays in the file, unlinked)",
default=True,
)
@classmethod
def poll(cls, context):
return connected()
def _oid(self, context):
return context_oid(context.scene) or current_session().get("current_oid") or ""
def invoke(self, context, event):
candidates = lib.assetize_candidates()
if not candidates:
self.report({"WARNING"}, "No local collection to publish")
return {"CANCELLED"}
oid = self._oid(context)
if not oid:
self.report({"WARNING"}, "Set a context oid first")
return {"CANCELLED"}
context.window.cursor_set("WAIT")
try:
STATE.assets = ipc.call("wtd_list_assets", session_uid(), oid=oid)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Assets: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
if not _asset_type_items(self, context):
self.report({"WARNING"}, "No asset type in the project")
return {"CANCELLED"}
active = context.collection
self.collection = active.name if active in candidates else candidates[0].name
self.asset_name = lib.asset_name_from(self.collection)
self.task = config.SCENE_MANAGER_ASSETIZE_TASK
return context.window_manager.invoke_props_dialog(self, width=420)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(self, "collection")
layout.prop(self, "asset_type")
layout.prop(self, "asset_name")
exists = _existing_asset(self.asset_type, self.asset_name, self.asset_lib)
row = layout.row()
row.label(text="")
row.label(text="New version of this asset" if exists else "New asset", icon="FILE_REFRESH" if exists else "ADD")
layout.prop(self, "asset_lib")
layout.prop(self, "task")
layout.prop(self, "file_name")
layout.prop(self, "comment")
layout.prop(self, "upload")
layout.prop(self, "replace")
def execute(self, context):
name = self.asset_name.strip()
if not re.fullmatch(r"[A-Za-z0-9_]+", name or ""):
self.report({"WARNING"}, "The asset name can only use letters, digits and underscores")
return {"CANCELLED"}
collection = bpy.data.collections.get(self.collection)
if collection is None or not self.asset_type or not self.task.strip():
self.report({"WARNING"}, "Pick a collection, an asset type and a task")
return {"CANCELLED"}
folder = tempfile.mkdtemp(prefix="wtd_assetize_")
path = os.path.join(folder, f"{self.file_name.strip() or name}.blend")
lib.write_collection(collection, path)
kwargs = dict(
oid=self._oid(context),
asset_type=self.asset_type,
asset_name=name,
task=self.task.strip(),
source_path=path,
file_name=self.file_name.strip() or None,
comment=self.comment,
asset_lib=self.asset_lib.strip() or None,
create_asset=not _existing_asset(self.asset_type, name, self.asset_lib.strip()),
upload=self.upload,
)
uid, replace, asset_type = session_uid(), self.replace, self.asset_type
scene_name, view_layer_name, collection_name = context.scene.name, context.view_layer.name, collection.name
def done(result):
shutil.rmtree(folder, ignore_errors=True)
STATE.files[result["file_oid"]] = result
revision = (result.get("latest") or {}).get("name", "")
created = " (new asset)" if result.get("asset_created") else ""
message = f"Published {name} {revision}{created}"
if replace and (scene := bpy.data.scenes.get(scene_name)) is not None:
asset = {"name": name, "oid": result["asset_oid"], "asset_type": asset_type, "file": result}
try:
replace_with_publish(
scene, scene.view_layers[view_layer_name], collection_name, asset, kwargs["oid"]
)
_undo_push("Replace with the publish")
message += ", the collection is replaced by its link"
except (lib.LoadError, RuntimeError) as exc:
report(f"{message}, not replaced: {exc}", "WARNING")
return
report(message)
def failed(exc):
shutil.rmtree(folder, ignore_errors=True)
_on_error(f"Publishing {name}")(exc)
ipc.run(f"Publishing {name}", lambda: ipc.call("wtd_assetize", uid, **kwargs), done, failed)
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Registration
# --------------------------------------------------------------------------- #
@persistent
def _on_load_post(*_args):
"""New file: forget the cached answers, then reconnect in the background."""
ipc.reset()
sessions, error = STATE.sessions, STATE.connection_error
STATE.clear()
STATE.sessions, STATE.connection_error = sessions, error
bpy.app.timers.register(_auto_connect, first_interval=0.5)
def _auto_connect():
if os.path.isfile(os.path.expanduser(config.LIBREFLOW_SERVER_FILE)):
connect(main_scene(), quiet=True)
return None
classes = (
WTD_SceneManagerSettings,
WTD_LibreflowSettings,
WTD_OT_scene_connect,
WTD_OT_scene_select_session,
WTD_OT_scene_use_current_oid,
WTD_OT_scene_pick_shot,
WTD_OT_scene_refresh,
WTD_OT_scene_load,
WTD_OT_scene_update,
WTD_OT_scene_pick_revision,
WTD_OT_scene_remove,
WTD_OT_scene_select_instance,
WTD_OT_scene_toggle_expand,
WTD_OT_scene_assetize,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.wtd_scene_manager = PointerProperty(type=WTD_SceneManagerSettings)
bpy.types.WindowManager.wtd_libreflow = PointerProperty(type=WTD_LibreflowSettings)
bpy.app.handlers.load_post.append(_on_load_post)
bpy.app.timers.register(_auto_connect, first_interval=1.0)
def unregister():
ipc.reset()
if _on_load_post in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(_on_load_post)
if bpy.app.timers.is_registered(_auto_connect):
bpy.app.timers.unregister(_auto_connect)
del bpy.types.WindowManager.wtd_libreflow
del bpy.types.Scene.wtd_scene_manager
for cls in reversed(classes):
bpy.utils.unregister_class(cls)