- Request (globe) on casting rows and loaded assets whose latest publish is not on this site, and Request Missing for all of them, through the new wtdcmds command wtd_request_revisions. Requested publishes show "requested, waiting for the download". - The version picker requests a version that is not on this site. - A 404 explains that the session is gone or wtdcmds is too old. - Fake server: car (vehicles) with a publish not on this site, wtd_request_revisions and /_test/download; 45 checks. - Tested live: car_paul modeling.blend v001 -> Requested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
975 lines
37 KiB
Python
975 lines
37 KiB
Python
"""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"
|
|
if latest.get("sync_status") == "Requested":
|
|
return "PENDING", f"{latest['name']} requested, waiting for the download"
|
|
return "PENDING", f"{latest['name']} published, not available on this site yet"
|
|
|
|
|
|
def file_data(file_oid, default=None):
|
|
"""Latest known revisions of a file (refreshed by requests and version checks)."""
|
|
return STATE.files.get(file_oid) or default
|
|
|
|
|
|
def to_request(file):
|
|
"""The latest revision of a file when it is not on this site and not requested yet, else None."""
|
|
latest = (file or {}).get("latest")
|
|
if latest and latest.get("oid") and not latest.get("available") and latest.get("sync_status") != "Requested":
|
|
return latest
|
|
return None
|
|
|
|
|
|
def missing_files(scene):
|
|
"""File oids of the cast and loaded assets whose latest publish can be requested."""
|
|
casting = STATE.casting.get(context_oid(scene)) or {"assets": []}
|
|
file_oids = [a["file"]["file_oid"] for a in casting["assets"] if a.get("file")]
|
|
file_oids += [library["wtd_file_oid"] for library in lib.managed_libraries()]
|
|
return [oid for oid in dict.fromkeys(file_oids) if to_request(file_data(oid))]
|
|
|
|
|
|
def _asset_of(file_oid):
|
|
return file_oid.split("/assets/", 1)[-1].split("/", 1)[0]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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),
|
|
)
|
|
|
|
|
|
def request_revisions(revisions):
|
|
"""Ask Libreflow to download revisions on this site ({revision oid: file oid})."""
|
|
uid = session_uid()
|
|
|
|
def done(result):
|
|
requested, messages = [], []
|
|
for oid, data in result.items():
|
|
file_oid = revisions.get(oid, "")
|
|
what = f"{_asset_of(file_oid)} {data.get('name') or oid.rsplit('/', 1)[-1]}"
|
|
if data.get("error"):
|
|
messages.append(f"{what}: {data['error']}")
|
|
continue
|
|
file = STATE.files.get(file_oid)
|
|
if file is not None and (file.get("latest") or {}).get("oid") == oid:
|
|
file["latest"] = data
|
|
if data.get("available"):
|
|
file["latest_available"] = data
|
|
for revision in STATE.revisions.get(file_oid, []):
|
|
if revision.get("oid") == oid:
|
|
revision.update(data)
|
|
if data.get("available"):
|
|
messages.append(f"{what} is already on this site: refresh")
|
|
else:
|
|
requested.append(what)
|
|
text = f"Requested {', '.join(requested)}" if requested else "Nothing requested"
|
|
report(text + ("; " + "; ".join(messages) if messages else ""), "WARNING" if messages else "INFO")
|
|
|
|
ipc.run(
|
|
"Requesting",
|
|
lambda: ipc.call("wtd_request_revisions", uid, revision_oids=list(revisions)),
|
|
done,
|
|
_on_error("Request"),
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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"}
|
|
|
|
|
|
class WTD_OT_scene_request(bpy.types.Operator):
|
|
bl_idname = "wtd_scene.request"
|
|
bl_label = "Request"
|
|
bl_description = (
|
|
"Ask Libreflow to download the latest publish on this site (from the exchange site, "
|
|
"or from the site that published it). Refresh once it is downloaded"
|
|
)
|
|
|
|
file_oid: StringProperty(description="Only the latest publish of this file", options={"SKIP_SAVE"})
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return connected()
|
|
|
|
def execute(self, context):
|
|
file_oids = [self.file_oid] if self.file_oid else missing_files(context.scene)
|
|
revisions = {}
|
|
for file_oid in file_oids:
|
|
revision = to_request(file_data(file_oid))
|
|
if revision is not None:
|
|
revisions[revision["oid"]] = file_oid
|
|
if not revisions:
|
|
self.report({"INFO"}, "Nothing to request")
|
|
return {"CANCELLED"}
|
|
request_revisions(revisions)
|
|
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 " (requested)" if r.get("sync_status") == "Requested" else " (not on this site: request)"
|
|
),
|
|
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"}
|
|
file_oid = library["wtd_file_oid"]
|
|
revision = next(r for r in STATE.revisions[file_oid] if r["name"] == self.revision)
|
|
if not revision.get("available"):
|
|
if revision.get("sync_status") == "Requested":
|
|
self.report({"WARNING"}, f"{revision['name']} is requested, waiting for the download")
|
|
else:
|
|
request_revisions({revision["oid"]: file_oid})
|
|
self.report({"INFO"}, f"{revision['name']} is not on this site: requested, pick it again once downloaded")
|
|
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_request,
|
|
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)
|