12 Commits
Author SHA1 Message Date
Nero Frame 13 644aa32014 Shot Manager's Kitsu creator now allows the choice of 2D/3D camera. 2026-10-28 13:49:10 +01:00
Nero Frame 13 8dcd4daad1 Added functionality in Shot Manager to create all cameras for a Kitsu Sequence 2026-10-28 13:35:54 +01:00
NDemaerschalkandClaude Opus 5.5 4161ec4ad5 Stop tracking dev/ (ignored, kept locally)
The fake Libreflow server, test assets script and end-to-end test stay
on disk; dev/ is in .gitignore since 3f87904.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:24:44 +01:00
NDemaerschalkandClaude Opus 5.5 ce30491cb8 Panels: colored headers and fixed order
Every WTD panel gets a colored icon in its header (config PANEL_COLORS,
the Scene Manager sub-panels share its green) and the sidebar order is
set with bl_order: Tools, Rename, Scene Manager, Shot Manager, Sanity
Check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:24:12 +01:00
Nero Frame 13 3f87904cd1 Panel update + fix for remove function so it doesn't remove all instances 2026-10-28 11:03:16 +01:00
NDemaerschalkandClaude Opus 5.5 fbc7ac18e4 Scene Manager: Load Publish and numbered instances
Load Publish (Loaded panel) searches every asset of the project, then
loads any of its published .blend files at any version, cast in the shot
or not. The file the casting would load and the latest version on this
site are preselected; a version not on this site is requested instead.
Built on the existing wtd_list_assets / wtd_get_asset_files /
wtd_get_revisions commands.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:47:05 +01:00
NDemaerschalkandClaude Opus 5.5 6ec2b228ae Scene Manager: named LODs
{lod} in SCENE_MANAGER_LOD_COLLECTION now matches any name (COL_bob_LODhigh
as well as COL_bob_LOD0). SCENE_MANAGER_LODS (high, mid, low) sets the
button order and the LOD shown on load; unlisted LODs follow, numbers in
order, then names A-Z. It replaces SCENE_MANAGER_DEFAULT_LOD. The LOD of
Selected buttons get their own line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:46:36 +01:00
NDemaerschalkandClaude Opus 5.5 bac7a6f5da Scene Manager: LOD switching; Shot Viewer renamed Shot Manager
LODs are child collections of the asset collection (COL_{asset}_LOD{n}).
Overrides hide the other LOD collections (viewport and render), keeping
the rig, pose and animation; collection instances instance the LOD
collection. Each instance has its own LOD, set per instance or for the
selection in the Loaded panel, kept through save, reload and updates.
Loading shows SCENE_MANAGER_DEFAULT_LOD (0).

The Shot Viewer panel is now "Shot Manager" and starts closed. Code
names and SHOT_VIEWER_* config keys are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:42:24 +01:00
NDemaerschalkandClaude Opus 5.5 9fe38ce58a Scene Manager: request publishes missing on this site
- Request (globe) on casting rows and loaded assets whose latest publish
  is not on this site, and Request Missing for all of them, through the
  new wtdcmds command wtd_request_revisions. Requested publishes show
  "requested, waiting for the download".
- The version picker requests a version that is not on this site.
- A 404 explains that the session is gone or wtdcmds is too old.
- Fake server: car (vehicles) with a publish not on this site,
  wtd_request_revisions and /_test/download; 45 checks.
- Tested live: car_paul modeling.blend v001 -> Requested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:45:17 +01:00
NDemaerschalkandClaude Opus 5.5 9ee7710479 Scene Manager: fixes from the first run against a real Libreflow
- Update / version switch: Blender 5.2 replaces the library on reload
  (renamed after its file, custom properties gone); find it by path and
  restore its name and tags.
- Remove the libraries an asset linked indirectly when it is removed or
  fails to load (remove_unused_libraries), never a managed one.
- Config: real Kitsu asset types for link modes and Shot Viewer groups,
  setdress_low files for bg_location, "environments" collection fallback.
- Dev fake server and test use the real asset types; rock links a node
  group from the essentials to cover the indirect library cleanup.
- README and CLAUDE.md updated with the live findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:30:46 +01:00
Nero Frame 13andClaude Opus 5.5 017bdfc2fd Added dev test tools and CLAUDE.md
dev/: test asset files, a fake Libreflow IPC server and an end-to-end
Scene Manager test (excluded from the extension build). CLAUDE.md: notes
for working on the add-on (conventions, testing, design findings, open
points).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:15:02 +01:00
Nero Frame 13andClaude Opus 5.5 c238f4db7c Added the Scene Manager (Libreflow casting, load, update, assetize)
New Scene Manager panel (WTD tab) talking to a running Libreflow through
its IPC server and the wtdcmds commands: pick the shot context, read the
casting, link the latest available publishes (overrides for chars and
props, collection instances otherwise), flag out-of-date versions in red,
update / roll back / remove loaded assets, and publish local collections
as assets. Requests run in the background. Settings in config.py
(SCENE_MANAGER_*, LIBREFLOW_*).

The Shot Viewer groups rigs loaded by the Scene Manager by asset type.
The manifest declares the network permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:15:02 +01:00
11 changed files with 2725 additions and 65 deletions

No files matched your search

