Compare commits
8
Commits
scene-manager
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644aa32014 | ||
|
|
8dcd4daad1 | ||
|
|
4161ec4ad5 | ||
|
|
ce30491cb8 | ||
|
|
3f87904cd1 | ||
|
|
fbc7ac18e4 | ||
|
|
6ec2b228ae | ||
|
|
bac7a6f5da |
No files matched your search
@@ -7,3 +7,6 @@ private
|
||||
.idea
|
||||
# ignore mkdocs build
|
||||
site/
|
||||
|
||||
# ignore dev utilities
|
||||
dev/
|
||||
@@ -1,99 +0,0 @@
|
||||
# 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.
|
||||
@@ -4,7 +4,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.
|
||||
- **[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.
|
||||
|
||||
@@ -19,7 +19,7 @@ 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)
|
||||
@@ -31,7 +31,7 @@ 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.
|
||||
|
||||
@@ -56,15 +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 |
|
||||
| **Scene Manager** (Casting, Loaded, Assetize) | 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -238,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.
|
||||
|
||||
@@ -255,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.
|
||||
|
||||
@@ -293,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 **−**:
|
||||
@@ -333,7 +356,7 @@ The first line shows the Libreflow session, the user and the site. When several
|
||||
The field under the connection is the **context**: the Libreflow oid of the shot this file belongs to, like `/melvile/films/f01/sequences/s010/shots/p010`. It is saved in the file and does not change when you browse in Libreflow.
|
||||
|
||||
- **Libreflow Oid**: takes the oid shown in the Libreflow window.
|
||||
- **Pick Shot**: search the shots of the project.
|
||||
- **Pick Shot**: search the shots of the project (every film). Only shots created in Libreflow are listed: a film whose sequences and shots are not in Libreflow yet shows none.
|
||||
|
||||
A valid shot is confirmed under the field (`shot: f01 / s010 / p010`). The casting and the versions are then read on their own.
|
||||
|
||||
@@ -360,6 +383,8 @@ Lists the assets cast in the shot, with how many instances are loaded out of how
|
||||
- **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).
|
||||
|
||||
@@ -367,15 +392,53 @@ All of these can be changed in the [Configuration](#configuration).
|
||||
|
||||
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.
|
||||
- **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. **Ctrl+Z** undoes removing and updating.
|
||||
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
|
||||
|
||||
@@ -432,15 +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_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` |
|
||||
| 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.
|
||||
|
||||
@@ -453,10 +520,10 @@ 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.
|
||||
|
||||
**Scene Manager: "Libreflow is not running".**
|
||||
@@ -471,7 +538,10 @@ The newer version is published but not downloaded on this site yet. Request it (
|
||||
**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.**
|
||||
**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.
|
||||
|
||||
---
|
||||
@@ -489,7 +559,7 @@ Libreflow runs an older `wtdcmds` without the command (e.g. requests). Update th
|
||||
| `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` |
|
||||
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ 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)
|
||||
@@ -80,7 +80,7 @@ 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)
|
||||
|
||||
@@ -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.
|
||||
@@ -253,11 +253,11 @@ SHOT_VIEWER_ASSET_TYPE_GROUPS = {
|
||||
"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"].
|
||||
@@ -298,6 +298,17 @@ SCENE_MANAGER_LINK_MODES = {
|
||||
"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.
|
||||
@@ -306,6 +317,16 @@ 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",
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,61 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,249 +0,0 @@
|
||||
"""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")
|
||||
+117
-12
@@ -67,7 +67,12 @@ def instances(library):
|
||||
|
||||
|
||||
def instance_objects(instance):
|
||||
return [instance] if isinstance(instance, bpy.types.Object) else list(instance.all_objects)
|
||||
"""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):
|
||||
@@ -158,17 +163,114 @@ def add_instance(scene, view_layer, library, parent=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
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -190,6 +292,9 @@ def update(library_name, revision):
|
||||
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
|
||||
|
||||
|
||||
|
||||
+240
-17
@@ -1,10 +1,11 @@
|
||||
"""Scene Manager operators: connection, shot context, casting, load, update, remove, assetize.
|
||||
"""Scene Manager operators: connection, shot context, casting, load, update, LOD, 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 fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -13,7 +14,7 @@ import time
|
||||
|
||||
import bpy
|
||||
from bpy.app.handlers import persistent
|
||||
from bpy.props import BoolProperty, EnumProperty, PointerProperty, StringProperty
|
||||
from bpy.props import BoolProperty, EnumProperty, IntProperty, PointerProperty, StringProperty
|
||||
|
||||
from .. import config
|
||||
from . import ipc
|
||||
@@ -33,7 +34,8 @@ class _State:
|
||||
self.files = {} # file oid -> {latest, latest_available} or {error}
|
||||
self.revisions = {} # file oid -> wtd_get_revisions result
|
||||
self.shots = [] # wtd_list_shots, for the shot picker
|
||||
self.assets = [] # wtd_list_assets, for the assetize dialog
|
||||
self.assets = [] # wtd_list_assets, for Load Publish and the assetize dialog
|
||||
self.browse = {} # Load Publish: {asset, files: [wtd_get_asset_files + revisions]}
|
||||
self.message = ("", "INFO") # last outcome, shown in the panel
|
||||
|
||||
|
||||
@@ -480,6 +482,190 @@ class WTD_OT_scene_load(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Load any publish
|
||||
# --------------------------------------------------------------------------- #
|
||||
def project_oid(scene):
|
||||
"""Project of the scene's context, else of the oid open in Libreflow ("/melvile")."""
|
||||
oid = (context_oid(scene) or current_session().get("current_oid") or "").strip("/")
|
||||
return "/" + oid.split("/")[0] if oid else ""
|
||||
|
||||
|
||||
def default_file(asset_type, files):
|
||||
"""The file the casting would load (config SCENE_MANAGER_FILE_PATTERNS), else the first one."""
|
||||
patterns = config.SCENE_MANAGER_FILE_PATTERNS
|
||||
for pattern in patterns.get(asset_type, patterns.get("*", [])):
|
||||
found = next((f for f in files if fnmatch.fnmatchcase(f["label"], pattern)), None)
|
||||
if found:
|
||||
return found
|
||||
return files[0]
|
||||
|
||||
|
||||
def default_revision(file):
|
||||
"""Latest revision on this site, else the latest one."""
|
||||
revisions = file.get("revisions") or []
|
||||
return next((r for r in revisions if r.get("available")), revisions[0] if revisions else None)
|
||||
|
||||
|
||||
_BROWSE_ASSET_ITEMS = []
|
||||
_BROWSE_FILE_ITEMS = []
|
||||
_BROWSE_REVISION_ITEMS = {} # file oid -> enum items (kept alive for Blender)
|
||||
|
||||
|
||||
def _browse_asset_items(self, context):
|
||||
return _BROWSE_ASSET_ITEMS
|
||||
|
||||
|
||||
def _browse_file_items(self, context):
|
||||
return _BROWSE_FILE_ITEMS
|
||||
|
||||
|
||||
def _browse_revision_items(self, context):
|
||||
return _BROWSE_REVISION_ITEMS.get(self.file, [])
|
||||
|
||||
|
||||
def _browse_file_update(self, context):
|
||||
file = next((f for f in STATE.browse.get("files", []) if f["file_oid"] == self.file), None)
|
||||
revision = default_revision(file) if file else None
|
||||
if revision is not None:
|
||||
self.revision = revision["name"]
|
||||
|
||||
|
||||
class WTD_OT_scene_browse(bpy.types.Operator):
|
||||
bl_idname = "wtd_scene.browse"
|
||||
bl_label = "Load Publish"
|
||||
bl_description = "Search any asset of the project, then load one of its published files at any version"
|
||||
bl_property = "asset"
|
||||
|
||||
asset: EnumProperty(items=_browse_asset_items)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return connected()
|
||||
|
||||
def invoke(self, context, event):
|
||||
project = project_oid(context.scene)
|
||||
if not project:
|
||||
self.report({"WARNING"}, "Set a context oid (or open the project in Libreflow) first")
|
||||
return {"CANCELLED"}
|
||||
context.window.cursor_set("WAIT")
|
||||
try:
|
||||
STATE.assets = ipc.call("wtd_list_assets", session_uid(), oid=project)
|
||||
except ipc.LibreflowError as exc:
|
||||
self.report({"WARNING"}, f"Assets: {exc}")
|
||||
return {"CANCELLED"}
|
||||
finally:
|
||||
context.window.cursor_set("DEFAULT")
|
||||
assets = sorted(STATE.assets, key=lambda a: (a["asset"].lower(), a.get("asset_type") or ""))
|
||||
_BROWSE_ASSET_ITEMS[:] = [(a["oid"], f"{a['asset']} ({a.get('asset_type') or ''})", a["oid"]) for a in assets]
|
||||
if not _BROWSE_ASSET_ITEMS:
|
||||
self.report({"WARNING"}, f"No asset in {project}")
|
||||
return {"CANCELLED"}
|
||||
context.window_manager.invoke_search_popup(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def execute(self, context):
|
||||
asset = next((a for a in STATE.assets if a["oid"] == self.asset), None)
|
||||
if asset is None:
|
||||
return {"CANCELLED"}
|
||||
context.window.cursor_set("WAIT")
|
||||
try:
|
||||
browse_asset(asset)
|
||||
except ipc.LibreflowError as exc:
|
||||
self.report({"WARNING"}, f"{asset['asset']}: {exc}")
|
||||
return {"CANCELLED"}
|
||||
finally:
|
||||
context.window.cursor_set("DEFAULT")
|
||||
if not STATE.browse["files"]:
|
||||
self.report({"WARNING"}, f"{asset['asset']}: no published .blend file")
|
||||
return {"CANCELLED"}
|
||||
bpy.ops.wtd_scene.load_publish("INVOKE_DEFAULT")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def browse_asset(asset):
|
||||
"""Read the published files of an asset and all their revisions, for the Load Publish dialog."""
|
||||
uid = session_uid()
|
||||
files = ipc.call("wtd_get_asset_files", uid, oid=asset["oid"])
|
||||
for file in files:
|
||||
file["revisions"] = ipc.call("wtd_get_revisions", uid, file_oid=file["file_oid"])
|
||||
files = [f for f in files if f["revisions"]]
|
||||
STATE.browse = {"asset": asset, "files": files}
|
||||
_BROWSE_FILE_ITEMS[:] = [(f["file_oid"], f["label"], f["file_oid"], index) for index, f in enumerate(files)]
|
||||
_BROWSE_REVISION_ITEMS.clear()
|
||||
for file in files:
|
||||
_BROWSE_REVISION_ITEMS[file["file_oid"]] = [
|
||||
(r["name"], revision_label(r), r.get("comment") or "", index) for index, r in enumerate(file["revisions"])
|
||||
]
|
||||
|
||||
|
||||
class WTD_OT_scene_load_publish(bpy.types.Operator):
|
||||
bl_idname = "wtd_scene.load_publish"
|
||||
bl_label = "Load Publish"
|
||||
bl_description = "Load a published file of the asset picked in Load Publish"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
file: EnumProperty(name="File", items=_browse_file_items, update=_browse_file_update)
|
||||
revision: EnumProperty(name="Version", items=_browse_revision_items)
|
||||
count: IntProperty(name="Instances", default=1, min=1, max=50)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return connected() and bool(STATE.browse)
|
||||
|
||||
def invoke(self, context, event):
|
||||
asset = STATE.browse["asset"]
|
||||
self.file = default_file(asset.get("asset_type") or "", STATE.browse["files"])["file_oid"]
|
||||
_browse_file_update(self, context)
|
||||
title = f"Load {asset['asset']} ({asset.get('asset_type') or ''})"
|
||||
return context.window_manager.invoke_props_dialog(self, width=450, title=title, confirm_text="Load")
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "file")
|
||||
layout.prop(self, "revision")
|
||||
layout.prop(self, "count")
|
||||
library = lib.find_library(self.file)
|
||||
if library is not None:
|
||||
layout.label(text=f"Loaded at {library.get('wtd_revision')}: adds instances of that version", icon="INFO")
|
||||
|
||||
def execute(self, context):
|
||||
asset = STATE.browse["asset"]
|
||||
file = next(f for f in STATE.browse["files"] if f["file_oid"] == self.file)
|
||||
revision = next(r for r in file["revisions"] if r["name"] == self.revision)
|
||||
name = asset["asset"]
|
||||
if not revision.get("available"):
|
||||
if revision.get("sync_status") == "Requested":
|
||||
self.report({"WARNING"}, f"{name} {revision['name']} is requested, waiting for the download")
|
||||
else:
|
||||
request_revisions({revision["oid"]: file["file_oid"]})
|
||||
self.report({"INFO"}, f"{name} {revision['name']} is not on this site: requested, load it once downloaded")
|
||||
return {"CANCELLED"}
|
||||
library = lib.find_library(file["file_oid"])
|
||||
if library is not None and library.get("wtd_revision") != revision["name"]:
|
||||
self.report(
|
||||
{"WARNING"},
|
||||
f"{name} {file['label']} is loaded at {library.get('wtd_revision')}: "
|
||||
f"switch it with the version picker of the Loaded panel",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
# Known file: the Loaded panel can tell whether it is the latest version.
|
||||
STATE.files[file["file_oid"]] = {k: file.get(k) for k in ("file_oid", "label", "latest", "latest_available")}
|
||||
try:
|
||||
if library is None:
|
||||
library = lib.link_asset(
|
||||
{"name": name, "oid": asset["oid"], "asset_type": asset.get("asset_type") or "", "file": file},
|
||||
revision,
|
||||
context_oid(context.scene),
|
||||
)
|
||||
added = [lib.add_instance(context.scene, context.view_layer, library).name for _ in range(self.count)]
|
||||
except (lib.LoadError, RuntimeError) as exc:
|
||||
self.report({"WARNING"}, str(exc) if isinstance(exc, lib.LoadError) else f"{name}: {exc}")
|
||||
return {"CANCELLED"}
|
||||
report(f"Loaded {name} {revision['name']}: {', '.join(added)}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Update / versions
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -576,6 +762,19 @@ class WTD_OT_scene_request(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def revision_label(revision):
|
||||
"""'v002 2026-10-27 nero', marked when the revision is not on this site."""
|
||||
r = revision
|
||||
date = time.strftime("%Y-%m-%d", time.localtime(r["date"])) if r.get("date") else ""
|
||||
if r.get("available"):
|
||||
where = ""
|
||||
elif r.get("sync_status") == "Requested":
|
||||
where = " (requested)"
|
||||
else:
|
||||
where = " (not on this site: request)"
|
||||
return f"{r['name']} {date} {r.get('user') or ''}{where}"
|
||||
|
||||
|
||||
def _revision_items(self, context):
|
||||
return _REVISION_ITEMS
|
||||
|
||||
@@ -611,19 +810,7 @@ class WTD_OT_scene_pick_revision(bpy.types.Operator):
|
||||
context.window.cursor_set("DEFAULT")
|
||||
current = library.get("wtd_revision")
|
||||
_REVISION_ITEMS[:] = [
|
||||
(
|
||||
r["name"],
|
||||
f"{r['name']} {time.strftime('%Y-%m-%d', time.localtime(r['date'])) if r.get('date') else ''}"
|
||||
f" {r.get('user') or ''}"
|
||||
+ (
|
||||
""
|
||||
if r.get("available")
|
||||
else " (requested)" if r.get("sync_status") == "Requested" else " (not on this site: request)"
|
||||
),
|
||||
r.get("comment") or "",
|
||||
"CHECKMARK" if r["name"] == current else "NONE",
|
||||
index,
|
||||
)
|
||||
(r["name"], revision_label(r), r.get("comment") or "", "CHECKMARK" if r["name"] == current else "NONE", index)
|
||||
for index, r in enumerate(STATE.revisions[file_oid])
|
||||
]
|
||||
if not _REVISION_ITEMS:
|
||||
@@ -731,6 +918,39 @@ class WTD_OT_scene_select_instance(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class WTD_OT_scene_set_lod(bpy.types.Operator):
|
||||
bl_idname = "wtd_scene.set_lod"
|
||||
bl_label = "Set LOD"
|
||||
bl_description = "Show this level of detail (one of the asset's LOD collections)"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
lod: StringProperty(name="LOD", description="LOD name (the {lod} part of the LOD collection name)")
|
||||
library: StringProperty(options={"SKIP_SAVE"})
|
||||
instance: StringProperty(description="Only this instance (object or collection name)", options={"SKIP_SAVE"})
|
||||
selected: BoolProperty(description="The instances of the selected objects", options={"SKIP_SAVE"})
|
||||
|
||||
def execute(self, context):
|
||||
if self.selected:
|
||||
targets = lib.selected_instances(context.view_layer)
|
||||
else:
|
||||
library = bpy.data.libraries.get(self.library)
|
||||
targets = [(library, i) for i in lib.instances(library) if i.name == self.instance] if library else []
|
||||
targets = [(library, instance) for library, instance in targets if lib.lods(library, instance)]
|
||||
if not targets:
|
||||
self.report({"WARNING"}, "No loaded instance with LODs selected")
|
||||
return {"CANCELLED"}
|
||||
switched, errors = [], []
|
||||
for library, instance in targets:
|
||||
try:
|
||||
lib.set_lod(library, instance, self.lod)
|
||||
switched.append(instance.name)
|
||||
except lib.LoadError as exc:
|
||||
errors.append(str(exc))
|
||||
text = f"LOD {self.lod}: {', '.join(switched)}" if switched else f"LOD {self.lod} not set"
|
||||
self.report({"WARNING"} if errors else {"INFO"}, "; ".join([text, *errors]))
|
||||
return {"FINISHED"} if switched else {"CANCELLED"}
|
||||
|
||||
|
||||
class WTD_OT_scene_toggle_expand(bpy.types.Operator):
|
||||
bl_idname = "wtd_scene.toggle_expand"
|
||||
bl_label = "Show Instances"
|
||||
@@ -784,7 +1004,7 @@ def replace_with_publish(scene, view_layer, collection_name, asset, context_oid_
|
||||
parents = lib.parents_of(local, scene)
|
||||
for parent in parents:
|
||||
parent.children.unlink(local)
|
||||
# Kept in the file, out of the way: the published asset takes its name.
|
||||
# Kept in the file, out of the way: the published asset takes its place (numbered, COL_x_01).
|
||||
local.use_fake_user = True
|
||||
local.name = f"{collection_name}_local"
|
||||
lib.add_instance(scene, view_layer, library, parent=parents[0] if parents else None)
|
||||
@@ -943,11 +1163,14 @@ classes = (
|
||||
WTD_OT_scene_pick_shot,
|
||||
WTD_OT_scene_refresh,
|
||||
WTD_OT_scene_load,
|
||||
WTD_OT_scene_browse,
|
||||
WTD_OT_scene_load_publish,
|
||||
WTD_OT_scene_update,
|
||||
WTD_OT_scene_request,
|
||||
WTD_OT_scene_pick_revision,
|
||||
WTD_OT_scene_remove,
|
||||
WTD_OT_scene_select_instance,
|
||||
WTD_OT_scene_set_lod,
|
||||
WTD_OT_scene_toggle_expand,
|
||||
WTD_OT_scene_assetize,
|
||||
)
|
||||
|
||||
+286
-22
@@ -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,28 +135,114 @@ 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -287,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"}
|
||||
|
||||
|
||||
@@ -361,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"
|
||||
@@ -399,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,
|
||||
)
|
||||
|
||||
|
||||
+227
-40
@@ -3,11 +3,11 @@ import textwrap
|
||||
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 ..core import CheckContext, Severity, checks_for_context, contexts, runner
|
||||
from ..core.runner import Status
|
||||
from .operators import EXPANDED
|
||||
|
||||
STATUS_ICONS = {
|
||||
@@ -166,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)
|
||||
@@ -180,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
|
||||
@@ -197,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
|
||||
|
||||
@@ -244,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")
|
||||
@@ -256,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"
|
||||
@@ -278,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
|
||||
@@ -293,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)
|
||||
@@ -326,6 +385,9 @@ 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
|
||||
@@ -354,8 +416,14 @@ class _SceneManagerPanel:
|
||||
|
||||
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")
|
||||
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()
|
||||
@@ -376,6 +444,8 @@ 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:
|
||||
@@ -385,11 +455,17 @@ class _SceneCastingPanel:
|
||||
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_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"
|
||||
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"]:
|
||||
@@ -398,52 +474,87 @@ class _SceneCastingPanel:
|
||||
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
|
||||
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
|
||||
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"
|
||||
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"{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"]
|
||||
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"]
|
||||
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]
|
||||
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")
|
||||
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_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()
|
||||
@@ -458,16 +569,29 @@ class _SceneLoadedPanel:
|
||||
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")
|
||||
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
|
||||
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
|
||||
@@ -478,30 +602,93 @@ class _SceneLoadedPanel:
|
||||
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")
|
||||
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
|
||||
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
|
||||
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,
|
||||
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",
|
||||
)
|
||||
layout.operator(sm.WTD_OT_scene_assetize.bl_idname, text="Assetize Collection...", icon="EXPORT")
|
||||
|
||||
|
||||
class WTD_PT_sanity_view3d(_SanityPanel, bpy.types.Panel):
|
||||
@@ -556,15 +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_rename_view3d,
|
||||
WTD_PT_rename_node_editor,
|
||||
WTD_PT_shot_viewer_view3d,
|
||||
WTD_PT_sanity_view3d,
|
||||
WTD_PT_sanity_sequencer,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user