diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index cf3cb8ba1a..87d904fc84 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,10 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.17.1-nightly.2
+ - 3.17.1-nightly.1
+ - 3.17.0
+ - 3.16.7
- 3.16.7-nightly.2
- 3.16.7-nightly.1
- 3.16.6
@@ -131,10 +135,6 @@ body:
- 3.14.10-nightly.5
- 3.14.10-nightly.4
- 3.14.10-nightly.3
- - 3.14.10-nightly.2
- - 3.14.10-nightly.1
- - 3.14.9
- - 3.14.9-nightly.5
validations:
required: true
- type: dropdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d7620869b..4bcf66a210 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,379 @@
# Changelog
+## [3.17.0](https://github.com/ynput/OpenPype/tree/3.17.0)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.7...3.17.0)
+
+### **🚀 Enhancements**
+
+
+
+Chore: Remove schema from OpenPype root #5355
+
+Remove unused schema directory in root of repository which was moved inside openpype/pipeline/schema.
+
+
+___
+
+
+
+
+
+Igniter: Allow custom Qt scale factor rounding policy #5554
+
+Do not force `PassThrough` rounding policy if different policy is defined via env variable.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Chore: Lower urllib3 to support older OpenSSL #5538
+
+Lowered `urllib3` to `1.26.16` to support older OpenSSL.
+
+
+___
+
+
+
+
+
+Chore: Do not try to add schema to zip files #5557
+
+Do not add `schema` folder to zip file. This fixes issue cause by https://github.com/ynput/OpenPype/pull/5355 .
+
+
+___
+
+
+
+
+
+Chore: Lower click dependency version #5629
+
+Lower click version to support older versions of python.
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Bump certifi from 2023.5.7 to 2023.7.22 #5351
+
+Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.5.7 to 2023.7.22.
+
+Commits
+
+
+
+
+
+[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
+
+You can trigger a rebase of this PR by commenting `@dependabot rebase`.
+
+[//]: # (dependabot-automerge-start)
+[//]: # (dependabot-automerge-end)
+
+---
+
+
+Dependabot commands and options
+
+
+You can trigger Dependabot actions by commenting on this PR:
+- `@dependabot rebase` will rebase this PR
+- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
+- `@dependabot merge` will merge this PR after your CI passes on it
+- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
+- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
+- `@dependabot reopen` will reopen this PR if it is closed
+- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
+- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
+- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
+- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
+You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ynput/OpenPype/network/alerts).
+
+
+
+> **Note**
+> Automatic rebases have been disabled on this pull request as it has been open for over 30 days.
+
+___
+
+
+
+
+
+
+## [3.16.7](https://github.com/ynput/OpenPype/tree/3.16.7)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.6...3.16.7)
+
+### **🆕 New features**
+
+
+
+Maya: Extract active view as thumbnail when no thumbnail set #5426
+
+This sets the Maya instance's thumbnail to the current active view if no thumbnail was set yet.
+
+
+___
+
+
+
+
+
+Maya: Implement USD publish and load using native `mayaUsdPlugin` #5573
+
+Implement Creator and Loaders for extraction and loading of USD files using Maya's own `mayaUsdPlugin`.Also adds support to load a `usd` file into an Arnold Standin (`aiStandin`) and assigning looks to it.
+
+
+___
+
+
+
+
+
+AYON: Ignore separated modules #5619
+
+Do not load already separated modules from default directory.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Maya: Reduce amount of code for Collect Looks #5253
+
+- Refactor `get_file_node_files` because popping from `paths` by index should have been done in reversed order anyway. It's now changed to not need popping at all.
+- Removed unused `RENDERER_NODE_TYPES` and if-branch which collected `node_attrs` list which was unused + collected members which was also done outside of the if branch and thus generated no extra data.
+- Collected all materials from look set attributes at once instead of multiple queries
+- Collected all file nodes in history from a single query instead of per type
+- Restructured assignment of `instance.data["resources"]` to be more readable
+- Cached `PXR_NODES` only ones (Note: plugin load is checked on discovery of the collect look plugin) instead of querying plugin load and its nodes per file node per attribute
+- Removed some debug logs or combined some messages
+
+
+___
+
+
+
+
+
+AYON: Mark deprecated settings in Maya #5627
+
+Added deprecated info to docstrings of maya colormanagement settings.Resolves: https://github.com/ynput/OpenPype/issues/5556
+
+
+___
+
+
+
+
+
+Max: switching versions of maxScene maintain parentage/links with the loaders #5424
+
+When using scene inventory to manage or update the version of the loading objects, the linked modifiers or parentage of the objects would be kept.Meanwhile, loaded objects from all loaders no longer parented to the container with OP Data.
+
+
+___
+
+
+
+
+
+3ds max: small tweaks to obj extractor and model publishing flow #5605
+
+There migh be situation where OBJ Extractor passes without failure, but no obj file is produced. This is adding simple check directly into the extractor to catch it earlier then in the integration phase. Also switched `Validate USD Plugin` to optional, because it was always run no matter if the Extract USD was enabled or not, hindering testing (and publishing).
+
+
+___
+
+
+
+
+
+TVPaint: Plugin can be reopened #5610
+
+TVPaint plugin can be reopened.
+
+
+___
+
+
+
+
+
+Maya: Remove context prompt #5632
+
+More of a plea than a PR, but could we please remove the context prompt in Maya when switching tasks?
+
+
+___
+
+
+
+
+
+General: Create a desktop icon is checked #5636
+
+In OP Installer `Create a desktop icon` is checked by default.
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Extract look is not AYON compatible - OP-5375 #5341
+
+The textures that would use hardlinking are going through texture processors. Currently all texture processors are hardcoded to copy texture instead of respecting the settings of forcing to copy.The texture processors were last modified 4 months ago, so effectively all clients that are on any pipeline updated in the last 4 months wont be utilizing hardlinking at all, since the hardcoded texture processors will copy texture no matter the OS.This opts for completely disabling the hardlinking feature, while we figure out what to do about it.
+
+
+___
+
+
+
+
+
+Maya: Multiverse USD Override inherit from correct new style creator #5566
+
+Fix Creator for Multiverse USD Override by inheriting from correct new style creator class type
+
+
+___
+
+
+
+
+
+Max: Bug Fix Alembic Loaders with Ornatrix #5434
+
+Bugfix the alembic loader with both ornatrix alembic and max alembic supportsAdd the ornatrix alembic loaders for loading the alembic with Ornatrix-related modifiers.
+
+
+___
+
+
+
+
+
+AYON: Avoid creation of duplicated links #5593
+
+Handle cases when an existing link should be recreated and do not create the same link multitple times during single publishing.
+
+
+___
+
+
+
+
+
+Extract Review: Multilayer specification for ffmpeg #5613
+
+Extract review is specifying layer name when exr is multilayer.
+
+
+___
+
+
+
+
+
+Fussion: added support for Fusion 17 #5614
+
+Fusion 17 still uses Python 3.6 which causes issues with some our delivered libraries. Vendorized necessary set for Python 3.6
+
+
+___
+
+
+
+
+
+Publisher: Fix screenshot widget #5615
+
+Use correct super method name.EDITED:Removed fade animation which is not triggered at some cases, e.g. in Nuke the animation does not start. I do expect that is caused by `exec_` on the dialog, which blocks event processing to the animation, even when I've added the window as parent it still didn't trigger registered callback.Modified how the "empty" space is not filled by using paths instead of clear mode on painter. Added render hints to add antialiasing.
+
+
+___
+
+
+
+
+
+Photoshop: auto_images without alpha will not fail #5620
+
+ExtractReview caused issue on `auto_image` instance without alpha channel, this fixes it.
+
+
+___
+
+
+
+
+
+Fix - _id key used instead of id in get_last_version_by_subset_name #5626
+
+Just 'id' is not returned because value in fields. Caused KeyError.
+
+
+___
+
+
+
+
+
+Bugfix: create symlinks for ssl libs on Centos 7 #5633
+
+Docker build was missing `libssl.1.1.so` and `libcrypto.1.1.so` symlinks needed by the executable itself, because Python is now explicitly built with OpenSSL 1.1.1
+
+
+___
+
+
+
+### **📃 Documentation**
+
+
+
+Documentation/local settings #5102
+
+I completed the "Working with local settings" page. I updated the screenshot, wrote an explanation for each empty category, and if available, linked the more detailed pages already existing. I also added the "Environments" category.
+
+
+___
+
+
+
+
+
+
## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6)
diff --git a/igniter/__init__.py b/igniter/__init__.py
index 16ffb940f6..085a825860 100644
--- a/igniter/__init__.py
+++ b/igniter/__init__.py
@@ -34,7 +34,11 @@ def _get_qt_app():
if attr is not None:
QtWidgets.QApplication.setAttribute(attr)
- if hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy"):
+ policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY")
+ if (
+ hasattr(QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy")
+ and not policy
+ ):
QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy(
QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py
index 408764e1a8..e7b440f812 100644
--- a/igniter/bootstrap_repos.py
+++ b/igniter/bootstrap_repos.py
@@ -589,7 +589,7 @@ class BootstrapRepos:
self.registry = OpenPypeSettingsRegistry()
self.zip_filter = [".pyc", "__pycache__"]
self.openpype_filter = [
- "openpype", "schema", "LICENSE"
+ "openpype", "LICENSE"
]
# dummy progress reporter
diff --git a/inno_setup.iss b/inno_setup.iss
index 418bedbd4d..d9a41d22ee 100644
--- a/inno_setup.iss
+++ b/inno_setup.iss
@@ -36,7 +36,7 @@ WizardStyle=modern
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
-Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[InstallDelete]
; clean everything in previous installation folder
@@ -53,4 +53,3 @@ Name: "{autodesktop}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe"
[Run]
Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent
-
diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py
index f67a1ef9c4..8c18cb1c13 100644
--- a/openpype/client/server/conversion_utils.py
+++ b/openpype/client/server/conversion_utils.py
@@ -235,6 +235,8 @@ def convert_v4_project_to_v3(project):
new_task_types = {}
for task_type in task_types:
name = task_type.pop("name")
+ # Change 'shortName' to 'short_name'
+ task_type["short_name"] = task_type.pop("shortName", None)
new_task_types[name] = task_type
config["tasks"] = new_task_types
diff --git a/openpype/hosts/fusion/api/__init__.py b/openpype/hosts/fusion/api/__init__.py
index dba55a98d9..aabc624016 100644
--- a/openpype/hosts/fusion/api/__init__.py
+++ b/openpype/hosts/fusion/api/__init__.py
@@ -3,9 +3,7 @@ from .pipeline import (
ls,
imprint_container,
- parse_container,
- list_instances,
- remove_instance
+ parse_container
)
from .lib import (
@@ -22,6 +20,7 @@ from .menu import launch_openpype_menu
__all__ = [
# pipeline
+ "FusionHost",
"ls",
"imprint_container",
@@ -32,6 +31,7 @@ __all__ = [
"update_frame_range",
"set_asset_framerange",
"get_current_comp",
+ "get_bmd_library",
"comp_lock_and_undo_chunk",
# menu
diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py
index d96557571b..c4a1488606 100644
--- a/openpype/hosts/fusion/api/lib.py
+++ b/openpype/hosts/fusion/api/lib.py
@@ -181,80 +181,6 @@ def validate_comp_prefs(comp=None, force_repair=False):
dialog.setStyleSheet(load_stylesheet())
-def switch_item(container,
- asset_name=None,
- subset_name=None,
- representation_name=None):
- """Switch container asset, subset or representation of a container by name.
-
- It'll always switch to the latest version - of course a different
- approach could be implemented.
-
- Args:
- container (dict): data of the item to switch with
- asset_name (str): name of the asset
- subset_name (str): name of the subset
- representation_name (str): name of the representation
-
- Returns:
- dict
-
- """
-
- if all(not x for x in [asset_name, subset_name, representation_name]):
- raise ValueError("Must have at least one change provided to switch.")
-
- # Collect any of current asset, subset and representation if not provided
- # so we can use the original name from those.
- project_name = get_current_project_name()
- if any(not x for x in [asset_name, subset_name, representation_name]):
- repre_id = container["representation"]
- representation = get_representation_by_id(project_name, repre_id)
- repre_parent_docs = get_representation_parents(
- project_name, representation)
- if repre_parent_docs:
- version, subset, asset, _ = repre_parent_docs
- else:
- version = subset = asset = None
-
- if asset_name is None:
- asset_name = asset["name"]
-
- if subset_name is None:
- subset_name = subset["name"]
-
- if representation_name is None:
- representation_name = representation["name"]
-
- # Find the new one
- asset = get_asset_by_name(project_name, asset_name, fields=["_id"])
- assert asset, ("Could not find asset in the database with the name "
- "'%s'" % asset_name)
-
- subset = get_subset_by_name(
- project_name, subset_name, asset["_id"], fields=["_id"]
- )
- assert subset, ("Could not find subset in the database with the name "
- "'%s'" % subset_name)
-
- version = get_last_version_by_subset_id(
- project_name, subset["_id"], fields=["_id"]
- )
- assert version, "Could not find a version for {}.{}".format(
- asset_name, subset_name
- )
-
- representation = get_representation_by_name(
- project_name, representation_name, version["_id"]
- )
- assert representation, ("Could not find representation in the database "
- "with the name '%s'" % representation_name)
-
- switch_container(container, representation)
-
- return representation
-
-
@contextlib.contextmanager
def maintained_selection(comp=None):
"""Reset comp selection from before the context after the context"""
diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py
index a768a3f0f8..a886086758 100644
--- a/openpype/hosts/fusion/api/pipeline.py
+++ b/openpype/hosts/fusion/api/pipeline.py
@@ -287,49 +287,6 @@ def parse_container(tool):
return container
-# TODO: Function below is currently unused prototypes
-def list_instances(creator_id=None):
- """Return created instances in current workfile which will be published.
- Returns:
- (list) of dictionaries matching instances format
- """
-
- comp = get_current_comp()
- tools = comp.GetToolList(False).values()
-
- instance_signature = {
- "id": "pyblish.avalon.instance",
- "identifier": creator_id
- }
- instances = []
- for tool in tools:
-
- data = tool.GetData('openpype')
- if not isinstance(data, dict):
- continue
-
- if data.get("id") != instance_signature["id"]:
- continue
-
- if creator_id and data.get("identifier") != creator_id:
- continue
-
- instances.append(tool)
-
- return instances
-
-
-# TODO: Function below is currently unused prototypes
-def remove_instance(instance):
- """Remove instance from current workfile.
-
- Args:
- instance (dict): instance representation from subsetmanager model
- """
- # Assume instance is a Fusion tool directly
- instance["tool"].Delete()
-
-
class FusionEventThread(QtCore.QThread):
"""QThread which will periodically ping Fusion app for any events.
The fusion.UIManager must be set up to be notified of events before they'll
diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py
index 75c7ff9fee..a3f691e1fc 100644
--- a/openpype/hosts/houdini/api/lib.py
+++ b/openpype/hosts/houdini/api/lib.py
@@ -649,3 +649,101 @@ def get_color_management_preferences():
"display": hou.Color.ocio_defaultDisplay(),
"view": hou.Color.ocio_defaultView()
}
+
+
+def get_obj_node_output(obj_node):
+ """Find output node.
+
+ If the node has any output node return the
+ output node with the minimum `outputidx`.
+ When no output is present return the node
+ with the display flag set. If no output node is
+ detected then None is returned.
+
+ Arguments:
+ node (hou.Node): The node to retrieve a single
+ the output node for.
+
+ Returns:
+ Optional[hou.Node]: The child output node.
+
+ """
+
+ outputs = obj_node.subnetOutputs()
+ if not outputs:
+ return
+
+ elif len(outputs) == 1:
+ return outputs[0]
+
+ else:
+ return min(outputs,
+ key=lambda node: node.evalParm('outputidx'))
+
+
+def get_output_children(output_node, include_sops=True):
+ """Recursively return a list of all output nodes
+ contained in this node including this node.
+
+ It works in a similar manner to output_node.allNodes().
+ """
+ out_list = [output_node]
+
+ if output_node.childTypeCategory() == hou.objNodeTypeCategory():
+ for child in output_node.children():
+ out_list += get_output_children(child, include_sops=include_sops)
+
+ elif include_sops and \
+ output_node.childTypeCategory() == hou.sopNodeTypeCategory():
+ out = get_obj_node_output(output_node)
+ if out:
+ out_list += [out]
+
+ return out_list
+
+
+def get_resolution_from_doc(doc):
+ """Get resolution from the given asset document. """
+
+ if not doc or "data" not in doc:
+ print("Entered document is not valid. \"{}\"".format(str(doc)))
+ return None
+
+ resolution_width = doc["data"].get("resolutionWidth")
+ resolution_height = doc["data"].get("resolutionHeight")
+
+ # Make sure both width and height are set
+ if resolution_width is None or resolution_height is None:
+ print("No resolution information found for \"{}\"".format(doc["name"]))
+ return None
+
+ return int(resolution_width), int(resolution_height)
+
+
+def set_camera_resolution(camera, asset_doc=None):
+ """Apply resolution to camera from asset document of the publish"""
+
+ if not asset_doc:
+ asset_doc = get_current_project_asset()
+
+ resolution = get_resolution_from_doc(asset_doc)
+
+ if resolution:
+ print("Setting camera resolution: {} -> {}x{}".format(
+ camera.name(), resolution[0], resolution[1]
+ ))
+ camera.parm("resx").set(resolution[0])
+ camera.parm("resy").set(resolution[1])
+
+
+def get_camera_from_container(container):
+ """Get camera from container node. """
+
+ cameras = container.recursiveGlob(
+ "*",
+ filter=hou.nodeTypeFilter.ObjCamera,
+ include_subnets=False
+ )
+
+ assert len(cameras) == 1, "Camera instance must have only one camera"
+ return cameras[0]
diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py
index c9ae801af5..6aa65deb89 100644
--- a/openpype/hosts/houdini/api/pipeline.py
+++ b/openpype/hosts/houdini/api/pipeline.py
@@ -14,6 +14,7 @@ import pyblish.api
from openpype.pipeline import (
register_creator_plugin_path,
register_loader_plugin_path,
+ register_inventory_action_path,
AVALON_CONTAINER_ID,
)
from openpype.pipeline.load import any_outdated_containers
@@ -55,6 +56,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
pyblish.api.register_plugin_path(PUBLISH_PATH)
register_loader_plugin_path(LOAD_PATH)
register_creator_plugin_path(CREATE_PATH)
+ register_inventory_action_path(INVENTORY_PATH)
log.info("Installing callbacks ... ")
# register_event_callback("init", on_init)
diff --git a/openpype/hosts/houdini/plugins/create/create_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py
new file mode 100644
index 0000000000..ea0b36f03f
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""Creator for Unreal Static Meshes."""
+from openpype.hosts.houdini.api import plugin
+from openpype.lib import BoolDef, EnumDef
+
+import hou
+
+
+class CreateStaticMesh(plugin.HoudiniCreator):
+ """Static Meshes as FBX. """
+
+ identifier = "io.openpype.creators.houdini.staticmesh.fbx"
+ label = "Static Mesh (FBX)"
+ family = "staticMesh"
+ icon = "fa5s.cubes"
+
+ default_variants = ["Main"]
+
+ def create(self, subset_name, instance_data, pre_create_data):
+
+ instance_data.update({"node_type": "filmboxfbx"})
+
+ instance = super(CreateStaticMesh, self).create(
+ subset_name,
+ instance_data,
+ pre_create_data)
+
+ # get the created rop node
+ instance_node = hou.node(instance.get("instance_node"))
+
+ # prepare parms
+ output_path = hou.text.expandString(
+ "$HIP/pyblish/{}.fbx".format(subset_name)
+ )
+
+ parms = {
+ "startnode": self.get_selection(),
+ "sopoutput": output_path,
+ # vertex cache format
+ "vcformat": pre_create_data.get("vcformat"),
+ "convertunits": pre_create_data.get("convertunits"),
+ # set render range to use frame range start-end frame
+ "trange": 1,
+ "createsubnetroot": pre_create_data.get("createsubnetroot")
+ }
+
+ # set parms
+ instance_node.setParms(parms)
+
+ # Lock any parameters in this list
+ to_lock = ["family", "id"]
+ self.lock_parameters(instance_node, to_lock)
+
+ def get_network_categories(self):
+ return [
+ hou.ropNodeTypeCategory(),
+ hou.sopNodeTypeCategory()
+ ]
+
+ def get_pre_create_attr_defs(self):
+ """Add settings for users. """
+
+ attrs = super(CreateStaticMesh, self).get_pre_create_attr_defs()
+ createsubnetroot = BoolDef("createsubnetroot",
+ tooltip="Create an extra root for the "
+ "Export node when it's a "
+ "subnetwork. This causes the "
+ "exporting subnetwork node to be "
+ "represented in the FBX file.",
+ default=False,
+ label="Create Root for Subnet")
+ vcformat = EnumDef("vcformat",
+ items={
+ 0: "Maya Compatible (MC)",
+ 1: "3DS MAX Compatible (PC2)"
+ },
+ default=0,
+ label="Vertex Cache Format")
+ convert_units = BoolDef("convertunits",
+ tooltip="When on, the FBX is converted"
+ "from the current Houdini "
+ "system units to the native "
+ "FBX unit of centimeters.",
+ default=False,
+ label="Convert Units")
+
+ return attrs + [createsubnetroot, vcformat, convert_units]
+
+ def get_dynamic_data(
+ self, variant, task_name, asset_doc, project_name, host_name, instance
+ ):
+ """
+ The default subset name templates for Unreal include {asset} and thus
+ we should pass that along as dynamic data.
+ """
+ dynamic_data = super(CreateStaticMesh, self).get_dynamic_data(
+ variant, task_name, asset_doc, project_name, host_name, instance
+ )
+ dynamic_data["asset"] = asset_doc["name"]
+ return dynamic_data
+
+ def get_selection(self):
+ """Selection Logic.
+
+ how self.selected_nodes should be processed to get
+ the desirable node from selection.
+
+ Returns:
+ str : node path
+ """
+
+ selection = ""
+
+ if self.selected_nodes:
+ selected_node = self.selected_nodes[0]
+
+ # Accept sop level nodes (e.g. /obj/geo1/box1)
+ if isinstance(selected_node, hou.SopNode):
+ selection = selected_node.path()
+ self.log.debug(
+ "Valid SopNode selection, 'Export' in filmboxfbx"
+ " will be set to '%s'.", selected_node
+ )
+
+ # Accept object level nodes (e.g. /obj/geo1)
+ elif isinstance(selected_node, hou.ObjNode):
+ selection = selected_node.path()
+ self.log.debug(
+ "Valid ObjNode selection, 'Export' in filmboxfbx "
+ "will be set to the child path '%s'.", selection
+ )
+
+ else:
+ self.log.debug(
+ "Selection isn't valid. 'Export' in "
+ "filmboxfbx will be empty."
+ )
+ else:
+ self.log.debug(
+ "No Selection. 'Export' in filmboxfbx will be empty."
+ )
+
+ return selection
diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py
new file mode 100644
index 0000000000..18ececb019
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py
@@ -0,0 +1,26 @@
+from openpype.pipeline import InventoryAction
+from openpype.hosts.houdini.api.lib import (
+ get_camera_from_container,
+ set_camera_resolution
+)
+from openpype.pipeline.context_tools import get_current_project_asset
+
+
+class SetCameraResolution(InventoryAction):
+
+ label = "Set Camera Resolution"
+ icon = "desktop"
+ color = "orange"
+
+ @staticmethod
+ def is_compatible(container):
+ return (
+ container.get("loader") == "CameraLoader"
+ )
+
+ def process(self, containers):
+ asset_doc = get_current_project_asset()
+ for container in containers:
+ node = container["node"]
+ camera = get_camera_from_container(node)
+ set_camera_resolution(camera, asset_doc)
diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py
index 7b4a04809e..e16146a267 100644
--- a/openpype/hosts/houdini/plugins/load/load_camera.py
+++ b/openpype/hosts/houdini/plugins/load/load_camera.py
@@ -4,6 +4,13 @@ from openpype.pipeline import (
)
from openpype.hosts.houdini.api import pipeline
+from openpype.hosts.houdini.api.lib import (
+ set_camera_resolution,
+ get_camera_from_container
+)
+
+import hou
+
ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")'
'.alembicGetCameraDict')
@@ -25,7 +32,15 @@ def transfer_non_default_values(src, dest, ignore=None):
channel expression and ignore certain Parm types.
"""
- import hou
+
+ ignore_types = {
+ hou.parmTemplateType.Toggle,
+ hou.parmTemplateType.Menu,
+ hou.parmTemplateType.Button,
+ hou.parmTemplateType.FolderSet,
+ hou.parmTemplateType.Separator,
+ hou.parmTemplateType.Label,
+ }
src.updateParmStates()
@@ -62,14 +77,6 @@ def transfer_non_default_values(src, dest, ignore=None):
continue
# Ignore folders, separators, etc.
- ignore_types = {
- hou.parmTemplateType.Toggle,
- hou.parmTemplateType.Menu,
- hou.parmTemplateType.Button,
- hou.parmTemplateType.FolderSet,
- hou.parmTemplateType.Separator,
- hou.parmTemplateType.Label,
- }
if parm.parmTemplate().type() in ignore_types:
continue
@@ -90,13 +97,8 @@ class CameraLoader(load.LoaderPlugin):
def load(self, context, name=None, namespace=None, data=None):
- import os
- import hou
-
# Format file name, Houdini only wants forward slashes
- file_path = self.filepath_from_context(context)
- file_path = os.path.normpath(file_path)
- file_path = file_path.replace("\\", "/")
+ file_path = self.filepath_from_context(context).replace("\\", "/")
# Get the root node
obj = hou.node("/obj")
@@ -106,19 +108,21 @@ class CameraLoader(load.LoaderPlugin):
node_name = "{}_{}".format(namespace, name) if namespace else name
# Create a archive node
- container = self.create_and_connect(obj, "alembicarchive", node_name)
+ node = self.create_and_connect(obj, "alembicarchive", node_name)
# TODO: add FPS of project / asset
- container.setParms({"fileName": file_path,
- "channelRef": True})
+ node.setParms({"fileName": file_path, "channelRef": True})
# Apply some magic
- container.parm("buildHierarchy").pressButton()
- container.moveToGoodPosition()
+ node.parm("buildHierarchy").pressButton()
+ node.moveToGoodPosition()
# Create an alembic xform node
- nodes = [container]
+ nodes = [node]
+ camera = get_camera_from_container(node)
+ self._match_maya_render_mask(camera)
+ set_camera_resolution(camera, asset_doc=context["asset"])
self[:] = nodes
return pipeline.containerise(node_name,
@@ -143,14 +147,14 @@ class CameraLoader(load.LoaderPlugin):
# Store the cam temporarily next to the Alembic Archive
# so that we can preserve parm values the user set on it
# after build hierarchy was triggered.
- old_camera = self._get_camera(node)
+ old_camera = get_camera_from_container(node)
temp_camera = old_camera.copyTo(node.parent())
# Rebuild
node.parm("buildHierarchy").pressButton()
# Apply values to the new camera
- new_camera = self._get_camera(node)
+ new_camera = get_camera_from_container(node)
transfer_non_default_values(temp_camera,
new_camera,
# The hidden uniform scale attribute
@@ -158,6 +162,9 @@ class CameraLoader(load.LoaderPlugin):
# "icon_scale" just skip that completely
ignore={"scale"})
+ self._match_maya_render_mask(new_camera)
+ set_camera_resolution(new_camera)
+
temp_camera.destroy()
def remove(self, container):
@@ -165,15 +172,6 @@ class CameraLoader(load.LoaderPlugin):
node = container["node"]
node.destroy()
- def _get_camera(self, node):
- import hou
- cameras = node.recursiveGlob("*",
- filter=hou.nodeTypeFilter.ObjCamera,
- include_subnets=False)
-
- assert len(cameras) == 1, "Camera instance must have only one camera"
- return cameras[0]
-
def create_and_connect(self, node, node_type, name=None):
"""Create a node within a node which and connect it to the input
@@ -194,5 +192,20 @@ class CameraLoader(load.LoaderPlugin):
new_node.moveToGoodPosition()
return new_node
- def switch(self, container, representation):
- self.update(container, representation)
+ def _match_maya_render_mask(self, camera):
+ """Workaround to match Maya render mask in Houdini"""
+
+ # print("Setting match maya render mask ")
+ parm = camera.parm("aperture")
+ expression = parm.expression()
+ expression = expression.replace("return ", "aperture = ")
+ expression += """
+# Match maya render mask (logic from Houdini's own FBX importer)
+node = hou.pwd()
+resx = node.evalParm('resx')
+resy = node.evalParm('resy')
+aspect = node.evalParm('aspect')
+aperture *= min(1, (resx / resy * aspect) / 1.5)
+return aperture
+"""
+ parm.setExpression(expression, language=hou.exprLanguage.Python)
diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py
new file mode 100644
index 0000000000..cac22d62d4
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/load/load_fbx.py
@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+"""Fbx Loader for houdini. """
+from openpype.pipeline import (
+ load,
+ get_representation_path,
+)
+from openpype.hosts.houdini.api import pipeline
+
+
+class FbxLoader(load.LoaderPlugin):
+ """Load fbx files. """
+
+ label = "Load FBX"
+ icon = "code-fork"
+ color = "orange"
+
+ order = -10
+
+ families = ["staticMesh", "fbx"]
+ representations = ["fbx"]
+
+ def load(self, context, name=None, namespace=None, data=None):
+
+ # get file path from context
+ file_path = self.filepath_from_context(context)
+ file_path = file_path.replace("\\", "/")
+
+ # get necessary data
+ namespace, node_name = self.get_node_name(context, name, namespace)
+
+ # create load tree
+ nodes = self.create_load_node_tree(file_path, node_name, name)
+
+ self[:] = nodes
+
+ # Call containerise function which does some automations for you
+ # like moving created nodes to the AVALON_CONTAINERS subnetwork
+ containerised_nodes = pipeline.containerise(
+ node_name,
+ namespace,
+ nodes,
+ context,
+ self.__class__.__name__,
+ suffix="",
+ )
+
+ return containerised_nodes
+
+ def update(self, container, representation):
+
+ node = container["node"]
+ try:
+ file_node = next(
+ n for n in node.children() if n.type().name() == "file"
+ )
+ except StopIteration:
+ self.log.error("Could not find node of type `file`")
+ return
+
+ # Update the file path from representation
+ file_path = get_representation_path(representation)
+ file_path = file_path.replace("\\", "/")
+
+ file_node.setParms({"file": file_path})
+
+ # Update attribute
+ node.setParms({"representation": str(representation["_id"])})
+
+ def remove(self, container):
+
+ node = container["node"]
+ node.destroy()
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def get_node_name(self, context, name=None, namespace=None):
+ """Define node name."""
+
+ if not namespace:
+ namespace = context["asset"]["name"]
+
+ if namespace:
+ node_name = "{}_{}".format(namespace, name)
+ else:
+ node_name = name
+
+ return namespace, node_name
+
+ def create_load_node_tree(self, file_path, node_name, subset_name):
+ """Create Load network.
+
+ you can start building your tree at any obj level.
+ it'll be much easier to build it in the root obj level.
+
+ Afterwards, your tree will be automatically moved to
+ '/obj/AVALON_CONTAINERS' subnetwork.
+ """
+ import hou
+
+ # Get the root obj level
+ obj = hou.node("/obj")
+
+ # Create a new obj geo node
+ parent_node = obj.createNode("geo", node_name=node_name)
+
+ # In older houdini,
+ # when reating a new obj geo node, a default file node will be
+ # automatically created.
+ # so, we will delete it if exists.
+ file_node = parent_node.node("file1")
+ if file_node:
+ file_node.destroy()
+
+ # Create a new file node
+ file_node = parent_node.createNode("file", node_name=node_name)
+ file_node.setParms({"file": file_path})
+
+ # Create attribute delete
+ attribdelete_name = "attribdelete_{}".format(subset_name)
+ attribdelete = parent_node.createNode("attribdelete",
+ node_name=attribdelete_name)
+ attribdelete.setParms({"ptdel": "fbx_*"})
+ attribdelete.setInput(0, file_node)
+
+ # Create a Null node
+ null_name = "OUT_{}".format(subset_name)
+ null = parent_node.createNode("null", node_name=null_name)
+ null.setInput(0, attribdelete)
+
+ # Ensure display flag is on the file_node input node and not on the OUT
+ # node to optimize "debug" displaying in the viewport.
+ file_node.setDisplayFlag(True)
+
+ # Set new position for children nodes
+ parent_node.layoutChildren()
+
+ # Return all the nodes
+ return [parent_node, file_node, attribdelete, null]
diff --git a/openpype/hosts/houdini/plugins/publish/collect_output_node.py b/openpype/hosts/houdini/plugins/publish/collect_output_node.py
index 0b27678ed0..bca3d9fdc1 100644
--- a/openpype/hosts/houdini/plugins/publish/collect_output_node.py
+++ b/openpype/hosts/houdini/plugins/publish/collect_output_node.py
@@ -14,7 +14,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin):
"imagesequence",
"usd",
"usdrender",
- "redshiftproxy"
+ "redshiftproxy",
+ "staticMesh"
]
hosts = ["houdini"]
@@ -59,6 +60,10 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin):
elif node_type == "Redshift_Proxy_Output":
out_node = node.parm("RS_archive_sopPath").evalAsNode()
+
+ elif node_type == "filmboxfbx":
+ out_node = node.parm("startnode").evalAsNode()
+
else:
raise KnownPublishError(
"ROP node type '{}' is not supported.".format(node_type)
diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py
new file mode 100644
index 0000000000..db9efec7a1
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+"""Collector for staticMesh types. """
+
+import pyblish.api
+
+
+class CollectStaticMeshType(pyblish.api.InstancePlugin):
+ """Collect data type for fbx instance."""
+
+ hosts = ["houdini"]
+ families = ["staticMesh"]
+ label = "Collect type of staticMesh"
+
+ order = pyblish.api.CollectorOrder
+
+ def process(self, instance):
+
+ if instance.data["creator_identifier"] == "io.openpype.creators.houdini.staticmesh.fbx": # noqa: E501
+ # Marking this instance as FBX triggers the FBX extractor.
+ instance.data["families"] += ["fbx"]
diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py
new file mode 100644
index 0000000000..7993b3352f
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+"""Fbx Extractor for houdini. """
+
+import os
+import pyblish.api
+from openpype.pipeline import publish
+from openpype.hosts.houdini.api.lib import render_rop
+
+import hou
+
+
+class ExtractFBX(publish.Extractor):
+
+ label = "Extract FBX"
+ families = ["fbx"]
+ hosts = ["houdini"]
+
+ order = pyblish.api.ExtractorOrder + 0.1
+
+ def process(self, instance):
+
+ # get rop node
+ ropnode = hou.node(instance.data.get("instance_node"))
+ output_file = ropnode.evalParm("sopoutput")
+
+ # get staging_dir and file_name
+ staging_dir = os.path.normpath(os.path.dirname(output_file))
+ file_name = os.path.basename(output_file)
+
+ # render rop
+ self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir)
+ render_rop(ropnode)
+
+ # prepare representation
+ representation = {
+ "name": "fbx",
+ "ext": "fbx",
+ "files": file_name,
+ "stagingDir": staging_dir
+ }
+
+ # A single frame may also be rendered without start/end frame.
+ if "frameStart" in instance.data and "frameEnd" in instance.data:
+ representation["frameStart"] = instance.data["frameStart"]
+ representation["frameEnd"] = instance.data["frameEnd"]
+
+ # set value type for 'representations' key to list
+ if "representations" not in instance.data:
+ instance.data["representations"] = []
+
+ # update instance data
+ instance.data["stagingDir"] = staging_dir
+ instance.data["representations"].append(representation)
diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py
new file mode 100644
index 0000000000..894dad7d72
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+import pyblish.api
+from openpype.pipeline import PublishValidationError
+from openpype.hosts.houdini.api.action import (
+ SelectInvalidAction,
+ SelectROPAction,
+)
+from openpype.hosts.houdini.api.lib import get_obj_node_output
+import hou
+
+
+class ValidateFBXOutputNode(pyblish.api.InstancePlugin):
+ """Validate the instance Output Node.
+
+ This will ensure:
+ - The Output Node Path is set.
+ - The Output Node Path refers to an existing object.
+ - The Output Node is a Sop or Obj node.
+ - The Output Node has geometry data.
+ - The Output Node doesn't include invalid primitive types.
+ """
+
+ order = pyblish.api.ValidatorOrder
+ families = ["fbx"]
+ hosts = ["houdini"]
+ label = "Validate FBX Output Node"
+ actions = [SelectROPAction, SelectInvalidAction]
+
+ def process(self, instance):
+
+ invalid = self.get_invalid(instance)
+ if invalid:
+ nodes = [n.path() for n in invalid]
+ raise PublishValidationError(
+ "See log for details. "
+ "Invalid nodes: {0}".format(nodes),
+ title="Invalid output node(s)"
+ )
+
+ @classmethod
+ def get_invalid(cls, instance):
+ output_node = instance.data.get("output_node")
+
+ # Check if The Output Node Path is set and
+ # refers to an existing object.
+ if output_node is None:
+ rop_node = hou.node(instance.data["instance_node"])
+ cls.log.error(
+ "Output node in '%s' does not exist. "
+ "Ensure a valid output path is set.", rop_node.path()
+ )
+
+ return [rop_node]
+
+ # Check if the Output Node is a Sop or an Obj node
+ # also, list all sop output nodes inside as well as
+ # invalid empty nodes.
+ all_out_sops = []
+ invalid = []
+
+ # if output_node is an ObjSubnet or an ObjNetwork
+ if output_node.childTypeCategory() == hou.objNodeTypeCategory():
+ for node in output_node.allSubChildren():
+ if node.type().name() == "geo":
+ out = get_obj_node_output(node)
+ if out:
+ all_out_sops.append(out)
+ else:
+ invalid.append(node) # empty_objs
+ cls.log.error(
+ "Geo Obj Node '%s' is empty!",
+ node.path()
+ )
+ if not all_out_sops:
+ invalid.append(output_node) # empty_objs
+ cls.log.error(
+ "Output Node '%s' is empty!",
+ node.path()
+ )
+
+ # elif output_node is an ObjNode
+ elif output_node.type().name() == "geo":
+ out = get_obj_node_output(output_node)
+ if out:
+ all_out_sops.append(out)
+ else:
+ invalid.append(node) # empty_objs
+ cls.log.error(
+ "Output Node '%s' is empty!",
+ node.path()
+ )
+
+ # elif output_node is a SopNode
+ elif output_node.type().category().name() == "Sop":
+ all_out_sops.append(output_node)
+
+ # Then it's a wrong node type
+ else:
+ cls.log.error(
+ "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. "
+ "Instead found category type: %s %s",
+ output_node.path(), output_node.type().category().name(),
+ output_node.type().name()
+ )
+ return [output_node]
+
+ # Check if all output sop nodes have geometry
+ # and don't contain invalid prims
+ invalid_prim_types = ["VDB", "Volume"]
+ for sop_node in all_out_sops:
+ # Empty Geometry test
+ if not hasattr(sop_node, "geometry"):
+ invalid.append(sop_node) # empty_geometry
+ cls.log.error(
+ "Sop node '%s' doesn't include any prims.",
+ sop_node.path()
+ )
+ continue
+
+ frame = instance.data.get("frameStart", 0)
+ geo = sop_node.geometryAtFrame(frame)
+ if len(geo.iterPrims()) == 0:
+ invalid.append(sop_node) # empty_geometry
+ cls.log.error(
+ "Sop node '%s' doesn't include any prims.",
+ sop_node.path()
+ )
+ continue
+
+ # Invalid Prims test
+ for prim_type in invalid_prim_types:
+ if geo.countPrimType(prim_type) > 0:
+ invalid.append(sop_node) # invalid_prims
+ cls.log.error(
+ "Sop node '%s' includes invalid prims of type '%s'.",
+ sop_node.path(), prim_type
+ )
+
+ if invalid:
+ return invalid
diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py
new file mode 100644
index 0000000000..b499682e0b
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+"""Validator for correct naming of Static Meshes."""
+import pyblish.api
+from openpype.pipeline import (
+ PublishValidationError,
+ OptionalPyblishPluginMixin
+)
+from openpype.pipeline.publish import ValidateContentsOrder
+
+from openpype.hosts.houdini.api.action import SelectInvalidAction
+from openpype.hosts.houdini.api.lib import get_output_children
+
+
+class ValidateMeshIsStatic(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Validate mesh is static.
+
+ It checks if output node is time dependent.
+ """
+
+ families = ["staticMesh"]
+ hosts = ["houdini"]
+ label = "Validate Mesh is Static"
+ order = ValidateContentsOrder + 0.1
+ actions = [SelectInvalidAction]
+
+ def process(self, instance):
+
+ invalid = self.get_invalid(instance)
+ if invalid:
+ nodes = [n.path() for n in invalid]
+ raise PublishValidationError(
+ "See log for details. "
+ "Invalid nodes: {0}".format(nodes)
+ )
+
+ @classmethod
+ def get_invalid(cls, instance):
+
+ invalid = []
+
+ output_node = instance.data.get("output_node")
+ if output_node is None:
+ cls.log.debug(
+ "No Output Node, skipping check.."
+ )
+ return
+
+ all_outputs = get_output_children(output_node)
+
+ for output in all_outputs:
+ if output.isTimeDependent():
+ invalid.append(output)
+ cls.log.error(
+ "Output node '%s' is time dependent.",
+ output.path()
+ )
+
+ return invalid
diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py
index d9dee38680..9590e37d26 100644
--- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py
+++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py
@@ -24,7 +24,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder
families = ["pointcache", "vdbcache"]
hosts = ["houdini"]
- label = "Validate Output Node"
+ label = "Validate Output Node (SOP)"
actions = [SelectROPAction, SelectInvalidAction]
def process(self, instance):
diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py
new file mode 100644
index 0000000000..bb3648f361
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+"""Validator for correct naming of Static Meshes."""
+import pyblish.api
+from openpype.pipeline import (
+ PublishValidationError,
+ OptionalPyblishPluginMixin
+)
+from openpype.pipeline.publish import (
+ ValidateContentsOrder,
+ RepairAction,
+)
+from openpype.hosts.houdini.api.action import SelectInvalidAction
+from openpype.pipeline.create import get_subset_name
+
+import hou
+
+
+class FixSubsetNameAction(RepairAction):
+ label = "Fix Subset Name"
+
+
+class ValidateSubsetName(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Validate Subset name.
+
+ """
+
+ families = ["staticMesh"]
+ hosts = ["houdini"]
+ label = "Validate Subset Name"
+ order = ValidateContentsOrder + 0.1
+ actions = [FixSubsetNameAction, SelectInvalidAction]
+
+ optional = True
+
+ def process(self, instance):
+
+ if not self.is_active(instance.data):
+ return
+
+ invalid = self.get_invalid(instance)
+ if invalid:
+ nodes = [n.path() for n in invalid]
+ raise PublishValidationError(
+ "See log for details. "
+ "Invalid nodes: {0}".format(nodes)
+ )
+
+ @classmethod
+ def get_invalid(cls, instance):
+
+ invalid = []
+
+ rop_node = hou.node(instance.data["instance_node"])
+
+ # Check subset name
+ subset_name = get_subset_name(
+ family=instance.data["family"],
+ variant=instance.data["variant"],
+ task_name=instance.data["task"],
+ asset_doc=instance.data["assetEntity"],
+ dynamic_data={"asset": instance.data["asset"]}
+ )
+
+ if instance.data.get("subset") != subset_name:
+ invalid.append(rop_node)
+ cls.log.error(
+ "Invalid subset name on rop node '%s' should be '%s'.",
+ rop_node.path(), subset_name
+ )
+
+ return invalid
+
+ @classmethod
+ def repair(cls, instance):
+ rop_node = hou.node(instance.data["instance_node"])
+
+ # Check subset name
+ subset_name = get_subset_name(
+ family=instance.data["family"],
+ variant=instance.data["variant"],
+ task_name=instance.data["task"],
+ asset_doc=instance.data["assetEntity"],
+ dynamic_data={"asset": instance.data["asset"]}
+ )
+
+ instance.data["subset"] = subset_name
+ rop_node.parm("subset").set(subset_name)
+
+ cls.log.debug(
+ "Subset name on rop node '%s' has been set to '%s'.",
+ rop_node.path(), subset_name
+ )
diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py
new file mode 100644
index 0000000000..ae3c7e5602
--- /dev/null
+++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+"""Validator for correct naming of Static Meshes."""
+import pyblish.api
+from openpype.pipeline import (
+ PublishValidationError,
+ OptionalPyblishPluginMixin
+)
+from openpype.pipeline.publish import ValidateContentsOrder
+
+from openpype.hosts.houdini.api.action import SelectInvalidAction
+from openpype.hosts.houdini.api.lib import get_output_children
+
+import hou
+
+
+class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin,
+ OptionalPyblishPluginMixin):
+ """Validate name of Unreal Static Mesh.
+
+ This validator checks if output node name has a collision prefix:
+ - UBX
+ - UCP
+ - USP
+ - UCX
+
+ This validator also checks if subset name is correct
+ - {static mesh prefix}_{Asset-Name}{Variant}.
+
+ """
+
+ families = ["staticMesh"]
+ hosts = ["houdini"]
+ label = "Unreal Static Mesh Name (FBX)"
+ order = ValidateContentsOrder + 0.1
+ actions = [SelectInvalidAction]
+
+ optional = True
+ collision_prefixes = []
+ static_mesh_prefix = ""
+
+ @classmethod
+ def apply_settings(cls, project_settings, system_settings):
+
+ settings = (
+ project_settings["houdini"]["create"]["CreateStaticMesh"]
+ )
+ cls.collision_prefixes = settings["collision_prefixes"]
+ cls.static_mesh_prefix = settings["static_mesh_prefix"]
+
+ def process(self, instance):
+
+ if not self.is_active(instance.data):
+ return
+
+ invalid = self.get_invalid(instance)
+ if invalid:
+ nodes = [n.path() for n in invalid]
+ raise PublishValidationError(
+ "See log for details. "
+ "Invalid nodes: {0}".format(nodes)
+ )
+
+ @classmethod
+ def get_invalid(cls, instance):
+
+ invalid = []
+
+ rop_node = hou.node(instance.data["instance_node"])
+ output_node = instance.data.get("output_node")
+ if output_node is None:
+ cls.log.debug(
+ "No Output Node, skipping check.."
+ )
+ return
+
+ if rop_node.evalParm("buildfrompath"):
+ # This validator doesn't support naming check if
+ # building hierarchy from path' is used
+ cls.log.info(
+ "Using 'Build Hierarchy from Path Attribute', skipping check.."
+ )
+ return
+
+ # Check nodes names
+ all_outputs = get_output_children(output_node, include_sops=False)
+ for output in all_outputs:
+ for prefix in cls.collision_prefixes:
+ if output.name().startswith(prefix):
+ invalid.append(output)
+ cls.log.error(
+ "Invalid node name: Node '%s' "
+ "includes a collision prefix '%s'",
+ output.path(), prefix
+ )
+ break
+
+ return invalid
diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py
index 86a0a99ca9..bd680a3d84 100644
--- a/openpype/hosts/max/api/pipeline.py
+++ b/openpype/hosts/max/api/pipeline.py
@@ -18,7 +18,6 @@ from openpype.hosts.max.api import lib
from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB
from openpype.hosts.max import MAX_HOST_DIR
-
from pymxs import runtime as rt # noqa
log = logging.getLogger("openpype.hosts.max")
diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py
index 5acb57b923..c41608c860 100644
--- a/openpype/hosts/max/plugins/load/load_model.py
+++ b/openpype/hosts/max/plugins/load/load_model.py
@@ -14,7 +14,7 @@ class ModelAbcLoader(load.LoaderPlugin):
"""Loading model with the Alembic loader."""
families = ["model"]
- label = "Load Model(Alembic)"
+ label = "Load Model with Alembic"
representations = ["abc"]
order = -10
icon = "code-fork"
diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py
index 995e56ca37..3c2dfe8c25 100644
--- a/openpype/hosts/max/plugins/load/load_pointcache.py
+++ b/openpype/hosts/max/plugins/load/load_pointcache.py
@@ -55,7 +55,7 @@ class AbcLoader(load.LoaderPlugin):
selections = rt.GetCurrentSelection()
for abc in selections:
for cam_shape in abc.Children:
- cam_shape.playbackType = 2
+ cam_shape.playbackType = 0
namespace = unique_namespace(
name + "_",
diff --git a/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py
new file mode 100644
index 0000000000..96060a6a6f
--- /dev/null
+++ b/openpype/hosts/max/plugins/load/load_pointcache_ornatrix.py
@@ -0,0 +1,108 @@
+import os
+from openpype.pipeline import load, get_representation_path
+from openpype.pipeline.load import LoadError
+from openpype.hosts.max.api.pipeline import (
+ containerise,
+ get_previous_loaded_object,
+ update_custom_attribute_data
+)
+
+from openpype.hosts.max.api.lib import (
+ unique_namespace,
+ get_namespace,
+ object_transform_set,
+ get_plugins
+)
+from openpype.hosts.max.api import lib
+from pymxs import runtime as rt
+
+
+class OxAbcLoader(load.LoaderPlugin):
+ """Ornatrix Alembic loader."""
+
+ families = ["camera", "animation", "pointcache"]
+ label = "Load Alembic with Ornatrix"
+ representations = ["abc"]
+ order = -10
+ icon = "code-fork"
+ color = "orange"
+ postfix = "param"
+
+ def load(self, context, name=None, namespace=None, data=None):
+ plugin_list = get_plugins()
+ if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list:
+ raise LoadError("Ornatrix plugin not "
+ "found/installed in Max yet..")
+
+ file_path = os.path.normpath(self.filepath_from_context(context))
+ rt.AlembicImport.ImportToRoot = True
+ rt.AlembicImport.CustomAttributes = True
+ rt.importFile(
+ file_path, rt.name("noPrompt"),
+ using=rt.Ornatrix_Alembic_Importer)
+
+ scene_object = []
+ for obj in rt.rootNode.Children:
+ obj_type = rt.ClassOf(obj)
+ if str(obj_type).startswith("Ox_"):
+ scene_object.append(obj)
+
+ namespace = unique_namespace(
+ name + "_",
+ suffix="_",
+ )
+ abc_container = []
+ for abc in scene_object:
+ abc.name = f"{namespace}:{abc.name}"
+ abc_container.append(abc)
+
+ return containerise(
+ name, abc_container, context,
+ namespace, loader=self.__class__.__name__
+ )
+
+ def update(self, container, representation):
+ path = get_representation_path(representation)
+ node_name = container["instance_node"]
+ namespace, name = get_namespace(node_name)
+ node = rt.getNodeByName(node_name)
+ node_list = get_previous_loaded_object(node)
+ rt.Select(node_list)
+ selections = rt.getCurrentSelection()
+ transform_data = object_transform_set(selections)
+ for prev_obj in selections:
+ if rt.isValidNode(prev_obj):
+ rt.Delete(prev_obj)
+
+ rt.AlembicImport.ImportToRoot = False
+ rt.AlembicImport.CustomAttributes = True
+ rt.importFile(
+ path, rt.name("noPrompt"),
+ using=rt.Ornatrix_Alembic_Importer)
+
+ scene_object = []
+ for obj in rt.rootNode.Children:
+ obj_type = rt.ClassOf(obj)
+ if str(obj_type).startswith("Ox_"):
+ scene_object.append(obj)
+ ox_abc_objects = []
+ for abc in scene_object:
+ abc.Parent = container
+ abc.name = f"{namespace}:{abc.name}"
+ ox_abc_objects.append(abc)
+ ox_transform = f"{abc.name}.transform"
+ if ox_transform in transform_data.keys():
+ abc.pos = transform_data[ox_transform] or 0
+ abc.scale = transform_data[f"{abc.name}.scale"] or 0
+ update_custom_attribute_data(node, ox_abc_objects)
+ lib.imprint(
+ container["instance_node"],
+ {"representation": str(representation["_id"])},
+ )
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ node = rt.GetNodeByName(container["instance_node"])
+ rt.Delete(node)
diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py
index 60495ac652..3647ec0b6b 100644
--- a/openpype/hosts/maya/api/pipeline.py
+++ b/openpype/hosts/maya/api/pipeline.py
@@ -659,17 +659,6 @@ def on_task_changed():
lib.set_context_settings()
lib.update_content_on_context_change()
- msg = " project: {}\n asset: {}\n task:{}".format(
- get_current_project_name(),
- get_current_asset_name(),
- get_current_task_name()
- )
-
- lib.show_message(
- "Context was changed",
- ("Context was changed to:\n{}".format(msg)),
- )
-
def before_workfile_open():
if handle_workfile_locks():
diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py
index 4032618afb..79fcf9bc8b 100644
--- a/openpype/hosts/maya/api/plugin.py
+++ b/openpype/hosts/maya/api/plugin.py
@@ -129,18 +129,49 @@ class MayaCreatorBase(object):
shared_data["maya_cached_legacy_subsets"] = cache_legacy
return shared_data
+ def get_publish_families(self):
+ """Return families for the instances of this creator.
+
+ Allow a Creator to define multiple families so that a creator can
+ e.g. specify `usd` and `usdMaya` and another USD creator can also
+ specify `usd` but apply different extractors like `usdMultiverse`.
+
+ There is no need to override this method if you only have the
+ primary family defined by the `family` property as that will always
+ be set.
+
+ Returns:
+ list: families for instances of this creator
+
+ """
+ return []
+
def imprint_instance_node(self, node, data):
# We never store the instance_node as value on the node since
# it's the node name itself
data.pop("instance_node", None)
+ # Don't store `families` since it's up to the creator itself
+ # to define the initial publish families - not a stored attribute of
+ # `families`
+ data.pop("families", None)
+
# We store creator attributes at the root level and assume they
# will not clash in names with `subset`, `task`, etc. and other
# default names. This is just so these attributes in many cases
# are still editable in the maya UI by artists.
- # pop to move to end of dict to sort attributes last on the node
+ # note: pop to move to end of dict to sort attributes last on the node
creator_attributes = data.pop("creator_attributes", {})
+
+ # We only flatten value types which `imprint` function supports
+ json_creator_attributes = {}
+ for key, value in dict(creator_attributes).items():
+ if isinstance(value, (list, tuple, dict)):
+ creator_attributes.pop(key)
+ json_creator_attributes[key] = value
+
+ # Flatten remaining creator attributes to the node itself
data.update(creator_attributes)
# We know the "publish_attributes" will be complex data of
@@ -150,6 +181,10 @@ class MayaCreatorBase(object):
data.pop("publish_attributes", {})
)
+ # Persist the non-flattened creator attributes (special value types,
+ # like multiselection EnumDef)
+ data["creator_attributes"] = json.dumps(json_creator_attributes)
+
# Since we flattened the data structure for creator attributes we want
# to correctly detect which flattened attributes should end back in the
# creator attributes when reading the data from the node, so we store
@@ -170,15 +205,22 @@ class MayaCreatorBase(object):
# being read as 'data'
node_data.pop("cbId", None)
+ # Make sure we convert any creator attributes from the json string
+ creator_attributes = node_data.get("creator_attributes")
+ if creator_attributes:
+ node_data["creator_attributes"] = json.loads(creator_attributes)
+ else:
+ node_data["creator_attributes"] = {}
+
# Move the relevant attributes into "creator_attributes" that
# we flattened originally
- node_data["creator_attributes"] = {}
creator_attribute_keys = node_data.pop("__creator_attributes_keys",
"").split(",")
for key in creator_attribute_keys:
if key in node_data:
node_data["creator_attributes"][key] = node_data.pop(key)
+ # Make sure we convert any publish attributes from the json string
publish_attributes = node_data.get("publish_attributes")
if publish_attributes:
node_data["publish_attributes"] = json.loads(publish_attributes)
@@ -186,6 +228,11 @@ class MayaCreatorBase(object):
# Explicitly re-parse the node name
node_data["instance_node"] = node
+ # If the creator plug-in specifies
+ families = self.get_publish_families()
+ if families:
+ node_data["families"] = families
+
return node_data
def _default_collect_instances(self):
@@ -230,6 +277,14 @@ class MayaCreator(NewCreator, MayaCreatorBase):
if pre_create_data.get("use_selection"):
members = cmds.ls(selection=True)
+ # Allow a Creator to define multiple families
+ publish_families = self.get_publish_families()
+ if publish_families:
+ families = instance_data.setdefault("families", [])
+ for family in self.get_publish_families():
+ if family not in families:
+ families.append(family)
+
with lib.undo_chunk():
instance_node = cmds.sets(members, name=subset_name)
instance_data["instance_node"] = instance_node
diff --git a/openpype/hosts/maya/plugins/create/create_maya_usd.py b/openpype/hosts/maya/plugins/create/create_maya_usd.py
new file mode 100644
index 0000000000..cc9a14bd3a
--- /dev/null
+++ b/openpype/hosts/maya/plugins/create/create_maya_usd.py
@@ -0,0 +1,102 @@
+from openpype.hosts.maya.api import plugin, lib
+from openpype.lib import (
+ BoolDef,
+ EnumDef,
+ TextDef
+)
+
+from maya import cmds
+
+
+class CreateMayaUsd(plugin.MayaCreator):
+ """Create Maya USD Export"""
+
+ identifier = "io.openpype.creators.maya.mayausd"
+ label = "Maya USD"
+ family = "usd"
+ icon = "cubes"
+ description = "Create Maya USD Export"
+
+ cache = {}
+
+ def get_publish_families(self):
+ return ["usd", "mayaUsd"]
+
+ def get_instance_attr_defs(self):
+
+ if "jobContextItems" not in self.cache:
+ # Query once instead of per instance
+ job_context_items = {}
+ try:
+ cmds.loadPlugin("mayaUsdPlugin", quiet=True)
+ job_context_items = {
+ cmds.mayaUSDListJobContexts(jobContext=name): name
+ for name in cmds.mayaUSDListJobContexts(export=True) or []
+ }
+ except RuntimeError:
+ # Likely `mayaUsdPlugin` plug-in not available
+ self.log.warning("Unable to retrieve available job "
+ "contexts for `mayaUsdPlugin` exports")
+
+ if not job_context_items:
+ # enumdef multiselection may not be empty
+ job_context_items = [""]
+
+ self.cache["jobContextItems"] = job_context_items
+
+ defs = lib.collect_animation_defs()
+ defs.extend([
+ EnumDef("defaultUSDFormat",
+ label="File format",
+ items={
+ "usdc": "Binary",
+ "usda": "ASCII"
+ },
+ default="usdc"),
+ BoolDef("stripNamespaces",
+ label="Strip Namespaces",
+ tooltip=(
+ "Remove namespaces during export. By default, "
+ "namespaces are exported to the USD file in the "
+ "following format: nameSpaceExample_pPlatonic1"
+ ),
+ default=True),
+ BoolDef("mergeTransformAndShape",
+ label="Merge Transform and Shape",
+ tooltip=(
+ "Combine Maya transform and shape into a single USD"
+ "prim that has transform and geometry, for all"
+ " \"geometric primitives\" (gprims).\n"
+ "This results in smaller and faster scenes. Gprims "
+ "will be \"unpacked\" back into transform and shape "
+ "nodes when imported into Maya from USD."
+ ),
+ default=True),
+ BoolDef("includeUserDefinedAttributes",
+ label="Include User Defined Attributes",
+ tooltip=(
+ "Whether to include all custom maya attributes found "
+ "on nodes as metadata (userProperties) in USD."
+ ),
+ default=False),
+ TextDef("attr",
+ label="Custom Attributes",
+ default="",
+ placeholder="attr1, attr2"),
+ TextDef("attrPrefix",
+ label="Custom Attributes Prefix",
+ default="",
+ placeholder="prefix1, prefix2"),
+ EnumDef("jobContext",
+ label="Job Context",
+ items=self.cache["jobContextItems"],
+ tooltip=(
+ "Specifies an additional export context to handle.\n"
+ "These usually contain extra schemas, primitives,\n"
+ "and materials that are to be exported for a "
+ "specific\ntask, a target renderer for example."
+ ),
+ multiselection=True),
+ ])
+
+ return defs
diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py
index 0b0ad3bccb..2963d4d5b6 100644
--- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py
+++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py
@@ -14,6 +14,10 @@ class CreateMultiverseUsd(plugin.MayaCreator):
label = "Multiverse USD Asset"
family = "usd"
icon = "cubes"
+ description = "Create Multiverse USD Asset"
+
+ def get_publish_families(self):
+ return ["usd", "mvUsd"]
def get_instance_attr_defs(self):
diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py
index b5cc4d629b..2e1329f201 100644
--- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py
+++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py
@@ -17,6 +17,7 @@ from openpype.hosts.maya.api.lib import (
)
from openpype.hosts.maya.api.pipeline import containerise
+
def is_sequence(files):
sequence = False
collections, remainder = clique.assemble(files, minimum_items=1)
@@ -29,11 +30,12 @@ def get_current_session_fps():
session_fps = float(legacy_io.Session.get('AVALON_FPS', 25))
return convert_to_maya_fps(session_fps)
+
class ArnoldStandinLoader(load.LoaderPlugin):
"""Load as Arnold standin"""
- families = ["ass", "animation", "model", "proxyAbc", "pointcache"]
- representations = ["ass", "abc"]
+ families = ["ass", "animation", "model", "proxyAbc", "pointcache", "usd"]
+ representations = ["ass", "abc", "usda", "usdc", "usd"]
label = "Load as Arnold standin"
order = -5
diff --git a/openpype/hosts/maya/plugins/load/load_maya_usd.py b/openpype/hosts/maya/plugins/load/load_maya_usd.py
new file mode 100644
index 0000000000..2fb1a625a5
--- /dev/null
+++ b/openpype/hosts/maya/plugins/load/load_maya_usd.py
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+import maya.cmds as cmds
+
+from openpype.pipeline import (
+ load,
+ get_representation_path,
+)
+from openpype.pipeline.load import get_representation_path_from_context
+from openpype.hosts.maya.api.lib import (
+ namespaced,
+ unique_namespace
+)
+from openpype.hosts.maya.api.pipeline import containerise
+
+
+class MayaUsdLoader(load.LoaderPlugin):
+ """Read USD data in a Maya USD Proxy"""
+
+ families = ["model", "usd", "pointcache", "animation"]
+ representations = ["usd", "usda", "usdc", "usdz", "abc"]
+
+ label = "Load USD to Maya Proxy"
+ order = -1
+ icon = "code-fork"
+ color = "orange"
+
+ def load(self, context, name=None, namespace=None, options=None):
+ asset = context['asset']['name']
+ namespace = namespace or unique_namespace(
+ asset + "_",
+ prefix="_" if asset[0].isdigit() else "",
+ suffix="_",
+ )
+
+ # Make sure we can load the plugin
+ cmds.loadPlugin("mayaUsdPlugin", quiet=True)
+
+ path = get_representation_path_from_context(context)
+
+ # Create the shape
+ cmds.namespace(addNamespace=namespace)
+ with namespaced(namespace, new=False):
+ transform = cmds.createNode("transform",
+ name=name,
+ skipSelect=True)
+ proxy = cmds.createNode('mayaUsdProxyShape',
+ name="{}Shape".format(name),
+ parent=transform,
+ skipSelect=True)
+
+ cmds.connectAttr("time1.outTime", "{}.time".format(proxy))
+ cmds.setAttr("{}.filePath".format(proxy), path, type="string")
+
+ # By default, we force the proxy to not use a shared stage because
+ # when doing so Maya will quite easily allow to save into the
+ # loaded usd file. Since we are loading published files we want to
+ # avoid altering them. Unshared stages also save their edits into
+ # the workfile as an artist might expect it to do.
+ cmds.setAttr("{}.shareStage".format(proxy), False)
+ # cmds.setAttr("{}.shareStage".format(proxy), lock=True)
+
+ nodes = [transform, proxy]
+ self[:] = nodes
+
+ return containerise(
+ name=name,
+ namespace=namespace,
+ nodes=nodes,
+ context=context,
+ loader=self.__class__.__name__)
+
+ def update(self, container, representation):
+ # type: (dict, dict) -> None
+ """Update container with specified representation."""
+ node = container['objectName']
+ assert cmds.objExists(node), "Missing container"
+
+ members = cmds.sets(node, query=True) or []
+ shapes = cmds.ls(members, type="mayaUsdProxyShape")
+
+ path = get_representation_path(representation)
+ for shape in shapes:
+ cmds.setAttr("{}.filePath".format(shape), path, type="string")
+
+ cmds.setAttr("{}.representation".format(node),
+ str(representation["_id"]),
+ type="string")
+
+ def switch(self, container, representation):
+ self.update(container, representation)
+
+ def remove(self, container):
+ # type: (dict) -> None
+ """Remove loaded container."""
+ # Delete container and its contents
+ if cmds.objExists(container['objectName']):
+ members = cmds.sets(container['objectName'], query=True) or []
+ cmds.delete([container['objectName']] + members)
+
+ # Remove the namespace, if empty
+ namespace = container['namespace']
+ if cmds.namespace(exists=namespace):
+ members = cmds.namespaceInfo(namespace, listNamespace=True)
+ if not members:
+ cmds.namespace(removeNamespace=namespace)
+ else:
+ self.log.warning("Namespace not deleted because it "
+ "still has members: %s", namespace)
diff --git a/openpype/hosts/maya/plugins/publish/collect_animation.py b/openpype/hosts/maya/plugins/publish/collect_animation.py
index 8f523f770b..26a0a01c8b 100644
--- a/openpype/hosts/maya/plugins/publish/collect_animation.py
+++ b/openpype/hosts/maya/plugins/publish/collect_animation.py
@@ -58,17 +58,3 @@ class CollectAnimationOutputGeometry(pyblish.api.InstancePlugin):
if instance.data.get("farm"):
instance.data["families"].append("publish.farm")
- # Collect user defined attributes.
- if not instance.data.get("includeUserDefinedAttributes", False):
- return
-
- user_defined_attributes = set()
- for node in hierarchy:
- attrs = cmds.listAttr(node, userDefined=True) or list()
- shapes = cmds.listRelatives(node, shapes=True) or list()
- for shape in shapes:
- attrs.extend(cmds.listAttr(shape, userDefined=True) or list())
-
- user_defined_attributes.update(attrs)
-
- instance.data["userDefinedAttributes"] = list(user_defined_attributes)
diff --git a/openpype/hosts/maya/plugins/publish/collect_pointcache.py b/openpype/hosts/maya/plugins/publish/collect_pointcache.py
index bb9065792f..5578a57f31 100644
--- a/openpype/hosts/maya/plugins/publish/collect_pointcache.py
+++ b/openpype/hosts/maya/plugins/publish/collect_pointcache.py
@@ -45,18 +45,3 @@ class CollectPointcache(pyblish.api.InstancePlugin):
if proxy_set:
instance.remove(proxy_set)
instance.data["setMembers"].remove(proxy_set)
-
- # Collect user defined attributes.
- if not instance.data.get("includeUserDefinedAttributes", False):
- return
-
- user_defined_attributes = set()
- for node in instance:
- attrs = cmds.listAttr(node, userDefined=True) or list()
- shapes = cmds.listRelatives(node, shapes=True) or list()
- for shape in shapes:
- attrs.extend(cmds.listAttr(shape, userDefined=True) or list())
-
- user_defined_attributes.update(attrs)
-
- instance.data["userDefinedAttributes"] = list(user_defined_attributes)
diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py
index 82392f67bd..886c2b4caa 100644
--- a/openpype/hosts/maya/plugins/publish/collect_render.py
+++ b/openpype/hosts/maya/plugins/publish/collect_render.py
@@ -157,10 +157,10 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
# append full path
aov_dict = {}
- default_render_folder = context.data.get("project_settings")\
- .get("maya")\
- .get("RenderSettings")\
- .get("default_render_image_folder") or ""
+ image_directory = os.path.join(
+ cmds.workspace(query=True, rootDirectory=True),
+ cmds.workspace(fileRuleEntry="images")
+ )
# replace relative paths with absolute. Render products are
# returned as list of dictionaries.
publish_meta_path = None
@@ -168,8 +168,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
full_paths = []
aov_first_key = list(aov.keys())[0]
for file in aov[aov_first_key]:
- full_path = os.path.join(workspace, default_render_folder,
- file)
+ full_path = os.path.join(image_directory, file)
full_path = full_path.replace("\\", "/")
full_paths.append(full_path)
publish_meta_path = os.path.dirname(full_path)
diff --git a/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py
new file mode 100644
index 0000000000..16fef2e168
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/collect_user_defined_attributes.py
@@ -0,0 +1,39 @@
+from maya import cmds
+
+import pyblish.api
+
+
+class CollectUserDefinedAttributes(pyblish.api.InstancePlugin):
+ """Collect user defined attributes for nodes in instance."""
+
+ order = pyblish.api.CollectorOrder + 0.45
+ families = ["pointcache", "animation", "usd"]
+ label = "Collect User Defined Attributes"
+ hosts = ["maya"]
+
+ def process(self, instance):
+
+ # Collect user defined attributes.
+ if not instance.data.get("includeUserDefinedAttributes", False):
+ return
+
+ if "out_hierarchy" in instance.data:
+ # animation family
+ nodes = instance.data["out_hierarchy"]
+ else:
+ nodes = instance[:]
+ if not nodes:
+ return
+
+ shapes = cmds.listRelatives(nodes, shapes=True, fullPath=True) or []
+ nodes = set(nodes).union(shapes)
+
+ attrs = cmds.listAttr(list(nodes), userDefined=True) or []
+ user_defined_attributes = list(sorted(set(attrs)))
+ instance.data["userDefinedAttributes"] = user_defined_attributes
+
+ self.log.debug(
+ "Collected user defined attributes: {}".format(
+ ", ".join(user_defined_attributes)
+ )
+ )
diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_usd.py b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py
new file mode 100644
index 0000000000..8c32ac1e39
--- /dev/null
+++ b/openpype/hosts/maya/plugins/publish/extract_maya_usd.py
@@ -0,0 +1,293 @@
+import os
+import six
+import json
+import contextlib
+
+from maya import cmds
+
+import pyblish.api
+from openpype.pipeline import publish
+from openpype.hosts.maya.api.lib import maintained_selection
+
+
+@contextlib.contextmanager
+def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None):
+ """Define attributes for the given nodes that should be exported.
+
+ MayaUSDExport will export custom attributes if the Maya node has a
+ string attribute `USD_UserExportedAttributesJson` that provides an
+ export mapping for the maya attributes. This context manager will try
+ to autogenerate such an attribute during the export to include attributes
+ for the export.
+
+ Arguments:
+ nodes (List[str]): Nodes to process.
+ attrs (Optional[List[str]]): Full name of attributes to include.
+ attr_prefixes (Optional[List[str]]): Prefixes of attributes to include.
+ mapping (Optional[Dict[Dict]]): A mapping per attribute name for the
+ conversion to a USD attribute, including renaming, defining type,
+ converting attribute precision, etc. This match the usual
+ `USD_UserExportedAttributesJson` json mapping of `mayaUSDExport`.
+ When no mapping provided for an attribute it will use `{}` as
+ value.
+
+ Examples:
+ >>> with usd_export_attributes(
+ >>> ["pCube1"], attrs="myDoubleAttributeAsFloat", mapping={
+ >>> "myDoubleAttributeAsFloat": {
+ >>> "usdAttrName": "my:namespace:attrib",
+ >>> "translateMayaDoubleToUsdSinglePrecision": True,
+ >>> }
+ >>> })
+
+ """
+ # todo: this might be better done with a custom export chaser
+ # see `chaser` argument for `mayaUSDExport`
+
+ import maya.api.OpenMaya as om
+
+ if not attrs and not attr_prefixes:
+ # context manager does nothing
+ yield
+ return
+
+ if attrs is None:
+ attrs = []
+ if attr_prefixes is None:
+ attr_prefixes = []
+ if mapping is None:
+ mapping = {}
+
+ usd_json_attr = "USD_UserExportedAttributesJson"
+ strings = attrs + ["{}*".format(prefix) for prefix in attr_prefixes]
+ context_state = {}
+ for node in set(nodes):
+ node_attrs = cmds.listAttr(node, st=strings)
+ if not node_attrs:
+ # Nothing to do for this node
+ continue
+
+ node_attr_data = {}
+ for node_attr in set(node_attrs):
+ node_attr_data[node_attr] = mapping.get(node_attr, {})
+
+ if cmds.attributeQuery(usd_json_attr, node=node, exists=True):
+ existing_node_attr_value = cmds.getAttr(
+ "{}.{}".format(node, usd_json_attr)
+ )
+ if existing_node_attr_value and existing_node_attr_value != "{}":
+ # Any existing attribute mappings in an existing
+ # `USD_UserExportedAttributesJson` attribute always take
+ # precedence over what this function tries to imprint
+ existing_node_attr_data = json.loads(existing_node_attr_value)
+ node_attr_data.update(existing_node_attr_data)
+
+ context_state[node] = json.dumps(node_attr_data)
+
+ sel = om.MSelectionList()
+ dg_mod = om.MDGModifier()
+ fn_string = om.MFnStringData()
+ fn_typed = om.MFnTypedAttribute()
+ try:
+ for node, value in context_state.items():
+ data = fn_string.create(value)
+ sel.clear()
+ if cmds.attributeQuery(usd_json_attr, node=node, exists=True):
+ # Set the attribute value
+ sel.add("{}.{}".format(node, usd_json_attr))
+ plug = sel.getPlug(0)
+ dg_mod.newPlugValue(plug, data)
+ else:
+ # Create attribute with the value as default value
+ sel.add(node)
+ node_obj = sel.getDependNode(0)
+ attr_obj = fn_typed.create(usd_json_attr,
+ usd_json_attr,
+ om.MFnData.kString,
+ data)
+ dg_mod.addAttribute(node_obj, attr_obj)
+ dg_mod.doIt()
+ yield
+ finally:
+ dg_mod.undoIt()
+
+
+class ExtractMayaUsd(publish.Extractor):
+ """Extractor for Maya USD Asset data.
+
+ Upon publish a .usd (or .usdz) asset file will typically be written.
+ """
+
+ label = "Extract Maya USD Asset"
+ hosts = ["maya"]
+ families = ["mayaUsd"]
+
+ @property
+ def options(self):
+ """Overridable options for Maya USD Export
+
+ Given in the following format
+ - {NAME: EXPECTED TYPE}
+
+ If the overridden option's type does not match,
+ the option is not included and a warning is logged.
+
+ """
+
+ # TODO: Support more `mayaUSDExport` parameters
+ return {
+ "defaultUSDFormat": str,
+ "stripNamespaces": bool,
+ "mergeTransformAndShape": bool,
+ "exportDisplayColor": bool,
+ "exportColorSets": bool,
+ "exportInstances": bool,
+ "exportUVs": bool,
+ "exportVisibility": bool,
+ "exportComponentTags": bool,
+ "exportRefsAsInstanceable": bool,
+ "eulerFilter": bool,
+ "renderableOnly": bool,
+ "jobContext": (list, None) # optional list
+ # "worldspace": bool,
+ }
+
+ @property
+ def default_options(self):
+ """The default options for Maya USD Export."""
+
+ # TODO: Support more `mayaUSDExport` parameters
+ return {
+ "defaultUSDFormat": "usdc",
+ "stripNamespaces": False,
+ "mergeTransformAndShape": False,
+ "exportDisplayColor": False,
+ "exportColorSets": True,
+ "exportInstances": True,
+ "exportUVs": True,
+ "exportVisibility": True,
+ "exportComponentTags": True,
+ "exportRefsAsInstanceable": False,
+ "eulerFilter": True,
+ "renderableOnly": False,
+ "jobContext": None
+ # "worldspace": False
+ }
+
+ def parse_overrides(self, instance, options):
+ """Inspect data of instance to determine overridden options"""
+
+ for key in instance.data:
+ if key not in self.options:
+ continue
+
+ # Ensure the data is of correct type
+ value = instance.data[key]
+ if isinstance(value, six.text_type):
+ value = str(value)
+ if not isinstance(value, self.options[key]):
+ self.log.warning(
+ "Overridden attribute {key} was of "
+ "the wrong type: {invalid_type} "
+ "- should have been {valid_type}".format(
+ key=key,
+ invalid_type=type(value).__name__,
+ valid_type=self.options[key].__name__))
+ continue
+
+ options[key] = value
+
+ return options
+
+ def filter_members(self, members):
+ # Can be overridden by inherited classes
+ return members
+
+ def process(self, instance):
+
+ # Load plugin first
+ cmds.loadPlugin("mayaUsdPlugin", quiet=True)
+
+ # Define output file path
+ staging_dir = self.staging_dir(instance)
+ file_name = "{0}.usd".format(instance.name)
+ file_path = os.path.join(staging_dir, file_name)
+ file_path = file_path.replace('\\', '/')
+
+ # Parse export options
+ options = self.default_options
+ options = self.parse_overrides(instance, options)
+ self.log.debug("Export options: {0}".format(options))
+
+ # Perform extraction
+ self.log.debug("Performing extraction ...")
+
+ members = instance.data("setMembers")
+ self.log.debug('Collected objects: {}'.format(members))
+ members = self.filter_members(members)
+ if not members:
+ self.log.error('No members!')
+ return
+
+ start = instance.data["frameStartHandle"]
+ end = instance.data["frameEndHandle"]
+
+ def parse_attr_str(attr_str):
+ result = list()
+ for attr in attr_str.split(","):
+ attr = attr.strip()
+ if not attr:
+ continue
+ result.append(attr)
+ return result
+
+ attrs = parse_attr_str(instance.data.get("attr", ""))
+ attrs += instance.data.get("userDefinedAttributes", [])
+ attrs += ["cbId"]
+ attr_prefixes = parse_attr_str(instance.data.get("attrPrefix", ""))
+
+ self.log.debug('Exporting USD: {} / {}'.format(file_path, members))
+ with maintained_selection():
+ with usd_export_attributes(instance[:],
+ attrs=attrs,
+ attr_prefixes=attr_prefixes):
+ cmds.mayaUSDExport(file=file_path,
+ frameRange=(start, end),
+ frameStride=instance.data.get("step", 1.0),
+ exportRoots=members,
+ **options)
+
+ representation = {
+ 'name': "usd",
+ 'ext': "usd",
+ 'files': file_name,
+ 'stagingDir': staging_dir
+ }
+ instance.data.setdefault("representations", []).append(representation)
+
+ self.log.debug(
+ "Extracted instance {} to {}".format(instance.name, file_path)
+ )
+
+
+class ExtractMayaUsdAnim(ExtractMayaUsd):
+ """Extractor for Maya USD Animation Sparse Cache data.
+
+ This will extract the sparse cache data from the scene and generate a
+ USD file with all the animation data.
+
+ Upon publish a .usd sparse cache will be written.
+ """
+ label = "Extract Maya USD Animation Sparse Cache"
+ families = ["animation", "mayaUsd"]
+ match = pyblish.api.Subset
+
+ def filter_members(self, members):
+ out_set = next((i for i in members if i.endswith("out_SET")), None)
+
+ if out_set is None:
+ self.log.warning("Expecting out_SET")
+ return None
+
+ members = cmds.ls(cmds.sets(out_set, query=True), long=True)
+ return members
diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py
index 17d5891e59..60185bb152 100644
--- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py
+++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py
@@ -28,7 +28,7 @@ class ExtractMultiverseUsd(publish.Extractor):
label = "Extract Multiverse USD Asset"
hosts = ["maya"]
- families = ["usd"]
+ families = ["mvUsd"]
scene_type = "usd"
file_formats = ["usd", "usda", "usdz"]
diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py
index 5530446e3d..0cc802fa7a 100644
--- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py
+++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py
@@ -107,7 +107,8 @@ class ExtractAlembic(publish.Extractor):
}
instance.data["representations"].append(representation)
- instance.context.data["cleanupFullPaths"].append(path)
+ if not instance.data.get("stagingDir_persistent", False):
+ instance.context.data["cleanupFullPaths"].append(path)
self.log.debug("Extracted {} to {}".format(instance, dirname))
diff --git a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py
index 921ee44a24..d9bec87cfd 100644
--- a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py
+++ b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py
@@ -80,7 +80,8 @@ class ExtractProxyAlembic(publish.Extractor):
}
instance.data["representations"].append(representation)
- instance.context.data["cleanupFullPaths"].append(path)
+ if not instance.data.get("stagingDir_persistent", False):
+ instance.context.data["cleanupFullPaths"].append(path)
self.log.debug("Extracted {} to {}".format(instance, dirname))
# remove the bounding box
diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py
index e44204cae0..c0be3d77db 100644
--- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py
@@ -92,7 +92,6 @@ class ExtractThumbnail(publish.Extractor):
"Create temp directory {} for thumbnail".format(dst_staging)
)
# Store new staging to cleanup paths
- instance.context.data["cleanupFullPaths"].append(dst_staging)
filename = "{0}".format(instance.name)
path = os.path.join(dst_staging, filename)
diff --git a/openpype/hosts/maya/plugins/publish/validate_color_sets.py b/openpype/hosts/maya/plugins/publish/validate_color_sets.py
index 766124cd9e..173fee4179 100644
--- a/openpype/hosts/maya/plugins/publish/validate_color_sets.py
+++ b/openpype/hosts/maya/plugins/publish/validate_color_sets.py
@@ -3,9 +3,10 @@ from maya import cmds
import pyblish.api
import openpype.hosts.maya.api.action
from openpype.pipeline.publish import (
- RepairAction,
ValidateMeshOrder,
- OptionalPyblishPluginMixin
+ OptionalPyblishPluginMixin,
+ PublishValidationError,
+ RepairAction
)
@@ -22,8 +23,9 @@ class ValidateColorSets(pyblish.api.Validator,
hosts = ['maya']
families = ['model']
label = 'Mesh ColorSets'
- actions = [openpype.hosts.maya.api.action.SelectInvalidAction,
- RepairAction]
+ actions = [
+ openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction
+ ]
optional = True
@staticmethod
@@ -48,8 +50,9 @@ class ValidateColorSets(pyblish.api.Validator,
invalid = self.get_invalid(instance)
if invalid:
- raise ValueError("Meshes found with "
- "Color Sets: {0}".format(invalid))
+ raise PublishValidationError(
+ message="Meshes found with Color Sets: {0}".format(invalid)
+ )
@classmethod
def repair(cls, instance):
diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py
index f9aa7f82d0..030e41ca1f 100644
--- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py
+++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py
@@ -1,4 +1,7 @@
+import os
+
import pyblish.api
+
from maya import cmds
from openpype.pipeline.publish import (
@@ -22,8 +25,12 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin):
def process(self, instance):
- required_images_rule = self.get_default_render_image_folder(instance)
- current_images_rule = cmds.workspace(fileRuleEntry="images")
+ required_images_rule = os.path.normpath(
+ self.get_default_render_image_folder(instance)
+ )
+ current_images_rule = os.path.normpath(
+ cmds.workspace(fileRuleEntry="images")
+ )
if current_images_rule != required_images_rule:
raise PublishValidationError(
@@ -42,8 +49,17 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin):
cmds.workspace(fileRule=("images", required_images_rule))
cmds.workspace(saveWorkspace=True)
- @staticmethod
- def get_default_render_image_folder(instance):
+ @classmethod
+ def get_default_render_image_folder(cls, instance):
+ staging_dir = instance.data.get("stagingDir")
+ if staging_dir:
+ cls.log.debug(
+ "Staging dir found: \"{}\". Ignoring setting from "
+ "`project_settings/maya/RenderSettings/"
+ "default_render_image_folder`.".format(staging_dir)
+ )
+ return staging_dir
+
return instance.context.data.get('project_settings')\
.get('maya') \
.get('RenderSettings') \
diff --git a/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py b/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py
index 0ce2b21dcd..076b0047bb 100644
--- a/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py
+++ b/openpype/hosts/maya/tools/mayalookassigner/arnold_standin.py
@@ -10,6 +10,7 @@ from openpype.client import get_last_version_by_subset_name
from openpype.hosts.maya import api
from . import lib
from .alembic import get_alembic_ids_cache
+from .usd import is_usd_lib_supported, get_usd_ids_cache
log = logging.getLogger(__name__)
@@ -74,6 +75,13 @@ def get_nodes_by_id(standin):
# Support alembic files directly
return get_alembic_ids_cache(path)
+ elif (
+ is_usd_lib_supported and
+ any(path.endswith(ext) for ext in [".usd", ".usda", ".usdc"])
+ ):
+ # Support usd files directly
+ return get_usd_ids_cache(path)
+
json_path = None
for f in os.listdir(os.path.dirname(path)):
if f.endswith(".json"):
diff --git a/openpype/hosts/maya/tools/mayalookassigner/usd.py b/openpype/hosts/maya/tools/mayalookassigner/usd.py
new file mode 100644
index 0000000000..6b5cb2f0f5
--- /dev/null
+++ b/openpype/hosts/maya/tools/mayalookassigner/usd.py
@@ -0,0 +1,38 @@
+from collections import defaultdict
+
+try:
+ from pxr import Usd
+ is_usd_lib_supported = True
+except ImportError:
+ is_usd_lib_supported = False
+
+
+def get_usd_ids_cache(path):
+ # type: (str) -> dict
+ """Build a id to node mapping in a USD file.
+
+ Nodes without IDs are ignored.
+
+ Returns:
+ dict: Mapping of id to nodes in the USD file.
+
+ """
+ if not is_usd_lib_supported:
+ raise RuntimeError("No pxr.Usd python library available.")
+
+ stage = Usd.Stage.Open(path)
+ ids = {}
+ for prim in stage.Traverse():
+ attr = prim.GetAttribute("userProperties:cbId")
+ if not attr.IsValid():
+ continue
+ value = attr.Get()
+ if not value:
+ continue
+ path = str(prim.GetPath())
+ ids[path] = value
+
+ cache = defaultdict(list)
+ for path, value in ids.items():
+ cache[value].append(path)
+ return dict(cache)
diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py
index a13a91de46..fd568b2826 100644
--- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py
+++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py
@@ -6,7 +6,10 @@ from PIL import Image
import pyblish.api
-from openpype.pipeline.publish import KnownPublishError
+from openpype.pipeline.publish import (
+ KnownPublishError,
+ get_publish_instance_families,
+)
from openpype.hosts.tvpaint.api.lib import (
execute_george,
execute_george_through_file,
@@ -140,8 +143,9 @@ class ExtractSequence(pyblish.api.Extractor):
)
# Fill tags and new families from project settings
+ instance_families = get_publish_instance_families(instance)
tags = []
- if "review" in instance.data["families"]:
+ if "review" in instance_families:
tags.append("review")
# Sequence of one frame
diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py
index 3fb35a7e7b..dae6e074af 100644
--- a/openpype/lib/local_settings.py
+++ b/openpype/lib/local_settings.py
@@ -494,10 +494,18 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry):
"""
def __init__(self, name=None):
- self.vendor = "pypeclub"
- self.product = "openpype"
+ if AYON_SERVER_ENABLED:
+ vendor = "Ynput"
+ product = "AYON"
+ default_name = "AYON_settings"
+ else:
+ vendor = "pypeclub"
+ product = "openpype"
+ default_name = "openpype_settings"
+ self.vendor = vendor
+ self.product = product
if not name:
- name = "openpype_settings"
+ name = default_name
path = appdirs.user_data_dir(self.product, self.vendor)
super(OpenPypeSettingsRegistry, self).__init__(name, path)
diff --git a/openpype/modules/base.py b/openpype/modules/base.py
index 84e213288c..a3c21718b9 100644
--- a/openpype/modules/base.py
+++ b/openpype/modules/base.py
@@ -59,6 +59,14 @@ IGNORED_DEFAULT_FILENAMES = (
"example_addons",
"default_modules",
)
+# Modules that won't be loaded in AYON mode from "./openpype/modules"
+# - the same modules are ignored in "./server_addon/create_ayon_addons.py"
+IGNORED_FILENAMES_IN_AYON = {
+ "ftrack",
+ "shotgrid",
+ "sync_server",
+ "slack",
+}
# Inherit from `object` for Python 2 hosts
@@ -392,9 +400,9 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
folder_name = "{}_{}".format(addon_name, addon_version)
addon_dir = os.path.join(addons_dir, folder_name)
if not os.path.exists(addon_dir):
- log.warning((
- "Directory for addon {} {} does not exists. Path \"{}\""
- ).format(addon_name, addon_version, addon_dir))
+ log.debug((
+ "No localized client code found for addon {} {}."
+ ).format(addon_name, addon_version))
continue
sys.path.insert(0, addon_dir)
@@ -483,6 +491,10 @@ def _load_modules():
is_in_current_dir = dirpath == current_dir
is_in_host_dir = dirpath == hosts_dir
+ ignored_current_dir_filenames = set(IGNORED_DEFAULT_FILENAMES)
+ if AYON_SERVER_ENABLED:
+ ignored_current_dir_filenames |= IGNORED_FILENAMES_IN_AYON
+
for filename in os.listdir(dirpath):
# Ignore filenames
if filename in IGNORED_FILENAMES:
@@ -490,7 +502,7 @@ def _load_modules():
if (
is_in_current_dir
- and filename in IGNORED_DEFAULT_FILENAMES
+ and filename in ignored_current_dir_filenames
):
continue
diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
index 74ecdbe7bf..7775191b12 100644
--- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
@@ -290,7 +290,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
def process_submission(self):
instance = self._instance
- context = instance.context
filepath = self.scene_path # publish if `use_publish` else workfile
@@ -306,13 +305,11 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
self._patch_workfile()
# Gather needed data ------------------------------------------------
- workspace = context.data["workspaceDir"]
- default_render_file = instance.context.data.get('project_settings')\
- .get('maya')\
- .get('RenderSettings')\
- .get('default_render_image_folder')
filename = os.path.basename(filepath)
- dirname = os.path.join(workspace, default_render_file)
+ dirname = os.path.join(
+ cmds.workspace(query=True, rootDirectory=True),
+ cmds.workspace(fileRuleEntry="images")
+ )
# Fill in common data to payload ------------------------------------
# TODO: Replace these with collected data from CollectRender
diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
index 20bebe583f..909975f7ab 100644
--- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py
+++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py
@@ -345,6 +345,151 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
self.log.debug("Skipping local instance.")
return
+ data = instance.data.copy()
+ context = instance.context
+ self.context = context
+ self.anatomy = instance.context.data["anatomy"]
+
+ asset = data.get("asset") or context.data["asset"]
+ subset = data.get("subset")
+
+ start = instance.data.get("frameStart")
+ if start is None:
+ start = context.data["frameStart"]
+
+ end = instance.data.get("frameEnd")
+ if end is None:
+ end = context.data["frameEnd"]
+
+ handle_start = instance.data.get("handleStart")
+ if handle_start is None:
+ handle_start = context.data["handleStart"]
+
+ handle_end = instance.data.get("handleEnd")
+ if handle_end is None:
+ handle_end = context.data["handleEnd"]
+
+ fps = instance.data.get("fps")
+ if fps is None:
+ fps = context.data["fps"]
+
+ if data.get("extendFrames", False):
+ start, end = self._extend_frames(
+ asset,
+ subset,
+ start,
+ end,
+ data["overrideExistingFrame"])
+
+ try:
+ source = data["source"]
+ except KeyError:
+ source = context.data["currentFile"]
+
+ success, rootless_path = (
+ self.anatomy.find_root_template_from_path(source)
+ )
+ if success:
+ source = rootless_path
+
+ else:
+ # `rootless_path` is not set to `source` if none of roots match
+ self.log.warning((
+ "Could not find root path for remapping \"{}\"."
+ " This may cause issues."
+ ).format(source))
+
+ family = "render"
+ if ("prerender" in instance.data["families"] or
+ "prerender.farm" in instance.data["families"]):
+ family = "prerender"
+ families = [family]
+
+ # pass review to families if marked as review
+ do_not_add_review = False
+ if data.get("review"):
+ families.append("review")
+ elif data.get("review") is False:
+ self.log.debug("Instance has review explicitly disabled.")
+ do_not_add_review = True
+
+ instance_skeleton_data = {
+ "family": family,
+ "subset": subset,
+ "families": families,
+ "asset": asset,
+ "frameStart": start,
+ "frameEnd": end,
+ "handleStart": handle_start,
+ "handleEnd": handle_end,
+ "frameStartHandle": start - handle_start,
+ "frameEndHandle": end + handle_end,
+ "comment": instance.data["comment"],
+ "fps": fps,
+ "source": source,
+ "extendFrames": data.get("extendFrames"),
+ "overrideExistingFrame": data.get("overrideExistingFrame"),
+ "pixelAspect": data.get("pixelAspect", 1),
+ "resolutionWidth": data.get("resolutionWidth", 1920),
+ "resolutionHeight": data.get("resolutionHeight", 1080),
+ "multipartExr": data.get("multipartExr", False),
+ "jobBatchName": data.get("jobBatchName", ""),
+ "useSequenceForReview": data.get("useSequenceForReview", True),
+ # map inputVersions `ObjectId` -> `str` so json supports it
+ "inputVersions": list(map(str, data.get("inputVersions", []))),
+ "colorspace": instance.data.get("colorspace"),
+ "stagingDir_persistent": instance.data.get(
+ "stagingDir_persistent", False
+ )
+ }
+
+ # skip locking version if we are creating v01
+ instance_version = instance.data.get("version") # take this if exists
+ if instance_version != 1:
+ instance_skeleton_data["version"] = instance_version
+
+ # transfer specific families from original instance to new render
+ for item in self.families_transfer:
+ if item in instance.data.get("families", []):
+ instance_skeleton_data["families"] += [item]
+
+ # transfer specific properties from original instance based on
+ # mapping dictionary `instance_transfer`
+ for key, values in self.instance_transfer.items():
+ if key in instance.data.get("families", []):
+ for v in values:
+ instance_skeleton_data[v] = instance.data.get(v)
+
+ # look into instance data if representations are not having any
+ # which are having tag `publish_on_farm` and include them
+ for repre in instance.data.get("representations", []):
+ staging_dir = repre.get("stagingDir")
+ if staging_dir:
+ success, rootless_staging_dir = (
+ self.anatomy.find_root_template_from_path(
+ staging_dir
+ )
+ )
+ if success:
+ repre["stagingDir"] = rootless_staging_dir
+ else:
+ self.log.warning((
+ "Could not find root path for remapping \"{}\"."
+ " This may cause issues on farm."
+ ).format(staging_dir))
+ repre["stagingDir"] = staging_dir
+
+ if "publish_on_farm" in repre.get("tags"):
+ # create representations attribute of not there
+ if "representations" not in instance_skeleton_data.keys():
+ instance_skeleton_data["representations"] = []
+
+ instance_skeleton_data["representations"].append(repre)
+
+ instances = None
+ assert data.get("expectedFiles"), ("Submission from old Pype version"
+ " - missing expectedFiles")
+
anatomy = instance.context.data["anatomy"]
instance_skeleton_data = create_skeleton_instance(
diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py
index 97875215ae..e9b81369ca 100644
--- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py
+++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py
@@ -385,6 +385,12 @@ def inject_openpype_environment(deadlinePlugin):
for key, value in contents.items():
deadlinePlugin.SetProcessEnvironmentVariable(key, value)
+ if "PATH" in contents:
+ # Set os.environ[PATH] so studio settings' path entries
+ # can be used to define search path for executables.
+ print(f">>> Setting 'PATH' Environment to: {contents['PATH']}")
+ os.environ["PATH"] = contents["PATH"]
+
script_url = job.GetJobPluginInfoKeyValue("ScriptFilename")
if script_url:
script_url = script_url.format(**contents).replace("\\", "/")
@@ -509,6 +515,12 @@ def inject_ayon_environment(deadlinePlugin):
for key, value in contents.items():
deadlinePlugin.SetProcessEnvironmentVariable(key, value)
+ if "PATH" in contents:
+ # Set os.environ[PATH] so studio settings' path entries
+ # can be used to define search path for executables.
+ print(f">>> Setting 'PATH' Environment to: {contents['PATH']}")
+ os.environ["PATH"] = contents["PATH"]
+
script_url = job.GetJobPluginInfoKeyValue("ScriptFilename")
if script_url:
script_url = script_url.format(**contents).replace("\\", "/")
diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py
index c4331b6094..5e14f25f76 100644
--- a/openpype/modules/launcher_action.py
+++ b/openpype/modules/launcher_action.py
@@ -1,3 +1,6 @@
+import os
+
+from openpype import PLUGINS_DIR, AYON_SERVER_ENABLED
from openpype.modules import (
OpenPypeModule,
ITrayAction,
@@ -13,36 +16,66 @@ class LauncherAction(OpenPypeModule, ITrayAction):
self.enabled = True
# Tray attributes
- self.window = None
+ self._window = None
def tray_init(self):
- self.create_window()
+ self._create_window()
- self.add_doubleclick_callback(self.show_launcher)
+ self.add_doubleclick_callback(self._show_launcher)
def tray_start(self):
return
def connect_with_modules(self, enabled_modules):
# Register actions
- if self.tray_initialized:
- from openpype.tools.launcher import actions
- actions.register_config_actions()
- actions_paths = self.manager.collect_plugin_paths()["actions"]
- actions.register_actions_from_paths(actions_paths)
- actions.register_environment_actions()
-
- def create_window(self):
- if self.window:
+ if not self.tray_initialized:
return
- from openpype.tools.launcher import LauncherWindow
- self.window = LauncherWindow()
+
+ from openpype.pipeline.actions import register_launcher_action_path
+
+ actions_dir = os.path.join(PLUGINS_DIR, "actions")
+ if os.path.exists(actions_dir):
+ register_launcher_action_path(actions_dir)
+
+ actions_paths = self.manager.collect_plugin_paths()["actions"]
+ for path in actions_paths:
+ if path and os.path.exists(path):
+ register_launcher_action_path(actions_dir)
+
+ paths_str = os.environ.get("AVALON_ACTIONS") or ""
+ if paths_str:
+ self.log.warning(
+ "WARNING: 'AVALON_ACTIONS' is deprecated. Support of this"
+ " environment variable will be removed in future versions."
+ " Please consider using 'OpenPypeModule' to define custom"
+ " action paths. Planned version to drop the support"
+ " is 3.17.2 or 3.18.0 ."
+ )
+
+ for path in paths_str.split(os.pathsep):
+ if path and os.path.exists(path):
+ register_launcher_action_path(path)
def on_action_trigger(self):
- self.show_launcher()
+ """Implementation for ITrayAction interface.
- def show_launcher(self):
- if self.window:
- self.window.show()
- self.window.raise_()
- self.window.activateWindow()
+ Show launcher tool on action trigger.
+ """
+
+ self._show_launcher()
+
+ def _create_window(self):
+ if self._window:
+ return
+ if AYON_SERVER_ENABLED:
+ from openpype.tools.ayon_launcher.ui import LauncherWindow
+ else:
+ from openpype.tools.launcher import LauncherWindow
+ self._window = LauncherWindow()
+
+ def _show_launcher(self):
+ if self._window is None:
+ return
+ self._window.show()
+ self._window.raise_()
+ self._window.activateWindow()
diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py
index b488fe3e1f..feb1bd05d2 100644
--- a/openpype/pipeline/actions.py
+++ b/openpype/pipeline/actions.py
@@ -20,7 +20,13 @@ class LauncherAction(object):
log.propagate = True
def is_compatible(self, session):
- """Return whether the class is compatible with the Session."""
+ """Return whether the class is compatible with the Session.
+
+ Args:
+ session (dict[str, Union[str, None]]): Session data with
+ AVALON_PROJECT, AVALON_ASSET and AVALON_TASK.
+ """
+
return True
def process(self, session, **kwargs):
diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py
index 0c57915c05..3a82d6f565 100644
--- a/openpype/pipeline/publish/__init__.py
+++ b/openpype/pipeline/publish/__init__.py
@@ -40,6 +40,7 @@ from .lib import (
apply_plugin_settings_automatically,
get_plugin_settings,
get_publish_instance_label,
+ get_publish_instance_families,
)
from .abstract_expected_files import ExpectedFiles
@@ -87,6 +88,7 @@ __all__ = (
"apply_plugin_settings_automatically",
"get_plugin_settings",
"get_publish_instance_label",
+ "get_publish_instance_families",
"ExpectedFiles",
diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py
index 1ae6ea43b2..4d9443f635 100644
--- a/openpype/pipeline/publish/lib.py
+++ b/openpype/pipeline/publish/lib.py
@@ -1002,3 +1002,27 @@ def get_publish_instance_label(instance):
or instance.data.get("name")
or str(instance)
)
+
+
+def get_publish_instance_families(instance):
+ """Get all families of the instance.
+
+ Look for families under 'family' and 'families' keys in instance data.
+ Value of 'family' is used as first family and then all other families
+ in random order.
+
+ Args:
+ pyblish.api.Instance: Instance to get families from.
+
+ Returns:
+ list[str]: List of families.
+ """
+
+ family = instance.data.get("family")
+ families = set(instance.data.get("families") or [])
+ output = []
+ if family:
+ output.append(family)
+ families.discard(family)
+ output.extend(families)
+ return output
diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py
index aaf290ace7..8a5a5a83f1 100644
--- a/openpype/plugins/publish/collect_rendered_files.py
+++ b/openpype/plugins/publish/collect_rendered_files.py
@@ -103,13 +103,16 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
# stash render job id for later validation
instance.data["render_job_id"] = data.get("job").get("_id")
-
+ staging_dir_persistent = instance.data.get(
+ "stagingDir_persistent", False
+ )
representations = []
for repre_data in instance_data.get("representations") or []:
self._fill_staging_dir(repre_data, anatomy)
representations.append(repre_data)
- add_repre_files_for_cleanup(instance, repre_data)
+ if not staging_dir_persistent:
+ add_repre_files_for_cleanup(instance, repre_data)
instance.data["representations"] = representations
@@ -124,6 +127,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
self.log.debug(
f"Adding audio to instance: {instance.data['audio']}")
+ return staging_dir_persistent
+
def process(self, context):
self._context = context
@@ -160,9 +165,12 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
legacy_io.Session.update(session_data)
os.environ.update(session_data)
session_is_set = True
- self._process_path(data, anatomy)
- context.data["cleanupFullPaths"].append(path)
- context.data["cleanupEmptyDirs"].append(os.path.dirname(path))
+ staging_dir_persistent = self._process_path(data, anatomy)
+ if not staging_dir_persistent:
+ context.data["cleanupFullPaths"].append(path)
+ context.data["cleanupEmptyDirs"].append(
+ os.path.dirname(path)
+ )
except Exception as e:
self.log.error(e, exc_info=True)
raise Exception("Error") from e
diff --git a/openpype/plugins/publish/integrate_inputlinks_ayon.py b/openpype/plugins/publish/integrate_inputlinks_ayon.py
index 180524cd08..28684aa889 100644
--- a/openpype/plugins/publish/integrate_inputlinks_ayon.py
+++ b/openpype/plugins/publish/integrate_inputlinks_ayon.py
@@ -1,7 +1,11 @@
import collections
import pyblish.api
-from ayon_api import create_link, make_sure_link_type_exists
+from ayon_api import (
+ create_link,
+ make_sure_link_type_exists,
+ get_versions_links,
+)
from openpype import AYON_SERVER_ENABLED
@@ -124,6 +128,33 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
version_entity["_id"],
)
+ def _get_existing_links(self, project_name, link_type, entity_ids):
+ """Find all existing links for given version ids.
+
+ Args:
+ project_name (str): Name of project.
+ link_type (str): Type of link.
+ entity_ids (set[str]): Set of version ids.
+
+ Returns:
+ dict[str, set[str]]: Existing links by version id.
+ """
+
+ output = collections.defaultdict(set)
+ if not entity_ids:
+ return output
+
+ existing_in_links = get_versions_links(
+ project_name, entity_ids, [link_type], "output"
+ )
+
+ for entity_id, links in existing_in_links.items():
+ if not links:
+ continue
+ for link in links:
+ output[entity_id].add(link["entityId"])
+ return output
+
def create_links_on_server(self, context, new_links):
"""Create new links on server.
@@ -144,16 +175,32 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
# Create link themselves
for link_type, items in new_links.items():
+ mapping = collections.defaultdict(set)
+ # Make sure there are no duplicates of src > dst ids
for item in items:
- input_id, output_id = item
- create_link(
- project_name,
- link_type,
- input_id,
- "version",
- output_id,
- "version"
- )
+ _input_id, _output_id = item
+ mapping[_input_id].add(_output_id)
+
+ existing_links_by_in_id = self._get_existing_links(
+ project_name, link_type, set(mapping.keys())
+ )
+
+ for input_id, output_ids in mapping.items():
+ existing_links = existing_links_by_in_id[input_id]
+ for output_id in output_ids:
+ # Skip creation of link if already exists
+ # NOTE: AYON server does not support
+ # to have same links
+ if output_id in existing_links:
+ continue
+ create_link(
+ project_name,
+ link_type,
+ input_id,
+ "version",
+ output_id,
+ "version"
+ )
if not AYON_SERVER_ENABLED:
diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py
index 9a4f0607e0..3be8ac8ae5 100644
--- a/openpype/settings/ayon_settings.py
+++ b/openpype/settings/ayon_settings.py
@@ -1102,7 +1102,7 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
"studio_name",
"studio_code",
):
- ayon_core.pop(key)
+ ayon_core.pop(key, None)
# Publish conversion
ayon_publish = ayon_core["publish"]
@@ -1140,6 +1140,27 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
profile["outputs"] = new_outputs
+ # ExtractOIIOTranscode plugin
+ extract_oiio_transcode = ayon_publish["ExtractOIIOTranscode"]
+ extract_oiio_transcode_profiles = extract_oiio_transcode["profiles"]
+ for profile in extract_oiio_transcode_profiles:
+ new_outputs = {}
+ name_counter = {}
+ for output in profile["outputs"]:
+ if "name" in output:
+ name = output.pop("name")
+ else:
+ # Backwards compatibility for setting without 'name' in model
+ name = 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
+ profile["outputs"] = new_outputs
+
# Extract Burnin plugin
extract_burnin = ayon_publish["ExtractBurnin"]
extract_burnin_options = extract_burnin["options"]
diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json
index 93d5c50d5e..5392fc34dd 100644
--- a/openpype/settings/defaults/project_settings/houdini.json
+++ b/openpype/settings/defaults/project_settings/houdini.json
@@ -19,6 +19,19 @@
],
"ext": ".ass"
},
+ "CreateStaticMesh": {
+ "enabled": true,
+ "default_variants": [
+ "Main"
+ ],
+ "static_mesh_prefix": "S",
+ "collision_prefixes": [
+ "UBX",
+ "UCP",
+ "USP",
+ "UCX"
+ ]
+ },
"CreateAlembicCamera": {
"enabled": true,
"default_variants": [
@@ -102,6 +115,21 @@
"enabled": true,
"optional": true,
"active": true
+ },
+ "ValidateSubsetName": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateMeshIsStatic": {
+ "enabled": true,
+ "optional": true,
+ "active": true
+ },
+ "ValidateUnrealStaticMeshName": {
+ "enabled": false,
+ "optional": true,
+ "active": true
}
}
}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json
index 799bc0e81a..cd8c260124 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json
@@ -39,6 +39,37 @@
]
},
+ {
+ "type": "dict",
+ "collapsible": true,
+ "key": "CreateStaticMesh",
+ "label": "Create Static Mesh",
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ },
+ {
+ "type": "list",
+ "key": "default_variants",
+ "label": "Default Variants",
+ "object_type": "text"
+ },
+ {
+ "type": "text",
+ "key": "static_mesh_prefix",
+ "label": "Static Mesh Prefix"
+ },
+ {
+ "type": "list",
+ "key": "collision_prefixes",
+ "label": "Collision Mesh Prefixes",
+ "object_type": "text"
+ }
+ ]
+ },
{
"type": "schema_template",
"name": "template_create_plugin",
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
index b57089007e..d5f70b0312 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json
@@ -47,6 +47,18 @@
{
"key": "ValidateContainers",
"label": "ValidateContainers"
+ },
+ {
+ "key": "ValidateSubsetName",
+ "label": "Validate Subset Name"
+ },
+ {
+ "key": "ValidateMeshIsStatic",
+ "label": "Validate Mesh is Static"
+ },
+ {
+ "key": "ValidateUnrealStaticMeshName",
+ "label": "Validate Unreal Static Mesh Name"
}
]
}
diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json
index 636dfa114c..fc4e750e3b 100644
--- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json
+++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json
@@ -12,7 +12,7 @@
{
"type": "text",
"key": "default_render_image_folder",
- "label": "Default render image folder"
+ "label": "Default render image folder. This setting can be\noverwritten by custom staging directory profile;\n\"project_settings/global/tools/publish\n/custom_staging_dir_profiles\"."
},
{
"type": "boolean",
diff --git a/openpype/tests/test_lib_restructuralization.py b/openpype/tests/test_lib_restructuralization.py
index 669706d470..a91d65f7a8 100644
--- a/openpype/tests/test_lib_restructuralization.py
+++ b/openpype/tests/test_lib_restructuralization.py
@@ -18,8 +18,6 @@ def test_backward_compatibility(printer):
from openpype.lib import get_ffprobe_streams
- from openpype.hosts.fusion.lib import switch_item
-
from openpype.lib import source_hash
from openpype.lib import run_subprocess
diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py
new file mode 100644
index 0000000000..00502fe930
--- /dev/null
+++ b/openpype/tools/ayon_launcher/abstract.py
@@ -0,0 +1,297 @@
+from abc import ABCMeta, abstractmethod
+
+import six
+
+
+@six.add_metaclass(ABCMeta)
+class AbstractLauncherCommon(object):
+ @abstractmethod
+ def register_event_callback(self, topic, callback):
+ """Register event callback.
+
+ Listen for events with given topic.
+
+ Args:
+ topic (str): Name of topic.
+ callback (Callable): Callback that will be called when event
+ is triggered.
+ """
+
+ pass
+
+
+class AbstractLauncherBackend(AbstractLauncherCommon):
+ @abstractmethod
+ def emit_event(self, topic, data=None, source=None):
+ """Emit event.
+
+ Args:
+ topic (str): Event topic used for callbacks filtering.
+ data (Optional[dict[str, Any]]): Event data.
+ source (Optional[str]): Event source.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_project_settings(self, project_name):
+ """Project settings for current project.
+
+ Args:
+ project_name (Union[str, None]): Project name.
+
+ Returns:
+ dict[str, Any]: Project settings.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_project_entity(self, project_name):
+ """Get project entity by name.
+
+ Args:
+ project_name (str): Project name.
+
+ Returns:
+ dict[str, Any]: Project entity data.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_folder_entity(self, project_name, folder_id):
+ """Get folder entity by id.
+
+ Args:
+ project_name (str): Project name.
+ folder_id (str): Folder id.
+
+ Returns:
+ dict[str, Any]: Folder entity data.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_task_entity(self, project_name, task_id):
+ """Get task entity by id.
+
+ Args:
+ project_name (str): Project name.
+ task_id (str): Task id.
+
+ Returns:
+ dict[str, Any]: Task entity data.
+ """
+
+ pass
+
+
+class AbstractLauncherFrontEnd(AbstractLauncherCommon):
+ # Entity items for UI
+ @abstractmethod
+ def get_project_items(self, sender=None):
+ """Project items for all projects.
+
+ This function may trigger events 'projects.refresh.started' and
+ 'projects.refresh.finished' which will contain 'sender' value in data.
+ That may help to avoid re-refresh of project items in UI elements.
+
+ Args:
+ sender (str): Who requested folder items.
+
+ Returns:
+ list[ProjectItem]: Minimum possible information needed
+ for visualisation of folder hierarchy.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_folder_items(self, project_name, sender=None):
+ """Folder items to visualize project hierarchy.
+
+ This function may trigger events 'folders.refresh.started' and
+ 'folders.refresh.finished' which will contain 'sender' value in data.
+ That may help to avoid re-refresh of folder items in UI elements.
+
+ Args:
+ project_name (str): Project name.
+ sender (str): Who requested folder items.
+
+ Returns:
+ list[FolderItem]: Minimum possible information needed
+ for visualisation of folder hierarchy.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_task_items(self, project_name, folder_id, sender=None):
+ """Task items.
+
+ This function may trigger events 'tasks.refresh.started' and
+ 'tasks.refresh.finished' which will contain 'sender' value in data.
+ That may help to avoid re-refresh of task items in UI elements.
+
+ Args:
+ project_name (str): Project name.
+ folder_id (str): Folder ID for which are tasks requested.
+ sender (str): Who requested folder items.
+
+ Returns:
+ list[TaskItem]: Minimum possible information needed
+ for visualisation of tasks.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_project_name(self):
+ """Selected project name.
+
+ Returns:
+ Union[str, None]: Selected project name.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_folder_id(self):
+ """Selected folder id.
+
+ Returns:
+ Union[str, None]: Selected folder id.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_task_id(self):
+ """Selected task id.
+
+ Returns:
+ Union[str, None]: Selected task id.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_task_name(self):
+ """Selected task name.
+
+ Returns:
+ Union[str, None]: Selected task name.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_context(self):
+ """Get whole selected context.
+
+ Example:
+ {
+ "project_name": self.get_selected_project_name(),
+ "folder_id": self.get_selected_folder_id(),
+ "task_id": self.get_selected_task_id(),
+ "task_name": self.get_selected_task_name(),
+ }
+
+ Returns:
+ dict[str, Union[str, None]]: Selected context.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_project(self, project_name):
+ """Change selected folder.
+
+ Args:
+ project_name (Union[str, None]): Project nameor None if no project
+ is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_folder(self, folder_id):
+ """Change selected folder.
+
+ Args:
+ folder_id (Union[str, None]): Folder id or None if no folder
+ is selected.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_task(self, task_id, task_name):
+ """Change selected task.
+
+ Args:
+ task_id (Union[str, None]): Task id or None if no task
+ is selected.
+ task_name (Union[str, None]): Task name or None if no task
+ is selected.
+ """
+
+ pass
+
+ # Actions
+ @abstractmethod
+ def get_action_items(self, project_name, folder_id, task_id):
+ """Get action items for given context.
+
+ Args:
+ project_name (Union[str, None]): Project name.
+ folder_id (Union[str, None]): Folder id.
+ task_id (Union[str, None]): Task id.
+
+ Returns:
+ list[ActionItem]: List of action items that should be shown
+ for given context.
+ """
+
+ pass
+
+ @abstractmethod
+ def trigger_action(self, project_name, folder_id, task_id, action_id):
+ """Trigger action on given context.
+
+ Args:
+ project_name (Union[str, None]): Project name.
+ folder_id (Union[str, None]): Folder id.
+ task_id (Union[str, None]): Task id.
+ action_id (str): Action identifier.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_application_force_not_open_workfile(
+ self, project_name, folder_id, task_id, action_id, enabled
+ ):
+ """This is application action related to force not open last workfile.
+
+ Args:
+ project_name (Union[str, None]): Project name.
+ folder_id (Union[str, None]): Folder id.
+ task_id (Union[str, None]): Task id.
+ action_id (str): Action identifier.
+ enabled (bool): New value of force not open workfile.
+ """
+
+ pass
+
+ @abstractmethod
+ def refresh(self):
+ """Refresh everything, models, ui etc.
+
+ Triggers 'controller.refresh.started' event at the beginning and
+ 'controller.refresh.finished' at the end.
+ """
+
+ pass
diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py
new file mode 100644
index 0000000000..09e07893c3
--- /dev/null
+++ b/openpype/tools/ayon_launcher/control.py
@@ -0,0 +1,149 @@
+from openpype.lib import Logger
+from openpype.lib.events import QueuedEventSystem
+from openpype.settings import get_project_settings
+from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel
+
+from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
+from .models import LauncherSelectionModel, ActionsModel
+
+
+class BaseLauncherController(
+ AbstractLauncherFrontEnd, AbstractLauncherBackend
+):
+ def __init__(self):
+ self._project_settings = {}
+ self._event_system = None
+ self._log = None
+
+ self._selection_model = LauncherSelectionModel(self)
+ self._projects_model = ProjectsModel(self)
+ self._hierarchy_model = HierarchyModel(self)
+ self._actions_model = ActionsModel(self)
+
+ @property
+ def log(self):
+ if self._log is None:
+ self._log = Logger.get_logger(self.__class__.__name__)
+ return self._log
+
+ @property
+ def event_system(self):
+ """Inner event system for workfiles tool controller.
+
+ Is used for communication with UI. Event system is created on demand.
+
+ Returns:
+ QueuedEventSystem: Event system which can trigger callbacks
+ for topics.
+ """
+
+ if self._event_system is None:
+ self._event_system = QueuedEventSystem()
+ return self._event_system
+
+ # ---------------------------------
+ # 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)
+
+ # Entity items for UI
+ 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_task_items(self, project_name, folder_id, sender=None):
+ return self._hierarchy_model.get_task_items(
+ project_name, folder_id, sender)
+
+ # Project settings for applications actions
+ def get_project_settings(self, project_name):
+ if project_name in self._project_settings:
+ return self._project_settings[project_name]
+ settings = get_project_settings(project_name)
+ self._project_settings[project_name] = settings
+ return settings
+
+ # Entity for backend
+ def get_project_entity(self, project_name):
+ return self._projects_model.get_project_entity(project_name)
+
+ def get_folder_entity(self, project_name, folder_id):
+ return self._hierarchy_model.get_folder_entity(
+ project_name, folder_id)
+
+ def get_task_entity(self, project_name, task_id):
+ return self._hierarchy_model.get_task_entity(project_name, task_id)
+
+ # Selection methods
+ 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)
+
+ def get_selected_folder_id(self):
+ return self._selection_model.get_selected_folder_id()
+
+ def set_selected_folder(self, folder_id):
+ self._selection_model.set_selected_folder(folder_id)
+
+ def get_selected_task_id(self):
+ return self._selection_model.get_selected_task_id()
+
+ def get_selected_task_name(self):
+ return self._selection_model.get_selected_task_name()
+
+ def set_selected_task(self, task_id, task_name):
+ self._selection_model.set_selected_task(task_id, task_name)
+
+ def get_selected_context(self):
+ return {
+ "project_name": self.get_selected_project_name(),
+ "folder_id": self.get_selected_folder_id(),
+ "task_id": self.get_selected_task_id(),
+ "task_name": self.get_selected_task_name(),
+ }
+
+ # Actions
+ def get_action_items(self, project_name, folder_id, task_id):
+ return self._actions_model.get_action_items(
+ project_name, folder_id, task_id)
+
+ def set_application_force_not_open_workfile(
+ self, project_name, folder_id, task_id, action_id, enabled
+ ):
+ self._actions_model.set_application_force_not_open_workfile(
+ project_name, folder_id, task_id, action_id, enabled
+ )
+
+ def trigger_action(self, project_name, folder_id, task_id, identifier):
+ self._actions_model.trigger_action(
+ project_name, folder_id, task_id, identifier)
+
+ # General methods
+ def refresh(self):
+ self._emit_event("controller.refresh.started")
+
+ self._project_settings = {}
+
+ self._projects_model.reset()
+ self._hierarchy_model.reset()
+
+ self._actions_model.refresh()
+ self._projects_model.refresh()
+
+ self._emit_event("controller.refresh.finished")
+
+ def _emit_event(self, topic, data=None):
+ self.emit_event(topic, data, "controller")
diff --git a/openpype/tools/ayon_launcher/models/__init__.py b/openpype/tools/ayon_launcher/models/__init__.py
new file mode 100644
index 0000000000..1bc60c85f0
--- /dev/null
+++ b/openpype/tools/ayon_launcher/models/__init__.py
@@ -0,0 +1,8 @@
+from .actions import ActionsModel
+from .selection import LauncherSelectionModel
+
+
+__all__ = (
+ "ActionsModel",
+ "LauncherSelectionModel",
+)
diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py
new file mode 100644
index 0000000000..24fea44db2
--- /dev/null
+++ b/openpype/tools/ayon_launcher/models/actions.py
@@ -0,0 +1,505 @@
+import os
+
+from openpype import resources
+from openpype.lib import Logger, OpenPypeSettingsRegistry
+from openpype.pipeline.actions import (
+ discover_launcher_actions,
+ LauncherAction,
+)
+
+
+# class Action:
+# def __init__(self, label, icon=None, identifier=None):
+# self._label = label
+# self._icon = icon
+# self._callbacks = []
+# self._identifier = identifier or uuid.uuid4().hex
+# self._checked = True
+# self._checkable = False
+#
+# def set_checked(self, checked):
+# self._checked = checked
+#
+# def set_checkable(self, checkable):
+# self._checkable = checkable
+#
+# def set_label(self, label):
+# self._label = label
+#
+# def add_callback(self, callback):
+# self._callbacks = callback
+#
+#
+# class Menu:
+# def __init__(self, label, icon=None):
+# self.label = label
+# self.icon = icon
+# self._actions = []
+#
+# def add_action(self, action):
+# self._actions.append(action)
+
+
+class ApplicationAction(LauncherAction):
+ """Action to launch an application.
+
+ Application action based on 'ApplicationManager' system.
+
+ Handling of applications in launcher is not ideal and should be completely
+ redone from scratch. This is just a temporary solution to keep backwards
+ compatibility with OpenPype launcher.
+
+ Todos:
+ Move handling of errors to frontend.
+ """
+
+ # Application object
+ application = None
+ # Action attributes
+ name = None
+ label = None
+ label_variant = None
+ group = None
+ icon = None
+ color = None
+ order = 0
+ data = {}
+ project_settings = {}
+ project_entities = {}
+
+ _log = None
+ required_session_keys = (
+ "AVALON_PROJECT",
+ "AVALON_ASSET",
+ "AVALON_TASK"
+ )
+
+ @property
+ def log(self):
+ if self._log is None:
+ self._log = Logger.get_logger(self.__class__.__name__)
+ return self._log
+
+ def is_compatible(self, session):
+ for key in self.required_session_keys:
+ if not session.get(key):
+ return False
+
+ project_name = session["AVALON_PROJECT"]
+ project_entity = self.project_entities[project_name]
+ apps = project_entity["attrib"].get("applications")
+ if not apps or self.application.full_name not in apps:
+ return False
+
+ project_settings = self.project_settings[project_name]
+ only_available = project_settings["applications"]["only_available"]
+ if only_available and not self.application.find_executable():
+ return False
+ return True
+
+ def _show_message_box(self, title, message, details=None):
+ from qtpy import QtWidgets, QtGui
+ from openpype import style
+
+ dialog = QtWidgets.QMessageBox()
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
+ dialog.setWindowIcon(icon)
+ dialog.setStyleSheet(style.load_stylesheet())
+ dialog.setWindowTitle(title)
+ dialog.setText(message)
+ if details:
+ dialog.setDetailedText(details)
+ dialog.exec_()
+
+ def process(self, session, **kwargs):
+ """Process the full Application action"""
+
+ from openpype.lib import (
+ ApplictionExecutableNotFound,
+ ApplicationLaunchFailed,
+ )
+
+ project_name = session["AVALON_PROJECT"]
+ asset_name = session["AVALON_ASSET"]
+ task_name = session["AVALON_TASK"]
+ try:
+ self.application.launch(
+ project_name=project_name,
+ asset_name=asset_name,
+ task_name=task_name,
+ **self.data
+ )
+
+ except ApplictionExecutableNotFound as exc:
+ details = exc.details
+ msg = exc.msg
+ log_msg = str(msg)
+ if details:
+ log_msg += "\n" + details
+ self.log.warning(log_msg)
+ self._show_message_box(
+ "Application executable not found", msg, details
+ )
+
+ except ApplicationLaunchFailed as exc:
+ msg = str(exc)
+ self.log.warning(msg, exc_info=True)
+ self._show_message_box("Application launch failed", msg)
+
+
+class ActionItem:
+ """Item representing single action to trigger.
+
+ Todos:
+ Get rid of application specific logic.
+
+ Args:
+ identifier (str): Unique identifier of action item.
+ label (str): Action label.
+ variant_label (Union[str, None]): Variant label, full label is
+ concatenated with space. Actions are grouped under single
+ action if it has same 'label' and have set 'variant_label'.
+ icon (dict[str, str]): Icon definition.
+ order (int): Action ordering.
+ is_application (bool): Is action application action.
+ force_not_open_workfile (bool): Force not open workfile. Application
+ related.
+ full_label (Optional[str]): Full label, if not set it is generated
+ from 'label' and 'variant_label'.
+ """
+
+ def __init__(
+ self,
+ identifier,
+ label,
+ variant_label,
+ icon,
+ order,
+ is_application,
+ force_not_open_workfile,
+ full_label=None
+ ):
+ self.identifier = identifier
+ self.label = label
+ self.variant_label = variant_label
+ self.icon = icon
+ self.order = order
+ self.is_application = is_application
+ self.force_not_open_workfile = force_not_open_workfile
+ self._full_label = full_label
+
+ def copy(self):
+ return self.from_data(self.to_data())
+
+ @property
+ def full_label(self):
+ if self._full_label is None:
+ if self.variant_label:
+ self._full_label = " ".join([self.label, self.variant_label])
+ else:
+ self._full_label = self.label
+ return self._full_label
+
+ def to_data(self):
+ return {
+ "identifier": self.identifier,
+ "label": self.label,
+ "variant_label": self.variant_label,
+ "icon": self.icon,
+ "order": self.order,
+ "is_application": self.is_application,
+ "force_not_open_workfile": self.force_not_open_workfile,
+ "full_label": self._full_label,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ return cls(**data)
+
+
+def get_action_icon(action):
+ """Get action icon info.
+
+ Args:
+ action (LacunherAction): Action instance.
+
+ Returns:
+ dict[str, str]: Icon info.
+ """
+
+ icon = action.icon
+ if not icon:
+ return {
+ "type": "awesome-font",
+ "name": "fa.cube",
+ "color": "white"
+ }
+
+ if isinstance(icon, dict):
+ return icon
+
+ icon_path = resources.get_resource(icon)
+ if not os.path.exists(icon_path):
+ try:
+ icon_path = icon.format(resources.RESOURCES_DIR)
+ except Exception:
+ pass
+
+ if os.path.exists(icon_path):
+ return {
+ "type": "path",
+ "path": icon_path,
+ }
+
+ return {
+ "type": "awesome-font",
+ "name": icon,
+ "color": action.color or "white"
+ }
+
+
+class ActionsModel:
+ """Actions model.
+
+ Args:
+ controller (AbstractLauncherBackend): Controller instance.
+ """
+
+ _not_open_workfile_reg_key = "force_not_open_workfile"
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ self._log = None
+
+ self._discovered_actions = None
+ self._actions = None
+ self._action_items = {}
+
+ self._launcher_tool_reg = OpenPypeSettingsRegistry("launcher_tool")
+
+ @property
+ def log(self):
+ if self._log is None:
+ self._log = Logger.get_logger(self.__class__.__name__)
+ return self._log
+
+ def refresh(self):
+ self._discovered_actions = None
+ self._actions = None
+ self._action_items = {}
+
+ self._controller.emit_event("actions.refresh.started")
+ self._get_action_objects()
+ self._controller.emit_event("actions.refresh.finished")
+
+ def get_action_items(self, project_name, folder_id, task_id):
+ """Get actions for project.
+
+ Args:
+ project_name (Union[str, None]): Project name.
+ folder_id (Union[str, None]): Folder id.
+ task_id (Union[str, None]): Task id.
+
+ Returns:
+ list[ActionItem]: List of actions.
+ """
+
+ not_open_workfile_actions = self._get_no_last_workfile_for_context(
+ project_name, folder_id, task_id)
+ session = self._prepare_session(project_name, folder_id, task_id)
+ output = []
+ action_items = self._get_action_items(project_name)
+ for identifier, action in self._get_action_objects().items():
+ if not action.is_compatible(session):
+ continue
+
+ action_item = action_items[identifier]
+ # Handling of 'force_not_open_workfile' for applications
+ if action_item.is_application:
+ action_item = action_item.copy()
+ action_item.force_not_open_workfile = (
+ not_open_workfile_actions.get(identifier, False)
+ )
+
+ output.append(action_item)
+ return output
+
+ def set_application_force_not_open_workfile(
+ self, project_name, folder_id, task_id, action_id, enabled
+ ):
+ no_workfile_reg_data = self._get_no_last_workfile_reg_data()
+ project_data = no_workfile_reg_data.setdefault(project_name, {})
+ folder_data = project_data.setdefault(folder_id, {})
+ task_data = folder_data.setdefault(task_id, {})
+ task_data[action_id] = enabled
+ self._launcher_tool_reg.set_item(
+ self._not_open_workfile_reg_key, no_workfile_reg_data
+ )
+
+ def trigger_action(self, project_name, folder_id, task_id, identifier):
+ session = self._prepare_session(project_name, folder_id, task_id)
+ failed = False
+ error_message = None
+ action_label = identifier
+ action_items = self._get_action_items(project_name)
+ try:
+ action = self._actions[identifier]
+ action_item = action_items[identifier]
+ action_label = action_item.full_label
+ self._controller.emit_event(
+ "action.trigger.started",
+ {
+ "identifier": identifier,
+ "full_label": action_label,
+ }
+ )
+ if isinstance(action, ApplicationAction):
+ per_action = self._get_no_last_workfile_for_context(
+ project_name, folder_id, task_id
+ )
+ force_not_open_workfile = per_action.get(identifier, False)
+ action.data["start_last_workfile"] = force_not_open_workfile
+ action.process(session)
+ except Exception as exc:
+ self.log.warning("Action trigger failed.", exc_info=True)
+ failed = True
+ error_message = str(exc)
+
+ self._controller.emit_event(
+ "action.trigger.finished",
+ {
+ "identifier": identifier,
+ "failed": failed,
+ "error_message": error_message,
+ "full_label": action_label,
+ }
+ )
+
+ def _get_no_last_workfile_reg_data(self):
+ try:
+ no_workfile_reg_data = self._launcher_tool_reg.get_item(
+ self._not_open_workfile_reg_key)
+ except ValueError:
+ no_workfile_reg_data = {}
+ self._launcher_tool_reg.set_item(
+ self._not_open_workfile_reg_key, no_workfile_reg_data)
+ return no_workfile_reg_data
+
+ def _get_no_last_workfile_for_context(
+ self, project_name, folder_id, task_id
+ ):
+ not_open_workfile_reg_data = self._get_no_last_workfile_reg_data()
+ return (
+ not_open_workfile_reg_data
+ .get(project_name, {})
+ .get(folder_id, {})
+ .get(task_id, {})
+ )
+
+ def _prepare_session(self, project_name, folder_id, task_id):
+ folder_name = None
+ if folder_id:
+ folder = self._controller.get_folder_entity(
+ project_name, folder_id)
+ if folder:
+ folder_name = folder["name"]
+
+ task_name = None
+ if task_id:
+ task = self._controller.get_task_entity(project_name, task_id)
+ if task:
+ task_name = task["name"]
+
+ return {
+ "AVALON_PROJECT": project_name,
+ "AVALON_ASSET": folder_name,
+ "AVALON_TASK": task_name,
+ }
+
+ def _get_discovered_action_classes(self):
+ if self._discovered_actions is None:
+ self._discovered_actions = (
+ discover_launcher_actions()
+ + self._get_applications_action_classes()
+ )
+ return self._discovered_actions
+
+ def _get_action_objects(self):
+ if self._actions is None:
+ actions = {}
+ for cls in self._get_discovered_action_classes():
+ obj = cls()
+ identifier = getattr(obj, "identifier", None)
+ if identifier is None:
+ identifier = cls.__name__
+ actions[identifier] = obj
+ self._actions = actions
+ return self._actions
+
+ def _get_action_items(self, project_name):
+ action_items = self._action_items.get(project_name)
+ if action_items is not None:
+ return action_items
+
+ project_entity = None
+ if project_name:
+ project_entity = self._controller.get_project_entity(project_name)
+ project_settings = self._controller.get_project_settings(project_name)
+
+ action_items = {}
+ for identifier, action in self._get_action_objects().items():
+ is_application = isinstance(action, ApplicationAction)
+ if is_application:
+ action.project_entities[project_name] = project_entity
+ action.project_settings[project_name] = project_settings
+ label = action.label or identifier
+ variant_label = getattr(action, "label_variant", None)
+ icon = get_action_icon(action)
+ item = ActionItem(
+ identifier,
+ label,
+ variant_label,
+ icon,
+ action.order,
+ is_application,
+ False
+ )
+ action_items[identifier] = item
+ self._action_items[project_name] = action_items
+ return action_items
+
+ def _get_applications_action_classes(self):
+ from openpype.lib.applications import (
+ CUSTOM_LAUNCH_APP_GROUPS,
+ ApplicationManager,
+ )
+
+ actions = []
+
+ manager = ApplicationManager()
+ for full_name, application in manager.applications.items():
+ if (
+ application.group.name in CUSTOM_LAUNCH_APP_GROUPS
+ or not application.enabled
+ ):
+ continue
+
+ action = type(
+ "app_{}".format(full_name),
+ (ApplicationAction,),
+ {
+ "identifier": "application.{}".format(full_name),
+ "application": application,
+ "name": application.name,
+ "label": application.group.label,
+ "label_variant": application.label,
+ "group": None,
+ "icon": application.icon,
+ "color": getattr(application, "color", None),
+ "order": getattr(application, "order", None) or 0,
+ "data": {}
+ }
+ )
+ actions.append(action)
+ return actions
diff --git a/openpype/tools/ayon_launcher/models/selection.py b/openpype/tools/ayon_launcher/models/selection.py
new file mode 100644
index 0000000000..b156d2084c
--- /dev/null
+++ b/openpype/tools/ayon_launcher/models/selection.py
@@ -0,0 +1,72 @@
+class LauncherSelectionModel(object):
+ """Model handling selection changes.
+
+ Triggering events:
+ - "selection.project.changed"
+ - "selection.folder.changed"
+ - "selection.task.changed"
+ """
+
+ event_source = "launcher.selection.model"
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ self._project_name = None
+ self._folder_id = None
+ self._task_name = None
+ self._task_id = None
+
+ def get_selected_project_name(self):
+ return self._project_name
+
+ def set_selected_project(self, project_name):
+ if project_name == self._project_name:
+ return
+
+ self._project_name = project_name
+ self._controller.emit_event(
+ "selection.project.changed",
+ {"project_name": project_name},
+ self.event_source
+ )
+
+ def get_selected_folder_id(self):
+ return self._folder_id
+
+ def set_selected_folder(self, folder_id):
+ if folder_id == self._folder_id:
+ return
+
+ self._folder_id = folder_id
+ self._controller.emit_event(
+ "selection.folder.changed",
+ {
+ "project_name": self._project_name,
+ "folder_id": folder_id,
+ },
+ self.event_source
+ )
+
+ def get_selected_task_name(self):
+ return self._task_name
+
+ def get_selected_task_id(self):
+ return self._task_id
+
+ def set_selected_task(self, task_id, task_name):
+ if task_id == self._task_id:
+ return
+
+ self._task_name = task_name
+ self._task_id = task_id
+ self._controller.emit_event(
+ "selection.task.changed",
+ {
+ "project_name": self._project_name,
+ "folder_id": self._folder_id,
+ "task_name": task_name,
+ "task_id": task_id,
+ },
+ self.event_source
+ )
diff --git a/openpype/tools/ayon_launcher/ui/__init__.py b/openpype/tools/ayon_launcher/ui/__init__.py
new file mode 100644
index 0000000000..da30c84656
--- /dev/null
+++ b/openpype/tools/ayon_launcher/ui/__init__.py
@@ -0,0 +1,6 @@
+from .window import LauncherWindow
+
+
+__all__ = (
+ "LauncherWindow",
+)
diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py
new file mode 100644
index 0000000000..d04f8f8d24
--- /dev/null
+++ b/openpype/tools/ayon_launcher/ui/actions_widget.py
@@ -0,0 +1,453 @@
+import time
+import collections
+
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.tools.flickcharm import FlickCharm
+from openpype.tools.ayon_utils.widgets import get_qt_icon
+
+from .resources import get_options_image_path
+
+ANIMATION_LEN = 7
+
+ACTION_ID_ROLE = QtCore.Qt.UserRole + 1
+ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2
+ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3
+ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4
+ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5
+ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6
+FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7
+
+
+class ActionsQtModel(QtGui.QStandardItemModel):
+ """Qt model for actions.
+
+ Args:
+ controller (AbstractLauncherFrontEnd): Controller instance.
+ """
+
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(ActionsQtModel, self).__init__()
+
+ controller.register_event_callback(
+ "controller.refresh.finished",
+ self._on_controller_refresh_finished,
+ )
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_selection_project_changed,
+ )
+ controller.register_event_callback(
+ "selection.folder.changed",
+ self._on_selection_folder_changed,
+ )
+ controller.register_event_callback(
+ "selection.task.changed",
+ self._on_selection_task_changed,
+ )
+
+ self._controller = controller
+
+ self._items_by_id = {}
+ self._groups_by_id = {}
+
+ self._selected_project_name = None
+ self._selected_folder_id = None
+ self._selected_task_id = None
+
+ def get_selected_project_name(self):
+ return self._selected_project_name
+
+ def get_selected_folder_id(self):
+ return self._selected_folder_id
+
+ def get_selected_task_id(self):
+ return self._selected_task_id
+
+ def get_group_items(self, action_id):
+ return self._groups_by_id[action_id]
+
+ def get_item_by_id(self, action_id):
+ return self._items_by_id.get(action_id)
+
+ def _clear_items(self):
+ self._items_by_id = {}
+ self._groups_by_id = {}
+ root = self.invisibleRootItem()
+ root.removeRows(0, root.rowCount())
+
+ def refresh(self):
+ items = self._controller.get_action_items(
+ self._selected_project_name,
+ self._selected_folder_id,
+ self._selected_task_id,
+ )
+ if not items:
+ self._clear_items()
+ self.refreshed.emit()
+ return
+
+ root_item = self.invisibleRootItem()
+
+ all_action_items_info = []
+ items_by_label = collections.defaultdict(list)
+ for item in items:
+ if not item.variant_label:
+ all_action_items_info.append((item, False))
+ else:
+ items_by_label[item.label].append(item)
+
+ groups_by_id = {}
+ for action_items in items_by_label.values():
+ first_item = next(iter(action_items))
+ all_action_items_info.append((first_item, len(action_items) > 1))
+ groups_by_id[first_item.identifier] = action_items
+
+ new_items = []
+ items_by_id = {}
+ for action_item_info in all_action_items_info:
+ action_item, is_group = action_item_info
+ icon = get_qt_icon(action_item.icon)
+ if is_group:
+ label = action_item.label
+ else:
+ label = action_item.full_label
+
+ item = self._items_by_id.get(action_item.identifier)
+ if item is None:
+ item = QtGui.QStandardItem()
+ item.setData(action_item.identifier, ACTION_ID_ROLE)
+ new_items.append(item)
+
+ item.setFlags(QtCore.Qt.ItemIsEnabled)
+ item.setData(label, QtCore.Qt.DisplayRole)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setData(is_group, ACTION_IS_GROUP_ROLE)
+ item.setData(action_item.order, ACTION_SORT_ROLE)
+ item.setData(
+ action_item.is_application, ACTION_IS_APPLICATION_ROLE)
+ item.setData(
+ action_item.force_not_open_workfile,
+ FORCE_NOT_OPEN_WORKFILE_ROLE)
+ items_by_id[action_item.identifier] = item
+
+ if new_items:
+ root_item.appendRows(new_items)
+
+ to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys())
+ for identifier in to_remove:
+ item = self._items_by_id.pop(identifier)
+ root_item.removeRow(item.row())
+
+ self._groups_by_id = groups_by_id
+ self._items_by_id = items_by_id
+ self.refreshed.emit()
+
+ def _on_controller_refresh_finished(self):
+ context = self._controller.get_selected_context()
+ self._selected_project_name = context["project_name"]
+ self._selected_folder_id = context["folder_id"]
+ self._selected_task_id = context["task_id"]
+ self.refresh()
+
+ def _on_selection_project_changed(self, event):
+ self._selected_project_name = event["project_name"]
+ self._selected_folder_id = None
+ self._selected_task_id = None
+ self.refresh()
+
+ def _on_selection_folder_changed(self, event):
+ self._selected_project_name = event["project_name"]
+ self._selected_folder_id = event["folder_id"]
+ self._selected_task_id = None
+ self.refresh()
+
+ def _on_selection_task_changed(self, event):
+ self._selected_project_name = event["project_name"]
+ self._selected_folder_id = event["folder_id"]
+ self._selected_task_id = event["task_id"]
+ self.refresh()
+
+
+class ActionDelegate(QtWidgets.QStyledItemDelegate):
+ _cached_extender = {}
+
+ def __init__(self, *args, **kwargs):
+ super(ActionDelegate, self).__init__(*args, **kwargs)
+ self._anim_start_color = QtGui.QColor(178, 255, 246)
+ self._anim_end_color = QtGui.QColor(5, 44, 50)
+
+ def _draw_animation(self, painter, option, index):
+ grid_size = option.widget.gridSize()
+ x_offset = int(
+ (grid_size.width() / 2)
+ - (option.rect.width() / 2)
+ )
+ item_x = option.rect.x() - x_offset
+ rect_offset = grid_size.width() / 20
+ size = grid_size.width() - (rect_offset * 2)
+ anim_rect = QtCore.QRect(
+ item_x + rect_offset,
+ option.rect.y() + rect_offset,
+ size,
+ size
+ )
+
+ painter.save()
+
+ painter.setBrush(QtCore.Qt.transparent)
+
+ gradient = QtGui.QConicalGradient()
+ gradient.setCenter(QtCore.QPointF(anim_rect.center()))
+ gradient.setColorAt(0, self._anim_start_color)
+ gradient.setColorAt(1, self._anim_end_color)
+
+ time_diff = time.time() - index.data(ANIMATION_START_ROLE)
+
+ # Repeat 4 times
+ part_anim = 2.5
+ part_time = time_diff % part_anim
+ offset = (part_time / part_anim) * 360
+ angle = (offset + 90) % 360
+
+ gradient.setAngle(-angle)
+
+ pen = QtGui.QPen(QtGui.QBrush(gradient), rect_offset)
+ pen.setCapStyle(QtCore.Qt.RoundCap)
+ painter.setPen(pen)
+ painter.drawArc(
+ anim_rect,
+ -16 * (angle + 10),
+ -16 * offset
+ )
+
+ painter.restore()
+
+ @classmethod
+ def _get_extender_pixmap(cls, size):
+ pix = cls._cached_extender.get(size)
+ if pix is not None:
+ return pix
+ pix = QtGui.QPixmap(get_options_image_path()).scaled(
+ size, size,
+ QtCore.Qt.KeepAspectRatio,
+ QtCore.Qt.SmoothTransformation
+ )
+ cls._cached_extender[size] = pix
+ return pix
+
+ def paint(self, painter, option, index):
+ painter.setRenderHints(
+ QtGui.QPainter.Antialiasing
+ | QtGui.QPainter.SmoothPixmapTransform
+ )
+
+ if index.data(ANIMATION_STATE_ROLE):
+ self._draw_animation(painter, option, index)
+
+ super(ActionDelegate, self).paint(painter, option, index)
+
+ if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
+ rect = QtCore.QRectF(
+ option.rect.x(), option.rect.height(), 5, 5)
+ painter.setPen(QtCore.Qt.NoPen)
+ painter.setBrush(QtGui.QColor(200, 0, 0))
+ painter.drawEllipse(rect)
+
+ if not index.data(ACTION_IS_GROUP_ROLE):
+ return
+
+ grid_size = option.widget.gridSize()
+ x_offset = int(
+ (grid_size.width() / 2)
+ - (option.rect.width() / 2)
+ )
+ item_x = option.rect.x() - x_offset
+
+ tenth_size = int(grid_size.width() / 10)
+ extender_size = int(tenth_size * 2.4)
+
+ extender_x = item_x + tenth_size
+ extender_y = option.rect.y() + tenth_size
+
+ pix = self._get_extender_pixmap(extender_size)
+ painter.drawPixmap(extender_x, extender_y, pix)
+
+
+class ActionsWidget(QtWidgets.QWidget):
+ def __init__(self, controller, parent):
+ super(ActionsWidget, self).__init__(parent)
+
+ self._controller = controller
+
+ view = QtWidgets.QListView(self)
+ view.setProperty("mode", "icon")
+ view.setObjectName("IconView")
+ view.setViewMode(QtWidgets.QListView.IconMode)
+ view.setResizeMode(QtWidgets.QListView.Adjust)
+ view.setSelectionMode(QtWidgets.QListView.NoSelection)
+ view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
+ view.setWrapping(True)
+ view.setGridSize(QtCore.QSize(70, 75))
+ view.setIconSize(QtCore.QSize(30, 30))
+ view.setSpacing(0)
+ view.setWordWrap(True)
+
+ # Make view flickable
+ flick = FlickCharm(parent=view)
+ flick.activateOn(view)
+
+ model = ActionsQtModel(controller)
+
+ proxy_model = QtCore.QSortFilterProxyModel()
+ proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ proxy_model.setSortRole(ACTION_SORT_ROLE)
+
+ proxy_model.setSourceModel(model)
+ view.setModel(proxy_model)
+
+ delegate = ActionDelegate(self)
+ view.setItemDelegate(delegate)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(view)
+
+ animation_timer = QtCore.QTimer()
+ animation_timer.setInterval(40)
+ animation_timer.timeout.connect(self._on_animation)
+
+ view.clicked.connect(self._on_clicked)
+ view.customContextMenuRequested.connect(self._on_context_menu)
+ model.refreshed.connect(self._on_model_refresh)
+
+ self._animated_items = set()
+ self._animation_timer = animation_timer
+
+ self._context_menu = None
+
+ self._flick = flick
+ self._view = view
+ self._model = model
+ self._proxy_model = proxy_model
+
+ self._set_row_height(1)
+
+ def _set_row_height(self, rows):
+ self.setMinimumHeight(rows * 75)
+
+ def _on_model_refresh(self):
+ self._proxy_model.sort(0)
+
+ def _on_animation(self):
+ time_now = time.time()
+ for action_id in tuple(self._animated_items):
+ item = self._model.get_item_by_id(action_id)
+ if item is None:
+ self._animated_items.discard(action_id)
+ continue
+
+ start_time = item.data(ANIMATION_START_ROLE)
+ if start_time is None or (time_now - start_time) > ANIMATION_LEN:
+ item.setData(0, ANIMATION_STATE_ROLE)
+ self._animated_items.discard(action_id)
+
+ if not self._animated_items:
+ self._animation_timer.stop()
+
+ self.update()
+
+ def _start_animation(self, index):
+ # Offset refresh timout
+ model_index = self._proxy_model.mapToSource(index)
+ if not model_index.isValid():
+ return
+ action_id = model_index.data(ACTION_ID_ROLE)
+ self._model.setData(model_index, time.time(), ANIMATION_START_ROLE)
+ self._model.setData(model_index, 1, ANIMATION_STATE_ROLE)
+ self._animated_items.add(action_id)
+ self._animation_timer.start()
+
+ def _on_context_menu(self, point):
+ """Creates menu to force skip opening last workfile."""
+ index = self._view.indexAt(point)
+ if not index.isValid():
+ return
+
+ if not index.data(ACTION_IS_APPLICATION_ROLE):
+ return
+
+ menu = QtWidgets.QMenu(self._view)
+ checkbox = QtWidgets.QCheckBox(
+ "Skip opening last workfile.", menu)
+ if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
+ checkbox.setChecked(True)
+
+ action_id = index.data(ACTION_ID_ROLE)
+ checkbox.stateChanged.connect(
+ lambda: self._on_checkbox_changed(
+ action_id, checkbox.isChecked()
+ )
+ )
+ action = QtWidgets.QWidgetAction(menu)
+ action.setDefaultWidget(checkbox)
+
+ menu.addAction(action)
+
+ self._context_menu = menu
+ global_point = self.mapToGlobal(point)
+ menu.exec_(global_point)
+ self._context_menu = None
+
+ def _on_checkbox_changed(self, action_id, is_checked):
+ if self._context_menu is not None:
+ self._context_menu.close()
+
+ project_name = self._model.get_selected_project_name()
+ folder_id = self._model.get_selected_folder_id()
+ task_id = self._model.get_selected_task_id()
+ self._controller.set_application_force_not_open_workfile(
+ project_name, folder_id, task_id, action_id, is_checked)
+ self._model.refresh()
+
+ def _on_clicked(self, index):
+ if not index or not index.isValid():
+ return
+
+ is_group = index.data(ACTION_IS_GROUP_ROLE)
+ action_id = index.data(ACTION_ID_ROLE)
+
+ project_name = self._model.get_selected_project_name()
+ folder_id = self._model.get_selected_folder_id()
+ task_id = self._model.get_selected_task_id()
+
+ if not is_group:
+ self._controller.trigger_action(
+ project_name, folder_id, task_id, action_id
+ )
+ self._start_animation(index)
+ return
+
+ action_items = self._model.get_group_items(action_id)
+
+ menu = QtWidgets.QMenu(self)
+ actions_mapping = {}
+
+ for action_item in action_items:
+ menu_action = QtWidgets.QAction(action_item.full_label)
+ menu.addAction(menu_action)
+ actions_mapping[menu_action] = action_item
+
+ result = menu.exec_(QtGui.QCursor.pos())
+ if not result:
+ return
+
+ action_item = actions_mapping[result]
+
+ self._controller.trigger_action(
+ project_name, folder_id, task_id, action_item.identifier
+ )
+ self._start_animation(index)
diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py
new file mode 100644
index 0000000000..5047cdc692
--- /dev/null
+++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py
@@ -0,0 +1,102 @@
+import qtawesome
+from qtpy import QtWidgets, QtCore
+
+from openpype.tools.utils import (
+ PlaceholderLineEdit,
+ SquareButton,
+ RefreshButton,
+)
+from openpype.tools.ayon_utils.widgets import (
+ ProjectsCombobox,
+ FoldersWidget,
+ TasksWidget,
+)
+
+
+class HierarchyPage(QtWidgets.QWidget):
+ def __init__(self, controller, parent):
+ super(HierarchyPage, self).__init__(parent)
+
+ # Header
+ header_widget = QtWidgets.QWidget(self)
+
+ btn_back_icon = qtawesome.icon("fa.angle-left", color="white")
+ btn_back = SquareButton(header_widget)
+ btn_back.setIcon(btn_back_icon)
+
+ projects_combobox = ProjectsCombobox(controller, header_widget)
+
+ refresh_btn = RefreshButton(header_widget)
+
+ header_layout = QtWidgets.QHBoxLayout(header_widget)
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.addWidget(btn_back, 0)
+ header_layout.addWidget(projects_combobox, 1)
+ header_layout.addWidget(refresh_btn, 0)
+
+ # Body - Folders + Tasks selection
+ content_body = QtWidgets.QSplitter(self)
+ content_body.setContentsMargins(0, 0, 0, 0)
+ content_body.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding
+ )
+ content_body.setOrientation(QtCore.Qt.Horizontal)
+
+ # - Folders widget with filter
+ folders_wrapper = QtWidgets.QWidget(content_body)
+
+ folders_filter_text = PlaceholderLineEdit(folders_wrapper)
+ folders_filter_text.setPlaceholderText("Filter folders...")
+
+ folders_widget = FoldersWidget(controller, folders_wrapper)
+
+ folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper)
+ folders_wrapper_layout.setContentsMargins(0, 0, 0, 0)
+ folders_wrapper_layout.addWidget(folders_filter_text, 0)
+ folders_wrapper_layout.addWidget(folders_widget, 1)
+
+ # - Tasks widget
+ tasks_widget = TasksWidget(controller, content_body)
+
+ content_body.addWidget(folders_wrapper)
+ content_body.addWidget(tasks_widget)
+ content_body.setStretchFactor(0, 100)
+ content_body.setStretchFactor(1, 65)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(header_widget, 0)
+ main_layout.addWidget(content_body, 1)
+
+ btn_back.clicked.connect(self._on_back_clicked)
+ refresh_btn.clicked.connect(self._on_refreh_clicked)
+ folders_filter_text.textChanged.connect(self._on_filter_text_changed)
+
+ self._is_visible = False
+ self._controller = controller
+
+ self._btn_back = btn_back
+ self._projects_combobox = projects_combobox
+ self._folders_widget = folders_widget
+ self._tasks_widget = tasks_widget
+
+ # Post init
+ projects_combobox.set_listen_to_selection_change(self._is_visible)
+
+ def set_page_visible(self, visible, project_name=None):
+ if self._is_visible == visible:
+ return
+ self._is_visible = visible
+ self._projects_combobox.set_listen_to_selection_change(visible)
+ if visible and project_name:
+ self._projects_combobox.set_selection(project_name)
+
+ def _on_back_clicked(self):
+ self._controller.set_selected_project(None)
+
+ def _on_refreh_clicked(self):
+ self._controller.refresh()
+
+ def _on_filter_text_changed(self, text):
+ self._folders_widget.set_name_filer(text)
diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py
new file mode 100644
index 0000000000..baa399d0ed
--- /dev/null
+++ b/openpype/tools/ayon_launcher/ui/projects_widget.py
@@ -0,0 +1,135 @@
+from qtpy import QtWidgets, QtCore
+
+from openpype.tools.flickcharm import FlickCharm
+from openpype.tools.utils import PlaceholderLineEdit, RefreshButton
+from openpype.tools.ayon_utils.widgets import (
+ ProjectsModel,
+ ProjectSortFilterProxy,
+)
+from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER
+
+
+class ProjectIconView(QtWidgets.QListView):
+ """Styled ListView that allows to toggle between icon and list mode.
+
+ Toggling between the two modes is done by Right Mouse Click.
+ """
+
+ IconMode = 0
+ ListMode = 1
+
+ def __init__(self, parent=None, mode=ListMode):
+ super(ProjectIconView, self).__init__(parent=parent)
+
+ # Workaround for scrolling being super slow or fast when
+ # toggling between the two visual modes
+ self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
+ self.setObjectName("IconView")
+
+ self._mode = None
+ self.set_mode(mode)
+
+ def set_mode(self, mode):
+ if mode == self._mode:
+ return
+
+ self._mode = mode
+
+ if mode == self.IconMode:
+ self.setViewMode(QtWidgets.QListView.IconMode)
+ self.setResizeMode(QtWidgets.QListView.Adjust)
+ self.setWrapping(True)
+ self.setWordWrap(True)
+ self.setGridSize(QtCore.QSize(151, 90))
+ self.setIconSize(QtCore.QSize(50, 50))
+ self.setSpacing(0)
+ self.setAlternatingRowColors(False)
+
+ self.setProperty("mode", "icon")
+ self.style().polish(self)
+
+ self.verticalScrollBar().setSingleStep(30)
+
+ elif self.ListMode:
+ self.setProperty("mode", "list")
+ self.style().polish(self)
+
+ self.setViewMode(QtWidgets.QListView.ListMode)
+ self.setResizeMode(QtWidgets.QListView.Adjust)
+ self.setWrapping(False)
+ self.setWordWrap(False)
+ self.setIconSize(QtCore.QSize(20, 20))
+ self.setGridSize(QtCore.QSize(100, 25))
+ self.setSpacing(0)
+ self.setAlternatingRowColors(False)
+
+ self.verticalScrollBar().setSingleStep(34)
+
+ def mousePressEvent(self, event):
+ if event.button() == QtCore.Qt.RightButton:
+ self.set_mode(int(not self._mode))
+ return super(ProjectIconView, self).mousePressEvent(event)
+
+
+class ProjectsWidget(QtWidgets.QWidget):
+ """Projects Page"""
+ def __init__(self, controller, parent=None):
+ super(ProjectsWidget, self).__init__(parent=parent)
+
+ header_widget = QtWidgets.QWidget(self)
+
+ projects_filter_text = PlaceholderLineEdit(header_widget)
+ projects_filter_text.setPlaceholderText("Filter projects...")
+
+ refresh_btn = RefreshButton(header_widget)
+
+ header_layout = QtWidgets.QHBoxLayout(header_widget)
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.addWidget(projects_filter_text, 1)
+ header_layout.addWidget(refresh_btn, 0)
+
+ projects_view = ProjectIconView(parent=self)
+ projects_view.setSelectionMode(QtWidgets.QListView.NoSelection)
+ flick = FlickCharm(parent=self)
+ flick.activateOn(projects_view)
+ projects_model = ProjectsModel(controller)
+ projects_proxy_model = ProjectSortFilterProxy()
+ projects_proxy_model.setSourceModel(projects_model)
+
+ projects_view.setModel(projects_proxy_model)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(header_widget, 0)
+ main_layout.addWidget(projects_view, 1)
+
+ projects_view.clicked.connect(self._on_view_clicked)
+ projects_filter_text.textChanged.connect(
+ self._on_project_filter_change)
+ refresh_btn.clicked.connect(self._on_refresh_clicked)
+
+ controller.register_event_callback(
+ "projects.refresh.finished",
+ self._on_projects_refresh_finished
+ )
+
+ self._controller = controller
+
+ self._projects_view = projects_view
+ self._projects_model = projects_model
+ self._projects_proxy_model = projects_proxy_model
+
+ def _on_view_clicked(self, index):
+ if index.isValid():
+ project_name = index.data(QtCore.Qt.DisplayRole)
+ self._controller.set_selected_project(project_name)
+
+ def _on_project_filter_change(self, text):
+ self._projects_proxy_model.setFilterFixedString(text)
+
+ def _on_refresh_clicked(self):
+ self._controller.refresh()
+
+ def _on_projects_refresh_finished(self, event):
+ if event["sender"] != PROJECTS_MODEL_SENDER:
+ self._projects_model.refresh()
diff --git a/openpype/tools/ayon_launcher/ui/resources/__init__.py b/openpype/tools/ayon_launcher/ui/resources/__init__.py
new file mode 100644
index 0000000000..27c59af2ba
--- /dev/null
+++ b/openpype/tools/ayon_launcher/ui/resources/__init__.py
@@ -0,0 +1,7 @@
+import os
+
+RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+def get_options_image_path():
+ return os.path.join(RESOURCES_DIR, "options.png")
diff --git a/openpype/tools/ayon_launcher/ui/resources/options.png b/openpype/tools/ayon_launcher/ui/resources/options.png
new file mode 100644
index 0000000000..a9617d0d19
Binary files /dev/null and b/openpype/tools/ayon_launcher/ui/resources/options.png differ
diff --git a/openpype/tools/ayon_launcher/ui/window.py b/openpype/tools/ayon_launcher/ui/window.py
new file mode 100644
index 0000000000..139da42a2e
--- /dev/null
+++ b/openpype/tools/ayon_launcher/ui/window.py
@@ -0,0 +1,295 @@
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype import style
+from openpype import resources
+
+from openpype.tools.ayon_launcher.control import BaseLauncherController
+
+from .projects_widget import ProjectsWidget
+from .hierarchy_page import HierarchyPage
+from .actions_widget import ActionsWidget
+
+
+class LauncherWindow(QtWidgets.QWidget):
+ """Launcher interface"""
+ message_interval = 5000
+ refresh_interval = 10000
+ page_side_anim_interval = 250
+
+ def __init__(self, controller=None, parent=None):
+ super(LauncherWindow, self).__init__(parent)
+
+ if controller is None:
+ controller = BaseLauncherController()
+
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
+ self.setWindowIcon(icon)
+ self.setWindowTitle("Launcher")
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
+
+ self.setStyleSheet(style.load_stylesheet())
+
+ # Allow minimize
+ self.setWindowFlags(
+ QtCore.Qt.Window
+ | QtCore.Qt.CustomizeWindowHint
+ | QtCore.Qt.WindowTitleHint
+ | QtCore.Qt.WindowMinimizeButtonHint
+ | QtCore.Qt.WindowCloseButtonHint
+ )
+
+ self._controller = controller
+
+ # Main content - Pages & Actions
+ content_body = QtWidgets.QSplitter(self)
+
+ # Pages
+ pages_widget = QtWidgets.QWidget(content_body)
+
+ # - First page - Projects
+ projects_page = ProjectsWidget(controller, pages_widget)
+
+ # - Second page - Hierarchy (folders & tasks)
+ hierarchy_page = HierarchyPage(controller, pages_widget)
+
+ pages_layout = QtWidgets.QHBoxLayout(pages_widget)
+ pages_layout.setContentsMargins(0, 0, 0, 0)
+ pages_layout.addWidget(projects_page, 1)
+ pages_layout.addWidget(hierarchy_page, 1)
+
+ # Actions
+ actions_widget = ActionsWidget(controller, content_body)
+
+ # Vertically split Pages and Actions
+ content_body.setContentsMargins(0, 0, 0, 0)
+ content_body.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding
+ )
+ content_body.setOrientation(QtCore.Qt.Vertical)
+ content_body.addWidget(pages_widget)
+ content_body.addWidget(actions_widget)
+
+ # Set useful default sizes and set stretch
+ # for the pages so that is the only one that
+ # stretches on UI resize.
+ content_body.setStretchFactor(0, 10)
+ content_body.setSizes([580, 160])
+
+ # Footer
+ footer_widget = QtWidgets.QWidget(self)
+
+ # - Message label
+ message_label = QtWidgets.QLabel(footer_widget)
+
+ # action_history = ActionHistory(footer_widget)
+ # action_history.setStatusTip("Show Action History")
+
+ footer_layout = QtWidgets.QHBoxLayout(footer_widget)
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+ footer_layout.addWidget(message_label, 1)
+ # footer_layout.addWidget(action_history, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(content_body, 1)
+ layout.addWidget(footer_widget, 0)
+
+ message_timer = QtCore.QTimer()
+ message_timer.setInterval(self.message_interval)
+ message_timer.setSingleShot(True)
+
+ refresh_timer = QtCore.QTimer()
+ refresh_timer.setInterval(self.refresh_interval)
+
+ page_slide_anim = QtCore.QVariantAnimation(self)
+ page_slide_anim.setDuration(self.page_side_anim_interval)
+ page_slide_anim.setStartValue(0.0)
+ page_slide_anim.setEndValue(1.0)
+ page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad)
+
+ message_timer.timeout.connect(self._on_message_timeout)
+ refresh_timer.timeout.connect(self._on_refresh_timeout)
+ page_slide_anim.valueChanged.connect(
+ self._on_page_slide_value_changed)
+ page_slide_anim.finished.connect(self._on_page_slide_finished)
+
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_project_selection_change,
+ )
+ controller.register_event_callback(
+ "action.trigger.started",
+ self._on_action_trigger_started,
+ )
+ controller.register_event_callback(
+ "action.trigger.finished",
+ self._on_action_trigger_finished,
+ )
+
+ self._controller = controller
+
+ self._is_on_projects_page = True
+ self._window_is_active = False
+ self._refresh_on_activate = False
+
+ self._pages_widget = pages_widget
+ self._pages_layout = pages_layout
+ self._projects_page = projects_page
+ self._hierarchy_page = hierarchy_page
+ self._actions_widget = actions_widget
+
+ self._message_label = message_label
+ # self._action_history = action_history
+
+ self._message_timer = message_timer
+ self._refresh_timer = refresh_timer
+ self._page_slide_anim = page_slide_anim
+
+ hierarchy_page.setVisible(not self._is_on_projects_page)
+ self.resize(520, 740)
+
+ def showEvent(self, event):
+ super(LauncherWindow, self).showEvent(event)
+ self._window_is_active = True
+ if not self._refresh_timer.isActive():
+ self._refresh_timer.start()
+ self._controller.refresh()
+
+ def closeEvent(self, event):
+ super(LauncherWindow, self).closeEvent(event)
+ self._window_is_active = False
+ self._refresh_timer.stop()
+
+ def changeEvent(self, event):
+ if event.type() in (
+ QtCore.QEvent.Type.WindowStateChange,
+ QtCore.QEvent.ActivationChange,
+ ):
+ is_active = self.isActiveWindow() and not self.isMinimized()
+ self._window_is_active = is_active
+ if is_active and self._refresh_on_activate:
+ self._refresh_on_activate = False
+ self._on_refresh_timeout()
+ self._refresh_timer.start()
+
+ super(LauncherWindow, self).changeEvent(event)
+
+ def _on_refresh_timeout(self):
+ # Stop timer if widget is not visible
+ if self._window_is_active:
+ self._controller.refresh()
+ else:
+ self._refresh_on_activate = True
+
+ def _echo(self, message):
+ self._message_label.setText(str(message))
+ self._message_timer.start()
+
+ def _on_message_timeout(self):
+ self._message_label.setText("")
+
+ def _on_project_selection_change(self, event):
+ project_name = event["project_name"]
+ if not project_name:
+ self._go_to_projects_page()
+
+ elif self._is_on_projects_page:
+ self._go_to_hierarchy_page(project_name)
+
+ def _on_action_trigger_started(self, event):
+ self._echo("Running action: {}".format(event["full_label"]))
+
+ def _on_action_trigger_finished(self, event):
+ if not event["failed"]:
+ return
+ self._echo("Failed: {}".format(event["error_message"]))
+
+ def _is_page_slide_anim_running(self):
+ return (
+ self._page_slide_anim.state() == QtCore.QAbstractAnimation.Running
+ )
+
+ def _go_to_projects_page(self):
+ if self._is_on_projects_page:
+ return
+ self._is_on_projects_page = True
+ self._hierarchy_page.set_page_visible(False)
+
+ self._start_page_slide_animation()
+
+ def _go_to_hierarchy_page(self, project_name):
+ if not self._is_on_projects_page:
+ return
+ self._is_on_projects_page = False
+ self._hierarchy_page.set_page_visible(True, project_name)
+
+ self._start_page_slide_animation()
+
+ def _start_page_slide_animation(self):
+ if self._is_on_projects_page:
+ direction = QtCore.QAbstractAnimation.Backward
+ else:
+ direction = QtCore.QAbstractAnimation.Forward
+ self._page_slide_anim.setDirection(direction)
+ if self._is_page_slide_anim_running():
+ return
+
+ layout_spacing = self._pages_layout.spacing()
+ if self._is_on_projects_page:
+ hierarchy_geo = self._hierarchy_page.geometry()
+ projects_geo = QtCore.QRect(hierarchy_geo)
+ projects_geo.moveRight(
+ hierarchy_geo.left() - (layout_spacing + 1))
+
+ self._projects_page.setVisible(True)
+
+ else:
+ projects_geo = self._projects_page.geometry()
+ hierarchy_geo = QtCore.QRect(projects_geo)
+ hierarchy_geo.moveLeft(projects_geo.right() + layout_spacing)
+ self._hierarchy_page.setVisible(True)
+
+ while self._pages_layout.count():
+ self._pages_layout.takeAt(0)
+
+ self._projects_page.setGeometry(projects_geo)
+ self._hierarchy_page.setGeometry(hierarchy_geo)
+
+ self._page_slide_anim.start()
+
+ def _on_page_slide_value_changed(self, value):
+ layout_spacing = self._pages_layout.spacing()
+ content_width = self._pages_widget.width() - layout_spacing
+ content_height = self._pages_widget.height()
+
+ # Visible widths of other widgets
+ hierarchy_width = int(content_width * value)
+
+ hierarchy_geo = QtCore.QRect(
+ content_width - hierarchy_width, 0, content_width, content_height
+ )
+ projects_geo = QtCore.QRect(hierarchy_geo)
+ projects_geo.moveRight(hierarchy_geo.left() - (layout_spacing + 1))
+
+ self._projects_page.setGeometry(projects_geo)
+ self._hierarchy_page.setGeometry(hierarchy_geo)
+
+ def _on_page_slide_finished(self):
+ self._pages_layout.addWidget(self._projects_page, 1)
+ self._pages_layout.addWidget(self._hierarchy_page, 1)
+ self._projects_page.setVisible(self._is_on_projects_page)
+ self._hierarchy_page.setVisible(not self._is_on_projects_page)
+
+ # def _on_history_action(self, history_data):
+ # action, session = history_data
+ # app = QtWidgets.QApplication.instance()
+ # modifiers = app.keyboardModifiers()
+ #
+ # is_control_down = QtCore.Qt.ControlModifier & modifiers
+ # if is_control_down:
+ # # Revert to that "session" location
+ # self.set_session(session)
+ # else:
+ # # User is holding control, rerun the action
+ # self.run_action(action, session=session)
diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py
new file mode 100644
index 0000000000..1434282c5b
--- /dev/null
+++ b/openpype/tools/ayon_utils/models/__init__.py
@@ -0,0 +1,29 @@
+"""Backend models that can be used in controllers."""
+
+from .cache import CacheItem, NestedCacheItem
+from .projects import (
+ ProjectItem,
+ ProjectsModel,
+ PROJECTS_MODEL_SENDER,
+)
+from .hierarchy import (
+ FolderItem,
+ TaskItem,
+ HierarchyModel,
+ HIERARCHY_MODEL_SENDER,
+)
+
+
+__all__ = (
+ "CacheItem",
+ "NestedCacheItem",
+
+ "ProjectItem",
+ "ProjectsModel",
+ "PROJECTS_MODEL_SENDER",
+
+ "FolderItem",
+ "TaskItem",
+ "HierarchyModel",
+ "HIERARCHY_MODEL_SENDER",
+)
diff --git a/openpype/tools/ayon_utils/models/cache.py b/openpype/tools/ayon_utils/models/cache.py
new file mode 100644
index 0000000000..44b97e930d
--- /dev/null
+++ b/openpype/tools/ayon_utils/models/cache.py
@@ -0,0 +1,196 @@
+import time
+import collections
+
+InitInfo = collections.namedtuple(
+ "InitInfo",
+ ["default_factory", "lifetime"]
+)
+
+
+def _default_factory_func():
+ return None
+
+
+class CacheItem:
+ """Simple cache item with lifetime and default value.
+
+ Args:
+ default_factory (Optional[callable]): Function that returns default
+ value used on init and on reset.
+ lifetime (Optional[int]): Lifetime of the cache data in seconds.
+ """
+
+ def __init__(self, default_factory=None, lifetime=None):
+ if lifetime is None:
+ lifetime = 120
+ self._lifetime = lifetime
+ self._last_update = None
+ if default_factory is None:
+ default_factory = _default_factory_func
+ self._default_factory = default_factory
+ self._data = default_factory()
+
+ @property
+ def is_valid(self):
+ """Is cache valid to use.
+
+ Return:
+ bool: True if cache is valid, False otherwise.
+ """
+
+ if self._last_update is None:
+ return False
+
+ return (time.time() - self._last_update) < self._lifetime
+
+ def set_lifetime(self, lifetime):
+ """Change lifetime of cache item.
+
+ Args:
+ lifetime (int): Lifetime of the cache data in seconds.
+ """
+
+ self._lifetime = lifetime
+
+ def set_invalid(self):
+ """Set cache as invalid."""
+
+ self._last_update = None
+
+ def reset(self):
+ """Set cache as invalid and reset data."""
+
+ self._last_update = None
+ self._data = self._default_factory()
+
+ def get_data(self):
+ """Receive cached data.
+
+ Returns:
+ Any: Any data that are cached.
+ """
+
+ return self._data
+
+ def update_data(self, data):
+ self._data = data
+ self._last_update = time.time()
+
+
+class NestedCacheItem:
+ """Helper for cached items stored in nested structure.
+
+ Example:
+ >>> cache = NestedCacheItem(levels=2)
+ >>> cache["a"]["b"].is_valid
+ False
+ >>> cache["a"]["b"].get_data()
+ None
+ >>> cache["a"]["b"] = 1
+ >>> cache["a"]["b"].is_valid
+ True
+ >>> cache["a"]["b"].get_data()
+ 1
+ >>> cache.reset()
+ >>> cache["a"]["b"].is_valid
+ False
+
+ Args:
+ levels (int): Number of nested levels where read cache is stored.
+ default_factory (Optional[callable]): Function that returns default
+ value used on init and on reset.
+ lifetime (Optional[int]): Lifetime of the cache data in seconds.
+ _init_info (Optional[InitInfo]): Private argument. Init info for
+ nested cache where created from parent item.
+ """
+
+ def __init__(
+ self, levels=1, default_factory=None, lifetime=None, _init_info=None
+ ):
+ if levels < 1:
+ raise ValueError("Nested levels must be greater than 0")
+ self._data_by_key = {}
+ if _init_info is None:
+ _init_info = InitInfo(default_factory, lifetime)
+ self._init_info = _init_info
+ self._levels = levels
+
+ def __getitem__(self, key):
+ """Get cached data.
+
+ Args:
+ key (str): Key of the cache item.
+
+ Returns:
+ Union[NestedCacheItem, CacheItem]: Cache item.
+ """
+
+ cache = self._data_by_key.get(key)
+ if cache is None:
+ if self._levels > 1:
+ cache = NestedCacheItem(
+ levels=self._levels - 1,
+ _init_info=self._init_info
+ )
+ else:
+ cache = CacheItem(
+ self._init_info.default_factory,
+ self._init_info.lifetime
+ )
+ self._data_by_key[key] = cache
+ return cache
+
+ def __setitem__(self, key, value):
+ """Update cached data.
+
+ Args:
+ key (str): Key of the cache item.
+ value (Any): Any data that are cached.
+ """
+
+ if self._levels > 1:
+ raise AttributeError((
+ "{} does not support '__setitem__'. Lower nested level by {}"
+ ).format(self.__class__.__name__, self._levels - 1))
+ cache = self[key]
+ cache.update_data(value)
+
+ def get(self, key):
+ """Get cached data.
+
+ Args:
+ key (str): Key of the cache item.
+
+ Returns:
+ Union[NestedCacheItem, CacheItem]: Cache item.
+ """
+
+ return self[key]
+
+ def reset(self):
+ """Reset cache."""
+
+ self._data_by_key = {}
+
+ def set_lifetime(self, lifetime):
+ """Change lifetime of all children cache items.
+
+ Args:
+ lifetime (int): Lifetime of the cache data in seconds.
+ """
+
+ self._init_info.lifetime = lifetime
+ for cache in self._data_by_key.values():
+ cache.set_lifetime(lifetime)
+
+ @property
+ def is_valid(self):
+ """Raise reasonable error when called on wront level.
+
+ Raises:
+ AttributeError: If called on nested cache item.
+ """
+
+ raise AttributeError((
+ "{} does not support 'is_valid'. Lower nested level by '{}'"
+ ).format(self.__class__.__name__, self._levels))
diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py
new file mode 100644
index 0000000000..8e01c557c5
--- /dev/null
+++ b/openpype/tools/ayon_utils/models/hierarchy.py
@@ -0,0 +1,340 @@
+import collections
+import contextlib
+from abc import ABCMeta, abstractmethod
+
+import ayon_api
+import six
+
+from openpype.style import get_default_entity_icon_color
+
+from .cache import NestedCacheItem
+
+HIERARCHY_MODEL_SENDER = "hierarchy.model"
+
+
+@six.add_metaclass(ABCMeta)
+class AbstractHierarchyController:
+ @abstractmethod
+ def emit_event(self, topic, data, source):
+ pass
+
+
+class FolderItem:
+ """Item representing folder entity on a server.
+
+ Folder can be a child of another folder or a project.
+
+ Args:
+ entity_id (str): Folder id.
+ parent_id (Union[str, None]): Parent folder id. If 'None' then project
+ is parent.
+ name (str): Name of folder.
+ label (str): Folder label.
+ icon_name (str): Name of icon from font awesome.
+ icon_color (str): Hex color string that will be used for icon.
+ """
+
+ def __init__(
+ self, entity_id, parent_id, name, label, icon
+ ):
+ self.entity_id = entity_id
+ self.parent_id = parent_id
+ self.name = name
+ if not icon:
+ icon = {
+ "type": "awesome-font",
+ "name": "fa.folder",
+ "color": get_default_entity_icon_color()
+ }
+ self.icon = icon
+ self.label = label or name
+
+ def to_data(self):
+ """Converts folder item to data.
+
+ Returns:
+ dict[str, Any]: Folder item data.
+ """
+
+ return {
+ "entity_id": self.entity_id,
+ "parent_id": self.parent_id,
+ "name": self.name,
+ "label": self.label,
+ "icon": self.icon,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-creates folder item from data.
+
+ Args:
+ data (dict[str, Any]): Folder item data.
+
+ Returns:
+ FolderItem: Folder item.
+ """
+
+ return cls(**data)
+
+
+class TaskItem:
+ """Task item representing task entity on a server.
+
+ Task is child of a folder.
+
+ Task item has label that is used for display in UI. The label is by
+ default using task name and type.
+
+ Args:
+ task_id (str): Task id.
+ name (str): Name of task.
+ task_type (str): Type of task.
+ parent_id (str): Parent folder id.
+ icon_name (str): Name of icon from font awesome.
+ icon_color (str): Hex color string that will be used for icon.
+ """
+
+ def __init__(
+ self, task_id, name, task_type, parent_id, icon
+ ):
+ self.task_id = task_id
+ self.name = name
+ self.task_type = task_type
+ self.parent_id = parent_id
+ if icon is None:
+ icon = {
+ "type": "awesome-font",
+ "name": "fa.male",
+ "color": get_default_entity_icon_color()
+ }
+ self.icon = icon
+
+ self._label = None
+
+ @property
+ def id(self):
+ """Alias for task_id.
+
+ Returns:
+ str: Task id.
+ """
+
+ return self.task_id
+
+ @property
+ def label(self):
+ """Label of task item for UI.
+
+ Returns:
+ str: Label of task item.
+ """
+
+ if self._label is None:
+ self._label = "{} ({})".format(self.name, self.task_type)
+ return self._label
+
+ def to_data(self):
+ """Converts task item to data.
+
+ Returns:
+ dict[str, Any]: Task item data.
+ """
+
+ return {
+ "task_id": self.task_id,
+ "name": self.name,
+ "parent_id": self.parent_id,
+ "task_type": self.task_type,
+ "icon": self.icon,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-create task item from data.
+
+ Args:
+ data (dict[str, Any]): Task item data.
+
+ Returns:
+ TaskItem: Task item.
+ """
+
+ return cls(**data)
+
+
+def _get_task_items_from_tasks(tasks):
+ """
+
+ Returns:
+ TaskItem: Task item.
+ """
+
+ output = []
+ for task in tasks:
+ folder_id = task["folderId"]
+ output.append(TaskItem(
+ task["id"],
+ task["name"],
+ task["type"],
+ folder_id,
+ None
+ ))
+ return output
+
+
+def _get_folder_item_from_hierarchy_item(item):
+ return FolderItem(
+ item["id"],
+ item["parentId"],
+ item["name"],
+ item["label"],
+ None
+ )
+
+
+class HierarchyModel(object):
+ """Model for project hierarchy items.
+
+ Hierarchy items are folders and tasks. Folders can have as parent another
+ folder or project. Tasks can have as parent only folder.
+ """
+
+ def __init__(self, controller):
+ self._folders_items = NestedCacheItem(levels=1, default_factory=dict)
+ self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict)
+
+ self._task_items = NestedCacheItem(levels=2, default_factory=dict)
+ self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict)
+
+ self._folders_refreshing = set()
+ self._tasks_refreshing = set()
+ self._controller = controller
+
+ def reset(self):
+ self._folders_items.reset()
+ self._folders_by_id.reset()
+
+ self._task_items.reset()
+ self._tasks_by_id.reset()
+
+ def refresh_project(self, project_name):
+ self._refresh_folders_cache(project_name)
+
+ def get_folder_items(self, project_name, sender):
+ if not self._folders_items[project_name].is_valid:
+ self._refresh_folders_cache(project_name, sender)
+ return self._folders_items[project_name].get_data()
+
+ def get_task_items(self, project_name, folder_id, sender):
+ if not project_name or not folder_id:
+ return []
+
+ task_cache = self._task_items[project_name][folder_id]
+ if not task_cache.is_valid:
+ self._refresh_tasks_cache(project_name, folder_id, sender)
+ return task_cache.get_data()
+
+ def get_folder_entity(self, project_name, folder_id):
+ cache = self._folders_by_id[project_name][folder_id]
+ if not cache.is_valid:
+ entity = None
+ if folder_id:
+ entity = ayon_api.get_folder_by_id(project_name, folder_id)
+ cache.update_data(entity)
+ return cache.get_data()
+
+ def get_task_entity(self, project_name, task_id):
+ cache = self._tasks_by_id[project_name][task_id]
+ if not cache.is_valid:
+ entity = None
+ if task_id:
+ entity = ayon_api.get_task_by_id(project_name, task_id)
+ cache.update_data(entity)
+ return cache.get_data()
+
+ @contextlib.contextmanager
+ def _folder_refresh_event_manager(self, project_name, sender):
+ self._folders_refreshing.add(project_name)
+ self._controller.emit_event(
+ "folders.refresh.started",
+ {"project_name": project_name, "sender": sender},
+ HIERARCHY_MODEL_SENDER
+ )
+ try:
+ yield
+
+ finally:
+ self._controller.emit_event(
+ "folders.refresh.finished",
+ {"project_name": project_name, "sender": sender},
+ HIERARCHY_MODEL_SENDER
+ )
+ self._folders_refreshing.remove(project_name)
+
+ @contextlib.contextmanager
+ def _task_refresh_event_manager(
+ self, project_name, folder_id, sender
+ ):
+ self._tasks_refreshing.add(folder_id)
+ self._controller.emit_event(
+ "tasks.refresh.started",
+ {
+ "project_name": project_name,
+ "folder_id": folder_id,
+ "sender": sender,
+ },
+ HIERARCHY_MODEL_SENDER
+ )
+ try:
+ yield
+
+ finally:
+ self._controller.emit_event(
+ "tasks.refresh.finished",
+ {
+ "project_name": project_name,
+ "folder_id": folder_id,
+ "sender": sender,
+ },
+ HIERARCHY_MODEL_SENDER
+ )
+ self._tasks_refreshing.discard(folder_id)
+
+ def _refresh_folders_cache(self, project_name, sender=None):
+ if project_name in self._folders_refreshing:
+ return
+
+ with self._folder_refresh_event_manager(project_name, sender):
+ folder_items = self._query_folders(project_name)
+ self._folders_items[project_name].update_data(folder_items)
+
+ def _query_folders(self, project_name):
+ hierarchy = ayon_api.get_folders_hierarchy(project_name)
+
+ folder_items = {}
+ hierachy_queue = collections.deque(hierarchy["hierarchy"])
+ while hierachy_queue:
+ item = hierachy_queue.popleft()
+ folder_item = _get_folder_item_from_hierarchy_item(item)
+ folder_items[folder_item.entity_id] = folder_item
+ hierachy_queue.extend(item["children"] or [])
+ return folder_items
+
+ def _refresh_tasks_cache(self, project_name, folder_id, sender=None):
+ if folder_id in self._tasks_refreshing:
+ return
+
+ with self._task_refresh_event_manager(
+ project_name, folder_id, sender
+ ):
+ task_items = self._query_tasks(project_name, folder_id)
+ self._task_items[project_name][folder_id] = task_items
+
+ def _query_tasks(self, project_name, folder_id):
+ tasks = list(ayon_api.get_tasks(
+ project_name,
+ folder_ids=[folder_id],
+ fields={"id", "name", "label", "folderId", "type"}
+ ))
+ return _get_task_items_from_tasks(tasks)
diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py
new file mode 100644
index 0000000000..ae3eeecea4
--- /dev/null
+++ b/openpype/tools/ayon_utils/models/projects.py
@@ -0,0 +1,145 @@
+import contextlib
+from abc import ABCMeta, abstractmethod
+
+import ayon_api
+import six
+
+from openpype.style import get_default_entity_icon_color
+
+from .cache import CacheItem
+
+PROJECTS_MODEL_SENDER = "projects.model"
+
+
+@six.add_metaclass(ABCMeta)
+class AbstractHierarchyController:
+ @abstractmethod
+ def emit_event(self, topic, data, source):
+ pass
+
+
+class ProjectItem:
+ """Item representing folder entity on a server.
+
+ Folder can be a child of another folder or a project.
+
+ Args:
+ name (str): Project name.
+ active (Union[str, None]): Parent folder id. If 'None' then project
+ is parent.
+ """
+
+ def __init__(self, name, active, icon=None):
+ self.name = name
+ self.active = active
+ if icon is None:
+ icon = {
+ "type": "awesome-font",
+ "name": "fa.map",
+ "color": get_default_entity_icon_color(),
+ }
+ self.icon = icon
+
+ def to_data(self):
+ """Converts folder item to data.
+
+ Returns:
+ dict[str, Any]: Folder item data.
+ """
+
+ return {
+ "name": self.name,
+ "active": self.active,
+ "icon": self.icon,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ """Re-creates folder item from data.
+
+ Args:
+ data (dict[str, Any]): Folder item data.
+
+ Returns:
+ FolderItem: Folder item.
+ """
+
+ return cls(**data)
+
+
+def _get_project_items_from_entitiy(projects):
+ """
+
+ Args:
+ projects (list[dict[str, Any]]): List of projects.
+
+ Returns:
+ ProjectItem: Project item.
+ """
+
+ return [
+ ProjectItem(project["name"], project["active"])
+ for project in projects
+ ]
+
+
+class ProjectsModel(object):
+ def __init__(self, controller):
+ self._projects_cache = CacheItem(default_factory=dict)
+ self._project_items_by_name = {}
+ self._projects_by_name = {}
+
+ self._is_refreshing = False
+ self._controller = controller
+
+ def reset(self):
+ self._projects_cache.reset()
+ self._project_items_by_name = {}
+ self._projects_by_name = {}
+
+ def refresh(self):
+ self._refresh_projects_cache()
+
+ def get_project_items(self, sender):
+ if not self._projects_cache.is_valid:
+ self._refresh_projects_cache(sender)
+ return self._projects_cache.get_data()
+
+ def get_project_entity(self, project_name):
+ if project_name not in self._projects_by_name:
+ entity = None
+ if project_name:
+ entity = ayon_api.get_project(project_name)
+ self._projects_by_name[project_name] = entity
+ return self._projects_by_name[project_name]
+
+ @contextlib.contextmanager
+ def _project_refresh_event_manager(self, sender):
+ self._is_refreshing = True
+ self._controller.emit_event(
+ "projects.refresh.started",
+ {"sender": sender},
+ PROJECTS_MODEL_SENDER
+ )
+ try:
+ yield
+
+ finally:
+ self._controller.emit_event(
+ "projects.refresh.finished",
+ {"sender": sender},
+ PROJECTS_MODEL_SENDER
+ )
+ self._is_refreshing = False
+
+ def _refresh_projects_cache(self, sender=None):
+ if self._is_refreshing:
+ return
+
+ with self._project_refresh_event_manager(sender):
+ project_items = self._query_projects()
+ self._projects_cache.update_data(project_items)
+
+ def _query_projects(self):
+ projects = ayon_api.get_projects(fields=["name", "active"])
+ return _get_project_items_from_entitiy(projects)
diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py
new file mode 100644
index 0000000000..59aef98faf
--- /dev/null
+++ b/openpype/tools/ayon_utils/widgets/__init__.py
@@ -0,0 +1,37 @@
+from .projects_widget import (
+ # ProjectsWidget,
+ ProjectsCombobox,
+ ProjectsModel,
+ ProjectSortFilterProxy,
+)
+
+from .folders_widget import (
+ FoldersWidget,
+ FoldersModel,
+)
+
+from .tasks_widget import (
+ TasksWidget,
+ TasksModel,
+)
+from .utils import (
+ get_qt_icon,
+ RefreshThread,
+)
+
+
+__all__ = (
+ # "ProjectsWidget",
+ "ProjectsCombobox",
+ "ProjectsModel",
+ "ProjectSortFilterProxy",
+
+ "FoldersWidget",
+ "FoldersModel",
+
+ "TasksWidget",
+ "TasksModel",
+
+ "get_qt_icon",
+ "RefreshThread",
+)
diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py
new file mode 100644
index 0000000000..3fab64f657
--- /dev/null
+++ b/openpype/tools/ayon_utils/widgets/folders_widget.py
@@ -0,0 +1,364 @@
+import collections
+
+from qtpy import QtWidgets, QtGui, QtCore
+
+from openpype.tools.utils import (
+ RecursiveSortFilterProxyModel,
+ DeselectableTreeView,
+)
+
+from .utils import RefreshThread, get_qt_icon
+
+SENDER_NAME = "qt_folders_model"
+ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
+ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2
+
+
+class FoldersModel(QtGui.QStandardItemModel):
+ """Folders model which cares about refresh of folders.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(FoldersModel, self).__init__()
+
+ self._controller = controller
+ self._items_by_id = {}
+ self._parent_id_by_id = {}
+
+ self._refresh_threads = {}
+ self._current_refresh_thread = None
+ self._last_project_name = None
+
+ self._has_content = False
+ self._is_refreshing = False
+
+ @property
+ def is_refreshing(self):
+ """Model is refreshing.
+
+ Returns:
+ bool: True if model is refreshing.
+ """
+ return self._is_refreshing
+
+ @property
+ def has_content(self):
+ """Has at least one folder.
+
+ Returns:
+ bool: True if model has at least one folder.
+ """
+
+ return self._has_content
+
+ def clear(self):
+ self._items_by_id = {}
+ self._parent_id_by_id = {}
+ self._has_content = False
+ super(FoldersModel, self).clear()
+
+ def get_index_by_id(self, item_id):
+ """Get index by folder id.
+
+ Returns:
+ QtCore.QModelIndex: Index of the folder. Can be invalid if folder
+ is not available.
+ """
+ item = self._items_by_id.get(item_id)
+ if item is None:
+ return QtCore.QModelIndex()
+ return self.indexFromItem(item)
+
+ def set_project_name(self, project_name):
+ """Refresh folders items.
+
+ Refresh start thread because it can cause that controller can
+ start query from database if folders are not cached.
+ """
+
+ if not project_name:
+ self._last_project_name = project_name
+ self._current_refresh_thread = None
+ self._fill_items({})
+ return
+
+ self._is_refreshing = True
+
+ if self._last_project_name != project_name:
+ self.clear()
+ self._last_project_name = project_name
+
+ thread = self._refresh_threads.get(project_name)
+ if thread is not None:
+ self._current_refresh_thread = thread
+ return
+
+ thread = RefreshThread(
+ project_name,
+ self._controller.get_folder_items,
+ project_name,
+ SENDER_NAME
+ )
+ self._current_refresh_thread = thread
+ self._refresh_threads[thread.id] = thread
+ thread.refresh_finished.connect(self._on_refresh_thread)
+ thread.start()
+
+ def _on_refresh_thread(self, thread_id):
+ """Callback when refresh thread is finished.
+
+ Technically can be running multiple refresh threads at the same time,
+ to avoid using values from wrong thread, we check if thread id is
+ current refresh thread id.
+
+ Folders are stored by id.
+
+ Args:
+ thread_id (str): Thread id.
+ """
+
+ # Make sure to remove thread from '_refresh_threads' dict
+ thread = self._refresh_threads.pop(thread_id)
+ if (
+ self._current_refresh_thread is None
+ or thread_id != self._current_refresh_thread.id
+ ):
+ return
+
+ self._fill_items(thread.get_result())
+
+ def _fill_items(self, folder_items_by_id):
+ if not folder_items_by_id:
+ if folder_items_by_id is not None:
+ self.clear()
+ self._is_refreshing = False
+ self.refreshed.emit()
+ return
+
+ self._has_content = True
+
+ folder_ids = set(folder_items_by_id)
+ ids_to_remove = set(self._items_by_id) - folder_ids
+
+ folder_items_by_parent = collections.defaultdict(dict)
+ for folder_item in folder_items_by_id.values():
+ (
+ folder_items_by_parent
+ [folder_item.parent_id]
+ [folder_item.entity_id]
+ ) = folder_item
+
+ hierarchy_queue = collections.deque()
+ hierarchy_queue.append((self.invisibleRootItem(), None))
+
+ # Keep pointers to removed items until the refresh finishes
+ # - some children of the items could be moved and reused elsewhere
+ removed_items = []
+ while hierarchy_queue:
+ item = hierarchy_queue.popleft()
+ parent_item, parent_id = item
+ folder_items = folder_items_by_parent[parent_id]
+
+ items_by_id = {}
+ folder_ids_to_add = set(folder_items)
+ for row_idx in reversed(range(parent_item.rowCount())):
+ child_item = parent_item.child(row_idx)
+ child_id = child_item.data(ITEM_ID_ROLE)
+ if child_id in ids_to_remove:
+ removed_items.append(parent_item.takeRow(row_idx))
+ else:
+ items_by_id[child_id] = child_item
+
+ new_items = []
+ for item_id in folder_ids_to_add:
+ folder_item = folder_items[item_id]
+ item = items_by_id.get(item_id)
+ if item is None:
+ is_new = True
+ item = QtGui.QStandardItem()
+ item.setEditable(False)
+ else:
+ is_new = self._parent_id_by_id[item_id] != parent_id
+
+ icon = get_qt_icon(folder_item.icon)
+ item.setData(item_id, ITEM_ID_ROLE)
+ item.setData(folder_item.name, ITEM_NAME_ROLE)
+ item.setData(folder_item.label, QtCore.Qt.DisplayRole)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ if is_new:
+ new_items.append(item)
+ self._items_by_id[item_id] = item
+ self._parent_id_by_id[item_id] = parent_id
+
+ hierarchy_queue.append((item, item_id))
+
+ if new_items:
+ parent_item.appendRows(new_items)
+
+ for item_id in ids_to_remove:
+ self._items_by_id.pop(item_id)
+ self._parent_id_by_id.pop(item_id)
+
+ self._is_refreshing = False
+ self.refreshed.emit()
+
+
+class FoldersWidget(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.
+ handle_expected_selection (bool): If True, the widget will handle
+ the expected selection. Defaults to False.
+ """
+
+ def __init__(self, controller, parent, handle_expected_selection=False):
+ super(FoldersWidget, self).__init__(parent)
+
+ folders_view = DeselectableTreeView(self)
+ folders_view.setHeaderHidden(True)
+
+ folders_model = FoldersModel(controller)
+ folders_proxy_model = RecursiveSortFilterProxyModel()
+ folders_proxy_model.setSourceModel(folders_model)
+
+ folders_view.setModel(folders_proxy_model)
+
+ 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._handle_expected_selection = handle_expected_selection
+ self._expected_selection = None
+
+ def set_name_filer(self, name):
+ """Set filter of folder name.
+
+ Args:
+ name (str): The string filter.
+ """
+
+ self._folders_proxy_model.setFilterFixedString(name)
+
+ 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"] != 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)
+
+ def _get_selected_item_id(self):
+ selection_model = self._folders_view.selectionModel()
+ for index in selection_model.selectedIndexes():
+ item_id = index.data(ITEM_ID_ROLE)
+ if item_id is not None:
+ return item_id
+ return None
+
+ def _on_selection_change(self):
+ item_id = self._get_selected_item_id()
+ self._controller.set_selected_folder(item_id)
+
+ # 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 not self._handle_expected_selection:
+ return
+
+ 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):
+ if not self._handle_expected_selection:
+ return
+
+ folder_id = self._expected_selection
+ self._expected_selection = None
+ if (
+ folder_id is not None
+ and folder_id != self._get_selected_item_id()
+ ):
+ 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)
diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py
new file mode 100644
index 0000000000..818d574910
--- /dev/null
+++ b/openpype/tools/ayon_utils/widgets/projects_widget.py
@@ -0,0 +1,325 @@
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER
+from .utils import RefreshThread, get_qt_icon
+
+PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
+PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
+
+
+class ProjectsModel(QtGui.QStandardItemModel):
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(ProjectsModel, self).__init__()
+ self._controller = controller
+
+ self._project_items = {}
+
+ self._empty_item = None
+ self._empty_item_added = False
+
+ self._is_refreshing = False
+ self._refresh_thread = None
+
+ @property
+ def is_refreshing(self):
+ return self._is_refreshing
+
+ def refresh(self):
+ self._refresh()
+
+ def has_content(self):
+ return len(self._project_items) > 0
+
+ def _add_empty_item(self):
+ item = self._get_empty_item()
+ if not self._empty_item_added:
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(item)
+ self._empty_item_added = True
+
+ def _remove_empty_item(self):
+ if not self._empty_item_added:
+ return
+
+ root_item = self.invisibleRootItem()
+ item = self._get_empty_item()
+ root_item.takeRow(item.row())
+ self._empty_item_added = False
+
+ def _get_empty_item(self):
+ if self._empty_item is None:
+ item = QtGui.QStandardItem("< No projects >")
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ self._empty_item = item
+ return self._empty_item
+
+ def _refresh(self):
+ if self._is_refreshing:
+ return
+ self._is_refreshing = True
+ refresh_thread = RefreshThread(
+ "projects", self._query_project_items
+ )
+ refresh_thread.refresh_finished.connect(self._refresh_finished)
+ refresh_thread.start()
+ self._refresh_thread = refresh_thread
+
+ def _query_project_items(self):
+ return self._controller.get_project_items()
+
+ def _refresh_finished(self):
+ # TODO check if failed
+ result = self._refresh_thread.get_result()
+ self._refresh_thread = None
+
+ self._fill_items(result)
+
+ self._is_refreshing = False
+ self.refreshed.emit()
+
+ def _fill_items(self, project_items):
+ items_to_remove = set(self._project_items.keys())
+ new_items = []
+ for project_item in project_items:
+ project_name = project_item.name
+ items_to_remove.discard(project_name)
+ item = self._project_items.get(project_name)
+ if item is None:
+ item = QtGui.QStandardItem()
+ new_items.append(item)
+ icon = get_qt_icon(project_item.icon)
+ item.setData(project_name, QtCore.Qt.DisplayRole)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setData(project_name, PROJECT_NAME_ROLE)
+ item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
+ self._project_items[project_name] = item
+
+ root_item = self.invisibleRootItem()
+ if new_items:
+ root_item.appendRows(new_items)
+
+ for project_name in items_to_remove:
+ item = self._project_items.pop(project_name)
+ root_item.removeRow(item.row())
+
+ if self.has_content():
+ self._remove_empty_item()
+ else:
+ self._add_empty_item()
+
+
+class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
+ def __init__(self, *args, **kwargs):
+ super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
+ self._filter_inactive = True
+ # Disable case sensitivity
+ self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ def lessThan(self, left_index, right_index):
+ if left_index.data(PROJECT_NAME_ROLE) is None:
+ return True
+
+ if right_index.data(PROJECT_NAME_ROLE) is None:
+ return False
+
+ left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE)
+ right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE)
+ if right_is_active == left_is_active:
+ return super(ProjectSortFilterProxy, self).lessThan(
+ left_index, right_index
+ )
+
+ if left_is_active:
+ return True
+ return False
+
+ def filterAcceptsRow(self, source_row, source_parent):
+ index = self.sourceModel().index(source_row, 0, source_parent)
+ string_pattern = self.filterRegularExpression().pattern()
+ if (
+ self._filter_inactive
+ and not index.data(PROJECT_IS_ACTIVE_ROLE)
+ ):
+ return False
+
+ if string_pattern:
+ project_name = index.data(PROJECT_IS_ACTIVE_ROLE)
+ if project_name is not None:
+ return string_pattern.lower() in project_name.lower()
+
+ return super(ProjectSortFilterProxy, self).filterAcceptsRow(
+ source_row, source_parent
+ )
+
+ def _custom_index_filter(self, index):
+ return bool(index.data(PROJECT_IS_ACTIVE_ROLE))
+
+ def is_active_filter_enabled(self):
+ return self._filter_inactive
+
+ def set_active_filter_enabled(self, value):
+ if self._filter_inactive == value:
+ return
+ self._filter_inactive = value
+ self.invalidateFilter()
+
+
+class ProjectsCombobox(QtWidgets.QWidget):
+ def __init__(self, controller, parent, handle_expected_selection=False):
+ super(ProjectsCombobox, self).__init__(parent)
+
+ projects_combobox = QtWidgets.QComboBox(self)
+ combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox)
+ projects_combobox.setItemDelegate(combobox_delegate)
+ projects_model = ProjectsModel(controller)
+ projects_proxy_model = ProjectSortFilterProxy()
+ projects_proxy_model.setSourceModel(projects_model)
+ projects_combobox.setModel(projects_proxy_model)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(projects_combobox, 1)
+
+ projects_model.refreshed.connect(self._on_model_refresh)
+
+ controller.register_event_callback(
+ "projects.refresh.finished",
+ self._on_projects_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
+ )
+
+ projects_combobox.currentIndexChanged.connect(
+ self._on_current_index_changed
+ )
+
+ self._controller = controller
+ self._listen_selection_change = True
+
+ self._handle_expected_selection = handle_expected_selection
+ self._expected_selection = None
+
+ self._projects_combobox = projects_combobox
+ self._projects_model = projects_model
+ self._projects_proxy_model = projects_proxy_model
+ self._combobox_delegate = combobox_delegate
+
+ def refresh(self):
+ self._projects_model.refresh()
+
+ def set_selection(self, project_name):
+ """Set selection to a given project.
+
+ Selection change is ignored if project is not found.
+
+ Args:
+ project_name (str): Name of project.
+
+ Returns:
+ bool: True if selection was changed, False otherwise. NOTE:
+ Selection may not be changed if project is not found, or if
+ project is already selected.
+ """
+
+ idx = self._projects_combobox.findData(
+ project_name, PROJECT_NAME_ROLE)
+ if idx < 0:
+ return False
+ if idx != self._projects_combobox.currentIndex():
+ self._projects_combobox.setCurrentIndex(idx)
+ return True
+ return False
+
+ def set_listen_to_selection_change(self, listen):
+ """Disable listening to changes of the selection.
+
+ Because combobox is triggering selection change when it's model
+ is refreshed, it's necessary to disable listening to selection for
+ some cases, e.g. when is on a different page of UI and should be just
+ refreshed.
+
+ Args:
+ listen (bool): Enable or disable listening to selection changes.
+ """
+
+ self._listen_selection_change = listen
+
+ def get_current_project_name(self):
+ """Name of selected project.
+
+ Returns:
+ Union[str, None]: Name of selected project, or None if no project
+ """
+
+ idx = self._projects_combobox.currentIndex()
+ if idx < 0:
+ return None
+ return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
+
+ def _on_current_index_changed(self, idx):
+ if not self._listen_selection_change:
+ return
+ project_name = self._projects_combobox.itemData(
+ idx, PROJECT_NAME_ROLE)
+ self._controller.set_selected_project(project_name)
+
+ def _on_model_refresh(self):
+ self._projects_proxy_model.sort(0)
+ if self._expected_selection:
+ self._set_expected_selection()
+
+ def _on_projects_refresh_finished(self, event):
+ if event["sender"] != PROJECTS_MODEL_SENDER:
+ self._projects_model.refresh()
+
+ def _on_controller_refresh(self):
+ self._update_expected_selection()
+
+ # Expected selection handling
+ def _on_expected_selection_change(self, event):
+ self._update_expected_selection(event.data)
+
+ def _set_expected_selection(self):
+ if not self._handle_expected_selection:
+ return
+ project_name = self._expected_selection
+ if project_name is not None:
+ if project_name != self.get_current_project_name():
+ self.set_selection(project_name)
+ else:
+ # Fake project change
+ self._on_current_index_changed(
+ self._projects_combobox.currentIndex()
+ )
+
+ self._controller.expected_project_selected(project_name)
+
+ def _update_expected_selection(self, expected_data=None):
+ if not self._handle_expected_selection:
+ return
+ if expected_data is None:
+ expected_data = self._controller.get_expected_selection_data()
+
+ project_data = expected_data.get("project")
+ if (
+ not project_data
+ or not project_data["current"]
+ or project_data["selected"]
+ ):
+ return
+ self._expected_selection = project_data["name"]
+ if not self._projects_model.is_refreshing:
+ self._set_expected_selection()
+
+
+class ProjectsWidget(QtWidgets.QWidget):
+ # TODO implement
+ pass
diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py
new file mode 100644
index 0000000000..66ebd0b777
--- /dev/null
+++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py
@@ -0,0 +1,436 @@
+from qtpy import QtWidgets, QtGui, QtCore
+
+from openpype.style import get_disabled_entity_icon_color
+from openpype.tools.utils import DeselectableTreeView
+
+from .utils import RefreshThread, get_qt_icon
+
+SENDER_NAME = "qt_tasks_model"
+ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
+PARENT_ID_ROLE = QtCore.Qt.UserRole + 2
+ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3
+TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4
+
+
+class TasksModel(QtGui.QStandardItemModel):
+ """Tasks model which cares about refresh of tasks by folder id.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ """
+
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(TasksModel, self).__init__()
+
+ self._controller = controller
+
+ self._items_by_name = {}
+ self._has_content = False
+ self._is_refreshing = False
+
+ self._invalid_selection_item_used = False
+ self._invalid_selection_item = None
+ self._empty_tasks_item_used = False
+ self._empty_tasks_item = None
+
+ self._last_project_name = None
+ self._last_folder_id = None
+
+ self._refresh_threads = {}
+ self._current_refresh_thread = None
+
+ # Initial state
+ self._add_invalid_selection_item()
+
+ def clear(self):
+ self._items_by_name = {}
+ self._has_content = False
+ self._remove_invalid_items()
+ super(TasksModel, self).clear()
+
+ def refresh(self, project_name, folder_id):
+ """Refresh tasks for folder.
+
+ Args:
+ project_name (Union[str]): Name of project.
+ folder_id (Union[str, None]): Folder id.
+ """
+
+ self._refresh(project_name, folder_id)
+
+ def get_index_by_name(self, task_name):
+ """Find item by name and return its index.
+
+ Returns:
+ QtCore.QModelIndex: Index of item. Is invalid if task is not
+ found by name.
+ """
+
+ item = self._items_by_name.get(task_name)
+ if item is None:
+ return QtCore.QModelIndex()
+ return self.indexFromItem(item)
+
+ def get_last_project_name(self):
+ """Get last refreshed project name.
+
+ Returns:
+ Union[str, None]: Project name.
+ """
+
+ return self._last_project_name
+
+ def get_last_folder_id(self):
+ """Get last refreshed folder id.
+
+ Returns:
+ Union[str, None]: Folder id.
+ """
+
+ return self._last_folder_id
+
+ def set_selected_project(self, project_name):
+ self._selected_project_name = project_name
+
+ def _get_invalid_selection_item(self):
+ if self._invalid_selection_item is None:
+ item = QtGui.QStandardItem("Select a folder")
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ icon = get_qt_icon({
+ "type": "awesome-font",
+ "name": "fa.times",
+ "color": get_disabled_entity_icon_color(),
+ })
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ self._invalid_selection_item = item
+ return self._invalid_selection_item
+
+ def _get_empty_task_item(self):
+ if self._empty_tasks_item is None:
+ item = QtGui.QStandardItem("No task")
+ icon = get_qt_icon({
+ "type": "awesome-font",
+ "name": "fa.exclamation-circle",
+ "color": get_disabled_entity_icon_color(),
+ })
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ self._empty_tasks_item = item
+ return self._empty_tasks_item
+
+ def _add_invalid_item(self, item):
+ self.clear()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(item)
+
+ def _remove_invalid_item(self, item):
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(item.row())
+
+ def _remove_invalid_items(self):
+ self._remove_invalid_selection_item()
+ self._remove_empty_task_item()
+
+ def _add_invalid_selection_item(self):
+ if not self._invalid_selection_item_used:
+ self._add_invalid_item(self._get_invalid_selection_item())
+ self._invalid_selection_item_used = True
+
+ def _remove_invalid_selection_item(self):
+ if self._invalid_selection_item:
+ self._remove_invalid_item(self._get_invalid_selection_item())
+ self._invalid_selection_item_used = False
+
+ def _add_empty_task_item(self):
+ if not self._empty_tasks_item_used:
+ self._add_invalid_item(self._get_empty_task_item())
+ self._empty_tasks_item_used = True
+
+ def _remove_empty_task_item(self):
+ if self._empty_tasks_item_used:
+ self._remove_invalid_item(self._get_empty_task_item())
+ self._empty_tasks_item_used = False
+
+ def _refresh(self, project_name, folder_id):
+ self._is_refreshing = True
+ self._last_project_name = project_name
+ self._last_folder_id = folder_id
+ if not folder_id:
+ self._add_invalid_selection_item()
+ self._current_refresh_thread = None
+ self._is_refreshing = False
+ self.refreshed.emit()
+ return
+
+ thread = self._refresh_threads.get(folder_id)
+ if thread is not None:
+ self._current_refresh_thread = thread
+ return
+ thread = RefreshThread(
+ folder_id,
+ self._controller.get_task_items,
+ project_name,
+ folder_id
+ )
+ self._current_refresh_thread = thread
+ self._refresh_threads[thread.id] = thread
+ thread.refresh_finished.connect(self._on_refresh_thread)
+ thread.start()
+
+ def _on_refresh_thread(self, thread_id):
+ """Callback when refresh thread is finished.
+
+ Technically can be running multiple refresh threads at the same time,
+ to avoid using values from wrong thread, we check if thread id is
+ current refresh thread id.
+
+ Tasks are stored by name, so if a folder has same task name as
+ previously selected folder it keeps the selection.
+
+ Args:
+ thread_id (str): Thread id.
+ """
+
+ # Make sure to remove thread from '_refresh_threads' dict
+ thread = self._refresh_threads.pop(thread_id)
+ if (
+ self._current_refresh_thread is None
+ or thread_id != self._current_refresh_thread.id
+ ):
+ return
+
+ task_items = thread.get_result()
+ # Task items are refreshed
+ if task_items is None:
+ return
+
+ # No tasks are available on folder
+ if not task_items:
+ self._add_empty_task_item()
+ return
+ self._remove_invalid_items()
+
+ new_items = []
+ new_names = set()
+ for task_item in task_items:
+ name = task_item.name
+ new_names.add(name)
+ item = self._items_by_name.get(name)
+ if item is None:
+ item = QtGui.QStandardItem()
+ item.setEditable(False)
+ new_items.append(item)
+ self._items_by_name[name] = item
+
+ # TODO cache locally
+ icon = get_qt_icon(task_item.icon)
+ item.setData(task_item.label, QtCore.Qt.DisplayRole)
+ item.setData(name, ITEM_NAME_ROLE)
+ item.setData(task_item.id, ITEM_ID_ROLE)
+ item.setData(task_item.parent_id, PARENT_ID_ROLE)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+
+ root_item = self.invisibleRootItem()
+
+ for name in set(self._items_by_name) - new_names:
+ item = self._items_by_name.pop(name)
+ root_item.removeRow(item.row())
+
+ if new_items:
+ root_item.appendRows(new_items)
+
+ self._has_content = root_item.rowCount() > 0
+ self._is_refreshing = False
+ self.refreshed.emit()
+
+ @property
+ def is_refreshing(self):
+ """Model is refreshing.
+
+ Returns:
+ bool: Model is refreshing
+ """
+
+ return self._is_refreshing
+
+ @property
+ def has_content(self):
+ """Model has content.
+
+ Returns:
+ bools: Have at least one task.
+ """
+
+ return self._has_content
+
+ def headerData(self, section, orientation, role):
+ # Show nice labels in the header
+ if (
+ role == QtCore.Qt.DisplayRole
+ and orientation == QtCore.Qt.Horizontal
+ ):
+ if section == 0:
+ return "Tasks"
+
+ return super(TasksModel, self).headerData(
+ section, orientation, role
+ )
+
+
+class TasksWidget(QtWidgets.QWidget):
+ """Tasks widget.
+
+ Widget that handles tasks view, model and selection.
+
+ Args:
+ controller (AbstractWorkfilesFrontend): Workfiles controller.
+ parent (QtWidgets.QWidget): Parent widget.
+ handle_expected_selection (Optional[bool]): Handle expected selection.
+ """
+
+ def __init__(self, controller, parent, handle_expected_selection=False):
+ super(TasksWidget, self).__init__(parent)
+
+ tasks_view = DeselectableTreeView(self)
+ tasks_view.setIndentation(0)
+
+ tasks_model = TasksModel(controller)
+ tasks_proxy_model = QtCore.QSortFilterProxyModel()
+ tasks_proxy_model.setSourceModel(tasks_model)
+
+ tasks_view.setModel(tasks_proxy_model)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(tasks_view, 1)
+
+ controller.register_event_callback(
+ "tasks.refresh.finished",
+ self._on_tasks_refresh_finished
+ )
+ controller.register_event_callback(
+ "selection.folder.changed",
+ self._folder_selection_changed
+ )
+ controller.register_event_callback(
+ "expected_selection_changed",
+ self._on_expected_selection_change
+ )
+
+ selection_model = tasks_view.selectionModel()
+ selection_model.selectionChanged.connect(self._on_selection_change)
+
+ tasks_model.refreshed.connect(self._on_tasks_model_refresh)
+
+ self._controller = controller
+ self._tasks_view = tasks_view
+ self._tasks_model = tasks_model
+ self._tasks_proxy_model = tasks_proxy_model
+
+ self._selected_folder_id = None
+
+ self._handle_expected_selection = handle_expected_selection
+ self._expected_selection_data = None
+
+ def _clear(self):
+ self._tasks_model.clear()
+
+ def _on_tasks_refresh_finished(self, event):
+ """Tasks were refreshed in controller.
+
+ Ignore if refresh was triggered by tasks model, or refreshed folder is
+ not the same as currently selected folder.
+
+ Args:
+ event (Event): Event object.
+ """
+
+ # Refresh only if current folder id is the same
+ if (
+ event["sender"] == SENDER_NAME
+ or event["folder_id"] != self._selected_folder_id
+ ):
+ return
+ self._tasks_model.refresh(
+ event["project_name"], self._selected_folder_id
+ )
+
+ def _folder_selection_changed(self, event):
+ self._selected_folder_id = event["folder_id"]
+ self._tasks_model.refresh(
+ event["project_name"], self._selected_folder_id
+ )
+
+ def _on_tasks_model_refresh(self):
+ if not self._set_expected_selection():
+ self._on_selection_change()
+ self._tasks_proxy_model.sort(0)
+
+ def _get_selected_item_ids(self):
+ selection_model = self._tasks_view.selectionModel()
+ for index in selection_model.selectedIndexes():
+ task_id = index.data(ITEM_ID_ROLE)
+ task_name = index.data(ITEM_NAME_ROLE)
+ parent_id = index.data(PARENT_ID_ROLE)
+ if task_name is not None:
+ return parent_id, task_id, task_name
+ return self._selected_folder_id, None, None
+
+ def _on_selection_change(self):
+ # Don't trigger task change during refresh
+ # - a task was deselected if that happens
+ # - can cause crash triggered during tasks refreshing
+ if self._tasks_model.is_refreshing:
+ return
+
+ parent_id, task_id, task_name = self._get_selected_item_ids()
+ self._controller.set_selected_task(task_id, task_name)
+
+ # Expected selection handling
+ def _on_expected_selection_change(self, event):
+ self._update_expected_selection(event.data)
+
+ def _set_expected_selection(self):
+ if not self._handle_expected_selection:
+ return False
+
+ if self._expected_selection_data is None:
+ return False
+ folder_id = self._expected_selection_data["folder_id"]
+ task_name = self._expected_selection_data["task_name"]
+ self._expected_selection_data = None
+ model_folder_id = self._tasks_model.get_last_folder_id()
+ if folder_id != model_folder_id:
+ return False
+ if task_name is not None:
+ index = self._tasks_model.get_index_by_name(task_name)
+ if index.isValid():
+ proxy_index = self._tasks_proxy_model.mapFromSource(index)
+ self._tasks_view.setCurrentIndex(proxy_index)
+ self._controller.expected_task_selected(folder_id, task_name)
+ return True
+
+ def _update_expected_selection(self, expected_data=None):
+ if not self._handle_expected_selection:
+ return
+ if expected_data is None:
+ expected_data = self._controller.get_expected_selection_data()
+ folder_data = expected_data.get("folder")
+ task_data = expected_data.get("task")
+ if (
+ not folder_data
+ or not task_data
+ or not task_data["current"]
+ ):
+ return
+ folder_id = folder_data["id"]
+ self._expected_selection_data = {
+ "task_name": task_data["name"],
+ "folder_id": folder_id,
+ }
+ model_folder_id = self._tasks_model.get_last_folder_id()
+ if folder_id != model_folder_id or self._tasks_model.is_refreshing:
+ return
+ self._set_expected_selection()
diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py
new file mode 100644
index 0000000000..8bc3b1ea9b
--- /dev/null
+++ b/openpype/tools/ayon_utils/widgets/utils.py
@@ -0,0 +1,98 @@
+import os
+from functools import partial
+
+from qtpy import QtCore, QtGui
+
+from openpype.tools.utils.lib import get_qta_icon_by_name_and_color
+
+
+class RefreshThread(QtCore.QThread):
+ refresh_finished = QtCore.Signal(str)
+
+ def __init__(self, thread_id, func, *args, **kwargs):
+ super(RefreshThread, self).__init__()
+ self._id = thread_id
+ self._callback = partial(func, *args, **kwargs)
+ self._exception = None
+ self._result = None
+
+ @property
+ def id(self):
+ return self._id
+
+ @property
+ def failed(self):
+ return self._exception is not None
+
+ def run(self):
+ try:
+ self._result = self._callback()
+ except Exception as exc:
+ self._exception = exc
+ self.refresh_finished.emit(self.id)
+
+ def get_result(self):
+ return self._result
+
+
+class _IconsCache:
+ """Cache for icons."""
+
+ _cache = {}
+ _default = None
+
+ @classmethod
+ def _get_cache_key(cls, icon_def):
+ parts = []
+ icon_type = icon_def["type"]
+ if icon_type == "path":
+ parts = [icon_type, icon_def["path"]]
+
+ elif icon_type == "awesome-font":
+ parts = [icon_type, icon_def["name"], icon_def["color"]]
+ return "|".join(parts)
+
+ @classmethod
+ def get_icon(cls, icon_def):
+ icon_type = icon_def["type"]
+ cache_key = cls._get_cache_key(icon_def)
+ cache = cls._cache.get(cache_key)
+ if cache is not None:
+ return cache
+
+ icon = None
+ if icon_type == "path":
+ path = icon_def["path"]
+ if os.path.exists(path):
+ icon = QtGui.QIcon(path)
+
+ elif icon_type == "awesome-font":
+ icon_name = icon_def["name"]
+ icon_color = icon_def["color"]
+ icon = get_qta_icon_by_name_and_color(icon_name, icon_color)
+ if icon is None:
+ icon = get_qta_icon_by_name_and_color(
+ "fa.{}".format(icon_name), icon_color)
+ if icon is None:
+ icon = cls.get_default()
+ cls._cache[cache_key] = icon
+ return icon
+
+ @classmethod
+ def get_default(cls):
+ pix = QtGui.QPixmap(1, 1)
+ pix.fill(QtCore.Qt.transparent)
+ return QtGui.QIcon(pix)
+
+
+def get_qt_icon(icon_def):
+ """Returns icon from cache or creates new one.
+
+ Args:
+ icon_def (dict[str, Any]): Icon definition.
+
+ Returns:
+ QtGui.QIcon: Icon.
+ """
+
+ return _IconsCache.get_icon(icon_def)
diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py
index e30a2c2499..f511181837 100644
--- a/openpype/tools/ayon_workfiles/abstract.py
+++ b/openpype/tools/ayon_workfiles/abstract.py
@@ -442,6 +442,16 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
pass
+ @abstractmethod
+ def get_project_entity(self):
+ """Get current project entity.
+
+ Returns:
+ dict[str, Any]: Project entity data.
+ """
+
+ pass
+
@abstractmethod
def get_folder_entity(self, folder_id):
"""Get folder entity by id.
diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py
index fc8819bff3..1153a3c01f 100644
--- a/openpype/tools/ayon_workfiles/control.py
+++ b/openpype/tools/ayon_workfiles/control.py
@@ -193,6 +193,9 @@ class BaseWorkfileController(
self._project_anatomy = Anatomy(self.get_current_project_name())
return self._project_anatomy
+ def get_project_entity(self):
+ return self._entities_model.get_project_entity()
+
def get_folder_entity(self, folder_id):
return self._entities_model.get_folder_entity(folder_id)
diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py
index 948c0b8a17..a1d51525da 100644
--- a/openpype/tools/ayon_workfiles/models/hierarchy.py
+++ b/openpype/tools/ayon_workfiles/models/hierarchy.py
@@ -77,8 +77,11 @@ class EntitiesModel(object):
event_source = "entities.model"
def __init__(self, controller):
+ project_cache = CacheItem()
+ project_cache.set_invalid({})
folders_cache = CacheItem()
folders_cache.set_invalid({})
+ self._project_cache = project_cache
self._folders_cache = folders_cache
self._tasks_cache = {}
@@ -90,6 +93,7 @@ class EntitiesModel(object):
self._controller = controller
def reset(self):
+ self._project_cache.set_invalid({})
self._folders_cache.set_invalid({})
self._tasks_cache = {}
@@ -99,6 +103,13 @@ class EntitiesModel(object):
def refresh(self):
self._refresh_folders_cache()
+ def get_project_entity(self):
+ if not self._project_cache.is_valid:
+ project_name = self._controller.get_current_project_name()
+ project_entity = ayon_api.get_project(project_name)
+ self._project_cache.update_data(project_entity)
+ return self._project_cache.get_data()
+
def get_folder_items(self, sender):
if not self._folders_cache.is_valid:
self._refresh_folders_cache(sender)
diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py
index eb82f62de3..316d8b2a16 100644
--- a/openpype/tools/ayon_workfiles/models/workfiles.py
+++ b/openpype/tools/ayon_workfiles/models/workfiles.py
@@ -43,13 +43,21 @@ def get_folder_template_data(folder):
}
-def get_task_template_data(task):
+def get_task_template_data(project_entity, task):
if not task:
return {}
+ short_name = None
+ task_type_name = task["taskType"]
+ for task_type_info in project_entity["config"]["taskTypes"]:
+ if task_type_info["name"] == task_type_name:
+ short_name = task_type_info["shortName"]
+ break
+
return {
"task": {
"name": task["name"],
- "type": task["taskType"]
+ "type": task_type_name,
+ "short": short_name,
}
}
@@ -145,12 +153,13 @@ class WorkareaModel:
self._fill_data_by_folder_id[folder_id] = fill_data
return copy.deepcopy(fill_data)
- def _get_task_data(self, folder_id, task_id):
+ def _get_task_data(self, project_entity, folder_id, task_id):
task_data = self._task_data_by_folder_id.setdefault(folder_id, {})
if task_id not in task_data:
task = self._controller.get_task_entity(task_id)
if task:
- task_data[task_id] = get_task_template_data(task)
+ task_data[task_id] = get_task_template_data(
+ project_entity, task)
return copy.deepcopy(task_data[task_id])
def _prepare_fill_data(self, folder_id, task_id):
@@ -159,7 +168,8 @@ class WorkareaModel:
base_data = self._get_base_data()
folder_data = self._get_folder_data(folder_id)
- task_data = self._get_task_data(folder_id, task_id)
+ project_entity = self._controller.get_project_entity()
+ task_data = self._get_task_data(project_entity, folder_id, task_id)
base_data.update(folder_data)
base_data.update(task_data)
diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py
index 61660ee9b7..285b5d04ca 100644
--- a/openpype/tools/launcher/actions.py
+++ b/openpype/tools/launcher/actions.py
@@ -1,8 +1,5 @@
-import os
-
from qtpy import QtWidgets, QtGui
-from openpype import PLUGINS_DIR
from openpype import style
from openpype import resources
from openpype.lib import (
@@ -10,46 +7,7 @@ from openpype.lib import (
ApplictionExecutableNotFound,
ApplicationLaunchFailed
)
-from openpype.pipeline import (
- LauncherAction,
- register_launcher_action_path,
-)
-
-
-def register_actions_from_paths(paths):
- if not paths:
- return
-
- for path in paths:
- if not path:
- continue
-
- if path.startswith("."):
- print((
- "BUG: Relative paths are not allowed for security reasons. {}"
- ).format(path))
- continue
-
- if not os.path.exists(path):
- print("Path was not found: {}".format(path))
- continue
-
- register_launcher_action_path(path)
-
-
-def register_config_actions():
- """Register actions from the configuration for Launcher"""
-
- actions_dir = os.path.join(PLUGINS_DIR, "actions")
- if os.path.exists(actions_dir):
- register_actions_from_paths([actions_dir])
-
-
-def register_environment_actions():
- """Register actions from AVALON_ACTIONS for Launcher."""
-
- paths_str = os.environ.get("AVALON_ACTIONS") or ""
- register_actions_from_paths(paths_str.split(os.pathsep))
+from openpype.pipeline import LauncherAction
# TODO move to 'openpype.pipeline.actions'
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
index d343353112..018088e916 100644
--- a/openpype/tools/utils/__init__.py
+++ b/openpype/tools/utils/__init__.py
@@ -15,6 +15,10 @@ from .widgets import (
IconButton,
PixmapButton,
SeparatorWidget,
+ VerticalExpandButton,
+ SquareButton,
+ RefreshButton,
+ GoToCurrentButton,
)
from .views import DeselectableTreeView
from .error_dialog import ErrorMessageBox
@@ -60,6 +64,11 @@ __all__ = (
"PixmapButton",
"SeparatorWidget",
+ "VerticalExpandButton",
+ "SquareButton",
+ "RefreshButton",
+ "GoToCurrentButton",
+
"DeselectableTreeView",
"ErrorMessageBox",
diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py
index a70437cc65..9223afecaa 100644
--- a/openpype/tools/utils/widgets.py
+++ b/openpype/tools/utils/widgets.py
@@ -6,10 +6,13 @@ import qtawesome
from openpype.style import (
get_objected_colors,
- get_style_image_path
+ get_style_image_path,
+ get_default_tools_icon_color,
)
from openpype.lib.attribute_definitions import AbstractAttrDef
+from .lib import get_qta_icon_by_name_and_color
+
log = logging.getLogger(__name__)
@@ -777,3 +780,77 @@ class SeparatorWidget(QtWidgets.QFrame):
self._orientation = orientation
self._set_size(self._size)
+
+
+def get_refresh_icon():
+ return get_qta_icon_by_name_and_color(
+ "fa.refresh", get_default_tools_icon_color()
+ )
+
+
+def get_go_to_current_icon():
+ return get_qta_icon_by_name_and_color(
+ "fa.arrow-down", get_default_tools_icon_color()
+ )
+
+
+class VerticalExpandButton(QtWidgets.QPushButton):
+ """Button which is expanding vertically.
+
+ By default, button is a little bit smaller than other widgets like
+ QLineEdit. This button is expanding vertically to match size of
+ other widgets, next to it.
+ """
+
+ def __init__(self, parent=None):
+ super(VerticalExpandButton, self).__init__(parent)
+
+ sp = self.sizePolicy()
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
+ self.setSizePolicy(sp)
+
+
+class SquareButton(QtWidgets.QPushButton):
+ """Make button square shape.
+
+ Change width to match height on resize.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(SquareButton, self).__init__(*args, **kwargs)
+
+ sp = self.sizePolicy()
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum)
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum)
+ self.setSizePolicy(sp)
+ self._ideal_width = None
+
+ def showEvent(self, event):
+ super(SquareButton, self).showEvent(event)
+ self._ideal_width = self.height()
+ self.updateGeometry()
+
+ def resizeEvent(self, event):
+ super(SquareButton, self).resizeEvent(event)
+ self._ideal_width = self.height()
+ self.updateGeometry()
+
+ def sizeHint(self):
+ sh = super(SquareButton, self).sizeHint()
+ ideal_width = self._ideal_width
+ if ideal_width is None:
+ ideal_width = sh.height()
+ sh.setWidth(ideal_width)
+ return sh
+
+
+class RefreshButton(VerticalExpandButton):
+ def __init__(self, parent=None):
+ super(RefreshButton, self).__init__(parent)
+ self.setIcon(get_refresh_icon())
+
+
+class GoToCurrentButton(VerticalExpandButton):
+ def __init__(self, parent=None):
+ super(GoToCurrentButton, self).__init__(parent)
+ self.setIcon(get_go_to_current_icon())
diff --git a/openpype/version.py b/openpype/version.py
index bbed1b0ef3..d1ebde3d04 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.16.7-nightly.2"
+__version__ = "3.17.1-nightly.2"
diff --git a/poetry.lock b/poetry.lock
index 5621d39988..d074a0c3d9 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,10 +1,9 @@
-# This file is automatically @generated by Poetry and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
[[package]]
name = "acre"
version = "1.0.0"
description = "Lightweight cross-platform environment management Python package that makes it trivial to launch applications in their own configurable working environment."
-category = "main"
optional = false
python-versions = ">=2.7"
files = []
@@ -20,7 +19,6 @@ resolved_reference = "126f7a188cfe36718f707f42ebbc597e86aa86c3"
name = "aiohttp"
version = "3.8.4"
description = "Async http client/server framework (asyncio)"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -129,7 +127,6 @@ speedups = ["Brotli", "aiodns", "cchardet"]
name = "aiohttp-json-rpc"
version = "0.13.3"
description = "Implementation JSON-RPC 2.0 server and client using aiohttp on top of websockets transport"
-category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -144,7 +141,6 @@ aiohttp = ">=3,<4"
name = "aiohttp-middlewares"
version = "2.2.0"
description = "Collection of useful middlewares for aiohttp applications."
-category = "main"
optional = false
python-versions = ">=3.7,<4.0"
files = [
@@ -161,7 +157,6 @@ yarl = ">=1.5.1,<2.0.0"
name = "aiosignal"
version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -176,7 +171,6 @@ frozenlist = ">=1.1.0"
name = "alabaster"
version = "0.7.13"
description = "A configurable sidebar-enabled Sphinx theme"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -188,7 +182,6 @@ files = [
name = "ansicon"
version = "1.89.0"
description = "Python wrapper for loading Jason Hood's ANSICON"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -200,7 +193,6 @@ files = [
name = "appdirs"
version = "1.4.4"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = []
@@ -216,7 +208,6 @@ resolved_reference = "8734277956c1df3b85385e6b308e954910533884"
name = "arrow"
version = "0.17.0"
description = "Better dates & times for Python"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
@@ -231,7 +222,6 @@ python-dateutil = ">=2.7.0"
name = "astroid"
version = "2.15.5"
description = "An abstract syntax tree for Python with inference support."
-category = "dev"
optional = false
python-versions = ">=3.7.2"
files = [
@@ -248,7 +238,6 @@ wrapt = {version = ">=1.11,<2", markers = "python_version < \"3.11\""}
name = "async-timeout"
version = "4.0.2"
description = "Timeout context manager for asyncio programs"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -260,7 +249,6 @@ files = [
name = "atomicwrites"
version = "1.4.1"
description = "Atomic file writes."
-category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -271,7 +259,6 @@ files = [
name = "attrs"
version = "23.1.0"
description = "Classes Without Boilerplate"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -290,7 +277,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte
name = "autopep8"
version = "2.0.2"
description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -306,7 +292,6 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
name = "babel"
version = "2.12.1"
description = "Internationalization utilities"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -318,7 +303,6 @@ files = [
name = "bcrypt"
version = "4.0.1"
description = "Modern password hashing for your software and your servers"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -353,7 +337,6 @@ typecheck = ["mypy"]
name = "bidict"
version = "0.22.1"
description = "The bidirectional mapping library for Python."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -370,7 +353,6 @@ test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "py
name = "blessed"
version = "1.20.0"
description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities."
-category = "main"
optional = false
python-versions = ">=2.7"
files = [
@@ -387,7 +369,6 @@ wcwidth = ">=0.1.4"
name = "cachetools"
version = "5.3.1"
description = "Extensible memoizing collections and decorators"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -397,21 +378,19 @@ files = [
[[package]]
name = "certifi"
-version = "2023.5.7"
+version = "2023.7.22"
description = "Python package for providing Mozilla's CA Bundle."
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
- {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
- {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
+ {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
+ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
]
[[package]]
name = "cffi"
version = "1.15.1"
description = "Foreign Function Interface for Python calling C code."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -488,7 +467,6 @@ pycparser = "*"
name = "cfgv"
version = "3.3.1"
description = "Validate configuration and produce human readable error messages."
-category = "dev"
optional = false
python-versions = ">=3.6.1"
files = [
@@ -500,7 +478,6 @@ files = [
name = "charset-normalizer"
version = "3.1.0"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "main"
optional = false
python-versions = ">=3.7.0"
files = [
@@ -583,24 +560,19 @@ files = [
[[package]]
name = "click"
-version = "8.1.3"
+version = "7.1.2"
description = "Composable command line interface toolkit"
-category = "main"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
- {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
- {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+ {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
+ {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
-[package.dependencies]
-colorama = {version = "*", markers = "platform_system == \"Windows\""}
-
[[package]]
name = "clique"
version = "1.6.1"
description = "Manage collections with common numerical component"
-category = "main"
optional = false
python-versions = ">=2.7, <4.0"
files = [
@@ -617,7 +589,6 @@ test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)"
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
@@ -629,7 +600,6 @@ files = [
name = "commonmark"
version = "0.9.1"
description = "Python parser for the CommonMark Markdown spec"
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -644,7 +614,6 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
name = "coolname"
version = "2.2.0"
description = "Random name and slug generator"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -656,7 +625,6 @@ files = [
name = "coverage"
version = "7.2.7"
description = "Code coverage measurement for Python"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -732,7 +700,6 @@ toml = ["tomli"]
name = "cryptography"
version = "39.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -776,7 +743,6 @@ test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0
name = "cx-freeze"
version = "6.12.0"
description = "Create standalone executables from Python scripts"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -834,7 +800,6 @@ test = ["nose (==1.3.7)", "pygments (>=2.11.2)", "pytest (>=7.0.1)", "pytest-cov
name = "cx-logging"
version = "3.1.0"
description = "Python and C interfaces for logging"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -865,7 +830,6 @@ files = [
name = "dill"
version = "0.3.6"
description = "serialize all of python"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -880,7 +844,6 @@ graph = ["objgraph (>=1.7.2)"]
name = "distlib"
version = "0.3.6"
description = "Distribution utilities"
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -892,7 +855,6 @@ files = [
name = "dnspython"
version = "2.3.0"
description = "DNS toolkit"
-category = "main"
optional = false
python-versions = ">=3.7,<4.0"
files = [
@@ -913,7 +875,6 @@ wmi = ["wmi (>=1.5.1,<2.0.0)"]
name = "docutils"
version = "0.19"
description = "Docutils -- Python Documentation Utilities"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -925,7 +886,6 @@ files = [
name = "dropbox"
version = "11.36.0"
description = "Official Dropbox API Client"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -943,7 +903,6 @@ stone = ">=2"
name = "enlighten"
version = "1.11.2"
description = "Enlighten Progress Bar"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -959,7 +918,6 @@ prefixed = ">=0.3.2"
name = "evdev"
version = "1.6.1"
description = "Bindings to the Linux input handling subsystem"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -970,7 +928,6 @@ files = [
name = "filelock"
version = "3.12.0"
description = "A platform independent file lock."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -986,7 +943,6 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "p
name = "flake8"
version = "6.0.0"
description = "the modular source code checker: pep8 pyflakes and co"
-category = "dev"
optional = false
python-versions = ">=3.8.1"
files = [
@@ -1003,7 +959,6 @@ pyflakes = ">=3.0.0,<3.1.0"
name = "frozenlist"
version = "1.3.3"
description = "A list-like structure which implements collections.abc.MutableSequence"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1087,7 +1042,6 @@ files = [
name = "ftrack-python-api"
version = "2.5.0"
description = "Python API for ftrack."
-category = "main"
optional = false
python-versions = ">=2.7.9, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
files = [
@@ -1110,7 +1064,6 @@ websocket-client = ">=0.40.0,<1"
name = "future"
version = "0.18.3"
description = "Clean single-source support for Python 3 and 2"
-category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -1121,7 +1074,6 @@ files = [
name = "gazu"
version = "0.9.3"
description = "Gazu is a client for Zou, the API to store the data of your CG production."
-category = "main"
optional = false
python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*, != 3.4.*, != 3.5.*, != 3.6.1, != 3.6.2"
files = [
@@ -1141,7 +1093,6 @@ test = ["pytest", "pytest-cov", "requests-mock"]
name = "gitdb"
version = "4.0.10"
description = "Git Object Database"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1156,7 +1107,6 @@ smmap = ">=3.0.1,<6"
name = "gitpython"
version = "3.1.31"
description = "GitPython is a Python library used to interact with Git repositories"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1171,7 +1121,6 @@ gitdb = ">=4.0.1,<5"
name = "google-api-core"
version = "2.11.0"
description = "Google API client core library"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1194,7 +1143,6 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0dev)"]
name = "google-api-python-client"
version = "1.12.11"
description = "Google API Client Library for Python"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
files = [
@@ -1214,7 +1162,6 @@ uritemplate = ">=3.0.0,<4dev"
name = "google-auth"
version = "2.17.3"
description = "Google Authentication Library"
-category = "main"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*"
files = [
@@ -1239,7 +1186,6 @@ requests = ["requests (>=2.20.0,<3.0.0dev)"]
name = "google-auth-httplib2"
version = "0.1.0"
description = "Google Authentication Library: httplib2 transport"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1256,7 +1202,6 @@ six = "*"
name = "googleapis-common-protos"
version = "1.59.0"
description = "Common protobufs used in Google APIs"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1274,7 +1219,6 @@ grpc = ["grpcio (>=1.44.0,<2.0.0dev)"]
name = "httplib2"
version = "0.22.0"
description = "A comprehensive HTTP client library."
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -1289,7 +1233,6 @@ pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0
name = "identify"
version = "2.5.24"
description = "File identification library for Python"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1304,7 +1247,6 @@ license = ["ukkonen"]
name = "idna"
version = "3.4"
description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -1316,7 +1258,6 @@ files = [
name = "imagesize"
version = "1.4.1"
description = "Getting image size from png/jpeg/jpeg2000/gif file"
-category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -1328,7 +1269,6 @@ files = [
name = "importlib-metadata"
version = "6.6.0"
description = "Read metadata from Python packages"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1348,7 +1288,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1360,7 +1299,6 @@ files = [
name = "isort"
version = "5.12.0"
description = "A Python utility / library to sort Python imports."
-category = "dev"
optional = false
python-versions = ">=3.8.0"
files = [
@@ -1378,7 +1316,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
name = "jedi"
version = "0.13.3"
description = "An autocompletion tool for Python that can be used for text editors."
-category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -1396,7 +1333,6 @@ testing = ["colorama", "docopt", "pytest (>=3.1.0)"]
name = "jeepney"
version = "0.8.0"
description = "Low-level, pure Python DBus protocol wrapper."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1412,7 +1348,6 @@ trio = ["async_generator", "trio"]
name = "jinja2"
version = "3.1.2"
description = "A very fast and expressive template engine."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1430,7 +1365,6 @@ i18n = ["Babel (>=2.7)"]
name = "jinxed"
version = "1.2.0"
description = "Jinxed Terminal Library"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1445,7 +1379,6 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""}
name = "jsonschema"
version = "2.6.0"
description = "An implementation of JSON Schema validation for Python"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1460,7 +1393,6 @@ format = ["rfc3987", "strict-rfc3339", "webcolors"]
name = "keyring"
version = "22.4.0"
description = "Store and access your passwords safely."
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1482,7 +1414,6 @@ testing = ["pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=1.2.
name = "lazy-object-proxy"
version = "1.9.0"
description = "A fast and thorough lazy object proxy."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1528,7 +1459,6 @@ files = [
name = "lief"
version = "0.13.1"
description = "Library to instrument executable formats"
-category = "dev"
optional = false
python-versions = ">=3.8"
files = [
@@ -1561,7 +1491,6 @@ files = [
name = "linkify-it-py"
version = "2.0.2"
description = "Links recognition library with FULL unicode support."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1582,7 +1511,6 @@ test = ["coverage", "pytest", "pytest-cov"]
name = "log4mongo"
version = "1.7.0"
description = "mongo database handler for python logging"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1596,7 +1524,6 @@ pymongo = "*"
name = "m2r2"
version = "0.3.3.post2"
description = "Markdown and reStructuredText in a single file."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1612,7 +1539,6 @@ mistune = "0.8.4"
name = "markdown-it-py"
version = "2.2.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1637,7 +1563,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
name = "markupsafe"
version = "2.0.1"
description = "Safely add untrusted strings to HTML/XML markup."
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -1716,7 +1641,6 @@ files = [
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -1728,7 +1652,6 @@ files = [
name = "mdit-py-plugins"
version = "0.3.5"
description = "Collection of plugins for markdown-it-py"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1748,7 +1671,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1760,7 +1682,6 @@ files = [
name = "mistune"
version = "0.8.4"
description = "The fastest markdown parser in pure Python"
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -1772,7 +1693,6 @@ files = [
name = "multidict"
version = "6.0.4"
description = "multidict implementation"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1856,7 +1776,6 @@ files = [
name = "myst-parser"
version = "0.18.1"
description = "An extended commonmark compliant parser, with bridges to docutils & sphinx."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -1883,7 +1802,6 @@ testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov",
name = "nodeenv"
version = "1.8.0"
description = "Node.js virtual environment builder"
-category = "dev"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
files = [
@@ -1898,7 +1816,6 @@ setuptools = "*"
name = "packaging"
version = "23.1"
description = "Core utilities for Python packages"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -1910,7 +1827,6 @@ files = [
name = "paramiko"
version = "3.2.0"
description = "SSH2 protocol library"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -1932,7 +1848,6 @@ invoke = ["invoke (>=2.0)"]
name = "parso"
version = "0.8.3"
description = "A Python Parser"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -1948,7 +1863,6 @@ testing = ["docopt", "pytest (<6.0.0)"]
name = "patchelf"
version = "0.17.2.1"
description = "A small utility to modify the dynamic linker and RPATH of ELF executables."
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -1968,7 +1882,6 @@ test = ["importlib-metadata", "pytest"]
name = "pathlib2"
version = "2.3.7.post1"
description = "Object-oriented filesystem paths"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -1983,7 +1896,6 @@ six = "*"
name = "pillow"
version = "9.5.0"
description = "Python Imaging Library (Fork)"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2063,7 +1975,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
name = "platformdirs"
version = "3.5.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -2079,7 +1990,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-
name = "pluggy"
version = "1.0.0"
description = "plugin and hook calling mechanisms for python"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -2095,7 +2005,6 @@ testing = ["pytest", "pytest-benchmark"]
name = "ply"
version = "3.11"
description = "Python Lex & Yacc"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2107,7 +2016,6 @@ files = [
name = "pockets"
version = "0.9.1"
description = "A collection of helpful Python tools!"
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -2122,7 +2030,6 @@ six = ">=1.5.2"
name = "pre-commit"
version = "3.3.2"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
-category = "dev"
optional = false
python-versions = ">=3.8"
files = [
@@ -2141,7 +2048,6 @@ virtualenv = ">=20.10.0"
name = "prefixed"
version = "0.7.0"
description = "Prefixed alternative numeric library"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2153,7 +2059,6 @@ files = [
name = "protobuf"
version = "4.23.2"
description = ""
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2176,7 +2081,6 @@ files = [
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
-category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
@@ -2188,7 +2092,6 @@ files = [
name = "pyasn1"
version = "0.5.0"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
files = [
@@ -2200,7 +2103,6 @@ files = [
name = "pyasn1-modules"
version = "0.3.0"
description = "A collection of ASN.1-based protocols modules"
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
files = [
@@ -2215,7 +2117,6 @@ pyasn1 = ">=0.4.6,<0.6.0"
name = "pyblish-base"
version = "1.8.11"
description = "Plug-in driven automation framework for content"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2227,7 +2128,6 @@ files = [
name = "pycodestyle"
version = "2.10.0"
description = "Python style guide checker"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -2239,7 +2139,6 @@ files = [
name = "pycparser"
version = "2.21"
description = "C parser in Python"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -2251,7 +2150,6 @@ files = [
name = "pydocstyle"
version = "6.3.0"
description = "Python docstring style checker"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -2269,7 +2167,6 @@ toml = ["tomli (>=1.2.3)"]
name = "pyflakes"
version = "3.0.1"
description = "passive checker of Python programs"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -2281,7 +2178,6 @@ files = [
name = "pygments"
version = "2.15.1"
description = "Pygments is a syntax highlighting package written in Python."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -2296,7 +2192,6 @@ plugins = ["importlib-metadata"]
name = "pylint"
version = "2.17.4"
description = "python code static checker"
-category = "dev"
optional = false
python-versions = ">=3.7.2"
files = [
@@ -2323,7 +2218,6 @@ testutils = ["gitpython (>3)"]
name = "pymongo"
version = "3.13.0"
description = "Python driver for MongoDB "
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2452,7 +2346,6 @@ zstd = ["zstandard"]
name = "pynacl"
version = "1.5.0"
description = "Python binding to the Networking and Cryptography (NaCl) library"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -2479,7 +2372,6 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
name = "pynput"
version = "1.7.6"
description = "Monitor and control user input devices"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2498,7 +2390,6 @@ six = "*"
name = "pyobjc-core"
version = "9.1.1"
description = "Python<->ObjC Interoperability Module"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2515,7 +2406,6 @@ files = [
name = "pyobjc-framework-applicationservices"
version = "9.1.1"
description = "Wrappers for the framework ApplicationServices on macOS"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2537,7 +2427,6 @@ pyobjc-framework-Quartz = ">=9.1.1"
name = "pyobjc-framework-cocoa"
version = "9.1.1"
description = "Wrappers for the Cocoa frameworks on macOS"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2557,7 +2446,6 @@ pyobjc-core = ">=9.1.1"
name = "pyobjc-framework-quartz"
version = "9.1.1"
description = "Wrappers for the Quartz frameworks on macOS"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2578,7 +2466,6 @@ pyobjc-framework-Cocoa = ">=9.1.1"
name = "pyparsing"
version = "2.4.7"
description = "Python parsing module"
-category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -2590,7 +2477,6 @@ files = [
name = "pysftp"
version = "0.2.9"
description = "A friendly face on SFTP"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2604,7 +2490,6 @@ paramiko = ">=1.17"
name = "pytest"
version = "6.2.5"
description = "pytest: simple powerful testing with Python"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -2629,7 +2514,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
name = "pytest-cov"
version = "4.1.0"
description = "Pytest plugin for measuring coverage."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -2648,7 +2532,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
name = "pytest-print"
version = "0.3.1"
description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -2666,7 +2549,6 @@ test = ["coverage (>=5)"]
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
-category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
@@ -2681,7 +2563,6 @@ six = ">=1.5"
name = "python-engineio"
version = "4.4.1"
description = "Engine.IO server and client for Python"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -2697,7 +2578,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
name = "python-socketio"
version = "5.8.0"
description = "Socket.IO server and client for Python"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -2719,7 +2599,6 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
name = "python-xlib"
version = "0.33"
description = "Python X Library"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2734,7 +2613,6 @@ six = ">=1.10.0"
name = "python3-xlib"
version = "0.15"
description = "Python3 X Library"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2745,7 +2623,6 @@ files = [
name = "pywin32"
version = "301"
description = "Python for Window Extensions"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2765,7 +2642,6 @@ files = [
name = "pywin32-ctypes"
version = "0.2.0"
description = ""
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2777,7 +2653,6 @@ files = [
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -2827,7 +2702,6 @@ files = [
name = "qt-py"
version = "1.3.8"
description = "Python 2 & 3 compatibility wrapper around all Qt bindings - PySide, PySide2, PyQt4 and PyQt5."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2842,7 +2716,6 @@ types-PySide2 = "*"
name = "qtawesome"
version = "0.7.3"
description = "FontAwesome icons in PyQt and PySide applications"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -2858,7 +2731,6 @@ six = "*"
name = "qtpy"
version = "2.3.1"
description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2876,7 +2748,6 @@ test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"]
name = "recommonmark"
version = "0.7.1"
description = "A docutils-compatibility bridge to CommonMark, enabling you to write CommonMark inside of Docutils & Sphinx projects."
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -2893,7 +2764,6 @@ sphinx = ">=1.3.1"
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -2915,7 +2785,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
name = "revitron-sphinx-theme"
version = "0.7.2"
description = "Revitron theme for Sphinx"
-category = "dev"
optional = false
python-versions = "*"
files = []
@@ -2937,7 +2806,6 @@ resolved_reference = "c0779c66365d9d258d93575ebaff7db9d3aee282"
name = "rsa"
version = "4.9"
description = "Pure-Python RSA implementation"
-category = "main"
optional = false
python-versions = ">=3.6,<4"
files = [
@@ -2952,7 +2820,6 @@ pyasn1 = ">=0.1.3"
name = "secretstorage"
version = "3.3.3"
description = "Python bindings to FreeDesktop.org Secret Service API"
-category = "main"
optional = false
python-versions = ">=3.6"
files = [
@@ -2968,7 +2835,6 @@ jeepney = ">=0.6"
name = "semver"
version = "2.13.0"
description = "Python helper for Semantic Versioning (http://semver.org/)"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -2980,7 +2846,6 @@ files = [
name = "setuptools"
version = "65.7.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -2997,7 +2862,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
name = "shotgun-api3"
version = "3.3.3"
description = "Shotgun Python API"
-category = "main"
optional = false
python-versions = "*"
files = []
@@ -3013,7 +2877,6 @@ resolved_reference = "b9f066c0edbea6e0733242e18f32f75489064840"
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -3025,7 +2888,6 @@ files = [
name = "slack-sdk"
version = "3.21.3"
description = "The Slack API Platform SDK for Python"
-category = "main"
optional = false
python-versions = ">=3.6.0"
files = [
@@ -3041,7 +2903,6 @@ testing = ["Flask (>=1,<2)", "Flask-Sockets (>=0.2,<1)", "Jinja2 (==3.0.3)", "We
name = "smmap"
version = "5.0.0"
description = "A pure Python implementation of a sliding window memory map manager"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -3053,7 +2914,6 @@ files = [
name = "snowballstemmer"
version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -3065,7 +2925,6 @@ files = [
name = "speedcopy"
version = "2.1.4"
description = "Replacement or alternative for python copyfile() utilizing server side copy on network shares for faster copying."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -3077,7 +2936,6 @@ files = [
name = "sphinx"
version = "5.3.0"
description = "Python documentation generator"
-category = "dev"
optional = false
python-versions = ">=3.6"
files = [
@@ -3113,7 +2971,6 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"]
name = "sphinx-autoapi"
version = "2.1.0"
description = "Sphinx API documentation generator"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3137,7 +2994,6 @@ go = ["sphinxcontrib-golangdomain"]
name = "sphinxcontrib-applehelp"
version = "1.0.4"
description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
-category = "dev"
optional = false
python-versions = ">=3.8"
files = [
@@ -3153,7 +3009,6 @@ test = ["pytest"]
name = "sphinxcontrib-devhelp"
version = "1.0.2"
description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
-category = "dev"
optional = false
python-versions = ">=3.5"
files = [
@@ -3169,7 +3024,6 @@ test = ["pytest"]
name = "sphinxcontrib-htmlhelp"
version = "2.0.1"
description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
-category = "dev"
optional = false
python-versions = ">=3.8"
files = [
@@ -3185,7 +3039,6 @@ test = ["html5lib", "pytest"]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
description = "A sphinx extension which renders display math in HTML via JavaScript"
-category = "dev"
optional = false
python-versions = ">=3.5"
files = [
@@ -3200,7 +3053,6 @@ test = ["flake8", "mypy", "pytest"]
name = "sphinxcontrib-napoleon"
version = "0.7"
description = "Sphinx \"napoleon\" extension."
-category = "dev"
optional = false
python-versions = "*"
files = [
@@ -3216,7 +3068,6 @@ six = ">=1.5.2"
name = "sphinxcontrib-qthelp"
version = "1.0.3"
description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
-category = "dev"
optional = false
python-versions = ">=3.5"
files = [
@@ -3232,7 +3083,6 @@ test = ["pytest"]
name = "sphinxcontrib-serializinghtml"
version = "1.1.5"
description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
-category = "dev"
optional = false
python-versions = ">=3.5"
files = [
@@ -3248,7 +3098,6 @@ test = ["pytest"]
name = "stone"
version = "3.3.1"
description = "Stone is an interface description language (IDL) for APIs."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -3265,7 +3114,6 @@ six = ">=1.12.0"
name = "termcolor"
version = "1.1.0"
description = "ANSII Color formatting for output in terminal."
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -3276,7 +3124,6 @@ files = [
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
-category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -3288,7 +3135,6 @@ files = [
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3300,7 +3146,6 @@ files = [
name = "tomlkit"
version = "0.11.8"
description = "Style preserving TOML library"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3312,7 +3157,6 @@ files = [
name = "types-pyside2"
version = "5.15.2.1.5"
description = "The most accurate stubs for PySide2"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -3323,7 +3167,6 @@ files = [
name = "typing-extensions"
version = "4.6.2"
description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3335,7 +3178,6 @@ files = [
name = "uc-micro-py"
version = "1.0.2"
description = "Micro subset of unicode data files for linkify-it-py projects."
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3350,7 +3192,6 @@ test = ["coverage", "pytest", "pytest-cov"]
name = "unidecode"
version = "1.2.0"
description = "ASCII transliterations of Unicode text"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -3362,7 +3203,6 @@ files = [
name = "uritemplate"
version = "3.0.1"
description = "URI templates"
-category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -3372,27 +3212,24 @@ files = [
[[package]]
name = "urllib3"
-version = "2.0.2"
+version = "1.26.16"
description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "main"
optional = false
-python-versions = ">=3.7"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
files = [
- {file = "urllib3-2.0.2-py3-none-any.whl", hash = "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e"},
- {file = "urllib3-2.0.2.tar.gz", hash = "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc"},
+ {file = "urllib3-1.26.16-py2.py3-none-any.whl", hash = "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f"},
+ {file = "urllib3-1.26.16.tar.gz", hash = "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14"},
]
[package.extras]
-brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
-secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
-socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
-zstd = ["zstandard (>=0.18.0)"]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
+secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
+socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
version = "20.23.0"
description = "Virtual Python Environment builder"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3413,7 +3250,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess
name = "wcwidth"
version = "0.2.6"
description = "Measures the displayed width of unicode strings in a terminal"
-category = "main"
optional = false
python-versions = "*"
files = [
@@ -3425,7 +3261,6 @@ files = [
name = "websocket-client"
version = "0.59.0"
description = "WebSocket client for Python with low level API options"
-category = "main"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
files = [
@@ -3440,7 +3275,6 @@ six = "*"
name = "wheel"
version = "0.40.0"
description = "A built-package format for Python"
-category = "dev"
optional = false
python-versions = ">=3.7"
files = [
@@ -3455,7 +3289,6 @@ test = ["pytest (>=6.0.0)"]
name = "wrapt"
version = "1.15.0"
description = "Module for decorators, wrappers and monkey patching."
-category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
files = [
@@ -3540,7 +3373,6 @@ files = [
name = "wsrpc-aiohttp"
version = "3.2.0"
description = "WSRPC is the RPC over WebSocket for aiohttp"
-category = "main"
optional = false
python-versions = ">3.5.*, <4"
files = [
@@ -3561,7 +3393,6 @@ ujson = ["ujson"]
name = "yarl"
version = "1.9.2"
description = "Yet another URL library"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -3649,7 +3480,6 @@ multidict = ">=4.0"
name = "zipp"
version = "3.15.0"
description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -3666,5 +3496,5 @@ docs = []
[metadata]
lock-version = "2.0"
-python-versions = ">=3.9.1,<3.10"
-content-hash = "d2b8da22dcd11e0b03f19b9b79e51f205156c5ce75e41cc0225392e9afd8803b"
+python-versions = "~3.9"
+content-hash = "bc3e256094db6e33894840bb6a5adda4473d3736b852433ad8d5bd478c7e0c1c"
diff --git a/pyproject.toml b/pyproject.toml
index f859e1aff4..d0b1ecf589 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.16.6" # OpenPype
+version = "3.17.0" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
@@ -36,7 +36,7 @@ appdirs = { git = "https://github.com/ActiveState/appdirs.git", branch = "master
blessed = "^1.17" # openpype terminal formatting
coolname = "*"
clique = "1.6.*"
-Click = "^8"
+Click = "7.1.2"
dnspython = "^2.1.0"
ftrack-python-api = "^2.3.3"
arrow = "^0.17"
@@ -56,6 +56,7 @@ QtPy = "^2.3.0"
qtawesome = "0.7.3"
speedcopy = "^2.1"
six = "^1.15"
+urllib3 = "1.26.16"
semver = "^2.13.0" # for version resolution
wsrpc_aiohttp = "^3.1.1" # websocket server
pywin32 = { version = "301", markers = "sys_platform == 'win32'" }
diff --git a/schema/application-1.0.json b/schema/application-1.0.json
deleted file mode 100644
index 953abee569..0000000000
--- a/schema/application-1.0.json
+++ /dev/null
@@ -1,68 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:application-1.0",
- "description": "An application definition.",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "label",
- "application_dir",
- "executable"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "label": {
- "description": "Nice name of application.",
- "type": "string"
- },
- "application_dir": {
- "description": "Name of directory used for application resources.",
- "type": "string"
- },
- "executable": {
- "description": "Name of callable executable, this is called to launch the application",
- "type": "string"
- },
- "description": {
- "description": "Description of application.",
- "type": "string"
- },
- "environment": {
- "description": "Key/value pairs for environment variables related to this application. Supports lists for paths, such as PYTHONPATH.",
- "type": "object",
- "items": {
- "oneOf": [
- {"type": "string"},
- {"type": "array", "items": {"type": "string"}}
- ]
- }
- },
- "default_dirs": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "copy": {
- "type": "object",
- "patternProperties": {
- "^.*$": {
- "anyOf": [
- {"type": "string"},
- {"type": "null"}
- ]
- }
- },
- "additionalProperties": false
- }
- }
-}
diff --git a/schema/asset-1.0.json b/schema/asset-1.0.json
deleted file mode 100644
index ab104c002a..0000000000
--- a/schema/asset-1.0.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:asset-1.0",
- "description": "A unit of data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "name",
- "subsets"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "name": {
- "description": "Name of directory",
- "type": "string"
- },
- "subsets": {
- "type": "array",
- "items": {
- "$ref": "subset.json"
- }
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/asset-2.0.json b/schema/asset-2.0.json
deleted file mode 100644
index b894d79792..0000000000
--- a/schema/asset-2.0.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:asset-2.0",
- "description": "A unit of data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "name",
- "silo",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:asset-2.0"],
- "example": "openpype:asset-2.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["asset"],
- "example": "asset"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of asset",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "Bruce"
- },
- "silo": {
- "description": "Group or container of asset",
- "type": "string",
- "example": "assets"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {"key": "value"}
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/asset-3.0.json b/schema/asset-3.0.json
deleted file mode 100644
index 948704d2a1..0000000000
--- a/schema/asset-3.0.json
+++ /dev/null
@@ -1,55 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:asset-3.0",
- "description": "A unit of data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "name",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:asset-3.0"],
- "example": "openpype:asset-3.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["asset"],
- "example": "asset"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of asset",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "Bruce"
- },
- "silo": {
- "description": "Group or container of asset",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "assets"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {"key": "value"}
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/config-1.0.json b/schema/config-1.0.json
deleted file mode 100644
index 49398a57cd..0000000000
--- a/schema/config-1.0.json
+++ /dev/null
@@ -1,85 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:config-1.0",
- "description": "A project configuration.",
-
- "type": "object",
-
- "additionalProperties": false,
- "required": [
- "tasks",
- "apps"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "template": {
- "type": "object",
- "additionalProperties": false,
- "patternProperties": {
- "^.*$": {
- "type": "string"
- }
- }
- },
- "tasks": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "group": {"type": "string"},
- "label": {"type": "string"}
- },
- "required": ["name"]
- }
- },
- "apps": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "group": {"type": "string"},
- "label": {"type": "string"}
- },
- "required": ["name"]
- }
- },
- "families": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "label": {"type": "string"},
- "hideFilter": {"type": "boolean"}
- },
- "required": ["name"]
- }
- },
- "groups": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "color": {"type": "string"},
- "order": {"type": ["integer", "number"]}
- },
- "required": ["name"]
- }
- },
- "copy": {
- "type": "object"
- }
- }
-}
diff --git a/schema/config-1.1.json b/schema/config-1.1.json
deleted file mode 100644
index 6e15514aaf..0000000000
--- a/schema/config-1.1.json
+++ /dev/null
@@ -1,87 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:config-1.1",
- "description": "A project configuration.",
-
- "type": "object",
-
- "additionalProperties": false,
- "required": [
- "tasks",
- "apps"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "template": {
- "type": "object",
- "additionalProperties": false,
- "patternProperties": {
- "^.*$": {
- "type": "string"
- }
- }
- },
- "tasks": {
- "type": "object",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "group": {"type": "string"},
- "label": {"type": "string"}
- },
- "required": [
- "short_name"
- ]
- }
- },
- "apps": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "group": {"type": "string"},
- "label": {"type": "string"}
- },
- "required": ["name"]
- }
- },
- "families": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "label": {"type": "string"},
- "hideFilter": {"type": "boolean"}
- },
- "required": ["name"]
- }
- },
- "groups": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "color": {"type": "string"},
- "order": {"type": ["integer", "number"]}
- },
- "required": ["name"]
- }
- },
- "copy": {
- "type": "object"
- }
- }
-}
diff --git a/schema/config-2.0.json b/schema/config-2.0.json
deleted file mode 100644
index 54b226711a..0000000000
--- a/schema/config-2.0.json
+++ /dev/null
@@ -1,87 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:config-2.0",
- "description": "A project configuration.",
-
- "type": "object",
-
- "additionalProperties": false,
- "required": [
- "tasks",
- "apps"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "templates": {
- "type": "object"
- },
- "roots": {
- "type": "object"
- },
- "imageio": {
- "type": "object"
- },
- "tasks": {
- "type": "object",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "group": {"type": "string"},
- "label": {"type": "string"}
- },
- "required": [
- "short_name"
- ]
- }
- },
- "apps": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "group": {"type": "string"},
- "label": {"type": "string"}
- },
- "required": ["name"]
- }
- },
- "families": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "label": {"type": "string"},
- "hideFilter": {"type": "boolean"}
- },
- "required": ["name"]
- }
- },
- "groups": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "icon": {"type": "string"},
- "color": {"type": "string"},
- "order": {"type": ["integer", "number"]}
- },
- "required": ["name"]
- }
- },
- "copy": {
- "type": "object"
- }
- }
-}
diff --git a/schema/container-1.0.json b/schema/container-1.0.json
deleted file mode 100644
index 012e8499e6..0000000000
--- a/schema/container-1.0.json
+++ /dev/null
@@ -1,100 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:container-1.0",
- "description": "A loaded asset",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "id",
- "objectName",
- "name",
- "author",
- "loader",
- "families",
- "time",
- "subset",
- "asset",
- "representation",
- "version",
- "silo",
- "path",
- "source"
- ],
- "properties": {
- "id": {
- "description": "Identifier for finding object in host",
- "type": "string",
- "enum": ["pyblish.mindbender.container"],
- "example": "pyblish.mindbender.container"
- },
- "objectName": {
- "description": "Name of internal object, such as the objectSet in Maya.",
- "type": "string",
- "example": "Bruce_:rigDefault_CON"
- },
- "name": {
- "description": "Full name of application object",
- "type": "string",
- "example": "modelDefault"
- },
- "author": {
- "description": "Name of the author of the published version",
- "type": "string",
- "example": "Marcus Ottosson"
- },
- "loader": {
- "description": "Name of loader plug-in used to produce this container",
- "type": "string",
- "example": "ModelLoader"
- },
- "families": {
- "description": "Families associated with the this subset",
- "type": "string",
- "example": "mindbender.model"
- },
- "time": {
- "description": "File-system safe, formatted time",
- "type": "string",
- "example": "20170329T131545Z"
- },
- "subset": {
- "description": "Name of source subset",
- "type": "string",
- "example": "modelDefault"
- },
- "asset": {
- "description": "Name of source asset",
- "type": "string" ,
- "example": "Bruce"
- },
- "representation": {
- "description": "Name of source representation",
- "type": "string" ,
- "example": ".ma"
- },
- "version": {
- "description": "Version number",
- "type": "number",
- "example": 12
- },
- "silo": {
- "description": "Silo of parent asset",
- "type": "string",
- "example": "assets"
- },
- "path": {
- "description": "Absolute path on disk",
- "type": "string",
- "example": "{root}/assets/Bruce/publish/rigDefault/v002"
- },
- "source": {
- "description": "Absolute path to file from which this version was published",
- "type": "string",
- "example": "{root}/assets/Bruce/work/rigging/maya/scenes/rig_v001.ma"
- }
- }
-}
diff --git a/schema/container-2.0.json b/schema/container-2.0.json
deleted file mode 100644
index 1673ee5d1d..0000000000
--- a/schema/container-2.0.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:container-2.0",
- "description": "A loaded asset",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "id",
- "objectName",
- "name",
- "namespace",
- "loader",
- "representation"
- ],
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:container-2.0"],
- "example": "openpype:container-2.0"
- },
- "id": {
- "description": "Identifier for finding object in host",
- "type": "string",
- "enum": ["pyblish.avalon.container"],
- "example": "pyblish.avalon.container"
- },
- "objectName": {
- "description": "Name of internal object, such as the objectSet in Maya.",
- "type": "string",
- "example": "Bruce_:rigDefault_CON"
- },
- "loader": {
- "description": "Name of loader plug-in used to produce this container",
- "type": "string",
- "example": "ModelLoader"
- },
- "name": {
- "description": "Internal object name of container in application",
- "type": "string",
- "example": "modelDefault_01"
- },
- "namespace": {
- "description": "Internal namespace of container in application",
- "type": "string",
- "example": "Bruce_"
- },
- "representation": {
- "description": "Unique id of representation in database",
- "type": "string",
- "example": "59523f355f8c1b5f6c5e8348"
- }
- }
-}
diff --git a/schema/hero_version-1.0.json b/schema/hero_version-1.0.json
deleted file mode 100644
index b720dc2887..0000000000
--- a/schema/hero_version-1.0.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:hero_version-1.0",
- "description": "Hero version of asset",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "version_id",
- "schema",
- "type",
- "parent"
- ],
-
- "properties": {
- "_id": {
- "description": "Document's id (database will create it's if not entered)",
- "example": "ObjectId(592c33475f8c1b064c4d1696)"
- },
- "version_id": {
- "description": "The version ID from which it was created",
- "example": "ObjectId(592c33475f8c1b064c4d1695)"
- },
- "schema": {
- "description": "The schema associated with this document",
- "type": "string",
- "enum": ["openpype:hero_version-1.0"],
- "example": "openpype:hero_version-1.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["hero_version"],
- "example": "hero_version"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "ObjectId(592c33475f8c1b064c4d1697)"
- }
- }
-}
diff --git a/schema/inventory-1.0.json b/schema/inventory-1.0.json
deleted file mode 100644
index 2fe78794ab..0000000000
--- a/schema/inventory-1.0.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:config-1.0",
- "description": "A project configuration.",
-
- "type": "object",
-
- "additionalProperties": true
-}
diff --git a/schema/inventory-1.1.json b/schema/inventory-1.1.json
deleted file mode 100644
index b61a76b32a..0000000000
--- a/schema/inventory-1.1.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:config-1.1",
- "description": "A project configuration.",
-
- "type": "object",
-
- "additionalProperties": true
-}
diff --git a/schema/project-2.0.json b/schema/project-2.0.json
deleted file mode 100644
index 0ed5a55599..0000000000
--- a/schema/project-2.0.json
+++ /dev/null
@@ -1,86 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:project-2.0",
- "description": "A unit of data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "name",
- "data",
- "config"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:project-2.0"],
- "example": "openpype:project-2.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["project"],
- "example": "project"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of directory",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "hulk"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {
- "fps": 24,
- "width": 1920,
- "height": 1080
- }
- },
- "config": {
- "type": "object",
- "description": "Document metadata",
- "example": {
- "schema": "openpype:config-1.0",
- "apps": [
- {
- "name": "maya2016",
- "label": "Autodesk Maya 2016"
- },
- {
- "name": "nuke10",
- "label": "The Foundry Nuke 10.0"
- }
- ],
- "tasks": [
- {"name": "model"},
- {"name": "render"},
- {"name": "animate"},
- {"name": "rig"},
- {"name": "lookdev"},
- {"name": "layout"}
- ],
- "template": {
- "work":
- "{root}/{project}/{silo}/{asset}/work/{task}/{app}",
- "publish":
- "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}"
- }
- },
- "$ref": "config-1.0.json"
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/project-2.1.json b/schema/project-2.1.json
deleted file mode 100644
index 9413c9f691..0000000000
--- a/schema/project-2.1.json
+++ /dev/null
@@ -1,86 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:project-2.1",
- "description": "A unit of data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "name",
- "data",
- "config"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:project-2.1"],
- "example": "openpype:project-2.1"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["project"],
- "example": "project"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of directory",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "hulk"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {
- "fps": 24,
- "width": 1920,
- "height": 1080
- }
- },
- "config": {
- "type": "object",
- "description": "Document metadata",
- "example": {
- "schema": "openpype:config-1.1",
- "apps": [
- {
- "name": "maya2016",
- "label": "Autodesk Maya 2016"
- },
- {
- "name": "nuke10",
- "label": "The Foundry Nuke 10.0"
- }
- ],
- "tasks": {
- "Model": {"short_name": "mdl"},
- "Render": {"short_name": "rnd"},
- "Animate": {"short_name": "anim"},
- "Rig": {"short_name": "rig"},
- "Lookdev": {"short_name": "look"},
- "Layout": {"short_name": "lay"}
- },
- "template": {
- "work":
- "{root}/{project}/{silo}/{asset}/work/{task}/{app}",
- "publish":
- "{root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/{subset}.{representation}"
- }
- },
- "$ref": "config-1.1.json"
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/project-3.0.json b/schema/project-3.0.json
deleted file mode 100644
index be23e10c93..0000000000
--- a/schema/project-3.0.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:project-3.0",
- "description": "A unit of data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "name",
- "data",
- "config"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:project-3.0"],
- "example": "openpype:project-3.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["project"],
- "example": "project"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of directory",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "hulk"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {
- "fps": 24,
- "width": 1920,
- "height": 1080
- }
- },
- "config": {
- "type": "object",
- "description": "Document metadata",
- "$ref": "config-2.0.json"
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/representation-1.0.json b/schema/representation-1.0.json
deleted file mode 100644
index 347c585f52..0000000000
--- a/schema/representation-1.0.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:representation-1.0",
- "description": "The inverse of an instance",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "format",
- "path"
- ],
-
- "properties": {
- "schema": {"type": "string"},
- "format": {
- "description": "File extension, including '.'",
- "type": "string"
- },
- "path": {
- "description": "Unformatted path to version.",
- "type": "string"
- }
- }
-}
diff --git a/schema/representation-2.0.json b/schema/representation-2.0.json
deleted file mode 100644
index f47c16a10a..0000000000
--- a/schema/representation-2.0.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:representation-2.0",
- "description": "The inverse of an instance",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "parent",
- "name",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:representation-2.0"],
- "example": "openpype:representation-2.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["representation"],
- "example": "representation"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of representation",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "abc"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {
- "label": "Alembic"
- }
- },
- "dependencies": {
- "description": "Other representation that this representation depends on",
- "type": "array",
- "items": {"type": "string"},
- "example": [
- "592d547a5f8c1b388093c145"
- ]
- },
- "context": {
- "description": "Summary of the context to which this representation belong.",
- "type": "object",
- "properties": {
- "project": {"type": "object"},
- "asset": {"type": "string"},
- "silo": {"type": ["string", "null"]},
- "subset": {"type": "string"},
- "version": {"type": "number"},
- "representation": {"type": "string"}
- },
- "example": {
- "project": "hulk",
- "asset": "Bruce",
- "silo": "assets",
- "subset": "rigDefault",
- "version": 12,
- "representation": "ma"
- }
- }
- }
-}
diff --git a/schema/session-1.0.json b/schema/session-1.0.json
deleted file mode 100644
index 5ced0a6f08..0000000000
--- a/schema/session-1.0.json
+++ /dev/null
@@ -1,143 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:session-1.0",
- "description": "The Avalon environment",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "AVALON_PROJECTS",
- "AVALON_PROJECT",
- "AVALON_ASSET",
- "AVALON_SILO",
- "AVALON_CONFIG"
- ],
-
- "properties": {
- "AVALON_PROJECTS": {
- "description": "Absolute path to root of project directories",
- "type": "string",
- "example": "/nas/projects"
- },
- "AVALON_PROJECT": {
- "description": "Name of project",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "Hulk"
- },
- "AVALON_ASSET": {
- "description": "Name of asset",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "Bruce"
- },
- "AVALON_SILO": {
- "description": "Name of asset group or container",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "assets"
- },
- "AVALON_TASK": {
- "description": "Name of task",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "modeling"
- },
- "AVALON_CONFIG": {
- "description": "Name of Avalon configuration",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "polly"
- },
- "AVALON_APP": {
- "description": "Name of application",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "maya2016"
- },
- "AVALON_MONGO": {
- "description": "Address to the asset database",
- "type": "string",
- "pattern": "^mongodb://[\\w/@:.]*$",
- "example": "mongodb://localhost:27017",
- "default": "mongodb://localhost:27017"
- },
- "AVALON_DB": {
- "description": "Name of database",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "avalon",
- "default": "avalon"
- },
- "AVALON_LABEL": {
- "description": "Nice name of Avalon, used in e.g. graphical user interfaces",
- "type": "string",
- "example": "Mindbender",
- "default": "Avalon"
- },
- "AVALON_SENTRY": {
- "description": "Address to Sentry",
- "type": "string",
- "pattern": "^http[\\w/@:.]*$",
- "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2",
- "default": null
- },
- "AVALON_DEADLINE": {
- "description": "Address to Deadline",
- "type": "string",
- "pattern": "^http[\\w/@:.]*$",
- "example": "http://192.168.99.101",
- "default": null
- },
- "AVALON_TIMEOUT": {
- "description": "Wherever there is a need for a timeout, this is the default value.",
- "type": "string",
- "pattern": "^[0-9]*$",
- "default": "1000",
- "example": "1000"
- },
- "AVALON_UPLOAD": {
- "description": "Boolean of whether to upload published material to central asset repository",
- "type": "string",
- "default": null,
- "example": "True"
- },
- "AVALON_USERNAME": {
- "description": "Generic username",
- "type": "string",
- "pattern": "^\\w*$",
- "default": "avalon",
- "example": "myself"
- },
- "AVALON_PASSWORD": {
- "description": "Generic password",
- "type": "string",
- "pattern": "^\\w*$",
- "default": "secret",
- "example": "abc123"
- },
- "AVALON_INSTANCE_ID": {
- "description": "Unique identifier for instances in a working file",
- "type": "string",
- "pattern": "^[\\w.]*$",
- "default": "avalon.instance",
- "example": "avalon.instance"
- },
- "AVALON_CONTAINER_ID": {
- "description": "Unique identifier for a loaded representation in a working file",
- "type": "string",
- "pattern": "^[\\w.]*$",
- "default": "avalon.container",
- "example": "avalon.container"
- },
- "AVALON_DEBUG": {
- "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.",
- "type": "string",
- "default": null,
- "example": "True"
- }
- }
-}
diff --git a/schema/session-2.0.json b/schema/session-2.0.json
deleted file mode 100644
index 0a4d51beb2..0000000000
--- a/schema/session-2.0.json
+++ /dev/null
@@ -1,134 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:session-2.0",
- "description": "The Avalon environment",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "AVALON_PROJECT",
- "AVALON_ASSET",
- "AVALON_CONFIG"
- ],
-
- "properties": {
- "AVALON_PROJECTS": {
- "description": "Absolute path to root of project directories",
- "type": "string",
- "example": "/nas/projects"
- },
- "AVALON_PROJECT": {
- "description": "Name of project",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "Hulk"
- },
- "AVALON_ASSET": {
- "description": "Name of asset",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "Bruce"
- },
- "AVALON_SILO": {
- "description": "Name of asset group or container",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "assets"
- },
- "AVALON_TASK": {
- "description": "Name of task",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "modeling"
- },
- "AVALON_CONFIG": {
- "description": "Name of Avalon configuration",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "polly"
- },
- "AVALON_APP": {
- "description": "Name of application",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "maya2016"
- },
- "AVALON_DB": {
- "description": "Name of database",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "avalon",
- "default": "avalon"
- },
- "AVALON_LABEL": {
- "description": "Nice name of Avalon, used in e.g. graphical user interfaces",
- "type": "string",
- "example": "Mindbender",
- "default": "Avalon"
- },
- "AVALON_SENTRY": {
- "description": "Address to Sentry",
- "type": "string",
- "pattern": "^http[\\w/@:.]*$",
- "example": "https://5b872b280de742919b115bdc8da076a5:8d278266fe764361b8fa6024af004a9c@logs.mindbender.com/2",
- "default": null
- },
- "AVALON_DEADLINE": {
- "description": "Address to Deadline",
- "type": "string",
- "pattern": "^http[\\w/@:.]*$",
- "example": "http://192.168.99.101",
- "default": null
- },
- "AVALON_TIMEOUT": {
- "description": "Wherever there is a need for a timeout, this is the default value.",
- "type": "string",
- "pattern": "^[0-9]*$",
- "default": "1000",
- "example": "1000"
- },
- "AVALON_UPLOAD": {
- "description": "Boolean of whether to upload published material to central asset repository",
- "type": "string",
- "default": null,
- "example": "True"
- },
- "AVALON_USERNAME": {
- "description": "Generic username",
- "type": "string",
- "pattern": "^\\w*$",
- "default": "avalon",
- "example": "myself"
- },
- "AVALON_PASSWORD": {
- "description": "Generic password",
- "type": "string",
- "pattern": "^\\w*$",
- "default": "secret",
- "example": "abc123"
- },
- "AVALON_INSTANCE_ID": {
- "description": "Unique identifier for instances in a working file",
- "type": "string",
- "pattern": "^[\\w.]*$",
- "default": "avalon.instance",
- "example": "avalon.instance"
- },
- "AVALON_CONTAINER_ID": {
- "description": "Unique identifier for a loaded representation in a working file",
- "type": "string",
- "pattern": "^[\\w.]*$",
- "default": "avalon.container",
- "example": "avalon.container"
- },
- "AVALON_DEBUG": {
- "description": "Enable debugging mode. Some applications may use this for e.g. extended verbosity or mock plug-ins.",
- "type": "string",
- "default": null,
- "example": "True"
- }
- }
-}
diff --git a/schema/session-3.0.json b/schema/session-3.0.json
deleted file mode 100644
index 9f785939e4..0000000000
--- a/schema/session-3.0.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:session-3.0",
- "description": "The Avalon environment",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "AVALON_PROJECT",
- "AVALON_ASSET"
- ],
-
- "properties": {
- "AVALON_PROJECTS": {
- "description": "Absolute path to root of project directories",
- "type": "string",
- "example": "/nas/projects"
- },
- "AVALON_PROJECT": {
- "description": "Name of project",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "Hulk"
- },
- "AVALON_ASSET": {
- "description": "Name of asset",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "Bruce"
- },
- "AVALON_TASK": {
- "description": "Name of task",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "modeling"
- },
- "AVALON_APP": {
- "description": "Name of host",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "maya2016"
- },
- "AVALON_DB": {
- "description": "Name of database",
- "type": "string",
- "pattern": "^\\w*$",
- "example": "avalon",
- "default": "avalon"
- },
- "AVALON_LABEL": {
- "description": "Nice name of Avalon, used in e.g. graphical user interfaces",
- "type": "string",
- "example": "Mindbender",
- "default": "Avalon"
- },
- "AVALON_TIMEOUT": {
- "description": "Wherever there is a need for a timeout, this is the default value.",
- "type": "string",
- "pattern": "^[0-9]*$",
- "default": "1000",
- "example": "1000"
- },
- "AVALON_INSTANCE_ID": {
- "description": "Unique identifier for instances in a working file",
- "type": "string",
- "pattern": "^[\\w.]*$",
- "default": "avalon.instance",
- "example": "avalon.instance"
- },
- "AVALON_CONTAINER_ID": {
- "description": "Unique identifier for a loaded representation in a working file",
- "type": "string",
- "pattern": "^[\\w.]*$",
- "default": "avalon.container",
- "example": "avalon.container"
- }
- }
-}
diff --git a/schema/shaders-1.0.json b/schema/shaders-1.0.json
deleted file mode 100644
index 7102ba1861..0000000000
--- a/schema/shaders-1.0.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:shaders-1.0",
- "description": "Relationships between shaders and Avalon IDs",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "shader"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "shader": {
- "description": "Name of directory",
- "type": "array",
- "items": {
- "type": "str",
- "description": "Avalon ID and optional face indexes, e.g. 'f9520572-ac1d-11e6-b39e-3085a99791c9.f[5002:5185]'"
- }
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/subset-1.0.json b/schema/subset-1.0.json
deleted file mode 100644
index a299a6d341..0000000000
--- a/schema/subset-1.0.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:subset-1.0",
- "description": "A container of instances",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "name",
- "versions"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string"
- },
- "name": {
- "description": "Name of directory",
- "type": "string"
- },
- "versions": {
- "type": "array",
- "items": {
- "$ref": "version.json"
- }
- }
- },
-
- "definitions": {}
-}
diff --git a/schema/subset-2.0.json b/schema/subset-2.0.json
deleted file mode 100644
index db256ec7fb..0000000000
--- a/schema/subset-2.0.json
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:subset-2.0",
- "description": "A container of instances",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "parent",
- "name",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "The schema associated with this document",
- "type": "string",
- "enum": ["openpype:subset-2.0"],
- "example": "openpype:subset-2.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["subset"],
- "example": "subset"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of directory",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "shot01"
- },
- "data": {
- "type": "object",
- "description": "Document metadata",
- "example": {
- "frameStart": 1000,
- "frameEnd": 1201
- }
- }
- }
-}
diff --git a/schema/subset-3.0.json b/schema/subset-3.0.json
deleted file mode 100644
index 1a0db53c04..0000000000
--- a/schema/subset-3.0.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:subset-3.0",
- "description": "A container of instances",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "parent",
- "name",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "The schema associated with this document",
- "type": "string",
- "enum": ["openpype:subset-3.0"],
- "example": "openpype:subset-3.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["subset"],
- "example": "subset"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Name of directory",
- "type": "string",
- "pattern": "^[a-zA-Z0-9_.]*$",
- "example": "shot01"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "required": ["families"],
- "properties": {
- "families": {
- "type": "array",
- "items": {"type": "string"},
- "description": "One or more families associated with this subset"
- }
- },
- "example": {
- "families" : [
- "avalon.camera"
- ],
- "frameStart": 1000,
- "frameEnd": 1201
- }
- }
- }
-}
diff --git a/schema/thumbnail-1.0.json b/schema/thumbnail-1.0.json
deleted file mode 100644
index 5bdf78a4b1..0000000000
--- a/schema/thumbnail-1.0.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:thumbnail-1.0",
- "description": "Entity with thumbnail data",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "The schema associated with this document",
- "type": "string",
- "enum": ["openpype:thumbnail-1.0"],
- "example": "openpype:thumbnail-1.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["thumbnail"],
- "example": "thumbnail"
- },
- "data": {
- "description": "Thumbnail data",
- "type": "object",
- "example": {
- "binary_data": "Binary({byte data of image})",
- "template": "{thumbnail_root}/{project[name]}/{_id}{ext}}",
- "template_data": {
- "ext": ".jpg"
- }
- }
- }
- }
-}
diff --git a/schema/version-1.0.json b/schema/version-1.0.json
deleted file mode 100644
index daa1997721..0000000000
--- a/schema/version-1.0.json
+++ /dev/null
@@ -1,50 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:version-1.0",
- "description": "An individual version",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "version",
- "path",
- "time",
- "author",
- "source",
- "representations"
- ],
-
- "properties": {
- "schema": {"type": "string"},
- "representations": {
- "type": "array",
- "items": {
- "$ref": "representation.json"
- }
- },
- "time": {
- "description": "ISO formatted, file-system compatible time",
- "type": "string"
- },
- "author": {
- "description": "User logged on to the machine at time of publish",
- "type": "string"
- },
- "version": {
- "description": "Number of this version",
- "type": "number"
- },
- "path": {
- "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001",
- "type": "string"
- },
- "source": {
- "description": "Original file from which this version was made.",
- "type": "string"
- }
- }
-}
diff --git a/schema/version-2.0.json b/schema/version-2.0.json
deleted file mode 100644
index 099e9be70a..0000000000
--- a/schema/version-2.0.json
+++ /dev/null
@@ -1,92 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:version-2.0",
- "description": "An individual version",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "parent",
- "name",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "The schema associated with this document",
- "type": "string",
- "enum": ["openpype:version-2.0"],
- "example": "openpype:version-2.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["version"],
- "example": "version"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Number of version",
- "type": "number",
- "example": 12
- },
- "locations": {
- "description": "Where on the planet this version can be found.",
- "type": "array",
- "items": {"type": "string"},
- "example": ["data.avalon.com"]
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "required": ["families", "author", "source", "time"],
- "properties": {
- "time": {
- "description": "ISO formatted, file-system compatible time",
- "type": "string"
- },
- "timeFormat": {
- "description": "ISO format of time",
- "type": "string"
- },
- "author": {
- "description": "User logged on to the machine at time of publish",
- "type": "string"
- },
- "version": {
- "description": "Number of this version",
- "type": "number"
- },
- "path": {
- "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001",
- "type": "string"
- },
- "source": {
- "description": "Original file from which this version was made.",
- "type": "string"
- },
- "families": {
- "type": "array",
- "items": {"type": "string"},
- "description": "One or more families associated with this version"
- }
- },
- "example": {
- "source" : "{root}/f02_prod/assets/BubbleWitch/work/modeling/marcus/maya/scenes/model_v001.ma",
- "author" : "marcus",
- "families" : [
- "avalon.model"
- ],
- "time" : "20170510T090203Z"
- }
- }
- }
-}
diff --git a/schema/version-3.0.json b/schema/version-3.0.json
deleted file mode 100644
index 3e07fc4499..0000000000
--- a/schema/version-3.0.json
+++ /dev/null
@@ -1,84 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:version-3.0",
- "description": "An individual version",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "parent",
- "name",
- "data"
- ],
-
- "properties": {
- "schema": {
- "description": "The schema associated with this document",
- "type": "string",
- "enum": ["openpype:version-3.0"],
- "example": "openpype:version-3.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["version"],
- "example": "version"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "name": {
- "description": "Number of version",
- "type": "number",
- "example": 12
- },
- "locations": {
- "description": "Where on the planet this version can be found.",
- "type": "array",
- "items": {"type": "string"},
- "example": ["data.avalon.com"]
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "required": ["author", "source", "time"],
- "properties": {
- "time": {
- "description": "ISO formatted, file-system compatible time",
- "type": "string"
- },
- "timeFormat": {
- "description": "ISO format of time",
- "type": "string"
- },
- "author": {
- "description": "User logged on to the machine at time of publish",
- "type": "string"
- },
- "version": {
- "description": "Number of this version",
- "type": "number"
- },
- "path": {
- "description": "Unformatted path, e.g. '{root}/assets/Bruce/publish/lookdevDefault/v001",
- "type": "string"
- },
- "source": {
- "description": "Original file from which this version was made.",
- "type": "string"
- }
- },
- "example": {
- "source" : "{root}/f02_prod/assets/BubbleWitch/work/modeling/marcus/maya/scenes/model_v001.ma",
- "author" : "marcus",
- "time" : "20170510T090203Z"
- }
- }
- }
-}
diff --git a/schema/workfile-1.0.json b/schema/workfile-1.0.json
deleted file mode 100644
index 5f9600ef20..0000000000
--- a/schema/workfile-1.0.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-04/schema#",
-
- "title": "openpype:workfile-1.0",
- "description": "Workfile additional information.",
-
- "type": "object",
-
- "additionalProperties": true,
-
- "required": [
- "schema",
- "type",
- "filename",
- "task_name",
- "parent"
- ],
-
- "properties": {
- "schema": {
- "description": "Schema identifier for payload",
- "type": "string",
- "enum": ["openpype:workfile-1.0"],
- "example": "openpype:workfile-1.0"
- },
- "type": {
- "description": "The type of document",
- "type": "string",
- "enum": ["workfile"],
- "example": "workfile"
- },
- "parent": {
- "description": "Unique identifier to parent document",
- "example": "592c33475f8c1b064c4d1696"
- },
- "filename": {
- "description": "Workfile's filename",
- "type": "string",
- "example": "kuba_each_case_Alpaca_01_animation_v001.ma"
- },
- "task_name": {
- "description": "Task name",
- "type": "string",
- "example": "animation"
- },
- "data": {
- "description": "Document metadata",
- "type": "object",
- "example": {"key": "value"}
- }
- }
-}
diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py
index c012312579..69a759465e 100644
--- a/server_addon/core/server/settings/publish_plugins.py
+++ b/server_addon/core/server/settings/publish_plugins.py
@@ -116,6 +116,8 @@ class OIIOToolArgumentsModel(BaseSettingsModel):
class ExtractOIIOTranscodeOutputModel(BaseSettingsModel):
+ _layout = "expanded"
+ name: str = Field("", title="Name")
extension: str = Field("", title="Extension")
transcoding_type: str = Field(
"colorspace",
@@ -164,6 +166,11 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel):
title="Output Definitions",
)
+ @validator("outputs")
+ def validate_unique_outputs(cls, value):
+ ensure_unique_names(value)
+ return value
+
class ExtractOIIOTranscodeModel(BaseSettingsModel):
enabled: bool = Field(True)
diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py
index 4534d8d0d9..58240b0205 100644
--- a/server_addon/houdini/server/settings/publish_plugins.py
+++ b/server_addon/houdini/server/settings/publish_plugins.py
@@ -21,10 +21,28 @@ class CreateArnoldAssModel(BaseSettingsModel):
ext: str = Field(Title="Extension")
+class CreateStaticMeshModel(BaseSettingsModel):
+ enabled: bool = Field(title="Enabled")
+ default_variants: list[str] = Field(
+ default_factory=list,
+ title="Default Products"
+ )
+ static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix")
+ collision_prefixes: list[str] = Field(
+ default_factory=list,
+ title="Collision Prefixes"
+ )
+
+
class CreatePluginsModel(BaseSettingsModel):
CreateArnoldAss: CreateArnoldAssModel = Field(
default_factory=CreateArnoldAssModel,
title="Create Alembic Camera")
+ # "-" is not compatible in the new model
+ CreateStaticMesh: CreateStaticMeshModel = Field(
+ default_factory=CreateStaticMeshModel,
+ title="Create Static Mesh"
+ )
CreateAlembicCamera: CreatorModel = Field(
default_factory=CreatorModel,
title="Create Alembic Camera")
@@ -63,6 +81,19 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = {
"default_variants": ["Main"],
"ext": ".ass"
},
+ "CreateStaticMesh": {
+ "enabled": True,
+ "default_variants": [
+ "Main"
+ ],
+ "static_mesh_prefix": "S",
+ "collision_prefixes": [
+ "UBX",
+ "UCP",
+ "USP",
+ "UCX"
+ ]
+ },
"CreateAlembicCamera": {
"enabled": True,
"default_variants": ["Main"]
@@ -136,6 +167,15 @@ class PublishPluginsModel(BaseSettingsModel):
ValidateContainers: BasicValidateModel = Field(
default_factory=BasicValidateModel,
title="Validate Latest Containers.")
+ ValidateSubsetName: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Subset Name.")
+ ValidateMeshIsStatic: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Mesh is Static.")
+ ValidateUnrealStaticMeshName: BasicValidateModel = Field(
+ default_factory=BasicValidateModel,
+ title="Validate Unreal Static Mesh Name.")
DEFAULT_HOUDINI_PUBLISH_SETTINGS = {
@@ -160,5 +200,20 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = {
"enabled": True,
"optional": True,
"active": True
+ },
+ "ValidateSubsetName": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateMeshIsStatic": {
+ "enabled": True,
+ "optional": True,
+ "active": True
+ },
+ "ValidateUnrealStaticMeshName": {
+ "enabled": False,
+ "optional": True,
+ "active": True
}
}
diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py
index b3f4756216..ae7362549b 100644
--- a/server_addon/houdini/server/version.py
+++ b/server_addon/houdini/server/version.py
@@ -1 +1 @@
-__version__ = "0.1.2"
+__version__ = "0.1.3"
diff --git a/setup.cfg b/setup.cfg
index 10cca3eb3f..ead9b25164 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,4 +28,11 @@ omit = /tests
directory = ./coverage
[tool:pytest]
-norecursedirs = repos/* openpype/modules/ftrack/*
\ No newline at end of file
+norecursedirs = openpype/modules/ftrack/*
+
+[isort]
+line_length = 79
+multi_line_output = 3
+include_trailing_comma = True
+force_grid_wrap = 0
+combine_as_imports = True
diff --git a/setup.py b/setup.py
index 4b6f286730..6179de1d34 100644
--- a/setup.py
+++ b/setup.py
@@ -124,7 +124,6 @@ bin_includes = [
include_files = [
"igniter",
"openpype",
- "schema",
"LICENSE",
"README.md"
]
diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md
index 5ddf247d98..e0481a8717 100644
--- a/website/docs/project_settings/settings_project_global.md
+++ b/website/docs/project_settings/settings_project_global.md
@@ -192,7 +192,7 @@ A profile may generate multiple outputs from a single input. Each output must de
- Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags`
- Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input.
-
+
### Extract Burnin
Plugin is responsible for adding burnins into review representations.
@@ -226,13 +226,13 @@ A burnin profile may set multiple burnin outputs from one input. The burnin's na
| **Bottom Centered** | Bottom center content. | str | "{username}" |
| **Bottom Right** | Bottom right corner content. | str | "{frame_start}-{current_frame}-{frame_end}" |
-Each burnin profile can be configured with additional family filtering and can
-add additional tags to the burnin representation, these can be configured under
+Each burnin profile can be configured with additional family filtering and can
+add additional tags to the burnin representation, these can be configured under
the profile's **Additional filtering** section.
:::note Filename suffix
-The filename suffix is appended to filename of the source representation. For
-example, if the source representation has suffix **"h264"** and the burnin
+The filename suffix is appended to filename of the source representation. For
+example, if the source representation has suffix **"h264"** and the burnin
suffix is **"client"** then the final suffix is **"h264_client"**.
:::
@@ -343,6 +343,10 @@ One of the key advantages of this feature is that it allows users to choose the
In some cases, these DCCs (Nuke, Houdini, Maya) automatically add a rendering path during the creation stage, which is then used in publishing. Creators and extractors of such DCCs need to use these profiles to fill paths in DCC's nodes to use this functionality.
+:::note
+Maya's setting `project_settings/maya/RenderSettings/default_render_image_folder` is be overwritten by the custom staging dir.
+:::
+
The custom staging folder uses a path template configured in `project_anatomy/templates/others` with `transient` being a default example path that could be used. The template requires a 'folder' key for it to be usable as custom staging folder.
##### Known issues