Compare commits
4
Commits
master
...
scene-manager
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fe38ce58a | ||
|
|
9ee7710479 | ||
|
|
017bdfc2fd | ||
|
|
c238f4db7c |
No files matched your search
@@ -0,0 +1,99 @@
|
||||
# Melvile Tools: notes for Claude
|
||||
|
||||
Blender add-on (Blender 5.2) for the Melvile pipeline at Walking The Dog: Sanity Check,
|
||||
Rename, Shot Viewer and Scene Manager panels in the 3D View sidebar (WTD tab). `README.md`
|
||||
is the user guide: keep it up to date with every user-visible change.
|
||||
|
||||
## Layout and conventions
|
||||
|
||||
- `config.py`: every production value (prefixes, formats, patterns, modes). Values are
|
||||
read at run time (`config.X`, never copied at import) because a studio JSON file can
|
||||
override them (add-on preferences "Config Override", or `WTD_SANITY_CONFIG`).
|
||||
New tunable values go here, with a comment, and in the README's Configuration table.
|
||||
- `ui/panel.py`: all panels. Operators live with their tool (`rename.py`,
|
||||
`shot_viewer.py`, `scene_manager/`, `ui/operators.py` for the Sanity Check).
|
||||
- `checks/`: sanity checks, auto-registered (see README > For developers).
|
||||
- `__init__.py` deletes cached submodules on reload, so Reload Scripts picks up every file.
|
||||
- Versions: `blender_manifest.toml` and `bl_info` in `__init__.py` (keep both in sync).
|
||||
- Style: match the surrounding code; short docstrings, few comments, no heavy abstractions.
|
||||
|
||||
## Working with the user
|
||||
|
||||
- Commits: when asked, create a branch from `master`, commit there (end with the
|
||||
Co-Authored-By line), and give the user the merge / push / branch-delete commands:
|
||||
they run them themselves. Only commit straight on `master` when told to.
|
||||
- Real Blender API facts beat memory: probe in headless Blender before relying on an API.
|
||||
|
||||
## Testing
|
||||
|
||||
Blender binary: macOS `/Applications/Blender.app/Contents/MacOS/Blender`, Windows
|
||||
`blender.exe` from the install folder. Import the add-on in tests by putting the folder
|
||||
that contains this repo on `sys.path` and importing the repo folder name (see
|
||||
`dev/test_scene_manager.py`).
|
||||
|
||||
- Headless: `blender -b --factory-startup --python-exit-code 1 --python <script> -- <args>`.
|
||||
Timers do not run in background mode: pump background jobs by hand
|
||||
(`scene_manager.ipc._poll()`).
|
||||
- UI: run Blender with a window (`--factory-startup <file.blend> --python <script>`, a
|
||||
.blend argument avoids the splash), drive it with `bpy.app.timers`, take screenshots with
|
||||
`bpy.ops.screen.screenshot_area`, quit with `bpy.ops.wm.quit_blender()`. A sidebar tab
|
||||
(`region.active_panel_category`) can only be set once the region has been drawn.
|
||||
- Scene Manager without Libreflow (`dev/`):
|
||||
1. `blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>`
|
||||
2. `python dev/fake_libreflow.py <workdir>` (keep running; restart for a clean state)
|
||||
3. `blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>`
|
||||
(45 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.
|
||||
|
||||
## External pieces
|
||||
|
||||
- Libreflow `wtdcmds` extension (commands used by the Scene Manager), on the share:
|
||||
`WG/_Dev/GIT/ndemaerschalk/_MELVILE/libreflow_dev_utils/dev/src/wtdcmds/`
|
||||
(`src/libreflow/extensions/wtdcmds/wtd_cmds.py`, `BLENDER_ADDON_BRIEF.md`).
|
||||
Server address/token: `~/.kabaret/ipc/server.json` (config `LIBREFLOW_SERVER_FILE`).
|
||||
- Add Camera Rigs extension: `bpy.ops.object.build_camera_rig(mode=...)` builds the
|
||||
Shot Viewer cameras. It finds its rigs by the `rig_id` custom property, not by name.
|
||||
- `tools_ops.py` "Get Oid" uses the separate kabaret Blender client (`bpy.ops.kabaret.cmds_call`).
|
||||
|
||||
## Design notes (found by testing)
|
||||
|
||||
- Shot Viewer: shots are timeline markers bound to cameras, in one scene. Setting
|
||||
`scene.frame_current` does not switch the camera (only `frame_set` does): set
|
||||
`scene.camera` explicitly.
|
||||
- Scene Manager: tags live on `bpy.types.Library` only. Library overrides lose custom
|
||||
properties when they resync, so instances are traced back to their library (empty →
|
||||
`instance_collection.library`, override → `override_library.reference.library`).
|
||||
- `library.reload()` invalidates every Python reference to linked and override data:
|
||||
look IDs up again afterwards. In Blender 5.2 it also replaces the Library itself (renamed
|
||||
after its file, custom properties gone): `library.update()` finds it by path and restores
|
||||
the name and tags.
|
||||
- Removing a library leaves the libraries it linked indirectly (e.g. Blender's
|
||||
`geometry_nodes_essentials.blend`): `remove_unused_libraries()` drops the ones whose data
|
||||
is no longer used, never a managed library.
|
||||
- Background requests (`scene_manager.ipc.run`) hand results back through a timer;
|
||||
in callbacks, find scenes by name (`bpy.context.scene` is not reliable in timers).
|
||||
|
||||
## Real Libreflow (first run, 2026-10-27)
|
||||
|
||||
- Kitsu asset types: `main_character`, `secondary_character`, `tertiary_character`,
|
||||
`main_props`, `secondary_props`, `set_props`, `vehicles`, `bg_location`, `bg_set`,
|
||||
`skies`, `setdress_bank`. The fake server uses the same names.
|
||||
- Paths come back as Windows paths with backslashes; `comment` can be `null`.
|
||||
- Set dressing publishes (`setdress_low`) hold a whole layout scene: the set is the
|
||||
`environments` collection, next to `lights`, `cameras`, `characters`, `_unused`.
|
||||
- Tested live: connection, context, casting, load (instance and override), version switch
|
||||
and update, remove, assetize errors, one request (`car_paul` modeling.blend v001 →
|
||||
`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).
|
||||
|
||||
## Open points
|
||||
|
||||
- Override vs instance per asset type (`SCENE_MANAGER_LINK_MODES`), and the file patterns
|
||||
of sets (`setdress_low` seen on one asset only): confirm with production.
|
||||
- The server picks the first file matching a pattern even when it is not on this site,
|
||||
while another matching file is (e.g. `car_paul`: `modeling/modeling.blend` v001 not
|
||||
available, `modeling/tes_modeling.blend` v002 available).
|
||||
- Values marked TODO in `config.py` wait for production specs.
|
||||
@@ -5,6 +5,7 @@ Blender add-on for the Melvile pipeline (Walking The Dog). It brings together:
|
||||
- **[Sanity Check](#sanity-check)**: checks a file against the production specs and fixes what can be fixed.
|
||||
- **[Rename](#rename)**: batch renames objects and materials to the naming convention, with consistent numbering.
|
||||
- **[Shot Viewer](#shot-viewer)**: switch, create and delete the shots of a sequence (camera markers) from the sidebar, with quick access to the character and prop rigs.
|
||||
- **[Scene Manager](#scene-manager)**: loads the assets cast in a shot from Libreflow, keeps them up to date, and publishes scene collections as new assets.
|
||||
- **[Get Oid](#get-oid-for-open-scene)**: asks Libreflow for the id of the open file.
|
||||
|
||||
Most values the tools rely on (prefixes, shot naming, frame rate…) live in `config.py` and can be overridden per studio without touching the code. See [Configuration](#configuration).
|
||||
@@ -19,6 +20,7 @@ Most values the tools rely on (prefixes, shot naming, frame rate…) live in `co
|
||||
- [Tools panel](#tools-panel)
|
||||
- [Rename](#rename)
|
||||
- [Shot Viewer](#shot-viewer)
|
||||
- [Scene Manager](#scene-manager)
|
||||
- [Configuration](#configuration)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [For developers](#for-developers)
|
||||
@@ -31,6 +33,8 @@ Tested on **Blender 5.2**.
|
||||
|
||||
The Shot Viewer builds its cameras with the **Add Camera Rigs** extension (*Edit > Preferences > Get Extensions*, search "Add Camera Rigs"). Install and enable it next to Melvile Tools.
|
||||
|
||||
The Scene Manager talks to a **running Libreflow** with the `wtdcmds` extension, on the same machine.
|
||||
|
||||
**As an extension (artists):** build the package, then in Blender go to *Edit > Preferences > Get Extensions > Install from Disk…* and pick the `.zip`.
|
||||
|
||||
```bash
|
||||
@@ -60,6 +64,7 @@ Open the sidebar of the 3D Viewport (**N**) and click the **WTD** tab.
|
||||
| **Tools** (Get Oid) | 3D Viewport |
|
||||
| **Rename** | 3D Viewport, Shader Editor |
|
||||
| **Shot Viewer** | 3D Viewport |
|
||||
| **Scene Manager** (Casting, Loaded, Assetize) | 3D Viewport |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,7 +312,90 @@ The bottom of the panel lists the armatures of the scene, grouped into **Charact
|
||||
- **Shift+click**: adds it to the selection, to pose several rigs at once.
|
||||
- The rig(s) currently selected are highlighted.
|
||||
|
||||
A rig counts as a character or a prop when its name, one of its collections, or the library file it comes from starts with `chr_` / `prp_` (see `SHOT_VIEWER_RIG_GROUPS` in [Configuration](#configuration)). Hidden rigs cannot be selected. Camera rigs (armatures carrying a camera) are not listed.
|
||||
A rig loaded by the [Scene Manager](#scene-manager) is grouped by its Libreflow asset type (`main_character`, `secondary_character`, `tertiary_character` → Characters; `main_props`, `secondary_props`, `vehicles` → Props; see `SHOT_VIEWER_ASSET_TYPE_GROUPS`). Otherwise, a rig counts as a character or a prop when its name, one of its collections, or the library file it comes from starts with `chr_` / `prp_` (see `SHOT_VIEWER_RIG_GROUPS` in [Configuration](#configuration)). Hidden rigs cannot be selected. Camera rigs (armatures carrying a camera) are not listed.
|
||||
|
||||
---
|
||||
|
||||
## Scene Manager
|
||||
|
||||
Manages the assets of a shot by talking to the running Libreflow: it reads the shot's casting (from Kitsu), links the published files of the cast assets, flags the ones that are out of date, and publishes local collections as new assets.
|
||||
|
||||
Everything goes through Libreflow: Blender never touches Kitsu or the Libreflow database. Requests run in the background, so Blender stays usable while Libreflow answers. What is in progress shows at the bottom of the main panel, with the last result (or error) under it.
|
||||
|
||||
### Connecting
|
||||
|
||||
Start Libreflow, then open *WTD tab > **Scene Manager***. Blender connects on its own when it starts and when a file is opened. Click the refresh icon on the first line to connect again, for example after starting Libreflow.
|
||||
|
||||
The first line shows the Libreflow session, the user and the site. When several Libreflow sessions are open, they are listed: click the one to work with.
|
||||
|
||||
### The shot (context)
|
||||
|
||||
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.
|
||||
|
||||
A valid shot is confirmed under the field (`shot: f01 / s010 / p010`). The casting and the versions are then read on their own.
|
||||
|
||||
### Casting
|
||||
|
||||
Lists the assets cast in the shot, with how many instances are loaded out of how many are cast (`2/2`):
|
||||
|
||||
| Icon | Meaning |
|
||||
|---|---|
|
||||
| ✔ | Loaded. |
|
||||
| Import arrow | Not loaded (or fewer instances than cast): click it to load this asset only. |
|
||||
| ⚠ red | Cannot be loaded, with the reason from Libreflow: not found in the flow, no published file, or nothing available on this site yet. |
|
||||
| Globe | The latest publish is not on this site: click it to request it (see below). |
|
||||
| Hourglass | Requested, waiting for the download. |
|
||||
|
||||
- **Load Missing** links every cast asset that is not loaded yet, and adds the missing instances. Running it again only adds what is missing.
|
||||
- **Request Missing** (shown when needed) asks Libreflow to download, on this site, the latest publish of every cast or loaded asset that is not here yet: the same as **Request** in Libreflow (from the exchange site, or from the site that published it). The download is done by the Libreflow sync: click **Refresh** later, then load or update.
|
||||
- **Refresh** reads the casting and the versions again.
|
||||
- **Loaded, no longer cast** lists assets loaded earlier that the shot's casting no longer contains.
|
||||
|
||||
**What gets loaded:**
|
||||
|
||||
- **File:** the latest publish **available on this site** of the asset's first file matching `rigging/*.blend`, then `modeling/*.blend` (sets, `bg_location`: `setdress_low/*.blend`).
|
||||
- **Collection:** from that file, `COL_<asset>`, else `<asset>`, 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`…
|
||||
|
||||
All of these can be changed in the [Configuration](#configuration).
|
||||
|
||||
### Loaded
|
||||
|
||||
Lists everything the Scene Manager loaded, one line per asset, with its version:
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
- **✕**: removes the asset entirely: all its instances and the link to its file.
|
||||
- **Update All** / **Update Selected**: switch all the loaded assets (or the ones of the selected objects) to their latest available publish.
|
||||
- **Remove Selected**: removes the instances of the selected objects.
|
||||
- The magnifier re-checks the versions.
|
||||
|
||||
Updating swaps the linked file and reloads it. Overrides keep their posing and animation. **Ctrl+Z** undoes removing and updating.
|
||||
|
||||
### Assetize
|
||||
|
||||
Publishes a collection of the scene to Libreflow as its own `.blend`: a new asset, or a new version of an existing one.
|
||||
|
||||
Click **Assetize Collection...** (in the *Assetize* sub-panel):
|
||||
|
||||
| Setting | Use |
|
||||
|---|---|
|
||||
| **Collection** | Local collections only. Collections holding loaded assets are not offered. The active collection of the Outliner is picked by default. |
|
||||
| **Asset Type** | The Libreflow asset type (props, sets...). |
|
||||
| **Asset Name** | Letters, digits and underscores. Taken from the collection name, without its prefix (`COL_street_lamp` → `street_lamp`). The dialog says whether this makes a **new asset** or a **new version** of an existing one. |
|
||||
| **Asset Library** | Optional: publish under that asset library. |
|
||||
| **Task** | The asset task to publish in (default `modeling`). If it does not exist, the error lists the asset's tasks. |
|
||||
| **File Name** | Optional, defaults to the asset name. |
|
||||
| **Comment** | Saved with the revision. |
|
||||
| **Upload** | Also sends the revision to the exchange site. |
|
||||
| **Replace with the Publish** | On by default: once published, the local collection is replaced by a link to the published file, so the Scene Manager manages it like any other asset. The local collection stays in the file, unlinked, renamed `<name>_local`. |
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
@@ -346,7 +434,8 @@ Name formats use Python's `str.format`: `"sq{:04d}"` turns `10` into `sq0010`. `
|
||||
| Sanity Check: layout | `SET_COLLECTION_PATTERN` |
|
||||
| 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 Viewer | `SHOT_VIEWER_RIG_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` |
|
||||
| Shot Viewer | `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_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` |
|
||||
|
||||
---
|
||||
|
||||
@@ -370,6 +459,18 @@ Only armatures in the scene's view layer are listed. A collection instance that
|
||||
**Shot Viewer: a shot is missing from the list.**
|
||||
Only markers bound to a camera are shots. Select the marker, select its camera, and press **Ctrl+B** in the Timeline.
|
||||
|
||||
**Scene Manager: "Libreflow is not running".**
|
||||
Start Libreflow (with the `wtdcmds` extension), then click the refresh icon of the Scene Manager. Its IPC server stops 30 seconds after the last Libreflow session closes.
|
||||
|
||||
**Scene Manager: an asset cannot be loaded.**
|
||||
Its red line says why. "No file matching..." means none of its files matches `SCENE_MANAGER_FILE_PATTERNS`. "No collection named..." means the file has none of `COL_<asset>`, `<asset>` or `environments`, and several top-level collections: rename the published collection, or add its name to `SCENE_MANAGER_COLLECTION_NAMES`.
|
||||
|
||||
**Scene Manager: the version stays red after Update.**
|
||||
The newer version is published but not downloaded on this site yet. Request it (globe icon) if it is not requested yet, wait for the sync, then refresh and update again.
|
||||
|
||||
**Scene Manager: "...its wtdcmds extension is too old".**
|
||||
Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update the extension and restart Libreflow.
|
||||
|
||||
**Shot Viewer: playback only plays one shot.**
|
||||
**Loop Shot** is on. Untick it to play the whole sequence.
|
||||
|
||||
@@ -390,6 +491,7 @@ Only markers bound to a camera are shots. Select the marker, select its camera,
|
||||
| `rename.py` | Rename tool (logic, settings, operator) |
|
||||
| `shot_viewer.py` | Shot Viewer (logic, settings, operators) |
|
||||
| `tools_ops.py` | Libreflow tools (Get Oid) |
|
||||
| `scene_manager/` | Scene Manager: `ipc.py` (Libreflow client and background jobs), `library.py` (linking, overrides, updates), `operators.py` |
|
||||
|
||||
### Adding a check
|
||||
|
||||
|
||||
+7
-1
@@ -8,7 +8,7 @@ for _name in [name for name in sys.modules if name.startswith(__name__ + ".")]:
|
||||
import bpy # noqa: E402
|
||||
from bpy.app.handlers import persistent # noqa: E402
|
||||
|
||||
from . import checks, core, rename, shot_viewer, tools_ops, ui # noqa: E402
|
||||
from . import checks, core, rename, scene_manager, shot_viewer, tools_ops, ui # noqa: E402
|
||||
from .core import runner # noqa: E402
|
||||
|
||||
api = core
|
||||
@@ -49,6 +49,9 @@ def register() -> None:
|
||||
# Register the shot viewer (settings and operators, before its panel)
|
||||
shot_viewer.register()
|
||||
|
||||
# Register the scene manager (settings and operators, before its panels)
|
||||
scene_manager.register()
|
||||
|
||||
# Load all the sanity checks into the sanity REGISTRY
|
||||
checks.load_all()
|
||||
|
||||
@@ -80,6 +83,9 @@ def unregister() -> None:
|
||||
# Unregister the shot viewer (after its panel)
|
||||
shot_viewer.unregister()
|
||||
|
||||
# Unregister the scene manager (after its panels)
|
||||
scene_manager.unregister()
|
||||
|
||||
runner.clear()
|
||||
# The check registry is plain Python and is rebuilt with the modules on
|
||||
# re-import, so it is kept as is (enable/disable without re-import).
|
||||
@@ -16,12 +16,14 @@ copyright = ["2026 WTD"]
|
||||
|
||||
[permissions]
|
||||
files = "Read reference, config and sound files"
|
||||
network = "Talk to the local Libreflow server (Scene Manager)"
|
||||
|
||||
[build]
|
||||
paths_exclude_pattern = [
|
||||
"__pycache__/",
|
||||
"/.git/",
|
||||
"docs/",
|
||||
"dev/",
|
||||
".*",
|
||||
"*.blend",
|
||||
"*.md",
|
||||
|
||||
@@ -243,6 +243,15 @@ SHOT_VIEWER_RIG_GROUPS = {
|
||||
"Characters": r"(?i)^(?:chr|char)[_-]",
|
||||
"Props": r"(?i)^(?:prp|prop)[_-]",
|
||||
}
|
||||
# Rigs loaded by the Scene Manager are grouped by their Libreflow asset type first.
|
||||
SHOT_VIEWER_ASSET_TYPE_GROUPS = {
|
||||
"main_character": "Characters",
|
||||
"secondary_character": "Characters",
|
||||
"tertiary_character": "Characters",
|
||||
"main_props": "Props",
|
||||
"secondary_props": "Props",
|
||||
"vehicles": "Props",
|
||||
}
|
||||
|
||||
# Shots added by the shot viewer. TODO: confirm with production.
|
||||
SHOT_NAME_FORMAT = "sh{:04d}"
|
||||
@@ -259,6 +268,41 @@ SHOT_NUMBER_STEP = 10
|
||||
SHOT_FRAME_START = 1001
|
||||
SHOT_DEFAULT_DURATION = 48
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scene Manager (Libreflow)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Address and token of the Libreflow IPC server (kabaret.ipc).
|
||||
LIBREFLOW_SERVER_FILE = "~/.kabaret/ipc/server.json"
|
||||
# Seconds to wait for an answer (the server itself gives up after 10 s).
|
||||
LIBREFLOW_TIMEOUT = 15
|
||||
|
||||
# Published file loaded for each asset type ("*" = the others): ordered
|
||||
# "task/file.ext" glob patterns, the first file of the asset matching one is loaded.
|
||||
# TODO: confirm the set publishes (only setdress_low seen so far).
|
||||
SCENE_MANAGER_FILE_PATTERNS = {
|
||||
"*": ["rigging/*.blend", "modeling/*.blend"],
|
||||
"bg_location": ["setdress_low/*.blend"],
|
||||
}
|
||||
# Collection linked from an asset file: the first of these names found ({asset}
|
||||
# = asset name), else the file's single top-level collection. "environments"
|
||||
# holds the set in the setdress files.
|
||||
SCENE_MANAGER_COLLECTION_NAMES = ["COL_{asset}", "{asset}", "environments"]
|
||||
# How an asset type (Kitsu name) is brought in: "OVERRIDE" (library override, posable)
|
||||
# or "INSTANCE" (collection instance). "*" = the other types. TODO: confirm with production.
|
||||
SCENE_MANAGER_LINK_MODES = {
|
||||
"main_character": "OVERRIDE",
|
||||
"secondary_character": "OVERRIDE",
|
||||
"tertiary_character": "OVERRIDE",
|
||||
"main_props": "OVERRIDE",
|
||||
"secondary_props": "OVERRIDE",
|
||||
"vehicles": "OVERRIDE",
|
||||
"*": "INSTANCE",
|
||||
}
|
||||
# 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.
|
||||
SCENE_MANAGER_ASSETIZE_TASK = "modeling"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UI
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Fake Libreflow IPC server (kabaret.ipc + wtdcmds commands) to test the Scene Manager.
|
||||
|
||||
python dev/fake_libreflow.py <workdir>
|
||||
|
||||
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
|
||||
from dev/make_test_assets.py (<workdir>/lib). Writes <workdir>/kabaret/server.json:
|
||||
point config LIBREFLOW_SERVER_FILE to it (see dev/test_scene_manager.py).
|
||||
|
||||
Test-only endpoints: POST /_test/publish {file_oid, name, available, path},
|
||||
POST /_test/download {revision_oid} (a requested revision reaches this site),
|
||||
POST /_test/sessions {uid: session}, POST /_test/calls (commands received).
|
||||
State lives in memory: restart the server for a clean state.
|
||||
"""
|
||||
import json, os, shutil, sys, time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
ROOT = sys.argv[1]
|
||||
TOKEN = "secret-token"
|
||||
PROJECT = "/melvile"
|
||||
SHOT = "/melvile/films/f01/sequences/s010/shots/p010"
|
||||
SESSIONS = {"uid-1": {"session_name": "libreflow_dev", "cluster_name": "melvile", "is_gui": True, "current_oid": SHOT}}
|
||||
|
||||
def rev(name, path, available=True, comment=""):
|
||||
return {"name": name, "oid": f"x/history/revisions/{name}", "path": path, "sync_status": "Available" if available else "NotAvailable",
|
||||
"available": available, "date": time.time(), "user": "nero", "comment": comment}
|
||||
|
||||
ASSETS = { # asset oid -> (type, {file_oid: (label, [revisions oldest first])})
|
||||
f"{PROJECT}/asset_types/main_character/assets/bob": ("main_character", {f"{PROJECT}/asset_types/main_character/assets/bob/tasks/rigging/files/rig_blend": ("rigging/rig.blend", [
|
||||
rev("v001", f"{ROOT}/lib/bob/v001/rig.blend"), rev("v002", f"{ROOT}/lib/bob/v002/rig.blend", comment="two meshes")])}),
|
||||
f"{PROJECT}/asset_types/bg_set/assets/tree": ("bg_set", {f"{PROJECT}/asset_types/bg_set/assets/tree/tasks/modeling/files/model_blend": ("modeling/model.blend", [
|
||||
rev("v001", f"{ROOT}/lib/tree/v001/model.blend")])}),
|
||||
f"{PROJECT}/asset_types/main_props/assets/rock": ("main_props", {f"{PROJECT}/asset_types/main_props/assets/rock/tasks/modeling/files/rock_blend": ("modeling/rock.blend", [
|
||||
rev("v001", f"{ROOT}/lib/rock/v001/rock.blend")])}),
|
||||
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)])}),
|
||||
}
|
||||
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 = []
|
||||
|
||||
def set_oids():
|
||||
for _a, (_t, files) in ASSETS.items():
|
||||
for f, (_l, revs) in files.items():
|
||||
for r in revs: r["oid"] = f"{f}/history/revisions/{r['name']}"
|
||||
|
||||
def find_rev(oid):
|
||||
return next((r for _a, (_t, files) in ASSETS.items() for _l, revs in files.values() for r in revs if r["oid"] == oid), None)
|
||||
|
||||
set_oids()
|
||||
|
||||
def file_data(file_oid):
|
||||
for asset_oid, (atype, files) in ASSETS.items():
|
||||
if file_oid in files:
|
||||
label, revs = files[file_oid]
|
||||
avail = [r for r in revs if r["available"]]
|
||||
return {"label": label, "file_oid": file_oid, "latest": revs[-1] if revs else None, "latest_available": avail[-1] if avail else None}
|
||||
raise ValueError(f"No file {file_oid}")
|
||||
|
||||
def context(oid):
|
||||
parts = oid.strip("/").split("/")
|
||||
if oid.startswith(PROJECT + "/films"):
|
||||
return {"oid": oid, "kind": "shot", "project": "melvile", "film": parts[2], "film_oid": "/".join([""] + parts[:3]),
|
||||
"sequence": parts[4], "sequence_oid": "/".join([""] + parts[:5]), "shot": parts[6], "shot_oid": SHOT}
|
||||
if "/assets/" in oid:
|
||||
return {"oid": oid, "kind": "asset", "project": "melvile", "asset_type": parts[2], "asset": parts[4]}
|
||||
if oid == PROJECT:
|
||||
return {"oid": oid, "kind": "project", "project": "melvile"}
|
||||
raise ValueError(f"Oid {oid} does not exist")
|
||||
|
||||
def cmd(name, kw):
|
||||
import fnmatch
|
||||
if name == "wtd_ping":
|
||||
return {"version": "0.1.0", **({"project": "melvile", "user": "nero", "site": "lyon", "root": ROOT} if kw.get("oid") else {})}
|
||||
if name == "wtd_get_context":
|
||||
return context(kw["oid"])
|
||||
if name == "wtd_list_shots":
|
||||
return [context(SHOT), context(SHOT.replace("p010", "p020"))]
|
||||
if name == "wtd_list_assets":
|
||||
return [context(oid) for oid in ASSETS]
|
||||
if name == "wtd_get_casting_publishes":
|
||||
if not kw["oid"].startswith(SHOT):
|
||||
raise ValueError("Not a shot")
|
||||
patterns = kw.get("file_patterns") or ["*/*.blend"]
|
||||
out = []
|
||||
for aname, atype, nb in CASTING:
|
||||
oid = next((o for o in ASSETS if o.endswith("/assets/" + aname)), None)
|
||||
a = {"name": aname, "kitsu_id": "k-" + aname, "asset_type": atype, "nb_occurences": nb, "label": "animate", "oid": oid, "candidates": [oid] if oid else [], "file": None, "error": None}
|
||||
if oid is None:
|
||||
a["error"] = "Asset not found in the flow"
|
||||
else:
|
||||
files = ASSETS[oid][1]
|
||||
match = next((f for p in patterns for f, (label, _r) in files.items() if fnmatch.fnmatchcase(label, p)), None)
|
||||
if match is None:
|
||||
a["error"] = "No file matching " + ", ".join(patterns)
|
||||
else:
|
||||
a["file"] = file_data(match)
|
||||
if a["file"]["latest_available"] is None:
|
||||
a["error"] = "No published revision available on this site"
|
||||
out.append(a)
|
||||
return {"shot_oid": SHOT, "assets": out}
|
||||
if name == "wtd_get_latest_revisions":
|
||||
res = {}
|
||||
for f in kw["file_oids"]:
|
||||
try: res[f] = file_data(f)
|
||||
except ValueError as exc: res[f] = {"file_oid": f, "error": str(exc)}
|
||||
return res
|
||||
if name == "wtd_get_revisions":
|
||||
for _a, (_t, files) in ASSETS.items():
|
||||
if kw["file_oid"] in files:
|
||||
return list(reversed(files[kw["file_oid"]][1]))
|
||||
raise ValueError("No file")
|
||||
if name == "wtd_request_revisions":
|
||||
res = {}
|
||||
for oid in kw["revision_oids"]:
|
||||
r = find_rev(oid)
|
||||
if r is None:
|
||||
res[oid] = {"oid": oid, "error": f"Could not find {oid}"}; continue
|
||||
if not r["available"]: r["sync_status"] = "Requested"
|
||||
res[oid] = r
|
||||
return res
|
||||
if name == "wtd_assetize":
|
||||
src = kw["source_path"]
|
||||
if not os.path.isfile(src): raise ValueError(f"Source file {src} does not exist")
|
||||
atype, aname, task = kw["asset_type"], kw["asset_name"], kw["task"]
|
||||
asset_oid = f"{PROJECT}/asset_types/{atype}/assets/{aname}"
|
||||
created = asset_oid not in ASSETS
|
||||
if created and not kw.get("create_asset"):
|
||||
raise ValueError(f"Asset {aname} does not exist (pass create_asset=true to create it)")
|
||||
if task not in ("modeling", "rigging"):
|
||||
raise ValueError(f"Task {task} does not exist on {asset_oid} (available: modeling, rigging)")
|
||||
fname = kw.get("file_name") or aname
|
||||
ASSETS.setdefault(asset_oid, (atype, {}))
|
||||
files = ASSETS[asset_oid][1]
|
||||
file_oid = f"{asset_oid}/tasks/{task}/files/{fname}_blend"
|
||||
label, revs = files.setdefault(file_oid, (f"{task}/{fname}.blend", []))
|
||||
name = f"v{len(revs) + 1:03d}"
|
||||
dest = f"{ROOT}/lib/{aname}/{name}/{fname}.blend"; os.makedirs(os.path.dirname(dest), exist_ok=True); shutil.copy(src, dest)
|
||||
revs.append(rev(name, dest, comment=kw.get("comment", ""))); set_oids()
|
||||
return {"asset_oid": asset_oid, "asset_created": created, **file_data(file_oid)}
|
||||
raise KeyError(name)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
def reply(self, code, body):
|
||||
data = json.dumps(body).encode(); self.send_response(code); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
|
||||
def auth(self):
|
||||
if self.headers.get("Authorization") != f"Bearer {TOKEN}":
|
||||
self.reply(401, {"detail": "Invalid token"}); return False
|
||||
return True
|
||||
def do_GET(self):
|
||||
if not self.auth(): return
|
||||
if self.path == "/sessions": return self.reply(200, SESSIONS)
|
||||
self.reply(404, {"detail": "Not found"})
|
||||
def do_POST(self):
|
||||
if not self.auth(): return
|
||||
url = urlparse(self.path); qs = parse_qs(url.query)
|
||||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
|
||||
if url.path == "/_test/publish": # simulate a new publish
|
||||
f = body["file_oid"]
|
||||
for _a, (_t, files) in ASSETS.items():
|
||||
if f in files: files[f][1].append(rev(body["name"], body.get("path", "/nowhere/" + body["name"]), body.get("available", True)))
|
||||
set_oids()
|
||||
return self.reply(200, {"result": True})
|
||||
if url.path == "/_test/download":
|
||||
r = find_rev(body["revision_oid"]); r["available"] = True; r["sync_status"] = "Available"
|
||||
return self.reply(200, {"result": True})
|
||||
if url.path == "/_test/sessions":
|
||||
SESSIONS.clear(); SESSIONS.update(body); return self.reply(200, {"result": True})
|
||||
if url.path == "/_test/calls":
|
||||
return self.reply(200, {"result": CALLS})
|
||||
name = url.path.rsplit("/", 1)[-1]
|
||||
uid = qs.get("session_uid", [None])[0]
|
||||
if uid is None and len(SESSIONS) > 1: return self.reply(400, {"detail": "Several sessions connected: session_uid required"})
|
||||
if uid is not None and uid not in SESSIONS: return self.reply(404, {"detail": f"Session {uid} not found"})
|
||||
CALLS.append([name, body.get("kwargs", {})])
|
||||
try:
|
||||
return self.reply(200, {"result": cmd(name, body.get("kwargs", {}))})
|
||||
except KeyError as exc: return self.reply(404, {"detail": f"Unknown command {exc}"})
|
||||
except Exception as exc: return self.reply(500, {"detail": str(exc)})
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
os.makedirs(f"{ROOT}/kabaret", exist_ok=True)
|
||||
with open(f"{ROOT}/kabaret/server.json", "w") as fh:
|
||||
json.dump({"host": "127.0.0.1", "port": server.server_address[1], "token": TOKEN, "pid": os.getpid()}, fh)
|
||||
print("listening", server.server_address[1], flush=True)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Write the asset files used by fake_libreflow.py into <workdir>/lib.
|
||||
|
||||
blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>
|
||||
|
||||
bob: a rig (COL_bob: armature + meshes), v001 has 1 mesh, v002 has 2.
|
||||
tree: COL_tree, plus an unrelated top-level collection.
|
||||
car: COL_car.
|
||||
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).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
WORKDIR = sys.argv[sys.argv.index("--") + 1]
|
||||
|
||||
|
||||
def mesh_object(name):
|
||||
mesh = bpy.data.meshes.new(name)
|
||||
mesh.from_pydata([(0, 0, 0), (1, 0, 0), (0, 1, 0)], [], [(0, 1, 2)])
|
||||
return bpy.data.objects.new(name, mesh)
|
||||
|
||||
|
||||
def write(path, collection_name, meshes=1, rig=False, extra=False, node_group=False):
|
||||
bpy.ops.wm.read_homefile(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
collection = bpy.data.collections.new(collection_name)
|
||||
scene.collection.children.link(collection)
|
||||
parent = None
|
||||
if rig:
|
||||
parent = bpy.data.objects.new("RIG_" + collection_name, bpy.data.armatures.new("RIG_" + collection_name))
|
||||
collection.objects.link(parent)
|
||||
bpy.context.view_layer.objects.active = parent
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bone = parent.data.edit_bones.new("root")
|
||||
bone.tail = (0, 0, 1)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
for index in range(meshes):
|
||||
ob = mesh_object(f"GEO_{collection_name}_{index}")
|
||||
ob.parent = parent
|
||||
collection.objects.link(ob)
|
||||
if node_group:
|
||||
essentials = os.path.join(bpy.utils.system_resource("DATAFILES"), "assets", "nodes", "geometry_nodes_essentials.blend")
|
||||
with bpy.data.libraries.load(essentials, link=True) as (_data_from, data_to):
|
||||
data_to.node_groups = ["Smooth by Angle"]
|
||||
ob.modifiers.new("Smooth", "NODES").node_group = data_to.node_groups[0]
|
||||
if extra:
|
||||
scene.collection.children.link(bpy.data.collections.new("WIP_other"))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
|
||||
|
||||
lib = os.path.join(WORKDIR, "lib")
|
||||
write(os.path.join(lib, "bob", "v001", "rig.blend"), "COL_bob", meshes=1, rig=True, extra=True)
|
||||
write(os.path.join(lib, "bob", "v002", "rig.blend"), "COL_bob", meshes=2, rig=True, extra=True)
|
||||
write(os.path.join(lib, "tree", "v001", "model.blend"), "COL_tree", 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")
|
||||
print("Test assets written in", lib)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""End-to-end test of the Scene Manager against dev/fake_libreflow.py.
|
||||
|
||||
blender -b --factory-startup --python dev/make_test_assets.py -- <workdir>
|
||||
python dev/fake_libreflow.py <workdir> (keep it running, fresh state)
|
||||
blender -b --factory-startup --python-exit-code 1 --python dev/test_scene_manager.py -- <workdir>
|
||||
|
||||
Prints every check and exits with an error when one fails. Timers do not run in
|
||||
background mode, so the test runs the background-job poller itself (pump).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import bpy
|
||||
|
||||
WORKDIR = sys.argv[sys.argv.index("--") + 1]
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(REPO))
|
||||
PACKAGE = os.path.basename(REPO)
|
||||
addon = importlib.import_module(PACKAGE)
|
||||
addon.register()
|
||||
config = sys.modules[PACKAGE + ".config"]
|
||||
ipc = sys.modules[PACKAGE + ".scene_manager.ipc"]
|
||||
sm = sys.modules[PACKAGE + ".scene_manager.operators"]
|
||||
lib = sys.modules[PACKAGE + ".scene_manager.library"]
|
||||
shot_viewer = sys.modules[PACKAGE + ".shot_viewer"]
|
||||
|
||||
SERVER_FILE = os.path.join(WORKDIR, "kabaret", "server.json")
|
||||
config.LIBREFLOW_SERVER_FILE = SERVER_FILE
|
||||
SHOT = "/melvile/films/f01/sequences/s010/shots/p010"
|
||||
BOB_FILE = "/melvile/asset_types/main_character/assets/bob/tasks/rigging/files/rig_blend"
|
||||
CAR = "/melvile/asset_types/vehicles/assets/car"
|
||||
CAR_FILE = CAR + "/tasks/rigging/files/car_blend"
|
||||
FAILED = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
print((" ok " if condition else " FAIL ") + label + (f" ({detail})" if detail else ""))
|
||||
if not condition:
|
||||
FAILED.append(label)
|
||||
|
||||
|
||||
def pump(timeout=15):
|
||||
end = time.time() + timeout
|
||||
while ipc._jobs or not ipc._results.empty():
|
||||
ipc._poll()
|
||||
if time.time() > end:
|
||||
check("background jobs finish", False, str(ipc._jobs))
|
||||
return
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def fake(path, body):
|
||||
info = json.load(open(SERVER_FILE))
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{info['port']}{path}",
|
||||
data=json.dumps(body).encode(),
|
||||
method="POST",
|
||||
headers={"Authorization": "Bearer " + info["token"], "Content-Type": "application/json"},
|
||||
)
|
||||
return json.loads(urllib.request.urlopen(request).read())["result"]
|
||||
|
||||
|
||||
def loaded():
|
||||
found = lib.all_instances()
|
||||
return {
|
||||
library.name: (library.get("wtd_revision"), [i.name for i in found.get(library.name, [])])
|
||||
for library in lib.managed_libraries()
|
||||
}
|
||||
|
||||
|
||||
def message():
|
||||
return sm.STATE.message[0]
|
||||
|
||||
|
||||
bpy.ops.wm.read_homefile(use_empty=True)
|
||||
scene = bpy.context.scene
|
||||
|
||||
print("Connection")
|
||||
sm.connect(scene)
|
||||
pump()
|
||||
check("connected to the session", sm.connected() and sm.current_session().get("session_name") == "libreflow_dev")
|
||||
check("user and site known", sm.STATE.ping.get("user") == "nero" and sm.STATE.ping.get("site") == "lyon")
|
||||
|
||||
print("Context")
|
||||
scene.wtd_scene_manager.context_oid = "/melvile/nope"
|
||||
pump()
|
||||
check("invalid oid reported", not sm.context_label("/melvile/nope")[1])
|
||||
scene.wtd_scene_manager.context_oid = SHOT
|
||||
pump()
|
||||
check("shot context validated", sm.context_label(SHOT) == ("shot: f01 / s010 / p010", True))
|
||||
errors = {a["name"]: a["error"] for a in sm.STATE.casting.get(SHOT, {}).get("assets", [])}
|
||||
check("casting read", set(errors) == {"bob", "tree", "rock", "lamp", "car", "ghost"}, str(errors))
|
||||
|
||||
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("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),
|
||||
)
|
||||
check("shot viewer groups bob as character", "Characters" in shot_viewer.rig_groups(bpy.context.view_layer))
|
||||
bpy.ops.wtd_scene.load()
|
||||
pump()
|
||||
check("load again adds nothing", message().startswith("Nothing new to load"), message())
|
||||
|
||||
print("Request")
|
||||
check("car can be requested", sm.missing_files(scene) == [CAR_FILE], str(sm.missing_files(scene)))
|
||||
bpy.ops.wtd_scene.request()
|
||||
pump()
|
||||
check("car v001 requested", message() == "Requested car v001", message())
|
||||
check("nothing left to request", sm.missing_files(scene) == [])
|
||||
calls = fake("/_test/calls", {})
|
||||
check("request sent to Libreflow", ["wtd_request_revisions", {"revision_oids": [CAR_FILE + "/history/revisions/v001"]}] in calls)
|
||||
fake("/_test/download", {"revision_oid": CAR_FILE + "/history/revisions/v001"})
|
||||
bpy.ops.wtd_scene.refresh()
|
||||
pump()
|
||||
bpy.ops.wtd_scene.load(asset_oid=CAR)
|
||||
pump()
|
||||
check("car loaded once downloaded", loaded().get("car", (None,))[0] == "v001", message())
|
||||
|
||||
print("Versions")
|
||||
check("bob is the latest", sm.version_status(bpy.data.libraries["bob"])[0] == "LATEST")
|
||||
fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v003", "available": False})
|
||||
sm.refresh_versions()
|
||||
pump()
|
||||
check("v003 not on this site: pending", sm.version_status(bpy.data.libraries["bob"])[0] == "PENDING")
|
||||
bpy.ops.wtd_scene.request(file_oid=BOB_FILE)
|
||||
pump()
|
||||
status = sm.version_status(bpy.data.libraries["bob"])
|
||||
check("v003 requested", status == ("PENDING", "v003 requested, waiting for the download"), str(status))
|
||||
fake(
|
||||
"/_test/publish",
|
||||
{"file_oid": BOB_FILE, "name": "v004", "available": True, "path": os.path.join(WORKDIR, "lib", "bob", "v001", "rig.blend")},
|
||||
)
|
||||
sm.refresh_versions()
|
||||
pump()
|
||||
check("v004 available: update", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
|
||||
bpy.ops.wtd_scene.update(library="bob")
|
||||
pump()
|
||||
check("bob updated to v004", loaded()["bob"][0] == "v004", message())
|
||||
meshes = {c.name: len([o for o in c.all_objects if o.type == "MESH"]) for c in lib.instances(bpy.data.libraries["bob"])}
|
||||
check("overrides resynced to v004 (1 mesh)", set(meshes.values()) == {1}, str(meshes))
|
||||
|
||||
print("Version picker")
|
||||
sm.STATE.revisions[BOB_FILE] = ipc.call("wtd_get_revisions", None, file_oid=BOB_FILE)
|
||||
sm._REVISION_ITEMS[:] = [(r["name"], r["name"], "", "NONE", i) for i, r in enumerate(sm.STATE.revisions[BOB_FILE])]
|
||||
bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v002")
|
||||
check("rolled back to v002", loaded()["bob"][0] == "v002")
|
||||
check("unavailable v003 refused", bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v003") == {"CANCELLED"})
|
||||
fake("/_test/publish", {"file_oid": BOB_FILE, "name": "v005", "available": False})
|
||||
sm.STATE.revisions[BOB_FILE] = ipc.call("wtd_get_revisions", None, file_oid=BOB_FILE)
|
||||
sm._REVISION_ITEMS[:] = [(r["name"], r["name"], "", "NONE", i) for i, r in enumerate(sm.STATE.revisions[BOB_FILE])]
|
||||
bpy.ops.wtd_scene.pick_revision("EXEC_DEFAULT", library="bob", revision="v005")
|
||||
pump()
|
||||
check("picking a version not on this site requests it", message() == "Requested bob v005", message())
|
||||
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"])
|
||||
for ob in bpy.context.view_layer.objects:
|
||||
ob.select_set(False)
|
||||
bpy.data.objects["tree"].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")
|
||||
check("whole asset removed", "rock" not in bpy.data.libraries)
|
||||
check("its indirect library removed too", set(bpy.data.libraries.keys()) == {"bob", "car", "tree"}, str(bpy.data.libraries.keys()))
|
||||
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})
|
||||
|
||||
print("Ambiguous file")
|
||||
try:
|
||||
lib.link_asset(
|
||||
{"name": "zzz", "oid": "x", "asset_type": "main_props", "file": {"file_oid": "f"}},
|
||||
{"name": "v1", "path": os.path.join(WORKDIR, "lib", "bob", "v001", "rig.blend")},
|
||||
SHOT,
|
||||
)
|
||||
check("ambiguous file refused", False)
|
||||
except lib.LoadError as exc:
|
||||
check("ambiguous file refused", "top-level collections" in str(exc), str(exc))
|
||||
|
||||
print("Assetize")
|
||||
local = bpy.data.collections.new("COL_lamp")
|
||||
scene.collection.children.link(local)
|
||||
local.objects.link(bpy.data.objects.new("GEO_lamp", bpy.data.meshes.new("GEO_lamp")))
|
||||
check("only free local collections offered", [c.name for c in lib.assetize_candidates()] == ["COL_lamp"])
|
||||
sm.STATE.assets = ipc.call("wtd_list_assets", None, oid=SHOT)
|
||||
bpy.ops.wtd_scene.assetize(
|
||||
"EXEC_DEFAULT", collection="COL_lamp", asset_type="main_props", asset_name="lamp", task="modeling", comment="test"
|
||||
)
|
||||
pump()
|
||||
check("published and replaced", "replaced" in message(), message())
|
||||
check("lamp linked", loaded().get("lamp") == ("v001", ["COL_lamp"]), 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)
|
||||
bpy.ops.wtd_scene.assetize("EXEC_DEFAULT", collection="COL_bad", asset_type="main_props", asset_name="bad", task="layout")
|
||||
pump()
|
||||
check("unknown task reported", "available: modeling, rigging" in message(), message())
|
||||
|
||||
print("Save / reopen")
|
||||
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"})
|
||||
sm.connect(bpy.context.scene)
|
||||
pump()
|
||||
check("versions checked again", sm.version_status(bpy.data.libraries["bob"])[0] == "UPDATE")
|
||||
|
||||
print("Several sessions")
|
||||
fake(
|
||||
"/_test/sessions",
|
||||
{
|
||||
"uid-1": {"session_name": "a", "cluster_name": "melvile", "is_gui": True, "current_oid": SHOT},
|
||||
"uid-2": {"session_name": "b", "cluster_name": "melvile", "is_gui": False, "current_oid": "/melvile"},
|
||||
},
|
||||
)
|
||||
bpy.context.window_manager.wtd_libreflow.session_uid = ""
|
||||
sm.connect(bpy.context.scene)
|
||||
pump()
|
||||
check("asks to pick a session", "pick one" in message(), message())
|
||||
bpy.ops.wtd_scene.select_session(uid="uid-2")
|
||||
pump()
|
||||
check("works once a session is picked", sm.context_label(SHOT)[1])
|
||||
|
||||
print("Libreflow not running")
|
||||
config.LIBREFLOW_SERVER_FILE = os.path.join(WORKDIR, "missing.json")
|
||||
sm.connect(bpy.context.scene)
|
||||
pump()
|
||||
check("reported as not running", not sm.connected() and sm.STATE.connection_error == "Libreflow is not running")
|
||||
|
||||
addon.unregister()
|
||||
print(f"\n{len(FAILED)} check(s) failed: {', '.join(FAILED)}" if FAILED else "\nAll checks passed")
|
||||
if FAILED:
|
||||
raise RuntimeError(f"{len(FAILED)} Scene Manager check(s) failed")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Scene Manager: load, update and publish the assets of a shot through Libreflow.
|
||||
|
||||
Libreflow is reached through its IPC server (``ipc``), the linked assets are
|
||||
handled in ``library`` and the operators in ``operators``.
|
||||
"""
|
||||
|
||||
from . import operators
|
||||
|
||||
|
||||
def register():
|
||||
operators.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
operators.unregister()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Client of the Libreflow IPC server (kabaret.ipc) and its wtdcmds commands.
|
||||
|
||||
The server writes its address and token in ``config.LIBREFLOW_SERVER_FILE``.
|
||||
The file is read again on every request: the port changes each time the
|
||||
server restarts.
|
||||
|
||||
``run`` executes a request in a worker thread and hands the result back on
|
||||
Blender's main thread, so the UI never waits on the network.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import bpy
|
||||
|
||||
from .. import config
|
||||
|
||||
# Hints added to the server's message for the statuses the user can act on.
|
||||
_STATUS_HINTS = {
|
||||
401: "the server refused the token, try again",
|
||||
404: "the Libreflow session is gone, or its wtdcmds extension is too old: update it and restart Libreflow",
|
||||
504: "no answer within 10 s, the command may still complete in Libreflow",
|
||||
}
|
||||
# Statuses whose message replaces the server's.
|
||||
_STATUS_MESSAGES = {400: "Several Libreflow sessions are connected: pick one in the Scene Manager panel"}
|
||||
|
||||
|
||||
class LibreflowError(Exception):
|
||||
"""A request failed. ``status`` is the HTTP status, None when the server was not reached."""
|
||||
|
||||
def __init__(self, message, status=None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class NotRunning(LibreflowError):
|
||||
"""No IPC server: Libreflow is not running."""
|
||||
|
||||
|
||||
def server_info():
|
||||
path = os.path.expanduser(config.LIBREFLOW_SERVER_FILE)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except FileNotFoundError:
|
||||
raise NotRunning("Libreflow is not running") from None
|
||||
except (OSError, ValueError) as exc:
|
||||
raise LibreflowError(f"Cannot read {path}: {exc}") from None
|
||||
|
||||
|
||||
def _request(method, path, body=None, params=None):
|
||||
info = server_info()
|
||||
url = f"http://{info['host']}:{info['port']}{path}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={"Authorization": f"Bearer {info['token']}", "Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=config.LIBREFLOW_TIMEOUT) as response:
|
||||
return json.loads(response.read() or b"null")
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
detail = json.loads(exc.read() or b"{}").get("detail", exc.reason)
|
||||
except ValueError:
|
||||
detail = exc.reason
|
||||
hint = _STATUS_HINTS.get(exc.code)
|
||||
message = _STATUS_MESSAGES.get(exc.code) or (f"{detail} ({hint})" if hint else str(detail))
|
||||
raise LibreflowError(message, exc.code) from None
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise NotRunning(f"Libreflow is not reachable ({getattr(exc, 'reason', exc)})") from None
|
||||
|
||||
|
||||
def sessions():
|
||||
"""{session_uid: {session_name, cluster_name, is_gui, current_oid}} of the connected sessions."""
|
||||
return _request("GET", "/sessions") or {}
|
||||
|
||||
|
||||
def call(cmd_name, session_uid=None, **kwargs):
|
||||
"""Run a Flow command and return its result."""
|
||||
params = {"session_uid": session_uid} if session_uid else None
|
||||
return _request("POST", f"/cmds/Flow/{cmd_name}", {"kwargs": kwargs}, params)["result"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Background jobs
|
||||
# --------------------------------------------------------------------------- #
|
||||
_results = queue.Queue()
|
||||
_jobs = {} # job id -> label, for the jobs not finished yet
|
||||
_generation = 0 # bumped on file load: results of older jobs are dropped
|
||||
_next_id = 0
|
||||
|
||||
|
||||
def run(label, func, on_success, on_error):
|
||||
"""Run ``func()`` in a thread, then ``on_success(result)`` or ``on_error(exception)`` on the main thread."""
|
||||
global _next_id
|
||||
_next_id += 1
|
||||
job_id, generation = _next_id, _generation
|
||||
_jobs[job_id] = label
|
||||
|
||||
def worker():
|
||||
try:
|
||||
_results.put((job_id, generation, on_success, func()))
|
||||
except Exception as exc: # noqa: BLE001 - handed to on_error on the main thread
|
||||
_results.put((job_id, generation, on_error, exc))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
if not bpy.app.timers.is_registered(_poll):
|
||||
bpy.app.timers.register(_poll, first_interval=0.05)
|
||||
|
||||
|
||||
def busy_label():
|
||||
"""Label of a running job, or ""."""
|
||||
return next(iter(_jobs.values()), "")
|
||||
|
||||
|
||||
def reset():
|
||||
"""Forget the running jobs (their results will be dropped), e.g. when another file is loaded."""
|
||||
global _generation
|
||||
_generation += 1
|
||||
_jobs.clear()
|
||||
|
||||
|
||||
def _poll():
|
||||
while True:
|
||||
try:
|
||||
job_id, generation, callback, value = _results.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if generation != _generation:
|
||||
continue
|
||||
_jobs.pop(job_id, None)
|
||||
try:
|
||||
callback(value)
|
||||
except Exception: # noqa: BLE001 - keep the poller alive
|
||||
traceback.print_exc()
|
||||
for window in bpy.context.window_manager.windows:
|
||||
for area in window.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.tag_redraw()
|
||||
return 0.1 if _jobs else None
|
||||
@@ -0,0 +1,272 @@
|
||||
"""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)
|
||||
remove_unused_libraries()
|
||||
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))
|
||||
tags = dict(library)
|
||||
library.filepath = path
|
||||
library.reload()
|
||||
# The reload replaces the library (named after its file, no custom properties):
|
||||
# find it by its path, then give it back its name and tags.
|
||||
library = next((lib for lib in bpy.data.libraries if _same_path(lib.filepath, path)), None)
|
||||
if library is None:
|
||||
raise LoadError(f"{library_name}: {os.path.basename(path)} could not be reloaded")
|
||||
library.name = library_name
|
||||
for key, value in tags.items():
|
||||
library[key] = value
|
||||
library["wtd_revision"] = revision["name"]
|
||||
return library
|
||||
|
||||
|
||||
def _same_path(a, b):
|
||||
return os.path.normcase(os.path.normpath(bpy.path.abspath(a))) == os.path.normcase(os.path.normpath(b))
|
||||
|
||||
|
||||
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)
|
||||
remove_unused_libraries()
|
||||
|
||||
|
||||
def remove_unused_libraries():
|
||||
"""Remove the libraries none of whose data is used any more, e.g. the ones a removed
|
||||
asset linked indirectly (node group assets...). Managed libraries are kept."""
|
||||
while True:
|
||||
used = {
|
||||
id.library.name
|
||||
for id in bpy.data.user_map()
|
||||
if id.library is not None and (id.users or id.use_fake_user)
|
||||
}
|
||||
unused = [lib for lib in bpy.data.libraries if lib.name not in used and not lib.get(ASSET_OID)]
|
||||
if not unused:
|
||||
return
|
||||
for lib in unused:
|
||||
bpy.data.libraries.remove(lib)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Assetize
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _holds_linked_data(collection):
|
||||
return any(c.override_library or c.library for c in collection.children_recursive) or any(
|
||||
ob.override_library or ob.library or (ob.instance_collection and ob.instance_collection.library)
|
||||
for ob in collection.all_objects
|
||||
)
|
||||
|
||||
|
||||
def assetize_candidates():
|
||||
"""Local collections that can be published: not linked, not an override, holding no loaded asset."""
|
||||
return [
|
||||
c
|
||||
for c in bpy.data.collections
|
||||
if c.library is None and c.override_library is None and not c.get(ASSET_OID) and not _holds_linked_data(c)
|
||||
]
|
||||
|
||||
|
||||
def asset_name_from(collection_name):
|
||||
"""'COL_street lamp.001' -> 'street_lamp': the prefix and duplicate suffix removed, an identifier."""
|
||||
name = re.sub(r"\.\d{3,}$", "", collection_name)
|
||||
prefix = config.DATABLOCK_PREFIXES.get("collections", "")
|
||||
if prefix and name.upper().startswith(prefix.upper()):
|
||||
name = name[len(prefix):]
|
||||
return re.sub(r"[^A-Za-z0-9_]+", "_", name).strip("_")
|
||||
|
||||
|
||||
def write_collection(collection, path):
|
||||
"""Write the collection and what it uses to a .blend, for publishing."""
|
||||
bpy.data.libraries.write(path, {collection}, path_remap="RELATIVE_ALL", fake_user=True)
|
||||
|
||||
|
||||
def parents_of(collection, scene):
|
||||
return [p for p in [scene.collection, *bpy.data.collections] if p.children.get(collection.name) == collection]
|
||||
@@ -0,0 +1,974 @@
|
||||
"""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)
|
||||
+18
-8
@@ -150,20 +150,35 @@ def _new_camera_rig(context, shot, is_3d):
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Rigs
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _rig_library(ob):
|
||||
reference = ob.override_library.reference if ob.override_library else None
|
||||
return ob.library or (reference.library if reference else None)
|
||||
|
||||
|
||||
def _rig_names(ob):
|
||||
"""Names a rig is classified by: object, its collections and its library file."""
|
||||
names = [ob.name, *(collection.name for collection in ob.users_collection)]
|
||||
reference = ob.override_library.reference if ob.override_library else None
|
||||
library = ob.library or (reference.library if reference else None)
|
||||
library = _rig_library(ob)
|
||||
if library is not None:
|
||||
names.append(os.path.basename(library.filepath))
|
||||
return names
|
||||
|
||||
|
||||
def _rig_group(ob, patterns):
|
||||
library = _rig_library(ob)
|
||||
# Loaded by the Scene Manager: its Libreflow asset type decides.
|
||||
asset_type = library.get("wtd_asset_type") if library is not None else None
|
||||
if asset_type in config.SHOT_VIEWER_ASSET_TYPE_GROUPS:
|
||||
return config.SHOT_VIEWER_ASSET_TYPE_GROUPS[asset_type]
|
||||
names = _rig_names(ob)
|
||||
return next((label for label, pattern in patterns if any(pattern.search(name) for name in names)), OTHER_GROUP)
|
||||
|
||||
|
||||
def rig_groups(view_layer):
|
||||
"""{group label: [armature objects]} for the armatures of the view layer, empty groups left out."""
|
||||
patterns = [(label, re.compile(pattern)) for label, pattern in config.SHOT_VIEWER_RIG_GROUPS.items()]
|
||||
groups = {label: [] for label, _pattern in patterns}
|
||||
groups.update({label: [] for label in config.SHOT_VIEWER_ASSET_TYPE_GROUPS.values() if label not in groups})
|
||||
groups[OTHER_GROUP] = []
|
||||
# Camera rigs (armatures carrying a camera) are left out: one per shot.
|
||||
rigs = sorted(
|
||||
@@ -175,12 +190,7 @@ def rig_groups(view_layer):
|
||||
key=lambda ob: ob.name.lower(),
|
||||
)
|
||||
for ob in rigs:
|
||||
names = _rig_names(ob)
|
||||
label = next(
|
||||
(label for label, pattern in patterns if any(pattern.search(name) for name in names)),
|
||||
OTHER_GROUP,
|
||||
)
|
||||
groups[label].append(ob)
|
||||
groups[_rig_group(ob, patterns)].append(ob)
|
||||
return {label: rigs for label, rigs in groups.items() if rigs}
|
||||
|
||||
|
||||
|
||||
+212
@@ -3,6 +3,9 @@ import textwrap
|
||||
import bpy
|
||||
|
||||
from .. import config, rename, shot_viewer, tools_ops
|
||||
from ..scene_manager import ipc
|
||||
from ..scene_manager import library as sm_library
|
||||
from ..scene_manager import operators as sm
|
||||
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
|
||||
from ..core.runner import Status
|
||||
from .operators import EXPANDED
|
||||
@@ -316,6 +319,191 @@ class _ShotViewerPanel:
|
||||
).name = rig.name
|
||||
|
||||
|
||||
MESSAGE_ICONS = {"INFO": "INFO", "WARNING": "ERROR", "ERROR": "CANCEL"}
|
||||
|
||||
|
||||
class _SceneManagerPanel:
|
||||
bl_region_type = "UI"
|
||||
bl_category = "WTD"
|
||||
bl_label = "Scene Manager"
|
||||
|
||||
def draw(self, context) -> None:
|
||||
layout = self.layout
|
||||
state = sm.STATE
|
||||
scene = context.scene
|
||||
|
||||
row = layout.row(align=True)
|
||||
if sm.connected():
|
||||
session = sm.current_session()
|
||||
text = f"{session.get('session_name', '?')} ({session.get('cluster_name', '')})"
|
||||
if state.ping.get("user"):
|
||||
text += f" {state.ping['user']} @ {state.ping.get('site', '')}"
|
||||
row.label(text=text, icon="LINKED")
|
||||
else:
|
||||
row.label(text=state.connection_error, icon="UNLINKED")
|
||||
row.operator(sm.WTD_OT_scene_connect.bl_idname, text="", icon="FILE_REFRESH")
|
||||
if len(state.sessions) > 1:
|
||||
col = layout.column(align=True)
|
||||
chosen = context.window_manager.wtd_libreflow.session_uid
|
||||
for uid, info in state.sessions.items():
|
||||
col.operator(
|
||||
sm.WTD_OT_scene_select_session.bl_idname,
|
||||
text=f"{info.get('session_name', uid)}: {info.get('current_oid') or ''}",
|
||||
depress=uid == chosen,
|
||||
).uid = uid
|
||||
|
||||
layout.prop(scene.wtd_scene_manager, "context_oid", text="", icon="SEQUENCE")
|
||||
row = layout.row(align=True)
|
||||
row.operator(sm.WTD_OT_scene_use_current_oid.bl_idname, text="Libreflow Oid", icon="IMPORT")
|
||||
row.operator(sm.WTD_OT_scene_pick_shot.bl_idname, text="Pick Shot", icon="VIEWZOOM")
|
||||
text, valid = sm.context_label(sm.context_oid(scene))
|
||||
if text:
|
||||
row = layout.row()
|
||||
row.alert = not valid
|
||||
_wrapped_label(row, context, text, icon="CHECKMARK" if valid else "ERROR")
|
||||
|
||||
busy = ipc.busy_label()
|
||||
if busy:
|
||||
layout.label(text=f"{busy}...", icon="SORTTIME")
|
||||
message, level = state.message
|
||||
if message:
|
||||
row = layout.row()
|
||||
row.alert = level == "ERROR"
|
||||
_wrapped_label(row, context, message, icon=MESSAGE_ICONS[level])
|
||||
|
||||
|
||||
class _SceneCastingPanel:
|
||||
bl_region_type = "UI"
|
||||
bl_category = "WTD"
|
||||
bl_label = "Casting"
|
||||
bl_parent_id = "WTD_PT_scene_manager_view3d"
|
||||
|
||||
def draw(self, context) -> None:
|
||||
layout = self.layout
|
||||
oid = sm.context_oid(context.scene)
|
||||
row = layout.row(align=True)
|
||||
row.scale_y = 1.3
|
||||
row.operator(sm.WTD_OT_scene_load.bl_idname, text="Load Missing", icon="IMPORT")
|
||||
if sm.missing_files(context.scene):
|
||||
row.operator(sm.WTD_OT_scene_request.bl_idname, text="Request Missing", icon="URL")
|
||||
row.operator(sm.WTD_OT_scene_refresh.bl_idname, text="", icon="FILE_REFRESH")
|
||||
casting = sm.STATE.casting.get(oid)
|
||||
if casting is None:
|
||||
text = "Not read yet: refresh" if sm.is_shot_context(oid) else "Set a shot as context"
|
||||
layout.label(text=text, icon="INFO")
|
||||
return
|
||||
if not casting["assets"]:
|
||||
layout.label(text="Nothing cast in this shot", icon="INFO")
|
||||
|
||||
found = sm_library.all_instances()
|
||||
col = layout.column(align=True)
|
||||
for asset in casting["assets"]:
|
||||
library = sm_library.find_library(asset["file"]["file_oid"]) if asset.get("file") else None
|
||||
have = len(found.get(library.name, [])) if library else 0
|
||||
want = asset.get("nb_occurences") or 1
|
||||
error = asset.get("error")
|
||||
file = sm.file_data(asset["file"]["file_oid"], asset["file"]) if asset.get("file") else None
|
||||
latest = (file or {}).get("latest") or {}
|
||||
requested = bool(error) and latest.get("sync_status") == "Requested"
|
||||
if requested:
|
||||
error = f"{latest['name']} requested, waiting for the download"
|
||||
icon = "SORTTIME" if requested else "ERROR" if error else "CHECKMARK" if have >= want else "IMPORT"
|
||||
row = col.row(align=True)
|
||||
row.alert = bool(error) and not requested
|
||||
row.label(text=f"{asset['name']} ({asset.get('asset_type', '')})", icon=icon)
|
||||
row.label(text=f"{have}/{want}")
|
||||
if not error and have < want:
|
||||
row.operator(sm.WTD_OT_scene_load.bl_idname, text="", icon="IMPORT").asset_oid = asset["oid"]
|
||||
if sm.to_request(file):
|
||||
row.operator(sm.WTD_OT_scene_request.bl_idname, text="", icon="URL").file_oid = file["file_oid"]
|
||||
if error:
|
||||
sub = col.row()
|
||||
sub.alert = not requested
|
||||
_wrapped_label(sub, context, error, icon="BLANK1")
|
||||
|
||||
cast = {asset["oid"] for asset in casting["assets"]}
|
||||
extra = [library for library in sm_library.managed_libraries() if library["wtd_asset_oid"] not in cast]
|
||||
if extra:
|
||||
box = layout.box()
|
||||
box.label(text="Loaded, no longer cast:", icon="ERROR")
|
||||
for library in extra:
|
||||
box.label(text=library.get("wtd_asset_name", library.name), icon="BLANK1")
|
||||
|
||||
|
||||
class _SceneLoadedPanel:
|
||||
bl_region_type = "UI"
|
||||
bl_category = "WTD"
|
||||
bl_label = "Loaded"
|
||||
bl_parent_id = "WTD_PT_scene_manager_view3d"
|
||||
|
||||
def draw(self, context) -> None:
|
||||
layout = self.layout
|
||||
libraries = sm_library.managed_libraries()
|
||||
if not libraries:
|
||||
layout.label(text="Nothing loaded by the Scene Manager", icon="INFO")
|
||||
return
|
||||
row = layout.row(align=True)
|
||||
row.operator(sm.WTD_OT_scene_update.bl_idname, text="Update All", icon="FILE_REFRESH")
|
||||
row.operator(sm.WTD_OT_scene_refresh.bl_idname, text="", icon="VIEWZOOM")
|
||||
|
||||
found = sm_library.all_instances()
|
||||
for library in libraries:
|
||||
state, text = sm.version_status(library)
|
||||
outdated = state in {"UPDATE", "PENDING"}
|
||||
expanded = library.name in sm.EXPANDED
|
||||
row = layout.row(align=True)
|
||||
row.operator(
|
||||
sm.WTD_OT_scene_toggle_expand.bl_idname,
|
||||
text="",
|
||||
emboss=False,
|
||||
icon="DOWNARROW_HLT" if expanded else "RIGHTARROW",
|
||||
).library = library.name
|
||||
row.label(text=library.get("wtd_asset_name", library.name), icon="LIBRARY_DATA_DIRECT")
|
||||
version = row.row(align=True)
|
||||
version.alert = outdated
|
||||
version.label(text=library.get("wtd_revision", "?"))
|
||||
if state == "UPDATE":
|
||||
row.operator(sm.WTD_OT_scene_update.bl_idname, text="", icon="FILE_REFRESH").library = library.name
|
||||
elif state == "PENDING" and sm.to_request(sm.file_data(library["wtd_file_oid"])):
|
||||
row.operator(sm.WTD_OT_scene_request.bl_idname, text="", icon="URL").file_oid = library["wtd_file_oid"]
|
||||
row.operator(sm.WTD_OT_scene_pick_revision.bl_idname, text="", icon="COLLAPSEMENU").library = library.name
|
||||
row.operator(sm.WTD_OT_scene_remove.bl_idname, text="", icon="X").library = library.name
|
||||
if outdated:
|
||||
sub = layout.row()
|
||||
sub.alert = True
|
||||
_wrapped_label(sub, context, text, icon="BLANK1")
|
||||
if not expanded:
|
||||
continue
|
||||
col = layout.column(align=True)
|
||||
for instance in found.get(library.name, []):
|
||||
is_object = isinstance(instance, bpy.types.Object)
|
||||
sub = col.row(align=True)
|
||||
sub.label(text=instance.name, icon="OUTLINER_OB_EMPTY" if is_object else "OUTLINER_COLLECTION")
|
||||
op = sub.operator(sm.WTD_OT_scene_select_instance.bl_idname, text="", icon="RESTRICT_SELECT_OFF")
|
||||
op.library, op.instance = library.name, instance.name
|
||||
sub.operator(sm.WTD_OT_scene_remove.bl_idname, text="", icon="X").instance = instance.name
|
||||
|
||||
layout.separator()
|
||||
row = layout.row(align=True)
|
||||
row.operator(sm.WTD_OT_scene_update.bl_idname, text="Update Selected").selected = True
|
||||
row.operator(sm.WTD_OT_scene_remove.bl_idname, text="Remove Selected").selected = True
|
||||
|
||||
|
||||
class _SceneAssetizePanel:
|
||||
bl_region_type = "UI"
|
||||
bl_category = "WTD"
|
||||
bl_label = "Assetize"
|
||||
bl_parent_id = "WTD_PT_scene_manager_view3d"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
def draw(self, context) -> None:
|
||||
layout = self.layout
|
||||
_wrapped_label(
|
||||
layout, context, "Publish a local collection of this scene to Libreflow as an asset.", icon="INFO"
|
||||
)
|
||||
layout.operator(sm.WTD_OT_scene_assetize.bl_idname, text="Assetize Collection...", icon="EXPORT")
|
||||
|
||||
|
||||
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
|
||||
bl_idname = "WTD_PT_sanity_view3d"
|
||||
bl_space_type = "VIEW_3D"
|
||||
@@ -341,6 +529,26 @@ class WTD_PT_shot_viewer_view3d(_ShotViewerPanel, bpy.types.Panel):
|
||||
bl_space_type = "VIEW_3D"
|
||||
|
||||
|
||||
class WTD_PT_scene_manager_view3d(_SceneManagerPanel, bpy.types.Panel):
|
||||
bl_idname = "WTD_PT_scene_manager_view3d"
|
||||
bl_space_type = "VIEW_3D"
|
||||
|
||||
|
||||
class WTD_PT_scene_casting_view3d(_SceneCastingPanel, bpy.types.Panel):
|
||||
bl_idname = "WTD_PT_scene_casting_view3d"
|
||||
bl_space_type = "VIEW_3D"
|
||||
|
||||
|
||||
class WTD_PT_scene_loaded_view3d(_SceneLoadedPanel, bpy.types.Panel):
|
||||
bl_idname = "WTD_PT_scene_loaded_view3d"
|
||||
bl_space_type = "VIEW_3D"
|
||||
|
||||
|
||||
class WTD_PT_scene_assetize_view3d(_SceneAssetizePanel, bpy.types.Panel):
|
||||
bl_idname = "WTD_PT_scene_assetize_view3d"
|
||||
bl_space_type = "VIEW_3D"
|
||||
|
||||
|
||||
class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
|
||||
bl_idname = "WTD_PT_sanity_sequencer"
|
||||
bl_space_type = "SEQUENCE_EDITOR"
|
||||
@@ -351,6 +559,10 @@ classes = (
|
||||
WTD_PT_sanity_view3d,
|
||||
WTD_PT_tools_view3d,
|
||||
WTD_PT_shot_viewer_view3d,
|
||||
WTD_PT_scene_manager_view3d,
|
||||
WTD_PT_scene_casting_view3d,
|
||||
WTD_PT_scene_loaded_view3d,
|
||||
WTD_PT_scene_assetize_view3d,
|
||||
WTD_PT_rename_view3d,
|
||||
WTD_PT_rename_node_editor,
|
||||
WTD_PT_sanity_sequencer,
|
||||
|
||||
Reference in new issue
Block a user