From 8dea1724f9d13c1bb11f6840fb73228eb95b7086 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 23 Jun 2023 16:01:24 +0100 Subject: [PATCH 01/90] Check custom staging dir for Maya images folder. --- .../publish/validate_render_image_rule.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index 78bb022785..96a57ee5d2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -1,3 +1,5 @@ +import os + from maya import cmds import pyblish.api @@ -24,8 +26,12 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): def process(self, instance): - required_images_rule = self.get_default_render_image_folder(instance) - current_images_rule = cmds.workspace(fileRuleEntry="images") + required_images_rule = os.path.normpath( + self.get_default_render_image_folder(self, instance) + ) + current_images_rule = os.path.normpath( + cmds.workspace(fileRuleEntry="images") + ) assert current_images_rule == required_images_rule, ( "Invalid workspace `images` file rule value: '{}'. " @@ -37,7 +43,9 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - required_images_rule = cls.get_default_render_image_folder(instance) + required_images_rule = cls.get_default_render_image_folder( + cls, instance + ) current_images_rule = cmds.workspace(fileRuleEntry="images") if current_images_rule != required_images_rule: @@ -45,7 +53,16 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): cmds.workspace(saveWorkspace=True) @staticmethod - def get_default_render_image_folder(instance): + def get_default_render_image_folder(cls, instance): + staging_dir = instance.data.get("stagingDir") + if staging_dir: + cls.log.debug( + "Staging dir found: \"{}\". Ignoring setting from " + "`project_settings/maya/RenderSettings/" + "default_render_image_folder`.".format(staging_dir) + ) + return staging_dir + return instance.context.data.get('project_settings')\ .get('maya') \ .get('RenderSettings') \ From 7dba2378844a2268ed1f8dc7f3cc6a8a3d47d02e Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 23 Jun 2023 16:21:45 +0100 Subject: [PATCH 02/90] Docs and setting note. --- .../schemas/schema_maya_render_settings.json | 2 +- .../project_settings/settings_project_global.md | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json index 636dfa114c..fc4e750e3b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json @@ -12,7 +12,7 @@ { "type": "text", "key": "default_render_image_folder", - "label": "Default render image folder" + "label": "Default render image folder. This setting can be\noverwritten by custom staging directory profile;\n\"project_settings/global/tools/publish\n/custom_staging_dir_profiles\"." }, { "type": "boolean", diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 5ddf247d98..e0481a8717 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -192,7 +192,7 @@ A profile may generate multiple outputs from a single input. Each output must de - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags` - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. - + ### Extract Burnin Plugin is responsible for adding burnins into review representations. @@ -226,13 +226,13 @@ A burnin profile may set multiple burnin outputs from one input. The burnin's na | **Bottom Centered** | Bottom center content. | str | "{username}" | | **Bottom Right** | Bottom right corner content. | str | "{frame_start}-{current_frame}-{frame_end}" | -Each burnin profile can be configured with additional family filtering and can -add additional tags to the burnin representation, these can be configured under +Each burnin profile can be configured with additional family filtering and can +add additional tags to the burnin representation, these can be configured under the profile's **Additional filtering** section. :::note Filename suffix -The filename suffix is appended to filename of the source representation. For -example, if the source representation has suffix **"h264"** and the burnin +The filename suffix is appended to filename of the source representation. For +example, if the source representation has suffix **"h264"** and the burnin suffix is **"client"** then the final suffix is **"h264_client"**. ::: @@ -343,6 +343,10 @@ One of the key advantages of this feature is that it allows users to choose the In some cases, these DCCs (Nuke, Houdini, Maya) automatically add a rendering path during the creation stage, which is then used in publishing. Creators and extractors of such DCCs need to use these profiles to fill paths in DCC's nodes to use this functionality. +:::note +Maya's setting `project_settings/maya/RenderSettings/default_render_image_folder` is be overwritten by the custom staging dir. +::: + The custom staging folder uses a path template configured in `project_anatomy/templates/others` with `transient` being a default example path that could be used. The template requires a 'folder' key for it to be usable as custom staging folder. ##### Known issues From 49dc54c64767c62c7c71718cf047a489886f84ab Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Sun, 25 Jun 2023 15:37:52 +0100 Subject: [PATCH 03/90] Fix getting render paths. --- openpype/hosts/maya/plugins/publish/collect_render.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index babd494758..ac318dfbf7 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -201,10 +201,10 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # append full path aov_dict = {} - default_render_file = context.data.get('project_settings')\ - .get('maya')\ - .get('RenderSettings')\ - .get('default_render_image_folder') or "" + image_directory = os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="images") + ) # replace relative paths with absolute. Render products are # returned as list of dictionaries. publish_meta_path = None @@ -212,8 +212,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): full_paths = [] aov_first_key = list(aov.keys())[0] for file in aov[aov_first_key]: - full_path = os.path.join(workspace, default_render_file, - file) + full_path = os.path.join(image_directory, file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) publish_meta_path = os.path.dirname(full_path) From 96d796c4f5bdb51bd0461ad0f9a984019e48664c Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Sun, 25 Jun 2023 15:38:26 +0100 Subject: [PATCH 04/90] Account for custom staging directory persistentcy. --- .../hosts/maya/plugins/publish/extract_pointcache.py | 3 ++- .../hosts/maya/plugins/publish/extract_proxy_abc.py | 3 ++- .../hosts/maya/plugins/publish/extract_thumbnail.py | 3 ++- .../deadline/plugins/publish/submit_publish_job.py | 7 +++++-- openpype/plugins/publish/collect_rendered_files.py | 11 ++++++++--- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index f44c13767c..f0d914fd7a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -108,7 +108,8 @@ class ExtractAlembic(publish.Extractor): } instance.data["representations"].append(representation) - instance.context.data["cleanupFullPaths"].append(path) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(path) self.log.info("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py index cf6351fdca..5894907795 100644 --- a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py @@ -80,7 +80,8 @@ class ExtractProxyAlembic(publish.Extractor): } instance.data["representations"].append(representation) - instance.context.data["cleanupFullPaths"].append(path) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(path) self.log.info("Extracted {} to {}".format(instance, dirname)) # remove the bounding box diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 4160ac4cb2..3c7277121c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -92,7 +92,8 @@ class ExtractThumbnail(publish.Extractor): "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths - instance.context.data["cleanupFullPaths"].append(dst_staging) + if not instance.data.get("stagingDir_persistent", False): + instance.context.data["cleanupFullPaths"].append(dst_staging) filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 69e9fb6449..7c29a68dc7 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -844,7 +844,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, do_not_add_review = False if data.get("review"): families.append("review") - elif data.get("review") == False: + elif data.get("review") is False: self.log.debug("Instance has review explicitly disabled.") do_not_add_review = True @@ -872,7 +872,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "useSequenceForReview": data.get("useSequenceForReview", True), # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))), - "colorspace": instance.data.get("colorspace") + "colorspace": instance.data.get("colorspace"), + "stagingDir_persistent": instance.data.get( + "stagingDir_persistent", False + ) } # skip locking version if we are creating v01 diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 6c8d1e9ca5..4b95d8ac44 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -124,6 +124,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.info( f"Adding audio to instance: {instance.data['audio']}") + return instance.data.get("stagingDir_persistent", False) + def process(self, context): self._context = context @@ -160,9 +162,12 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): legacy_io.Session.update(session_data) os.environ.update(session_data) session_is_set = True - self._process_path(data, anatomy) - context.data["cleanupFullPaths"].append(path) - context.data["cleanupEmptyDirs"].append(os.path.dirname(path)) + staging_dir_persistent = self._process_path(data, anatomy) + if not staging_dir_persistent: + context.data["cleanupFullPaths"].append(path) + context.data["cleanupEmptyDirs"].append( + os.path.dirname(path) + ) except Exception as e: self.log.error(e, exc_info=True) raise Exception("Error") from e From 1f6934afbfc18816c53e64d9484405c34ac6c97f Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 26 Jun 2023 16:38:29 +0100 Subject: [PATCH 05/90] staticmethod > classmethod --- .../hosts/maya/plugins/publish/validate_render_image_rule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index 96a57ee5d2..fdb069ae43 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -52,7 +52,7 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): cmds.workspace(fileRule=("images", required_images_rule)) cmds.workspace(saveWorkspace=True) - @staticmethod + @classmethod def get_default_render_image_folder(cls, instance): staging_dir = instance.data.get("stagingDir") if staging_dir: From 8492491f8382acc9372017f8ae316117e11cb6ca Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 27 Jun 2023 10:40:51 +0100 Subject: [PATCH 06/90] Fix get_default_render_image_folder arguments. --- .../maya/plugins/publish/validate_render_image_rule.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index fdb069ae43..f8c848e08b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -27,7 +27,7 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): def process(self, instance): required_images_rule = os.path.normpath( - self.get_default_render_image_folder(self, instance) + self.get_default_render_image_folder(instance) ) current_images_rule = os.path.normpath( cmds.workspace(fileRuleEntry="images") @@ -43,9 +43,7 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - required_images_rule = cls.get_default_render_image_folder( - cls, instance - ) + required_images_rule = cls.get_default_render_image_folder(instance) current_images_rule = cmds.workspace(fileRuleEntry="images") if current_images_rule != required_images_rule: From a6727800a36be1d8abb25f4ffe19858f552c0d94 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 3 Jul 2023 12:10:39 +0100 Subject: [PATCH 07/90] Dont change thumbnail extraction --- openpype/hosts/maya/plugins/publish/extract_thumbnail.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 3c7277121c..a4e5d4f8df 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -92,8 +92,6 @@ class ExtractThumbnail(publish.Extractor): "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths - if not instance.data.get("stagingDir_persistent", False): - instance.context.data["cleanupFullPaths"].append(dst_staging) filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) From f7c368892b6a34a808b21a0f4da51b9f74b3228f Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 13 Jul 2023 15:49:16 +0100 Subject: [PATCH 08/90] Fix Maya Deadline submit plugin --- .../deadline/plugins/publish/submit_maya_deadline.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 159ac43289..8193ca2734 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -289,7 +289,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, def process_submission(self): instance = self._instance - context = instance.context filepath = self.scene_path # publish if `use_publish` else workfile @@ -306,13 +305,11 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self._patch_workfile() # Gather needed data ------------------------------------------------ - workspace = context.data["workspaceDir"] - default_render_file = instance.context.data.get('project_settings')\ - .get('maya')\ - .get('RenderSettings')\ - .get('default_render_image_folder') filename = os.path.basename(filepath) - dirname = os.path.join(workspace, default_render_file) + dirname = os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="images") + ) # Fill in common data to payload ------------------------------------ # TODO: Replace these with collected data from CollectRender From a2e18323ff1103861891cf84dd9e2ac3b6efab53 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 27 Jul 2023 22:32:43 +0300 Subject: [PATCH 09/90] update fbx creator --- .../houdini/plugins/create/create_fbx.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/create/create_fbx.py diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py new file mode 100644 index 0000000000..65f613bdea --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating fbx.""" +from openpype.hosts.houdini.api import plugin + +import hou + + +class CreateFilmboxFBX(plugin.HoudiniCreator): + """Filmbox FBX Driver""" + identifier = "io.openpype.creators.houdini.filmboxfbx" + label = "Filmbox FBX" + family = "filmboxfbx" + icon = "fa5s.cubes" + + def create(self, subset_name, instance_data, pre_create_data): + instance_data.pop("active", None) + instance_data.update({"node_type": "filmboxfbx"}) + + instance = super(CreateFilmboxFBX, self).create( + subset_name, + instance_data, + pre_create_data) + + instance_node = hou.node(instance.get("instance_node")) + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name)) + + parms = { + "sopoutput": output_path + } + + if self.selected_nodes: + selected_node = self.selected_nodes[0] + + # Although Houdini allows ObjNode path on `startnode` for the + # the ROP node we prefer it set to the SopNode path explicitly + + # Allow sop level paths (e.g. /obj/geo1/box1) + if isinstance(selected_node, hou.SopNode): + parms["startnode"] = selected_node.path() + self.log.debug( + "Valid SopNode selection, 'Export' in filmboxfbx" + " will be set to '%s'." + % selected_node + ) + + # Allow object level paths to Geometry nodes (e.g. /obj/geo1) + # but do not allow other object level nodes types like cameras, etc. + elif isinstance(selected_node, hou.ObjNode) and \ + selected_node.type().name() in ["geo"]: + + # get the output node with the minimum + # 'outputidx' or the node with display flag + sop_path = self.get_obj_output(selected_node) + + if sop_path: + parms["startnode"] = sop_path.path() + self.log.debug( + "Valid ObjNode selection, 'Export' in filmboxfbx " + "will be set to the child path '%s'." + % sop_path + ) + + if not parms.get("startnode", None): + self.log.debug( + "Selection isn't valid. 'Export' in filmboxfbx will be empty." + ) + else: + self.log.debug( + "No Selection. 'Export' in filmboxfbx will be empty." + ) + + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = [] + self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] + + def get_obj_output(self, obj_node): + """Find output node with the smallest 'outputidx'.""" + + outputs = obj_node.subnetOutputs() + + # if obj_node is empty + if not outputs: + return + + # if obj_node has one output child whether its + # sop output node or a node with the render flag + elif len(outputs) == 1: + return outputs[0] + + # if there are more than one, then it have multiple ouput nodes + # return the one with the minimum 'outputidx' + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) From 5a9281823d3d18cc9857875b796f7e65d9c747f1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 00:06:55 +0300 Subject: [PATCH 10/90] update create_fbx --- .../houdini/plugins/create/create_fbx.py | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 65f613bdea..b59a8ccfc6 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -12,23 +12,61 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): family = "filmboxfbx" icon = "fa5s.cubes" + # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): - instance_data.pop("active", None) + # instance_data.pop("active", None) + + # set node type instance_data.update({"node_type": "filmboxfbx"}) + # set essential extra parameters instance = super(CreateFilmboxFBX, self).create( subset_name, instance_data, pre_create_data) + # get the created node instance_node = hou.node(instance.get("instance_node")) + + # get output path output_path = hou.text.expandString( "$HIP/pyblish/{}.fbx".format(subset_name)) + # get selection + selection = self.get_selection() + + # parms dictionary parms = { + "startnode" : selection, "sopoutput": output_path } + # set parms + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = [] + self.lock_parameters(instance_node, to_lock) + + # Overrides HoudiniCreator.get_network_categories() + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] + + def get_selection(self): + """Selection Logic. + + how self.selected_nodes should be processed to get + the desirable node from selection. + + Returns: + str : node path + """ + + selection = "" + if self.selected_nodes: selected_node = self.selected_nodes[0] @@ -37,7 +75,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # Allow sop level paths (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): - parms["startnode"] = selected_node.path() + selection = selected_node.path() self.log.debug( "Valid SopNode selection, 'Export' in filmboxfbx" " will be set to '%s'." @@ -54,14 +92,14 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): sop_path = self.get_obj_output(selected_node) if sop_path: - parms["startnode"] = sop_path.path() + selection = sop_path.path() self.log.debug( "Valid ObjNode selection, 'Export' in filmboxfbx " "will be set to the child path '%s'." % sop_path ) - if not parms.get("startnode", None): + if not selection: self.log.debug( "Selection isn't valid. 'Export' in filmboxfbx will be empty." ) @@ -70,20 +108,12 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): "No Selection. 'Export' in filmboxfbx will be empty." ) - instance_node.setParms(parms) - - # Lock any parameters in this list - to_lock = [] - self.lock_parameters(instance_node, to_lock) - - def get_network_categories(self): - return [ - hou.ropNodeTypeCategory(), - hou.sopNodeTypeCategory() - ] + return selection def get_obj_output(self, obj_node): - """Find output node with the smallest 'outputidx'.""" + """Find output node with the smallest 'outputidx' + or return tje node with the render flag instead. + """ outputs = obj_node.subnetOutputs() From 9724ea4c84f51e68785cbd54c1d3eef87cdd4a3f Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 00:08:23 +0300 Subject: [PATCH 11/90] update create_fbx --- openpype/hosts/houdini/plugins/create/create_fbx.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index b59a8ccfc6..d4594edcbe 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- -"""Creator plugin for creating fbx.""" +"""Creator plugin for creating fbx. + +It was made to pratice publish process. +""" from openpype.hosts.houdini.api import plugin import hou From 8a431f2d44c967daab56dce3ed06fdfd7bcae0c5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 00:18:47 +0300 Subject: [PATCH 12/90] update create_fbx --- openpype/hosts/houdini/plugins/create/create_fbx.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index d4594edcbe..30cc98ddb9 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -31,11 +31,12 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # get the created node instance_node = hou.node(instance.get("instance_node")) - # get output path + # Get this node specific parms + # 1. get output path output_path = hou.text.expandString( "$HIP/pyblish/{}.fbx".format(subset_name)) - # get selection + # 2. get selection selection = self.get_selection() # parms dictionary From e4c9275ac36e1900d48a3605f2693a189ab92158 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 20:08:20 +0300 Subject: [PATCH 13/90] update default_variants --- openpype/hosts/houdini/plugins/create/create_fbx.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 30cc98ddb9..18fd879ac7 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -15,6 +15,9 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): family = "filmboxfbx" icon = "fa5s.cubes" + default_variant = "FBX" + default_variants = ["FBX", "Main", "Test"] + # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): # instance_data.pop("active", None) From 919247b3d4675b5febbb3ebebdc2b92075312399 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 21:17:38 +0300 Subject: [PATCH 14/90] update creator --- .../houdini/plugins/create/create_fbx.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 18fd879ac7..fed1ad0562 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -4,6 +4,7 @@ It was made to pratice publish process. """ from openpype.hosts.houdini.api import plugin +from openpype.lib import EnumDef import hou @@ -42,10 +43,14 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # 2. get selection selection = self.get_selection() + # 3. get Vertex Cache Format + vcformat = pre_create_data.get("vcformat") + # parms dictionary parms = { "startnode" : selection, - "sopoutput": output_path + "sopoutput": output_path, + "vcformat" : vcformat } # set parms @@ -62,6 +67,19 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): hou.sopNodeTypeCategory() ] + # Overrides HoudiniCreator.get_pre_create_attr_defs() + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + vcformat = EnumDef("vcformat", + items={ + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") + + return attrs + [vcformat] + def get_selection(self): """Selection Logic. From 7918668e72ec743c63d966cbf1db3f60159292ef Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 28 Jul 2023 22:15:59 +0300 Subject: [PATCH 15/90] add Valid Frame Range --- .../hosts/houdini/plugins/create/create_fbx.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index fed1ad0562..0345e07ea6 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -46,11 +46,15 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # 3. get Vertex Cache Format vcformat = pre_create_data.get("vcformat") + # 3. get Valid Frame Range + trange = pre_create_data.get("trange") + # parms dictionary parms = { "startnode" : selection, "sopoutput": output_path, - "vcformat" : vcformat + "vcformat" : vcformat, + "trange" : trange } # set parms @@ -77,8 +81,15 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): }, default=0, label="Vertex Cache Format") + trange = EnumDef("trange", + items={ + 0: "Render Current Frame", + 1: "Render Frame Range" + }, + default=0, + label="Valid Frame Range") - return attrs + [vcformat] + return attrs + [vcformat, trange] def get_selection(self): """Selection Logic. From ed2a48e2cd9af28fdd45237cecd9d1d9c17eab7e Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Sat, 29 Jul 2023 02:28:02 +0300 Subject: [PATCH 16/90] add fbx collector --- .../plugins/publish/collect_fbx_type.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/collect_fbx_type.py diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py new file mode 100644 index 0000000000..f25dbf3a5b --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -0,0 +1,56 @@ +"""Collector for filmboxfbx types. + +A Collector can act as a preprocessor for the validation stage. +It is used mainly to update instance.data + +P.S. + There are some collectors that run by default for all types. +""" +import pyblish.api + + +class CollectFilmboxfbxType(pyblish.api.InstancePlugin): + """Collect data type for filmboxfbx instance.""" + + order = pyblish.api.CollectorOrder + hosts = ["houdini"] + families = ["filmboxfbx"] + label = "Collect type of filmboxfbx" + + def process(self, instance): + + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 + # such a condition can be used to differentiate between + # instances by identifier even if they have the same type. + pass + + # Update instance.data with ouptut_node + out_node = self.get_output_node(instance) + + if out_node: + instance.data["output_node"] = out_node + + # Disclaimer : As a convntin we use collect_output_node.py + # to Update instance.data with ouptut_node of different types + # however, we use this collector instead for demonstration + + + def get_output_node(self, instance): + """Getting output_node Logic. + + It's moved here so that it become easier to focus on + process method. + """ + + import hou + + # get output node + node = hou.node(instance.data["instance_node"]) + out_node = node.parm("startnode").evalAsNode() + + if not out_node: + self.log.warning("No output node collected.") + return + + self.log.debug("Output node: %s" % out_node.path()) + return out_node From 051f03f4fa4e4718c6be56878ae2f8b07b5a3a11 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 31 Jul 2023 21:00:16 +0300 Subject: [PATCH 17/90] add export validator --- .../publish/validate_fbx_export_node.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py new file mode 100644 index 0000000000..f9f2461415 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""Validator plugin for Export node in filmbox instance.""" +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateNoExportPath(pyblish.api.InstancePlugin): + """Validate if Export node in filmboxfbx instance exists.""" + + order = pyblish.api.ValidatorOrder + families = ["filmboxfbx"] + label = "Validate Filmbox Export Node" + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Export node is incorrect", + title="Invalid Export Node" + ) + + @classmethod + def get_invalid(cls, instance): + + import hou + + fbx_rop = hou.node(instance.data.get("instance_node")) + export_node = fbx_rop.parm("startnode").evalAsNode() + + if not export_node: + cls.log.error( + ("Empty Export ('Export' parameter) found in " + "the filmbox instance - {}".format(fbx_rop.path())) + ) + return [fbx_rop] + + if not isinstance(export_node, hou.SopNode): + cls.log.error( + "Export node '{}' is not pointing to valid SOP" + " node".format(export_node.path()) + ) + return [export_node] From ecc583f8f2eec059e8589e6c45934540085aad34 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 22:04:04 +0300 Subject: [PATCH 18/90] update create collect validate --- .../houdini/plugins/create/create_fbx.py | 11 +- .../plugins/publish/collect_fbx_type.py | 1 + .../publish/validate_fbx_export_node.py | 43 ---- .../publish/validate_fbx_hierarchy_path.py | 220 ++++++++++++++++++ .../publish/validate_sop_output_node.py | 2 +- 5 files changed, 231 insertions(+), 46 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 0345e07ea6..ee2fdcd73f 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -2,7 +2,14 @@ """Creator plugin for creating fbx. It was made to pratice publish process. + +Filmbox by default expects an ObjNode +it's by default selects the output sop with mimimum idx +or the node with render flag isntead. + +to eleminate any confusion, we set the sop node explictly. """ + from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef @@ -16,8 +23,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): family = "filmboxfbx" icon = "fa5s.cubes" - default_variant = "FBX" - default_variants = ["FBX", "Main", "Test"] + default_variant = "Main" + default_variants = ["Main", "Test"] # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index f25dbf3a5b..05a0af659f 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -17,6 +17,7 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): families = ["filmboxfbx"] label = "Collect type of filmboxfbx" + # overrides InstancePlugin.process() def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py deleted file mode 100644 index f9f2461415..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_export_node.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator plugin for Export node in filmbox instance.""" -import pyblish.api -from openpype.pipeline import PublishValidationError - - -class ValidateNoExportPath(pyblish.api.InstancePlugin): - """Validate if Export node in filmboxfbx instance exists.""" - - order = pyblish.api.ValidatorOrder - families = ["filmboxfbx"] - label = "Validate Filmbox Export Node" - - def process(self, instance): - - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError( - "Export node is incorrect", - title="Invalid Export Node" - ) - - @classmethod - def get_invalid(cls, instance): - - import hou - - fbx_rop = hou.node(instance.data.get("instance_node")) - export_node = fbx_rop.parm("startnode").evalAsNode() - - if not export_node: - cls.log.error( - ("Empty Export ('Export' parameter) found in " - "the filmbox instance - {}".format(fbx_rop.path())) - ) - return [fbx_rop] - - if not isinstance(export_node, hou.SopNode): - cls.log.error( - "Export node '{}' is not pointing to valid SOP" - " node".format(export_node.path()) - ) - return [export_node] diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py new file mode 100644 index 0000000000..24f88b384f --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +"""It's almost the same as +'validate_primitive_hierarchy_paths.py' +however this one includes more comments for demonstration. + +FYI, path for fbx behaves a little differently. +In maya terms: +in Filmbox FBX: it sets the name of the object +in Alembic ROP: it sets the name of the shape +""" + +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou + +# Each validation can have a single repair action +# which calls the repair method +class AddDefaultPathAction(RepairAction): + label = "Add a default path" + icon = "mdi.pencil-plus-outline" + + +class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): + """Validate all primitives build hierarchy from attribute + when enabled. + + The name of the attribute must exist on the prims and have the + same name as Build Hierarchy from Attribute's `Path Attribute` + value on the FilmBox node. + This validation enables 'Build Hierarchy from Attribute' + by default. + """ + + # Usually you will this value by default + order = ValidateContentsOrder + 0.1 + families = ["filmboxfbx"] + hosts = ["houdini"] + label = "Validate FBX Hierarchy Path" + + # Validation can have as many actions as you want + # all of these actions are defined in a seperate place + # unlike the repair action + actions = [SelectInvalidAction, AddDefaultPathAction, + SelectROPAction] + + # overrides InstancePlugin.process() + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.name() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes), + title=self.label + ) + + # This method was named get_invalid as a convention + # it's also used by SelectInvalidAction to select + # the returned node + @classmethod + def get_invalid(cls, instance): + + output_node = instance.data.get("output_node") + rop_node = hou.node(instance.data["instance_node"]) + + if output_node is None: + cls.log.error( + "SOP Output node in '%s' does not exist. " + "Ensure a valid SOP output path is set.", + rop_node.path() + ) + + return [rop_node] + + build_from_path = rop_node.parm("buildfrompath").eval() + if not build_from_path: + cls.log.debug( + "Filmbox FBX has 'Build from Path' disabled. " + "Enbaling it as default." + ) + rop_node.parm("buildfrompath").set(1) + + path_attr = rop_node.parm("pathattrib").eval() + if not path_attr: + cls.log.debug( + "Filmbox FBX node has no Path Attribute" + "value set, setting it to 'path' as default." + ) + rop_node.parm("pathattrib").set("path") + + cls.log.debug("Checking for attribute: %s", path_attr) + + if not hasattr(output_node, "geometry"): + # In the case someone has explicitly set an Object + # node instead of a SOP node in Geometry context + # then for now we ignore - this allows us to also + # export object transforms. + cls.log.warning("No geometry output node found," + " skipping check..") + return + + # Check if the primitive attribute exists + frame = instance.data.get("frameStart", 0) + geo = output_node.geometryAtFrame(frame) + + # If there are no primitives on the current frame then + # we can't check whether the path names are correct. + # So we'll just issue a warning that the check can't + # be done consistently and skip validation. + + if len(geo.iterPrims()) == 0: + cls.log.warning( + "No primitives found on current frame." + " Validation for primitive hierarchy" + " paths will be skipped," + " thus can't be validated." + ) + return + + # Check if there are any values for the primitives + attrib = geo.findPrimAttrib(path_attr) + if not attrib: + cls.log.info( + "Geometry Primitives are missing " + "path attribute: `%s`", path_attr + ) + return [output_node] + + # Ensure at least a single string value is present + if not attrib.strings(): + cls.log.info( + "Primitive path attribute has no " + "string values: %s", path_attr + ) + return [output_node] + + paths = geo.primStringAttribValues(path_attr) + # Ensure all primitives are set to a valid path + # Collect all invalid primitive numbers + invalid_prims = [i for i, path in enumerate(paths) if not path] + if invalid_prims: + num_prims = len(geo.iterPrims()) # faster than len(geo.prims()) + cls.log.info( + "Prims have no value for attribute `%s` " + "(%s of %s prims)", + path_attr, len(invalid_prims), num_prims + ) + return [output_node] + + # what repair action expects to find and call + @classmethod + def repair(cls, instance): + """Add a default path attribute Action. + + It is a helper action more than a repair action, + used to add a default single value for the path. + """ + + rop_node = hou.node(instance.data["instance_node"]) + # I'm doing so because an artist may change output node + # before clicking the button. + output_node = rop_node.parm("startnode").evalAsNode() + + if not output_node: + cls.log.debug( + "Action isn't performed, invalid SOP Path on %s", + rop_node + ) + return + + # This check to prevent the action from running multiple times. + # git_invalid only returns [output_node] when + # path attribute is the problem + if cls.get_invalid(instance) != [output_node]: + return + + path_attr = rop_node.parm("pathattrib").eval() + + path_node = output_node.parent().createNode("name", + "AUTO_PATH") + path_node.parm("attribname").set(path_attr) + path_node.parm("name1").set('`opname("..")`_GEO') + + cls.log.debug( + "'%s' was created. It adds '%s' with a default" + " single value", path_node, path_attr + ) + + path_node.setGenericFlag(hou.nodeFlag.DisplayComment, True) + path_node.setComment( + 'Auto path node was created automatically by ' + '"Add a default path attribute"' + '\nFeel free to modify or replace it.' + ) + + if output_node.type().name() in ["null", "output"]: + # Connect before + path_node.setFirstInput(output_node.input(0)) + path_node.moveToGoodPosition() + output_node.setFirstInput(path_node) + output_node.moveToGoodPosition() + else: + # Connect after + path_node.setFirstInput(output_node) + rop_node.parm("startnode").set(path_node.path()) + path_node.moveToGoodPosition() + + cls.log.debug( + "SOP path on '%s' updated to new output node '%s'", + rop_node, path_node + ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index d9dee38680..da9752505a 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -22,7 +22,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache"] + families = ["pointcache", "vdbcache", "filmboxfbx"] hosts = ["houdini"] label = "Validate Output Node" actions = [SelectROPAction, SelectInvalidAction] From 0a7d6aa31845a172dea4899f98a99bac66d806d8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 22:09:01 +0300 Subject: [PATCH 19/90] update validate --- .../houdini/plugins/publish/validate_fbx_hierarchy_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 24f88b384f..7e890db58e 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -56,7 +56,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - nodes = [n.name() for n in invalid] + nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes), From 39225ee0479c0bdf257d02fcfff236d105806fd6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 22:54:05 +0300 Subject: [PATCH 20/90] add fbx extractor --- .../houdini/plugins/publish/extract_fbx.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/extract_fbx.py diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py new file mode 100644 index 0000000000..70bf3e1017 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -0,0 +1,50 @@ +import os + +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.houdini.api.lib import render_rop + +import hou + + +class ExtractRedshiftProxy(publish.Extractor): + + order = pyblish.api.ExtractorOrder + 0.1 + label = "Extract FilmBox FBX" + families = ["filmboxfbx"] + hosts = ["houdini"] + + # overrides InstancePlugin.process() + def process(self, instance): + + ropnode = hou.node(instance.data.get("instance_node")) + + # Get the filename from the filename parameter + # `.evalParm(parameter)` will make sure all tokens are resolved + output = ropnode.evalParm("sopoutput") + staging_dir = os.path.normpath(os.path.dirname(output)) + instance.data["stagingDir"] = staging_dir + file_name = os.path.basename(output) + + self.log.info("Writing FBX '%s' to '%s'" % (file_name, + staging_dir)) + + render_rop(ropnode) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + "name": "fbx", + "ext": "fbx", + "files": file_name, + "stagingDir": staging_dir, + } + + # A single frame may also be rendered without start/end frame. + if "frameStart" in instance.data and "frameEnd" in instance.data: + representation["frameStart"] = instance.data["frameStart"] + representation["frameEnd"] = instance.data["frameEnd"] + + instance.data["representations"].append(representation) From b507c2d52b2b2ad23ec1d300e8f5d8cbbd97fb58 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 23:03:55 +0300 Subject: [PATCH 21/90] register filmbox family in integrate.py --- openpype/plugins/publish/integrate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index ffb9acf4a7..05ffe0bd3d 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -138,7 +138,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene" + "blendScene", + "filmboxfbx" ] default_template_name = "publish" From 686ba073cef895b17bc6393e4a4dd9bbb4bf73f5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 1 Aug 2023 23:06:40 +0300 Subject: [PATCH 22/90] update comments --- openpype/hosts/houdini/plugins/publish/collect_fbx_type.py | 1 + openpype/hosts/houdini/plugins/publish/extract_fbx.py | 3 ++- .../houdini/plugins/publish/validate_fbx_hierarchy_path.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 05a0af659f..c665852bb6 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -12,6 +12,7 @@ import pyblish.api class CollectFilmboxfbxType(pyblish.api.InstancePlugin): """Collect data type for filmboxfbx instance.""" + # Usually you will use this value as default order = pyblish.api.CollectorOrder hosts = ["houdini"] families = ["filmboxfbx"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 70bf3e1017..6a4b541c33 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -10,12 +10,13 @@ import hou class ExtractRedshiftProxy(publish.Extractor): + # Usually you will use this value as default order = pyblish.api.ExtractorOrder + 0.1 label = "Extract FilmBox FBX" families = ["filmboxfbx"] hosts = ["houdini"] - # overrides InstancePlugin.process() + # overrides Extractor.process() def process(self, instance): ropnode = hou.node(instance.data.get("instance_node")) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 7e890db58e..871b347155 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -40,7 +40,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): by default. """ - # Usually you will this value by default + # Usually you will use this value as default order = ValidateContentsOrder + 0.1 families = ["filmboxfbx"] hosts = ["houdini"] From 7a74608b11bfd891d52b92ae2bdf78ebf3dcd468 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 21:42:26 +0300 Subject: [PATCH 23/90] add loader --- .../hosts/houdini/plugins/load/load_fbx.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/load/load_fbx.py diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py new file mode 100644 index 0000000000..e69bdbb10a --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +"""Fbx Loader for houdini. + +It's an exact copy of +'load_bgeo.py' +however this one includes extra comments for demonstration. + +This plugin is part of publish process guide. +""" +import os +import re + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.houdini.api import pipeline + + +class FbxLoader(load.LoaderPlugin): + """Load fbx files to Houdini.""" + + label = "Load FBX" + families = ["filmboxfbx", "fbx"] + representations = ["fbx"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import hou + + # Get the root node + obj = hou.node("/obj") + + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name + + # Create a new geo node + container = obj.createNode("geo", node_name=node_name) + is_sequence = bool(context["representation"]["context"].get("frame")) + + # Remove the file node, it only loads static meshes + # Houdini 17 has removed the file node from the geo node + file_node = container.node("file1") + if file_node: + file_node.destroy() + + # Explicitly create a file node + path = self.filepath_from_context(context) + file_node = container.createNode("file", node_name=node_name) + file_node.setParms( + {"file": self.format_path(path, context["representation"])}) + + # Set display on last node + file_node.setDisplayFlag(True) + + nodes = [container, file_node] + self[:] = nodes + + return pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + @staticmethod + def format_path(path, representation): + """Format file path correctly for single bgeo or bgeo sequence.""" + if not os.path.exists(path): + raise RuntimeError("Path does not exist: %s" % path) + + is_sequence = bool(representation["context"].get("frame")) + # The path is either a single file or sequence in a folder. + if not is_sequence: + filename = path + else: + filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) + + filename = os.path.join(path, filename) + + filename = os.path.normpath(filename) + filename = filename.replace("\\", "/") + + return filename + + def update(self, container, representation): + + node = container["node"] + try: + file_node = next( + n for n in node.children() if n.type().name() == "file" + ) + except StopIteration: + self.log.error("Could not find node of type `file`") + return + + # Update the file path + file_path = get_representation_path(representation) + file_path = self.format_path(file_path, representation) + + file_node.setParms({"file": file_path}) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() + + def switch(self, container, representation): + self.update(container, representation) From a1b0c409aca7af1f9d44563bc4c797d884b67b5a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 21:43:50 +0300 Subject: [PATCH 24/90] update doc strings --- openpype/hosts/houdini/plugins/create/create_fbx.py | 2 ++ .../hosts/houdini/plugins/publish/collect_fbx_type.py | 2 ++ .../plugins/publish/validate_fbx_hierarchy_path.py | 8 ++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index ee2fdcd73f..ac76dc0441 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -8,6 +8,8 @@ it's by default selects the output sop with mimimum idx or the node with render flag isntead. to eleminate any confusion, we set the sop node explictly. + +This plugin is part of publish process guide. """ from openpype.hosts.houdini.api import plugin diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index c665852bb6..f1665b27f0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -5,6 +5,8 @@ It is used mainly to update instance.data P.S. There are some collectors that run by default for all types. + +This plugin is part of publish process guide. """ import pyblish.api diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 871b347155..6a2bda1bca 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -1,12 +1,16 @@ # -*- coding: utf-8 -*- -"""It's almost the same as +"""Validate path attribute for all primitives. + +It's almost the same as 'validate_primitive_hierarchy_paths.py' -however this one includes more comments for demonstration. +however this one includes extra comments for demonstration. FYI, path for fbx behaves a little differently. In maya terms: in Filmbox FBX: it sets the name of the object in Alembic ROP: it sets the name of the shape + +This plugin is part of publish process guide. """ import pyblish.api From 08fdbf1283151e2109f1649321700ea8697c261d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 23:34:32 +0300 Subject: [PATCH 25/90] make loader look prettier --- .../hosts/houdini/plugins/load/load_fbx.py | 151 +++++++++++------- 1 file changed, 96 insertions(+), 55 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index e69bdbb10a..5afd39ea99 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -23,71 +23,25 @@ class FbxLoader(load.LoaderPlugin): label = "Load FBX" families = ["filmboxfbx", "fbx"] representations = ["fbx"] - order = -10 - icon = "code-fork" - color = "orange" + order = -10 # you can use this by default. + icon = "code-fork" # you can use this by default. + color = "orange" # you can use this by default. def load(self, context, name=None, namespace=None, data=None): - import hou + file_path = self.get_file_path(context) - # Get the root node - obj = hou.node("/obj") + namespace, node_name = self.get_node_name(context, name, namespace) - # Define node name - namespace = namespace if namespace else context["asset"]["name"] - node_name = "{}_{}".format(namespace, name) if namespace else name + nodes = self.create_load_node_tree(file_path, node_name, name) - # Create a new geo node - container = obj.createNode("geo", node_name=node_name) - is_sequence = bool(context["representation"]["context"].get("frame")) - - # Remove the file node, it only loads static meshes - # Houdini 17 has removed the file node from the geo node - file_node = container.node("file1") - if file_node: - file_node.destroy() - - # Explicitly create a file node - path = self.filepath_from_context(context) - file_node = container.createNode("file", node_name=node_name) - file_node.setParms( - {"file": self.format_path(path, context["representation"])}) - - # Set display on last node - file_node.setDisplayFlag(True) - - nodes = [container, file_node] self[:] = nodes - return pipeline.containerise( - node_name, - namespace, - nodes, - context, - self.__class__.__name__, - suffix="", + containerised_nodes = self.get_containerised_nodes( + nodes, context, node_name, namespace ) - @staticmethod - def format_path(path, representation): - """Format file path correctly for single bgeo or bgeo sequence.""" - if not os.path.exists(path): - raise RuntimeError("Path does not exist: %s" % path) - - is_sequence = bool(representation["context"].get("frame")) - # The path is either a single file or sequence in a folder. - if not is_sequence: - filename = path - else: - filename = re.sub(r"(.*)\.(\d+)\.(bgeo.*)", "\\1.$F4.\\3", path) - - filename = os.path.join(path, filename) - - filename = os.path.normpath(filename) - filename = filename.replace("\\", "/") - - return filename + return containerised_nodes def update(self, container, representation): @@ -116,3 +70,90 @@ class FbxLoader(load.LoaderPlugin): def switch(self, container, representation): self.update(container, representation) + + def get_file_path(self, context): + """Return formatted file path.""" + + # Format file name, Houdini only wants forward slashes + file_path = self.filepath_from_context(context) + file_path = os.path.normpath(file_path) + file_path = file_path.replace("\\", "/") + + return file_path + + def get_node_name(self, context, name=None, namespace=None): + """Define node name.""" + + if not namespace: + namespace = context["asset"]["name"] + + if namespace: + node_name = "{}_{}".format(namespace, name) + else: + node_name = name + + return namespace, node_name + + def create_load_node_tree(self, file_path, node_name, subset_name): + """Create Load node network. + + you can start building your tree at any obj level. + it'll be much easier to build it in the root obj level. + + Afterwards, your tree will be automatically moved to + '/obj/AVALON_CONTAINERS' subnetwork. + """ + import hou + + # Get the root obj level + obj = hou.node("/obj") + + # Create a new obj geo node + parent_node = obj.createNode("geo", node_name=node_name) + + # In older houdini, + # when reating a new obj geo node, a default file node will be + # automatically created. + # so, we will delete it if exists. + file_node = parent_node.node("file1") + if file_node: + file_node.destroy() + + # Create a new file node + file_node = parent_node.createNode("file", node_name= node_name) + file_node.setParms({"file":file_path}) + + # Create attribute delete + attribdelete_name = "attribdelete_{}".format(subset_name) + attribdelete = parent_node.createNode("attribdelete", + node_name= attribdelete_name) + attribdelete.setParms({"ptdel":"fbx_*"}) + attribdelete.setInput(0, file_node) + + # Create a Null node + null_name = "OUT_{}".format(subset_name) + null = parent_node.createNode("null", node_name= null_name) + null.setInput(0, attribdelete) + + # Ensure display flag is on the file_node input node and not on the OUT + # node to optimize "debug" displaying in the viewport. + file_node.setDisplayFlag(True) + + # Set new position for unpack node else it gets cluttered + nodes = [parent_node, file_node, attribdelete, null] + for nr, node in enumerate(nodes): + node.setPosition([0, (0 - nr)]) + + return nodes + + def get_containerised_nodes(self, nodes, context, node_name, namespace): + containerised_nodes = pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", + ) + + return containerised_nodes From de56b53ac6c48f0ca73b9111ce0edbf73d02d950 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 23:35:28 +0300 Subject: [PATCH 26/90] update doc string --- openpype/hosts/houdini/plugins/load/load_fbx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 5afd39ea99..6bac7a7cec 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """Fbx Loader for houdini. -It's an exact copy of -'load_bgeo.py' +It's almost a copy of +'load_bgeo.py'and 'load_alembic.py' however this one includes extra comments for demonstration. This plugin is part of publish process guide. From ba5f7eb417ccf86edccb4a3b1ef2b6dfe64fecca Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 3 Aug 2023 23:36:33 +0300 Subject: [PATCH 27/90] remove un-necessary import --- openpype/hosts/houdini/plugins/load/load_fbx.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 6bac7a7cec..3f66c8e88f 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -8,7 +8,6 @@ however this one includes extra comments for demonstration. This plugin is part of publish process guide. """ import os -import re from openpype.pipeline import ( load, From 31f8c4cd13ef4240566f8dbf98af86f2488d3936 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 7 Aug 2023 18:59:04 +0300 Subject: [PATCH 28/90] update fbx creator --- .../houdini/plugins/create/create_fbx.py | 62 +++++++++++-------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index ac76dc0441..9e1dbea2f4 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -4,10 +4,12 @@ It was made to pratice publish process. Filmbox by default expects an ObjNode -it's by default selects the output sop with mimimum idx -or the node with render flag isntead. +however, we set the sop node explictly +to eleminate any confusion. -to eleminate any confusion, we set the sop node explictly. +This creator by default will select +the output sop with mimimum idx +or the node with render flag isntead. This plugin is part of publish process guide. """ @@ -30,12 +32,11 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): - # instance_data.pop("active", None) # set node type instance_data.update({"node_type": "filmboxfbx"}) - # set essential extra parameters + # create instance (calls super create method) instance = super(CreateFilmboxFBX, self).create( subset_name, instance_data, @@ -44,33 +45,14 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # get the created node instance_node = hou.node(instance.get("instance_node")) - # Get this node specific parms - # 1. get output path - output_path = hou.text.expandString( - "$HIP/pyblish/{}.fbx".format(subset_name)) - - # 2. get selection - selection = self.get_selection() - - # 3. get Vertex Cache Format - vcformat = pre_create_data.get("vcformat") - - # 3. get Valid Frame Range - trange = pre_create_data.get("trange") - - # parms dictionary - parms = { - "startnode" : selection, - "sopoutput": output_path, - "vcformat" : vcformat, - "trange" : trange - } + # get parms + parms = self.get_parms(subset_name, pre_create_data) # set parms instance_node.setParms(parms) # Lock any parameters in this list - to_lock = [] + to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) # Overrides HoudiniCreator.get_network_categories() @@ -100,6 +82,32 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): return attrs + [vcformat, trange] + def get_parms(self, subset_name, pre_create_data): + """Get parameters values for this specific node.""" + + # 1. get output path + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name)) + + # 2. get selection + selection = self.get_selection() + + # 3. get Vertex Cache Format + vcformat = pre_create_data.get("vcformat") + + # 4. get Valid Frame Range + trange = pre_create_data.get("trange") + + # parms dictionary + parms = { + "startnode" : selection, + "sopoutput": output_path, + "vcformat" : vcformat, + "trange" : trange + } + + return parms + def get_selection(self): """Selection Logic. From b1ac707a68335990970481885f7b3ac6bcbb87c5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 7 Aug 2023 22:11:01 +0300 Subject: [PATCH 29/90] update plugins for better demonstration --- .../houdini/plugins/create/create_fbx.py | 7 ++- .../hosts/houdini/plugins/load/load_fbx.py | 8 ++-- .../plugins/publish/collect_fbx_type.py | 22 ++++----- .../houdini/plugins/publish/extract_fbx.py | 48 +++++++++++++++---- .../publish/validate_fbx_hierarchy_path.py | 10 ++-- 5 files changed, 66 insertions(+), 29 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 9e1dbea2f4..2bfdb3e729 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -21,12 +21,15 @@ import hou class CreateFilmboxFBX(plugin.HoudiniCreator): - """Filmbox FBX Driver""" + """Filmbox FBX Driver.""" + + # you should set identifier = "io.openpype.creators.houdini.filmboxfbx" label = "Filmbox FBX" family = "filmboxfbx" icon = "fa5s.cubes" + # optional to set default_variant = "Main" default_variants = ["Main", "Test"] @@ -36,7 +39,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # set node type instance_data.update({"node_type": "filmboxfbx"}) - # create instance (calls super create method) + # create instance (calls HoudiniCreator.create()) instance = super(CreateFilmboxFBX, self).create( subset_name, instance_data, diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 3f66c8e88f..993b57ad21 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -22,9 +22,11 @@ class FbxLoader(load.LoaderPlugin): label = "Load FBX" families = ["filmboxfbx", "fbx"] representations = ["fbx"] - order = -10 # you can use this by default. - icon = "code-fork" # you can use this by default. - color = "orange" # you can use this by default. + + # Usually you will use these value as default + order = -10 + icon = "code-fork" + color = "orange" def load(self, context, name=None, namespace=None, data=None): diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index f1665b27f0..794d8bd6e7 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -1,10 +1,11 @@ """Collector for filmboxfbx types. -A Collector can act as a preprocessor for the validation stage. +Collectors act as a pre process for the validation stage. It is used mainly to update instance.data P.S. - There are some collectors that run by default for all types. + There are some collectors that run by default + for all types. This plugin is part of publish process guide. """ @@ -14,18 +15,21 @@ import pyblish.api class CollectFilmboxfbxType(pyblish.api.InstancePlugin): """Collect data type for filmboxfbx instance.""" - # Usually you will use this value as default - order = pyblish.api.CollectorOrder hosts = ["houdini"] families = ["filmboxfbx"] label = "Collect type of filmboxfbx" + # Usually you will use this value as default + order = pyblish.api.CollectorOrder + # overrides InstancePlugin.process() def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 # such a condition can be used to differentiate between - # instances by identifier even if they have the same type. + # instances by identifier becuase sometimes instances + # may have the same family but different identifier + # e.g. bgeo and alembic pass # Update instance.data with ouptut_node @@ -36,15 +40,11 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # Disclaimer : As a convntin we use collect_output_node.py # to Update instance.data with ouptut_node of different types - # however, we use this collector instead for demonstration + # however, this collector is used for demonstration def get_output_node(self, instance): - """Getting output_node Logic. - - It's moved here so that it become easier to focus on - process method. - """ + """Getting output_node Logic.""" import hou diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 6a4b541c33..102b075838 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -1,3 +1,11 @@ +"""Extract FilmBox FBX. + +Extractors are used to generate output and +update representation dictionary. + +This plugin is part of publish process guide. +""" + import os import pyblish.api @@ -10,31 +18,51 @@ import hou class ExtractRedshiftProxy(publish.Extractor): - # Usually you will use this value as default - order = pyblish.api.ExtractorOrder + 0.1 label = "Extract FilmBox FBX" families = ["filmboxfbx"] hosts = ["houdini"] + # Usually you will use this value as default + order = pyblish.api.ExtractorOrder + 0.1 + # overrides Extractor.process() def process(self, instance): + # get rop node ropnode = hou.node(instance.data.get("instance_node")) - # Get the filename from the filename parameter - # `.evalParm(parameter)` will make sure all tokens are resolved - output = ropnode.evalParm("sopoutput") - staging_dir = os.path.normpath(os.path.dirname(output)) + # render rop + render_rop(ropnode) + + # get required data + file_name, staging_dir = self.get_paths_data(ropnode) + representation = self.get_representation(instance, + file_name, + staging_dir) + + # set value type for 'representations' key to list + if "representations" not in instance.data: + instance.data["representations"] = [] + + # update instance data instance.data["stagingDir"] = staging_dir + instance.data["representations"].append(representation) + + def get_paths_data(self, ropnode): + # Get the filename from the filename parameter + output = ropnode.evalParm("sopoutput") + + staging_dir = os.path.normpath(os.path.dirname(output)) + file_name = os.path.basename(output) self.log.info("Writing FBX '%s' to '%s'" % (file_name, staging_dir)) - render_rop(ropnode) + return file_name, staging_dir - if "representations" not in instance.data: - instance.data["representations"] = [] + def get_representation(self, instance, + file_name, staging_dir): representation = { "name": "fbx", @@ -48,4 +76,4 @@ class ExtractRedshiftProxy(publish.Extractor): representation["frameStart"] = instance.data["frameStart"] representation["frameEnd"] = instance.data["frameEnd"] - instance.data["representations"].append(representation) + return representation diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 6a2bda1bca..e98e562fe8 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """Validate path attribute for all primitives. +Validators are used to verify the work of artists, +by running some checks which automates the approval process. + It's almost the same as 'validate_primitive_hierarchy_paths.py' however this one includes extra comments for demonstration. @@ -44,12 +47,13 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): by default. """ - # Usually you will use this value as default - order = ValidateContentsOrder + 0.1 families = ["filmboxfbx"] hosts = ["houdini"] label = "Validate FBX Hierarchy Path" + # Usually you will use this value as default + order = ValidateContentsOrder + 0.1 + # Validation can have as many actions as you want # all of these actions are defined in a seperate place # unlike the repair action @@ -69,7 +73,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): # This method was named get_invalid as a convention # it's also used by SelectInvalidAction to select - # the returned node + # the returned nodes @classmethod def get_invalid(cls, instance): From f41ad17b7bbf962baf209adcce243372ab7948bd Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 7 Aug 2023 22:22:47 +0300 Subject: [PATCH 30/90] update comments --- openpype/hosts/houdini/plugins/load/load_fbx.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 993b57ad21..5294f5248d 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -30,14 +30,19 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): + # get file path file_path = self.get_file_path(context) + # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) + # create load tree nodes = self.create_load_node_tree(file_path, node_name, name) self[:] = nodes + # Call containerise function which does some + # automations for you containerised_nodes = self.get_containerised_nodes( nodes, context, node_name, namespace ) @@ -96,7 +101,7 @@ class FbxLoader(load.LoaderPlugin): return namespace, node_name def create_load_node_tree(self, file_path, node_name, subset_name): - """Create Load node network. + """Create Load network. you can start building your tree at any obj level. it'll be much easier to build it in the root obj level. @@ -148,6 +153,12 @@ class FbxLoader(load.LoaderPlugin): return nodes def get_containerised_nodes(self, nodes, context, node_name, namespace): + """Call containerise function. + + It does some automations that you don't have to worry about, e.g. + 1. It moves created nodes to the AVALON_CONTAINERS subnetwork + 2. Add extra parameters + """ containerised_nodes = pipeline.containerise( node_name, namespace, From ef58284bceb6bff2a6ee965bc456600d821e80a8 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 22 Aug 2023 11:30:45 +0100 Subject: [PATCH 31/90] Respect persistent dir on Deadline. --- openpype/plugins/publish/collect_rendered_files.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 4b95d8ac44..dc54e296e1 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -103,13 +103,16 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # stash render job id for later validation instance.data["render_job_id"] = data.get("job").get("_id") - + staging_dir_persistent = instance.data.get( + "stagingDir_persistent", False + ) representations = [] for repre_data in instance_data.get("representations") or []: self._fill_staging_dir(repre_data, anatomy) representations.append(repre_data) - add_repre_files_for_cleanup(instance, repre_data) + if not staging_dir_persistent: + add_repre_files_for_cleanup(instance, repre_data) instance.data["representations"] = representations @@ -124,7 +127,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.info( f"Adding audio to instance: {instance.data['audio']}") - return instance.data.get("stagingDir_persistent", False) + return staging_dir_persistent def process(self, context): self._context = context From 77d18d0b060d009a80102f00a3f76b31f488bd03 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:20:33 +0300 Subject: [PATCH 32/90] make hound happy --- .../houdini/plugins/create/create_fbx.py | 32 +++++++++---------- .../hosts/houdini/plugins/load/load_fbx.py | 10 +++--- .../plugins/publish/collect_fbx_type.py | 1 - .../publish/validate_fbx_hierarchy_path.py | 1 + 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 2bfdb3e729..1ff63ab2c4 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -69,19 +69,19 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", - items={ - 0: "Maya Compatible (MC)", - 1: "3DS MAX Compatible (PC2)" - }, - default=0, - label="Vertex Cache Format") + items={ + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") trange = EnumDef("trange", - items={ - 0: "Render Current Frame", - 1: "Render Frame Range" - }, - default=0, - label="Valid Frame Range") + items={ + 0: "Render Current Frame", + 1: "Render Frame Range" + }, + default=0, + label="Valid Frame Range") return attrs + [vcformat, trange] @@ -103,10 +103,10 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # parms dictionary parms = { - "startnode" : selection, + "startnode": selection, "sopoutput": output_path, - "vcformat" : vcformat, - "trange" : trange + "vcformat": vcformat, + "trange": trange } return parms @@ -139,7 +139,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): ) # Allow object level paths to Geometry nodes (e.g. /obj/geo1) - # but do not allow other object level nodes types like cameras, etc. + # but do not allow other object level nodes types like cameras. elif isinstance(selected_node, hou.ObjNode) and \ selected_node.type().name() in ["geo"]: diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 5294f5248d..34f75e1485 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -126,19 +126,19 @@ class FbxLoader(load.LoaderPlugin): file_node.destroy() # Create a new file node - file_node = parent_node.createNode("file", node_name= node_name) - file_node.setParms({"file":file_path}) + file_node = parent_node.createNode("file", node_name=node_name) + file_node.setParms({"file": file_path}) # Create attribute delete attribdelete_name = "attribdelete_{}".format(subset_name) attribdelete = parent_node.createNode("attribdelete", - node_name= attribdelete_name) - attribdelete.setParms({"ptdel":"fbx_*"}) + node_name=attribdelete_name) + attribdelete.setParms({"ptdel": "fbx_*"}) attribdelete.setInput(0, file_node) # Create a Null node null_name = "OUT_{}".format(subset_name) - null = parent_node.createNode("null", node_name= null_name) + null = parent_node.createNode("null", node_name=null_name) null.setInput(0, attribdelete) # Ensure display flag is on the file_node input node and not on the OUT diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 794d8bd6e7..3ee2541f72 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -42,7 +42,6 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # to Update instance.data with ouptut_node of different types # however, this collector is used for demonstration - def get_output_node(self, instance): """Getting output_node Logic.""" diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index e98e562fe8..9208a16bd1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -29,6 +29,7 @@ from openpype.hosts.houdini.api.action import ( import hou + # Each validation can have a single repair action # which calls the repair method class AddDefaultPathAction(RepairAction): From 0fe908a7cc4e2130e36f52820f9064ba48621d59 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:23:56 +0300 Subject: [PATCH 33/90] make hound happy again --- openpype/hosts/houdini/plugins/create/create_fbx.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index 1ff63ab2c4..a92a4a5a24 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -70,11 +70,11 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", items={ - 0: "Maya Compatible (MC)", - 1: "3DS MAX Compatible (PC2)" - }, - default=0, - label="Vertex Cache Format") + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" + }, + default=0, + label="Vertex Cache Format") trange = EnumDef("trange", items={ 0: "Render Current Frame", From 4bfde17a420cc3686734d049728e7eaf66b51538 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:26:21 +0300 Subject: [PATCH 34/90] make hound happy --- openpype/hosts/houdini/plugins/create/create_fbx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index a92a4a5a24..e26fd660ba 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -70,8 +70,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", items={ - 0: "Maya Compatible (MC)", - 1: "3DS MAX Compatible (PC2)" + 0: "Maya Compatible (MC)", + 1: "3DS MAX Compatible (PC2)" }, default=0, label="Vertex Cache Format") From 433160e75ae70c2a17d5460c6815588bb17c58b0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:29:02 +0300 Subject: [PATCH 35/90] make hound happy again --- openpype/hosts/houdini/plugins/create/create_fbx.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index e26fd660ba..ed95daafca 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -72,16 +72,16 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): items={ 0: "Maya Compatible (MC)", 1: "3DS MAX Compatible (PC2)" - }, + }, default=0, label="Vertex Cache Format") trange = EnumDef("trange", items={ 0: "Render Current Frame", 1: "Render Frame Range" - }, - default=0, - label="Valid Frame Range") + }, + default=0, + label="Valid Frame Range") return attrs + [vcformat, trange] From 58408817999f053118a5c14cff8058c909a512cf Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 22 Aug 2023 23:30:00 +0300 Subject: [PATCH 36/90] make hound happy --- openpype/hosts/houdini/plugins/create/create_fbx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index ed95daafca..cac90f1e87 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -72,7 +72,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): items={ 0: "Maya Compatible (MC)", 1: "3DS MAX Compatible (PC2)" - }, + }, default=0, label="Vertex Cache Format") trange = EnumDef("trange", From ee8a2b1aa37567ccfc77fe3f31562eca0622f294 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:10:40 +0300 Subject: [PATCH 37/90] update geo validation --- .../publish/validate_fbx_hierarchy_path.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 9208a16bd1..e060756801 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -17,7 +17,10 @@ This plugin is part of publish process guide. """ import pyblish.api -from openpype.pipeline import PublishValidationError +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, @@ -37,7 +40,8 @@ class AddDefaultPathAction(RepairAction): icon = "mdi.pencil-plus-outline" -class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): +class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validate all primitives build hierarchy from attribute when enabled. @@ -50,7 +54,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): families = ["filmboxfbx"] hosts = ["houdini"] - label = "Validate FBX Hierarchy Path" + label = "Validate Prims Hierarchy Path (FBX)" # Usually you will use this value as default order = ValidateContentsOrder + 0.1 @@ -61,6 +65,10 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): actions = [SelectInvalidAction, AddDefaultPathAction, SelectROPAction] + # 'OptionalPyblishPluginMixin' where logic for 'optional' is implemented. + # It requires updating project settings + optional = True + # overrides InstancePlugin.process() def process(self, instance): invalid = self.get_invalid(instance) @@ -108,19 +116,21 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): cls.log.debug("Checking for attribute: %s", path_attr) - if not hasattr(output_node, "geometry"): - # In the case someone has explicitly set an Object - # node instead of a SOP node in Geometry context - # then for now we ignore - this allows us to also - # export object transforms. - cls.log.warning("No geometry output node found," + # Get frame + frame = hou.intFrame() + trange = rop_node.parm("trange").eval() + if trange: + frame = int(hou.playbar.frameRange()[0]) + + frame = instance.data.get("frameStart", frame) + + # Get Geo at that frame + geo = output_node.geometryAtFrame(frame) + if not geo: + cls.log.warning("No geometry found," " skipping check..") return - # Check if the primitive attribute exists - frame = instance.data.get("frameStart", 0) - geo = output_node.geometryAtFrame(frame) - # If there are no primitives on the current frame then # we can't check whether the path names are correct. # So we'll just issue a warning that the check can't From 1d0c78f044a4f0d5dd6092968ff543f09d5c5987 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:11:04 +0300 Subject: [PATCH 38/90] update label --- .../plugins/publish/validate_primitive_hierarchy_paths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index 471fa5b6d1..930978ef16 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -26,7 +26,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): order = ValidateContentsOrder + 0.1 families = ["abc"] hosts = ["houdini"] - label = "Validate Prims Hierarchy Path" + label = "Validate Prims Hierarchy Path (ABC)" actions = [AddDefaultPathAction] def process(self, instance): From 4ea1f2b586835f6a6cad97633d5149fc614ce991 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:11:47 +0300 Subject: [PATCH 39/90] include fbx validator in settings --- openpype/settings/defaults/project_settings/houdini.json | 5 +++++ .../projects_schema/schemas/schema_houdini_publish.json | 6 +++++- server_addon/houdini/server/settings/publish_plugins.py | 8 ++++++++ server_addon/houdini/server/version.py | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 9d047c28bd..2295422202 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -97,6 +97,11 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateFBXPrimitiveHierarchyPaths": { + "enabled": true, + "optional": true, + "active": true } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index aa6eaf5164..d58b36eff1 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -43,8 +43,12 @@ { "key": "ValidateContainers", "label": "ValidateContainers" + }, + { + "key": "ValidateFBXPrimitiveHierarchyPaths", + "label": "Validate Path Attribute for FBX" } ] } ] -} \ No newline at end of file +} diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 7d35d7e634..44ff00c318 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -133,6 +133,9 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Latest Containers.") + ValidateFBXPrimitiveHierarchyPaths: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Path Attribute for FBX.") DEFAULT_HOUDINI_PUBLISH_SETTINGS = { @@ -152,5 +155,10 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "enabled": True, "optional": True, "active": True + }, + "ValidateFBXPrimitiveHierarchyPaths": { + "enabled": True, + "optional": True, + "active": True } } diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From 96893f6ab0a56db976aa14709f2fbbd734b70f88 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 23 Aug 2023 19:56:29 +0300 Subject: [PATCH 40/90] update doc string --- openpype/hosts/houdini/plugins/create/create_fbx.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_fbx.py index cac90f1e87..b45aef8fdf 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_fbx.py @@ -67,6 +67,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # Overrides HoudiniCreator.get_pre_create_attr_defs() def get_pre_create_attr_defs(self): + """Add settings for users. """ + attrs = super().get_pre_create_attr_defs() vcformat = EnumDef("vcformat", items={ From e181eeab93db55b7804f3b6f5055369e1a30ede7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 24 Aug 2023 16:02:18 +0300 Subject: [PATCH 41/90] convert filmboxfbx to UE static mesh --- ...ate_fbx.py => create_unreal_staticmesh.py} | 25 ++++++--------- .../hosts/houdini/plugins/load/load_fbx.py | 2 +- .../plugins/publish/collect_fbx_type.py | 10 +++--- .../houdini/plugins/publish/extract_fbx.py | 4 +-- .../publish/validate_fbx_hierarchy_path.py | 2 +- .../publish/validate_sop_output_node.py | 2 +- openpype/plugins/publish/integrate.py | 3 +- .../defaults/project_settings/houdini.json | 13 ++++++++ .../schemas/schema_houdini_create.json | 31 +++++++++++++++++++ .../server/settings/publish_plugins.py | 29 +++++++++++++++++ 10 files changed, 94 insertions(+), 27 deletions(-) rename openpype/hosts/houdini/plugins/create/{create_fbx.py => create_unreal_staticmesh.py} (89%) diff --git a/openpype/hosts/houdini/plugins/create/create_fbx.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py similarity index 89% rename from openpype/hosts/houdini/plugins/create/create_fbx.py rename to openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index b45aef8fdf..4543f14934 100644 --- a/openpype/hosts/houdini/plugins/create/create_fbx.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -20,17 +20,18 @@ from openpype.lib import EnumDef import hou -class CreateFilmboxFBX(plugin.HoudiniCreator): +class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): """Filmbox FBX Driver.""" # you should set - identifier = "io.openpype.creators.houdini.filmboxfbx" - label = "Filmbox FBX" - family = "filmboxfbx" + identifier = "io.openpype.creators.houdini.unrealstaticmesh" + label = "Unreal - Static Mesh" + family = "staticMesh" icon = "fa5s.cubes" # optional to set default_variant = "Main" + # 'default_variants' will be overriden by settings. default_variants = ["Main", "Test"] # Overrides HoudiniCreator.create() @@ -40,7 +41,7 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): instance_data.update({"node_type": "filmboxfbx"}) # create instance (calls HoudiniCreator.create()) - instance = super(CreateFilmboxFBX, self).create( + instance = super(HouCreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) @@ -77,15 +78,8 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): }, default=0, label="Vertex Cache Format") - trange = EnumDef("trange", - items={ - 0: "Render Current Frame", - 1: "Render Frame Range" - }, - default=0, - label="Valid Frame Range") - return attrs + [vcformat, trange] + return attrs + [vcformat] def get_parms(self, subset_name, pre_create_data): """Get parameters values for this specific node.""" @@ -100,8 +94,9 @@ class CreateFilmboxFBX(plugin.HoudiniCreator): # 3. get Vertex Cache Format vcformat = pre_create_data.get("vcformat") - # 4. get Valid Frame Range - trange = pre_create_data.get("trange") + # 4. Valid Frame Range + # It should publish the current frame. + trange = 0 # parms dictionary parms = { diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 34f75e1485..d661f84eeb 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -20,7 +20,7 @@ class FbxLoader(load.LoaderPlugin): """Load fbx files to Houdini.""" label = "Load FBX" - families = ["filmboxfbx", "fbx"] + families = ["staticMesh", "fbx"] representations = ["fbx"] # Usually you will use these value as default diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 3ee2541f72..4c83829c67 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -13,11 +13,11 @@ import pyblish.api class CollectFilmboxfbxType(pyblish.api.InstancePlugin): - """Collect data type for filmboxfbx instance.""" + """Collect data type for fbx instance.""" hosts = ["houdini"] - families = ["filmboxfbx"] - label = "Collect type of filmboxfbx" + families = ["fbx", "staticMesh"] + label = "Collect type of fbx" # Usually you will use this value as default order = pyblish.api.CollectorOrder @@ -25,12 +25,12 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # overrides InstancePlugin.process() def process(self, instance): - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.filmboxfbx": # noqa: E501 + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh": # noqa: E501 # such a condition can be used to differentiate between # instances by identifier becuase sometimes instances # may have the same family but different identifier # e.g. bgeo and alembic - pass + instance.data["families"] += ["fbx"] # Update instance.data with ouptut_node out_node = self.get_output_node(instance) diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 102b075838..8e45a554c0 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -18,8 +18,8 @@ import hou class ExtractRedshiftProxy(publish.Extractor): - label = "Extract FilmBox FBX" - families = ["filmboxfbx"] + label = "Extract FBX" + families = ["fbx"] hosts = ["houdini"] # Usually you will use this value as default diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index e060756801..01cb01f497 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -52,7 +52,7 @@ class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, by default. """ - families = ["filmboxfbx"] + families = ["fbx"] hosts = ["houdini"] label = "Validate Prims Hierarchy Path (FBX)" diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index da9752505a..2b426d96dd 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -22,7 +22,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache", "filmboxfbx"] + families = ["pointcache", "vdbcache", "fbx"] hosts = ["houdini"] label = "Validate Output Node" actions = [SelectROPAction, SelectInvalidAction] diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index ee4af1a0e0..be07cffe72 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -139,8 +139,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "simpleUnrealTexture", "online", "uasset", - "blendScene", - "filmboxfbx" + "blendScene" ] default_template_name = "publish" diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 2295422202..e19e71de17 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -19,6 +19,19 @@ ], "ext": ".ass" }, + "HouCreateUnrealStaticMesh": { + "enabled": true, + "default_variants": [ + "Main" + ], + "static_mesh_prefix": "S", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] + }, "CreateAlembicCamera": { "enabled": true, "default_variants": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json index 799bc0e81a..3d55bd834f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json @@ -39,6 +39,37 @@ ] }, + { + "type": "dict", + "collapsible": true, + "key": "HouCreateUnrealStaticMesh", + "label": "Create Unreal - Static Mesh", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Variants", + "object_type": "text" + }, + { + "type": "text", + "key": "static_mesh_prefix", + "label": "Static Mesh Prefix" + }, + { + "type": "list", + "key": "collision_prefixes", + "label": "Collision Mesh Prefixes", + "object_type": "text" + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 44ff00c318..5ddfa07bc4 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -20,11 +20,27 @@ class CreateArnoldAssModel(BaseSettingsModel): ) ext: str = Field(Title="Extension") +class HouCreateUnrealStaticMeshModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + default_variants: list[str] = Field( + default_factory=list, + title="Default Products" + ) + static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix") + collision_prefixes: list[str] = Field( + default_factory=list, + title="Collision Prefixes" + ) class CreatePluginsModel(BaseSettingsModel): CreateArnoldAss: CreateArnoldAssModel = Field( default_factory=CreateArnoldAssModel, title="Create Alembic Camera") + # "-" is not compatible in the new model + HouCreateUnrealStaticMesh: HouCreateUnrealStaticMeshModel = Field( + default_factory=HouCreateUnrealStaticMeshModel, + title="Create Unreal_Static Mesh" + ) CreateAlembicCamera: CreatorModel = Field( default_factory=CreatorModel, title="Create Alembic Camera") @@ -63,6 +79,19 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"], "ext": ".ass" }, + "HouCreateUnrealStaticMesh": { + "enabled": True, + "default_variants": [ + "Main" + ], + "static_mesh_prefix": "S", + "collision_prefixes": [ + "UBX", + "UCP", + "USP", + "UCX" + ] + }, "CreateAlembicCamera": { "enabled": True, "default_variants": ["Main"] From b6a81bd64b74c60e2b0e8e271f7f2704b6b80bf7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 24 Aug 2023 16:06:21 +0300 Subject: [PATCH 42/90] resolve hound conversations --- .../hosts/houdini/plugins/create/create_unreal_staticmesh.py | 3 ++- server_addon/houdini/server/settings/publish_plugins.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 4543f14934..2a284f7979 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -154,7 +154,8 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): if not selection: self.log.debug( - "Selection isn't valid. 'Export' in filmboxfbx will be empty." + "Selection isn't valid. 'Export' in " + "filmboxfbx will be empty." ) else: self.log.debug( diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 5ddfa07bc4..aaa1d0ba1d 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -20,6 +20,7 @@ class CreateArnoldAssModel(BaseSettingsModel): ) ext: str = Field(Title="Extension") + class HouCreateUnrealStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") default_variants: list[str] = Field( @@ -32,6 +33,7 @@ class HouCreateUnrealStaticMeshModel(BaseSettingsModel): title="Collision Prefixes" ) + class CreatePluginsModel(BaseSettingsModel): CreateArnoldAss: CreateArnoldAssModel = Field( default_factory=CreateArnoldAssModel, From e1f2a77089f406dfa7ebb8bfc22102155f1a3f1b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 25 Aug 2023 22:36:38 +0300 Subject: [PATCH 43/90] Introduce houdini unreal static mesh --- .../create/create_unreal_staticmesh.py | 36 +++-- .../houdini/plugins/publish/extract_fbx.py | 2 +- .../publish/validate_fbx_hierarchy_path.py | 14 +- .../publish/validate_mesh_is_static.py | 133 ++++++++++++++++++ .../defaults/project_settings/houdini.json | 2 +- .../schemas/schema_houdini_create.json | 2 +- .../server/settings/publish_plugins.py | 8 +- 7 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 2a284f7979..479392f231 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- -"""Creator plugin for creating fbx. +"""Creator plugin for creating Unreal Static Meshes. -It was made to pratice publish process. +Unreal Static Meshes will be published as FBX. Filmbox by default expects an ObjNode however, we set the sop node explictly to eleminate any confusion. -This creator by default will select +This will make Filmbox to ignore any object transformations! + +get_obj_output selects the output sop with mimimum idx or the node with render flag isntead. @@ -15,13 +17,13 @@ This plugin is part of publish process guide. """ from openpype.hosts.houdini.api import plugin -from openpype.lib import EnumDef +from openpype.lib import BoolDef, EnumDef import hou -class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): - """Filmbox FBX Driver.""" +class CreateUnrealStaticMesh(plugin.HoudiniCreator): + """Unreal Static Meshes with collisions. """ # you should set identifier = "io.openpype.creators.houdini.unrealstaticmesh" @@ -41,7 +43,7 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): instance_data.update({"node_type": "filmboxfbx"}) # create instance (calls HoudiniCreator.create()) - instance = super(HouCreateUnrealStaticMesh, self).create( + instance = super(CreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) @@ -78,8 +80,15 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): }, default=0, label="Vertex Cache Format") + convert_units = BoolDef("convertunits", + tooltip="When on, the FBX is converted" + "from the current Houdini " + "system units to the native " + "FBX unit of centimeters.", + default=False, + label="Convert Units") - return attrs + [vcformat] + return attrs + [vcformat, convert_units] def get_parms(self, subset_name, pre_create_data): """Get parameters values for this specific node.""" @@ -94,15 +103,18 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): # 3. get Vertex Cache Format vcformat = pre_create_data.get("vcformat") - # 4. Valid Frame Range - # It should publish the current frame. - trange = 0 + # 4. get convert_units + convertunits = pre_create_data.get("convertunits") + + # 5. get Valid Frame Range + trange = 1 # parms dictionary parms = { "startnode": selection, "sopoutput": output_path, "vcformat": vcformat, + "convertunits": convertunits, "trange": trange } @@ -166,7 +178,7 @@ class HouCreateUnrealStaticMesh(plugin.HoudiniCreator): def get_obj_output(self, obj_node): """Find output node with the smallest 'outputidx' - or return tje node with the render flag instead. + or return the node with the render flag instead. """ outputs = obj_node.subnetOutputs() diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 8e45a554c0..c0e84c00c8 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -16,7 +16,7 @@ from openpype.hosts.houdini.api.lib import render_rop import hou -class ExtractRedshiftProxy(publish.Extractor): +class ExtractFBX(publish.Extractor): label = "Extract FBX" families = ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py index 01cb01f497..be73ccd223 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py @@ -65,8 +65,8 @@ class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, actions = [SelectInvalidAction, AddDefaultPathAction, SelectROPAction] - # 'OptionalPyblishPluginMixin' where logic for 'optional' is implemented. - # It requires updating project settings + # 'OptionalPyblishPluginMixin' adds the functionality to + # enable/disable plugins, It requires adding new settings. optional = True # overrides InstancePlugin.process() @@ -116,13 +116,9 @@ class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, cls.log.debug("Checking for attribute: %s", path_attr) - # Get frame - frame = hou.intFrame() - trange = rop_node.parm("trange").eval() - if trange: - frame = int(hou.playbar.frameRange()[0]) - - frame = instance.data.get("frameStart", frame) + # Use current frame if "frameStart" doesn't exist + # This only happens when ""trange" is 0 + frame = instance.data.get("frameStart", hou.intFrame()) # Get Geo at that frame geo = output_node.geometryAtFrame(frame) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py new file mode 100644 index 0000000000..fa6442d0d4 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""Validate mesh is static. + +This plugin is part of publish process guide. +""" + +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou + + +# Each validation can have a single repair action +# which calls the repair method +class FreezeTimeAction(RepairAction): + label = "Freeze Time" + icon = "ei.pause-alt" + + +class ValidateMeshIsStatic(pyblish.api.InstancePlugin): + """Validate mesh is static. + + It checks if node is time dependant. + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate mesh is static" + + # Usually you will use this value as default + order = ValidateContentsOrder + 0.1 + + # Validation can have as many actions as you want + # all of these actions are defined in a seperate place + # unlike the repair action + actions = [FreezeTimeAction, SelectInvalidAction, + SelectROPAction] + + # overrides InstancePlugin.process() + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes), + title=self.label + ) + + # This method was named get_invalid as a convention + # it's also used by SelectInvalidAction to select + # the returned nodes + @classmethod + def get_invalid(cls, instance): + + output_node = instance.data.get("output_node") + if output_node.isTimeDependent(): + cls.log.info("Mesh is not static!") + return [output_node] + + + + # what repair action expects to find and call + @classmethod + def repair(cls, instance): + """Adds a time shift node. + + It should kill time dependency. + """ + + rop_node = hou.node(instance.data["instance_node"]) + # I'm doing so because an artist may change output node + # before clicking the button. + output_node = rop_node.parm("startnode").evalAsNode() + + if not output_node: + cls.log.debug( + "Action isn't performed, invalid SOP Path on %s", + rop_node + ) + return + + # This check to prevent the action from running multiple times. + # git_invalid only returns [output_node] when + # path attribute is the problem + if cls.get_invalid(instance) != [output_node]: + return + + + + time_shift = output_node.parent().createNode("timeshift", + "freeze_time") + time_shift.parm("frame").deleteAllKeyframes() + + frame = instance.data.get("frameStart", hou.intFrame()) + time_shift.parm("frame").set(frame) + + cls.log.debug( + "'%s' was created. It will kill time dependency." + , time_shift + ) + + time_shift.setGenericFlag(hou.nodeFlag.DisplayComment, True) + time_shift.setComment( + 'This node was created automatically by ' + '"Freeze Time" Action' + '\nFeel free to modify or replace it.' + ) + + if output_node.type().name() in ["null", "output"]: + # Connect before + time_shift.setFirstInput(output_node.input(0)) + time_shift.moveToGoodPosition() + output_node.setFirstInput(time_shift) + output_node.moveToGoodPosition() + else: + # Connect after + time_shift.setFirstInput(output_node) + rop_node.parm("startnode").set(time_shift.path()) + time_shift.moveToGoodPosition() + + cls.log.debug( + "SOP path on '%s' updated to new output node '%s'", + rop_node, time_shift + ) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index e19e71de17..c39eb717fd 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -19,7 +19,7 @@ ], "ext": ".ass" }, - "HouCreateUnrealStaticMesh": { + "CreateUnrealStaticMesh": { "enabled": true, "default_variants": [ "Main" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json index 3d55bd834f..b19761df91 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json @@ -42,7 +42,7 @@ { "type": "dict", "collapsible": true, - "key": "HouCreateUnrealStaticMesh", + "key": "CreateUnrealStaticMesh", "label": "Create Unreal - Static Mesh", "checkbox_key": "enabled", "children": [ diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index aaa1d0ba1d..1e5cd7f551 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class CreateArnoldAssModel(BaseSettingsModel): ext: str = Field(Title="Extension") -class HouCreateUnrealStaticMeshModel(BaseSettingsModel): +class CreateUnrealStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") default_variants: list[str] = Field( default_factory=list, @@ -39,8 +39,8 @@ class CreatePluginsModel(BaseSettingsModel): default_factory=CreateArnoldAssModel, title="Create Alembic Camera") # "-" is not compatible in the new model - HouCreateUnrealStaticMesh: HouCreateUnrealStaticMeshModel = Field( - default_factory=HouCreateUnrealStaticMeshModel, + CreateUnrealStaticMesh: CreateUnrealStaticMeshModel = Field( + default_factory=CreateUnrealStaticMeshModel, title="Create Unreal_Static Mesh" ) CreateAlembicCamera: CreatorModel = Field( @@ -81,7 +81,7 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"], "ext": ".ass" }, - "HouCreateUnrealStaticMesh": { + "CreateUnrealStaticMesh": { "enabled": True, "default_variants": [ "Main" From 3d658bb3f23aa9bf50f0a46a62f810b01bc06139 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 25 Aug 2023 22:39:10 +0300 Subject: [PATCH 44/90] resolve hound conversations --- .../houdini/plugins/publish/validate_mesh_is_static.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index fa6442d0d4..ac80fda537 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -66,8 +66,6 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin): cls.log.info("Mesh is not static!") return [output_node] - - # what repair action expects to find and call @classmethod def repair(cls, instance): @@ -94,8 +92,6 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin): if cls.get_invalid(instance) != [output_node]: return - - time_shift = output_node.parent().createNode("timeshift", "freeze_time") time_shift.parm("frame").deleteAllKeyframes() @@ -104,8 +100,8 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin): time_shift.parm("frame").set(frame) cls.log.debug( - "'%s' was created. It will kill time dependency." - , time_shift + "'%s' was created. It will kill time dependency.", + time_shift ) time_shift.setGenericFlag(hou.nodeFlag.DisplayComment, True) From c610a850b9e12023b2a6e5f92769b2a971b7b2d1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Sat, 26 Aug 2023 01:23:20 +0300 Subject: [PATCH 45/90] dynamic subset name --- .../plugins/create/create_unreal_staticmesh.py | 14 ++++++++++++++ .../settings/defaults/project_settings/global.json | 3 ++- server_addon/core/server/settings/tools.py | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 479392f231..179c81510a 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -90,6 +90,20 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): return attrs + [vcformat, convert_units] + # Overrides BaseCreator.get_dynamic_data() + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name, instance + ): + """ + The default subset name templates for Unreal include {asset} and thus + we should pass that along as dynamic data. + """ + dynamic_data = super(CreateUnrealStaticMesh, self).get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name, instance + ) + dynamic_data["asset"] = asset_doc["name"] + return dynamic_data + def get_parms(self, subset_name, pre_create_data): """Get parameters values for this specific node.""" diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 06a595d1c5..52ac745f6d 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -429,7 +429,8 @@ "staticMesh" ], "hosts": [ - "maya" + "maya", + "houdini" ], "task_types": [], "tasks": [], diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 7befc795e4..5dbe6ab215 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -370,7 +370,8 @@ DEFAULT_TOOLS_VALUES = { "staticMesh" ], "hosts": [ - "maya" + "maya", + "houdini" ], "task_types": [], "tasks": [], From 3a43806a5b30496c44b0adb469f1b1a52355b66d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Sat, 26 Aug 2023 01:23:52 +0300 Subject: [PATCH 46/90] dynamic subset name --- server_addon/core/server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/core/server/version.py b/server_addon/core/server/version.py index 485f44ac21..b3f4756216 100644 --- a/server_addon/core/server/version.py +++ b/server_addon/core/server/version.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" From e4819fc952a8711e013c5e473933559219014483 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 01:02:32 +0300 Subject: [PATCH 47/90] Kayla's and BigRoy's comments --- .../create/create_unreal_staticmesh.py | 4 +- .../hosts/houdini/plugins/load/load_fbx.py | 58 ++++++++----------- .../plugins/publish/collect_fbx_type.py | 32 +--------- .../plugins/publish/collect_output_node.py | 7 ++- 4 files changed, 34 insertions(+), 67 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 179c81510a..a048965364 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -26,8 +26,8 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): """Unreal Static Meshes with collisions. """ # you should set - identifier = "io.openpype.creators.houdini.unrealstaticmesh" - label = "Unreal - Static Mesh" + identifier = "io.openpype.creators.houdini.unrealstaticmesh.fbx" + label = "Unreal - Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index d661f84eeb..2e4dafc2d8 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -31,7 +31,7 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): # get file path - file_path = self.get_file_path(context) + file_path = self.get_file_path(context=context) # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) @@ -41,10 +41,15 @@ class FbxLoader(load.LoaderPlugin): self[:] = nodes - # Call containerise function which does some - # automations for you - containerised_nodes = self.get_containerised_nodes( - nodes, context, node_name, namespace + # Call containerise function which does some automations for you + # like moving created nodes to the AVALON_CONTAINERS subnetwork + containerised_nodes = pipeline.containerise( + node_name, + namespace, + nodes, + context, + self.__class__.__name__, + suffix="", ) return containerised_nodes @@ -61,8 +66,7 @@ class FbxLoader(load.LoaderPlugin): return # Update the file path - file_path = get_representation_path(representation) - file_path = self.format_path(file_path, representation) + file_path = self.get_file_path(representation=representation) file_node.setParms({"file": file_path}) @@ -77,15 +81,18 @@ class FbxLoader(load.LoaderPlugin): def switch(self, container, representation): self.update(container, representation) - def get_file_path(self, context): + def get_file_path(self, context=None, representation=None): """Return formatted file path.""" # Format file name, Houdini only wants forward slashes - file_path = self.filepath_from_context(context) - file_path = os.path.normpath(file_path) - file_path = file_path.replace("\\", "/") + if context: + file_path = self.filepath_from_context(context) + elif representation: + file_path = get_representation_path(representation) + else: + return "" - return file_path + return file_path.replace("\\", "/") def get_node_name(self, context, name=None, namespace=None): """Define node name.""" @@ -145,27 +152,8 @@ class FbxLoader(load.LoaderPlugin): # node to optimize "debug" displaying in the viewport. file_node.setDisplayFlag(True) - # Set new position for unpack node else it gets cluttered - nodes = [parent_node, file_node, attribdelete, null] - for nr, node in enumerate(nodes): - node.setPosition([0, (0 - nr)]) + # Set new position for children nodes + parent_node.layoutChildren() - return nodes - - def get_containerised_nodes(self, nodes, context, node_name, namespace): - """Call containerise function. - - It does some automations that you don't have to worry about, e.g. - 1. It moves created nodes to the AVALON_CONTAINERS subnetwork - 2. Add extra parameters - """ - containerised_nodes = pipeline.containerise( - node_name, - namespace, - nodes, - context, - self.__class__.__name__, - suffix="", - ) - - return containerised_nodes + # Retrun all the nodes + return [parent_node, file_node, attribdelete, null] diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py index 4c83829c67..6ac40a4f50 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py @@ -16,7 +16,7 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): """Collect data type for fbx instance.""" hosts = ["houdini"] - families = ["fbx", "staticMesh"] + families = ["staticMesh"] label = "Collect type of fbx" # Usually you will use this value as default @@ -25,35 +25,9 @@ class CollectFilmboxfbxType(pyblish.api.InstancePlugin): # overrides InstancePlugin.process() def process(self, instance): - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh": # noqa: E501 + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 # such a condition can be used to differentiate between - # instances by identifier becuase sometimes instances + # instances by identifier because sometimes instances # may have the same family but different identifier # e.g. bgeo and alembic instance.data["families"] += ["fbx"] - - # Update instance.data with ouptut_node - out_node = self.get_output_node(instance) - - if out_node: - instance.data["output_node"] = out_node - - # Disclaimer : As a convntin we use collect_output_node.py - # to Update instance.data with ouptut_node of different types - # however, this collector is used for demonstration - - def get_output_node(self, instance): - """Getting output_node Logic.""" - - import hou - - # get output node - node = hou.node(instance.data["instance_node"]) - out_node = node.parm("startnode").evalAsNode() - - if not out_node: - self.log.warning("No output node collected.") - return - - self.log.debug("Output node: %s" % out_node.path()) - return out_node diff --git a/openpype/hosts/houdini/plugins/publish/collect_output_node.py b/openpype/hosts/houdini/plugins/publish/collect_output_node.py index 601ed17b39..91bd5fdb15 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/collect_output_node.py @@ -12,7 +12,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): "imagesequence", "usd", "usdrender", - "redshiftproxy" + "redshiftproxy", + "staticMesh" ] hosts = ["houdini"] @@ -57,6 +58,10 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): elif node_type == "Redshift_Proxy_Output": out_node = node.parm("RS_archive_sopPath").evalAsNode() + + elif node_type == "filmboxfbx": + out_node = node.parm("startnode").evalAsNode() + else: raise ValueError( "ROP node type '%s' is" " not supported." % node_type From 127c63eb94b98af297287833e81f0ec9b2a8c5e7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 01:04:11 +0300 Subject: [PATCH 48/90] hound comments --- openpype/hosts/houdini/plugins/load/load_fbx.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 2e4dafc2d8..681837e046 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -7,8 +7,6 @@ however this one includes extra comments for demonstration. This plugin is part of publish process guide. """ -import os - from openpype.pipeline import ( load, get_representation_path, From d1e82ceb7ed7e0681a9856b6376cc8831f1c2d2a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 15:00:45 +0300 Subject: [PATCH 49/90] allow publishing sop and obj nodes --- .../create/create_unreal_staticmesh.py | 70 ++++++------------- .../publish/validate_sop_output_node.py | 2 +- 2 files changed, 24 insertions(+), 48 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index a048965364..0b5b313d9e 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -73,6 +73,13 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): """Add settings for users. """ attrs = super().get_pre_create_attr_defs() + createsubnetroot = BoolDef("createsubnetroot", + tooltip="Create an extra root for the Export node " + "when it’s a subnetwork. This causes the " + "exporting subnetwork node to be " + "represented in the FBX file.", + default=False, + label="Create Root for Subnet") vcformat = EnumDef("vcformat", items={ 0: "Maya Compatible (MC)", @@ -88,7 +95,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): default=False, label="Convert Units") - return attrs + [vcformat, convert_units] + return attrs + [createsubnetroot, vcformat, convert_units] # Overrides BaseCreator.get_dynamic_data() def get_dynamic_data( @@ -123,13 +130,17 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): # 5. get Valid Frame Range trange = 1 + # 6. get createsubnetroot + createsubnetroot = pre_create_data.get("createsubnetroot") + # parms dictionary parms = { "startnode": selection, "sopoutput": output_path, "vcformat": vcformat, "convertunits": convertunits, - "trange": trange + "trange": trange, + "createsubnetroot": createsubnetroot } return parms @@ -149,36 +160,23 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): if self.selected_nodes: selected_node = self.selected_nodes[0] - # Although Houdini allows ObjNode path on `startnode` for the - # the ROP node we prefer it set to the SopNode path explicitly - - # Allow sop level paths (e.g. /obj/geo1/box1) + # Accept sop level nodes (e.g. /obj/geo1/box1) if isinstance(selected_node, hou.SopNode): selection = selected_node.path() self.log.debug( "Valid SopNode selection, 'Export' in filmboxfbx" - " will be set to '%s'." - % selected_node + " will be set to '%s'.", selected_node ) - # Allow object level paths to Geometry nodes (e.g. /obj/geo1) - # but do not allow other object level nodes types like cameras. - elif isinstance(selected_node, hou.ObjNode) and \ - selected_node.type().name() in ["geo"]: + # Accept object level nodes (e.g. /obj/geo1) + elif isinstance(selected_node, hou.ObjNode): + selection = selected_node.path() + self.log.debug( + "Valid ObjNode selection, 'Export' in filmboxfbx " + "will be set to the child path '%s'.", selection + ) - # get the output node with the minimum - # 'outputidx' or the node with display flag - sop_path = self.get_obj_output(selected_node) - - if sop_path: - selection = sop_path.path() - self.log.debug( - "Valid ObjNode selection, 'Export' in filmboxfbx " - "will be set to the child path '%s'." - % sop_path - ) - - if not selection: + else: self.log.debug( "Selection isn't valid. 'Export' in " "filmboxfbx will be empty." @@ -189,25 +187,3 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): ) return selection - - def get_obj_output(self, obj_node): - """Find output node with the smallest 'outputidx' - or return the node with the render flag instead. - """ - - outputs = obj_node.subnetOutputs() - - # if obj_node is empty - if not outputs: - return - - # if obj_node has one output child whether its - # sop output node or a node with the render flag - elif len(outputs) == 1: - return outputs[0] - - # if there are more than one, then it have multiple ouput nodes - # return the one with the minimum 'outputidx' - else: - return min(outputs, - key=lambda node: node.evalParm('outputidx')) diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index 2b426d96dd..d9dee38680 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -22,7 +22,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "vdbcache", "fbx"] + families = ["pointcache", "vdbcache"] hosts = ["houdini"] label = "Validate Output Node" actions = [SelectROPAction, SelectInvalidAction] From e6b9a7d0381a6325100c65537dd744d0796c3b31 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:04:22 +0300 Subject: [PATCH 50/90] validate mesh name --- .../publish/validate_fbx_hierarchy_path.py | 235 ------------------ .../publish/validate_mesh_is_static.py | 129 ---------- .../validate_unreal_staticmesh_naming.py | 102 ++++++++ 3 files changed, 102 insertions(+), 364 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py delete mode 100644 openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py deleted file mode 100644 index be73ccd223..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_hierarchy_path.py +++ /dev/null @@ -1,235 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate path attribute for all primitives. - -Validators are used to verify the work of artists, -by running some checks which automates the approval process. - -It's almost the same as -'validate_primitive_hierarchy_paths.py' -however this one includes extra comments for demonstration. - -FYI, path for fbx behaves a little differently. -In maya terms: -in Filmbox FBX: it sets the name of the object -in Alembic ROP: it sets the name of the shape - -This plugin is part of publish process guide. -""" - -import pyblish.api -from openpype.pipeline import ( - PublishValidationError, - OptionalPyblishPluginMixin -) -from openpype.pipeline.publish import ( - ValidateContentsOrder, - RepairAction, -) -from openpype.hosts.houdini.api.action import ( - SelectInvalidAction, - SelectROPAction, -) - -import hou - - -# Each validation can have a single repair action -# which calls the repair method -class AddDefaultPathAction(RepairAction): - label = "Add a default path" - icon = "mdi.pencil-plus-outline" - - -class ValidateFBXPrimitiveHierarchyPaths(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validate all primitives build hierarchy from attribute - when enabled. - - The name of the attribute must exist on the prims and have the - same name as Build Hierarchy from Attribute's `Path Attribute` - value on the FilmBox node. - This validation enables 'Build Hierarchy from Attribute' - by default. - """ - - families = ["fbx"] - hosts = ["houdini"] - label = "Validate Prims Hierarchy Path (FBX)" - - # Usually you will use this value as default - order = ValidateContentsOrder + 0.1 - - # Validation can have as many actions as you want - # all of these actions are defined in a seperate place - # unlike the repair action - actions = [SelectInvalidAction, AddDefaultPathAction, - SelectROPAction] - - # 'OptionalPyblishPluginMixin' adds the functionality to - # enable/disable plugins, It requires adding new settings. - optional = True - - # overrides InstancePlugin.process() - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - nodes = [n.path() for n in invalid] - raise PublishValidationError( - "See log for details. " - "Invalid nodes: {0}".format(nodes), - title=self.label - ) - - # This method was named get_invalid as a convention - # it's also used by SelectInvalidAction to select - # the returned nodes - @classmethod - def get_invalid(cls, instance): - - output_node = instance.data.get("output_node") - rop_node = hou.node(instance.data["instance_node"]) - - if output_node is None: - cls.log.error( - "SOP Output node in '%s' does not exist. " - "Ensure a valid SOP output path is set.", - rop_node.path() - ) - - return [rop_node] - - build_from_path = rop_node.parm("buildfrompath").eval() - if not build_from_path: - cls.log.debug( - "Filmbox FBX has 'Build from Path' disabled. " - "Enbaling it as default." - ) - rop_node.parm("buildfrompath").set(1) - - path_attr = rop_node.parm("pathattrib").eval() - if not path_attr: - cls.log.debug( - "Filmbox FBX node has no Path Attribute" - "value set, setting it to 'path' as default." - ) - rop_node.parm("pathattrib").set("path") - - cls.log.debug("Checking for attribute: %s", path_attr) - - # Use current frame if "frameStart" doesn't exist - # This only happens when ""trange" is 0 - frame = instance.data.get("frameStart", hou.intFrame()) - - # Get Geo at that frame - geo = output_node.geometryAtFrame(frame) - if not geo: - cls.log.warning("No geometry found," - " skipping check..") - return - - # If there are no primitives on the current frame then - # we can't check whether the path names are correct. - # So we'll just issue a warning that the check can't - # be done consistently and skip validation. - - if len(geo.iterPrims()) == 0: - cls.log.warning( - "No primitives found on current frame." - " Validation for primitive hierarchy" - " paths will be skipped," - " thus can't be validated." - ) - return - - # Check if there are any values for the primitives - attrib = geo.findPrimAttrib(path_attr) - if not attrib: - cls.log.info( - "Geometry Primitives are missing " - "path attribute: `%s`", path_attr - ) - return [output_node] - - # Ensure at least a single string value is present - if not attrib.strings(): - cls.log.info( - "Primitive path attribute has no " - "string values: %s", path_attr - ) - return [output_node] - - paths = geo.primStringAttribValues(path_attr) - # Ensure all primitives are set to a valid path - # Collect all invalid primitive numbers - invalid_prims = [i for i, path in enumerate(paths) if not path] - if invalid_prims: - num_prims = len(geo.iterPrims()) # faster than len(geo.prims()) - cls.log.info( - "Prims have no value for attribute `%s` " - "(%s of %s prims)", - path_attr, len(invalid_prims), num_prims - ) - return [output_node] - - # what repair action expects to find and call - @classmethod - def repair(cls, instance): - """Add a default path attribute Action. - - It is a helper action more than a repair action, - used to add a default single value for the path. - """ - - rop_node = hou.node(instance.data["instance_node"]) - # I'm doing so because an artist may change output node - # before clicking the button. - output_node = rop_node.parm("startnode").evalAsNode() - - if not output_node: - cls.log.debug( - "Action isn't performed, invalid SOP Path on %s", - rop_node - ) - return - - # This check to prevent the action from running multiple times. - # git_invalid only returns [output_node] when - # path attribute is the problem - if cls.get_invalid(instance) != [output_node]: - return - - path_attr = rop_node.parm("pathattrib").eval() - - path_node = output_node.parent().createNode("name", - "AUTO_PATH") - path_node.parm("attribname").set(path_attr) - path_node.parm("name1").set('`opname("..")`_GEO') - - cls.log.debug( - "'%s' was created. It adds '%s' with a default" - " single value", path_node, path_attr - ) - - path_node.setGenericFlag(hou.nodeFlag.DisplayComment, True) - path_node.setComment( - 'Auto path node was created automatically by ' - '"Add a default path attribute"' - '\nFeel free to modify or replace it.' - ) - - if output_node.type().name() in ["null", "output"]: - # Connect before - path_node.setFirstInput(output_node.input(0)) - path_node.moveToGoodPosition() - output_node.setFirstInput(path_node) - output_node.moveToGoodPosition() - else: - # Connect after - path_node.setFirstInput(output_node) - rop_node.parm("startnode").set(path_node.path()) - path_node.moveToGoodPosition() - - cls.log.debug( - "SOP path on '%s' updated to new output node '%s'", - rop_node, path_node - ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py deleted file mode 100644 index ac80fda537..0000000000 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ /dev/null @@ -1,129 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate mesh is static. - -This plugin is part of publish process guide. -""" - -import pyblish.api -from openpype.pipeline import PublishValidationError -from openpype.pipeline.publish import ( - ValidateContentsOrder, - RepairAction, -) -from openpype.hosts.houdini.api.action import ( - SelectInvalidAction, - SelectROPAction, -) - -import hou - - -# Each validation can have a single repair action -# which calls the repair method -class FreezeTimeAction(RepairAction): - label = "Freeze Time" - icon = "ei.pause-alt" - - -class ValidateMeshIsStatic(pyblish.api.InstancePlugin): - """Validate mesh is static. - - It checks if node is time dependant. - """ - - families = ["staticMesh"] - hosts = ["houdini"] - label = "Validate mesh is static" - - # Usually you will use this value as default - order = ValidateContentsOrder + 0.1 - - # Validation can have as many actions as you want - # all of these actions are defined in a seperate place - # unlike the repair action - actions = [FreezeTimeAction, SelectInvalidAction, - SelectROPAction] - - # overrides InstancePlugin.process() - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - nodes = [n.path() for n in invalid] - raise PublishValidationError( - "See log for details. " - "Invalid nodes: {0}".format(nodes), - title=self.label - ) - - # This method was named get_invalid as a convention - # it's also used by SelectInvalidAction to select - # the returned nodes - @classmethod - def get_invalid(cls, instance): - - output_node = instance.data.get("output_node") - if output_node.isTimeDependent(): - cls.log.info("Mesh is not static!") - return [output_node] - - # what repair action expects to find and call - @classmethod - def repair(cls, instance): - """Adds a time shift node. - - It should kill time dependency. - """ - - rop_node = hou.node(instance.data["instance_node"]) - # I'm doing so because an artist may change output node - # before clicking the button. - output_node = rop_node.parm("startnode").evalAsNode() - - if not output_node: - cls.log.debug( - "Action isn't performed, invalid SOP Path on %s", - rop_node - ) - return - - # This check to prevent the action from running multiple times. - # git_invalid only returns [output_node] when - # path attribute is the problem - if cls.get_invalid(instance) != [output_node]: - return - - time_shift = output_node.parent().createNode("timeshift", - "freeze_time") - time_shift.parm("frame").deleteAllKeyframes() - - frame = instance.data.get("frameStart", hou.intFrame()) - time_shift.parm("frame").set(frame) - - cls.log.debug( - "'%s' was created. It will kill time dependency.", - time_shift - ) - - time_shift.setGenericFlag(hou.nodeFlag.DisplayComment, True) - time_shift.setComment( - 'This node was created automatically by ' - '"Freeze Time" Action' - '\nFeel free to modify or replace it.' - ) - - if output_node.type().name() in ["null", "output"]: - # Connect before - time_shift.setFirstInput(output_node.input(0)) - time_shift.moveToGoodPosition() - output_node.setFirstInput(time_shift) - output_node.moveToGoodPosition() - else: - # Connect after - time_shift.setFirstInput(output_node) - rop_node.parm("startnode").set(time_shift.path()) - time_shift.moveToGoodPosition() - - cls.log.debug( - "SOP path on '%s' updated to new output node '%s'", - rop_node, time_shift - ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py new file mode 100644 index 0000000000..be450a0410 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ValidateContentsOrder + +from openpype.hosts.houdini.api.action import SelectInvalidAction + +import hou + + +class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate name of Unreal Static Mesh + + This validator checks if output node name has a collision prefix: + - UBX + - UCP + - USP + - UCX + + This validator also checks if subset name is correct + - {static mesh prefix}_{Asset-Name}{Variant}. + + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Unreal Static Mesh Name (FBX)" + order = ValidateContentsOrder + 0.1 + actions = [SelectInvalidAction] + + optional = True + + @classmethod + def apply_settings(cls, project_settings, system_settings): + settings = ( + project_settings["houdini"]["create"]["CreateUnrealStaticMesh"] + ) + cls.collision_prefixes = settings["collision_prefixes"] + cls.static_mesh_prefix = settings["static_mesh_prefix"] + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes), + title=self.label + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + rop_node = hou.node(instance.data["instance_node"]) + output_node = instance.data.get("output_node") + cls.log.debug(cls.collision_prefixes) + + # Check nodes names + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + for prefix in cls.collision_prefixes: + if child.name().startswith(prefix): + invalid.append(child) + cls.log.error( + "Invalid name: Child node '%s' in '%s' " + "has a collision prefix '%s'" + , child.name(), output_node.path(), prefix + ) + break + else: + cls.log.debug(output_node.name()) + for prefix in cls.collision_prefixes: + if output_node.name().startswith(prefix): + invalid.append(output_node) + cls.log.error( + "Invalid name: output node '%s' " + "has a collision prefix '%s'" + , output_node.name(), prefix + ) + + # Check subset name + subset_name = "{}_{}{}".format( + cls.static_mesh_prefix, + instance.data["asset"], + instance.data.get("variant", "") + ) + + if instance.data.get("subset") != subset_name: + invalid.append(rop_node) + cls.log.error( + "Invalid subset name on rop node '%s' should be '%s'." + , rop_node.path(), subset_name + ) + + return invalid From 128939068084b3b5af50db7812d1a9b8021a471c Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:23:53 +0300 Subject: [PATCH 51/90] update settings --- .../plugins/publish/validate_unreal_staticmesh_naming.py | 6 +++++- openpype/settings/defaults/project_settings/houdini.json | 2 +- .../projects_schema/schemas/schema_houdini_publish.json | 4 ++-- server_addon/houdini/server/settings/publish_plugins.py | 6 +++--- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index be450a0410..24ef304185 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -14,7 +14,7 @@ import hou class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): - """Validate name of Unreal Static Mesh + """Validate name of Unreal Static Mesh. This validator checks if output node name has a collision prefix: - UBX @@ -44,6 +44,10 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, cls.static_mesh_prefix = settings["static_mesh_prefix"] def process(self, instance): + + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index c39eb717fd..65f13fa1ab 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -111,7 +111,7 @@ "optional": true, "active": true }, - "ValidateFBXPrimitiveHierarchyPaths": { + "ValidateUnrealStaticMeshName": { "enabled": true, "optional": true, "active": true diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index d58b36eff1..4339f86db6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -45,8 +45,8 @@ "label": "ValidateContainers" }, { - "key": "ValidateFBXPrimitiveHierarchyPaths", - "label": "Validate Path Attribute for FBX" + "key": "ValidateUnrealStaticMeshName", + "label": "Validate Unreal Static Mesh Name" } ] } diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 1e5cd7f551..335751e5f9 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -164,9 +164,9 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Latest Containers.") - ValidateFBXPrimitiveHierarchyPaths: ValidateContainersModel = Field( + ValidateUnrealStaticMeshName: ValidateContainersModel = Field( default_factory=ValidateContainersModel, - title="Validate Path Attribute for FBX.") + title="Validate Unreal Static Mesh Name.") DEFAULT_HOUDINI_PUBLISH_SETTINGS = { @@ -187,7 +187,7 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "optional": True, "active": True }, - "ValidateFBXPrimitiveHierarchyPaths": { + "ValidateUnrealStaticMeshName": { "enabled": True, "optional": True, "active": True From c73d76ef15289f868d016c689ecaa880c3144262 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:33:54 +0300 Subject: [PATCH 52/90] resolve houn --- .../plugins/create/create_unreal_staticmesh.py | 13 +++++++------ .../publish/validate_unreal_staticmesh_naming.py | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 0b5b313d9e..6002f7b1d7 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -74,12 +74,13 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): attrs = super().get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", - tooltip="Create an extra root for the Export node " - "when it’s a subnetwork. This causes the " - "exporting subnetwork node to be " - "represented in the FBX file.", - default=False, - label="Create Root for Subnet") + tooltip="Create an extra root for the " + "Export node when it’s a " + "subnetwork. This causes the " + "exporting subnetwork node to be " + "represented in the FBX file.", + default=False, + label="Create Root for Subnet") vcformat = EnumDef("vcformat", items={ 0: "Maya Compatible (MC)", diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 24ef304185..a3426d2f19 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -74,8 +74,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid.append(child) cls.log.error( "Invalid name: Child node '%s' in '%s' " - "has a collision prefix '%s'" - , child.name(), output_node.path(), prefix + "has a collision prefix '%s'", + child.name(), output_node.path(), prefix ) break else: @@ -85,8 +85,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid.append(output_node) cls.log.error( "Invalid name: output node '%s' " - "has a collision prefix '%s'" - , output_node.name(), prefix + "has a collision prefix '%s'", + output_node.name(), prefix ) # Check subset name @@ -99,8 +99,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, if instance.data.get("subset") != subset_name: invalid.append(rop_node) cls.log.error( - "Invalid subset name on rop node '%s' should be '%s'." - , rop_node.path(), subset_name + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name ) return invalid From 1f452510d8e4fb38deaebe59eb5f4d970d2b5aa1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 17:35:57 +0300 Subject: [PATCH 53/90] resolve hound --- .../plugins/publish/validate_unreal_staticmesh_naming.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index a3426d2f19..7820be4009 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -99,8 +99,8 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, if instance.data.get("subset") != subset_name: invalid.append(rop_node) cls.log.error( - "Invalid subset name on rop node '%s' should be '%s'.", - rop_node.path(), subset_name + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name ) return invalid From 938dc72d9179ee8938a98f0ce326c4dfadda5657 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 11:56:10 +0300 Subject: [PATCH 54/90] revise creator and collector --- .../create/create_unreal_staticmesh.py | 36 +++---------------- .../plugins/publish/collect_fbx_type.py | 33 ----------------- .../publish/collect_staticmesh_type.py | 20 +++++++++++ .../validate_primitive_hierarchy_paths.py | 2 +- 4 files changed, 26 insertions(+), 65 deletions(-) delete mode 100644 openpype/hosts/houdini/plugins/publish/collect_fbx_type.py create mode 100644 openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index 6002f7b1d7..ca5e2e8fb4 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -1,21 +1,5 @@ # -*- coding: utf-8 -*- -"""Creator plugin for creating Unreal Static Meshes. - -Unreal Static Meshes will be published as FBX. - -Filmbox by default expects an ObjNode -however, we set the sop node explictly -to eleminate any confusion. - -This will make Filmbox to ignore any object transformations! - -get_obj_output selects -the output sop with mimimum idx -or the node with render flag isntead. - -This plugin is part of publish process guide. -""" - +"""Creator for Unreal Static Meshes.""" from openpype.hosts.houdini.api import plugin from openpype.lib import BoolDef, EnumDef @@ -25,30 +9,23 @@ import hou class CreateUnrealStaticMesh(plugin.HoudiniCreator): """Unreal Static Meshes with collisions. """ - # you should set identifier = "io.openpype.creators.houdini.unrealstaticmesh.fbx" label = "Unreal - Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" - # optional to set - default_variant = "Main" - # 'default_variants' will be overriden by settings. - default_variants = ["Main", "Test"] + default_variants = ["Main"] - # Overrides HoudiniCreator.create() def create(self, subset_name, instance_data, pre_create_data): - # set node type instance_data.update({"node_type": "filmboxfbx"}) - # create instance (calls HoudiniCreator.create()) instance = super(CreateUnrealStaticMesh, self).create( subset_name, instance_data, pre_create_data) - # get the created node + # get the created rop node instance_node = hou.node(instance.get("instance_node")) # get parms @@ -61,21 +38,19 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) - # Overrides HoudiniCreator.get_network_categories() def get_network_categories(self): return [ hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] - # Overrides HoudiniCreator.get_pre_create_attr_defs() def get_pre_create_attr_defs(self): """Add settings for users. """ attrs = super().get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " - "Export node when it’s a " + "Export node when it's a " "subnetwork. This causes the " "exporting subnetwork node to be " "represented in the FBX file.", @@ -98,7 +73,6 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): return attrs + [createsubnetroot, vcformat, convert_units] - # Overrides BaseCreator.get_dynamic_data() def get_dynamic_data( self, variant, task_name, asset_doc, project_name, host_name, instance ): @@ -113,7 +87,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): return dynamic_data def get_parms(self, subset_name, pre_create_data): - """Get parameters values for this specific node.""" + """Get parameters values. """ # 1. get output path output_path = hou.text.expandString( diff --git a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py b/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py deleted file mode 100644 index 6ac40a4f50..0000000000 --- a/openpype/hosts/houdini/plugins/publish/collect_fbx_type.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Collector for filmboxfbx types. - -Collectors act as a pre process for the validation stage. -It is used mainly to update instance.data - -P.S. - There are some collectors that run by default - for all types. - -This plugin is part of publish process guide. -""" -import pyblish.api - - -class CollectFilmboxfbxType(pyblish.api.InstancePlugin): - """Collect data type for fbx instance.""" - - hosts = ["houdini"] - families = ["staticMesh"] - label = "Collect type of fbx" - - # Usually you will use this value as default - order = pyblish.api.CollectorOrder - - # overrides InstancePlugin.process() - def process(self, instance): - - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 - # such a condition can be used to differentiate between - # instances by identifier because sometimes instances - # may have the same family but different identifier - # e.g. bgeo and alembic - instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py new file mode 100644 index 0000000000..8fb07c1c5c --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""Collector for staticMesh types. """ + +import pyblish.api + + +class CollectStaticMeshType(pyblish.api.InstancePlugin): + """Collect data type for fbx instance.""" + + hosts = ["houdini"] + families = ["staticMesh"] + label = "Collect type of staticMesh" + + order = pyblish.api.CollectorOrder + + def process(self, instance): + + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 + # Marking this instance as FBX which triggers the FBX extractor. + instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index 930978ef16..471fa5b6d1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -26,7 +26,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): order = ValidateContentsOrder + 0.1 families = ["abc"] hosts = ["houdini"] - label = "Validate Prims Hierarchy Path (ABC)" + label = "Validate Prims Hierarchy Path" actions = [AddDefaultPathAction] def process(self, instance): From ad62e1fd469fe122b9b7490a5d68b49db26741b2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 15:06:42 +0300 Subject: [PATCH 55/90] revise collect-validators-extract --- .../hosts/houdini/plugins/load/load_fbx.py | 21 ++---- .../publish/collect_staticmesh_type.py | 2 +- .../houdini/plugins/publish/extract_fbx.py | 13 +--- .../publish/validate_mesh_is_static.py | 70 +++++++++++++++++++ .../plugins/publish/validate_output_node.py | 55 +++++++++++++++ .../publish/validate_sop_output_node.py | 2 +- .../validate_unreal_staticmesh_naming.py | 9 ++- 7 files changed, 142 insertions(+), 30 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py create mode 100644 openpype/hosts/houdini/plugins/publish/validate_output_node.py diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 681837e046..9c7dbf578e 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -1,12 +1,5 @@ # -*- coding: utf-8 -*- -"""Fbx Loader for houdini. - -It's almost a copy of -'load_bgeo.py'and 'load_alembic.py' -however this one includes extra comments for demonstration. - -This plugin is part of publish process guide. -""" +"""Fbx Loader for houdini. """ from openpype.pipeline import ( load, get_representation_path, @@ -15,17 +8,17 @@ from openpype.hosts.houdini.api import pipeline class FbxLoader(load.LoaderPlugin): - """Load fbx files to Houdini.""" + """Load fbx files. """ label = "Load FBX" - families = ["staticMesh", "fbx"] - representations = ["fbx"] - - # Usually you will use these value as default - order = -10 icon = "code-fork" color = "orange" + order = -10 + + families = ["staticMesh", "fbx"] + representations = ["fbx"] + def load(self, context, name=None, namespace=None, data=None): # get file path diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py index 8fb07c1c5c..263d7c1001 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -16,5 +16,5 @@ class CollectStaticMeshType(pyblish.api.InstancePlugin): def process(self, instance): if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 - # Marking this instance as FBX which triggers the FBX extractor. + # Marking this instance as FBX triggers the FBX extractor. instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index c0e84c00c8..2a95734ece 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -1,15 +1,8 @@ -"""Extract FilmBox FBX. - -Extractors are used to generate output and -update representation dictionary. - -This plugin is part of publish process guide. -""" +# -*- coding: utf-8 -*- +"""Fbx Extractor for houdini. """ import os - import pyblish.api - from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop @@ -22,10 +15,8 @@ class ExtractFBX(publish.Extractor): families = ["fbx"] hosts = ["houdini"] - # Usually you will use this value as default order = pyblish.api.ExtractorOrder + 0.1 - # overrides Extractor.process() def process(self, instance): # get rop node diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py new file mode 100644 index 0000000000..90985b4239 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ValidateContentsOrder + +from openpype.hosts.houdini.api.action import SelectInvalidAction + +import hou + + +class ValidateMeshIsStatic(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate mesh is static. + + It checks if output node is time dependant. + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate Mesh is Static" + order = ValidateContentsOrder + 0.1 + actions = [SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + output_node = instance.data.get("output_node") + if output_node is None: + cls.log.debug( + "No Output Node, skipping check.." + ) + return + + + + if output_node.name().isTimeDependent(): + invalid.append(output_node) + cls.log.error( + "Output node '%s' is time dependent.", + output_node.name() + ) + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + if output_node.name().isTimeDependent(): + invalid.append(child) + cls.log.error( + "Child node '%s' in '%s' " + "his time dependent.", + child.name(), output_node.path() + ) + break + + return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_output_node.py new file mode 100644 index 0000000000..99a6cda077 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_output_node.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou + + +class ValidateOutputNode(pyblish.api.InstancePlugin): + """Validate the instance Output Node. + + This will ensure: + - The Output Node Path is set. + - The Output Node Path refers to an existing object. + """ + + order = pyblish.api.ValidatorOrder + families = ["fbx"] + hosts = ["houdini"] + label = "Validate Output Node" + actions = [SelectROPAction, SelectInvalidAction] + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Output node(s) are incorrect", + title="Invalid output node(s)" + ) + + @classmethod + def get_invalid(cls, instance): + output_node = instance.data.get("output_node") + + if output_node is None: + rop_node = hou.node(instance.data["instance_node"]) + cls.log.error( + "Output node in '%s' does not exist. " + "Ensure a valid output path is set.", rop_node.path() + ) + + return [rop_node] + + if output_node.type().category().name() not in ["Sop", "Object"]: + cls.log.error( + "Output node %s is not a SOP or OBJ node. " + "It must point to a SOP or OBJ node, " + "instead found category type: %s" + % (output_node.path(), output_node.type().category().name()) + ) + return [output_node] diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index d9dee38680..9590e37d26 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -24,7 +24,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["pointcache", "vdbcache"] hosts = ["houdini"] - label = "Validate Output Node" + label = "Validate Output Node (SOP)" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 7820be4009..f1ea9b3844 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -53,8 +53,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] raise PublishValidationError( "See log for details. " - "Invalid nodes: {0}".format(nodes), - title=self.label + "Invalid nodes: {0}".format(nodes) ) @classmethod @@ -64,7 +63,11 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, rop_node = hou.node(instance.data["instance_node"]) output_node = instance.data.get("output_node") - cls.log.debug(cls.collision_prefixes) + if output_node is None: + cls.log.debug( + "No Output Node, skipping check.." + ) + return # Check nodes names if output_node.childTypeCategory() == hou.objNodeTypeCategory(): From 1678bd56032e8975301651c4bcdfb6ae1290dce6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 19:48:15 +0300 Subject: [PATCH 56/90] update fetch output --- .../validate_unreal_staticmesh_naming.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index f1ea9b3844..3c13f081a9 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -107,3 +107,30 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return invalid + + def get_outputs(self, output_node): + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + out_list = [output_node] + for child in output_node.children(): + out_list += self.get_outputs(child) + + return out_list + + elif output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + return [output_node, self.get_obj_output(output_node)] + + def get_obj_output(self, obj_node): + """Find sop output node, """ + + outputs = obj_node.subnetOutputs() + + if not outputs: + return + + elif len(outputs) == 1: + return outputs[0] + + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) From 79aab2534ea53931980b4bb44e52713047898c65 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 21:59:04 +0300 Subject: [PATCH 57/90] update retrieving output nodes --- openpype/hosts/houdini/api/lib.py | 39 ++++++++++++ .../publish/validate_mesh_is_static.py | 27 +++----- .../validate_unreal_staticmesh_naming.py | 62 +++++-------------- 3 files changed, 65 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 75c7ff9fee..0f1cfe0717 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -649,3 +649,42 @@ def get_color_management_preferences(): "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } + + +def get_obj_node_output(obj_node): + """Find output node. + + get the output node with the minimum 'outputidx' + or the node with display flag. + """ + + outputs = obj_node.subnetOutputs() + if not outputs: + return + + elif len(outputs) == 1: + return outputs[0] + + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) + + +def get_output_children(output_node, include_sops=True): + """Recursively return a list of all output nodes + contained in this node including this node. + + It works in a similar manner to output_node.allNodes(). + """ + out_list = [output_node] + + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + for child in output_node.children(): + out_list += get_output_children(child, include_sops=include_sops) + + elif include_sops and output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + out = get_obj_node_output(output_node) + if out: + out_list += [out] + + return out_list diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 90985b4239..36c8ef6d63 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.hosts.houdini.api.lib import get_output_children import hou @@ -47,24 +48,14 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin, ) return + all_outputs = get_output_children(output_node) - - if output_node.name().isTimeDependent(): - invalid.append(output_node) - cls.log.error( - "Output node '%s' is time dependent.", - output_node.name() - ) - - if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - for child in output_node.children(): - if output_node.name().isTimeDependent(): - invalid.append(child) - cls.log.error( - "Child node '%s' in '%s' " - "his time dependent.", - child.name(), output_node.path() - ) - break + for output in all_outputs: + if output.isTimeDependent(): + invalid.append(output) + cls.log.error( + "Output node '%s' is time dependent.", + output.path() + ) return invalid diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 3c13f081a9..5558b43258 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -8,6 +8,7 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.hosts.houdini.api.lib import get_output_children import hou @@ -69,28 +70,26 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return + if not rop_node.evalParm('buildfrompath'): + # This validator doesn't support naming check if + # building hierarchy from path' is used + cls.log.info( + "Using 'Build Hierarchy from Path Attribute', skipping check.." + ) + return + # Check nodes names - if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - for child in output_node.children(): - for prefix in cls.collision_prefixes: - if child.name().startswith(prefix): - invalid.append(child) - cls.log.error( - "Invalid name: Child node '%s' in '%s' " - "has a collision prefix '%s'", - child.name(), output_node.path(), prefix - ) - break - else: - cls.log.debug(output_node.name()) + all_outputs = get_output_children(output_node, include_sops=False) + for output in all_outputs: for prefix in cls.collision_prefixes: - if output_node.name().startswith(prefix): - invalid.append(output_node) + if output.name().startswith(prefix): + invalid.append(output) cls.log.error( - "Invalid name: output node '%s' " - "has a collision prefix '%s'", - output_node.name(), prefix + "Invalid node name: Node '%s' " + "includes a collision prefix '%s'", + output.path(), prefix ) + break # Check subset name subset_name = "{}_{}{}".format( @@ -107,30 +106,3 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return invalid - - def get_outputs(self, output_node): - - if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - out_list = [output_node] - for child in output_node.children(): - out_list += self.get_outputs(child) - - return out_list - - elif output_node.childTypeCategory() == hou.sopNodeTypeCategory(): - return [output_node, self.get_obj_output(output_node)] - - def get_obj_output(self, obj_node): - """Find sop output node, """ - - outputs = obj_node.subnetOutputs() - - if not outputs: - return - - elif len(outputs) == 1: - return outputs[0] - - else: - return min(outputs, - key=lambda node: node.evalParm('outputidx')) From 9ea11e7f2d90faf25487ebe2f8f7db4d461da018 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 22:02:23 +0300 Subject: [PATCH 58/90] resolve hound --- openpype/hosts/houdini/api/lib.py | 7 ++++--- .../houdini/plugins/publish/validate_mesh_is_static.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 0f1cfe0717..3e51912c26 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -667,7 +667,7 @@ def get_obj_node_output(obj_node): else: return min(outputs, - key=lambda node: node.evalParm('outputidx')) + key=lambda node: node.evalParm('outputidx')) def get_output_children(output_node, include_sops=True): @@ -682,8 +682,9 @@ def get_output_children(output_node, include_sops=True): for child in output_node.children(): out_list += get_output_children(child, include_sops=include_sops) - elif include_sops and output_node.childTypeCategory() == hou.sopNodeTypeCategory(): - out = get_obj_node_output(output_node) + elif include_sops and \ + output_node.childTypeCategory() == hou.sopNodeTypeCategory(): + out = get_obj_node_output(output_node) if out: out_list += [out] diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 36c8ef6d63..25ab362a88 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -14,7 +14,7 @@ import hou class ValidateMeshIsStatic(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): + OptionalPyblishPluginMixin): """Validate mesh is static. It checks if output node is time dependant. From d54111bd75843c4713cac3409c1770594ce634e2 Mon Sep 17 00:00:00 2001 From: Mustafa Taher Date: Tue, 5 Sep 2023 11:59:12 +0300 Subject: [PATCH 59/90] Update docstring Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 3e51912c26..b108d0d881 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -654,8 +654,19 @@ def get_color_management_preferences(): def get_obj_node_output(obj_node): """Find output node. - get the output node with the minimum 'outputidx' - or the node with display flag. + If the node has any output node return the + output node with the minimum `outputidx`. + When no output is present return the node + with the display flag set. If no output node is + detected then None is returned. + + Arguments: + node (hou.Node): The node to retrieve a single + the output node for. + + Returns: + Optional[hou.Node]: The child output node. + """ outputs = obj_node.subnetOutputs() From 4456ac86f41e7fa529b5f07d32da6e71f601128d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 5 Sep 2023 17:56:49 +0200 Subject: [PATCH 60/90] :art: add default isort config --- setup.cfg | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 10cca3eb3f..216bae848f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,4 +28,11 @@ omit = /tests directory = ./coverage [tool:pytest] -norecursedirs = repos/* openpype/modules/ftrack/* \ No newline at end of file +norecursedirs = repos/* openpype/modules/ftrack/* + +[isort] +line_length = 79 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +combine_as_imports = True From 45f86749e165f3825ccdff4dbe8200180f4369b8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 5 Sep 2023 19:52:13 +0300 Subject: [PATCH 61/90] resolve some comments --- .../create/create_unreal_staticmesh.py | 50 +++------- .../hosts/houdini/plugins/load/load_fbx.py | 2 +- .../houdini/plugins/publish/extract_fbx.py | 45 +++------ ...ut_node.py => validate_fbx_output_node.py} | 3 +- .../publish/validate_mesh_is_static.py | 2 +- .../plugins/publish/validate_subset_name.py | 94 +++++++++++++++++++ .../validate_unreal_staticmesh_naming.py | 21 +---- .../defaults/project_settings/houdini.json | 10 ++ .../schemas/schema_houdini_publish.json | 8 ++ .../server/settings/publish_plugins.py | 16 ++++ 10 files changed, 164 insertions(+), 87 deletions(-) rename openpype/hosts/houdini/plugins/publish/{validate_output_node.py => validate_fbx_output_node.py} (93%) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_subset_name.py diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index ca5e2e8fb4..fc3783c0d1 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -28,8 +28,18 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): # get the created rop node instance_node = hou.node(instance.get("instance_node")) - # get parms - parms = self.get_parms(subset_name, pre_create_data) + # prepare parms + output_path = hou.text.expandString("$HIP/pyblish/{}.fbx".format(subset_name)) + parms = { + "startnode": self.get_selection(), + "sopoutput": output_path, + # vertex cache format + "vcformat": pre_create_data.get("vcformat"), + "convertunits": pre_create_data.get("convertunits"), + # set render range to use frame range start-end frame + "trange": 1, + "createsubnetroot": pre_create_data.get("createsubnetroot") + } # set parms instance_node.setParms(parms) @@ -47,7 +57,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): """Add settings for users. """ - attrs = super().get_pre_create_attr_defs() + attrs = super(CreateUnrealStaticMesh, self).get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " "Export node when it's a " @@ -86,40 +96,6 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): dynamic_data["asset"] = asset_doc["name"] return dynamic_data - def get_parms(self, subset_name, pre_create_data): - """Get parameters values. """ - - # 1. get output path - output_path = hou.text.expandString( - "$HIP/pyblish/{}.fbx".format(subset_name)) - - # 2. get selection - selection = self.get_selection() - - # 3. get Vertex Cache Format - vcformat = pre_create_data.get("vcformat") - - # 4. get convert_units - convertunits = pre_create_data.get("convertunits") - - # 5. get Valid Frame Range - trange = 1 - - # 6. get createsubnetroot - createsubnetroot = pre_create_data.get("createsubnetroot") - - # parms dictionary - parms = { - "startnode": selection, - "sopoutput": output_path, - "vcformat": vcformat, - "convertunits": convertunits, - "trange": trange, - "createsubnetroot": createsubnetroot - } - - return parms - def get_selection(self): """Selection Logic. diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 9c7dbf578e..7e7f0c04e5 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -146,5 +146,5 @@ class FbxLoader(load.LoaderPlugin): # Set new position for children nodes parent_node.layoutChildren() - # Retrun all the nodes + # Return all the nodes return [parent_node, file_node, attribdelete, null] diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 2a95734ece..e8cd207818 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -21,40 +21,17 @@ class ExtractFBX(publish.Extractor): # get rop node ropnode = hou.node(instance.data.get("instance_node")) + output_node = ropnode.evalParm("sopoutput") + + # get staging_dir and file_name + staging_dir = os.path.normpath(os.path.dirname(output_node)) + file_name = os.path.basename(output_node) # render rop + self.log.debug("Writing FBX '%s' to '%s'",file_name, staging_dir) render_rop(ropnode) - # get required data - file_name, staging_dir = self.get_paths_data(ropnode) - representation = self.get_representation(instance, - file_name, - staging_dir) - - # set value type for 'representations' key to list - if "representations" not in instance.data: - instance.data["representations"] = [] - - # update instance data - instance.data["stagingDir"] = staging_dir - instance.data["representations"].append(representation) - - def get_paths_data(self, ropnode): - # Get the filename from the filename parameter - output = ropnode.evalParm("sopoutput") - - staging_dir = os.path.normpath(os.path.dirname(output)) - - file_name = os.path.basename(output) - - self.log.info("Writing FBX '%s' to '%s'" % (file_name, - staging_dir)) - - return file_name, staging_dir - - def get_representation(self, instance, - file_name, staging_dir): - + # prepare representation representation = { "name": "fbx", "ext": "fbx", @@ -67,4 +44,10 @@ class ExtractFBX(publish.Extractor): representation["frameStart"] = instance.data["frameStart"] representation["frameEnd"] = instance.data["frameEnd"] - return representation + # set value type for 'representations' key to list + if "representations" not in instance.data: + instance.data["representations"] = [] + + # update instance data + instance.data["stagingDir"] = staging_dir + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py similarity index 93% rename from openpype/hosts/houdini/plugins/publish/validate_output_node.py rename to openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index 99a6cda077..503a3bb3c1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -9,12 +9,13 @@ from openpype.hosts.houdini.api.action import ( import hou -class ValidateOutputNode(pyblish.api.InstancePlugin): +class ValidateFBXOutputNode(pyblish.api.InstancePlugin): """Validate the instance Output Node. This will ensure: - The Output Node Path is set. - The Output Node Path refers to an existing object. + - The Output Node is a Sop or Obj node. """ order = pyblish.api.ValidatorOrder diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 25ab362a88..4d0904eb53 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -17,7 +17,7 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validate mesh is static. - It checks if output node is time dependant. + It checks if output node is time dependent. """ families = ["staticMesh"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py new file mode 100644 index 0000000000..299729a6e8 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""Validator for correct naming of Static Meshes.""" +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) +from openpype.pipeline.publish import ( + ValidateContentsOrder, + RepairAction, +) +from openpype.hosts.houdini.api.action import SelectInvalidAction +from openpype.pipeline.create import get_subset_name + +import hou + + +class FixSubsetNameAction(RepairAction): + label = "Fix Subset Name" + + +class ValidateSubsetName(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validate Subset name. + + """ + + families = ["staticMesh"] + hosts = ["houdini"] + label = "Validate Subset Name" + order = ValidateContentsOrder + 0.1 + actions = [FixSubsetNameAction, SelectInvalidAction] + + optional = True + + + def process(self, instance): + + if not self.is_active(instance.data): + return + + invalid = self.get_invalid(instance) + if invalid: + nodes = [n.path() for n in invalid] + raise PublishValidationError( + "See log for details. " + "Invalid nodes: {0}".format(nodes) + ) + + @classmethod + def get_invalid(cls, instance): + + invalid = [] + + rop_node = hou.node(instance.data["instance_node"]) + + # Check subset name + subset_name = get_subset_name( + family=instance.data["family"], + variant=instance.data["variant"], + task_name=instance.data["task"], + asset_doc=instance.data["assetEntity"], + dynamic_data={"asset":instance.data["asset"]} + ) + + if instance.data.get("subset") != subset_name: + invalid.append(rop_node) + cls.log.error( + "Invalid subset name on rop node '%s' should be '%s'.", + rop_node.path(), subset_name + ) + + return invalid + + @classmethod + def repair(cls, instance): + rop_node = hou.node(instance.data["instance_node"]) + + # Check subset name + subset_name = get_subset_name( + family=instance.data["family"], + variant=instance.data["variant"], + task_name=instance.data["task"], + asset_doc=instance.data["assetEntity"], + dynamic_data={"asset":instance.data["asset"]} + ) + + instance.data["subset"] = subset_name + rop_node.parm("subset").set(subset_name) + + cls.log.debug( + "Subset name on rop node '%s' has been set to '%s'.", + rop_node.path(), subset_name + ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 5558b43258..791db8198f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -35,9 +35,12 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, actions = [SelectInvalidAction] optional = True + collision_prefixes = [] + static_mesh_prefix = "" @classmethod def apply_settings(cls, project_settings, system_settings): + settings = ( project_settings["houdini"]["create"]["CreateUnrealStaticMesh"] ) @@ -51,7 +54,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, invalid = self.get_invalid(instance) if invalid: - nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) @@ -70,7 +73,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) return - if not rop_node.evalParm('buildfrompath'): + if rop_node.evalParm("buildfrompath"): # This validator doesn't support naming check if # building hierarchy from path' is used cls.log.info( @@ -91,18 +94,4 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, ) break - # Check subset name - subset_name = "{}_{}{}".format( - cls.static_mesh_prefix, - instance.data["asset"], - instance.data.get("variant", "") - ) - - if instance.data.get("subset") != subset_name: - invalid.append(rop_node) - cls.log.error( - "Invalid subset name on rop node '%s' should be '%s'.", - rop_node.path(), subset_name - ) - return invalid diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 65f13fa1ab..7673725831 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -111,6 +111,16 @@ "optional": true, "active": true }, + "ValidateSubsetName": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateMeshIsStatic": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateUnrealStaticMeshName": { "enabled": true, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index 4339f86db6..670b1a0bc2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -44,6 +44,14 @@ "key": "ValidateContainers", "label": "ValidateContainers" }, + { + "key": "ValidateSubsetName", + "label": "Validate Subset Name" + }, + { + "key": "ValidateMeshIsStatic", + "label": "Validate Mesh is Static" + }, { "key": "ValidateUnrealStaticMeshName", "label": "Validate Unreal Static Mesh Name" diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 335751e5f9..b3e47d6948 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -164,6 +164,12 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Latest Containers.") + ValidateSubsetName: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Subset Name.") + ValidateMeshIsStatic: ValidateContainersModel = Field( + default_factory=ValidateContainersModel, + title="Validate Mesh is Static.") ValidateUnrealStaticMeshName: ValidateContainersModel = Field( default_factory=ValidateContainersModel, title="Validate Unreal Static Mesh Name.") @@ -187,6 +193,16 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateSubsetName": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateMeshIsStatic": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateUnrealStaticMeshName": { "enabled": True, "optional": True, From ebae3cf03ef443b51296eb160a06d26e3c4ba637 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 5 Sep 2023 19:54:31 +0300 Subject: [PATCH 62/90] remove white spaces --- openpype/hosts/houdini/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index b108d0d881..7d3edbc707 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -654,19 +654,19 @@ def get_color_management_preferences(): def get_obj_node_output(obj_node): """Find output node. - If the node has any output node return the + If the node has any output node return the output node with the minimum `outputidx`. When no output is present return the node with the display flag set. If no output node is detected then None is returned. - + Arguments: node (hou.Node): The node to retrieve a single the output node for. - + Returns: Optional[hou.Node]: The child output node. - + """ outputs = obj_node.subnetOutputs() From f00d76c0330f37eff8956cfd633484e1cd607ec5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 5 Sep 2023 19:58:13 +0300 Subject: [PATCH 63/90] resolve hound --- .../hosts/houdini/plugins/create/create_unreal_staticmesh.py | 5 ++++- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 4 ++-- .../hosts/houdini/plugins/publish/validate_subset_name.py | 5 ++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py index fc3783c0d1..2f92def54a 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py @@ -29,7 +29,10 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): instance_node = hou.node(instance.get("instance_node")) # prepare parms - output_path = hou.text.expandString("$HIP/pyblish/{}.fbx".format(subset_name)) + output_path = hou.text.expandString( + "$HIP/pyblish/{}.fbx".format(subset_name) + ) + parms = { "startnode": self.get_selection(), "sopoutput": output_path, diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index e8cd207818..dd61e68f3b 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -28,7 +28,7 @@ class ExtractFBX(publish.Extractor): file_name = os.path.basename(output_node) # render rop - self.log.debug("Writing FBX '%s' to '%s'",file_name, staging_dir) + self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) render_rop(ropnode) # prepare representation @@ -36,7 +36,7 @@ class ExtractFBX(publish.Extractor): "name": "fbx", "ext": "fbx", "files": file_name, - "stagingDir": staging_dir, + "stagingDir": staging_dir } # A single frame may also be rendered without start/end frame. diff --git a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py index 299729a6e8..bb3648f361 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_subset_name.py +++ b/openpype/hosts/houdini/plugins/publish/validate_subset_name.py @@ -33,7 +33,6 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, optional = True - def process(self, instance): if not self.is_active(instance.data): @@ -60,7 +59,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, variant=instance.data["variant"], task_name=instance.data["task"], asset_doc=instance.data["assetEntity"], - dynamic_data={"asset":instance.data["asset"]} + dynamic_data={"asset": instance.data["asset"]} ) if instance.data.get("subset") != subset_name: @@ -82,7 +81,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin, variant=instance.data["variant"], task_name=instance.data["task"], asset_doc=instance.data["assetEntity"], - dynamic_data={"asset":instance.data["asset"]} + dynamic_data={"asset": instance.data["asset"]} ) instance.data["subset"] = subset_name From d2140c36192d353d68c287af6385fac1b8fa68d7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 6 Sep 2023 21:37:20 +0300 Subject: [PATCH 64/90] match maya render mask in houdini --- openpype/hosts/houdini/api/lib.py | 53 ++++++++++++ openpype/hosts/houdini/api/pipeline.py | 2 + .../plugins/inventory/set_asset_resolution.py | 24 ++++++ .../hosts/houdini/plugins/load/load_camera.py | 81 +++++++++++-------- 4 files changed, 126 insertions(+), 34 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 75c7ff9fee..c6672cf969 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -649,3 +649,56 @@ def get_color_management_preferences(): "display": hou.Color.ocio_defaultDisplay(), "view": hou.Color.ocio_defaultView() } + + +def get_current_asset_doc(): + """Get asset document of the current asset. """ + + project_name = get_current_project_name() + asset_name = get_current_asset_name() + asset_doc = get_asset_by_name(project_name, asset_name) + + return asset_doc + + +def get_resolution_from_data(doc): + if not doc or "data" not in doc: + print("Entered document is not valid. \"{}\"".format(str(doc))) + return None + + resolution_width = doc["data"].get("resolutionWidth") + resolution_height = doc["data"].get("resolutionHeight") + + # Make sure both width and height are set + if resolution_width is None or resolution_height is None: + print("No resolution information found for \"{}\"".format(doc["name"])) + return None + + return int(resolution_width), int(resolution_height) + + +def set_camera_resolution(camera, asset_doc=None): + """Apply resolution to camera from asset document of the publish""" + + if not asset_doc: + asset_doc = get_current_asset_doc() + + resolution = get_resolution_from_data(asset_doc) + + if resolution: + print("Setting camera resolution: {} -> {}x{}".format( + camera.name(), resolution[0], resolution[1] + )) + camera.parm("resx").set(resolution[0]) + camera.parm("resy").set(resolution[1]) + + +def get_camera_from_container(container): + """Get camera from container node. """ + + cameras = container.recursiveGlob("*", + filter=hou.nodeTypeFilter.ObjCamera, + include_subnets=False) + + assert len(cameras) == 1, "Camera instance must have only one camera" + return cameras[0] diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index c9ae801af5..6aa65deb89 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -14,6 +14,7 @@ import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, register_loader_plugin_path, + register_inventory_action_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers @@ -55,6 +56,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) + register_inventory_action_path(INVENTORY_PATH) log.info("Installing callbacks ... ") # register_event_callback("init", on_init) diff --git a/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py new file mode 100644 index 0000000000..cff7f89288 --- /dev/null +++ b/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py @@ -0,0 +1,24 @@ +from openpype.pipeline import InventoryAction +from openpype.hosts.houdini.api.lib import ( + get_camera_from_container, + set_camera_resolution +) + +class SetAssetResolution(InventoryAction): + + label = "Set Asset Resolution" + icon = "desktop" + color = "orange" + + @staticmethod + def is_compatible(container): + print(container) + return ( + container.get("loader") == "CameraLoader" + ) + + def process(self, containers): + for container in containers: + node = container["node"] + camera = get_camera_from_container(node) + set_camera_resolution(camera) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 7b4a04809e..cffa5ca813 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -4,6 +4,13 @@ from openpype.pipeline import ( ) from openpype.hosts.houdini.api import pipeline +from openpype.hosts.houdini.api.lib import ( + set_camera_resolution, + get_camera_from_container +) + +import hou + ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' '.alembicGetCameraDict') @@ -25,7 +32,15 @@ def transfer_non_default_values(src, dest, ignore=None): channel expression and ignore certain Parm types. """ - import hou + + ignore_types = { + hou.parmTemplateType.Toggle, + hou.parmTemplateType.Menu, + hou.parmTemplateType.Button, + hou.parmTemplateType.FolderSet, + hou.parmTemplateType.Separator, + hou.parmTemplateType.Label, + } src.updateParmStates() @@ -62,14 +77,6 @@ def transfer_non_default_values(src, dest, ignore=None): continue # Ignore folders, separators, etc. - ignore_types = { - hou.parmTemplateType.Toggle, - hou.parmTemplateType.Menu, - hou.parmTemplateType.Button, - hou.parmTemplateType.FolderSet, - hou.parmTemplateType.Separator, - hou.parmTemplateType.Label, - } if parm.parmTemplate().type() in ignore_types: continue @@ -90,13 +97,8 @@ class CameraLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - import os - import hou - # Format file name, Houdini only wants forward slashes - file_path = self.filepath_from_context(context) - file_path = os.path.normpath(file_path) - file_path = file_path.replace("\\", "/") + file_path = self.fname.replace("\\", "/") # Get the root node obj = hou.node("/obj") @@ -106,19 +108,21 @@ class CameraLoader(load.LoaderPlugin): node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node - container = self.create_and_connect(obj, "alembicarchive", node_name) + node = self.create_and_connect(obj, "alembicarchive", node_name) # TODO: add FPS of project / asset - container.setParms({"fileName": file_path, - "channelRef": True}) + node.setParms({"fileName": file_path, "channelRef": True}) # Apply some magic - container.parm("buildHierarchy").pressButton() - container.moveToGoodPosition() + node.parm("buildHierarchy").pressButton() + node.moveToGoodPosition() # Create an alembic xform node - nodes = [container] + nodes = [node] + camera = get_camera_from_container(node) + self._match_maya_render_mask(camera) + set_camera_resolution(camera, asset_doc=context["asset"]) self[:] = nodes return pipeline.containerise(node_name, @@ -143,14 +147,14 @@ class CameraLoader(load.LoaderPlugin): # Store the cam temporarily next to the Alembic Archive # so that we can preserve parm values the user set on it # after build hierarchy was triggered. - old_camera = self._get_camera(node) + old_camera = get_camera_from_container(node) temp_camera = old_camera.copyTo(node.parent()) # Rebuild node.parm("buildHierarchy").pressButton() # Apply values to the new camera - new_camera = self._get_camera(node) + new_camera = get_camera_from_container(node) transfer_non_default_values(temp_camera, new_camera, # The hidden uniform scale attribute @@ -158,6 +162,9 @@ class CameraLoader(load.LoaderPlugin): # "icon_scale" just skip that completely ignore={"scale"}) + self._match_maya_render_mask(new_camera) + set_camera_resolution(new_camera) + temp_camera.destroy() def remove(self, container): @@ -165,15 +172,6 @@ class CameraLoader(load.LoaderPlugin): node = container["node"] node.destroy() - def _get_camera(self, node): - import hou - cameras = node.recursiveGlob("*", - filter=hou.nodeTypeFilter.ObjCamera, - include_subnets=False) - - assert len(cameras) == 1, "Camera instance must have only one camera" - return cameras[0] - def create_and_connect(self, node, node_type, name=None): """Create a node within a node which and connect it to the input @@ -194,5 +192,20 @@ class CameraLoader(load.LoaderPlugin): new_node.moveToGoodPosition() return new_node - def switch(self, container, representation): - self.update(container, representation) + def _match_maya_render_mask(self, camera): + """Workaround to match Maya render mask in Houdini""" + + # print("Setting match maya render mask ") + parm = camera.parm("aperture") + expression = parm.expression() + expression = expression.replace("return ", "aperture = ") + expression += """ +# Match maya render mask (logic from Houdini's own FBX importer) +node = hou.pwd() +resx = node.evalParm('resx') +resy = node.evalParm('resy') +aspect = node.evalParm('aspect') +aperture *= min(1, (resx / resy * aspect) / 1.5) +return aperture +""" + parm.setExpression(expression, language=hou.exprLanguage.Python) From 32a192b1929598c82fc8d199b19ff0edb5763564 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 6 Sep 2023 21:44:46 +0300 Subject: [PATCH 65/90] change action name --- .../{set_asset_resolution.py => set_camera_resolution.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename openpype/hosts/houdini/plugins/inventory/{set_asset_resolution.py => set_camera_resolution.py} (87%) diff --git a/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py similarity index 87% rename from openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py rename to openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index cff7f89288..441aef8b1c 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_asset_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -4,9 +4,9 @@ from openpype.hosts.houdini.api.lib import ( set_camera_resolution ) -class SetAssetResolution(InventoryAction): +class SetCameraResolution(InventoryAction): - label = "Set Asset Resolution" + label = "Set Camera Resolution" icon = "desktop" color = "orange" From f8d03955f71b6d7fe9d87b1835cb1d62a2adf788 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 6 Sep 2023 21:59:11 +0300 Subject: [PATCH 66/90] resolve hound --- openpype/hosts/houdini/api/lib.py | 14 +++++++++----- .../plugins/inventory/set_camera_resolution.py | 1 + openpype/hosts/houdini/plugins/load/load_camera.py | 14 +++++++------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index c6672cf969..f83519ddb8 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -661,7 +661,9 @@ def get_current_asset_doc(): return asset_doc -def get_resolution_from_data(doc): +def get_resolution_from_doc(doc): + """Get resolution from the given asset document. """ + if not doc or "data" not in doc: print("Entered document is not valid. \"{}\"".format(str(doc))) return None @@ -683,7 +685,7 @@ def set_camera_resolution(camera, asset_doc=None): if not asset_doc: asset_doc = get_current_asset_doc() - resolution = get_resolution_from_data(asset_doc) + resolution = get_resolution_from_doc(asset_doc) if resolution: print("Setting camera resolution: {} -> {}x{}".format( @@ -696,9 +698,11 @@ def set_camera_resolution(camera, asset_doc=None): def get_camera_from_container(container): """Get camera from container node. """ - cameras = container.recursiveGlob("*", - filter=hou.nodeTypeFilter.ObjCamera, - include_subnets=False) + cameras = container.recursiveGlob( + "*", + filter=hou.nodeTypeFilter.ObjCamera, + include_subnets=False + ) assert len(cameras) == 1, "Camera instance must have only one camera" return cameras[0] diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index 441aef8b1c..5dd94232b8 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -4,6 +4,7 @@ from openpype.hosts.houdini.api.lib import ( set_camera_resolution ) + class SetCameraResolution(InventoryAction): label = "Set Camera Resolution" diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index cffa5ca813..53567b6f97 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -34,13 +34,13 @@ def transfer_non_default_values(src, dest, ignore=None): """ ignore_types = { - hou.parmTemplateType.Toggle, - hou.parmTemplateType.Menu, - hou.parmTemplateType.Button, - hou.parmTemplateType.FolderSet, - hou.parmTemplateType.Separator, - hou.parmTemplateType.Label, - } + hou.parmTemplateType.Toggle, + hou.parmTemplateType.Menu, + hou.parmTemplateType.Button, + hou.parmTemplateType.FolderSet, + hou.parmTemplateType.Separator, + hou.parmTemplateType.Label, + } src.updateParmStates() From 7a3aaa5408b3d4a9ff22220018506e25e55858bf Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 10:26:09 +0300 Subject: [PATCH 67/90] replace fname --- openpype/hosts/houdini/plugins/load/load_camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 53567b6f97..e16146a267 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -98,7 +98,7 @@ class CameraLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): # Format file name, Houdini only wants forward slashes - file_path = self.fname.replace("\\", "/") + file_path = self.filepath_from_context(context).replace("\\", "/") # Get the root node obj = hou.node("/obj") From 9d2fc2fca4622e8c91ba4539034a03f67b57fc40 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 22:30:03 +0300 Subject: [PATCH 68/90] Jakub comments --- openpype/hosts/houdini/api/lib.py | 12 +----------- .../plugins/inventory/set_camera_resolution.py | 6 +++--- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index f83519ddb8..eff98c05f1 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -651,16 +651,6 @@ def get_color_management_preferences(): } -def get_current_asset_doc(): - """Get asset document of the current asset. """ - - project_name = get_current_project_name() - asset_name = get_current_asset_name() - asset_doc = get_asset_by_name(project_name, asset_name) - - return asset_doc - - def get_resolution_from_doc(doc): """Get resolution from the given asset document. """ @@ -683,7 +673,7 @@ def set_camera_resolution(camera, asset_doc=None): """Apply resolution to camera from asset document of the publish""" if not asset_doc: - asset_doc = get_current_asset_doc() + asset_doc = get_current_project_asset() resolution = get_resolution_from_doc(asset_doc) diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index 5dd94232b8..97b94e66aa 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -3,7 +3,7 @@ from openpype.hosts.houdini.api.lib import ( get_camera_from_container, set_camera_resolution ) - +from openpype.pipeline.context_tools import get_current_project_asset class SetCameraResolution(InventoryAction): @@ -13,13 +13,13 @@ class SetCameraResolution(InventoryAction): @staticmethod def is_compatible(container): - print(container) return ( container.get("loader") == "CameraLoader" ) def process(self, containers): + asset_doc = get_current_project_asset() for container in containers: node = container["node"] camera = get_camera_from_container(node) - set_camera_resolution(camera) + set_camera_resolution(camera, asset_doc) From d38d25308dc393ced6504e86e925d8c821c92868 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 22:31:48 +0300 Subject: [PATCH 69/90] resolve hound --- .../hosts/houdini/plugins/inventory/set_camera_resolution.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py index 97b94e66aa..18ececb019 100644 --- a/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py +++ b/openpype/hosts/houdini/plugins/inventory/set_camera_resolution.py @@ -5,6 +5,7 @@ from openpype.hosts.houdini.api.lib import ( ) from openpype.pipeline.context_tools import get_current_project_asset + class SetCameraResolution(InventoryAction): label = "Set Camera Resolution" From 572d6e3ab52d5d398a9a25fd4405cad4fecea796 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 8 Sep 2023 12:44:14 +0300 Subject: [PATCH 70/90] update ayon settings --- .../houdini/server/settings/publish_plugins.py | 12 ++++++------ server_addon/houdini/server/version.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 528e847fce..6ceff028a5 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -167,14 +167,14 @@ class PublishPluginsModel(BaseSettingsModel): ValidateContainers: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Latest Containers.") - ValidateSubsetName: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateSubsetName: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Subset Name.") - ValidateMeshIsStatic: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateMeshIsStatic: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Mesh is Static.") - ValidateUnrealStaticMeshName: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateUnrealStaticMeshName: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Unreal Static Mesh Name.") diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" From 2d255f15bedc2aca61177d80cad987b7108b5a26 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 14:05:24 +0300 Subject: [PATCH 71/90] validate empty nodes and invalid prims --- .../publish/validate_fbx_output_node.py | 109 ++++++++++++++++-- 1 file changed, 100 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index 503a3bb3c1..9f6a1b8767 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -5,7 +5,8 @@ from openpype.hosts.houdini.api.action import ( SelectInvalidAction, SelectROPAction, ) - +from openpype.hosts.houdini.api.lib import get_obj_node_output +from collections import defaultdict import hou @@ -16,27 +17,38 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): - The Output Node Path is set. - The Output Node Path refers to an existing object. - The Output Node is a Sop or Obj node. + - The Output Node has geometry data. """ order = pyblish.api.ValidatorOrder families = ["fbx"] hosts = ["houdini"] - label = "Validate Output Node" + label = "Validate FBX Output Node" actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): - invalid = self.get_invalid(instance) + invalid = self.get_invalid_categorized(instance) if invalid: raise PublishValidationError( "Output node(s) are incorrect", title="Invalid output node(s)" ) - @classmethod def get_invalid(cls, instance): + out = cls.get_invalid_categorized(instance).values() + invalid = [] + for row in out: + invalid += row + return invalid + + + @classmethod + def get_invalid_categorized(cls, instance): output_node = instance.data.get("output_node") + # Check if The Output Node Path is set and + # refers to an existing object. if output_node is None: rop_node = hou.node(instance.data["instance_node"]) cls.log.error( @@ -46,11 +58,90 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): return [rop_node] - if output_node.type().category().name() not in ["Sop", "Object"]: + # Check if the Output Node is a Sop or Obj node + # also, make a dictionary of all geo obj nodes + # and their sop output node. + all_outputs = {} + # if user selects an ObjSubnet or an ObjNetwork + if output_node.childTypeCategory() == hou.objNodeTypeCategory(): + all_outputs.update({output_node : {}}) + for node in output_node.allSubChildren(): + if node.type().name() == "geo": + out = get_obj_node_output(node) + all_outputs[output_node].update({node: out}) + + # elif user selects a geometry ObjNode + elif output_node.type().name() == "geo": + out = get_obj_node_output(output_node) + all_outputs.update({output_node: out}) + + # elif user selects a SopNode + elif output_node.type().category().name() == "Sop": + # expetional case because output_node is not an obj node + all_outputs.update({output_node: output_node}) + + # Then it's wrong node type + else: cls.log.error( - "Output node %s is not a SOP or OBJ node. " - "It must point to a SOP or OBJ node, " - "instead found category type: %s" - % (output_node.path(), output_node.type().category().name()) + "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " + "Instead found category type: %s %s" + , output_node.path(), output_node.type().category().name() + , output_node.type().name() ) return [output_node] + + # Check if geo obj node have geometry. + # return geo obj node if their sop output node + valid = {} + invalid = defaultdict(list) + cls.filter_inner_dict(all_outputs, valid, invalid) + + invalid_prim_types = ["VDB", "Volume"] + for obj_node, sop_node in valid.items(): + # Empty Geometry test + if not hasattr(sop_node, "geometry"): + invalid["empty_geometry"].append(sop_node) + cls.log.error( + "Sop node '%s' includes no geometry." + , sop_node.path() + ) + continue + + frame = instance.data.get("frameStart", 0) + geo = sop_node.geometryAtFrame(frame) + if len(geo.iterPrims()) == 0: + invalid["empty_geometry"].append(sop_node) + cls.log.error( + "Sop node '%s' includes no geometry." + , sop_node.path() + ) + continue + + # Invalid Prims test + for prim_type in invalid_prim_types: + if geo.countPrimType(prim_type) > 0: + invalid["invalid_prims"].append(sop_node) + cls.log.error( + "Sop node '%s' includes invliad prims of type '%s'." + , sop_node.path(), prim_type + ) + + if invalid: + return invalid + + @classmethod + def filter_inner_dict(cls, d: dict, valid: dict, invalid: dict): + """Parse the dictionary and filter items to valid and invalid. + + Invalid items have empty values like {}, None + Valid dictionary is a flattened dictionary that includes + the valid inner items. + """ + + for k, v in d.items(): + if not v: + invalid["empty_objs"].append(k) + elif isinstance(v, dict): + cls.filter_inner_dict(v, valid, invalid) + else: + valid.update({k:v}) From ad0c6245cdd347fed47b075e1ecb13e5dfb8c359 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 15:52:11 +0300 Subject: [PATCH 72/90] remove unnecessary logic --- .../publish/validate_fbx_output_node.py | 89 +++++++++---------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index 9f6a1b8767..d06ef593d3 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -28,23 +28,15 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): def process(self, instance): - invalid = self.get_invalid_categorized(instance) + invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( "Output node(s) are incorrect", title="Invalid output node(s)" ) + @classmethod def get_invalid(cls, instance): - out = cls.get_invalid_categorized(instance).values() - invalid = [] - for row in out: - invalid += row - return invalid - - - @classmethod - def get_invalid_categorized(cls, instance): output_node = instance.data.get("output_node") # Check if The Output Node Path is set and @@ -58,27 +50,47 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): return [rop_node] - # Check if the Output Node is a Sop or Obj node - # also, make a dictionary of all geo obj nodes - # and their sop output node. - all_outputs = {} - # if user selects an ObjSubnet or an ObjNetwork + # Check if the Output Node is a Sop or an Obj node + # also, list all sop output nodes inside as well as + # invalid empty nodes. + all_out_sops = [] + invalid = defaultdict(list) + + # if output_node is an ObjSubnet or an ObjNetwork if output_node.childTypeCategory() == hou.objNodeTypeCategory(): - all_outputs.update({output_node : {}}) for node in output_node.allSubChildren(): if node.type().name() == "geo": out = get_obj_node_output(node) - all_outputs[output_node].update({node: out}) + if out: + all_out_sops.append(out) + else: + invalid["empty_objs"].append(node) + cls.log.error( + "Geo Obj Node '%s' is empty!" + , node.path() + ) + if not all_out_sops: + invalid["empty_objs"].append(output_node) + cls.log.error( + "Output Node '%s' is empty!" + , node.path() + ) - # elif user selects a geometry ObjNode + # elif output_node is an ObjNode elif output_node.type().name() == "geo": out = get_obj_node_output(output_node) - all_outputs.update({output_node: out}) + if out: + all_out_sops.append(out) + else: + invalid["empty_objs"].append(node) + cls.log.error( + "Output Node '%s' is empty!" + , node.path() + ) - # elif user selects a SopNode + # elif output_node is a SopNode elif output_node.type().category().name() == "Sop": - # expetional case because output_node is not an obj node - all_outputs.update({output_node: output_node}) + all_out_sops.append(output_node) # Then it's wrong node type else: @@ -90,19 +102,15 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): ) return [output_node] - # Check if geo obj node have geometry. - # return geo obj node if their sop output node - valid = {} - invalid = defaultdict(list) - cls.filter_inner_dict(all_outputs, valid, invalid) - + # Check if all output sop nodes have geometry + # and don't contain invalid prims invalid_prim_types = ["VDB", "Volume"] - for obj_node, sop_node in valid.items(): + for sop_node in all_out_sops: # Empty Geometry test if not hasattr(sop_node, "geometry"): invalid["empty_geometry"].append(sop_node) cls.log.error( - "Sop node '%s' includes no geometry." + "Sop node '%s' doesn't include any prims." , sop_node.path() ) continue @@ -112,7 +120,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if len(geo.iterPrims()) == 0: invalid["empty_geometry"].append(sop_node) cls.log.error( - "Sop node '%s' includes no geometry." + "Sop node '%s' doesn't include any prims." , sop_node.path() ) continue @@ -127,21 +135,4 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): ) if invalid: - return invalid - - @classmethod - def filter_inner_dict(cls, d: dict, valid: dict, invalid: dict): - """Parse the dictionary and filter items to valid and invalid. - - Invalid items have empty values like {}, None - Valid dictionary is a flattened dictionary that includes - the valid inner items. - """ - - for k, v in d.items(): - if not v: - invalid["empty_objs"].append(k) - elif isinstance(v, dict): - cls.filter_inner_dict(v, valid, invalid) - else: - valid.update({k:v}) + return [output_node] From 20f4b62213530118829dbbe027d25c982f80675b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:33:28 +0300 Subject: [PATCH 73/90] Remove unnecessary line --- .../hosts/houdini/plugins/publish/validate_mesh_is_static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 4d0904eb53..6bf94f7536 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -30,7 +30,7 @@ class ValidateMeshIsStatic(pyblish.api.InstancePlugin, invalid = self.get_invalid(instance) if invalid: - nodes = [n.path() for n in invalid if isinstance(n, hou.Node)] + nodes = [n.path() for n in invalid] raise PublishValidationError( "See log for details. " "Invalid nodes: {0}".format(nodes) From 21c174c47bd761392ff6856346bdedf5ece732d9 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:34:05 +0300 Subject: [PATCH 74/90] update error message --- .../publish/validate_fbx_output_node.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index d06ef593d3..d493092755 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -6,7 +6,6 @@ from openpype.hosts.houdini.api.action import ( SelectROPAction, ) from openpype.hosts.houdini.api.lib import get_obj_node_output -from collections import defaultdict import hou @@ -30,8 +29,10 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: + nodes = [n.path() for n in invalid] raise PublishValidationError( - "Output node(s) are incorrect", + "See log for details. " + "Invalid nodes: {0}".format(nodes), title="Invalid output node(s)" ) @@ -54,7 +55,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): # also, list all sop output nodes inside as well as # invalid empty nodes. all_out_sops = [] - invalid = defaultdict(list) + invalid = [] # if output_node is an ObjSubnet or an ObjNetwork if output_node.childTypeCategory() == hou.objNodeTypeCategory(): @@ -64,13 +65,13 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if out: all_out_sops.append(out) else: - invalid["empty_objs"].append(node) + invalid.append(node) # empty_objs cls.log.error( "Geo Obj Node '%s' is empty!" , node.path() ) if not all_out_sops: - invalid["empty_objs"].append(output_node) + invalid.append(output_node) # empty_objs cls.log.error( "Output Node '%s' is empty!" , node.path() @@ -82,7 +83,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if out: all_out_sops.append(out) else: - invalid["empty_objs"].append(node) + invalid.append(node) # empty_objs cls.log.error( "Output Node '%s' is empty!" , node.path() @@ -92,7 +93,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): elif output_node.type().category().name() == "Sop": all_out_sops.append(output_node) - # Then it's wrong node type + # Then it's a wrong node type else: cls.log.error( "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " @@ -108,7 +109,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): for sop_node in all_out_sops: # Empty Geometry test if not hasattr(sop_node, "geometry"): - invalid["empty_geometry"].append(sop_node) + invalid.append(sop_node) # empty_geometry cls.log.error( "Sop node '%s' doesn't include any prims." , sop_node.path() @@ -118,7 +119,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): frame = instance.data.get("frameStart", 0) geo = sop_node.geometryAtFrame(frame) if len(geo.iterPrims()) == 0: - invalid["empty_geometry"].append(sop_node) + invalid.append(sop_node) # empty_geometry cls.log.error( "Sop node '%s' doesn't include any prims." , sop_node.path() @@ -128,11 +129,11 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): # Invalid Prims test for prim_type in invalid_prim_types: if geo.countPrimType(prim_type) > 0: - invalid["invalid_prims"].append(sop_node) + invalid.append(sop_node) # invalid_prims cls.log.error( - "Sop node '%s' includes invliad prims of type '%s'." + "Sop node '%s' includes invalid prims of type '%s'." , sop_node.path(), prim_type ) if invalid: - return [output_node] + return invalid From 9105e74b4326686510536cc9e2f8fd37f44be563 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:43:26 +0300 Subject: [PATCH 75/90] resolve hound --- .../publish/validate_fbx_output_node.py | 32 +++++++++---------- .../publish/validate_mesh_is_static.py | 2 -- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index d493092755..ea13d25122 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -67,14 +67,14 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): else: invalid.append(node) # empty_objs cls.log.error( - "Geo Obj Node '%s' is empty!" - , node.path() + "Geo Obj Node '%s' is empty!", + node.path() ) if not all_out_sops: invalid.append(output_node) # empty_objs cls.log.error( - "Output Node '%s' is empty!" - , node.path() + "Output Node '%s' is empty!", + node.path() ) # elif output_node is an ObjNode @@ -85,21 +85,21 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): else: invalid.append(node) # empty_objs cls.log.error( - "Output Node '%s' is empty!" - , node.path() + "Output Node '%s' is empty!", + node.path() ) # elif output_node is a SopNode elif output_node.type().category().name() == "Sop": - all_out_sops.append(output_node) + all_out_sops.append(output_node) # Then it's a wrong node type else: cls.log.error( "Output node %s is not a SOP or OBJ Geo or OBJ SubNet node. " - "Instead found category type: %s %s" - , output_node.path(), output_node.type().category().name() - , output_node.type().name() + "Instead found category type: %s %s", + output_node.path(), output_node.type().category().name(), + output_node.type().name() ) return [output_node] @@ -111,8 +111,8 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if not hasattr(sop_node, "geometry"): invalid.append(sop_node) # empty_geometry cls.log.error( - "Sop node '%s' doesn't include any prims." - , sop_node.path() + "Sop node '%s' doesn't include any prims.", + sop_node.path() ) continue @@ -121,8 +121,8 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if len(geo.iterPrims()) == 0: invalid.append(sop_node) # empty_geometry cls.log.error( - "Sop node '%s' doesn't include any prims." - , sop_node.path() + "Sop node '%s' doesn't include any prims.", + sop_node.path() ) continue @@ -131,8 +131,8 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): if geo.countPrimType(prim_type) > 0: invalid.append(sop_node) # invalid_prims cls.log.error( - "Sop node '%s' includes invalid prims of type '%s'." - , sop_node.path(), prim_type + "Sop node '%s' includes invalid prims of type '%s'.", + sop_node.path(), prim_type ) if invalid: diff --git a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py index 6bf94f7536..b499682e0b 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py +++ b/openpype/hosts/houdini/plugins/publish/validate_mesh_is_static.py @@ -10,8 +10,6 @@ from openpype.pipeline.publish import ValidateContentsOrder from openpype.hosts.houdini.api.action import SelectInvalidAction from openpype.hosts.houdini.api.lib import get_output_children -import hou - class ValidateMeshIsStatic(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): From 583cfba38c2f4ffbf0c72795ac4adb48d98715e5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 11 Sep 2023 16:48:38 +0300 Subject: [PATCH 76/90] update doc string --- .../hosts/houdini/plugins/publish/validate_fbx_output_node.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py index ea13d25122..894dad7d72 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_fbx_output_node.py @@ -17,6 +17,7 @@ class ValidateFBXOutputNode(pyblish.api.InstancePlugin): - The Output Node Path refers to an existing object. - The Output Node is a Sop or Obj node. - The Output Node has geometry data. + - The Output Node doesn't include invalid primitive types. """ order = pyblish.api.ValidatorOrder From 2372e552d39137d0fecb3a93bf03dda1581c0361 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 13 Sep 2023 10:46:48 +0300 Subject: [PATCH 77/90] use more decriptive variable name --- openpype/hosts/houdini/plugins/publish/extract_fbx.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index dd61e68f3b..7993b3352f 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -21,11 +21,11 @@ class ExtractFBX(publish.Extractor): # get rop node ropnode = hou.node(instance.data.get("instance_node")) - output_node = ropnode.evalParm("sopoutput") + output_file = ropnode.evalParm("sopoutput") # get staging_dir and file_name - staging_dir = os.path.normpath(os.path.dirname(output_node)) - file_name = os.path.basename(output_node) + staging_dir = os.path.normpath(os.path.dirname(output_file)) + file_name = os.path.basename(output_file) # render rop self.log.debug("Writing FBX '%s' to '%s'", file_name, staging_dir) From 54a62348d02585ea9b186df2cabafb0f2748f6d0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 13 Sep 2023 22:16:14 +0200 Subject: [PATCH 78/90] Remove unused functions from Fusion integration --- openpype/hosts/fusion/api/__init__.py | 6 +- openpype/hosts/fusion/api/lib.py | 74 ------------------- openpype/hosts/fusion/api/pipeline.py | 43 ----------- .../tests/test_lib_restructuralization.py | 2 - 4 files changed, 3 insertions(+), 122 deletions(-) diff --git a/openpype/hosts/fusion/api/__init__.py b/openpype/hosts/fusion/api/__init__.py index dba55a98d9..aabc624016 100644 --- a/openpype/hosts/fusion/api/__init__.py +++ b/openpype/hosts/fusion/api/__init__.py @@ -3,9 +3,7 @@ from .pipeline import ( ls, imprint_container, - parse_container, - list_instances, - remove_instance + parse_container ) from .lib import ( @@ -22,6 +20,7 @@ from .menu import launch_openpype_menu __all__ = [ # pipeline + "FusionHost", "ls", "imprint_container", @@ -32,6 +31,7 @@ __all__ = [ "update_frame_range", "set_asset_framerange", "get_current_comp", + "get_bmd_library", "comp_lock_and_undo_chunk", # menu diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index d96557571b..c4a1488606 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -181,80 +181,6 @@ def validate_comp_prefs(comp=None, force_repair=False): dialog.setStyleSheet(load_stylesheet()) -def switch_item(container, - asset_name=None, - subset_name=None, - representation_name=None): - """Switch container asset, subset or representation of a container by name. - - It'll always switch to the latest version - of course a different - approach could be implemented. - - Args: - container (dict): data of the item to switch with - asset_name (str): name of the asset - subset_name (str): name of the subset - representation_name (str): name of the representation - - Returns: - dict - - """ - - if all(not x for x in [asset_name, subset_name, representation_name]): - raise ValueError("Must have at least one change provided to switch.") - - # Collect any of current asset, subset and representation if not provided - # so we can use the original name from those. - project_name = get_current_project_name() - if any(not x for x in [asset_name, subset_name, representation_name]): - repre_id = container["representation"] - representation = get_representation_by_id(project_name, repre_id) - repre_parent_docs = get_representation_parents( - project_name, representation) - if repre_parent_docs: - version, subset, asset, _ = repre_parent_docs - else: - version = subset = asset = None - - if asset_name is None: - asset_name = asset["name"] - - if subset_name is None: - subset_name = subset["name"] - - if representation_name is None: - representation_name = representation["name"] - - # Find the new one - asset = get_asset_by_name(project_name, asset_name, fields=["_id"]) - assert asset, ("Could not find asset in the database with the name " - "'%s'" % asset_name) - - subset = get_subset_by_name( - project_name, subset_name, asset["_id"], fields=["_id"] - ) - assert subset, ("Could not find subset in the database with the name " - "'%s'" % subset_name) - - version = get_last_version_by_subset_id( - project_name, subset["_id"], fields=["_id"] - ) - assert version, "Could not find a version for {}.{}".format( - asset_name, subset_name - ) - - representation = get_representation_by_name( - project_name, representation_name, version["_id"] - ) - assert representation, ("Could not find representation in the database " - "with the name '%s'" % representation_name) - - switch_container(container, representation) - - return representation - - @contextlib.contextmanager def maintained_selection(comp=None): """Reset comp selection from before the context after the context""" diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index a768a3f0f8..a886086758 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -287,49 +287,6 @@ def parse_container(tool): return container -# TODO: Function below is currently unused prototypes -def list_instances(creator_id=None): - """Return created instances in current workfile which will be published. - Returns: - (list) of dictionaries matching instances format - """ - - comp = get_current_comp() - tools = comp.GetToolList(False).values() - - instance_signature = { - "id": "pyblish.avalon.instance", - "identifier": creator_id - } - instances = [] - for tool in tools: - - data = tool.GetData('openpype') - if not isinstance(data, dict): - continue - - if data.get("id") != instance_signature["id"]: - continue - - if creator_id and data.get("identifier") != creator_id: - continue - - instances.append(tool) - - return instances - - -# TODO: Function below is currently unused prototypes -def remove_instance(instance): - """Remove instance from current workfile. - - Args: - instance (dict): instance representation from subsetmanager model - """ - # Assume instance is a Fusion tool directly - instance["tool"].Delete() - - class FusionEventThread(QtCore.QThread): """QThread which will periodically ping Fusion app for any events. The fusion.UIManager must be set up to be notified of events before they'll diff --git a/openpype/tests/test_lib_restructuralization.py b/openpype/tests/test_lib_restructuralization.py index 669706d470..a91d65f7a8 100644 --- a/openpype/tests/test_lib_restructuralization.py +++ b/openpype/tests/test_lib_restructuralization.py @@ -18,8 +18,6 @@ def test_backward_compatibility(printer): from openpype.lib import get_ffprobe_streams - from openpype.hosts.fusion.lib import switch_item - from openpype.lib import source_hash from openpype.lib import run_subprocess From 254a1859632b9b5ce59fb58c8cdb47a754ae746d Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 22:35:04 +0300 Subject: [PATCH 79/90] set PATH environment in deadline jobs --- .../repository/custom/plugins/GlobalJobPreLoad.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 97875215ae..79cd3968fb 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -385,6 +385,11 @@ def inject_openpype_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + if "PATH" in contents: + PATH = contents["PATH"] + print(f">>> Set 'PATH' Environment to: {PATH}") + os.environ["PATH"] = PATH + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") @@ -509,6 +514,11 @@ def inject_ayon_environment(deadlinePlugin): for key, value in contents.items(): deadlinePlugin.SetProcessEnvironmentVariable(key, value) + if "PATH" in contents: + PATH = contents["PATH"] + print(f">>> Set 'PATH' Environment to: {PATH}") + os.environ["PATH"] = PATH + script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: script_url = script_url.format(**contents).replace("\\", "/") From f7cc7f1908948fc0aa1526987b8cc12a20dd4736 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 23:17:52 +0300 Subject: [PATCH 80/90] BigRoy's comment --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 79cd3968fb..24bfd983f3 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -387,7 +387,7 @@ def inject_openpype_environment(deadlinePlugin): if "PATH" in contents: PATH = contents["PATH"] - print(f">>> Set 'PATH' Environment to: {PATH}") + print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") @@ -516,7 +516,7 @@ def inject_ayon_environment(deadlinePlugin): if "PATH" in contents: PATH = contents["PATH"] - print(f">>> Set 'PATH' Environment to: {PATH}") + print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") From 1ec11da6f7c25550edd030461845b88681c1b4a6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 14 Sep 2023 23:23:31 +0300 Subject: [PATCH 81/90] add a developer note --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 24bfd983f3..044a953083 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -386,6 +386,8 @@ def inject_openpype_environment(deadlinePlugin): deadlinePlugin.SetProcessEnvironmentVariable(key, value) if "PATH" in contents: + # Set os.environ[PATH] so studio settings' path entries + # can be used to define search path for executables. PATH = contents["PATH"] print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH @@ -515,6 +517,8 @@ def inject_ayon_environment(deadlinePlugin): deadlinePlugin.SetProcessEnvironmentVariable(key, value) if "PATH" in contents: + # Set os.environ[PATH] so studio settings' path entries + # can be used to define search path for executables. PATH = contents["PATH"] print(f">>> Setting 'PATH' Environment to: {PATH}") os.environ["PATH"] = PATH From 0c09a6772ce3c0f3c9ce47547e937e9dc254d6fe Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 15 Sep 2023 09:50:13 +0300 Subject: [PATCH 82/90] BigRoy's comment --- .../repository/custom/plugins/GlobalJobPreLoad.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 044a953083..e9b81369ca 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -388,9 +388,8 @@ def inject_openpype_environment(deadlinePlugin): if "PATH" in contents: # Set os.environ[PATH] so studio settings' path entries # can be used to define search path for executables. - PATH = contents["PATH"] - print(f">>> Setting 'PATH' Environment to: {PATH}") - os.environ["PATH"] = PATH + print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") + os.environ["PATH"] = contents["PATH"] script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: @@ -519,9 +518,8 @@ def inject_ayon_environment(deadlinePlugin): if "PATH" in contents: # Set os.environ[PATH] so studio settings' path entries # can be used to define search path for executables. - PATH = contents["PATH"] - print(f">>> Setting 'PATH' Environment to: {PATH}") - os.environ["PATH"] = PATH + print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") + os.environ["PATH"] = contents["PATH"] script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") if script_url: From 411f4bacd16d3d3c5c4ffb29d69f88f383b094dc Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 18 Sep 2023 15:03:45 +0100 Subject: [PATCH 83/90] Support new publisher for colorsets validation. --- .../maya/plugins/publish/validate_color_sets.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_color_sets.py b/openpype/hosts/maya/plugins/publish/validate_color_sets.py index 766124cd9e..173fee4179 100644 --- a/openpype/hosts/maya/plugins/publish/validate_color_sets.py +++ b/openpype/hosts/maya/plugins/publish/validate_color_sets.py @@ -3,9 +3,10 @@ from maya import cmds import pyblish.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - RepairAction, ValidateMeshOrder, - OptionalPyblishPluginMixin + OptionalPyblishPluginMixin, + PublishValidationError, + RepairAction ) @@ -22,8 +23,9 @@ class ValidateColorSets(pyblish.api.Validator, hosts = ['maya'] families = ['model'] label = 'Mesh ColorSets' - actions = [openpype.hosts.maya.api.action.SelectInvalidAction, - RepairAction] + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] optional = True @staticmethod @@ -48,8 +50,9 @@ class ValidateColorSets(pyblish.api.Validator, invalid = self.get_invalid(instance) if invalid: - raise ValueError("Meshes found with " - "Color Sets: {0}".format(invalid)) + raise PublishValidationError( + message="Meshes found with Color Sets: {0}".format(invalid) + ) @classmethod def repair(cls, instance): From 6c70df2a2e482cc1ad21d9f62b05a30e4282865a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 18 Sep 2023 21:02:58 +0300 Subject: [PATCH 84/90] remove unnecessary logic --- .../hosts/houdini/plugins/load/load_fbx.py | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_fbx.py b/openpype/hosts/houdini/plugins/load/load_fbx.py index 7e7f0c04e5..cac22d62d4 100644 --- a/openpype/hosts/houdini/plugins/load/load_fbx.py +++ b/openpype/hosts/houdini/plugins/load/load_fbx.py @@ -21,8 +21,9 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): - # get file path - file_path = self.get_file_path(context=context) + # get file path from context + file_path = self.filepath_from_context(context) + file_path = file_path.replace("\\", "/") # get necessary data namespace, node_name = self.get_node_name(context, name, namespace) @@ -56,8 +57,9 @@ class FbxLoader(load.LoaderPlugin): self.log.error("Could not find node of type `file`") return - # Update the file path - file_path = self.get_file_path(representation=representation) + # Update the file path from representation + file_path = get_representation_path(representation) + file_path = file_path.replace("\\", "/") file_node.setParms({"file": file_path}) @@ -72,19 +74,6 @@ class FbxLoader(load.LoaderPlugin): def switch(self, container, representation): self.update(container, representation) - def get_file_path(self, context=None, representation=None): - """Return formatted file path.""" - - # Format file name, Houdini only wants forward slashes - if context: - file_path = self.filepath_from_context(context) - elif representation: - file_path = get_representation_path(representation) - else: - return "" - - return file_path.replace("\\", "/") - def get_node_name(self, context, name=None, namespace=None): """Define node name.""" From 41845651b2aaf701b3a7d27c0091a23948c3c2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Wed, 20 Sep 2023 14:38:35 +0200 Subject: [PATCH 85/90] Update setup.cfg Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 216bae848f..ead9b25164 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ omit = /tests directory = ./coverage [tool:pytest] -norecursedirs = repos/* openpype/modules/ftrack/* +norecursedirs = openpype/modules/ftrack/* [isort] line_length = 79 From d91f54f2f74edac0a729513639f40ed2558e5ff5 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 20 Sep 2023 19:18:33 +0300 Subject: [PATCH 86/90] rename files and make validator optional --- ...e_unreal_staticmesh.py => create_staticmesh.py} | 14 +++++++------- .../plugins/publish/collect_staticmesh_type.py | 2 +- .../publish/validate_unreal_staticmesh_naming.py | 2 +- .../settings/defaults/project_settings/global.json | 3 +-- .../defaults/project_settings/houdini.json | 4 ++-- .../schemas/schema_houdini_create.json | 4 ++-- server_addon/core/server/settings/tools.py | 3 +-- .../houdini/server/settings/publish_plugins.py | 12 ++++++------ 8 files changed, 21 insertions(+), 23 deletions(-) rename openpype/hosts/houdini/plugins/create/{create_unreal_staticmesh.py => create_staticmesh.py} (91%) diff --git a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py similarity index 91% rename from openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py rename to openpype/hosts/houdini/plugins/create/create_staticmesh.py index 2f92def54a..ea0b36f03f 100644 --- a/openpype/hosts/houdini/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py @@ -6,11 +6,11 @@ from openpype.lib import BoolDef, EnumDef import hou -class CreateUnrealStaticMesh(plugin.HoudiniCreator): - """Unreal Static Meshes with collisions. """ +class CreateStaticMesh(plugin.HoudiniCreator): + """Static Meshes as FBX. """ - identifier = "io.openpype.creators.houdini.unrealstaticmesh.fbx" - label = "Unreal - Static Mesh (FBX)" + identifier = "io.openpype.creators.houdini.staticmesh.fbx" + label = "Static Mesh (FBX)" family = "staticMesh" icon = "fa5s.cubes" @@ -20,7 +20,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): instance_data.update({"node_type": "filmboxfbx"}) - instance = super(CreateUnrealStaticMesh, self).create( + instance = super(CreateStaticMesh, self).create( subset_name, instance_data, pre_create_data) @@ -60,7 +60,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): """Add settings for users. """ - attrs = super(CreateUnrealStaticMesh, self).get_pre_create_attr_defs() + attrs = super(CreateStaticMesh, self).get_pre_create_attr_defs() createsubnetroot = BoolDef("createsubnetroot", tooltip="Create an extra root for the " "Export node when it's a " @@ -93,7 +93,7 @@ class CreateUnrealStaticMesh(plugin.HoudiniCreator): The default subset name templates for Unreal include {asset} and thus we should pass that along as dynamic data. """ - dynamic_data = super(CreateUnrealStaticMesh, self).get_dynamic_data( + dynamic_data = super(CreateStaticMesh, self).get_dynamic_data( variant, task_name, asset_doc, project_name, host_name, instance ) dynamic_data["asset"] = asset_doc["name"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py index 263d7c1001..db9efec7a1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py +++ b/openpype/hosts/houdini/plugins/publish/collect_staticmesh_type.py @@ -15,6 +15,6 @@ class CollectStaticMeshType(pyblish.api.InstancePlugin): def process(self, instance): - if instance.data["creator_identifier"] == "io.openpype.creators.houdini.unrealstaticmesh.fbx": # noqa: E501 + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.staticmesh.fbx": # noqa: E501 # Marking this instance as FBX triggers the FBX extractor. instance.data["families"] += ["fbx"] diff --git a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py index 791db8198f..ae3c7e5602 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/houdini/plugins/publish/validate_unreal_staticmesh_naming.py @@ -42,7 +42,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, def apply_settings(cls, project_settings, system_settings): settings = ( - project_settings["houdini"]["create"]["CreateUnrealStaticMesh"] + project_settings["houdini"]["create"]["CreateStaticMesh"] ) cls.collision_prefixes = settings["collision_prefixes"] cls.static_mesh_prefix = settings["static_mesh_prefix"] diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 52ac745f6d..06a595d1c5 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -429,8 +429,7 @@ "staticMesh" ], "hosts": [ - "maya", - "houdini" + "maya" ], "task_types": [], "tasks": [], diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 6964db0013..5392fc34dd 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -19,7 +19,7 @@ ], "ext": ".ass" }, - "CreateUnrealStaticMesh": { + "CreateStaticMesh": { "enabled": true, "default_variants": [ "Main" @@ -127,7 +127,7 @@ "active": true }, "ValidateUnrealStaticMeshName": { - "enabled": true, + "enabled": false, "optional": true, "active": true } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json index b19761df91..cd8c260124 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_create.json @@ -42,8 +42,8 @@ { "type": "dict", "collapsible": true, - "key": "CreateUnrealStaticMesh", - "label": "Create Unreal - Static Mesh", + "key": "CreateStaticMesh", + "label": "Create Static Mesh", "checkbox_key": "enabled", "children": [ { diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index 5dbe6ab215..7befc795e4 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -370,8 +370,7 @@ DEFAULT_TOOLS_VALUES = { "staticMesh" ], "hosts": [ - "maya", - "houdini" + "maya" ], "task_types": [], "tasks": [], diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 6ceff028a5..58240b0205 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class CreateArnoldAssModel(BaseSettingsModel): ext: str = Field(Title="Extension") -class CreateUnrealStaticMeshModel(BaseSettingsModel): +class CreateStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") default_variants: list[str] = Field( default_factory=list, @@ -39,9 +39,9 @@ class CreatePluginsModel(BaseSettingsModel): default_factory=CreateArnoldAssModel, title="Create Alembic Camera") # "-" is not compatible in the new model - CreateUnrealStaticMesh: CreateUnrealStaticMeshModel = Field( - default_factory=CreateUnrealStaticMeshModel, - title="Create Unreal_Static Mesh" + CreateStaticMesh: CreateStaticMeshModel = Field( + default_factory=CreateStaticMeshModel, + title="Create Static Mesh" ) CreateAlembicCamera: CreatorModel = Field( default_factory=CreatorModel, @@ -81,7 +81,7 @@ DEFAULT_HOUDINI_CREATE_SETTINGS = { "default_variants": ["Main"], "ext": ".ass" }, - "CreateUnrealStaticMesh": { + "CreateStaticMesh": { "enabled": True, "default_variants": [ "Main" @@ -212,7 +212,7 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "active": True }, "ValidateUnrealStaticMeshName": { - "enabled": True, + "enabled": False, "optional": True, "active": True } From 23e9f5e504c482bc13268d16190378142b5ca936 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Sep 2023 10:26:33 +0200 Subject: [PATCH 87/90] TVPaint: Fix review family extraction (#5637) * mark review family representation for review * implemented 'get_publish_instance_families' in publish lib * use 'get_publish_instance_families' in tvpaint extract sequence --- .../plugins/publish/extract_sequence.py | 8 +++++-- openpype/pipeline/publish/__init__.py | 2 ++ openpype/pipeline/publish/lib.py | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index a13a91de46..fd568b2826 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -6,7 +6,10 @@ from PIL import Image import pyblish.api -from openpype.pipeline.publish import KnownPublishError +from openpype.pipeline.publish import ( + KnownPublishError, + get_publish_instance_families, +) from openpype.hosts.tvpaint.api.lib import ( execute_george, execute_george_through_file, @@ -140,8 +143,9 @@ class ExtractSequence(pyblish.api.Extractor): ) # Fill tags and new families from project settings + instance_families = get_publish_instance_families(instance) tags = [] - if "review" in instance.data["families"]: + if "review" in instance_families: tags.append("review") # Sequence of one frame diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index 0c57915c05..3a82d6f565 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -40,6 +40,7 @@ from .lib import ( apply_plugin_settings_automatically, get_plugin_settings, get_publish_instance_label, + get_publish_instance_families, ) from .abstract_expected_files import ExpectedFiles @@ -87,6 +88,7 @@ __all__ = ( "apply_plugin_settings_automatically", "get_plugin_settings", "get_publish_instance_label", + "get_publish_instance_families", "ExpectedFiles", diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 1ae6ea43b2..4d9443f635 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -1002,3 +1002,27 @@ def get_publish_instance_label(instance): or instance.data.get("name") or str(instance) ) + + +def get_publish_instance_families(instance): + """Get all families of the instance. + + Look for families under 'family' and 'families' keys in instance data. + Value of 'family' is used as first family and then all other families + in random order. + + Args: + pyblish.api.Instance: Instance to get families from. + + Returns: + list[str]: List of families. + """ + + family = instance.data.get("family") + families = set(instance.data.get("families") or []) + output = [] + if family: + output.append(family) + families.discard(family) + output.extend(families) + return output From 3cf203e46580d88c842bd931177ebdb159690f89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Sep 2023 10:48:31 +0200 Subject: [PATCH 88/90] AYON settings: Extract OIIO transcode settings (#5639) * added name to ExtractOIIOTranscode output definition * convert outputs of 'ExtractOIIOTranscode' to 'dict' --- openpype/settings/ayon_settings.py | 23 ++++++++++++++++++- .../core/server/settings/publish_plugins.py | 7 ++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 9a4f0607e0..3be8ac8ae5 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -1102,7 +1102,7 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): "studio_name", "studio_code", ): - ayon_core.pop(key) + ayon_core.pop(key, None) # Publish conversion ayon_publish = ayon_core["publish"] @@ -1140,6 +1140,27 @@ def _convert_global_project_settings(ayon_settings, output, default_settings): profile["outputs"] = new_outputs + # ExtractOIIOTranscode plugin + extract_oiio_transcode = ayon_publish["ExtractOIIOTranscode"] + extract_oiio_transcode_profiles = extract_oiio_transcode["profiles"] + for profile in extract_oiio_transcode_profiles: + new_outputs = {} + name_counter = {} + for output in profile["outputs"]: + if "name" in output: + name = output.pop("name") + else: + # Backwards compatibility for setting without 'name' in model + name = output["extension"] + if name in new_outputs: + name_counter[name] += 1 + name = "{}_{}".format(name, name_counter[name]) + else: + name_counter[name] = 0 + + new_outputs[name] = output + profile["outputs"] = new_outputs + # Extract Burnin plugin extract_burnin = ayon_publish["ExtractBurnin"] extract_burnin_options = extract_burnin["options"] diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py index c012312579..69a759465e 100644 --- a/server_addon/core/server/settings/publish_plugins.py +++ b/server_addon/core/server/settings/publish_plugins.py @@ -116,6 +116,8 @@ class OIIOToolArgumentsModel(BaseSettingsModel): class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): + _layout = "expanded" + name: str = Field("", title="Name") extension: str = Field("", title="Extension") transcoding_type: str = Field( "colorspace", @@ -164,6 +166,11 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): title="Output Definitions", ) + @validator("outputs") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + class ExtractOIIOTranscodeModel(BaseSettingsModel): enabled: bool = Field(True) From 87ed2f960daa97d4d94b73403c5076519bf6b20c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Sep 2023 11:47:39 +0200 Subject: [PATCH 89/90] Launcher tool: Refactor launcher tool (for AYON) (#5612) * added helper classes to utils * implemented base of ayon utils * initial commit for launcher tool * use image for extender * actions are shown and can be triggered * fix actions on finished refresh * refresh automatically * fix re-refreshing of projects model * added page slide animation * updated abstrack classes * change how icon is prepared * fix actions sorting * show messages like in launcher tool * do not clear items on refresh * stop refresh timer only on close event * use Ynput/AYON for local settings json * register default actions in launcher action module * change register naming * move 'SquareButton' to utils widgets * removed duplicated method * removed unused variable * removed unused import * don't use lambda * swap default name for 'OpenPypeSettingsRegistry' * Change support version --- openpype/lib/local_settings.py | 14 +- openpype/modules/launcher_action.py | 73 ++- openpype/pipeline/actions.py | 8 +- openpype/tools/ayon_launcher/abstract.py | 297 ++++++++++ openpype/tools/ayon_launcher/control.py | 149 ++++++ .../tools/ayon_launcher/models/__init__.py | 8 + .../tools/ayon_launcher/models/actions.py | 505 ++++++++++++++++++ .../tools/ayon_launcher/models/selection.py | 72 +++ openpype/tools/ayon_launcher/ui/__init__.py | 6 + .../tools/ayon_launcher/ui/actions_widget.py | 453 ++++++++++++++++ .../tools/ayon_launcher/ui/hierarchy_page.py | 102 ++++ .../tools/ayon_launcher/ui/projects_widget.py | 135 +++++ .../ayon_launcher/ui/resources/__init__.py | 7 + .../ayon_launcher/ui/resources/options.png | Bin 0 -> 1772 bytes openpype/tools/ayon_launcher/ui/window.py | 295 ++++++++++ openpype/tools/ayon_utils/models/__init__.py | 29 + openpype/tools/ayon_utils/models/cache.py | 196 +++++++ openpype/tools/ayon_utils/models/hierarchy.py | 340 ++++++++++++ openpype/tools/ayon_utils/models/projects.py | 145 +++++ openpype/tools/ayon_utils/widgets/__init__.py | 37 ++ .../ayon_utils/widgets/folders_widget.py | 364 +++++++++++++ .../ayon_utils/widgets/projects_widget.py | 325 +++++++++++ .../tools/ayon_utils/widgets/tasks_widget.py | 436 +++++++++++++++ openpype/tools/ayon_utils/widgets/utils.py | 98 ++++ openpype/tools/launcher/actions.py | 44 +- openpype/tools/utils/__init__.py | 9 + openpype/tools/utils/widgets.py | 79 ++- 27 files changed, 4158 insertions(+), 68 deletions(-) create mode 100644 openpype/tools/ayon_launcher/abstract.py create mode 100644 openpype/tools/ayon_launcher/control.py create mode 100644 openpype/tools/ayon_launcher/models/__init__.py create mode 100644 openpype/tools/ayon_launcher/models/actions.py create mode 100644 openpype/tools/ayon_launcher/models/selection.py create mode 100644 openpype/tools/ayon_launcher/ui/__init__.py create mode 100644 openpype/tools/ayon_launcher/ui/actions_widget.py create mode 100644 openpype/tools/ayon_launcher/ui/hierarchy_page.py create mode 100644 openpype/tools/ayon_launcher/ui/projects_widget.py create mode 100644 openpype/tools/ayon_launcher/ui/resources/__init__.py create mode 100644 openpype/tools/ayon_launcher/ui/resources/options.png create mode 100644 openpype/tools/ayon_launcher/ui/window.py create mode 100644 openpype/tools/ayon_utils/models/__init__.py create mode 100644 openpype/tools/ayon_utils/models/cache.py create mode 100644 openpype/tools/ayon_utils/models/hierarchy.py create mode 100644 openpype/tools/ayon_utils/models/projects.py create mode 100644 openpype/tools/ayon_utils/widgets/__init__.py create mode 100644 openpype/tools/ayon_utils/widgets/folders_widget.py create mode 100644 openpype/tools/ayon_utils/widgets/projects_widget.py create mode 100644 openpype/tools/ayon_utils/widgets/tasks_widget.py create mode 100644 openpype/tools/ayon_utils/widgets/utils.py diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 3fb35a7e7b..dae6e074af 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -494,10 +494,18 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): """ def __init__(self, name=None): - self.vendor = "pypeclub" - self.product = "openpype" + if AYON_SERVER_ENABLED: + vendor = "Ynput" + product = "AYON" + default_name = "AYON_settings" + else: + vendor = "pypeclub" + product = "openpype" + default_name = "openpype_settings" + self.vendor = vendor + self.product = product if not name: - name = "openpype_settings" + name = default_name path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index c4331b6094..5e14f25f76 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -1,3 +1,6 @@ +import os + +from openpype import PLUGINS_DIR, AYON_SERVER_ENABLED from openpype.modules import ( OpenPypeModule, ITrayAction, @@ -13,36 +16,66 @@ class LauncherAction(OpenPypeModule, ITrayAction): self.enabled = True # Tray attributes - self.window = None + self._window = None def tray_init(self): - self.create_window() + self._create_window() - self.add_doubleclick_callback(self.show_launcher) + self.add_doubleclick_callback(self._show_launcher) def tray_start(self): return def connect_with_modules(self, enabled_modules): # Register actions - if self.tray_initialized: - from openpype.tools.launcher import actions - actions.register_config_actions() - actions_paths = self.manager.collect_plugin_paths()["actions"] - actions.register_actions_from_paths(actions_paths) - actions.register_environment_actions() - - def create_window(self): - if self.window: + if not self.tray_initialized: return - from openpype.tools.launcher import LauncherWindow - self.window = LauncherWindow() + + from openpype.pipeline.actions import register_launcher_action_path + + actions_dir = os.path.join(PLUGINS_DIR, "actions") + if os.path.exists(actions_dir): + register_launcher_action_path(actions_dir) + + actions_paths = self.manager.collect_plugin_paths()["actions"] + for path in actions_paths: + if path and os.path.exists(path): + register_launcher_action_path(actions_dir) + + paths_str = os.environ.get("AVALON_ACTIONS") or "" + if paths_str: + self.log.warning( + "WARNING: 'AVALON_ACTIONS' is deprecated. Support of this" + " environment variable will be removed in future versions." + " Please consider using 'OpenPypeModule' to define custom" + " action paths. Planned version to drop the support" + " is 3.17.2 or 3.18.0 ." + ) + + for path in paths_str.split(os.pathsep): + if path and os.path.exists(path): + register_launcher_action_path(path) def on_action_trigger(self): - self.show_launcher() + """Implementation for ITrayAction interface. - def show_launcher(self): - if self.window: - self.window.show() - self.window.raise_() - self.window.activateWindow() + Show launcher tool on action trigger. + """ + + self._show_launcher() + + def _create_window(self): + if self._window: + return + if AYON_SERVER_ENABLED: + from openpype.tools.ayon_launcher.ui import LauncherWindow + else: + from openpype.tools.launcher import LauncherWindow + self._window = LauncherWindow() + + def _show_launcher(self): + if self._window is None: + return + self._window.show() + self._window.raise_() + self._window.activateWindow() diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index b488fe3e1f..feb1bd05d2 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -20,7 +20,13 @@ class LauncherAction(object): log.propagate = True def is_compatible(self, session): - """Return whether the class is compatible with the Session.""" + """Return whether the class is compatible with the Session. + + Args: + session (dict[str, Union[str, None]]): Session data with + AVALON_PROJECT, AVALON_ASSET and AVALON_TASK. + """ + return True def process(self, session, **kwargs): diff --git a/openpype/tools/ayon_launcher/abstract.py b/openpype/tools/ayon_launcher/abstract.py new file mode 100644 index 0000000000..00502fe930 --- /dev/null +++ b/openpype/tools/ayon_launcher/abstract.py @@ -0,0 +1,297 @@ +from abc import ABCMeta, abstractmethod + +import six + + +@six.add_metaclass(ABCMeta) +class AbstractLauncherCommon(object): + @abstractmethod + def register_event_callback(self, topic, callback): + """Register event callback. + + Listen for events with given topic. + + Args: + topic (str): Name of topic. + callback (Callable): Callback that will be called when event + is triggered. + """ + + pass + + +class AbstractLauncherBackend(AbstractLauncherCommon): + @abstractmethod + def emit_event(self, topic, data=None, source=None): + """Emit event. + + Args: + topic (str): Event topic used for callbacks filtering. + data (Optional[dict[str, Any]]): Event data. + source (Optional[str]): Event source. + """ + + pass + + @abstractmethod + def get_project_settings(self, project_name): + """Project settings for current project. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + dict[str, Any]: Project settings. + """ + + pass + + @abstractmethod + def get_project_entity(self, project_name): + """Get project entity by name. + + Args: + project_name (str): Project name. + + Returns: + dict[str, Any]: Project entity data. + """ + + pass + + @abstractmethod + def get_folder_entity(self, project_name, folder_id): + """Get folder entity by id. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + + Returns: + dict[str, Any]: Folder entity data. + """ + + pass + + @abstractmethod + def get_task_entity(self, project_name, task_id): + """Get task entity by id. + + Args: + project_name (str): Project name. + task_id (str): Task id. + + Returns: + dict[str, Any]: Task entity data. + """ + + pass + + +class AbstractLauncherFrontEnd(AbstractLauncherCommon): + # Entity items for UI + @abstractmethod + def get_project_items(self, sender=None): + """Project items for all projects. + + This function may trigger events 'projects.refresh.started' and + 'projects.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of project items in UI elements. + + Args: + sender (str): Who requested folder items. + + Returns: + list[ProjectItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_folder_items(self, project_name, sender=None): + """Folder items to visualize project hierarchy. + + This function may trigger events 'folders.refresh.started' and + 'folders.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of folder items in UI elements. + + Args: + project_name (str): Project name. + sender (str): Who requested folder items. + + Returns: + list[FolderItem]: Minimum possible information needed + for visualisation of folder hierarchy. + """ + + pass + + @abstractmethod + def get_task_items(self, project_name, folder_id, sender=None): + """Task items. + + This function may trigger events 'tasks.refresh.started' and + 'tasks.refresh.finished' which will contain 'sender' value in data. + That may help to avoid re-refresh of task items in UI elements. + + Args: + project_name (str): Project name. + folder_id (str): Folder ID for which are tasks requested. + sender (str): Who requested folder items. + + Returns: + list[TaskItem]: Minimum possible information needed + for visualisation of tasks. + """ + + pass + + @abstractmethod + def get_selected_project_name(self): + """Selected project name. + + Returns: + Union[str, None]: Selected project name. + """ + + pass + + @abstractmethod + def get_selected_folder_id(self): + """Selected folder id. + + Returns: + Union[str, None]: Selected folder id. + """ + + pass + + @abstractmethod + def get_selected_task_id(self): + """Selected task id. + + Returns: + Union[str, None]: Selected task id. + """ + + pass + + @abstractmethod + def get_selected_task_name(self): + """Selected task name. + + Returns: + Union[str, None]: Selected task name. + """ + + pass + + @abstractmethod + def get_selected_context(self): + """Get whole selected context. + + Example: + { + "project_name": self.get_selected_project_name(), + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + } + + Returns: + dict[str, Union[str, None]]: Selected context. + """ + + pass + + @abstractmethod + def set_selected_project(self, project_name): + """Change selected folder. + + Args: + project_name (Union[str, None]): Project nameor None if no project + is selected. + """ + + pass + + @abstractmethod + def set_selected_folder(self, folder_id): + """Change selected folder. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + """ + + pass + + @abstractmethod + def set_selected_task(self, task_id, task_name): + """Change selected task. + + Args: + task_id (Union[str, None]): Task id or None if no task + is selected. + task_name (Union[str, None]): Task name or None if no task + is selected. + """ + + pass + + # Actions + @abstractmethod + def get_action_items(self, project_name, folder_id, task_id): + """Get action items for given context. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[ActionItem]: List of action items that should be shown + for given context. + """ + + pass + + @abstractmethod + def trigger_action(self, project_name, folder_id, task_id, action_id): + """Trigger action on given context. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + action_id (str): Action identifier. + """ + + pass + + @abstractmethod + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_id, enabled + ): + """This is application action related to force not open last workfile. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + action_id (str): Action identifier. + enabled (bool): New value of force not open workfile. + """ + + pass + + @abstractmethod + def refresh(self): + """Refresh everything, models, ui etc. + + Triggers 'controller.refresh.started' event at the beginning and + 'controller.refresh.finished' at the end. + """ + + pass diff --git a/openpype/tools/ayon_launcher/control.py b/openpype/tools/ayon_launcher/control.py new file mode 100644 index 0000000000..09e07893c3 --- /dev/null +++ b/openpype/tools/ayon_launcher/control.py @@ -0,0 +1,149 @@ +from openpype.lib import Logger +from openpype.lib.events import QueuedEventSystem +from openpype.settings import get_project_settings +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel + +from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend +from .models import LauncherSelectionModel, ActionsModel + + +class BaseLauncherController( + AbstractLauncherFrontEnd, AbstractLauncherBackend +): + def __init__(self): + self._project_settings = {} + self._event_system = None + self._log = None + + self._selection_model = LauncherSelectionModel(self) + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._actions_model = ActionsModel(self) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + @property + def event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self.event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self.event_system.add_callback(topic, callback) + + # Entity items for UI + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender) + + # Project settings for applications actions + def get_project_settings(self, project_name): + if project_name in self._project_settings: + return self._project_settings[project_name] + settings = get_project_settings(project_name) + self._project_settings[project_name] = settings + return settings + + # Entity for backend + def get_project_entity(self, project_name): + return self._projects_model.get_project_entity(project_name) + + def get_folder_entity(self, project_name, folder_id): + return self._hierarchy_model.get_folder_entity( + project_name, folder_id) + + def get_task_entity(self, project_name, task_id): + return self._hierarchy_model.get_task_entity(project_name, task_id) + + # Selection methods + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def get_selected_context(self): + return { + "project_name": self.get_selected_project_name(), + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + } + + # Actions + def get_action_items(self, project_name, folder_id, task_id): + return self._actions_model.get_action_items( + project_name, folder_id, task_id) + + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_id, enabled + ): + self._actions_model.set_application_force_not_open_workfile( + project_name, folder_id, task_id, action_id, enabled + ) + + def trigger_action(self, project_name, folder_id, task_id, identifier): + self._actions_model.trigger_action( + project_name, folder_id, task_id, identifier) + + # General methods + def refresh(self): + self._emit_event("controller.refresh.started") + + self._project_settings = {} + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._actions_model.refresh() + self._projects_model.refresh() + + self._emit_event("controller.refresh.finished") + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") diff --git a/openpype/tools/ayon_launcher/models/__init__.py b/openpype/tools/ayon_launcher/models/__init__.py new file mode 100644 index 0000000000..1bc60c85f0 --- /dev/null +++ b/openpype/tools/ayon_launcher/models/__init__.py @@ -0,0 +1,8 @@ +from .actions import ActionsModel +from .selection import LauncherSelectionModel + + +__all__ = ( + "ActionsModel", + "LauncherSelectionModel", +) diff --git a/openpype/tools/ayon_launcher/models/actions.py b/openpype/tools/ayon_launcher/models/actions.py new file mode 100644 index 0000000000..24fea44db2 --- /dev/null +++ b/openpype/tools/ayon_launcher/models/actions.py @@ -0,0 +1,505 @@ +import os + +from openpype import resources +from openpype.lib import Logger, OpenPypeSettingsRegistry +from openpype.pipeline.actions import ( + discover_launcher_actions, + LauncherAction, +) + + +# class Action: +# def __init__(self, label, icon=None, identifier=None): +# self._label = label +# self._icon = icon +# self._callbacks = [] +# self._identifier = identifier or uuid.uuid4().hex +# self._checked = True +# self._checkable = False +# +# def set_checked(self, checked): +# self._checked = checked +# +# def set_checkable(self, checkable): +# self._checkable = checkable +# +# def set_label(self, label): +# self._label = label +# +# def add_callback(self, callback): +# self._callbacks = callback +# +# +# class Menu: +# def __init__(self, label, icon=None): +# self.label = label +# self.icon = icon +# self._actions = [] +# +# def add_action(self, action): +# self._actions.append(action) + + +class ApplicationAction(LauncherAction): + """Action to launch an application. + + Application action based on 'ApplicationManager' system. + + Handling of applications in launcher is not ideal and should be completely + redone from scratch. This is just a temporary solution to keep backwards + compatibility with OpenPype launcher. + + Todos: + Move handling of errors to frontend. + """ + + # Application object + application = None + # Action attributes + name = None + label = None + label_variant = None + group = None + icon = None + color = None + order = 0 + data = {} + project_settings = {} + project_entities = {} + + _log = None + required_session_keys = ( + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK" + ) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def is_compatible(self, session): + for key in self.required_session_keys: + if not session.get(key): + return False + + project_name = session["AVALON_PROJECT"] + project_entity = self.project_entities[project_name] + apps = project_entity["attrib"].get("applications") + if not apps or self.application.full_name not in apps: + return False + + project_settings = self.project_settings[project_name] + only_available = project_settings["applications"]["only_available"] + if only_available and not self.application.find_executable(): + return False + return True + + def _show_message_box(self, title, message, details=None): + from qtpy import QtWidgets, QtGui + from openpype import style + + dialog = QtWidgets.QMessageBox() + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + dialog.setWindowIcon(icon) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle(title) + dialog.setText(message) + if details: + dialog.setDetailedText(details) + dialog.exec_() + + def process(self, session, **kwargs): + """Process the full Application action""" + + from openpype.lib import ( + ApplictionExecutableNotFound, + ApplicationLaunchFailed, + ) + + project_name = session["AVALON_PROJECT"] + asset_name = session["AVALON_ASSET"] + task_name = session["AVALON_TASK"] + try: + self.application.launch( + project_name=project_name, + asset_name=asset_name, + task_name=task_name, + **self.data + ) + + except ApplictionExecutableNotFound as exc: + details = exc.details + msg = exc.msg + log_msg = str(msg) + if details: + log_msg += "\n" + details + self.log.warning(log_msg) + self._show_message_box( + "Application executable not found", msg, details + ) + + except ApplicationLaunchFailed as exc: + msg = str(exc) + self.log.warning(msg, exc_info=True) + self._show_message_box("Application launch failed", msg) + + +class ActionItem: + """Item representing single action to trigger. + + Todos: + Get rid of application specific logic. + + Args: + identifier (str): Unique identifier of action item. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + icon (dict[str, str]): Icon definition. + order (int): Action ordering. + is_application (bool): Is action application action. + force_not_open_workfile (bool): Force not open workfile. Application + related. + full_label (Optional[str]): Full label, if not set it is generated + from 'label' and 'variant_label'. + """ + + def __init__( + self, + identifier, + label, + variant_label, + icon, + order, + is_application, + force_not_open_workfile, + full_label=None + ): + self.identifier = identifier + self.label = label + self.variant_label = variant_label + self.icon = icon + self.order = order + self.is_application = is_application + self.force_not_open_workfile = force_not_open_workfile + self._full_label = full_label + + def copy(self): + return self.from_data(self.to_data()) + + @property + def full_label(self): + if self._full_label is None: + if self.variant_label: + self._full_label = " ".join([self.label, self.variant_label]) + else: + self._full_label = self.label + return self._full_label + + def to_data(self): + return { + "identifier": self.identifier, + "label": self.label, + "variant_label": self.variant_label, + "icon": self.icon, + "order": self.order, + "is_application": self.is_application, + "force_not_open_workfile": self.force_not_open_workfile, + "full_label": self._full_label, + } + + @classmethod + def from_data(cls, data): + return cls(**data) + + +def get_action_icon(action): + """Get action icon info. + + Args: + action (LacunherAction): Action instance. + + Returns: + dict[str, str]: Icon info. + """ + + icon = action.icon + if not icon: + return { + "type": "awesome-font", + "name": "fa.cube", + "color": "white" + } + + if isinstance(icon, dict): + return icon + + icon_path = resources.get_resource(icon) + if not os.path.exists(icon_path): + try: + icon_path = icon.format(resources.RESOURCES_DIR) + except Exception: + pass + + if os.path.exists(icon_path): + return { + "type": "path", + "path": icon_path, + } + + return { + "type": "awesome-font", + "name": icon, + "color": action.color or "white" + } + + +class ActionsModel: + """Actions model. + + Args: + controller (AbstractLauncherBackend): Controller instance. + """ + + _not_open_workfile_reg_key = "force_not_open_workfile" + + def __init__(self, controller): + self._controller = controller + + self._log = None + + self._discovered_actions = None + self._actions = None + self._action_items = {} + + self._launcher_tool_reg = OpenPypeSettingsRegistry("launcher_tool") + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def refresh(self): + self._discovered_actions = None + self._actions = None + self._action_items = {} + + self._controller.emit_event("actions.refresh.started") + self._get_action_objects() + self._controller.emit_event("actions.refresh.finished") + + def get_action_items(self, project_name, folder_id, task_id): + """Get actions for project. + + Args: + project_name (Union[str, None]): Project name. + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[ActionItem]: List of actions. + """ + + not_open_workfile_actions = self._get_no_last_workfile_for_context( + project_name, folder_id, task_id) + session = self._prepare_session(project_name, folder_id, task_id) + output = [] + action_items = self._get_action_items(project_name) + for identifier, action in self._get_action_objects().items(): + if not action.is_compatible(session): + continue + + action_item = action_items[identifier] + # Handling of 'force_not_open_workfile' for applications + if action_item.is_application: + action_item = action_item.copy() + action_item.force_not_open_workfile = ( + not_open_workfile_actions.get(identifier, False) + ) + + output.append(action_item) + return output + + def set_application_force_not_open_workfile( + self, project_name, folder_id, task_id, action_id, enabled + ): + no_workfile_reg_data = self._get_no_last_workfile_reg_data() + project_data = no_workfile_reg_data.setdefault(project_name, {}) + folder_data = project_data.setdefault(folder_id, {}) + task_data = folder_data.setdefault(task_id, {}) + task_data[action_id] = enabled + self._launcher_tool_reg.set_item( + self._not_open_workfile_reg_key, no_workfile_reg_data + ) + + def trigger_action(self, project_name, folder_id, task_id, identifier): + session = self._prepare_session(project_name, folder_id, task_id) + failed = False + error_message = None + action_label = identifier + action_items = self._get_action_items(project_name) + try: + action = self._actions[identifier] + action_item = action_items[identifier] + action_label = action_item.full_label + self._controller.emit_event( + "action.trigger.started", + { + "identifier": identifier, + "full_label": action_label, + } + ) + if isinstance(action, ApplicationAction): + per_action = self._get_no_last_workfile_for_context( + project_name, folder_id, task_id + ) + force_not_open_workfile = per_action.get(identifier, False) + action.data["start_last_workfile"] = force_not_open_workfile + action.process(session) + except Exception as exc: + self.log.warning("Action trigger failed.", exc_info=True) + failed = True + error_message = str(exc) + + self._controller.emit_event( + "action.trigger.finished", + { + "identifier": identifier, + "failed": failed, + "error_message": error_message, + "full_label": action_label, + } + ) + + def _get_no_last_workfile_reg_data(self): + try: + no_workfile_reg_data = self._launcher_tool_reg.get_item( + self._not_open_workfile_reg_key) + except ValueError: + no_workfile_reg_data = {} + self._launcher_tool_reg.set_item( + self._not_open_workfile_reg_key, no_workfile_reg_data) + return no_workfile_reg_data + + def _get_no_last_workfile_for_context( + self, project_name, folder_id, task_id + ): + not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() + return ( + not_open_workfile_reg_data + .get(project_name, {}) + .get(folder_id, {}) + .get(task_id, {}) + ) + + def _prepare_session(self, project_name, folder_id, task_id): + folder_name = None + if folder_id: + folder = self._controller.get_folder_entity( + project_name, folder_id) + if folder: + folder_name = folder["name"] + + task_name = None + if task_id: + task = self._controller.get_task_entity(project_name, task_id) + if task: + task_name = task["name"] + + return { + "AVALON_PROJECT": project_name, + "AVALON_ASSET": folder_name, + "AVALON_TASK": task_name, + } + + def _get_discovered_action_classes(self): + if self._discovered_actions is None: + self._discovered_actions = ( + discover_launcher_actions() + + self._get_applications_action_classes() + ) + return self._discovered_actions + + def _get_action_objects(self): + if self._actions is None: + actions = {} + for cls in self._get_discovered_action_classes(): + obj = cls() + identifier = getattr(obj, "identifier", None) + if identifier is None: + identifier = cls.__name__ + actions[identifier] = obj + self._actions = actions + return self._actions + + def _get_action_items(self, project_name): + action_items = self._action_items.get(project_name) + if action_items is not None: + return action_items + + project_entity = None + if project_name: + project_entity = self._controller.get_project_entity(project_name) + project_settings = self._controller.get_project_settings(project_name) + + action_items = {} + for identifier, action in self._get_action_objects().items(): + is_application = isinstance(action, ApplicationAction) + if is_application: + action.project_entities[project_name] = project_entity + action.project_settings[project_name] = project_settings + label = action.label or identifier + variant_label = getattr(action, "label_variant", None) + icon = get_action_icon(action) + item = ActionItem( + identifier, + label, + variant_label, + icon, + action.order, + is_application, + False + ) + action_items[identifier] = item + self._action_items[project_name] = action_items + return action_items + + def _get_applications_action_classes(self): + from openpype.lib.applications import ( + CUSTOM_LAUNCH_APP_GROUPS, + ApplicationManager, + ) + + actions = [] + + manager = ApplicationManager() + for full_name, application in manager.applications.items(): + if ( + application.group.name in CUSTOM_LAUNCH_APP_GROUPS + or not application.enabled + ): + continue + + action = type( + "app_{}".format(full_name), + (ApplicationAction,), + { + "identifier": "application.{}".format(full_name), + "application": application, + "name": application.name, + "label": application.group.label, + "label_variant": application.label, + "group": None, + "icon": application.icon, + "color": getattr(application, "color", None), + "order": getattr(application, "order", None) or 0, + "data": {} + } + ) + actions.append(action) + return actions diff --git a/openpype/tools/ayon_launcher/models/selection.py b/openpype/tools/ayon_launcher/models/selection.py new file mode 100644 index 0000000000..b156d2084c --- /dev/null +++ b/openpype/tools/ayon_launcher/models/selection.py @@ -0,0 +1,72 @@ +class LauncherSelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "launcher.selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_name = None + self._task_id = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if project_name == self._project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) diff --git a/openpype/tools/ayon_launcher/ui/__init__.py b/openpype/tools/ayon_launcher/ui/__init__.py new file mode 100644 index 0000000000..da30c84656 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import LauncherWindow + + +__all__ = ( + "LauncherWindow", +) diff --git a/openpype/tools/ayon_launcher/ui/actions_widget.py b/openpype/tools/ayon_launcher/ui/actions_widget.py new file mode 100644 index 0000000000..d04f8f8d24 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/actions_widget.py @@ -0,0 +1,453 @@ +import time +import collections + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.flickcharm import FlickCharm +from openpype.tools.ayon_utils.widgets import get_qt_icon + +from .resources import get_options_image_path + +ANIMATION_LEN = 7 + +ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 +ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 +FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 + + +class ActionsQtModel(QtGui.QStandardItemModel): + """Qt model for actions. + + Args: + controller (AbstractLauncherFrontEnd): Controller instance. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ActionsQtModel, self).__init__() + + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh_finished, + ) + controller.register_event_callback( + "selection.project.changed", + self._on_selection_project_changed, + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_selection_folder_changed, + ) + controller.register_event_callback( + "selection.task.changed", + self._on_selection_task_changed, + ) + + self._controller = controller + + self._items_by_id = {} + self._groups_by_id = {} + + self._selected_project_name = None + self._selected_folder_id = None + self._selected_task_id = None + + def get_selected_project_name(self): + return self._selected_project_name + + def get_selected_folder_id(self): + return self._selected_folder_id + + def get_selected_task_id(self): + return self._selected_task_id + + def get_group_items(self, action_id): + return self._groups_by_id[action_id] + + def get_item_by_id(self, action_id): + return self._items_by_id.get(action_id) + + def _clear_items(self): + self._items_by_id = {} + self._groups_by_id = {} + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + + def refresh(self): + items = self._controller.get_action_items( + self._selected_project_name, + self._selected_folder_id, + self._selected_task_id, + ) + if not items: + self._clear_items() + self.refreshed.emit() + return + + root_item = self.invisibleRootItem() + + all_action_items_info = [] + items_by_label = collections.defaultdict(list) + for item in items: + if not item.variant_label: + all_action_items_info.append((item, False)) + else: + items_by_label[item.label].append(item) + + groups_by_id = {} + for action_items in items_by_label.values(): + first_item = next(iter(action_items)) + all_action_items_info.append((first_item, len(action_items) > 1)) + groups_by_id[first_item.identifier] = action_items + + new_items = [] + items_by_id = {} + for action_item_info in all_action_items_info: + action_item, is_group = action_item_info + icon = get_qt_icon(action_item.icon) + if is_group: + label = action_item.label + else: + label = action_item.full_label + + item = self._items_by_id.get(action_item.identifier) + if item is None: + item = QtGui.QStandardItem() + item.setData(action_item.identifier, ACTION_ID_ROLE) + new_items.append(item) + + item.setFlags(QtCore.Qt.ItemIsEnabled) + item.setData(label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(action_item.order, ACTION_SORT_ROLE) + item.setData( + action_item.is_application, ACTION_IS_APPLICATION_ROLE) + item.setData( + action_item.force_not_open_workfile, + FORCE_NOT_OPEN_WORKFILE_ROLE) + items_by_id[action_item.identifier] = item + + if new_items: + root_item.appendRows(new_items) + + to_remove = set(self._items_by_id.keys()) - set(items_by_id.keys()) + for identifier in to_remove: + item = self._items_by_id.pop(identifier) + root_item.removeRow(item.row()) + + self._groups_by_id = groups_by_id + self._items_by_id = items_by_id + self.refreshed.emit() + + def _on_controller_refresh_finished(self): + context = self._controller.get_selected_context() + self._selected_project_name = context["project_name"] + self._selected_folder_id = context["folder_id"] + self._selected_task_id = context["task_id"] + self.refresh() + + def _on_selection_project_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = None + self._selected_task_id = None + self.refresh() + + def _on_selection_folder_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = None + self.refresh() + + def _on_selection_task_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + self.refresh() + + +class ActionDelegate(QtWidgets.QStyledItemDelegate): + _cached_extender = {} + + def __init__(self, *args, **kwargs): + super(ActionDelegate, self).__init__(*args, **kwargs) + self._anim_start_color = QtGui.QColor(178, 255, 246) + self._anim_end_color = QtGui.QColor(5, 44, 50) + + def _draw_animation(self, painter, option, index): + grid_size = option.widget.gridSize() + x_offset = int( + (grid_size.width() / 2) + - (option.rect.width() / 2) + ) + item_x = option.rect.x() - x_offset + rect_offset = grid_size.width() / 20 + size = grid_size.width() - (rect_offset * 2) + anim_rect = QtCore.QRect( + item_x + rect_offset, + option.rect.y() + rect_offset, + size, + size + ) + + painter.save() + + painter.setBrush(QtCore.Qt.transparent) + + gradient = QtGui.QConicalGradient() + gradient.setCenter(QtCore.QPointF(anim_rect.center())) + gradient.setColorAt(0, self._anim_start_color) + gradient.setColorAt(1, self._anim_end_color) + + time_diff = time.time() - index.data(ANIMATION_START_ROLE) + + # Repeat 4 times + part_anim = 2.5 + part_time = time_diff % part_anim + offset = (part_time / part_anim) * 360 + angle = (offset + 90) % 360 + + gradient.setAngle(-angle) + + pen = QtGui.QPen(QtGui.QBrush(gradient), rect_offset) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + painter.drawArc( + anim_rect, + -16 * (angle + 10), + -16 * offset + ) + + painter.restore() + + @classmethod + def _get_extender_pixmap(cls, size): + pix = cls._cached_extender.get(size) + if pix is not None: + return pix + pix = QtGui.QPixmap(get_options_image_path()).scaled( + size, size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + cls._cached_extender[size] = pix + return pix + + def paint(self, painter, option, index): + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + if index.data(ANIMATION_STATE_ROLE): + self._draw_animation(painter, option, index) + + super(ActionDelegate, self).paint(painter, option, index) + + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + rect = QtCore.QRectF( + option.rect.x(), option.rect.height(), 5, 5) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QColor(200, 0, 0)) + painter.drawEllipse(rect) + + if not index.data(ACTION_IS_GROUP_ROLE): + return + + grid_size = option.widget.gridSize() + x_offset = int( + (grid_size.width() / 2) + - (option.rect.width() / 2) + ) + item_x = option.rect.x() - x_offset + + tenth_size = int(grid_size.width() / 10) + extender_size = int(tenth_size * 2.4) + + extender_x = item_x + tenth_size + extender_y = option.rect.y() + tenth_size + + pix = self._get_extender_pixmap(extender_size) + painter.drawPixmap(extender_x, extender_y, pix) + + +class ActionsWidget(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(ActionsWidget, self).__init__(parent) + + self._controller = controller + + view = QtWidgets.QListView(self) + view.setProperty("mode", "icon") + view.setObjectName("IconView") + view.setViewMode(QtWidgets.QListView.IconMode) + view.setResizeMode(QtWidgets.QListView.Adjust) + view.setSelectionMode(QtWidgets.QListView.NoSelection) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + view.setWrapping(True) + view.setGridSize(QtCore.QSize(70, 75)) + view.setIconSize(QtCore.QSize(30, 30)) + view.setSpacing(0) + view.setWordWrap(True) + + # Make view flickable + flick = FlickCharm(parent=view) + flick.activateOn(view) + + model = ActionsQtModel(controller) + + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setSortRole(ACTION_SORT_ROLE) + + proxy_model.setSourceModel(model) + view.setModel(proxy_model) + + delegate = ActionDelegate(self) + view.setItemDelegate(delegate) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view) + + animation_timer = QtCore.QTimer() + animation_timer.setInterval(40) + animation_timer.timeout.connect(self._on_animation) + + view.clicked.connect(self._on_clicked) + view.customContextMenuRequested.connect(self._on_context_menu) + model.refreshed.connect(self._on_model_refresh) + + self._animated_items = set() + self._animation_timer = animation_timer + + self._context_menu = None + + self._flick = flick + self._view = view + self._model = model + self._proxy_model = proxy_model + + self._set_row_height(1) + + def _set_row_height(self, rows): + self.setMinimumHeight(rows * 75) + + def _on_model_refresh(self): + self._proxy_model.sort(0) + + def _on_animation(self): + time_now = time.time() + for action_id in tuple(self._animated_items): + item = self._model.get_item_by_id(action_id) + if item is None: + self._animated_items.discard(action_id) + continue + + start_time = item.data(ANIMATION_START_ROLE) + if start_time is None or (time_now - start_time) > ANIMATION_LEN: + item.setData(0, ANIMATION_STATE_ROLE) + self._animated_items.discard(action_id) + + if not self._animated_items: + self._animation_timer.stop() + + self.update() + + def _start_animation(self, index): + # Offset refresh timout + model_index = self._proxy_model.mapToSource(index) + if not model_index.isValid(): + return + action_id = model_index.data(ACTION_ID_ROLE) + self._model.setData(model_index, time.time(), ANIMATION_START_ROLE) + self._model.setData(model_index, 1, ANIMATION_STATE_ROLE) + self._animated_items.add(action_id) + self._animation_timer.start() + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self._view.indexAt(point) + if not index.isValid(): + return + + if not index.data(ACTION_IS_APPLICATION_ROLE): + return + + menu = QtWidgets.QMenu(self._view) + checkbox = QtWidgets.QCheckBox( + "Skip opening last workfile.", menu) + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + checkbox.setChecked(True) + + action_id = index.data(ACTION_ID_ROLE) + checkbox.stateChanged.connect( + lambda: self._on_checkbox_changed( + action_id, checkbox.isChecked() + ) + ) + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + menu.addAction(action) + + self._context_menu = menu + global_point = self.mapToGlobal(point) + menu.exec_(global_point) + self._context_menu = None + + def _on_checkbox_changed(self, action_id, is_checked): + if self._context_menu is not None: + self._context_menu.close() + + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + self._controller.set_application_force_not_open_workfile( + project_name, folder_id, task_id, action_id, is_checked) + self._model.refresh() + + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + is_group = index.data(ACTION_IS_GROUP_ROLE) + action_id = index.data(ACTION_ID_ROLE) + + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + + if not is_group: + self._controller.trigger_action( + project_name, folder_id, task_id, action_id + ) + self._start_animation(index) + return + + action_items = self._model.get_group_items(action_id) + + menu = QtWidgets.QMenu(self) + actions_mapping = {} + + for action_item in action_items: + menu_action = QtWidgets.QAction(action_item.full_label) + menu.addAction(menu_action) + actions_mapping[menu_action] = action_item + + result = menu.exec_(QtGui.QCursor.pos()) + if not result: + return + + action_item = actions_mapping[result] + + self._controller.trigger_action( + project_name, folder_id, task_id, action_item.identifier + ) + self._start_animation(index) diff --git a/openpype/tools/ayon_launcher/ui/hierarchy_page.py b/openpype/tools/ayon_launcher/ui/hierarchy_page.py new file mode 100644 index 0000000000..5047cdc692 --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/hierarchy_page.py @@ -0,0 +1,102 @@ +import qtawesome +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import ( + PlaceholderLineEdit, + SquareButton, + RefreshButton, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) + + +class HierarchyPage(QtWidgets.QWidget): + def __init__(self, controller, parent): + super(HierarchyPage, self).__init__(parent) + + # Header + header_widget = QtWidgets.QWidget(self) + + btn_back_icon = qtawesome.icon("fa.angle-left", color="white") + btn_back = SquareButton(header_widget) + btn_back.setIcon(btn_back_icon) + + projects_combobox = ProjectsCombobox(controller, header_widget) + + refresh_btn = RefreshButton(header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(btn_back, 0) + header_layout.addWidget(projects_combobox, 1) + header_layout.addWidget(refresh_btn, 0) + + # Body - Folders + Tasks selection + content_body = QtWidgets.QSplitter(self) + content_body.setContentsMargins(0, 0, 0, 0) + content_body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + content_body.setOrientation(QtCore.Qt.Horizontal) + + # - Folders widget with filter + folders_wrapper = QtWidgets.QWidget(content_body) + + folders_filter_text = PlaceholderLineEdit(folders_wrapper) + folders_filter_text.setPlaceholderText("Filter folders...") + + folders_widget = FoldersWidget(controller, folders_wrapper) + + folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper) + folders_wrapper_layout.setContentsMargins(0, 0, 0, 0) + folders_wrapper_layout.addWidget(folders_filter_text, 0) + folders_wrapper_layout.addWidget(folders_widget, 1) + + # - Tasks widget + tasks_widget = TasksWidget(controller, content_body) + + content_body.addWidget(folders_wrapper) + content_body.addWidget(tasks_widget) + content_body.setStretchFactor(0, 100) + content_body.setStretchFactor(1, 65) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(header_widget, 0) + main_layout.addWidget(content_body, 1) + + btn_back.clicked.connect(self._on_back_clicked) + refresh_btn.clicked.connect(self._on_refreh_clicked) + folders_filter_text.textChanged.connect(self._on_filter_text_changed) + + self._is_visible = False + self._controller = controller + + self._btn_back = btn_back + self._projects_combobox = projects_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + # Post init + projects_combobox.set_listen_to_selection_change(self._is_visible) + + def set_page_visible(self, visible, project_name=None): + if self._is_visible == visible: + return + self._is_visible = visible + self._projects_combobox.set_listen_to_selection_change(visible) + if visible and project_name: + self._projects_combobox.set_selection(project_name) + + def _on_back_clicked(self): + self._controller.set_selected_project(None) + + def _on_refreh_clicked(self): + self._controller.refresh() + + def _on_filter_text_changed(self, text): + self._folders_widget.set_name_filer(text) diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py new file mode 100644 index 0000000000..baa399d0ed --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -0,0 +1,135 @@ +from qtpy import QtWidgets, QtCore + +from openpype.tools.flickcharm import FlickCharm +from openpype.tools.utils import PlaceholderLineEdit, RefreshButton +from openpype.tools.ayon_utils.widgets import ( + ProjectsModel, + ProjectSortFilterProxy, +) +from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER + + +class ProjectIconView(QtWidgets.QListView): + """Styled ListView that allows to toggle between icon and list mode. + + Toggling between the two modes is done by Right Mouse Click. + """ + + IconMode = 0 + ListMode = 1 + + def __init__(self, parent=None, mode=ListMode): + super(ProjectIconView, self).__init__(parent=parent) + + # Workaround for scrolling being super slow or fast when + # toggling between the two visual modes + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setObjectName("IconView") + + self._mode = None + self.set_mode(mode) + + def set_mode(self, mode): + if mode == self._mode: + return + + self._mode = mode + + if mode == self.IconMode: + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(True) + self.setWordWrap(True) + self.setGridSize(QtCore.QSize(151, 90)) + self.setIconSize(QtCore.QSize(50, 50)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.setProperty("mode", "icon") + self.style().polish(self) + + self.verticalScrollBar().setSingleStep(30) + + elif self.ListMode: + self.setProperty("mode", "list") + self.style().polish(self) + + self.setViewMode(QtWidgets.QListView.ListMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setWrapping(False) + self.setWordWrap(False) + self.setIconSize(QtCore.QSize(20, 20)) + self.setGridSize(QtCore.QSize(100, 25)) + self.setSpacing(0) + self.setAlternatingRowColors(False) + + self.verticalScrollBar().setSingleStep(34) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.set_mode(int(not self._mode)) + return super(ProjectIconView, self).mousePressEvent(event) + + +class ProjectsWidget(QtWidgets.QWidget): + """Projects Page""" + def __init__(self, controller, parent=None): + super(ProjectsWidget, self).__init__(parent=parent) + + header_widget = QtWidgets.QWidget(self) + + projects_filter_text = PlaceholderLineEdit(header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(projects_filter_text, 1) + header_layout.addWidget(refresh_btn, 0) + + projects_view = ProjectIconView(parent=self) + projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) + flick = FlickCharm(parent=self) + flick.activateOn(projects_view) + projects_model = ProjectsModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(header_widget, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.clicked.connect(self._on_view_clicked) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) + refresh_btn.clicked.connect(self._on_refresh_clicked) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + + def _on_view_clicked(self, index): + if index.isValid(): + project_name = index.data(QtCore.Qt.DisplayRole) + self._controller.set_selected_project(project_name) + + def _on_project_filter_change(self, text): + self._projects_proxy_model.setFilterFixedString(text) + + def _on_refresh_clicked(self): + self._controller.refresh() + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() diff --git a/openpype/tools/ayon_launcher/ui/resources/__init__.py b/openpype/tools/ayon_launcher/ui/resources/__init__.py new file mode 100644 index 0000000000..27c59af2ba --- /dev/null +++ b/openpype/tools/ayon_launcher/ui/resources/__init__.py @@ -0,0 +1,7 @@ +import os + +RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def get_options_image_path(): + return os.path.join(RESOURCES_DIR, "options.png") diff --git a/openpype/tools/ayon_launcher/ui/resources/options.png b/openpype/tools/ayon_launcher/ui/resources/options.png new file mode 100644 index 0000000000000000000000000000000000000000..a9617d0d1914634835f388c01479c672a8c8ffd7 GIT binary patch literal 1772 zcmb7^`B%~j7skJcd*X`HFm9u1j=6?t%;Iwb7j zwT|b(sB<3ljDb1wl4oJgfJ(AW&7Bpv&K*RI{SGm)zYu0jaC8|wa}(8OdA=SqaOrGq zQl&JypY*{sIl^1ofVAgks@SbOwLH%}dUorXyCcm^p8M0qpzTd|xz-DUnY^dOKTPeH z@AJ-CoGxRX%>y<5sp+n7Xk3?Ib+6{^?Cg%=d&rthvwyAl5BkShe>;4{WaeF*=5}tm ztt=TDXzceIC%r2%j9B($%Mh%fdW+^DZJMF}7T%OTA(YA8((QYh7f_5?|N5vQJC}2i zC0r;RJ>ErHqPC^l%s{3;^f+m96LRwULUx{B(Jk=u8K5KQK(F2Y*hE<*nGwM+6L<1iX zlK}t@mfr$Is`1VO02RGJA5=K0d~t%GY(ju`e_V2|K}V)SS?KCKM{H&_P7Y3Crp5mD z(`Z|5OJ5s)n{5lU+FfOFe`n~y1vEYAMcpmGYIrBiNw-)XnP|0HU<^ZOl*}9xr(R_s z-ktQ)Rp*UwVBUH#819d^g7kQOs&9HGq+ox_gXKk_@&4ncL<1u#E}<#6%&D0{)?q>@ z?Mq-Gs6s7mESn~{WhJteFd6)`mZ1U=UOLUm;q`|VcDE-YaVZ;Q~Te%Sofmu0UYVg!&X;RuJAt9igF;-A4LhWK?gT_dOlF*OOzD^7K zM0(b%SCz|j<0nL|yHR9|tdKubbevo`S#A{YvDG=|<+k;omrnFfC=_onNlmF}*P%aQ zz^g7hN^R`Pd;|)6L$)FXQese2SGyOAY{6{}Y=kB?Kv#n$PhZdf2ky zC~`-vdo>j`0wm``3CPJbVh^l!T^XN!WuMskOu14otdtzM{FZ(Ri1&Fi@A@7tB8dK~ zde}ZsK7UHkRttC-w4~8JO&(yw0aNwZM}4Ljqg`wPwrbtjQMQjA+>o+2e`K*~z`}^8 z$vMbbLM0Q2PC(c15PkyL_|q$ttu02DN;O3I7xQtiX;sVJ+ymk-oAE{EL?t$|+05dC zI@|D&m@;_t3WNN^wQ{8^-@~D`hWrV+gGfW9Ct4Myk1m@@3?wv$+$1JoCUMX~gcLy% zh~XxkM&oi1d}qIrrDlMz02j145nuS1%F=x$|b>QE}Uw_gWZT>QLRG z(V=0dJffRQo$`Clt5srtbzj?s=cCa_lk?tP8IX-f>!Ws5L@ck1<(p=DzydJQUDGyaDo3`;n#I_7#p1q zhXiA8A329}vHO`;fO84CeMF#Wsx?to(^2@VC=8r2mai3L)W}`t7f{yg}pV5{4$m~-H5NUJkTQN6t*Ssmw zEK7QvnhYK8CMbrSija`c4|B|61&Y%ub36cVDVz$Q7|_Ra`j1sDOF{i9-4hK;j#?7m zg4b(STtTXty_=b>KL8LX)j~j)sZmySrC>jPN|p?KUt2o6^)Rm87RP5K{aekI3Hu$u zbFte3N7JA8*R$kRE2H|HiFjp)Tf?0gH~)_%?0Q*P#>> cache = NestedCacheItem(levels=2) + >>> cache["a"]["b"].is_valid + False + >>> cache["a"]["b"].get_data() + None + >>> cache["a"]["b"] = 1 + >>> cache["a"]["b"].is_valid + True + >>> cache["a"]["b"].get_data() + 1 + >>> cache.reset() + >>> cache["a"]["b"].is_valid + False + + Args: + levels (int): Number of nested levels where read cache is stored. + default_factory (Optional[callable]): Function that returns default + value used on init and on reset. + lifetime (Optional[int]): Lifetime of the cache data in seconds. + _init_info (Optional[InitInfo]): Private argument. Init info for + nested cache where created from parent item. + """ + + def __init__( + self, levels=1, default_factory=None, lifetime=None, _init_info=None + ): + if levels < 1: + raise ValueError("Nested levels must be greater than 0") + self._data_by_key = {} + if _init_info is None: + _init_info = InitInfo(default_factory, lifetime) + self._init_info = _init_info + self._levels = levels + + def __getitem__(self, key): + """Get cached data. + + Args: + key (str): Key of the cache item. + + Returns: + Union[NestedCacheItem, CacheItem]: Cache item. + """ + + cache = self._data_by_key.get(key) + if cache is None: + if self._levels > 1: + cache = NestedCacheItem( + levels=self._levels - 1, + _init_info=self._init_info + ) + else: + cache = CacheItem( + self._init_info.default_factory, + self._init_info.lifetime + ) + self._data_by_key[key] = cache + return cache + + def __setitem__(self, key, value): + """Update cached data. + + Args: + key (str): Key of the cache item. + value (Any): Any data that are cached. + """ + + if self._levels > 1: + raise AttributeError(( + "{} does not support '__setitem__'. Lower nested level by {}" + ).format(self.__class__.__name__, self._levels - 1)) + cache = self[key] + cache.update_data(value) + + def get(self, key): + """Get cached data. + + Args: + key (str): Key of the cache item. + + Returns: + Union[NestedCacheItem, CacheItem]: Cache item. + """ + + return self[key] + + def reset(self): + """Reset cache.""" + + self._data_by_key = {} + + def set_lifetime(self, lifetime): + """Change lifetime of all children cache items. + + Args: + lifetime (int): Lifetime of the cache data in seconds. + """ + + self._init_info.lifetime = lifetime + for cache in self._data_by_key.values(): + cache.set_lifetime(lifetime) + + @property + def is_valid(self): + """Raise reasonable error when called on wront level. + + Raises: + AttributeError: If called on nested cache item. + """ + + raise AttributeError(( + "{} does not support 'is_valid'. Lower nested level by '{}'" + ).format(self.__class__.__name__, self._levels)) diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py new file mode 100644 index 0000000000..8e01c557c5 --- /dev/null +++ b/openpype/tools/ayon_utils/models/hierarchy.py @@ -0,0 +1,340 @@ +import collections +import contextlib +from abc import ABCMeta, abstractmethod + +import ayon_api +import six + +from openpype.style import get_default_entity_icon_color + +from .cache import NestedCacheItem + +HIERARCHY_MODEL_SENDER = "hierarchy.model" + + +@six.add_metaclass(ABCMeta) +class AbstractHierarchyController: + @abstractmethod + def emit_event(self, topic, data, source): + pass + + +class FolderItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + entity_id (str): Folder id. + parent_id (Union[str, None]): Parent folder id. If 'None' then project + is parent. + name (str): Name of folder. + label (str): Folder label. + icon_name (str): Name of icon from font awesome. + icon_color (str): Hex color string that will be used for icon. + """ + + def __init__( + self, entity_id, parent_id, name, label, icon + ): + self.entity_id = entity_id + self.parent_id = parent_id + self.name = name + if not icon: + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": get_default_entity_icon_color() + } + self.icon = icon + self.label = label or name + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "entity_id": self.entity_id, + "parent_id": self.parent_id, + "name": self.name, + "label": self.label, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +class TaskItem: + """Task item representing task entity on a server. + + Task is child of a folder. + + Task item has label that is used for display in UI. The label is by + default using task name and type. + + Args: + task_id (str): Task id. + name (str): Name of task. + task_type (str): Type of task. + parent_id (str): Parent folder id. + icon_name (str): Name of icon from font awesome. + icon_color (str): Hex color string that will be used for icon. + """ + + def __init__( + self, task_id, name, task_type, parent_id, icon + ): + self.task_id = task_id + self.name = name + self.task_type = task_type + self.parent_id = parent_id + if icon is None: + icon = { + "type": "awesome-font", + "name": "fa.male", + "color": get_default_entity_icon_color() + } + self.icon = icon + + self._label = None + + @property + def id(self): + """Alias for task_id. + + Returns: + str: Task id. + """ + + return self.task_id + + @property + def label(self): + """Label of task item for UI. + + Returns: + str: Label of task item. + """ + + if self._label is None: + self._label = "{} ({})".format(self.name, self.task_type) + return self._label + + def to_data(self): + """Converts task item to data. + + Returns: + dict[str, Any]: Task item data. + """ + + return { + "task_id": self.task_id, + "name": self.name, + "parent_id": self.parent_id, + "task_type": self.task_type, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-create task item from data. + + Args: + data (dict[str, Any]): Task item data. + + Returns: + TaskItem: Task item. + """ + + return cls(**data) + + +def _get_task_items_from_tasks(tasks): + """ + + Returns: + TaskItem: Task item. + """ + + output = [] + for task in tasks: + folder_id = task["folderId"] + output.append(TaskItem( + task["id"], + task["name"], + task["type"], + folder_id, + None + )) + return output + + +def _get_folder_item_from_hierarchy_item(item): + return FolderItem( + item["id"], + item["parentId"], + item["name"], + item["label"], + None + ) + + +class HierarchyModel(object): + """Model for project hierarchy items. + + Hierarchy items are folders and tasks. Folders can have as parent another + folder or project. Tasks can have as parent only folder. + """ + + def __init__(self, controller): + self._folders_items = NestedCacheItem(levels=1, default_factory=dict) + self._folders_by_id = NestedCacheItem(levels=2, default_factory=dict) + + self._task_items = NestedCacheItem(levels=2, default_factory=dict) + self._tasks_by_id = NestedCacheItem(levels=2, default_factory=dict) + + self._folders_refreshing = set() + self._tasks_refreshing = set() + self._controller = controller + + def reset(self): + self._folders_items.reset() + self._folders_by_id.reset() + + self._task_items.reset() + self._tasks_by_id.reset() + + def refresh_project(self, project_name): + self._refresh_folders_cache(project_name) + + def get_folder_items(self, project_name, sender): + if not self._folders_items[project_name].is_valid: + self._refresh_folders_cache(project_name, sender) + return self._folders_items[project_name].get_data() + + def get_task_items(self, project_name, folder_id, sender): + if not project_name or not folder_id: + return [] + + task_cache = self._task_items[project_name][folder_id] + if not task_cache.is_valid: + self._refresh_tasks_cache(project_name, folder_id, sender) + return task_cache.get_data() + + def get_folder_entity(self, project_name, folder_id): + cache = self._folders_by_id[project_name][folder_id] + if not cache.is_valid: + entity = None + if folder_id: + entity = ayon_api.get_folder_by_id(project_name, folder_id) + cache.update_data(entity) + return cache.get_data() + + def get_task_entity(self, project_name, task_id): + cache = self._tasks_by_id[project_name][task_id] + if not cache.is_valid: + entity = None + if task_id: + entity = ayon_api.get_task_by_id(project_name, task_id) + cache.update_data(entity) + return cache.get_data() + + @contextlib.contextmanager + def _folder_refresh_event_manager(self, project_name, sender): + self._folders_refreshing.add(project_name) + self._controller.emit_event( + "folders.refresh.started", + {"project_name": project_name, "sender": sender}, + HIERARCHY_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "folders.refresh.finished", + {"project_name": project_name, "sender": sender}, + HIERARCHY_MODEL_SENDER + ) + self._folders_refreshing.remove(project_name) + + @contextlib.contextmanager + def _task_refresh_event_manager( + self, project_name, folder_id, sender + ): + self._tasks_refreshing.add(folder_id) + self._controller.emit_event( + "tasks.refresh.started", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + HIERARCHY_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "tasks.refresh.finished", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + HIERARCHY_MODEL_SENDER + ) + self._tasks_refreshing.discard(folder_id) + + def _refresh_folders_cache(self, project_name, sender=None): + if project_name in self._folders_refreshing: + return + + with self._folder_refresh_event_manager(project_name, sender): + folder_items = self._query_folders(project_name) + self._folders_items[project_name].update_data(folder_items) + + def _query_folders(self, project_name): + hierarchy = ayon_api.get_folders_hierarchy(project_name) + + folder_items = {} + hierachy_queue = collections.deque(hierarchy["hierarchy"]) + while hierachy_queue: + item = hierachy_queue.popleft() + folder_item = _get_folder_item_from_hierarchy_item(item) + folder_items[folder_item.entity_id] = folder_item + hierachy_queue.extend(item["children"] or []) + return folder_items + + def _refresh_tasks_cache(self, project_name, folder_id, sender=None): + if folder_id in self._tasks_refreshing: + return + + with self._task_refresh_event_manager( + project_name, folder_id, sender + ): + task_items = self._query_tasks(project_name, folder_id) + self._task_items[project_name][folder_id] = task_items + + def _query_tasks(self, project_name, folder_id): + tasks = list(ayon_api.get_tasks( + project_name, + folder_ids=[folder_id], + fields={"id", "name", "label", "folderId", "type"} + )) + return _get_task_items_from_tasks(tasks) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py new file mode 100644 index 0000000000..ae3eeecea4 --- /dev/null +++ b/openpype/tools/ayon_utils/models/projects.py @@ -0,0 +1,145 @@ +import contextlib +from abc import ABCMeta, abstractmethod + +import ayon_api +import six + +from openpype.style import get_default_entity_icon_color + +from .cache import CacheItem + +PROJECTS_MODEL_SENDER = "projects.model" + + +@six.add_metaclass(ABCMeta) +class AbstractHierarchyController: + @abstractmethod + def emit_event(self, topic, data, source): + pass + + +class ProjectItem: + """Item representing folder entity on a server. + + Folder can be a child of another folder or a project. + + Args: + name (str): Project name. + active (Union[str, None]): Parent folder id. If 'None' then project + is parent. + """ + + def __init__(self, name, active, icon=None): + self.name = name + self.active = active + if icon is None: + icon = { + "type": "awesome-font", + "name": "fa.map", + "color": get_default_entity_icon_color(), + } + self.icon = icon + + def to_data(self): + """Converts folder item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "name": self.name, + "active": self.active, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data): + """Re-creates folder item from data. + + Args: + data (dict[str, Any]): Folder item data. + + Returns: + FolderItem: Folder item. + """ + + return cls(**data) + + +def _get_project_items_from_entitiy(projects): + """ + + Args: + projects (list[dict[str, Any]]): List of projects. + + Returns: + ProjectItem: Project item. + """ + + return [ + ProjectItem(project["name"], project["active"]) + for project in projects + ] + + +class ProjectsModel(object): + def __init__(self, controller): + self._projects_cache = CacheItem(default_factory=dict) + self._project_items_by_name = {} + self._projects_by_name = {} + + self._is_refreshing = False + self._controller = controller + + def reset(self): + self._projects_cache.reset() + self._project_items_by_name = {} + self._projects_by_name = {} + + def refresh(self): + self._refresh_projects_cache() + + def get_project_items(self, sender): + if not self._projects_cache.is_valid: + self._refresh_projects_cache(sender) + return self._projects_cache.get_data() + + def get_project_entity(self, project_name): + if project_name not in self._projects_by_name: + entity = None + if project_name: + entity = ayon_api.get_project(project_name) + self._projects_by_name[project_name] = entity + return self._projects_by_name[project_name] + + @contextlib.contextmanager + def _project_refresh_event_manager(self, sender): + self._is_refreshing = True + self._controller.emit_event( + "projects.refresh.started", + {"sender": sender}, + PROJECTS_MODEL_SENDER + ) + try: + yield + + finally: + self._controller.emit_event( + "projects.refresh.finished", + {"sender": sender}, + PROJECTS_MODEL_SENDER + ) + self._is_refreshing = False + + def _refresh_projects_cache(self, sender=None): + if self._is_refreshing: + return + + with self._project_refresh_event_manager(sender): + project_items = self._query_projects() + self._projects_cache.update_data(project_items) + + def _query_projects(self): + projects = ayon_api.get_projects(fields=["name", "active"]) + return _get_project_items_from_entitiy(projects) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py new file mode 100644 index 0000000000..59aef98faf --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -0,0 +1,37 @@ +from .projects_widget import ( + # ProjectsWidget, + ProjectsCombobox, + ProjectsModel, + ProjectSortFilterProxy, +) + +from .folders_widget import ( + FoldersWidget, + FoldersModel, +) + +from .tasks_widget import ( + TasksWidget, + TasksModel, +) +from .utils import ( + get_qt_icon, + RefreshThread, +) + + +__all__ = ( + # "ProjectsWidget", + "ProjectsCombobox", + "ProjectsModel", + "ProjectSortFilterProxy", + + "FoldersWidget", + "FoldersModel", + + "TasksWidget", + "TasksModel", + + "get_qt_icon", + "RefreshThread", +) diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py new file mode 100644 index 0000000000..3fab64f657 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -0,0 +1,364 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) + +from .utils import RefreshThread, get_qt_icon + +SENDER_NAME = "qt_folders_model" +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2 + + +class FoldersModel(QtGui.QStandardItemModel): + """Folders model which cares about refresh of folders. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(FoldersModel, self).__init__() + + self._controller = controller + self._items_by_id = {} + self._parent_id_by_id = {} + + self._refresh_threads = {} + self._current_refresh_thread = None + self._last_project_name = None + + self._has_content = False + self._is_refreshing = False + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + return self._is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._has_content + + def clear(self): + self._items_by_id = {} + self._parent_id_by_id = {} + self._has_content = False + super(FoldersModel, self).clear() + + def get_index_by_id(self, item_id): + """Get index by folder id. + + Returns: + QtCore.QModelIndex: Index of the folder. Can be invalid if folder + is not available. + """ + item = self._items_by_id.get(item_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def set_project_name(self, project_name): + """Refresh folders items. + + Refresh start thread because it can cause that controller can + start query from database if folders are not cached. + """ + + if not project_name: + self._last_project_name = project_name + self._current_refresh_thread = None + self._fill_items({}) + return + + self._is_refreshing = True + + if self._last_project_name != project_name: + self.clear() + self._last_project_name = project_name + + thread = self._refresh_threads.get(project_name) + if thread is not None: + self._current_refresh_thread = thread + return + + thread = RefreshThread( + project_name, + self._controller.get_folder_items, + project_name, + SENDER_NAME + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Folders are stored by id. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_items(thread.get_result()) + + def _fill_items(self, folder_items_by_id): + if not folder_items_by_id: + if folder_items_by_id is not None: + self.clear() + self._is_refreshing = False + self.refreshed.emit() + return + + self._has_content = True + + folder_ids = set(folder_items_by_id) + ids_to_remove = set(self._items_by_id) - folder_ids + + folder_items_by_parent = collections.defaultdict(dict) + for folder_item in folder_items_by_id.values(): + ( + folder_items_by_parent + [folder_item.parent_id] + [folder_item.entity_id] + ) = folder_item + + hierarchy_queue = collections.deque() + hierarchy_queue.append((self.invisibleRootItem(), None)) + + # Keep pointers to removed items until the refresh finishes + # - some children of the items could be moved and reused elsewhere + removed_items = [] + while hierarchy_queue: + item = hierarchy_queue.popleft() + parent_item, parent_id = item + folder_items = folder_items_by_parent[parent_id] + + items_by_id = {} + folder_ids_to_add = set(folder_items) + for row_idx in reversed(range(parent_item.rowCount())): + child_item = parent_item.child(row_idx) + child_id = child_item.data(ITEM_ID_ROLE) + if child_id in ids_to_remove: + removed_items.append(parent_item.takeRow(row_idx)) + else: + items_by_id[child_id] = child_item + + new_items = [] + for item_id in folder_ids_to_add: + folder_item = folder_items[item_id] + item = items_by_id.get(item_id) + if item is None: + is_new = True + item = QtGui.QStandardItem() + item.setEditable(False) + else: + is_new = self._parent_id_by_id[item_id] != parent_id + + icon = get_qt_icon(folder_item.icon) + item.setData(item_id, ITEM_ID_ROLE) + item.setData(folder_item.name, ITEM_NAME_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + if is_new: + new_items.append(item) + self._items_by_id[item_id] = item + self._parent_id_by_id[item_id] = parent_id + + hierarchy_queue.append((item, item_id)) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in ids_to_remove: + self._items_by_id.pop(item_id) + self._parent_id_by_id.pop(item_id) + + self._is_refreshing = False + self.refreshed.emit() + + +class FoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Expected selection handling is disabled by default. If enabled, the + widget will handle the expected in predefined way. Widget is listening + to event 'expected_selection_changed' with expected event data below, + the same data must be available when called method + 'get_expected_selection_data' on controller. + + { + "folder": { + "current": bool, # Folder is what should be set now + "folder_id": Union[str, None], # Folder id that should be selected + }, + ... + } + + Selection is confirmed by calling method 'expected_folder_selected' on + controller. + + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + handle_expected_selection (bool): If True, the widget will handle + the expected selection. Defaults to False. + """ + + def __init__(self, controller, parent, handle_expected_selection=False): + super(FoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + + folders_model = FoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + + folders_view.setModel(folders_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change, + ) + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + def set_name_filer(self, name): + """Set filter of folder name. + + Args: + name (str): The string filter. + """ + + self._folders_proxy_model.setFilterFixedString(name) + + def _on_project_selection_change(self, event): + project_name = event["project_name"] + self._set_project_name(project_name) + + def _set_project_name(self, project_name): + self._folders_model.set_project_name(project_name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != SENDER_NAME: + self._set_project_name(event["project_name"]) + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + + def _get_selected_item_id(self): + selection_model = self._folders_view.selectionModel() + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is not None: + return item_id + return None + + def _on_selection_change(self): + item_id = self._get_selected_item_id() + self._controller.set_selected_folder(item_id) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + folder_data = expected_data.get("folder") + if not folder_data or not folder_data["current"]: + return + + folder_id = folder_data["id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + + folder_id = self._expected_selection + self._expected_selection = None + if ( + folder_id is not None + and folder_id != self._get_selected_item_id() + ): + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py new file mode 100644 index 0000000000..818d574910 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -0,0 +1,325 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER +from .utils import RefreshThread, get_qt_icon + +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 + + +class ProjectsModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ProjectsModel, self).__init__() + self._controller = controller + + self._project_items = {} + + self._empty_item = None + self._empty_item_added = False + + self._is_refreshing = False + self._refresh_thread = None + + @property + def is_refreshing(self): + return self._is_refreshing + + def refresh(self): + self._refresh() + + def has_content(self): + return len(self._project_items) > 0 + + def _add_empty_item(self): + item = self._get_empty_item() + if not self._empty_item_added: + root_item = self.invisibleRootItem() + root_item.appendRow(item) + self._empty_item_added = True + + def _remove_empty_item(self): + if not self._empty_item_added: + return + + root_item = self.invisibleRootItem() + item = self._get_empty_item() + root_item.takeRow(item.row()) + self._empty_item_added = False + + def _get_empty_item(self): + if self._empty_item is None: + item = QtGui.QStandardItem("< No projects >") + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_item = item + return self._empty_item + + def _refresh(self): + if self._is_refreshing: + return + self._is_refreshing = True + refresh_thread = RefreshThread( + "projects", self._query_project_items + ) + refresh_thread.refresh_finished.connect(self._refresh_finished) + refresh_thread.start() + self._refresh_thread = refresh_thread + + def _query_project_items(self): + return self._controller.get_project_items() + + def _refresh_finished(self): + # TODO check if failed + result = self._refresh_thread.get_result() + self._refresh_thread = None + + self._fill_items(result) + + self._is_refreshing = False + self.refreshed.emit() + + def _fill_items(self, project_items): + items_to_remove = set(self._project_items.keys()) + new_items = [] + for project_item in project_items: + project_name = project_item.name + items_to_remove.discard(project_name) + item = self._project_items.get(project_name) + if item is None: + item = QtGui.QStandardItem() + new_items.append(item) + icon = get_qt_icon(project_item.icon) + item.setData(project_name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(project_name, PROJECT_NAME_ROLE) + item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) + self._project_items[project_name] = item + + root_item = self.invisibleRootItem() + if new_items: + root_item.appendRows(new_items) + + for project_name in items_to_remove: + item = self._project_items.pop(project_name) + root_item.removeRow(item.row()) + + if self.has_content(): + self._remove_empty_item() + else: + self._add_empty_item() + + +class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + self._filter_inactive = True + # Disable case sensitivity + self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + def lessThan(self, left_index, right_index): + if left_index.data(PROJECT_NAME_ROLE) is None: + return True + + if right_index.data(PROJECT_NAME_ROLE) is None: + return False + + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active == left_is_active: + return super(ProjectSortFilterProxy, self).lessThan( + left_index, right_index + ) + + if left_is_active: + return True + return False + + def filterAcceptsRow(self, source_row, source_parent): + index = self.sourceModel().index(source_row, 0, source_parent) + string_pattern = self.filterRegularExpression().pattern() + if ( + self._filter_inactive + and not index.data(PROJECT_IS_ACTIVE_ROLE) + ): + return False + + if string_pattern: + project_name = index.data(PROJECT_IS_ACTIVE_ROLE) + if project_name is not None: + return string_pattern.lower() in project_name.lower() + + return super(ProjectSortFilterProxy, self).filterAcceptsRow( + source_row, source_parent + ) + + def _custom_index_filter(self, index): + return bool(index.data(PROJECT_IS_ACTIVE_ROLE)) + + def is_active_filter_enabled(self): + return self._filter_inactive + + def set_active_filter_enabled(self, value): + if self._filter_inactive == value: + return + self._filter_inactive = value + self.invalidateFilter() + + +class ProjectsCombobox(QtWidgets.QWidget): + def __init__(self, controller, parent, handle_expected_selection=False): + super(ProjectsCombobox, self).__init__(parent) + + projects_combobox = QtWidgets.QComboBox(self) + combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + projects_combobox.setItemDelegate(combobox_delegate) + projects_model = ProjectsModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_combobox.setModel(projects_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_combobox, 1) + + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + projects_combobox.currentIndexChanged.connect( + self._on_current_index_changed + ) + + self._controller = controller + self._listen_selection_change = True + + self._handle_expected_selection = handle_expected_selection + self._expected_selection = None + + self._projects_combobox = projects_combobox + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._combobox_delegate = combobox_delegate + + def refresh(self): + self._projects_model.refresh() + + def set_selection(self, project_name): + """Set selection to a given project. + + Selection change is ignored if project is not found. + + Args: + project_name (str): Name of project. + + Returns: + bool: True if selection was changed, False otherwise. NOTE: + Selection may not be changed if project is not found, or if + project is already selected. + """ + + idx = self._projects_combobox.findData( + project_name, PROJECT_NAME_ROLE) + if idx < 0: + return False + if idx != self._projects_combobox.currentIndex(): + self._projects_combobox.setCurrentIndex(idx) + return True + return False + + def set_listen_to_selection_change(self, listen): + """Disable listening to changes of the selection. + + Because combobox is triggering selection change when it's model + is refreshed, it's necessary to disable listening to selection for + some cases, e.g. when is on a different page of UI and should be just + refreshed. + + Args: + listen (bool): Enable or disable listening to selection changes. + """ + + self._listen_selection_change = listen + + def get_current_project_name(self): + """Name of selected project. + + Returns: + Union[str, None]: Name of selected project, or None if no project + """ + + idx = self._projects_combobox.currentIndex() + if idx < 0: + return None + return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) + + def _on_current_index_changed(self, idx): + if not self._listen_selection_change: + return + project_name = self._projects_combobox.itemData( + idx, PROJECT_NAME_ROLE) + self._controller.set_selected_project(project_name) + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + if self._expected_selection: + self._set_expected_selection() + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() + + def _on_controller_refresh(self): + self._update_expected_selection() + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return + project_name = self._expected_selection + if project_name is not None: + if project_name != self.get_current_project_name(): + self.set_selection(project_name) + else: + # Fake project change + self._on_current_index_changed( + self._projects_combobox.currentIndex() + ) + + self._controller.expected_project_selected(project_name) + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + project_data = expected_data.get("project") + if ( + not project_data + or not project_data["current"] + or project_data["selected"] + ): + return + self._expected_selection = project_data["name"] + if not self._projects_model.is_refreshing: + self._set_expected_selection() + + +class ProjectsWidget(QtWidgets.QWidget): + # TODO implement + pass diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py new file mode 100644 index 0000000000..66ebd0b777 --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -0,0 +1,436 @@ +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import get_disabled_entity_icon_color +from openpype.tools.utils import DeselectableTreeView + +from .utils import RefreshThread, get_qt_icon + +SENDER_NAME = "qt_tasks_model" +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +PARENT_ID_ROLE = QtCore.Qt.UserRole + 2 +ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 + + +class TasksModel(QtGui.QStandardItemModel): + """Tasks model which cares about refresh of tasks by folder id. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(TasksModel, self).__init__() + + self._controller = controller + + self._items_by_name = {} + self._has_content = False + self._is_refreshing = False + + self._invalid_selection_item_used = False + self._invalid_selection_item = None + self._empty_tasks_item_used = False + self._empty_tasks_item = None + + self._last_project_name = None + self._last_folder_id = None + + self._refresh_threads = {} + self._current_refresh_thread = None + + # Initial state + self._add_invalid_selection_item() + + def clear(self): + self._items_by_name = {} + self._has_content = False + self._remove_invalid_items() + super(TasksModel, self).clear() + + def refresh(self, project_name, folder_id): + """Refresh tasks for folder. + + Args: + project_name (Union[str]): Name of project. + folder_id (Union[str, None]): Folder id. + """ + + self._refresh(project_name, folder_id) + + def get_index_by_name(self, task_name): + """Find item by name and return its index. + + Returns: + QtCore.QModelIndex: Index of item. Is invalid if task is not + found by name. + """ + + item = self._items_by_name.get(task_name) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def get_last_project_name(self): + """Get last refreshed project name. + + Returns: + Union[str, None]: Project name. + """ + + return self._last_project_name + + def get_last_folder_id(self): + """Get last refreshed folder id. + + Returns: + Union[str, None]: Folder id. + """ + + return self._last_folder_id + + def set_selected_project(self, project_name): + self._selected_project_name = project_name + + def _get_invalid_selection_item(self): + if self._invalid_selection_item is None: + item = QtGui.QStandardItem("Select a folder") + item.setFlags(QtCore.Qt.NoItemFlags) + icon = get_qt_icon({ + "type": "awesome-font", + "name": "fa.times", + "color": get_disabled_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + self._invalid_selection_item = item + return self._invalid_selection_item + + def _get_empty_task_item(self): + if self._empty_tasks_item is None: + item = QtGui.QStandardItem("No task") + icon = get_qt_icon({ + "type": "awesome-font", + "name": "fa.exclamation-circle", + "color": get_disabled_entity_icon_color(), + }) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_tasks_item = item + return self._empty_tasks_item + + def _add_invalid_item(self, item): + self.clear() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_invalid_item(self, item): + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _remove_invalid_items(self): + self._remove_invalid_selection_item() + self._remove_empty_task_item() + + def _add_invalid_selection_item(self): + if not self._invalid_selection_item_used: + self._add_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = True + + def _remove_invalid_selection_item(self): + if self._invalid_selection_item: + self._remove_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = False + + def _add_empty_task_item(self): + if not self._empty_tasks_item_used: + self._add_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = True + + def _remove_empty_task_item(self): + if self._empty_tasks_item_used: + self._remove_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = False + + def _refresh(self, project_name, folder_id): + self._is_refreshing = True + self._last_project_name = project_name + self._last_folder_id = folder_id + if not folder_id: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread = self._refresh_threads.get(folder_id) + if thread is not None: + self._current_refresh_thread = thread + return + thread = RefreshThread( + folder_id, + self._controller.get_task_items, + project_name, + folder_id + ) + self._current_refresh_thread = thread + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + task_items = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + new_items = [] + new_names = set() + for task_item in task_items: + name = task_item.name + new_names.add(name) + item = self._items_by_name.get(name) + if item is None: + item = QtGui.QStandardItem() + item.setEditable(False) + new_items.append(item) + self._items_by_name[name] = item + + # TODO cache locally + icon = get_qt_icon(task_item.icon) + item.setData(task_item.label, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + + for name in set(self._items_by_name) - new_names: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + if new_items: + root_item.appendRows(new_items) + + self._has_content = root_item.rowCount() > 0 + self._is_refreshing = False + self.refreshed.emit() + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: Model is refreshing + """ + + return self._is_refreshing + + @property + def has_content(self): + """Model has content. + + Returns: + bools: Have at least one task. + """ + + return self._has_content + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Tasks" + + return super(TasksModel, self).headerData( + section, orientation, role + ) + + +class TasksWidget(QtWidgets.QWidget): + """Tasks widget. + + Widget that handles tasks view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): Workfiles controller. + parent (QtWidgets.QWidget): Parent widget. + handle_expected_selection (Optional[bool]): Handle expected selection. + """ + + def __init__(self, controller, parent, handle_expected_selection=False): + super(TasksWidget, self).__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setIndentation(0) + + tasks_model = TasksModel(controller) + tasks_proxy_model = QtCore.QSortFilterProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + + tasks_view.setModel(tasks_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + controller.register_event_callback( + "selection.folder.changed", + self._folder_selection_changed + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_tasks_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + self._selected_folder_id = None + + self._handle_expected_selection = handle_expected_selection + self._expected_selection_data = None + + def _clear(self): + self._tasks_model.clear() + + def _on_tasks_refresh_finished(self, event): + """Tasks were refreshed in controller. + + Ignore if refresh was triggered by tasks model, or refreshed folder is + not the same as currently selected folder. + + Args: + event (Event): Event object. + """ + + # Refresh only if current folder id is the same + if ( + event["sender"] == SENDER_NAME + or event["folder_id"] != self._selected_folder_id + ): + return + self._tasks_model.refresh( + event["project_name"], self._selected_folder_id + ) + + def _folder_selection_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._tasks_model.refresh( + event["project_name"], self._selected_folder_id + ) + + def _on_tasks_model_refresh(self): + if not self._set_expected_selection(): + self._on_selection_change() + self._tasks_proxy_model.sort(0) + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + for index in selection_model.selectedIndexes(): + task_id = index.data(ITEM_ID_ROLE) + task_name = index.data(ITEM_NAME_ROLE) + parent_id = index.data(PARENT_ID_ROLE) + if task_name is not None: + return parent_id, task_id, task_name + return self._selected_folder_id, None, None + + def _on_selection_change(self): + # Don't trigger task change during refresh + # - a task was deselected if that happens + # - can cause crash triggered during tasks refreshing + if self._tasks_model.is_refreshing: + return + + parent_id, task_id, task_name = self._get_selected_item_ids() + self._controller.set_selected_task(task_id, task_name) + + # Expected selection handling + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _set_expected_selection(self): + if not self._handle_expected_selection: + return False + + if self._expected_selection_data is None: + return False + folder_id = self._expected_selection_data["folder_id"] + task_name = self._expected_selection_data["task_name"] + self._expected_selection_data = None + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id: + return False + if task_name is not None: + index = self._tasks_model.get_index_by_name(task_name) + if index.isValid(): + proxy_index = self._tasks_proxy_model.mapFromSource(index) + self._tasks_view.setCurrentIndex(proxy_index) + self._controller.expected_task_selected(folder_id, task_name) + return True + + def _update_expected_selection(self, expected_data=None): + if not self._handle_expected_selection: + return + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + folder_data = expected_data.get("folder") + task_data = expected_data.get("task") + if ( + not folder_data + or not task_data + or not task_data["current"] + ): + return + folder_id = folder_data["id"] + self._expected_selection_data = { + "task_name": task_data["name"], + "folder_id": folder_id, + } + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id or self._tasks_model.is_refreshing: + return + self._set_expected_selection() diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py new file mode 100644 index 0000000000..8bc3b1ea9b --- /dev/null +++ b/openpype/tools/ayon_utils/widgets/utils.py @@ -0,0 +1,98 @@ +import os +from functools import partial + +from qtpy import QtCore, QtGui + +from openpype.tools.utils.lib import get_qta_icon_by_name_and_color + + +class RefreshThread(QtCore.QThread): + refresh_finished = QtCore.Signal(str) + + def __init__(self, thread_id, func, *args, **kwargs): + super(RefreshThread, self).__init__() + self._id = thread_id + self._callback = partial(func, *args, **kwargs) + self._exception = None + self._result = None + + @property + def id(self): + return self._id + + @property + def failed(self): + return self._exception is not None + + def run(self): + try: + self._result = self._callback() + except Exception as exc: + self._exception = exc + self.refresh_finished.emit(self.id) + + def get_result(self): + return self._result + + +class _IconsCache: + """Cache for icons.""" + + _cache = {} + _default = None + + @classmethod + def _get_cache_key(cls, icon_def): + parts = [] + icon_type = icon_def["type"] + if icon_type == "path": + parts = [icon_type, icon_def["path"]] + + elif icon_type == "awesome-font": + parts = [icon_type, icon_def["name"], icon_def["color"]] + return "|".join(parts) + + @classmethod + def get_icon(cls, icon_def): + icon_type = icon_def["type"] + cache_key = cls._get_cache_key(icon_def) + cache = cls._cache.get(cache_key) + if cache is not None: + return cache + + icon = None + if icon_type == "path": + path = icon_def["path"] + if os.path.exists(path): + icon = QtGui.QIcon(path) + + elif icon_type == "awesome-font": + icon_name = icon_def["name"] + icon_color = icon_def["color"] + icon = get_qta_icon_by_name_and_color(icon_name, icon_color) + if icon is None: + icon = get_qta_icon_by_name_and_color( + "fa.{}".format(icon_name), icon_color) + if icon is None: + icon = cls.get_default() + cls._cache[cache_key] = icon + return icon + + @classmethod + def get_default(cls): + pix = QtGui.QPixmap(1, 1) + pix.fill(QtCore.Qt.transparent) + return QtGui.QIcon(pix) + + +def get_qt_icon(icon_def): + """Returns icon from cache or creates new one. + + Args: + icon_def (dict[str, Any]): Icon definition. + + Returns: + QtGui.QIcon: Icon. + """ + + return _IconsCache.get_icon(icon_def) diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 61660ee9b7..285b5d04ca 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -1,8 +1,5 @@ -import os - from qtpy import QtWidgets, QtGui -from openpype import PLUGINS_DIR from openpype import style from openpype import resources from openpype.lib import ( @@ -10,46 +7,7 @@ from openpype.lib import ( ApplictionExecutableNotFound, ApplicationLaunchFailed ) -from openpype.pipeline import ( - LauncherAction, - register_launcher_action_path, -) - - -def register_actions_from_paths(paths): - if not paths: - return - - for path in paths: - if not path: - continue - - if path.startswith("."): - print(( - "BUG: Relative paths are not allowed for security reasons. {}" - ).format(path)) - continue - - if not os.path.exists(path): - print("Path was not found: {}".format(path)) - continue - - register_launcher_action_path(path) - - -def register_config_actions(): - """Register actions from the configuration for Launcher""" - - actions_dir = os.path.join(PLUGINS_DIR, "actions") - if os.path.exists(actions_dir): - register_actions_from_paths([actions_dir]) - - -def register_environment_actions(): - """Register actions from AVALON_ACTIONS for Launcher.""" - - paths_str = os.environ.get("AVALON_ACTIONS") or "" - register_actions_from_paths(paths_str.split(os.pathsep)) +from openpype.pipeline import LauncherAction # TODO move to 'openpype.pipeline.actions' diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index d343353112..018088e916 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,6 +15,10 @@ from .widgets import ( IconButton, PixmapButton, SeparatorWidget, + VerticalExpandButton, + SquareButton, + RefreshButton, + GoToCurrentButton, ) from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox @@ -60,6 +64,11 @@ __all__ = ( "PixmapButton", "SeparatorWidget", + "VerticalExpandButton", + "SquareButton", + "RefreshButton", + "GoToCurrentButton", + "DeselectableTreeView", "ErrorMessageBox", diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index a70437cc65..9223afecaa 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -6,10 +6,13 @@ import qtawesome from openpype.style import ( get_objected_colors, - get_style_image_path + get_style_image_path, + get_default_tools_icon_color, ) from openpype.lib.attribute_definitions import AbstractAttrDef +from .lib import get_qta_icon_by_name_and_color + log = logging.getLogger(__name__) @@ -777,3 +780,77 @@ class SeparatorWidget(QtWidgets.QFrame): self._orientation = orientation self._set_size(self._size) + + +def get_refresh_icon(): + return get_qta_icon_by_name_and_color( + "fa.refresh", get_default_tools_icon_color() + ) + + +def get_go_to_current_icon(): + return get_qta_icon_by_name_and_color( + "fa.arrow-down", get_default_tools_icon_color() + ) + + +class VerticalExpandButton(QtWidgets.QPushButton): + """Button which is expanding vertically. + + By default, button is a little bit smaller than other widgets like + QLineEdit. This button is expanding vertically to match size of + other widgets, next to it. + """ + + def __init__(self, parent=None): + super(VerticalExpandButton, self).__init__(parent) + + sp = self.sizePolicy() + sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + self.setSizePolicy(sp) + + +class SquareButton(QtWidgets.QPushButton): + """Make button square shape. + + Change width to match height on resize. + """ + + def __init__(self, *args, **kwargs): + super(SquareButton, self).__init__(*args, **kwargs) + + sp = self.sizePolicy() + sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum) + self.setSizePolicy(sp) + self._ideal_width = None + + def showEvent(self, event): + super(SquareButton, self).showEvent(event) + self._ideal_width = self.height() + self.updateGeometry() + + def resizeEvent(self, event): + super(SquareButton, self).resizeEvent(event) + self._ideal_width = self.height() + self.updateGeometry() + + def sizeHint(self): + sh = super(SquareButton, self).sizeHint() + ideal_width = self._ideal_width + if ideal_width is None: + ideal_width = sh.height() + sh.setWidth(ideal_width) + return sh + + +class RefreshButton(VerticalExpandButton): + def __init__(self, parent=None): + super(RefreshButton, self).__init__(parent) + self.setIcon(get_refresh_icon()) + + +class GoToCurrentButton(VerticalExpandButton): + def __init__(self, parent=None): + super(GoToCurrentButton, self).__init__(parent) + self.setIcon(get_go_to_current_icon()) From bfb5868417f1fbf127b65bdf8cb214585a2312b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Sep 2023 14:18:57 +0200 Subject: [PATCH 90/90] AYON: Fix task type short name conversion (#5641) * fix task type short name conversion * workfiles tool can query project entity * use project entity to fill task template data --- openpype/client/server/conversion_utils.py | 2 ++ openpype/tools/ayon_workfiles/abstract.py | 10 ++++++++++ openpype/tools/ayon_workfiles/control.py | 3 +++ .../tools/ayon_workfiles/models/hierarchy.py | 11 ++++++++++ .../tools/ayon_workfiles/models/workfiles.py | 20 ++++++++++++++----- 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index f67a1ef9c4..8c18cb1c13 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -235,6 +235,8 @@ def convert_v4_project_to_v3(project): new_task_types = {} for task_type in task_types: name = task_type.pop("name") + # Change 'shortName' to 'short_name' + task_type["short_name"] = task_type.pop("shortName", None) new_task_types[name] = task_type config["tasks"] = new_task_types diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py index e30a2c2499..f511181837 100644 --- a/openpype/tools/ayon_workfiles/abstract.py +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -442,6 +442,16 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): pass + @abstractmethod + def get_project_entity(self): + """Get current project entity. + + Returns: + dict[str, Any]: Project entity data. + """ + + pass + @abstractmethod def get_folder_entity(self, folder_id): """Get folder entity by id. diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py index fc8819bff3..1153a3c01f 100644 --- a/openpype/tools/ayon_workfiles/control.py +++ b/openpype/tools/ayon_workfiles/control.py @@ -193,6 +193,9 @@ class BaseWorkfileController( self._project_anatomy = Anatomy(self.get_current_project_name()) return self._project_anatomy + def get_project_entity(self): + return self._entities_model.get_project_entity() + def get_folder_entity(self, folder_id): return self._entities_model.get_folder_entity(folder_id) diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py index 948c0b8a17..a1d51525da 100644 --- a/openpype/tools/ayon_workfiles/models/hierarchy.py +++ b/openpype/tools/ayon_workfiles/models/hierarchy.py @@ -77,8 +77,11 @@ class EntitiesModel(object): event_source = "entities.model" def __init__(self, controller): + project_cache = CacheItem() + project_cache.set_invalid({}) folders_cache = CacheItem() folders_cache.set_invalid({}) + self._project_cache = project_cache self._folders_cache = folders_cache self._tasks_cache = {} @@ -90,6 +93,7 @@ class EntitiesModel(object): self._controller = controller def reset(self): + self._project_cache.set_invalid({}) self._folders_cache.set_invalid({}) self._tasks_cache = {} @@ -99,6 +103,13 @@ class EntitiesModel(object): def refresh(self): self._refresh_folders_cache() + def get_project_entity(self): + if not self._project_cache.is_valid: + project_name = self._controller.get_current_project_name() + project_entity = ayon_api.get_project(project_name) + self._project_cache.update_data(project_entity) + return self._project_cache.get_data() + def get_folder_items(self, sender): if not self._folders_cache.is_valid: self._refresh_folders_cache(sender) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py index eb82f62de3..316d8b2a16 100644 --- a/openpype/tools/ayon_workfiles/models/workfiles.py +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -43,13 +43,21 @@ def get_folder_template_data(folder): } -def get_task_template_data(task): +def get_task_template_data(project_entity, task): if not task: return {} + short_name = None + task_type_name = task["taskType"] + for task_type_info in project_entity["config"]["taskTypes"]: + if task_type_info["name"] == task_type_name: + short_name = task_type_info["shortName"] + break + return { "task": { "name": task["name"], - "type": task["taskType"] + "type": task_type_name, + "short": short_name, } } @@ -145,12 +153,13 @@ class WorkareaModel: self._fill_data_by_folder_id[folder_id] = fill_data return copy.deepcopy(fill_data) - def _get_task_data(self, folder_id, task_id): + def _get_task_data(self, project_entity, folder_id, task_id): task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) if task_id not in task_data: task = self._controller.get_task_entity(task_id) if task: - task_data[task_id] = get_task_template_data(task) + task_data[task_id] = get_task_template_data( + project_entity, task) return copy.deepcopy(task_data[task_id]) def _prepare_fill_data(self, folder_id, task_id): @@ -159,7 +168,8 @@ class WorkareaModel: base_data = self._get_base_data() folder_data = self._get_folder_data(folder_id) - task_data = self._get_task_data(folder_id, task_id) + project_entity = self._controller.get_project_entity() + task_data = self._get_task_data(project_entity, folder_id, task_id) base_data.update(folder_data) base_data.update(task_data)