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