Scene Manager: Load Publish and numbered instances

Load Publish (Loaded panel) searches every asset of the project, then
loads any of its published .blend files at any version, cast in the shot
or not. The file the casting would load and the latest version on this
site are preselected; a version not on this site is requested instead.
Built on the existing wtd_list_assets / wtd_get_asset_files /
wtd_get_revisions commands.

New instances are numbered (COL_bob_01, tree_01...), counting on from the
highest number in the file (SCENE_MANAGER_INSTANCE_NAME).

README: Pick Shot only lists shots that exist in Libreflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NDemaerschalkandClaude Opus 5.5 committed 2026-10-28 10:47:05 +01:00
1 parent 6ec2b228ae
commit fbc7ac18e4
9 files changed
+307 -32

No files matched your search

+14 -1
View File
@@ -163,8 +163,10 @@ def add_instance(scene, view_layer, library, parent=None):
scene.collection.children.unlink(instance)
if parent.children.get(instance.name) != instance:
parent.children.link(instance)
instance.name = numbered_name(collection.name, bpy.data.collections)
else:
instance = bpy.data.objects.new(library.get("wtd_asset_name", collection.name), None)
name = numbered_name(library.get("wtd_asset_name", collection.name), bpy.data.objects)
instance = bpy.data.objects.new(name, None)
instance.instance_type = "COLLECTION"
instance.instance_collection = collection
parent.objects.link(instance)
@@ -172,6 +174,17 @@ def add_instance(scene, view_layer, library, parent=None):
return instance
def numbered_name(base, ids):
"""config SCENE_MANAGER_INSTANCE_NAME for the number after the highest one ``ids`` use
(bob_01, bob_02...; removing bob_01 does not free its number while bob_02 exists)."""
template = config.SCENE_MANAGER_INSTANCE_NAME
before, _, rest = template.partition("{number")
after = rest.partition("}")[2]
pattern = re.compile(re.escape(before.format(name=base)) + r"(\d+)" + re.escape(after.format(name=base)) + "$")
numbers = [int(match.group(1)) for id in ids if (match := pattern.match(id.name))]
return template.format(name=base, number=max(numbers, default=0) + 1)
# --------------------------------------------------------------------------- #
# Levels of detail
# --------------------------------------------------------------------------- #
+205 -16
View File
@@ -5,6 +5,7 @@ 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 fnmatch
import os
import re
import shutil
@@ -13,7 +14,7 @@ import time
import bpy
from bpy.app.handlers import persistent
from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty
from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty
from .. import config
from . import ipc
@@ -33,7 +34,8 @@ class _State:
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.assets = [] # wtd_list_assets, for Load Publish and the assetize dialog
self.browse = {} # Load Publish: {asset, files: [wtd_get_asset_files + revisions]}
self.message = ("", "INFO") # last outcome, shown in the panel
@@ -480,6 +482,190 @@ class WTD_OT_scene_load(bpy.types.Operator):
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Load any publish
# --------------------------------------------------------------------------- #
def project_oid(scene):
"""Project of the scene's context, else of the oid open in Libreflow ("/melvile")."""
oid = (context_oid(scene) or current_session().get("current_oid") or "").strip("/")
return "/" + oid.split("/")[0] if oid else ""
def default_file(asset_type, files):
"""The file the casting would load (config SCENE_MANAGER_FILE_PATTERNS), else the first one."""
patterns = config.SCENE_MANAGER_FILE_PATTERNS
for pattern in patterns.get(asset_type, patterns.get("*", [])):
found = next((f for f in files if fnmatch.fnmatchcase(f["label"], pattern)), None)
if found:
return found
return files[0]
def default_revision(file):
"""Latest revision on this site, else the latest one."""
revisions = file.get("revisions") or []
return next((r for r in revisions if r.get("available")), revisions[0] if revisions else None)
_BROWSE_ASSET_ITEMS = []
_BROWSE_FILE_ITEMS = []
_BROWSE_REVISION_ITEMS = {} # file oid -> enum items (kept alive for Blender)
def _browse_asset_items(self, context):
return _BROWSE_ASSET_ITEMS
def _browse_file_items(self, context):
return _BROWSE_FILE_ITEMS
def _browse_revision_items(self, context):
return _BROWSE_REVISION_ITEMS.get(self.file, [])
def _browse_file_update(self, context):
file = next((f for f in STATE.browse.get("files", []) if f["file_oid"] == self.file), None)
revision = default_revision(file) if file else None
if revision is not None:
self.revision = revision["name"]
class WTD_OT_scene_browse(bpy.types.Operator):
bl_idname = "wtd_scene.browse"
bl_label = "Load Publish"
bl_description = "Search any asset of the project, then load one of its published files at any version"
bl_property = "asset"
asset: EnumProperty(items=_browse_asset_items)
@classmethod
def poll(cls, context):
return connected()
def invoke(self, context, event):
project = project_oid(context.scene)
if not project:
self.report({"WARNING"}, "Set a context oid (or open the project in Libreflow) first")
return {"CANCELLED"}
context.window.cursor_set("WAIT")
try:
STATE.assets = ipc.call("wtd_list_assets", session_uid(), oid=project)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Assets: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
assets = sorted(STATE.assets, key=lambda a: (a["asset"].lower(), a.get("asset_type") or ""))
_BROWSE_ASSET_ITEMS[:] = [(a["oid"], f"{a['asset']} ({a.get('asset_type') or ''})", a["oid"]) for a in assets]
if not _BROWSE_ASSET_ITEMS:
self.report({"WARNING"}, f"No asset in {project}")
return {"CANCELLED"}
context.window_manager.invoke_search_popup(self)
return {"RUNNING_MODAL"}
def execute(self, context):
asset = next((a for a in STATE.assets if a["oid"] == self.asset), None)
if asset is None:
return {"CANCELLED"}
context.window.cursor_set("WAIT")
try:
browse_asset(asset)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"{asset['asset']}: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
if not STATE.browse["files"]:
self.report({"WARNING"}, f"{asset['asset']}: no published .blend file")
return {"CANCELLED"}
bpy.ops.wtd_scene.load_publish("INVOKE_DEFAULT")
return {"FINISHED"}
def browse_asset(asset):
"""Read the published files of an asset and all their revisions, for the Load Publish dialog."""
uid = session_uid()
files = ipc.call("wtd_get_asset_files", uid, oid=asset["oid"])
for file in files:
file["revisions"] = ipc.call("wtd_get_revisions", uid, file_oid=file["file_oid"])
files = [f for f in files if f["revisions"]]
STATE.browse = {"asset": asset, "files": files}
_BROWSE_FILE_ITEMS[:] = [(f["file_oid"], f["label"], f["file_oid"], index) for index, f in enumerate(files)]
_BROWSE_REVISION_ITEMS.clear()
for file in files:
_BROWSE_REVISION_ITEMS[file["file_oid"]] = [
(r["name"], revision_label(r), r.get("comment") or "", index) for index, r in enumerate(file["revisions"])
]
class WTD_OT_scene_load_publish(bpy.types.Operator):
bl_idname = "wtd_scene.load_publish"
bl_label = "Load Publish"
bl_description = "Load a published file of the asset picked in Load Publish"
bl_options = {"REGISTER", "UNDO"}
file: EnumProperty(name="File", items=_browse_file_items, update=_browse_file_update)
revision: EnumProperty(name="Version", items=_browse_revision_items)
count: IntProperty(name="Instances", default=1, min=1, max=50)
@classmethod
def poll(cls, context):
return connected() and bool(STATE.browse)
def invoke(self, context, event):
asset = STATE.browse["asset"]
self.file = default_file(asset.get("asset_type") or "", STATE.browse["files"])["file_oid"]
_browse_file_update(self, context)
title = f"Load {asset['asset']} ({asset.get('asset_type') or ''})"
return context.window_manager.invoke_props_dialog(self, width=450, title=title, confirm_text="Load")
def draw(self, context):
layout = self.layout
layout.prop(self, "file")
layout.prop(self, "revision")
layout.prop(self, "count")
library = lib.find_library(self.file)
if library is not None:
layout.label(text=f"Loaded at {library.get('wtd_revision')}: adds instances of that version", icon="INFO")
def execute(self, context):
asset = STATE.browse["asset"]
file = next(f for f in STATE.browse["files"] if f["file_oid"] == self.file)
revision = next(r for r in file["revisions"] if r["name"] == self.revision)
name = asset["asset"]
if not revision.get("available"):
if revision.get("sync_status") == "Requested":
self.report({"WARNING"}, f"{name} {revision['name']} is requested, waiting for the download")
else:
request_revisions({revision["oid"]: file["file_oid"]})
self.report({"INFO"}, f"{name} {revision['name']} is not on this site: requested, load it once downloaded")
return {"CANCELLED"}
library = lib.find_library(file["file_oid"])
if library is not None and library.get("wtd_revision") != revision["name"]:
self.report(
{"WARNING"},
f"{name} {file['label']} is loaded at {library.get('wtd_revision')}: "
f"switch it with the version picker of the Loaded panel",
)
return {"CANCELLED"}
# Known file: the Loaded panel can tell whether it is the latest version.
STATE.files[file["file_oid"]] = {k: file.get(k) for k in ("file_oid", "label", "latest", "latest_available")}
try:
if library is None:
library = lib.link_asset(
{"name": name, "oid": asset["oid"], "asset_type": asset.get("asset_type") or "", "file": file},
revision,
context_oid(context.scene),
)
added = [lib.add_instance(context.scene, context.view_layer, library).name for _ in range(self.count)]
except (lib.LoadError, RuntimeError) as exc:
self.report({"WARNING"}, str(exc) if isinstance(exc, lib.LoadError) else f"{name}: {exc}")
return {"CANCELLED"}
report(f"Loaded {name} {revision['name']}: {', '.join(added)}")
return {"FINISHED"}
# --------------------------------------------------------------------------- #
# Update / versions
# --------------------------------------------------------------------------- #
@@ -576,6 +762,19 @@ class WTD_OT_scene_request(bpy.types.Operator):
return {"FINISHED"}
def revision_label(revision):
"""'v002 2026-10-27 nero', marked when the revision is not on this site."""
r = revision
date = time.strftime("%Y-%m-%d", time.localtime(r["date"])) if r.get("date") else ""
if r.get("available"):
where = ""
elif r.get("sync_status") == "Requested":
where = " (requested)"
else:
where = " (not on this site: request)"
return f"{r['name']} {date} {r.get('user') or ''}{where}"
def _revision_items(self, context):
return _REVISION_ITEMS
@@ -611,19 +810,7 @@ class WTD_OT_scene_pick_revision(bpy.types.Operator):
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,
)
(r["name"], revision_label(r), 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:
@@ -817,7 +1004,7 @@ def replace_with_publish(scene, view_layer, collection_name, asset, context_oid_
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.
# Kept in the file, out of the way: the published asset takes its place (numbered, COL_x_01).
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)
@@ -976,6 +1163,8 @@ classes = (
WTD_OT_scene_pick_shot,
WTD_OT_scene_refresh,
WTD_OT_scene_load,
WTD_OT_scene_browse,
WTD_OT_scene_load_publish,
WTD_OT_scene_update,
WTD_OT_scene_request,
WTD_OT_scene_pick_revision,