From 803fb616492f5e34feec7a3a3bdd7e7599dcc8d8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 22:31:27 +0800 Subject: [PATCH 01/45] validate loaded plugins tweaks in 3dsmax --- .../plugins/publish/validate_loaded_plugin.py | 69 +++++++++++++++++++ .../plugins/publish/validate_usd_plugin.py | 49 ------------- .../defaults/project_settings/max.json | 5 ++ .../schemas/schema_max_publish.json | 25 +++++++ .../max/server/settings/publishers.py | 18 ++++- server_addon/max/server/version.py | 2 +- 6 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_loaded_plugin.py delete mode 100644 openpype/hosts/max/plugins/publish/validate_usd_plugin.py diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py new file mode 100644 index 0000000000..10cbdf22fb --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""Validator for USD plugin.""" +from pyblish.api import InstancePlugin, ValidatorOrder +from pymxs import runtime as rt + +from openpype.pipeline.publish import ( + RepairAction, + OptionalPyblishPluginMixin, + PublishValidationError +) +from openpype.hosts.max.api.lib import get_plugins + + +class ValidateLoadedPlugin(OptionalPyblishPluginMixin, + InstancePlugin): + """Validates if the specific plugin is loaded in 3ds max. + User can add the plugins they want to check through""" + + order = ValidatorOrder + hosts = ["max"] + label = "Validate Loaded Plugin" + optional = True + actions = [RepairAction] + + def get_invalid(self, instance): + """Plugin entry point.""" + invalid = [] + # display all DLL loaded plugins in Max + plugin_info = get_plugins() + project_settings = instance.context.data[ + "project_settings"]["max"]["publish"] + target_plugins = project_settings[ + "ValidateLoadedPlugin"]["plugins_for_check"] + for plugin in target_plugins: + if plugin.lower() not in plugin_info: + invalid.append( + f"Plugin {plugin} not exists in 3dsMax Plugin List.") + for i, _ in enumerate(plugin_info): + if plugin.lower() == rt.pluginManager.pluginDllName(i): + if not rt.pluginManager.isPluginDllLoaded(i): + invalid.append( + f"Plugin {plugin} not loaded.") + return invalid + + def process(self, instance): + invalid_plugins = self.get_invalid(instance) + if invalid_plugins: + bullet_point_invalid_statement = "\n".join( + "- {}".format(invalid) for invalid in invalid_plugins + ) + report = ( + "Required plugins fails to load.\n\n" + f"{bullet_point_invalid_statement}\n\n" + "You can use repair action to load the plugin." + ) + raise PublishValidationError(report, title="Required Plugins unloaded") + + @classmethod + def repair(cls, instance): + plugin_info = get_plugins() + project_settings = instance.context.data[ + "project_settings"]["max"]["publish"] + target_plugins = project_settings[ + "ValidateLoadedPlugin"]["plugins_for_check"] + for plugin in target_plugins: + for i, _ in enumerate(plugin_info): + if plugin == rt.pluginManager.pluginDllName(i): + if not rt.pluginManager.isPluginDllLoaded(i): + rt.pluginManager.loadPluginDll(i) diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py deleted file mode 100644 index 36c4291925..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator for USD plugin.""" -from pyblish.api import InstancePlugin, ValidatorOrder -from pymxs import runtime as rt - -from openpype.pipeline import ( - OptionalPyblishPluginMixin, - PublishValidationError -) - - -def get_plugins() -> list: - """Get plugin list from 3ds max.""" - manager = rt.PluginManager - count = manager.pluginDllCount - plugin_info_list = [] - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list - - -class ValidateUSDPlugin(OptionalPyblishPluginMixin, - InstancePlugin): - """Validates if USD plugin is installed or loaded in 3ds max.""" - - order = ValidatorOrder - 0.01 - families = ["model"] - hosts = ["max"] - label = "Validate USD Plugin loaded" - optional = True - - def process(self, instance): - """Plugin entry point.""" - - for sc in ValidateUSDPlugin.__subclasses__(): - self.log.info(sc) - - if not self.is_active(instance.data): - return - - plugin_info = get_plugins() - usd_import = "usdimport.dli" - if usd_import not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_import} not found") - usd_export = "usdexport.dle" - if usd_export not in plugin_info: - raise PublishValidationError(f"USD Plugin {usd_export} not found") diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..45246fdf2b 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -36,6 +36,11 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateLoadedPlugin": { + "enabled": false, + "optional": true, + "plugins_for_check": [] } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index ea08c735a6..4490c5353d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -28,6 +28,31 @@ "label": "Active" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateLoadedPlugin", + "label": "Validate Loaded Plugin", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "list", + "key": "plugins_for_check", + "label": "Plugins Needed For Check", + "object_type": "text" + } + ] } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a695b85e89..8a28224a07 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -3,6 +3,14 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel +class ValidateLoadedPluginModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateLoadedPlugin") + optional: bool = Field(title="Optional") + plugins_for_check: list[str] = Field( + default_factory=list, title="Plugins Needed For Check" + ) + + class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") @@ -15,12 +23,20 @@ class PublishersModel(BaseSettingsModel): title="Validate Frame Range", section="Validators" ) - + ValidateLoadedPlugin: ValidateLoadedPluginModel = Field( + default_factory=ValidateLoadedPluginModel, + title="Validate Loaded Plugin" + ) DEFAULT_PUBLISH_SETTINGS = { "ValidateFrameRange": { "enabled": True, "optional": True, "active": True + }, + "ValidateLoadedPlugin": { + "enabled": False, + "optional": True, + "plugins_for_check": [] } } diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" From 77776d0943ae8750876f98521a9e493dbcdf584e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 25 Oct 2023 22:53:28 +0800 Subject: [PATCH 02/45] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 10cbdf22fb..44343bada2 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -53,7 +53,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, f"{bullet_point_invalid_statement}\n\n" "You can use repair action to load the plugin." ) - raise PublishValidationError(report, title="Required Plugins unloaded") + raise PublishValidationError( + report, title="Required Plugins unloaded") @classmethod def repair(cls, instance): From a43b842097b48924345cb8be98f8ca380a2b73a5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 15:49:28 +0800 Subject: [PATCH 03/45] add missing codes for switching on/off the loaded plugin validator --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 44343bada2..0090c69269 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -24,6 +24,9 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, def get_invalid(self, instance): """Plugin entry point.""" + if not self.is_active(instance.data): + self.log.debug("Skipping Validate Loaded Plugin...") + return invalid = [] # display all DLL loaded plugins in Max plugin_info = get_plugins() From 5d87d08ab83b81815e6bc47bddcd7300a30fcc60 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:04:00 +0800 Subject: [PATCH 04/45] clean up the code of validate loaded plugins --- .../plugins/publish/validate_loaded_plugin.py | 77 +++++++++++-------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 0090c69269..a8bdf7f903 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -"""Validator for USD plugin.""" -from pyblish.api import InstancePlugin, ValidatorOrder +"""Validator for Loaded Plugin.""" +from pyblish.api import ContextPlugin, ValidatorOrder from pymxs import runtime as rt from openpype.pipeline.publish import ( - RepairAction, + RepairContextAction, OptionalPyblishPluginMixin, PublishValidationError ) @@ -12,7 +12,7 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - InstancePlugin): + ContextPlugin): """Validates if the specific plugin is loaded in 3ds max. User can add the plugins they want to check through""" @@ -20,29 +20,38 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, hosts = ["max"] label = "Validate Loaded Plugin" optional = True - actions = [RepairAction] + actions = [RepairContextAction] - def get_invalid(self, instance): + def get_invalid(self, context): """Plugin entry point.""" - if not self.is_active(instance.data): + if not self.is_active(context.data): self.log.debug("Skipping Validate Loaded Plugin...") return invalid = [] - # display all DLL loaded plugins in Max - plugin_info = get_plugins() - project_settings = instance.context.data[ - "project_settings"]["max"]["publish"] - target_plugins = project_settings[ - "ValidateLoadedPlugin"]["plugins_for_check"] - for plugin in target_plugins: - if plugin.lower() not in plugin_info: + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate(\ + get_plugins()) + } + required_plugins = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["plugins_for_check"] + ) + for plugin in required_plugins: + plugin_name = plugin.lower() + + plugin_index = available_plugins.get(plugin_name) + + if plugin_index is None: invalid.append( - f"Plugin {plugin} not exists in 3dsMax Plugin List.") - for i, _ in enumerate(plugin_info): - if plugin.lower() == rt.pluginManager.pluginDllName(i): - if not rt.pluginManager.isPluginDllLoaded(i): - invalid.append( - f"Plugin {plugin} not loaded.") + f"Plugin {plugin} not exists in 3dsMax Plugin List." + ) + continue + + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + invalid.append( + f"Plugin {plugin} not loaded.") + return invalid def process(self, instance): @@ -60,14 +69,18 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, report, title="Required Plugins unloaded") @classmethod - def repair(cls, instance): - plugin_info = get_plugins() - project_settings = instance.context.data[ - "project_settings"]["max"]["publish"] - target_plugins = project_settings[ - "ValidateLoadedPlugin"]["plugins_for_check"] - for plugin in target_plugins: - for i, _ in enumerate(plugin_info): - if plugin == rt.pluginManager.pluginDllName(i): - if not rt.pluginManager.isPluginDllLoaded(i): - rt.pluginManager.loadPluginDll(i) + def repair(cls, context): + # get all DLL loaded plugins in Max and their plugin index + available_plugins = { + plugin_name.lower(): index for index, plugin_name in enumerate( + get_plugins()) + } + required_plugins = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["plugins_for_check"] + ) + for plugin in required_plugins: + plugin_name = plugin.lower() + plugin_index = available_plugins.get(plugin_name) + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) From 6c24e55d9697bd28d6d7e7cac813f2d0756aa6a8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:05:10 +0800 Subject: [PATCH 05/45] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index a8bdf7f903..564cfd0e67 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -30,7 +30,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, invalid = [] # get all DLL loaded plugins in Max and their plugin index available_plugins = { - plugin_name.lower(): index for index, plugin_name in enumerate(\ + plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } required_plugins = ( From b0a12848b92a825e7d979fa9c63416310ec6d528 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:47:40 +0800 Subject: [PATCH 06/45] clean up code and add condition to make sure the plugin not erroring out during validation --- .../plugins/publish/validate_loaded_plugin.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 564cfd0e67..49f0f3041b 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -18,7 +18,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, order = ValidatorOrder hosts = ["max"] - label = "Validate Loaded Plugin" + label = "Validate Loaded Plugins" optional = True actions = [RepairContextAction] @@ -27,16 +27,23 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not self.is_active(context.data): self.log.debug("Skipping Validate Loaded Plugin...") return + + required_plugins = ( + context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["plugins_for_check"] + ) + + if not required_plugins: + return + invalid = [] + # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - required_plugins = ( - context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["plugins_for_check"] - ) + for plugin in required_plugins: plugin_name = plugin.lower() @@ -49,8 +56,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, continue if not rt.pluginManager.isPluginDllLoaded(plugin_index): - invalid.append( - f"Plugin {plugin} not loaded.") + invalid.append(f"Plugin {plugin} not loaded.") return invalid @@ -82,5 +88,10 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, for plugin in required_plugins: plugin_name = plugin.lower() plugin_index = available_plugins.get(plugin_name) + + if plugin_index is None: + cls.log.warning(f"Can't enable missing plugin: {plugin}") + continue + if not rt.pluginManager.isPluginDllLoaded(plugin_index): rt.pluginManager.loadPluginDll(plugin_index) From a8c4c05b7329b6ccf22309f14aa3990f18b96844 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 17:58:03 +0800 Subject: [PATCH 07/45] Docstring edit --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 49f0f3041b..9602d0f313 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -14,7 +14,8 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, ContextPlugin): """Validates if the specific plugin is loaded in 3ds max. - User can add the plugins they want to check through""" + Studio Admin(s) can add the plugins they want to check in validation + via studio defined project settings""" order = ValidatorOrder hosts = ["max"] From ca2ff805910510a6ecf75e0ae233b8b818665924 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 Oct 2023 12:43:13 +0200 Subject: [PATCH 08/45] nuke: updating colorspace defaults --- .../defaults/project_settings/nuke.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 1cadedd797..20df0ad5c2 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -19,16 +19,16 @@ "rules": {} }, "viewer": { - "viewerProcess": "sRGB" + "viewerProcess": "sRGB (default)" }, "baking": { - "viewerProcess": "rec709" + "viewerProcess": "rec709 (default)" }, "workfile": { - "colorManagement": "Nuke", + "colorManagement": "OCIO", "OCIO_config": "nuke-default", - "workingSpaceLUT": "linear", - "monitorLut": "sRGB" + "workingSpaceLUT": "scene_linear", + "monitorLut": "sRGB (default)" }, "nodes": { "requiredNodes": [ @@ -76,7 +76,7 @@ { "type": "text", "name": "colorspace", - "value": "linear" + "value": "scene_linear" }, { "type": "bool", @@ -129,7 +129,7 @@ { "type": "text", "name": "colorspace", - "value": "linear" + "value": "scene_linear" }, { "type": "bool", @@ -177,7 +177,7 @@ { "type": "text", "name": "colorspace", - "value": "sRGB" + "value": "texture_paint" }, { "type": "bool", @@ -193,7 +193,7 @@ "inputs": [ { "regex": "(beauty).*(?=.exr)", - "colorspace": "linear" + "colorspace": "scene_linear" } ] } From 1ecd96acf6acb98f9fb27aa70a345ad5014343b9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 26 Oct 2023 21:18:28 +0800 Subject: [PATCH 09/45] use context.data instead of instance data --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 9602d0f313..69f72ccf1d 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -61,8 +61,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, return invalid - def process(self, instance): - invalid_plugins = self.get_invalid(instance) + def process(self, context): + invalid_plugins = self.get_invalid(context) if invalid_plugins: bullet_point_invalid_statement = "\n".join( "- {}".format(invalid) for invalid in invalid_plugins From ae2c4bd5548c6ba81e7937320b0b91554ea614cd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 26 Oct 2023 17:09:34 +0200 Subject: [PATCH 10/45] nuke: aligning server addon settings with openpype --- server_addon/nuke/server/settings/imageio.py | 16 ++++++++-------- server_addon/nuke/server/version.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index 15ccd4e89a..19ad5ff24a 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -213,16 +213,16 @@ class ImageIOSettings(BaseSettingsModel): DEFAULT_IMAGEIO_SETTINGS = { "viewer": { - "viewerProcess": "sRGB" + "viewerProcess": "sRGB (default)" }, "baking": { - "viewerProcess": "rec709" + "viewerProcess": "rec709 (default)" }, "workfile": { - "color_management": "Nuke", + "color_management": "OCIO", "native_ocio_config": "nuke-default", - "working_space": "linear", - "thumbnail_space": "sRGB", + "working_space": "scene_linear", + "thumbnail_space": "sRGB (default)", }, "nodes": { "required_nodes": [ @@ -269,7 +269,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "linear" + "text": "scene_linear" }, { "type": "boolean", @@ -321,7 +321,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "linear" + "text": "scene_linear" }, { "type": "boolean", @@ -368,7 +368,7 @@ DEFAULT_IMAGEIO_SETTINGS = { { "type": "text", "name": "colorspace", - "text": "sRGB" + "text": "texture_paint" }, { "type": "boolean", diff --git a/server_addon/nuke/server/version.py b/server_addon/nuke/server/version.py index bbab0242f6..1276d0254f 100644 --- a/server_addon/nuke/server/version.py +++ b/server_addon/nuke/server/version.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5" From 881340b60a1dd2eba9d76331082679b9e26e6df9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:27:22 +0800 Subject: [PATCH 11/45] supports families check before the validation of loaded plugins --- .../plugins/publish/validate_loaded_plugin.py | 59 +++++++++++++------ .../plugins/publish/collect_scene_version.py | 1 + openpype/settings/ayon_settings.py | 13 ++++ .../defaults/project_settings/max.json | 2 +- .../schemas/schema_max_publish.json | 12 ++-- .../max/server/settings/publishers.py | 12 +++- 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 69f72ccf1d..e8284aeedd 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- """Validator for Loaded Plugin.""" -from pyblish.api import ContextPlugin, ValidatorOrder +import os +from pyblish.api import InstancePlugin, ValidatorOrder from pymxs import runtime as rt from openpype.pipeline.publish import ( - RepairContextAction, + RepairAction, OptionalPyblishPluginMixin, PublishValidationError ) @@ -12,7 +13,7 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - ContextPlugin): + InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings""" @@ -21,17 +22,17 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, hosts = ["max"] label = "Validate Loaded Plugins" optional = True - actions = [RepairContextAction] + actions = [RepairAction] - def get_invalid(self, context): + def get_invalid(self, instance): """Plugin entry point.""" - if not self.is_active(context.data): + if not self.is_active(instance.data): self.log.debug("Skipping Validate Loaded Plugin...") return required_plugins = ( - context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["plugins_for_check"] + instance.context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["family_plugins_mapping"] ) if not required_plugins: @@ -45,9 +46,21 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, get_plugins()) } - for plugin in required_plugins: - plugin_name = plugin.lower() + for families, plugin in required_plugins.items(): + families_list = families.split(",") + excluded_families = [family for family in families_list + if instance.data["family"]!=family + and family!="_"] + if excluded_families: + self.log.debug("The {} instance is not part of {}.".format( + instance.data["family"], excluded_families + )) + return + if not plugin: + return + + plugin_name = plugin.format(**os.environ).lower() plugin_index = available_plugins.get(plugin_name) if plugin_index is None: @@ -61,8 +74,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, return invalid - def process(self, context): - invalid_plugins = self.get_invalid(context) + def process(self, instance): + invalid_plugins = self.get_invalid(instance) if invalid_plugins: bullet_point_invalid_statement = "\n".join( "- {}".format(invalid) for invalid in invalid_plugins @@ -76,18 +89,30 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, report, title="Required Plugins unloaded") @classmethod - def repair(cls, context): + def repair(cls, instance): # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } required_plugins = ( - context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["plugins_for_check"] + instance.context.data["project_settings"]["max"]["publish"] + ["ValidateLoadedPlugin"]["family_plugins_mapping"] ) - for plugin in required_plugins: - plugin_name = plugin.lower() + for families, plugin in required_plugins.items(): + families_list = families.split(",") + excluded_families = [family for family in families_list + if instance.data["family"]!=family + and family!="_"] + if excluded_families: + cls.log.debug("The {} instance is not part of {}.".format( + instance.data["family"], excluded_families + )) + continue + if not plugin: + continue + + plugin_name = plugin.format(**os.environ).lower() plugin_index = available_plugins.get(plugin_name) if plugin_index is None: diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 7920c1e82b..f870ae9ad7 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -24,6 +24,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "hiero", "houdini", "maya", + "max", "nuke", "photoshop", "resolve", diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..0cc2abdda4 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -640,6 +640,19 @@ def _convert_3dsmax_project_settings(ayon_settings, output): } ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + ayon_publish = ayon_max["publish"] + if "ValidateLoadedPlugin" in ayon_publish: + family_plugin_mapping = ( + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] + ) + new_family_plugin_mapping = { + item["families"]: item["plugins"] + for item in family_plugin_mapping + } + ayon_max["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( + new_family_plugin_mapping + ) + output["max"] = ayon_max diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index 45246fdf2b..78eba08750 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -40,7 +40,7 @@ "ValidateLoadedPlugin": { "enabled": false, "optional": true, - "plugins_for_check": [] + "family_plugins_mapping": {} } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index 4490c5353d..74c06f8156 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -47,10 +47,14 @@ "label": "Optional" }, { - "type": "list", - "key": "plugins_for_check", - "label": "Plugins Needed For Check", - "object_type": "text" + "type": "dict-modifiable", + "collapsible": true, + "key": "family_plugins_mapping", + "label": "Family Plugins Mapping", + "use_label_wrap": true, + "object_type": { + "type": "text" + } } ] } diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index 8a28224a07..3cf3ecf2a5 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -3,11 +3,17 @@ from pydantic import Field from ayon_server.settings import BaseSettingsModel +class FamilyPluginsMappingModel(BaseSettingsModel): + _layout = "compact" + families: str = Field(title="Families") + plugins: str = Field(title="Plugins") + + class ValidateLoadedPluginModel(BaseSettingsModel): enabled: bool = Field(title="ValidateLoadedPlugin") optional: bool = Field(title="Optional") - plugins_for_check: list[str] = Field( - default_factory=list, title="Plugins Needed For Check" + family_plugins_mapping: list[FamilyPluginsMappingModel] = Field( + default_factory=list, title="Family Plugins Mapping" ) @@ -37,6 +43,6 @@ DEFAULT_PUBLISH_SETTINGS = { "ValidateLoadedPlugin": { "enabled": False, "optional": True, - "plugins_for_check": [] + "family_plugins_mapping": {} } } From 8d727a9b80922bb07b468f694ab4f57f69945926 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:30:38 +0800 Subject: [PATCH 12/45] hound --- .../max/plugins/publish/validate_loaded_plugin.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index e8284aeedd..dc82c7ed65 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -32,7 +32,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, required_plugins = ( instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["family_plugins_mapping"] + ["ValidateLoadedPlugin"] + ["family_plugins_mapping"] ) if not required_plugins: @@ -49,8 +50,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, for families, plugin in required_plugins.items(): families_list = families.split(",") excluded_families = [family for family in families_list - if instance.data["family"]!=family - and family!="_"] + if instance.data["family"] != family + and family != "_"] if excluded_families: self.log.debug("The {} instance is not part of {}.".format( instance.data["family"], excluded_families @@ -97,13 +98,14 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, } required_plugins = ( instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"]["family_plugins_mapping"] + ["ValidateLoadedPlugin"] + ["family_plugins_mapping"] ) for families, plugin in required_plugins.items(): families_list = families.split(",") excluded_families = [family for family in families_list - if instance.data["family"]!=family - and family!="_"] + if instance.data["family"] != family + and family != "_"] if excluded_families: cls.log.debug("The {} instance is not part of {}.".format( instance.data["family"], excluded_families From 1aef9dc449b525313760db6e3d7e86378e6f1ab9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Nov 2023 12:45:48 +0800 Subject: [PATCH 13/45] make sure the validator can be loaded in AYON --- openpype/settings/ayon_settings.py | 2 +- server_addon/max/server/settings/publishers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 0cc2abdda4..4fe19c95a2 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -649,7 +649,7 @@ def _convert_3dsmax_project_settings(ayon_settings, output): item["families"]: item["plugins"] for item in family_plugin_mapping } - ayon_max["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( new_family_plugin_mapping ) diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index 3cf3ecf2a5..d0fbb3d552 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -43,6 +43,6 @@ DEFAULT_PUBLISH_SETTINGS = { "ValidateLoadedPlugin": { "enabled": False, "optional": True, - "family_plugins_mapping": {} + "family_plugins_mapping": [] } } From 2d3ae5a0d346a95586abde0a11632291e8c83467 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 22:02:13 +0800 Subject: [PATCH 14/45] supports checking the required plugins with families type * --- .../plugins/publish/validate_loaded_plugin.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index dc82c7ed65..d6f849a57e 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -16,7 +16,11 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation - via studio defined project settings""" + via studio defined project settings + If families = ["*"], all the required plugins would be validated + If families + + """ order = ValidatorOrder hosts = ["max"] @@ -48,15 +52,17 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, } for families, plugin in required_plugins.items(): - families_list = families.split(",") - excluded_families = [family for family in families_list - if instance.data["family"] != family - and family != "_"] - if excluded_families: - self.log.debug("The {} instance is not part of {}.".format( - instance.data["family"], excluded_families - )) - return + # Out of for loop build the instance family lookup + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + self.log.debug(f"{instance_families}") + # In the for loop check whether any family matches + match_families = {fam.strip() for fam in families.split(",") if fam.strip()} + self.log.debug(f"match_families: {match_families}") + has_match = "*" in match_families or match_families.intersection( + instance_families) or families == "_" + if not has_match: + continue if not plugin: return @@ -66,7 +72,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if plugin_index is None: invalid.append( - f"Plugin {plugin} not exists in 3dsMax Plugin List." + f"Plugin {plugin} does not exist in 3dsMax Plugin List." ) continue @@ -82,12 +88,12 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, "- {}".format(invalid) for invalid in invalid_plugins ) report = ( - "Required plugins fails to load.\n\n" + "Required plugins are not loaded.\n\n" f"{bullet_point_invalid_statement}\n\n" "You can use repair action to load the plugin." ) raise PublishValidationError( - report, title="Required Plugins unloaded") + report, title="Missing Required Plugins") @classmethod def repair(cls, instance): From e61d03556a1a7d915a6cd421c6a0ec45d40c8481 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 3 Nov 2023 22:03:00 +0800 Subject: [PATCH 15/45] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index d6f849a57e..e58685cc4d 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -57,7 +57,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, instance_families.update(instance.data.get("families", [])) self.log.debug(f"{instance_families}") # In the for loop check whether any family matches - match_families = {fam.strip() for fam in families.split(",") if fam.strip()} + match_families = {fam.strip() for fam in + families.split(",") if fam.strip()} self.log.debug(f"match_families: {match_families}") has_match = "*" in match_families or match_families.intersection( instance_families) or families == "_" From b079ca8d0f76ede60866dcb26f506189cbfcea9e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 6 Nov 2023 15:41:20 +0800 Subject: [PATCH 16/45] clean up the duplicated variable --- openpype/settings/ayon_settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 88fbbd5124..fa73199269 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -649,7 +649,6 @@ def _convert_3dsmax_project_settings(ayon_settings, output): attributes = {} ayon_publish["ValidateAttributes"]["attributes"] = attributes - ayon_publish = ayon_max["publish"] if "ValidateLoadedPlugin" in ayon_publish: family_plugin_mapping = ( ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] From cfc54439095140f2b6e2d84adc9ebfa09e96a4d3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 16:35:28 +0800 Subject: [PATCH 17/45] clean up code tweaks for OP settings(not suitable for ayon yet) --- .../plugins/publish/validate_loaded_plugin.py | 82 ++++++++++--------- openpype/settings/ayon_settings.py | 3 + .../schemas/schema_max_publish.json | 10 ++- .../max/server/settings/publishers.py | 5 +- 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index e58685cc4d..8d59bbc120 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -51,34 +51,36 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, get_plugins()) } - for families, plugin in required_plugins.items(): - # Out of for loop build the instance family lookup - instance_families = {instance.data["family"]} - instance_families.update(instance.data.get("families", [])) - self.log.debug(f"{instance_families}") - # In the for loop check whether any family matches + # Build instance families lookup + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + self.log.debug(f"Checking plug-in validation for instance families: {instance_families}") + for families in required_plugins.keys(): + # Check for matching families match_families = {fam.strip() for fam in families.split(",") if fam.strip()} - self.log.debug(f"match_families: {match_families}") + self.log.debug(f"Plug-in family requirements: {match_families}") has_match = "*" in match_families or match_families.intersection( - instance_families) or families == "_" + instance_families) + if not has_match: continue - if not plugin: - return + plugins = [plugin for plugin in required_plugins[families]["plugins"]] + for plugin in plugins: + if not plugin: + return + plugin_name = plugin.format(**os.environ).lower() + plugin_index = available_plugins.get(plugin_name) - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) + if plugin_index is None: + invalid.append( + f"Plugin {plugin} does not exist in 3dsMax Plugin List." + ) + continue - if plugin_index is None: - invalid.append( - f"Plugin {plugin} does not exist in 3dsMax Plugin List." - ) - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - invalid.append(f"Plugin {plugin} not loaded.") + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + invalid.append(f"Plugin {plugin} not loaded.") return invalid @@ -108,25 +110,29 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, ["ValidateLoadedPlugin"] ["family_plugins_mapping"] ) - for families, plugin in required_plugins.items(): - families_list = families.split(",") - excluded_families = [family for family in families_list - if instance.data["family"] != family - and family != "_"] - if excluded_families: - cls.log.debug("The {} instance is not part of {}.".format( - instance.data["family"], excluded_families - )) - continue - if not plugin: + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + cls.log.debug(f"Checking plug-in validation for instance families: {instance_families}") + for families in required_plugins.keys(): + match_families = {fam.strip() for fam in + families.split(",") if fam.strip()} + cls.log.debug(f"Plug-in family requirements: {match_families}") + has_match = "*" in match_families or match_families.intersection( + instance_families) + + if not has_match: continue - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) + plugins = [plugin for plugin in required_plugins[families]["plugins"]] + for plugin in plugins: + if not plugin: + return + plugin_name = plugin.format(**os.environ).lower() + plugin_index = available_plugins.get(plugin_name) - if plugin_index is None: - cls.log.warning(f"Can't enable missing plugin: {plugin}") - continue + if plugin_index is None: + cls.log.warning(f"Can't enable missing plugin: {plugin}") + continue - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - rt.pluginManager.loadPluginDll(plugin_index) + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index fa73199269..0cefd047b1 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -653,6 +653,9 @@ def _convert_3dsmax_project_settings(ayon_settings, output): family_plugin_mapping = ( ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] ) + for item in family_plugin_mapping: + if "product_types" in item: + item["families"] = item.pop("product_types") new_family_plugin_mapping = { item["families"]: item["plugins"] for item in family_plugin_mapping diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index b48ce20f5d..c44c7525da 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -72,7 +72,15 @@ "label": "Family Plugins Mapping", "use_label_wrap": true, "object_type": { - "type": "text" + "type": "dict", + "children": [ + { + "key": "plugins", + "label": "plugins", + "type": "list", + "object_type": "text" + } + ] } } ] diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index d23acc6dd7..d7169f8b96 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -29,8 +29,9 @@ class ValidateAttributesModel(BaseSettingsModel): class FamilyPluginsMappingModel(BaseSettingsModel): _layout = "compact" - families: str = Field(title="Families") - plugins: str = Field(title="Plugins") + product_types: str = Field(title="Product Types") + plugins: list[str] = Field( + default_factory=list,title="Plugins") class ValidateLoadedPluginModel(BaseSettingsModel): From d15dfaaf849d51c49d9a0e14d5be8f1c6f85ea2d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 18:47:42 +0800 Subject: [PATCH 18/45] hound & code tweak regarding to OP setting --- .../plugins/publish/validate_loaded_plugin.py | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 8d59bbc120..d348e37abc 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Validator for Loaded Plugin.""" import os -from pyblish.api import InstancePlugin, ValidatorOrder +import pyblish.api from pymxs import runtime as rt from openpype.pipeline.publish import ( @@ -13,7 +13,7 @@ from openpype.hosts.max.api.lib import get_plugins class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - InstancePlugin): + pyblish.api.InstancePlugin): """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings @@ -22,24 +22,21 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, """ - order = ValidatorOrder + order = pyblish.api.ValidatorOrder hosts = ["max"] label = "Validate Loaded Plugins" optional = True actions = [RepairAction] + family_plugins_mapping = {} + def get_invalid(self, instance): """Plugin entry point.""" if not self.is_active(instance.data): self.log.debug("Skipping Validate Loaded Plugin...") return - required_plugins = ( - instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"] - ["family_plugins_mapping"] - ) - + required_plugins = self.family_plugins_mapping if not required_plugins: return @@ -54,11 +51,12 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, # Build instance families lookup instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) - self.log.debug(f"Checking plug-in validation for instance families: {instance_families}") - for families in required_plugins.keys(): + self.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + for family in required_plugins: # Check for matching families match_families = {fam.strip() for fam in - families.split(",") if fam.strip()} + family.split(",") if fam.strip()} self.log.debug(f"Plug-in family requirements: {match_families}") has_match = "*" in match_families or match_families.intersection( instance_families) @@ -66,7 +64,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in required_plugins[families]["plugins"]] + plugins = [plugin for plugin in + required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return @@ -75,7 +74,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if plugin_index is None: invalid.append( - f"Plugin {plugin} does not exist in 3dsMax Plugin List." + f"Plugin {plugin} does not exist" + " in 3dsMax Plugin List." ) continue @@ -105,17 +105,14 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - required_plugins = ( - instance.context.data["project_settings"]["max"]["publish"] - ["ValidateLoadedPlugin"] - ["family_plugins_mapping"] - ) + required_plugins = cls.family_plugins_mapping instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) - cls.log.debug(f"Checking plug-in validation for instance families: {instance_families}") - for families in required_plugins.keys(): + cls.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + for family in required_plugins.keys(): match_families = {fam.strip() for fam in - families.split(",") if fam.strip()} + family.split(",") if fam.strip()} cls.log.debug(f"Plug-in family requirements: {match_families}") has_match = "*" in match_families or match_families.intersection( instance_families) @@ -123,7 +120,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in required_plugins[families]["plugins"]] + plugins = [plugin for plugin in + required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return From d410899714cffcf75ab4be154b78af6acd99a601 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 22:05:33 +0800 Subject: [PATCH 19/45] add ayon settings support for validate loaded plugins --- .../plugins/publish/validate_loaded_plugin.py | 11 ++++---- openpype/settings/ayon_settings.py | 19 ++++++-------- .../max/server/settings/publishers.py | 25 +++++++++++++------ 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index d348e37abc..a681dc507f 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -63,12 +63,12 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - - plugins = [plugin for plugin in - required_plugins[family]["plugins"]] + plugins = [plugin for plugin in required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return + # make sure the validation applied for + # plugins with different Max version plugin_name = plugin.format(**os.environ).lower() plugin_index = available_plugins.get(plugin_name) @@ -110,7 +110,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, instance_families.update(instance.data.get("families", [])) cls.log.debug("Checking plug-in validation " f"for instance families: {instance_families}") - for family in required_plugins.keys(): + for family in required_plugins: match_families = {fam.strip() for fam in family.split(",") if fam.strip()} cls.log.debug(f"Plug-in family requirements: {match_families}") @@ -120,8 +120,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in - required_plugins[family]["plugins"]] + plugins = [plugin for plugin in family["plugins"]] for plugin in plugins: if not plugin: return diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 0cefd047b1..8fd7f990c4 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -650,19 +650,14 @@ def _convert_3dsmax_project_settings(ayon_settings, output): ayon_publish["ValidateAttributes"]["attributes"] = attributes if "ValidateLoadedPlugin" in ayon_publish: - family_plugin_mapping = ( - ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] - ) - for item in family_plugin_mapping: - if "product_types" in item: - item["families"] = item.pop("product_types") - new_family_plugin_mapping = { - item["families"]: item["plugins"] - for item in family_plugin_mapping - } - ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] = ( - new_family_plugin_mapping + new_plugin_mapping = {} + loaded_plugin = ( + ayon_publish["ValidateLoadedPlugin"] ) + for item in loaded_plugin["family_plugins_mapping"]: + name = item.pop("name") + new_plugin_mapping[name] = item + loaded_plugin["family_plugins_mapping"] = new_plugin_mapping output["max"] = ayon_max diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index d7169f8b96..cf482d59d8 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,7 +1,7 @@ import json from pydantic import Field, validator -from ayon_server.settings import BaseSettingsModel +from ayon_server.settings import BaseSettingsModel, ensure_unique_names from ayon_server.exceptions import BadRequestException @@ -27,20 +27,31 @@ class ValidateAttributesModel(BaseSettingsModel): return value -class FamilyPluginsMappingModel(BaseSettingsModel): +class FamilyMappingItemModel(BaseSettingsModel): _layout = "compact" - product_types: str = Field(title="Product Types") + name: str = Field("", title="Product type") plugins: list[str] = Field( - default_factory=list,title="Plugins") + default_factory=list, + title="Plugins" + ) class ValidateLoadedPluginModel(BaseSettingsModel): - enabled: bool = Field(title="ValidateLoadedPlugin") + enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") - family_plugins_mapping: list[FamilyPluginsMappingModel] = Field( - default_factory=list, title="Family Plugins Mapping" + family_plugins_mapping: list[FamilyMappingItemModel] = ( + Field( + default_factory=list, + title="Family Plugins Mapping" + ) ) + # This is to validate unique names (like in dict) + @validator("family_plugins_mapping") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") From 57131a8b40bb6633aaeda75528a58bb3967298b3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Nov 2023 22:07:36 +0800 Subject: [PATCH 20/45] hound --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 ++- server_addon/max/server/settings/publishers.py | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index a681dc507f..06486e94a6 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -63,7 +63,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not has_match: continue - plugins = [plugin for plugin in required_plugins[family]["plugins"]] + plugins = [plugin for plugin in + required_plugins[family]["plugins"]] for plugin in plugins: if not plugin: return diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index cf482d59d8..a752d8cb74 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -39,11 +39,9 @@ class FamilyMappingItemModel(BaseSettingsModel): class ValidateLoadedPluginModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") - family_plugins_mapping: list[FamilyMappingItemModel] = ( - Field( + family_plugins_mapping: list[FamilyMappingItemModel] = Field( default_factory=list, title="Family Plugins Mapping" - ) ) # This is to validate unique names (like in dict) From 0403af298e795cdc7a206b88485f40bd6e07072b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 17:21:37 +0800 Subject: [PATCH 21/45] tweaks the codes to use list instead of dict for OP settings --- .../plugins/publish/validate_loaded_plugin.py | 141 +++++++++--------- .../defaults/project_settings/max.json | 2 +- .../schemas/schema_max_publish.json | 10 +- 3 files changed, 77 insertions(+), 76 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 06486e94a6..7450c8f971 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -17,9 +17,6 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, """Validates if the specific plugin is loaded in 3ds max. Studio Admin(s) can add the plugins they want to check in validation via studio defined project settings - If families = ["*"], all the required plugins would be validated - If families - """ order = pyblish.api.ValidatorOrder @@ -30,66 +27,77 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, family_plugins_mapping = {} - def get_invalid(self, instance): + @classmethod + def get_invalid(cls, instance): """Plugin entry point.""" - if not self.is_active(instance.data): - self.log.debug("Skipping Validate Loaded Plugin...") - return - - required_plugins = self.family_plugins_mapping - if not required_plugins: + family_plugins_mapping = cls.family_plugins_mapping + if not family_plugins_mapping: return invalid = [] + # Find all plug-in requirements for current instance + instance_families = {instance.data["family"]} + instance_families.update(instance.data.get("families", [])) + cls.log.debug("Checking plug-in validation " + f"for instance families: {instance_families}") + all_required_plugins = set() + + for mapping in family_plugins_mapping: + # Check for matching families + if not mapping: + return + + match_families = {fam for fam in mapping["families"] if fam.strip()} + has_match = "*" in match_families or match_families.intersection( + instance_families) + + if not has_match: + continue + + cls.log.debug(f"Found plug-in family requirements: {match_families}") + required_plugins = [ + # match lowercase and format with os.environ to allow + # plugin names defined by max version, e.g. {3DSMAX_VERSION} + plugin.format(**os.environ).lower() + for plugin in mapping["plugins"] + # ignore empty fields in settings + if plugin.strip() + ] + + all_required_plugins.update(required_plugins) + + if not all_required_plugins: + # Instance has no plug-in requirements + return # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - - # Build instance families lookup - instance_families = {instance.data["family"]} - instance_families.update(instance.data.get("families", [])) - self.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") - for family in required_plugins: - # Check for matching families - match_families = {fam.strip() for fam in - family.split(",") if fam.strip()} - self.log.debug(f"Plug-in family requirements: {match_families}") - has_match = "*" in match_families or match_families.intersection( - instance_families) - - if not has_match: + # validate the required plug-ins + for plugin in sorted(all_required_plugins): + plugin_index = available_plugins.get(plugin) + if plugin_index is None: + debug_msg = ( + f"Plugin {plugin} does not exist" + " in 3dsMax Plugin List." + ) + invalid.append((plugin, debug_msg)) continue - plugins = [plugin for plugin in - required_plugins[family]["plugins"]] - for plugin in plugins: - if not plugin: - return - # make sure the validation applied for - # plugins with different Max version - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) - - if plugin_index is None: - invalid.append( - f"Plugin {plugin} does not exist" - " in 3dsMax Plugin List." - ) - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - invalid.append(f"Plugin {plugin} not loaded.") - + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + debug_msg = f"Plugin {plugin} not loaded." + invalid.append((plugin, debug_msg)) return invalid def process(self, instance): - invalid_plugins = self.get_invalid(instance) - if invalid_plugins: + if not self.is_active(instance.data): + self.log.debug("Skipping Validate Loaded Plugin...") + return + invalid = self.get_invalid(instance) + if invalid: bullet_point_invalid_statement = "\n".join( - "- {}".format(invalid) for invalid in invalid_plugins + "- {}".format(message) for _, message in invalid ) report = ( "Required plugins are not loaded.\n\n" @@ -101,36 +109,23 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, @classmethod def repair(cls, instance): + # get all DLL loaded plugins in Max and their plugin index + invalid = cls.get_invalid(instance) + if not invalid: + return + # get all DLL loaded plugins in Max and their plugin index available_plugins = { plugin_name.lower(): index for index, plugin_name in enumerate( get_plugins()) } - required_plugins = cls.family_plugins_mapping - instance_families = {instance.data["family"]} - instance_families.update(instance.data.get("families", [])) - cls.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") - for family in required_plugins: - match_families = {fam.strip() for fam in - family.split(",") if fam.strip()} - cls.log.debug(f"Plug-in family requirements: {match_families}") - has_match = "*" in match_families or match_families.intersection( - instance_families) - if not has_match: + for invalid_plugin, _ in invalid: + plugin_index = available_plugins.get(invalid_plugin) + + if plugin_index is None: + cls.log.warning(f"Can't enable missing plugin: {invalid_plugin}") continue - plugins = [plugin for plugin in family["plugins"]] - for plugin in plugins: - if not plugin: - return - plugin_name = plugin.format(**os.environ).lower() - plugin_index = available_plugins.get(plugin_name) - - if plugin_index is None: - cls.log.warning(f"Can't enable missing plugin: {plugin}") - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - rt.pluginManager.loadPluginDll(plugin_index) + if not rt.pluginManager.isPluginDllLoaded(plugin_index): + rt.pluginManager.loadPluginDll(plugin_index) diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index 57927b48c7..92049cdbe9 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -44,7 +44,7 @@ "ValidateLoadedPlugin": { "enabled": false, "optional": true, - "family_plugins_mapping": {} + "family_plugins_mapping": [] } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index c44c7525da..c6d37ae993 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -66,7 +66,7 @@ "label": "Optional" }, { - "type": "dict-modifiable", + "type": "list", "collapsible": true, "key": "family_plugins_mapping", "label": "Family Plugins Mapping", @@ -74,9 +74,15 @@ "object_type": { "type": "dict", "children": [ + { + "key": "families", + "label": "Famiies", + "type": "list", + "object_type": "text" + }, { "key": "plugins", - "label": "plugins", + "label": "Plugins", "type": "list", "object_type": "text" } From 12c8d3d2f8d79dbadca22efe8cc691c998def4a1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 21:17:29 +0800 Subject: [PATCH 22/45] add supports for ayon settings --- openpype/settings/ayon_settings.py | 9 +++------ server_addon/max/server/settings/publishers.py | 11 ++++------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8fd7f990c4..eb7e3a2d0f 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -650,14 +650,11 @@ def _convert_3dsmax_project_settings(ayon_settings, output): ayon_publish["ValidateAttributes"]["attributes"] = attributes if "ValidateLoadedPlugin" in ayon_publish: - new_plugin_mapping = {} loaded_plugin = ( - ayon_publish["ValidateLoadedPlugin"] + ayon_publish["ValidateLoadedPlugin"]["family_plugins_mapping"] ) - for item in loaded_plugin["family_plugins_mapping"]: - name = item.pop("name") - new_plugin_mapping[name] = item - loaded_plugin["family_plugins_mapping"] = new_plugin_mapping + for item in loaded_plugin: + item["families"] = item.pop("product_types") output["max"] = ayon_max diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a752d8cb74..eeb6478216 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -29,7 +29,10 @@ class ValidateAttributesModel(BaseSettingsModel): class FamilyMappingItemModel(BaseSettingsModel): _layout = "compact" - name: str = Field("", title="Product type") + product_types: list[str] = Field( + default_factory=list, + title="Product Types" + ) plugins: list[str] = Field( default_factory=list, title="Plugins" @@ -44,12 +47,6 @@ class ValidateLoadedPluginModel(BaseSettingsModel): title="Family Plugins Mapping" ) - # This is to validate unique names (like in dict) - @validator("family_plugins_mapping") - def validate_unique_outputs(cls, value): - ensure_unique_names(value) - return value - class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") From e0fba84c9293fa6fe7d9ddb5aa5e1783faaa56d7 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 21:17:42 +0800 Subject: [PATCH 23/45] add supports for ayon settings --- server_addon/max/server/settings/publishers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index eeb6478216..4b6429250f 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,7 +1,7 @@ import json from pydantic import Field, validator -from ayon_server.settings import BaseSettingsModel, ensure_unique_names +from ayon_server.settings import BaseSettingsModel from ayon_server.exceptions import BadRequestException From 25be7762f18518ffacc3f0ac511f647025b6fbb0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 9 Nov 2023 21:19:14 +0800 Subject: [PATCH 24/45] hound --- .../max/plugins/publish/validate_loaded_plugin.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index 7450c8f971..ea2fee353d 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -39,7 +39,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, instance_families = {instance.data["family"]} instance_families.update(instance.data.get("families", [])) cls.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") + f"for instance families: {instance_families}") all_required_plugins = set() for mapping in family_plugins_mapping: @@ -47,14 +47,16 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not mapping: return - match_families = {fam for fam in mapping["families"] if fam.strip()} + match_families = {fam for fam in mapping["families"] + if fam.strip()} has_match = "*" in match_families or match_families.intersection( instance_families) if not has_match: continue - cls.log.debug(f"Found plug-in family requirements: {match_families}") + cls.log.debug( + f"Found plug-in family requirements: {match_families}") required_plugins = [ # match lowercase and format with os.environ to allow # plugin names defined by max version, e.g. {3DSMAX_VERSION} @@ -124,7 +126,8 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, plugin_index = available_plugins.get(invalid_plugin) if plugin_index is None: - cls.log.warning(f"Can't enable missing plugin: {invalid_plugin}") + cls.log.warning( + f"Can't enable missing plugin: {invalid_plugin}") continue if not rt.pluginManager.isPluginDllLoaded(plugin_index): From 958e3019faee4ec25e50674cfeb3988827de52d3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 10 Nov 2023 18:47:14 +0800 Subject: [PATCH 25/45] dont make the layout compact --- server_addon/max/server/settings/publishers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index 4b6429250f..b48f14a064 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -28,7 +28,6 @@ class ValidateAttributesModel(BaseSettingsModel): class FamilyMappingItemModel(BaseSettingsModel): - _layout = "compact" product_types: list[str] = Field( default_factory=list, title="Product Types" From f906d05c7305f123d8e037bd02e3d5742103883f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 13 Nov 2023 16:56:47 +0100 Subject: [PATCH 26/45] fusion: removing hardcoded template name for saver --- openpype/hosts/fusion/plugins/create/create_saver.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 2dc48f4b60..e8ba2880a4 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -149,9 +149,13 @@ class CreateSaver(NewCreator): # get frame padding from anatomy templates anatomy = Anatomy() - frame_padding = int( - anatomy.templates["render"].get("frame_padding", 4) - ) + render_anatomy_template = anatomy.templates.get("render") + if render_anatomy_template: + frame_padding = int( + render_anatomy_template.get("frame_padding", 4) + ) + else: + frame_padding = int(anatomy.templates.get("frame_padding", 4)) # Subset change detected workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) From 2053c4f4c970032e275d72535341e70254230055 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 14 Nov 2023 16:13:06 +0800 Subject: [PATCH 27/45] fix the subset name not changing acoordingly after the subset changes --- openpype/hosts/max/api/lib.py | 3 +-- openpype/hosts/max/api/plugin.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index cbaf8a0c33..0a848cb322 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -42,6 +42,7 @@ def imprint(node_name: str, data: dict) -> bool: rt.SetUserProp(node, k, f"{JSON_PREFIX}{json.dumps(v)}") else: rt.SetUserProp(node, k, v) + print(k) return True @@ -359,8 +360,6 @@ def reset_colorspace(): colorspace_mgr.Mode = rt.Name("OCIO_Custom") colorspace_mgr.OCIOConfigPath = ocio_config_path - colorspace_mgr.OCIOConfigPath = ocio_config_path - def check_colorspace(): parent = get_main_window() diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index fa6db073db..2874cfc1ce 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -204,6 +204,8 @@ class MaxCreator(Creator, MaxCreatorBase): def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection"): self.selected_nodes = rt.GetCurrentSelection() + if rt.getNodeByName(subset_name): + raise CreatorError(f"'{subset_name}' is already created..") instance_node = self.create_instance_node(subset_name) instance_data["instance_node"] = instance_node.name @@ -246,14 +248,25 @@ class MaxCreator(Creator, MaxCreatorBase): def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = created_inst.get("instance_node") - new_values = { key: changes[key].new_value for key in changes.changed_keys } + subset = new_values.get("subset", "") + if subset: + if instance_node != subset: + node = rt.getNodeByName(instance_node) + new_subset_name = new_values["subset"] + if rt.getNodeByName(new_subset_name): + raise CreatorError( + "The subset '{}' already exists.".format( + new_subset_name)) + created_inst["instance_node"] = new_values["subset"] + node.name = created_inst["instance_node"] + imprint( - instance_node, - new_values, + created_inst["instance_node"], + created_inst.data_to_store(), ) def remove_instances(self, instances): From 2b5c20e6e0beaf56767a000c09d09c8860d45d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 14 Nov 2023 12:49:46 +0100 Subject: [PATCH 28/45] Update openpype/hosts/fusion/plugins/create/create_saver.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/fusion/plugins/create/create_saver.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index e8ba2880a4..ecf36abdd2 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -149,13 +149,7 @@ class CreateSaver(NewCreator): # get frame padding from anatomy templates anatomy = Anatomy() - render_anatomy_template = anatomy.templates.get("render") - if render_anatomy_template: - frame_padding = int( - render_anatomy_template.get("frame_padding", 4) - ) - else: - frame_padding = int(anatomy.templates.get("frame_padding", 4)) + frame_padding = anatomy.templates["frame_padding"] # Subset change detected workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) From 68c3ef37ef358ac71d80c993392e6bbe865ee2f0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 15 Nov 2023 14:43:39 +0800 Subject: [PATCH 29/45] code tweaks --- openpype/hosts/max/api/plugin.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 2874cfc1ce..2cf0d69146 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -253,19 +253,19 @@ class MaxCreator(Creator, MaxCreatorBase): for key in changes.changed_keys } subset = new_values.get("subset", "") - if subset: - if instance_node != subset: - node = rt.getNodeByName(instance_node) - new_subset_name = new_values["subset"] - if rt.getNodeByName(new_subset_name): - raise CreatorError( - "The subset '{}' already exists.".format( - new_subset_name)) - created_inst["instance_node"] = new_values["subset"] - node.name = created_inst["instance_node"] + if subset and instance_node != subset: + node = rt.getNodeByName(instance_node) + new_subset_name = new_values["subset"] + if rt.getNodeByName(new_subset_name): + raise CreatorError( + "The subset '{}' already exists.".format( + new_subset_name)) + instance_node = new_subset_name + created_inst["instance_node"] = instance_node + node.name = instance_node imprint( - created_inst["instance_node"], + instance_node, created_inst.data_to_store(), ) From ca21655c18d7163cfe77ec74a26fe86af2817ad4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 15 Nov 2023 17:02:37 +0800 Subject: [PATCH 30/45] remove print debug function --- openpype/hosts/max/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 0a848cb322..298084a4e8 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -42,7 +42,6 @@ def imprint(node_name: str, data: dict) -> bool: rt.SetUserProp(node, k, f"{JSON_PREFIX}{json.dumps(v)}") else: rt.SetUserProp(node, k, v) - print(k) return True From 8794b9ca9a5d821c12903fd613c3fbba0bc2bb28 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 15 Nov 2023 21:06:42 +0800 Subject: [PATCH 31/45] code tweaks on loaded plugins validator --- openpype/hosts/max/plugins/publish/validate_loaded_plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py index ea2fee353d..efa06795b0 100644 --- a/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_loaded_plugin.py @@ -47,8 +47,7 @@ class ValidateLoadedPlugin(OptionalPyblishPluginMixin, if not mapping: return - match_families = {fam for fam in mapping["families"] - if fam.strip()} + match_families = {fam.strip() for fam in mapping["families"]} has_match = "*" in match_families or match_families.intersection( instance_families) From 23291ac53c1fcc00ed603ecdd3d23897b1c844e0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 15 Nov 2023 18:35:54 +0100 Subject: [PATCH 32/45] AYON workfiles tools: Revisit workfiles tool (#5897) * implemented base hierarchy expected selection model * use existing models and widgets in ayon workfiles tool * move private method under public method * added more methods to cache * reset models during controller reset * create workfile info all the time --- openpype/tools/ayon_utils/models/__init__.py | 3 + openpype/tools/ayon_utils/models/cache.py | 49 +- openpype/tools/ayon_utils/models/selection.py | 179 ++++++++ .../ayon_utils/widgets/projects_widget.py | 22 +- openpype/tools/ayon_workfiles/abstract.py | 48 +- openpype/tools/ayon_workfiles/control.py | 224 ++++++---- .../tools/ayon_workfiles/models/__init__.py | 2 - .../tools/ayon_workfiles/models/hierarchy.py | 236 ---------- .../tools/ayon_workfiles/models/selection.py | 23 +- .../tools/ayon_workfiles/models/workfiles.py | 24 +- .../ayon_workfiles/widgets/files_widget.py | 2 +- .../widgets/files_widget_published.py | 34 +- .../widgets/files_widget_workarea.py | 22 +- .../ayon_workfiles/widgets/folders_widget.py | 324 -------------- .../ayon_workfiles/widgets/side_panel.py | 2 +- .../ayon_workfiles/widgets/tasks_widget.py | 420 ------------------ .../tools/ayon_workfiles/widgets/window.py | 57 +-- 17 files changed, 501 insertions(+), 1170 deletions(-) create mode 100644 openpype/tools/ayon_utils/models/selection.py delete mode 100644 openpype/tools/ayon_workfiles/models/hierarchy.py delete mode 100644 openpype/tools/ayon_workfiles/widgets/folders_widget.py delete mode 100644 openpype/tools/ayon_workfiles/widgets/tasks_widget.py diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py index 69722b5e21..8895515b1a 100644 --- a/openpype/tools/ayon_utils/models/__init__.py +++ b/openpype/tools/ayon_utils/models/__init__.py @@ -13,6 +13,7 @@ from .hierarchy import ( HIERARCHY_MODEL_SENDER, ) from .thumbnails import ThumbnailsModel +from .selection import HierarchyExpectedSelection __all__ = ( @@ -29,4 +30,6 @@ __all__ = ( "HIERARCHY_MODEL_SENDER", "ThumbnailsModel", + + "HierarchyExpectedSelection", ) diff --git a/openpype/tools/ayon_utils/models/cache.py b/openpype/tools/ayon_utils/models/cache.py index 44b97e930d..221a14160c 100644 --- a/openpype/tools/ayon_utils/models/cache.py +++ b/openpype/tools/ayon_utils/models/cache.py @@ -81,11 +81,11 @@ class NestedCacheItem: """Helper for cached items stored in nested structure. Example: - >>> cache = NestedCacheItem(levels=2) + >>> cache = NestedCacheItem(levels=2, default_factory=lambda: 0) >>> cache["a"]["b"].is_valid False >>> cache["a"]["b"].get_data() - None + 0 >>> cache["a"]["b"] = 1 >>> cache["a"]["b"].is_valid True @@ -167,8 +167,51 @@ class NestedCacheItem: return self[key] + def cached_count(self): + """Amount of cached items. + + Returns: + int: Amount of cached items. + """ + + return len(self._data_by_key) + + def clear_key(self, key): + """Clear cached item by key. + + Args: + key (str): Key of the cache item. + """ + + self._data_by_key.pop(key, None) + + def clear_invalid(self): + """Clear all invalid cache items. + + Note: + To clear all cache items use 'reset'. + """ + + changed = {} + children_are_nested = self._levels > 1 + for key, cache in tuple(self._data_by_key.items()): + if children_are_nested: + output = cache.clear_invalid() + if output: + changed[key] = output + if not cache.cached_count(): + self._data_by_key.pop(key) + elif not cache.is_valid: + changed[key] = cache.get_data() + self._data_by_key.pop(key) + return changed + def reset(self): - """Reset cache.""" + """Reset cache. + + Note: + To clear only invalid cache items use 'clear_invalid'. + """ self._data_by_key = {} diff --git a/openpype/tools/ayon_utils/models/selection.py b/openpype/tools/ayon_utils/models/selection.py new file mode 100644 index 0000000000..0ff239882b --- /dev/null +++ b/openpype/tools/ayon_utils/models/selection.py @@ -0,0 +1,179 @@ +class _ExampleController: + def emit_event(self, topic, data, **kwargs): + pass + + +class HierarchyExpectedSelection: + """Base skeleton of expected selection model. + + Expected selection model holds information about which entities should be + selected. The order of selection is very important as change of project + will affect what folders are available in folders UI and so on. Because + of that should expected selection model know what is current entity + to select. + + If any of 'handle_project', 'handle_folder' or 'handle_task' is set to + 'False' expected selection data won't contain information about the + entity type at all. Also if project is not handled then it is not + necessary to call 'expected_project_selected'. Same goes for folder and + task. + + Model is triggering event with 'expected_selection_changed' topic and + data > data structure is matching 'get_expected_selection_data' method. + + Questions: + Require '_ExampleController' as abstraction? + + Args: + controller (Any): Controller object. ('_ExampleController') + handle_project (bool): Project can be considered as can have expected + selection. + handle_folder (bool): Folder can be considered as can have expected + selection. + handle_task (bool): Task can be considered as can have expected + selection. + """ + + def __init__( + self, + controller, + handle_project=True, + handle_folder=True, + handle_task=True + ): + self._project_name = None + self._folder_id = None + self._task_name = None + + self._project_selected = True + self._folder_selected = True + self._task_selected = True + + self._controller = controller + + self._handle_project = handle_project + self._handle_folder = handle_folder + self._handle_task = handle_task + + def set_expected_selection( + self, + project_name=None, + folder_id=None, + task_name=None + ): + """Sets expected selection. + + Args: + project_name (Optional[str]): Project name. + folder_id (Optional[str]): Folder id. + task_name (Optional[str]): Task name. + """ + + self._project_name = project_name + self._folder_id = folder_id + self._task_name = task_name + + self._project_selected = not self._handle_project + self._folder_selected = not self._handle_folder + self._task_selected = not self._handle_task + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + task_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + elif not self._task_selected: + task_current = True + data = {} + if self._handle_project: + data["project"] = { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + } + if self._handle_folder: + data["folder"] = { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + } + if self._handle_task: + data["task"] = { + "name": self._task_name, + "current": task_current, + "selected": self._task_selected, + } + + return data + + def is_expected_project_selected(self, project_name): + if not self._handle_project: + return True + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + if not self._handle_folder: + return True + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + """UI selected requested project. + + Other entity types can be requested for selection. + + Args: + project_name (str): Name of project. + """ + + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + """UI selected requested folder. + + Other entity types can be requested for selection. + + Args: + folder_id (str): Folder id. + """ + + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + def expected_task_selected(self, folder_id, task_name): + """UI selected requested task. + + Other entity types can be requested for selection. + + Because task name is not unique across project a folder id is also + required to confirm the right task has been selected. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + """ + + if self._folder_id != folder_id: + return False + + if task_name != self._task_name: + return False + self._task_selected = True + self._emit_change() + return True + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index f98bfcdf8a..728433f929 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -503,17 +503,6 @@ class ProjectsCombobox(QtWidgets.QWidget): self._projects_model.set_current_context_project(project_name) self._projects_proxy_model.invalidateFilter() - def _update_select_item_visiblity(self, **kwargs): - if not self._select_item_visible: - return - if "project_name" not in kwargs: - project_name = self.get_selected_project_name() - else: - project_name = kwargs.get("project_name") - - # Hide the item if a project is selected - self._projects_model.set_selected_project(project_name) - def set_select_item_visible(self, visible): self._select_item_visible = visible self._projects_model.set_select_item_visible(visible) @@ -534,6 +523,17 @@ class ProjectsCombobox(QtWidgets.QWidget): def set_library_filter_enabled(self, enabled): return self._projects_proxy_model.set_library_filter_enabled(enabled) + def _update_select_item_visiblity(self, **kwargs): + if not self._select_item_visible: + return + if "project_name" not in kwargs: + project_name = self.get_selected_project_name() + else: + project_name = kwargs.get("project_name") + + # Hide the item if a project is selected + self._projects_model.set_selected_project(project_name) + def _on_current_index_changed(self, idx): if not self._listen_selection_change: return diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py index ce399fd4c6..260f701d4b 100644 --- a/openpype/tools/ayon_workfiles/abstract.py +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -443,8 +443,11 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): pass @abstractmethod - def get_project_entity(self): - """Get current project entity. + 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. @@ -453,10 +456,11 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): pass @abstractmethod - def get_folder_entity(self, folder_id): + 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: @@ -466,10 +470,11 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): pass @abstractmethod - def get_task_entity(self, task_id): + 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: @@ -574,12 +579,10 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def set_selected_task(self, folder_id, task_id, task_name): + def set_selected_task(self, task_id, task_name): """Change selected task. Args: - folder_id (Union[str, None]): Folder id or None if no folder - is selected. 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 @@ -711,21 +714,27 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def expected_representation_selected(self, representation_id): + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): """Expected representation was selected in UI. Args: + folder_id (str): Folder id under which representation is. + task_name (str): Task name under which representation is. representation_id (str): Representation id which was selected. """ pass @abstractmethod - def expected_workfile_selected(self, workfile_path): + def expected_workfile_selected(self, folder_id, task_name, workfile_name): """Expected workfile was selected in UI. Args: - workfile_path (str): Workfile path which was selected. + folder_id (str): Folder id under which workfile is. + task_name (str): Task name under which workfile is. + workfile_name (str): Workfile filename which was selected. """ pass @@ -738,7 +747,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): # Model functions @abstractmethod - def get_folder_items(self, sender): + def get_folder_items(self, project_name, sender): """Folder items to visualize project hierarchy. This function may trigger events 'folders.refresh.started' and @@ -746,6 +755,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): That may help to avoid re-refresh of folder items in UI elements. Args: + project_name (str): Project name for which are folders requested. sender (str): Who requested folder items. Returns: @@ -756,7 +766,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def get_task_items(self, folder_id, sender): + def get_task_items(self, project_name, folder_id, sender): """Task items. This function may trigger events 'tasks.refresh.started' and @@ -764,6 +774,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): That may help to avoid re-refresh of task items in UI elements. Args: + project_name (str): Project name for which are tasks requested. folder_id (str): Folder ID for which are tasks requested. sender (str): Who requested folder items. @@ -892,22 +903,25 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): At this moment the only information which can be saved about workfile is 'note'. + When 'note' is 'None' it is only validated if workfile info exists, + and if not then creates one with empty note. + Args: folder_id (str): Folder id. task_id (str): Task id. filepath (str): Workfile path. - note (str): Note. + note (Union[str, None]): Note. """ pass # General commands @abstractmethod - def refresh(self): - """Refresh everything, models, ui etc. + def reset(self): + """Reset everything, models, ui etc. - Triggers 'controller.refresh.started' event at the beginning and - 'controller.refresh.finished' at the end. + Triggers 'controller.reset.started' event at the beginning and + 'controller.reset.finished' at the end. """ pass diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py index 3784959caf..fbe6df3155 100644 --- a/openpype/tools/ayon_workfiles/control.py +++ b/openpype/tools/ayon_workfiles/control.py @@ -16,93 +16,120 @@ from openpype.pipeline.context_tools import ( ) from openpype.pipeline.workfile import create_workdir_extra_folders +from openpype.tools.ayon_utils.models import ( + HierarchyModel, + HierarchyExpectedSelection, + ProjectsModel, +) + from .abstract import ( AbstractWorkfilesFrontend, AbstractWorkfilesBackend, ) -from .models import SelectionModel, EntitiesModel, WorkfilesModel +from .models import SelectionModel, WorkfilesModel -class ExpectedSelection: - def __init__(self): - self._folder_id = None - self._task_name = None +class WorkfilesToolExpectedSelection(HierarchyExpectedSelection): + def __init__(self, controller): + super(WorkfilesToolExpectedSelection, self).__init__( + controller, + handle_project=False, + handle_folder=True, + handle_task=True, + ) + self._workfile_name = None self._representation_id = None - self._folder_selected = True - self._task_selected = True - self._workfile_name_selected = True - self._representation_id_selected = True + + self._workfile_selected = True + self._representation_selected = True def set_expected_selection( self, - folder_id, - task_name, + project_name=None, + folder_id=None, + task_name=None, workfile_name=None, - representation_id=None + representation_id=None, ): - self._folder_id = folder_id - self._task_name = task_name self._workfile_name = workfile_name self._representation_id = representation_id - self._folder_selected = False - self._task_selected = False - self._workfile_name_selected = workfile_name is None - self._representation_id_selected = representation_id is None + + self._workfile_selected = False + self._representation_selected = False + + super(WorkfilesToolExpectedSelection, self).set_expected_selection( + project_name, + folder_id, + task_name, + ) def get_expected_selection_data(self): - return { - "folder_id": self._folder_id, - "task_name": self._task_name, - "workfile_name": self._workfile_name, - "representation_id": self._representation_id, - "folder_selected": self._folder_selected, - "task_selected": self._task_selected, - "workfile_name_selected": self._workfile_name_selected, - "representation_id_selected": self._representation_id_selected, + data = super( + WorkfilesToolExpectedSelection, self + ).get_expected_selection_data() + + _is_current = ( + self._project_selected + and self._folder_selected + and self._task_selected + ) + workfile_is_current = False + repre_is_current = False + if _is_current: + workfile_is_current = not self._workfile_selected + repre_is_current = not self._representation_selected + + data["workfile"] = { + "name": self._workfile_name, + "current": workfile_is_current, + "selected": self._workfile_selected, } + data["representation"] = { + "id": self._representation_id, + "current": repre_is_current, + "selected": self._workfile_selected, + } + return data - def is_expected_folder_selected(self, folder_id): - return folder_id == self._folder_id and self._folder_selected + def is_expected_workfile_selected(self, workfile_name): + return ( + workfile_name == self._workfile_name + and self._workfile_selected + ) - def is_expected_task_selected(self, folder_id, task_name): - if not self.is_expected_folder_selected(folder_id): - return False - return task_name == self._task_name and self._task_selected + def is_expected_representation_selected(self, representation_id): + return ( + representation_id == self._representation_id + and self._representation_selected + ) - def expected_folder_selected(self, folder_id): + def expected_workfile_selected(self, folder_id, task_name, workfile_name): if folder_id != self._folder_id: return False - self._folder_selected = True - return True - - def expected_task_selected(self, folder_id, task_name): - if not self.is_expected_folder_selected(folder_id): - return False if task_name != self._task_name: return False - self._task_selected = True - return True - - def expected_workfile_selected(self, folder_id, task_name, workfile_name): - if not self.is_expected_task_selected(folder_id, task_name): - return False - if workfile_name != self._workfile_name: return False - self._workfile_name_selected = True + self._workfile_selected = True + self._emit_change() return True def expected_representation_selected( self, folder_id, task_name, representation_id ): - if not self.is_expected_task_selected(folder_id, task_name): + if folder_id != self._folder_id: return False + + if task_name != self._task_name: + return False + if representation_id != self._representation_id: return False - self._representation_id_selected = True + self._representation_selected = True + self._emit_change() return True @@ -136,9 +163,9 @@ class BaseWorkfileController( # Expected selected folder and task self._expected_selection = self._create_expected_selection_obj() - self._selection_model = self._create_selection_model() - self._entities_model = self._create_entities_model() + self._projects_model = self._create_projects_model() + self._hierarchy_model = self._create_hierarchy_model() self._workfiles_model = self._create_workfiles_model() @property @@ -151,13 +178,16 @@ class BaseWorkfileController( return self._host_is_valid def _create_expected_selection_obj(self): - return ExpectedSelection() + return WorkfilesToolExpectedSelection(self) + + def _create_projects_model(self): + return ProjectsModel(self) def _create_selection_model(self): return SelectionModel(self) - def _create_entities_model(self): - return EntitiesModel(self) + def _create_hierarchy_model(self): + return HierarchyModel(self) def _create_workfiles_model(self): return WorkfilesModel(self) @@ -193,14 +223,17 @@ 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_project_entity(self, project_name): + return self._projects_model.get_project_entity( + project_name) - def get_folder_entity(self, folder_id): - return self._entities_model.get_folder_entity(folder_id) + 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, task_id): - return self._entities_model.get_task_entity(task_id) + def get_task_entity(self, project_name, task_id): + return self._hierarchy_model.get_task_entity( + project_name, task_id) # --------------------------------- # Implementation of abstract methods @@ -293,9 +326,8 @@ class BaseWorkfileController( def get_selected_task_name(self): return self._selection_model.get_selected_task_name() - def set_selected_task(self, folder_id, task_id, task_name): - return self._selection_model.set_selected_task( - folder_id, task_id, task_name) + def set_selected_task(self, task_id, task_name): + return self._selection_model.set_selected_task(task_id, task_name) def get_selected_workfile_path(self): return self._selection_model.get_selected_workfile_path() @@ -318,7 +350,11 @@ class BaseWorkfileController( representation_id=None ): self._expected_selection.set_expected_selection( - folder_id, task_name, workfile_name, representation_id + self.get_current_project_name(), + folder_id, + task_name, + workfile_name, + representation_id ) self._trigger_expected_selection_changed() @@ -355,11 +391,13 @@ class BaseWorkfileController( ) # Model functions - def get_folder_items(self, sender): - return self._entities_model.get_folder_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, folder_id, sender): - return self._entities_model.get_tasks_items(folder_id, sender) + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) def get_workarea_dir_by_context(self, folder_id, task_id): return self._workfiles_model.get_workarea_dir_by_context( @@ -394,7 +432,9 @@ class BaseWorkfileController( def get_published_file_items(self, folder_id, task_id): task_name = None if task_id: - task = self.get_task_entity(task_id) + task = self.get_task_entity( + self.get_current_project_name(), task_id + ) task_name = task.get("name") return self._workfiles_model.get_published_file_items( @@ -410,21 +450,27 @@ class BaseWorkfileController( folder_id, task_id, filepath, note ) - def refresh(self): + def reset(self): if not self._host_is_valid: - self._emit_event("controller.refresh.started") - self._emit_event("controller.refresh.finished") + self._emit_event("controller.reset.started") + self._emit_event("controller.reset.finished") return expected_folder_id = self.get_selected_folder_id() expected_task_name = self.get_selected_task_name() + expected_work_path = self.get_selected_workfile_path() + expected_repre_id = self.get_selected_representation_id() + expected_work_name = None + if expected_work_path: + expected_work_name = os.path.basename(expected_work_path) - self._emit_event("controller.refresh.started") + self._emit_event("controller.reset.started") context = self._get_host_current_context() project_name = context["project_name"] folder_name = context["asset_name"] task_name = context["task_name"] + current_file = self.get_current_workfile() folder_id = None if folder_name: folder = ayon_api.get_folder_by_name(project_name, folder_name) @@ -439,18 +485,25 @@ class BaseWorkfileController( self._current_folder_id = folder_id self._current_task_name = task_name + self._projects_model.reset() + self._hierarchy_model.reset() + if not expected_folder_id: expected_folder_id = folder_id expected_task_name = task_name + if current_file: + expected_work_name = os.path.basename(current_file) + + self._emit_event("controller.reset.finished") self._expected_selection.set_expected_selection( - expected_folder_id, expected_task_name + project_name, + expected_folder_id, + expected_task_name, + expected_work_name, + expected_repre_id, ) - self._entities_model.refresh() - - self._emit_event("controller.refresh.finished") - # Controller actions def open_workfile(self, folder_id, task_id, filepath): self._emit_event("open_workfile.started") @@ -579,9 +632,9 @@ class BaseWorkfileController( self, project_name, folder_id, task_id, folder=None, task=None ): if folder is None: - folder = self.get_folder_entity(folder_id) + folder = self.get_folder_entity(project_name, folder_id) if task is None: - task = self.get_task_entity(task_id) + task = self.get_task_entity(project_name, task_id) # NOTE keys should be OpenPype compatible return { "project_name": project_name, @@ -633,8 +686,8 @@ class BaseWorkfileController( ): # Trigger before save event project_name = self.get_current_project_name() - folder = self.get_folder_entity(folder_id) - task = self.get_task_entity(task_id) + folder = self.get_folder_entity(project_name, folder_id) + task = self.get_task_entity(project_name, task_id) task_name = task["name"] # QUESTION should the data be different for 'before' and 'after'? @@ -674,6 +727,9 @@ class BaseWorkfileController( else: self._host_save_workfile(dst_filepath) + # Make sure workfile info exists + self.save_workfile_info(folder_id, task_id, dst_filepath, None) + # Create extra folders create_workdir_extra_folders( workdir, @@ -685,4 +741,4 @@ class BaseWorkfileController( # Trigger after save events emit_event("workfile.save.after", event_data, source="workfiles.tool") - self.refresh() + self.reset() diff --git a/openpype/tools/ayon_workfiles/models/__init__.py b/openpype/tools/ayon_workfiles/models/__init__.py index d906b9e7bd..734cb08cb6 100644 --- a/openpype/tools/ayon_workfiles/models/__init__.py +++ b/openpype/tools/ayon_workfiles/models/__init__.py @@ -1,10 +1,8 @@ -from .hierarchy import EntitiesModel from .selection import SelectionModel from .workfiles import WorkfilesModel __all__ = ( "SelectionModel", - "EntitiesModel", "WorkfilesModel", ) diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py deleted file mode 100644 index a1d51525da..0000000000 --- a/openpype/tools/ayon_workfiles/models/hierarchy.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Hierarchy model that handles folders and tasks. - -The model can be extracted for common usage. In that case it will be required -to add more handling of project name changes. -""" - -import time -import collections -import contextlib - -import ayon_api - -from openpype.tools.ayon_workfiles.abstract import ( - FolderItem, - TaskItem, -) - - -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, - None - )) - return output - - -def _get_folder_item_from_hierarchy_item(item): - return FolderItem( - item["id"], - item["parentId"], - item["name"], - item["label"], - None, - None, - ) - - -class CacheItem: - def __init__(self, lifetime=120): - self._lifetime = lifetime - self._last_update = None - self._data = None - - @property - def is_valid(self): - if self._last_update is None: - return False - - return (time.time() - self._last_update) < self._lifetime - - def set_invalid(self, data=None): - self._last_update = None - self._data = data - - def get_data(self): - return self._data - - def update_data(self, data): - self._data = data - self._last_update = time.time() - - -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 = {} - - self._folders_by_id = {} - self._tasks_by_id = {} - - self._folders_refreshing = False - self._tasks_refreshing = set() - self._controller = controller - - def reset(self): - self._project_cache.set_invalid({}) - self._folders_cache.set_invalid({}) - self._tasks_cache = {} - - self._folders_by_id = {} - self._tasks_by_id = {} - - 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) - return self._folders_cache.get_data() - - def get_tasks_items(self, folder_id, sender): - if not folder_id: - return [] - - task_cache = self._tasks_cache.get(folder_id) - if task_cache is None or not task_cache.is_valid: - self._refresh_tasks_cache(folder_id, sender) - task_cache = self._tasks_cache.get(folder_id) - return task_cache.get_data() - - def get_folder_entity(self, folder_id): - if folder_id not in self._folders_by_id: - entity = None - if folder_id: - project_name = self._controller.get_current_project_name() - entity = ayon_api.get_folder_by_id(project_name, folder_id) - self._folders_by_id[folder_id] = entity - return self._folders_by_id[folder_id] - - def get_task_entity(self, task_id): - if task_id not in self._tasks_by_id: - entity = None - if task_id: - project_name = self._controller.get_current_project_name() - entity = ayon_api.get_task_by_id(project_name, task_id) - self._tasks_by_id[task_id] = entity - return self._tasks_by_id[task_id] - - @contextlib.contextmanager - def _folder_refresh_event_manager(self, project_name, sender): - self._folders_refreshing = True - self._controller.emit_event( - "folders.refresh.started", - {"project_name": project_name, "sender": sender}, - self.event_source - ) - try: - yield - - finally: - self._controller.emit_event( - "folders.refresh.finished", - {"project_name": project_name, "sender": sender}, - self.event_source - ) - self._folders_refreshing = False - - @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, - }, - self.event_source - ) - try: - yield - - finally: - self._controller.emit_event( - "tasks.refresh.finished", - { - "project_name": project_name, - "folder_id": folder_id, - "sender": sender, - }, - self.event_source - ) - self._tasks_refreshing.discard(folder_id) - - def _refresh_folders_cache(self, sender=None): - if self._folders_refreshing: - return - project_name = self._controller.get_current_project_name() - with self._folder_refresh_event_manager(project_name, sender): - folder_items = self._query_folders(project_name) - self._folders_cache.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, folder_id, sender=None): - if folder_id in self._tasks_refreshing: - return - - project_name = self._controller.get_current_project_name() - with self._task_refresh_event_manager( - project_name, folder_id, sender - ): - cache_item = self._tasks_cache.get(folder_id) - if cache_item is None: - cache_item = CacheItem() - self._tasks_cache[folder_id] = cache_item - - task_items = self._query_tasks(project_name, folder_id) - cache_item.update_data(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_workfiles/models/selection.py b/openpype/tools/ayon_workfiles/models/selection.py index ad034794d8..2f0896842d 100644 --- a/openpype/tools/ayon_workfiles/models/selection.py +++ b/openpype/tools/ayon_workfiles/models/selection.py @@ -4,7 +4,7 @@ class SelectionModel(object): Triggering events: - "selection.folder.changed" - "selection.task.changed" - - "workarea.selection.changed" + - "selection.workarea.changed" - "selection.representation.changed" """ @@ -29,7 +29,10 @@ class SelectionModel(object): self._folder_id = folder_id self._controller.emit_event( "selection.folder.changed", - {"folder_id": folder_id}, + { + "project_name": self._controller.get_current_project_name(), + "folder_id": folder_id + }, self.event_source ) @@ -39,10 +42,7 @@ class SelectionModel(object): def get_selected_task_id(self): return self._task_id - def set_selected_task(self, folder_id, task_id, task_name): - if folder_id != self._folder_id: - self.set_selected_folder(folder_id) - + def set_selected_task(self, task_id, task_name): if task_id == self._task_id: return @@ -51,7 +51,8 @@ class SelectionModel(object): self._controller.emit_event( "selection.task.changed", { - "folder_id": folder_id, + "project_name": self._controller.get_current_project_name(), + "folder_id": self._folder_id, "task_name": task_name, "task_id": task_id }, @@ -67,8 +68,9 @@ class SelectionModel(object): self._workfile_path = path self._controller.emit_event( - "workarea.selection.changed", + "selection.workarea.changed", { + "project_name": self._controller.get_current_project_name(), "path": path, "folder_id": self._folder_id, "task_name": self._task_name, @@ -86,6 +88,9 @@ class SelectionModel(object): self._representation_id = representation_id self._controller.emit_event( "selection.representation.changed", - {"representation_id": representation_id}, + { + "project_name": self._controller.get_current_project_name(), + "representation_id": representation_id, + }, self.event_source ) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py index 4d989ed22c..907b9b5383 100644 --- a/openpype/tools/ayon_workfiles/models/workfiles.py +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -148,7 +148,9 @@ class WorkareaModel: def _get_folder_data(self, folder_id): fill_data = self._fill_data_by_folder_id.get(folder_id) if fill_data is None: - folder = self._controller.get_folder_entity(folder_id) + folder = self._controller.get_folder_entity( + self.project_name, folder_id + ) fill_data = get_folder_template_data(folder) self._fill_data_by_folder_id[folder_id] = fill_data return copy.deepcopy(fill_data) @@ -156,7 +158,9 @@ class WorkareaModel: 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) + task = self._controller.get_task_entity( + self.project_name, task_id + ) if task: task_data[task_id] = get_task_template_data( project_entity, task) @@ -167,8 +171,9 @@ class WorkareaModel: return {} base_data = self._get_base_data() + project_name = base_data["project"]["name"] folder_data = self._get_folder_data(folder_id) - project_entity = self._controller.get_project_entity() + project_entity = self._controller.get_project_entity(project_name) task_data = self._get_task_data(project_entity, folder_id, task_id) base_data.update(folder_data) @@ -292,9 +297,13 @@ class WorkareaModel: folder = None task = None if folder_id: - folder = self._controller.get_folder_entity(folder_id) + folder = self._controller.get_folder_entity( + self.project_name, folder_id + ) if task_id: - task = self._controller.get_task_entity(task_id) + task = self._controller.get_task_entity( + self.project_name, task_id + ) if not folder or not task: return { @@ -491,10 +500,13 @@ class WorkfileEntitiesModel: ) if not workfile_info: self._cache[identifier] = self._create_workfile_info_entity( - task_id, rootless_path, note) + task_id, rootless_path, note or "") self._items.pop(identifier, None) return + if note is None: + return + new_workfile_info = copy.deepcopy(workfile_info) attrib = new_workfile_info.setdefault("attrib", {}) attrib["description"] = note diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py index 656ddf1dd8..16f0b6fce3 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py @@ -69,7 +69,7 @@ class FilesWidget(QtWidgets.QWidget): main_layout.addWidget(btns_widget, 0) controller.register_event_callback( - "workarea.selection.changed", + "selection.workarea.changed", self._on_workarea_path_changed ) controller.register_event_callback( diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py index 576cf18d73..704f7b2f39 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py @@ -59,14 +59,6 @@ class PublishedFilesModel(QtGui.QStandardItemModel): self._add_empty_item() - def _clear_items(self): - self._remove_missing_context_item() - self._remove_empty_item() - if self._items_by_id: - root = self.invisibleRootItem() - root.removeRows(0, root.rowCount()) - self._items_by_id = {} - def set_published_mode(self, published_mode): if self._published_mode == published_mode: return @@ -89,6 +81,18 @@ class PublishedFilesModel(QtGui.QStandardItemModel): return QtCore.QModelIndex() return self.indexFromItem(item) + def refresh(self): + if self._published_mode: + self._fill_items() + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_id: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_id = {} + def _get_missing_context_item(self): if self._missing_context_item is None: message = "Select folder" @@ -149,7 +153,6 @@ class PublishedFilesModel(QtGui.QStandardItemModel): def _on_folder_changed(self, event): self._last_folder_id = event["folder_id"] - self._last_task_id = None if self._context_select_mode: return @@ -356,14 +359,13 @@ class PublishedFilesWidget(QtWidgets.QWidget): self.save_as_requested.emit() def _on_expected_selection_change(self, event): - if ( - event["representation_id_selected"] - or not event["folder_selected"] - or (event["task_name"] and not event["task_selected"]) - ): + repre_info = event["representation"] + if not repre_info["current"]: return - representation_id = event["representation_id"] + self._model.refresh() + + representation_id = repre_info["id"] selected_repre_id = self.get_selected_repre_id() if ( representation_id is not None @@ -376,5 +378,5 @@ class PublishedFilesWidget(QtWidgets.QWidget): self._view.setCurrentIndex(proxy_index) self._controller.expected_representation_selected( - event["folder_id"], event["task_name"], representation_id + event["folder"]["id"], event["task"]["name"], representation_id ) diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py index 3a8e90f933..8eefd3cf81 100644 --- a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -28,6 +28,10 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_changed + ) controller.register_event_callback( "selection.task.changed", self._on_task_changed @@ -63,6 +67,10 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): return QtCore.QModelIndex() return self.indexFromItem(item) + def refresh(self): + if not self._published_mode: + self._fill_items() + def _get_missing_context_item(self): if self._missing_context_item is None: message = "Select folder and task" @@ -129,6 +137,11 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): root_item.takeRow(self._empty_root_item.row()) self._empty_item_used = False + def _on_folder_changed(self, event): + self._selected_folder_id = event["folder_id"] + if not self._published_mode: + self._fill_items() + def _on_task_changed(self, event): self._selected_folder_id = event["folder_id"] self._selected_task_id = event["task_id"] @@ -362,10 +375,13 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): self.duplicate_requested.emit() def _on_expected_selection_change(self, event): - if event["workfile_name_selected"]: + workfile_info = event["workfile"] + if not workfile_info["current"]: return - workfile_name = event["workfile_name"] + self._model.refresh() + + workfile_name = workfile_info["name"] if ( workfile_name is not None and workfile_name != self._get_selected_info()["filename"] @@ -376,5 +392,5 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): self._view.setCurrentIndex(proxy_index) self._controller.expected_workfile_selected( - event["folder_id"], event["task_name"], workfile_name + event["folder"]["id"], event["task"]["name"], workfile_name ) diff --git a/openpype/tools/ayon_workfiles/widgets/folders_widget.py b/openpype/tools/ayon_workfiles/widgets/folders_widget.py deleted file mode 100644 index b04f8e4098..0000000000 --- a/openpype/tools/ayon_workfiles/widgets/folders_widget.py +++ /dev/null @@ -1,324 +0,0 @@ -import uuid -import collections - -import qtawesome -from qtpy import QtWidgets, QtGui, QtCore - -from openpype.tools.utils import ( - RecursiveSortFilterProxyModel, - DeselectableTreeView, -) - -from .constants import ITEM_ID_ROLE, ITEM_NAME_ROLE - -SENDER_NAME = "qt_folders_model" - - -class FoldersRefreshThread(QtCore.QThread): - """Thread for refreshing folders. - - Call controller to get folders and emit signal when finished. - - Args: - controller (AbstractWorkfilesFrontend): The control object. - """ - - refresh_finished = QtCore.Signal(str) - - def __init__(self, controller): - super(FoldersRefreshThread, self).__init__() - self._id = uuid.uuid4().hex - self._controller = controller - self._result = None - - @property - def id(self): - """Thread id. - - Returns: - str: Unique id of the thread. - """ - - return self._id - - def run(self): - self._result = self._controller.get_folder_items(SENDER_NAME) - self.refresh_finished.emit(self.id) - - def get_result(self): - return self._result - - -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._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 refresh(self): - """Refresh folders items. - - Refresh start thread because it can cause that controller can - start query from database if folders are not cached. - """ - - self._is_refreshing = True - - thread = FoldersRefreshThread(self._controller) - self._current_refresh_thread = thread.id - 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. - """ - - thread = self._refresh_threads.pop(thread_id) - if thread_id != self._current_refresh_thread: - return - - folder_items_by_id = thread.get_result() - if not folder_items_by_id: - if folder_items_by_id is not None: - self.clear() - self._is_refreshing = False - 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(list) - for folder_item in folder_items_by_id.values(): - folder_items_by_parent[folder_item.parent_id].append(folder_item) - - hierarchy_queue = collections.deque() - hierarchy_queue.append(None) - - while hierarchy_queue: - parent_id = hierarchy_queue.popleft() - folder_items = folder_items_by_parent[parent_id] - if parent_id is None: - parent_item = self.invisibleRootItem() - else: - parent_item = self._items_by_id[parent_id] - - new_items = [] - for folder_item in folder_items: - item_id = folder_item.entity_id - item = self._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 = qtawesome.icon( - folder_item.icon_name, - color=folder_item.icon_color, - ) - 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_id) - - if new_items: - parent_item.appendRows(new_items) - - for item_id in ids_to_remove: - item = self._items_by_id[item_id] - parent_id = self._parent_id_by_id[item_id] - if parent_id is None: - parent_item = self.invisibleRootItem() - else: - parent_item = self._items_by_id[parent_id] - parent_item.takeChild(item.row()) - - 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. - - Args: - controller (AbstractWorkfilesFrontend): The control object. - parent (QtWidgets.QWidget): The parent widget. - """ - - def __init__(self, controller, parent): - 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( - "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._expected_selection = None - - def set_name_filter(self, name): - self._folders_proxy_model.setFilterFixedString(name) - - def _clear(self): - self._folders_model.clear() - - def _on_folders_refresh_finished(self, event): - if event["sender"] != SENDER_NAME: - self._folders_model.refresh() - - def _on_controller_refresh(self): - self._update_expected_selection() - - def _update_expected_selection(self, expected_data=None): - if expected_data is None: - expected_data = self._controller.get_expected_selection_data() - - # We're done - if expected_data["folder_selected"]: - return - - folder_id = expected_data["folder_id"] - self._expected_selection = folder_id - if not self._folders_model.is_refreshing: - self._set_expected_selection() - - def _set_expected_selection(self): - folder_id = self._expected_selection - 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) - - def _on_model_refresh(self): - if self._expected_selection: - self._set_expected_selection() - self._folders_proxy_model.sort(0) - - def _on_expected_selection_change(self, event): - self._update_expected_selection(event.data) - - 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) diff --git a/openpype/tools/ayon_workfiles/widgets/side_panel.py b/openpype/tools/ayon_workfiles/widgets/side_panel.py index 7f06576a00..5085f4701e 100644 --- a/openpype/tools/ayon_workfiles/widgets/side_panel.py +++ b/openpype/tools/ayon_workfiles/widgets/side_panel.py @@ -66,7 +66,7 @@ class SidePanelWidget(QtWidgets.QWidget): btn_note_save.clicked.connect(self._on_save_click) controller.register_event_callback( - "workarea.selection.changed", self._on_selection_change + "selection.workarea.changed", self._on_selection_change ) self._details_input = details_input diff --git a/openpype/tools/ayon_workfiles/widgets/tasks_widget.py b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py deleted file mode 100644 index 04f5b286b1..0000000000 --- a/openpype/tools/ayon_workfiles/widgets/tasks_widget.py +++ /dev/null @@ -1,420 +0,0 @@ -import uuid -import qtawesome -from qtpy import QtWidgets, QtGui, QtCore - -from openpype.style import get_disabled_entity_icon_color -from openpype.tools.utils import DeselectableTreeView - -from .constants import ( - ITEM_NAME_ROLE, - ITEM_ID_ROLE, - PARENT_ID_ROLE, -) - -SENDER_NAME = "qt_tasks_model" - - -class RefreshThread(QtCore.QThread): - """Thread for refreshing tasks. - - Call controller to get tasks and emit signal when finished. - - Args: - controller (AbstractWorkfilesFrontend): The control object. - folder_id (str): Folder id. - """ - - refresh_finished = QtCore.Signal(str) - - def __init__(self, controller, folder_id): - super(RefreshThread, self).__init__() - self._id = uuid.uuid4().hex - self._controller = controller - self._folder_id = folder_id - self._result = None - - @property - def id(self): - return self._id - - def run(self): - self._result = self._controller.get_task_items( - self._folder_id, SENDER_NAME) - self.refresh_finished.emit(self.id) - - def get_result(self): - return self._result - - -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_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, folder_id): - """Refresh tasks for folder. - - Args: - folder_id (Union[str, None]): Folder id. - """ - - self._refresh(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_folder_id(self): - """Get last refreshed folder id. - - Returns: - Union[str, None]: Folder id. - """ - - return self._last_folder_id - - 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 = qtawesome.icon( - "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 = qtawesome.icon( - "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, folder_id): - self._is_refreshing = True - 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 = RefreshThread(self._controller, folder_id) - self._current_refresh_thread = thread.id - 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. - """ - - thread = self._refresh_threads.pop(thread_id) - if thread_id != self._current_refresh_thread: - 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 = qtawesome.icon( - task_item.icon_name, - color=task_item.icon_color, - ) - 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. - """ - - def __init__(self, controller, parent): - 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._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(self._selected_folder_id) - - def _folder_selection_changed(self, event): - self._selected_folder_id = event["folder_id"] - self._tasks_model.refresh(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 _set_expected_selection(self): - 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 _on_expected_selection_change(self, event): - if event["task_selected"] or not event["folder_selected"]: - return - - model_folder_id = self._tasks_model.get_last_folder_id() - folder_id = event["folder_id"] - self._expected_selection_data = { - "task_name": event["task_name"], - "folder_id": folder_id, - } - - if folder_id != model_folder_id or self._tasks_model.is_refreshing: - return - self._set_expected_selection() - - 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(parent_id, task_id, task_name) diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py index 6218d2dd06..eb2f2bc1c7 100644 --- a/openpype/tools/ayon_workfiles/widgets/window.py +++ b/openpype/tools/ayon_workfiles/widgets/window.py @@ -5,32 +5,16 @@ from openpype.tools.utils import ( PlaceholderLineEdit, MessageOverlayObject, ) -from openpype.tools.utils.lib import get_qta_icon_by_name_and_color +from openpype.tools.ayon_utils.widgets import FoldersWidget, TasksWidget from openpype.tools.ayon_workfiles.control import BaseWorkfileController +from openpype.tools.utils import GoToCurrentButton, RefreshButton from .side_panel import SidePanelWidget -from .folders_widget import FoldersWidget -from .tasks_widget import TasksWidget from .files_widget import FilesWidget from .utils import BaseOverlayFrame -# TODO move to utils -# from openpype.tools.utils.lib import ( -# get_refresh_icon, get_go_to_current_icon) -def get_refresh_icon(): - return get_qta_icon_by_name_and_color( - "fa.refresh", style.get_default_tools_icon_color() - ) - - -def get_go_to_current_icon(): - return get_qta_icon_by_name_and_color( - "fa.arrow-down", style.get_default_tools_icon_color() - ) - - class InvalidHostOverlay(BaseOverlayFrame): def __init__(self, parent): super(InvalidHostOverlay, self).__init__(parent) @@ -80,7 +64,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._default_window_flags = flags - self._folder_widget = None + self._folders_widget = None self._folder_filter_input = None self._files_widget = None @@ -100,7 +84,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget): home_body_widget = QtWidgets.QWidget(home_page_widget) col_1_widget = self._create_col_1_widget(controller, parent) - tasks_widget = TasksWidget(controller, home_body_widget) + tasks_widget = TasksWidget( + controller, home_body_widget, handle_expected_selection=True + ) col_3_widget = self._create_col_3_widget(controller, home_body_widget) side_panel = SidePanelWidget(controller, home_body_widget) @@ -151,11 +137,11 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._on_open_finished ) controller.register_event_callback( - "controller.refresh.started", + "controller.reset.started", self._on_controller_refresh_started, ) controller.register_event_callback( - "controller.refresh.finished", + "controller.reset.finished", self._on_controller_refresh_finished, ) @@ -188,19 +174,12 @@ class WorkfilesToolWindow(QtWidgets.QWidget): folder_filter_input = PlaceholderLineEdit(header_widget) folder_filter_input.setPlaceholderText("Filter folders..") - go_to_current_btn = QtWidgets.QPushButton(header_widget) - go_to_current_btn.setIcon(get_go_to_current_icon()) - go_to_current_btn_sp = go_to_current_btn.sizePolicy() - go_to_current_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) - go_to_current_btn.setSizePolicy(go_to_current_btn_sp) + go_to_current_btn = GoToCurrentButton(header_widget) + refresh_btn = RefreshButton(header_widget) - refresh_btn = QtWidgets.QPushButton(header_widget) - refresh_btn.setIcon(get_refresh_icon()) - refresh_btn_sp = refresh_btn.sizePolicy() - refresh_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) - refresh_btn.setSizePolicy(refresh_btn_sp) - - folder_widget = FoldersWidget(controller, col_widget) + folder_widget = FoldersWidget( + controller, col_widget, handle_expected_selection=True + ) header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) @@ -218,7 +197,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): refresh_btn.clicked.connect(self._on_refresh_clicked) self._folder_filter_input = folder_filter_input - self._folder_widget = folder_widget + self._folders_widget = folder_widget return col_widget @@ -300,7 +279,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): def refresh(self): """Trigger refresh of workfiles tool controller.""" - self._controller.refresh() + self._controller.reset() def showEvent(self, event): super(WorkfilesToolWindow, self).showEvent(event) @@ -338,7 +317,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._side_panel.set_published_mode(published_mode) def _on_folder_filter_change(self, text): - self._folder_widget.set_name_filter(text) + self._folders_widget.set_name_filter(text) def _on_go_to_current_clicked(self): self._controller.go_to_current_context() @@ -357,6 +336,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget): if not self._host_is_valid: return + self._folders_widget.set_project_name( + self._controller.get_current_project_name() + ) + def _on_save_as_finished(self, event): if event["failed"]: self._overlay_messages_widget.add_message( From e340b9c7bc772d41c272bdbb4dfd42321c03df5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 16 Nov 2023 10:57:09 +0100 Subject: [PATCH 33/45] rename function 'asset_name' to 'prepare_scene_name' --- openpype/hosts/blender/api/plugin.py | 10 +++++----- openpype/hosts/blender/plugins/create/create_action.py | 2 +- openpype/hosts/blender/plugins/load/import_workfile.py | 2 +- openpype/hosts/blender/plugins/load/load_abc.py | 4 ++-- openpype/hosts/blender/plugins/load/load_action.py | 10 +++++----- openpype/hosts/blender/plugins/load/load_audio.py | 4 ++-- openpype/hosts/blender/plugins/load/load_blend.py | 4 ++-- openpype/hosts/blender/plugins/load/load_blendscene.py | 4 ++-- openpype/hosts/blender/plugins/load/load_camera_abc.py | 4 ++-- openpype/hosts/blender/plugins/load/load_camera_fbx.py | 4 ++-- openpype/hosts/blender/plugins/load/load_fbx.py | 4 ++-- .../hosts/blender/plugins/load/load_layout_json.py | 4 ++-- openpype/hosts/blender/plugins/load/load_look.py | 4 ++-- 13 files changed, 30 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 7ac12b5549..d7155a1b53 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -28,7 +28,7 @@ from .lib import imprint VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"] -def asset_name( +def prepare_scene_name( asset: str, subset: str, namespace: Optional[str] = None ) -> str: """Return a consistent name for an asset.""" @@ -225,7 +225,7 @@ class BaseCreator(Creator): bpy.context.scene.collection.children.link(instances) # Create asset group - name = asset_name(instance_data["asset"], subset_name) + name = prepare_scene_name(instance_data["asset"], subset_name) if self.create_as_asset_group: # Create instance as empty instance_node = bpy.data.objects.new(name=name, object_data=None) @@ -298,7 +298,7 @@ class BaseCreator(Creator): "subset" in changes.changed_keys or "asset" in changes.changed_keys ): - name = asset_name(asset=data["asset"], subset=data["subset"]) + name = prepare_scene_name(asset=data["asset"], subset=data["subset"]) node.name = name imprint(node, data) @@ -454,7 +454,7 @@ class AssetLoader(LoaderPlugin): asset, subset ) namespace = namespace or f"{asset}_{unique_number}" - name = name or asset_name( + name = name or prepare_scene_name( asset, subset, unique_number ) @@ -483,7 +483,7 @@ class AssetLoader(LoaderPlugin): # asset = context["asset"]["name"] # subset = context["subset"]["name"] - # instance_name = asset_name(asset, subset, unique_number) + '_CON' + # instance_name = prepare_scene_name(asset, subset, unique_number) + '_CON' # return self._get_instance_collection(instance_name, nodes) diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index 0929778d78..caaa72fe8d 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -22,7 +22,7 @@ class CreateAction(plugin.BaseCreator): ) # Get instance name - name = plugin.asset_name(instance_data["asset"], subset_name) + name = plugin.prepare_scene_name(instance_data["asset"], subset_name) if pre_create_data.get("use_selection"): for obj in lib.get_selection(): diff --git a/openpype/hosts/blender/plugins/load/import_workfile.py b/openpype/hosts/blender/plugins/load/import_workfile.py index 4f5016d422..331f6a8bdb 100644 --- a/openpype/hosts/blender/plugins/load/import_workfile.py +++ b/openpype/hosts/blender/plugins/load/import_workfile.py @@ -7,7 +7,7 @@ def append_workfile(context, fname, do_import): asset = context['asset']['name'] subset = context['subset']['name'] - group_name = plugin.asset_name(asset, subset) + group_name = plugin.prepare_scene_name(asset, subset) # We need to preserve the original names of the scenes, otherwise, # if there are duplicate names in the current workfile, the imported diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 8d1863d4d5..d7e82d1900 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -137,9 +137,9 @@ class CacheModelLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" containers = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_action.py b/openpype/hosts/blender/plugins/load/load_action.py index 3447e67ebf..f7d32f92a5 100644 --- a/openpype/hosts/blender/plugins/load/load_action.py +++ b/openpype/hosts/blender/plugins/load/load_action.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional import bpy from openpype.pipeline import get_representation_path -import openpype.hosts.blender.api.plugin +from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( containerise_existing, AVALON_PROPERTY, @@ -16,7 +16,7 @@ from openpype.hosts.blender.api.pipeline import ( logger = logging.getLogger("openpype").getChild("blender").getChild("load_action") -class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): +class BlendActionLoader(plugin.AssetLoader): """Load action from a .blend file. Warning: @@ -46,8 +46,8 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - container_name = openpype.hosts.blender.api.plugin.asset_name( + lib_container = plugin.prepare_scene_name(asset, subset) + container_name = plugin.prepare_scene_name( asset, subset, namespace ) @@ -152,7 +152,7 @@ class BlendActionLoader(openpype.hosts.blender.api.plugin.AssetLoader): assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) - assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, ( + assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py index ac8f363316..1e5bd39a32 100644 --- a/openpype/hosts/blender/plugins/load/load_audio.py +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -42,9 +42,9 @@ class AudioLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 8b1af5a0da..f437e66795 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -133,9 +133,9 @@ class BlendLoader(plugin.AssetLoader): representation = str(context["representation"]["_id"]) - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_blendscene.py b/openpype/hosts/blender/plugins/load/load_blendscene.py index 2c955af9e8..6cc7f39d03 100644 --- a/openpype/hosts/blender/plugins/load/load_blendscene.py +++ b/openpype/hosts/blender/plugins/load/load_blendscene.py @@ -85,9 +85,9 @@ class BlendSceneLoader(plugin.AssetLoader): except ValueError: family = "model" - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_camera_abc.py b/openpype/hosts/blender/plugins/load/load_camera_abc.py index 05d3fb764d..ecd6bb98f1 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_abc.py +++ b/openpype/hosts/blender/plugins/load/load_camera_abc.py @@ -87,9 +87,9 @@ class AbcCameraLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index 3cca6e7fd3..2d53d3e573 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -90,9 +90,9 @@ class FbxCameraLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index e129ea6754..8fce53a5d5 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -134,9 +134,9 @@ class FbxModelLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index a941c77a8e..748ac619b6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -149,9 +149,9 @@ class JsonLayoutLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - asset_name = plugin.asset_name(asset, subset) + asset_name = plugin.prepare_scene_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) + group_name = plugin.prepare_scene_name(asset, subset, unique_number) namespace = namespace or f"{asset}_{unique_number}" avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) diff --git a/openpype/hosts/blender/plugins/load/load_look.py b/openpype/hosts/blender/plugins/load/load_look.py index c121f55633..8d3118d83b 100644 --- a/openpype/hosts/blender/plugins/load/load_look.py +++ b/openpype/hosts/blender/plugins/load/load_look.py @@ -96,14 +96,14 @@ class BlendLookLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = plugin.asset_name( + lib_container = plugin.prepare_scene_name( asset, subset ) unique_number = plugin.get_unique_number( asset, subset ) namespace = namespace or f"{asset}_{unique_number}" - container_name = plugin.asset_name( + container_name = plugin.prepare_scene_name( asset, subset, unique_number ) From e8f7f146ab670e5c21a8374143456858df80260b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 16 Nov 2023 11:02:29 +0100 Subject: [PATCH 34/45] formatting fix --- openpype/hosts/blender/api/plugin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index d7155a1b53..8d33187da3 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -298,7 +298,9 @@ class BaseCreator(Creator): "subset" in changes.changed_keys or "asset" in changes.changed_keys ): - name = prepare_scene_name(asset=data["asset"], subset=data["subset"]) + name = prepare_scene_name( + asset=data["asset"], subset=data["subset"] + ) node.name = name imprint(node, data) @@ -483,7 +485,9 @@ class AssetLoader(LoaderPlugin): # asset = context["asset"]["name"] # subset = context["subset"]["name"] - # instance_name = prepare_scene_name(asset, subset, unique_number) + '_CON' + # instance_name = prepare_scene_name( + # asset, subset, unique_number + # ) + '_CON' # return self._get_instance_collection(instance_name, nodes) From 781a1047de260a8afa570b2e67c957fb5622fac4 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 16 Nov 2023 12:45:04 +0000 Subject: [PATCH 35/45] [Automated] Release --- CHANGELOG.md | 380 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 382 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3daf581ac..5909c26f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,386 @@ # Changelog +## [3.17.6](https://github.com/ynput/OpenPype/tree/3.17.6) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.5...3.17.6) + +### **🚀 Enhancements** + + +
+Testing: Validate Maya Logs #5775 + +This PR adds testing of the logs within Maya such as Python and Pyblish errors.The reason why we need to touch so many files outside of Maya is because of the pyblish errors below; +``` +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "collect_otio_frame_ranges" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "collect_otio_review" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "collect_otio_review" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "collect_otio_subset_resources" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_audio_tracks" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_file" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_file" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_review" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_review" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') +# Error: pyblish.plugin : Skipped: "extract_otio_trimming_video" (No module named 'opentimelineio') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_blender_deadline" (No module named 'bpy') +# Error: pyblish.plugin : Skipped: "submit_blender_deadline" (No module named 'bpy') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_houdini_remote_publish" (No module named 'hou') +# Error: pyblish.plugin : Skipped: "submit_houdini_remote_publish" (No module named 'hou') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_houdini_render_deadline" (No module named 'hou') +# Error: pyblish.plugin : Skipped: "submit_houdini_render_deadline" (No module named 'hou') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_max_deadline" (No module named 'pymxs') +# Error: pyblish.plugin : Skipped: "submit_max_deadline" (No module named 'pymxs') # +pyblish (ERROR) (line: 1371) pyblish.plugin: +Skipped: "submit_nuke_deadline" (No module named 'nuke') +# Error: pyblish.plugin : Skipped: "submit_nuke_deadline" (No module named 'nuke') # +``` +We also needed to `stdout` and `stderr` from the launched application to capture the output.Split from #5644.Dependent on #5734 + + +___ + +
+ + +
+Maya: Render Settings cleanup remove global `RENDER_ATTRS` #5801 + +Remove global `lib.RENDER_ATTRS` and implement a `RenderSettings.get_padding_attr(renderer)` method instead. + + +___ + +
+ + +
+Testing: Ingest expected files and input workfile #5840 + +This ingests the Maya workfile from the Drive storage. Have changed the format to MayaAscii so its easier to see what changes are happening in a PR. This meant changing the expected files and database entries as well. + + +___ + +
+ + +
+Chore: Create plugin auto-apply settings #5908 + +Create plugins can auto-apply settings. + + +___ + +
+ + +
+Resolve: Add save current file button + "Save" shortcut when menu is active #5691 + +Adds a "Save current file" to the OpenPype menu.Also adds a "Save" shortcut key sequence (CTRL+S on Windows) to the button, so that clicking CTRL+S when the menu is active will save the current workfile. However this of course does not work if the menu does not receive the key press event (e.g. when Resolve UI is active instead)Resolves #5684 + + +___ + +
+ + +
+Reference USD file as maya native geometry #5781 + +Add MayaUsdReferenceLoader to reference USD as Maya native geometry using `mayaUSDImport` file translator. + + +___ + +
+ + +
+Max: Bug fix on wrong aspect ratio and viewport not being maximized during context in review family #5839 + +This PR will fix the bug on wrong aspect ratio and viewport not being maximized when creating preview animationBesides, the support of tga image format and the options for AA quality are implemented in this PR + + +___ + +
+ + +
+Blender: Incorporate blender "Collections" into Publish/Load #5841 + +Allow `blendScene` family to include collections. + + +___ + +
+ + +
+Max: Allows user preset the setting of preview animation in OP/AYON Setting #5859 + +Allows user preset the setting of preview animation in OP/AYON Setting for review family. +- [x] Openpype +- [x] AYON + + +___ + +
+ + +
+Publisher: Center publisher window on first show #5877 + +Move publisher window to center of a screen on first show. + + +___ + +
+ + +
+Publisher: Instance context changes confirm works #5881 + +Confirmation of context changes in publisher on existing instances does not cause glitches. + + +___ + +
+ + +
+AYON workfiles tools: Revisit workfiles tool #5897 + +Revisited workfiles tool for AYON mode to reuse common models and widgets. + + +___ + +
+ + +
+Nuke: updated colorspace settings #5906 + +Updating nuke colorspace settings into more convenient way with usage of ocio config roles rather then particular colorspace names. This way we should not have troubles to switch between linear Rec709 or ACES configs without any additional settings changes. + + +___ + +
+ + +
+Blender: Refactor to new publisher #5910 + +Refactor Blender integration to use the new publisher + + +___ + +
+ + +
+Enhancement: Some publish logs cosmetics #5917 + +General logging message tweaks: +- Sort some lists of folder/filenames so they appear sorted in the logs +- Fix some grammar / typos +- In some cases provide slightly more information in a log + + +___ + +
+ + +
+Blender: Better name of 'asset_name' function #5927 + +Renamed function `asset_name` to `prepare_scene_name`. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Bug fix the fbx animation export errored out when the skeletonAnim set is empty #5875 + +Resolve this bug discordIf the skeletonAnim SET is empty and fbx animation collect, the fbx animation extractor would skip the fbx extraction + + +___ + +
+ + +
+Bugfix: fix few typos in houdini's and Maya's Ayon settings #5882 + +Fixing few typos +- [x] Maya unreal static mesh +- [x] Houdini static mesh +- [x] Houdini collect asset handles + + +___ + +
+ + +
+Bugfix: Ayon Deadline env vars + error message on no executable found #5815 + +Fix some Ayon x Deadline issues as came up in this topic: +- missing Environment Variables issue explained here for `deadlinePlugin.RunProcess` for the AYON _extract environments_ call. +- wrong error formatting described here with a `;` between each character like this: `Ayon executable was not found in the semicolon separated list "C;:;/;P;r;o;g;r;a;m; ;F;i;l;e;s;/;Y;n;p;u;t;/;A;Y;O;N; ;1;.;0;.;0;-;b;e;t;a;.;5;/;a;y;o;n;_;c;o;n;s;o;l;e;.;e;x;e". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor.` + + +___ + +
+ + +
+AYON: Fix bundles access in settings #5856 + +Fixed access to bundles data in settings to define correct develop variant. + + +___ + +
+ + +
+AYON 3dsMax settings: 'ValidateAttributes' settings converte only if available #5878 + +Convert `ValidateAttributes` settings only if are available in AYON settings. + + +___ + +
+ + +
+AYON: Fix TrayPublisher editorial settings #5880 + +Fixing Traypublisher settings for adding task in simple editorial. + + +___ + +
+ + +
+TrayPublisher: editorial frame range check not needed #5884 + +Validator for frame ranges is not needed during editorial publishing since entity data are not yet in database. + + +___ + +
+ + +
+Update houdini license validator #5886 + +As reported in this community commentHoudini USD publishing is only restricted in Houdini apprentice. + + +___ + +
+ + +
+Blender: Fix blend extraction and packed images #5888 + +Fixed a with blend extractor and packed images. + + +___ + +
+ + +
+AYON: Initialize connection with all information #5890 + +Create global AYON api connection with all informations all the time. + + +___ + +
+ + +
+AYON: Scene inventory tool without site sync #5896 + +Skip 'get_site_icons' if site sync addon is disabled. + + +___ + +
+ + +
+Publish report tool: Fix PySide6 #5898 + +Use constants from classes instead of objects. + + +___ + +
+ + +
+fusion: removing hardcoded template name for saver #5907 + +Fusion is not hardcoded for `render` anatomy template only anymore. This was blocking AYON deployment. + + +___ + +
+ + + + ## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5) diff --git a/openpype/version.py b/openpype/version.py index b7394c203d..adb62abd9d 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.6-nightly.3" +__version__ = "3.17.6" diff --git a/pyproject.toml b/pyproject.toml index c6f4880cdd..21ba7d1199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.5" # OpenPype +version = "3.17.6" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 95209df395b995782cc5bba872ca34d70bec21a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Nov 2023 12:46:03 +0000 Subject: [PATCH 36/45] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 86e3638ffe..6f1b01bd2f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.6 - 3.17.6-nightly.3 - 3.17.6-nightly.2 - 3.17.6-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.2-nightly.3 - 3.15.2-nightly.2 - 3.15.2-nightly.1 - - 3.15.1 validations: required: true - type: dropdown From 05673e934d6c872c953243d059d468a3bc9f1d77 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:26:52 +0100 Subject: [PATCH 37/45] AYON: Loader tool bugs hunt (#5915) * handle cases when server is missing product type in project database * ignore representations which do not match uuid * add comments --- openpype/tools/ayon_loader/control.py | 18 ++++++++++++++++-- openpype/tools/ayon_loader/models/products.py | 19 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 2b779f5c2e..c38973d0b3 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -1,4 +1,5 @@ import logging +import uuid import ayon_api @@ -314,8 +315,21 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): containers = self._host.get_containers() else: containers = self._host.ls() - repre_ids = {c.get("representation") for c in containers} - repre_ids.discard(None) + repre_ids = set() + for container in containers: + repre_id = container.get("representation") + # Ignore invalid representation ids. + # - invalid representation ids may be available if e.g. is + # opened scene from OpenPype whe 'ObjectId' was used instead + # of 'uuid'. + # NOTE: Server call would crash if there is any invalid id. + # That would cause crash we won't get any information. + try: + uuid.UUID(repre_id) + repre_ids.add(repre_id) + except ValueError: + pass + product_ids = self._products_model.get_product_ids_by_repre_ids( project_name, repre_ids ) diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 33023cc164..816dabaf90 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -77,7 +77,15 @@ def product_item_from_entity( product_attribs = product_entity["attrib"] group = product_attribs.get("productGroup") product_type = product_entity["productType"] - product_type_item = product_type_items_by_name[product_type] + product_type_item = product_type_items_by_name.get(product_type) + # NOTE This is needed for cases when products were not created on server + # using api functions. In that case product type item may not be + # available and we need to create a default. + if product_type_item is None: + product_type_item = create_default_product_type_item(product_type) + # Cache the item for future use + product_type_items_by_name[product_type] = product_type_item + product_type_icon = product_type_item.icon product_icon = { @@ -117,6 +125,15 @@ def product_type_item_from_data(product_type_data): return ProductTypeItem(product_type_data["name"], icon, True) +def create_default_product_type_item(product_type): + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductTypeItem(product_type, icon, True) + + class ProductsModel: """Model for products, version and representation. From a3fc30b408161b6d6aa116142661acffbf9659ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:27:33 +0100 Subject: [PATCH 38/45] use project, task and host name from context data (#5918) --- .../hosts/photoshop/plugins/publish/collect_auto_image.py | 4 ++-- .../hosts/photoshop/plugins/publish/collect_auto_review.py | 4 ++-- .../hosts/photoshop/plugins/publish/collect_auto_workfile.py | 4 ++-- .../modules/deadline/plugins/publish/submit_publish_job.py | 3 ++- .../modules/slack/plugins/publish/collect_slack_family.py | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py index 77f1a3e91f..d4b5f480b1 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py @@ -22,9 +22,9 @@ class CollectAutoImage(pyblish.api.ContextPlugin): self.log.debug("Auto image instance found, won't create new") return - project_name = context.data["anatomyData"]["project"]["name"] + project_name = context.data["projectName"] proj_settings = context.data["project_settings"] - task_name = context.data["anatomyData"]["task"]["name"] + task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] asset_name = asset_doc["name"] diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py index 82ba0ac09c..8964582a45 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_review.py @@ -60,9 +60,9 @@ class CollectAutoReview(pyblish.api.ContextPlugin): variant = (context.data.get("variant") or auto_creator["default_variant"]) - project_name = context.data["anatomyData"]["project"]["name"] + project_name = context.data["projectName"] proj_settings = context.data["project_settings"] - task_name = context.data["anatomyData"]["task"]["name"] + task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] asset_name = asset_doc["name"] diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py index 01dc50af40..d3cc8d44d0 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_workfile.py @@ -51,7 +51,7 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin): self.log.debug("Workfile instance disabled") return - project_name = context.data["anatomyData"]["project"]["name"] + project_name = context.data["projectName"] proj_settings = context.data["project_settings"] auto_creator = proj_settings.get( "photoshop", {}).get( @@ -66,7 +66,7 @@ class CollectAutoWorkfile(pyblish.api.ContextPlugin): variant = (context.data.get("variant") or auto_creator["default_variant"]) - task_name = context.data["anatomyData"]["task"]["name"] + task_name = context.data["task"] host_name = context.data["hostName"] asset_doc = context.data["assetEntity"] asset_name = asset_doc["name"] diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 6ed5819f2b..c9019b496b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -708,6 +708,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, """ project_name = context.data["projectName"] + host_name = context.data["hostName"] if not version: version = get_last_version_by_subset_name( project_name, @@ -719,7 +720,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, else: version = get_versioning_start( project_name, - template_data["app"], + host_name, task_name=template_data["task"]["name"], task_type=template_data["task"]["type"], family="render", diff --git a/openpype/modules/slack/plugins/publish/collect_slack_family.py b/openpype/modules/slack/plugins/publish/collect_slack_family.py index b3e7bbdcec..cbed2d1012 100644 --- a/openpype/modules/slack/plugins/publish/collect_slack_family.py +++ b/openpype/modules/slack/plugins/publish/collect_slack_family.py @@ -38,7 +38,7 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin, "families": family, "tasks": task_data.get("name"), "task_types": task_data.get("type"), - "hosts": instance.data["anatomyData"]["app"], + "hosts": instance.context.data["hostName"], "subsets": instance.data["subset"] } profile = filter_profiles(self.profiles, key_values, From 7c86115b7e40625e670e0fa69ca5e28929b3ee57 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:41:41 +0100 Subject: [PATCH 39/45] AYON: Handle staging templates category (#5905) * do template replacements on all templates * handle and convert staging templates * use key 'staging' instead of 'staging_dir' --- openpype/client/server/conversion_utils.py | 45 ++++++++++++++++------ 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index 8c18cb1c13..51af99e722 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -138,16 +138,22 @@ def _template_replacements_to_v3(template): ) -def _convert_template_item(template): - # Others won't have 'directory' - if "directory" not in template: - return - folder = _template_replacements_to_v3(template.pop("directory")) - template["folder"] = folder - template["file"] = _template_replacements_to_v3(template["file"]) - template["path"] = "/".join( - (folder, template["file"]) - ) +def _convert_template_item(template_item): + for key, value in tuple(template_item.items()): + template_item[key] = _template_replacements_to_v3(value) + + # Change 'directory' to 'folder' + if "directory" in template_item: + template_item["folder"] = template_item.pop("directory") + + if ( + "path" not in template_item + and "file" in template_item + and "folder" in template_item + ): + template_item["path"] = "/".join( + (template_item["folder"], template_item["file"]) + ) def _fill_template_category(templates, cat_templates, cat_key): @@ -212,10 +218,27 @@ def convert_v4_project_to_v3(project): _convert_template_item(template) new_others_templates[name] = template + staging_templates = templates.pop("staging", None) + # Key 'staging_directories' is legacy key that changed + # to 'staging_dir' + _legacy_staging_templates = templates.pop("staging_directories", None) + if staging_templates is None: + staging_templates = _legacy_staging_templates + + if staging_templates is None: + staging_templates = {} + + # Prefix all staging template names with 'staging_' prefix + # and add them to 'others' + for name, template in staging_templates.items(): + _convert_template_item(template) + new_name = "staging_{}".format(name) + new_others_templates[new_name] = template + for key in ( "work", "publish", - "hero" + "hero", ): cat_templates = templates.pop(key) _fill_template_category(templates, cat_templates, key) From fe8711cf7804336593de7d0ac1ff94ec6e1b3b13 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:42:44 +0100 Subject: [PATCH 40/45] Publisher: Bugfixes and enhancements (#5924) * fix logger getter * catch crashes of create plugin initializations * use 'product' instead of 'subset' in AYON mode * fix import --- openpype/tools/publisher/control.py | 19 ++++++++++++++----- .../publisher/widgets/overview_widget.py | 7 ++++++- openpype/tools/publisher/widgets/widgets.py | 10 ++++++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index a6264303d5..3192fe949f 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -1453,7 +1453,7 @@ class BasePublisherController(AbstractPublisherController): """ if self._log is None: - self._log = logging.getLogget(self.__class__.__name__) + self._log = logging.getLogger(self.__class__.__name__) return self._log @property @@ -1881,10 +1881,19 @@ class PublisherController(BasePublisherController): self._emit_event("plugins.refresh.finished") def _collect_creator_items(self): - return { - identifier: CreatorItem.from_creator(creator) - for identifier, creator in self._create_context.creators.items() - } + # TODO add crashed initialization of create plugins to report + output = {} + for identifier, creator in self._create_context.creators.items(): + try: + output[identifier] = CreatorItem.from_creator(creator) + except Exception: + self.log.error( + "Failed to create creator item for '%s'", + identifier, + exc_info=True + ) + + return output def _reset_instances(self): """Reset create instances.""" diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 778aa1139f..10151250f6 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -1,5 +1,7 @@ from qtpy import QtWidgets, QtCore +from openpype import AYON_SERVER_ENABLED + from .border_label_widget import BorderedLabelWidget from .card_view_widgets import InstanceCardView @@ -35,7 +37,10 @@ class OverviewWidget(QtWidgets.QFrame): # --- Created Subsets/Instances --- # Common widget for creation and overview subset_views_widget = BorderedLabelWidget( - "Subsets to publish", subset_content_widget + "{} to publish".format( + "Products" if AYON_SERVER_ENABLED else "Subsets" + ), + subset_content_widget ) subset_view_cards = InstanceCardView(controller, subset_views_widget) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 6dbeaad821..1860287fcf 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -210,7 +210,9 @@ class CreateBtn(PublishIconBtn): def __init__(self, parent=None): icon_path = get_icon_path("create") super(CreateBtn, self).__init__(icon_path, "Create", parent) - self.setToolTip("Create new subset/s") + self.setToolTip("Create new {}/s".format( + "product" if AYON_SERVER_ENABLED else "subset" + )) self.setLayoutDirection(QtCore.Qt.RightToLeft) @@ -655,7 +657,11 @@ class TasksCombobox(QtWidgets.QComboBox): self._proxy_model.set_filter_empty(invalid) if invalid: self._set_is_valid(False) - self.set_text("< One or more subsets require Task selected >") + self.set_text( + "< One or more {} require Task selected >".format( + "products" if AYON_SERVER_ENABLED else "subsets" + ) + ) else: self.set_text(None) From 19f0a77966dbc84898e48425b06015416c48eeef Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 16 Nov 2023 17:22:04 +0100 Subject: [PATCH 41/45] Nuke: Change context label enhancement (#5887) * use QAction to change label of context action * do not handle context label change in nuke assist --- openpype/hosts/nuke/api/lib.py | 2 +- openpype/hosts/nuke/api/pipeline.py | 35 ++++++++++++++--------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 8b1ba0ab0d..de5d13d347 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -120,7 +120,7 @@ def deprecated(new_destination): class Context: main_window = None - context_label = None + context_action_item = None project_name = os.getenv("AVALON_PROJECT") # Workfile related code workfiles_launched = False diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index ba4d66ab63..7bc17ff504 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -236,9 +236,13 @@ def _install_menu(): if not ASSIST: label = get_context_label() - Context.context_label = label - context_action = menu.addCommand(label) - context_action.setEnabled(False) + context_action_item = menu.addCommand("Context") + context_action_item.setEnabled(False) + + Context.context_action_item = context_action_item + + context_action = context_action_item.action() + context_action.setText(label) # add separator after context label menu.addSeparator() @@ -348,26 +352,21 @@ def _install_menu(): def change_context_label(): - menubar = nuke.menu("Nuke") - menu = menubar.findItem(MENU_LABEL) + if ASSIST: + return - label = get_context_label() + context_action_item = Context.context_action_item + if context_action_item is None: + return + context_action = context_action_item.action() - rm_item = [ - (i, item) for i, item in enumerate(menu.items()) - if Context.context_label in item.name() - ][0] + old_label = context_action.text() + new_label = get_context_label() - menu.removeItem(rm_item[1].name()) - - context_action = menu.addCommand( - label, - index=(rm_item[0]) - ) - context_action.setEnabled(False) + context_action.setText(new_label) log.info("Task label changed from `{}` to `{}`".format( - Context.context_label, label)) + old_label, new_label)) def add_shortcuts_from_presets(): From 4e4277bd0383e0354965af819fd29e1dabe4479c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 18 Nov 2023 03:24:59 +0000 Subject: [PATCH 42/45] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index adb62abd9d..9d8724f926 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.6" +__version__ = "3.17.7-nightly.1" From 771e40cf5105cf6acc4130c6797ac582f5b3d291 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Nov 2023 03:25:34 +0000 Subject: [PATCH 43/45] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 6f1b01bd2f..f484016bfe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.7-nightly.1 - 3.17.6 - 3.17.6-nightly.3 - 3.17.6-nightly.2 @@ -134,7 +135,6 @@ body: - 3.15.2-nightly.4 - 3.15.2-nightly.3 - 3.15.2-nightly.2 - - 3.15.2-nightly.1 validations: required: true - type: dropdown From 8348fe954ed759d934dc1369cd036848382be9d6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Nov 2023 11:02:35 +0100 Subject: [PATCH 44/45] AYON: Prepare for 'data' via graphql (#5923) * function 'get_folders_with_tasks' can expect 'data' in graphql result * fix docstring --- openpype/client/server/openpype_comp.py | 31 ++++++++++++++----------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/openpype/client/server/openpype_comp.py b/openpype/client/server/openpype_comp.py index a123fe3167..71a141e913 100644 --- a/openpype/client/server/openpype_comp.py +++ b/openpype/client/server/openpype_comp.py @@ -1,4 +1,7 @@ import collections +import json + +import six from ayon_api.graphql import GraphQlQuery, FIELD_VALUE, fields_to_dict from .constants import DEFAULT_FOLDER_FIELDS @@ -84,12 +87,12 @@ def get_folders_with_tasks( for folder. All possible folder fields are returned if 'None' is passed. - Returns: - List[Dict[str, Any]]: Queried folder entities. + Yields: + Dict[str, Any]: Queried folder entities. """ if not project_name: - return [] + return filters = { "projectName": project_name @@ -97,25 +100,25 @@ def get_folders_with_tasks( if folder_ids is not None: folder_ids = set(folder_ids) if not folder_ids: - return [] + return filters["folderIds"] = list(folder_ids) if folder_paths is not None: folder_paths = set(folder_paths) if not folder_paths: - return [] + return filters["folderPaths"] = list(folder_paths) if folder_names is not None: folder_names = set(folder_names) if not folder_names: - return [] + return filters["folderNames"] = list(folder_names) if parent_ids is not None: parent_ids = set(parent_ids) if not parent_ids: - return [] + return if None in parent_ids: # Replace 'None' with '"root"' which is used during GraphQl # query for parent ids filter for folders without folder @@ -147,10 +150,10 @@ def get_folders_with_tasks( parsed_data = query.query(con) folders = parsed_data["project"]["folders"] - if active is None: - return folders - return [ - folder - for folder in folders - if folder["active"] is active - ] + for folder in folders: + if active is not None and folder["active"] is not active: + continue + folder_data = folder.get("data") + if isinstance(folder_data, six.string_types): + folder["data"] = json.loads(folder_data) + yield folder From 07d00ee787fe287292075ca4914b810e890c3778 Mon Sep 17 00:00:00 2001 From: Kayla Man <64118225+moonyuet@users.noreply.github.com> Date: Mon, 20 Nov 2023 21:30:09 +0800 Subject: [PATCH 45/45] Chore: Substance Painter Addons for Ayon (#5914) * Substance Painter Addons for Ayon * hound * make sure the class name is SubstancePainterAddon * use AYON as tab menu name when it is launched with AYON --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../hosts/substancepainter/api/pipeline.py | 3 +- openpype/settings/ayon_settings.py | 18 ++++++ .../applications/server/applications.json | 26 ++++++++ server_addon/applications/server/settings.py | 2 + server_addon/applications/server/version.py | 2 +- .../substancepainter/server/__init__.py | 17 ++++++ .../server/settings/__init__.py | 10 +++ .../server/settings/imageio.py | 61 +++++++++++++++++++ .../substancepainter/server/settings/main.py | 26 ++++++++ .../substancepainter/server/version.py | 1 + 10 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 server_addon/substancepainter/server/__init__.py create mode 100644 server_addon/substancepainter/server/settings/__init__.py create mode 100644 server_addon/substancepainter/server/settings/imageio.py create mode 100644 server_addon/substancepainter/server/settings/main.py create mode 100644 server_addon/substancepainter/server/version.py diff --git a/openpype/hosts/substancepainter/api/pipeline.py b/openpype/hosts/substancepainter/api/pipeline.py index e96064b2bf..a13075127f 100644 --- a/openpype/hosts/substancepainter/api/pipeline.py +++ b/openpype/hosts/substancepainter/api/pipeline.py @@ -170,7 +170,8 @@ class SubstanceHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): parent = substance_painter.ui.get_main_window() - menu = QtWidgets.QMenu("OpenPype") + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" + menu = QtWidgets.QMenu(tab_menu_label) action = menu.addAction("Create...") action.triggered.connect( diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 771598f51f..5171517232 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -940,6 +940,23 @@ def _convert_photoshop_project_settings(ayon_settings, output): output["photoshop"] = ayon_photoshop +def _convert_substancepainter_project_settings(ayon_settings, output): + if "substancepainter" not in ayon_settings: + return + + ayon_substance_painter = ayon_settings["substancepainter"] + _convert_host_imageio(ayon_substance_painter) + if "shelves" in ayon_substance_painter: + shelves_items = ayon_substance_painter["shelves"] + new_shelves_items = { + item["name"]: item["value"] + for item in shelves_items + } + ayon_substance_painter["shelves"] = new_shelves_items + + output["substancepainter"] = ayon_substance_painter + + def _convert_tvpaint_project_settings(ayon_settings, output): if "tvpaint" not in ayon_settings: return @@ -1398,6 +1415,7 @@ def convert_project_settings(ayon_settings, default_settings): _convert_nuke_project_settings(ayon_settings, output) _convert_hiero_project_settings(ayon_settings, output) _convert_photoshop_project_settings(ayon_settings, output) + _convert_substancepainter_project_settings(ayon_settings, output) _convert_tvpaint_project_settings(ayon_settings, output) _convert_traypublisher_project_settings(ayon_settings, output) _convert_webpublisher_project_settings(ayon_settings, output) diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index db7f86e357..f846b04215 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -1092,6 +1092,32 @@ } ] }, + "substancepainter": { + "enabled": true, + "label": "Substance Painter", + "icon": "{}/app_icons/substancepainter.png", + "host_name": "substancepainter", + "environment": "{}", + "variants": [ + { + "name": "8-2-0", + "label": "8.2", + "executables": { + "windows": [ + "C:\\Program Files\\Adobe\\Adobe Substance 3D Painter\\Adobe Substance 3D Painter.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": "{}" + } + ] + }, "unreal": { "enabled": true, "label": "Unreal Editor", diff --git a/server_addon/applications/server/settings.py b/server_addon/applications/server/settings.py index be9a2ea07e..981d56c30f 100644 --- a/server_addon/applications/server/settings.py +++ b/server_addon/applications/server/settings.py @@ -164,6 +164,8 @@ class ApplicationsSettings(BaseSettingsModel): default_factory=AppGroupWithPython, title="Adobe After Effects") celaction: AppGroup = Field( default_factory=AppGroupWithPython, title="Celaction 2D") + substancepainter: AppGroup = Field( + default_factory=AppGroupWithPython, title="Substance Painter") unreal: AppGroup = Field( default_factory=AppGroupWithPython, title="Unreal Editor") additional_apps: list[AdditionalAppGroup] = Field( diff --git a/server_addon/applications/server/version.py b/server_addon/applications/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/applications/server/version.py +++ b/server_addon/applications/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/server_addon/substancepainter/server/__init__.py b/server_addon/substancepainter/server/__init__.py new file mode 100644 index 0000000000..2bf808d508 --- /dev/null +++ b/server_addon/substancepainter/server/__init__.py @@ -0,0 +1,17 @@ +from typing import Type + +from ayon_server.addons import BaseServerAddon + +from .version import __version__ +from .settings import SubstancePainterSettings, DEFAULT_SPAINTER_SETTINGS + + +class SubstancePainterAddon(BaseServerAddon): + name = "substancepainter" + title = "Substance Painter" + version = __version__ + settings_model: Type[SubstancePainterSettings] = SubstancePainterSettings + + async def get_default_settings(self): + settings_model_cls = self.get_settings_model() + return settings_model_cls(**DEFAULT_SPAINTER_SETTINGS) diff --git a/server_addon/substancepainter/server/settings/__init__.py b/server_addon/substancepainter/server/settings/__init__.py new file mode 100644 index 0000000000..f47f064536 --- /dev/null +++ b/server_addon/substancepainter/server/settings/__init__.py @@ -0,0 +1,10 @@ +from .main import ( + SubstancePainterSettings, + DEFAULT_SPAINTER_SETTINGS, +) + + +__all__ = ( + "SubstancePainterSettings", + "DEFAULT_SPAINTER_SETTINGS", +) diff --git a/server_addon/substancepainter/server/settings/imageio.py b/server_addon/substancepainter/server/settings/imageio.py new file mode 100644 index 0000000000..e301d3d865 --- /dev/null +++ b/server_addon/substancepainter/server/settings/imageio.py @@ -0,0 +1,61 @@ +from pydantic import Field, validator +from ayon_server.settings import BaseSettingsModel +from ayon_server.settings.validators import ensure_unique_names + + +class ImageIOConfigModel(BaseSettingsModel): + override_global_config: bool = Field( + False, + title="Override global OCIO config" + ) + filepath: list[str] = Field( + default_factory=list, + title="Config path" + ) + + +class ImageIOFileRuleModel(BaseSettingsModel): + name: str = Field("", title="Rule name") + pattern: str = Field("", title="Regex pattern") + colorspace: str = Field("", title="Colorspace name") + ext: str = Field("", title="File extension") + + +class ImageIOFileRulesModel(BaseSettingsModel): + activate_host_rules: bool = Field(False) + rules: list[ImageIOFileRuleModel] = Field( + default_factory=list, + title="Rules" + ) + + @validator("rules") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ImageIOSettings(BaseSettingsModel): + activate_host_color_management: bool = Field( + True, title="Enable Color Management" + ) + ocio_config: ImageIOConfigModel = Field( + default_factory=ImageIOConfigModel, + title="OCIO config" + ) + file_rules: ImageIOFileRulesModel = Field( + default_factory=ImageIOFileRulesModel, + title="File Rules" + ) + + +DEFAULT_IMAGEIO_SETTINGS = { + "activate_host_color_management": True, + "ocio_config": { + "override_global_config": False, + "filepath": [] + }, + "file_rules": { + "activate_host_rules": False, + "rules": [] + } +} diff --git a/server_addon/substancepainter/server/settings/main.py b/server_addon/substancepainter/server/settings/main.py new file mode 100644 index 0000000000..f8397c3c08 --- /dev/null +++ b/server_addon/substancepainter/server/settings/main.py @@ -0,0 +1,26 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel +from .imageio import ImageIOSettings, DEFAULT_IMAGEIO_SETTINGS + + +class ShelvesSettingsModel(BaseSettingsModel): + _layout = "compact" + name: str = Field(title="Name") + value: str = Field(title="Path") + + +class SubstancePainterSettings(BaseSettingsModel): + imageio: ImageIOSettings = Field( + default_factory=ImageIOSettings, + title="Color Management (ImageIO)" + ) + shelves: list[ShelvesSettingsModel] = Field( + default_factory=list, + title="Shelves" + ) + + +DEFAULT_SPAINTER_SETTINGS = { + "imageio": DEFAULT_IMAGEIO_SETTINGS, + "shelves": [] +} diff --git a/server_addon/substancepainter/server/version.py b/server_addon/substancepainter/server/version.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/server_addon/substancepainter/server/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0"