+3
View File
@@ -7,3 +7,6 @@ private
.idea
# ignore mkdocs build
site/
# ignore dev utilities
dev/
+190 -18
View File
@@ -4,7 +4,8 @@ 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.
- **[Shot Manager](#shot-manager)**: 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).
@@ -18,7 +19,8 @@ Most values the tools rely on (prefixes, shot naming, frame rate…) live in `co
- [Sanity Check](#sanity-check)
- [Tools panel](#tools-panel)
- [Rename](#rename)
- [Shot Viewer](#shot-viewer)
- [Shot Manager](#shot-manager)
- [Scene Manager](#scene-manager)
- [Configuration](#configuration)
- [Troubleshooting](#troubleshooting)
- [For developers](#for-developers)
@@ -29,7 +31,9 @@ Most values the tools rely on (prefixes, shot naming, frame rate…) live in `co
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 Shot Manager 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`.
@@ -52,14 +56,17 @@ blender --command extension build --source-dir path/to/wtd-libreflow-addon
## Where to find the tools
Open the sidebar of the 3D Viewport (**N**) and click the **WTD** tab.
Open the sidebar of the 3D Viewport (**N**) and click the **WTD** tab. The panels come in this order, each with a colored icon in its header:
| Panel | Editors |
|---|---|
| **Sanity Check** | 3D Viewport, Video Sequencer |
| **Tools** (Get Oid) | 3D Viewport |
| **Rename** | 3D Viewport, Shader Editor |
| **Shot Viewer** | 3D Viewport |
| Panel | Color | Editors |
|---|---|---|
| **Tools** (Get Oid) | blue | 3D Viewport |
| **Rename** | yellow | 3D Viewport, Shader Editor |
| **Scene Manager** (Casting, Loaded, Assetize) | green | 3D Viewport |
| **Shot Manager** | violet | 3D Viewport |
| **Sanity Check** | orange | 3D Viewport, Video Sequencer |
Panels you drag to another place stay there: a file keeps its own panel order. The colors are `PANEL_COLORS` in the [Configuration](#configuration).
---
@@ -233,7 +240,7 @@ The box under the **Rename** button previews every change (`old name → new nam
---
## Shot Viewer
## Shot Manager
A sidebar panel to go through the shots of a sequence in their camera. You can switch shots, add and delete them, and grab the character and prop rigs.
@@ -250,7 +257,7 @@ During playback, Blender itself switches to the camera of each marker, so playin
### Where it is
*3D Viewport sidebar (**N**) > WTD tab > **Shot Viewer***.
*3D Viewport sidebar (**N**) > WTD tab > **Shot Manager***.
Choosing a shot in the panel moves the playhead to it and puts **the 3D view you clicked in** into that shot's camera view. Keep a Timeline open (for example at the bottom of the Layout workspace) to see the camera markers and where you are in the sequence.
@@ -288,6 +295,27 @@ The shot is added at the end of the sequence and the scene's **End** frame grows
The rig is built at the **3D cursor**, in the active collection: place the cursor and pick the collection first if you have a camera collection. The add-on keeps the rig's bone shapes in a hidden **Widgets** collection. If the add-on is not enabled, the dialog says so and no shot is added.
### Shots from Kitsu
**Shots from Kitsu...** builds every camera a sequence needs in one go, from the shots and casting in Kitsu. It needs Libreflow connected in the [Scene Manager](#connecting).
1. Click **Shots from Kitsu...** and pick the sequence. The sequence of the Scene Manager's shot is at the top of the list.
2. The dialog lists the Kitsu shots of the sequence in name order. Each line shows its frame count, what is cast in it and, for a shot to build, its camera type (**3D** / **2D**):
- camera icon: to build;
- check mark: a shot of that name is in the scene already, left as it is;
- greyed out: nothing is cast in it (see **Only Cast Shots**).
3. Choose the settings and click **OK**.
| Setting | Use |
|---|---|
| **Only Cast Shots** | On (default): shots with nothing cast in Kitsu get no camera. |
| **All Cameras** | Sets the camera type of every shot at once (**3D**: a **crane** rig, **2D**: a 2D camera rig). Then change single shots in their line. The types are the keys of `SHOT_CAMERA_RIG_MODES`: add one there (`"Dolly": "DOLLY"`) and it shows up as a choice. |
| **Default Duration** | Length of the shots without a frame count in Kitsu. |
Each shot is built as with **+**: an Add Camera Rigs rig at the 3D cursor, `CAM_<sequence>_<shot>` / `RIG_<sequence>_<shot>`, named with the Kitsu sequence name, and a camera marker named after the Kitsu shot. The shots go one after the other after the shots of the scene, in Kitsu order. Each one is as long as its Kitsu frame count (**Nb Frames**). When the sequence name follows `SEQUENCE_NAME_FORMAT`, the **Sequence** field is set from it. Running it again only adds the shots that are still missing, so it also picks up shots added in Kitsu later. **Ctrl+Z** undoes it.
The frame counts and the casting of the whole sequence come from the wtdcmds command `wtd_get_sequence_shots`. With an older wtdcmds the shots are found shot by shot from their casting: it is slower, the shots that are not in Kitsu are left out, and every shot gets the **Default Duration**.
### Deleting a shot
Put the playhead on the shot (or select it in the list) and click **−**:
@@ -307,7 +335,130 @@ 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 (every film). Only shots created in Libreflow are listed: a film whose sequences and shots are not in Libreflow yet shows none.
A valid shot is confirmed under the field (`shot: f01 / s010 / p010`). The casting and the versions are then read on their own.
### 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`…
- **Numbering:** every instance gets a number: the first one loaded is `COL_bob_01` (an override, named after the asset collection) or `tree_01` (a collection instance, named after the asset), the next `_02`, and so on. Numbers go on from the highest one in the file, so removing `_01` does not reuse it while `_02` exists.
- **Level of detail:** when the asset collection holds LOD collections (`COL_<asset>_LODhigh`, `COL_<asset>_LOD0`…), only the first one is shown. See [Levels of detail](#levels-of-detail).
All of these can be changed in the [Configuration](#configuration).
### Loaded
Lists everything the Scene Manager loaded, one line per asset, with its version:
- **Load...**: load any published file of the project, cast in the shot or not (see [Load any publish](#load-any-publish)).
- The version is **red** when it is not the latest publish. The line under it says either "v004 available" (click the update icon), or "v004 published, not available on this site yet" (click the globe to request it, then "v004 requested, waiting for the download").
- **Arrow**: shows the asset's instances, each with a button to select its objects and one to remove it. Under an instance with levels of detail, one button per LOD (`LOD high mid low`), the shown one pressed.
- **Menu icon**: pick another published version, for example to roll back. Versions not on this site are marked: picking one requests it; pick it again once it is downloaded.
- **✕**: 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.
- **LOD of Selected** (shown when a selected instance has LODs): switch the instances of the selected objects to that LOD.
- The magnifier re-checks the versions.
Updating swaps the linked file and reloads it. Overrides keep their posing and animation, and instances keep their LOD (an instance showing a LOD the new version no longer has goes back to the first one). **Ctrl+Z** undoes removing, updating and LOD switches.
### Load any publish
**Load...** (top of the *Loaded* sub-panel) searches every asset of the project: type a few letters of its name, pick it. A dialog then shows:
| Setting | Use |
|---|---|
| **File** | The asset's published `.blend` files (`task/file.blend`). The file the casting would load is picked by default (`rigging/*.blend`, then `modeling/*.blend`). |
| **Version** | All its published versions, newest first, with date and author. The latest version on this site is picked. Versions not on this site are marked. |
| **Instances** | How many instances to add (numbered `_01`, `_02`...). |
**Load** links the file and adds the instances, like a cast asset (same collection, override or instance by asset type, LODs). The asset then appears in *Loaded*, with its version followed and updatable.
- A version not on this site is **requested** instead of loaded: load it again once Libreflow has downloaded it.
- A file already loaded at another version is refused: switch it with the version picker of *Loaded* first. At the same version, Load just adds instances.
- It works without a shot context: the project is taken from the oid open in Libreflow.
### Levels of detail
Rigs and models can hold their levels of detail as **child collections of the asset collection**, named `COL_<asset>_LOD<name>`. The name can be anything: `high`, `mid`, `low`, or numbers `0`, `1`, `2`. One LOD is shown at a time:
```
COL_bob
├─ RIG_bob (shared: rig, controllers…)
├─ COL_bob_LODhigh shown
├─ COL_bob_LODmid hidden
└─ COL_bob_LODlow hidden
```
- **Overrides** (characters, props, vehicles): the other LOD collections are hidden in the viewport and the render, so they cost nothing. The rig and everything outside the LOD collections stay; posing and animation are kept when switching.
- **Collection instances** (sets, set props…): the instance shows the LOD collection itself. This needs everything of the asset to sit in its LOD collections: an asset with objects or collections next to them cannot switch LODs as an instance (load it as an override instead).
Each instance has its own LOD: two instances of the same asset can show different LODs. The choice is saved with the file.
The buttons follow `SCENE_MANAGER_LODS` (`high`, `mid`, `low`), and the first of these the file has is shown on load. LODs not in that list come after it: numbers in order (`0`, `1`, `2`… `10`), then other names A-Z. So a file with `COL_bob_LOD0`, `COL_bob_LOD1` shows LOD `0` on load. The naming is `SCENE_MANAGER_LOD_COLLECTION` (`COL_{asset}_LOD{lod}`): keep a fixed part like `LOD` in it, since every child collection matching it counts as a LOD. Both are in the [Configuration](#configuration).
### 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`. |
---
---
@@ -344,14 +495,19 @@ Name formats use Python's `str.format`: `"sq{:04d}"` turns `10` into `sq0010`. `
| Sanity Check: assets | `REQUIRED_COLLECTIONS`, `MAX_FACE_SIDES`, `CONCAVE_TOLERANCE`, `NON_MANIFOLD_ALLOW_BOUNDARY`, `UV_NEGATIVE_TOLERANCE`, `ALLOWED_MODIFIERS`, `ALLOWED_MODIFIERS_LINEART`, `APPLY_TRANSFORM`, `APPLY_TRANSFORM_TYPES`, `TRANSFORM_TOLERANCE`, `FORBIDDEN_ASSET_OBJECT_TYPES` |
| Sanity Check: characters | `CHAR_REFERENCE_PATH`, `CHAR_BODY_NAME`, `CHAR_PART_KEYWORDS`, `CHAR_BODY_VERTEX_GROUPS` |
| Sanity Check: layout | `SET_COLLECTION_PATTERN` |
| Panels | `PANEL_COLORS` |
| 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 Manager | `SHOT_VIEWER_RIG_GROUPS`, `SHOT_VIEWER_ASSET_TYPE_GROUPS`, `SHOT_NAME_FORMAT`, `SHOT_NUMBER_STEP`, `SHOT_FRAME_START`, `SHOT_DEFAULT_DURATION`, `SEQUENCE_NAME_FORMAT`, `SHOT_CAMERA_NAME_FORMAT`, `SHOT_CAMERA_RIG_NAME_FORMAT`, `SHOT_CAMERA_RIG_MODES` |
| Scene Manager | `LIBREFLOW_SERVER_FILE`, `LIBREFLOW_TIMEOUT`, `SCENE_MANAGER_FILE_PATTERNS`, `SCENE_MANAGER_COLLECTION_NAMES`, `SCENE_MANAGER_LINK_MODES`, `SCENE_MANAGER_LOD_COLLECTION`, `SCENE_MANAGER_LODS`, `SCENE_MANAGER_INSTANCE_NAME`, `SCENE_MANAGER_TYPE_COLLECTION`, `SCENE_MANAGER_ASSETIZE_TASK` |
---
## Troubleshooting
**Scene Manager: Pick Shot only lists `melvile_test`.**
It lists the shots that exist in Libreflow. The `melvile` film has no sequences or shots in Libreflow yet (checked 2026-10-28); they appear once they are created there.
**A check says "crashed".**
The check hit an unexpected error. Unfold it to read the message, and send it with the file to the pipeline team. The other checks still run.
@@ -364,13 +520,28 @@ Either no character reference is set (see [Character reference](#character-refer
**Rename: the preview shows nothing.**
Nothing is selected, the selection is linked from a library, or (in *Materials* mode) the selected objects have no local material.
**Shot Viewer: a rig is not in the list.**
**Shot Manager: a rig is not in the list.**
Only armatures in the scene's view layer are listed. A collection instance that has not been overridden does not expose its armature: make a library override first.
**Shot Viewer: a shot is missing from the list.**
**Shot Manager: 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.
**Shot Viewer: playback only plays one shot.**
**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 Manager: Shots from Kitsu lists no shot.**
The sequence has no shot in Kitsu, or its shots have Kitsu names that differ from the Libreflow ones. `melvile_test / sq00A` is such a sequence.
**Shot Manager: playback only plays one shot.**
**Loop Shot** is on. Untick it to play the whole sequence.
---
@@ -388,8 +559,9 @@ Only markers bound to a camera are shots. Select the marker, select its camera,
| `checks/` | The checks, one folder per family |
| `ui/` | Sanity Check operators, settings and every panel |
| `rename.py` | Rename tool (logic, settings, operator) |
| `shot_viewer.py` | Shot Viewer (logic, settings, operators) |
| `shot_viewer.py` | Shot Manager (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
+9 -3
View File
@@ -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
@@ -46,9 +46,12 @@ def register() -> None:
# Register the rename tool (settings and operator, before its panel)
rename.register()
# Register the shot viewer (settings and operators, before its panel)
# Register the shot manager (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()
@@ -77,9 +80,12 @@ def unregister() -> None:
# Unregister the rename tool (after its panel)
rename.unregister()
# Unregister the shot viewer (after its panel)
# Unregister the shot manager (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).
+2
View File
@@ -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",
+69 -4
View File
@@ -234,7 +234,7 @@ RENAME_TYPE_SUFFIXES = {
}
# --------------------------------------------------------------------------- #
# Shot viewer
# Shot manager
# --------------------------------------------------------------------------- #
# Groups of the rig quick selection: an armature goes to the first group whose
# pattern matches its name, one of its collections or its library file name.
@@ -243,12 +243,21 @@ 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.
# Shots added by the shot manager. TODO: confirm with production.
SHOT_NAME_FORMAT = "sh{:04d}"
# Sequence of the scene (number set in the Shot Viewer panel): SEQUENCE_NAME_FORMAT.format(number).
# Sequence of the scene (number set in the Shot Manager panel): SEQUENCE_NAME_FORMAT.format(number).
SEQUENCE_NAME_FORMAT = "sq{:04d}"
# Cameras added by the shot viewer. Tokens: {prefix} (OBJECT_PREFIXES["CAMERA"]),
# Cameras added by the shot manager. Tokens: {prefix} (OBJECT_PREFIXES["CAMERA"]),
# {sequence} (SEQUENCE_NAME_FORMAT) and {shot} (the shot name).
SHOT_CAMERA_NAME_FORMAT = "{prefix}{sequence}_{shot}"
# Their rig, same tokens with {prefix} = OBJECT_PREFIXES["ARMATURE"].
@@ -259,9 +268,65 @@ 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",
}
# Levels of detail: child collections of the asset collection, one per LOD ({asset} =
# asset name, {lod} = LOD name, any name: COL_bob_LOD0, COL_bob_LODhigh...). An override
# shows one and hides the others; a collection instance instances it. Keep a fixed part
# around {lod} ("LOD"): every child collection matching the pattern counts as a LOD.
SCENE_MANAGER_LOD_COLLECTION = "COL_{asset}_LOD{lod}"
# Order of the LOD names; the first one the file has is shown when an asset is loaded.
# LODs not listed come after them: numbers in order (0, 1, 2...), then names A-Z.
SCENE_MANAGER_LODS = ["high", "mid", "low"]
# Name of each new instance: {name} is the asset collection (override) or the asset
# (collection instance), {number} counts on from the highest number in the file.
SCENE_MANAGER_INSTANCE_NAME = "{name}_{number:02d}"
# Collection gathering the loaded assets of a type, under the scene collection.
SCENE_MANAGER_TYPE_COLLECTION = "COL_{asset_type}"
# Default task of the assetize dialog.
SCENE_MANAGER_ASSETIZE_TASK = "modeling"
# --------------------------------------------------------------------------- #
# UI
# --------------------------------------------------------------------------- #
# Colored icon in the header of each panel, to tell the tools apart (the Scene Manager
# sub-panels use its color). COLLECTION_COLOR_01 to _08: red, orange, yellow, green,
# blue, violet, pink, brown. Red is left out: it marks errors in the panels.
PANEL_COLORS = {
"Tools": "COLLECTION_COLOR_05",
"Rename": "COLLECTION_COLOR_03",
"Scene Manager": "COLLECTION_COLOR_04",
"Shot Manager": "COLLECTION_COLOR_06",
"Sanity Check": "COLLECTION_COLOR_02",
}
CATEGORY_ORDER = [
"Scene",
"File",
+15
View File
@@ -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()
+150
View File
@@ -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
+377
View File
@@ -0,0 +1,377 @@
"""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):
"""The objects of one instance: the empty, or the override's own objects. Linked objects an
override keeps (bone widgets in a collection with nothing to override...) are shared by
every instance of the asset: they are left out, so selecting them picks no instance."""
if isinstance(instance, bpy.types.Object):
return [instance]
return [ob for ob in instance.all_objects if ob.library is None]
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":
instance = collection.override_hierarchy_create(scene, view_layer)
if scene.collection.children.get(instance.name) == instance and parent != scene.collection:
scene.collection.children.unlink(instance)
if parent.children.get(instance.name) != instance:
parent.children.link(instance)
instance.name = numbered_name(collection.name, bpy.data.collections)
else:
name = numbered_name(library.get("wtd_asset_name", collection.name), bpy.data.objects)
instance = bpy.data.objects.new(name, None)
instance.instance_type = "COLLECTION"
instance.instance_collection = collection
parent.objects.link(instance)
apply_default_lod(library, instance)
return instance
def numbered_name(base, ids):
"""config SCENE_MANAGER_INSTANCE_NAME for the number after the highest one ``ids`` use
(bob_01, bob_02...; removing bob_01 does not free its number while bob_02 exists)."""
template = config.SCENE_MANAGER_INSTANCE_NAME
before, _, rest = template.partition("{number")
after = rest.partition("}")[2]
pattern = re.compile(re.escape(before.format(name=base)) + r"(\d+)" + re.escape(after.format(name=base)) + "$")
numbers = [int(match.group(1)) for id in ids if (match := pattern.match(id.name))]
return template.format(name=base, number=max(numbers, default=0) + 1)
# --------------------------------------------------------------------------- #
# Levels of detail
# --------------------------------------------------------------------------- #
def _lod_pattern(asset_name):
before, _, after = config.SCENE_MANAGER_LOD_COLLECTION.partition("{lod}")
return re.compile(
re.escape(before.format(asset=asset_name)) + r"(.+?)" + re.escape(after.format(asset=asset_name)) + r"(?:\.\d{3,})?$"
)
def lod_order(name):
"""Sort key of a LOD name: config SCENE_MANAGER_LODS order, then numbers, then names A-Z."""
listed = [str(n) for n in config.SCENE_MANAGER_LODS]
if name in listed:
return (0, listed.index(name), "")
if name.isdigit():
return (1, int(name), "")
return (2, 0, name.lower())
def lods(library, instance):
"""{LOD name: collection} in config order: the LOD child collections of the asset
collection, linked for a collection instance, their overrides for an override."""
root = linked_collection(library) if isinstance(instance, bpy.types.Object) else instance
if root is None:
return {}
pattern = _lod_pattern(library.get("wtd_asset_name", library.name))
found = {}
for child in root.children:
# Overrides of a second instance are renamed (COL_bob_LOD0.001): match the linked name.
name = child.override_library.reference.name if child.override_library else child.name
match = pattern.match(name)
if match:
found[match.group(1)] = child
return {name: found[name] for name in sorted(found, key=lod_order)}
def current_lod(library, instance):
"""LOD shown by the instance, None when it shows none or several."""
found = lods(library, instance)
if isinstance(instance, bpy.types.Object):
return next((lod for lod, c in found.items() if c == instance.instance_collection), None)
shown = [lod for lod, c in found.items() if not c.hide_viewport]
return shown[0] if len(shown) == 1 else None
def set_lod(library, instance, lod):
"""Show one LOD: an override hides the other LOD collections (viewport and render, so
they are not evaluated), a collection instance instances the LOD collection."""
found = lods(library, instance)
if lod not in found:
available = ", ".join(found) or "none"
raise LoadError(f"{instance.name}: no LOD {lod} (LODs in the file: {available})")
if isinstance(instance, bpy.types.Object):
root = linked_collection(library)
others = [c.name for c in root.children if c not in found.values()]
if root.objects or others:
raise LoadError(
f"{instance.name}: {library.name} has content outside its LOD collections "
f"({', '.join([ob.name for ob in root.objects] + others)}); load it as an override to switch LODs"
)
instance.instance_collection = found[lod]
return
for name, collection in found.items():
collection.hide_viewport = collection.hide_render = name != lod
def apply_default_lod(library, instance):
"""After a load or an update: show the first LOD in config order when the instance shows
no single LOD. An instance of a LOD the file no longer has goes back to the whole asset
collection first."""
found = lods(library, instance)
root = linked_collection(library)
if isinstance(instance, bpy.types.Object) and root and instance.instance_collection not in (root, *found.values()):
instance.instance_collection = root
if not found or current_lod(library, instance) is not None:
return
try:
set_lod(library, instance, next(iter(found)))
except LoadError:
pass # a collection instance with content outside its LODs keeps the whole collection
# --------------------------------------------------------------------------- #
# 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"]
# The new revision may add, rename or drop LOD collections.
for instance in instances(library):
apply_default_lod(library, instance)
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]
File diff suppressed because it is too large. Load diff
+304 -30
View File
@@ -1,9 +1,10 @@
"""Shot viewer: switch, add and delete the shots of the scene from the sidebar.
"""Shot manager: switch, add and delete the shots of the scene from the sidebar.
Shots are the timeline markers bound to a camera (Ctrl+B in the timeline). A
shot runs from its marker to the frame before the next camera marker, the last
one to the scene end. Showing a shot puts the 3D view the panel is used from
in that shot's camera view. The panel also selects the character / prop rigs.
in that shot's camera view. The panel also selects the character / prop rigs,
and builds the shots of a Kitsu sequence (Libreflow, through the Scene Manager).
"""
import os
@@ -11,9 +12,11 @@ import re
import bpy
from bpy.app.handlers import persistent
from bpy.props import BoolProperty, IntProperty, PointerProperty, StringProperty
from bpy.props import BoolProperty, CollectionProperty, EnumProperty, IntProperty, PointerProperty, StringProperty
from . import config
from .scene_manager import ipc
from .scene_manager import operators as sm
OTHER_GROUP = "Other"
@@ -113,10 +116,17 @@ def sequence_name(scene):
return config.SEQUENCE_NAME_FORMAT.format(scene.wtd_sequence)
def camera_name(scene, shot):
"""Name of a new shot camera (config SHOT_CAMERA_NAME_FORMAT)."""
def sequence_number(name):
"""Number of a sequence name written with SEQUENCE_NAME_FORMAT, else None ("sq0040" -> 40)."""
head, _sep, rest = config.SEQUENCE_NAME_FORMAT.partition("{")
match = re.fullmatch(re.escape(head) + r"(\d+)" + re.escape(rest.partition("}")[2]), name)
return int(match.group(1)) if match else None
def camera_name(scene, shot, sequence=None):
"""Name of a new shot camera (config SHOT_CAMERA_NAME_FORMAT), in the scene's sequence by default."""
return config.SHOT_CAMERA_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("CAMERA", ""), sequence=sequence_name(scene), shot=shot
prefix=config.OBJECT_PREFIXES.get("CAMERA", ""), sequence=sequence or sequence_name(scene), shot=shot
)
@@ -125,45 +135,146 @@ def camera_rig_available():
return hasattr(bpy.types, "OBJECT_OT_build_camera_rig")
def _new_camera_rig(context, shot, is_3d):
def _new_camera_rig(context, shot, kind, sequence=None):
"""Camera rig built by the Add Camera Rigs add-on at the 3D cursor, named for the shot.
Returns the camera object."""
kind is a key of config SHOT_CAMERA_RIG_MODES ("3D", "2D"). Returns the camera object."""
scene = context.scene
if context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
before = {ob.as_pointer() for ob in bpy.data.objects}
bpy.ops.object.build_camera_rig(mode=config.SHOT_CAMERA_RIG_MODES["3D" if is_3d else "2D"])
bpy.ops.object.build_camera_rig(mode=config.SHOT_CAMERA_RIG_MODES[kind])
# Found by difference: the add-on may also add widget objects for the bone shapes.
new = [ob for ob in bpy.data.objects if ob.as_pointer() not in before]
camera = next((ob for ob in new if ob.type == "CAMERA"), None)
if camera is None:
raise RuntimeError("build_camera_rig created no camera")
camera.name = camera.data.name = camera_name(scene, shot)
camera.name = camera.data.name = camera_name(scene, shot, sequence)
rig = camera.parent
if rig is not None and rig in new:
rig.name = rig.data.name = config.SHOT_CAMERA_RIG_NAME_FORMAT.format(
prefix=config.OBJECT_PREFIXES.get("ARMATURE", ""), sequence=sequence_name(scene), shot=shot
prefix=config.OBJECT_PREFIXES.get("ARMATURE", ""), sequence=sequence or sequence_name(scene), shot=shot
)
return camera
def append_shot(context, name, camera, duration):
"""Camera marker for the shot after the last one (at SHOT_FRAME_START in an empty scene),
the scene end moved to its last frame. A marker of that name without camera is reused."""
scene = context.scene
ordered = shots(scene)
if ordered:
start = shot_range(ordered[-1])[1] + 1
else:
start = config.SHOT_FRAME_START
scene.frame_start = start
marker = scene.timeline_markers.get(name)
if marker is None or marker.camera is not None:
marker = scene.timeline_markers.new(name, frame=start)
marker.frame = start
marker.camera = camera
scene.frame_end = start + duration - 1
return marker
# --------------------------------------------------------------------------- #
# Kitsu sequence
# --------------------------------------------------------------------------- #
# Shots of the sequence picked for Shots from Kitsu (wtd_get_sequence_shots result).
KITSU = {}
def kitsu_sequence_shots(uid, oid):
"""wtd_get_sequence_shots for the sequence of the oid. With a wtdcmds too old to have it,
built from wtd_list_shots and the casting of every shot, without frame counts: the shots
Kitsu does not know are left out."""
try:
return ipc.call("wtd_get_sequence_shots", uid, oid=oid)
except ipc.LibreflowError as exc:
if exc.status != 404:
raise
context = ipc.call("wtd_get_context", uid, oid=oid)
result = {key: context.get(key) for key in ("film", "sequence", "sequence_oid")}
result["shots"] = []
for shot in ipc.call("wtd_list_shots", uid, oid=oid, film=context.get("film"), sequence=context.get("sequence")):
try:
casting = ipc.call("wtd_get_casting", uid, oid=shot["oid"])
except ipc.LibreflowError as exc:
if exc.status is None:
raise
continue
result["shots"].append(
{
"shot": shot["shot"],
"oid": shot["oid"],
"nb_frames": None,
"casting": [
{"name": a["name"], "asset_type": a["asset_type"], "nb_occurences": a["nb_occurences"]}
for a in casting["assets"]
],
}
)
return result
def kitsu_shot_state(scene, shot, only_cast):
""""EXISTS" (a camera marker has its name), "UNCAST" (no casting, skipped with only_cast) or "NEW"."""
if any(m.name == shot["shot"] for m in shots(scene)):
return "EXISTS"
if only_cast and not shot["casting"]:
return "UNCAST"
return "NEW"
def create_kitsu_shots(context, data, cameras, only_cast, duration, default_camera):
"""A camera rig and a shot for every NEW shot of the sequence, in Kitsu order, after the
shots of the scene. The rig is cameras[shot name], else default_camera (keys of config
SHOT_CAMERA_RIG_MODES). Kitsu's frame count is the shot length, else duration. Returns the markers."""
scene = context.scene
number = sequence_number(data["sequence"])
if number is not None:
scene.wtd_sequence = number
markers = []
for shot in data["shots"]:
if kitsu_shot_state(scene, shot, only_cast) != "NEW":
continue
kind = cameras.get(shot["shot"], default_camera)
camera = _new_camera_rig(context, shot["shot"], kind, data["sequence"])
markers.append(append_shot(context, shot["shot"], camera, shot.get("nb_frames") or duration))
return markers
# --------------------------------------------------------------------------- #
# 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 +286,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}
@@ -277,24 +383,16 @@ class WTD_OT_shot_viewer_new_shot(bpy.types.Operator):
if not camera_rig_available():
self.report({"WARNING"}, "Enable the Add Camera Rigs add-on to build the shot camera")
return {"CANCELLED"}
camera = _new_camera_rig(context, name, self.camera_3d)
camera = _new_camera_rig(context, name, "3D" if self.camera_3d else "2D")
else:
camera = scene.objects.get(self.camera_name)
if camera is None or camera.type != "CAMERA":
self.report({"WARNING"}, "Pick a camera of the scene, or tick New Camera")
return {"CANCELLED"}
ordered = shots(scene)
if ordered:
start = shot_range(ordered[-1])[1] + 1
else:
start = config.SHOT_FRAME_START
scene.frame_start = start
marker = scene.timeline_markers.new(name, frame=start)
marker.camera = camera
scene.frame_end = start + self.duration - 1
marker = append_shot(context, name, camera, self.duration)
show_shot(context, marker)
self.report({"INFO"}, f"Shot '{marker.name}' added at frame {start} ({camera.name})")
self.report({"INFO"}, f"Shot '{marker.name}' added at frame {marker.frame} ({camera.name})")
return {"FINISHED"}
@@ -351,6 +449,179 @@ class WTD_OT_shot_viewer_delete_shot(bpy.types.Operator):
return {"FINISHED"}
def _sequence_items(self, context):
return _SEQUENCE_ITEMS
_SEQUENCE_ITEMS = []
class WTD_OT_shot_viewer_kitsu_sequence(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_kitsu_sequence"
bl_label = "Shots from Kitsu"
bl_description = (
"Pick a sequence, then build a camera rig and a shot for each of its Kitsu shots "
"(Libreflow, connected in the Scene Manager)"
)
bl_property = "sequence"
sequence: EnumProperty(items=_sequence_items)
@classmethod
def poll(cls, context):
return sm.connected()
def invoke(self, context, event):
oid = sm.project_oid(context.scene)
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:
listed = ipc.call("wtd_list_shots", sm.session_uid(), oid=oid)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Sequences: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
sequences = {s["sequence_oid"]: f"{s.get('film', '')} / {s['sequence']}" for s in listed if s.get("sequence_oid")}
# The sequence of the scene's context first.
current = (sm.context_oid(context.scene) or "").split("/shots/")[0]
_SEQUENCE_ITEMS[:] = sorted(
((oid, label, oid) for oid, label in sequences.items()), key=lambda item: (item[0] != current, item[1])
)
if not _SEQUENCE_ITEMS:
self.report({"WARNING"}, "No sequence in the project")
return {"CANCELLED"}
context.window_manager.invoke_search_popup(self)
return {"RUNNING_MODAL"}
def execute(self, context):
context.window.cursor_set("WAIT")
try:
data = kitsu_sequence_shots(sm.session_uid(), self.sequence)
except ipc.LibreflowError as exc:
self.report({"WARNING"}, f"Kitsu shots: {exc}")
return {"CANCELLED"}
finally:
context.window.cursor_set("DEFAULT")
if not data["shots"]:
self.report({"WARNING"}, f"No Kitsu shot in {data['sequence']}")
return {"CANCELLED"}
KITSU.clear()
KITSU.update(data)
bpy.ops.wtd_tools.shot_viewer_kitsu_shots("INVOKE_DEFAULT")
return {"FINISHED"}
KITSU_STATE_ICONS = {"NEW": "CAMERA_DATA", "EXISTS": "CHECKMARK", "UNCAST": "BLANK1"}
# Camera rig choices: the keys of config SHOT_CAMERA_RIG_MODES ("3D", "2D").
CAMERA_KIND_ITEMS = [
(kind, kind, f"{mode.title()} camera rig (Add Camera Rigs)") for kind, mode in config.SHOT_CAMERA_RIG_MODES.items()
]
DEFAULT_CAMERA_KIND = "2D" if "2D" in config.SHOT_CAMERA_RIG_MODES else CAMERA_KIND_ITEMS[0][0]
class WTD_KitsuShotCamera(bpy.types.PropertyGroup):
"""Camera rig of one Kitsu shot in Build Kitsu Shots (name: the shot)."""
camera: EnumProperty(name="Camera", items=CAMERA_KIND_ITEMS, default=DEFAULT_CAMERA_KIND)
def _all_cameras_update(self, context):
for item in self.cameras:
item.camera = self.camera
class WTD_OT_shot_viewer_kitsu_shots(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_kitsu_shots"
bl_label = "Build Kitsu Shots"
bl_description = "Build a camera rig and a shot for each Kitsu shot of the picked sequence not in the scene yet"
bl_options = {"REGISTER", "UNDO"}
only_cast: BoolProperty(
name="Only Cast Shots",
description="Leave out the shots with nothing cast in Kitsu",
default=True,
)
camera: EnumProperty(
name="All Cameras",
description="Camera rig of every shot. Each shot can then be changed in its line",
items=CAMERA_KIND_ITEMS,
default=DEFAULT_CAMERA_KIND,
update=_all_cameras_update,
)
cameras: CollectionProperty(type=WTD_KitsuShotCamera)
duration: IntProperty(
name="Default Duration",
description="Length of the shots without a frame count in Kitsu",
min=1,
default=48,
)
@classmethod
def poll(cls, context):
return bool(KITSU.get("shots"))
def invoke(self, context, event):
self.duration = config.SHOT_DEFAULT_DURATION
self.cameras.clear()
for shot in KITSU["shots"]:
item = self.cameras.add()
item.name = shot["shot"]
item.camera = self.camera
return context.window_manager.invoke_props_dialog(self, width=560)
def draw(self, context):
layout = self.layout
scene = context.scene
layout.label(text=f"{KITSU.get('film') or ''} / {KITSU['sequence']}", icon="SEQUENCE")
col = layout.column(align=True)
for shot in KITSU["shots"]:
state = kitsu_shot_state(scene, shot, self.only_cast)
row = col.row()
info = row.row()
info.active = state == "NEW"
info.label(text=shot["shot"], icon=KITSU_STATE_ICONS[state])
info.label(text=f"{shot['nb_frames']} fr" if shot.get("nb_frames") else "? fr")
names = ", ".join(c["name"] for c in shot["casting"])
info.label(text=names or "nothing cast")
choice = row.row()
choice.ui_units_x = 5
item = self.cameras.get(shot["shot"])
if state == "NEW" and item is not None:
choice.prop(item, "camera", expand=True)
else:
choice.label(text="")
layout.separator()
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(self, "camera", expand=True)
layout.prop(self, "only_cast")
layout.prop(self, "duration")
new = [s for s in KITSU["shots"] if kitsu_shot_state(scene, s, self.only_cast) == "NEW"]
if not new:
layout.label(text="Every shot is in the scene already", icon="INFO")
else:
first = camera_name(scene, new[0]["shot"], KITSU["sequence"])
layout.label(text=f"{len(new)} shot(s) to build: {first}...", icon="CAMERA_DATA")
if not camera_rig_available():
layout.label(text="Enable the Add Camera Rigs add-on", icon="ERROR")
def execute(self, context):
if not camera_rig_available():
self.report({"WARNING"}, "Enable the Add Camera Rigs add-on to build the shot cameras")
return {"CANCELLED"}
cameras = {item.name: item.camera for item in self.cameras}
markers = create_kitsu_shots(context, KITSU, cameras, self.only_cast, self.duration, self.camera)
if not markers:
self.report({"INFO"}, "Every shot is in the scene already")
return {"CANCELLED"}
show_shot(context, markers[0])
self.report({"INFO"}, f"{len(markers)} shot(s) built from {KITSU['sequence']}: " + ", ".join(m.name for m in markers))
return {"FINISHED"}
class WTD_OT_shot_viewer_select_rig(bpy.types.Operator):
bl_idname = "wtd_tools.shot_viewer_select_rig"
bl_label = "Select Rig"
@@ -389,6 +660,9 @@ classes = (
WTD_OT_shot_viewer_step,
WTD_OT_shot_viewer_new_shot,
WTD_OT_shot_viewer_delete_shot,
WTD_OT_shot_viewer_kitsu_sequence,
WTD_KitsuShotCamera,
WTD_OT_shot_viewer_kitsu_shots,
WTD_OT_shot_viewer_select_rig,
)
+409 -10
View File
@@ -5,6 +5,9 @@ import bpy
from .. import config, rename, shot_viewer, tools_ops
from ..core import CheckContext, Severity, checks_for_context, contexts, runner
from ..core.runner import Status
from ..scene_manager import ipc
from ..scene_manager import library as sm_library
from ..scene_manager import operators as sm
from .operators import EXPANDED
STATUS_ICONS = {
@@ -163,11 +166,21 @@ def _draw_check(layout, context, check, report, disabled):
col.separator(factor=0.5)
def _draw_color_header(self, context):
"""Colored icon in the panel header (config PANEL_COLORS), to tell the tools apart."""
self.layout.label(text="", icon=config.PANEL_COLORS.get(self.color, "NONE"))
# Sidebar order (bl_order), top to bottom: Tools, Rename, Scene Manager, Shot Manager,
# Sanity Check.
class _SanityPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Sanity Check"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
bl_order = 4
color = "Sanity Check"
draw_header = _draw_color_header
def draw(self, context):
draw_sanity_checks_panel(self, context)
@@ -177,6 +190,9 @@ class _ToolsPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Tools"
bl_order = 0
color = "Tools"
draw_header = _draw_color_header
def draw(self, context) -> None:
layout = self.layout
@@ -194,6 +210,9 @@ class _RenamePanel:
bl_category = "WTD"
bl_label = "Rename"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
bl_order = 1
color = "Rename"
draw_header = _draw_color_header
PREVIEW_COUNT = 10
@@ -241,7 +260,18 @@ class _RenamePanel:
class WTD_UL_shots(bpy.types.UIList):
"""Camera markers of the scene, in time order."""
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
def draw_item(
self,
context,
layout,
data,
item,
icon,
active_data,
active_property,
index=0,
flt_flag=0,
):
start, end = shot_viewer.shot_range(item)
split = layout.split(factor=0.4)
split.label(text=item.name, icon="MARKER_HLT")
@@ -253,7 +283,10 @@ class WTD_UL_shots(bpy.types.UIList):
markers = getattr(data, propname)
ordered = [marker.as_pointer() for marker in shot_viewer.shots(data)]
position = {pointer: index for index, pointer in enumerate(ordered)}
flags = [self.bitflag_filter_item if marker.as_pointer() in position else 0 for marker in markers]
flags = [
self.bitflag_filter_item if marker.as_pointer() in position else 0
for marker in markers
]
if self.filter_name:
by_name = bpy.types.UI_UL_list.filter_items_by_name(
self.filter_name, self.bitflag_filter_item, markers, "name"
@@ -275,7 +308,11 @@ class WTD_UL_shots(bpy.types.UIList):
class _ShotViewerPanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Shot Viewer"
bl_label = "Shot Manager"
bl_options = {"DEFAULT_CLOSED"} # panel starts collapsed
bl_order = 3
color = "Shot Manager"
draw_header = _draw_color_header
def draw(self, context) -> None:
layout = self.layout
@@ -290,15 +327,40 @@ class _ShotViewerPanel:
row = layout.row(align=True)
row.scale_y = 1.4
row.enabled = has_shots
row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Previous", icon="TRIA_LEFT").step = -1
row.operator(shot_viewer.WTD_OT_shot_viewer_step.bl_idname, text="Next", icon="TRIA_RIGHT").step = 1
row.operator(
shot_viewer.WTD_OT_shot_viewer_step.bl_idname,
text="Previous",
icon="TRIA_LEFT",
).step = -1
row.operator(
shot_viewer.WTD_OT_shot_viewer_step.bl_idname,
text="Next",
icon="TRIA_RIGHT",
).step = 1
row = layout.row()
row.template_list("WTD_UL_shots", "", scene, "timeline_markers", settings, "shot_index", rows=5)
row.template_list(
"WTD_UL_shots",
"",
scene,
"timeline_markers",
settings,
"shot_index",
rows=5,
)
col = row.column(align=True)
col.operator(shot_viewer.WTD_OT_shot_viewer_new_shot.bl_idname, text="", icon="ADD")
col.operator(shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE")
col.operator(
shot_viewer.WTD_OT_shot_viewer_new_shot.bl_idname, text="", icon="ADD"
)
col.operator(
shot_viewer.WTD_OT_shot_viewer_delete_shot.bl_idname, text="", icon="REMOVE"
)
if not has_shots:
layout.label(text="No camera marker yet: add a shot with +", icon="INFO")
layout.operator(
shot_viewer.WTD_OT_shot_viewer_kitsu_sequence.bl_idname,
text="Shots from Kitsu...",
icon="OUTLINER_OB_CAMERA",
)
layout.prop(settings, "loop_shot")
groups = shot_viewer.rig_groups(context.view_layer)
@@ -316,6 +378,319 @@ 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"
bl_order = 2
color = "Scene Manager"
draw_header = _draw_color_header
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"
color = "Scene Manager"
draw_header = _draw_color_header
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"
color = "Scene Manager"
draw_header = _draw_color_header
bl_parent_id = "WTD_PT_scene_manager_view3d"
def draw(self, context) -> None:
layout = self.layout
layout.operator(
sm.WTD_OT_scene_browse.bl_idname, text="Load...", icon="VIEWZOOM"
)
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
lods = sm_library.lods(library, instance)
if lods:
current = sm_library.current_lod(library, instance)
sub = col.row(align=True)
sub.label(text="LOD", icon="BLANK1")
for name in lods:
op = sub.operator(
sm.WTD_OT_scene_set_lod.bl_idname,
text=name,
depress=name == current,
)
op.lod, op.library, op.instance = (
name,
library.name,
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
selected = [
(library, instance)
for library, instance in sm_library.selected_instances(context.view_layer)
if sm_library.lods(library, instance)
]
if selected:
col = layout.column(align=True)
col.label(text="LOD of Selected")
row = col.row(align=True)
shown = {
sm_library.current_lod(library, instance)
for library, instance in selected
}
names = {
name
for library, instance in selected
for name in sm_library.lods(library, instance)
}
for name in sorted(names, key=sm_library.lod_order):
op = row.operator(
sm.WTD_OT_scene_set_lod.bl_idname,
text=name,
depress=shown == {name},
)
op.lod, op.selected = name, True
class _SceneAssetizePanel:
bl_region_type = "UI"
bl_category = "WTD"
bl_label = "Assetize"
color = "Scene Manager"
draw_header = _draw_color_header
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 +716,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"
@@ -348,11 +743,15 @@ class WTD_PT_sanity_sequencer(_SanityPanel, bpy.types.Panel):
classes = (
WTD_UL_shots,
WTD_PT_sanity_view3d,
WTD_PT_tools_view3d,
WTD_PT_shot_viewer_view3d,
WTD_PT_rename_view3d,
WTD_PT_rename_node_editor,
WTD_PT_scene_manager_view3d,
WTD_PT_scene_casting_view3d,
WTD_PT_scene_loaded_view3d,
WTD_PT_scene_assetize_view3d,
WTD_PT_shot_viewer_view3d,
WTD_PT_sanity_view3d,
WTD_PT_sanity_sequencer,
)