mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge remote-tracking branch 'origin/feature/OP-6460_maya-multishot-layout' into feature/OP-6460_maya-multishot-layout
This commit is contained in:
commit
36209ca36f
127 changed files with 12156 additions and 572 deletions
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -35,6 +35,9 @@ body:
|
|||
label: Version
|
||||
description: What version are you running? Look to OpenPype Tray
|
||||
options:
|
||||
- 3.17.3-nightly.1
|
||||
- 3.17.2
|
||||
- 3.17.2-nightly.4
|
||||
- 3.17.2-nightly.3
|
||||
- 3.17.2-nightly.2
|
||||
- 3.17.2-nightly.1
|
||||
|
|
@ -132,9 +135,6 @@ body:
|
|||
- 3.14.11-nightly.3
|
||||
- 3.14.11-nightly.2
|
||||
- 3.14.11-nightly.1
|
||||
- 3.14.10
|
||||
- 3.14.10-nightly.9
|
||||
- 3.14.10-nightly.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
|||
471
CHANGELOG.md
471
CHANGELOG.md
|
|
@ -1,6 +1,477 @@
|
|||
# Changelog
|
||||
|
||||
|
||||
## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2)
|
||||
|
||||
|
||||
[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2)
|
||||
|
||||
### **🆕 New features**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Add MayaPy application. <a href="https://github.com/ynput/OpenPype/pull/5705">#5705</a></summary>
|
||||
|
||||
This adds mayapy to the application to be launched from a task.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Feature: Copy resources when downloading last workfile <a href="https://github.com/ynput/OpenPype/pull/4944">#4944</a></summary>
|
||||
|
||||
When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Blender: Deadline support <a href="https://github.com/ynput/OpenPype/pull/5438">#5438</a></summary>
|
||||
|
||||
Add Deadline support for Blender.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Fusion: implement toggle to use Deadline plugin FusionCmd <a href="https://github.com/ynput/OpenPype/pull/5678">#5678</a></summary>
|
||||
|
||||
Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin:
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Loader tool: Refactor loader tool (for AYON) <a href="https://github.com/ynput/OpenPype/pull/5729">#5729</a></summary>
|
||||
|
||||
Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **🚀 Enhancements**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: implement matchmove publishing <a href="https://github.com/ynput/OpenPype/pull/5445">#5445</a></summary>
|
||||
|
||||
Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Add optional Fbx extractors in Rig and Animation family <a href="https://github.com/ynput/OpenPype/pull/5589">#5589</a></summary>
|
||||
|
||||
This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Optional Resolution Validator for Render <a href="https://github.com/ynput/OpenPype/pull/5693">#5693</a></summary>
|
||||
|
||||
Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Use host's node uniqueness for instance id in new publisher <a href="https://github.com/ynput/OpenPype/pull/5490">#5490</a></summary>
|
||||
|
||||
Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Max: Implementation of OCIO configuration <a href="https://github.com/ynput/OpenPype/pull/5499">#5499</a></summary>
|
||||
|
||||
Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: Multiple format supports for ExtractReviewDataMov <a href="https://github.com/ynput/OpenPype/pull/5623">#5623</a></summary>
|
||||
|
||||
This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Bugfix: houdini switching context doesnt update variables <a href="https://github.com/ynput/OpenPype/pull/5651">#5651</a></summary>
|
||||
|
||||
Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Publisher: Fix report maker memory leak + optimize lookups using set <a href="https://github.com/ynput/OpenPype/pull/5667">#5667</a></summary>
|
||||
|
||||
Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Add openpype_mongo command flag for testing. <a href="https://github.com/ynput/OpenPype/pull/5676">#5676</a></summary>
|
||||
|
||||
Instead of changing the environment, this command flag allows for changing the database.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: minor docstring and code tweaks for ExtractReviewMov <a href="https://github.com/ynput/OpenPype/pull/5695">#5695</a></summary>
|
||||
|
||||
Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON: Small settings fixes <a href="https://github.com/ynput/OpenPype/pull/5699">#5699</a></summary>
|
||||
|
||||
Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Blender: Alembic Animation loader <a href="https://github.com/ynput/OpenPype/pull/5711">#5711</a></summary>
|
||||
|
||||
Implemented loading Alembic Animations in Blender.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **🐛 Bug fixes**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Missing "data" field and enabling of audio <a href="https://github.com/ynput/OpenPype/pull/5618">#5618</a></summary>
|
||||
|
||||
When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Maya: Bug in validate Plug-in Path Attribute <a href="https://github.com/ynput/OpenPype/pull/5687">#5687</a></summary>
|
||||
|
||||
Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>General: Avoid fallback if value is 0 for handle start/end <a href="https://github.com/ynput/OpenPype/pull/5652">#5652</a></summary>
|
||||
|
||||
There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Fusion: added missing env vars to Deadline submission <a href="https://github.com/ynput/OpenPype/pull/5659">#5659</a></summary>
|
||||
|
||||
Environment variables discerning type of job was missing. Without this injection of environment variables won't start.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: workfile version synchronization settings fixed <a href="https://github.com/ynput/OpenPype/pull/5662">#5662</a></summary>
|
||||
|
||||
Settings for synchronizing workfile version to published products is fixed.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON Workfiles Tool: Open workfile changes context <a href="https://github.com/ynput/OpenPype/pull/5671">#5671</a></summary>
|
||||
|
||||
Change context when workfile is opened.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Blender: Fix remove/update in new layout instance <a href="https://github.com/ynput/OpenPype/pull/5679">#5679</a></summary>
|
||||
|
||||
Fixes an error that occurs when removing or updating an asset in a new layout instance.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON Launcher tool: Fix refresh btn <a href="https://github.com/ynput/OpenPype/pull/5685">#5685</a></summary>
|
||||
|
||||
Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Deadline: handle all valid paths in RenderExecutable <a href="https://github.com/ynput/OpenPype/pull/5694">#5694</a></summary>
|
||||
|
||||
This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON Launcher tool: Fix skip last workfile boolean <a href="https://github.com/ynput/OpenPype/pull/5700">#5700</a></summary>
|
||||
|
||||
Skip last workfile boolean works as expected.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Chore: Explore here action can work without task <a href="https://github.com/ynput/OpenPype/pull/5703">#5703</a></summary>
|
||||
|
||||
Explore here action does not crash when task is not selected, and change error message a little.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Testing: Inject mongo_url argument earlier <a href="https://github.com/ynput/OpenPype/pull/5706">#5706</a></summary>
|
||||
|
||||
Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Blender: Add support to auto-install PySide2 in blender 4 <a href="https://github.com/ynput/OpenPype/pull/5723">#5723</a></summary>
|
||||
|
||||
Change version regex to support blender 4 subfolder.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Fix: Hardcoded main site and wrongly copied workfile <a href="https://github.com/ynput/OpenPype/pull/5733">#5733</a></summary>
|
||||
|
||||
Fixing these two issues:
|
||||
- Hardcoded main site -> Replaced by `anatomy.fill_root`.
|
||||
- Workfiles can sometimes be copied while they shouldn't.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Bugfix: ServerDeleteOperation asset -> folder conversion typo <a href="https://github.com/ynput/OpenPype/pull/5735">#5735</a></summary>
|
||||
|
||||
Fix ServerDeleteOperation asset -> folder conversion typo
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: loaders are filtering correctly <a href="https://github.com/ynput/OpenPype/pull/5739">#5739</a></summary>
|
||||
|
||||
Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Nuke: failing multiple thumbnails integration <a href="https://github.com/ynput/OpenPype/pull/5741">#5741</a></summary>
|
||||
|
||||
This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails`
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>AYON Settings: Fix global overrides <a href="https://github.com/ynput/OpenPype/pull/5745">#5745</a></summary>
|
||||
|
||||
The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Chore: AYON query functions arguments <a href="https://github.com/ynput/OpenPype/pull/5752">#5752</a></summary>
|
||||
|
||||
Fixed how `archived` argument is handled in get subsets/assets function.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **🔀 Refactored code**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id <a href="https://github.com/ynput/OpenPype/pull/5668">#5668</a></summary>
|
||||
|
||||
Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__`
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost <a href="https://github.com/ynput/OpenPype/pull/5701">#5701</a></summary>
|
||||
|
||||
Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
### **Merged pull requests**
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Chore: Maya reduce get project settings calls <a href="https://github.com/ynput/OpenPype/pull/5669">#5669</a></summary>
|
||||
|
||||
Re-use system settings / project settings where we can instead of requerying.
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Extended error message when getting subset name <a href="https://github.com/ynput/OpenPype/pull/5649">#5649</a></summary>
|
||||
|
||||
Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message:
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Tests: Remove checks for env var <a href="https://github.com/ynput/OpenPype/pull/5696">#5696</a></summary>
|
||||
|
||||
Env var will be filled in `env_var` fixture, here it is too early to check
|
||||
|
||||
|
||||
___
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
||||
|
||||
## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ def _get_subsets(
|
|||
):
|
||||
fields.add(key)
|
||||
|
||||
active = None
|
||||
active = True
|
||||
if archived:
|
||||
active = False
|
||||
active = None
|
||||
|
||||
for subset in con.get_products(
|
||||
project_name,
|
||||
|
|
@ -196,7 +196,7 @@ def get_assets(
|
|||
|
||||
active = True
|
||||
if archived:
|
||||
active = False
|
||||
active = None
|
||||
|
||||
con = get_server_api_connection()
|
||||
fields = folder_fields_v3_to_v4(fields, con)
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ def failed_json_default(value):
|
|||
|
||||
|
||||
class ServerCreateOperation(CreateOperation):
|
||||
"""Opeartion to create an entity.
|
||||
"""Operation to create an entity.
|
||||
|
||||
Args:
|
||||
project_name (str): On which project operation will happen.
|
||||
|
|
@ -634,7 +634,7 @@ class ServerUpdateOperation(UpdateOperation):
|
|||
|
||||
|
||||
class ServerDeleteOperation(DeleteOperation):
|
||||
"""Opeartion to delete an entity.
|
||||
"""Operation to delete an entity.
|
||||
|
||||
Args:
|
||||
project_name (str): On which project operation will happen.
|
||||
|
|
@ -647,7 +647,7 @@ class ServerDeleteOperation(DeleteOperation):
|
|||
self._session = session
|
||||
|
||||
if entity_type == "asset":
|
||||
entity_type == "folder"
|
||||
entity_type = "folder"
|
||||
|
||||
elif entity_type == "hero_version":
|
||||
entity_type = "version"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import subprocess
|
|||
from openpype.lib.applications import PreLaunchHook, LaunchTypes
|
||||
|
||||
|
||||
class LaunchFoundryAppsWindows(PreLaunchHook):
|
||||
class LaunchNewConsoleApps(PreLaunchHook):
|
||||
"""Foundry applications have specific way how to launch them.
|
||||
|
||||
Nuke is executed "like" python process so it is required to pass
|
||||
|
|
@ -13,13 +13,15 @@ class LaunchFoundryAppsWindows(PreLaunchHook):
|
|||
|
||||
# Should be as last hook because must change launch arguments to string
|
||||
order = 1000
|
||||
app_groups = {"nuke", "nukeassist", "nukex", "hiero", "nukestudio"}
|
||||
app_groups = {
|
||||
"nuke", "nukeassist", "nukex", "hiero", "nukestudio", "mayapy"
|
||||
}
|
||||
platforms = {"windows"}
|
||||
launch_types = {LaunchTypes.local}
|
||||
|
||||
def execute(self):
|
||||
# Change `creationflags` to CREATE_NEW_CONSOLE
|
||||
# - on Windows nuke will create new window using its console
|
||||
# - on Windows some apps will create new window using its console
|
||||
# Set `stdout` and `stderr` to None so new created console does not
|
||||
# have redirected output to DEVNULL in build
|
||||
self.launch_context.kwargs.update({
|
||||
|
|
@ -460,36 +460,6 @@ def ls() -> Iterator:
|
|||
yield parse_container(container)
|
||||
|
||||
|
||||
def update_hierarchy(containers):
|
||||
"""Hierarchical container support
|
||||
|
||||
This is the function to support Scene Inventory to draw hierarchical
|
||||
view for containers.
|
||||
|
||||
We need both parent and children to visualize the graph.
|
||||
|
||||
"""
|
||||
|
||||
all_containers = set(ls()) # lookup set
|
||||
|
||||
for container in containers:
|
||||
# Find parent
|
||||
# FIXME (jasperge): re-evaluate this. How would it be possible
|
||||
# to 'nest' assets? Collections can have several parents, for
|
||||
# now assume it has only 1 parent
|
||||
parent = [
|
||||
coll for coll in bpy.data.collections if container in coll.children
|
||||
]
|
||||
for node in parent:
|
||||
if node in all_containers:
|
||||
container["parent"] = node
|
||||
break
|
||||
|
||||
log.debug("Container: %s", container)
|
||||
|
||||
yield container
|
||||
|
||||
|
||||
def publish():
|
||||
"""Shorthand to publish from within host."""
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
Note:
|
||||
At least for now it only supports Alembic files.
|
||||
"""
|
||||
|
||||
families = ["model", "pointcache"]
|
||||
families = ["model", "pointcache", "animation"]
|
||||
representations = ["abc"]
|
||||
|
||||
label = "Load Alembic"
|
||||
|
|
@ -53,16 +52,12 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
def _process(self, libpath, asset_group, group_name):
|
||||
plugin.deselect_all()
|
||||
|
||||
collection = bpy.context.view_layer.active_layer_collection.collection
|
||||
|
||||
relative = bpy.context.preferences.filepaths.use_relative_paths
|
||||
bpy.ops.wm.alembic_import(
|
||||
filepath=libpath,
|
||||
relative_path=relative
|
||||
)
|
||||
|
||||
parent = bpy.context.scene.collection
|
||||
|
||||
imported = lib.get_selection()
|
||||
|
||||
# Children must be linked before parents,
|
||||
|
|
@ -79,6 +74,10 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
objects.reverse()
|
||||
|
||||
for obj in objects:
|
||||
# Unlink the object from all collections
|
||||
collections = obj.users_collection
|
||||
for collection in collections:
|
||||
collection.objects.unlink(obj)
|
||||
name = obj.name
|
||||
obj.name = f"{group_name}:{name}"
|
||||
if obj.type != 'EMPTY':
|
||||
|
|
@ -90,7 +89,7 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
material_slot.material.name = f"{group_name}:{name_mat}"
|
||||
|
||||
if not obj.get(AVALON_PROPERTY):
|
||||
obj[AVALON_PROPERTY] = dict()
|
||||
obj[AVALON_PROPERTY] = {}
|
||||
|
||||
avalon_info = obj[AVALON_PROPERTY]
|
||||
avalon_info.update({"container_name": group_name})
|
||||
|
|
@ -99,6 +98,18 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
|
||||
return objects
|
||||
|
||||
def _link_objects(self, objects, collection, containers, asset_group):
|
||||
# Link the imported objects to any collection where the asset group is
|
||||
# linked to, except the AVALON_CONTAINERS collection
|
||||
group_collections = [
|
||||
collection
|
||||
for collection in asset_group.users_collection
|
||||
if collection != containers]
|
||||
|
||||
for obj in objects:
|
||||
for collection in group_collections:
|
||||
collection.objects.link(obj)
|
||||
|
||||
def process_asset(
|
||||
self, context: dict, name: str, namespace: Optional[str] = None,
|
||||
options: Optional[Dict] = None
|
||||
|
|
@ -120,18 +131,21 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
group_name = plugin.asset_name(asset, subset, unique_number)
|
||||
namespace = namespace or f"{asset}_{unique_number}"
|
||||
|
||||
avalon_containers = bpy.data.collections.get(AVALON_CONTAINERS)
|
||||
if not avalon_containers:
|
||||
avalon_containers = bpy.data.collections.new(
|
||||
name=AVALON_CONTAINERS)
|
||||
bpy.context.scene.collection.children.link(avalon_containers)
|
||||
containers = bpy.data.collections.get(AVALON_CONTAINERS)
|
||||
if not containers:
|
||||
containers = bpy.data.collections.new(name=AVALON_CONTAINERS)
|
||||
bpy.context.scene.collection.children.link(containers)
|
||||
|
||||
asset_group = bpy.data.objects.new(group_name, object_data=None)
|
||||
avalon_containers.objects.link(asset_group)
|
||||
containers.objects.link(asset_group)
|
||||
|
||||
objects = self._process(libpath, asset_group, group_name)
|
||||
|
||||
bpy.context.scene.collection.objects.link(asset_group)
|
||||
# Link the asset group to the active collection
|
||||
collection = bpy.context.view_layer.active_layer_collection.collection
|
||||
collection.objects.link(asset_group)
|
||||
|
||||
self._link_objects(objects, asset_group, containers, asset_group)
|
||||
|
||||
asset_group[AVALON_PROPERTY] = {
|
||||
"schema": "openpype:container-2.0",
|
||||
|
|
@ -207,7 +221,11 @@ class CacheModelLoader(plugin.AssetLoader):
|
|||
mat = asset_group.matrix_basis.copy()
|
||||
self._remove(asset_group)
|
||||
|
||||
self._process(str(libpath), asset_group, object_name)
|
||||
objects = self._process(str(libpath), asset_group, object_name)
|
||||
|
||||
containers = bpy.data.collections.get(AVALON_CONTAINERS)
|
||||
self._link_objects(objects, asset_group, containers, asset_group)
|
||||
|
||||
asset_group.matrix_basis = mat
|
||||
|
||||
metadata["libpath"] = str(libpath)
|
||||
|
|
|
|||
|
|
@ -165,7 +165,8 @@ class CreateSaver(NewCreator):
|
|||
filepath = self.temp_rendering_path_template.format(
|
||||
**formatting_data)
|
||||
|
||||
tool["Clip"] = os.path.normpath(filepath)
|
||||
comp = get_current_comp()
|
||||
tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath))
|
||||
|
||||
# Rename tool
|
||||
if tool.Name != subset:
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class FusionLoadSequence(load.LoaderPlugin):
|
|||
with comp_lock_and_undo_chunk(comp, "Create Loader"):
|
||||
args = (-32768, -32768)
|
||||
tool = comp.AddTool("Loader", *args)
|
||||
tool["Clip"] = path
|
||||
tool["Clip"] = comp.ReverseMapPath(path)
|
||||
|
||||
# Set global in point to start frame (if in version.data)
|
||||
start = self._get_start(context["version"], tool)
|
||||
|
|
@ -244,7 +244,7 @@ class FusionLoadSequence(load.LoaderPlugin):
|
|||
"TimeCodeOffset",
|
||||
),
|
||||
):
|
||||
tool["Clip"] = path
|
||||
tool["Clip"] = comp.ReverseMapPath(path)
|
||||
|
||||
# Set the global in to the start frame of the sequence
|
||||
global_in_changed = loader_shift(tool, start, relative=False)
|
||||
|
|
|
|||
|
|
@ -145,9 +145,11 @@ class CollectFusionRender(
|
|||
start = render_instance.frameStart - render_instance.handleStart
|
||||
end = render_instance.frameEnd + render_instance.handleEnd
|
||||
|
||||
path = (
|
||||
render_instance.tool["Clip"]
|
||||
[render_instance.workfileComp.TIME_UNDEFINED]
|
||||
comp = render_instance.workfileComp
|
||||
path = comp.MapPath(
|
||||
render_instance.tool["Clip"][
|
||||
render_instance.workfileComp.TIME_UNDEFINED
|
||||
]
|
||||
)
|
||||
output_dir = os.path.dirname(path)
|
||||
render_instance.outputDir = output_dir
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import pyblish.api
|
||||
from openpype.pipeline import PublishValidationError
|
||||
from pymxs import runtime as rt
|
||||
|
||||
|
||||
class ValidateMaxContents(pyblish.api.InstancePlugin):
|
||||
"""Validates Max contents.
|
||||
class ValidateInstanceHasMembers(pyblish.api.InstancePlugin):
|
||||
"""Validates Instance has members.
|
||||
|
||||
Check if MaxScene container includes any contents underneath.
|
||||
Check if MaxScene containers includes any contents underneath.
|
||||
"""
|
||||
|
||||
order = pyblish.api.ValidatorOrder
|
||||
families = ["camera",
|
||||
"model",
|
||||
"maxScene",
|
||||
"review"]
|
||||
"review",
|
||||
"pointcache",
|
||||
"pointcloud",
|
||||
"redshiftproxy"]
|
||||
hosts = ["max"]
|
||||
label = "Max Scene Contents"
|
||||
label = "Container Contents"
|
||||
|
||||
def process(self, instance):
|
||||
if not instance.data["members"]:
|
||||
|
|
@ -95,6 +95,8 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
|
|||
self.log.info("Installing callbacks ... ")
|
||||
register_event_callback("init", on_init)
|
||||
|
||||
_set_project()
|
||||
|
||||
if lib.IS_HEADLESS:
|
||||
self.log.info((
|
||||
"Running in headless mode, skipping Maya save/open/new"
|
||||
|
|
@ -103,7 +105,6 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
|
|||
|
||||
return
|
||||
|
||||
_set_project()
|
||||
self._register_callbacks()
|
||||
|
||||
menu.install(project_settings)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ class PreCopyMel(PreLaunchHook):
|
|||
|
||||
Hook `GlobalHostDataHook` must be executed before this hook.
|
||||
"""
|
||||
app_groups = {"maya"}
|
||||
app_groups = {"maya", "mayapy"}
|
||||
launch_types = {LaunchTypes.local}
|
||||
|
||||
def execute(self):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Maya look extractor."""
|
||||
import sys
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import OrderedDict
|
||||
import contextlib
|
||||
|
|
@ -176,6 +177,24 @@ class MakeRSTexBin(TextureProcessor):
|
|||
source
|
||||
]
|
||||
|
||||
# if color management is enabled we pass color space information
|
||||
if color_management["enabled"]:
|
||||
config_path = color_management["config"]
|
||||
if not os.path.exists(config_path):
|
||||
raise RuntimeError("OCIO config not found at: "
|
||||
"{}".format(config_path))
|
||||
|
||||
if not os.getenv("OCIO"):
|
||||
self.log.debug(
|
||||
"OCIO environment variable not set."
|
||||
"Setting it with OCIO config from Maya."
|
||||
)
|
||||
os.environ["OCIO"] = config_path
|
||||
|
||||
self.log.debug("converting colorspace {0} to redshift render "
|
||||
"colorspace".format(colorspace))
|
||||
subprocess_args.extend(["-cs", colorspace])
|
||||
|
||||
hash_args = ["rstex"]
|
||||
texture_hash = source_hash(source, *hash_args)
|
||||
|
||||
|
|
@ -186,11 +205,11 @@ class MakeRSTexBin(TextureProcessor):
|
|||
|
||||
self.log.debug(" ".join(subprocess_args))
|
||||
try:
|
||||
run_subprocess(subprocess_args)
|
||||
run_subprocess(subprocess_args, logger=self.log)
|
||||
except Exception:
|
||||
self.log.error("Texture .rstexbin conversion failed",
|
||||
exc_info=True)
|
||||
raise
|
||||
six.reraise(*sys.exc_info())
|
||||
|
||||
return TextureResult(
|
||||
path=destination,
|
||||
|
|
@ -472,7 +491,7 @@ class ExtractLook(publish.Extractor):
|
|||
"rstex": MakeRSTexBin
|
||||
}.items():
|
||||
if instance.data.get(key, False):
|
||||
processor = Processor()
|
||||
processor = Processor(log=self.log)
|
||||
processor.apply_settings(context.data["system_settings"],
|
||||
context.data["project_settings"])
|
||||
processors.append(processor)
|
||||
|
|
|
|||
117
openpype/hosts/maya/plugins/publish/validate_resolution.py
Normal file
117
openpype/hosts/maya/plugins/publish/validate_resolution.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import pyblish.api
|
||||
from openpype.pipeline import (
|
||||
PublishValidationError,
|
||||
OptionalPyblishPluginMixin
|
||||
)
|
||||
from maya import cmds
|
||||
from openpype.pipeline.publish import RepairAction
|
||||
from openpype.hosts.maya.api import lib
|
||||
from openpype.hosts.maya.api.lib import reset_scene_resolution
|
||||
|
||||
|
||||
class ValidateResolution(pyblish.api.InstancePlugin,
|
||||
OptionalPyblishPluginMixin):
|
||||
"""Validate the render resolution setting aligned with DB"""
|
||||
|
||||
order = pyblish.api.ValidatorOrder
|
||||
families = ["renderlayer"]
|
||||
hosts = ["maya"]
|
||||
label = "Validate Resolution"
|
||||
actions = [RepairAction]
|
||||
optional = True
|
||||
|
||||
def process(self, instance):
|
||||
if not self.is_active(instance.data):
|
||||
return
|
||||
invalid = self.get_invalid_resolution(instance)
|
||||
if invalid:
|
||||
raise PublishValidationError(
|
||||
"Render resolution is invalid. See log for details.",
|
||||
description=(
|
||||
"Wrong render resolution setting. "
|
||||
"Please use repair button to fix it.\n\n"
|
||||
"If current renderer is V-Ray, "
|
||||
"make sure vraySettings node has been created."
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_invalid_resolution(cls, instance):
|
||||
width, height, pixelAspect = cls.get_db_resolution(instance)
|
||||
current_renderer = instance.data["renderer"]
|
||||
layer = instance.data["renderlayer"]
|
||||
invalid = False
|
||||
if current_renderer == "vray":
|
||||
vray_node = "vraySettings"
|
||||
if cmds.objExists(vray_node):
|
||||
current_width = lib.get_attr_in_layer(
|
||||
"{}.width".format(vray_node), layer=layer)
|
||||
current_height = lib.get_attr_in_layer(
|
||||
"{}.height".format(vray_node), layer=layer)
|
||||
current_pixelAspect = lib.get_attr_in_layer(
|
||||
"{}.pixelAspect".format(vray_node), layer=layer
|
||||
)
|
||||
else:
|
||||
cls.log.error(
|
||||
"Can't detect VRay resolution because there is no node "
|
||||
"named: `{}`".format(vray_node)
|
||||
)
|
||||
return True
|
||||
else:
|
||||
current_width = lib.get_attr_in_layer(
|
||||
"defaultResolution.width", layer=layer)
|
||||
current_height = lib.get_attr_in_layer(
|
||||
"defaultResolution.height", layer=layer)
|
||||
current_pixelAspect = lib.get_attr_in_layer(
|
||||
"defaultResolution.pixelAspect", layer=layer
|
||||
)
|
||||
if current_width != width or current_height != height:
|
||||
cls.log.error(
|
||||
"Render resolution {}x{} does not match "
|
||||
"asset resolution {}x{}".format(
|
||||
current_width, current_height,
|
||||
width, height
|
||||
))
|
||||
invalid = True
|
||||
if current_pixelAspect != pixelAspect:
|
||||
cls.log.error(
|
||||
"Render pixel aspect {} does not match "
|
||||
"asset pixel aspect {}".format(
|
||||
current_pixelAspect, pixelAspect
|
||||
))
|
||||
invalid = True
|
||||
return invalid
|
||||
|
||||
@classmethod
|
||||
def get_db_resolution(cls, instance):
|
||||
asset_doc = instance.data["assetEntity"]
|
||||
project_doc = instance.context.data["projectEntity"]
|
||||
for data in [asset_doc["data"], project_doc["data"]]:
|
||||
if (
|
||||
"resolutionWidth" in data and
|
||||
"resolutionHeight" in data and
|
||||
"pixelAspect" in data
|
||||
):
|
||||
width = data["resolutionWidth"]
|
||||
height = data["resolutionHeight"]
|
||||
pixelAspect = data["pixelAspect"]
|
||||
return int(width), int(height), float(pixelAspect)
|
||||
|
||||
# Defaults if not found in asset document or project document
|
||||
return 1920, 1080, 1.0
|
||||
|
||||
@classmethod
|
||||
def repair(cls, instance):
|
||||
# Usually without renderlayer overrides the renderlayers
|
||||
# all share the same resolution value - so fixing the first
|
||||
# will have fixed all the others too. It's much faster to
|
||||
# check whether it's invalid first instead of switching
|
||||
# into all layers individually
|
||||
if not cls.get_invalid_resolution(instance):
|
||||
cls.log.debug(
|
||||
"Nothing to repair on instance: {}".format(instance)
|
||||
)
|
||||
return
|
||||
layer_node = instance.data['setMembers']
|
||||
with lib.renderlayer(layer_node):
|
||||
reset_scene_resolution()
|
||||
|
|
@ -50,6 +50,11 @@ from .utils import (
|
|||
get_colorspace_list
|
||||
)
|
||||
|
||||
from .actions import (
|
||||
SelectInvalidAction,
|
||||
SelectInstanceNodeAction
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"file_extensions",
|
||||
"has_unsaved_changes",
|
||||
|
|
@ -92,5 +97,8 @@ __all__ = (
|
|||
"create_write_node",
|
||||
|
||||
"colorspace_exists_on_node",
|
||||
"get_colorspace_list"
|
||||
"get_colorspace_list",
|
||||
|
||||
"SelectInvalidAction",
|
||||
"SelectInstanceNodeAction"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,33 +20,58 @@ class SelectInvalidAction(pyblish.api.Action):
|
|||
|
||||
def process(self, context, plugin):
|
||||
|
||||
try:
|
||||
import nuke
|
||||
except ImportError:
|
||||
raise ImportError("Current host is not Nuke")
|
||||
|
||||
errored_instances = get_errored_instances_from_context(context,
|
||||
plugin=plugin)
|
||||
|
||||
# Get the invalid nodes for the plug-ins
|
||||
self.log.info("Finding invalid nodes..")
|
||||
invalid = list()
|
||||
invalid = set()
|
||||
for instance in errored_instances:
|
||||
invalid_nodes = plugin.get_invalid(instance)
|
||||
|
||||
if invalid_nodes:
|
||||
if isinstance(invalid_nodes, (list, tuple)):
|
||||
invalid.append(invalid_nodes[0])
|
||||
invalid.update(invalid_nodes)
|
||||
else:
|
||||
self.log.warning("Plug-in returned to be invalid, "
|
||||
"but has no selectable nodes.")
|
||||
|
||||
# Ensure unique (process each node only once)
|
||||
invalid = list(set(invalid))
|
||||
|
||||
if invalid:
|
||||
self.log.info("Selecting invalid nodes: {}".format(invalid))
|
||||
reset_selection()
|
||||
select_nodes(invalid)
|
||||
else:
|
||||
self.log.info("No invalid nodes found.")
|
||||
|
||||
|
||||
class SelectInstanceNodeAction(pyblish.api.Action):
|
||||
"""Select instance node for failed plugin."""
|
||||
label = "Select instance node"
|
||||
on = "failed" # This action is only available on a failed plug-in
|
||||
icon = "mdi.cursor-default-click"
|
||||
|
||||
def process(self, context, plugin):
|
||||
|
||||
# Get the errored instances for the plug-in
|
||||
errored_instances = get_errored_instances_from_context(
|
||||
context, plugin)
|
||||
|
||||
# Get the invalid nodes for the plug-ins
|
||||
self.log.info("Finding instance nodes..")
|
||||
nodes = set()
|
||||
for instance in errored_instances:
|
||||
instance_node = instance.data.get("transientData", {}).get("node")
|
||||
if not instance_node:
|
||||
raise RuntimeError(
|
||||
"No transientData['node'] found on instance: {}".format(
|
||||
instance
|
||||
)
|
||||
)
|
||||
nodes.add(instance_node)
|
||||
|
||||
if nodes:
|
||||
self.log.info("Selecting instance nodes: {}".format(nodes))
|
||||
reset_selection()
|
||||
select_nodes(nodes)
|
||||
else:
|
||||
self.log.info("No instance nodes found.")
|
||||
|
|
|
|||
|
|
@ -2833,9 +2833,10 @@ def select_nodes(nodes):
|
|||
"""Selects all inputted nodes
|
||||
|
||||
Arguments:
|
||||
nodes (list): nuke nodes to be selected
|
||||
nodes (Union[list, tuple, set]): nuke nodes to be selected
|
||||
"""
|
||||
assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple"
|
||||
assert isinstance(nodes, (list, tuple, set)), \
|
||||
"nodes has to be list, tuple or set"
|
||||
|
||||
for node in nodes:
|
||||
node["selected"].setValue(True)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin):
|
|||
"yeticache",
|
||||
"pointcache"]
|
||||
representations = ["*"]
|
||||
extension = {"*"}
|
||||
extensions = {"*"}
|
||||
|
||||
label = "Set frame range"
|
||||
order = 11
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class LoadBackdropNodes(load.LoaderPlugin):
|
|||
|
||||
families = ["workfile", "nukenodes"]
|
||||
representations = ["*"]
|
||||
extension = {"nk"}
|
||||
extensions = {"nk"}
|
||||
|
||||
label = "Import Nuke Nodes"
|
||||
order = 0
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class AlembicCameraLoader(load.LoaderPlugin):
|
|||
|
||||
families = ["camera"]
|
||||
representations = ["*"]
|
||||
extension = {"abc"}
|
||||
extensions = {"abc"}
|
||||
|
||||
label = "Load Alembic Camera"
|
||||
icon = "camera"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class LoadEffects(load.LoaderPlugin):
|
|||
|
||||
families = ["effect"]
|
||||
representations = ["*"]
|
||||
extension = {"json"}
|
||||
extensions = {"json"}
|
||||
|
||||
label = "Load Effects - nodes"
|
||||
order = 0
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin):
|
|||
|
||||
families = ["effect"]
|
||||
representations = ["*"]
|
||||
extension = {"json"}
|
||||
extensions = {"json"}
|
||||
|
||||
label = "Load Effects - Input Process"
|
||||
order = 0
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class LoadGizmo(load.LoaderPlugin):
|
|||
|
||||
families = ["gizmo"]
|
||||
representations = ["*"]
|
||||
extension = {"gizmo"}
|
||||
extensions = {"gizmo"}
|
||||
|
||||
label = "Load Gizmo"
|
||||
order = 0
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
|
|||
|
||||
families = ["gizmo"]
|
||||
representations = ["*"]
|
||||
extension = {"gizmo"}
|
||||
extensions = {"gizmo"}
|
||||
|
||||
label = "Load Gizmo - Input Process"
|
||||
order = 0
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class MatchmoveLoader(load.LoaderPlugin):
|
|||
|
||||
families = ["matchmove"]
|
||||
representations = ["*"]
|
||||
extension = {"py"}
|
||||
extensions = {"py"}
|
||||
|
||||
defaults = ["Camera", "Object"]
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class AlembicModelLoader(load.LoaderPlugin):
|
|||
|
||||
families = ["model", "pointcache", "animation"]
|
||||
representations = ["*"]
|
||||
extension = {"abc"}
|
||||
extensions = {"abc"}
|
||||
|
||||
label = "Load Alembic"
|
||||
icon = "cube"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class LinkAsGroup(load.LoaderPlugin):
|
|||
|
||||
families = ["workfile", "nukenodes"]
|
||||
representations = ["*"]
|
||||
extension = {"nk"}
|
||||
extensions = {"nk"}
|
||||
|
||||
label = "Load Precomp"
|
||||
order = 0
|
||||
|
|
|
|||
|
|
@ -57,4 +57,4 @@ class CollectBackdrops(pyblish.api.InstancePlugin):
|
|||
if version:
|
||||
instance.data['version'] = version
|
||||
|
||||
self.log.info("Backdrop instance collected: `{}`".format(instance))
|
||||
self.log.debug("Backdrop instance collected: `{}`".format(instance))
|
||||
|
|
|
|||
|
|
@ -64,4 +64,4 @@ class CollectContextData(pyblish.api.ContextPlugin):
|
|||
context.data["scriptData"] = script_data
|
||||
context.data.update(script_data)
|
||||
|
||||
self.log.info('Context from Nuke script collected')
|
||||
self.log.debug('Context from Nuke script collected')
|
||||
|
|
|
|||
|
|
@ -43,4 +43,4 @@ class CollectGizmo(pyblish.api.InstancePlugin):
|
|||
"frameStart": first_frame,
|
||||
"frameEnd": last_frame
|
||||
})
|
||||
self.log.info("Gizmo instance collected: `{}`".format(instance))
|
||||
self.log.debug("Gizmo instance collected: `{}`".format(instance))
|
||||
|
|
|
|||
|
|
@ -43,4 +43,4 @@ class CollectModel(pyblish.api.InstancePlugin):
|
|||
"frameStart": first_frame,
|
||||
"frameEnd": last_frame
|
||||
})
|
||||
self.log.info("Model instance collected: `{}`".format(instance))
|
||||
self.log.debug("Model instance collected: `{}`".format(instance))
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class CollectSlate(pyblish.api.InstancePlugin):
|
|||
instance.data["slateNode"] = slate_node
|
||||
instance.data["slate"] = True
|
||||
instance.data["families"].append("slate")
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"Slate node is in node graph: `{}`".format(slate.name()))
|
||||
self.log.debug(
|
||||
"__ instance.data: `{}`".format(instance.data))
|
||||
|
|
|
|||
|
|
@ -37,4 +37,6 @@ class CollectWorkfile(pyblish.api.InstancePlugin):
|
|||
# adding basic script data
|
||||
instance.data.update(script_data)
|
||||
|
||||
self.log.info("Collect script version")
|
||||
self.log.debug(
|
||||
"Collected current script version: {}".format(current_file)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ class ExtractBackdropNode(publish.Extractor):
|
|||
# connect output node
|
||||
for n, output in connections_out.items():
|
||||
opn = nuke.createNode("Output")
|
||||
self.log.info(n.name())
|
||||
self.log.info(output.name())
|
||||
output.setInput(
|
||||
next((i for i, d in enumerate(output.dependencies())
|
||||
if d.name() in n.name()), 0), opn)
|
||||
|
|
@ -102,5 +100,5 @@ class ExtractBackdropNode(publish.Extractor):
|
|||
}
|
||||
instance.data["representations"].append(representation)
|
||||
|
||||
self.log.info("Extracted instance '{}' to: {}".format(
|
||||
self.log.debug("Extracted instance '{}' to: {}".format(
|
||||
instance.name, path))
|
||||
|
|
|
|||
|
|
@ -36,11 +36,8 @@ class ExtractCamera(publish.Extractor):
|
|||
step = 1
|
||||
output_range = str(nuke.FrameRange(first_frame, last_frame, step))
|
||||
|
||||
self.log.info("instance.data: `{}`".format(
|
||||
pformat(instance.data)))
|
||||
|
||||
rm_nodes = []
|
||||
self.log.info("Crating additional nodes")
|
||||
self.log.debug("Creating additional nodes for 3D Camera Extractor")
|
||||
subset = instance.data["subset"]
|
||||
staging_dir = self.staging_dir(instance)
|
||||
|
||||
|
|
@ -84,8 +81,6 @@ class ExtractCamera(publish.Extractor):
|
|||
for n in rm_nodes:
|
||||
nuke.delete(n)
|
||||
|
||||
self.log.info(file_path)
|
||||
|
||||
# create representation data
|
||||
if "representations" not in instance.data:
|
||||
instance.data["representations"] = []
|
||||
|
|
@ -112,7 +107,7 @@ class ExtractCamera(publish.Extractor):
|
|||
"frameEndHandle": last_frame,
|
||||
})
|
||||
|
||||
self.log.info("Extracted instance '{0}' to: {1}".format(
|
||||
self.log.debug("Extracted instance '{0}' to: {1}".format(
|
||||
instance.name, file_path))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,5 @@ class ExtractGizmo(publish.Extractor):
|
|||
}
|
||||
instance.data["representations"].append(representation)
|
||||
|
||||
self.log.info("Extracted instance '{}' to: {}".format(
|
||||
self.log.debug("Extracted instance '{}' to: {}".format(
|
||||
instance.name, path))
|
||||
|
||||
self.log.info("Data {}".format(
|
||||
instance.data))
|
||||
|
|
|
|||
|
|
@ -33,13 +33,13 @@ class ExtractModel(publish.Extractor):
|
|||
first_frame = int(nuke.root()["first_frame"].getValue())
|
||||
last_frame = int(nuke.root()["last_frame"].getValue())
|
||||
|
||||
self.log.info("instance.data: `{}`".format(
|
||||
self.log.debug("instance.data: `{}`".format(
|
||||
pformat(instance.data)))
|
||||
|
||||
rm_nodes = []
|
||||
model_node = instance.data["transientData"]["node"]
|
||||
|
||||
self.log.info("Crating additional nodes")
|
||||
self.log.debug("Creating additional nodes for Extract Model")
|
||||
subset = instance.data["subset"]
|
||||
staging_dir = self.staging_dir(instance)
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ class ExtractModel(publish.Extractor):
|
|||
for n in rm_nodes:
|
||||
nuke.delete(n)
|
||||
|
||||
self.log.info(file_path)
|
||||
self.log.debug("Filepath: {}".format(file_path))
|
||||
|
||||
# create representation data
|
||||
if "representations" not in instance.data:
|
||||
|
|
@ -104,5 +104,5 @@ class ExtractModel(publish.Extractor):
|
|||
"frameEndHandle": last_frame,
|
||||
})
|
||||
|
||||
self.log.info("Extracted instance '{0}' to: {1}".format(
|
||||
self.log.debug("Extracted instance '{0}' to: {1}".format(
|
||||
instance.name, file_path))
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class CreateOutputNode(pyblish.api.ContextPlugin):
|
|||
|
||||
if active_node:
|
||||
active_node = active_node.pop()
|
||||
self.log.info(active_node)
|
||||
self.log.debug("Active node: {}".format(active_node))
|
||||
active_node['selected'].setValue(True)
|
||||
|
||||
# select only instance render node
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ class NukeRenderLocal(publish.Extractor,
|
|||
|
||||
instance.data["representations"].append(repre)
|
||||
|
||||
self.log.info("Extracted instance '{0}' to: {1}".format(
|
||||
self.log.debug("Extracted instance '{0}' to: {1}".format(
|
||||
instance.name,
|
||||
out_dir
|
||||
))
|
||||
|
|
@ -143,7 +143,7 @@ class NukeRenderLocal(publish.Extractor,
|
|||
instance.data["families"] = families
|
||||
|
||||
collections, remainder = clique.assemble(filenames)
|
||||
self.log.info('collections: {}'.format(str(collections)))
|
||||
self.log.debug('collections: {}'.format(str(collections)))
|
||||
|
||||
if collections:
|
||||
collection = collections[0]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class ExtractReviewDataLut(publish.Extractor):
|
|||
hosts = ["nuke"]
|
||||
|
||||
def process(self, instance):
|
||||
self.log.info("Creating staging dir...")
|
||||
self.log.debug("Creating staging dir...")
|
||||
if "representations" in instance.data:
|
||||
staging_dir = instance.data[
|
||||
"representations"][0]["stagingDir"].replace("\\", "/")
|
||||
|
|
@ -33,7 +33,7 @@ class ExtractReviewDataLut(publish.Extractor):
|
|||
staging_dir = os.path.normpath(os.path.dirname(render_path))
|
||||
instance.data["stagingDir"] = staging_dir
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
|
||||
|
||||
# generate data
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class ExtractReviewIntermediates(publish.Extractor):
|
|||
|
||||
task_type = instance.context.data["taskType"]
|
||||
subset = instance.data["subset"]
|
||||
self.log.info("Creating staging dir...")
|
||||
self.log.debug("Creating staging dir...")
|
||||
|
||||
if "representations" not in instance.data:
|
||||
instance.data["representations"] = []
|
||||
|
|
@ -62,10 +62,10 @@ class ExtractReviewIntermediates(publish.Extractor):
|
|||
|
||||
instance.data["stagingDir"] = staging_dir
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
|
||||
|
||||
self.log.info(self.outputs)
|
||||
self.log.debug("Outputs: {}".format(self.outputs))
|
||||
|
||||
# generate data
|
||||
with maintained_selection():
|
||||
|
|
@ -104,9 +104,10 @@ class ExtractReviewIntermediates(publish.Extractor):
|
|||
re.search(s, subset) for s in f_subsets):
|
||||
continue
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"Baking output `{}` with settings: {}".format(
|
||||
o_name, o_data))
|
||||
o_name, o_data)
|
||||
)
|
||||
|
||||
# check if settings have more then one preset
|
||||
# so we dont need to add outputName to representation
|
||||
|
|
@ -155,10 +156,10 @@ class ExtractReviewIntermediates(publish.Extractor):
|
|||
instance.data["useSequenceForReview"] = False
|
||||
else:
|
||||
instance.data["families"].remove("review")
|
||||
self.log.info((
|
||||
self.log.debug(
|
||||
"Removing `review` from families. "
|
||||
"Not available baking profile."
|
||||
))
|
||||
)
|
||||
self.log.debug(instance.data["families"])
|
||||
|
||||
self.log.debug(
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import pyblish.api
|
|||
|
||||
|
||||
class ExtractScriptSave(pyblish.api.Extractor):
|
||||
"""
|
||||
"""
|
||||
"""Save current Nuke workfile script"""
|
||||
label = 'Script Save'
|
||||
order = pyblish.api.Extractor.order - 0.1
|
||||
hosts = ['nuke']
|
||||
|
||||
def process(self, instance):
|
||||
|
||||
self.log.info('saving script')
|
||||
self.log.debug('Saving current script')
|
||||
nuke.scriptSave()
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class ExtractSlateFrame(publish.Extractor):
|
|||
|
||||
if instance.data.get("bakePresets"):
|
||||
for o_name, o_data in instance.data["bakePresets"].items():
|
||||
self.log.info("_ o_name: {}, o_data: {}".format(
|
||||
self.log.debug("_ o_name: {}, o_data: {}".format(
|
||||
o_name, pformat(o_data)))
|
||||
self.render_slate(
|
||||
instance,
|
||||
|
|
@ -65,14 +65,14 @@ class ExtractSlateFrame(publish.Extractor):
|
|||
|
||||
def _create_staging_dir(self, instance):
|
||||
|
||||
self.log.info("Creating staging dir...")
|
||||
self.log.debug("Creating staging dir...")
|
||||
|
||||
staging_dir = os.path.normpath(
|
||||
os.path.dirname(instance.data["path"]))
|
||||
|
||||
instance.data["stagingDir"] = staging_dir
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
|
||||
|
||||
def _check_frames_exists(self, instance):
|
||||
|
|
@ -275,10 +275,10 @@ class ExtractSlateFrame(publish.Extractor):
|
|||
break
|
||||
|
||||
if not matching_repre:
|
||||
self.log.info((
|
||||
"Matching reresentaion was not found."
|
||||
self.log.info(
|
||||
"Matching reresentation was not found."
|
||||
" Representation files were not filled with slate."
|
||||
))
|
||||
)
|
||||
return
|
||||
|
||||
# Add frame to matching representation files
|
||||
|
|
@ -345,7 +345,7 @@ class ExtractSlateFrame(publish.Extractor):
|
|||
|
||||
try:
|
||||
node[key].setValue(value)
|
||||
self.log.info("Change key \"{}\" to value \"{}\"".format(
|
||||
self.log.debug("Change key \"{}\" to value \"{}\"".format(
|
||||
key, value
|
||||
))
|
||||
except NameError:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from openpype.hosts.nuke import api as napi
|
|||
from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings
|
||||
|
||||
|
||||
# Python 2/3 compatibility
|
||||
if sys.version_info[0] >= 3:
|
||||
unicode = str
|
||||
|
||||
|
|
@ -45,11 +46,12 @@ class ExtractThumbnail(publish.Extractor):
|
|||
for o_name, o_data in instance.data["bakePresets"].items():
|
||||
self.render_thumbnail(instance, o_name, **o_data)
|
||||
else:
|
||||
viewer_process_swithes = {
|
||||
viewer_process_switches = {
|
||||
"bake_viewer_process": True,
|
||||
"bake_viewer_input_process": True
|
||||
}
|
||||
self.render_thumbnail(instance, None, **viewer_process_swithes)
|
||||
self.render_thumbnail(
|
||||
instance, None, **viewer_process_switches)
|
||||
|
||||
def render_thumbnail(self, instance, output_name=None, **kwargs):
|
||||
first_frame = instance.data["frameStartHandle"]
|
||||
|
|
@ -61,15 +63,13 @@ class ExtractThumbnail(publish.Extractor):
|
|||
|
||||
# solve output name if any is set
|
||||
output_name = output_name or ""
|
||||
if output_name:
|
||||
output_name = "_" + output_name
|
||||
|
||||
bake_viewer_process = kwargs["bake_viewer_process"]
|
||||
bake_viewer_input_process_node = kwargs[
|
||||
"bake_viewer_input_process"]
|
||||
|
||||
node = instance.data["transientData"]["node"] # group node
|
||||
self.log.info("Creating staging dir...")
|
||||
self.log.debug("Creating staging dir...")
|
||||
|
||||
if "representations" not in instance.data:
|
||||
instance.data["representations"] = []
|
||||
|
|
@ -79,7 +79,7 @@ class ExtractThumbnail(publish.Extractor):
|
|||
|
||||
instance.data["stagingDir"] = staging_dir
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
|
||||
|
||||
temporary_nodes = []
|
||||
|
|
@ -166,26 +166,42 @@ class ExtractThumbnail(publish.Extractor):
|
|||
previous_node = dag_node
|
||||
temporary_nodes.append(dag_node)
|
||||
|
||||
thumb_name = "thumbnail"
|
||||
# only add output name and
|
||||
# if there are more than one bake preset
|
||||
if (
|
||||
output_name
|
||||
and len(instance.data.get("bakePresets", {}).keys()) > 1
|
||||
):
|
||||
thumb_name = "{}_{}".format(output_name, thumb_name)
|
||||
|
||||
# create write node
|
||||
write_node = nuke.createNode("Write")
|
||||
file = fhead[:-1] + output_name + ".jpg"
|
||||
name = "thumbnail"
|
||||
path = os.path.join(staging_dir, file).replace("\\", "/")
|
||||
instance.data["thumbnail"] = path
|
||||
write_node["file"].setValue(path)
|
||||
file = fhead[:-1] + thumb_name + ".jpg"
|
||||
thumb_path = os.path.join(staging_dir, file).replace("\\", "/")
|
||||
|
||||
# add thumbnail to cleanup
|
||||
instance.context.data["cleanupFullPaths"].append(thumb_path)
|
||||
|
||||
# make sure only one thumbnail path is set
|
||||
# and it is existing file
|
||||
instance_thumb_path = instance.data.get("thumbnailPath")
|
||||
if not instance_thumb_path or not os.path.isfile(instance_thumb_path):
|
||||
instance.data["thumbnailPath"] = thumb_path
|
||||
|
||||
write_node["file"].setValue(thumb_path)
|
||||
write_node["file_type"].setValue("jpg")
|
||||
write_node["raw"].setValue(1)
|
||||
write_node.setInput(0, previous_node)
|
||||
temporary_nodes.append(write_node)
|
||||
tags = ["thumbnail", "publish_on_farm"]
|
||||
|
||||
repre = {
|
||||
'name': name,
|
||||
'name': thumb_name,
|
||||
'ext': "jpg",
|
||||
"outputName": "thumb",
|
||||
"outputName": thumb_name,
|
||||
'files': file,
|
||||
"stagingDir": staging_dir,
|
||||
"tags": tags
|
||||
"tags": ["thumbnail", "publish_on_farm", "delete"]
|
||||
}
|
||||
instance.data["representations"].append(repre)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root>
|
||||
<error id="main">
|
||||
<title>Shot/Asset name</title>
|
||||
<description>
|
||||
## Publishing to a different asset context
|
||||
|
||||
There are publish instances present which are publishing into a different asset than your current context.
|
||||
|
||||
Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task.
|
||||
|
||||
If that's the case you can disable the validation on the instance to ignore it.
|
||||
|
||||
The wrong node's name is: \`{node_name}\`
|
||||
|
||||
### Correct context keys and values:
|
||||
|
||||
\`{correct_values}\`
|
||||
|
||||
### Wrong keys and values:
|
||||
|
||||
\`{wrong_values}\`.
|
||||
|
||||
|
||||
## How to repair?
|
||||
|
||||
1. Use \"Repair\" button.
|
||||
2. Hit Reload button on the publisher.
|
||||
</description>
|
||||
</error>
|
||||
</root>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<root>
|
||||
<error id="main">
|
||||
<title>Shot/Asset name</title>
|
||||
<description>
|
||||
## Invalid Shot/Asset name in subset
|
||||
|
||||
Following Node with name `{node_name}`:
|
||||
Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}`.
|
||||
|
||||
### How to repair?
|
||||
|
||||
1. Either use Repair or Select button.
|
||||
2. If you chose Select then rename asset knob to correct name.
|
||||
3. Hit Reload button on the publisher.
|
||||
</description>
|
||||
</error>
|
||||
</root>
|
||||
112
openpype/hosts/nuke/plugins/publish/validate_asset_context.py
Normal file
112
openpype/hosts/nuke/plugins/publish/validate_asset_context.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Validate if instance asset is the same as context asset."""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import pyblish.api
|
||||
|
||||
from openpype.pipeline.publish import (
|
||||
RepairAction,
|
||||
ValidateContentsOrder,
|
||||
PublishXmlValidationError,
|
||||
OptionalPyblishPluginMixin
|
||||
)
|
||||
from openpype.hosts.nuke.api import SelectInstanceNodeAction
|
||||
|
||||
|
||||
class ValidateCorrectAssetContext(
|
||||
pyblish.api.InstancePlugin,
|
||||
OptionalPyblishPluginMixin
|
||||
):
|
||||
"""Validator to check if instance asset context match context asset.
|
||||
|
||||
When working in per-shot style you always publish data in context of
|
||||
current asset (shot). This validator checks if this is so. It is optional
|
||||
so it can be disabled when needed.
|
||||
|
||||
Checking `asset` and `task` keys.
|
||||
"""
|
||||
order = ValidateContentsOrder
|
||||
label = "Validate asset context"
|
||||
hosts = ["nuke"]
|
||||
actions = [
|
||||
RepairAction,
|
||||
SelectInstanceNodeAction
|
||||
]
|
||||
optional = True
|
||||
|
||||
@classmethod
|
||||
def apply_settings(cls, project_settings):
|
||||
"""Apply deprecated settings from project settings.
|
||||
"""
|
||||
nuke_publish = project_settings["nuke"]["publish"]
|
||||
if "ValidateCorrectAssetName" in nuke_publish:
|
||||
settings = nuke_publish["ValidateCorrectAssetName"]
|
||||
else:
|
||||
settings = nuke_publish["ValidateCorrectAssetContext"]
|
||||
|
||||
cls.enabled = settings["enabled"]
|
||||
cls.optional = settings["optional"]
|
||||
cls.active = settings["active"]
|
||||
|
||||
def process(self, instance):
|
||||
if not self.is_active(instance.data):
|
||||
return
|
||||
|
||||
invalid_keys = self.get_invalid(instance)
|
||||
|
||||
if not invalid_keys:
|
||||
return
|
||||
|
||||
message_values = {
|
||||
"node_name": instance.data["transientData"]["node"].name(),
|
||||
"correct_values": ", ".join([
|
||||
"{} > {}".format(_key, instance.context.data[_key])
|
||||
for _key in invalid_keys
|
||||
]),
|
||||
"wrong_values": ", ".join([
|
||||
"{} > {}".format(_key, instance.data.get(_key))
|
||||
for _key in invalid_keys
|
||||
])
|
||||
}
|
||||
|
||||
msg = (
|
||||
"Instance `{node_name}` has wrong context keys:\n"
|
||||
"Correct: `{correct_values}` | Wrong: `{wrong_values}`").format(
|
||||
**message_values)
|
||||
|
||||
self.log.debug(msg)
|
||||
|
||||
raise PublishXmlValidationError(
|
||||
self, msg, formatting_data=message_values
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_invalid(cls, instance):
|
||||
"""Get invalid keys from instance data and context data."""
|
||||
|
||||
invalid_keys = []
|
||||
testing_keys = ["asset", "task"]
|
||||
for _key in testing_keys:
|
||||
if _key not in instance.data:
|
||||
invalid_keys.append(_key)
|
||||
continue
|
||||
if instance.data[_key] != instance.context.data[_key]:
|
||||
invalid_keys.append(_key)
|
||||
|
||||
return invalid_keys
|
||||
|
||||
@classmethod
|
||||
def repair(cls, instance):
|
||||
"""Repair instance data with context data."""
|
||||
invalid_keys = cls.get_invalid(instance)
|
||||
|
||||
create_context = instance.context.data["create_context"]
|
||||
|
||||
instance_id = instance.data.get("instance_id")
|
||||
created_instance = create_context.get_instance_by_id(
|
||||
instance_id
|
||||
)
|
||||
for _key in invalid_keys:
|
||||
created_instance[_key] = instance.context.data[_key]
|
||||
|
||||
create_context.save_changes()
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Validate if instance asset is the same as context asset."""
|
||||
from __future__ import absolute_import
|
||||
|
||||
import pyblish.api
|
||||
|
||||
import openpype.hosts.nuke.api.lib as nlib
|
||||
|
||||
from openpype.pipeline.publish import (
|
||||
ValidateContentsOrder,
|
||||
PublishXmlValidationError,
|
||||
OptionalPyblishPluginMixin
|
||||
)
|
||||
|
||||
class SelectInvalidInstances(pyblish.api.Action):
|
||||
"""Select invalid instances in Outliner."""
|
||||
|
||||
label = "Select"
|
||||
icon = "briefcase"
|
||||
on = "failed"
|
||||
|
||||
def process(self, context, plugin):
|
||||
"""Process invalid validators and select invalid instances."""
|
||||
# Get the errored instances
|
||||
failed = []
|
||||
for result in context.data["results"]:
|
||||
if (
|
||||
result["error"] is None
|
||||
or result["instance"] is None
|
||||
or result["instance"] in failed
|
||||
or result["plugin"] != plugin
|
||||
):
|
||||
continue
|
||||
|
||||
failed.append(result["instance"])
|
||||
|
||||
# Apply pyblish.logic to get the instances for the plug-in
|
||||
instances = pyblish.api.instances_by_plugin(failed, plugin)
|
||||
|
||||
if instances:
|
||||
self.deselect()
|
||||
self.log.info(
|
||||
"Selecting invalid nodes: %s" % ", ".join(
|
||||
[str(x) for x in instances]
|
||||
)
|
||||
)
|
||||
self.select(instances)
|
||||
else:
|
||||
self.log.info("No invalid nodes found.")
|
||||
self.deselect()
|
||||
|
||||
def select(self, instances):
|
||||
for inst in instances:
|
||||
if inst.data.get("transientData", {}).get("node"):
|
||||
select_node = inst.data["transientData"]["node"]
|
||||
select_node["selected"].setValue(True)
|
||||
|
||||
def deselect(self):
|
||||
nlib.reset_selection()
|
||||
|
||||
|
||||
class RepairSelectInvalidInstances(pyblish.api.Action):
|
||||
"""Repair the instance asset."""
|
||||
|
||||
label = "Repair"
|
||||
icon = "wrench"
|
||||
on = "failed"
|
||||
|
||||
def process(self, context, plugin):
|
||||
# Get the errored instances
|
||||
failed = []
|
||||
for result in context.data["results"]:
|
||||
if (
|
||||
result["error"] is None
|
||||
or result["instance"] is None
|
||||
or result["instance"] in failed
|
||||
or result["plugin"] != plugin
|
||||
):
|
||||
continue
|
||||
|
||||
failed.append(result["instance"])
|
||||
|
||||
# Apply pyblish.logic to get the instances for the plug-in
|
||||
instances = pyblish.api.instances_by_plugin(failed, plugin)
|
||||
self.log.debug(instances)
|
||||
|
||||
context_asset = context.data["assetEntity"]["name"]
|
||||
for instance in instances:
|
||||
node = instance.data["transientData"]["node"]
|
||||
node_data = nlib.get_node_data(node, nlib.INSTANCE_DATA_KNOB)
|
||||
node_data["asset"] = context_asset
|
||||
nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data)
|
||||
|
||||
|
||||
class ValidateCorrectAssetName(
|
||||
pyblish.api.InstancePlugin,
|
||||
OptionalPyblishPluginMixin
|
||||
):
|
||||
"""Validator to check if instance asset match context asset.
|
||||
|
||||
When working in per-shot style you always publish data in context of
|
||||
current asset (shot). This validator checks if this is so. It is optional
|
||||
so it can be disabled when needed.
|
||||
|
||||
Action on this validator will select invalid instances in Outliner.
|
||||
"""
|
||||
order = ValidateContentsOrder
|
||||
label = "Validate correct asset name"
|
||||
hosts = ["nuke"]
|
||||
actions = [
|
||||
SelectInvalidInstances,
|
||||
RepairSelectInvalidInstances
|
||||
]
|
||||
optional = True
|
||||
|
||||
def process(self, instance):
|
||||
if not self.is_active(instance.data):
|
||||
return
|
||||
|
||||
asset = instance.data.get("asset")
|
||||
context_asset = instance.context.data["assetEntity"]["name"]
|
||||
node = instance.data["transientData"]["node"]
|
||||
|
||||
msg = (
|
||||
"Instance `{}` has wrong shot/asset name:\n"
|
||||
"Correct: `{}` | Wrong: `{}`").format(
|
||||
instance.name, asset, context_asset)
|
||||
|
||||
self.log.debug(msg)
|
||||
|
||||
if asset != context_asset:
|
||||
raise PublishXmlValidationError(
|
||||
self, msg, formatting_data={
|
||||
"node_name": node.name(),
|
||||
"wrong_name": asset,
|
||||
"correct_name": context_asset
|
||||
}
|
||||
)
|
||||
|
|
@ -43,8 +43,8 @@ class SelectCenterInNodeGraph(pyblish.api.Action):
|
|||
all_xC.append(xC)
|
||||
all_yC.append(yC)
|
||||
|
||||
self.log.info("all_xC: `{}`".format(all_xC))
|
||||
self.log.info("all_yC: `{}`".format(all_yC))
|
||||
self.log.debug("all_xC: `{}`".format(all_xC))
|
||||
self.log.debug("all_yC: `{}`".format(all_yC))
|
||||
|
||||
# zoom to nodes in node graph
|
||||
nuke.zoom(2, [min(all_xC), min(all_yC)])
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class ValidateOutputResolution(
|
|||
order = pyblish.api.ValidatorOrder
|
||||
optional = True
|
||||
families = ["render"]
|
||||
label = "Write resolution"
|
||||
label = "Validate Write resolution"
|
||||
hosts = ["nuke"]
|
||||
actions = [RepairAction]
|
||||
|
||||
|
|
@ -104,9 +104,9 @@ class ValidateOutputResolution(
|
|||
_rfn["resize"].setValue(0)
|
||||
_rfn["black_outside"].setValue(1)
|
||||
|
||||
cls.log.info("I am adding reformat node")
|
||||
cls.log.info("Adding reformat node")
|
||||
|
||||
if cls.resolution_msg == invalid:
|
||||
reformat = cls.get_reformat(instance)
|
||||
reformat["format"].setValue(nuke.root()["format"].value())
|
||||
cls.log.info("I am fixing reformat to root.format")
|
||||
cls.log.info("Fixing reformat to root.format")
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
|
|||
return
|
||||
|
||||
collections, remainder = clique.assemble(repre["files"])
|
||||
self.log.info("collections: {}".format(str(collections)))
|
||||
self.log.info("remainder: {}".format(str(remainder)))
|
||||
self.log.debug("collections: {}".format(str(collections)))
|
||||
self.log.debug("remainder: {}".format(str(remainder)))
|
||||
|
||||
collection = collections[0]
|
||||
|
||||
|
|
@ -103,15 +103,15 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
|
|||
coll_start = min(collection.indexes)
|
||||
coll_end = max(collection.indexes)
|
||||
|
||||
self.log.info("frame_length: {}".format(frame_length))
|
||||
self.log.info("collected_frames_len: {}".format(
|
||||
self.log.debug("frame_length: {}".format(frame_length))
|
||||
self.log.debug("collected_frames_len: {}".format(
|
||||
collected_frames_len))
|
||||
self.log.info("f_start_h-f_end_h: {}-{}".format(
|
||||
self.log.debug("f_start_h-f_end_h: {}-{}".format(
|
||||
f_start_h, f_end_h))
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"coll_start-coll_end: {}-{}".format(coll_start, coll_end))
|
||||
|
||||
self.log.info(
|
||||
self.log.debug(
|
||||
"len(collection.indexes): {}".format(collected_frames_len)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action):
|
|||
|
||||
set_node_knobs_from_settings(write_node, correct_data["knobs"])
|
||||
|
||||
self.log.info("Node attributes were fixed")
|
||||
self.log.debug("Node attributes were fixed")
|
||||
|
||||
|
||||
class ValidateNukeWriteNode(
|
||||
|
|
@ -82,12 +82,6 @@ class ValidateNukeWriteNode(
|
|||
correct_data = get_write_node_template_attr(write_group_node)
|
||||
|
||||
check = []
|
||||
self.log.debug("__ write_node: {}".format(
|
||||
write_node
|
||||
))
|
||||
self.log.debug("__ correct_data: {}".format(
|
||||
correct_data
|
||||
))
|
||||
|
||||
# Collect key values of same type in a list.
|
||||
values_by_name = defaultdict(list)
|
||||
|
|
@ -96,9 +90,6 @@ class ValidateNukeWriteNode(
|
|||
|
||||
for knob_data in correct_data["knobs"]:
|
||||
knob_type = knob_data["type"]
|
||||
self.log.debug("__ knob_type: {}".format(
|
||||
knob_type
|
||||
))
|
||||
|
||||
if (
|
||||
knob_type == "__legacy__"
|
||||
|
|
@ -134,9 +125,6 @@ class ValidateNukeWriteNode(
|
|||
|
||||
fixed_values.append(value)
|
||||
|
||||
self.log.debug("__ key: {} | values: {}".format(
|
||||
key, fixed_values
|
||||
))
|
||||
if (
|
||||
node_value not in fixed_values
|
||||
and key != "file"
|
||||
|
|
@ -144,8 +132,6 @@ class ValidateNukeWriteNode(
|
|||
):
|
||||
check.append([key, value, write_node[key].value()])
|
||||
|
||||
self.log.info(check)
|
||||
|
||||
if check:
|
||||
self._make_error(check)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
|
||||
from openpype import AYON_SERVER_ENABLED
|
||||
from openpype.modules import OpenPypeModule, ITrayModule
|
||||
|
||||
|
||||
|
|
@ -75,20 +76,11 @@ class AvalonModule(OpenPypeModule, ITrayModule):
|
|||
|
||||
def show_library_loader(self):
|
||||
if self._library_loader_window is None:
|
||||
from qtpy import QtCore
|
||||
from openpype.tools.libraryloader import LibraryLoaderWindow
|
||||
from openpype.pipeline import install_openpype_plugins
|
||||
|
||||
libraryloader = LibraryLoaderWindow(
|
||||
show_projects=True,
|
||||
show_libraries=True
|
||||
)
|
||||
# Remove always on top flag for tray
|
||||
window_flags = libraryloader.windowFlags()
|
||||
if window_flags | QtCore.Qt.WindowStaysOnTopHint:
|
||||
window_flags ^= QtCore.Qt.WindowStaysOnTopHint
|
||||
libraryloader.setWindowFlags(window_flags)
|
||||
self._library_loader_window = libraryloader
|
||||
if AYON_SERVER_ENABLED:
|
||||
self._init_ayon_loader()
|
||||
else:
|
||||
self._init_library_loader()
|
||||
|
||||
install_openpype_plugins()
|
||||
|
||||
|
|
@ -106,3 +98,25 @@ class AvalonModule(OpenPypeModule, ITrayModule):
|
|||
if self.tray_initialized:
|
||||
from .rest_api import AvalonRestApiResource
|
||||
self.rest_api_obj = AvalonRestApiResource(self, server_manager)
|
||||
|
||||
def _init_library_loader(self):
|
||||
from qtpy import QtCore
|
||||
from openpype.tools.libraryloader import LibraryLoaderWindow
|
||||
|
||||
libraryloader = LibraryLoaderWindow(
|
||||
show_projects=True,
|
||||
show_libraries=True
|
||||
)
|
||||
# Remove always on top flag for tray
|
||||
window_flags = libraryloader.windowFlags()
|
||||
if window_flags | QtCore.Qt.WindowStaysOnTopHint:
|
||||
window_flags ^= QtCore.Qt.WindowStaysOnTopHint
|
||||
libraryloader.setWindowFlags(window_flags)
|
||||
self._library_loader_window = libraryloader
|
||||
|
||||
def _init_ayon_loader(self):
|
||||
from openpype.tools.ayon_loader.ui import LoaderWindow
|
||||
|
||||
libraryloader = LoaderWindow()
|
||||
|
||||
self._library_loader_window = libraryloader
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import os
|
||||
import shutil
|
||||
import filecmp
|
||||
|
||||
from openpype.client.entities import get_representations
|
||||
from openpype.lib.applications import PreLaunchHook, LaunchTypes
|
||||
|
|
@ -194,3 +195,69 @@ class CopyLastPublishedWorkfile(PreLaunchHook):
|
|||
self.data["last_workfile_path"] = local_workfile_path
|
||||
# Keep source filepath for further path conformation
|
||||
self.data["source_filepath"] = last_published_workfile_path
|
||||
|
||||
# Get resources directory
|
||||
resources_dir = os.path.join(
|
||||
os.path.dirname(local_workfile_path), 'resources'
|
||||
)
|
||||
# Make resource directory if it doesn't exist
|
||||
if not os.path.exists(resources_dir):
|
||||
os.mkdir(resources_dir)
|
||||
|
||||
# Copy resources to the local resources directory
|
||||
for file in workfile_representation['files']:
|
||||
# Get resource main path
|
||||
resource_main_path = anatomy.fill_root(file["path"])
|
||||
|
||||
# Get resource file basename
|
||||
resource_basename = os.path.basename(resource_main_path)
|
||||
|
||||
# Only copy if the resource file exists, and it's not the workfile
|
||||
if (
|
||||
not os.path.exists(resource_main_path)
|
||||
or resource_basename == os.path.basename(
|
||||
last_published_workfile_path
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
# Get resource path in workfile folder
|
||||
resource_work_path = os.path.join(
|
||||
resources_dir, resource_basename
|
||||
)
|
||||
|
||||
# Check if the resource file already exists in the resources folder
|
||||
if os.path.exists(resource_work_path):
|
||||
# Check if both files are the same
|
||||
if filecmp.cmp(resource_main_path, resource_work_path):
|
||||
self.log.warning(
|
||||
'Resource "{}" already exists.'
|
||||
.format(resource_basename)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Add `.old` to existing resource path
|
||||
resource_path_old = resource_work_path + '.old'
|
||||
if os.path.exists(resource_work_path + '.old'):
|
||||
for i in range(1, 100):
|
||||
p = resource_path_old + '%02d' % i
|
||||
if not os.path.exists(p):
|
||||
# Rename existing resource file to
|
||||
# `resource_name.old` + 2 digits
|
||||
shutil.move(resource_work_path, p)
|
||||
break
|
||||
else:
|
||||
self.log.warning(
|
||||
'There are a hundred old files for '
|
||||
'resource "{}". '
|
||||
'Perhaps is it time to clean up your '
|
||||
'resources folder'
|
||||
.format(resource_basename)
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Rename existing resource file to `resource_name.old`
|
||||
shutil.move(resource_work_path, resource_path_old)
|
||||
|
||||
# Copy resource file to workfile resources folder
|
||||
shutil.copy(resource_main_path, resources_dir)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ class TaskNotSetError(KeyError):
|
|||
super(TaskNotSetError, self).__init__(msg)
|
||||
|
||||
|
||||
class TemplateFillError(Exception):
|
||||
def __init__(self, msg=None):
|
||||
if not msg:
|
||||
msg = "Creator's subset name template is missing key value."
|
||||
super(TemplateFillError, self).__init__(msg)
|
||||
|
||||
|
||||
def get_subset_name_template(
|
||||
project_name,
|
||||
family,
|
||||
|
|
@ -112,6 +119,10 @@ def get_subset_name(
|
|||
for project. Settings are queried if not passed.
|
||||
family_filter (Optional[str]): Use different family for subset template
|
||||
filtering. Value of 'family' is used when not passed.
|
||||
|
||||
Raises:
|
||||
TemplateFillError: If filled template contains placeholder key which is not
|
||||
collected.
|
||||
"""
|
||||
|
||||
if not family:
|
||||
|
|
@ -154,4 +165,10 @@ def get_subset_name(
|
|||
for key, value in dynamic_data.items():
|
||||
fill_pairs[key] = value
|
||||
|
||||
return template.format(**prepare_template_data(fill_pairs))
|
||||
try:
|
||||
return template.format(**prepare_template_data(fill_pairs))
|
||||
except KeyError as exp:
|
||||
raise TemplateFillError(
|
||||
"Value for {} key is missing in template '{}'."
|
||||
" Available values are {}".format(str(exp), template, fill_pairs)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,17 +107,18 @@ def get_time_data_from_instance_or_context(instance):
|
|||
TimeData: dataclass holding time information.
|
||||
|
||||
"""
|
||||
context = instance.context
|
||||
return TimeData(
|
||||
start=(instance.data.get("frameStart") or
|
||||
instance.context.data.get("frameStart")),
|
||||
end=(instance.data.get("frameEnd") or
|
||||
instance.context.data.get("frameEnd")),
|
||||
fps=(instance.data.get("fps") or
|
||||
instance.context.data.get("fps")),
|
||||
handle_start=(instance.data.get("handleStart") or
|
||||
instance.context.data.get("handleStart")), # noqa: E501
|
||||
handle_end=(instance.data.get("handleEnd") or
|
||||
instance.context.data.get("handleEnd"))
|
||||
start=instance.data.get("frameStart", context.data.get("frameStart")),
|
||||
end=instance.data.get("frameEnd", context.data.get("frameEnd")),
|
||||
fps=instance.data.get("fps", context.data.get("fps")),
|
||||
step=instance.data.get("byFrameStep", instance.data.get("step", 1)),
|
||||
handle_start=instance.data.get(
|
||||
"handleStart", context.data.get("handleStart")
|
||||
),
|
||||
handle_end=instance.data.get(
|
||||
"handleEnd", context.data.get("handleEnd")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from .utils import (
|
|||
|
||||
loaders_from_repre_context,
|
||||
loaders_from_representation,
|
||||
filter_repre_contexts_by_loader,
|
||||
|
||||
any_outdated_containers,
|
||||
get_outdated_containers,
|
||||
|
|
@ -85,6 +86,7 @@ __all__ = (
|
|||
|
||||
"loaders_from_repre_context",
|
||||
"loaders_from_representation",
|
||||
"filter_repre_contexts_by_loader",
|
||||
|
||||
"any_outdated_containers",
|
||||
"get_outdated_containers",
|
||||
|
|
|
|||
|
|
@ -790,6 +790,24 @@ def loaders_from_repre_context(loaders, repre_context):
|
|||
]
|
||||
|
||||
|
||||
def filter_repre_contexts_by_loader(repre_contexts, loader):
|
||||
"""Filter representation contexts for loader.
|
||||
|
||||
Args:
|
||||
repre_contexts (list[dict[str, Ant]]): Representation context.
|
||||
loader (LoaderPlugin): Loader plugin to filter contexts for.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: Filtered representation contexts.
|
||||
"""
|
||||
|
||||
return [
|
||||
repre_context
|
||||
for repre_context in repre_contexts
|
||||
if is_compatible_loader(loader, repre_context)
|
||||
]
|
||||
|
||||
|
||||
def loaders_from_representation(loaders, representation):
|
||||
"""Return all compatible loaders for a representation."""
|
||||
|
||||
|
|
|
|||
|
|
@ -166,8 +166,12 @@ class ServerThumbnailResolver(ThumbnailResolver):
|
|||
|
||||
# This is new way how thumbnails can be received from server
|
||||
# - output is 'ThumbnailContent' object
|
||||
if hasattr(ayon_api, "get_thumbnail_by_id"):
|
||||
result = ayon_api.get_thumbnail_by_id(thumbnail_id)
|
||||
# NOTE Use 'get_server_api_connection' because public function
|
||||
# 'get_thumbnail_by_id' does not return output of 'ServerAPI'
|
||||
# method.
|
||||
con = ayon_api.get_server_api_connection()
|
||||
if hasattr(con, "get_thumbnail_by_id"):
|
||||
result = con.get_thumbnail_by_id(thumbnail_id)
|
||||
if result.is_valid:
|
||||
filepath = cache.store_thumbnail(
|
||||
project_name,
|
||||
|
|
@ -178,7 +182,7 @@ class ServerThumbnailResolver(ThumbnailResolver):
|
|||
else:
|
||||
# Backwards compatibility for ayon api where 'get_thumbnail_by_id'
|
||||
# is not implemented and output is filepath
|
||||
filepath = ayon_api.get_thumbnail(
|
||||
filepath = con.get_thumbnail(
|
||||
project_name, entity_type, entity_id, thumbnail_id
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,13 +29,12 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin):
|
|||
if not repres:
|
||||
return
|
||||
|
||||
thumbnail_repre = None
|
||||
thumbnail_repres = []
|
||||
for repre in repres:
|
||||
if repre["name"] == "thumbnail":
|
||||
thumbnail_repre = repre
|
||||
break
|
||||
if "thumbnail" in repre.get("tags", []):
|
||||
thumbnail_repres.append(repre)
|
||||
|
||||
if not thumbnail_repre:
|
||||
if not thumbnail_repres:
|
||||
return
|
||||
|
||||
family = instance.data["family"]
|
||||
|
|
@ -60,14 +59,15 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin):
|
|||
if not found_profile:
|
||||
return
|
||||
|
||||
thumbnail_repre.setdefault("tags", [])
|
||||
for thumbnail_repre in thumbnail_repres:
|
||||
thumbnail_repre.setdefault("tags", [])
|
||||
|
||||
if not found_profile["integrate_thumbnail"]:
|
||||
if "delete" not in thumbnail_repre["tags"]:
|
||||
thumbnail_repre["tags"].append("delete")
|
||||
else:
|
||||
if "delete" in thumbnail_repre["tags"]:
|
||||
thumbnail_repre["tags"].remove("delete")
|
||||
if not found_profile["integrate_thumbnail"]:
|
||||
if "delete" not in thumbnail_repre["tags"]:
|
||||
thumbnail_repre["tags"].append("delete")
|
||||
else:
|
||||
if "delete" in thumbnail_repre["tags"]:
|
||||
thumbnail_repre["tags"].remove("delete")
|
||||
|
||||
self.log.debug(
|
||||
"Thumbnail repre tags {}".format(thumbnail_repre["tags"]))
|
||||
self.log.debug(
|
||||
"Thumbnail repre tags {}".format(thumbnail_repre["tags"]))
|
||||
|
|
|
|||
|
|
@ -1164,19 +1164,19 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
|
|||
for profile in extract_oiio_transcode_profiles:
|
||||
new_outputs = {}
|
||||
name_counter = {}
|
||||
for output in profile["outputs"]:
|
||||
if "name" in output:
|
||||
name = output.pop("name")
|
||||
for profile_output in profile["outputs"]:
|
||||
if "name" in profile_output:
|
||||
name = profile_output.pop("name")
|
||||
else:
|
||||
# Backwards compatibility for setting without 'name' in model
|
||||
name = output["extension"]
|
||||
name = profile_output["extension"]
|
||||
if name in new_outputs:
|
||||
name_counter[name] += 1
|
||||
name = "{}_{}".format(name, name_counter[name])
|
||||
else:
|
||||
name_counter[name] = 0
|
||||
|
||||
new_outputs[name] = output
|
||||
new_outputs[name] = profile_output
|
||||
profile["outputs"] = new_outputs
|
||||
|
||||
# Extract Burnin plugin
|
||||
|
|
|
|||
|
|
@ -829,6 +829,11 @@
|
|||
"redshift_render_attributes": [],
|
||||
"renderman_render_attributes": []
|
||||
},
|
||||
"ValidateResolution": {
|
||||
"enabled": true,
|
||||
"optional": true,
|
||||
"active": true
|
||||
},
|
||||
"ValidateCurrentRenderLayerIsRenderable": {
|
||||
"enabled": true,
|
||||
"optional": false,
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@
|
|||
"write"
|
||||
]
|
||||
},
|
||||
"ValidateCorrectAssetName": {
|
||||
"ValidateCorrectAssetContext": {
|
||||
"enabled": true,
|
||||
"optional": true,
|
||||
"active": true
|
||||
|
|
|
|||
|
|
@ -114,6 +114,65 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"mayapy": {
|
||||
"enabled": true,
|
||||
"label": "MayaPy",
|
||||
"icon": "{}/app_icons/maya.png",
|
||||
"host_name": "maya",
|
||||
"environment": {
|
||||
"MAYA_DISABLE_CLIC_IPM": "Yes",
|
||||
"MAYA_DISABLE_CIP": "Yes",
|
||||
"MAYA_DISABLE_CER": "Yes",
|
||||
"PYMEL_SKIP_MEL_INIT": "Yes",
|
||||
"LC_ALL": "C"
|
||||
},
|
||||
"variants": {
|
||||
"2024": {
|
||||
"use_python_2": false,
|
||||
"executables": {
|
||||
"windows": [
|
||||
"C:\\Program Files\\Autodesk\\Maya2024\\bin\\mayapy.exe"
|
||||
],
|
||||
"darwin": [],
|
||||
"linux": [
|
||||
"/usr/autodesk/maya2024/bin/mayapy"
|
||||
]
|
||||
},
|
||||
"arguments": {
|
||||
"windows": [
|
||||
"-I"
|
||||
],
|
||||
"darwin": [],
|
||||
"linux": [
|
||||
"-I"
|
||||
]
|
||||
},
|
||||
"environment": {}
|
||||
},
|
||||
"2023": {
|
||||
"use_python_2": false,
|
||||
"executables": {
|
||||
"windows": [
|
||||
"C:\\Program Files\\Autodesk\\Maya2023\\bin\\mayapy.exe"
|
||||
],
|
||||
"darwin": [],
|
||||
"linux": [
|
||||
"/usr/autodesk/maya2023/bin/mayapy"
|
||||
]
|
||||
},
|
||||
"arguments": {
|
||||
"windows": [
|
||||
"-I"
|
||||
],
|
||||
"darwin": [],
|
||||
"linux": [
|
||||
"-I"
|
||||
]
|
||||
},
|
||||
"environment": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"3dsmax": {
|
||||
"enabled": true,
|
||||
"label": "3ds max",
|
||||
|
|
|
|||
|
|
@ -431,6 +431,10 @@
|
|||
"type": "schema_template",
|
||||
"name": "template_publish_plugin",
|
||||
"template_data": [
|
||||
{
|
||||
"key": "ValidateResolution",
|
||||
"label": "Validate Resolution Settings"
|
||||
},
|
||||
{
|
||||
"key": "ValidateCurrentRenderLayerIsRenderable",
|
||||
"label": "Validate Current Render Layer Has Renderable Camera"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
"name": "template_publish_plugin",
|
||||
"template_data": [
|
||||
{
|
||||
"key": "ValidateCorrectAssetName",
|
||||
"key": "ValidateCorrectAssetContext",
|
||||
"label": "Validate Correct Asset Name"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"type": "dict",
|
||||
"key": "mayapy",
|
||||
"label": "Autodesk MayaPy",
|
||||
"collapsible": true,
|
||||
"checkbox_key": "enabled",
|
||||
"children": [
|
||||
{
|
||||
"type": "boolean",
|
||||
"key": "enabled",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"type": "schema_template",
|
||||
"name": "template_host_unchangables"
|
||||
},
|
||||
{
|
||||
"key": "environment",
|
||||
"label": "Environment",
|
||||
"type": "raw-json"
|
||||
},
|
||||
{
|
||||
"type": "dict-modifiable",
|
||||
"key": "variants",
|
||||
"collapsible_key": true,
|
||||
"use_label_wrap": false,
|
||||
"object_type": {
|
||||
"type": "dict",
|
||||
"collapsible": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "schema_template",
|
||||
"name": "template_host_variant_items"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
"type": "schema",
|
||||
"name": "schema_maya"
|
||||
},
|
||||
{
|
||||
"type": "schema",
|
||||
"name": "schema_mayapy"
|
||||
},
|
||||
{
|
||||
"type": "schema",
|
||||
"name": "schema_3dsmax"
|
||||
|
|
|
|||
|
|
@ -103,4 +103,4 @@ class HierarchyPage(QtWidgets.QWidget):
|
|||
self._controller.refresh()
|
||||
|
||||
def _on_filter_text_changed(self, text):
|
||||
self._folders_widget.set_name_filer(text)
|
||||
self._folders_widget.set_name_filter(text)
|
||||
|
|
|
|||
6
openpype/tools/ayon_loader/__init__.py
Normal file
6
openpype/tools/ayon_loader/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .control import LoaderController
|
||||
|
||||
|
||||
__all__ = (
|
||||
"LoaderController",
|
||||
)
|
||||
851
openpype/tools/ayon_loader/abstract.py
Normal file
851
openpype/tools/ayon_loader/abstract.py
Normal file
|
|
@ -0,0 +1,851 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
import six
|
||||
|
||||
from openpype.lib.attribute_definitions import (
|
||||
AbstractAttrDef,
|
||||
serialize_attr_defs,
|
||||
deserialize_attr_defs,
|
||||
)
|
||||
|
||||
|
||||
class ProductTypeItem:
|
||||
"""Item representing product type.
|
||||
|
||||
Args:
|
||||
name (str): Product type name.
|
||||
icon (dict[str, Any]): Product type icon definition.
|
||||
checked (bool): Is product type checked for filtering.
|
||||
"""
|
||||
|
||||
def __init__(self, name, icon, checked):
|
||||
self.name = name
|
||||
self.icon = icon
|
||||
self.checked = checked
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"icon": self.icon,
|
||||
"checked": self.checked,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class ProductItem:
|
||||
"""Product item with it versions.
|
||||
|
||||
Args:
|
||||
product_id (str): Product id.
|
||||
product_type (str): Product type.
|
||||
product_name (str): Product name.
|
||||
product_icon (dict[str, Any]): Product icon definition.
|
||||
product_type_icon (dict[str, Any]): Product type icon definition.
|
||||
product_in_scene (bool): Is product in scene (only when used in DCC).
|
||||
group_name (str): Group name.
|
||||
folder_id (str): Folder id.
|
||||
folder_label (str): Folder label.
|
||||
version_items (dict[str, VersionItem]): Version items by id.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product_id,
|
||||
product_type,
|
||||
product_name,
|
||||
product_icon,
|
||||
product_type_icon,
|
||||
product_in_scene,
|
||||
group_name,
|
||||
folder_id,
|
||||
folder_label,
|
||||
version_items,
|
||||
):
|
||||
self.product_id = product_id
|
||||
self.product_type = product_type
|
||||
self.product_name = product_name
|
||||
self.product_icon = product_icon
|
||||
self.product_type_icon = product_type_icon
|
||||
self.product_in_scene = product_in_scene
|
||||
self.group_name = group_name
|
||||
self.folder_id = folder_id
|
||||
self.folder_label = folder_label
|
||||
self.version_items = version_items
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"product_id": self.product_id,
|
||||
"product_type": self.product_type,
|
||||
"product_name": self.product_name,
|
||||
"product_icon": self.product_icon,
|
||||
"product_type_icon": self.product_type_icon,
|
||||
"product_in_scene": self.product_in_scene,
|
||||
"group_name": self.group_name,
|
||||
"folder_id": self.folder_id,
|
||||
"folder_label": self.folder_label,
|
||||
"version_items": {
|
||||
version_id: version_item.to_data()
|
||||
for version_id, version_item in self.version_items.items()
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
version_items = {
|
||||
version_id: VersionItem.from_data(version)
|
||||
for version_id, version in data["version_items"].items()
|
||||
}
|
||||
data["version_items"] = version_items
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class VersionItem:
|
||||
"""Version item.
|
||||
|
||||
Object have implemented comparison operators to be sortable.
|
||||
|
||||
Args:
|
||||
version_id (str): Version id.
|
||||
version (int): Version. Can be negative when is hero version.
|
||||
is_hero (bool): Is hero version.
|
||||
product_id (str): Product id.
|
||||
thumbnail_id (Union[str, None]): Thumbnail id.
|
||||
published_time (Union[str, None]): Published time in format
|
||||
'%Y%m%dT%H%M%SZ'.
|
||||
author (Union[str, None]): Author.
|
||||
frame_range (Union[str, None]): Frame range.
|
||||
duration (Union[int, None]): Duration.
|
||||
handles (Union[str, None]): Handles.
|
||||
step (Union[int, None]): Step.
|
||||
comment (Union[str, None]): Comment.
|
||||
source (Union[str, None]): Source.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version_id,
|
||||
version,
|
||||
is_hero,
|
||||
product_id,
|
||||
thumbnail_id,
|
||||
published_time,
|
||||
author,
|
||||
frame_range,
|
||||
duration,
|
||||
handles,
|
||||
step,
|
||||
comment,
|
||||
source
|
||||
):
|
||||
self.version_id = version_id
|
||||
self.product_id = product_id
|
||||
self.thumbnail_id = thumbnail_id
|
||||
self.version = version
|
||||
self.is_hero = is_hero
|
||||
self.published_time = published_time
|
||||
self.author = author
|
||||
self.frame_range = frame_range
|
||||
self.duration = duration
|
||||
self.handles = handles
|
||||
self.step = step
|
||||
self.comment = comment
|
||||
self.source = source
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, VersionItem):
|
||||
return False
|
||||
return (
|
||||
self.is_hero == other.is_hero
|
||||
and self.version == other.version
|
||||
and self.version_id == other.version_id
|
||||
and self.product_id == other.product_id
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __gt__(self, other):
|
||||
if not isinstance(other, VersionItem):
|
||||
return False
|
||||
if (
|
||||
other.version == self.version
|
||||
and self.is_hero
|
||||
):
|
||||
return True
|
||||
return other.version < self.version
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"version_id": self.version_id,
|
||||
"product_id": self.product_id,
|
||||
"thumbnail_id": self.thumbnail_id,
|
||||
"version": self.version,
|
||||
"is_hero": self.is_hero,
|
||||
"published_time": self.published_time,
|
||||
"author": self.author,
|
||||
"frame_range": self.frame_range,
|
||||
"duration": self.duration,
|
||||
"handles": self.handles,
|
||||
"step": self.step,
|
||||
"comment": self.comment,
|
||||
"source": self.source,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class RepreItem:
|
||||
"""Representation item.
|
||||
|
||||
Args:
|
||||
representation_id (str): Representation id.
|
||||
representation_name (str): Representation name.
|
||||
representation_icon (dict[str, Any]): Representation icon definition.
|
||||
product_name (str): Product name.
|
||||
folder_label (str): Folder label.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
representation_id,
|
||||
representation_name,
|
||||
representation_icon,
|
||||
product_name,
|
||||
folder_label,
|
||||
):
|
||||
self.representation_id = representation_id
|
||||
self.representation_name = representation_name
|
||||
self.representation_icon = representation_icon
|
||||
self.product_name = product_name
|
||||
self.folder_label = folder_label
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"representation_id": self.representation_id,
|
||||
"representation_name": self.representation_name,
|
||||
"representation_icon": self.representation_icon,
|
||||
"product_name": self.product_name,
|
||||
"folder_label": self.folder_label,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class ActionItem:
|
||||
"""Action item that can be triggered.
|
||||
|
||||
Action item is defined for a specific context. To trigger the action
|
||||
use 'identifier' and context, it necessary also use 'options'.
|
||||
|
||||
Args:
|
||||
identifier (str): Action identifier.
|
||||
label (str): Action label.
|
||||
icon (dict[str, Any]): Action icon definition.
|
||||
tooltip (str): Action tooltip.
|
||||
options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]):
|
||||
Action options. Note: 'qargparse' is considered as deprecated.
|
||||
order (int): Action order.
|
||||
project_name (str): Project name.
|
||||
folder_ids (list[str]): Folder ids.
|
||||
product_ids (list[str]): Product ids.
|
||||
version_ids (list[str]): Version ids.
|
||||
representation_ids (list[str]): Representation ids.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier,
|
||||
label,
|
||||
icon,
|
||||
tooltip,
|
||||
options,
|
||||
order,
|
||||
project_name,
|
||||
folder_ids,
|
||||
product_ids,
|
||||
version_ids,
|
||||
representation_ids,
|
||||
):
|
||||
self.identifier = identifier
|
||||
self.label = label
|
||||
self.icon = icon
|
||||
self.tooltip = tooltip
|
||||
self.options = options
|
||||
self.order = order
|
||||
self.project_name = project_name
|
||||
self.folder_ids = folder_ids
|
||||
self.product_ids = product_ids
|
||||
self.version_ids = version_ids
|
||||
self.representation_ids = representation_ids
|
||||
|
||||
def _options_to_data(self):
|
||||
options = self.options
|
||||
if not options:
|
||||
return options
|
||||
if isinstance(options[0], AbstractAttrDef):
|
||||
return serialize_attr_defs(options)
|
||||
# NOTE: Data conversion is not used by default in loader tool. But for
|
||||
# future development of detached UI tools it would be better to be
|
||||
# prepared for it.
|
||||
raise NotImplementedError(
|
||||
"{}.to_data is not implemented. Use Attribute definitions"
|
||||
" from 'openpype.lib' instead of 'qargparse'.".format(
|
||||
self.__class__.__name__
|
||||
)
|
||||
)
|
||||
|
||||
def to_data(self):
|
||||
options = self._options_to_data()
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"label": self.label,
|
||||
"icon": self.icon,
|
||||
"tooltip": self.tooltip,
|
||||
"options": options,
|
||||
"order": self.order,
|
||||
"project_name": self.project_name,
|
||||
"folder_ids": self.folder_ids,
|
||||
"product_ids": self.product_ids,
|
||||
"version_ids": self.version_ids,
|
||||
"representation_ids": self.representation_ids,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data):
|
||||
options = data["options"]
|
||||
if options:
|
||||
options = deserialize_attr_defs(options)
|
||||
data["options"] = options
|
||||
return cls(**data)
|
||||
|
||||
|
||||
@six.add_metaclass(ABCMeta)
|
||||
class _BaseLoaderController(object):
|
||||
"""Base loader controller abstraction.
|
||||
|
||||
Abstract base class that is required for both frontend and backed.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_current_context(self):
|
||||
"""Current context is a context of the current scene.
|
||||
|
||||
Example output:
|
||||
{
|
||||
"project_name": "MyProject",
|
||||
"folder_id": "0011223344-5566778-99",
|
||||
"task_name": "Compositing",
|
||||
}
|
||||
|
||||
Returns:
|
||||
dict[str, Union[str, None]]: Context data.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reset(self):
|
||||
"""Reset all cached data to reload everything.
|
||||
|
||||
Triggers events "controller.reset.started" and
|
||||
"controller.reset.finished".
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Model wrappers
|
||||
@abstractmethod
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
"""Folder items for a project.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
sender (Optional[str]): Sender who requested the name.
|
||||
|
||||
Returns:
|
||||
list[FolderItem]: Folder items for the project.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Expected selection helpers
|
||||
@abstractmethod
|
||||
def get_expected_selection_data(self):
|
||||
"""Full expected selection information.
|
||||
|
||||
Expected selection is a selection that may not be yet selected in UI
|
||||
e.g. because of refreshing, this data tell the UI what should be
|
||||
selected when they finish their refresh.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Expected selection data.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_expected_selection(self, project_name, folder_id):
|
||||
"""Set expected selection.
|
||||
|
||||
Args:
|
||||
project_name (str): Name of project to be selected.
|
||||
folder_id (str): Id of folder to be selected.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BackendLoaderController(_BaseLoaderController):
|
||||
"""Backend loader controller abstraction.
|
||||
|
||||
What backend logic requires from a controller for proper logic.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
"""Emit event with a certain topic, data and source.
|
||||
|
||||
The event should be sent to both frontend and backend.
|
||||
|
||||
Args:
|
||||
topic (str): Event topic name.
|
||||
data (Optional[dict[str, Any]]): Event data.
|
||||
source (Optional[str]): Event source.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_loaded_product_ids(self):
|
||||
"""Return set of loaded product ids.
|
||||
|
||||
Returns:
|
||||
set[str]: Set of loaded product ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FrontendLoaderController(_BaseLoaderController):
|
||||
@abstractmethod
|
||||
def register_event_callback(self, topic, callback):
|
||||
"""Register callback for an event topic.
|
||||
|
||||
Args:
|
||||
topic (str): Event topic name.
|
||||
callback (func): Callback triggered when the event is emitted.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Expected selection helpers
|
||||
@abstractmethod
|
||||
def expected_project_selected(self, project_name):
|
||||
"""Expected project was selected in frontend.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def expected_folder_selected(self, folder_id):
|
||||
"""Expected folder was selected in frontend.
|
||||
|
||||
Args:
|
||||
folder_id (str): Folder id.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Model wrapper calls
|
||||
@abstractmethod
|
||||
def get_project_items(self, sender=None):
|
||||
"""Items for all projects available on server.
|
||||
|
||||
Triggers event topics "projects.refresh.started" and
|
||||
"projects.refresh.finished" with data:
|
||||
{
|
||||
"sender": sender
|
||||
}
|
||||
|
||||
Notes:
|
||||
Filtering of projects is done in UI.
|
||||
|
||||
Args:
|
||||
sender (Optional[str]): Sender who requested the items.
|
||||
|
||||
Returns:
|
||||
list[ProjectItem]: List of project items.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_product_items(self, project_name, folder_ids, sender=None):
|
||||
"""Product items for folder ids.
|
||||
|
||||
Triggers event topics "products.refresh.started" and
|
||||
"products.refresh.finished" with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"sender": sender
|
||||
}
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
folder_ids (Iterable[str]): Folder ids.
|
||||
sender (Optional[str]): Sender who requested the items.
|
||||
|
||||
Returns:
|
||||
list[ProductItem]: List of product items.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_product_item(self, project_name, product_id):
|
||||
"""Receive single product item.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
product_id (str): Product id.
|
||||
|
||||
Returns:
|
||||
Union[ProductItem, None]: Product info or None if not found.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_product_type_items(self, project_name):
|
||||
"""Product type items for a project.
|
||||
|
||||
Product types have defined if are checked for filtering or not.
|
||||
|
||||
Returns:
|
||||
list[ProductTypeItem]: List of product type items for a project.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_representation_items(
|
||||
self, project_name, version_ids, sender=None
|
||||
):
|
||||
"""Representation items for version ids.
|
||||
|
||||
Triggers event topics "model.representations.refresh.started" and
|
||||
"model.representations.refresh.finished" with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"version_ids": version_ids,
|
||||
"sender": sender
|
||||
}
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
sender (Optional[str]): Sender who requested the items.
|
||||
|
||||
Returns:
|
||||
list[RepreItem]: List of representation items.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_version_thumbnail_ids(self, project_name, version_ids):
|
||||
"""Get thumbnail ids for version ids.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
|
||||
Returns:
|
||||
dict[str, Union[str, Any]]: Thumbnail id by version id.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder_thumbnail_ids(self, project_name, folder_ids):
|
||||
"""Get thumbnail ids for folder ids.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
folder_ids (Iterable[str]): Folder ids.
|
||||
|
||||
Returns:
|
||||
dict[str, Union[str, Any]]: Thumbnail id by folder id.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_thumbnail_path(self, project_name, thumbnail_id):
|
||||
"""Get thumbnail path for thumbnail id.
|
||||
|
||||
This method should get a path to a thumbnail based on thumbnail id.
|
||||
Which probably means to download the thumbnail from server and store
|
||||
it locally.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
thumbnail_id (str): Thumbnail id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Thumbnail path or None if not found.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Selection model wrapper calls
|
||||
@abstractmethod
|
||||
def get_selected_project_name(self):
|
||||
"""Get selected project name.
|
||||
|
||||
The information is based on last selection from UI.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected project name.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_folder_ids(self):
|
||||
"""Get selected folder ids.
|
||||
|
||||
The information is based on last selection from UI.
|
||||
|
||||
Returns:
|
||||
list[str]: Selected folder ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_version_ids(self):
|
||||
"""Get selected version ids.
|
||||
|
||||
The information is based on last selection from UI.
|
||||
|
||||
Returns:
|
||||
list[str]: Selected version ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_selected_representation_ids(self):
|
||||
"""Get selected representation ids.
|
||||
|
||||
The information is based on last selection from UI.
|
||||
|
||||
Returns:
|
||||
list[str]: Selected representation ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_project(self, project_name):
|
||||
"""Set selected project.
|
||||
|
||||
Project selection changed in UI. Method triggers event with topic
|
||||
"selection.project.changed" with data:
|
||||
{
|
||||
"project_name": self._project_name
|
||||
}
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Selected project name.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_folders(self, folder_ids):
|
||||
"""Set selected folders.
|
||||
|
||||
Folder selection changed in UI. Method triggers event with topic
|
||||
"selection.folders.changed" with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids
|
||||
}
|
||||
|
||||
Args:
|
||||
folder_ids (Iterable[str]): Selected folder ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_versions(self, version_ids):
|
||||
"""Set selected versions.
|
||||
|
||||
Version selection changed in UI. Method triggers event with topic
|
||||
"selection.versions.changed" with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"version_ids": version_ids
|
||||
}
|
||||
|
||||
Args:
|
||||
version_ids (Iterable[str]): Selected version ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_selected_representations(self, repre_ids):
|
||||
"""Set selected representations.
|
||||
|
||||
Representation selection changed in UI. Method triggers event with
|
||||
topic "selection.representations.changed" with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"version_ids": version_ids,
|
||||
"representation_ids": representation_ids
|
||||
}
|
||||
|
||||
Args:
|
||||
repre_ids (Iterable[str]): Selected representation ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# Load action items
|
||||
@abstractmethod
|
||||
def get_versions_action_items(self, project_name, version_ids):
|
||||
"""Action items for versions selection.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of action items.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_representations_action_items(
|
||||
self, project_name, representation_ids
|
||||
):
|
||||
"""Action items for representations selection.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
representation_ids (Iterable[str]): Representation ids.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of action items.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def trigger_action_item(
|
||||
self,
|
||||
identifier,
|
||||
options,
|
||||
project_name,
|
||||
version_ids,
|
||||
representation_ids
|
||||
):
|
||||
"""Trigger action item.
|
||||
|
||||
Triggers event "load.started" with data:
|
||||
{
|
||||
"identifier": identifier,
|
||||
"id": <Random UUID>,
|
||||
}
|
||||
|
||||
And triggers "load.finished" with data:
|
||||
{
|
||||
"identifier": identifier,
|
||||
"id": <Random UUID>,
|
||||
"error_info": [...],
|
||||
}
|
||||
|
||||
Args:
|
||||
identifier (str): Action identifier.
|
||||
options (dict[str, Any]): Action option values from UI.
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
representation_ids (Iterable[str]): Representation ids.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def change_products_group(self, project_name, product_ids, group_name):
|
||||
"""Change group of products.
|
||||
|
||||
Triggers event "products.group.changed" with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"product_ids": product_ids,
|
||||
"group_name": group_name,
|
||||
}
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
product_ids (Iterable[str]): Product ids.
|
||||
group_name (str): New group name.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fill_root_in_source(self, source):
|
||||
"""Fill root in source path.
|
||||
|
||||
Args:
|
||||
source (Union[str, None]): Source of a published version. Usually
|
||||
rootless workfile path.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
# NOTE: Methods 'is_loaded_products_supported' and
|
||||
# 'is_standard_projects_filter_enabled' are both based on being in host
|
||||
# or not. Maybe we could implement only single method 'is_in_host'?
|
||||
@abstractmethod
|
||||
def is_loaded_products_supported(self):
|
||||
"""Is capable to get information about loaded products.
|
||||
|
||||
Returns:
|
||||
bool: True if it is supported.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_standard_projects_filter_enabled(self):
|
||||
"""Is standard projects filter enabled.
|
||||
|
||||
This is used for filtering out when loader tool is used in a host. In
|
||||
that case only current project and library projects should be shown.
|
||||
|
||||
Returns:
|
||||
bool: Frontend should filter out non-library projects, except
|
||||
current context project.
|
||||
"""
|
||||
|
||||
pass
|
||||
343
openpype/tools/ayon_loader/control.py
Normal file
343
openpype/tools/ayon_loader/control.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import logging
|
||||
|
||||
import ayon_api
|
||||
|
||||
from openpype.lib.events import QueuedEventSystem
|
||||
from openpype.pipeline import Anatomy, get_current_context
|
||||
from openpype.host import ILoadHost
|
||||
from openpype.tools.ayon_utils.models import (
|
||||
ProjectsModel,
|
||||
HierarchyModel,
|
||||
NestedCacheItem,
|
||||
CacheItem,
|
||||
ThumbnailsModel,
|
||||
)
|
||||
|
||||
from .abstract import BackendLoaderController, FrontendLoaderController
|
||||
from .models import SelectionModel, ProductsModel, LoaderActionsModel
|
||||
|
||||
|
||||
class ExpectedSelection:
|
||||
def __init__(self, controller):
|
||||
self._project_name = None
|
||||
self._folder_id = None
|
||||
|
||||
self._project_selected = True
|
||||
self._folder_selected = True
|
||||
|
||||
self._controller = controller
|
||||
|
||||
def _emit_change(self):
|
||||
self._controller.emit_event(
|
||||
"expected_selection_changed",
|
||||
self.get_expected_selection_data(),
|
||||
)
|
||||
|
||||
def set_expected_selection(self, project_name, folder_id):
|
||||
self._project_name = project_name
|
||||
self._folder_id = folder_id
|
||||
|
||||
self._project_selected = False
|
||||
self._folder_selected = False
|
||||
self._emit_change()
|
||||
|
||||
def get_expected_selection_data(self):
|
||||
project_current = False
|
||||
folder_current = False
|
||||
if not self._project_selected:
|
||||
project_current = True
|
||||
elif not self._folder_selected:
|
||||
folder_current = True
|
||||
return {
|
||||
"project": {
|
||||
"name": self._project_name,
|
||||
"current": project_current,
|
||||
"selected": self._project_selected,
|
||||
},
|
||||
"folder": {
|
||||
"id": self._folder_id,
|
||||
"current": folder_current,
|
||||
"selected": self._folder_selected,
|
||||
},
|
||||
}
|
||||
|
||||
def is_expected_project_selected(self, project_name):
|
||||
return project_name == self._project_name and self._project_selected
|
||||
|
||||
def is_expected_folder_selected(self, folder_id):
|
||||
return folder_id == self._folder_id and self._folder_selected
|
||||
|
||||
def expected_project_selected(self, project_name):
|
||||
if project_name != self._project_name:
|
||||
return False
|
||||
self._project_selected = True
|
||||
self._emit_change()
|
||||
return True
|
||||
|
||||
def expected_folder_selected(self, folder_id):
|
||||
if folder_id != self._folder_id:
|
||||
return False
|
||||
self._folder_selected = True
|
||||
self._emit_change()
|
||||
return True
|
||||
|
||||
|
||||
class LoaderController(BackendLoaderController, FrontendLoaderController):
|
||||
"""
|
||||
|
||||
Args:
|
||||
host (Optional[AbstractHost]): Host object. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, host=None):
|
||||
self._log = None
|
||||
self._host = host
|
||||
|
||||
self._event_system = self._create_event_system()
|
||||
|
||||
self._project_anatomy_cache = NestedCacheItem(
|
||||
levels=1, lifetime=60)
|
||||
self._loaded_products_cache = CacheItem(
|
||||
default_factory=set, lifetime=60)
|
||||
|
||||
self._selection_model = SelectionModel(self)
|
||||
self._expected_selection = ExpectedSelection(self)
|
||||
self._projects_model = ProjectsModel(self)
|
||||
self._hierarchy_model = HierarchyModel(self)
|
||||
self._products_model = ProductsModel(self)
|
||||
self._loader_actions_model = LoaderActionsModel(self)
|
||||
self._thumbnails_model = ThumbnailsModel()
|
||||
|
||||
@property
|
||||
def log(self):
|
||||
if self._log is None:
|
||||
self._log = logging.getLogger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
# ---------------------------------
|
||||
# Implementation of abstract methods
|
||||
# ---------------------------------
|
||||
# Events system
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
"""Use implemented event system to trigger event."""
|
||||
|
||||
if data is None:
|
||||
data = {}
|
||||
self._event_system.emit(topic, data, source)
|
||||
|
||||
def register_event_callback(self, topic, callback):
|
||||
self._event_system.add_callback(topic, callback)
|
||||
|
||||
def reset(self):
|
||||
self._emit_event("controller.reset.started")
|
||||
|
||||
project_name = self.get_selected_project_name()
|
||||
folder_ids = self.get_selected_folder_ids()
|
||||
|
||||
self._project_anatomy_cache.reset()
|
||||
self._loaded_products_cache.reset()
|
||||
|
||||
self._products_model.reset()
|
||||
self._hierarchy_model.reset()
|
||||
self._loader_actions_model.reset()
|
||||
self._projects_model.reset()
|
||||
self._thumbnails_model.reset()
|
||||
|
||||
self._projects_model.refresh()
|
||||
|
||||
if not project_name and not folder_ids:
|
||||
context = self.get_current_context()
|
||||
project_name = context["project_name"]
|
||||
folder_id = context["folder_id"]
|
||||
self.set_expected_selection(project_name, folder_id)
|
||||
|
||||
self._emit_event("controller.reset.finished")
|
||||
|
||||
# Expected selection helpers
|
||||
def get_expected_selection_data(self):
|
||||
return self._expected_selection.get_expected_selection_data()
|
||||
|
||||
def set_expected_selection(self, project_name, folder_id):
|
||||
self._expected_selection.set_expected_selection(
|
||||
project_name, folder_id
|
||||
)
|
||||
|
||||
def expected_project_selected(self, project_name):
|
||||
self._expected_selection.expected_project_selected(project_name)
|
||||
|
||||
def expected_folder_selected(self, folder_id):
|
||||
self._expected_selection.expected_folder_selected(folder_id)
|
||||
|
||||
# Entity model wrappers
|
||||
def get_project_items(self, sender=None):
|
||||
return self._projects_model.get_project_items(sender)
|
||||
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
return self._hierarchy_model.get_folder_items(project_name, sender)
|
||||
|
||||
def get_product_items(self, project_name, folder_ids, sender=None):
|
||||
return self._products_model.get_product_items(
|
||||
project_name, folder_ids, sender)
|
||||
|
||||
def get_product_item(self, project_name, product_id):
|
||||
return self._products_model.get_product_item(
|
||||
project_name, product_id
|
||||
)
|
||||
|
||||
def get_product_type_items(self, project_name):
|
||||
return self._products_model.get_product_type_items(project_name)
|
||||
|
||||
def get_representation_items(
|
||||
self, project_name, version_ids, sender=None
|
||||
):
|
||||
return self._products_model.get_repre_items(
|
||||
project_name, version_ids, sender
|
||||
)
|
||||
|
||||
def get_folder_thumbnail_ids(self, project_name, folder_ids):
|
||||
return self._thumbnails_model.get_folder_thumbnail_ids(
|
||||
project_name, folder_ids)
|
||||
|
||||
def get_version_thumbnail_ids(self, project_name, version_ids):
|
||||
return self._thumbnails_model.get_version_thumbnail_ids(
|
||||
project_name, version_ids)
|
||||
|
||||
def get_thumbnail_path(self, project_name, thumbnail_id):
|
||||
return self._thumbnails_model.get_thumbnail_path(
|
||||
project_name, thumbnail_id
|
||||
)
|
||||
|
||||
def change_products_group(self, project_name, product_ids, group_name):
|
||||
self._products_model.change_products_group(
|
||||
project_name, product_ids, group_name
|
||||
)
|
||||
|
||||
def get_versions_action_items(self, project_name, version_ids):
|
||||
return self._loader_actions_model.get_versions_action_items(
|
||||
project_name, version_ids)
|
||||
|
||||
def get_representations_action_items(
|
||||
self, project_name, representation_ids):
|
||||
return self._loader_actions_model.get_representations_action_items(
|
||||
project_name, representation_ids)
|
||||
|
||||
def trigger_action_item(
|
||||
self,
|
||||
identifier,
|
||||
options,
|
||||
project_name,
|
||||
version_ids,
|
||||
representation_ids
|
||||
):
|
||||
self._loader_actions_model.trigger_action_item(
|
||||
identifier,
|
||||
options,
|
||||
project_name,
|
||||
version_ids,
|
||||
representation_ids
|
||||
)
|
||||
|
||||
# Selection model wrappers
|
||||
def get_selected_project_name(self):
|
||||
return self._selection_model.get_selected_project_name()
|
||||
|
||||
def set_selected_project(self, project_name):
|
||||
self._selection_model.set_selected_project(project_name)
|
||||
|
||||
# Selection model wrappers
|
||||
def get_selected_folder_ids(self):
|
||||
return self._selection_model.get_selected_folder_ids()
|
||||
|
||||
def set_selected_folders(self, folder_ids):
|
||||
self._selection_model.set_selected_folders(folder_ids)
|
||||
|
||||
def get_selected_version_ids(self):
|
||||
return self._selection_model.get_selected_version_ids()
|
||||
|
||||
def set_selected_versions(self, version_ids):
|
||||
self._selection_model.set_selected_versions(version_ids)
|
||||
|
||||
def get_selected_representation_ids(self):
|
||||
return self._selection_model.get_selected_representation_ids()
|
||||
|
||||
def set_selected_representations(self, repre_ids):
|
||||
self._selection_model.set_selected_representations(repre_ids)
|
||||
|
||||
def fill_root_in_source(self, source):
|
||||
project_name = self.get_selected_project_name()
|
||||
anatomy = self._get_project_anatomy(project_name)
|
||||
if anatomy is None:
|
||||
return source
|
||||
|
||||
try:
|
||||
return anatomy.fill_root(source)
|
||||
except Exception:
|
||||
return source
|
||||
|
||||
def get_current_context(self):
|
||||
if self._host is None:
|
||||
return {
|
||||
"project_name": None,
|
||||
"folder_id": None,
|
||||
"task_name": None,
|
||||
}
|
||||
if hasattr(self._host, "get_current_context"):
|
||||
context = self._host.get_current_context()
|
||||
else:
|
||||
context = get_current_context()
|
||||
folder_id = None
|
||||
project_name = context.get("project_name")
|
||||
asset_name = context.get("asset_name")
|
||||
if project_name and asset_name:
|
||||
folder = ayon_api.get_folder_by_name(
|
||||
project_name, asset_name, fields=["id"]
|
||||
)
|
||||
if folder:
|
||||
folder_id = folder["id"]
|
||||
return {
|
||||
"project_name": project_name,
|
||||
"folder_id": folder_id,
|
||||
"task_name": context.get("task_name"),
|
||||
}
|
||||
|
||||
def get_loaded_product_ids(self):
|
||||
if self._host is None:
|
||||
return set()
|
||||
|
||||
context = self.get_current_context()
|
||||
project_name = context["project_name"]
|
||||
if not project_name:
|
||||
return set()
|
||||
|
||||
if not self._loaded_products_cache.is_valid:
|
||||
if isinstance(self._host, ILoadHost):
|
||||
containers = self._host.get_containers()
|
||||
else:
|
||||
containers = self._host.ls()
|
||||
repre_ids = {c.get("representation") for c in containers}
|
||||
repre_ids.discard(None)
|
||||
product_ids = self._products_model.get_product_ids_by_repre_ids(
|
||||
project_name, repre_ids
|
||||
)
|
||||
self._loaded_products_cache.update_data(product_ids)
|
||||
return self._loaded_products_cache.get_data()
|
||||
|
||||
def is_loaded_products_supported(self):
|
||||
return self._host is not None
|
||||
|
||||
def is_standard_projects_filter_enabled(self):
|
||||
return self._host is not None
|
||||
|
||||
def _get_project_anatomy(self, project_name):
|
||||
if not project_name:
|
||||
return None
|
||||
cache = self._project_anatomy_cache[project_name]
|
||||
if not cache.is_valid:
|
||||
cache.update_data(Anatomy(project_name))
|
||||
return cache.get_data()
|
||||
|
||||
def _create_event_system(self):
|
||||
return QueuedEventSystem()
|
||||
|
||||
def _emit_event(self, topic, data=None):
|
||||
self._event_system.emit(topic, data or {}, "controller")
|
||||
10
openpype/tools/ayon_loader/models/__init__.py
Normal file
10
openpype/tools/ayon_loader/models/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from .selection import SelectionModel
|
||||
from .products import ProductsModel
|
||||
from .actions import LoaderActionsModel
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SelectionModel",
|
||||
"ProductsModel",
|
||||
"LoaderActionsModel",
|
||||
)
|
||||
870
openpype/tools/ayon_loader/models/actions.py
Normal file
870
openpype/tools/ayon_loader/models/actions.py
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
import sys
|
||||
import traceback
|
||||
import inspect
|
||||
import copy
|
||||
import collections
|
||||
import uuid
|
||||
|
||||
from openpype.client import (
|
||||
get_project,
|
||||
get_assets,
|
||||
get_subsets,
|
||||
get_versions,
|
||||
get_representations,
|
||||
)
|
||||
from openpype.pipeline.load import (
|
||||
discover_loader_plugins,
|
||||
SubsetLoaderPlugin,
|
||||
filter_repre_contexts_by_loader,
|
||||
get_loader_identifier,
|
||||
load_with_repre_context,
|
||||
load_with_subset_context,
|
||||
load_with_subset_contexts,
|
||||
LoadError,
|
||||
IncompatibleLoaderError,
|
||||
)
|
||||
from openpype.tools.ayon_utils.models import NestedCacheItem
|
||||
from openpype.tools.ayon_loader.abstract import ActionItem
|
||||
|
||||
ACTIONS_MODEL_SENDER = "actions.model"
|
||||
NOT_SET = object()
|
||||
|
||||
|
||||
class LoaderActionsModel:
|
||||
"""Model for loader actions.
|
||||
|
||||
This is probably only part of models that requires to use codebase from
|
||||
'openpype.client' because of backwards compatibility with loaders logic
|
||||
which are expecting mongo documents.
|
||||
|
||||
TODOs:
|
||||
Deprecate 'qargparse' usage in loaders and implement conversion
|
||||
of 'ActionItem' to data (and 'from_data').
|
||||
Use controller to get entities (documents) -> possible only when
|
||||
loaders are able to handle AYON vs. OpenPype logic.
|
||||
Add missing site sync logic, and if possible remove it from loaders.
|
||||
Implement loader actions to replace load plugins.
|
||||
Ask loader actions to return action items instead of guessing them.
|
||||
"""
|
||||
|
||||
# Cache loader plugins for some time
|
||||
# NOTE Set to '0' for development
|
||||
loaders_cache_lifetime = 30
|
||||
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
self._current_context_project = NOT_SET
|
||||
self._loaders_by_identifier = NestedCacheItem(
|
||||
levels=1, lifetime=self.loaders_cache_lifetime)
|
||||
self._product_loaders = NestedCacheItem(
|
||||
levels=1, lifetime=self.loaders_cache_lifetime)
|
||||
self._repre_loaders = NestedCacheItem(
|
||||
levels=1, lifetime=self.loaders_cache_lifetime)
|
||||
|
||||
def reset(self):
|
||||
"""Reset the model with all cached items."""
|
||||
|
||||
self._current_context_project = NOT_SET
|
||||
self._loaders_by_identifier.reset()
|
||||
self._product_loaders.reset()
|
||||
self._repre_loaders.reset()
|
||||
|
||||
def get_versions_action_items(self, project_name, version_ids):
|
||||
"""Get action items for given version ids.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of action items.
|
||||
"""
|
||||
|
||||
(
|
||||
version_context_by_id,
|
||||
repre_context_by_id
|
||||
) = self._contexts_for_versions(
|
||||
project_name,
|
||||
version_ids
|
||||
)
|
||||
return self._get_action_items_for_contexts(
|
||||
project_name,
|
||||
version_context_by_id,
|
||||
repre_context_by_id
|
||||
)
|
||||
|
||||
def get_representations_action_items(
|
||||
self, project_name, representation_ids
|
||||
):
|
||||
"""Get action items for given representation ids.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
representation_ids (Iterable[str]): Representation ids.
|
||||
|
||||
Returns:
|
||||
list[ActionItem]: List of action items.
|
||||
"""
|
||||
|
||||
(
|
||||
product_context_by_id,
|
||||
repre_context_by_id
|
||||
) = self._contexts_for_representations(
|
||||
project_name,
|
||||
representation_ids
|
||||
)
|
||||
return self._get_action_items_for_contexts(
|
||||
project_name,
|
||||
product_context_by_id,
|
||||
repre_context_by_id
|
||||
)
|
||||
|
||||
def trigger_action_item(
|
||||
self,
|
||||
identifier,
|
||||
options,
|
||||
project_name,
|
||||
version_ids,
|
||||
representation_ids
|
||||
):
|
||||
"""Trigger action by identifier.
|
||||
|
||||
Triggers the action by identifier for given contexts.
|
||||
|
||||
Triggers events "load.started" and "load.finished". Finished event
|
||||
also contains "error_info" key with error information if any
|
||||
happened.
|
||||
|
||||
Args:
|
||||
identifier (str): Loader identifier.
|
||||
options (dict[str, Any]): Loader option values.
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
representation_ids (Iterable[str]): Representation ids.
|
||||
"""
|
||||
|
||||
event_data = {
|
||||
"identifier": identifier,
|
||||
"id": uuid.uuid4().hex,
|
||||
}
|
||||
self._controller.emit_event(
|
||||
"load.started",
|
||||
event_data,
|
||||
ACTIONS_MODEL_SENDER,
|
||||
)
|
||||
loader = self._get_loader_by_identifier(project_name, identifier)
|
||||
if representation_ids is not None:
|
||||
error_info = self._trigger_representation_loader(
|
||||
loader,
|
||||
options,
|
||||
project_name,
|
||||
representation_ids,
|
||||
)
|
||||
elif version_ids is not None:
|
||||
error_info = self._trigger_version_loader(
|
||||
loader,
|
||||
options,
|
||||
project_name,
|
||||
version_ids,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"Invalid arguments to trigger action item")
|
||||
|
||||
event_data["error_info"] = error_info
|
||||
self._controller.emit_event(
|
||||
"load.finished",
|
||||
event_data,
|
||||
ACTIONS_MODEL_SENDER,
|
||||
)
|
||||
|
||||
def _get_current_context_project(self):
|
||||
"""Get current context project name.
|
||||
|
||||
The value is based on controller (host) and cached.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Current context project.
|
||||
"""
|
||||
|
||||
if self._current_context_project is NOT_SET:
|
||||
context = self._controller.get_current_context()
|
||||
self._current_context_project = context["project_name"]
|
||||
return self._current_context_project
|
||||
|
||||
def _get_action_label(self, loader, representation=None):
|
||||
"""Pull label info from loader class.
|
||||
|
||||
Args:
|
||||
loader (LoaderPlugin): Plugin class.
|
||||
representation (Optional[dict[str, Any]]): Representation data.
|
||||
|
||||
Returns:
|
||||
str: Action label.
|
||||
"""
|
||||
|
||||
label = getattr(loader, "label", None)
|
||||
if label is None:
|
||||
label = loader.__name__
|
||||
if representation:
|
||||
# Add the representation as suffix
|
||||
label = "{} ({})".format(label, representation["name"])
|
||||
return label
|
||||
|
||||
def _get_action_icon(self, loader):
|
||||
"""Pull icon info from loader class.
|
||||
|
||||
Args:
|
||||
loader (LoaderPlugin): Plugin class.
|
||||
|
||||
Returns:
|
||||
Union[dict[str, Any], None]: Icon definition based on
|
||||
loader plugin.
|
||||
"""
|
||||
|
||||
# Support font-awesome icons using the `.icon` and `.color`
|
||||
# attributes on plug-ins.
|
||||
icon = getattr(loader, "icon", None)
|
||||
if icon is not None and not isinstance(icon, dict):
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": icon,
|
||||
"color": getattr(loader, "color", None) or "white"
|
||||
}
|
||||
return icon
|
||||
|
||||
def _get_action_tooltip(self, loader):
|
||||
"""Pull tooltip info from loader class.
|
||||
|
||||
Args:
|
||||
loader (LoaderPlugin): Plugin class.
|
||||
|
||||
Returns:
|
||||
str: Action tooltip.
|
||||
"""
|
||||
|
||||
# Add tooltip and statustip from Loader docstring
|
||||
return inspect.getdoc(loader)
|
||||
|
||||
def _filter_loaders_by_tool_name(self, project_name, loaders):
|
||||
"""Filter loaders by tool name.
|
||||
|
||||
Tool names are based on OpenPype tools loader tool and library
|
||||
loader tool. The new tool merged both into one tool and the difference
|
||||
is based only on current project name.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
loaders (list[LoaderPlugin]): List of loader plugins.
|
||||
|
||||
Returns:
|
||||
list[LoaderPlugin]: Filtered list of loader plugins.
|
||||
"""
|
||||
|
||||
# Keep filtering by tool name
|
||||
# - if current context project name is same as project name we do
|
||||
# expect the tool is used as OpenPype loader tool, otherwise
|
||||
# as library loader tool.
|
||||
if project_name == self._get_current_context_project():
|
||||
tool_name = "loader"
|
||||
else:
|
||||
tool_name = "library_loader"
|
||||
filtered_loaders = []
|
||||
for loader in loaders:
|
||||
tool_names = getattr(loader, "tool_names", None)
|
||||
if (
|
||||
tool_names is None
|
||||
or "*" in tool_names
|
||||
or tool_name in tool_names
|
||||
):
|
||||
filtered_loaders.append(loader)
|
||||
return filtered_loaders
|
||||
|
||||
def _create_loader_action_item(
|
||||
self,
|
||||
loader,
|
||||
contexts,
|
||||
project_name,
|
||||
folder_ids=None,
|
||||
product_ids=None,
|
||||
version_ids=None,
|
||||
representation_ids=None,
|
||||
repre_name=None,
|
||||
):
|
||||
label = self._get_action_label(loader)
|
||||
if repre_name:
|
||||
label = "{} ({})".format(label, repre_name)
|
||||
return ActionItem(
|
||||
get_loader_identifier(loader),
|
||||
label=label,
|
||||
icon=self._get_action_icon(loader),
|
||||
tooltip=self._get_action_tooltip(loader),
|
||||
options=loader.get_options(contexts),
|
||||
order=loader.order,
|
||||
project_name=project_name,
|
||||
folder_ids=folder_ids,
|
||||
product_ids=product_ids,
|
||||
version_ids=version_ids,
|
||||
representation_ids=representation_ids,
|
||||
)
|
||||
|
||||
def _get_loaders(self, project_name):
|
||||
"""Loaders with loaded settings for a project.
|
||||
|
||||
Questions:
|
||||
Project name is required because of settings. Should we actually
|
||||
pass in current project name instead of project name where
|
||||
we want to show loaders for?
|
||||
|
||||
Returns:
|
||||
tuple[list[SubsetLoaderPlugin], list[LoaderPlugin]]: Discovered
|
||||
loader plugins.
|
||||
"""
|
||||
|
||||
loaders_by_identifier_c = self._loaders_by_identifier[project_name]
|
||||
product_loaders_c = self._product_loaders[project_name]
|
||||
repre_loaders_c = self._repre_loaders[project_name]
|
||||
if loaders_by_identifier_c.is_valid:
|
||||
return product_loaders_c.get_data(), repre_loaders_c.get_data()
|
||||
|
||||
# Get all representation->loader combinations available for the
|
||||
# index under the cursor, so we can list the user the options.
|
||||
available_loaders = self._filter_loaders_by_tool_name(
|
||||
project_name, discover_loader_plugins(project_name)
|
||||
)
|
||||
|
||||
repre_loaders = []
|
||||
product_loaders = []
|
||||
loaders_by_identifier = {}
|
||||
for loader_cls in available_loaders:
|
||||
if not loader_cls.enabled:
|
||||
continue
|
||||
|
||||
identifier = get_loader_identifier(loader_cls)
|
||||
loaders_by_identifier[identifier] = loader_cls
|
||||
if issubclass(loader_cls, SubsetLoaderPlugin):
|
||||
product_loaders.append(loader_cls)
|
||||
else:
|
||||
repre_loaders.append(loader_cls)
|
||||
|
||||
loaders_by_identifier_c.update_data(loaders_by_identifier)
|
||||
product_loaders_c.update_data(product_loaders)
|
||||
repre_loaders_c.update_data(repre_loaders)
|
||||
return product_loaders, repre_loaders
|
||||
|
||||
def _get_loader_by_identifier(self, project_name, identifier):
|
||||
if not self._loaders_by_identifier[project_name].is_valid:
|
||||
self._get_loaders(project_name)
|
||||
loaders_by_identifier_c = self._loaders_by_identifier[project_name]
|
||||
loaders_by_identifier = loaders_by_identifier_c.get_data()
|
||||
return loaders_by_identifier.get(identifier)
|
||||
|
||||
def _actions_sorter(self, action_item):
|
||||
"""Sort the Loaders by their order and then their name.
|
||||
|
||||
Returns:
|
||||
tuple[int, str]: Sort keys.
|
||||
"""
|
||||
|
||||
return action_item.order, action_item.label
|
||||
|
||||
def _get_version_docs(self, project_name, version_ids):
|
||||
"""Get version documents for given version ids.
|
||||
|
||||
This function also handles hero versions and copies data from
|
||||
source version to it.
|
||||
|
||||
Todos:
|
||||
Remove this function when this is completely rewritten to
|
||||
use AYON calls.
|
||||
"""
|
||||
|
||||
version_docs = list(get_versions(
|
||||
project_name, version_ids=version_ids, hero=True
|
||||
))
|
||||
hero_versions_by_src_id = collections.defaultdict(list)
|
||||
src_hero_version = set()
|
||||
for version_doc in version_docs:
|
||||
if version_doc["type"] != "hero":
|
||||
continue
|
||||
version_id = ""
|
||||
src_hero_version.add(version_id)
|
||||
hero_versions_by_src_id[version_id].append(version_doc)
|
||||
|
||||
src_versions = []
|
||||
if src_hero_version:
|
||||
src_versions = get_versions(project_name, version_ids=version_ids)
|
||||
for src_version in src_versions:
|
||||
src_version_id = src_version["_id"]
|
||||
for hero_version in hero_versions_by_src_id[src_version_id]:
|
||||
hero_version["data"] = copy.deepcopy(src_version["data"])
|
||||
|
||||
return version_docs
|
||||
|
||||
def _contexts_for_versions(self, project_name, version_ids):
|
||||
"""Get contexts for given version ids.
|
||||
|
||||
Prepare version contexts for 'SubsetLoaderPlugin' and representation
|
||||
contexts for 'LoaderPlugin' for all children representations of
|
||||
given versions.
|
||||
|
||||
This method is very similar to '_contexts_for_representations' but the
|
||||
queries of documents are called in a different order.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
|
||||
representation contexts.
|
||||
"""
|
||||
|
||||
# TODO fix hero version
|
||||
version_context_by_id = {}
|
||||
repre_context_by_id = {}
|
||||
if not project_name and not version_ids:
|
||||
return version_context_by_id, repre_context_by_id
|
||||
|
||||
version_docs = self._get_version_docs(project_name, version_ids)
|
||||
version_docs_by_id = {}
|
||||
version_docs_by_product_id = collections.defaultdict(list)
|
||||
for version_doc in version_docs:
|
||||
version_id = version_doc["_id"]
|
||||
product_id = version_doc["parent"]
|
||||
version_docs_by_id[version_id] = version_doc
|
||||
version_docs_by_product_id[product_id].append(version_doc)
|
||||
|
||||
_product_ids = set(version_docs_by_product_id.keys())
|
||||
_product_docs = get_subsets(project_name, subset_ids=_product_ids)
|
||||
product_docs_by_id = {p["_id"]: p for p in _product_docs}
|
||||
|
||||
_folder_ids = {p["parent"] for p in product_docs_by_id.values()}
|
||||
_folder_docs = get_assets(project_name, asset_ids=_folder_ids)
|
||||
folder_docs_by_id = {f["_id"]: f for f in _folder_docs}
|
||||
|
||||
project_doc = get_project(project_name)
|
||||
project_doc["code"] = project_doc["data"]["code"]
|
||||
|
||||
for version_doc in version_docs:
|
||||
product_id = version_doc["parent"]
|
||||
product_doc = product_docs_by_id[product_id]
|
||||
folder_id = product_doc["parent"]
|
||||
folder_doc = folder_docs_by_id[folder_id]
|
||||
version_context_by_id[product_id] = {
|
||||
"project": project_doc,
|
||||
"asset": folder_doc,
|
||||
"subset": product_doc,
|
||||
"version": version_doc,
|
||||
}
|
||||
|
||||
repre_docs = get_representations(
|
||||
project_name, version_ids=version_ids)
|
||||
for repre_doc in repre_docs:
|
||||
version_id = repre_doc["parent"]
|
||||
version_doc = version_docs_by_id[version_id]
|
||||
product_id = version_doc["parent"]
|
||||
product_doc = product_docs_by_id[product_id]
|
||||
folder_id = product_doc["parent"]
|
||||
folder_doc = folder_docs_by_id[folder_id]
|
||||
|
||||
repre_context_by_id[repre_doc["_id"]] = {
|
||||
"project": project_doc,
|
||||
"asset": folder_doc,
|
||||
"subset": product_doc,
|
||||
"version": version_doc,
|
||||
"representation": repre_doc,
|
||||
}
|
||||
|
||||
return version_context_by_id, repre_context_by_id
|
||||
|
||||
def _contexts_for_representations(self, project_name, repre_ids):
|
||||
"""Get contexts for given representation ids.
|
||||
|
||||
Prepare version contexts for 'SubsetLoaderPlugin' and representation
|
||||
contexts for 'LoaderPlugin' for all children representations of
|
||||
given versions.
|
||||
|
||||
This method is very similar to '_contexts_for_versions' but the
|
||||
queries of documents are called in a different order.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
repre_ids (Iterable[str]): Representation ids.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
|
||||
representation contexts.
|
||||
"""
|
||||
|
||||
product_context_by_id = {}
|
||||
repre_context_by_id = {}
|
||||
if not project_name and not repre_ids:
|
||||
return product_context_by_id, repre_context_by_id
|
||||
|
||||
repre_docs = list(get_representations(
|
||||
project_name, representation_ids=repre_ids
|
||||
))
|
||||
version_ids = {r["parent"] for r in repre_docs}
|
||||
version_docs = self._get_version_docs(project_name, version_ids)
|
||||
version_docs_by_id = {
|
||||
v["_id"]: v for v in version_docs
|
||||
}
|
||||
|
||||
product_ids = {v["parent"] for v in version_docs_by_id.values()}
|
||||
product_docs = get_subsets(project_name, subset_ids=product_ids)
|
||||
product_docs_by_id = {
|
||||
p["_id"]: p for p in product_docs
|
||||
}
|
||||
|
||||
folder_ids = {p["parent"] for p in product_docs_by_id.values()}
|
||||
folder_docs = get_assets(project_name, asset_ids=folder_ids)
|
||||
folder_docs_by_id = {
|
||||
f["_id"]: f for f in folder_docs
|
||||
}
|
||||
|
||||
project_doc = get_project(project_name)
|
||||
project_doc["code"] = project_doc["data"]["code"]
|
||||
|
||||
for product_id, product_doc in product_docs_by_id.items():
|
||||
folder_id = product_doc["parent"]
|
||||
folder_doc = folder_docs_by_id[folder_id]
|
||||
product_context_by_id[product_id] = {
|
||||
"project": project_doc,
|
||||
"asset": folder_doc,
|
||||
"subset": product_doc,
|
||||
}
|
||||
|
||||
for repre_doc in repre_docs:
|
||||
version_id = repre_doc["parent"]
|
||||
version_doc = version_docs_by_id[version_id]
|
||||
product_id = version_doc["parent"]
|
||||
product_doc = product_docs_by_id[product_id]
|
||||
folder_id = product_doc["parent"]
|
||||
folder_doc = folder_docs_by_id[folder_id]
|
||||
|
||||
repre_context_by_id[repre_doc["_id"]] = {
|
||||
"project": project_doc,
|
||||
"asset": folder_doc,
|
||||
"subset": product_doc,
|
||||
"version": version_doc,
|
||||
"representation": repre_doc,
|
||||
}
|
||||
return product_context_by_id, repre_context_by_id
|
||||
|
||||
def _get_action_items_for_contexts(
|
||||
self,
|
||||
project_name,
|
||||
version_context_by_id,
|
||||
repre_context_by_id
|
||||
):
|
||||
"""Prepare action items based on contexts.
|
||||
|
||||
Actions are prepared based on discovered loader plugins and contexts.
|
||||
The context must be valid for the loader plugin.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_context_by_id (dict[str, dict[str, Any]]): Version
|
||||
contexts by version id.
|
||||
repre_context_by_id (dict[str, dict[str, Any]]): Representation
|
||||
"""
|
||||
|
||||
action_items = []
|
||||
if not version_context_by_id and not repre_context_by_id:
|
||||
return action_items
|
||||
|
||||
product_loaders, repre_loaders = self._get_loaders(project_name)
|
||||
|
||||
repre_contexts_by_name = collections.defaultdict(list)
|
||||
for repre_context in repre_context_by_id.values():
|
||||
repre_name = repre_context["representation"]["name"]
|
||||
repre_contexts_by_name[repre_name].append(repre_context)
|
||||
|
||||
for loader in repre_loaders:
|
||||
# # do not allow download whole repre, select specific repre
|
||||
# if tools_lib.is_sync_loader(loader):
|
||||
# continue
|
||||
|
||||
for repre_name, repre_contexts in repre_contexts_by_name.items():
|
||||
filtered_repre_contexts = filter_repre_contexts_by_loader(
|
||||
repre_contexts, loader)
|
||||
if not filtered_repre_contexts:
|
||||
continue
|
||||
|
||||
repre_ids = set()
|
||||
repre_version_ids = set()
|
||||
repre_product_ids = set()
|
||||
repre_folder_ids = set()
|
||||
for repre_context in filtered_repre_contexts:
|
||||
repre_ids.add(repre_context["representation"]["_id"])
|
||||
repre_product_ids.add(repre_context["subset"]["_id"])
|
||||
repre_version_ids.add(repre_context["version"]["_id"])
|
||||
repre_folder_ids.add(repre_context["asset"]["_id"])
|
||||
|
||||
item = self._create_loader_action_item(
|
||||
loader,
|
||||
repre_contexts,
|
||||
project_name=project_name,
|
||||
folder_ids=repre_folder_ids,
|
||||
product_ids=repre_product_ids,
|
||||
version_ids=repre_version_ids,
|
||||
representation_ids=repre_ids,
|
||||
repre_name=repre_name,
|
||||
)
|
||||
action_items.append(item)
|
||||
|
||||
# Subset Loaders.
|
||||
version_ids = set(version_context_by_id.keys())
|
||||
product_folder_ids = set()
|
||||
product_ids = set()
|
||||
for product_context in version_context_by_id.values():
|
||||
product_ids.add(product_context["subset"]["_id"])
|
||||
product_folder_ids.add(product_context["asset"]["_id"])
|
||||
|
||||
version_contexts = list(version_context_by_id.values())
|
||||
for loader in product_loaders:
|
||||
item = self._create_loader_action_item(
|
||||
loader,
|
||||
version_contexts,
|
||||
project_name=project_name,
|
||||
folder_ids=product_folder_ids,
|
||||
product_ids=product_ids,
|
||||
version_ids=version_ids,
|
||||
)
|
||||
action_items.append(item)
|
||||
|
||||
action_items.sort(key=self._actions_sorter)
|
||||
return action_items
|
||||
|
||||
def _trigger_version_loader(
|
||||
self,
|
||||
loader,
|
||||
options,
|
||||
project_name,
|
||||
version_ids,
|
||||
):
|
||||
"""Trigger version loader.
|
||||
|
||||
This triggers 'load' method of 'SubsetLoaderPlugin' for given version
|
||||
ids.
|
||||
|
||||
Note:
|
||||
Even when the plugin is 'SubsetLoaderPlugin' it actually expects
|
||||
versions and should be named 'VersionLoaderPlugin'. Because it
|
||||
is planned to refactor load system and introduce
|
||||
'LoaderAction' plugins it is not relevant to change it
|
||||
anymore.
|
||||
|
||||
Args:
|
||||
loader (SubsetLoaderPlugin): Loader plugin to use.
|
||||
options (dict): Option values for loader.
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
"""
|
||||
|
||||
project_doc = get_project(project_name)
|
||||
project_doc["code"] = project_doc["data"]["code"]
|
||||
|
||||
version_docs = self._get_version_docs(project_name, version_ids)
|
||||
product_ids = {v["parent"] for v in version_docs}
|
||||
product_docs = get_subsets(project_name, subset_ids=product_ids)
|
||||
product_docs_by_id = {f["_id"]: f for f in product_docs}
|
||||
folder_ids = {p["parent"] for p in product_docs_by_id.values()}
|
||||
folder_docs = get_assets(project_name, asset_ids=folder_ids)
|
||||
folder_docs_by_id = {f["_id"]: f for f in folder_docs}
|
||||
product_contexts = []
|
||||
for version_doc in version_docs:
|
||||
product_id = version_doc["parent"]
|
||||
product_doc = product_docs_by_id[product_id]
|
||||
folder_id = product_doc["parent"]
|
||||
folder_doc = folder_docs_by_id[folder_id]
|
||||
product_contexts.append({
|
||||
"project": project_doc,
|
||||
"asset": folder_doc,
|
||||
"subset": product_doc,
|
||||
"version": version_doc,
|
||||
})
|
||||
|
||||
return self._load_products_by_loader(
|
||||
loader, product_contexts, options
|
||||
)
|
||||
|
||||
def _trigger_representation_loader(
|
||||
self,
|
||||
loader,
|
||||
options,
|
||||
project_name,
|
||||
representation_ids,
|
||||
):
|
||||
"""Trigger representation loader.
|
||||
|
||||
This triggers 'load' method of 'LoaderPlugin' for given representation
|
||||
ids. For that are prepared contexts for each representation, with
|
||||
all parent documents.
|
||||
|
||||
Args:
|
||||
loader (LoaderPlugin): Loader plugin to use.
|
||||
options (dict): Option values for loader.
|
||||
project_name (str): Project name.
|
||||
representation_ids (Iterable[str]): Representation ids.
|
||||
"""
|
||||
|
||||
project_doc = get_project(project_name)
|
||||
project_doc["code"] = project_doc["data"]["code"]
|
||||
repre_docs = list(get_representations(
|
||||
project_name, representation_ids=representation_ids
|
||||
))
|
||||
version_ids = {r["parent"] for r in repre_docs}
|
||||
version_docs = self._get_version_docs(project_name, version_ids)
|
||||
version_docs_by_id = {v["_id"]: v for v in version_docs}
|
||||
product_ids = {v["parent"] for v in version_docs_by_id.values()}
|
||||
product_docs = get_subsets(project_name, subset_ids=product_ids)
|
||||
product_docs_by_id = {p["_id"]: p for p in product_docs}
|
||||
folder_ids = {p["parent"] for p in product_docs_by_id.values()}
|
||||
folder_docs = get_assets(project_name, asset_ids=folder_ids)
|
||||
folder_docs_by_id = {f["_id"]: f for f in folder_docs}
|
||||
repre_contexts = []
|
||||
for repre_doc in repre_docs:
|
||||
version_id = repre_doc["parent"]
|
||||
version_doc = version_docs_by_id[version_id]
|
||||
product_id = version_doc["parent"]
|
||||
product_doc = product_docs_by_id[product_id]
|
||||
folder_id = product_doc["parent"]
|
||||
folder_doc = folder_docs_by_id[folder_id]
|
||||
repre_contexts.append({
|
||||
"project": project_doc,
|
||||
"asset": folder_doc,
|
||||
"subset": product_doc,
|
||||
"version": version_doc,
|
||||
"representation": repre_doc,
|
||||
})
|
||||
|
||||
return self._load_representations_by_loader(
|
||||
loader, repre_contexts, options
|
||||
)
|
||||
|
||||
def _load_representations_by_loader(self, loader, repre_contexts, options):
|
||||
"""Loops through list of repre_contexts and loads them with one loader
|
||||
|
||||
Args:
|
||||
loader (LoaderPlugin): Loader plugin to use.
|
||||
repre_contexts (list[dict]): Full info about selected
|
||||
representations, containing repre, version, subset, asset and
|
||||
project documents.
|
||||
options (dict): Data from options.
|
||||
"""
|
||||
|
||||
error_info = []
|
||||
for repre_context in repre_contexts:
|
||||
version_doc = repre_context["version"]
|
||||
if version_doc["type"] == "hero_version":
|
||||
version_name = "Hero"
|
||||
else:
|
||||
version_name = version_doc.get("name")
|
||||
try:
|
||||
load_with_repre_context(
|
||||
loader,
|
||||
repre_context,
|
||||
options=options
|
||||
)
|
||||
|
||||
except IncompatibleLoaderError as exc:
|
||||
print(exc)
|
||||
error_info.append((
|
||||
"Incompatible Loader",
|
||||
None,
|
||||
repre_context["representation"]["name"],
|
||||
repre_context["subset"]["name"],
|
||||
version_name
|
||||
))
|
||||
|
||||
except Exception as exc:
|
||||
formatted_traceback = None
|
||||
if not isinstance(exc, LoadError):
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
formatted_traceback = "".join(traceback.format_exception(
|
||||
exc_type, exc_value, exc_traceback
|
||||
))
|
||||
|
||||
error_info.append((
|
||||
str(exc),
|
||||
formatted_traceback,
|
||||
repre_context["representation"]["name"],
|
||||
repre_context["subset"]["name"],
|
||||
version_name
|
||||
))
|
||||
return error_info
|
||||
|
||||
def _load_products_by_loader(self, loader, version_contexts, options):
|
||||
"""Triggers load with SubsetLoader type of loaders.
|
||||
|
||||
Warning:
|
||||
Plugin is named 'SubsetLoader' but version is passed to context
|
||||
too.
|
||||
|
||||
Args:
|
||||
loader (SubsetLoder): Loader used to load.
|
||||
version_contexts (list[dict[str, Any]]): For context for each
|
||||
version.
|
||||
options (dict[str, Any]): Options for loader that user could fill.
|
||||
"""
|
||||
|
||||
error_info = []
|
||||
if loader.is_multiple_contexts_compatible:
|
||||
subset_names = []
|
||||
for context in version_contexts:
|
||||
subset_name = context.get("subset", {}).get("name") or "N/A"
|
||||
subset_names.append(subset_name)
|
||||
try:
|
||||
load_with_subset_contexts(
|
||||
loader,
|
||||
version_contexts,
|
||||
options=options
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
formatted_traceback = None
|
||||
if not isinstance(exc, LoadError):
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
formatted_traceback = "".join(traceback.format_exception(
|
||||
exc_type, exc_value, exc_traceback
|
||||
))
|
||||
error_info.append((
|
||||
str(exc),
|
||||
formatted_traceback,
|
||||
None,
|
||||
", ".join(subset_names),
|
||||
None
|
||||
))
|
||||
else:
|
||||
for version_context in version_contexts:
|
||||
subset_name = (
|
||||
version_context.get("subset", {}).get("name") or "N/A"
|
||||
)
|
||||
try:
|
||||
load_with_subset_context(
|
||||
loader,
|
||||
version_context,
|
||||
options=options
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
formatted_traceback = None
|
||||
if not isinstance(exc, LoadError):
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
formatted_traceback = "".join(
|
||||
traceback.format_exception(
|
||||
exc_type, exc_value, exc_traceback
|
||||
)
|
||||
)
|
||||
|
||||
error_info.append((
|
||||
str(exc),
|
||||
formatted_traceback,
|
||||
None,
|
||||
subset_name,
|
||||
None
|
||||
))
|
||||
|
||||
return error_info
|
||||
682
openpype/tools/ayon_loader/models/products.py
Normal file
682
openpype/tools/ayon_loader/models/products.py
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
import collections
|
||||
import contextlib
|
||||
|
||||
import arrow
|
||||
import ayon_api
|
||||
from ayon_api.operations import OperationsSession
|
||||
|
||||
from openpype.style import get_default_entity_icon_color
|
||||
from openpype.tools.ayon_utils.models import NestedCacheItem
|
||||
from openpype.tools.ayon_loader.abstract import (
|
||||
ProductTypeItem,
|
||||
ProductItem,
|
||||
VersionItem,
|
||||
RepreItem,
|
||||
)
|
||||
|
||||
PRODUCTS_MODEL_SENDER = "products.model"
|
||||
|
||||
|
||||
def version_item_from_entity(version):
|
||||
version_attribs = version["attrib"]
|
||||
frame_start = version_attribs.get("frameStart")
|
||||
frame_end = version_attribs.get("frameEnd")
|
||||
handle_start = version_attribs.get("handleStart")
|
||||
handle_end = version_attribs.get("handleEnd")
|
||||
step = version_attribs.get("step")
|
||||
comment = version_attribs.get("comment")
|
||||
source = version_attribs.get("source")
|
||||
|
||||
frame_range = None
|
||||
duration = None
|
||||
handles = None
|
||||
if frame_start is not None and frame_end is not None:
|
||||
# Remove superfluous zeros from numbers (3.0 -> 3) to improve
|
||||
# readability for most frame ranges
|
||||
frame_start = int(frame_start)
|
||||
frame_end = int(frame_end)
|
||||
frame_range = "{}-{}".format(frame_start, frame_end)
|
||||
duration = frame_end - frame_start + 1
|
||||
|
||||
if handle_start is not None and handle_end is not None:
|
||||
handles = "{}-{}".format(int(handle_start), int(handle_end))
|
||||
|
||||
# NOTE There is also 'updatedAt', should be used that instead?
|
||||
# TODO skip conversion - converting to '%Y%m%dT%H%M%SZ' is because
|
||||
# 'PrettyTimeDelegate' expects it
|
||||
created_at = arrow.get(version["createdAt"])
|
||||
published_time = created_at.strftime("%Y%m%dT%H%M%SZ")
|
||||
author = version["author"]
|
||||
version_num = version["version"]
|
||||
is_hero = version_num < 0
|
||||
|
||||
return VersionItem(
|
||||
version_id=version["id"],
|
||||
version=version_num,
|
||||
is_hero=is_hero,
|
||||
product_id=version["productId"],
|
||||
thumbnail_id=version["thumbnailId"],
|
||||
published_time=published_time,
|
||||
author=author,
|
||||
frame_range=frame_range,
|
||||
duration=duration,
|
||||
handles=handles,
|
||||
step=step,
|
||||
comment=comment,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def product_item_from_entity(
|
||||
product_entity,
|
||||
version_entities,
|
||||
product_type_items_by_name,
|
||||
folder_label,
|
||||
product_in_scene,
|
||||
):
|
||||
product_attribs = product_entity["attrib"]
|
||||
group = product_attribs.get("productGroup")
|
||||
product_type = product_entity["productType"]
|
||||
product_type_item = product_type_items_by_name[product_type]
|
||||
product_type_icon = product_type_item.icon
|
||||
|
||||
product_icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.file-o",
|
||||
"color": get_default_entity_icon_color(),
|
||||
}
|
||||
version_items = {
|
||||
version_entity["id"]: version_item_from_entity(version_entity)
|
||||
for version_entity in version_entities
|
||||
}
|
||||
|
||||
return ProductItem(
|
||||
product_id=product_entity["id"],
|
||||
product_type=product_type,
|
||||
product_name=product_entity["name"],
|
||||
product_icon=product_icon,
|
||||
product_type_icon=product_type_icon,
|
||||
product_in_scene=product_in_scene,
|
||||
group_name=group,
|
||||
folder_id=product_entity["folderId"],
|
||||
folder_label=folder_label,
|
||||
version_items=version_items,
|
||||
)
|
||||
|
||||
|
||||
def product_type_item_from_data(product_type_data):
|
||||
# TODO implement icon implementation
|
||||
# icon = product_type_data["icon"]
|
||||
# color = product_type_data["color"]
|
||||
icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.folder",
|
||||
"color": "#0091B2",
|
||||
}
|
||||
# TODO implement checked logic
|
||||
return ProductTypeItem(product_type_data["name"], icon, True)
|
||||
|
||||
|
||||
class ProductsModel:
|
||||
"""Model for products, version and representation.
|
||||
|
||||
All of the entities are product based. This model prepares data for UI
|
||||
and caches it for faster access.
|
||||
|
||||
Note:
|
||||
Data are not used for actions model because that would require to
|
||||
break OpenPype compatibility of 'LoaderPlugin's.
|
||||
"""
|
||||
|
||||
lifetime = 60 # In seconds (minute by default)
|
||||
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
|
||||
# Mapping helpers
|
||||
# NOTE - mapping must be cleaned up with cache cleanup
|
||||
self._product_item_by_id = collections.defaultdict(dict)
|
||||
self._version_item_by_id = collections.defaultdict(dict)
|
||||
self._product_folder_ids_mapping = collections.defaultdict(dict)
|
||||
|
||||
# Cache helpers
|
||||
self._product_type_items_cache = NestedCacheItem(
|
||||
levels=1, default_factory=list, lifetime=self.lifetime)
|
||||
self._product_items_cache = NestedCacheItem(
|
||||
levels=2, default_factory=dict, lifetime=self.lifetime)
|
||||
self._repre_items_cache = NestedCacheItem(
|
||||
levels=2, default_factory=dict, lifetime=self.lifetime)
|
||||
|
||||
def reset(self):
|
||||
"""Reset model with all cached data."""
|
||||
|
||||
self._product_item_by_id.clear()
|
||||
self._version_item_by_id.clear()
|
||||
self._product_folder_ids_mapping.clear()
|
||||
|
||||
self._product_type_items_cache.reset()
|
||||
self._product_items_cache.reset()
|
||||
self._repre_items_cache.reset()
|
||||
|
||||
def get_product_type_items(self, project_name):
|
||||
"""Product type items for project.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
|
||||
Returns:
|
||||
list[ProductTypeItem]: Product type items.
|
||||
"""
|
||||
|
||||
cache = self._product_type_items_cache[project_name]
|
||||
if not cache.is_valid:
|
||||
product_types = ayon_api.get_project_product_types(project_name)
|
||||
cache.update_data([
|
||||
product_type_item_from_data(product_type)
|
||||
for product_type in product_types
|
||||
])
|
||||
return cache.get_data()
|
||||
|
||||
def get_product_items(self, project_name, folder_ids, sender):
|
||||
"""Product items with versions for project and folder ids.
|
||||
|
||||
Product items also contain version items. They're directly connected
|
||||
to product items in the UI and the separation is not needed.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Project name.
|
||||
folder_ids (Iterable[str]): Folder ids.
|
||||
sender (Union[str, None]): Who triggered the method.
|
||||
|
||||
Returns:
|
||||
list[ProductItem]: Product items.
|
||||
"""
|
||||
|
||||
if not project_name or not folder_ids:
|
||||
return []
|
||||
|
||||
project_cache = self._product_items_cache[project_name]
|
||||
output = []
|
||||
folder_ids_to_update = set()
|
||||
for folder_id in folder_ids:
|
||||
cache = project_cache[folder_id]
|
||||
if cache.is_valid:
|
||||
output.extend(cache.get_data().values())
|
||||
else:
|
||||
folder_ids_to_update.add(folder_id)
|
||||
|
||||
self._refresh_product_items(
|
||||
project_name, folder_ids_to_update, sender)
|
||||
|
||||
for folder_id in folder_ids_to_update:
|
||||
cache = project_cache[folder_id]
|
||||
output.extend(cache.get_data().values())
|
||||
return output
|
||||
|
||||
def get_product_item(self, project_name, product_id):
|
||||
"""Get product item based on passed product id.
|
||||
|
||||
This method is using cached items, but if cache is not valid it also
|
||||
can query the item.
|
||||
|
||||
Args:
|
||||
project_name (Union[str, None]): Where to look for product.
|
||||
product_id (Union[str, None]): Product id to receive.
|
||||
|
||||
Returns:
|
||||
Union[ProductItem, None]: Product item or 'None' if not found.
|
||||
"""
|
||||
|
||||
if not any((project_name, product_id)):
|
||||
return None
|
||||
|
||||
product_items_by_id = self._product_item_by_id[project_name]
|
||||
product_item = product_items_by_id.get(product_id)
|
||||
if product_item is not None:
|
||||
return product_item
|
||||
for product_item in self._query_product_items_by_ids(
|
||||
project_name, product_ids=[product_id]
|
||||
).values():
|
||||
return product_item
|
||||
|
||||
def get_product_ids_by_repre_ids(self, project_name, repre_ids):
|
||||
"""Get product ids based on passed representation ids.
|
||||
|
||||
Args:
|
||||
project_name (str): Where to look for representations.
|
||||
repre_ids (Iterable[str]): Representation ids.
|
||||
|
||||
Returns:
|
||||
set[str]: Product ids for passed representation ids.
|
||||
"""
|
||||
|
||||
# TODO look out how to use single server call
|
||||
if not repre_ids:
|
||||
return set()
|
||||
repres = ayon_api.get_representations(
|
||||
project_name, repre_ids, fields=["versionId"]
|
||||
)
|
||||
version_ids = {repre["versionId"] for repre in repres}
|
||||
if not version_ids:
|
||||
return set()
|
||||
versions = ayon_api.get_versions(
|
||||
project_name, version_ids=version_ids, fields=["productId"]
|
||||
)
|
||||
return {v["productId"] for v in versions}
|
||||
|
||||
def get_repre_items(self, project_name, version_ids, sender):
|
||||
"""Get representation items for passed version ids.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
version_ids (Iterable[str]): Version ids.
|
||||
sender (Union[str, None]): Who triggered the method.
|
||||
|
||||
Returns:
|
||||
list[RepreItem]: Representation items.
|
||||
"""
|
||||
|
||||
output = []
|
||||
if not any((project_name, version_ids)):
|
||||
return output
|
||||
|
||||
invalid_version_ids = set()
|
||||
project_cache = self._repre_items_cache[project_name]
|
||||
for version_id in version_ids:
|
||||
version_cache = project_cache[version_id]
|
||||
if version_cache.is_valid:
|
||||
output.extend(version_cache.get_data().values())
|
||||
else:
|
||||
invalid_version_ids.add(version_id)
|
||||
|
||||
if invalid_version_ids:
|
||||
self.refresh_representation_items(
|
||||
project_name, invalid_version_ids, sender
|
||||
)
|
||||
|
||||
for version_id in invalid_version_ids:
|
||||
version_cache = project_cache[version_id]
|
||||
output.extend(version_cache.get_data().values())
|
||||
|
||||
return output
|
||||
|
||||
def change_products_group(self, project_name, product_ids, group_name):
|
||||
"""Change group name for passed product ids.
|
||||
|
||||
Group name is stored in 'attrib' of product entity and is used in UI
|
||||
to group items.
|
||||
|
||||
Method triggers "products.group.changed" event with data:
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"product_ids": product_ids,
|
||||
"group_name": group_name
|
||||
}
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
product_ids (Iterable[str]): Product ids to change group name for.
|
||||
group_name (str): Group name to set.
|
||||
"""
|
||||
|
||||
if not product_ids:
|
||||
return
|
||||
|
||||
product_items = self._get_product_items_by_id(
|
||||
project_name, product_ids
|
||||
)
|
||||
if not product_items:
|
||||
return
|
||||
|
||||
session = OperationsSession()
|
||||
folder_ids = set()
|
||||
for product_item in product_items.values():
|
||||
session.update_entity(
|
||||
project_name,
|
||||
"product",
|
||||
product_item.product_id,
|
||||
{"attrib": {"productGroup": group_name}}
|
||||
)
|
||||
folder_ids.add(product_item.folder_id)
|
||||
product_item.group_name = group_name
|
||||
|
||||
session.commit()
|
||||
self._controller.emit_event(
|
||||
"products.group.changed",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"product_ids": product_ids,
|
||||
"group_name": group_name,
|
||||
},
|
||||
PRODUCTS_MODEL_SENDER
|
||||
)
|
||||
|
||||
def _get_product_items_by_id(self, project_name, product_ids):
|
||||
product_item_by_id = self._product_item_by_id[project_name]
|
||||
missing_product_ids = set()
|
||||
output = {}
|
||||
for product_id in product_ids:
|
||||
product_item = product_item_by_id.get(product_id)
|
||||
if product_item is not None:
|
||||
output[product_id] = product_item
|
||||
else:
|
||||
missing_product_ids.add(product_id)
|
||||
|
||||
output.update(
|
||||
self._query_product_items_by_ids(
|
||||
project_name, missing_product_ids
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
def _get_version_items_by_id(self, project_name, version_ids):
|
||||
version_item_by_id = self._version_item_by_id[project_name]
|
||||
missing_version_ids = set()
|
||||
output = {}
|
||||
for version_id in version_ids:
|
||||
version_item = version_item_by_id.get(version_id)
|
||||
if version_item is not None:
|
||||
output[version_id] = version_item
|
||||
else:
|
||||
missing_version_ids.add(version_id)
|
||||
|
||||
output.update(
|
||||
self._query_version_items_by_ids(
|
||||
project_name, missing_version_ids
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
def _create_product_items(
|
||||
self,
|
||||
project_name,
|
||||
products,
|
||||
versions,
|
||||
folder_items=None,
|
||||
product_type_items=None,
|
||||
):
|
||||
if folder_items is None:
|
||||
folder_items = self._controller.get_folder_items(project_name)
|
||||
|
||||
if product_type_items is None:
|
||||
product_type_items = self.get_product_type_items(project_name)
|
||||
|
||||
loaded_product_ids = self._controller.get_loaded_product_ids()
|
||||
|
||||
versions_by_product_id = collections.defaultdict(list)
|
||||
for version in versions:
|
||||
versions_by_product_id[version["productId"]].append(version)
|
||||
product_type_items_by_name = {
|
||||
product_type_item.name: product_type_item
|
||||
for product_type_item in product_type_items
|
||||
}
|
||||
output = {}
|
||||
for product in products:
|
||||
product_id = product["id"]
|
||||
folder_id = product["folderId"]
|
||||
folder_item = folder_items.get(folder_id)
|
||||
if not folder_item:
|
||||
continue
|
||||
versions = versions_by_product_id[product_id]
|
||||
if not versions:
|
||||
continue
|
||||
product_item = product_item_from_entity(
|
||||
product,
|
||||
versions,
|
||||
product_type_items_by_name,
|
||||
folder_item.label,
|
||||
product_id in loaded_product_ids,
|
||||
)
|
||||
output[product_id] = product_item
|
||||
return output
|
||||
|
||||
def _query_product_items_by_ids(
|
||||
self,
|
||||
project_name,
|
||||
folder_ids=None,
|
||||
product_ids=None,
|
||||
folder_items=None
|
||||
):
|
||||
"""Query product items.
|
||||
|
||||
This method does get from, or store to, cache attributes.
|
||||
|
||||
One of 'product_ids' or 'folder_ids' must be passed to the method.
|
||||
|
||||
Args:
|
||||
project_name (str): Project name.
|
||||
folder_ids (Optional[Iterable[str]]): Folder ids under which are
|
||||
products.
|
||||
product_ids (Optional[Iterable[str]]): Product ids to use.
|
||||
folder_items (Optional[Dict[str, FolderItem]]): Prepared folder
|
||||
items from controller.
|
||||
|
||||
Returns:
|
||||
dict[str, ProductItem]: Product items by product id.
|
||||
"""
|
||||
|
||||
if not folder_ids and not product_ids:
|
||||
return {}
|
||||
|
||||
kwargs = {}
|
||||
if folder_ids is not None:
|
||||
kwargs["folder_ids"] = folder_ids
|
||||
|
||||
if product_ids is not None:
|
||||
kwargs["product_ids"] = product_ids
|
||||
|
||||
products = list(ayon_api.get_products(project_name, **kwargs))
|
||||
product_ids = {product["id"] for product in products}
|
||||
|
||||
versions = ayon_api.get_versions(
|
||||
project_name, product_ids=product_ids
|
||||
)
|
||||
|
||||
return self._create_product_items(
|
||||
project_name, products, versions, folder_items=folder_items
|
||||
)
|
||||
|
||||
def _query_version_items_by_ids(self, project_name, version_ids):
|
||||
versions = list(ayon_api.get_versions(
|
||||
project_name, version_ids=version_ids
|
||||
))
|
||||
product_ids = {version["productId"] for version in versions}
|
||||
products = list(ayon_api.get_products(
|
||||
project_name, product_ids=product_ids
|
||||
))
|
||||
product_items = self._create_product_items(
|
||||
project_name, products, versions
|
||||
)
|
||||
version_items = {}
|
||||
for product_item in product_items.values():
|
||||
version_items.update(product_item.version_items)
|
||||
return version_items
|
||||
|
||||
def _clear_product_version_items(self, project_name, folder_ids):
|
||||
"""Clear product and version items from memory.
|
||||
|
||||
When products are re-queried for a folders, the old product and version
|
||||
items in '_product_item_by_id' and '_version_item_by_id' should
|
||||
be cleaned up from memory. And mapping in stored in
|
||||
'_product_folder_ids_mapping' is not relevant either.
|
||||
|
||||
Args:
|
||||
project_name (str): Name of project.
|
||||
folder_ids (Iterable[str]): Folder ids which are being refreshed.
|
||||
"""
|
||||
|
||||
project_mapping = self._product_folder_ids_mapping[project_name]
|
||||
if not project_mapping:
|
||||
return
|
||||
|
||||
product_item_by_id = self._product_item_by_id[project_name]
|
||||
version_item_by_id = self._version_item_by_id[project_name]
|
||||
for folder_id in folder_ids:
|
||||
product_ids = project_mapping.pop(folder_id, None)
|
||||
if not product_ids:
|
||||
continue
|
||||
|
||||
for product_id in product_ids:
|
||||
product_item = product_item_by_id.pop(product_id, None)
|
||||
if product_item is None:
|
||||
continue
|
||||
for version_item in product_item.version_items.values():
|
||||
version_item_by_id.pop(version_item.version_id, None)
|
||||
|
||||
def _refresh_product_items(self, project_name, folder_ids, sender):
|
||||
"""Refresh product items and store them in cache.
|
||||
|
||||
Args:
|
||||
project_name (str): Name of project.
|
||||
folder_ids (Iterable[str]): Folder ids which are being refreshed.
|
||||
sender (Union[str, None]): Who triggered the refresh.
|
||||
"""
|
||||
|
||||
if not project_name or not folder_ids:
|
||||
return
|
||||
|
||||
self._clear_product_version_items(project_name, folder_ids)
|
||||
|
||||
project_mapping = self._product_folder_ids_mapping[project_name]
|
||||
product_item_by_id = self._product_item_by_id[project_name]
|
||||
version_item_by_id = self._version_item_by_id[project_name]
|
||||
|
||||
for folder_id in folder_ids:
|
||||
project_mapping[folder_id] = set()
|
||||
|
||||
with self._product_refresh_event_manager(
|
||||
project_name, folder_ids, sender
|
||||
):
|
||||
folder_items = self._controller.get_folder_items(project_name)
|
||||
items_by_folder_id = {
|
||||
folder_id: {}
|
||||
for folder_id in folder_ids
|
||||
}
|
||||
product_items_by_id = self._query_product_items_by_ids(
|
||||
project_name,
|
||||
folder_ids=folder_ids,
|
||||
folder_items=folder_items
|
||||
)
|
||||
for product_id, product_item in product_items_by_id.items():
|
||||
folder_id = product_item.folder_id
|
||||
items_by_folder_id[product_item.folder_id][product_id] = (
|
||||
product_item
|
||||
)
|
||||
|
||||
project_mapping[folder_id].add(product_id)
|
||||
product_item_by_id[product_id] = product_item
|
||||
for version_id, version_item in (
|
||||
product_item.version_items.items()
|
||||
):
|
||||
version_item_by_id[version_id] = version_item
|
||||
|
||||
project_cache = self._product_items_cache[project_name]
|
||||
for folder_id, product_items in items_by_folder_id.items():
|
||||
project_cache[folder_id].update_data(product_items)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _product_refresh_event_manager(
|
||||
self, project_name, folder_ids, sender
|
||||
):
|
||||
self._controller.emit_event(
|
||||
"products.refresh.started",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"sender": sender,
|
||||
},
|
||||
PRODUCTS_MODEL_SENDER
|
||||
)
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
self._controller.emit_event(
|
||||
"products.refresh.finished",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"folder_ids": folder_ids,
|
||||
"sender": sender,
|
||||
},
|
||||
PRODUCTS_MODEL_SENDER
|
||||
)
|
||||
|
||||
def refresh_representation_items(
|
||||
self, project_name, version_ids, sender
|
||||
):
|
||||
if not any((project_name, version_ids)):
|
||||
return
|
||||
self._controller.emit_event(
|
||||
"model.representations.refresh.started",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"version_ids": version_ids,
|
||||
"sender": sender,
|
||||
},
|
||||
PRODUCTS_MODEL_SENDER
|
||||
)
|
||||
failed = False
|
||||
try:
|
||||
self._refresh_representation_items(project_name, version_ids)
|
||||
except Exception:
|
||||
# TODO add more information about failed refresh
|
||||
failed = True
|
||||
|
||||
self._controller.emit_event(
|
||||
"model.representations.refresh.finished",
|
||||
{
|
||||
"project_name": project_name,
|
||||
"version_ids": version_ids,
|
||||
"sender": sender,
|
||||
"failed": failed,
|
||||
},
|
||||
PRODUCTS_MODEL_SENDER
|
||||
)
|
||||
|
||||
def _refresh_representation_items(self, project_name, version_ids):
|
||||
representations = list(ayon_api.get_representations(
|
||||
project_name,
|
||||
version_ids=version_ids,
|
||||
fields=["id", "name", "versionId"]
|
||||
))
|
||||
|
||||
version_items_by_id = self._get_version_items_by_id(
|
||||
project_name, version_ids
|
||||
)
|
||||
product_ids = {
|
||||
version_item.product_id
|
||||
for version_item in version_items_by_id.values()
|
||||
}
|
||||
product_items_by_id = self._get_product_items_by_id(
|
||||
project_name, product_ids
|
||||
)
|
||||
repre_icon = {
|
||||
"type": "awesome-font",
|
||||
"name": "fa.file-o",
|
||||
"color": get_default_entity_icon_color(),
|
||||
}
|
||||
repre_items_by_version_id = collections.defaultdict(dict)
|
||||
for representation in representations:
|
||||
version_id = representation["versionId"]
|
||||
version_item = version_items_by_id.get(version_id)
|
||||
if version_item is None:
|
||||
continue
|
||||
product_item = product_items_by_id.get(version_item.product_id)
|
||||
if product_item is None:
|
||||
continue
|
||||
repre_id = representation["id"]
|
||||
repre_item = RepreItem(
|
||||
repre_id,
|
||||
representation["name"],
|
||||
repre_icon,
|
||||
product_item.product_name,
|
||||
product_item.folder_label,
|
||||
)
|
||||
repre_items_by_version_id[version_id][repre_id] = repre_item
|
||||
|
||||
project_cache = self._repre_items_cache[project_name]
|
||||
for version_id, repre_items in repre_items_by_version_id.items():
|
||||
version_cache = project_cache[version_id]
|
||||
version_cache.update_data(repre_items)
|
||||
85
openpype/tools/ayon_loader/models/selection.py
Normal file
85
openpype/tools/ayon_loader/models/selection.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
class SelectionModel(object):
|
||||
"""Model handling selection changes.
|
||||
|
||||
Triggering events:
|
||||
- "selection.project.changed"
|
||||
- "selection.folders.changed"
|
||||
- "selection.versions.changed"
|
||||
"""
|
||||
|
||||
event_source = "selection.model"
|
||||
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
|
||||
self._project_name = None
|
||||
self._folder_ids = set()
|
||||
self._version_ids = set()
|
||||
self._representation_ids = set()
|
||||
|
||||
def get_selected_project_name(self):
|
||||
return self._project_name
|
||||
|
||||
def set_selected_project(self, project_name):
|
||||
if self._project_name == project_name:
|
||||
return
|
||||
|
||||
self._project_name = project_name
|
||||
self._controller.emit_event(
|
||||
"selection.project.changed",
|
||||
{"project_name": self._project_name},
|
||||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_folder_ids(self):
|
||||
return self._folder_ids
|
||||
|
||||
def set_selected_folders(self, folder_ids):
|
||||
if folder_ids == self._folder_ids:
|
||||
return
|
||||
|
||||
self._folder_ids = folder_ids
|
||||
self._controller.emit_event(
|
||||
"selection.folders.changed",
|
||||
{
|
||||
"project_name": self._project_name,
|
||||
"folder_ids": folder_ids,
|
||||
},
|
||||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_version_ids(self):
|
||||
return self._version_ids
|
||||
|
||||
def set_selected_versions(self, version_ids):
|
||||
if version_ids == self._version_ids:
|
||||
return
|
||||
|
||||
self._version_ids = version_ids
|
||||
self._controller.emit_event(
|
||||
"selection.versions.changed",
|
||||
{
|
||||
"project_name": self._project_name,
|
||||
"folder_ids": self._folder_ids,
|
||||
"version_ids": self._version_ids,
|
||||
},
|
||||
self.event_source
|
||||
)
|
||||
|
||||
def get_selected_representation_ids(self):
|
||||
return self._representation_ids
|
||||
|
||||
def set_selected_representations(self, repre_ids):
|
||||
if repre_ids == self._representation_ids:
|
||||
return
|
||||
|
||||
self._representation_ids = repre_ids
|
||||
self._controller.emit_event(
|
||||
"selection.representations.changed",
|
||||
{
|
||||
"project_name": self._project_name,
|
||||
"folder_ids": self._folder_ids,
|
||||
"version_ids": self._version_ids,
|
||||
"representation_ids": self._representation_ids,
|
||||
}
|
||||
)
|
||||
6
openpype/tools/ayon_loader/ui/__init__.py
Normal file
6
openpype/tools/ayon_loader/ui/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .window import LoaderWindow
|
||||
|
||||
|
||||
__all__ = (
|
||||
"LoaderWindow",
|
||||
)
|
||||
118
openpype/tools/ayon_loader/ui/actions_utils.py
Normal file
118
openpype/tools/ayon_loader/ui/actions_utils.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import uuid
|
||||
|
||||
from qtpy import QtWidgets, QtGui
|
||||
import qtawesome
|
||||
|
||||
from openpype.lib.attribute_definitions import AbstractAttrDef
|
||||
from openpype.tools.attribute_defs import AttributeDefinitionsDialog
|
||||
from openpype.tools.utils.widgets import (
|
||||
OptionalMenu,
|
||||
OptionalAction,
|
||||
OptionDialog,
|
||||
)
|
||||
from openpype.tools.ayon_utils.widgets import get_qt_icon
|
||||
|
||||
|
||||
def show_actions_menu(action_items, global_point, one_item_selected, parent):
|
||||
selected_action_item = None
|
||||
selected_options = None
|
||||
|
||||
if not action_items:
|
||||
menu = QtWidgets.QMenu(parent)
|
||||
action = _get_no_loader_action(menu, one_item_selected)
|
||||
menu.addAction(action)
|
||||
menu.exec_(global_point)
|
||||
return selected_action_item, selected_options
|
||||
|
||||
menu = OptionalMenu(parent)
|
||||
|
||||
action_items_by_id = {}
|
||||
for action_item in action_items:
|
||||
item_id = uuid.uuid4().hex
|
||||
action_items_by_id[item_id] = action_item
|
||||
item_options = action_item.options
|
||||
icon = get_qt_icon(action_item.icon)
|
||||
use_option = bool(item_options)
|
||||
action = OptionalAction(
|
||||
action_item.label,
|
||||
icon,
|
||||
use_option,
|
||||
menu
|
||||
)
|
||||
if use_option:
|
||||
# Add option box tip
|
||||
action.set_option_tip(item_options)
|
||||
|
||||
tip = action_item.tooltip
|
||||
if tip:
|
||||
action.setToolTip(tip)
|
||||
action.setStatusTip(tip)
|
||||
|
||||
action.setData(item_id)
|
||||
|
||||
menu.addAction(action)
|
||||
|
||||
action = menu.exec_(global_point)
|
||||
if action is not None:
|
||||
item_id = action.data()
|
||||
selected_action_item = action_items_by_id.get(item_id)
|
||||
|
||||
if selected_action_item is not None:
|
||||
selected_options = _get_options(action, selected_action_item, parent)
|
||||
|
||||
return selected_action_item, selected_options
|
||||
|
||||
|
||||
def _get_options(action, action_item, parent):
|
||||
"""Provides dialog to select value from loader provided options.
|
||||
|
||||
Loader can provide static or dynamically created options based on
|
||||
AttributeDefinitions, and for backwards compatibility qargparse.
|
||||
|
||||
Args:
|
||||
action (OptionalAction) - Action object in menu.
|
||||
action_item (ActionItem) - Action item with context information.
|
||||
parent (QtCore.QObject) - Parent object for dialog.
|
||||
|
||||
Returns:
|
||||
Union[dict[str, Any], None]: Selected value from attributes or
|
||||
'None' if dialog was cancelled.
|
||||
"""
|
||||
|
||||
# Pop option dialog
|
||||
options = action_item.options
|
||||
if not getattr(action, "optioned", False) or not options:
|
||||
return {}
|
||||
|
||||
if isinstance(options[0], AbstractAttrDef):
|
||||
qargparse_options = False
|
||||
dialog = AttributeDefinitionsDialog(options, parent)
|
||||
else:
|
||||
qargparse_options = True
|
||||
dialog = OptionDialog(parent)
|
||||
dialog.create(options)
|
||||
|
||||
dialog.setWindowTitle(action.label + " Options")
|
||||
|
||||
if not dialog.exec_():
|
||||
return None
|
||||
|
||||
# Get option
|
||||
if qargparse_options:
|
||||
return dialog.parse()
|
||||
return dialog.get_values()
|
||||
|
||||
|
||||
def _get_no_loader_action(menu, one_item_selected):
|
||||
"""Creates dummy no loader option in 'menu'"""
|
||||
|
||||
if one_item_selected:
|
||||
submsg = "this version."
|
||||
else:
|
||||
submsg = "your selection."
|
||||
msg = "No compatible loaders for {}".format(submsg)
|
||||
icon = qtawesome.icon(
|
||||
"fa.exclamation",
|
||||
color=QtGui.QColor(255, 51, 0)
|
||||
)
|
||||
return QtWidgets.QAction(icon, ("*" + msg), menu)
|
||||
407
openpype/tools/ayon_loader/ui/folders_widget.py
Normal file
407
openpype/tools/ayon_loader/ui/folders_widget.py
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import qtpy
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.tools.utils import (
|
||||
RecursiveSortFilterProxyModel,
|
||||
DeselectableTreeView,
|
||||
)
|
||||
from openpype.style import get_objected_colors
|
||||
|
||||
from openpype.tools.ayon_utils.widgets import (
|
||||
FoldersModel,
|
||||
FOLDERS_MODEL_SENDER_NAME,
|
||||
)
|
||||
from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE
|
||||
|
||||
if qtpy.API == "pyside":
|
||||
from PySide.QtGui import QStyleOptionViewItemV4
|
||||
elif qtpy.API == "pyqt4":
|
||||
from PyQt4.QtGui import QStyleOptionViewItemV4
|
||||
|
||||
UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 50
|
||||
|
||||
|
||||
class UnderlinesFolderDelegate(QtWidgets.QItemDelegate):
|
||||
"""Item delegate drawing bars under folder label.
|
||||
|
||||
This is used in loader tool. Multiselection of folders
|
||||
may group products by name under colored groups. Selected color groups are
|
||||
then propagated back to selected folders as underlines.
|
||||
"""
|
||||
bar_height = 3
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(UnderlinesFolderDelegate, self).__init__(*args, **kwargs)
|
||||
colors = get_objected_colors("loader", "asset-view")
|
||||
self._selected_color = colors["selected"].get_qcolor()
|
||||
self._hover_color = colors["hover"].get_qcolor()
|
||||
self._selected_hover_color = colors["selected-hover"].get_qcolor()
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
"""Add bar height to size hint."""
|
||||
result = super(UnderlinesFolderDelegate, self).sizeHint(option, index)
|
||||
height = result.height()
|
||||
result.setHeight(height + self.bar_height)
|
||||
|
||||
return result
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Replicate painting of an item and draw color bars if needed."""
|
||||
# Qt4 compat
|
||||
if qtpy.API in ("pyside", "pyqt4"):
|
||||
option = QStyleOptionViewItemV4(option)
|
||||
|
||||
painter.save()
|
||||
|
||||
item_rect = QtCore.QRect(option.rect)
|
||||
item_rect.setHeight(option.rect.height() - self.bar_height)
|
||||
|
||||
subset_colors = index.data(UNDERLINE_COLORS_ROLE) or []
|
||||
|
||||
subset_colors_width = 0
|
||||
if subset_colors:
|
||||
subset_colors_width = option.rect.width() / len(subset_colors)
|
||||
|
||||
subset_rects = []
|
||||
counter = 0
|
||||
for subset_c in subset_colors:
|
||||
new_color = None
|
||||
new_rect = None
|
||||
if subset_c:
|
||||
new_color = QtGui.QColor(subset_c)
|
||||
|
||||
new_rect = QtCore.QRect(
|
||||
option.rect.left() + (counter * subset_colors_width),
|
||||
option.rect.top() + (
|
||||
option.rect.height() - self.bar_height
|
||||
),
|
||||
subset_colors_width,
|
||||
self.bar_height
|
||||
)
|
||||
subset_rects.append((new_color, new_rect))
|
||||
counter += 1
|
||||
|
||||
# Background
|
||||
if option.state & QtWidgets.QStyle.State_Selected:
|
||||
if len(subset_colors) == 0:
|
||||
item_rect.setTop(item_rect.top() + (self.bar_height / 2))
|
||||
|
||||
if option.state & QtWidgets.QStyle.State_MouseOver:
|
||||
bg_color = self._selected_hover_color
|
||||
else:
|
||||
bg_color = self._selected_color
|
||||
else:
|
||||
item_rect.setTop(item_rect.top() + (self.bar_height / 2))
|
||||
if option.state & QtWidgets.QStyle.State_MouseOver:
|
||||
bg_color = self._hover_color
|
||||
else:
|
||||
bg_color = QtGui.QColor()
|
||||
bg_color.setAlpha(0)
|
||||
|
||||
# When not needed to do a rounded corners (easier and without
|
||||
# painter restore):
|
||||
painter.fillRect(
|
||||
option.rect,
|
||||
QtGui.QBrush(bg_color)
|
||||
)
|
||||
|
||||
if option.state & QtWidgets.QStyle.State_Selected:
|
||||
for color, subset_rect in subset_rects:
|
||||
if not color or not subset_rect:
|
||||
continue
|
||||
painter.fillRect(subset_rect, QtGui.QBrush(color))
|
||||
|
||||
# Icon
|
||||
icon_index = index.model().index(
|
||||
index.row(), index.column(), index.parent()
|
||||
)
|
||||
# - Default icon_rect if not icon
|
||||
icon_rect = QtCore.QRect(
|
||||
item_rect.left(),
|
||||
item_rect.top(),
|
||||
# To make sure it's same size all the time
|
||||
option.rect.height() - self.bar_height,
|
||||
option.rect.height() - self.bar_height
|
||||
)
|
||||
icon = index.model().data(icon_index, QtCore.Qt.DecorationRole)
|
||||
|
||||
if icon:
|
||||
mode = QtGui.QIcon.Normal
|
||||
if not (option.state & QtWidgets.QStyle.State_Enabled):
|
||||
mode = QtGui.QIcon.Disabled
|
||||
elif option.state & QtWidgets.QStyle.State_Selected:
|
||||
mode = QtGui.QIcon.Selected
|
||||
|
||||
if isinstance(icon, QtGui.QPixmap):
|
||||
icon = QtGui.QIcon(icon)
|
||||
option.decorationSize = icon.size() / icon.devicePixelRatio()
|
||||
|
||||
elif isinstance(icon, QtGui.QColor):
|
||||
pixmap = QtGui.QPixmap(option.decorationSize)
|
||||
pixmap.fill(icon)
|
||||
icon = QtGui.QIcon(pixmap)
|
||||
|
||||
elif isinstance(icon, QtGui.QImage):
|
||||
icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon))
|
||||
option.decorationSize = icon.size() / icon.devicePixelRatio()
|
||||
|
||||
elif isinstance(icon, QtGui.QIcon):
|
||||
state = QtGui.QIcon.Off
|
||||
if option.state & QtWidgets.QStyle.State_Open:
|
||||
state = QtGui.QIcon.On
|
||||
actual_size = option.icon.actualSize(
|
||||
option.decorationSize, mode, state
|
||||
)
|
||||
option.decorationSize = QtCore.QSize(
|
||||
min(option.decorationSize.width(), actual_size.width()),
|
||||
min(option.decorationSize.height(), actual_size.height())
|
||||
)
|
||||
|
||||
state = QtGui.QIcon.Off
|
||||
if option.state & QtWidgets.QStyle.State_Open:
|
||||
state = QtGui.QIcon.On
|
||||
|
||||
icon.paint(
|
||||
painter, icon_rect,
|
||||
QtCore.Qt.AlignLeft, mode, state
|
||||
)
|
||||
|
||||
# Text
|
||||
text_rect = QtCore.QRect(
|
||||
icon_rect.left() + icon_rect.width() + 2,
|
||||
item_rect.top(),
|
||||
item_rect.width(),
|
||||
item_rect.height()
|
||||
)
|
||||
|
||||
painter.drawText(
|
||||
text_rect, QtCore.Qt.AlignVCenter,
|
||||
index.data(QtCore.Qt.DisplayRole)
|
||||
)
|
||||
|
||||
painter.restore()
|
||||
|
||||
|
||||
class LoaderFoldersModel(FoldersModel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LoaderFoldersModel, self).__init__(*args, **kwargs)
|
||||
|
||||
self._colored_items = set()
|
||||
|
||||
def _fill_item_data(self, item, folder_item):
|
||||
"""
|
||||
|
||||
Args:
|
||||
item (QtGui.QStandardItem): Item to fill data.
|
||||
folder_item (FolderItem): Folder item.
|
||||
"""
|
||||
|
||||
super(LoaderFoldersModel, self)._fill_item_data(item, folder_item)
|
||||
|
||||
def set_merged_products_selection(self, items):
|
||||
changes = {
|
||||
folder_id: None
|
||||
for folder_id in self._colored_items
|
||||
}
|
||||
|
||||
all_folder_ids = set()
|
||||
for item in items:
|
||||
folder_ids = item["folder_ids"]
|
||||
all_folder_ids.update(folder_ids)
|
||||
|
||||
for folder_id in all_folder_ids:
|
||||
changes[folder_id] = []
|
||||
|
||||
for item in items:
|
||||
item_color = item["color"]
|
||||
item_folder_ids = item["folder_ids"]
|
||||
for folder_id in all_folder_ids:
|
||||
folder_color = (
|
||||
item_color
|
||||
if folder_id in item_folder_ids
|
||||
else None
|
||||
)
|
||||
changes[folder_id].append(folder_color)
|
||||
|
||||
for folder_id, color_value in changes.items():
|
||||
item = self._items_by_id.get(folder_id)
|
||||
if item is not None:
|
||||
item.setData(color_value, UNDERLINE_COLORS_ROLE)
|
||||
|
||||
self._colored_items = all_folder_ids
|
||||
|
||||
|
||||
class LoaderFoldersWidget(QtWidgets.QWidget):
|
||||
"""Folders widget.
|
||||
|
||||
Widget that handles folders view, model and selection.
|
||||
|
||||
Expected selection handling is disabled by default. If enabled, the
|
||||
widget will handle the expected in predefined way. Widget is listening
|
||||
to event 'expected_selection_changed' with expected event data below,
|
||||
the same data must be available when called method
|
||||
'get_expected_selection_data' on controller.
|
||||
|
||||
{
|
||||
"folder": {
|
||||
"current": bool, # Folder is what should be set now
|
||||
"folder_id": Union[str, None], # Folder id that should be selected
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
Selection is confirmed by calling method 'expected_folder_selected' on
|
||||
controller.
|
||||
|
||||
|
||||
Args:
|
||||
controller (AbstractWorkfilesFrontend): The control object.
|
||||
parent (QtWidgets.QWidget): The parent widget.
|
||||
"""
|
||||
|
||||
refreshed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(LoaderFoldersWidget, self).__init__(parent)
|
||||
|
||||
folders_view = DeselectableTreeView(self)
|
||||
folders_view.setHeaderHidden(True)
|
||||
folders_view.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.ExtendedSelection)
|
||||
|
||||
folders_model = LoaderFoldersModel(controller)
|
||||
folders_proxy_model = RecursiveSortFilterProxyModel()
|
||||
folders_proxy_model.setSourceModel(folders_model)
|
||||
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
folders_label_delegate = UnderlinesFolderDelegate(folders_view)
|
||||
|
||||
folders_view.setModel(folders_proxy_model)
|
||||
folders_view.setItemDelegate(folders_label_delegate)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(folders_view, 1)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_selection_change,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"folders.refresh.finished",
|
||||
self._on_folders_refresh_finished
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"controller.refresh.finished",
|
||||
self._on_controller_refresh
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"expected_selection_changed",
|
||||
self._on_expected_selection_change
|
||||
)
|
||||
|
||||
selection_model = folders_view.selectionModel()
|
||||
selection_model.selectionChanged.connect(self._on_selection_change)
|
||||
|
||||
folders_model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
self._controller = controller
|
||||
self._folders_view = folders_view
|
||||
self._folders_model = folders_model
|
||||
self._folders_proxy_model = folders_proxy_model
|
||||
self._folders_label_delegate = folders_label_delegate
|
||||
|
||||
self._expected_selection = None
|
||||
|
||||
def set_name_filter(self, name):
|
||||
"""Set filter of folder name.
|
||||
|
||||
Args:
|
||||
name (str): The string filter.
|
||||
"""
|
||||
|
||||
self._folders_proxy_model.setFilterFixedString(name)
|
||||
|
||||
def set_merged_products_selection(self, items):
|
||||
"""
|
||||
|
||||
Args:
|
||||
items (list[dict[str, Any]]): List of merged items with folder
|
||||
ids.
|
||||
"""
|
||||
|
||||
self._folders_model.set_merged_products_selection(items)
|
||||
|
||||
def refresh(self):
|
||||
self._folders_model.refresh()
|
||||
|
||||
def _on_project_selection_change(self, event):
|
||||
project_name = event["project_name"]
|
||||
self._set_project_name(project_name)
|
||||
|
||||
def _set_project_name(self, project_name):
|
||||
self._folders_model.set_project_name(project_name)
|
||||
|
||||
def _clear(self):
|
||||
self._folders_model.clear()
|
||||
|
||||
def _on_folders_refresh_finished(self, event):
|
||||
if event["sender"] != FOLDERS_MODEL_SENDER_NAME:
|
||||
self._set_project_name(event["project_name"])
|
||||
|
||||
def _on_controller_refresh(self):
|
||||
self._update_expected_selection()
|
||||
|
||||
def _on_model_refresh(self):
|
||||
if self._expected_selection:
|
||||
self._set_expected_selection()
|
||||
self._folders_proxy_model.sort(0)
|
||||
self.refreshed.emit()
|
||||
|
||||
def _get_selected_item_ids(self):
|
||||
selection_model = self._folders_view.selectionModel()
|
||||
item_ids = []
|
||||
for index in selection_model.selectedIndexes():
|
||||
item_id = index.data(FOLDER_ID_ROLE)
|
||||
if item_id is not None:
|
||||
item_ids.append(item_id)
|
||||
return item_ids
|
||||
|
||||
def _on_selection_change(self):
|
||||
item_ids = self._get_selected_item_ids()
|
||||
self._controller.set_selected_folders(item_ids)
|
||||
|
||||
# Expected selection handling
|
||||
def _on_expected_selection_change(self, event):
|
||||
self._update_expected_selection(event.data)
|
||||
|
||||
def _update_expected_selection(self, expected_data=None):
|
||||
if expected_data is None:
|
||||
expected_data = self._controller.get_expected_selection_data()
|
||||
|
||||
folder_data = expected_data.get("folder")
|
||||
if not folder_data or not folder_data["current"]:
|
||||
return
|
||||
|
||||
folder_id = folder_data["id"]
|
||||
self._expected_selection = folder_id
|
||||
if not self._folders_model.is_refreshing:
|
||||
self._set_expected_selection()
|
||||
|
||||
def _set_expected_selection(self):
|
||||
folder_id = self._expected_selection
|
||||
selected_ids = self._get_selected_item_ids()
|
||||
self._expected_selection = None
|
||||
skip_selection = (
|
||||
folder_id is None
|
||||
or (
|
||||
folder_id in selected_ids
|
||||
and len(selected_ids) == 1
|
||||
)
|
||||
)
|
||||
if not skip_selection:
|
||||
index = self._folders_model.get_index_by_id(folder_id)
|
||||
if index.isValid():
|
||||
proxy_index = self._folders_proxy_model.mapFromSource(index)
|
||||
self._folders_view.setCurrentIndex(proxy_index)
|
||||
self._controller.expected_folder_selected(folder_id)
|
||||
141
openpype/tools/ayon_loader/ui/info_widget.py
Normal file
141
openpype/tools/ayon_loader/ui/info_widget.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import datetime
|
||||
|
||||
from qtpy import QtWidgets
|
||||
|
||||
from openpype.tools.utils.lib import format_version
|
||||
|
||||
|
||||
class VersionTextEdit(QtWidgets.QTextEdit):
|
||||
"""QTextEdit that displays version specific information.
|
||||
|
||||
This also overrides the context menu to add actions like copying
|
||||
source path to clipboard or copying the raw data of the version
|
||||
to clipboard.
|
||||
|
||||
"""
|
||||
def __init__(self, controller, parent):
|
||||
super(VersionTextEdit, self).__init__(parent=parent)
|
||||
|
||||
self._version_item = None
|
||||
self._product_item = None
|
||||
|
||||
self._controller = controller
|
||||
|
||||
# Reset
|
||||
self.set_current_item()
|
||||
|
||||
def set_current_item(self, product_item=None, version_item=None):
|
||||
"""
|
||||
|
||||
Args:
|
||||
product_item (Union[ProductItem, None]): Product item.
|
||||
version_item (Union[VersionItem, None]): Version item to display.
|
||||
"""
|
||||
|
||||
self._product_item = product_item
|
||||
self._version_item = version_item
|
||||
|
||||
if version_item is None:
|
||||
# Reset state to empty
|
||||
self.setText("")
|
||||
return
|
||||
|
||||
version_label = format_version(abs(version_item.version))
|
||||
if version_item.version < 0:
|
||||
version_label = "Hero version {}".format(version_label)
|
||||
|
||||
# Define readable creation timestamp
|
||||
created = version_item.published_time
|
||||
created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ")
|
||||
created = datetime.datetime.strftime(created, "%b %d %Y %H:%M")
|
||||
|
||||
comment = version_item.comment or "No comment"
|
||||
source = version_item.source or "No source"
|
||||
|
||||
self.setHtml(
|
||||
(
|
||||
"<h2>{product_name}</h2>"
|
||||
"<h3>{version_label}</h3>"
|
||||
"<b>Comment</b><br>"
|
||||
"{comment}<br><br>"
|
||||
|
||||
"<b>Created</b><br>"
|
||||
"{created}<br><br>"
|
||||
|
||||
"<b>Source</b><br>"
|
||||
"{source}"
|
||||
).format(
|
||||
product_name=product_item.product_name,
|
||||
version_label=version_label,
|
||||
comment=comment,
|
||||
created=created,
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""Context menu with additional actions"""
|
||||
menu = self.createStandardContextMenu()
|
||||
|
||||
# Add additional actions when any text, so we can assume
|
||||
# the version is set.
|
||||
source = None
|
||||
if self._version_item is not None:
|
||||
source = self._version_item.source
|
||||
|
||||
if source:
|
||||
menu.addSeparator()
|
||||
action = QtWidgets.QAction(
|
||||
"Copy source path to clipboard", menu
|
||||
)
|
||||
action.triggered.connect(self._on_copy_source)
|
||||
menu.addAction(action)
|
||||
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _on_copy_source(self):
|
||||
"""Copy formatted source path to clipboard."""
|
||||
|
||||
source = self._version_item.source
|
||||
if not source:
|
||||
return
|
||||
|
||||
filled_source = self._controller.fill_root_in_source(source)
|
||||
clipboard = QtWidgets.QApplication.clipboard()
|
||||
clipboard.setText(filled_source)
|
||||
|
||||
|
||||
class InfoWidget(QtWidgets.QWidget):
|
||||
"""A Widget that display information about a specific version"""
|
||||
def __init__(self, controller, parent):
|
||||
super(InfoWidget, self).__init__(parent=parent)
|
||||
|
||||
label_widget = QtWidgets.QLabel("Version Info", self)
|
||||
info_text_widget = VersionTextEdit(controller, self)
|
||||
info_text_widget.setReadOnly(True)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(label_widget, 0)
|
||||
layout.addWidget(info_text_widget, 1)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._info_text_widget = info_text_widget
|
||||
self._label_widget = label_widget
|
||||
|
||||
def set_selected_version_info(self, project_name, items):
|
||||
if not items or not project_name:
|
||||
self._info_text_widget.set_current_item()
|
||||
return
|
||||
first_item = next(iter(items))
|
||||
product_item = self._controller.get_product_item(
|
||||
project_name,
|
||||
first_item["product_id"],
|
||||
)
|
||||
version_id = first_item["version_id"]
|
||||
version_item = None
|
||||
if product_item is not None:
|
||||
version_item = product_item.version_items.get(version_id)
|
||||
|
||||
self._info_text_widget.set_current_item(product_item, version_item)
|
||||
45
openpype/tools/ayon_loader/ui/product_group_dialog.py
Normal file
45
openpype/tools/ayon_loader/ui/product_group_dialog.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from qtpy import QtWidgets
|
||||
|
||||
from openpype.tools.utils import PlaceholderLineEdit
|
||||
|
||||
|
||||
class ProductGroupDialog(QtWidgets.QDialog):
|
||||
def __init__(self, controller, parent):
|
||||
super(ProductGroupDialog, self).__init__(parent)
|
||||
self.setWindowTitle("Grouping products")
|
||||
self.setMinimumWidth(250)
|
||||
self.setModal(True)
|
||||
|
||||
main_label = QtWidgets.QLabel("Group Name", self)
|
||||
|
||||
group_name_input = PlaceholderLineEdit(self)
|
||||
group_name_input.setPlaceholderText("Remain blank to ungroup..")
|
||||
|
||||
group_btn = QtWidgets.QPushButton("Apply", self)
|
||||
group_btn.setAutoDefault(True)
|
||||
group_btn.setDefault(True)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addWidget(main_label, 0)
|
||||
layout.addWidget(group_name_input, 0)
|
||||
layout.addWidget(group_btn, 0)
|
||||
|
||||
group_btn.clicked.connect(self._on_apply_click)
|
||||
|
||||
self._project_name = None
|
||||
self._product_ids = set()
|
||||
|
||||
self._controller = controller
|
||||
self._group_btn = group_btn
|
||||
self._group_name_input = group_name_input
|
||||
|
||||
def set_product_ids(self, project_name, product_ids):
|
||||
self._project_name = project_name
|
||||
self._product_ids = product_ids
|
||||
|
||||
def _on_apply_click(self):
|
||||
group_name = self._group_name_input.text().strip() or None
|
||||
self._controller.change_products_group(
|
||||
self._project_name, self._product_ids, group_name
|
||||
)
|
||||
self.close()
|
||||
220
openpype/tools/ayon_loader/ui/product_types_widget.py
Normal file
220
openpype/tools/ayon_loader/ui/product_types_widget.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
from qtpy import QtWidgets, QtGui, QtCore
|
||||
|
||||
from openpype.tools.ayon_utils.widgets import get_qt_icon
|
||||
|
||||
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
|
||||
|
||||
|
||||
class ProductTypesQtModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
filter_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller):
|
||||
super(ProductTypesQtModel, self).__init__()
|
||||
self._controller = controller
|
||||
|
||||
self._refreshing = False
|
||||
self._bulk_change = False
|
||||
self._items_by_name = {}
|
||||
|
||||
def is_refreshing(self):
|
||||
return self._refreshing
|
||||
|
||||
def get_filter_info(self):
|
||||
"""Product types filtering info.
|
||||
|
||||
Returns:
|
||||
dict[str, bool]: Filtering value by product type name. False value
|
||||
means to hide product type.
|
||||
"""
|
||||
|
||||
return {
|
||||
name: item.checkState() == QtCore.Qt.Checked
|
||||
for name, item in self._items_by_name.items()
|
||||
}
|
||||
|
||||
def refresh(self, project_name):
|
||||
self._refreshing = True
|
||||
product_type_items = self._controller.get_product_type_items(
|
||||
project_name)
|
||||
|
||||
items_to_remove = set(self._items_by_name.keys())
|
||||
new_items = []
|
||||
for product_type_item in product_type_items:
|
||||
name = product_type_item.name
|
||||
items_to_remove.discard(name)
|
||||
item = self._items_by_name.get(product_type_item.name)
|
||||
if item is None:
|
||||
item = QtGui.QStandardItem(name)
|
||||
item.setData(name, PRODUCT_TYPE_ROLE)
|
||||
item.setEditable(False)
|
||||
item.setCheckable(True)
|
||||
new_items.append(item)
|
||||
self._items_by_name[name] = item
|
||||
|
||||
item.setCheckState(
|
||||
QtCore.Qt.Checked
|
||||
if product_type_item.checked
|
||||
else QtCore.Qt.Unchecked
|
||||
)
|
||||
icon = get_qt_icon(product_type_item.icon)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
||||
for name in items_to_remove:
|
||||
item = self._items_by_name.pop(name)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
self._refreshing = False
|
||||
self.refreshed.emit()
|
||||
|
||||
def setData(self, index, value, role=None):
|
||||
checkstate_changed = False
|
||||
if role is None:
|
||||
role = QtCore.Qt.EditRole
|
||||
elif role == QtCore.Qt.CheckStateRole:
|
||||
checkstate_changed = True
|
||||
output = super(ProductTypesQtModel, self).setData(index, value, role)
|
||||
if checkstate_changed and not self._bulk_change:
|
||||
self.filter_changed.emit()
|
||||
return output
|
||||
|
||||
def change_state_for_all(self, checked):
|
||||
if self._items_by_name:
|
||||
self.change_states(checked, self._items_by_name.keys())
|
||||
|
||||
def change_states(self, checked, product_types):
|
||||
product_types = set(product_types)
|
||||
if not product_types:
|
||||
return
|
||||
|
||||
if checked is None:
|
||||
state = None
|
||||
elif checked:
|
||||
state = QtCore.Qt.Checked
|
||||
else:
|
||||
state = QtCore.Qt.Unchecked
|
||||
|
||||
self._bulk_change = True
|
||||
|
||||
changed = False
|
||||
for product_type in product_types:
|
||||
item = self._items_by_name.get(product_type)
|
||||
if item is None:
|
||||
continue
|
||||
new_state = state
|
||||
item_checkstate = item.checkState()
|
||||
if new_state is None:
|
||||
if item_checkstate == QtCore.Qt.Checked:
|
||||
new_state = QtCore.Qt.Unchecked
|
||||
else:
|
||||
new_state = QtCore.Qt.Checked
|
||||
elif item_checkstate == new_state:
|
||||
continue
|
||||
changed = True
|
||||
item.setCheckState(new_state)
|
||||
|
||||
self._bulk_change = False
|
||||
|
||||
if changed:
|
||||
self.filter_changed.emit()
|
||||
|
||||
|
||||
class ProductTypesView(QtWidgets.QListView):
|
||||
filter_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(ProductTypesView, self).__init__(parent)
|
||||
|
||||
self.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.ExtendedSelection
|
||||
)
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
product_types_model = ProductTypesQtModel(controller)
|
||||
product_types_proxy_model = QtCore.QSortFilterProxyModel()
|
||||
product_types_proxy_model.setSourceModel(product_types_model)
|
||||
|
||||
self.setModel(product_types_proxy_model)
|
||||
|
||||
product_types_model.refreshed.connect(self._on_refresh_finished)
|
||||
product_types_model.filter_changed.connect(self._on_filter_change)
|
||||
self.customContextMenuRequested.connect(self._on_context_menu)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_change
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._product_types_model = product_types_model
|
||||
self._product_types_proxy_model = product_types_proxy_model
|
||||
|
||||
def get_filter_info(self):
|
||||
return self._product_types_model.get_filter_info()
|
||||
|
||||
def _on_project_change(self, event):
|
||||
project_name = event["project_name"]
|
||||
self._product_types_model.refresh(project_name)
|
||||
|
||||
def _on_refresh_finished(self):
|
||||
self.filter_changed.emit()
|
||||
|
||||
def _on_filter_change(self):
|
||||
if not self._product_types_model.is_refreshing():
|
||||
self.filter_changed.emit()
|
||||
|
||||
def _change_selection_state(self, checkstate):
|
||||
selection_model = self.selectionModel()
|
||||
product_types = {
|
||||
index.data(PRODUCT_TYPE_ROLE)
|
||||
for index in selection_model.selectedIndexes()
|
||||
}
|
||||
product_types.discard(None)
|
||||
self._product_types_model.change_states(checkstate, product_types)
|
||||
|
||||
def _on_enable_all(self):
|
||||
self._product_types_model.change_state_for_all(True)
|
||||
|
||||
def _on_disable_all(self):
|
||||
self._product_types_model.change_state_for_all(False)
|
||||
|
||||
def _on_context_menu(self, pos):
|
||||
menu = QtWidgets.QMenu(self)
|
||||
|
||||
# Add enable all action
|
||||
action_check_all = QtWidgets.QAction(menu)
|
||||
action_check_all.setText("Enable All")
|
||||
action_check_all.triggered.connect(self._on_enable_all)
|
||||
# Add disable all action
|
||||
action_uncheck_all = QtWidgets.QAction(menu)
|
||||
action_uncheck_all.setText("Disable All")
|
||||
action_uncheck_all.triggered.connect(self._on_disable_all)
|
||||
|
||||
menu.addAction(action_check_all)
|
||||
menu.addAction(action_uncheck_all)
|
||||
|
||||
# Get mouse position
|
||||
global_pos = self.viewport().mapToGlobal(pos)
|
||||
menu.exec_(global_pos)
|
||||
|
||||
def event(self, event):
|
||||
if event.type() == QtCore.QEvent.KeyPress:
|
||||
if event.key() == QtCore.Qt.Key_Space:
|
||||
self._change_selection_state(None)
|
||||
return True
|
||||
|
||||
if event.key() == QtCore.Qt.Key_Backspace:
|
||||
self._change_selection_state(False)
|
||||
return True
|
||||
|
||||
if event.key() == QtCore.Qt.Key_Return:
|
||||
self._change_selection_state(True)
|
||||
return True
|
||||
|
||||
return super(ProductTypesView, self).event(event)
|
||||
191
openpype/tools/ayon_loader/ui/products_delegates.py
Normal file
191
openpype/tools/ayon_loader/ui/products_delegates.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import numbers
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.tools.utils.lib import format_version
|
||||
|
||||
from .products_model import (
|
||||
PRODUCT_ID_ROLE,
|
||||
VERSION_NAME_EDIT_ROLE,
|
||||
VERSION_ID_ROLE,
|
||||
PRODUCT_IN_SCENE_ROLE,
|
||||
)
|
||||
|
||||
|
||||
class VersionComboBox(QtWidgets.QComboBox):
|
||||
value_changed = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, product_id, parent):
|
||||
super(VersionComboBox, self).__init__(parent)
|
||||
self._product_id = product_id
|
||||
self._items_by_id = {}
|
||||
|
||||
self._current_id = None
|
||||
|
||||
self.currentIndexChanged.connect(self._on_index_change)
|
||||
|
||||
def update_versions(self, version_items, current_version_id):
|
||||
model = self.model()
|
||||
root_item = model.invisibleRootItem()
|
||||
version_items = list(reversed(version_items))
|
||||
version_ids = [
|
||||
version_item.version_id
|
||||
for version_item in version_items
|
||||
]
|
||||
if current_version_id not in version_ids and version_ids:
|
||||
current_version_id = version_ids[0]
|
||||
self._current_id = current_version_id
|
||||
|
||||
to_remove = set(self._items_by_id.keys()) - set(version_ids)
|
||||
for item_id in to_remove:
|
||||
item = self._items_by_id.pop(item_id)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
for idx, version_item in enumerate(version_items):
|
||||
version_id = version_item.version_id
|
||||
|
||||
item = self._items_by_id.get(version_id)
|
||||
if item is None:
|
||||
label = format_version(
|
||||
abs(version_item.version), version_item.is_hero
|
||||
)
|
||||
item = QtGui.QStandardItem(label)
|
||||
item.setData(version_id, QtCore.Qt.UserRole)
|
||||
self._items_by_id[version_id] = item
|
||||
|
||||
if item.row() != idx:
|
||||
root_item.insertRow(idx, item)
|
||||
|
||||
index = version_ids.index(current_version_id)
|
||||
if self.currentIndex() != index:
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
def _on_index_change(self):
|
||||
idx = self.currentIndex()
|
||||
value = self.itemData(idx)
|
||||
if value == self._current_id:
|
||||
return
|
||||
self._current_id = value
|
||||
self.value_changed.emit(self._product_id)
|
||||
|
||||
|
||||
class VersionDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""A delegate that display version integer formatted as version string."""
|
||||
|
||||
version_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(VersionDelegate, self).__init__(*args, **kwargs)
|
||||
self._editor_by_product_id = {}
|
||||
|
||||
def displayText(self, value, locale):
|
||||
if not isinstance(value, numbers.Integral):
|
||||
return "N/A"
|
||||
return format_version(abs(value), value < 0)
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
fg_color = index.data(QtCore.Qt.ForegroundRole)
|
||||
if fg_color:
|
||||
if isinstance(fg_color, QtGui.QBrush):
|
||||
fg_color = fg_color.color()
|
||||
elif isinstance(fg_color, QtGui.QColor):
|
||||
pass
|
||||
else:
|
||||
fg_color = None
|
||||
|
||||
if not fg_color:
|
||||
return super(VersionDelegate, self).paint(painter, option, index)
|
||||
|
||||
if option.widget:
|
||||
style = option.widget.style()
|
||||
else:
|
||||
style = QtWidgets.QApplication.style()
|
||||
|
||||
style.drawControl(
|
||||
style.CE_ItemViewItem, option, painter, option.widget
|
||||
)
|
||||
|
||||
painter.save()
|
||||
|
||||
text = self.displayText(
|
||||
index.data(QtCore.Qt.DisplayRole), option.locale
|
||||
)
|
||||
pen = painter.pen()
|
||||
pen.setColor(fg_color)
|
||||
painter.setPen(pen)
|
||||
|
||||
text_rect = style.subElementRect(style.SE_ItemViewItemText, option)
|
||||
text_margin = style.proxy().pixelMetric(
|
||||
style.PM_FocusFrameHMargin, option, option.widget
|
||||
) + 1
|
||||
|
||||
painter.drawText(
|
||||
text_rect.adjusted(text_margin, 0, - text_margin, 0),
|
||||
option.displayAlignment,
|
||||
text
|
||||
)
|
||||
|
||||
painter.restore()
|
||||
|
||||
def createEditor(self, parent, option, index):
|
||||
product_id = index.data(PRODUCT_ID_ROLE)
|
||||
if not product_id:
|
||||
return
|
||||
|
||||
editor = VersionComboBox(product_id, parent)
|
||||
self._editor_by_product_id[product_id] = editor
|
||||
editor.value_changed.connect(self._on_editor_change)
|
||||
|
||||
return editor
|
||||
|
||||
def _on_editor_change(self, product_id):
|
||||
editor = self._editor_by_product_id[product_id]
|
||||
|
||||
# Update model data
|
||||
self.commitData.emit(editor)
|
||||
# Display model data
|
||||
self.version_changed.emit()
|
||||
|
||||
def setEditorData(self, editor, index):
|
||||
editor.clear()
|
||||
|
||||
# Current value of the index
|
||||
versions = index.data(VERSION_NAME_EDIT_ROLE) or []
|
||||
version_id = index.data(VERSION_ID_ROLE)
|
||||
editor.update_versions(versions, version_id)
|
||||
|
||||
def setModelData(self, editor, model, index):
|
||||
"""Apply the integer version back in the model"""
|
||||
|
||||
version_id = editor.itemData(editor.currentIndex())
|
||||
model.setData(index, version_id, VERSION_NAME_EDIT_ROLE)
|
||||
|
||||
|
||||
class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Delegate for Loaded in Scene state columns.
|
||||
|
||||
Shows "Yes" or "No" for 1 or 0 values, or "N/A" for other values.
|
||||
Colorizes green or dark grey based on values.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LoadedInSceneDelegate, self).__init__(*args, **kwargs)
|
||||
self._colors = {
|
||||
1: QtGui.QColor(80, 170, 80),
|
||||
0: QtGui.QColor(90, 90, 90),
|
||||
}
|
||||
self._default_color = QtGui.QColor(90, 90, 90)
|
||||
|
||||
def displayText(self, value, locale):
|
||||
if value == 0:
|
||||
return "No"
|
||||
elif value == 1:
|
||||
return "Yes"
|
||||
return "N/A"
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super(LoadedInSceneDelegate, self).initStyleOption(option, index)
|
||||
|
||||
# Colorize based on value
|
||||
value = index.data(PRODUCT_IN_SCENE_ROLE)
|
||||
color = self._colors.get(value, self._default_color)
|
||||
option.palette.setBrush(QtGui.QPalette.Text, color)
|
||||
590
openpype/tools/ayon_loader/ui/products_model.py
Normal file
590
openpype/tools/ayon_loader/ui/products_model.py
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
import collections
|
||||
|
||||
import qtawesome
|
||||
from qtpy import QtGui, QtCore
|
||||
|
||||
from openpype.style import get_default_entity_icon_color
|
||||
from openpype.tools.ayon_utils.widgets import get_qt_icon
|
||||
|
||||
PRODUCTS_MODEL_SENDER_NAME = "qt_products_model"
|
||||
|
||||
GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1
|
||||
MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2
|
||||
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3
|
||||
FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4
|
||||
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5
|
||||
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6
|
||||
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7
|
||||
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8
|
||||
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9
|
||||
VERSION_ID_ROLE = QtCore.Qt.UserRole + 10
|
||||
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11
|
||||
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12
|
||||
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13
|
||||
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
|
||||
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15
|
||||
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16
|
||||
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17
|
||||
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18
|
||||
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19
|
||||
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20
|
||||
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21
|
||||
|
||||
|
||||
class ProductsModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
version_changed = QtCore.Signal()
|
||||
column_labels = [
|
||||
"Product name",
|
||||
"Product type",
|
||||
"Folder",
|
||||
"Version",
|
||||
"Time",
|
||||
"Author",
|
||||
"Frames",
|
||||
"Duration",
|
||||
"Handles",
|
||||
"Step",
|
||||
"In scene",
|
||||
"Availability",
|
||||
]
|
||||
merged_items_colors = [
|
||||
("#{0:02x}{1:02x}{2:02x}".format(*c), QtGui.QColor(*c))
|
||||
for c in [
|
||||
(55, 161, 222), # Light Blue
|
||||
(231, 176, 0), # Yellow
|
||||
(154, 13, 255), # Purple
|
||||
(130, 184, 30), # Light Green
|
||||
(211, 79, 63), # Light Red
|
||||
(179, 181, 182), # Grey
|
||||
(194, 57, 179), # Pink
|
||||
(0, 120, 215), # Dark Blue
|
||||
(0, 204, 106), # Dark Green
|
||||
(247, 99, 12), # Orange
|
||||
]
|
||||
]
|
||||
|
||||
version_col = column_labels.index("Version")
|
||||
published_time_col = column_labels.index("Time")
|
||||
folders_label_col = column_labels.index("Folder")
|
||||
in_scene_col = column_labels.index("In scene")
|
||||
|
||||
def __init__(self, controller):
|
||||
super(ProductsModel, self).__init__()
|
||||
self.setColumnCount(len(self.column_labels))
|
||||
for idx, label in enumerate(self.column_labels):
|
||||
self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
|
||||
self._controller = controller
|
||||
|
||||
# Variables to store 'QStandardItem'
|
||||
self._items_by_id = {}
|
||||
self._group_items_by_name = {}
|
||||
self._merged_items_by_id = {}
|
||||
|
||||
# product item objects (they have version information)
|
||||
self._product_items_by_id = {}
|
||||
self._grouping_enabled = True
|
||||
self._reset_merge_color = False
|
||||
self._color_iterator = self._color_iter()
|
||||
self._group_icon = None
|
||||
|
||||
self._last_project_name = None
|
||||
self._last_folder_ids = []
|
||||
|
||||
def get_product_item_indexes(self):
|
||||
return [
|
||||
item.index()
|
||||
for item in self._items_by_id.values()
|
||||
]
|
||||
|
||||
def get_product_item_by_id(self, product_id):
|
||||
"""
|
||||
|
||||
Args:
|
||||
product_id (str): Product id.
|
||||
|
||||
Returns:
|
||||
Union[ProductItem, None]: Product item with version information.
|
||||
"""
|
||||
|
||||
return self._product_items_by_id.get(product_id)
|
||||
|
||||
def set_enable_grouping(self, enable_grouping):
|
||||
if enable_grouping is self._grouping_enabled:
|
||||
return
|
||||
self._grouping_enabled = enable_grouping
|
||||
# Ignore change if groups are not available
|
||||
self.refresh(self._last_project_name, self._last_folder_ids)
|
||||
|
||||
def flags(self, index):
|
||||
# Make the version column editable
|
||||
if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE):
|
||||
return (
|
||||
QtCore.Qt.ItemIsEnabled
|
||||
| QtCore.Qt.ItemIsSelectable
|
||||
| QtCore.Qt.ItemIsEditable
|
||||
)
|
||||
if index.column() != 0:
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
return super(ProductsModel, self).flags(index)
|
||||
|
||||
def data(self, index, role=None):
|
||||
if role is None:
|
||||
role = QtCore.Qt.DisplayRole
|
||||
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
col = index.column()
|
||||
if col == 0:
|
||||
return super(ProductsModel, self).data(index, role)
|
||||
|
||||
if role == QtCore.Qt.DecorationRole:
|
||||
if col == 1:
|
||||
role = PRODUCT_TYPE_ICON_ROLE
|
||||
else:
|
||||
return None
|
||||
|
||||
if (
|
||||
role == VERSION_NAME_EDIT_ROLE
|
||||
or (role == QtCore.Qt.EditRole and col == self.version_col)
|
||||
):
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
product_id = index.data(PRODUCT_ID_ROLE)
|
||||
product_item = self._product_items_by_id.get(product_id)
|
||||
if product_item is None:
|
||||
return None
|
||||
return list(product_item.version_items.values())
|
||||
|
||||
if role == QtCore.Qt.EditRole:
|
||||
return None
|
||||
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
if not index.data(PRODUCT_ID_ROLE):
|
||||
return None
|
||||
if col == self.version_col:
|
||||
role = VERSION_NAME_ROLE
|
||||
elif col == 1:
|
||||
role = PRODUCT_TYPE_ROLE
|
||||
elif col == 2:
|
||||
role = FOLDER_LABEL_ROLE
|
||||
elif col == 4:
|
||||
role = VERSION_PUBLISH_TIME_ROLE
|
||||
elif col == 5:
|
||||
role = VERSION_AUTHOR_ROLE
|
||||
elif col == 6:
|
||||
role = VERSION_FRAME_RANGE_ROLE
|
||||
elif col == 7:
|
||||
role = VERSION_DURATION_ROLE
|
||||
elif col == 8:
|
||||
role = VERSION_HANDLES_ROLE
|
||||
elif col == 9:
|
||||
role = VERSION_STEP_ROLE
|
||||
elif col == 10:
|
||||
role = PRODUCT_IN_SCENE_ROLE
|
||||
elif col == 11:
|
||||
role = VERSION_AVAILABLE_ROLE
|
||||
else:
|
||||
return None
|
||||
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
|
||||
return super(ProductsModel, self).data(index, role)
|
||||
|
||||
def setData(self, index, value, role=None):
|
||||
if not index.isValid():
|
||||
return False
|
||||
|
||||
if role is None:
|
||||
role = QtCore.Qt.EditRole
|
||||
|
||||
col = index.column()
|
||||
if col == self.version_col and role == QtCore.Qt.EditRole:
|
||||
role = VERSION_NAME_EDIT_ROLE
|
||||
|
||||
if role == VERSION_NAME_EDIT_ROLE:
|
||||
if col != 0:
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
product_id = index.data(PRODUCT_ID_ROLE)
|
||||
product_item = self._product_items_by_id[product_id]
|
||||
final_version_item = None
|
||||
for v_id, version_item in product_item.version_items.items():
|
||||
if v_id == value:
|
||||
final_version_item = version_item
|
||||
break
|
||||
|
||||
if final_version_item is None:
|
||||
return False
|
||||
if index.data(VERSION_ID_ROLE) == final_version_item.version_id:
|
||||
return True
|
||||
item = self.itemFromIndex(index)
|
||||
self._set_version_data_to_product_item(item, final_version_item)
|
||||
self.version_changed.emit()
|
||||
return True
|
||||
return super(ProductsModel, self).setData(index, value, role)
|
||||
|
||||
def _get_next_color(self):
|
||||
return next(self._color_iterator)
|
||||
|
||||
def _color_iter(self):
|
||||
while True:
|
||||
for color in self.merged_items_colors:
|
||||
if self._reset_merge_color:
|
||||
self._reset_merge_color = False
|
||||
break
|
||||
yield color
|
||||
|
||||
def _clear(self):
|
||||
root_item = self.invisibleRootItem()
|
||||
root_item.removeRows(0, root_item.rowCount())
|
||||
|
||||
self._items_by_id = {}
|
||||
self._group_items_by_name = {}
|
||||
self._merged_items_by_id = {}
|
||||
self._product_items_by_id = {}
|
||||
self._reset_merge_color = True
|
||||
|
||||
def _get_group_icon(self):
|
||||
if self._group_icon is None:
|
||||
self._group_icon = qtawesome.icon(
|
||||
"fa.object-group",
|
||||
color=get_default_entity_icon_color()
|
||||
)
|
||||
return self._group_icon
|
||||
|
||||
def _get_group_model_item(self, group_name):
|
||||
model_item = self._group_items_by_name.get(group_name)
|
||||
if model_item is None:
|
||||
model_item = QtGui.QStandardItem(group_name)
|
||||
model_item.setData(
|
||||
self._get_group_icon(), QtCore.Qt.DecorationRole
|
||||
)
|
||||
model_item.setData(0, GROUP_TYPE_ROLE)
|
||||
model_item.setEditable(False)
|
||||
model_item.setColumnCount(self.columnCount())
|
||||
self._group_items_by_name[group_name] = model_item
|
||||
return model_item
|
||||
|
||||
def _get_merged_model_item(self, path, count, hex_color):
|
||||
model_item = self._merged_items_by_id.get(path)
|
||||
if model_item is None:
|
||||
model_item = QtGui.QStandardItem()
|
||||
model_item.setData(1, GROUP_TYPE_ROLE)
|
||||
model_item.setData(hex_color, MERGED_COLOR_ROLE)
|
||||
model_item.setEditable(False)
|
||||
model_item.setColumnCount(self.columnCount())
|
||||
self._merged_items_by_id[path] = model_item
|
||||
label = "{} ({})".format(path, count)
|
||||
model_item.setData(label, QtCore.Qt.DisplayRole)
|
||||
return model_item
|
||||
|
||||
def _set_version_data_to_product_item(self, model_item, version_item):
|
||||
"""
|
||||
|
||||
Args:
|
||||
model_item (QtGui.QStandardItem): Item which should have values
|
||||
from version item.
|
||||
version_item (VersionItem): Item from entities model with
|
||||
information about version.
|
||||
"""
|
||||
|
||||
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
|
||||
model_item.setData(version_item.version, VERSION_NAME_ROLE)
|
||||
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
|
||||
model_item.setData(version_item.is_hero, VERSION_HERO_ROLE)
|
||||
model_item.setData(
|
||||
version_item.published_time, VERSION_PUBLISH_TIME_ROLE
|
||||
)
|
||||
model_item.setData(version_item.author, VERSION_AUTHOR_ROLE)
|
||||
model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE)
|
||||
model_item.setData(version_item.duration, VERSION_DURATION_ROLE)
|
||||
model_item.setData(version_item.handles, VERSION_HANDLES_ROLE)
|
||||
model_item.setData(version_item.step, VERSION_STEP_ROLE)
|
||||
model_item.setData(
|
||||
version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE)
|
||||
|
||||
def _get_product_model_item(self, product_item):
|
||||
model_item = self._items_by_id.get(product_item.product_id)
|
||||
versions = list(product_item.version_items.values())
|
||||
versions.sort()
|
||||
last_version = versions[-1]
|
||||
if model_item is None:
|
||||
product_id = product_item.product_id
|
||||
model_item = QtGui.QStandardItem(product_item.product_name)
|
||||
model_item.setEditable(False)
|
||||
icon = get_qt_icon(product_item.product_icon)
|
||||
product_type_icon = get_qt_icon(product_item.product_type_icon)
|
||||
model_item.setColumnCount(self.columnCount())
|
||||
model_item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
model_item.setData(product_id, PRODUCT_ID_ROLE)
|
||||
model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE)
|
||||
model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE)
|
||||
model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
|
||||
model_item.setData(product_item.folder_id, FOLDER_ID_ROLE)
|
||||
|
||||
self._product_items_by_id[product_id] = product_item
|
||||
self._items_by_id[product_id] = model_item
|
||||
|
||||
model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE)
|
||||
in_scene = 1 if product_item.product_in_scene else 0
|
||||
model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE)
|
||||
|
||||
self._set_version_data_to_product_item(model_item, last_version)
|
||||
return model_item
|
||||
|
||||
def get_last_project_name(self):
|
||||
return self._last_project_name
|
||||
|
||||
def refresh(self, project_name, folder_ids):
|
||||
self._clear()
|
||||
|
||||
self._last_project_name = project_name
|
||||
self._last_folder_ids = folder_ids
|
||||
|
||||
product_items = self._controller.get_product_items(
|
||||
project_name,
|
||||
folder_ids,
|
||||
sender=PRODUCTS_MODEL_SENDER_NAME
|
||||
)
|
||||
product_items_by_id = {
|
||||
product_item.product_id: product_item
|
||||
for product_item in product_items
|
||||
}
|
||||
|
||||
# Prepare product groups
|
||||
product_name_matches_by_group = collections.defaultdict(dict)
|
||||
for product_item in product_items_by_id.values():
|
||||
group_name = None
|
||||
if self._grouping_enabled:
|
||||
group_name = product_item.group_name
|
||||
|
||||
product_name = product_item.product_name
|
||||
group = product_name_matches_by_group[group_name]
|
||||
if product_name not in group:
|
||||
group[product_name] = [product_item]
|
||||
continue
|
||||
group[product_name].append(product_item)
|
||||
|
||||
group_names = set(product_name_matches_by_group.keys())
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
new_root_items = []
|
||||
merged_paths = set()
|
||||
for group_name in group_names:
|
||||
key_parts = []
|
||||
if group_name:
|
||||
key_parts.append(group_name)
|
||||
|
||||
groups = product_name_matches_by_group[group_name]
|
||||
merged_product_items = {}
|
||||
top_items = []
|
||||
group_product_types = set()
|
||||
for product_name, product_items in groups.items():
|
||||
group_product_types |= {p.product_type for p in product_items}
|
||||
if len(product_items) == 1:
|
||||
top_items.append(product_items[0])
|
||||
else:
|
||||
path = "/".join(key_parts + [product_name])
|
||||
merged_paths.add(path)
|
||||
merged_product_items[path] = (
|
||||
product_name,
|
||||
product_items,
|
||||
)
|
||||
|
||||
parent_item = None
|
||||
if group_name:
|
||||
parent_item = self._get_group_model_item(group_name)
|
||||
parent_item.setData(
|
||||
"|".join(group_product_types), PRODUCT_TYPE_ROLE)
|
||||
|
||||
new_items = []
|
||||
if parent_item is not None and parent_item.row() < 0:
|
||||
new_root_items.append(parent_item)
|
||||
|
||||
for product_item in top_items:
|
||||
item = self._get_product_model_item(product_item)
|
||||
new_items.append(item)
|
||||
|
||||
for path_info in merged_product_items.values():
|
||||
product_name, product_items = path_info
|
||||
(merged_color_hex, merged_color_qt) = self._get_next_color()
|
||||
merged_color = qtawesome.icon(
|
||||
"fa.circle", color=merged_color_qt)
|
||||
merged_item = self._get_merged_model_item(
|
||||
product_name, len(product_items), merged_color_hex)
|
||||
merged_item.setData(merged_color, QtCore.Qt.DecorationRole)
|
||||
new_items.append(merged_item)
|
||||
|
||||
merged_product_types = set()
|
||||
new_merged_items = []
|
||||
for product_item in product_items:
|
||||
item = self._get_product_model_item(product_item)
|
||||
new_merged_items.append(item)
|
||||
merged_product_types.add(product_item.product_type)
|
||||
|
||||
merged_item.setData(
|
||||
"|".join(merged_product_types), PRODUCT_TYPE_ROLE)
|
||||
if new_merged_items:
|
||||
merged_item.appendRows(new_merged_items)
|
||||
|
||||
if not new_items:
|
||||
continue
|
||||
|
||||
if parent_item is None:
|
||||
new_root_items.extend(new_items)
|
||||
else:
|
||||
parent_item.appendRows(new_items)
|
||||
|
||||
if new_root_items:
|
||||
root_item.appendRows(new_root_items)
|
||||
|
||||
self.refreshed.emit()
|
||||
# ---------------------------------
|
||||
# This implementation does not call '_clear' at the start
|
||||
# but is more complex and probably slower
|
||||
# ---------------------------------
|
||||
# def _remove_items(self, items):
|
||||
# if not items:
|
||||
# return
|
||||
# root_item = self.invisibleRootItem()
|
||||
# for item in items:
|
||||
# row = item.row()
|
||||
# if row < 0:
|
||||
# continue
|
||||
# parent = item.parent()
|
||||
# if parent is None:
|
||||
# parent = root_item
|
||||
# parent.removeRow(row)
|
||||
#
|
||||
# def _remove_group_items(self, group_names):
|
||||
# group_items = [
|
||||
# self._group_items_by_name.pop(group_name)
|
||||
# for group_name in group_names
|
||||
# ]
|
||||
# self._remove_items(group_items)
|
||||
#
|
||||
# def _remove_merged_items(self, paths):
|
||||
# merged_items = [
|
||||
# self._merged_items_by_id.pop(path)
|
||||
# for path in paths
|
||||
# ]
|
||||
# self._remove_items(merged_items)
|
||||
#
|
||||
# def _remove_product_items(self, product_ids):
|
||||
# product_items = []
|
||||
# for product_id in product_ids:
|
||||
# self._product_items_by_id.pop(product_id)
|
||||
# product_items.append(self._items_by_id.pop(product_id))
|
||||
# self._remove_items(product_items)
|
||||
#
|
||||
# def _add_to_new_items(self, item, parent_item, new_items, root_item):
|
||||
# if item.row() < 0:
|
||||
# new_items.append(item)
|
||||
# else:
|
||||
# item_parent = item.parent()
|
||||
# if item_parent is not parent_item:
|
||||
# if item_parent is None:
|
||||
# item_parent = root_item
|
||||
# item_parent.takeRow(item.row())
|
||||
# new_items.append(item)
|
||||
|
||||
# def refresh(self, project_name, folder_ids):
|
||||
# product_items = self._controller.get_product_items(
|
||||
# project_name,
|
||||
# folder_ids,
|
||||
# sender=PRODUCTS_MODEL_SENDER_NAME
|
||||
# )
|
||||
# product_items_by_id = {
|
||||
# product_item.product_id: product_item
|
||||
# for product_item in product_items
|
||||
# }
|
||||
# # Remove product items that are not available
|
||||
# product_ids_to_remove = (
|
||||
# set(self._items_by_id.keys()) - set(product_items_by_id.keys())
|
||||
# )
|
||||
# self._remove_product_items(product_ids_to_remove)
|
||||
#
|
||||
# # Prepare product groups
|
||||
# product_name_matches_by_group = collections.defaultdict(dict)
|
||||
# for product_item in product_items_by_id.values():
|
||||
# group_name = None
|
||||
# if self._grouping_enabled:
|
||||
# group_name = product_item.group_name
|
||||
#
|
||||
# product_name = product_item.product_name
|
||||
# group = product_name_matches_by_group[group_name]
|
||||
# if product_name not in group:
|
||||
# group[product_name] = [product_item]
|
||||
# continue
|
||||
# group[product_name].append(product_item)
|
||||
#
|
||||
# group_names = set(product_name_matches_by_group.keys())
|
||||
#
|
||||
# root_item = self.invisibleRootItem()
|
||||
# new_root_items = []
|
||||
# merged_paths = set()
|
||||
# for group_name in group_names:
|
||||
# key_parts = []
|
||||
# if group_name:
|
||||
# key_parts.append(group_name)
|
||||
#
|
||||
# groups = product_name_matches_by_group[group_name]
|
||||
# merged_product_items = {}
|
||||
# top_items = []
|
||||
# for product_name, product_items in groups.items():
|
||||
# if len(product_items) == 1:
|
||||
# top_items.append(product_items[0])
|
||||
# else:
|
||||
# path = "/".join(key_parts + [product_name])
|
||||
# merged_paths.add(path)
|
||||
# merged_product_items[path] = product_items
|
||||
#
|
||||
# parent_item = None
|
||||
# if group_name:
|
||||
# parent_item = self._get_group_model_item(group_name)
|
||||
#
|
||||
# new_items = []
|
||||
# if parent_item is not None and parent_item.row() < 0:
|
||||
# new_root_items.append(parent_item)
|
||||
#
|
||||
# for product_item in top_items:
|
||||
# item = self._get_product_model_item(product_item)
|
||||
# self._add_to_new_items(
|
||||
# item, parent_item, new_items, root_item
|
||||
# )
|
||||
#
|
||||
# for path, product_items in merged_product_items.items():
|
||||
# merged_item = self._get_merged_model_item(path)
|
||||
# self._add_to_new_items(
|
||||
# merged_item, parent_item, new_items, root_item
|
||||
# )
|
||||
#
|
||||
# new_merged_items = []
|
||||
# for product_item in product_items:
|
||||
# item = self._get_product_model_item(product_item)
|
||||
# self._add_to_new_items(
|
||||
# item, merged_item, new_merged_items, root_item
|
||||
# )
|
||||
#
|
||||
# if new_merged_items:
|
||||
# merged_item.appendRows(new_merged_items)
|
||||
#
|
||||
# if not new_items:
|
||||
# continue
|
||||
#
|
||||
# if parent_item is not None:
|
||||
# parent_item.appendRows(new_items)
|
||||
# continue
|
||||
#
|
||||
# new_root_items.extend(new_items)
|
||||
#
|
||||
# root_item.appendRows(new_root_items)
|
||||
#
|
||||
# merged_item_ids_to_remove = (
|
||||
# set(self._merged_items_by_id.keys()) - merged_paths
|
||||
# )
|
||||
# group_names_to_remove = (
|
||||
# set(self._group_items_by_name.keys()) - set(group_names)
|
||||
# )
|
||||
# self._remove_merged_items(merged_item_ids_to_remove)
|
||||
# self._remove_group_items(group_names_to_remove)
|
||||
400
openpype/tools/ayon_loader/ui/products_widget.py
Normal file
400
openpype/tools/ayon_loader/ui/products_widget.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import collections
|
||||
|
||||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from openpype.tools.utils import (
|
||||
RecursiveSortFilterProxyModel,
|
||||
DeselectableTreeView,
|
||||
)
|
||||
from openpype.tools.utils.delegates import PrettyTimeDelegate
|
||||
|
||||
from .products_model import (
|
||||
ProductsModel,
|
||||
PRODUCTS_MODEL_SENDER_NAME,
|
||||
PRODUCT_TYPE_ROLE,
|
||||
GROUP_TYPE_ROLE,
|
||||
MERGED_COLOR_ROLE,
|
||||
FOLDER_ID_ROLE,
|
||||
PRODUCT_ID_ROLE,
|
||||
VERSION_ID_ROLE,
|
||||
VERSION_THUMBNAIL_ID_ROLE,
|
||||
)
|
||||
from .products_delegates import VersionDelegate, LoadedInSceneDelegate
|
||||
from .actions_utils import show_actions_menu
|
||||
|
||||
|
||||
class ProductsProxyModel(RecursiveSortFilterProxyModel):
|
||||
def __init__(self, parent=None):
|
||||
super(ProductsProxyModel, self).__init__(parent)
|
||||
|
||||
self._product_type_filters = {}
|
||||
self._ascending_sort = True
|
||||
|
||||
def set_product_type_filters(self, product_type_filters):
|
||||
self._product_type_filters = product_type_filters
|
||||
self.invalidateFilter()
|
||||
|
||||
def filterAcceptsRow(self, source_row, source_parent):
|
||||
source_model = self.sourceModel()
|
||||
index = source_model.index(source_row, 0, source_parent)
|
||||
product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE)
|
||||
product_types = []
|
||||
if product_types_s:
|
||||
product_types = product_types_s.split("|")
|
||||
|
||||
for product_type in product_types:
|
||||
if not self._product_type_filters.get(product_type, True):
|
||||
return False
|
||||
return super(ProductsProxyModel, self).filterAcceptsRow(
|
||||
source_row, source_parent)
|
||||
|
||||
def lessThan(self, left, right):
|
||||
l_model = left.model()
|
||||
r_model = right.model()
|
||||
left_group_type = l_model.data(left, GROUP_TYPE_ROLE)
|
||||
right_group_type = r_model.data(right, GROUP_TYPE_ROLE)
|
||||
# Groups are always on top, merged product types are below
|
||||
# and items without group at the bottom
|
||||
# QUESTION Do we need to do it this way?
|
||||
if left_group_type != right_group_type:
|
||||
if left_group_type is None:
|
||||
output = False
|
||||
elif right_group_type is None:
|
||||
output = True
|
||||
else:
|
||||
output = left_group_type < right_group_type
|
||||
if not self._ascending_sort:
|
||||
output = not output
|
||||
return output
|
||||
return super(ProductsProxyModel, self).lessThan(left, right)
|
||||
|
||||
def sort(self, column, order=None):
|
||||
if order is None:
|
||||
order = QtCore.Qt.AscendingOrder
|
||||
self._ascending_sort = order == QtCore.Qt.AscendingOrder
|
||||
super(ProductsProxyModel, self).sort(column, order)
|
||||
|
||||
|
||||
class ProductsWidget(QtWidgets.QWidget):
|
||||
refreshed = QtCore.Signal()
|
||||
merged_products_selection_changed = QtCore.Signal()
|
||||
selection_changed = QtCore.Signal()
|
||||
version_changed = QtCore.Signal()
|
||||
default_widths = (
|
||||
200, # Product name
|
||||
90, # Product type
|
||||
130, # Folder label
|
||||
60, # Version
|
||||
125, # Time
|
||||
75, # Author
|
||||
75, # Frames
|
||||
60, # Duration
|
||||
55, # Handles
|
||||
10, # Step
|
||||
25, # Loaded in scene
|
||||
65, # Site info (maybe?)
|
||||
)
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(ProductsWidget, self).__init__(parent)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
products_view = DeselectableTreeView(self)
|
||||
# TODO - define custom object name in style
|
||||
products_view.setObjectName("SubsetView")
|
||||
products_view.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.ExtendedSelection
|
||||
)
|
||||
products_view.setAllColumnsShowFocus(True)
|
||||
# TODO - add context menu
|
||||
products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
products_view.setSortingEnabled(True)
|
||||
# Sort by product type
|
||||
products_view.sortByColumn(1, QtCore.Qt.AscendingOrder)
|
||||
products_view.setAlternatingRowColors(True)
|
||||
|
||||
products_model = ProductsModel(controller)
|
||||
products_proxy_model = ProductsProxyModel()
|
||||
products_proxy_model.setSourceModel(products_model)
|
||||
|
||||
products_view.setModel(products_proxy_model)
|
||||
|
||||
for idx, width in enumerate(self.default_widths):
|
||||
products_view.setColumnWidth(idx, width)
|
||||
|
||||
version_delegate = VersionDelegate()
|
||||
products_view.setItemDelegateForColumn(
|
||||
products_model.version_col, version_delegate)
|
||||
|
||||
time_delegate = PrettyTimeDelegate()
|
||||
products_view.setItemDelegateForColumn(
|
||||
products_model.published_time_col, time_delegate)
|
||||
|
||||
in_scene_delegate = LoadedInSceneDelegate()
|
||||
products_view.setItemDelegateForColumn(
|
||||
products_model.in_scene_col, in_scene_delegate)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(products_view, 1)
|
||||
|
||||
products_proxy_model.rowsInserted.connect(self._on_rows_inserted)
|
||||
products_proxy_model.rowsMoved.connect(self._on_rows_moved)
|
||||
products_model.refreshed.connect(self._on_refresh)
|
||||
products_view.customContextMenuRequested.connect(
|
||||
self._on_context_menu)
|
||||
products_view.selectionModel().selectionChanged.connect(
|
||||
self._on_selection_change)
|
||||
products_model.version_changed.connect(self._on_version_change)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.folders.changed",
|
||||
self._on_folders_selection_change,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"products.refresh.finished",
|
||||
self._on_products_refresh_finished
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"products.group.changed",
|
||||
self._on_group_changed
|
||||
)
|
||||
|
||||
self._products_view = products_view
|
||||
self._products_model = products_model
|
||||
self._products_proxy_model = products_proxy_model
|
||||
|
||||
self._version_delegate = version_delegate
|
||||
self._time_delegate = time_delegate
|
||||
|
||||
self._selected_project_name = None
|
||||
self._selected_folder_ids = set()
|
||||
|
||||
self._selected_merged_products = []
|
||||
self._selected_versions_info = []
|
||||
|
||||
# Set initial state of widget
|
||||
# - Hide folders column
|
||||
self._update_folders_label_visible()
|
||||
# - Hide in scene column if is not supported (this won't change)
|
||||
products_view.setColumnHidden(
|
||||
products_model.in_scene_col,
|
||||
not controller.is_loaded_products_supported()
|
||||
)
|
||||
|
||||
def set_name_filter(self, name):
|
||||
"""Set filter of product name.
|
||||
|
||||
Args:
|
||||
name (str): The string filter.
|
||||
"""
|
||||
|
||||
self._products_proxy_model.setFilterFixedString(name)
|
||||
|
||||
def set_product_type_filter(self, product_type_filters):
|
||||
"""
|
||||
|
||||
Args:
|
||||
product_type_filters (dict[str, bool]): The filter of product
|
||||
types.
|
||||
"""
|
||||
|
||||
self._products_proxy_model.set_product_type_filters(
|
||||
product_type_filters
|
||||
)
|
||||
|
||||
def set_enable_grouping(self, enable_grouping):
|
||||
self._products_model.set_enable_grouping(enable_grouping)
|
||||
|
||||
def get_selected_merged_products(self):
|
||||
return self._selected_merged_products
|
||||
|
||||
def get_selected_version_info(self):
|
||||
return self._selected_versions_info
|
||||
|
||||
def refresh(self):
|
||||
self._refresh_model()
|
||||
|
||||
def _fill_version_editor(self):
|
||||
model = self._products_proxy_model
|
||||
index_queue = collections.deque()
|
||||
for row in range(model.rowCount()):
|
||||
index_queue.append((row, None))
|
||||
|
||||
version_col = self._products_model.version_col
|
||||
while index_queue:
|
||||
(row, parent_index) = index_queue.popleft()
|
||||
args = [row, 0]
|
||||
if parent_index is not None:
|
||||
args.append(parent_index)
|
||||
index = model.index(*args)
|
||||
rows = model.rowCount(index)
|
||||
for row in range(rows):
|
||||
index_queue.append((row, index))
|
||||
|
||||
product_id = model.data(index, PRODUCT_ID_ROLE)
|
||||
if product_id is not None:
|
||||
args[1] = version_col
|
||||
v_index = model.index(*args)
|
||||
self._products_view.openPersistentEditor(v_index)
|
||||
|
||||
def _on_refresh(self):
|
||||
self._fill_version_editor()
|
||||
self.refreshed.emit()
|
||||
|
||||
def _on_rows_inserted(self):
|
||||
self._fill_version_editor()
|
||||
|
||||
def _on_rows_moved(self):
|
||||
self._fill_version_editor()
|
||||
|
||||
def _refresh_model(self):
|
||||
self._products_model.refresh(
|
||||
self._selected_project_name,
|
||||
self._selected_folder_ids
|
||||
)
|
||||
|
||||
def _on_context_menu(self, point):
|
||||
selection_model = self._products_view.selectionModel()
|
||||
model = self._products_view.model()
|
||||
project_name = self._products_model.get_last_project_name()
|
||||
|
||||
version_ids = set()
|
||||
indexes_queue = collections.deque()
|
||||
indexes_queue.extend(selection_model.selectedIndexes())
|
||||
while indexes_queue:
|
||||
index = indexes_queue.popleft()
|
||||
for row in range(model.rowCount(index)):
|
||||
child_index = model.index(row, 0, index)
|
||||
indexes_queue.append(child_index)
|
||||
version_id = model.data(index, VERSION_ID_ROLE)
|
||||
if version_id is not None:
|
||||
version_ids.add(version_id)
|
||||
|
||||
action_items = self._controller.get_versions_action_items(
|
||||
project_name, version_ids)
|
||||
|
||||
# Prepare global point where to show the menu
|
||||
global_point = self._products_view.mapToGlobal(point)
|
||||
|
||||
result = show_actions_menu(
|
||||
action_items,
|
||||
global_point,
|
||||
len(version_ids) == 1,
|
||||
self
|
||||
)
|
||||
action_item, options = result
|
||||
if action_item is None or options is None:
|
||||
return
|
||||
|
||||
self._controller.trigger_action_item(
|
||||
action_item.identifier,
|
||||
options,
|
||||
action_item.project_name,
|
||||
version_ids=action_item.version_ids,
|
||||
representation_ids=action_item.representation_ids,
|
||||
)
|
||||
|
||||
def _on_selection_change(self):
|
||||
selected_merged_products = []
|
||||
selection_model = self._products_view.selectionModel()
|
||||
model = self._products_view.model()
|
||||
indexes_queue = collections.deque()
|
||||
indexes_queue.extend(selection_model.selectedIndexes())
|
||||
|
||||
# Helper for 'version_items' to avoid duplicated items
|
||||
all_product_ids = set()
|
||||
selected_version_ids = set()
|
||||
# Version items contains information about selected version items
|
||||
selected_versions_info = []
|
||||
while indexes_queue:
|
||||
index = indexes_queue.popleft()
|
||||
if index.column() != 0:
|
||||
continue
|
||||
|
||||
group_type = model.data(index, GROUP_TYPE_ROLE)
|
||||
if group_type is None:
|
||||
product_id = model.data(index, PRODUCT_ID_ROLE)
|
||||
# Skip duplicates - when group and item are selected the item
|
||||
# would be in the loop multiple times
|
||||
if product_id in all_product_ids:
|
||||
continue
|
||||
|
||||
all_product_ids.add(product_id)
|
||||
|
||||
version_id = model.data(index, VERSION_ID_ROLE)
|
||||
selected_version_ids.add(version_id)
|
||||
|
||||
thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE)
|
||||
selected_versions_info.append({
|
||||
"folder_id": model.data(index, FOLDER_ID_ROLE),
|
||||
"product_id": product_id,
|
||||
"version_id": version_id,
|
||||
"thumbnail_id": thumbnail_id,
|
||||
})
|
||||
continue
|
||||
|
||||
if group_type == 0:
|
||||
for row in range(model.rowCount(index)):
|
||||
child_index = model.index(row, 0, index)
|
||||
indexes_queue.append(child_index)
|
||||
continue
|
||||
|
||||
if group_type != 1:
|
||||
continue
|
||||
|
||||
item_folder_ids = set()
|
||||
for row in range(model.rowCount(index)):
|
||||
child_index = model.index(row, 0, index)
|
||||
indexes_queue.append(child_index)
|
||||
|
||||
folder_id = model.data(child_index, FOLDER_ID_ROLE)
|
||||
item_folder_ids.add(folder_id)
|
||||
|
||||
if not item_folder_ids:
|
||||
continue
|
||||
|
||||
hex_color = model.data(index, MERGED_COLOR_ROLE)
|
||||
item_data = {
|
||||
"color": hex_color,
|
||||
"folder_ids": item_folder_ids
|
||||
}
|
||||
selected_merged_products.append(item_data)
|
||||
|
||||
prev_selected_merged_products = self._selected_merged_products
|
||||
self._selected_merged_products = selected_merged_products
|
||||
self._selected_versions_info = selected_versions_info
|
||||
|
||||
if selected_merged_products != prev_selected_merged_products:
|
||||
self.merged_products_selection_changed.emit()
|
||||
self.selection_changed.emit()
|
||||
self._controller.set_selected_versions(selected_version_ids)
|
||||
|
||||
def _on_version_change(self):
|
||||
self._on_selection_change()
|
||||
|
||||
def _on_folders_selection_change(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
self._selected_folder_ids = event["folder_ids"]
|
||||
self._refresh_model()
|
||||
self._update_folders_label_visible()
|
||||
|
||||
def _update_folders_label_visible(self):
|
||||
folders_label_hidden = len(self._selected_folder_ids) <= 1
|
||||
self._products_view.setColumnHidden(
|
||||
self._products_model.folders_label_col,
|
||||
folders_label_hidden
|
||||
)
|
||||
|
||||
def _on_products_refresh_finished(self, event):
|
||||
if event["sender"] != PRODUCTS_MODEL_SENDER_NAME:
|
||||
self._refresh_model()
|
||||
|
||||
def _on_group_changed(self, event):
|
||||
if event["project_name"] != self._selected_project_name:
|
||||
return
|
||||
folder_ids = event["folder_ids"]
|
||||
if not set(folder_ids).intersection(set(self._selected_folder_ids)):
|
||||
return
|
||||
self.refresh()
|
||||
338
openpype/tools/ayon_loader/ui/repres_widget.py
Normal file
338
openpype/tools/ayon_loader/ui/repres_widget.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import collections
|
||||
|
||||
from qtpy import QtWidgets, QtGui, QtCore
|
||||
import qtawesome
|
||||
|
||||
from openpype.style import get_default_entity_icon_color
|
||||
from openpype.tools.ayon_utils.widgets import get_qt_icon
|
||||
from openpype.tools.utils import DeselectableTreeView
|
||||
|
||||
from .actions_utils import show_actions_menu
|
||||
|
||||
REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1
|
||||
REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2
|
||||
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3
|
||||
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4
|
||||
GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5
|
||||
|
||||
|
||||
class RepresentationsModel(QtGui.QStandardItemModel):
|
||||
refreshed = QtCore.Signal()
|
||||
colums_info = [
|
||||
("Name", 120),
|
||||
("Product name", 125),
|
||||
("Folder", 125),
|
||||
# ("Active site", 85),
|
||||
# ("Remote site", 85)
|
||||
]
|
||||
column_labels = [label for label, _ in colums_info]
|
||||
column_widths = [width for _, width in colums_info]
|
||||
folder_column = column_labels.index("Product name")
|
||||
|
||||
def __init__(self, controller):
|
||||
super(RepresentationsModel, self).__init__()
|
||||
|
||||
self.setColumnCount(len(self.column_labels))
|
||||
|
||||
for idx, label in enumerate(self.column_labels):
|
||||
self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_change
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.versions.changed",
|
||||
self._on_version_change
|
||||
)
|
||||
self._selected_project_name = None
|
||||
self._selected_version_ids = None
|
||||
|
||||
self._group_icon = None
|
||||
|
||||
self._items_by_id = {}
|
||||
self._groups_items_by_name = {}
|
||||
|
||||
self._controller = controller
|
||||
|
||||
def refresh(self):
|
||||
repre_items = self._controller.get_representation_items(
|
||||
self._selected_project_name, self._selected_version_ids
|
||||
)
|
||||
self._fill_items(repre_items)
|
||||
self.refreshed.emit()
|
||||
|
||||
def data(self, index, role=None):
|
||||
if role is None:
|
||||
role = QtCore.Qt.DisplayRole
|
||||
|
||||
col = index.column()
|
||||
if col != 0:
|
||||
if role == QtCore.Qt.DecorationRole:
|
||||
return None
|
||||
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
if col == 1:
|
||||
role = PRODUCT_NAME_ROLE
|
||||
elif col == 2:
|
||||
role = FOLDER_LABEL_ROLE
|
||||
index = self.index(index.row(), 0, index.parent())
|
||||
return super(RepresentationsModel, self).data(index, role)
|
||||
|
||||
def setData(self, index, value, role=None):
|
||||
if role is None:
|
||||
role = QtCore.Qt.EditRole
|
||||
return super(RepresentationsModel, self).setData(index, value, role)
|
||||
|
||||
def _clear_items(self):
|
||||
self._items_by_id = {}
|
||||
root_item = self.invisibleRootItem()
|
||||
root_item.removeRows(0, root_item.rowCount())
|
||||
|
||||
def _get_repre_item(self, repre_item):
|
||||
repre_id = repre_item.representation_id
|
||||
repre_name = repre_item.representation_name
|
||||
repre_icon = repre_item.representation_icon
|
||||
item = self._items_by_id.get(repre_id)
|
||||
is_new_item = False
|
||||
if item is None:
|
||||
is_new_item = True
|
||||
item = QtGui.QStandardItem()
|
||||
self._items_by_id[repre_id] = item
|
||||
item.setColumnCount(self.columnCount())
|
||||
item.setEditable(False)
|
||||
|
||||
icon = get_qt_icon(repre_icon)
|
||||
item.setData(repre_name, QtCore.Qt.DisplayRole)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
item.setData(repre_name, REPRESENTAION_NAME_ROLE)
|
||||
item.setData(repre_id, REPRESENTATION_ID_ROLE)
|
||||
item.setData(repre_item.product_name, PRODUCT_NAME_ROLE)
|
||||
item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE)
|
||||
return is_new_item, item
|
||||
|
||||
def _get_group_icon(self):
|
||||
if self._group_icon is None:
|
||||
self._group_icon = qtawesome.icon(
|
||||
"fa.folder",
|
||||
color=get_default_entity_icon_color()
|
||||
)
|
||||
return self._group_icon
|
||||
|
||||
def _get_group_item(self, repre_name):
|
||||
item = self._groups_items_by_name.get(repre_name)
|
||||
if item is not None:
|
||||
return False, item
|
||||
|
||||
# TODO add color
|
||||
item = QtGui.QStandardItem()
|
||||
item.setColumnCount(self.columnCount())
|
||||
item.setData(repre_name, QtCore.Qt.DisplayRole)
|
||||
item.setData(self._get_group_icon(), QtCore.Qt.DecorationRole)
|
||||
item.setData(0, GROUP_TYPE_ROLE)
|
||||
item.setEditable(False)
|
||||
self._groups_items_by_name[repre_name] = item
|
||||
return True, item
|
||||
|
||||
def _fill_items(self, repre_items):
|
||||
items_to_remove = set(self._items_by_id.keys())
|
||||
repre_items_by_name = collections.defaultdict(list)
|
||||
for repre_item in repre_items:
|
||||
items_to_remove.discard(repre_item.representation_id)
|
||||
repre_name = repre_item.representation_name
|
||||
repre_items_by_name[repre_name].append(repre_item)
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
for repre_id in items_to_remove:
|
||||
item = self._items_by_id.pop(repre_id)
|
||||
parent_item = item.parent()
|
||||
if parent_item is None:
|
||||
parent_item = root_item
|
||||
parent_item.removeRow(item.row())
|
||||
|
||||
group_names = set()
|
||||
new_root_items = []
|
||||
for repre_name, repre_name_items in repre_items_by_name.items():
|
||||
group_item = None
|
||||
parent_is_group = False
|
||||
if len(repre_name_items) > 1:
|
||||
group_names.add(repre_name)
|
||||
is_new_group, group_item = self._get_group_item(repre_name)
|
||||
if is_new_group:
|
||||
new_root_items.append(group_item)
|
||||
parent_is_group = True
|
||||
|
||||
new_group_items = []
|
||||
for repre_item in repre_name_items:
|
||||
is_new_item, item = self._get_repre_item(repre_item)
|
||||
item_parent = item.parent()
|
||||
if item_parent is None:
|
||||
item_parent = root_item
|
||||
|
||||
if not is_new_item:
|
||||
if parent_is_group:
|
||||
if item_parent is group_item:
|
||||
continue
|
||||
elif item_parent is root_item:
|
||||
continue
|
||||
item_parent.takeRow(item.row())
|
||||
is_new_item = True
|
||||
|
||||
if is_new_item:
|
||||
new_group_items.append(item)
|
||||
|
||||
if not new_group_items:
|
||||
continue
|
||||
|
||||
if group_item is not None:
|
||||
group_item.appendRows(new_group_items)
|
||||
else:
|
||||
new_root_items.extend(new_group_items)
|
||||
|
||||
if new_root_items:
|
||||
root_item.appendRows(new_root_items)
|
||||
|
||||
for group_name in set(self._groups_items_by_name) - group_names:
|
||||
item = self._groups_items_by_name.pop(group_name)
|
||||
parent_item = item.parent()
|
||||
if parent_item is None:
|
||||
parent_item = root_item
|
||||
parent_item.removeRow(item.row())
|
||||
|
||||
def _on_project_change(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
|
||||
def _on_version_change(self, event):
|
||||
self._selected_version_ids = event["version_ids"]
|
||||
self.refresh()
|
||||
|
||||
|
||||
class RepresentationsWidget(QtWidgets.QWidget):
|
||||
def __init__(self, controller, parent):
|
||||
super(RepresentationsWidget, self).__init__(parent)
|
||||
|
||||
repre_view = DeselectableTreeView(self)
|
||||
repre_view.setSelectionMode(
|
||||
QtWidgets.QAbstractItemView.ExtendedSelection
|
||||
)
|
||||
repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
repre_view.setSortingEnabled(True)
|
||||
repre_view.setAlternatingRowColors(True)
|
||||
|
||||
repre_model = RepresentationsModel(controller)
|
||||
repre_proxy_model = QtCore.QSortFilterProxyModel()
|
||||
repre_proxy_model.setSourceModel(repre_model)
|
||||
repre_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
repre_view.setModel(repre_proxy_model)
|
||||
|
||||
for idx, width in enumerate(repre_model.column_widths):
|
||||
repre_view.setColumnWidth(idx, width)
|
||||
|
||||
main_layout = QtWidgets.QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(repre_view, 1)
|
||||
|
||||
repre_view.customContextMenuRequested.connect(
|
||||
self._on_context_menu)
|
||||
repre_view.selectionModel().selectionChanged.connect(
|
||||
self._on_selection_change)
|
||||
repre_model.refreshed.connect(self._on_model_refresh)
|
||||
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_change
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.folders.changed",
|
||||
self._on_folder_change
|
||||
)
|
||||
|
||||
self._controller = controller
|
||||
self._selected_project_name = None
|
||||
self._selected_multiple_folders = None
|
||||
|
||||
self._repre_view = repre_view
|
||||
self._repre_model = repre_model
|
||||
self._repre_proxy_model = repre_proxy_model
|
||||
|
||||
self._set_multiple_folders_selected(False)
|
||||
|
||||
def refresh(self):
|
||||
self._repre_model.refresh()
|
||||
|
||||
def _on_folder_change(self, event):
|
||||
self._set_multiple_folders_selected(len(event["folder_ids"]) > 1)
|
||||
|
||||
def _on_project_change(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
|
||||
def _set_multiple_folders_selected(self, selected_multiple_folders):
|
||||
if selected_multiple_folders == self._selected_multiple_folders:
|
||||
return
|
||||
self._selected_multiple_folders = selected_multiple_folders
|
||||
self._repre_view.setColumnHidden(
|
||||
self._repre_model.folder_column,
|
||||
not self._selected_multiple_folders
|
||||
)
|
||||
|
||||
def _on_model_refresh(self):
|
||||
self._repre_proxy_model.sort(0)
|
||||
|
||||
def _get_selected_repre_indexes(self):
|
||||
selection_model = self._repre_view.selectionModel()
|
||||
model = self._repre_view.model()
|
||||
indexes_queue = collections.deque()
|
||||
indexes_queue.extend(selection_model.selectedIndexes())
|
||||
|
||||
selected_indexes = []
|
||||
while indexes_queue:
|
||||
index = indexes_queue.popleft()
|
||||
if index.column() != 0:
|
||||
continue
|
||||
|
||||
group_type = model.data(index, GROUP_TYPE_ROLE)
|
||||
if group_type is None:
|
||||
selected_indexes.append(index)
|
||||
|
||||
elif group_type == 0:
|
||||
for row in range(model.rowCount(index)):
|
||||
child_index = model.index(row, 0, index)
|
||||
indexes_queue.append(child_index)
|
||||
|
||||
return selected_indexes
|
||||
|
||||
def _get_selected_repre_ids(self):
|
||||
repre_ids = {
|
||||
index.data(REPRESENTATION_ID_ROLE)
|
||||
for index in self._get_selected_repre_indexes()
|
||||
}
|
||||
repre_ids.discard(None)
|
||||
return repre_ids
|
||||
|
||||
def _on_selection_change(self):
|
||||
selected_repre_ids = self._get_selected_repre_ids()
|
||||
self._controller.set_selected_representations(selected_repre_ids)
|
||||
|
||||
def _on_context_menu(self, point):
|
||||
repre_ids = self._get_selected_repre_ids()
|
||||
action_items = self._controller.get_representations_action_items(
|
||||
self._selected_project_name, repre_ids
|
||||
)
|
||||
global_point = self._repre_view.mapToGlobal(point)
|
||||
result = show_actions_menu(
|
||||
action_items,
|
||||
global_point,
|
||||
len(repre_ids) == 1,
|
||||
self
|
||||
)
|
||||
action_item, options = result
|
||||
if action_item is None or options is None:
|
||||
return
|
||||
|
||||
self._controller.trigger_action_item(
|
||||
action_item.identifier,
|
||||
options,
|
||||
action_item.project_name,
|
||||
version_ids=action_item.version_ids,
|
||||
representation_ids=action_item.representation_ids,
|
||||
)
|
||||
511
openpype/tools/ayon_loader/ui/window.py
Normal file
511
openpype/tools/ayon_loader/ui/window.py
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.resources import get_openpype_icon_filepath
|
||||
from openpype.style import load_stylesheet
|
||||
from openpype.tools.utils import (
|
||||
PlaceholderLineEdit,
|
||||
ErrorMessageBox,
|
||||
ThumbnailPainterWidget,
|
||||
RefreshButton,
|
||||
GoToCurrentButton,
|
||||
)
|
||||
from openpype.tools.utils.lib import center_window
|
||||
from openpype.tools.ayon_utils.widgets import ProjectsCombobox
|
||||
from openpype.tools.ayon_loader.control import LoaderController
|
||||
|
||||
from .folders_widget import LoaderFoldersWidget
|
||||
from .products_widget import ProductsWidget
|
||||
from .product_types_widget import ProductTypesView
|
||||
from .product_group_dialog import ProductGroupDialog
|
||||
from .info_widget import InfoWidget
|
||||
from .repres_widget import RepresentationsWidget
|
||||
|
||||
|
||||
class LoadErrorMessageBox(ErrorMessageBox):
|
||||
def __init__(self, messages, parent=None):
|
||||
self._messages = messages
|
||||
super(LoadErrorMessageBox, self).__init__("Loading failed", parent)
|
||||
|
||||
def _create_top_widget(self, parent_widget):
|
||||
label_widget = QtWidgets.QLabel(parent_widget)
|
||||
label_widget.setText(
|
||||
"<span style='font-size:18pt;'>Failed to load items</span>"
|
||||
)
|
||||
return label_widget
|
||||
|
||||
def _get_report_data(self):
|
||||
report_data = []
|
||||
for exc_msg, tb_text, repre, product, version in self._messages:
|
||||
report_message = (
|
||||
"During load error happened on Product: \"{product}\""
|
||||
" Representation: \"{repre}\" Version: {version}"
|
||||
"\n\nError message: {message}"
|
||||
).format(
|
||||
product=product,
|
||||
repre=repre,
|
||||
version=version,
|
||||
message=exc_msg
|
||||
)
|
||||
if tb_text:
|
||||
report_message += "\n\n{}".format(tb_text)
|
||||
report_data.append(report_message)
|
||||
return report_data
|
||||
|
||||
def _create_content(self, content_layout):
|
||||
item_name_template = (
|
||||
"<span style='font-weight:bold;'>Product:</span> {}<br>"
|
||||
"<span style='font-weight:bold;'>Version:</span> {}<br>"
|
||||
"<span style='font-weight:bold;'>Representation:</span> {}<br>"
|
||||
)
|
||||
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
|
||||
|
||||
for exc_msg, tb_text, repre, product, version in self._messages:
|
||||
line = self._create_line()
|
||||
content_layout.addWidget(line)
|
||||
|
||||
item_name = item_name_template.format(product, version, repre)
|
||||
item_name_widget = QtWidgets.QLabel(
|
||||
item_name.replace("\n", "<br>"), self
|
||||
)
|
||||
item_name_widget.setWordWrap(True)
|
||||
content_layout.addWidget(item_name_widget)
|
||||
|
||||
exc_msg = exc_msg_template.format(exc_msg.replace("\n", "<br>"))
|
||||
message_label_widget = QtWidgets.QLabel(exc_msg, self)
|
||||
message_label_widget.setWordWrap(True)
|
||||
content_layout.addWidget(message_label_widget)
|
||||
|
||||
if tb_text:
|
||||
line = self._create_line()
|
||||
tb_widget = self._create_traceback_widget(tb_text, self)
|
||||
content_layout.addWidget(line)
|
||||
content_layout.addWidget(tb_widget)
|
||||
|
||||
|
||||
class RefreshHandler:
|
||||
def __init__(self):
|
||||
self._project_refreshed = False
|
||||
self._folders_refreshed = False
|
||||
self._products_refreshed = False
|
||||
|
||||
@property
|
||||
def project_refreshed(self):
|
||||
return self._products_refreshed
|
||||
|
||||
@property
|
||||
def folders_refreshed(self):
|
||||
return self._folders_refreshed
|
||||
|
||||
@property
|
||||
def products_refreshed(self):
|
||||
return self._products_refreshed
|
||||
|
||||
def reset(self):
|
||||
self._project_refreshed = False
|
||||
self._folders_refreshed = False
|
||||
self._products_refreshed = False
|
||||
|
||||
def set_project_refreshed(self):
|
||||
self._project_refreshed = True
|
||||
|
||||
def set_folders_refreshed(self):
|
||||
self._folders_refreshed = True
|
||||
|
||||
def set_products_refreshed(self):
|
||||
self._products_refreshed = True
|
||||
|
||||
|
||||
class LoaderWindow(QtWidgets.QWidget):
|
||||
def __init__(self, controller=None, parent=None):
|
||||
super(LoaderWindow, self).__init__(parent)
|
||||
|
||||
icon = QtGui.QIcon(get_openpype_icon_filepath())
|
||||
self.setWindowIcon(icon)
|
||||
self.setWindowTitle("AYON Loader")
|
||||
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
||||
self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
|
||||
self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window)
|
||||
|
||||
if controller is None:
|
||||
controller = LoaderController()
|
||||
|
||||
main_splitter = QtWidgets.QSplitter(self)
|
||||
|
||||
context_splitter = QtWidgets.QSplitter(main_splitter)
|
||||
context_splitter.setOrientation(QtCore.Qt.Vertical)
|
||||
|
||||
# Context selection widget
|
||||
context_widget = QtWidgets.QWidget(context_splitter)
|
||||
|
||||
context_top_widget = QtWidgets.QWidget(context_widget)
|
||||
projects_combobox = ProjectsCombobox(
|
||||
controller,
|
||||
context_top_widget,
|
||||
handle_expected_selection=True
|
||||
)
|
||||
projects_combobox.set_select_item_visible(True)
|
||||
projects_combobox.set_libraries_separator_visible(True)
|
||||
projects_combobox.set_standard_filter_enabled(
|
||||
controller.is_standard_projects_filter_enabled()
|
||||
)
|
||||
|
||||
go_to_current_btn = GoToCurrentButton(context_top_widget)
|
||||
refresh_btn = RefreshButton(context_top_widget)
|
||||
|
||||
context_top_layout = QtWidgets.QHBoxLayout(context_top_widget)
|
||||
context_top_layout.setContentsMargins(0, 0, 0, 0,)
|
||||
context_top_layout.addWidget(projects_combobox, 1)
|
||||
context_top_layout.addWidget(go_to_current_btn, 0)
|
||||
context_top_layout.addWidget(refresh_btn, 0)
|
||||
|
||||
folders_filter_input = PlaceholderLineEdit(context_widget)
|
||||
folders_filter_input.setPlaceholderText("Folder name filter...")
|
||||
|
||||
folders_widget = LoaderFoldersWidget(controller, context_widget)
|
||||
|
||||
product_types_widget = ProductTypesView(controller, context_splitter)
|
||||
|
||||
context_layout = QtWidgets.QVBoxLayout(context_widget)
|
||||
context_layout.setContentsMargins(0, 0, 0, 0)
|
||||
context_layout.addWidget(context_top_widget, 0)
|
||||
context_layout.addWidget(folders_filter_input, 0)
|
||||
context_layout.addWidget(folders_widget, 1)
|
||||
|
||||
context_splitter.addWidget(context_widget)
|
||||
context_splitter.addWidget(product_types_widget)
|
||||
context_splitter.setStretchFactor(0, 65)
|
||||
context_splitter.setStretchFactor(1, 35)
|
||||
|
||||
# Product + version selection item
|
||||
products_wrap_widget = QtWidgets.QWidget(main_splitter)
|
||||
|
||||
products_inputs_widget = QtWidgets.QWidget(products_wrap_widget)
|
||||
|
||||
products_filter_input = PlaceholderLineEdit(products_inputs_widget)
|
||||
products_filter_input.setPlaceholderText("Product name filter...")
|
||||
product_group_checkbox = QtWidgets.QCheckBox(
|
||||
"Enable grouping", products_inputs_widget)
|
||||
product_group_checkbox.setChecked(True)
|
||||
|
||||
products_widget = ProductsWidget(controller, products_wrap_widget)
|
||||
|
||||
products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget)
|
||||
products_inputs_layout.setContentsMargins(0, 0, 0, 0)
|
||||
products_inputs_layout.addWidget(products_filter_input, 1)
|
||||
products_inputs_layout.addWidget(product_group_checkbox, 0)
|
||||
|
||||
products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget)
|
||||
products_wrap_layout.setContentsMargins(0, 0, 0, 0)
|
||||
products_wrap_layout.addWidget(products_inputs_widget, 0)
|
||||
products_wrap_layout.addWidget(products_widget, 1)
|
||||
|
||||
right_panel_splitter = QtWidgets.QSplitter(main_splitter)
|
||||
right_panel_splitter.setOrientation(QtCore.Qt.Vertical)
|
||||
|
||||
thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter)
|
||||
thumbnails_widget.set_use_checkboard(False)
|
||||
|
||||
info_widget = InfoWidget(controller, right_panel_splitter)
|
||||
|
||||
repre_widget = RepresentationsWidget(controller, right_panel_splitter)
|
||||
|
||||
right_panel_splitter.addWidget(thumbnails_widget)
|
||||
right_panel_splitter.addWidget(info_widget)
|
||||
right_panel_splitter.addWidget(repre_widget)
|
||||
|
||||
right_panel_splitter.setStretchFactor(0, 1)
|
||||
right_panel_splitter.setStretchFactor(1, 1)
|
||||
right_panel_splitter.setStretchFactor(2, 2)
|
||||
|
||||
main_splitter.addWidget(context_splitter)
|
||||
main_splitter.addWidget(products_wrap_widget)
|
||||
main_splitter.addWidget(right_panel_splitter)
|
||||
|
||||
main_splitter.setStretchFactor(0, 4)
|
||||
main_splitter.setStretchFactor(1, 6)
|
||||
main_splitter.setStretchFactor(2, 1)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.addWidget(main_splitter)
|
||||
|
||||
show_timer = QtCore.QTimer()
|
||||
show_timer.setInterval(1)
|
||||
|
||||
show_timer.timeout.connect(self._on_show_timer)
|
||||
|
||||
projects_combobox.refreshed.connect(self._on_projects_refresh)
|
||||
folders_widget.refreshed.connect(self._on_folders_refresh)
|
||||
products_widget.refreshed.connect(self._on_products_refresh)
|
||||
folders_filter_input.textChanged.connect(
|
||||
self._on_folder_filter_change
|
||||
)
|
||||
product_types_widget.filter_changed.connect(
|
||||
self._on_product_type_filter_change
|
||||
)
|
||||
products_filter_input.textChanged.connect(
|
||||
self._on_product_filter_change
|
||||
)
|
||||
product_group_checkbox.stateChanged.connect(
|
||||
self._on_product_group_change
|
||||
)
|
||||
products_widget.merged_products_selection_changed.connect(
|
||||
self._on_merged_products_selection_change
|
||||
)
|
||||
products_widget.selection_changed.connect(
|
||||
self._on_products_selection_change
|
||||
)
|
||||
go_to_current_btn.clicked.connect(
|
||||
self._on_go_to_current_context_click
|
||||
)
|
||||
refresh_btn.clicked.connect(
|
||||
self._on_refresh_click
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"load.finished",
|
||||
self._on_load_finished,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.project.changed",
|
||||
self._on_project_selection_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.folders.changed",
|
||||
self._on_folders_selection_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"selection.versions.changed",
|
||||
self._on_versions_selection_changed,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"controller.reset.started",
|
||||
self._on_controller_reset_start,
|
||||
)
|
||||
controller.register_event_callback(
|
||||
"controller.reset.finished",
|
||||
self._on_controller_reset_finish,
|
||||
)
|
||||
|
||||
self._group_dialog = ProductGroupDialog(controller, self)
|
||||
|
||||
self._main_splitter = main_splitter
|
||||
|
||||
self._go_to_current_btn = go_to_current_btn
|
||||
self._refresh_btn = refresh_btn
|
||||
self._projects_combobox = projects_combobox
|
||||
|
||||
self._folders_filter_input = folders_filter_input
|
||||
self._folders_widget = folders_widget
|
||||
|
||||
self._product_types_widget = product_types_widget
|
||||
|
||||
self._products_filter_input = products_filter_input
|
||||
self._product_group_checkbox = product_group_checkbox
|
||||
self._products_widget = products_widget
|
||||
|
||||
self._right_panel_splitter = right_panel_splitter
|
||||
self._thumbnails_widget = thumbnails_widget
|
||||
self._info_widget = info_widget
|
||||
self._repre_widget = repre_widget
|
||||
|
||||
self._controller = controller
|
||||
self._refresh_handler = RefreshHandler()
|
||||
self._first_show = True
|
||||
self._reset_on_show = True
|
||||
self._show_counter = 0
|
||||
self._show_timer = show_timer
|
||||
self._selected_project_name = None
|
||||
self._selected_folder_ids = set()
|
||||
self._selected_version_ids = set()
|
||||
|
||||
self._products_widget.set_enable_grouping(
|
||||
self._product_group_checkbox.isChecked()
|
||||
)
|
||||
|
||||
def refresh(self):
|
||||
self._controller.reset()
|
||||
|
||||
def showEvent(self, event):
|
||||
super(LoaderWindow, self).showEvent(event)
|
||||
|
||||
if self._first_show:
|
||||
self._on_first_show()
|
||||
|
||||
self._show_timer.start()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
modifiers = event.modifiers()
|
||||
ctrl_pressed = QtCore.Qt.ControlModifier & modifiers
|
||||
|
||||
# Grouping products on pressing Ctrl + G
|
||||
if (
|
||||
ctrl_pressed
|
||||
and event.key() == QtCore.Qt.Key_G
|
||||
and not event.isAutoRepeat()
|
||||
):
|
||||
self._show_group_dialog()
|
||||
event.setAccepted(True)
|
||||
return
|
||||
|
||||
super(LoaderWindow, self).keyPressEvent(event)
|
||||
|
||||
def _on_first_show(self):
|
||||
self._first_show = False
|
||||
# width, height = 1800, 900
|
||||
width, height = 1500, 750
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
mid_width = int(width / 1.8)
|
||||
sides_width = int((width - mid_width) * 0.5)
|
||||
self._main_splitter.setSizes(
|
||||
[sides_width, mid_width, sides_width]
|
||||
)
|
||||
|
||||
thumbnail_height = int(height / 3.6)
|
||||
info_height = int((height - thumbnail_height) * 0.5)
|
||||
self._right_panel_splitter.setSizes(
|
||||
[thumbnail_height, info_height, info_height]
|
||||
)
|
||||
self.setStyleSheet(load_stylesheet())
|
||||
center_window(self)
|
||||
|
||||
def _on_show_timer(self):
|
||||
if self._show_counter < 2:
|
||||
self._show_counter += 1
|
||||
return
|
||||
|
||||
self._show_counter = 0
|
||||
self._show_timer.stop()
|
||||
|
||||
if self._reset_on_show:
|
||||
self._reset_on_show = False
|
||||
self._controller.reset()
|
||||
|
||||
def _show_group_dialog(self):
|
||||
project_name = self._projects_combobox.get_selected_project_name()
|
||||
if not project_name:
|
||||
return
|
||||
|
||||
product_ids = {
|
||||
i["product_id"]
|
||||
for i in self._products_widget.get_selected_version_info()
|
||||
}
|
||||
if not product_ids:
|
||||
return
|
||||
|
||||
self._group_dialog.set_product_ids(project_name, product_ids)
|
||||
self._group_dialog.show()
|
||||
|
||||
def _on_folder_filter_change(self, text):
|
||||
self._folders_widget.set_name_filter(text)
|
||||
|
||||
def _on_product_group_change(self):
|
||||
self._products_widget.set_enable_grouping(
|
||||
self._product_group_checkbox.isChecked()
|
||||
)
|
||||
|
||||
def _on_product_filter_change(self, text):
|
||||
self._products_widget.set_name_filter(text)
|
||||
|
||||
def _on_product_type_filter_change(self):
|
||||
self._products_widget.set_product_type_filter(
|
||||
self._product_types_widget.get_filter_info()
|
||||
)
|
||||
|
||||
def _on_merged_products_selection_change(self):
|
||||
items = self._products_widget.get_selected_merged_products()
|
||||
self._folders_widget.set_merged_products_selection(items)
|
||||
|
||||
def _on_products_selection_change(self):
|
||||
items = self._products_widget.get_selected_version_info()
|
||||
self._info_widget.set_selected_version_info(
|
||||
self._projects_combobox.get_selected_project_name(),
|
||||
items
|
||||
)
|
||||
|
||||
def _on_go_to_current_context_click(self):
|
||||
context = self._controller.get_current_context()
|
||||
self._controller.set_expected_selection(
|
||||
context["project_name"],
|
||||
context["folder_id"],
|
||||
)
|
||||
|
||||
def _on_refresh_click(self):
|
||||
self._controller.reset()
|
||||
|
||||
def _on_controller_reset_start(self):
|
||||
self._refresh_handler.reset()
|
||||
|
||||
def _on_controller_reset_finish(self):
|
||||
context = self._controller.get_current_context()
|
||||
project_name = context["project_name"]
|
||||
self._go_to_current_btn.setVisible(bool(project_name))
|
||||
self._projects_combobox.set_current_context_project(project_name)
|
||||
if not self._refresh_handler.project_refreshed:
|
||||
self._projects_combobox.refresh()
|
||||
|
||||
def _on_load_finished(self, event):
|
||||
error_info = event["error_info"]
|
||||
if not error_info:
|
||||
return
|
||||
|
||||
box = LoadErrorMessageBox(error_info, self)
|
||||
box.show()
|
||||
|
||||
def _on_project_selection_changed(self, event):
|
||||
self._selected_project_name = event["project_name"]
|
||||
|
||||
def _on_folders_selection_changed(self, event):
|
||||
self._selected_folder_ids = set(event["folder_ids"])
|
||||
self._update_thumbnails()
|
||||
|
||||
def _on_versions_selection_changed(self, event):
|
||||
self._selected_version_ids = set(event["version_ids"])
|
||||
self._update_thumbnails()
|
||||
|
||||
def _update_thumbnails(self):
|
||||
project_name = self._selected_project_name
|
||||
thumbnail_ids = set()
|
||||
if self._selected_version_ids:
|
||||
thumbnail_id_by_entity_id = (
|
||||
self._controller.get_version_thumbnail_ids(
|
||||
project_name,
|
||||
self._selected_version_ids
|
||||
)
|
||||
)
|
||||
thumbnail_ids = set(thumbnail_id_by_entity_id.values())
|
||||
elif self._selected_folder_ids:
|
||||
thumbnail_id_by_entity_id = (
|
||||
self._controller.get_folder_thumbnail_ids(
|
||||
project_name,
|
||||
self._selected_folder_ids
|
||||
)
|
||||
)
|
||||
thumbnail_ids = set(thumbnail_id_by_entity_id.values())
|
||||
|
||||
thumbnail_ids.discard(None)
|
||||
|
||||
if not thumbnail_ids:
|
||||
self._thumbnails_widget.set_current_thumbnails(None)
|
||||
return
|
||||
|
||||
thumbnail_paths = set()
|
||||
for thumbnail_id in thumbnail_ids:
|
||||
thumbnail_path = self._controller.get_thumbnail_path(
|
||||
project_name, thumbnail_id)
|
||||
thumbnail_paths.add(thumbnail_path)
|
||||
thumbnail_paths.discard(None)
|
||||
self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths)
|
||||
|
||||
def _on_projects_refresh(self):
|
||||
self._refresh_handler.set_project_refreshed()
|
||||
if not self._refresh_handler.folders_refreshed:
|
||||
self._folders_widget.refresh()
|
||||
|
||||
def _on_folders_refresh(self):
|
||||
self._refresh_handler.set_folders_refreshed()
|
||||
if not self._refresh_handler.products_refreshed:
|
||||
self._products_widget.refresh()
|
||||
|
||||
def _on_products_refresh(self):
|
||||
self._refresh_handler.set_products_refreshed()
|
||||
6
openpype/tools/ayon_sceneinventory/__init__.py
Normal file
6
openpype/tools/ayon_sceneinventory/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .control import SceneInventoryController
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SceneInventoryController",
|
||||
)
|
||||
134
openpype/tools/ayon_sceneinventory/control.py
Normal file
134
openpype/tools/ayon_sceneinventory/control.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import ayon_api
|
||||
|
||||
from openpype.lib.events import QueuedEventSystem
|
||||
from openpype.host import ILoadHost
|
||||
from openpype.pipeline import (
|
||||
registered_host,
|
||||
get_current_context,
|
||||
)
|
||||
from openpype.tools.ayon_utils.models import HierarchyModel
|
||||
|
||||
from .models import SiteSyncModel
|
||||
|
||||
|
||||
class SceneInventoryController:
|
||||
"""This is a temporary controller for AYON.
|
||||
|
||||
Goal of this temporary controller is to provide a way to get current
|
||||
context instead of using 'AvalonMongoDB' object (or 'legacy_io').
|
||||
|
||||
Also provides (hopefully) cleaner api for site sync.
|
||||
"""
|
||||
|
||||
def __init__(self, host=None):
|
||||
if host is None:
|
||||
host = registered_host()
|
||||
self._host = host
|
||||
self._current_context = None
|
||||
self._current_project = None
|
||||
self._current_folder_id = None
|
||||
self._current_folder_set = False
|
||||
|
||||
self._site_sync_model = SiteSyncModel(self)
|
||||
# Switch dialog requirements
|
||||
self._hierarchy_model = HierarchyModel(self)
|
||||
self._event_system = self._create_event_system()
|
||||
|
||||
def emit_event(self, topic, data=None, source=None):
|
||||
if data is None:
|
||||
data = {}
|
||||
self._event_system.emit(topic, data, source)
|
||||
|
||||
def register_event_callback(self, topic, callback):
|
||||
self._event_system.add_callback(topic, callback)
|
||||
|
||||
def reset(self):
|
||||
self._current_context = None
|
||||
self._current_project = None
|
||||
self._current_folder_id = None
|
||||
self._current_folder_set = False
|
||||
|
||||
self._site_sync_model.reset()
|
||||
self._hierarchy_model.reset()
|
||||
|
||||
def get_current_context(self):
|
||||
if self._current_context is None:
|
||||
if hasattr(self._host, "get_current_context"):
|
||||
self._current_context = self._host.get_current_context()
|
||||
else:
|
||||
self._current_context = get_current_context()
|
||||
return self._current_context
|
||||
|
||||
def get_current_project_name(self):
|
||||
if self._current_project is None:
|
||||
self._current_project = self.get_current_context()["project_name"]
|
||||
return self._current_project
|
||||
|
||||
def get_current_folder_id(self):
|
||||
if self._current_folder_set:
|
||||
return self._current_folder_id
|
||||
|
||||
context = self.get_current_context()
|
||||
project_name = context["project_name"]
|
||||
folder_path = context.get("folder_path")
|
||||
folder_name = context.get("asset_name")
|
||||
folder_id = None
|
||||
if folder_path:
|
||||
folder = ayon_api.get_folder_by_path(project_name, folder_path)
|
||||
if folder:
|
||||
folder_id = folder["id"]
|
||||
elif folder_name:
|
||||
for folder in ayon_api.get_folders(
|
||||
project_name, folder_names=[folder_name]
|
||||
):
|
||||
folder_id = folder["id"]
|
||||
break
|
||||
|
||||
self._current_folder_id = folder_id
|
||||
self._current_folder_set = True
|
||||
return self._current_folder_id
|
||||
|
||||
def get_containers(self):
|
||||
host = self._host
|
||||
if isinstance(host, ILoadHost):
|
||||
return host.get_containers()
|
||||
elif hasattr(host, "ls"):
|
||||
return host.ls()
|
||||
return []
|
||||
|
||||
# Site Sync methods
|
||||
def is_sync_server_enabled(self):
|
||||
return self._site_sync_model.is_sync_server_enabled()
|
||||
|
||||
def get_sites_information(self):
|
||||
return self._site_sync_model.get_sites_information()
|
||||
|
||||
def get_site_provider_icons(self):
|
||||
return self._site_sync_model.get_site_provider_icons()
|
||||
|
||||
def get_representations_site_progress(self, representation_ids):
|
||||
return self._site_sync_model.get_representations_site_progress(
|
||||
representation_ids
|
||||
)
|
||||
|
||||
def resync_representations(self, representation_ids, site_type):
|
||||
return self._site_sync_model.resync_representations(
|
||||
representation_ids, site_type
|
||||
)
|
||||
|
||||
# Switch dialog methods
|
||||
def get_folder_items(self, project_name, sender=None):
|
||||
return self._hierarchy_model.get_folder_items(project_name, sender)
|
||||
|
||||
def get_folder_label(self, folder_id):
|
||||
if not folder_id:
|
||||
return None
|
||||
project_name = self.get_current_project_name()
|
||||
folder_item = self._hierarchy_model.get_folder_item(
|
||||
project_name, folder_id)
|
||||
if folder_item is None:
|
||||
return None
|
||||
return folder_item.label
|
||||
|
||||
def _create_event_system(self):
|
||||
return QueuedEventSystem()
|
||||
622
openpype/tools/ayon_sceneinventory/model.py
Normal file
622
openpype/tools/ayon_sceneinventory/model.py
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
import collections
|
||||
import re
|
||||
import logging
|
||||
import uuid
|
||||
import copy
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from qtpy import QtCore, QtGui
|
||||
import qtawesome
|
||||
|
||||
from openpype.client import (
|
||||
get_assets,
|
||||
get_subsets,
|
||||
get_versions,
|
||||
get_last_version_by_subset_id,
|
||||
get_representations,
|
||||
)
|
||||
from openpype.pipeline import (
|
||||
get_current_project_name,
|
||||
schema,
|
||||
HeroVersionType,
|
||||
)
|
||||
from openpype.style import get_default_entity_icon_color
|
||||
from openpype.tools.utils.models import TreeModel, Item
|
||||
|
||||
|
||||
def walk_hierarchy(node):
|
||||
"""Recursively yield group node."""
|
||||
for child in node.children():
|
||||
if child.get("isGroupNode"):
|
||||
yield child
|
||||
|
||||
for _child in walk_hierarchy(child):
|
||||
yield _child
|
||||
|
||||
|
||||
class InventoryModel(TreeModel):
|
||||
"""The model for the inventory"""
|
||||
|
||||
Columns = [
|
||||
"Name",
|
||||
"version",
|
||||
"count",
|
||||
"family",
|
||||
"group",
|
||||
"loader",
|
||||
"objectName",
|
||||
"active_site",
|
||||
"remote_site",
|
||||
]
|
||||
active_site_col = Columns.index("active_site")
|
||||
remote_site_col = Columns.index("remote_site")
|
||||
|
||||
OUTDATED_COLOR = QtGui.QColor(235, 30, 30)
|
||||
CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30)
|
||||
GRAYOUT_COLOR = QtGui.QColor(160, 160, 160)
|
||||
|
||||
UniqueRole = QtCore.Qt.UserRole + 2 # unique label role
|
||||
|
||||
def __init__(self, controller, parent=None):
|
||||
super(InventoryModel, self).__init__(parent)
|
||||
self.log = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
self._controller = controller
|
||||
|
||||
self._hierarchy_view = False
|
||||
|
||||
self._default_icon_color = get_default_entity_icon_color()
|
||||
|
||||
site_icons = self._controller.get_site_provider_icons()
|
||||
|
||||
self._site_icons = {
|
||||
provider: QtGui.QIcon(icon_path)
|
||||
for provider, icon_path in site_icons.items()
|
||||
}
|
||||
|
||||
def outdated(self, item):
|
||||
value = item.get("version")
|
||||
if isinstance(value, HeroVersionType):
|
||||
return False
|
||||
|
||||
if item.get("version") == item.get("highest_version"):
|
||||
return False
|
||||
return True
|
||||
|
||||
def data(self, index, role):
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
item = index.internalPointer()
|
||||
|
||||
if role == QtCore.Qt.FontRole:
|
||||
# Make top-level entries bold
|
||||
if item.get("isGroupNode") or item.get("isNotSet"): # group-item
|
||||
font = QtGui.QFont()
|
||||
font.setBold(True)
|
||||
return font
|
||||
|
||||
if role == QtCore.Qt.ForegroundRole:
|
||||
# Set the text color to the OUTDATED_COLOR when the
|
||||
# collected version is not the same as the highest version
|
||||
key = self.Columns[index.column()]
|
||||
if key == "version": # version
|
||||
if item.get("isGroupNode"): # group-item
|
||||
if self.outdated(item):
|
||||
return self.OUTDATED_COLOR
|
||||
|
||||
if self._hierarchy_view:
|
||||
# If current group is not outdated, check if any
|
||||
# outdated children.
|
||||
for _node in walk_hierarchy(item):
|
||||
if self.outdated(_node):
|
||||
return self.CHILD_OUTDATED_COLOR
|
||||
else:
|
||||
|
||||
if self._hierarchy_view:
|
||||
# Although this is not a group item, we still need
|
||||
# to distinguish which one contain outdated child.
|
||||
for _node in walk_hierarchy(item):
|
||||
if self.outdated(_node):
|
||||
return self.CHILD_OUTDATED_COLOR.darker(150)
|
||||
|
||||
return self.GRAYOUT_COLOR
|
||||
|
||||
if key == "Name" and not item.get("isGroupNode"):
|
||||
return self.GRAYOUT_COLOR
|
||||
|
||||
# Add icons
|
||||
if role == QtCore.Qt.DecorationRole:
|
||||
if index.column() == 0:
|
||||
# Override color
|
||||
color = item.get("color", self._default_icon_color)
|
||||
if item.get("isGroupNode"): # group-item
|
||||
return qtawesome.icon("fa.folder", color=color)
|
||||
if item.get("isNotSet"):
|
||||
return qtawesome.icon("fa.exclamation-circle", color=color)
|
||||
|
||||
return qtawesome.icon("fa.file-o", color=color)
|
||||
|
||||
if index.column() == 3:
|
||||
# Family icon
|
||||
return item.get("familyIcon", None)
|
||||
|
||||
column_name = self.Columns[index.column()]
|
||||
|
||||
if column_name == "group" and item.get("group"):
|
||||
return qtawesome.icon("fa.object-group",
|
||||
color=get_default_entity_icon_color())
|
||||
|
||||
if item.get("isGroupNode"):
|
||||
if column_name == "active_site":
|
||||
provider = item.get("active_site_provider")
|
||||
return self._site_icons.get(provider)
|
||||
|
||||
if column_name == "remote_site":
|
||||
provider = item.get("remote_site_provider")
|
||||
return self._site_icons.get(provider)
|
||||
|
||||
if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"):
|
||||
column_name = self.Columns[index.column()]
|
||||
progress = None
|
||||
if column_name == "active_site":
|
||||
progress = item.get("active_site_progress", 0)
|
||||
elif column_name == "remote_site":
|
||||
progress = item.get("remote_site_progress", 0)
|
||||
if progress is not None:
|
||||
return "{}%".format(max(progress, 0) * 100)
|
||||
|
||||
if role == self.UniqueRole:
|
||||
return item["representation"] + item.get("objectName", "<none>")
|
||||
|
||||
return super(InventoryModel, self).data(index, role)
|
||||
|
||||
def set_hierarchy_view(self, state):
|
||||
"""Set whether to display subsets in hierarchy view."""
|
||||
state = bool(state)
|
||||
|
||||
if state != self._hierarchy_view:
|
||||
self._hierarchy_view = state
|
||||
|
||||
def refresh(self, selected=None, containers=None):
|
||||
"""Refresh the model"""
|
||||
|
||||
# for debugging or testing, injecting items from outside
|
||||
if containers is None:
|
||||
containers = self._controller.get_containers()
|
||||
|
||||
self.clear()
|
||||
if not selected or not self._hierarchy_view:
|
||||
self._add_containers(containers)
|
||||
return
|
||||
|
||||
# Filter by cherry-picked items
|
||||
self._add_containers((
|
||||
container
|
||||
for container in containers
|
||||
if container["objectName"] in selected
|
||||
))
|
||||
|
||||
def _add_containers(self, containers, parent=None):
|
||||
"""Add the items to the model.
|
||||
|
||||
The items should be formatted similar to `api.ls()` returns, an item
|
||||
is then represented as:
|
||||
{"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma,
|
||||
full/filename/of/loaded/filename_v001.ma],
|
||||
"nodetype" : "reference",
|
||||
"node": "referenceNode1"}
|
||||
|
||||
Note: When performing an additional call to `add_items` it will *not*
|
||||
group the new items with previously existing item groups of the
|
||||
same type.
|
||||
|
||||
Args:
|
||||
containers (generator): Container items.
|
||||
parent (Item, optional): Set this item as parent for the added
|
||||
items when provided. Defaults to the root of the model.
|
||||
|
||||
Returns:
|
||||
node.Item: root node which has children added based on the data
|
||||
"""
|
||||
|
||||
project_name = get_current_project_name()
|
||||
|
||||
self.beginResetModel()
|
||||
|
||||
# Group by representation
|
||||
grouped = defaultdict(lambda: {"containers": list()})
|
||||
for container in containers:
|
||||
repre_id = container["representation"]
|
||||
grouped[repre_id]["containers"].append(container)
|
||||
|
||||
(
|
||||
repres_by_id,
|
||||
versions_by_id,
|
||||
products_by_id,
|
||||
folders_by_id,
|
||||
) = self._query_entities(project_name, set(grouped.keys()))
|
||||
# Add to model
|
||||
not_found = defaultdict(list)
|
||||
not_found_ids = []
|
||||
for repre_id, group_dict in sorted(grouped.items()):
|
||||
group_containers = group_dict["containers"]
|
||||
representation = repres_by_id.get(repre_id)
|
||||
if not representation:
|
||||
not_found["representation"].extend(group_containers)
|
||||
not_found_ids.append(repre_id)
|
||||
continue
|
||||
|
||||
version = versions_by_id.get(representation["parent"])
|
||||
if not version:
|
||||
not_found["version"].extend(group_containers)
|
||||
not_found_ids.append(repre_id)
|
||||
continue
|
||||
|
||||
product = products_by_id.get(version["parent"])
|
||||
if not product:
|
||||
not_found["product"].extend(group_containers)
|
||||
not_found_ids.append(repre_id)
|
||||
continue
|
||||
|
||||
folder = folders_by_id.get(product["parent"])
|
||||
if not folder:
|
||||
not_found["folder"].extend(group_containers)
|
||||
not_found_ids.append(repre_id)
|
||||
continue
|
||||
|
||||
group_dict.update({
|
||||
"representation": representation,
|
||||
"version": version,
|
||||
"subset": product,
|
||||
"asset": folder
|
||||
})
|
||||
|
||||
for _repre_id in not_found_ids:
|
||||
grouped.pop(_repre_id)
|
||||
|
||||
for where, group_containers in not_found.items():
|
||||
# create the group header
|
||||
group_node = Item()
|
||||
name = "< NOT FOUND - {} >".format(where)
|
||||
group_node["Name"] = name
|
||||
group_node["representation"] = name
|
||||
group_node["count"] = len(group_containers)
|
||||
group_node["isGroupNode"] = False
|
||||
group_node["isNotSet"] = True
|
||||
|
||||
self.add_child(group_node, parent=parent)
|
||||
|
||||
for container in group_containers:
|
||||
item_node = Item()
|
||||
item_node.update(container)
|
||||
item_node["Name"] = container.get("objectName", "NO NAME")
|
||||
item_node["isNotFound"] = True
|
||||
self.add_child(item_node, parent=group_node)
|
||||
|
||||
# TODO Use product icons
|
||||
family_icon = qtawesome.icon(
|
||||
"fa.folder", color="#0091B2"
|
||||
)
|
||||
# Prepare site sync specific data
|
||||
progress_by_id = self._controller.get_representations_site_progress(
|
||||
set(grouped.keys())
|
||||
)
|
||||
sites_info = self._controller.get_sites_information()
|
||||
|
||||
for repre_id, group_dict in sorted(grouped.items()):
|
||||
group_containers = group_dict["containers"]
|
||||
representation = group_dict["representation"]
|
||||
version = group_dict["version"]
|
||||
subset = group_dict["subset"]
|
||||
asset = group_dict["asset"]
|
||||
|
||||
# Get the primary family
|
||||
maj_version, _ = schema.get_schema_version(subset["schema"])
|
||||
if maj_version < 3:
|
||||
src_doc = version
|
||||
else:
|
||||
src_doc = subset
|
||||
|
||||
prim_family = src_doc["data"].get("family")
|
||||
if not prim_family:
|
||||
families = src_doc["data"].get("families")
|
||||
if families:
|
||||
prim_family = families[0]
|
||||
|
||||
# Store the highest available version so the model can know
|
||||
# whether current version is currently up-to-date.
|
||||
highest_version = get_last_version_by_subset_id(
|
||||
project_name, version["parent"]
|
||||
)
|
||||
|
||||
# create the group header
|
||||
group_node = Item()
|
||||
group_node["Name"] = "{}_{}: ({})".format(
|
||||
asset["name"], subset["name"], representation["name"]
|
||||
)
|
||||
group_node["representation"] = repre_id
|
||||
group_node["version"] = version["name"]
|
||||
group_node["highest_version"] = highest_version["name"]
|
||||
group_node["family"] = prim_family or ""
|
||||
group_node["familyIcon"] = family_icon
|
||||
group_node["count"] = len(group_containers)
|
||||
group_node["isGroupNode"] = True
|
||||
group_node["group"] = subset["data"].get("subsetGroup")
|
||||
|
||||
# Site sync specific data
|
||||
progress = progress_by_id[repre_id]
|
||||
group_node.update(sites_info)
|
||||
group_node["active_site_progress"] = progress["active_site"]
|
||||
group_node["remote_site_progress"] = progress["remote_site"]
|
||||
|
||||
self.add_child(group_node, parent=parent)
|
||||
|
||||
for container in group_containers:
|
||||
item_node = Item()
|
||||
item_node.update(container)
|
||||
|
||||
# store the current version on the item
|
||||
item_node["version"] = version["name"]
|
||||
|
||||
# Remapping namespace to item name.
|
||||
# Noted that the name key is capital "N", by doing this, we
|
||||
# can view namespace in GUI without changing container data.
|
||||
item_node["Name"] = container["namespace"]
|
||||
|
||||
self.add_child(item_node, parent=group_node)
|
||||
|
||||
self.endResetModel()
|
||||
|
||||
return self._root_item
|
||||
|
||||
def _query_entities(self, project_name, repre_ids):
|
||||
"""Query entities for representations from containers.
|
||||
|
||||
Returns:
|
||||
tuple[dict, dict, dict, dict]: Representation, version, product
|
||||
and folder documents by id.
|
||||
"""
|
||||
|
||||
repres_by_id = {}
|
||||
versions_by_id = {}
|
||||
products_by_id = {}
|
||||
folders_by_id = {}
|
||||
output = (
|
||||
repres_by_id,
|
||||
versions_by_id,
|
||||
products_by_id,
|
||||
folders_by_id,
|
||||
)
|
||||
|
||||
filtered_repre_ids = set()
|
||||
for repre_id in repre_ids:
|
||||
# Filter out invalid representation ids
|
||||
# NOTE: This is added because scenes from OpenPype did contain
|
||||
# ObjectId from mongo.
|
||||
try:
|
||||
uuid.UUID(repre_id)
|
||||
filtered_repre_ids.add(repre_id)
|
||||
except ValueError:
|
||||
continue
|
||||
if not filtered_repre_ids:
|
||||
return output
|
||||
|
||||
repre_docs = get_representations(project_name, repre_ids)
|
||||
repres_by_id.update({
|
||||
repre_doc["_id"]: repre_doc
|
||||
for repre_doc in repre_docs
|
||||
})
|
||||
version_ids = {
|
||||
repre_doc["parent"] for repre_doc in repres_by_id.values()
|
||||
}
|
||||
if not version_ids:
|
||||
return output
|
||||
|
||||
version_docs = get_versions(project_name, version_ids, hero=True)
|
||||
versions_by_id.update({
|
||||
version_doc["_id"]: version_doc
|
||||
for version_doc in version_docs
|
||||
})
|
||||
hero_versions_by_subversion_id = collections.defaultdict(list)
|
||||
for version_doc in versions_by_id.values():
|
||||
if version_doc["type"] != "hero_version":
|
||||
continue
|
||||
subversion = version_doc["version_id"]
|
||||
hero_versions_by_subversion_id[subversion].append(version_doc)
|
||||
|
||||
if hero_versions_by_subversion_id:
|
||||
subversion_ids = set(
|
||||
hero_versions_by_subversion_id.keys()
|
||||
)
|
||||
subversion_docs = get_versions(project_name, subversion_ids)
|
||||
for subversion_doc in subversion_docs:
|
||||
subversion_id = subversion_doc["_id"]
|
||||
subversion_ids.discard(subversion_id)
|
||||
h_version_docs = hero_versions_by_subversion_id[subversion_id]
|
||||
for version_doc in h_version_docs:
|
||||
version_doc["name"] = HeroVersionType(
|
||||
subversion_doc["name"]
|
||||
)
|
||||
version_doc["data"] = copy.deepcopy(
|
||||
subversion_doc["data"]
|
||||
)
|
||||
|
||||
for subversion_id in subversion_ids:
|
||||
h_version_docs = hero_versions_by_subversion_id[subversion_id]
|
||||
for version_doc in h_version_docs:
|
||||
versions_by_id.pop(version_doc["_id"])
|
||||
|
||||
product_ids = {
|
||||
version_doc["parent"]
|
||||
for version_doc in versions_by_id.values()
|
||||
}
|
||||
if not product_ids:
|
||||
return output
|
||||
product_docs = get_subsets(project_name, product_ids)
|
||||
products_by_id.update({
|
||||
product_doc["_id"]: product_doc
|
||||
for product_doc in product_docs
|
||||
})
|
||||
folder_ids = {
|
||||
product_doc["parent"]
|
||||
for product_doc in products_by_id.values()
|
||||
}
|
||||
if not folder_ids:
|
||||
return output
|
||||
|
||||
folder_docs = get_assets(project_name, folder_ids)
|
||||
folders_by_id.update({
|
||||
folder_doc["_id"]: folder_doc
|
||||
for folder_doc in folder_docs
|
||||
})
|
||||
return output
|
||||
|
||||
|
||||
class FilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
"""Filter model to where key column's value is in the filtered tags"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FilterProxyModel, self).__init__(*args, **kwargs)
|
||||
self._filter_outdated = False
|
||||
self._hierarchy_view = False
|
||||
|
||||
def filterAcceptsRow(self, row, parent):
|
||||
model = self.sourceModel()
|
||||
source_index = model.index(row, self.filterKeyColumn(), parent)
|
||||
|
||||
# Always allow bottom entries (individual containers), since their
|
||||
# parent group hidden if it wouldn't have been validated.
|
||||
rows = model.rowCount(source_index)
|
||||
if not rows:
|
||||
return True
|
||||
|
||||
# Filter by regex
|
||||
if hasattr(self, "filterRegExp"):
|
||||
regex = self.filterRegExp()
|
||||
else:
|
||||
regex = self.filterRegularExpression()
|
||||
pattern = regex.pattern()
|
||||
if pattern:
|
||||
pattern = re.escape(pattern)
|
||||
|
||||
if not self._matches(row, parent, pattern):
|
||||
return False
|
||||
|
||||
if self._filter_outdated:
|
||||
# When filtering to outdated we filter the up to date entries
|
||||
# thus we "allow" them when they are outdated
|
||||
if not self._is_outdated(row, parent):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def set_filter_outdated(self, state):
|
||||
"""Set whether to show the outdated entries only."""
|
||||
state = bool(state)
|
||||
|
||||
if state != self._filter_outdated:
|
||||
self._filter_outdated = bool(state)
|
||||
self.invalidateFilter()
|
||||
|
||||
def set_hierarchy_view(self, state):
|
||||
state = bool(state)
|
||||
|
||||
if state != self._hierarchy_view:
|
||||
self._hierarchy_view = state
|
||||
|
||||
def _is_outdated(self, row, parent):
|
||||
"""Return whether row is outdated.
|
||||
|
||||
A row is considered outdated if it has "version" and "highest_version"
|
||||
data and in the internal data structure, and they are not of an
|
||||
equal value.
|
||||
|
||||
"""
|
||||
def outdated(node):
|
||||
version = node.get("version", None)
|
||||
highest = node.get("highest_version", None)
|
||||
|
||||
# Always allow indices that have no version data at all
|
||||
if version is None and highest is None:
|
||||
return True
|
||||
|
||||
# If either a version or highest is present but not the other
|
||||
# consider the item invalid.
|
||||
if not self._hierarchy_view:
|
||||
# Skip this check if in hierarchy view, or the child item
|
||||
# node will be hidden even it's actually outdated.
|
||||
if version is None or highest is None:
|
||||
return False
|
||||
return version != highest
|
||||
|
||||
index = self.sourceModel().index(row, self.filterKeyColumn(), parent)
|
||||
|
||||
# The scene contents are grouped by "representation", e.g. the same
|
||||
# "representation" loaded twice is grouped under the same header.
|
||||
# Since the version check filters these parent groups we skip that
|
||||
# check for the individual children.
|
||||
has_parent = index.parent().isValid()
|
||||
if has_parent and not self._hierarchy_view:
|
||||
return True
|
||||
|
||||
# Filter to those that have the different version numbers
|
||||
node = index.internalPointer()
|
||||
if outdated(node):
|
||||
return True
|
||||
|
||||
if self._hierarchy_view:
|
||||
for _node in walk_hierarchy(node):
|
||||
if outdated(_node):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _matches(self, row, parent, pattern):
|
||||
"""Return whether row matches regex pattern.
|
||||
|
||||
Args:
|
||||
row (int): row number in model
|
||||
parent (QtCore.QModelIndex): parent index
|
||||
pattern (regex.pattern): pattern to check for in key
|
||||
|
||||
Returns:
|
||||
bool
|
||||
|
||||
"""
|
||||
model = self.sourceModel()
|
||||
column = self.filterKeyColumn()
|
||||
role = self.filterRole()
|
||||
|
||||
def matches(row, parent, pattern):
|
||||
index = model.index(row, column, parent)
|
||||
key = model.data(index, role)
|
||||
if re.search(pattern, key, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
if matches(row, parent, pattern):
|
||||
return True
|
||||
|
||||
# Also allow if any of the children matches
|
||||
source_index = model.index(row, column, parent)
|
||||
rows = model.rowCount(source_index)
|
||||
|
||||
if any(
|
||||
matches(idx, source_index, pattern)
|
||||
for idx in range(rows)
|
||||
):
|
||||
return True
|
||||
|
||||
if not self._hierarchy_view:
|
||||
return False
|
||||
|
||||
for idx in range(rows):
|
||||
child_index = model.index(idx, column, source_index)
|
||||
child_rows = model.rowCount(child_index)
|
||||
return any(
|
||||
self._matches(child_idx, child_index, pattern)
|
||||
for child_idx in range(child_rows)
|
||||
)
|
||||
|
||||
return True
|
||||
6
openpype/tools/ayon_sceneinventory/models/__init__.py
Normal file
6
openpype/tools/ayon_sceneinventory/models/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .site_sync import SiteSyncModel
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SiteSyncModel",
|
||||
)
|
||||
176
openpype/tools/ayon_sceneinventory/models/site_sync.py
Normal file
176
openpype/tools/ayon_sceneinventory/models/site_sync.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from openpype.client import get_representations
|
||||
from openpype.modules import ModulesManager
|
||||
|
||||
NOT_SET = object()
|
||||
|
||||
|
||||
class SiteSyncModel:
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
|
||||
self._sync_server_module = NOT_SET
|
||||
self._sync_server_enabled = None
|
||||
self._active_site = NOT_SET
|
||||
self._remote_site = NOT_SET
|
||||
self._active_site_provider = NOT_SET
|
||||
self._remote_site_provider = NOT_SET
|
||||
|
||||
def reset(self):
|
||||
self._sync_server_module = NOT_SET
|
||||
self._sync_server_enabled = None
|
||||
self._active_site = NOT_SET
|
||||
self._remote_site = NOT_SET
|
||||
self._active_site_provider = NOT_SET
|
||||
self._remote_site_provider = NOT_SET
|
||||
|
||||
def is_sync_server_enabled(self):
|
||||
"""Site sync is enabled.
|
||||
|
||||
Returns:
|
||||
bool: Is enabled or not.
|
||||
"""
|
||||
|
||||
self._cache_sync_server_module()
|
||||
return self._sync_server_enabled
|
||||
|
||||
def get_site_provider_icons(self):
|
||||
"""Icon paths per provider.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: Path by provider name.
|
||||
"""
|
||||
|
||||
site_sync = self._get_sync_server_module()
|
||||
if site_sync is None:
|
||||
return {}
|
||||
return site_sync.get_site_icons()
|
||||
|
||||
def get_sites_information(self):
|
||||
return {
|
||||
"active_site": self._get_active_site(),
|
||||
"active_site_provider": self._get_active_site_provider(),
|
||||
"remote_site": self._get_remote_site(),
|
||||
"remote_site_provider": self._get_remote_site_provider()
|
||||
}
|
||||
|
||||
def get_representations_site_progress(self, representation_ids):
|
||||
"""Get progress of representations sync."""
|
||||
|
||||
representation_ids = set(representation_ids)
|
||||
output = {
|
||||
repre_id: {
|
||||
"active_site": 0,
|
||||
"remote_site": 0,
|
||||
}
|
||||
for repre_id in representation_ids
|
||||
}
|
||||
if not self.is_sync_server_enabled():
|
||||
return output
|
||||
|
||||
project_name = self._controller.get_current_project_name()
|
||||
site_sync = self._get_sync_server_module()
|
||||
repre_docs = get_representations(project_name, representation_ids)
|
||||
active_site = self._get_active_site()
|
||||
remote_site = self._get_remote_site()
|
||||
|
||||
for repre_doc in repre_docs:
|
||||
repre_output = output[repre_doc["_id"]]
|
||||
result = site_sync.get_progress_for_repre(
|
||||
repre_doc, active_site, remote_site
|
||||
)
|
||||
repre_output["active_site"] = result[active_site]
|
||||
repre_output["remote_site"] = result[remote_site]
|
||||
|
||||
return output
|
||||
|
||||
def resync_representations(self, representation_ids, site_type):
|
||||
"""
|
||||
|
||||
Args:
|
||||
representation_ids (Iterable[str]): Representation ids.
|
||||
site_type (Literal[active_site, remote_site]): Site type.
|
||||
"""
|
||||
|
||||
project_name = self._controller.get_current_project_name()
|
||||
site_sync = self._get_sync_server_module()
|
||||
active_site = self._get_active_site()
|
||||
remote_site = self._get_remote_site()
|
||||
progress = self.get_representations_site_progress(
|
||||
representation_ids
|
||||
)
|
||||
for repre_id in representation_ids:
|
||||
repre_progress = progress.get(repre_id)
|
||||
if not repre_progress:
|
||||
continue
|
||||
|
||||
if site_type == "active_site":
|
||||
# check opposite from added site, must be 1 or unable to sync
|
||||
check_progress = repre_progress["remote_site"]
|
||||
site = active_site
|
||||
else:
|
||||
check_progress = repre_progress["active_site"]
|
||||
site = remote_site
|
||||
|
||||
if check_progress == 1:
|
||||
site_sync.add_site(
|
||||
project_name, repre_id, site, force=True
|
||||
)
|
||||
|
||||
def _get_sync_server_module(self):
|
||||
self._cache_sync_server_module()
|
||||
return self._sync_server_module
|
||||
|
||||
def _cache_sync_server_module(self):
|
||||
if self._sync_server_module is not NOT_SET:
|
||||
return self._sync_server_module
|
||||
manager = ModulesManager()
|
||||
site_sync = manager.modules_by_name.get("sync_server")
|
||||
sync_enabled = site_sync is not None and site_sync.enabled
|
||||
self._sync_server_module = site_sync
|
||||
self._sync_server_enabled = sync_enabled
|
||||
|
||||
def _get_active_site(self):
|
||||
if self._active_site is NOT_SET:
|
||||
self._cache_sites()
|
||||
return self._active_site
|
||||
|
||||
def _get_remote_site(self):
|
||||
if self._remote_site is NOT_SET:
|
||||
self._cache_sites()
|
||||
return self._remote_site
|
||||
|
||||
def _get_active_site_provider(self):
|
||||
if self._active_site_provider is NOT_SET:
|
||||
self._cache_sites()
|
||||
return self._active_site_provider
|
||||
|
||||
def _get_remote_site_provider(self):
|
||||
if self._remote_site_provider is NOT_SET:
|
||||
self._cache_sites()
|
||||
return self._remote_site_provider
|
||||
|
||||
def _cache_sites(self):
|
||||
site_sync = self._get_sync_server_module()
|
||||
active_site = None
|
||||
remote_site = None
|
||||
active_site_provider = None
|
||||
remote_site_provider = None
|
||||
if site_sync is not None:
|
||||
project_name = self._controller.get_current_project_name()
|
||||
active_site = site_sync.get_active_site(project_name)
|
||||
remote_site = site_sync.get_remote_site(project_name)
|
||||
active_site_provider = "studio"
|
||||
remote_site_provider = "studio"
|
||||
if active_site != "studio":
|
||||
active_site_provider = site_sync.get_active_provider(
|
||||
project_name, active_site
|
||||
)
|
||||
if remote_site != "studio":
|
||||
remote_site_provider = site_sync.get_active_provider(
|
||||
project_name, remote_site
|
||||
)
|
||||
|
||||
self._active_site = active_site
|
||||
self._remote_site = remote_site
|
||||
self._active_site_provider = active_site_provider
|
||||
self._remote_site_provider = remote_site_provider
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from .dialog import SwitchAssetDialog
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SwitchAssetDialog",
|
||||
)
|
||||
1333
openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py
Normal file
1333
openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,307 @@
|
|||
from qtpy import QtWidgets, QtCore
|
||||
import qtawesome
|
||||
|
||||
from openpype.tools.utils import (
|
||||
PlaceholderLineEdit,
|
||||
BaseClickableFrame,
|
||||
set_style_property,
|
||||
)
|
||||
from openpype.tools.ayon_utils.widgets import FoldersWidget
|
||||
|
||||
NOT_SET = object()
|
||||
|
||||
|
||||
class ClickableLineEdit(QtWidgets.QLineEdit):
|
||||
"""QLineEdit capturing left mouse click.
|
||||
|
||||
Triggers `clicked` signal on mouse click.
|
||||
"""
|
||||
clicked = QtCore.Signal()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ClickableLineEdit, self).__init__(*args, **kwargs)
|
||||
self.setReadOnly(True)
|
||||
self._mouse_pressed = False
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
self._mouse_pressed = True
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._mouse_pressed:
|
||||
self._mouse_pressed = False
|
||||
if self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
event.accept()
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
event.accept()
|
||||
|
||||
|
||||
class ControllerWrap:
|
||||
def __init__(self, controller):
|
||||
self._controller = controller
|
||||
self._selected_folder_id = None
|
||||
|
||||
def emit_event(self, *args, **kwargs):
|
||||
self._controller.emit_event(*args, **kwargs)
|
||||
|
||||
def register_event_callback(self, *args, **kwargs):
|
||||
self._controller.register_event_callback(*args, **kwargs)
|
||||
|
||||
def get_current_project_name(self):
|
||||
return self._controller.get_current_project_name()
|
||||
|
||||
def get_folder_items(self, *args, **kwargs):
|
||||
return self._controller.get_folder_items(*args, **kwargs)
|
||||
|
||||
def set_selected_folder(self, folder_id):
|
||||
self._selected_folder_id = folder_id
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
return self._selected_folder_id
|
||||
|
||||
|
||||
class FoldersDialog(QtWidgets.QDialog):
|
||||
"""Dialog to select asset for a context of instance."""
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(FoldersDialog, self).__init__(parent)
|
||||
self.setWindowTitle("Select folder")
|
||||
|
||||
filter_input = PlaceholderLineEdit(self)
|
||||
filter_input.setPlaceholderText("Filter folders..")
|
||||
|
||||
controller_wrap = ControllerWrap(controller)
|
||||
folders_widget = FoldersWidget(controller_wrap, self)
|
||||
folders_widget.set_deselectable(True)
|
||||
|
||||
ok_btn = QtWidgets.QPushButton("OK", self)
|
||||
cancel_btn = QtWidgets.QPushButton("Cancel", self)
|
||||
|
||||
btns_layout = QtWidgets.QHBoxLayout()
|
||||
btns_layout.addStretch(1)
|
||||
btns_layout.addWidget(ok_btn)
|
||||
btns_layout.addWidget(cancel_btn)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addWidget(filter_input, 0)
|
||||
layout.addWidget(folders_widget, 1)
|
||||
layout.addLayout(btns_layout, 0)
|
||||
|
||||
folders_widget.double_clicked.connect(self._on_ok_clicked)
|
||||
folders_widget.refreshed.connect(self._on_folders_refresh)
|
||||
filter_input.textChanged.connect(self._on_filter_change)
|
||||
ok_btn.clicked.connect(self._on_ok_clicked)
|
||||
cancel_btn.clicked.connect(self._on_cancel_clicked)
|
||||
|
||||
self._filter_input = filter_input
|
||||
self._ok_btn = ok_btn
|
||||
self._cancel_btn = cancel_btn
|
||||
|
||||
self._folders_widget = folders_widget
|
||||
self._controller_wrap = controller_wrap
|
||||
|
||||
# Set selected folder only when user confirms the dialog
|
||||
self._selected_folder_id = None
|
||||
self._selected_folder_label = None
|
||||
|
||||
self._folder_id_to_select = NOT_SET
|
||||
|
||||
self._first_show = True
|
||||
self._default_height = 500
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Refresh asset model on show."""
|
||||
super(FoldersDialog, self).showEvent(event)
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self._on_first_show()
|
||||
|
||||
def refresh(self):
|
||||
project_name = self._controller_wrap.get_current_project_name()
|
||||
self._folders_widget.set_project_name(project_name)
|
||||
|
||||
def _on_first_show(self):
|
||||
center = self.rect().center()
|
||||
size = self.size()
|
||||
size.setHeight(self._default_height)
|
||||
|
||||
self.resize(size)
|
||||
new_pos = self.mapToGlobal(center)
|
||||
new_pos.setX(new_pos.x() - int(self.width() / 2))
|
||||
new_pos.setY(new_pos.y() - int(self.height() / 2))
|
||||
self.move(new_pos)
|
||||
|
||||
def _on_folders_refresh(self):
|
||||
if self._folder_id_to_select is NOT_SET:
|
||||
return
|
||||
self._folders_widget.set_selected_folder(self._folder_id_to_select)
|
||||
self._folder_id_to_select = NOT_SET
|
||||
|
||||
def _on_filter_change(self, text):
|
||||
"""Trigger change of filter of folders."""
|
||||
|
||||
self._folders_widget.set_name_filter(text)
|
||||
|
||||
def _on_cancel_clicked(self):
|
||||
self.done(0)
|
||||
|
||||
def _on_ok_clicked(self):
|
||||
self._selected_folder_id = (
|
||||
self._folders_widget.get_selected_folder_id()
|
||||
)
|
||||
self._selected_folder_label = (
|
||||
self._folders_widget.get_selected_folder_label()
|
||||
)
|
||||
self.done(1)
|
||||
|
||||
def set_selected_folder(self, folder_id):
|
||||
"""Change preselected folder before showing the dialog.
|
||||
|
||||
This also resets model and clean filter.
|
||||
"""
|
||||
|
||||
if (
|
||||
self._folders_widget.is_refreshing
|
||||
or self._folders_widget.get_project_name() is None
|
||||
):
|
||||
self._folder_id_to_select = folder_id
|
||||
else:
|
||||
self._folders_widget.set_selected_folder(folder_id)
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
"""Get selected folder id.
|
||||
|
||||
Returns:
|
||||
Union[str, None]: Selected folder id or None if nothing
|
||||
is selected.
|
||||
"""
|
||||
return self._selected_folder_id
|
||||
|
||||
def get_selected_folder_label(self):
|
||||
return self._selected_folder_label
|
||||
|
||||
|
||||
class FoldersField(BaseClickableFrame):
|
||||
"""Field where asset name of selected instance/s is showed.
|
||||
|
||||
Click on the field will trigger `FoldersDialog`.
|
||||
"""
|
||||
value_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(FoldersField, self).__init__(parent)
|
||||
self.setObjectName("AssetNameInputWidget")
|
||||
|
||||
# Don't use 'self' for parent!
|
||||
# - this widget has specific styles
|
||||
dialog = FoldersDialog(controller, parent)
|
||||
|
||||
name_input = ClickableLineEdit(self)
|
||||
name_input.setObjectName("AssetNameInput")
|
||||
|
||||
icon = qtawesome.icon("fa.window-maximize", color="white")
|
||||
icon_btn = QtWidgets.QPushButton(self)
|
||||
icon_btn.setIcon(icon)
|
||||
icon_btn.setObjectName("AssetNameInputButton")
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(name_input, 1)
|
||||
layout.addWidget(icon_btn, 0)
|
||||
|
||||
# Make sure all widgets are vertically extended to highest widget
|
||||
for widget in (
|
||||
name_input,
|
||||
icon_btn
|
||||
):
|
||||
w_size_policy = widget.sizePolicy()
|
||||
w_size_policy.setVerticalPolicy(
|
||||
QtWidgets.QSizePolicy.MinimumExpanding)
|
||||
widget.setSizePolicy(w_size_policy)
|
||||
|
||||
size_policy = self.sizePolicy()
|
||||
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Maximum)
|
||||
self.setSizePolicy(size_policy)
|
||||
|
||||
name_input.clicked.connect(self._mouse_release_callback)
|
||||
icon_btn.clicked.connect(self._mouse_release_callback)
|
||||
dialog.finished.connect(self._on_dialog_finish)
|
||||
|
||||
self._controller = controller
|
||||
self._dialog = dialog
|
||||
self._name_input = name_input
|
||||
self._icon_btn = icon_btn
|
||||
|
||||
self._selected_folder_id = None
|
||||
self._selected_folder_label = None
|
||||
self._selected_items = []
|
||||
self._is_valid = True
|
||||
|
||||
def refresh(self):
|
||||
self._dialog.refresh()
|
||||
|
||||
def is_valid(self):
|
||||
"""Is asset valid."""
|
||||
return self._is_valid
|
||||
|
||||
def get_selected_folder_id(self):
|
||||
"""Selected asset names."""
|
||||
return self._selected_folder_id
|
||||
|
||||
def get_selected_folder_label(self):
|
||||
return self._selected_folder_label
|
||||
|
||||
def set_text(self, text):
|
||||
"""Set text in text field.
|
||||
|
||||
Does not change selected items (assets).
|
||||
"""
|
||||
self._name_input.setText(text)
|
||||
|
||||
def set_valid(self, is_valid):
|
||||
state = ""
|
||||
if not is_valid:
|
||||
state = "invalid"
|
||||
self._set_state_property(state)
|
||||
|
||||
def set_selected_item(self, folder_id=None, folder_label=None):
|
||||
"""Set folder for selection.
|
||||
|
||||
Args:
|
||||
folder_id (Optional[str]): Folder id to select.
|
||||
folder_label (Optional[str]): Folder label.
|
||||
"""
|
||||
|
||||
self._selected_folder_id = folder_id
|
||||
if not folder_id:
|
||||
folder_label = None
|
||||
elif folder_id and not folder_label:
|
||||
folder_label = self._controller.get_folder_label(folder_id)
|
||||
self._selected_folder_label = folder_label
|
||||
self.set_text(folder_label if folder_label else "<folder>")
|
||||
|
||||
def _on_dialog_finish(self, result):
|
||||
if not result:
|
||||
return
|
||||
|
||||
folder_id = self._dialog.get_selected_folder_id()
|
||||
folder_label = self._dialog.get_selected_folder_label()
|
||||
self.set_selected_item(folder_id, folder_label)
|
||||
|
||||
self.value_changed.emit()
|
||||
|
||||
def _mouse_release_callback(self):
|
||||
self._dialog.set_selected_folder(self._selected_folder_id)
|
||||
self._dialog.open()
|
||||
|
||||
def _set_state_property(self, state):
|
||||
set_style_property(self, "state", state)
|
||||
set_style_property(self._name_input, "state", state)
|
||||
set_style_property(self._icon_btn, "state", state)
|
||||
94
openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py
Normal file
94
openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
from openpype import style
|
||||
|
||||
|
||||
class ButtonWithMenu(QtWidgets.QToolButton):
|
||||
def __init__(self, parent=None):
|
||||
super(ButtonWithMenu, self).__init__(parent)
|
||||
|
||||
self.setObjectName("ButtonWithMenu")
|
||||
|
||||
self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup)
|
||||
menu = QtWidgets.QMenu(self)
|
||||
|
||||
self.setMenu(menu)
|
||||
|
||||
self._menu = menu
|
||||
self._actions = []
|
||||
|
||||
def menu(self):
|
||||
return self._menu
|
||||
|
||||
def clear_actions(self):
|
||||
if self._menu is not None:
|
||||
self._menu.clear()
|
||||
self._actions = []
|
||||
|
||||
def add_action(self, action):
|
||||
self._actions.append(action)
|
||||
self._menu.addAction(action)
|
||||
|
||||
def _on_action_trigger(self):
|
||||
action = self.sender()
|
||||
if action not in self._actions:
|
||||
return
|
||||
action.trigger()
|
||||
|
||||
|
||||
class SearchComboBox(QtWidgets.QComboBox):
|
||||
"""Searchable ComboBox with empty placeholder value as first value"""
|
||||
|
||||
def __init__(self, parent):
|
||||
super(SearchComboBox, self).__init__(parent)
|
||||
|
||||
self.setEditable(True)
|
||||
self.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
|
||||
|
||||
combobox_delegate = QtWidgets.QStyledItemDelegate(self)
|
||||
self.setItemDelegate(combobox_delegate)
|
||||
|
||||
completer = self.completer()
|
||||
completer.setCompletionMode(
|
||||
QtWidgets.QCompleter.PopupCompletion
|
||||
)
|
||||
completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
completer_view = completer.popup()
|
||||
completer_view.setObjectName("CompleterView")
|
||||
completer_delegate = QtWidgets.QStyledItemDelegate(completer_view)
|
||||
completer_view.setItemDelegate(completer_delegate)
|
||||
completer_view.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
self._combobox_delegate = combobox_delegate
|
||||
|
||||
self._completer_delegate = completer_delegate
|
||||
self._completer = completer
|
||||
|
||||
def set_placeholder(self, placeholder):
|
||||
self.lineEdit().setPlaceholderText(placeholder)
|
||||
|
||||
def populate(self, items):
|
||||
self.clear()
|
||||
self.addItems([""]) # ensure first item is placeholder
|
||||
self.addItems(items)
|
||||
|
||||
def get_valid_value(self):
|
||||
"""Return the current text if it's a valid value else None
|
||||
|
||||
Note: The empty placeholder value is valid and returns as ""
|
||||
|
||||
"""
|
||||
|
||||
text = self.currentText()
|
||||
lookup = set(self.itemText(i) for i in range(self.count()))
|
||||
if text not in lookup:
|
||||
return None
|
||||
|
||||
return text or None
|
||||
|
||||
def set_valid_value(self, value):
|
||||
"""Try to locate 'value' and pre-select it in dropdown."""
|
||||
index = self.findText(value)
|
||||
if index > -1:
|
||||
self.setCurrentIndex(index)
|
||||
825
openpype/tools/ayon_sceneinventory/view.py
Normal file
825
openpype/tools/ayon_sceneinventory/view.py
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
import uuid
|
||||
import collections
|
||||
import logging
|
||||
import itertools
|
||||
from functools import partial
|
||||
|
||||
from qtpy import QtWidgets, QtCore
|
||||
import qtawesome
|
||||
|
||||
from openpype.client import (
|
||||
get_version_by_id,
|
||||
get_versions,
|
||||
get_hero_versions,
|
||||
get_representation_by_id,
|
||||
get_representations,
|
||||
)
|
||||
from openpype import style
|
||||
from openpype.pipeline import (
|
||||
HeroVersionType,
|
||||
update_container,
|
||||
remove_container,
|
||||
discover_inventory_actions,
|
||||
)
|
||||
from openpype.tools.utils.lib import (
|
||||
iter_model_rows,
|
||||
format_version
|
||||
)
|
||||
|
||||
from .switch_dialog import SwitchAssetDialog
|
||||
from .model import InventoryModel
|
||||
|
||||
|
||||
DEFAULT_COLOR = "#fb9c15"
|
||||
|
||||
log = logging.getLogger("SceneInventory")
|
||||
|
||||
|
||||
class SceneInventoryView(QtWidgets.QTreeView):
|
||||
data_changed = QtCore.Signal()
|
||||
hierarchy_view_changed = QtCore.Signal(bool)
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(SceneInventoryView, self).__init__(parent=parent)
|
||||
|
||||
# view settings
|
||||
self.setIndentation(12)
|
||||
self.setAlternatingRowColors(True)
|
||||
self.setSortingEnabled(True)
|
||||
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
self.customContextMenuRequested.connect(self._show_right_mouse_menu)
|
||||
|
||||
self._hierarchy_view = False
|
||||
self._selected = None
|
||||
|
||||
self._controller = controller
|
||||
|
||||
def _set_hierarchy_view(self, enabled):
|
||||
if enabled == self._hierarchy_view:
|
||||
return
|
||||
self._hierarchy_view = enabled
|
||||
self.hierarchy_view_changed.emit(enabled)
|
||||
|
||||
def _enter_hierarchy(self, items):
|
||||
self._selected = set(i["objectName"] for i in items)
|
||||
self._set_hierarchy_view(True)
|
||||
self.data_changed.emit()
|
||||
self.expandToDepth(1)
|
||||
self.setStyleSheet("""
|
||||
QTreeView {
|
||||
border-color: #fb9c15;
|
||||
}
|
||||
""")
|
||||
|
||||
def _leave_hierarchy(self):
|
||||
self._set_hierarchy_view(False)
|
||||
self.data_changed.emit()
|
||||
self.setStyleSheet("QTreeView {}")
|
||||
|
||||
def _build_item_menu_for_selection(self, items, menu):
|
||||
# Exclude items that are "NOT FOUND" since setting versions, updating
|
||||
# and removal won't work for those items.
|
||||
items = [item for item in items if not item.get("isNotFound")]
|
||||
if not items:
|
||||
return
|
||||
|
||||
# An item might not have a representation, for example when an item
|
||||
# is listed as "NOT FOUND"
|
||||
repre_ids = set()
|
||||
for item in items:
|
||||
repre_id = item["representation"]
|
||||
try:
|
||||
uuid.UUID(repre_id)
|
||||
repre_ids.add(repre_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
project_name = self._controller.get_current_project_name()
|
||||
repre_docs = get_representations(
|
||||
project_name, representation_ids=repre_ids, fields=["parent"]
|
||||
)
|
||||
|
||||
version_ids = {
|
||||
repre_doc["parent"]
|
||||
for repre_doc in repre_docs
|
||||
}
|
||||
|
||||
loaded_versions = get_versions(
|
||||
project_name, version_ids=version_ids, hero=True
|
||||
)
|
||||
|
||||
loaded_hero_versions = []
|
||||
versions_by_parent_id = collections.defaultdict(list)
|
||||
subset_ids = set()
|
||||
for version in loaded_versions:
|
||||
if version["type"] == "hero_version":
|
||||
loaded_hero_versions.append(version)
|
||||
else:
|
||||
parent_id = version["parent"]
|
||||
versions_by_parent_id[parent_id].append(version)
|
||||
subset_ids.add(parent_id)
|
||||
|
||||
all_versions = get_versions(
|
||||
project_name, subset_ids=subset_ids, hero=True
|
||||
)
|
||||
hero_versions = []
|
||||
versions = []
|
||||
for version in all_versions:
|
||||
if version["type"] == "hero_version":
|
||||
hero_versions.append(version)
|
||||
else:
|
||||
versions.append(version)
|
||||
|
||||
has_loaded_hero_versions = len(loaded_hero_versions) > 0
|
||||
has_available_hero_version = len(hero_versions) > 0
|
||||
has_outdated = False
|
||||
|
||||
for version in versions:
|
||||
parent_id = version["parent"]
|
||||
current_versions = versions_by_parent_id[parent_id]
|
||||
for current_version in current_versions:
|
||||
if current_version["name"] < version["name"]:
|
||||
has_outdated = True
|
||||
break
|
||||
|
||||
if has_outdated:
|
||||
break
|
||||
|
||||
switch_to_versioned = None
|
||||
if has_loaded_hero_versions:
|
||||
def _on_switch_to_versioned(items):
|
||||
repre_ids = {
|
||||
item["representation"]
|
||||
for item in items
|
||||
}
|
||||
|
||||
repre_docs = get_representations(
|
||||
project_name,
|
||||
representation_ids=repre_ids,
|
||||
fields=["parent"]
|
||||
)
|
||||
|
||||
version_ids = set()
|
||||
version_id_by_repre_id = {}
|
||||
for repre_doc in repre_docs:
|
||||
version_id = repre_doc["parent"]
|
||||
repre_id = str(repre_doc["_id"])
|
||||
version_id_by_repre_id[repre_id] = version_id
|
||||
version_ids.add(version_id)
|
||||
|
||||
hero_versions = get_hero_versions(
|
||||
project_name,
|
||||
version_ids=version_ids,
|
||||
fields=["version_id"]
|
||||
)
|
||||
|
||||
hero_src_version_ids = set()
|
||||
for hero_version in hero_versions:
|
||||
version_id = hero_version["version_id"]
|
||||
hero_src_version_ids.add(version_id)
|
||||
hero_version_id = hero_version["_id"]
|
||||
for _repre_id, current_version_id in (
|
||||
version_id_by_repre_id.items()
|
||||
):
|
||||
if current_version_id == hero_version_id:
|
||||
version_id_by_repre_id[_repre_id] = version_id
|
||||
|
||||
version_docs = get_versions(
|
||||
project_name,
|
||||
version_ids=hero_src_version_ids,
|
||||
fields=["name"]
|
||||
)
|
||||
version_name_by_id = {}
|
||||
for version_doc in version_docs:
|
||||
version_name_by_id[version_doc["_id"]] = \
|
||||
version_doc["name"]
|
||||
|
||||
# Specify version per item to update to
|
||||
update_items = []
|
||||
update_versions = []
|
||||
for item in items:
|
||||
repre_id = item["representation"]
|
||||
version_id = version_id_by_repre_id.get(repre_id)
|
||||
version_name = version_name_by_id.get(version_id)
|
||||
if version_name is not None:
|
||||
update_items.append(item)
|
||||
update_versions.append(version_name)
|
||||
self._update_containers(update_items, update_versions)
|
||||
|
||||
update_icon = qtawesome.icon(
|
||||
"fa.asterisk",
|
||||
color=DEFAULT_COLOR
|
||||
)
|
||||
switch_to_versioned = QtWidgets.QAction(
|
||||
update_icon,
|
||||
"Switch to versioned",
|
||||
menu
|
||||
)
|
||||
switch_to_versioned.triggered.connect(
|
||||
lambda: _on_switch_to_versioned(items)
|
||||
)
|
||||
|
||||
update_to_latest_action = None
|
||||
if has_outdated or has_loaded_hero_versions:
|
||||
update_icon = qtawesome.icon(
|
||||
"fa.angle-double-up",
|
||||
color=DEFAULT_COLOR
|
||||
)
|
||||
update_to_latest_action = QtWidgets.QAction(
|
||||
update_icon,
|
||||
"Update to latest",
|
||||
menu
|
||||
)
|
||||
update_to_latest_action.triggered.connect(
|
||||
lambda: self._update_containers(items, version=-1)
|
||||
)
|
||||
|
||||
change_to_hero = None
|
||||
if has_available_hero_version:
|
||||
# TODO change icon
|
||||
change_icon = qtawesome.icon(
|
||||
"fa.asterisk",
|
||||
color="#00b359"
|
||||
)
|
||||
change_to_hero = QtWidgets.QAction(
|
||||
change_icon,
|
||||
"Change to hero",
|
||||
menu
|
||||
)
|
||||
change_to_hero.triggered.connect(
|
||||
lambda: self._update_containers(items,
|
||||
version=HeroVersionType(-1))
|
||||
)
|
||||
|
||||
# set version
|
||||
set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR)
|
||||
set_version_action = QtWidgets.QAction(
|
||||
set_version_icon,
|
||||
"Set version",
|
||||
menu
|
||||
)
|
||||
set_version_action.triggered.connect(
|
||||
lambda: self._show_version_dialog(items))
|
||||
|
||||
# switch folder
|
||||
switch_folder_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR)
|
||||
switch_folder_action = QtWidgets.QAction(
|
||||
switch_folder_icon,
|
||||
"Switch Folder",
|
||||
menu
|
||||
)
|
||||
switch_folder_action.triggered.connect(
|
||||
lambda: self._show_switch_dialog(items))
|
||||
|
||||
# remove
|
||||
remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR)
|
||||
remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu)
|
||||
remove_action.triggered.connect(
|
||||
lambda: self._show_remove_warning_dialog(items))
|
||||
|
||||
# add the actions
|
||||
if switch_to_versioned:
|
||||
menu.addAction(switch_to_versioned)
|
||||
|
||||
if update_to_latest_action:
|
||||
menu.addAction(update_to_latest_action)
|
||||
|
||||
if change_to_hero:
|
||||
menu.addAction(change_to_hero)
|
||||
|
||||
menu.addAction(set_version_action)
|
||||
menu.addAction(switch_folder_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
menu.addAction(remove_action)
|
||||
|
||||
self._handle_sync_server(menu, repre_ids)
|
||||
|
||||
def _handle_sync_server(self, menu, repre_ids):
|
||||
"""Adds actions for download/upload when SyncServer is enabled
|
||||
|
||||
Args:
|
||||
menu (OptionMenu)
|
||||
repre_ids (list) of object_ids
|
||||
|
||||
Returns:
|
||||
(OptionMenu)
|
||||
"""
|
||||
|
||||
if not self._controller.is_sync_server_enabled():
|
||||
return
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR)
|
||||
download_active_action = QtWidgets.QAction(
|
||||
download_icon,
|
||||
"Download",
|
||||
menu
|
||||
)
|
||||
download_active_action.triggered.connect(
|
||||
lambda: self._add_sites(repre_ids, "active_site"))
|
||||
|
||||
upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR)
|
||||
upload_remote_action = QtWidgets.QAction(
|
||||
upload_icon,
|
||||
"Upload",
|
||||
menu
|
||||
)
|
||||
upload_remote_action.triggered.connect(
|
||||
lambda: self._add_sites(repre_ids, "remote_site"))
|
||||
|
||||
menu.addAction(download_active_action)
|
||||
menu.addAction(upload_remote_action)
|
||||
|
||||
def _add_sites(self, repre_ids, site_type):
|
||||
"""(Re)sync all 'repre_ids' to specific site.
|
||||
|
||||
It checks if opposite site has fully available content to limit
|
||||
accidents. (ReSync active when no remote >> losing active content)
|
||||
|
||||
Args:
|
||||
repre_ids (list)
|
||||
site_type (Literal[active_site, remote_site]): Site type.
|
||||
"""
|
||||
|
||||
self._controller.resync_representations(repre_ids, site_type)
|
||||
|
||||
self.data_changed.emit()
|
||||
|
||||
def _build_item_menu(self, items=None):
|
||||
"""Create menu for the selected items"""
|
||||
|
||||
if not items:
|
||||
items = []
|
||||
|
||||
menu = QtWidgets.QMenu(self)
|
||||
|
||||
# add the actions
|
||||
self._build_item_menu_for_selection(items, menu)
|
||||
|
||||
# These two actions should be able to work without selection
|
||||
# expand all items
|
||||
expandall_action = QtWidgets.QAction(menu, text="Expand all items")
|
||||
expandall_action.triggered.connect(self.expandAll)
|
||||
|
||||
# collapse all items
|
||||
collapse_action = QtWidgets.QAction(menu, text="Collapse all items")
|
||||
collapse_action.triggered.connect(self.collapseAll)
|
||||
|
||||
menu.addAction(expandall_action)
|
||||
menu.addAction(collapse_action)
|
||||
|
||||
custom_actions = self._get_custom_actions(containers=items)
|
||||
if custom_actions:
|
||||
submenu = QtWidgets.QMenu("Actions", self)
|
||||
for action in custom_actions:
|
||||
color = action.color or DEFAULT_COLOR
|
||||
icon = qtawesome.icon("fa.%s" % action.icon, color=color)
|
||||
action_item = QtWidgets.QAction(icon, action.label, submenu)
|
||||
action_item.triggered.connect(
|
||||
partial(self._process_custom_action, action, items))
|
||||
|
||||
submenu.addAction(action_item)
|
||||
|
||||
menu.addMenu(submenu)
|
||||
|
||||
# go back to flat view
|
||||
back_to_flat_action = None
|
||||
if self._hierarchy_view:
|
||||
back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR)
|
||||
back_to_flat_action = QtWidgets.QAction(
|
||||
back_to_flat_icon,
|
||||
"Back to Full-View",
|
||||
menu
|
||||
)
|
||||
back_to_flat_action.triggered.connect(self._leave_hierarchy)
|
||||
|
||||
# send items to hierarchy view
|
||||
enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8")
|
||||
enter_hierarchy_action = QtWidgets.QAction(
|
||||
enter_hierarchy_icon,
|
||||
"Cherry-Pick (Hierarchy)",
|
||||
menu
|
||||
)
|
||||
enter_hierarchy_action.triggered.connect(
|
||||
lambda: self._enter_hierarchy(items))
|
||||
|
||||
if items:
|
||||
menu.addAction(enter_hierarchy_action)
|
||||
|
||||
if back_to_flat_action is not None:
|
||||
menu.addAction(back_to_flat_action)
|
||||
|
||||
return menu
|
||||
|
||||
def _get_custom_actions(self, containers):
|
||||
"""Get the registered Inventory Actions
|
||||
|
||||
Args:
|
||||
containers(list): collection of containers
|
||||
|
||||
Returns:
|
||||
list: collection of filter and initialized actions
|
||||
"""
|
||||
|
||||
def sorter(Plugin):
|
||||
"""Sort based on order attribute of the plugin"""
|
||||
return Plugin.order
|
||||
|
||||
# Fedd an empty dict if no selection, this will ensure the compat
|
||||
# lookup always work, so plugin can interact with Scene Inventory
|
||||
# reversely.
|
||||
containers = containers or [dict()]
|
||||
|
||||
# Check which action will be available in the menu
|
||||
Plugins = discover_inventory_actions()
|
||||
compatible = [p() for p in Plugins if
|
||||
any(p.is_compatible(c) for c in containers)]
|
||||
|
||||
return sorted(compatible, key=sorter)
|
||||
|
||||
def _process_custom_action(self, action, containers):
|
||||
"""Run action and if results are returned positive update the view
|
||||
|
||||
If the result is list or dict, will select view items by the result.
|
||||
|
||||
Args:
|
||||
action (InventoryAction): Inventory Action instance
|
||||
containers (list): Data of currently selected items
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
result = action.process(containers)
|
||||
if result:
|
||||
self.data_changed.emit()
|
||||
|
||||
if isinstance(result, (list, set)):
|
||||
self._select_items_by_action(result)
|
||||
|
||||
if isinstance(result, dict):
|
||||
self._select_items_by_action(
|
||||
result["objectNames"], result["options"]
|
||||
)
|
||||
|
||||
def _select_items_by_action(self, object_names, options=None):
|
||||
"""Select view items by the result of action
|
||||
|
||||
Args:
|
||||
object_names (list or set): A list/set of container object name
|
||||
options (dict): GUI operation options.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
options = options or dict()
|
||||
|
||||
if options.get("clear", True):
|
||||
self.clearSelection()
|
||||
|
||||
object_names = set(object_names)
|
||||
if (
|
||||
self._hierarchy_view
|
||||
and not self._selected.issuperset(object_names)
|
||||
):
|
||||
# If any container not in current cherry-picked view, update
|
||||
# view before selecting them.
|
||||
self._selected.update(object_names)
|
||||
self.data_changed.emit()
|
||||
|
||||
model = self.model()
|
||||
selection_model = self.selectionModel()
|
||||
|
||||
select_mode = {
|
||||
"select": QtCore.QItemSelectionModel.Select,
|
||||
"deselect": QtCore.QItemSelectionModel.Deselect,
|
||||
"toggle": QtCore.QItemSelectionModel.Toggle,
|
||||
}[options.get("mode", "select")]
|
||||
|
||||
for index in iter_model_rows(model, 0):
|
||||
item = index.data(InventoryModel.ItemRole)
|
||||
if item.get("isGroupNode"):
|
||||
continue
|
||||
|
||||
name = item.get("objectName")
|
||||
if name in object_names:
|
||||
self.scrollTo(index) # Ensure item is visible
|
||||
flags = select_mode | QtCore.QItemSelectionModel.Rows
|
||||
selection_model.select(index, flags)
|
||||
|
||||
object_names.remove(name)
|
||||
|
||||
if len(object_names) == 0:
|
||||
break
|
||||
|
||||
def _show_right_mouse_menu(self, pos):
|
||||
"""Display the menu when at the position of the item clicked"""
|
||||
|
||||
globalpos = self.viewport().mapToGlobal(pos)
|
||||
|
||||
if not self.selectionModel().hasSelection():
|
||||
print("No selection")
|
||||
# Build menu without selection, feed an empty list
|
||||
menu = self._build_item_menu()
|
||||
menu.exec_(globalpos)
|
||||
return
|
||||
|
||||
active = self.currentIndex() # index under mouse
|
||||
active = active.sibling(active.row(), 0) # get first column
|
||||
|
||||
# move index under mouse
|
||||
indices = self.get_indices()
|
||||
if active in indices:
|
||||
indices.remove(active)
|
||||
|
||||
indices.append(active)
|
||||
|
||||
# Extend to the sub-items
|
||||
all_indices = self._extend_to_children(indices)
|
||||
items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices
|
||||
if i.parent().isValid()]
|
||||
|
||||
if self._hierarchy_view:
|
||||
# Ensure no group item
|
||||
items = [n for n in items if not n.get("isGroupNode")]
|
||||
|
||||
menu = self._build_item_menu(items)
|
||||
menu.exec_(globalpos)
|
||||
|
||||
def get_indices(self):
|
||||
"""Get the selected rows"""
|
||||
selection_model = self.selectionModel()
|
||||
return selection_model.selectedRows()
|
||||
|
||||
def _extend_to_children(self, indices):
|
||||
"""Extend the indices to the children indices.
|
||||
|
||||
Top-level indices are extended to its children indices. Sub-items
|
||||
are kept as is.
|
||||
|
||||
Args:
|
||||
indices (list): The indices to extend.
|
||||
|
||||
Returns:
|
||||
list: The children indices
|
||||
|
||||
"""
|
||||
def get_children(i):
|
||||
model = i.model()
|
||||
rows = model.rowCount(parent=i)
|
||||
for row in range(rows):
|
||||
child = model.index(row, 0, parent=i)
|
||||
yield child
|
||||
|
||||
subitems = set()
|
||||
for i in indices:
|
||||
valid_parent = i.parent().isValid()
|
||||
if valid_parent and i not in subitems:
|
||||
subitems.add(i)
|
||||
|
||||
if self._hierarchy_view:
|
||||
# Assume this is a group item
|
||||
for child in get_children(i):
|
||||
subitems.add(child)
|
||||
else:
|
||||
# is top level item
|
||||
for child in get_children(i):
|
||||
subitems.add(child)
|
||||
|
||||
return list(subitems)
|
||||
|
||||
def _show_version_dialog(self, items):
|
||||
"""Create a dialog with the available versions for the selected file
|
||||
|
||||
Args:
|
||||
items (list): list of items to run the "set_version" for
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
active = items[-1]
|
||||
|
||||
project_name = self._controller.get_current_project_name()
|
||||
# Get available versions for active representation
|
||||
repre_doc = get_representation_by_id(
|
||||
project_name,
|
||||
active["representation"],
|
||||
fields=["parent"]
|
||||
)
|
||||
|
||||
repre_version_doc = get_version_by_id(
|
||||
project_name,
|
||||
repre_doc["parent"],
|
||||
fields=["parent"]
|
||||
)
|
||||
|
||||
version_docs = list(get_versions(
|
||||
project_name,
|
||||
subset_ids=[repre_version_doc["parent"]],
|
||||
hero=True
|
||||
))
|
||||
hero_version = None
|
||||
standard_versions = []
|
||||
for version_doc in version_docs:
|
||||
if version_doc["type"] == "hero_version":
|
||||
hero_version = version_doc
|
||||
else:
|
||||
standard_versions.append(version_doc)
|
||||
versions = list(reversed(
|
||||
sorted(standard_versions, key=lambda item: item["name"])
|
||||
))
|
||||
if hero_version:
|
||||
_version_id = hero_version["version_id"]
|
||||
for _version in versions:
|
||||
if _version["_id"] != _version_id:
|
||||
continue
|
||||
|
||||
hero_version["name"] = HeroVersionType(
|
||||
_version["name"]
|
||||
)
|
||||
hero_version["data"] = _version["data"]
|
||||
break
|
||||
|
||||
# Get index among the listed versions
|
||||
current_item = None
|
||||
current_version = active["version"]
|
||||
if isinstance(current_version, HeroVersionType):
|
||||
current_item = hero_version
|
||||
else:
|
||||
for version in versions:
|
||||
if version["name"] == current_version:
|
||||
current_item = version
|
||||
break
|
||||
|
||||
all_versions = []
|
||||
if hero_version:
|
||||
all_versions.append(hero_version)
|
||||
all_versions.extend(versions)
|
||||
|
||||
if current_item:
|
||||
index = all_versions.index(current_item)
|
||||
else:
|
||||
index = 0
|
||||
|
||||
versions_by_label = dict()
|
||||
labels = []
|
||||
for version in all_versions:
|
||||
is_hero = version["type"] == "hero_version"
|
||||
label = format_version(version["name"], is_hero)
|
||||
labels.append(label)
|
||||
versions_by_label[label] = version["name"]
|
||||
|
||||
label, state = QtWidgets.QInputDialog.getItem(
|
||||
self,
|
||||
"Set version..",
|
||||
"Set version number to",
|
||||
labels,
|
||||
current=index,
|
||||
editable=False
|
||||
)
|
||||
if not state:
|
||||
return
|
||||
|
||||
if label:
|
||||
version = versions_by_label[label]
|
||||
self._update_containers(items, version)
|
||||
|
||||
def _show_switch_dialog(self, items):
|
||||
"""Display Switch dialog"""
|
||||
dialog = SwitchAssetDialog(self._controller, self, items)
|
||||
dialog.switched.connect(self.data_changed.emit)
|
||||
dialog.show()
|
||||
|
||||
def _show_remove_warning_dialog(self, items):
|
||||
"""Prompt a dialog to inform the user the action will remove items"""
|
||||
|
||||
accept = QtWidgets.QMessageBox.Ok
|
||||
buttons = accept | QtWidgets.QMessageBox.Cancel
|
||||
|
||||
state = QtWidgets.QMessageBox.question(
|
||||
self,
|
||||
"Are you sure?",
|
||||
"Are you sure you want to remove {} item(s)".format(len(items)),
|
||||
buttons=buttons,
|
||||
defaultButton=accept
|
||||
)
|
||||
|
||||
if state != accept:
|
||||
return
|
||||
|
||||
for item in items:
|
||||
remove_container(item)
|
||||
self.data_changed.emit()
|
||||
|
||||
def _show_version_error_dialog(self, version, items):
|
||||
"""Shows QMessageBox when version switch doesn't work
|
||||
|
||||
Args:
|
||||
version: str or int or None
|
||||
"""
|
||||
if version == -1:
|
||||
version_str = "latest"
|
||||
elif isinstance(version, HeroVersionType):
|
||||
version_str = "hero"
|
||||
elif isinstance(version, int):
|
||||
version_str = "v{:03d}".format(version)
|
||||
else:
|
||||
version_str = version
|
||||
|
||||
dialog = QtWidgets.QMessageBox(self)
|
||||
dialog.setIcon(QtWidgets.QMessageBox.Warning)
|
||||
dialog.setStyleSheet(style.load_stylesheet())
|
||||
dialog.setWindowTitle("Update failed")
|
||||
|
||||
switch_btn = dialog.addButton(
|
||||
"Switch Folder",
|
||||
QtWidgets.QMessageBox.ActionRole
|
||||
)
|
||||
switch_btn.clicked.connect(lambda: self._show_switch_dialog(items))
|
||||
|
||||
dialog.addButton(QtWidgets.QMessageBox.Cancel)
|
||||
|
||||
msg = (
|
||||
"Version update to '{}' failed as representation doesn't exist."
|
||||
"\n\nPlease update to version with a valid representation"
|
||||
" OR \n use 'Switch Folder' button to change folder."
|
||||
).format(version_str)
|
||||
dialog.setText(msg)
|
||||
dialog.exec_()
|
||||
|
||||
def update_all(self):
|
||||
"""Update all items that are currently 'outdated' in the view"""
|
||||
# Get the source model through the proxy model
|
||||
model = self.model().sourceModel()
|
||||
|
||||
# Get all items from outdated groups
|
||||
outdated_items = []
|
||||
for index in iter_model_rows(model,
|
||||
column=0,
|
||||
include_root=False):
|
||||
item = index.data(model.ItemRole)
|
||||
|
||||
if not item.get("isGroupNode"):
|
||||
continue
|
||||
|
||||
# Only the group nodes contain the "highest_version" data and as
|
||||
# such we find only the groups and take its children.
|
||||
if not model.outdated(item):
|
||||
continue
|
||||
|
||||
# Collect all children which we want to update
|
||||
children = item.children()
|
||||
outdated_items.extend(children)
|
||||
|
||||
if not outdated_items:
|
||||
log.info("Nothing to update.")
|
||||
return
|
||||
|
||||
# Trigger update to latest
|
||||
self._update_containers(outdated_items, version=-1)
|
||||
|
||||
def _update_containers(self, items, version):
|
||||
"""Helper to update items to given version (or version per item)
|
||||
|
||||
If at least one item is specified this will always try to refresh
|
||||
the inventory even if errors occurred on any of the items.
|
||||
|
||||
Arguments:
|
||||
items (list): Items to update
|
||||
version (int or list): Version to set to.
|
||||
This can be a list specifying a version for each item.
|
||||
Like `update_container` version -1 sets the latest version
|
||||
and HeroTypeVersion instances set the hero version.
|
||||
|
||||
"""
|
||||
|
||||
if isinstance(version, (list, tuple)):
|
||||
# We allow a unique version to be specified per item. In that case
|
||||
# the length must match with the items
|
||||
assert len(items) == len(version), (
|
||||
"Number of items mismatches number of versions: "
|
||||
"{} items - {} versions".format(len(items), len(version))
|
||||
)
|
||||
versions = version
|
||||
else:
|
||||
# Repeat the same version infinitely
|
||||
versions = itertools.repeat(version)
|
||||
|
||||
# Trigger update to latest
|
||||
try:
|
||||
for item, item_version in zip(items, versions):
|
||||
try:
|
||||
update_container(item, item_version)
|
||||
except AssertionError:
|
||||
self._show_version_error_dialog(item_version, [item])
|
||||
log.warning("Update failed", exc_info=True)
|
||||
finally:
|
||||
# Always update the scene inventory view, even if errors occurred
|
||||
self.data_changed.emit()
|
||||
200
openpype/tools/ayon_sceneinventory/window.py
Normal file
200
openpype/tools/ayon_sceneinventory/window.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
import qtawesome
|
||||
|
||||
from openpype import style, resources
|
||||
from openpype.tools.utils.delegates import VersionDelegate
|
||||
from openpype.tools.utils.lib import (
|
||||
preserve_expanded_rows,
|
||||
preserve_selection,
|
||||
)
|
||||
from openpype.tools.ayon_sceneinventory import SceneInventoryController
|
||||
|
||||
from .model import (
|
||||
InventoryModel,
|
||||
FilterProxyModel
|
||||
)
|
||||
from .view import SceneInventoryView
|
||||
|
||||
|
||||
class ControllerVersionDelegate(VersionDelegate):
|
||||
"""Version delegate that uses controller to get project.
|
||||
|
||||
Original VersionDelegate is using 'AvalonMongoDB' object instead. Don't
|
||||
worry about the variable name, object is stored to '_dbcon' attribute.
|
||||
"""
|
||||
|
||||
def get_project_name(self):
|
||||
self._dbcon.get_current_project_name()
|
||||
|
||||
|
||||
class SceneInventoryWindow(QtWidgets.QDialog):
|
||||
"""Scene Inventory window"""
|
||||
|
||||
def __init__(self, controller=None, parent=None):
|
||||
super(SceneInventoryWindow, self).__init__(parent)
|
||||
|
||||
if controller is None:
|
||||
controller = SceneInventoryController()
|
||||
|
||||
project_name = controller.get_current_project_name()
|
||||
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
|
||||
self.setWindowIcon(icon)
|
||||
self.setWindowTitle("Scene Inventory - {}".format(project_name))
|
||||
self.setObjectName("SceneInventory")
|
||||
|
||||
self.resize(1100, 480)
|
||||
|
||||
# region control
|
||||
|
||||
filter_label = QtWidgets.QLabel("Search", self)
|
||||
text_filter = QtWidgets.QLineEdit(self)
|
||||
|
||||
outdated_only_checkbox = QtWidgets.QCheckBox(
|
||||
"Filter to outdated", self
|
||||
)
|
||||
outdated_only_checkbox.setToolTip("Show outdated files only")
|
||||
outdated_only_checkbox.setChecked(False)
|
||||
|
||||
icon = qtawesome.icon("fa.arrow-up", color="white")
|
||||
update_all_button = QtWidgets.QPushButton(self)
|
||||
update_all_button.setToolTip("Update all outdated to latest version")
|
||||
update_all_button.setIcon(icon)
|
||||
|
||||
icon = qtawesome.icon("fa.refresh", color="white")
|
||||
refresh_button = QtWidgets.QPushButton(self)
|
||||
refresh_button.setToolTip("Refresh")
|
||||
refresh_button.setIcon(icon)
|
||||
|
||||
control_layout = QtWidgets.QHBoxLayout()
|
||||
control_layout.addWidget(filter_label)
|
||||
control_layout.addWidget(text_filter)
|
||||
control_layout.addWidget(outdated_only_checkbox)
|
||||
control_layout.addWidget(update_all_button)
|
||||
control_layout.addWidget(refresh_button)
|
||||
|
||||
model = InventoryModel(controller)
|
||||
proxy = FilterProxyModel()
|
||||
proxy.setSourceModel(model)
|
||||
proxy.setDynamicSortFilter(True)
|
||||
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
|
||||
|
||||
view = SceneInventoryView(controller, self)
|
||||
view.setModel(proxy)
|
||||
|
||||
sync_enabled = controller.is_sync_server_enabled()
|
||||
view.setColumnHidden(model.active_site_col, not sync_enabled)
|
||||
view.setColumnHidden(model.remote_site_col, not sync_enabled)
|
||||
|
||||
# set some nice default widths for the view
|
||||
view.setColumnWidth(0, 250) # name
|
||||
view.setColumnWidth(1, 55) # version
|
||||
view.setColumnWidth(2, 55) # count
|
||||
view.setColumnWidth(3, 150) # family
|
||||
view.setColumnWidth(4, 120) # group
|
||||
view.setColumnWidth(5, 150) # loader
|
||||
|
||||
# apply delegates
|
||||
version_delegate = ControllerVersionDelegate(controller, self)
|
||||
column = model.Columns.index("version")
|
||||
view.setItemDelegateForColumn(column, version_delegate)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addLayout(control_layout)
|
||||
layout.addWidget(view)
|
||||
|
||||
show_timer = QtCore.QTimer()
|
||||
show_timer.setInterval(0)
|
||||
show_timer.setSingleShot(False)
|
||||
|
||||
# signals
|
||||
show_timer.timeout.connect(self._on_show_timer)
|
||||
text_filter.textChanged.connect(self._on_text_filter_change)
|
||||
outdated_only_checkbox.stateChanged.connect(
|
||||
self._on_outdated_state_change
|
||||
)
|
||||
view.hierarchy_view_changed.connect(
|
||||
self._on_hierarchy_view_change
|
||||
)
|
||||
view.data_changed.connect(self._on_refresh_request)
|
||||
refresh_button.clicked.connect(self._on_refresh_request)
|
||||
update_all_button.clicked.connect(self._on_update_all)
|
||||
|
||||
self._show_timer = show_timer
|
||||
self._show_counter = 0
|
||||
self._controller = controller
|
||||
self._update_all_button = update_all_button
|
||||
self._outdated_only_checkbox = outdated_only_checkbox
|
||||
self._view = view
|
||||
self._model = model
|
||||
self._proxy = proxy
|
||||
self._version_delegate = version_delegate
|
||||
|
||||
self._first_show = True
|
||||
self._first_refresh = True
|
||||
|
||||
def showEvent(self, event):
|
||||
super(SceneInventoryWindow, self).showEvent(event)
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
self._show_counter = 0
|
||||
self._show_timer.start()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""Custom keyPressEvent.
|
||||
|
||||
Override keyPressEvent to do nothing so that Maya's panels won't
|
||||
take focus when pressing "SHIFT" whilst mouse is over viewport or
|
||||
outliner. This way users don't accidentally perform Maya commands
|
||||
whilst trying to name an instance.
|
||||
|
||||
"""
|
||||
|
||||
def _on_refresh_request(self):
|
||||
"""Signal callback to trigger 'refresh' without any arguments."""
|
||||
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, containers=None):
|
||||
self._first_refresh = False
|
||||
self._controller.reset()
|
||||
with preserve_expanded_rows(
|
||||
tree_view=self._view,
|
||||
role=self._model.UniqueRole
|
||||
):
|
||||
with preserve_selection(
|
||||
tree_view=self._view,
|
||||
role=self._model.UniqueRole,
|
||||
current_index=False
|
||||
):
|
||||
kwargs = {"containers": containers}
|
||||
# TODO do not touch view's inner attribute
|
||||
if self._view._hierarchy_view:
|
||||
kwargs["selected"] = self._view._selected
|
||||
self._model.refresh(**kwargs)
|
||||
|
||||
def _on_show_timer(self):
|
||||
if self._show_counter < 3:
|
||||
self._show_counter += 1
|
||||
return
|
||||
self._show_timer.stop()
|
||||
self.refresh()
|
||||
|
||||
def _on_hierarchy_view_change(self, enabled):
|
||||
self._proxy.set_hierarchy_view(enabled)
|
||||
self._model.set_hierarchy_view(enabled)
|
||||
|
||||
def _on_text_filter_change(self, text_filter):
|
||||
if hasattr(self._proxy, "setFilterRegExp"):
|
||||
self._proxy.setFilterRegExp(text_filter)
|
||||
else:
|
||||
self._proxy.setFilterRegularExpression(text_filter)
|
||||
|
||||
def _on_outdated_state_change(self):
|
||||
self._proxy.set_filter_outdated(
|
||||
self._outdated_only_checkbox.isChecked()
|
||||
)
|
||||
|
||||
def _on_update_all(self):
|
||||
self._view.update_all()
|
||||
|
|
@ -12,6 +12,7 @@ from .hierarchy import (
|
|||
HierarchyModel,
|
||||
HIERARCHY_MODEL_SENDER,
|
||||
)
|
||||
from .thumbnails import ThumbnailsModel
|
||||
|
||||
|
||||
__all__ = (
|
||||
|
|
@ -26,4 +27,6 @@ __all__ = (
|
|||
"TaskItem",
|
||||
"HierarchyModel",
|
||||
"HIERARCHY_MODEL_SENDER",
|
||||
|
||||
"ThumbnailsModel",
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue