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,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()
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user