From fbc7ac18e4a05c6b545f0c9e1a60036d0b5b7821 Mon Sep 17 00:00:00 2001 From: Nero WTD Date: Wed, 28 Oct 2026 10:47:05 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 10 +- README.md | 26 ++++- config.py | 3 + dev/fake_libreflow.py | 10 +- dev/make_test_assets.py | 2 + dev/test_scene_manager.py | 51 +++++++-- scene_manager/library.py | 15 ++- scene_manager/operators.py | 221 ++++++++++++++++++++++++++++++++++--- ui/panel.py | 1 + 9 files changed, 307 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb251c9..4da7d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ that contains this repo on `sys.path` and importing the repo folder name (see 1. `blender -b --factory-startup --python dev/make_test_assets.py -- ` 2. `python dev/fake_libreflow.py ` (keep running; restart for a clean state) 3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- ` - (60 checks, "All checks passed"). `/_test/publish` and `/_test/download` simulate a new + (71 checks, "All checks passed"). `/_test/publish` and `/_test/download` simulate a new publish and a requested revision reaching this site. The fake server follows the real `wtdcmds` replies; check them against the real Libreflow when one is available. @@ -96,6 +96,14 @@ that contains this repo on `sys.path` and importing the repo folder name (see `Requested`). Not tested: an actual assetize publish (it writes to production). New wtdcmds commands need every Libreflow session restarted (a running one keeps the old code and answers 404). +- Load Publish (2026-10-28) uses only existing commands (`wtd_list_assets`, + `wtd_get_asset_files`, `wtd_get_revisions`): 309 assets in ~0.1 s, live-tested on + `car_paul` (loaded twice: `car_paul_01`, `car_paul_02`). +- `/melvile/films/melvile` has no sequences or shots in Libreflow yet; `melvile_test` has 9. +- Deployed Libreflow (Overseer launcher): wtdcmds must be an "extension" entry, so the + launcher adds `libreflow.extensions.wtdcmds:install_extensions` to + `KABARET_FLOW_EXT_INSTALLERS`; the package name must stay `libreflow.extensions.wtdcmds`. + The launcher only rebuilds its env when the project config changes: "Force update". ## Open points diff --git a/README.md b/README.md index 165211b..b847763 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,7 @@ The first line shows the Libreflow session, the user and the site. When several The field under the connection is the **context**: the Libreflow oid of the shot this file belongs to, like `/melvile/films/f01/sequences/s010/shots/p010`. It is saved in the file and does not change when you browse in Libreflow. - **Libreflow Oid**: takes the oid shown in the Libreflow window. -- **Pick Shot**: search the shots of the project. +- **Pick Shot**: search the shots of the project (every film). Only shots created in Libreflow are listed: a film whose sequences and shots are not in Libreflow yet shows none. A valid shot is confirmed under the field (`shot: f01 / s010 / p010`). The casting and the versions are then read on their own. @@ -360,6 +360,7 @@ Lists the assets cast in the shot, with how many instances are loaded out of how - **Collection:** from that file, `COL_`, else ``, else `environments` (set dressing files), else the file's only top-level collection. - **Characters, props and vehicles** (`*_character`, `main_props`, `secondary_props`, `vehicles`) become **library overrides**, so animators can pose them. Other types, like sets and set props, become **collection instances**. - **Where they go:** each asset type gets its own collection, `COL_main_character`, `COL_main_props`, `COL_bg_location`… +- **Numbering:** every instance gets a number: the first one loaded is `COL_bob_01` (an override, named after the asset collection) or `tree_01` (a collection instance, named after the asset), the next `_02`, and so on. Numbers go on from the highest one in the file, so removing `_01` does not reuse it while `_02` exists. - **Level of detail:** when the asset collection holds LOD collections (`COL__LODhigh`, `COL__LOD0`…), only the first one is shown. See [Levels of detail](#levels-of-detail). All of these can be changed in the [Configuration](#configuration). @@ -368,6 +369,8 @@ All of these can be changed in the [Configuration](#configuration). Lists everything the Scene Manager loaded, one line per asset, with its version: +- **Load Publish...**: load any published file of the project, cast in the shot or not (see [Load any publish](#load-any-publish)). + - The version is **red** when it is not the latest publish. The line under it says either "v004 available" (click the update icon), or "v004 published, not available on this site yet" (click the globe to request it, then "v004 requested, waiting for the download"). - **Arrow**: shows the asset's instances, each with a button to select its objects and one to remove it. Under an instance with levels of detail, one button per LOD (`LOD high mid low`), the shown one pressed. - **Menu icon**: pick another published version, for example to roll back. Versions not on this site are marked: picking one requests it; pick it again once it is downloaded. @@ -379,6 +382,22 @@ Lists everything the Scene Manager loaded, one line per asset, with its version: Updating swaps the linked file and reloads it. Overrides keep their posing and animation, and instances keep their LOD (an instance showing a LOD the new version no longer has goes back to the first one). **Ctrl+Z** undoes removing, updating and LOD switches. +### Load any publish + +**Load Publish...** (top of the *Loaded* sub-panel) searches every asset of the project: type a few letters of its name, pick it. A dialog then shows: + +| Setting | Use | +|---|---| +| **File** | The asset's published `.blend` files (`task/file.blend`). The file the casting would load is picked by default (`rigging/*.blend`, then `modeling/*.blend`). | +| **Version** | All its published versions, newest first, with date and author. The latest version on this site is picked. Versions not on this site are marked. | +| **Instances** | How many instances to add (numbered `_01`, `_02`...). | + +**Load** links the file and adds the instances, like a cast asset (same collection, override or instance by asset type, LODs). The asset then appears in *Loaded*, with its version followed and updatable. + +- A version not on this site is **requested** instead of loaded: load it again once Libreflow has downloaded it. +- A file already loaded at another version is refused: switch it with the version picker of *Loaded* first. At the same version, Load just adds instances. +- It works without a shot context: the project is taken from the oid open in Libreflow. + ### Levels of detail Rigs and models can hold their levels of detail as **child collections of the asset collection**, named `COL__LOD`. The name can be anything: `high`, `mid`, `low`, or numbers `0`, `1`, `2`. One LOD is shown at a time: @@ -456,12 +475,15 @@ Name formats use Python's `str.format`: `"sq{:04d}"` turns `10` into `sq0010`. ` | Sanity Check: panel | `CATEGORY_ORDER`, `MAX_ISSUES_DRAWN` | | Rename | `RENAME_SEPARATOR`, `RENAME_PADDING`, `RENAME_SIDES`, `RENAME_TYPE_SUFFIXES` (plus `OBJECT_PREFIXES` / `DATABLOCK_PREFIXES` for *By Type* prefixes) | | Shot Manager | `SHOT_VIEWER_RIG_GROUPS`, `SHOT_VIEWER_ASSET_TYPE_GROUPS`, `SHOT_NAME_FORMAT`, `SHOT_NUMBER_STEP`, `SHOT_FRAME_START`, `SHOT_DEFAULT_DURATION`, `SEQUENCE_NAME_FORMAT`, `SHOT_CAMERA_NAME_FORMAT`, `SHOT_CAMERA_RIG_NAME_FORMAT`, `SHOT_CAMERA_RIG_MODES` | -| Scene Manager | `LIBREFLOW_SERVER_FILE`, `LIBREFLOW_TIMEOUT`, `SCENE_MANAGER_FILE_PATTERNS`, `SCENE_MANAGER_COLLECTION_NAMES`, `SCENE_MANAGER_LINK_MODES`, `SCENE_MANAGER_LOD_COLLECTION`, `SCENE_MANAGER_LODS`, `SCENE_MANAGER_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` | +| Scene Manager | `LIBREFLOW_SERVER_FILE`, `LIBREFLOW_TIMEOUT`, `SCENE_MANAGER_FILE_PATTERNS`, `SCENE_MANAGER_COLLECTION_NAMES`, `SCENE_MANAGER_LINK_MODES`, `SCENE_MANAGER_LOD_COLLECTION`, `SCENE_MANAGER_LODS`, `SCENE_MANAGER_INSTANCE_NAME`, `SCENE_MANAGER_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` | --- ## Troubleshooting +**Scene Manager: Pick Shot only lists `melvile_test`.** +It lists the shots that exist in Libreflow. The `melvile` film has no sequences or shots in Libreflow yet (checked 2026-10-28); they appear once they are created there. + **A check says "crashed".** The check hit an unexpected error. Unfold it to read the message, and send it with the file to the pipeline team. The other checks still run. diff --git a/config.py b/config.py index b3d47ae..2304155 100644 --- a/config.py +++ b/config.py @@ -306,6 +306,9 @@ SCENE_MANAGER_LOD_COLLECTION = "COL_{asset}_LOD{lod}" # Order of the LOD names; the first one the file has is shown when an asset is loaded. # LODs not listed come after them: numbers in order (0, 1, 2...), then names A-Z. SCENE_MANAGER_LODS = ["high", "mid", "low"] +# Name of each new instance: {name} is the asset collection (override) or the asset +# (collection instance), {number} counts on from the highest number in the file. +SCENE_MANAGER_INSTANCE_NAME = "{name}_{number:02d}" # Collection gathering the loaded assets of a type, under the scene collection. SCENE_MANAGER_TYPE_COLLECTION = "COL_{asset_type}" # Default task of the assetize dialog. diff --git a/dev/fake_libreflow.py b/dev/fake_libreflow.py index b0d6eed..0875f45 100644 --- a/dev/fake_libreflow.py +++ b/dev/fake_libreflow.py @@ -3,7 +3,8 @@ python dev/fake_libreflow.py Serves a project with one shot casting bob (main_character, x2), tree (bg_set), rock (main_props), -lamp (main_props, nothing published), car (vehicles, v001 not on this site) and ghost (not in the flow). The asset files come +lamp (main_props, nothing published), car (vehicles, v001 not on this site) and ghost (not in the flow); +chair (set_props) is not cast, for Load Publish. The asset files come from dev/make_test_assets.py (/lib). Writes /kabaret/server.json: point config LIBREFLOW_SERVER_FILE to it (see dev/test_scene_manager.py). @@ -36,6 +37,11 @@ ASSETS = { # asset oid -> (type, {file_oid: (label, [revisions oldest first])}) f"{PROJECT}/asset_types/main_props/assets/lamp": ("main_props", {}), f"{PROJECT}/asset_types/vehicles/assets/car": ("vehicles", {f"{PROJECT}/asset_types/vehicles/assets/car/tasks/rigging/files/car_blend": ("rigging/car.blend", [ rev("v001", f"{ROOT}/lib/car/v001/car.blend", available=False)])}), + f"{PROJECT}/asset_types/set_props/assets/chair": ("set_props", { + f"{PROJECT}/asset_types/set_props/assets/chair/tasks/shading/files/chair_shading_blend": ("shading/chair_shading.blend", [ + rev("v001", f"{ROOT}/lib/chair/v001/chair.blend")]), + f"{PROJECT}/asset_types/set_props/assets/chair/tasks/modeling/files/chair_blend": ("modeling/chair.blend", [ + rev("v001", f"{ROOT}/lib/chair/v001/chair.blend"), rev("v002", f"{ROOT}/lib/chair/v001/chair.blend", available=False)])}), } CASTING = [("bob", "main_character", 2), ("tree", "bg_set", 1), ("rock", "main_props", 1), ("lamp", "main_props", 1), ("car", "vehicles", 1), ("ghost", "main_character", 1)] CALLS = [] @@ -100,6 +106,8 @@ def cmd(name, kw): a["error"] = "No published revision available on this site" out.append(a) return {"shot_oid": SHOT, "assets": out} + if name == "wtd_get_asset_files": + return [file_data(f) for f in ASSETS[kw["oid"]][1]] if name == "wtd_get_latest_revisions": res = {} for f in kw["file_oids"]: diff --git a/dev/make_test_assets.py b/dev/make_test_assets.py index 0b6c4c5..26dd654 100644 --- a/dev/make_test_assets.py +++ b/dev/make_test_assets.py @@ -6,6 +6,7 @@ bob: a rig (COL_bob: armature + meshes), v001 has 1 mesh, v002 has 2. tree: COL_tree holding only LOD collections (COL_tree_LOD0, COL_tree_LOD1), plus an unrelated top-level collection. v002 adds a mesh outside the LODs, v003 has no LOD. car: a rig, COL_car: armature + named LODs COL_car_LODlow, _LODhigh, _LODmid. +chair: COL_chair (not cast, loaded with Load Publish). rock: a single top-level collection not named after the asset (fallback case), its mesh using a node group linked from Blender's essentials (an indirect library). """ @@ -67,4 +68,5 @@ write(os.path.join(lib, "tree", "v002", "model.blend"), "COL_tree", meshes=1, ex write(os.path.join(lib, "tree", "v003", "model.blend"), "COL_tree", meshes=1, extra=True) write(os.path.join(lib, "rock", "v001", "rock.blend"), "rock_geo", node_group=True) write(os.path.join(lib, "car", "v001", "car.blend"), "COL_car", meshes=0, rig=True, lods=["low", "high", "mid"]) +write(os.path.join(lib, "chair", "v001", "chair.blend"), "COL_chair") print("Test assets written in", lib) diff --git a/dev/test_scene_manager.py b/dev/test_scene_manager.py index 58cda78..0644a10 100644 --- a/dev/test_scene_manager.py +++ b/dev/test_scene_manager.py @@ -100,14 +100,14 @@ print("Load") bpy.ops.wtd_scene.load() pump() state = loaded() -check("bob: 2 instances of v002", state.get("bob") == ("v002", ["COL_bob", "COL_bob.001"]), str(state.get("bob"))) -check("tree: 1 collection instance", state.get("tree") == ("v001", ["tree"]), str(state.get("tree"))) -check("rock: single top-level collection fallback", state.get("rock") == ("v001", ["rock_geo"])) +check("bob: 2 numbered instances of v002", state.get("bob") == ("v002", ["COL_bob_01", "COL_bob_02"]), str(state.get("bob"))) +check("tree: 1 numbered collection instance", state.get("tree") == ("v001", ["tree_01"]), str(state.get("tree"))) +check("rock: single top-level collection fallback", state.get("rock") == ("v001", ["rock_geo_01"])) check("lamp and ghost reported", "lamp" in message() and "ghost" in message(), message()) check("type collections", {"COL_main_character", "COL_bg_set", "COL_main_props"} <= set(scene.collection.children.keys())) check( "chars are posable overrides", - all(ob.override_library for ob in bpy.data.collections["COL_bob"].all_objects), + all(ob.override_library for ob in bpy.data.collections["COL_bob_01"].all_objects), ) check("shot manager groups bob as character", "Characters" in shot_viewer.rig_groups(bpy.context.view_layer)) bpy.ops.wtd_scene.load() @@ -149,7 +149,7 @@ for ob in bpy.context.view_layer.objects: ob.select_set(ob in (tree, bpy.data.objects["RIG_COL_car"])) check("LOD of selected", bpy.ops.wtd_scene.set_lod(lod="1", selected=True) == {"FINISHED"}) check("only the selected instances having it switched", (lib.current_lod(tree_lib, tree), lib.current_lod(car_lib, car)) == ("1", "low")) -check("missing LOD refused", bpy.ops.wtd_scene.set_lod(lod="5", library="tree", instance="tree") == {"CANCELLED"}) +check("missing LOD refused", bpy.ops.wtd_scene.set_lod(lod="5", library="tree", instance="tree_01") == {"CANCELLED"}) check("LOD order: list, numbers, names", sorted(["b", "10", "low", "2", "a", "high"], key=lib.lod_order) == ["high", "low", "2", "10", "a", "b"]) second = lib.add_instance(scene, bpy.context.view_layer, car_lib) check( @@ -161,7 +161,7 @@ lib.remove_instance(second) lib.update("tree", {"name": "v002", "path": os.path.join(WORKDIR, "lib", "tree", "v002", "model.blend")}) tree = lib.instances(bpy.data.libraries["tree"])[0] check("LOD kept by an update", lib.current_lod(bpy.data.libraries["tree"], tree) == "1") -check("content outside the LODs: instance refused", bpy.ops.wtd_scene.set_lod(lod="0", library="tree", instance="tree") == {"CANCELLED"}) +check("content outside the LODs: instance refused", bpy.ops.wtd_scene.set_lod(lod="0", library="tree", instance="tree_01") == {"CANCELLED"}) lib.update("tree", {"name": "v003", "path": os.path.join(WORKDIR, "lib", "tree", "v003", "model.blend")}) tree = lib.instances(bpy.data.libraries["tree"])[0] check("LODs gone: whole collection instanced", tree.instance_collection.name == "COL_tree" and not lib.lods(bpy.data.libraries["tree"], tree)) @@ -207,11 +207,11 @@ check("picking a version not on this site requests it", message() == "Requested check("still on v002", loaded()["bob"][0] == "v002") print("Remove") -bpy.ops.wtd_scene.remove(instance="COL_bob.001") -check("one instance removed", loaded()["bob"][1] == ["COL_bob"]) +bpy.ops.wtd_scene.remove(instance="COL_bob_02") +check("one instance removed", loaded()["bob"][1] == ["COL_bob_01"]) for ob in bpy.context.view_layer.objects: ob.select_set(False) -bpy.data.objects["tree"].select_set(True) +bpy.data.objects["tree_01"].select_set(True) bpy.ops.wtd_scene.remove(selected=True) check("selected instance removed", loaded()["tree"][1] == []) bpy.ops.wtd_scene.remove("EXEC_DEFAULT", library="rock") @@ -220,6 +220,35 @@ check("its indirect library removed too", set(bpy.data.libraries.keys()) == {"bo bpy.ops.wtd_scene.load() pump() check("load restores what is missing", {k: len(v[1]) for k, v in loaded().items()} == {"bob": 2, "car": 1, "rock": 1, "tree": 1}) +check("numbers go on after the highest one", loaded()["bob"][1] == ["COL_bob_01", "COL_bob_02"], str(loaded()["bob"][1])) + +print("Load publish") +CHAIR = "/melvile/asset_types/set_props/assets/chair" +check("project of the context", sm.project_oid(scene) == "/melvile") +sm.STATE.assets = ipc.call("wtd_list_assets", None, oid=sm.project_oid(scene)) +check("any asset listed, cast or not", CHAIR in [a["oid"] for a in sm.STATE.assets]) +sm.browse_asset(next(a for a in sm.STATE.assets if a["oid"] == CHAIR)) +chair_file = sm.default_file("set_props", sm.STATE.browse["files"]) +check("default file follows the file patterns", chair_file["label"] == "modeling/chair.blend", chair_file["label"]) +check("versions newest first", [r["name"] for r in chair_file["revisions"]] == ["v002", "v001"]) +check("default version: latest on this site", sm.default_revision(chair_file)["name"] == "v001") +result = bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=chair_file["file_oid"], revision="v002") +pump() +check( + "version not on this site: requested, not loaded", + result == {"CANCELLED"} and "chair" not in loaded() + and ["wtd_request_revisions", {"revision_oids": [chair_file["file_oid"] + "/history/revisions/v002"]}] in fake("/_test/calls", {}), +) +bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=chair_file["file_oid"], revision="v001", count=2) +check("loaded twice, numbered", loaded().get("chair") == ("v001", ["chair_01", "chair_02"]), str(loaded().get("chair"))) +check("its versions followed in Loaded", sm.version_status(bpy.data.libraries["chair"])[0] == "PENDING") +sm.browse_asset(next(a for a in sm.STATE.assets if a["asset"] == "bob")) +check( + "another version of a loaded file refused", + bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=BOB_FILE, revision="v004") == {"CANCELLED"} and loaded()["bob"][0] == "v002", +) +bpy.ops.wtd_scene.load_publish("EXEC_DEFAULT", file=BOB_FILE, revision="v002") +check("same version: one more numbered instance", loaded()["bob"][1] == ["COL_bob_01", "COL_bob_02", "COL_bob_03"], str(loaded()["bob"][1])) print("Ambiguous file") try: @@ -243,7 +272,7 @@ bpy.ops.wtd_scene.assetize( ) pump() check("published and replaced", "replaced" in message(), message()) -check("lamp linked", loaded().get("lamp") == ("v001", ["COL_lamp"]), str(loaded().get("lamp"))) +check("lamp linked", loaded().get("lamp") == ("v001", ["COL_lamp_01"]), str(loaded().get("lamp"))) check("local collection kept, renamed", "COL_lamp_local" in bpy.data.collections) bad = bpy.data.collections.new("COL_bad") scene.collection.children.link(bad) @@ -256,7 +285,7 @@ path = os.path.join(WORKDIR, "scene_manager_test.blend") bpy.ops.wm.save_as_mainfile(filepath=path) bpy.ops.wm.open_mainfile(filepath=path) sm._on_load_post() -check("everything found after reopening", set(loaded()) == {"bob", "car", "lamp", "rock", "tree"}) +check("everything found after reopening", set(loaded()) == {"bob", "car", "chair", "lamp", "rock", "tree"}) car_lib = bpy.data.libraries["car"] check("LOD kept after reopening", lib.current_lod(car_lib, lib.instances(car_lib)[0]) == "low") sm.connect(bpy.context.scene) diff --git a/scene_manager/library.py b/scene_manager/library.py index fa4565a..9be68c0 100644 --- a/scene_manager/library.py +++ b/scene_manager/library.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/scene_manager/operators.py b/scene_manager/operators.py index 5d89ab4..0972bec 100644 --- a/scene_manager/operators.py +++ b/scene_manager/operators.py @@ -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, diff --git a/ui/panel.py b/ui/panel.py index 3827b28..8905b59 100644 --- a/ui/panel.py +++ b/ui/panel.py @@ -439,6 +439,7 @@ class _SceneLoadedPanel: def draw(self, context) -> None: layout = self.layout + layout.operator(sm.WTD_OT_scene_browse.bl_idname, text="Load Publish...", icon="VIEWZOOM") libraries = sm_library.managed_libraries() if not libraries: layout.label(text="Nothing loaded by the Scene Manager", icon="INFO")