Merge branch 'develop' into feature/OP-6460_maya-multishot-layout

This commit is contained in:
Ondřej Samohel 2023-10-16 10:06:14 +02:00 committed by GitHub
commit 18afb46e2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
127 changed files with 12156 additions and 572 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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"

View file

@ -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({

View file

@ -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."""

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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"]:

View file

@ -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)

View file

@ -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):

View file

@ -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)

View 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()

View file

@ -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"
)

View file

@ -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.")

View file

@ -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)

View file

@ -17,7 +17,7 @@ class SetFrameRangeLoader(load.LoaderPlugin):
"yeticache",
"pointcache"]
representations = ["*"]
extension = {"*"}
extensions = {"*"}
label = "Set frame range"
order = 11

View file

@ -27,7 +27,7 @@ class LoadBackdropNodes(load.LoaderPlugin):
families = ["workfile", "nukenodes"]
representations = ["*"]
extension = {"nk"}
extensions = {"nk"}
label = "Import Nuke Nodes"
order = 0

View file

@ -26,7 +26,7 @@ class AlembicCameraLoader(load.LoaderPlugin):
families = ["camera"]
representations = ["*"]
extension = {"abc"}
extensions = {"abc"}
label = "Load Alembic Camera"
icon = "camera"

View file

@ -24,7 +24,7 @@ class LoadEffects(load.LoaderPlugin):
families = ["effect"]
representations = ["*"]
extension = {"json"}
extensions = {"json"}
label = "Load Effects - nodes"
order = 0

View file

@ -25,7 +25,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin):
families = ["effect"]
representations = ["*"]
extension = {"json"}
extensions = {"json"}
label = "Load Effects - Input Process"
order = 0

View file

@ -26,7 +26,7 @@ class LoadGizmo(load.LoaderPlugin):
families = ["gizmo"]
representations = ["*"]
extension = {"gizmo"}
extensions = {"gizmo"}
label = "Load Gizmo"
order = 0

View file

@ -28,7 +28,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin):
families = ["gizmo"]
representations = ["*"]
extension = {"gizmo"}
extensions = {"gizmo"}
label = "Load Gizmo - Input Process"
order = 0

View file

@ -9,7 +9,7 @@ class MatchmoveLoader(load.LoaderPlugin):
families = ["matchmove"]
representations = ["*"]
extension = {"py"}
extensions = {"py"}
defaults = ["Camera", "Object"]

View file

@ -24,7 +24,7 @@ class AlembicModelLoader(load.LoaderPlugin):
families = ["model", "pointcache", "animation"]
representations = ["*"]
extension = {"abc"}
extensions = {"abc"}
label = "Load Alembic"
icon = "cube"

View file

@ -22,7 +22,7 @@ class LinkAsGroup(load.LoaderPlugin):
families = ["workfile", "nukenodes"]
representations = ["*"]
extension = {"nk"}
extensions = {"nk"}
label = "Load Precomp"
order = 0

View file

@ -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))

View file

@ -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')

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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)
)

View 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))

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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(

View file

@ -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()

View file

@ -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:

View file

@ -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)

View file

@ -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>

View file

@ -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>

View 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()

View file

@ -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
}
)

View file

@ -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)])

View file

@ -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")

View file

@ -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)
)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)
)

View file

@ -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")
)
)

View file

@ -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",

View file

@ -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."""

View file

@ -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
)

View file

@ -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"]))

View file

@ -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

View file

@ -829,6 +829,11 @@
"redshift_render_attributes": [],
"renderman_render_attributes": []
},
"ValidateResolution": {
"enabled": true,
"optional": true,
"active": true
},
"ValidateCurrentRenderLayerIsRenderable": {
"enabled": true,
"optional": false,

View file

@ -341,7 +341,7 @@
"write"
]
},
"ValidateCorrectAssetName": {
"ValidateCorrectAssetContext": {
"enabled": true,
"optional": true,
"active": true

View file

@ -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",

View file

@ -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"

View file

@ -61,7 +61,7 @@
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateCorrectAssetName",
"key": "ValidateCorrectAssetContext",
"label": "Validate Correct Asset Name"
}
]

View file

@ -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"
}
]
}
}
]
}

View file

@ -9,6 +9,10 @@
"type": "schema",
"name": "schema_maya"
},
{
"type": "schema",
"name": "schema_mayapy"
},
{
"type": "schema",
"name": "schema_3dsmax"

View file

@ -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)

View file

@ -0,0 +1,6 @@
from .control import LoaderController
__all__ = (
"LoaderController",
)

View 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

View 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")

View file

@ -0,0 +1,10 @@
from .selection import SelectionModel
from .products import ProductsModel
from .actions import LoaderActionsModel
__all__ = (
"SelectionModel",
"ProductsModel",
"LoaderActionsModel",
)

View 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

View 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)

View 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,
}
)

View file

@ -0,0 +1,6 @@
from .window import LoaderWindow
__all__ = (
"LoaderWindow",
)

View 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)

View 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)

View 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)

View 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()

View 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)

View 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)

View 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)

View 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()

View 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,
)

View 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()

View file

@ -0,0 +1,6 @@
from .control import SceneInventoryController
__all__ = (
"SceneInventoryController",
)

View 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()

View 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

View file

@ -0,0 +1,6 @@
from .site_sync import SiteSyncModel
__all__ = (
"SiteSyncModel",
)

View 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

View file

@ -0,0 +1,6 @@
from .dialog import SwitchAssetDialog
__all__ = (
"SwitchAssetDialog",
)

File diff suppressed because it is too large Load diff

View file

@ -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)

View 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)

View 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()

View 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()

View file

@ -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