From a6e482484eb8c633caf26e93ebb0774ffe98eac7 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 26 May 2023 11:30:40 +0100 Subject: [PATCH 001/291] Allow for knob values to be validated against multiple values. --- .../plugins/publish/validate_write_nodes.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index aeecea655f..2a925fbeff 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -1,3 +1,5 @@ +from collections import defaultdict + import pyblish.api from openpype.pipeline.publish import get_errored_instances_from_context from openpype.hosts.nuke.api.lib import ( @@ -87,6 +89,11 @@ class ValidateNukeWriteNode( correct_data )) + # Collect key values of same type in a list. + values_by_name = defaultdict(list) + for knob_data in correct_data["knobs"]: + values_by_name[knob_data["name"]].append(knob_data["value"]) + for knob_data in correct_data["knobs"]: knob_type = knob_data["type"] self.log.debug("__ knob_type: {}".format( @@ -105,28 +112,33 @@ class ValidateNukeWriteNode( ) key = knob_data["name"] - value = knob_data["value"] + values = values_by_name[key] node_value = write_node[key].value() # fix type differences - if type(node_value) in (int, float): - try: - if isinstance(value, list): - value = color_gui_to_int(value) - else: - value = float(value) - node_value = float(node_value) - except ValueError: - value = str(value) - else: - value = str(value) - node_value = str(node_value) + fixed_values = [] + for value in values: + if type(node_value) in (int, float): + try: - self.log.debug("__ key: {} | value: {}".format( - key, value + if isinstance(value, list): + value = color_gui_to_int(value) + else: + value = float(value) + node_value = float(node_value) + except ValueError: + value = str(value) + else: + value = str(value) + node_value = str(node_value) + + fixed_values.append(value) + + self.log.debug("__ key: {} | values: {}".format( + key, fixed_values )) if ( - node_value != value + node_value not in fixed_values and key != "file" and key != "tile_color" ): From a216087608ea12c2920b6220102f0c77a8312702 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 26 Jun 2023 10:16:51 -0500 Subject: [PATCH 002/291] Fix typos --- openpype/hosts/nuke/plugins/publish/extract_camera.py | 4 ++-- openpype/hosts/nuke/plugins/publish/extract_model.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index 4286f71e83..33df6258ae 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -11,9 +11,9 @@ from openpype.hosts.nuke.api.lib import maintained_selection class ExtractCamera(publish.Extractor): - """ 3D camera exctractor + """ 3D camera extractor """ - label = 'Exctract Camera' + label = 'Extract Camera' order = pyblish.api.ExtractorOrder families = ["camera"] hosts = ["nuke"] diff --git a/openpype/hosts/nuke/plugins/publish/extract_model.py b/openpype/hosts/nuke/plugins/publish/extract_model.py index 814d404137..00462f8035 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_model.py +++ b/openpype/hosts/nuke/plugins/publish/extract_model.py @@ -11,9 +11,9 @@ from openpype.hosts.nuke.api.lib import ( class ExtractModel(publish.Extractor): - """ 3D model exctractor + """ 3D model extractor """ - label = 'Exctract Model' + label = 'Extract Model' order = pyblish.api.ExtractorOrder families = ["model"] hosts = ["nuke"] From b4ae58634d3697c7ccf72ba58fd93d5afa3af24e Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 26 Jun 2023 10:19:02 -0500 Subject: [PATCH 003/291] Nuke: Add support to submit existing frames to the farm --- openpype/hosts/nuke/api/plugin.py | 3 ++- .../nuke/plugins/publish/collect_writes.py | 21 ++++++++++++++++++- .../plugins/publish/submit_nuke_deadline.py | 2 +- .../plugins/publish/submit_publish_job.py | 16 +++++++++----- .../validate_expected_and_rendered_files.py | 4 ++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 7035da2bb5..b82ed0a1e3 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -311,6 +311,7 @@ class NukeWriteCreator(NukeCreator): } if ("farm_rendering" in self.instance_attributes): rendering_targets["farm"] = "Farm rendering" + rendering_targets["farm_frames"] = "Existing frames farm rendering" return EnumDef( "render_target", @@ -669,7 +670,7 @@ class ExporterReviewLut(ExporterReview): self.ext = ext or "cube" self.cube_size = cube_size or 32 self.lut_size = lut_size or 1024 - self.lut_style = lut_style or "linear" + self.lut_style = lut_style or "scene_linear" # set frame start / end and file name to self self.get_file_info() diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 2d1caacdc3..0b6fe392d8 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -29,6 +29,9 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, instance.data["families"].append( "{}.{}".format(family, render_target) ) + self.log.debug("Appending render target to families: {}.{}".format( + family, render_target) + ) if instance.data.get("review"): instance.data["families"].append("review") @@ -73,7 +76,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, self.log.debug('output dir: {}'.format(output_dir)) - if render_target == "frames": + if render_target in ["frames", "farm_frames"]: representation = { 'name': ext, 'ext': ext, @@ -142,6 +145,22 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, instance.data["representations"].append(representation) self.log.info("Publishing rendered frames ...") + if render_target == "farm_frames": + # Farm rendering + instance.data["toBeRenderedOn"] = "deadline" + instance.data["transfer"] = False + instance.data["farm"] = True # to skip integrate + self.log.info("Farm rendering ON ...") + + self.log.info( + "Adding collected files %s to expectedFiles instance.data", + collected_frames + ) + if "expectedFiles" not in instance.data: + instance.data["expectedFiles"] = list() + for source_file in collected_frames: + instance.data["expectedFiles"].append(os.path.join(output_dir, source_file)) + elif render_target == "farm": farm_keys = ["farm_chunk", "farm_priority", "farm_concurrency"] for key in farm_keys: diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 4900231783..34e335e442 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -32,7 +32,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, label = "Submit Nuke to Deadline" order = pyblish.api.IntegratorOrder + 0.1 hosts = ["nuke"] - families = ["render", "prerender"] + families = ["render.farm", "prerender.farm"] optional = True targets = ["local"] diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 69e9fb6449..677d322a19 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -17,6 +17,7 @@ from openpype.client import ( from openpype.pipeline import ( get_representation_path, legacy_io, + publish, ) from openpype.tests.lib import is_in_tests from openpype.pipeline.farm.patterning import match_aov_pattern @@ -123,7 +124,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, hosts = ["fusion", "max", "maya", "nuke", "houdini", "celaction", "aftereffects", "harmony"] - families = ["render.farm", "prerender.farm", + families = ["render.farm", "render.farm_frames", + "prerender.farm", "prerender.farm_frames", "renderlayer", "imagesequence", "vrayscene", "maxrender", "arnold_rop", "mantra_rop", @@ -334,7 +336,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, for assembly_id in instance.data["bakingSubmissionJobs"]: payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 job_index += 1 - else: + elif job.get("_id"): payload["JobInfo"]["JobDependency0"] = job["_id"] if instance.data.get("suspend_publish"): @@ -870,6 +872,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "multipartExr": data.get("multipartExr", False), "jobBatchName": data.get("jobBatchName", ""), "useSequenceForReview": data.get("useSequenceForReview", True), + "colorspace": data.get("colorspace"), # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))), "colorspace": instance.data.get("colorspace") @@ -883,6 +886,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, # transfer specific families from original instance to new render for item in self.families_transfer: if item in instance.data.get("families", []): + self.log.debug("Transfering '%s' family to instance.", item) instance_skeleton_data["families"] += [item] # transfer specific properties from original instance based on @@ -890,6 +894,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, for key, values in self.instance_transfer.items(): if key in instance.data.get("families", []): for v in values: + self.log.debug("Transfering '%s' property to instance.", v) instance_skeleton_data[v] = instance.data.get(v) # look into instance data if representations are not having any @@ -912,7 +917,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, repre["stagingDir"] = staging_dir if "publish_on_farm" in repre.get("tags"): - # create representations attribute of not there + # create representations attribute if not there if "representations" not in instance_skeleton_data.keys(): instance_skeleton_data["representations"] = [] @@ -1095,6 +1100,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER": os.environ.get("FTRACK_SERVER"), } + deadline_publish_job_id = None if submission_type == "deadline": # get default deadline webservice url from deadline module self.deadline_url = instance.context.data["defaultDeadline"] @@ -1118,7 +1124,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "fps": context.data.get("fps", None), "source": source, "user": context.data["user"], - "version": context.data["version"], # this is workfile version + "version": context.data.get("version"), # this is workfile version "intent": context.data.get("intent"), "comment": context.data.get("comment"), "job": render_job or None, @@ -1151,7 +1157,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, json.dump(publish_job, f, indent=4, sort_keys=True) def _extend_frames(self, asset, subset, start, end): - """Get latest version of asset nad update frame range. + """Get latest version of asset and update frame range. Based on minimum and maximuma values. diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index ff4be677e7..6fbaa0d7b5 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -21,6 +21,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): def process(self, instance): self.instance = instance + # TODO: Find a better way to check whether a job has been submitted with + # existing frames + if not instance.data["render_job_id"]: + return frame_list = self._get_frame_list(instance.data["render_job_id"]) for repre in instance.data["representations"]: From 12ea12c2dedb232bae3b134142e124bc45605837 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 26 Jun 2023 10:46:21 -0500 Subject: [PATCH 004/291] Shush the hound --- openpype/hosts/nuke/plugins/publish/collect_writes.py | 6 ++++-- .../modules/deadline/plugins/publish/submit_publish_job.py | 3 --- .../plugins/publish/validate_expected_and_rendered_files.py | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 0b6fe392d8..83ad90135f 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -149,7 +149,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, # Farm rendering instance.data["toBeRenderedOn"] = "deadline" instance.data["transfer"] = False - instance.data["farm"] = True # to skip integrate + instance.data["farm"] = True # to skip integrate self.log.info("Farm rendering ON ...") self.log.info( @@ -159,7 +159,9 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() for source_file in collected_frames: - instance.data["expectedFiles"].append(os.path.join(output_dir, source_file)) + instance.data["expectedFiles"].append( + os.path.join(output_dir, source_file) + ) elif render_target == "farm": farm_keys = ["farm_chunk", "farm_priority", "farm_concurrency"] diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 677d322a19..b271f849ba 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -872,7 +872,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "multipartExr": data.get("multipartExr", False), "jobBatchName": data.get("jobBatchName", ""), "useSequenceForReview": data.get("useSequenceForReview", True), - "colorspace": data.get("colorspace"), # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))), "colorspace": instance.data.get("colorspace") @@ -886,7 +885,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, # transfer specific families from original instance to new render for item in self.families_transfer: if item in instance.data.get("families", []): - self.log.debug("Transfering '%s' family to instance.", item) instance_skeleton_data["families"] += [item] # transfer specific properties from original instance based on @@ -894,7 +892,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, for key, values in self.instance_transfer.items(): if key in instance.data.get("families", []): for v in values: - self.log.debug("Transfering '%s' property to instance.", v) instance_skeleton_data[v] = instance.data.get(v) # look into instance data if representations are not having any diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 6fbaa0d7b5..198db7872d 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -21,8 +21,8 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): def process(self, instance): self.instance = instance - # TODO: Find a better way to check whether a job has been submitted with - # existing frames + # TODO: Find a better way to check whether a job has been submitted + # with existing frames if not instance.data["render_job_id"]: return frame_list = self._get_frame_list(instance.data["render_job_id"]) From 5c0e02f889da4792a80edb745d5e7a7608f9eca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 3 Jul 2023 20:57:33 +0200 Subject: [PATCH 005/291] Reverting colorspace fallback --- openpype/hosts/nuke/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index b82ed0a1e3..be5230d1c1 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -670,7 +670,7 @@ class ExporterReviewLut(ExporterReview): self.ext = ext or "cube" self.cube_size = cube_size or 32 self.lut_size = lut_size or 1024 - self.lut_style = lut_style or "scene_linear" + self.lut_style = lut_style or "linear" # set frame start / end and file name to self self.get_file_info() From 494e662582011ab5e62fc5e814d48a62ef3747f0 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 13 Jul 2023 15:13:53 +0800 Subject: [PATCH 006/291] add optional viewport camera validator for validation of renderable camera --- .../publish/validate_no_max_content.py | 1 - .../publish/validate_viewport_camera.py | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_viewport_camera.py diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py index c6a27dace3..73e12e75c9 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -13,7 +13,6 @@ class ValidateMaxContents(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["camera", "maxScene", - "maxrender", "review"] hosts = ["max"] label = "Max Scene Contents" diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py new file mode 100644 index 0000000000..a52b7e0212 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin) +from openpype.pipeline.publish import RepairAction + +from pymxs import runtime as rt + + +class ValidateViewportCamera(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Viewport Camera + + Check if the renderable camera in scene used as viewport + camera for rendering + """ + + order = pyblish.api.ValidatorOrder + families = ["maxrender"] + hosts = ["max"] + label = "Viewport Camera" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + cameras_in_scene = [c for c in rt.Objects + if rt.classOf(c) in rt.Camera.Classes] + if rt.viewport.getCamera() not in cameras_in_scene: + raise PublishValidationError( + "Cameras in Scene not used as viewport camera" + ) + + @classmethod + def repair(cls, instance): + # Get all cameras in the scene + cameras_in_scene = [c for c in rt.Objects + if rt.classOf(c) in rt.Camera.Classes] + # Set the first camera as viewport camera for rendering + if cameras_in_scene: + rt.viewport.setCamera(cameras_in_scene[0]) \ No newline at end of file From ceaffbbfa92f50d2da009b3be30c5df94d015eed Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 13 Jul 2023 15:26:32 +0800 Subject: [PATCH 007/291] add log for repair action --- .../hosts/max/plugins/publish/validate_viewport_camera.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py index a52b7e0212..d5cf85eb69 100644 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -36,8 +36,10 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): # Get all cameras in the scene - cameras_in_scene = [c for c in rt.Objects + cameras_in_scene = [c.name for c in rt.Objects if rt.classOf(c) in rt.Camera.Classes] # Set the first camera as viewport camera for rendering if cameras_in_scene: - rt.viewport.setCamera(cameras_in_scene[0]) \ No newline at end of file + camera = rt.getNodeByName(cameras_in_scene[0]) + rt.viewport.setCamera(camera) + cls.log.info(f"Camera {camera} set as viewport camera") From 54e2687afa13b4963b86ecbc4b99786e1a57ced2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 18 Jul 2023 10:21:21 +0300 Subject: [PATCH 008/291] create review validator --- .../publish/validate_review_colorspace.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py new file mode 100644 index 0000000000..18ca39234c --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +import pyblish.api + +from collections import defaultdict +from openpype.pipeline import PublishValidationError + + +class ValidateReviewColorspace(pyblish.api.InstancePlugin): + """Validate Review Colorspace parameters. + + + """ + + order = pyblish.api.ValidatorOrder + 0.1 + families = ["review"] + hosts = ["houdini"] + label = "Validate Review Colorspace" + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + ("Colorspace parameter is not valid."), + title=self.label + ) + + @classmethod + def get_invalid(cls, instance): + import hou # noqa + 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.path()] + + pattern = rop_node.parm("prim_to_detail_pattern").eval().strip() + if not pattern: + cls.log.debug( + "Alembic ROP has no 'Primitive to Detail' pattern. " + "Validation is ignored.." + ) + return + + build_from_path = rop_node.parm("build_from_path").eval() + if not build_from_path: + cls.log.debug( + "Alembic ROP has 'Build from Path' disabled. " + "Validation is ignored.." + ) + return + + path_attr = rop_node.parm("path_attrib").eval() + if not path_attr: + cls.log.error( + "The Alembic ROP node has no Path Attribute" + "value set, but 'Build Hierarchy from Attribute'" + "is enabled." + ) + return [rop_node.path()] + + # Let's assume each attribute is explicitly named for now and has no + # wildcards for Primitive to Detail. This simplifies the check. + cls.log.debug("Checking Primitive to Detail pattern: %s" % pattern) + cls.log.debug("Checking with path 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 start frame then it might be + # something that is emitted over time. As such we can't actually + # validate whether the attributes exist, because they won't exist + # yet. In that case, just warn the user and allow it. + if len(geo.iterPrims()) == 0: + cls.log.warning( + "No primitives found on current frame. Validation" + " for Primitive to Detail will be skipped." + ) + return + + attrib = geo.findPrimAttrib(path_attr) + if not attrib: + cls.log.info( + "Geometry Primitives are missing " + "path attribute: `%s`" % path_attr + ) + return [output_node.path()] + + # 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.path()] + + paths = None + for attr in pattern.split(" "): + if not attr.strip(): + # Ignore empty values + continue + + # Check if the primitive attribute exists + attrib = geo.findPrimAttrib(attr) + if not attrib: + # It is allowed to not have the attribute at all + continue + + # The issue can only happen if at least one string attribute is + # present. So we ignore cases with no values whatsoever. + if not attrib.strings(): + continue + + check = defaultdict(set) + values = geo.primStringAttribValues(attr) + if paths is None: + paths = geo.primStringAttribValues(path_attr) + + for path, value in zip(paths, values): + check[path].add(value) + + for path, values in check.items(): + # Whenever a single path has multiple values for the + # Primitive to Detail attribute then we consider it + # inconsistent and invalidate the ROP node's content. + if len(values) > 1: + cls.log.warning( + "Path has multiple values: %s (path: %s)" + % (list(values), path) + ) + return [output_node.path()] From 94ec68ab21182bbb01258a9e73ccb01b10a5a1e8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 00:13:51 +0300 Subject: [PATCH 009/291] update review validator --- .../publish/validate_review_colorspace.py | 153 ++++++------------ 1 file changed, 49 insertions(+), 104 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 18ca39234c..47c1e886d1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -1,143 +1,88 @@ # -*- coding: utf-8 -*- import pyblish.api - -from collections import defaultdict from openpype.pipeline import PublishValidationError +from openpype.pipeline.publish import RepairAction +from openpype.hosts.houdini.api.action import SelectROPAction + + +class SetDefaultViewSpaceAction(RepairAction): + label = "Set default view space" + icon = "mdi.monitor" class ValidateReviewColorspace(pyblish.api.InstancePlugin): """Validate Review Colorspace parameters. - + It checks if 'OCIO Colorspace' parameter was set to valid value. """ order = pyblish.api.ValidatorOrder + 0.1 families = ["review"] hosts = ["houdini"] label = "Validate Review Colorspace" + actions = [SetDefaultViewSpaceAction, SelectROPAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( - ("Colorspace parameter is not valid."), + ("'OCIO Colorspace' parameter is not valid."), title=self.label ) @classmethod def get_invalid(cls, instance): import hou # noqa - 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.path()] - - pattern = rop_node.parm("prim_to_detail_pattern").eval().strip() - if not pattern: - cls.log.debug( - "Alembic ROP has no 'Primitive to Detail' pattern. " - "Validation is ignored.." - ) - return - - build_from_path = rop_node.parm("build_from_path").eval() - if not build_from_path: - cls.log.debug( - "Alembic ROP has 'Build from Path' disabled. " - "Validation is ignored.." - ) - return - - path_attr = rop_node.parm("path_attrib").eval() - if not path_attr: - cls.log.error( - "The Alembic ROP node has no Path Attribute" - "value set, but 'Build Hierarchy from Attribute'" - "is enabled." - ) - return [rop_node.path()] - - # Let's assume each attribute is explicitly named for now and has no - # wildcards for Primitive to Detail. This simplifies the check. - cls.log.debug("Checking Primitive to Detail pattern: %s" % pattern) - cls.log.debug("Checking with path 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 start frame then it might be - # something that is emitted over time. As such we can't actually - # validate whether the attributes exist, because they won't exist - # yet. In that case, just warn the user and allow it. - if len(geo.iterPrims()) == 0: + if hou.Color.ocio_defaultDisplay() == "default": cls.log.warning( - "No primitives found on current frame. Validation" - " for Primitive to Detail will be skipped." + "Default Houdini colorspace is used, " + " skipping check.." ) return - attrib = geo.findPrimAttrib(path_attr) - if not attrib: - cls.log.info( - "Geometry Primitives are missing " - "path attribute: `%s`" % path_attr + if rop_node.evalParm("colorcorrect") != 2: + # any colorspace settings other than default requires + # 'Color Correct' parm to be set to 'OpenColorIO' + rop_node.setParms({"colorcorrect": 2}) + cls.log.debug( + "'Color Correct' parm on '%s' has been set to" + " 'OpenColorIO'", rop_node ) - return [output_node.path()] - # 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 + if rop_node.evalParm("ociocolorspace") not in \ + hou.Color.ocio_spaces(): + + cls.log.error( + "'OCIO Colorspace' value on '%s' is not valid, " + "select a valid option from the dropdown menu.", + rop_node ) - return [output_node.path()] + return rop_node - paths = None - for attr in pattern.split(" "): - if not attr.strip(): - # Ignore empty values - continue + @classmethod + def repair(cls, instance): + """Set Default View Space Action. - # Check if the primitive attribute exists - attrib = geo.findPrimAttrib(attr) - if not attrib: - # It is allowed to not have the attribute at all - continue + It is a helper action more than a repair action, + used to set colorspace on opengl node to the default view. + """ - # The issue can only happen if at least one string attribute is - # present. So we ignore cases with no values whatsoever. - if not attrib.strings(): - continue + import hou + import PyOpenColorIO as OCIO - check = defaultdict(set) - values = geo.primStringAttribValues(attr) - if paths is None: - paths = geo.primStringAttribValues(path_attr) + rop_node = hou.node(instance.data["instance_node"]) - for path, value in zip(paths, values): - check[path].add(value) + config = OCIO.GetCurrentConfig() + display = hou.Color.ocio_defaultDisplay() + view = hou.Color.ocio_defaultView() - for path, values in check.items(): - # Whenever a single path has multiple values for the - # Primitive to Detail attribute then we consider it - # inconsistent and invalidate the ROP node's content. - if len(values) > 1: - cls.log.warning( - "Path has multiple values: %s (path: %s)" - % (list(values), path) - ) - return [output_node.path()] + default_view_space = config.getDisplayColorSpaceName( + display, view) + + rop_node.setParms({"ociocolorspace" : default_view_space}) + cls.log.debug( + "'OCIO Colorspace' parm on '%s' has been set to '%s'", + default_view_space, rop_node + ) From 4d79320cf4f1abdd05dc3ce6fc46d6ccb30b2aca Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 00:50:43 +0300 Subject: [PATCH 010/291] make hound happy --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 47c1e886d1..02284dc641 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -81,7 +81,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): default_view_space = config.getDisplayColorSpaceName( display, view) - rop_node.setParms({"ociocolorspace" : default_view_space}) + rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( "'OCIO Colorspace' parm on '%s' has been set to '%s'", default_view_space, rop_node From f4a5858edbed465f11908d18697b5dc0c6414b76 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 19:43:39 +0300 Subject: [PATCH 011/291] update validator --- .../houdini/plugins/publish/validate_review_colorspace.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 02284dc641..8f3799cde4 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -33,9 +33,10 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): import hou # noqa + import os rop_node = hou.node(instance.data["instance_node"]) - if hou.Color.ocio_defaultDisplay() == "default": + if os.getenv("OCIO") is None: cls.log.warning( "Default Houdini colorspace is used, " " skipping check.." @@ -78,8 +79,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): display = hou.Color.ocio_defaultDisplay() view = hou.Color.ocio_defaultView() - default_view_space = config.getDisplayColorSpaceName( - display, view) + default_view_space = config.getDisplayViewColorSpaceName( + display, view) # works with PyOpenColorIO 2.2.1 rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( From 00d4afd1178d4ee95ee8d09a4a8c8348f3948606 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 19 Jul 2023 19:44:45 +0300 Subject: [PATCH 012/291] make hound happy --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 8f3799cde4..addfa05bf1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -80,7 +80,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): view = hou.Color.ocio_defaultView() default_view_space = config.getDisplayViewColorSpaceName( - display, view) # works with PyOpenColorIO 2.2.1 + display, view) # works with PyOpenColorIO 2.2.1 rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( From d90d45c56df70e876d02acbf9d4e09aa1b6f5746 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 20 Jul 2023 13:38:09 +0300 Subject: [PATCH 013/291] move get function to colorspace.py --- .../publish/validate_review_colorspace.py | 19 +++++--- openpype/pipeline/colorspace.py | 36 ++++++++++++++++ openpype/scripts/ocio_wrapper.py | 43 +++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index addfa05bf1..cfc5a5d71d 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -71,19 +71,24 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): """ import hou - import PyOpenColorIO as OCIO + from openpype.pipeline.colorspace import get_display_view_colorspace_name + from openpype.hosts.houdini.api.lib import get_color_management_preferences #noqa rop_node = hou.node(instance.data["instance_node"]) - config = OCIO.GetCurrentConfig() - display = hou.Color.ocio_defaultDisplay() - view = hou.Color.ocio_defaultView() + data = get_color_management_preferences() + config_path = data.get("config") + display = data.get("display") + view = data.get("view") - default_view_space = config.getDisplayViewColorSpaceName( - display, view) # works with PyOpenColorIO 2.2.1 + cls.log.debug("Get default view colorspace name..") + + default_view_space = get_display_view_colorspace_name(config_path, + display, view) rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( - "'OCIO Colorspace' parm on '%s' has been set to '%s'", + "'OCIO Colorspace' parm on '%s' has been set to " + "the default view color space '%s'", default_view_space, rop_node ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3f2d4891c1..a1d86b2fec 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -589,3 +589,39 @@ def _get_imageio_settings(project_settings, host_name): imageio_host = project_settings.get(host_name, {}).get("imageio", {}) return imageio_global, imageio_host + +def get_display_view_colorspace_name(config_path, display, view): + + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess + return get_display_view_colorspace_subprocess(config_path, + display, view) + + from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name #noqa + + return _get_display_view_colorspace_name(config_path, display, view) + +def get_display_view_colorspace_subprocess(config_path, display, view): + with _make_temp_json_file() as tmp_json_path: + # Prepare subprocess arguments + args = [ + "run", get_ocio_config_script_path(), + "config", "get_display_view_colorspace_name", + "--in_path", config_path, + "--out_path", tmp_json_path, + "--display", display, + "--view", view + + ] + log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": log + } + + run_openpype_process(*args, **process_kwargs) + + # return all colorspaces + return_json_data = open(tmp_json_path).read() + return json.loads(return_json_data) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 16558642c6..ca703fd65c 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -173,6 +173,49 @@ def _get_views_data(config_path): return data +def _get_display_view_colorspace_name(config_path, display, view): + config_path = Path(config_path) + + if not config_path.is_file(): + raise IOError("Input path should be `config.ocio` file") + + config = ocio.Config().CreateFromFile(str(config_path)) + colorspace = config.getDisplayViewColorSpaceName(display, view) + + return colorspace + +@config.command( + name="get_display_view_colorspace_name", + help=( + "return default view colorspace name " + "for the given display and view " + "--path input arg is required" + ) +) +@click.option("--in_path", required=True, + help="path where to read ocio config file", + type=click.Path(exists=True)) +@click.option("--out_path", required=True, + help="path where to write output json file", + type=click.Path()) +@click.option("--display", required=True, + help="display", + type=click.STRING) +@click.option("--view", required=True, + help="view", + type=click.STRING) +def get_display_view_colorspace_name(in_path, out_path, + display, view): + + json_path = Path(out_path) + + out_data = _get_display_view_colorspace_name(in_path, + display, view) + + with open(json_path, "w") as f: + json.dump(out_data, f) + + print(f"Viewer data are saved to '{json_path}'") if __name__ == '__main__': main() From 2bb11d956ca2ec629f61d5d14e0b2ef4130a0363 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 20 Jul 2023 13:54:46 +0300 Subject: [PATCH 014/291] add doc strings --- .../publish/validate_review_colorspace.py | 2 +- openpype/pipeline/colorspace.py | 22 ++++++++++++ openpype/scripts/ocio_wrapper.py | 34 +++++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index cfc5a5d71d..67e29e0ee2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -71,7 +71,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): """ import hou - from openpype.pipeline.colorspace import get_display_view_colorspace_name + from openpype.pipeline.colorspace import get_display_view_colorspace_name #noqa from openpype.hosts.houdini.api.lib import get_color_management_preferences #noqa rop_node = hou.node(instance.data["instance_node"]) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a1d86b2fec..22e8175a7e 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -591,6 +591,16 @@ def _get_imageio_settings(project_settings, host_name): return imageio_global, imageio_host def get_display_view_colorspace_name(config_path, display, view): + """get view colorspace name for the given display and view. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ if not compatibility_check(): # python environment is not compatible with PyOpenColorIO @@ -603,6 +613,18 @@ def get_display_view_colorspace_name(config_path, display, view): return _get_display_view_colorspace_name(config_path, display, view) def get_display_view_colorspace_subprocess(config_path, display, view): + """get view colorspace name for the given display and view + via subprocess. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + with _make_temp_json_file() as tmp_json_path: # Prepare subprocess arguments args = [ diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index ca703fd65c..f94faabe11 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -174,6 +174,21 @@ def _get_views_data(config_path): return data def _get_display_view_colorspace_name(config_path, display, view): + """get view colorspace name for the given display and view. + + Args: + config_path (str): path string leading to config.ocio + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + + Raises: + IOError: Input config does not exist. + + Returns: + view color space name (str) e.g. "Output - sRGB" + """ + config_path = Path(config_path) if not config_path.is_file(): @@ -199,13 +214,28 @@ def _get_display_view_colorspace_name(config_path, display, view): help="path where to write output json file", type=click.Path()) @click.option("--display", required=True, - help="display", + help="display name", type=click.STRING) @click.option("--view", required=True, - help="view", + help="view name", type=click.STRING) def get_display_view_colorspace_name(in_path, out_path, display, view): + """Aggregate view colorspace name to file. + + Python 2 wrapped console command + + Args: + in_path (str): config file path string + out_path (str): temp json file path string + display (str): display name e.g. "ACES" + view (str): view name e.g. "sRGB" + + Example of use: + > pyton.exe ./ocio_wrapper.py config \ + get_display_view_colorspace_name --in_path= \ + --out_path= --display= --view= + """ json_path = Path(out_path) From c264a6abf1a2c6605b9d7d51d2e2cdb6c0d9a63b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Mon, 24 Jul 2023 08:14:57 +0100 Subject: [PATCH 015/291] Disable hardlinking. --- .../hosts/maya/plugins/publish/extract_look.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index e2c88ef44a..50a6db3bf7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -6,7 +6,6 @@ import contextlib import json import logging import os -import platform import tempfile import six import attr @@ -585,14 +584,12 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary fix to NOT create hardlinks on windows machines - if platform.system().lower() == "windows": - self.log.info( - "Forcing copy instead of hardlink due to issues on Windows..." - ) - force_copy = True - else: - force_copy = instance.data.get("forceCopy", False) + # Temporary disable all hardlinking, due to the feature not being used + # or properly working. + self.log.info( + "Forcing copy instead of hardlink." + ) + force_copy = True destinations_cache = {} From 1db5d5f838b78a4e807385ab5a202cd13134f1d5 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 28 Jul 2023 08:27:37 +0100 Subject: [PATCH 016/291] Ensure legacy_io_distinct is not used in AYON --- openpype/hosts/maya/plugins/publish/extract_look.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 50a6db3bf7..ec01a817b3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -18,6 +18,7 @@ from openpype.lib.vendor_bin_utils import find_executable from openpype.lib import source_hash, run_subprocess, get_oiio_tools_path from openpype.pipeline import legacy_io, publish, KnownPublishError from openpype.hosts.maya.api import lib +from openpype import AYON_SERVER_ENABLED # Modes for transfer COPY = 1 @@ -50,6 +51,12 @@ def find_paths_by_hash(texture_hash): str: path to texture if found. """ + if AYON_SERVER_ENABLED: + raise ValueError( + "This is a bug. \"find_paths_by_hash\" is not compatible with " + "AYON." + ) + key = "data.sourceHashes.{0}".format(texture_hash) return legacy_io.distinct(key, {"type": "version"}) From 1a6e7aa041cfdaffbae58ddfada7e03c753b89b5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 1 Aug 2023 22:05:16 +0800 Subject: [PATCH 017/291] adding OpenpypeData parameter as custom attributes back to the loaded objects --- openpype/hosts/max/api/pipeline.py | 25 ++++++++++++++++++ .../hosts/max/plugins/load/load_camera_fbx.py | 26 ++++++++----------- .../hosts/max/plugins/load/load_max_scene.py | 16 +++++++----- openpype/hosts/max/plugins/load/load_model.py | 18 +++++++++---- .../hosts/max/plugins/load/load_model_fbx.py | 9 ++++--- .../hosts/max/plugins/load/load_model_obj.py | 12 +++++---- .../hosts/max/plugins/load/load_model_usd.py | 11 +++++--- .../hosts/max/plugins/load/load_pointcache.py | 15 ++++++++--- .../hosts/max/plugins/load/load_pointcloud.py | 15 ++++++----- .../max/plugins/load/load_redshift_proxy.py | 5 ++-- 10 files changed, 101 insertions(+), 51 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 03b85a4066..82470dd510 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -15,8 +15,10 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.menu import OpenPypeMenu from openpype.hosts.max.api import lib +from openpype.hosts.max.api.plugin import MS_CUSTOM_ATTRIB from openpype.hosts.max import MAX_HOST_DIR + from pymxs import runtime as rt # noqa log = logging.getLogger("openpype.hosts.max") @@ -170,3 +172,26 @@ def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container + + +def load_OpenpypeData(container, loaded_nodes): + """Function to load the OpenpypeData Parameter along with + the published objects + + Args: + container (str): target container to set up + the custom attributes + loaded_nodes (list): list of nodes to be loaded + """ + attrs = rt.Execute(MS_CUSTOM_ATTRIB) + if rt.custAttributes.get(container.baseObject, attrs): + rt.custAttributes.delete(container.baseObject, attrs) + rt.custAttributes.add(container.baseObject, attrs) + node_list = [] + for i in loaded_nodes: + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + + # Setting the property + rt.setProperty( + container.openPypeData, "all_handles", node_list) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 62284b23d9..6b16bfe474 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -32,8 +32,9 @@ class FbxLoader(load.LoaderPlugin): if not container: container = rt.Container() container.name = f"{name}" - - for selection in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + load_OpenpypeData(container, selections) + for selection in selections: selection.Parent = container return containerise( @@ -45,18 +46,13 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) rt.Select(node.Children) - fbx_reimport_cmd = ( - f""" - -FBXImporterSetParam "Animation" true -FBXImporterSetParam "Cameras" true -FBXImporterSetParam "AxisConversionMethod" true -FbxExporterSetParam "UpAxis" "Y" -FbxExporterSetParam "Preserveinstances" true - -importFile @"{path}" #noPrompt using:FBXIMP - """) - rt.Execute(fbx_reimport_cmd) + rt.FBXImporterSetParam("Animation", True) + rt.FBXImporterSetParam("Camera", True) + rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Preserveinstances", True) + rt.ImportFile( + path, rt.name("noPrompt"), using=rt.FBXIMP) + load_OpenpypeData(node, node.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..468461bc0e 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,8 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData + from openpype.pipeline import get_representation_path, load @@ -27,6 +28,7 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") + load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -39,16 +41,16 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) + rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() container_node = rt.GetNodeByName(node_name) + instance_name, _ = os.path.splitext(node_name) + instance_container = rt.GetNodeByName(instance_name) for max_object in max_objects: - max_object.Parent = container_node - + max_object.Parent = instance_container + instance_container.Parent = container_node + load_OpenpypeData(container_node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index cff82a593c..810fc65968 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,6 +1,6 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -45,7 +45,10 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - + selections = rt.GetCurrentSelection() + abc_selections = [abc for abc in selections + if abc.name != "Alembic"] + load_OpenpypeData(abc_container, abc_selections) return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -57,6 +60,10 @@ class ModelAbcLoader(load.LoaderPlugin): node = rt.GetNodeByName(container["instance_node"]) rt.Select(node.Children) + nodes_list = [] + with maintained_selection(): + rt.Select(node) + for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) @@ -67,9 +74,10 @@ class ModelAbcLoader(load.LoaderPlugin): for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path - - with maintained_selection(): - rt.Select(node) + nodes_list.append(alembic_obj) + abc_selections = [abc for abc in nodes_list + if abc.name != "Alembic"] + load_OpenpypeData(node, abc_selections) lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 12f526ab95..8f2b4f4ac3 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,6 +1,6 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -28,7 +28,10 @@ class FbxModelLoader(load.LoaderPlugin): container = rt.Container() container.name = name - for selection in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + load_OpenpypeData(container, selections) + + for selection in selections: selection.Parent = container return containerise( @@ -47,7 +50,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - + load_OpenpypeData(container, node.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 18a19414fa..83b5ec49b9 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,7 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -25,9 +25,10 @@ class ObjLoader(load.LoaderPlugin): # create "missing" container for obj import container = rt.Container() container.name = name - + selections = rt.GetCurrentSelection() + load_OpenpypeData(container, selections) # get current selection - for selection in rt.GetCurrentSelection(): + for selection in selections: selection.Parent = container asset = rt.GetNodeByName(name) @@ -49,9 +50,10 @@ class ObjLoader(load.LoaderPlugin): rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection - for selection in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + for selection in selections: selection.Parent = container - + load_OpenpypeData(container, container.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 48b50b9b18..a1961e6d89 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -2,7 +2,7 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -30,8 +30,10 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - + selections = rt.GetCurrentSelection() asset = rt.GetNodeByName(name) + mesh_selections = [r for r in selections if r != asset] + load_OpenpypeData(asset, mesh_selections) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -55,11 +57,12 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogPath = log_filepath rt.LogLevel = rt.Name("info") - rt.USDImporter.importFile(path, - importOptions=import_options) + rt.USDImporter.importFile( + path, importOptions=import_options) asset = rt.GetNodeByName(instance_name) asset.Parent = node + load_OpenpypeData(asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 290503e053..026938feff 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,7 +7,7 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData class AbcLoader(load.LoaderPlugin): @@ -48,11 +48,15 @@ class AbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - - for abc in rt.GetCurrentSelection(): + selections = rt.GetCurrentSelection() + abc_selections = [abc for abc in selections + if abc.name != "Alembic"] + load_OpenpypeData(abc_container, abc_selections) + for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 + return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -71,7 +75,7 @@ class AbcLoader(load.LoaderPlugin): container["instance_node"], {"representation": str(representation["_id"])}, ) - + nodes_list = [] with maintained_selection(): rt.Select(node.Children) @@ -85,6 +89,9 @@ class AbcLoader(load.LoaderPlugin): for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path + nodes_list.append(alembic_obj) + abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] + load_OpenpypeData(node, abc_selections) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2a1175167a..18998f4529 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -21,8 +21,11 @@ class PointCloudLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.GetNodeByName(obj.name) + prt_container = rt.container() + prt_container.name = name + obj.Parent = prt_container + load_OpenpypeData(prt_container, [obj]) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -38,10 +41,10 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + load_OpenpypeData(node, node.Children) + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 31692f6367..b62400d2e5 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,7 +5,7 @@ from openpype.pipeline import ( load, get_representation_path ) -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.hosts.max.api import lib @@ -33,7 +33,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - + load_OpenpypeData(container, [rs_proxy]) asset = rt.getNodeByName(name) return containerise( @@ -49,6 +49,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path + load_OpenpypeData(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 9d1b8c6af9e721ae3703f8b11ce2cb2c3fe3c4f4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 3 Aug 2023 19:34:36 +0800 Subject: [PATCH 018/291] use alembic object to store the OP parameters --- openpype/hosts/max/plugins/load/load_model.py | 4 +++- openpype/hosts/max/plugins/load/load_pointcache.py | 8 +++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index 810fc65968..efd758063d 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -61,6 +61,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node.Children) nodes_list = [] + abc_object = None with maintained_selection(): rt.Select(node) @@ -69,6 +70,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) + abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: @@ -77,7 +79,7 @@ class ModelAbcLoader(load.LoaderPlugin): nodes_list.append(alembic_obj) abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] - load_OpenpypeData(node, abc_selections) + load_OpenpypeData(abc_object, abc_selections) lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 026938feff..7af588566e 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -67,15 +67,12 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - alembic_objects = self.get_container_children(node, "AlembicObject") - for alembic_object in alembic_objects: - alembic_object.source = path - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, ) nodes_list = [] + abc_object = None with maintained_selection(): rt.Select(node.Children) @@ -84,6 +81,7 @@ class AbcLoader(load.LoaderPlugin): rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) + abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: @@ -91,7 +89,7 @@ class AbcLoader(load.LoaderPlugin): alembic_obj.source = path nodes_list.append(alembic_obj) abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] - load_OpenpypeData(node, abc_selections) + load_OpenpypeData(abc_object, abc_selections) def switch(self, container, representation): self.update(container, representation) From 697c126ccee9daa204feb2dfb01b433f24ba8064 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 11:29:28 +0200 Subject: [PATCH 019/291] refactor expected files validator --- .../validate_expected_and_rendered_files.py | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index ff4be677e7..9f1f7bc518 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -20,8 +20,19 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): allow_user_override = True def process(self, instance): - self.instance = instance - frame_list = self._get_frame_list(instance.data["render_job_id"]) + """Process all the nodes in the instance""" + + # get dependency jobs ids for retrieving frame list + dependent_job_ids = self._get_dependent_job_ids(instance) + + if not dependent_job_ids: + self.log.warning("No dependent jobs found for instance: {}" + "".format(instance)) + return + + # get list of frames from dependent jobs + frame_list = self._get_dependent_jobs_frames( + instance, dependent_job_ids) for repre in instance.data["representations"]: expected_files = self._get_expected_files(repre) @@ -78,26 +89,45 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): ) ) - def _get_frame_list(self, original_job_id): + def _get_dependent_job_ids(self, instance): + """Returns list of dependent job ids from instance metadata.json + + Args: + instance (pyblish.api.Instance): pyblish instance + + Returns: + (list): list of dependent job ids + + """ + dependent_job_ids = [] + + # job_id collected from metadata.json + original_job_id = instance.data["render_job_id"] + + dependent_job_ids_env = os.environ.get("RENDER_JOB_IDS") + if dependent_job_ids_env: + dependent_job_ids = dependent_job_ids_env.split(',') + elif original_job_id: + dependent_job_ids = [original_job_id] + + return dependent_job_ids + + def _get_dependent_jobs_frames(self, instance, dependent_job_ids): """Returns list of frame ranges from all render job. Render job might be re-submitted so job_id in metadata.json could be invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS. Args: - original_job_id (str) + instance (pyblish.api.Instance): pyblish instance + dependent_job_ids (list): list of dependent job ids Returns: (list) """ all_frame_lists = [] - render_job_ids = os.environ.get("RENDER_JOB_IDS") - if render_job_ids: - render_job_ids = render_job_ids.split(',') - else: # fallback - render_job_ids = [original_job_id] - for job_id in render_job_ids: - job_info = self._get_job_info(job_id) + for job_id in dependent_job_ids: + job_info = self._get_job_info(instance, job_id) frame_list = job_info["Props"].get("Frames") if frame_list: all_frame_lists.extend(frame_list.split(',')) @@ -152,18 +182,25 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return file_name_template, frame_placeholder - def _get_job_info(self, job_id): + def _get_job_info(self, instance, job_id): """Calls DL for actual job info for 'job_id' Might be different than job info saved in metadata.json if user manually changes job pre/during rendering. + Args: + instance (pyblish.api.Instance): pyblish instance + job_id (str): Deadline job id + + Returns: + (dict): Job info from Deadline + """ # get default deadline webservice url from deadline module - deadline_url = self.instance.context.data["defaultDeadline"] + deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that - if self.instance.data.get("deadlineUrl"): - deadline_url = self.instance.data.get("deadlineUrl") + if instance.data.get("deadlineUrl"): + deadline_url = instance.data.get("deadlineUrl") assert deadline_url, "Requires Deadline Webservice URL" url = "{}/api/jobs?JobID={}".format(deadline_url, job_id) From ff77dc067834197e70e53ee6e56da2b2ee93a103 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 11:49:53 +0200 Subject: [PATCH 020/291] renaming target and its label to make it better sorted in ui --- openpype/hosts/nuke/api/plugin.py | 2 +- openpype/hosts/nuke/plugins/publish/collect_writes.py | 4 ++-- .../modules/deadline/plugins/publish/submit_publish_job.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 1843134b9e..6d48c09d60 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -327,8 +327,8 @@ class NukeWriteCreator(NukeCreator): "frames": "Use existing frames" } if ("farm_rendering" in self.instance_attributes): + rendering_targets["frames_farm"] = "Use existing frames - farm" rendering_targets["farm"] = "Farm rendering" - rendering_targets["farm_frames"] = "Existing frames farm rendering" return EnumDef( "render_target", diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 53827b19e9..0d552b7381 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -76,7 +76,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, self.log.debug('output dir: {}'.format(output_dir)) - if render_target in ["frames", "farm_frames"]: + if render_target in ["frames", "frames_farm"]: representation = { 'name': ext, 'ext': ext, @@ -145,7 +145,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, instance.data["representations"].append(representation) self.log.info("Publishing rendered frames ...") - if render_target == "farm_frames": + if render_target == "frames_farm": # Farm rendering instance.data["toBeRenderedOn"] = "deadline" instance.data["transfer"] = False diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 62ac36ecf1..b326f181dc 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -97,8 +97,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, hosts = ["fusion", "max", "maya", "nuke", "houdini", "celaction", "aftereffects", "harmony"] - families = ["render.farm", "render.farm_frames", - "prerender.farm", "prerender.farm_frames", + families = ["render.farm", "render.frames_farm", + "prerender.farm", "prerender.frames_farm", "renderlayer", "imagesequence", "vrayscene", "maxrender", "arnold_rop", "mantra_rop", From a39626d71040afb30182a3c5c0e45e5c931bb49c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 14:57:20 +0200 Subject: [PATCH 021/291] refactor collect_writes - also update exctract slates so it expect updated data - and move some data to collect instance data - also renaming collect instance data to host related name --- ..._data.py => collect_nuke_instance_data.py} | 17 +- .../nuke/plugins/publish/collect_writes.py | 412 ++++++++++++------ .../plugins/publish/extract_slate_frame.py | 2 +- 3 files changed, 297 insertions(+), 134 deletions(-) rename openpype/hosts/nuke/plugins/publish/{collect_instance_data.py => collect_nuke_instance_data.py} (71%) diff --git a/openpype/hosts/nuke/plugins/publish/collect_instance_data.py b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py similarity index 71% rename from openpype/hosts/nuke/plugins/publish/collect_instance_data.py rename to openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py index 3908aef4bc..ffcf49d697 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -2,11 +2,13 @@ import nuke import pyblish.api -class CollectInstanceData(pyblish.api.InstancePlugin): - """Collect all nodes with Avalon knob.""" +class CollectNukeInstanceData(pyblish.api.InstancePlugin): + """Collect Nuke instance data + + """ order = pyblish.api.CollectorOrder - 0.49 - label = "Collect Instance Data" + label = "Collect Nuke Instance Data" hosts = ["nuke", "nukeassist"] # presets @@ -40,5 +42,14 @@ class CollectInstanceData(pyblish.api.InstancePlugin): "pixelAspect": pixel_aspect }) + + # add review family if review activated on instance + if instance.data.get("review"): + instance.data["families"].append("review") + + # add creator attributes to instance + creator_attributes = instance.data["creator_attributes"] + instance.data.update(creator_attributes) + self.log.debug("Collected instance: {}".format( instance.data)) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 0d552b7381..307d323908 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -1,5 +1,4 @@ import os -from pprint import pformat import nuke import pyblish.api from openpype.hosts.nuke import api as napi @@ -15,33 +14,16 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, hosts = ["nuke", "nukeassist"] families = ["render", "prerender", "image"] + # cashing + _write_nodes = {} + _frame_ranges = {} + def process(self, instance): - self.log.debug(pformat(instance.data)) - creator_attributes = instance.data["creator_attributes"] - instance.data.update(creator_attributes) group_node = instance.data["transientData"]["node"] render_target = instance.data["render_target"] - family = instance.data["family"] - families = instance.data["families"] - # add targeted family to families - instance.data["families"].append( - "{}.{}".format(family, render_target) - ) - self.log.debug("Appending render target to families: {}.{}".format( - family, render_target) - ) - if instance.data.get("review"): - instance.data["families"].append("review") - - child_nodes = napi.get_instance_group_node_childs(instance) - instance.data["transientData"]["childNodes"] = child_nodes - - write_node = None - for x in child_nodes: - if x.Class() == "Write": - write_node = x + write_node = self._write_node_helper(instance) if write_node is None: self.log.warning( @@ -51,131 +33,134 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, ) return - instance.data["writeNode"] = write_node - self.log.debug("checking instance: {}".format(instance)) + # get colorspace and add to version data + colorspace = napi.get_colorspace_from_node(write_node) - # Determine defined file type - ext = write_node["file_type"].value() + if render_target == "frames": + self._set_existing_files_data(instance, colorspace) - # Get frame range - handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] - first_frame = int(nuke.root()["first_frame"].getValue()) - last_frame = int(nuke.root()["last_frame"].getValue()) - frame_length = int(last_frame - first_frame + 1) + elif render_target == "frames_farm": + collected_frames = self._set_existing_files_data( + instance, colorspace) - if write_node["use_limit"].getValue(): - first_frame = int(write_node["first"].getValue()) - last_frame = int(write_node["last"].getValue()) + self._set_expected_files(instance, collected_frames) + + self._add_farm_instance_data(instance) + + elif render_target == "farm": + self._add_farm_instance_data(instance) + + # set additional instance data + self._set_additional_instance_data(instance, render_target, colorspace) + + def _set_existing_files_data(self, instance, colorspace): + """Set existing files data to instance data. + + Args: + instance (pyblish.api.Instance): pyblish instance + colorspace (str): colorspace + + Returns: + list: collected frames + """ + collected_frames = self._get_collected_frames(instance) + + representation = self._get_existing_frames_representation( + instance, collected_frames + ) + + # inject colorspace data + self.set_representation_colorspace( + representation, instance.context, + colorspace=colorspace + ) + + instance.data["representations"].append(representation) + + return collected_frames + + def _set_expected_files(self, instance, collected_frames): + """Set expected files to instance data. + + Args: + instance (pyblish.api.Instance): pyblish instance + collected_frames (list): collected frames + """ + write_node = self._write_node_helper(instance) write_file_path = nuke.filename(write_node) output_dir = os.path.dirname(write_file_path) - # get colorspace and add to version data - colorspace = napi.get_colorspace_from_node(write_node) + instance.data["expectedFiles"] = [ + os.path.join(output_dir, source_file) + for source_file in collected_frames + ] - self.log.debug('output dir: {}'.format(output_dir)) + def _get_frame_range_data(self, instance): + """Get frame range data from instance. - if render_target in ["frames", "frames_farm"]: - representation = { - 'name': ext, - 'ext': ext, - "stagingDir": output_dir, - "tags": [] - } + Args: + instance (pyblish.api.Instance): pyblish instance - # get file path knob - node_file_knob = write_node["file"] - # list file paths based on input frames - expected_paths = list(sorted({ - node_file_knob.evaluate(frame) - for frame in range(first_frame, last_frame + 1) - })) + Returns: + tuple: first_frame, last_frame + """ - # convert only to base names - expected_filenames = [ - os.path.basename(filepath) - for filepath in expected_paths - ] + instance_name = instance.data["name"] - # make sure files are existing at folder - collected_frames = [ - filename - for filename in os.listdir(output_dir) - if filename in expected_filenames - ] + if self._frame_ranges.get(instance_name): + # return cashed write node + return self._frame_ranges[instance_name] - if collected_frames: - collected_frames_len = len(collected_frames) - frame_start_str = "%0{}d".format( - len(str(last_frame))) % first_frame - representation['frameStart'] = frame_start_str + write_node = self._write_node_helper(instance) - # in case slate is expected and not yet rendered - self.log.debug("_ frame_length: {}".format(frame_length)) - self.log.debug("_ collected_frames_len: {}".format( - collected_frames_len)) + # Get frame range from workfile + first_frame = int(nuke.root()["first_frame"].getValue()) + last_frame = int(nuke.root()["last_frame"].getValue()) - # this will only run if slate frame is not already - # rendered from previews publishes - if ( - "slate" in families - and frame_length == collected_frames_len - and family == "render" - ): - frame_slate_str = ( - "{{:0{}d}}".format(len(str(last_frame))) - ).format(first_frame - 1) + # Get frame range from write node if activated + if write_node["use_limit"].getValue(): + first_frame = int(write_node["first"].getValue()) + last_frame = int(write_node["last"].getValue()) - slate_frame = collected_frames[0].replace( - frame_start_str, frame_slate_str) - collected_frames.insert(0, slate_frame) + # add to cache + self._frame_ranges[instance_name] = (first_frame, last_frame) - if collected_frames_len == 1: - representation['files'] = collected_frames.pop() - else: - representation['files'] = collected_frames + return first_frame, last_frame - # inject colorspace data - self.set_representation_colorspace( - representation, instance.context, - colorspace=colorspace - ) + def _set_additional_instance_data( + self, instance, render_target, colorspace + ): + """Set additional instance data. - instance.data["representations"].append(representation) - self.log.info("Publishing rendered frames ...") + Args: + instance (pyblish.api.Instance): pyblish instance + render_target (str): render target + colorspace (str): colorspace + """ + family = instance.data["family"] - if render_target == "frames_farm": - # Farm rendering - instance.data["toBeRenderedOn"] = "deadline" - instance.data["transfer"] = False - instance.data["farm"] = True # to skip integrate - self.log.info("Farm rendering ON ...") + # add targeted family to families + instance.data["families"].append( + "{}.{}".format(family, render_target) + ) + self.log.info("Appending render target to families: {}.{}".format( + family, render_target) + ) - self.log.info( - "Adding collected files %s to expectedFiles instance.data", - collected_frames - ) - if "expectedFiles" not in instance.data: - instance.data["expectedFiles"] = list() - for source_file in collected_frames: - instance.data["expectedFiles"].append( - os.path.join(output_dir, source_file) - ) + write_node = self._write_node_helper(instance) - elif render_target == "farm": - farm_keys = ["farm_chunk", "farm_priority", "farm_concurrency"] - for key in farm_keys: - # Skip if key is not in creator attributes - if key not in creator_attributes: - continue - # Add farm attributes to instance - instance.data[key] = creator_attributes[key] + # Determine defined file type + ext = write_node["file_type"].value() - # Farm rendering - instance.data["transfer"] = False - instance.data["farm"] = True - self.log.info("Farm rendering ON ...") + # get frame range data + handle_start = instance.context.data["handleStart"] + handle_end = instance.context.data["handleEnd"] + first_frame, last_frame = self._get_frame_range_data(instance) + + # get output paths + write_file_path = nuke.filename(write_node) + output_dir = os.path.dirname(write_file_path) # TODO: remove this when we have proper colorspace support version_data = { @@ -209,10 +194,6 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, "frameEndHandle": last_frame, }) - # make sure rendered sequence on farm will - # be used for extract review - if not instance.data.get("review"): - instance.data["useSequenceForReview"] = False # TODO temporarily set stagingDir as persistent for backward # compatibility. This is mainly focused on `renders`folders which @@ -220,4 +201,175 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, # this logic should be removed and replaced with custom staging dir instance.data["stagingDir_persistent"] = True - self.log.debug("instance.data: {}".format(pformat(instance.data))) + def _write_node_helper(self, instance): + """Helper function to get write node from instance. + + Also sets instance transient data with child nodes. + + Args: + instance (pyblish.api.Instance): pyblish instance + + Returns: + nuke.Node: write node + """ + instance_name = instance.data["name"] + + if self._write_nodes.get(instance_name): + # return cashed write node + return self._write_nodes[instance_name] + + # get all child nodes from group node + child_nodes = napi.get_instance_group_node_childs(instance) + + # set child nodes to instance transient data + instance.data["transientData"]["childNodes"] = child_nodes + + write_node = None + for node_ in child_nodes: + if node_.Class() == "Write": + write_node = node_ + + if write_node: + # for slate frame extraction + instance.data["transientData"]["writeNode"] = write_node + # add to cache + self._write_nodes[instance_name] = write_node + + return self._write_nodes[instance_name] + + def _get_existing_frames_representation( + self, + instance, + collected_frames + ): + """Get existing frames representation. + + Args: + instance (pyblish.api.Instance): pyblish instance + collected_frames (list): collected frames + + Returns: + dict: representation + """ + + first_frame, last_frame = self._get_frame_range_data(instance) + + write_node = self._write_node_helper(instance) + + write_file_path = nuke.filename(write_node) + output_dir = os.path.dirname(write_file_path) + + # Determine defined file type + ext = write_node["file_type"].value() + + representation = { + "name": ext, + "ext": ext, + "stagingDir": output_dir, + "tags": [] + } + + frame_start_str = "%0{}d".format( + len(str(last_frame))) % first_frame + representation['frameStart'] = frame_start_str + + # set slate frame + collected_frames = self._add_slate_frame_to_collected_frames( + instance, + collected_frames, + frame_start_str, + first_frame, + last_frame + ) + + if len(collected_frames) == 1: + representation['files'] = collected_frames.pop() + else: + representation['files'] = collected_frames + + return representation + + def _add_slate_frame_to_collected_frames( + self, + instance, + collected_frames, + frame_start_str, + first_frame, + last_frame + ): + """Set slate frame.""" + frame_length = int(last_frame - first_frame + 1) + + # this will only run if slate frame is not already + # rendered from previews publishes + if ( + "slate" in instance.data["families"] + and frame_length == len(collected_frames) + and instance.data["family"] == "render" + ): + frame_slate_str = ( + "{{:0{}d}}".format(len(str(last_frame))) + ).format(first_frame - 1) + + slate_frame = collected_frames[0].replace( + frame_start_str, frame_slate_str) + collected_frames.insert(0, slate_frame) + + return collected_frames + + def _add_farm_instance_data(self, instance): + """Add farm publishing related instance data. + + Args: + instance (pyblish.api.Instance): pyblish instance + """ + + # make sure rendered sequence on farm will + # be used for extract review + if not instance.data.get("review"): + instance.data["useSequenceForReview"] = False + + # Farm rendering + instance.data["transfer"] = False + instance.data["farm"] = True + self.log.info("Farm rendering ON ...") + + def _get_collected_frames(self, instance): + """Get collected frames. + + Args: + instance (pyblish.api.Instance): pyblish instance + + Returns: + list: collected frames + """ + + first_frame, last_frame = self._get_frame_range_data(instance) + + write_node = self._write_node_helper(instance) + + write_file_path = nuke.filename(write_node) + output_dir = os.path.dirname(write_file_path) + + # get file path knob + node_file_knob = write_node["file"] + # list file paths based on input frames + expected_paths = list(sorted({ + node_file_knob.evaluate(frame) + for frame in range(first_frame, last_frame + 1) + })) + + # convert only to base names + expected_filenames = [ + os.path.basename(filepath) + for filepath in expected_paths + ] + + # make sure files are existing at folder + collected_frames = [ + filename + for filename in os.listdir(output_dir) + if filename in expected_filenames + ] + + return collected_frames diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 06c086b10d..25262a7418 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -249,7 +249,7 @@ class ExtractSlateFrame(publish.Extractor): # Add file to representation files # - get write node - write_node = instance.data["writeNode"] + write_node = instance.data["transientData"]["writeNode"] # - evaluate filepaths for first frame and slate frame first_filename = os.path.basename( write_node["file"].evaluate(first_frame)) From 8fc7d6c57d65e601fd2effa31695b03a53e84373 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 14:59:40 +0200 Subject: [PATCH 022/291] pr suggestions --- openpype/hosts/nuke/plugins/publish/collect_writes.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index 307d323908..fce2e571a0 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -330,8 +330,10 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, instance.data["useSequenceForReview"] = False # Farm rendering - instance.data["transfer"] = False - instance.data["farm"] = True + instance.data.update({ + "transfer": False, + "farm": True # to skip integrate + }) self.log.info("Farm rendering ON ...") def _get_collected_frames(self, instance): From 5b68f0cef6fc72b0a7e011e31acfc9bd6b7b2c9d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 7 Aug 2023 16:32:34 +0800 Subject: [PATCH 023/291] remove loading openpype attributes in load max scene as it is being loaded differently --- openpype/hosts/max/plugins/load/load_max_scene.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 468461bc0e..f161a19a4c 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import containerise from openpype.pipeline import get_representation_path, load @@ -28,7 +28,6 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") - load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -50,7 +49,6 @@ class MaxSceneLoader(load.LoaderPlugin): for max_object in max_objects: max_object.Parent = instance_container instance_container.Parent = container_node - load_OpenpypeData(container_node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 69f88b33d2ce3a96463105e7e3ff7e4066509bbc Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 7 Aug 2023 18:17:10 +0800 Subject: [PATCH 024/291] rstore max scene code --- openpype/hosts/max/plugins/load/load_max_scene.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index f161a19a4c..468461bc0e 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,7 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -28,6 +28,7 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") + load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -49,6 +50,7 @@ class MaxSceneLoader(load.LoaderPlugin): for max_object in max_objects: max_object.Parent = instance_container instance_container.Parent = container_node + load_OpenpypeData(container_node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From e35c9898af6716703f5d468f3e7490cdc7ddecc9 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:08:31 +0200 Subject: [PATCH 025/291] Align creator settings to use default_variants instead of defaults --- .../plugins/create/create_arnold_ass.py | 2 +- .../plugins/create/create_arnold_rop.py | 2 +- .../plugins/create/create_karma_rop.py | 2 +- .../plugins/create/create_mantra_rop.py | 2 +- .../plugins/create/create_redshift_rop.py | 2 +- .../houdini/plugins/create/create_vray_rop.py | 2 +- .../defaults/project_settings/houdini.json | 56 ++++- .../defaults/project_settings/maya.json | 47 ++-- .../schemas/schema_houdini_create.json | 206 ++++++++++-------- .../schemas/schema_maya_create.json | 26 ++- .../schemas/schema_maya_create_render.json | 4 +- .../schemas/template_create_plugin.json | 4 +- 12 files changed, 211 insertions(+), 144 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py index 8b310753d0..e587041e70 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -10,7 +10,7 @@ class CreateArnoldAss(plugin.HoudiniCreator): label = "Arnold ASS" family = "ass" icon = "magic" - defaults = ["Main"] + default_variants = ["Main"] # Default extension: `.ass` or `.ass.gz` ext = ".ass" diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py index ca516619f6..7d00e8b3e5 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py @@ -9,7 +9,7 @@ class CreateArnoldRop(plugin.HoudiniCreator): label = "Arnold ROP" family = "arnold_rop" icon = "magic" - defaults = ["master"] + default_variants = ["master"] # Default extension ext = "exr" diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py index 71c2bf1b28..e8b77c12e5 100644 --- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -11,7 +11,7 @@ class CreateKarmaROP(plugin.HoudiniCreator): label = "Karma ROP" family = "karma_rop" icon = "magic" - defaults = ["master"] + default_variants = ["master"] def create(self, subset_name, instance_data, pre_create_data): import hou # noqa diff --git a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py index 5c29adb33f..b2846d53fa 100644 --- a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py @@ -11,7 +11,7 @@ class CreateMantraROP(plugin.HoudiniCreator): label = "Mantra ROP" family = "mantra_rop" icon = "magic" - defaults = ["master"] + default_variants = ["master"] def create(self, subset_name, instance_data, pre_create_data): import hou # noqa diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py index 8f4aa1327d..9b0a36ddd1 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py @@ -13,7 +13,7 @@ class CreateRedshiftROP(plugin.HoudiniCreator): label = "Redshift ROP" family = "redshift_rop" icon = "magic" - defaults = ["master"] + default_variants = ["master"] ext = "exr" def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py index 58748d4c34..a3ae01f5ae 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,7 +14,7 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - defaults = ["master"] + default_variants = ["master"] ext = "exr" diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index a53f1ff202..389fdc6d96 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,48 +14,80 @@ "create": { "CreateArnoldAss": { "enabled": true, - "defaults": [], + "default_variants": [], "ext": ".ass" }, + "CreateArnoldRop": { + "enabled": true, + "default_variants": [] + }, "CreateAlembicCamera": { "enabled": true, - "defaults": [] + "default_variants": [] + }, + "CreateBGEO": { + "enabled": true, + "default_variants": [] }, "CreateCompositeSequence": { "enabled": true, - "defaults": [] + "default_variants": [] + }, + "CreateHDA": { + "enabled": true, + "default_variants": [] + }, + "CreateKarmaROP": { + "enabled": true, + "default_variants": [] + }, + "CreateMantraROP": { + "enabled": true, + "default_variants": [] }, "CreatePointCache": { "enabled": true, - "defaults": [] + "default_variants": [] + }, + "CreateRedshiftProxy": { + "enabled": true, + "default_variants": [] }, "CreateRedshiftROP": { "enabled": true, - "defaults": [] + "default_variants": [] }, "CreateRemotePublish": { "enabled": true, - "defaults": [] + "default_variants": [] }, - "CreateVDBCache": { + "CreateReview": { "enabled": true, - "defaults": [] + "default_variants": [] }, "CreateUSD": { "enabled": false, - "defaults": [] + "default_variants": [] }, "CreateUSDModel": { "enabled": false, - "defaults": [] + "default_variants": [] }, "USDCreateShadingWorkspace": { "enabled": false, - "defaults": [] + "default_variants": [] }, "CreateUSDRender": { "enabled": false, - "defaults": [] + "default_variants": [] + }, + "CreateVDBCache": { + "enabled": true, + "default_variants": [] + }, + "CreateVrayROP": { + "enabled": true, + "default_variants": [] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 8e1022f877..b9817fe400 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,19 +521,19 @@ "enabled": true, "make_tx": true, "rs_tex": false, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateRender": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateUnrealStaticMesh": { "enabled": true, - "defaults": [ + "default_variants": [ "", "_Main" ], @@ -547,7 +547,7 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, - "defaults": [], + "default_variants": [], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { @@ -555,11 +555,12 @@ "publish_mip_map": true }, "CreateAnimation": { + "enabled": false, "write_color_sets": false, "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -567,7 +568,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "defaults": [ + "default_variants": [ "Main", "Proxy", "Sculpt" @@ -578,7 +579,7 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -586,20 +587,20 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateReview": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ], "useMayaTimeline": true }, "CreateAss": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ], "expandProcedurals": false, @@ -621,61 +622,61 @@ "enabled": true, "vrmesh": true, "alembic": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsd": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateAssembly": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateCamera": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateLayout": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMayaScene": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateRig": { "enabled": true, - "defaults": [ + "default_variants": [ "Main", "Sim", "Cloth" @@ -683,20 +684,20 @@ }, "CreateSetDress": { "enabled": true, - "defaults": [ + "default_variants": [ "Main", "Anim" ] }, "CreateVRayScene": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": true, - "defaults": [ + "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 83e0cf789a..52a03677da 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 @@ -1,89 +1,121 @@ { - "type": "dict", - "collapsible": true, - "key": "create", - "label": "Creator plugins", - "children": [ - { - "type": "dict", - "collapsible": true, - "key": "CreateArnoldAss", - "label": "Create Arnold Ass", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "defaults", - "label": "Default Subsets", - "object_type": "text" - }, - { - "type": "enum", - "key": "ext", - "label": "Default Output Format (extension)", - "multiselection": false, - "enum_items": [ - { - ".ass": ".ass" - }, - { - ".ass.gz": ".ass.gz (gzipped)" - } - ] - } - ] - - }, + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ { - "type": "schema_template", - "name": "template_create_plugin", - "template_data": [ - { - "key": "CreateAlembicCamera", - "label": "Create Alembic Camera" - }, - { - "key": "CreateCompositeSequence", - "label": "Create Composite (Image Sequence)" - }, - { - "key": "CreatePointCache", - "label": "Create Point Cache" - }, - { - "key": "CreateRedshiftROP", - "label": "Create Redshift ROP" - }, - { - "key": "CreateRemotePublish", - "label": "Create Remote Publish" - }, - { - "key": "CreateVDBCache", - "label": "Create VDB Cache" - }, - { - "key": "CreateUSD", - "label": "Create USD" - }, - { - "key": "CreateUSDModel", - "label": "Create USD Model" - }, - { - "key": "USDCreateShadingWorkspace", - "label": "Create USD Shading Workspace" - }, - { - "key": "CreateUSDRender", - "label": "Create USD Render" - } - ] - } - ] -} \ No newline at end of file + "type": "dict", + "collapsible": true, + "key": "CreateArnoldAss", + "label": "Create Arnold Ass", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Variants", + "object_type": "text" + }, + { + "type": "enum", + "key": "ext", + "label": "Default Output Format (extension)", + "multiselection": false, + "enum_items": [ + { + ".ass": ".ass" + }, + { + ".ass.gz": ".ass.gz (gzipped)" + } + ] + } + ] + + }, + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateArnoldRop", + "label": "Create Arnold ROP" + }, + { + "key": "CreateAlembicCamera", + "label": "Create Alembic Camera" + }, + { + "key": "CreateBGEO", + "label": "Create Houdini BGEO" + }, + { + "key": "CreateCompositeSequence", + "label": "Create Composite (Image Sequence)" + }, + { + "key": "CreateKarmaROP", + "label": "Create Karma ROP" + }, + { + "key": "CreateMantraROP", + "label": "Create Mantra ROP" + }, + { + "key": "CreateHDA", + "label": "Create HDA" + }, + { + "key": "CreatePointCache", + "label": "Create Point Cache" + }, + { + "key": "CreateRedshiftProxy", + "label": "Create Redshift Proxy" + }, + { + "key": "CreateRedshiftROP", + "label": "Create Redshift ROP" + }, + { + "key": "CreateRemotePublish", + "label": "Create Remote Publish" + }, + { + "key": "CreateReview", + "label": "Create Review" + }, + { + "key": "CreateUSD", + "label": "Create USD" + }, + { + "key": "CreateUSDModel", + "label": "Create USD Model" + }, + { + "key": "USDCreateShadingWorkspace", + "label": "Create USD Shading Workspace" + }, + { + "key": "CreateUSDRender", + "label": "Create USD Render" + }, + { + "key": "CreateVDBCache", + "label": "Create VDB Cache" + }, + { + "key": "CreateVrayROP", + "label": "Create VRay ROP" + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index d28d42c10c..8faf3fcae8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -28,7 +28,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -52,7 +52,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -84,7 +84,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -120,10 +120,12 @@ "collapsible": true, "key": "CreateAnimation", "label": "Create Animation", + "checkbox_key": "enabled", "children": [ { - "type": "label", - "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." + "type": "boolean", + "key": "enabled", + "label": "Enabled" }, { "type": "boolean", @@ -147,7 +149,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -177,7 +179,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -212,7 +214,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -242,7 +244,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } @@ -262,7 +264,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -287,7 +289,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" }, @@ -389,7 +391,7 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json index 68ad7ad63d..9d7432fe51 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json @@ -12,9 +12,9 @@ }, { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Subsets", "object_type": "text" } ] -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 14d15e7840..3d2ed9f3d4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -13,8 +13,8 @@ }, { "type": "list", - "key": "defaults", - "label": "Default Subsets", + "key": "default_variants", + "label": "Default Variants", "object_type": "text" } ] From 1b794361034c023acef1c3362df6065f1839245a Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:08:50 +0200 Subject: [PATCH 026/291] Add function so Houdini creator settings get applied to instances --- openpype/hosts/houdini/api/plugin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 1e7eaa7e22..a5efb73c67 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -292,3 +292,22 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """ return [hou.ropNodeTypeCategory()] + + def get_creator_settings(self, project_settings, settings_key=None): + if not settings_key: + settings_key = self.__class__.__name__ + return project_settings["houdini"]["create"][settings_key] + + def apply_settings( + self, + project_settings, + system_settings + ): + """Method called on initialization of plugin to apply settings.""" + + # plugin settings + plugin_settings = self.get_creator_settings(project_settings) + + # individual attributes + self.default_variants = plugin_settings.get( + "default_variants") or self.default_variants From 73bbc86d8b54dbe7859737f34d6db71120d6f693 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:09:27 +0200 Subject: [PATCH 027/291] Use default variant from creator plugin for interactive shelve tool --- .../hosts/houdini/api/creator_node_shelves.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 7c6122cffe..c724acb16d 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -35,11 +35,11 @@ CATEGORY_GENERIC_TOOL = { CREATE_SCRIPT = """ from openpype.hosts.houdini.api.creator_node_shelves import create_interactive -create_interactive("{identifier}", **kwargs) +create_interactive("{identifier}", "{variant}", **kwargs) """ -def create_interactive(creator_identifier, **kwargs): +def create_interactive(creator_identifier, default_variant, **kwargs): """Create a Creator using its identifier interactively. This is used by the generated shelf tools as callback when a user selects @@ -59,9 +59,9 @@ def create_interactive(creator_identifier, **kwargs): """ # TODO Use Qt instead - result, variant = hou.ui.readInput('Define variant name', + result, variant = hou.ui.readInput("Define variant name", buttons=("Ok", "Cancel"), - initial_contents='Main', + initial_contents=default_variant, title="Define variant", help="Set the variant for the " "publish instance", @@ -196,7 +196,14 @@ def install(): key = "openpype_create.{}".format(identifier) log.debug(f"Registering {key}") - script = CREATE_SCRIPT.format(identifier=identifier) + default_variant = "Main" + if hasattr(creator, "default_variant"): + default_variant = creator.default_variant + elif hasattr(creator, "default_variants"): + default_variant = creator.default_variants[0] + script = CREATE_SCRIPT.format( + identifier=identifier, variant=default_variant + ) data = { "script": script, "language": hou.scriptLanguage.Python, From 3f626080d6fd042e5f5a5505d8ff08ae298b8b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:28:19 +0200 Subject: [PATCH 028/291] Remove default_variants class variable --- openpype/hosts/houdini/plugins/create/create_arnold_ass.py | 2 -- openpype/hosts/houdini/plugins/create/create_arnold_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_karma_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_mantra_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_redshift_rop.py | 1 - openpype/hosts/houdini/plugins/create/create_vray_rop.py | 1 - 6 files changed, 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py index 72b12ddba2..12d08f7d83 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_ass.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_ass.py @@ -10,8 +10,6 @@ class CreateArnoldAss(plugin.HoudiniCreator): label = "Arnold ASS" family = "ass" icon = "magic" - default_variants = ["Main"] - # Default extension: `.ass` or `.ass.gz` # however calling HoudiniCreator.create() diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py index 7d00e8b3e5..b58c377a20 100644 --- a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py @@ -9,7 +9,6 @@ class CreateArnoldRop(plugin.HoudiniCreator): label = "Arnold ROP" family = "arnold_rop" icon = "magic" - default_variants = ["master"] # Default extension ext = "exr" diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py index cb9c6dd711..4e1360ca45 100644 --- a/openpype/hosts/houdini/plugins/create/create_karma_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -11,7 +11,6 @@ class CreateKarmaROP(plugin.HoudiniCreator): label = "Karma ROP" family = "karma_rop" icon = "magic" - default_variants = ["master"] def create(self, subset_name, instance_data, pre_create_data): import hou # noqa diff --git a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py index b2846d53fa..d2f0e735a8 100644 --- a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py @@ -11,7 +11,6 @@ class CreateMantraROP(plugin.HoudiniCreator): label = "Mantra ROP" family = "mantra_rop" icon = "magic" - default_variants = ["master"] def create(self, subset_name, instance_data, pre_create_data): import hou # noqa diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py index 9bb1d58d8f..e37718129c 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py @@ -13,7 +13,6 @@ class CreateRedshiftROP(plugin.HoudiniCreator): label = "Redshift ROP" family = "redshift_rop" icon = "magic" - default_variants = ["master"] ext = "exr" diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py index a3ae01f5ae..34c8906bb0 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,7 +14,6 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - default_variants = ["master"] ext = "exr" From adca11d44bed5c705d4c3e7d9740f732053b7779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:28:58 +0200 Subject: [PATCH 029/291] Remove extra whitespace --- openpype/hosts/houdini/plugins/create/create_redshift_rop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py index e37718129c..1b8826a932 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py @@ -13,7 +13,6 @@ class CreateRedshiftROP(plugin.HoudiniCreator): label = "Redshift ROP" family = "redshift_rop" icon = "magic" - ext = "exr" def create(self, subset_name, instance_data, pre_create_data): From 84e3ade492bf51084824d520fb914a334a2361d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:30:16 +0200 Subject: [PATCH 030/291] Revert merge conflict --- .../schemas/projects_schema/schemas/schema_maya_create.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index 8faf3fcae8..10d5bff445 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -12,9 +12,8 @@ "checkbox_key": "enabled", "children": [ { - "type": "boolean", - "key": "enabled", - "label": "Enabled" + "type": "label", + "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." }, { "type": "boolean", From b57c8a79c090332ce488dc6daad78c77f7d59240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:33:46 +0200 Subject: [PATCH 031/291] Remove extra whitespace --- openpype/hosts/houdini/plugins/create/create_vray_rop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py index 34c8906bb0..793a544fdf 100644 --- a/openpype/hosts/houdini/plugins/create/create_vray_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -14,7 +14,6 @@ class CreateVrayROP(plugin.HoudiniCreator): label = "VRay ROP" family = "vray_rop" icon = "magic" - ext = "exr" def create(self, subset_name, instance_data, pre_create_data): From bb42369af27992a125ba7110954cd5a365cad542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Mon, 7 Aug 2023 19:34:51 +0200 Subject: [PATCH 032/291] Fix merge conflict --- .../projects_schema/schemas/schema_maya_create.json | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index 10d5bff445..8dec0a8817 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -12,8 +12,9 @@ "checkbox_key": "enabled", "children": [ { - "type": "label", - "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." + "type": "boolean", + "key": "enabled", + "label": "Enabled" }, { "type": "boolean", @@ -119,12 +120,10 @@ "collapsible": true, "key": "CreateAnimation", "label": "Create Animation", - "checkbox_key": "enabled", "children": [ { - "type": "boolean", - "key": "enabled", - "label": "Enabled" + "type": "label", + "label": "This plugin is not optional due to implicit creation through loading the \"rig\" family.\nThis family is also hidden from creation due to complexity in setup." }, { "type": "boolean", From 92254e2abc32d2305497dadf9319ebdef040ee14 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 19:53:32 +0200 Subject: [PATCH 033/291] Add a default variant to all Houdini creators --- openpype/hosts/houdini/api/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 70c837205e..3d3b0e49b9 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -169,6 +169,8 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): selected_nodes = [] settings_name = None + default_variant = "Main" + def create(self, subset_name, instance_data, pre_create_data): try: self.selected_nodes = [] From 9604656017cf06fb444435dcd37c15e8681719ee Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 20:21:13 +0200 Subject: [PATCH 034/291] Add default variant on all creators --- .../defaults/project_settings/houdini.json | 29 +++++++++++++++---- .../defaults/project_settings/maya.json | 23 +++++++++++++++ .../schemas/schema_houdini_create.json | 5 ++++ .../schemas/schema_maya_create.json | 5 ++++ .../schemas/template_create_plugin.json | 5 ++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 389fdc6d96..cf5d1c93d5 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,80 +14,99 @@ "create": { "CreateArnoldAss": { "enabled": true, + "default_variant": "Main", "default_variants": [], "ext": ".ass" }, "CreateArnoldRop": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreateAlembicCamera": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateBGEO": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateCompositeSequence": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateHDA": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateKarmaROP": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreateMantraROP": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreatePointCache": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateRedshiftProxy": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] }, "CreateRedshiftROP": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateRemotePublish": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateReview": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateUSD": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "CreateUSDModel": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "USDCreateShadingWorkspace": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "CreateUSDRender": { "enabled": false, + "default_variant": "Main", "default_variants": [] }, "CreateVDBCache": { "enabled": true, + "default_variant": "Main", "default_variants": [] }, "CreateVrayROP": { "enabled": true, - "default_variants": [] + "default_variant": "master", + "default_variants": ["master"] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b9817fe400..f53501c7ac 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,18 +521,21 @@ "enabled": true, "make_tx": true, "rs_tex": false, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRender": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateUnrealStaticMesh": { "enabled": true, + "default_variant": "", "default_variants": [ "", "_Main" @@ -547,6 +550,7 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, + "default_variant": "", "default_variants": [], "joint_hints": "jnt_org" }, @@ -560,6 +564,7 @@ "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, + "default_variant": "Main", "default_variants": [ "Main" ] @@ -568,6 +573,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "default_variant": "Main", "default_variants": [ "Main", "Proxy", @@ -579,6 +585,7 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, + "default_variant": "Main", "default_variants": [ "Main" ] @@ -587,12 +594,14 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateReview": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ], @@ -600,6 +609,7 @@ }, "CreateAss": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ], @@ -622,60 +632,70 @@ "enabled": true, "vrmesh": true, "alembic": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsd": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateAssembly": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateCamera": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateLayout": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMayaScene": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRig": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main", "Sim", @@ -684,6 +704,7 @@ }, "CreateSetDress": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main", "Anim" @@ -691,12 +712,14 @@ }, "CreateVRayScene": { "enabled": true, + "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": true, + "default_variant": "Main", "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 a1736c811d..6e1eaf7146 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 @@ -16,6 +16,11 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, { "type": "list", "key": "default_variants", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index 8dec0a8817..a8105bdb5d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -387,6 +387,11 @@ "key": "alembic", "label": "Alembic" }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, { "type": "list", "key": "default_variants", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 3d2ed9f3d4..7384060625 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -11,6 +11,11 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "text", + "key": "default_variant", + "label": "Default variant" + }, { "type": "list", "key": "default_variants", From 8a02654dbc133563fe57a0f7190fd7f72b2210ec Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Mon, 7 Aug 2023 20:24:31 +0200 Subject: [PATCH 035/291] Fill up default variants --- .../defaults/project_settings/houdini.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index cf5d1c93d5..630b189743 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -15,7 +15,7 @@ "CreateArnoldAss": { "enabled": true, "default_variant": "Main", - "default_variants": [], + "default_variants": ["Main"], "ext": ".ass" }, "CreateArnoldRop": { @@ -26,22 +26,22 @@ "CreateAlembicCamera": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateBGEO": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateCompositeSequence": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateHDA": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateKarmaROP": { "enabled": true, @@ -56,7 +56,7 @@ "CreatePointCache": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateRedshiftProxy": { "enabled": true, @@ -66,42 +66,42 @@ "CreateRedshiftROP": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateRemotePublish": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateReview": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateUSD": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateUSDModel": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "USDCreateShadingWorkspace": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateUSDRender": { "enabled": false, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateVDBCache": { "enabled": true, "default_variant": "Main", - "default_variants": [] + "default_variants": ["Main"] }, "CreateVrayROP": { "enabled": true, From 26851293b4bb26805e2070aacb37741f74f32283 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Aug 2023 21:48:52 +0200 Subject: [PATCH 036/291] simplification of code --- .../nuke/plugins/publish/collect_writes.py | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index fce2e571a0..d3899e0035 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -269,15 +269,14 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, "tags": [] } - frame_start_str = "%0{}d".format( - len(str(last_frame))) % first_frame + frame_start_str = self._get_frame_start_str(first_frame, last_frame) + representation['frameStart'] = frame_start_str # set slate frame collected_frames = self._add_slate_frame_to_collected_frames( instance, collected_frames, - frame_start_str, first_frame, last_frame ) @@ -289,15 +288,40 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, return representation + def _get_frame_start_str(self, first_frame, last_frame): + """Get frame start string. + + Args: + first_frame (int): first frame + last_frame (int): last frame + + Returns: + str: frame start string + """ + # convert first frame to string with padding + return ( + "{{:0{}d}}".format(len(str(last_frame))) + ).format(first_frame) + def _add_slate_frame_to_collected_frames( self, instance, collected_frames, - frame_start_str, first_frame, last_frame ): - """Set slate frame.""" + """Add slate frame to collected frames. + + Args: + instance (pyblish.api.Instance): pyblish instance + collected_frames (list): collected frames + first_frame (int): first frame + last_frame (int): last frame + + Returns: + list: collected frames + """ + frame_start_str = self._get_frame_start_str(first_frame, last_frame) frame_length = int(last_frame - first_frame + 1) # this will only run if slate frame is not already @@ -305,15 +329,15 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, if ( "slate" in instance.data["families"] and frame_length == len(collected_frames) - and instance.data["family"] == "render" ): - frame_slate_str = ( - "{{:0{}d}}".format(len(str(last_frame))) - ).format(first_frame - 1) + frame_slate_str = self._get_frame_start_str( + first_frame - 1, + last_frame + ) - slate_frame = collected_frames[0].replace( - frame_start_str, frame_slate_str) - collected_frames.insert(0, slate_frame) + slate_frame = collected_frames[0].replace( + frame_start_str, frame_slate_str) + collected_frames.insert(0, slate_frame) return collected_frames From 97664e1bbd9c71b17deb7dd4e7c40bbc8b2a5648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Mon, 7 Aug 2023 21:50:23 +0200 Subject: [PATCH 037/291] Update openpype/hosts/nuke/plugins/publish/collect_writes.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fabià Serra Arrizabalaga --- openpype/hosts/nuke/plugins/publish/collect_writes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index d3899e0035..bf9740563c 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -144,7 +144,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, instance.data["families"].append( "{}.{}".format(family, render_target) ) - self.log.info("Appending render target to families: {}.{}".format( + self.log.debug("Appending render target to families: {}.{}".format( family, render_target) ) From f4284e5e646d74d29fea23c25b1d86774f4de909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Mon, 7 Aug 2023 21:51:10 +0200 Subject: [PATCH 038/291] Update openpype/hosts/nuke/plugins/publish/collect_writes.py Co-authored-by: Roy Nieterau --- openpype/hosts/nuke/plugins/publish/collect_writes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index bf9740563c..f1b36ba886 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -386,10 +386,10 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, })) # convert only to base names - expected_filenames = [ + expected_filenames = { os.path.basename(filepath) for filepath in expected_paths - ] + } # make sure files are existing at folder collected_frames = [ From c72c8f332cbfbf3ded76ba9c6b2fcb9cbd9b1522 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 15:00:17 +0800 Subject: [PATCH 039/291] switching asset can maintain linkages on the modifiers and other data --- openpype/hosts/max/plugins/load/load_max_scene.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..c98b7909ee 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -24,7 +24,9 @@ class MaxSceneLoader(load.LoaderPlugin): path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path) + rt.MergeMaxFile( + path, rt.Name("autoRenameDups"), + includeFullGroup=True) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") for max_object in max_objects: @@ -38,11 +40,11 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) + merged_max_objects = rt.getLastMergedNodes() + rt.MergeMaxFile( + path, rt.Name("autoRenameDups"), + mergedNodes=merged_max_objects, + includeFullGroup=True) max_objects = rt.getLastMergedNodes() container_node = rt.GetNodeByName(node_name) From d51c1fa9fb3ba534d9b2ba5b0cd3c6254c465290 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 15:11:13 +0800 Subject: [PATCH 040/291] do not use autorename duplicate when updating or switching version --- openpype/hosts/max/plugins/load/load_max_scene.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index c98b7909ee..3af4613d1a 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -42,8 +42,7 @@ class MaxSceneLoader(load.LoaderPlugin): node_name = container["instance_node"] merged_max_objects = rt.getLastMergedNodes() rt.MergeMaxFile( - path, rt.Name("autoRenameDups"), - mergedNodes=merged_max_objects, + path, mergedNodes=merged_max_objects, includeFullGroup=True) max_objects = rt.getLastMergedNodes() From ab1172b277eaa396f3c3e992b3ad5759ff1635bf Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 15:54:35 +0800 Subject: [PATCH 041/291] delete old duplicates --- .../hosts/max/plugins/load/load_max_scene.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 3af4613d1a..92e1bdc59a 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -25,31 +25,27 @@ class MaxSceneLoader(load.LoaderPlugin): # import the max scene by using "merge file" path = path.replace('\\', '/') rt.MergeMaxFile( - path, rt.Name("autoRenameDups"), + path, rt.Name("mergeDups"), includeFullGroup=True) max_objects = rt.getLastMergedNodes() - max_container = rt.Container(name=f"{name}") - for max_object in max_objects: - max_object.Parent = max_container return containerise( - name, [max_container], context, loader=self.__class__.__name__) + name, [max_objects], context, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - node_name = container["instance_node"] - merged_max_objects = rt.getLastMergedNodes() + prev_max_objects = rt.getLastMergedNodes() + merged_max_objects = [obj.name for obj + in prev_max_objects] rt.MergeMaxFile( - path, mergedNodes=merged_max_objects, + path, merged_max_objects, + rt.Name("deleteOldDups"), + quiet=True, + mergedNodes=prev_max_objects, includeFullGroup=True) - max_objects = rt.getLastMergedNodes() - container_node = rt.GetNodeByName(node_name) - for max_object in max_objects: - max_object.Parent = container_node - lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 70cb60a17eb98ca7796674f4555f115019e27abe Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 17:19:42 +0800 Subject: [PATCH 042/291] deletion of the old objects --- openpype/hosts/max/plugins/load/load_max_scene.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 92e1bdc59a..1d105f1bc0 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -45,6 +45,11 @@ class MaxSceneLoader(load.LoaderPlugin): quiet=True, mergedNodes=prev_max_objects, includeFullGroup=True) + current_max_objects = rt.getLastMergedNodes() + for current_object in current_max_objects: + prev_max_objects = prev_max_objects.remove(current_object) + for prev_object in prev_max_objects: + rt.Delete(prev_object) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) From 38b905c6f92b7ff4b3b9977c27b77153a35f9e8d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 8 Aug 2023 18:27:13 +0800 Subject: [PATCH 043/291] restore the load max scene for resolving the possible conflict --- .../hosts/max/plugins/load/load_max_scene.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 468461bc0e..76cd3bf367 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,8 +1,7 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData - +from openpype.hosts.max.api.pipeline import containerise from openpype.pipeline import get_representation_path, load @@ -28,7 +27,6 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path) max_objects = rt.getLastMergedNodes() max_container = rt.Container(name=f"{name}") - load_OpenpypeData(max_container, max_objects) for max_object in max_objects: max_object.Parent = max_container @@ -41,16 +39,16 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - rt.MergeMaxFile(path) + rt.MergeMaxFile(path, + rt.Name("noRedraw"), + rt.Name("deleteOldDups"), + rt.Name("useSceneMtlDups")) max_objects = rt.getLastMergedNodes() container_node = rt.GetNodeByName(node_name) - instance_name, _ = os.path.splitext(node_name) - instance_container = rt.GetNodeByName(instance_name) for max_object in max_objects: - max_object.Parent = instance_container - instance_container.Parent = container_node - load_OpenpypeData(container_node, max_objects) + max_object.Parent = container_node + lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 20479595061f2e08c9ca3b3bb710cbe0f3147956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:06:56 +0200 Subject: [PATCH 044/291] Make use of `get_default_variant` function --- openpype/hosts/houdini/api/creator_node_shelves.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index c724acb16d..01da2fc3e1 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -196,13 +196,8 @@ def install(): key = "openpype_create.{}".format(identifier) log.debug(f"Registering {key}") - default_variant = "Main" - if hasattr(creator, "default_variant"): - default_variant = creator.default_variant - elif hasattr(creator, "default_variants"): - default_variant = creator.default_variants[0] script = CREATE_SCRIPT.format( - identifier=identifier, variant=default_variant + identifier=identifier, variant=creator.get_default_variant() ) data = { "script": script, From 2f663bc011ef4bd934f9cc7d690dfd3fd4769eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:20:08 +0200 Subject: [PATCH 045/291] Remove 'default_variant' from schemas --- .../defaults/project_settings/houdini.json | 57 +++++++------------ .../defaults/project_settings/maya.json | 24 -------- .../schemas/schema_houdini_create.json | 5 -- .../schemas/template_create_plugin.json | 5 -- 4 files changed, 19 insertions(+), 72 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 630b189743..512690bfd7 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,99 +14,80 @@ "create": { "CreateArnoldAss": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"], + "default_variants": ["main"], "ext": ".ass" }, "CreateArnoldRop": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreateAlembicCamera": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateBGEO": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateCompositeSequence": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateHDA": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateKarmaROP": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreateMantraROP": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreatePointCache": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateRedshiftProxy": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] }, "CreateRedshiftROP": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateRemotePublish": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateReview": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateUSD": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateUSDModel": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "USDCreateShadingWorkspace": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateUSDRender": { "enabled": false, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateVDBCache": { "enabled": true, - "default_variant": "Main", - "default_variants": ["Main"] + "default_variants": ["main"] }, "CreateVrayROP": { "enabled": true, - "default_variant": "master", - "default_variants": ["master"] + "default_variants": ["main"] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index f53501c7ac..e1c6d2d827 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -521,21 +521,18 @@ "enabled": true, "make_tx": true, "rs_tex": false, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRender": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateUnrealStaticMesh": { "enabled": true, - "default_variant": "", "default_variants": [ "", "_Main" @@ -550,7 +547,6 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, - "default_variant": "", "default_variants": [], "joint_hints": "jnt_org" }, @@ -559,12 +555,10 @@ "publish_mip_map": true }, "CreateAnimation": { - "enabled": false, "write_color_sets": false, "write_face_sets": false, "include_parent_hierarchy": false, "include_user_defined_attributes": false, - "default_variant": "Main", "default_variants": [ "Main" ] @@ -573,7 +567,6 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "default_variant": "Main", "default_variants": [ "Main", "Proxy", @@ -585,7 +578,6 @@ "write_color_sets": false, "write_face_sets": false, "include_user_defined_attributes": false, - "default_variant": "Main", "default_variants": [ "Main" ] @@ -594,14 +586,12 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateReview": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ], @@ -609,7 +599,6 @@ }, "CreateAss": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ], @@ -632,70 +621,60 @@ "enabled": true, "vrmesh": true, "alembic": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsd": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateAssembly": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateCamera": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateLayout": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateMayaScene": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateRig": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main", "Sim", @@ -704,7 +683,6 @@ }, "CreateSetDress": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main", "Anim" @@ -712,14 +690,12 @@ }, "CreateVRayScene": { "enabled": true, - "default_variant": "Main", "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": true, - "default_variant": "Main", "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 6e1eaf7146..a1736c811d 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 @@ -16,11 +16,6 @@ "key": "enabled", "label": "Enabled" }, - { - "type": "text", - "key": "default_variant", - "label": "Default variant" - }, { "type": "list", "key": "default_variants", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 7384060625..3d2ed9f3d4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -11,11 +11,6 @@ "key": "enabled", "label": "Enabled" }, - { - "type": "text", - "key": "default_variant", - "label": "Default variant" - }, { "type": "list", "key": "default_variants", From d06b9dcdca4e6af00789a77806ed67f240bea4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:21:02 +0200 Subject: [PATCH 046/291] Remove fallback default_variant from Houdini base creator --- openpype/hosts/houdini/api/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 3d3b0e49b9..70c837205e 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -169,8 +169,6 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): selected_nodes = [] settings_name = None - default_variant = "Main" - def create(self, subset_name, instance_data, pre_create_data): try: self.selected_nodes = [] From 0703c1c0d18206966cbb37885f9dc96b0e4336aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Tue, 8 Aug 2023 20:22:08 +0200 Subject: [PATCH 047/291] Remove default_variant from Maya schema --- .../schemas/projects_schema/schemas/schema_maya_create.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index a8105bdb5d..8dec0a8817 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -387,11 +387,6 @@ "key": "alembic", "label": "Alembic" }, - { - "type": "text", - "key": "default_variant", - "label": "Default variant" - }, { "type": "list", "key": "default_variants", From ef50ba5130d6be0b6f709ef60aa49181c67016a4 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Wed, 9 Aug 2023 15:25:48 +0200 Subject: [PATCH 048/291] Remove schema setting changes from PR --- .../defaults/project_settings/houdini.json | 76 ++++-------- .../defaults/project_settings/maya.json | 26 ++-- .../schemas/schema_houdini_create.json | 113 +++++++----------- .../schemas/schema_maya_create_render.json | 2 +- .../schemas/template_create_plugin.json | 4 +- 5 files changed, 79 insertions(+), 142 deletions(-) diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 512690bfd7..a5256aad8b 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,80 +14,48 @@ "create": { "CreateArnoldAss": { "enabled": true, - "default_variants": ["main"], + "default_variants": [], "ext": ".ass" }, - "CreateArnoldRop": { - "enabled": true, - "default_variants": ["main"] - }, "CreateAlembicCamera": { "enabled": true, - "default_variants": ["main"] - }, - "CreateBGEO": { - "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreateCompositeSequence": { "enabled": true, - "default_variants": ["main"] - }, - "CreateHDA": { - "enabled": true, - "default_variants": ["main"] - }, - "CreateKarmaROP": { - "enabled": true, - "default_variants": ["main"] - }, - "CreateMantraROP": { - "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreatePointCache": { "enabled": true, - "default_variants": ["main"] - }, - "CreateRedshiftProxy": { - "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreateRedshiftROP": { "enabled": true, - "default_variants": ["main"] + "defaults": [] }, "CreateRemotePublish": { "enabled": true, - "default_variants": ["main"] - }, - "CreateReview": { - "enabled": true, - "default_variants": ["main"] - }, - "CreateUSD": { - "enabled": false, - "default_variants": ["main"] - }, - "CreateUSDModel": { - "enabled": false, - "default_variants": ["main"] - }, - "USDCreateShadingWorkspace": { - "enabled": false, - "default_variants": ["main"] - }, - "CreateUSDRender": { - "enabled": false, - "default_variants": ["main"] + "defaults": [] }, "CreateVDBCache": { "enabled": true, - "default_variants": ["main"] + "defaults": [] }, - "CreateVrayROP": { - "enabled": true, - "default_variants": ["main"] + "CreateUSD": { + "enabled": false, + "defaults": [] + }, + "CreateUSDModel": { + "enabled": false, + "defaults": [] + }, + "USDCreateShadingWorkspace": { + "enabled": false, + "defaults": [] + }, + "CreateUSDRender": { + "enabled": false, + "defaults": [] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index e1c6d2d827..342d2bfb2a 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -527,7 +527,7 @@ }, "CreateRender": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, @@ -627,55 +627,55 @@ }, "CreateMultiverseUsd": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateAssembly": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateCamera": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateLayout": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateMayaScene": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateRenderSetup": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateRig": { "enabled": true, - "default_variants": [ + "defaults": [ "Main", "Sim", "Cloth" @@ -683,20 +683,20 @@ }, "CreateSetDress": { "enabled": true, - "default_variants": [ + "defaults": [ "Main", "Anim" ] }, "CreateVRayScene": { "enabled": true, - "default_variants": [ + "defaults": [ "Main" ] }, "CreateYetiRig": { "enabled": true, - "default_variants": [ + "defaults": [ "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 a1736c811d..4eb976d7b6 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 @@ -1,83 +1,60 @@ { - "type": "dict", - "collapsible": true, - "key": "create", - "label": "Creator plugins", - "children": [ - { - "type": "dict", - "collapsible": true, - "key": "CreateArnoldAss", - "label": "Create Arnold Ass", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "default_variants", - "label": "Default Subsets", - "object_type": "text" - }, - { - "type": "enum", - "key": "ext", - "label": "Default Output Format (extension)", - "multiselection": false, - "enum_items": [ - { - ".ass": ".ass" - }, - { - ".ass.gz": ".ass.gz (gzipped)" - } - ] - } - ] + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateArnoldAss", + "label": "Create Arnold Ass", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "enum", + "key": "ext", + "label": "Default Output Format (extension)", + "multiselection": false, + "enum_items": [ + { + ".ass": ".ass" + }, + { + ".ass.gz": ".ass.gz (gzipped)" + } + ] + } + ] + }, { "type": "schema_template", "name": "template_create_plugin", "template_data": [ - { - "key": "CreateArnoldRop", - "label": "Create Arnold ROP" - }, { "key": "CreateAlembicCamera", "label": "Create Alembic Camera" }, - { - "key": "CreateBGEO", - "label": "Create Houdini BGEO" - }, { "key": "CreateCompositeSequence", "label": "Create Composite (Image Sequence)" }, - { - "key": "CreateKarmaROP", - "label": "Create Karma ROP" - }, - { - "key": "CreateMantraROP", - "label": "Create Mantra ROP" - }, - { - "key": "CreateHDA", - "label": "Create HDA" - }, { "key": "CreatePointCache", "label": "Create Point Cache" }, - { - "key": "CreateRedshiftProxy", - "label": "Create Redshift Proxy" - }, { "key": "CreateRedshiftROP", "label": "Create Redshift ROP" @@ -87,8 +64,8 @@ "label": "Create Remote Publish" }, { - "key": "CreateReview", - "label": "Create Review" + "key": "CreateVDBCache", + "label": "Create VDB Cache" }, { "key": "CreateUSD", @@ -105,14 +82,6 @@ { "key": "CreateUSDRender", "label": "Create USD Render" - }, - { - "key": "CreateVDBCache", - "label": "Create VDB Cache" - }, - { - "key": "CreateVrayROP", - "label": "Create VRay ROP" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json index 9d7432fe51..bc203a0514 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json @@ -12,7 +12,7 @@ }, { "type": "list", - "key": "default_variants", + "key": "defaults", "label": "Default Subsets", "object_type": "text" } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json index 3d2ed9f3d4..14d15e7840 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_create_plugin.json @@ -13,8 +13,8 @@ }, { "type": "list", - "key": "default_variants", - "label": "Default Variants", + "key": "defaults", + "label": "Default Subsets", "object_type": "text" } ] From a9fd8349fc27b4744b19d6a125a25440de973917 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Wed, 9 Aug 2023 15:27:08 +0200 Subject: [PATCH 049/291] Remove whitespace differences --- .../schemas/schema_houdini_create.json | 170 +++++++++--------- 1 file changed, 85 insertions(+), 85 deletions(-) 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 4eb976d7b6..64d157d281 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 @@ -1,89 +1,89 @@ { - "type": "dict", - "collapsible": true, - "key": "create", - "label": "Creator plugins", - "children": [ - { - "type": "dict", - "collapsible": true, - "key": "CreateArnoldAss", - "label": "Create Arnold Ass", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "default_variants", - "label": "Default Subsets", - "object_type": "text" - }, - { - "type": "enum", - "key": "ext", - "label": "Default Output Format (extension)", - "multiselection": false, - "enum_items": [ - { - ".ass": ".ass" - }, - { - ".ass.gz": ".ass.gz (gzipped)" - } - ] - } - ] + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateArnoldAss", + "label": "Create Arnold Ass", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "default_variants", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "enum", + "key": "ext", + "label": "Default Output Format (extension)", + "multiselection": false, + "enum_items": [ + { + ".ass": ".ass" + }, + { + ".ass.gz": ".ass.gz (gzipped)" + } + ] + } + ] - }, - { - "type": "schema_template", - "name": "template_create_plugin", - "template_data": [ - { - "key": "CreateAlembicCamera", - "label": "Create Alembic Camera" }, - { - "key": "CreateCompositeSequence", - "label": "Create Composite (Image Sequence)" - }, - { - "key": "CreatePointCache", - "label": "Create Point Cache" - }, - { - "key": "CreateRedshiftROP", - "label": "Create Redshift ROP" - }, - { - "key": "CreateRemotePublish", - "label": "Create Remote Publish" - }, - { - "key": "CreateVDBCache", - "label": "Create VDB Cache" - }, - { - "key": "CreateUSD", - "label": "Create USD" - }, - { - "key": "CreateUSDModel", - "label": "Create USD Model" - }, - { - "key": "USDCreateShadingWorkspace", - "label": "Create USD Shading Workspace" - }, - { - "key": "CreateUSDRender", - "label": "Create USD Render" - } - ] - } - ] + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateAlembicCamera", + "label": "Create Alembic Camera" + }, + { + "key": "CreateCompositeSequence", + "label": "Create Composite (Image Sequence)" + }, + { + "key": "CreatePointCache", + "label": "Create Point Cache" + }, + { + "key": "CreateRedshiftROP", + "label": "Create Redshift ROP" + }, + { + "key": "CreateRemotePublish", + "label": "Create Remote Publish" + }, + { + "key": "CreateVDBCache", + "label": "Create VDB Cache" + }, + { + "key": "CreateUSD", + "label": "Create USD" + }, + { + "key": "CreateUSDModel", + "label": "Create USD Model" + }, + { + "key": "USDCreateShadingWorkspace", + "label": "Create USD Shading Workspace" + }, + { + "key": "CreateUSDRender", + "label": "Create USD Render" + } + ] + } + ] } From 08e86b50795a3b3cd9fbe5d9a8d8984233cce97e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 15:37:31 +0200 Subject: [PATCH 050/291] nuke: review was not added to families --- .../nuke/plugins/publish/collect_nuke_instance_data.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py index ffcf49d697..edd7a5cf27 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -43,13 +43,14 @@ class CollectNukeInstanceData(pyblish.api.InstancePlugin): }) - # add review family if review activated on instance - if instance.data.get("review"): - instance.data["families"].append("review") - # add creator attributes to instance creator_attributes = instance.data["creator_attributes"] instance.data.update(creator_attributes) + # add review family if review activated on instance + if instance.data.get("review"): + instance.data["families"].append("review") + + self.log.debug("Collected instance: {}".format( instance.data)) From 7b3d0b4f6d0686a44231d685da1fa70fe6e123d7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 15:38:21 +0200 Subject: [PATCH 051/291] deadline validations are activated for nuke render --- .../deadline/plugins/publish/validate_deadline_connection.py | 2 +- .../modules/deadline/plugins/publish/validate_deadline_pools.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index d5016a4d82..a30401e7dc 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -10,7 +10,7 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): label = "Validate Deadline Web Service" order = pyblish.api.ValidatorOrder hosts = ["maya", "nuke"] - families = ["renderlayer"] + families = ["renderlayer", "render"] def process(self, instance): # get default deadline webservice url from deadline module diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index e1c0595830..594f0ef866 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -19,6 +19,7 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, order = pyblish.api.ValidatorOrder families = ["rendering", "render.farm", + "render.frames_farm", "renderFarm", "renderlayer", "maxrender"] From 84480649c8643589085d900e2f664457adfe2bb8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 15:45:13 +0200 Subject: [PATCH 052/291] nuke: implementing `frame_farm` target to deadline submitter --- .../plugins/publish/submit_nuke_deadline.py | 95 ++++++++++++------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 93c6ad8139..cfdeb4968b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -90,7 +90,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return - instance.data["attributeValues"] = self.get_attr_values_from_data( instance.data) @@ -123,13 +122,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, render_path = instance.data['path'] script_path = context.data["currentFile"] - for item in context: - if "workfile" in item.data["families"]: - msg = "Workfile (scene) must be published along" - assert item.data["publish"] is True, msg - - template_data = item.data.get("anatomyData") - rep = item.data.get("representations")[0].get("name") + for item_ in context: + if "workfile" in item_.data["family"]: + template_data = item_.data.get("anatomyData") + rep = item_.data.get("representations")[0].get("name") template_data["representation"] = rep template_data["ext"] = rep template_data["comment"] = None @@ -141,19 +137,24 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "Using published scene for render {}".format(script_path) ) - response = self.payload_submit( - instance, - script_path, - render_path, - node.name(), - submit_frame_start, - submit_frame_end - ) - # Store output dir for unified publisher (filesequence) - instance.data["deadlineSubmissionJob"] = response.json() - instance.data["outputDir"] = os.path.dirname( - render_path).replace("\\", "/") - instance.data["publishJobState"] = "Suspended" + # only add main rendering job if target is not frames_farm + r_job_response_json = None + if instance.data["render_target"] != "frames_farm": + r_job_response = self.payload_submit( + instance, + script_path, + render_path, + node.name(), + submit_frame_start, + submit_frame_end + ) + r_job_response_json = r_job_response.json() + instance.data["deadlineSubmissionJob"] = r_job_response_json + + # Store output dir for unified publisher (filesequence) + instance.data["outputDir"] = os.path.dirname( + render_path).replace("\\", "/") + instance.data["publishJobState"] = "Suspended" if instance.data.get("bakingNukeScripts"): for baking_script in instance.data["bakingNukeScripts"]: @@ -161,18 +162,20 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, script_path = baking_script["bakeScriptPath"] exe_node_name = baking_script["bakeWriteNodeName"] - resp = self.payload_submit( + b_job_response = self.payload_submit( instance, script_path, render_path, exe_node_name, submit_frame_start, submit_frame_end, - response.json() + r_job_response_json, + baking_submission=True ) # Store output dir for unified publisher (filesequence) - instance.data["deadlineSubmissionJob"] = resp.json() + instance.data["deadlineSubmissionJob"] = b_job_response.json() + instance.data["publishJobState"] = "Suspended" # add to list of job Id @@ -180,7 +183,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, instance.data["bakingSubmissionJobs"] = [] instance.data["bakingSubmissionJobs"].append( - resp.json()["_id"]) + b_job_response.json()["_id"]) # redefinition of families if "render" in instance.data["family"]: @@ -199,15 +202,35 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, exe_node_name, start_frame, end_frame, - response_data=None + response_data=None, + baking_submission=False, ): + """Submit payload to Deadline + + Args: + instance (pyblish.api.Instance): pyblish instance + script_path (str): path to nuke script + render_path (str): path to rendered images + exe_node_name (str): name of the node to render + start_frame (int): start frame + end_frame (int): end frame + response_data Optional[dict]: response data from + previous submission + baking_submission Optional[bool]: if it's baking submission + + Returns: + requests.Response + """ render_dir = os.path.normpath(os.path.dirname(render_path)) - batch_name = os.path.basename(script_path) - jobname = "%s - %s" % (batch_name, instance.name) + + # batch name + src_filepath = instance.context.data["currentFile"] + batch_name = os.path.basename(src_filepath) + job_name = os.path.basename(render_path) + if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - output_filename_0 = self.preview_fname(render_path) if not response_data: @@ -228,11 +251,8 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, # Top-level group name "BatchName": batch_name, - # Asset dependency to wait for at least the scene file to sync. - # "AssetDependency0": script_path, - # Job name, as seen in Monitor - "Name": jobname, + "Name": job_name, # Arbitrary username, for visualisation in Monitor "UserName": self._deadline_user, @@ -294,12 +314,17 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "AuxFiles": [] } - if response_data.get("_id"): + # TODO: rewrite for baking with sequences + if baking_submission: payload["JobInfo"].update({ "JobType": "Normal", + "ChunkSize": 99999999 + }) + + if response_data.get("_id"): + payload["JobInfo"].update({ "BatchName": response_data["Props"]["Batch"], "JobDependency0": response_data["_id"], - "ChunkSize": 99999999 }) # Include critical environment variables with submission From 42d766ed91f5a82e91d24ae4ca17bd5ed3c4f573 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 16:38:31 +0200 Subject: [PATCH 053/291] nuke: collect slate fixed order --- .../nuke/plugins/publish/collect_slate_node.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_slate_node.py b/openpype/hosts/nuke/plugins/publish/collect_slate_node.py index 5701087697..c7d65ffd24 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_slate_node.py +++ b/openpype/hosts/nuke/plugins/publish/collect_slate_node.py @@ -5,7 +5,7 @@ import nuke class CollectSlate(pyblish.api.InstancePlugin): """Check if SLATE node is in scene and connected to rendering tree""" - order = pyblish.api.CollectorOrder + 0.09 + order = pyblish.api.CollectorOrder + 0.002 label = "Collect Slate Node" hosts = ["nuke"] families = ["render"] @@ -13,10 +13,14 @@ class CollectSlate(pyblish.api.InstancePlugin): def process(self, instance): node = instance.data["transientData"]["node"] - slate = next((n for n in nuke.allNodes() - if "slate" in n.name().lower() - if not n["disable"].getValue()), - None) + slate = next( + ( + n_ for n_ in nuke.allNodes() + if "slate" in n_.name().lower() + if not n_["disable"].getValue() + ), + None + ) if slate: # check if slate node is connected to write node tree From c8e66fd632323416a63c0c9d1bf248e516f2c2be Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 9 Aug 2023 20:34:05 +0300 Subject: [PATCH 054/291] resolve some conversations --- .../publish/validate_review_colorspace.py | 16 ++++++++-------- openpype/pipeline/colorspace.py | 4 +++- openpype/scripts/ocio_wrapper.py | 4 +++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 67e29e0ee2..e493349946 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -4,6 +4,9 @@ from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction +import os +import hou + class SetDefaultViewSpaceAction(RepairAction): label = "Set default view space" @@ -32,8 +35,6 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - import hou # noqa - import os rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: @@ -70,16 +71,15 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): used to set colorspace on opengl node to the default view. """ - import hou - from openpype.pipeline.colorspace import get_display_view_colorspace_name #noqa - from openpype.hosts.houdini.api.lib import get_color_management_preferences #noqa + from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa + from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa rop_node = hou.node(instance.data["instance_node"]) - data = get_color_management_preferences() + data = get_color_management_preferences() config_path = data.get("config") - display = data.get("display") - view = data.get("view") + display = data.get("display") + view = data.get("view") cls.log.debug("Get default view colorspace name..") diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 22e8175a7e..d84424270c 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -590,6 +590,7 @@ def _get_imageio_settings(project_settings, host_name): return imageio_global, imageio_host + def get_display_view_colorspace_name(config_path, display, view): """get view colorspace name for the given display and view. @@ -608,10 +609,11 @@ def get_display_view_colorspace_name(config_path, display, view): return get_display_view_colorspace_subprocess(config_path, display, view) - from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name #noqa + from openpype.scripts.ocio_wrapper import _get_display_view_colorspace_name # noqa return _get_display_view_colorspace_name(config_path, display, view) + def get_display_view_colorspace_subprocess(config_path, display, view): """get view colorspace name for the given display and view via subprocess. diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index f94faabe11..556568ce20 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -173,6 +173,7 @@ def _get_views_data(config_path): return data + def _get_display_view_colorspace_name(config_path, display, view): """get view colorspace name for the given display and view. @@ -199,6 +200,7 @@ def _get_display_view_colorspace_name(config_path, display, view): return colorspace + @config.command( name="get_display_view_colorspace_name", help=( @@ -223,7 +225,7 @@ def get_display_view_colorspace_name(in_path, out_path, display, view): """Aggregate view colorspace name to file. - Python 2 wrapped console command + Wrapper command for processes without acces to OpenColorIO Args: in_path (str): config file path string From 877facc5b89317a0fc541d2f06a9d9611c9e1acb Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 9 Aug 2023 21:56:42 +0300 Subject: [PATCH 055/291] set default colorspace on creation if there's OCIO --- .../houdini/plugins/create/create_review.py | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index ab06b30c35..797116aaca 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -3,6 +3,9 @@ from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef +import os +import hou + class CreateReview(plugin.HoudiniCreator): """Review with OpenGL ROP""" @@ -13,7 +16,6 @@ class CreateReview(plugin.HoudiniCreator): icon = "video-camera" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) instance_data.update({"node_type": "opengl"}) @@ -82,6 +84,10 @@ class CreateReview(plugin.HoudiniCreator): instance_node.setParms(parms) + # Set OCIO Colorspace to the default output colorspace + # if there's OCIO + self.set_colorcorrect_to_default_view_space(instance_node) + to_lock = ["id", "family"] self.lock_parameters(instance_node, to_lock) @@ -123,3 +129,46 @@ class CreateReview(plugin.HoudiniCreator): minimum=0.0001, decimals=3) ] + + def set_colorcorrect_to_default_view_space(self, + instance_node): + """Set ociocolorspace to the default output space.""" + + if os.getenv("OCIO") is None: + # No OCIO, skip setting ociocolorspace + return + + # if there's OCIO then set Color Correction parameter + # to OpenColorIO + instance_node.setParms({"colorcorrect": 2}) + + self.log.debug("Get default view colorspace name..") + + default_view_space = self.get_default_view_space() + instance_node.setParms( + {"ociocolorspace": default_view_space} + ) + + self.log.debug( + "'OCIO Colorspace' parm on '{}' has been set to " + "the default view color space '{}'" + .format(instance_node, default_view_space) + ) + + return default_view_space + + def get_default_view_space(self): + """Get default view space for ociocolorspace parm.""" + + from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa + from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa + + data = get_color_management_preferences() + config_path = data.get("config") + display = data.get("display") + view = data.get("view") + + default_view_space = get_display_view_colorspace_name(config_path, + display, view) + + return default_view_space From cff92425676acef62fc6b8489517e66286072925 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 9 Aug 2023 21:57:57 +0300 Subject: [PATCH 056/291] use .format instead of %s --- .../publish/validate_review_colorspace.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index e493349946..5390b6b52f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -49,17 +49,17 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): # 'Color Correct' parm to be set to 'OpenColorIO' rop_node.setParms({"colorcorrect": 2}) cls.log.debug( - "'Color Correct' parm on '%s' has been set to" - " 'OpenColorIO'", rop_node + "'Color Correct' parm on '{}' has been set to" + " 'OpenColorIO'".format(rop_node) ) if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): cls.log.error( - "'OCIO Colorspace' value on '%s' is not valid, " - "select a valid option from the dropdown menu.", - rop_node + "'OCIO Colorspace' value on '{}' is not valid, " + "select a valid option from the dropdown menu." + .format(rop_node) ) return rop_node @@ -88,7 +88,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( - "'OCIO Colorspace' parm on '%s' has been set to " - "the default view color space '%s'", - default_view_space, rop_node + "'OCIO Colorspace' parm on '{}' has been set to " + "the default view color space '{}'" + .formate(rop_node, default_view_space) + ) From 67840465ab7cdd2c9f282b13324d53620cbce60d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 14 Aug 2023 14:36:35 +0800 Subject: [PATCH 057/291] update the attribute after OP Param update --- openpype/hosts/max/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 82470dd510..36c29ddbbb 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -194,4 +194,4 @@ def load_OpenpypeData(container, loaded_nodes): # Setting the property rt.setProperty( - container.openPypeData, "all_handles", node_list) + container.modifiers[0].openPypeData, "all_handles", node_list) From cb086d113ec10131663d17d3a9e06495558f840d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 14 Aug 2023 18:06:23 +0800 Subject: [PATCH 058/291] clean up the load OpenpypeData code --- openpype/hosts/max/api/pipeline.py | 25 ++++--------------- .../hosts/max/plugins/load/load_camera_fbx.py | 4 +-- openpype/hosts/max/plugins/load/load_model.py | 11 +++----- .../hosts/max/plugins/load/load_model_fbx.py | 4 +-- .../hosts/max/plugins/load/load_model_obj.py | 4 +-- .../hosts/max/plugins/load/load_model_usd.py | 6 ++--- .../hosts/max/plugins/load/load_pointcache.py | 8 ++---- .../hosts/max/plugins/load/load_pointcloud.py | 4 +-- .../max/plugins/load/load_redshift_proxy.py | 4 +-- 9 files changed, 22 insertions(+), 48 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 36c29ddbbb..602b506ef0 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -174,24 +174,9 @@ def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): return container -def load_OpenpypeData(container, loaded_nodes): - """Function to load the OpenpypeData Parameter along with - the published objects - - Args: - container (str): target container to set up - the custom attributes - loaded_nodes (list): list of nodes to be loaded +def load_OpenpypeData(): + """Re-loading the Openpype parameter built by the creator + Returns: + attribute: re-loading the custom OP attributes set in Maxscript """ - attrs = rt.Execute(MS_CUSTOM_ATTRIB) - if rt.custAttributes.get(container.baseObject, attrs): - rt.custAttributes.delete(container.baseObject, attrs) - rt.custAttributes.add(container.baseObject, attrs) - node_list = [] - for i in loaded_nodes: - node_ref = rt.NodeTransformMonitor(node=i) - node_list.append(node_ref) - - # Setting the property - rt.setProperty( - container.modifiers[0].openPypeData, "all_handles", node_list) + return rt.Execute(MS_CUSTOM_ATTRIB) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 6b16bfe474..e7aa482b2e 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -33,7 +33,7 @@ class FbxLoader(load.LoaderPlugin): container = rt.Container() container.name = f"{name}" selections = rt.GetCurrentSelection() - load_OpenpypeData(container, selections) + load_OpenpypeData() for selection in selections: selection.Parent = container @@ -52,7 +52,7 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData(node, node.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index efd758063d..e987e5e900 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -45,10 +45,7 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - selections = rt.GetCurrentSelection() - abc_selections = [abc for abc in selections - if abc.name != "Alembic"] - load_OpenpypeData(abc_container, abc_selections) + load_OpenpypeData() return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -61,7 +58,6 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node.Children) nodes_list = [] - abc_object = None with maintained_selection(): rt.Select(node) @@ -77,9 +73,8 @@ class ModelAbcLoader(load.LoaderPlugin): alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) - abc_selections = [abc for abc in nodes_list - if abc.name != "Alembic"] - load_OpenpypeData(abc_object, abc_selections) + + load_OpenpypeData() lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 8f2b4f4ac3..76c2639388 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -29,7 +29,7 @@ class FbxModelLoader(load.LoaderPlugin): container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData(container, selections) + load_OpenpypeData() for selection in selections: selection.Parent = container @@ -50,7 +50,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData(container, node.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 83b5ec49b9..5a7181f438 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -26,7 +26,7 @@ class ObjLoader(load.LoaderPlugin): container = rt.Container() container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData(container, selections) + load_OpenpypeData() # get current selection for selection in selections: selection.Parent = container @@ -53,7 +53,7 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.Parent = container - load_OpenpypeData(container, container.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index a1961e6d89..0e275dd02e 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -30,10 +30,8 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - selections = rt.GetCurrentSelection() asset = rt.GetNodeByName(name) - mesh_selections = [r for r in selections if r != asset] - load_OpenpypeData(asset, mesh_selections) + load_OpenpypeData() return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -62,7 +60,7 @@ class ModelUSDLoader(load.LoaderPlugin): asset = rt.GetNodeByName(instance_name) asset.Parent = node - load_OpenpypeData(asset, asset.Children) + load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 7af588566e..dda57add69 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -49,9 +49,7 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - abc_selections = [abc for abc in selections - if abc.name != "Alembic"] - load_OpenpypeData(abc_container, abc_selections) + load_OpenpypeData() for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -72,7 +70,6 @@ class AbcLoader(load.LoaderPlugin): {"representation": str(representation["_id"])}, ) nodes_list = [] - abc_object = None with maintained_selection(): rt.Select(node.Children) @@ -88,8 +85,7 @@ class AbcLoader(load.LoaderPlugin): alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) - abc_selections = [abc for abc in nodes_list if abc.name != "Alembic"] - load_OpenpypeData(abc_object, abc_selections) + load_OpenpypeData() def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 18998f4529..8ab81d79e7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -25,7 +25,7 @@ class PointCloudLoader(load.LoaderPlugin): prt_container = rt.container() prt_container.name = name obj.Parent = prt_container - load_OpenpypeData(prt_container, [obj]) + load_OpenpypeData() return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -41,7 +41,7 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - load_OpenpypeData(node, node.Children) + load_OpenpypeData() lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index b62400d2e5..23f78d0629 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -33,7 +33,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - load_OpenpypeData(container, [rs_proxy]) + load_OpenpypeData() asset = rt.getNodeByName(name) return containerise( @@ -49,7 +49,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path - load_OpenpypeData(node, node.Children) + load_OpenpypeData() lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From fdad1a48b0cc0f460df53b6c9dc101a24b98d656 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 14 Aug 2023 18:09:02 +0800 Subject: [PATCH 059/291] Hound --- openpype/hosts/max/plugins/load/load_model.py | 2 -- openpype/hosts/max/plugins/load/load_pointcache.py | 1 - 2 files changed, 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index e987e5e900..7ba048c5e7 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -60,13 +60,11 @@ class ModelAbcLoader(load.LoaderPlugin): nodes_list = [] with maintained_selection(): rt.Select(node) - for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) - abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index dda57add69..ec379e39f7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -78,7 +78,6 @@ class AbcLoader(load.LoaderPlugin): rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) - abc_object = container container.source = path rt.Select(container.Children) for abc_obj in rt.Selection: From 256ffee407e8718ca6099c5f7185d0ec88a0ace7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 14 Aug 2023 15:54:14 +0300 Subject: [PATCH 060/291] resolve some conversations --- openpype/pipeline/colorspace.py | 2 +- openpype/scripts/ocio_wrapper.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 392089237b..a0efb5e18c 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -604,7 +604,7 @@ def _get_imageio_settings(project_settings, host_name): def get_display_view_colorspace_name(config_path, display, view): - """get view colorspace name for the given display and view. + """Return colorspace name for the given display and view. Args: config_path (str): path string leading to config.ocio diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 556568ce20..e491206ebb 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -247,7 +247,7 @@ def get_display_view_colorspace_name(in_path, out_path, with open(json_path, "w") as f: json.dump(out_data, f) - print(f"Viewer data are saved to '{json_path}'") + print(f"Display view colorspace saved to '{json_path}'") if __name__ == '__main__': main() From e666ff641b4e80dc5aaff837fffd2b0f9651f6c2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 15 Aug 2023 17:14:42 +0800 Subject: [PATCH 061/291] reload the modifiers to the container with OP Data --- openpype/hosts/max/api/pipeline.py | 27 +++++++++++++++++++ .../hosts/max/plugins/load/load_camera_fbx.py | 18 +++++++------ openpype/hosts/max/plugins/load/load_model.py | 11 ++++---- .../hosts/max/plugins/load/load_model_fbx.py | 13 ++++++--- .../hosts/max/plugins/load/load_model_obj.py | 6 ++--- .../hosts/max/plugins/load/load_model_usd.py | 8 +++--- .../hosts/max/plugins/load/load_pointcache.py | 11 +++++--- .../hosts/max/plugins/load/load_pointcloud.py | 8 +++--- .../max/plugins/load/load_redshift_proxy.py | 8 +++--- 9 files changed, 78 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 602b506ef0..6b02f06b85 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -180,3 +180,30 @@ def load_OpenpypeData(): attribute: re-loading the custom OP attributes set in Maxscript """ return rt.Execute(MS_CUSTOM_ATTRIB) + + +def import_OpenpypeData(container, selections): + attrs = load_OpenpypeData() + modifier = rt.EmptyModifier() + rt.addModifier(container, modifier) + container.modifiers[0].name = "OP Data" + rt.custAttributes.add(container.modifiers[0], attrs) + node_list = [] + sel_list = [] + for i in selections: + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + sel_list.append(str(i)) + # Setting the property + rt.setProperty( + container.modifiers[0].openPypeData, + "all_handles", node_list) + rt.setProperty( + container.modifiers[0].openPypeData, + "sel_list", sel_list) + + +def update_Openpype_Data(container, selections): + if container.modifiers[0].name == "OP Data": + rt.deleteModifier(container, container.modifiers[0]) + import_OpenpypeData(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index e7aa482b2e..7bd02e4615 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.pipeline import get_representation_path, load @@ -16,24 +18,21 @@ class FbxLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - filepath = self.filepath_from_context(context) filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(f"{name}") - if not container: - container = rt.Container() - container.name = f"{name}" + container = rt.container(name=name) selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(container, selections) for selection in selections: selection.Parent = container @@ -45,14 +44,17 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) + inst_name, _ = os.path.split(container["instance_node"]) + container = rt.getNodeByName(inst_name) rt.Select(node.Children) + update_Openpype_Data(container, rt.GetCurrentSelection()) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) + rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData() with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index 7ba048c5e7..ea60c33c19 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,6 +1,8 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -30,7 +32,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.AlembicImport.CustomAttributes = True rt.AlembicImport.UVs = True rt.AlembicImport.VertexColors = True - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c @@ -45,7 +47,7 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - load_OpenpypeData() + import_OpenpypeData(abc_container, abc_container.Children) return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -62,6 +64,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) + import_OpenpypeData(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) @@ -72,8 +75,6 @@ class ModelAbcLoader(load.LoaderPlugin): alembic_obj.source = path nodes_list.append(alembic_obj) - load_OpenpypeData() - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 76c2639388..9f80875d5b 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,6 +1,8 @@ import os from openpype.pipeline import load, get_representation_path -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -20,6 +22,7 @@ class FbxModelLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) @@ -29,7 +32,7 @@ class FbxModelLoader(load.LoaderPlugin): container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(container, selections) for selection in selections: selection.Parent = container @@ -42,15 +45,19 @@ class FbxModelLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) + inst_name, _ = os.path.splitext(container["instance_node"]) rt.select(node.Children) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) + rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - load_OpenpypeData() + + container = rt.getNodeByName(inst_name) + update_Openpype_Data(container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 5a7181f438..f4791bfbb3 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,7 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import containerise, import_OpenpypeData from openpype.pipeline import get_representation_path, load @@ -26,7 +26,7 @@ class ObjLoader(load.LoaderPlugin): container = rt.Container() container.name = name selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(container, selections) # get current selection for selection in selections: selection.Parent = container @@ -53,7 +53,7 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.Parent = container - load_OpenpypeData() + import_OpenpypeData(container, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 0e275dd02e..96b5cdedf0 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.pipeline import get_representation_path, load @@ -31,7 +33,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile(filepath, importOptions=import_options) asset = rt.GetNodeByName(name) - load_OpenpypeData() + import_OpenpypeData(asset, asset.Children) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -60,7 +62,7 @@ class ModelUSDLoader(load.LoaderPlugin): asset = rt.GetNodeByName(instance_name) asset.Parent = node - load_OpenpypeData() + update_Openpype_Data(asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index ec379e39f7..18a68732e9 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,7 +7,9 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) class AbcLoader(load.LoaderPlugin): @@ -33,7 +35,7 @@ class AbcLoader(load.LoaderPlugin): } rt.AlembicImport.ImportToRoot = False - rt.importFile(file_path, rt.name("noPrompt")) + rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) abc_after = { c @@ -49,7 +51,7 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - load_OpenpypeData() + import_OpenpypeData(abc_container, abc_container.Children) for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -75,6 +77,7 @@ class AbcLoader(load.LoaderPlugin): for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) + update_Openpype_Data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) @@ -84,7 +87,7 @@ class AbcLoader(load.LoaderPlugin): alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) - load_OpenpypeData() + def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 8ab81d79e7..2f41173bce 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.pipeline import get_representation_path, load @@ -25,7 +27,7 @@ class PointCloudLoader(load.LoaderPlugin): prt_container = rt.container() prt_container.name = name obj.Parent = prt_container - load_OpenpypeData() + import_OpenpypeData(prt_container, [obj]) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -41,7 +43,7 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - load_OpenpypeData() + update_Openpype_Data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 23f78d0629..4b488bcb7c 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,7 +5,9 @@ from openpype.pipeline import ( load, get_representation_path ) -from openpype.hosts.max.api.pipeline import containerise, load_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, import_OpenpypeData, update_Openpype_Data +) from openpype.hosts.max.api import lib @@ -33,7 +35,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - load_OpenpypeData() + import_OpenpypeData(container, [rs_proxy]) asset = rt.getNodeByName(name) return containerise( @@ -49,7 +51,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path - load_OpenpypeData() + update_Openpype_Data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 5f54f9082477162d65d9944365a285e732fad0fd Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 15 Aug 2023 17:16:14 +0800 Subject: [PATCH 062/291] reload the moddifier with OP Data in load model --- openpype/hosts/max/plugins/load/load_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index ea60c33c19..e1978e35ad 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -64,7 +64,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - import_OpenpypeData(abc, abc.Children) + update_Openpype_Data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) From cf4ce6bbc5d2684f7fbbcc4b66865dd1be2ebc98 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 15 Aug 2023 22:36:28 +0800 Subject: [PATCH 063/291] rename the loadOpenpypedata functions --- openpype/hosts/max/api/pipeline.py | 27 ++++++++++++++----- .../hosts/max/plugins/load/load_camera_fbx.py | 9 ++++--- openpype/hosts/max/plugins/load/load_model.py | 9 ++++--- .../hosts/max/plugins/load/load_model_fbx.py | 7 ++--- .../hosts/max/plugins/load/load_model_obj.py | 10 ++++--- .../hosts/max/plugins/load/load_model_usd.py | 8 +++--- .../hosts/max/plugins/load/load_pointcache.py | 9 ++++--- .../hosts/max/plugins/load/load_pointcloud.py | 8 +++--- .../max/plugins/load/load_redshift_proxy.py | 8 +++--- 9 files changed, 65 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 6b02f06b85..08ff5c6baf 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -174,16 +174,24 @@ def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): return container -def load_OpenpypeData(): - """Re-loading the Openpype parameter built by the creator +def load_custom_attribute_data(): + """Re-loading the Openpype/AYON custom parameter built by the creator + Returns: attribute: re-loading the custom OP attributes set in Maxscript """ return rt.Execute(MS_CUSTOM_ATTRIB) -def import_OpenpypeData(container, selections): - attrs = load_OpenpypeData() +def import_custom_attribute_data(container: str, selections: list): + """Importing the Openpype/AYON custom parameter built by the creator + + Args: + container (str): target container which adds custom attributes + selections (_type_): nodes to be added into + group in custom attributes + """ + attrs = load_custom_attribute_data() modifier = rt.EmptyModifier() rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" @@ -203,7 +211,14 @@ def import_OpenpypeData(container, selections): "sel_list", sel_list) -def update_Openpype_Data(container, selections): +def update_custom_attribute_data(container: str, selections: list): + """Updating the Openpype/AYON custom parameter built by the creator + + Args: + container (str): target container which adds custom attributes + selections (_type_): nodes to be added into + group in custom attributes + """ if container.modifiers[0].name == "OP Data": rt.deleteModifier(container, container.modifiers[0]) - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 7bd02e4615..1e4e5b3e91 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -32,7 +34,7 @@ class FbxLoader(load.LoaderPlugin): container = rt.container(name=name) selections = rt.GetCurrentSelection() - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) for selection in selections: selection.Parent = container @@ -47,7 +49,8 @@ class FbxLoader(load.LoaderPlugin): inst_name, _ = os.path.split(container["instance_node"]) container = rt.getNodeByName(inst_name) rt.Select(node.Children) - update_Openpype_Data(container, rt.GetCurrentSelection()) + update_custom_attribute_data( + container, rt.GetCurrentSelection()) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("Mode", rt.Name("merge")) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index e1978e35ad..f71e4e8f7f 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,7 +1,9 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -47,7 +49,8 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - import_OpenpypeData(abc_container, abc_container.Children) + import_custom_attribute_data( + abc_container, abc_container.Children) return containerise( name, [abc_container], context, loader=self.__class__.__name__ ) @@ -64,7 +67,7 @@ class ModelAbcLoader(load.LoaderPlugin): rt.Select(node) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_Openpype_Data(abc, abc.Children) + update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 9f80875d5b..26520307c9 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, import_custom_attribute_data, update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -32,7 +32,7 @@ class FbxModelLoader(load.LoaderPlugin): container.name = name selections = rt.GetCurrentSelection() - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) for selection in selections: selection.Parent = container @@ -57,7 +57,8 @@ class FbxModelLoader(load.LoaderPlugin): rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) container = rt.getNodeByName(inst_name) - update_Openpype_Data(container, rt.GetCurrentSelection()) + update_custom_attribute_data( + container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index f4791bfbb3..05f37f9e5a 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,11 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection -from openpype.hosts.max.api.pipeline import containerise, import_OpenpypeData +from openpype.hosts.max.api.pipeline import ( + containerise, + import_custom_attribute_data, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -26,7 +30,7 @@ class ObjLoader(load.LoaderPlugin): container = rt.Container() container.name = name selections = rt.GetCurrentSelection() - import_OpenpypeData(container, selections) + import_custom_attribute_data(container, selections) # get current selection for selection in selections: selection.Parent = container @@ -53,7 +57,7 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.Parent = container - import_OpenpypeData(container, selections) + update_custom_attribute_data(container, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 96b5cdedf0..425b152278 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -3,7 +3,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -33,7 +35,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile(filepath, importOptions=import_options) asset = rt.GetNodeByName(name) - import_OpenpypeData(asset, asset.Children) + import_custom_attribute_data(asset, asset.Children) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -62,7 +64,7 @@ class ModelUSDLoader(load.LoaderPlugin): asset = rt.GetNodeByName(instance_name) asset.Parent = node - update_Openpype_Data(asset, asset.Children) + update_custom_attribute_data(asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 18a68732e9..0ec9fda3d5 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -8,7 +8,9 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) @@ -51,7 +53,8 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - import_OpenpypeData(abc_container, abc_container.Children) + import_custom_attribute_data( + abc_container, abc_container.Children) for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -77,7 +80,7 @@ class AbcLoader(load.LoaderPlugin): for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_Openpype_Data(abc, abc.Children) + update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: container = rt.GetNodeByName(abc_con.name) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2f41173bce..c263019beb 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -27,7 +29,7 @@ class PointCloudLoader(load.LoaderPlugin): prt_container = rt.container() prt_container.name = name obj.Parent = prt_container - import_OpenpypeData(prt_container, [obj]) + import_custom_attribute_data(prt_container, [obj]) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -43,7 +45,7 @@ class PointCloudLoader(load.LoaderPlugin): for prt in rt.Selection: prt_object = rt.GetNodeByName(prt.name) prt_object.filename = path - update_Openpype_Data(node, node.Children) + update_custom_attribute_data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 4b488bcb7c..6b100df611 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -6,7 +6,9 @@ from openpype.pipeline import ( get_representation_path ) from openpype.hosts.max.api.pipeline import ( - containerise, import_OpenpypeData, update_Openpype_Data + containerise, + import_custom_attribute_data, + update_custom_attribute_data ) from openpype.hosts.max.api import lib @@ -35,7 +37,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): container = rt.container() container.name = name rs_proxy.Parent = container - import_OpenpypeData(container, [rs_proxy]) + import_custom_attribute_data(container, [rs_proxy]) asset = rt.getNodeByName(name) return containerise( @@ -51,7 +53,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): for proxy in children_node.Children: proxy.file = path - update_Openpype_Data(node, node.Children) + update_custom_attribute_data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 8b0ba25c37d177b7b6a43f3536d3f98e9eb67898 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 14:12:58 +0800 Subject: [PATCH 064/291] add load maxscene family --- .../hosts/max/plugins/load/load_max_scene.py | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 76cd3bf367..637659ed44 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,10 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.pipeline import ( + containerise, import_custom_attribute_data, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -19,36 +22,43 @@ class MaxSceneLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - path = self.filepath_from_context(context) path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path) + rt.MergeMaxFile(path, quiet=True) max_objects = rt.getLastMergedNodes() - max_container = rt.Container(name=f"{name}") - for max_object in max_objects: - max_object.Parent = max_container - + # implement the OP/AYON custom attributes before load + max_container = [] + container = rt.Container(name=name) + import_custom_attribute_data(container, max_objects) + max_container.append(container) + max_container.extend(max_objects) return containerise( - name, [max_container], context, loader=self.__class__.__name__) + name, max_container, context, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) node_name = container["instance_node"] - - rt.MergeMaxFile(path, - rt.Name("noRedraw"), - rt.Name("deleteOldDups"), - rt.Name("useSceneMtlDups")) - + node = rt.GetNodeByName(node_name) + inst_name, _ = os.path.splitext(node_name) + old_container = rt.getNodeByName(inst_name) + # delete the old container with attribute + # delete old duplicate + rt.Delete(old_container) + rt.MergeMaxFile(path, rt.Name("deleteOldDups")) + new_container = rt.Container(name=inst_name) max_objects = rt.getLastMergedNodes() - container_node = rt.GetNodeByName(node_name) - for max_object in max_objects: - max_object.Parent = container_node + max_objects_list = [] + max_objects_list.append(new_container) + max_objects_list.extend(max_objects) + + for max_object in max_objects_list: + max_object.Parent = node + update_custom_attribute_data(new_container, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 0825afa73a0f4a706d3b091cfb068565381d5a40 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 14:22:14 +0800 Subject: [PATCH 065/291] add includedfullgroup support for merging scene in max scene family --- openpype/hosts/max/plugins/load/load_max_scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 637659ed44..7bbc6419b8 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -26,7 +26,7 @@ class MaxSceneLoader(load.LoaderPlugin): path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile(path, quiet=True) + rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() # implement the OP/AYON custom attributes before load max_container = [] From 86f86db4f84db54eecd127cbac528f3f9752107e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 17:55:07 +0800 Subject: [PATCH 066/291] also resolves OP-6526_3dsMax-loading-an-asset-multiple-times --- openpype/hosts/max/api/pipeline.py | 4 ++-- openpype/hosts/max/plugins/load/load_max_scene.py | 6 +++++- openpype/hosts/max/plugins/load/load_model_fbx.py | 6 +----- openpype/hosts/max/plugins/load/load_model_obj.py | 8 +++----- openpype/hosts/max/plugins/load/load_model_usd.py | 1 + 5 files changed, 12 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 08ff5c6baf..f58bd05a13 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -188,7 +188,7 @@ def import_custom_attribute_data(container: str, selections: list): Args: container (str): target container which adds custom attributes - selections (_type_): nodes to be added into + selections (list): nodes to be added into group in custom attributes """ attrs = load_custom_attribute_data() @@ -216,7 +216,7 @@ def update_custom_attribute_data(container: str, selections: list): Args: container (str): target container which adds custom attributes - selections (_type_): nodes to be added into + selections (list): nodes to be added into group in custom attributes """ if container.modifiers[0].name == "OP Data": diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 7bbc6419b8..2f5108aec5 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -51,7 +51,11 @@ class MaxSceneLoader(load.LoaderPlugin): rt.MergeMaxFile(path, rt.Name("deleteOldDups")) new_container = rt.Container(name=inst_name) max_objects = rt.getLastMergedNodes() - + current_max_objects = rt.getLastMergedNodes() + for current_object in current_max_objects: + prev_max_objects = prev_max_objects.remove(current_object) + for prev_object in prev_max_objects: + rt.Delete(prev_object) max_objects_list = [] max_objects_list.append(new_container) max_objects_list.extend(max_objects) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 26520307c9..d076bf2de9 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -26,11 +26,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(name) - if not container: - container = rt.Container() - container.name = name - + container = rt.Container(name=name) selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 05f37f9e5a..bac5b8b4f3 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -27,18 +27,16 @@ class ObjLoader(load.LoaderPlugin): rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') # create "missing" container for obj import - container = rt.Container() - container.name = name + container = rt.Container(name=name) selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) # get current selection for selection in selections: selection.Parent = container - - asset = rt.GetNodeByName(name) + self.log.debug(f"{container.ClassID}") return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [container], context, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 425b152278..d3669fc10e 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -35,6 +35,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile(filepath, importOptions=import_options) asset = rt.GetNodeByName(name) + import_custom_attribute_data(asset, asset.Children) return containerise( From ae42d524c80bab1d5f3583111eb5208c9d515caf Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 18:22:59 +0800 Subject: [PATCH 067/291] fixing the error when updating the max scene in the loader --- openpype/hosts/max/plugins/load/load_max_scene.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 2f5108aec5..f73bb1941e 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -45,12 +45,12 @@ class MaxSceneLoader(load.LoaderPlugin): node = rt.GetNodeByName(node_name) inst_name, _ = os.path.splitext(node_name) old_container = rt.getNodeByName(inst_name) + prev_max_objects = rt.getLastMergedNodes() # delete the old container with attribute # delete old duplicate rt.Delete(old_container) rt.MergeMaxFile(path, rt.Name("deleteOldDups")) new_container = rt.Container(name=inst_name) - max_objects = rt.getLastMergedNodes() current_max_objects = rt.getLastMergedNodes() for current_object in current_max_objects: prev_max_objects = prev_max_objects.remove(current_object) @@ -58,11 +58,11 @@ class MaxSceneLoader(load.LoaderPlugin): rt.Delete(prev_object) max_objects_list = [] max_objects_list.append(new_container) - max_objects_list.extend(max_objects) + max_objects_list.extend(current_max_objects) for max_object in max_objects_list: max_object.Parent = node - update_custom_attribute_data(new_container, max_objects) + update_custom_attribute_data(new_container, current_max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 7b4a59e3338b1f3455e4362a876a3474454cf15b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 16 Aug 2023 13:42:02 +0200 Subject: [PATCH 068/291] nuke: adding inherited colorspace from instance --- openpype/hosts/nuke/plugins/publish/extract_thumbnail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 21eefda249..d57d55f85d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -54,6 +54,7 @@ class ExtractThumbnail(publish.Extractor): def render_thumbnail(self, instance, output_name=None, **kwargs): first_frame = instance.data["frameStartHandle"] last_frame = instance.data["frameEndHandle"] + colorspace = instance.data["colorspace"] # find frame range and define middle thumb frame mid_frame = int((last_frame - first_frame) / 2) @@ -112,8 +113,8 @@ class ExtractThumbnail(publish.Extractor): if self.use_rendered and os.path.isfile(path_render): # check if file exist otherwise connect to write node rnode = nuke.createNode("Read") - rnode["file"].setValue(path_render) + rnode["colorspace"].setValue(colorspace) # turn it raw if none of baking is ON if all([ From a081f5aed5239bbc88703904a83929530877f031 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 16 Aug 2023 15:26:16 +0300 Subject: [PATCH 069/291] add_kitsu_credentials --- .../modules/deadline/plugins/publish/submit_publish_job.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 5e8c005d07..76cb357f91 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -121,7 +121,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_SG_USER" + "OPENPYPE_SG_USER", + "KITSU_LOGIN", + "KITSU_PWD" ] # custom deadline attributes From 8aa150cfe5af1c7259b5f4466835638249c29b73 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 21:37:49 +0800 Subject: [PATCH 070/291] fixing the bug of not being able to update the scene when using maxSceneloader and some clean up --- .../hosts/max/plugins/load/load_camera_fbx.py | 13 ++++---- .../hosts/max/plugins/load/load_max_scene.py | 33 ++++++++++--------- openpype/hosts/max/plugins/load/load_model.py | 6 ++-- .../hosts/max/plugins/load/load_model_fbx.py | 14 +++++--- .../hosts/max/plugins/load/load_model_obj.py | 7 ++-- .../hosts/max/plugins/load/load_pointcache.py | 14 ++++---- 6 files changed, 46 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 1e4e5b3e91..87745ae881 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -45,12 +45,11 @@ class FbxLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.GetNodeByName(container["instance_node"]) - inst_name, _ = os.path.split(container["instance_node"]) - container = rt.getNodeByName(inst_name) + node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + inst_name, _ = node_name.split("_") rt.Select(node.Children) - update_custom_attribute_data( - container, rt.GetCurrentSelection()) + rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("Mode", rt.Name("merge")) @@ -58,7 +57,9 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - + inst_container = rt.getNodeByName(inst_name) + update_custom_attribute_data( + inst_container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index f73bb1941e..348b940b22 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -42,27 +42,28 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.GetNodeByName(node_name) - inst_name, _ = os.path.splitext(node_name) - old_container = rt.getNodeByName(inst_name) - prev_max_objects = rt.getLastMergedNodes() + node = rt.getNodeByName(node_name) + inst_name, _ = node_name.split("_") + inst_container = rt.getNodeByName(inst_name) # delete the old container with attribute # delete old duplicate - rt.Delete(old_container) + prev_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] rt.MergeMaxFile(path, rt.Name("deleteOldDups")) - new_container = rt.Container(name=inst_name) - current_max_objects = rt.getLastMergedNodes() - for current_object in current_max_objects: - prev_max_objects = prev_max_objects.remove(current_object) - for prev_object in prev_max_objects: - rt.Delete(prev_object) - max_objects_list = [] - max_objects_list.append(new_container) - max_objects_list.extend(current_max_objects) - for max_object in max_objects_list: + current_max_objects = rt.getLastMergedNodes() + current_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] + for obj in current_max_object_names: + idx = rt.findItem(prev_max_object_names, obj) + if idx: + prev_max_object_names = rt.deleteItem(prev_max_object_names, idx) + for object_name in prev_max_object_names: + prev_max_object = rt.getNodeByName(object_name) + rt.Delete(prev_max_object) + + update_custom_attribute_data(inst_container, current_max_objects) + + for max_object in current_max_objects: max_object.Parent = node - update_custom_attribute_data(new_container, current_max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index f71e4e8f7f..a84d497aab 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -70,9 +70,9 @@ class ModelAbcLoader(load.LoaderPlugin): update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: - container = rt.GetNodeByName(abc_con.name) - container.source = path - rt.Select(container.Children) + abc_container = rt.GetNodeByName(abc_con.name) + abc_container.source = path + rt.Select(abc_container.Children) for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index d076bf2de9..f7d3dab60c 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -26,7 +26,10 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) + container = rt.GetNodeByName(name) + container = rt.Container(name=name) + selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) @@ -40,8 +43,9 @@ class FbxModelLoader(load.LoaderPlugin): def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - inst_name, _ = os.path.splitext(container["instance_node"]) + node_name = container["instance_node"] + node = rt.getNodeByName(node_name) + inst_name, _ = node_name.split("_") rt.select(node.Children) rt.FBXImporterSetParam("Animation", False) @@ -52,14 +56,14 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.getNodeByName(inst_name) + inst_container = rt.getNodeByName(inst_name) update_custom_attribute_data( - container, rt.GetCurrentSelection()) + inst_container, rt.GetCurrentSelection()) with maintained_selection(): rt.Select(node) lib.imprint( - container["instance_node"], + node_name, {"representation": str(representation["_id"])}, ) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index bac5b8b4f3..9979ca36b0 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -33,7 +33,6 @@ class ObjLoader(load.LoaderPlugin): # get current selection for selection in selections: selection.Parent = container - self.log.debug(f"{container.ClassID}") return containerise( name, [container], context, loader=self.__class__.__name__) @@ -46,7 +45,7 @@ class ObjLoader(load.LoaderPlugin): node = rt.GetNodeByName(node_name) instance_name, _ = node_name.split("_") - container = rt.GetNodeByName(instance_name) + inst_container = rt.GetNodeByName(instance_name) for child in container.Children: rt.Delete(child) @@ -54,8 +53,8 @@ class ObjLoader(load.LoaderPlugin): # get current selection selections = rt.GetCurrentSelection() for selection in selections: - selection.Parent = container - update_custom_attribute_data(container, selections) + selection.Parent = inst_container + update_custom_attribute_data(inst_container, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 0ec9fda3d5..953141c4ac 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -70,10 +70,6 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - lib.imprint( - container["instance_node"], - {"representation": str(representation["_id"])}, - ) nodes_list = [] with maintained_selection(): rt.Select(node.Children) @@ -83,14 +79,18 @@ class AbcLoader(load.LoaderPlugin): update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in rt.Selection: - container = rt.GetNodeByName(abc_con.name) - container.source = path - rt.Select(container.Children) + abc_container = rt.GetNodeByName(abc_con.name) + abc_container.source = path + rt.Select(abc_container.Children) for abc_obj in rt.Selection: alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path nodes_list.append(alembic_obj) + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) def switch(self, container, representation): self.update(container, representation) From 3342ceff2cee9a44c34c265cb51c7e2e8bcfa799 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 22:39:36 +0800 Subject: [PATCH 071/291] clean up on the fbx and max_scene code --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 4 ++++ openpype/hosts/max/plugins/load/load_max_scene.py | 10 ++++++---- openpype/hosts/max/plugins/load/load_model_fbx.py | 11 ++++++----- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 87745ae881..86e201afa8 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -57,7 +57,11 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) + current_fbx_objects = rt.GetCurrentSelection() inst_container = rt.getNodeByName(inst_name) + for fbx_object in current_fbx_objects: + if fbx_object.Parent != inst_container: + fbx_object.Parent = inst_container update_custom_attribute_data( inst_container, rt.GetCurrentSelection()) with maintained_selection(): diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 348b940b22..4f29f6bd3a 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -47,13 +47,15 @@ class MaxSceneLoader(load.LoaderPlugin): inst_container = rt.getNodeByName(inst_name) # delete the old container with attribute # delete old duplicate - prev_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] + prev_max_object_names = [obj.name for obj + in rt.getLastMergedNodes()] rt.MergeMaxFile(path, rt.Name("deleteOldDups")) current_max_objects = rt.getLastMergedNodes() - current_max_object_names = [obj.name for obj in rt.getLastMergedNodes()] - for obj in current_max_object_names: - idx = rt.findItem(prev_max_object_names, obj) + current_max_object_names = [obj.name for obj + in current_max_objects] + for name in current_max_object_names: + idx = rt.findItem(prev_max_object_names, name) if idx: prev_max_object_names = rt.deleteItem(prev_max_object_names, idx) for object_name in prev_max_object_names: diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index f7d3dab60c..67252a73ff 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -46,19 +46,20 @@ class FbxModelLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) inst_name, _ = node_name.split("_") - rt.select(node.Children) + inst_container = rt.getNodeByName(inst_name) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("merge")) rt.FBXImporterSetParam("AxisConversionMethod", True) - rt.FBXImporterSetParam("UpAxis", "Y") rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - - inst_container = rt.getNodeByName(inst_name) + current_fbx_objects = rt.GetCurrentSelection() + for fbx_object in current_fbx_objects: + if fbx_object.Parent != inst_container: + fbx_object.Parent = inst_container update_custom_attribute_data( - inst_container, rt.GetCurrentSelection()) + inst_container, current_fbx_objects) with maintained_selection(): rt.Select(node) From 8345298913cf88205b1217c261ad3c0dcdf6a946 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 16 Aug 2023 22:40:56 +0800 Subject: [PATCH 072/291] hound --- openpype/hosts/max/plugins/load/load_max_scene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 4f29f6bd3a..9c7468b8fc 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -57,7 +57,8 @@ class MaxSceneLoader(load.LoaderPlugin): for name in current_max_object_names: idx = rt.findItem(prev_max_object_names, name) if idx: - prev_max_object_names = rt.deleteItem(prev_max_object_names, idx) + prev_max_object_names = rt.deleteItem( + prev_max_object_names, idx) for object_name in prev_max_object_names: prev_max_object = rt.getNodeByName(object_name) rt.Delete(prev_max_object) From b2a6e16ae8a1466843fdd4958a7b49bb14adc34a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 17 Aug 2023 21:22:34 +0800 Subject: [PATCH 073/291] master container is now with the namespace --- openpype/hosts/max/api/lib.py | 58 ++++++++++++++++++- openpype/hosts/max/api/pipeline.py | 7 ++- .../hosts/max/plugins/load/load_camera_fbx.py | 9 ++- .../hosts/max/plugins/load/load_max_scene.py | 10 +++- openpype/hosts/max/plugins/load/load_model.py | 13 ++++- .../hosts/max/plugins/load/load_model_fbx.py | 8 ++- .../hosts/max/plugins/load/load_model_obj.py | 8 ++- .../hosts/max/plugins/load/load_model_usd.py | 9 ++- .../hosts/max/plugins/load/load_pointcache.py | 8 ++- .../hosts/max/plugins/load/load_pointcloud.py | 9 ++- .../max/plugins/load/load_redshift_proxy.py | 9 ++- 11 files changed, 134 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index ccd4cd67e1..b58b4f5b11 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -6,7 +6,7 @@ from typing import Any, Dict, Union import six from openpype.pipeline.context_tools import ( - get_current_project, get_current_project_asset,) + get_current_project, get_current_project_asset) from pymxs import runtime as rt JSON_PREFIX = "JSON::" @@ -312,3 +312,59 @@ def set_timeline(frameStart, frameEnd): """ rt.animationRange = rt.interval(frameStart, frameEnd) return rt.animationRange + + +def unique_namespace(namespace, format="%02d", + prefix="", suffix="", con_suffix="CON"): + from pymxs import runtime as rt + """Return unique namespace + + Arguments: + namespace (str): Name of namespace to consider + format (str, optional): Formatting of the given iteration number + suffix (str, optional): Only consider namespaces with this suffix. + con_suffix: max only, for finding the name of the master container + + >>> unique_namespace("bar") + # bar01 + >>> unique_namespace(":hello") + # :hello01 + >>> unique_namespace("bar:", suffix="_NS") + # bar01_NS: + + """ + + def current_namespace(): + current = namespace + # When inside a namespace Maya adds no trailing : + if not current.endswith(":"): + current += ":" + return current + + # Always check against the absolute namespace root + # There's no clash with :x if we're defining namespace :a:x + ROOT = ":" if namespace.startswith(":") else current_namespace() + + # Strip trailing `:` tokens since we might want to add a suffix + start = ":" if namespace.startswith(":") else "" + end = ":" if namespace.endswith(":") else "" + namespace = namespace.strip(":") + if ":" in namespace: + # Split off any nesting that we don't uniqify anyway. + parents, namespace = namespace.rsplit(":", 1) + start += parents + ":" + ROOT += start + + iteration = 1 + increment_version = True + while increment_version: + nr_namespace = namespace + format % iteration + unique = prefix + nr_namespace + suffix + container_name = f"{unique}:{namespace}{con_suffix}" + if not rt.getNodeByName(container_name): + name_space = start + unique + end + increment_version = False + return name_space + else: + increment_version = True + iteration +=1 diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index f58bd05a13..459c8b32f0 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -154,17 +154,18 @@ def ls() -> list: yield lib.read(container) -def containerise(name: str, nodes: list, context, loader=None, suffix="_CON"): +def containerise(name: str, nodes: list, context, + namespace=None, loader=None, suffix="_CON"): data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, - "namespace": "", + "namespace": namespace, "loader": loader, "representation": context["representation"]["_id"], } - container_name = f"{name}{suffix}" + container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) for node in nodes: node.Parent = container diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 86e201afa8..180c1b48b8 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -38,8 +39,14 @@ class FbxLoader(load.LoaderPlugin): for selection in selections: selection.Parent = container + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [container], context, loader=self.__class__.__name__) + name, [container], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 9c7468b8fc..7c00706676 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data @@ -34,8 +35,15 @@ class MaxSceneLoader(load.LoaderPlugin): import_custom_attribute_data(container, max_objects) max_container.append(container) max_container.extend(max_objects) + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, max_container, context, loader=self.__class__.__name__) + name, max_container, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index a84d497aab..ebf3d684c8 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -6,7 +6,9 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import maintained_selection +from openpype.hosts.max.api.lib import ( + maintained_selection, unique_namespace +) class ModelAbcLoader(load.LoaderPlugin): @@ -51,8 +53,15 @@ class ModelAbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() import_custom_attribute_data( abc_container, abc_container.Children) + + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, [abc_container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 67252a73ff..34ac263821 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -4,6 +4,7 @@ from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data ) from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.lib import maintained_selection @@ -36,8 +37,13 @@ class FbxModelLoader(load.LoaderPlugin): for selection in selections: selection.Parent = container + namespace = unique_namespace( + name + "_", + suffix="_", + ) return containerise( - name, [container], context, loader=self.__class__.__name__ + name, [container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 9979ca36b0..e4ae687802 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, @@ -34,8 +35,13 @@ class ObjLoader(load.LoaderPlugin): for selection in selections: selection.Parent = container + namespace = unique_namespace( + name + "_", + suffix="_", + ) return containerise( - name, [container], context, loader=self.__class__.__name__) + name, [container], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index d3669fc10e..fa013f54ce 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, @@ -38,8 +39,14 @@ class ModelUSDLoader(load.LoaderPlugin): import_custom_attribute_data(asset, asset.Children) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [asset], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 953141c4ac..3dacab11c7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -7,6 +7,7 @@ Because of limited api, alembics can be only loaded, but not easily updated. import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -59,9 +60,14 @@ class AbcLoader(load.LoaderPlugin): for cam_shape in abc.Children: cam_shape.playbackType = 2 + namespace = unique_namespace( + name + "_", + suffix="_", + ) return containerise( - name, [abc_container], context, loader=self.__class__.__name__ + name, [abc_container], context, + namespace, loader=self.__class__.__name__ ) def update(self, container, representation): diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index c263019beb..58d5057aa7 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,6 +1,7 @@ import os from openpype.hosts.max.api import lib, maintained_selection +from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -31,8 +32,14 @@ class PointCloudLoader(load.LoaderPlugin): obj.Parent = prt_container import_custom_attribute_data(prt_container, [obj]) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [prt_container], context, loader=self.__class__.__name__) + name, [prt_container], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): """update the container""" diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 6b100df611..b4772ac0bc 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -11,6 +11,7 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib +from openpype.hosts.max.api.lib import unique_namespace class RedshiftProxyLoader(load.LoaderPlugin): @@ -40,8 +41,14 @@ class RedshiftProxyLoader(load.LoaderPlugin): import_custom_attribute_data(container, [rs_proxy]) asset = rt.getNodeByName(name) + namespace = unique_namespace( + name + "_", + suffix="_", + ) + return containerise( - name, [asset], context, loader=self.__class__.__name__) + name, [asset], context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt From 5a00cab24cd1f0dc4dd2988f28fa9b7b88b0b63b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 17 Aug 2023 21:24:10 +0800 Subject: [PATCH 074/291] hound --- openpype/hosts/max/api/lib.py | 2 +- openpype/hosts/max/api/pipeline.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index b58b4f5b11..e357080cbc 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -367,4 +367,4 @@ def unique_namespace(namespace, format="%02d", return name_space else: increment_version = True - iteration +=1 + iteration += 1 diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 459c8b32f0..161e2bdc7b 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -160,7 +160,7 @@ def containerise(name: str, nodes: list, context, "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, - "namespace": namespace, + "namespace": namespace or "", "loader": loader, "representation": context["representation"]["_id"], } From ab3d94fdb6f607b958704a06d3fa87187ac03d71 Mon Sep 17 00:00:00 2001 From: Fabia Serra Arrizabalaga Date: Thu, 17 Aug 2023 15:59:46 -0700 Subject: [PATCH 075/291] Move variant query to the create_interactive function --- .../hosts/houdini/api/creator_node_shelves.py | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 01da2fc3e1..1f9fef7417 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -35,11 +35,11 @@ CATEGORY_GENERIC_TOOL = { CREATE_SCRIPT = """ from openpype.hosts.houdini.api.creator_node_shelves import create_interactive -create_interactive("{identifier}", "{variant}", **kwargs) +create_interactive("{identifier}", **kwargs) """ -def create_interactive(creator_identifier, default_variant, **kwargs): +def create_interactive(creator_identifier, **kwargs): """Create a Creator using its identifier interactively. This is used by the generated shelf tools as callback when a user selects @@ -57,28 +57,31 @@ def create_interactive(creator_identifier, default_variant, **kwargs): list: The created instances. """ - - # TODO Use Qt instead - result, variant = hou.ui.readInput("Define variant name", - buttons=("Ok", "Cancel"), - initial_contents=default_variant, - title="Define variant", - help="Set the variant for the " - "publish instance", - close_choice=1) - if result == 1: - # User interrupted - return - variant = variant.strip() - if not variant: - raise RuntimeError("Empty variant value entered.") - host = registered_host() context = CreateContext(host) creator = context.manual_creators.get(creator_identifier) if not creator: - raise RuntimeError("Invalid creator identifier: " - "{}".format(creator_identifier)) + raise RuntimeError("Invalid creator identifier: {}".format( + creator_identifier) + ) + + # TODO Use Qt instead + result, variant = hou.ui.readInput( + "Define variant name", + buttons=("Ok", "Cancel"), + initial_contents=creator.get_default_variant(), + title="Define variant", + help="Set the variant for the publish instance", + close_choice=1 + ) + + if result == 1: + # User interrupted + return + + variant = variant.strip() + if not variant: + raise RuntimeError("Empty variant value entered.") # TODO: Once more elaborate unique create behavior should exist per Creator # instead of per network editor area then we should move this from here @@ -196,9 +199,7 @@ def install(): key = "openpype_create.{}".format(identifier) log.debug(f"Registering {key}") - script = CREATE_SCRIPT.format( - identifier=identifier, variant=creator.get_default_variant() - ) + script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, From f4e42e27ac01faa61c71b2dec930a78361645578 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 18 Aug 2023 18:43:13 +0800 Subject: [PATCH 076/291] updating version should be updated as expected --- .../hosts/max/plugins/load/load_camera_fbx.py | 15 ++++++++--- .../hosts/max/plugins/load/load_max_scene.py | 15 +++++++---- openpype/hosts/max/plugins/load/load_model.py | 26 ++++++++----------- .../hosts/max/plugins/load/load_model_fbx.py | 17 +++++++----- .../hosts/max/plugins/load/load_model_obj.py | 8 +++--- .../hosts/max/plugins/load/load_model_usd.py | 11 +++++--- .../hosts/max/plugins/load/load_pointcache.py | 14 ++++------ .../hosts/max/plugins/load/load_pointcloud.py | 14 +++++----- .../max/plugins/load/load_redshift_proxy.py | 15 +++++------ 9 files changed, 75 insertions(+), 60 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 180c1b48b8..c0e1172a6d 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -54,7 +54,10 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - inst_name, _ = node_name.split("_") + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") + + inst_container = rt.getNodeByName(param_container) rt.Select(node.Children) rt.FBXImporterSetParam("Animation", True) @@ -65,12 +68,16 @@ class FbxLoader(load.LoaderPlugin): rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() - inst_container = rt.getNodeByName(inst_name) for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container - update_custom_attribute_data( - inst_container, rt.GetCurrentSelection()) + + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_fbx_objects) + with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 7c00706676..aa177291d8 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -40,7 +40,6 @@ class MaxSceneLoader(load.LoaderPlugin): name + "_", suffix="_", ) - return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -50,9 +49,11 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] + node = rt.getNodeByName(node_name) - inst_name, _ = node_name.split("_") - inst_container = rt.getNodeByName(inst_name) + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") + # delete the old container with attribute # delete old duplicate prev_max_object_names = [obj.name for obj @@ -71,10 +72,14 @@ class MaxSceneLoader(load.LoaderPlugin): prev_max_object = rt.getNodeByName(object_name) rt.Delete(prev_max_object) - update_custom_attribute_data(inst_container, current_max_objects) - for max_object in current_max_objects: max_object.Parent = node + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_max_objects) + lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index ebf3d684c8..deb3389992 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -69,23 +69,19 @@ class ModelAbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - rt.Select(node.Children) - nodes_list = [] with maintained_selection(): - rt.Select(node) - for alembic in rt.Selection: - abc = rt.GetNodeByName(alembic.name) - update_custom_attribute_data(abc, abc.Children) - rt.Select(abc.Children) - for abc_con in rt.Selection: - abc_container = rt.GetNodeByName(abc_con.name) - abc_container.source = path - rt.Select(abc_container.Children) - for abc_obj in rt.Selection: - alembic_obj = rt.GetNodeByName(abc_obj.name) - alembic_obj.source = path - nodes_list.append(alembic_obj) + rt.Select(node.Children) + + for alembic in rt.Selection: + abc = rt.GetNodeByName(alembic.name) + update_custom_attribute_data(abc, abc.Children) + rt.Select(abc.Children) + for abc_con in abc.Children: + abc_con.source = path + rt.Select(abc_con.Children) + for abc_obj in abc_con.Children: + abc_obj.source = path lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 34ac263821..f85bfa03a1 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,8 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_custom_attribute_data, update_custom_attribute_data + containerise, import_custom_attribute_data, + update_custom_attribute_data ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import unique_namespace @@ -51,9 +52,8 @@ class FbxModelLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - inst_name, _ = node_name.split("_") - inst_container = rt.getNodeByName(inst_name) - + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("merge")) @@ -61,11 +61,16 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() + + inst_container = rt.getNodeByName(param_container) + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_fbx_objects) for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container - update_custom_attribute_data( - inst_container, current_fbx_objects) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index e4ae687802..b42ef399b0 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -50,9 +50,11 @@ class ObjLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.GetNodeByName(node_name) - instance_name, _ = node_name.split("_") - inst_container = rt.GetNodeByName(instance_name) - for child in container.Children: + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") + + inst_container = rt.getNodeByName(param_container) + for child in inst_container.Children: rt.Delete(child) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index fa013f54ce..4febba216e 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -58,7 +58,8 @@ class ModelUSDLoader(load.LoaderPlugin): for r in n.Children: rt.Delete(r) rt.Delete(n) - instance_name, _ = node_name.split("_") + container_name = node_name.split(":")[-1] + param_container, _ = container_name.split("_") import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(path) @@ -70,9 +71,13 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile( path, importOptions=import_options) - asset = rt.GetNodeByName(instance_name) + asset = rt.GetNodeByName(param_container) asset.Parent = node - update_custom_attribute_data(asset, asset.Children) + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + asset, asset.Children) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 3dacab11c7..af03e70236 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -76,7 +76,6 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - nodes_list = [] with maintained_selection(): rt.Select(node.Children) @@ -84,14 +83,11 @@ class AbcLoader(load.LoaderPlugin): abc = rt.GetNodeByName(alembic.name) update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) - for abc_con in rt.Selection: - abc_container = rt.GetNodeByName(abc_con.name) - abc_container.source = path - rt.Select(abc_container.Children) - for abc_obj in rt.Selection: - alembic_obj = rt.GetNodeByName(abc_obj.name) - alembic_obj.source = path - nodes_list.append(alembic_obj) + for abc_con in abc.Children: + abc_con.source = path + rt.Select(abc_con.Children) + for abc_obj in abc_con.Children: + abc_obj.source = path lib.imprint( container["instance_node"], diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 58d5057aa7..6c94fb7847 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -26,9 +26,7 @@ class PointCloudLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.GetNodeByName(obj.name) - prt_container = rt.container() - prt_container.name = name + prt_container = rt.Container(name=name) obj.Parent = prt_container import_custom_attribute_data(prt_container, [obj]) @@ -49,10 +47,12 @@ class PointCloudLoader(load.LoaderPlugin): node = rt.GetNodeByName(container["instance_node"]) with maintained_selection(): rt.Select(node.Children) - for prt in rt.Selection: - prt_object = rt.GetNodeByName(prt.name) - prt_object.filename = path - update_custom_attribute_data(node, node.Children) + for sub_node in rt.Selection: + children_node = sub_node.Children + update_custom_attribute_data( + sub_node, sub_node.Children) + for prt in children_node: + prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index b4772ac0bc..1c4cd02143 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -35,11 +35,9 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - container = rt.container() - container.name = name + container = rt.Container(name=name) rs_proxy.Parent = container import_custom_attribute_data(container, [rs_proxy]) - asset = rt.getNodeByName(name) namespace = unique_namespace( name + "_", @@ -47,7 +45,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): ) return containerise( - name, [asset], context, + name, [container], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -55,12 +53,13 @@ class RedshiftProxyLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) - for children in node.Children: - children_node = rt.getNodeByName(children.name) - for proxy in children_node.Children: + for sub_node in node.Children: + children_node = sub_node.Children + update_custom_attribute_data( + sub_node, children_node) + for proxy in children_node: proxy.file = path - update_custom_attribute_data(node, node.Children) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) From 444cca4213128d470641a58ffac4602b9ac5f833 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 21 Aug 2023 15:30:46 +0800 Subject: [PATCH 077/291] load max scene wip --- .../hosts/max/plugins/load/load_max_scene.py | 66 +++++++++++++------ 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 1d105f1bc0..6dfb3d0115 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,11 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.pipeline import ( + containerise, import_custom_attribute_data, + update_custom_attribute_data +) from openpype.pipeline import get_representation_path, load @@ -19,37 +23,61 @@ class MaxSceneLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - path = self.filepath_from_context(context) path = os.path.normpath(path) # import the max scene by using "merge file" path = path.replace('\\', '/') - rt.MergeMaxFile( - path, rt.Name("mergeDups"), - includeFullGroup=True) + rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() + # implement the OP/AYON custom attributes before load + max_container = [] + namespace = unique_namespace( + name + "_", + suffix="_", + ) + container = rt.Container(name=f"{namespace}:{name}") + import_custom_attribute_data(container, max_objects) + max_container.append(container) + max_container.extend(max_objects) return containerise( - name, [max_objects], context, loader=self.__class__.__name__) + name, max_container, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - prev_max_objects = rt.getLastMergedNodes() - merged_max_objects = [obj.name for obj - in prev_max_objects] - rt.MergeMaxFile( - path, merged_max_objects, - rt.Name("deleteOldDups"), - quiet=True, - mergedNodes=prev_max_objects, - includeFullGroup=True) + node_name = container["instance_node"] + + node = rt.getNodeByName(node_name) + param_container = node_name.split("_CON")[0] + + # delete the old container with attribute + # delete old duplicate + prev_max_object_names = [obj.name for obj + in rt.getLastMergedNodes()] + rt.MergeMaxFile(path, rt.Name("deleteOldDups")) + current_max_objects = rt.getLastMergedNodes() - for current_object in current_max_objects: - prev_max_objects = prev_max_objects.remove(current_object) - for prev_object in prev_max_objects: - rt.Delete(prev_object) + current_max_object_names = [obj.name for obj + in current_max_objects] + for name in current_max_object_names: + idx = rt.findItem(prev_max_object_names, name) + if idx: + prev_max_object_names = rt.deleteItem( + prev_max_object_names, idx) + for object_name in prev_max_object_names: + prev_max_object = rt.getNodeByName(object_name) + rt.Delete(prev_max_object) + + for max_object in current_max_objects: + max_object.Parent = node + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == param_container: + update_custom_attribute_data( + children, current_max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) From e742dd61fd85c7cac3d5027ae758bef2f7aa5af7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Aug 2023 14:42:02 +0200 Subject: [PATCH 078/291] fill entities and update template data on instances during extract AYON hierarchy --- .../publish/extract_hierarchy_to_ayon.py | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index 915650ae41..de9a70c233 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -8,6 +8,11 @@ from ayon_api import slugify_string from ayon_api.entity_hub import EntityHub from openpype import AYON_SERVER_ENABLED +from openpype.client import get_assets +from openpype.pipeline.template_data import ( + get_asset_template_data, + get_task_template_data, +) def _default_json_parse(value): @@ -27,13 +32,48 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): hierarchy_context = context.data.get("hierarchyContext") if not hierarchy_context: - self.log.info("Skipping") + self.log.debug("Skipping") return project_name = context.data["projectName"] + self._create_hierarchy(context, project_name) + self._fill_instance_entities(context, project_name) + + def _fill_instance_entities(self, context, project_name): + instances_by_asset_name = collections.defaultdict(list) + for instance in context: + instance_entity = instance.data.get("assetEntity") + if instance_entity: + continue + + # Skip if instance asset does not match + instance_asset_name = instance.data.get("asset") + instances_by_asset_name[instance_asset_name] = instance + + project_doc = context.data["projectEntity"] + asset_docs = get_assets( + project_name, asset_names=instances_by_asset_name.keys() + ) + asset_docs_by_name = { + asset_doc["name"]: asset_doc + for asset_doc in asset_docs + } + for asset_name, instances in instances_by_asset_name.items(): + asset_doc = asset_docs_by_name[asset_name] + asset_data = get_asset_template_data(asset_doc, project_name) + for instance in instances: + task_name = instance.data.get("task") + template_data = get_task_template_data( + project_doc, asset_doc, task_name) + template_data.update(copy.deepcopy(asset_data)) + + instance.data["anatomyData"].update(template_data) + instance.data["assetEntity"] = asset_doc + + def _create_hierarchy(self, context, project_name): hierarchy_context = self._filter_hierarchy(context) if not hierarchy_context: - self.log.info("All folders were filtered out") + self.log.debug("All folders were filtered out") return self.log.debug("Hierarchy_context: {}".format( From 32b93f998aa95c3647797040b4a73fd1e68a305f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 22 Aug 2023 12:47:36 +0800 Subject: [PATCH 079/291] allows the users to choose which camera as repair action in validate_viewport_camera --- openpype/hosts/max/api/lib_rendersettings.py | 5 +++-- .../publish/validate_viewport_camera.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py index 1b62edabee..afde5008d5 100644 --- a/openpype/hosts/max/api/lib_rendersettings.py +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -43,7 +43,7 @@ class RenderSettings(object): rt.viewport.setCamera(sel) break if not found: - raise RuntimeError("Camera not found") + raise RuntimeError("Active Camera not found") def render_output(self, container): folder = rt.maxFilePath @@ -113,7 +113,8 @@ class RenderSettings(object): # for setting up renderable camera arv = rt.MAXToAOps.ArnoldRenderView() render_camera = rt.viewport.GetCamera() - arv.setOption("Camera", str(render_camera)) + if render_camera: + arv.setOption("Camera", str(render_camera)) # TODO: add AOVs and extension img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py index d5cf85eb69..b35ba482a9 100644 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -4,6 +4,7 @@ from openpype.pipeline import ( PublishValidationError, OptionalPyblishPluginMixin) from openpype.pipeline.publish import RepairAction +from openpype.hosts.max.api.lib import get_current_renderer from pymxs import runtime as rt @@ -35,11 +36,13 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): - # Get all cameras in the scene - cameras_in_scene = [c.name for c in rt.Objects - if rt.classOf(c) in rt.Camera.Classes] - # Set the first camera as viewport camera for rendering - if cameras_in_scene: - camera = rt.getNodeByName(cameras_in_scene[0]) - rt.viewport.setCamera(camera) - cls.log.info(f"Camera {camera} set as viewport camera") + + rt.viewport.setType(rt.Name("view_camera")) + camera = rt.viewport.GetCamera() + cls.log.info(f"Camera {camera} set as viewport camera") + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + if renderer == "Arnold": + arv = rt.MAXToAOps.ArnoldRenderView() + arv.setOption("Camera", str(camera)) + arv.close() From dfd748ccd191e3d6058529d8deaf2865c7a9ec89 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 22 Aug 2023 08:27:52 +0100 Subject: [PATCH 080/291] Remove force copy option --- openpype/hosts/maya/plugins/create/create_look.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_look.py b/openpype/hosts/maya/plugins/create/create_look.py index 385ae81e01..11a69151fd 100644 --- a/openpype/hosts/maya/plugins/create/create_look.py +++ b/openpype/hosts/maya/plugins/create/create_look.py @@ -37,13 +37,7 @@ class CreateLook(plugin.MayaCreator): label="Convert textures to .rstex", tooltip="Whether to generate Redshift .rstex files for " "your textures", - default=self.rs_tex), - BoolDef("forceCopy", - label="Force Copy", - tooltip="Enable users to force a copy instead of hardlink." - "\nNote: On Windows copy is always forced due to " - "bugs in windows' implementation of hardlinks.", - default=False) + default=self.rs_tex) ] def get_pre_create_attr_defs(self): From 6376692fec24267af13f7900e2ac60fd65aed373 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 13:56:47 +0200 Subject: [PATCH 081/291] :recycle: use temp dir for project creation --- .../unreal/hooks/pre_workfile_preparation.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 202d7854f6..fad7a7ed0b 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -2,6 +2,7 @@ """Hook to launch Unreal and prepare projects.""" import os import copy +import tempfile from pathlib import Path from qtpy import QtCore @@ -224,10 +225,19 @@ class UnrealPrelaunchHook(PreLaunchHook): project_file = project_path / unreal_project_filename if not project_file.is_file(): - self.exec_ue_project_gen(engine_version, - unreal_project_name, - engine_path, - project_path) + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) / unreal_project_filename + self.exec_ue_project_gen(engine_version, + unreal_project_name, + engine_path, + temp_path) + try: + temp_path.rename(project_path) + except FileExistsError as e: + raise ApplicationLaunchFailed(( + f"{self.signature} Project folder " + f"already exists {project_path.as_posix()}" + )) from e self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version # Append project file to launch arguments From ec054ce939383f768fbcc3a7c622088e1fd4c1cb Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 22 Aug 2023 13:00:36 +0100 Subject: [PATCH 082/291] Fix version 0 when integrating to Ftrack. --- openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 6ca5d1d4ef..4d474fab10 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -353,7 +353,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): status_name = asset_version_data.pop("status_name", None) # Try query asset version by criteria (asset id and version) - version = asset_version_data.get("version") or 0 + version = asset_version_data.get("version") or "0" asset_version_entity = self._query_asset_version( session, version, asset_id ) From 92165f521ef5f51e46758b3068083339f8d7a14d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 15:41:24 +0200 Subject: [PATCH 083/291] :bug: fix copy function --- .../unreal/hooks/pre_workfile_preparation.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index fad7a7ed0b..fb489f04f7 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -2,6 +2,7 @@ """Hook to launch Unreal and prepare projects.""" import os import copy +import shutil import tempfile from pathlib import Path @@ -230,13 +231,19 @@ class UnrealPrelaunchHook(PreLaunchHook): self.exec_ue_project_gen(engine_version, unreal_project_name, engine_path, - temp_path) + Path(temp_dir)) try: - temp_path.rename(project_path) - except FileExistsError as e: + self.log.info(( + f"Moving from {temp_dir} to " + f"{project_path.as_posix()}" + )) + shutil.copytree( + temp_dir, project_path, dirs_exist_ok=True) + + except shutil.Error as e: raise ApplicationLaunchFailed(( - f"{self.signature} Project folder " - f"already exists {project_path.as_posix()}" + f"{self.signature} Cannot copy directory {temp_dir} " + f"to {project_path.as_posix()} - {e}" )) from e self.launch_context.env["AYON_UNREAL_VERSION"] = engine_version From c2b49a897eee6c33e405a3229bd48bcaee6c8178 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 15:43:48 +0200 Subject: [PATCH 084/291] :recycle: remove unused variable --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index fb489f04f7..efbbed27c8 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -227,7 +227,6 @@ class UnrealPrelaunchHook(PreLaunchHook): if not project_file.is_file(): with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) / unreal_project_filename self.exec_ue_project_gen(engine_version, unreal_project_name, engine_path, From 92000bc21e03d3ddf1fc99a62421ead1f278865f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Aug 2023 15:48:19 +0200 Subject: [PATCH 085/291] :rotating_light: fix hound :dog: --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index efbbed27c8..a635bd4cab 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -233,8 +233,8 @@ class UnrealPrelaunchHook(PreLaunchHook): Path(temp_dir)) try: self.log.info(( - f"Moving from {temp_dir} to " - f"{project_path.as_posix()}" + f"Moving from {temp_dir} to " + f"{project_path.as_posix()}" )) shutil.copytree( temp_dir, project_path, dirs_exist_ok=True) From cd9ec2b73a269d4973db81622dbadb268ec7a24d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Aug 2023 17:40:58 +0200 Subject: [PATCH 086/291] OCIO: adding exception for nuke, hiero into hook --- openpype/hooks/pre_ocio_hook.py | 3 +++ openpype/hosts/nuke/api/lib.py | 1 + 2 files changed, 4 insertions(+) diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index 1307ed9f76..add3a0adaf 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -45,6 +45,9 @@ class OCIOEnvHook(PreLaunchHook): if config_data: ocio_path = config_data["path"] + if self.host_name in ["nuke", "hiero"]: + ocio_path = ocio_path.replace("\\", "/") + self.log.info( f"Setting OCIO environment to config path: {ocio_path}") diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4a1e109b17..2a6c1fb12c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2177,6 +2177,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. """ # replace path with env var if possible ocio_path = self._replace_ocio_path_with_env_var(config_data) + ocio_path = ocio_path.replace("\\", "/") log.info("Setting OCIO config path to: `{}`".format( ocio_path)) From 83508a93eadb1387a49dca465d68bcf0d96badc6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 13:45:20 +0200 Subject: [PATCH 087/291] nuke: fixing thumbnail and monitor out root attributes --- openpype/hosts/nuke/api/lib.py | 54 +++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 4a1e109b17..cedbe6d5e6 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2041,6 +2041,7 @@ class WorkfileSettings(object): ) workfile_settings = imageio_host["workfile"] + viewer_process_settings = imageio_host["viewer"]["viewerProcess"] if not config_data: # TODO: backward compatibility for old projects - remove later @@ -2091,6 +2092,31 @@ class WorkfileSettings(object): workfile_settings.pop("colorManagement", None) workfile_settings.pop("OCIO_config", None) + # treat monitor lut separately + monitor_lut = workfile_settings.pop("monitorLut", None) + m_display, m_viewer = get_viewer_config_from_string(monitor_lut) + v_display, v_viewer = get_viewer_config_from_string( + viewer_process_settings + ) + + # set monitor lut differently for nuke version 14 + if nuke.NUKE_VERSION_MAJOR >= 14: + workfile_settings["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + workfile_settings["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=False) + + if nuke.NUKE_VERSION_MAJOR == 13: + workfile_settings["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + workfile_settings["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=True) + if nuke.NUKE_VERSION_MAJOR <= 12: + workfile_settings["monitorLut"] = create_viewer_profile_string( + m_viewer, m_display, path_like=True) + # then set the rest for knob, value_ in workfile_settings.items(): # skip unfilled ocio config path @@ -3320,11 +3346,11 @@ def get_viewer_config_from_string(input_string): display = split[0] elif "(" in viewer: pattern = r"([\w\d\s\.\-]+).*[(](.*)[)]" - result = re.findall(pattern, viewer) + result_ = re.findall(pattern, viewer) try: - result = result.pop() - display = str(result[1]).rstrip() - viewer = str(result[0]).rstrip() + result_ = result_.pop() + display = str(result_[1]).rstrip() + viewer = str(result_[0]).rstrip() except IndexError: raise IndexError(( "Viewer Input string is not correct. " @@ -3332,3 +3358,23 @@ def get_viewer_config_from_string(input_string): ).format(input_string)) return (display, viewer) + + +def create_viewer_profile_string(viewer, display=None, path_like=False): + """Convert viewer and display to string + + Args: + viewer (str): viewer name + display (Optional[str]): display name + path_like (Optional[bool]): if True, return path like string + + Returns: + str: viewer config string + """ + if display: + if path_like: + return "{}/{}".format(display, viewer) + else: + return "{} ({})".format(viewer, display) + else: + return viewer From 0f8cc0301fd2e44259c524eefe39b7db733fecaa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 14:47:10 +0200 Subject: [PATCH 088/291] Nuke: fixing missing `instance_id` --- openpype/hosts/nuke/api/pipeline.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 65b4b91323..a1d290646c 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -543,6 +543,9 @@ def list_instances(creator_id=None): For SubsetManager + Args: + creator_id (Optional[str]): creator identifier + Returns: (list) of dictionaries matching instances format """ @@ -575,10 +578,13 @@ def list_instances(creator_id=None): if creator_id and instance_data["creator_identifier"] != creator_id: continue - if instance_data["instance_id"] in instance_ids: + instance_id = instance_data.get("instance_id") + if not instance_id: + pass + elif instance_id in instance_ids: instance_data.pop("instance_id") else: - instance_ids.add(instance_data["instance_id"]) + instance_ids.add(instance_id) # node name could change, so update subset name data _update_subset_name_data(instance_data, node) From 3603fdfe0012f905634d1786275a2f72b4ca2203 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 15:11:26 +0200 Subject: [PATCH 089/291] Nuke: existing frames validator is repairing render target --- .../publish/validate_rendered_frames.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 45c20412c8..ef3d4d0bb5 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -14,19 +14,28 @@ class RepairActionBase(pyblish.api.Action): # Get the errored instances return get_errored_instances_from_context(context, plugin=plugin) - def repair_knob(self, instances, state): + def repair_knob(self, context, instances, state): + create_context = context.data["create_context"] for instance in instances: - node = instance.data["transientData"]["node"] - files_remove = [os.path.join(instance.data["outputDir"], f) - for r in instance.data.get("representations", []) - for f in r.get("files", []) - ] + files_remove = [ + os.path.join(instance.data["outputDir"], f_) + for r_ in instance.data.get("representations", []) + for f_ in r_.get("files", []) + ] self.log.info("Files to be removed: {}".format(files_remove)) - for f in files_remove: - os.remove(f) - self.log.debug("removing file: {}".format(f)) - node["render"].setValue(state) + for f_ in files_remove: + os.remove(f_) + self.log.debug("removing file: {}".format(f_)) + + # Reset the render knob + instance_id = instance.data["instance_id"] + created_instance = create_context.get_instance_by_id( + instance_id + ) + created_instance.creator_attributes["render_target"] = state + self.log.info("Rendering toggled to `{}`".format(state)) + create_context.save_changes() class RepairCollectionActionToLocal(RepairActionBase): @@ -34,7 +43,7 @@ class RepairCollectionActionToLocal(RepairActionBase): def process(self, context, plugin): instances = self.get_instance(context, plugin) - self.repair_knob(instances, "Local") + self.repair_knob(context, instances, "local") class RepairCollectionActionToFarm(RepairActionBase): @@ -42,7 +51,7 @@ class RepairCollectionActionToFarm(RepairActionBase): def process(self, context, plugin): instances = self.get_instance(context, plugin) - self.repair_knob(instances, "On farm") + self.repair_knob(context, instances, "farm") class ValidateRenderedFrames(pyblish.api.InstancePlugin): From ed5c299c515b46bd0efc1da705bb484746688370 Mon Sep 17 00:00:00 2001 From: Libor Batek Date: Wed, 23 Aug 2023 15:59:45 +0200 Subject: [PATCH 090/291] added UE to extract burnins families --- openpype/plugins/publish/extract_burnin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 4a64711bfd..e5b37ee3b4 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -53,8 +53,8 @@ class ExtractBurnin(publish.Extractor): "flame", "houdini", "max", - "blender" - # "resolve" + "blender", + "unreal" ] optional = True From 6021a43ab0255d6410d9c307f1507c166d81aa9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 23 Aug 2023 16:39:21 +0200 Subject: [PATCH 091/291] Update openpype/hosts/nuke/plugins/publish/collect_writes.py Co-authored-by: Roy Nieterau --- openpype/hosts/nuke/plugins/publish/collect_writes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_writes.py b/openpype/hosts/nuke/plugins/publish/collect_writes.py index f1b36ba886..6f9245f5b9 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/collect_writes.py @@ -14,7 +14,7 @@ class CollectNukeWrites(pyblish.api.InstancePlugin, hosts = ["nuke", "nukeassist"] families = ["render", "prerender", "image"] - # cashing + # cache _write_nodes = {} _frame_ranges = {} From fd2ba5a07fca076ecf6b2b6793b0f266ce9e4f47 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 16:48:51 +0200 Subject: [PATCH 092/291] empty line --- .../hosts/nuke/plugins/publish/collect_nuke_instance_data.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py index edd7a5cf27..b0f69e8ab8 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -51,6 +51,5 @@ class CollectNukeInstanceData(pyblish.api.InstancePlugin): if instance.data.get("review"): instance.data["families"].append("review") - self.log.debug("Collected instance: {}".format( instance.data)) From fe5dc20877355a8b23a54c359dedca7111e6a00e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 17:31:46 +0200 Subject: [PATCH 093/291] traypublisher: adding preset for audio product --- .../project_settings/traypublisher.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index dda958ebcd..7f7b7d1452 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -256,6 +256,23 @@ "allow_multiple_items": true, "allow_version_control": false, "extensions": [] + }, + { + "family": "audio", + "identifier": "", + "label": "Audio ", + "icon": "fa5s.file-audio", + "default_variants": [ + "Main" + ], + "description": "Audio product", + "detailed_description": "Audio files for review or final delivery", + "allow_sequences": false, + "allow_multiple_items": false, + "allow_version_control": false, + "extensions": [ + ".wav" + ] } ], "editorial_creators": { From fa80317f6a6fb8ac5f65d11304444cf128b0567a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 24 Aug 2023 17:08:37 +0800 Subject: [PATCH 094/291] namespace fix for most loaders except alembic loaders --- openpype/hosts/max/api/lib.py | 9 ++++ .../hosts/max/plugins/load/load_camera_fbx.py | 32 ++++++++----- .../hosts/max/plugins/load/load_max_scene.py | 48 +++++++++---------- openpype/hosts/max/plugins/load/load_model.py | 7 +++ .../hosts/max/plugins/load/load_model_fbx.py | 43 ++++++++++------- .../hosts/max/plugins/load/load_model_obj.py | 38 ++++++++------- .../hosts/max/plugins/load/load_model_usd.py | 43 ++++++++++------- .../hosts/max/plugins/load/load_pointcache.py | 8 ++++ .../hosts/max/plugins/load/load_pointcloud.py | 24 ++++++---- .../max/plugins/load/load_redshift_proxy.py | 26 +++++----- 10 files changed, 168 insertions(+), 110 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index e357080cbc..08819ba155 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -368,3 +368,12 @@ def unique_namespace(namespace, format="%02d", else: increment_version = True iteration += 1 + + +def get_namespace(container_name): + node = rt.getNodeByName(container_name) + if not node: + raise RuntimeError("Master Container Not Found..") + name = rt.getUserProp(node, "name") + namespace = rt.getUserProp(node, "namespace") + return namespace, name diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index c0e1172a6d..c70ece6293 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -33,16 +35,17 @@ class FbxLoader(load.LoaderPlugin): rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.container(name=name) - selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) - for selection in selections: - selection.Parent = container - namespace = unique_namespace( name + "_", suffix="_", ) + container = rt.container(name=f"{namespace}:{name}") + selections = rt.GetCurrentSelection() + import_custom_attribute_data(container, selections) + + for selection in selections: + selection.Parent = container + selection.name = f"{namespace}:{selection.name}" return containerise( name, [container], context, @@ -54,11 +57,13 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") - - inst_container = rt.getNodeByName(param_container) - rt.Select(node.Children) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + for prev_fbx_obj in rt.selection: + if rt.isValidNode(prev_fbx_obj): + rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", True) rt.FBXImporterSetParam("Camera", True) @@ -71,10 +76,11 @@ class FbxLoader(load.LoaderPlugin): for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container + fbx_object.name = f"{namespace}:{fbx_object.name}" for children in node.Children: if rt.classOf(children) == rt.Container: - if children.name == param_container: + if children.name == sub_node_name: update_custom_attribute_data( children, current_fbx_objects) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index aa177291d8..cf5f7736e3 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, update_custom_attribute_data @@ -29,17 +31,21 @@ class MaxSceneLoader(load.LoaderPlugin): path = path.replace('\\', '/') rt.MergeMaxFile(path, quiet=True, includeFullGroup=True) max_objects = rt.getLastMergedNodes() + max_object_names = [obj.name for obj in max_objects] # implement the OP/AYON custom attributes before load max_container = [] - container = rt.Container(name=name) - import_custom_attribute_data(container, max_objects) - max_container.append(container) - max_container.extend(max_objects) namespace = unique_namespace( name + "_", suffix="_", ) + container_name = f"{namespace}:{name}" + container = rt.Container(name=container_name) + import_custom_attribute_data(container, max_objects) + max_container.append(container) + max_container.extend(max_objects) + for max_obj, obj_name in zip(max_objects, max_object_names): + max_obj.name = f"{namespace}:{obj_name}" return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -51,34 +57,28 @@ class MaxSceneLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") - + namespace, name = get_namespace(node_name) + sub_container_name = f"{namespace}:{name}" # delete the old container with attribute # delete old duplicate - prev_max_object_names = [obj.name for obj - in rt.getLastMergedNodes()] + #TODO: get the prev_max_objects by using node.Children + rt.Select(node.Children) + for prev_max_obj in rt.GetCurrentSelection(): + if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa + rt.Delete(prev_max_obj) rt.MergeMaxFile(path, rt.Name("deleteOldDups")) current_max_objects = rt.getLastMergedNodes() current_max_object_names = [obj.name for obj in current_max_objects] - for name in current_max_object_names: - idx = rt.findItem(prev_max_object_names, name) - if idx: - prev_max_object_names = rt.deleteItem( - prev_max_object_names, idx) - for object_name in prev_max_object_names: - prev_max_object = rt.getNodeByName(object_name) - rt.Delete(prev_max_object) - + sub_container = rt.getNodeByName(sub_container_name) + update_custom_attribute_data(sub_container, current_max_objects) for max_object in current_max_objects: max_object.Parent = node - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == param_container: - update_custom_attribute_data( - children, current_max_objects) + for max_obj, obj_name in zip( + current_max_objects, current_max_object_names): + max_obj.name = f"{namespace}:{obj_name}" + lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index deb3389992..aee948f2e2 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -58,6 +58,13 @@ class ModelAbcLoader(load.LoaderPlugin): name + "_", suffix="_", ) + for abc_object in abc_container.Children: + abc_object.name = f"{namespace}:{abc_object.name}" + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}" + abc_container.name = abc_container_name + # get the correct container + abc_container = rt.GetNodeByName(abc_container_name) return containerise( name, [abc_container], context, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index f85bfa03a1..6097a4ca6e 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -5,7 +5,9 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.lib import maintained_selection @@ -28,20 +30,18 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.GetNodeByName(name) - - container = rt.Container(name=name) - + namespace = unique_namespace( + name + "_", + suffix="_", + ) + container = rt.container(name=f"{namespace}:{name}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) for selection in selections: selection.Parent = container + selection.name = f"{namespace}:{selection.name}" - namespace = unique_namespace( - name + "_", - suffix="_", - ) return containerise( name, [container], context, namespace, loader=self.__class__.__name__ @@ -52,8 +52,14 @@ class FbxModelLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + for prev_fbx_obj in rt.selection: + if rt.isValidNode(prev_fbx_obj): + rt.Delete(prev_fbx_obj) + rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("merge")) @@ -61,16 +67,17 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() - - inst_container = rt.getNodeByName(param_container) - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == param_container: - update_custom_attribute_data( - children, current_fbx_objects) for fbx_object in current_fbx_objects: if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container + fbx_object.name = f"{namespace}:{fbx_object.name}" + + for children in node.Children: + if rt.classOf(children) == rt.Container: + if children.name == sub_node_name: + update_custom_attribute_data( + children, current_fbx_objects) + with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index b42ef399b0..225801b8d0 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, @@ -27,18 +29,19 @@ class ObjLoader(load.LoaderPlugin): self.log.debug("Executing command to import..") rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') - # create "missing" container for obj import - container = rt.Container(name=name) - selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) - # get current selection - for selection in selections: - selection.Parent = container namespace = unique_namespace( name + "_", suffix="_", ) + # create "missing" container for obj import + container = rt.Container(name=f"{namespace}:{name}") + selections = rt.GetCurrentSelection() + import_custom_attribute_data(container, selections) + # get current selection + for selection in selections: + selection.Parent = container + selection.name = f"{namespace}:{selection.name}" return containerise( name, [container], context, namespace, loader=self.__class__.__name__) @@ -48,21 +51,22 @@ class ObjLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.GetNodeByName(node_name) - - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") - - inst_container = rt.getNodeByName(param_container) - for child in inst_container.Children: - rt.Delete(child) + node = rt.getNodeByName(node_name) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + rt.Select(inst_container.Children) + for prev_obj in rt.selection: + if rt.isValidNode(prev_obj): + rt.Delete(prev_obj) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection selections = rt.GetCurrentSelection() + update_custom_attribute_data(inst_container, selections) for selection in selections: selection.Parent = inst_container - update_custom_attribute_data(inst_container, selections) + selection.name = f"{namespace}:{selection.name}" with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 4febba216e..0c17736739 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,12 +1,13 @@ import os from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + import_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -35,14 +36,20 @@ class ModelUSDLoader(load.LoaderPlugin): rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - asset = rt.GetNodeByName(name) - - import_custom_attribute_data(asset, asset.Children) - namespace = unique_namespace( name + "_", suffix="_", ) + asset = rt.GetNodeByName(name) + import_custom_attribute_data(asset, asset.Children) + for usd_asset in asset.Children: + usd_asset.name = f"{namespace}:{usd_asset.name}" + + asset_name = f"{namespace}:{name}" + asset.name = asset_name + # need to get the correct container after renamed + asset = rt.GetNodeByName(asset_name) + return containerise( name, [asset], context, @@ -54,12 +61,14 @@ class ModelUSDLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.GetNodeByName(node_name) + namespace, name = get_namespace(node_name) + sub_node_name = f"{namespace}:{name}" for n in node.Children: - for r in n.Children: - rt.Delete(r) + rt.Select(n.Children) + for prev_usd_asset in rt.selection: + if rt.isValidNode(prev_usd_asset): + rt.Delete(prev_usd_asset) rt.Delete(n) - container_name = node_name.split(":")[-1] - param_container, _ = container_name.split("_") import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(path) @@ -71,13 +80,13 @@ class ModelUSDLoader(load.LoaderPlugin): rt.USDImporter.importFile( path, importOptions=import_options) - asset = rt.GetNodeByName(param_container) + asset = rt.GetNodeByName(name) asset.Parent = node - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == param_container: - update_custom_attribute_data( - asset, asset.Children) + import_custom_attribute_data(asset, asset.Children) + for children in asset.Children: + children.name = f"{namespace}:{children.name}" + asset.name = sub_node_name + with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index af03e70236..ca833a383c 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -65,6 +65,14 @@ class AbcLoader(load.LoaderPlugin): suffix="_", ) + for abc_object in abc_container.Children: + abc_object.name = f"{namespace}:{abc_object.name}" + # rename the abc container with namespace + abc_container_name = f"{namespace}:{name}" + abc_container.name = abc_container_name + # get the correct container + abc_container = rt.GetNodeByName(abc_container_name) + return containerise( name, [abc_container], context, namespace, loader=self.__class__.__name__ diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 6c94fb7847..87b7fce292 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,7 +1,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -26,14 +28,15 @@ class PointCloudLoader(load.LoaderPlugin): filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath - prt_container = rt.Container(name=name) - obj.Parent = prt_container - import_custom_attribute_data(prt_container, [obj]) namespace = unique_namespace( name + "_", suffix="_", ) + prt_container = rt.Container(name=f"{namespace}:{name}") + import_custom_attribute_data(prt_container, [obj]) + obj.Parent = prt_container + obj.name = f"{namespace}:{obj.name}" return containerise( name, [prt_container], context, @@ -45,14 +48,15 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) + namespace, name = get_namespace(container["instance_node"]) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + update_custom_attribute_data( + inst_container, inst_container.Children) with maintained_selection(): rt.Select(node.Children) - for sub_node in rt.Selection: - children_node = sub_node.Children - update_custom_attribute_data( - sub_node, sub_node.Children) - for prt in children_node: - prt.filename = path + for prt in inst_container.Children: + prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 1c4cd02143..a64bfa7de2 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -11,7 +11,9 @@ from openpype.hosts.max.api.pipeline import ( update_custom_attribute_data ) from openpype.hosts.max.api import lib -from openpype.hosts.max.api.lib import unique_namespace +from openpype.hosts.max.api.lib import ( + unique_namespace, get_namespace +) class RedshiftProxyLoader(load.LoaderPlugin): @@ -35,14 +37,15 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - container = rt.Container(name=name) - rs_proxy.Parent = container - import_custom_attribute_data(container, [rs_proxy]) namespace = unique_namespace( name + "_", suffix="_", ) + container = rt.Container(name=f"{namespace}:{name}") + rs_proxy.Parent = container + rs_proxy.name = f"{namespace}:{rs_proxy.name}" + import_custom_attribute_data(container, [rs_proxy]) return containerise( name, [container], context, @@ -52,13 +55,14 @@ class RedshiftProxyLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - for sub_node in node.Children: - children_node = sub_node.Children - update_custom_attribute_data( - sub_node, children_node) - for proxy in children_node: - proxy.file = path + namespace, name = get_namespace(container["instance_node"]) + sub_node_name = f"{namespace}:{name}" + inst_container = rt.getNodeByName(sub_node_name) + + update_custom_attribute_data( + inst_container, inst_container.Children) + for proxy in inst_container.Children: + proxy.file = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) From 637c6396cad3bfe85f8537d969232986694f9af4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 24 Aug 2023 17:13:38 +0800 Subject: [PATCH 095/291] hound --- openpype/hosts/max/plugins/load/load_max_scene.py | 5 ++--- openpype/hosts/max/plugins/load/load_redshift_proxy.py | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index cf5f7736e3..fada871c6d 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -61,7 +61,6 @@ class MaxSceneLoader(load.LoaderPlugin): sub_container_name = f"{namespace}:{name}" # delete the old container with attribute # delete old duplicate - #TODO: get the prev_max_objects by using node.Children rt.Select(node.Children) for prev_max_obj in rt.GetCurrentSelection(): if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa @@ -75,8 +74,8 @@ class MaxSceneLoader(load.LoaderPlugin): update_custom_attribute_data(sub_container, current_max_objects) for max_object in current_max_objects: max_object.Parent = node - for max_obj, obj_name in zip( - current_max_objects, current_max_object_names): + for max_obj, obj_name in zip(current_max_objects, + current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index a64bfa7de2..b240714314 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -37,7 +37,6 @@ class RedshiftProxyLoader(load.LoaderPlugin): if collections: rs_proxy.is_sequence = True - namespace = unique_namespace( name + "_", suffix="_", From d0857a63e0a9c107fdd9158c032b67afcd70d941 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 24 Aug 2023 17:27:52 +0800 Subject: [PATCH 096/291] alembic loader namespace fix --- openpype/hosts/max/plugins/load/load_model.py | 2 -- openpype/hosts/max/plugins/load/load_pointcache.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index aee948f2e2..acc2a4032b 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -63,8 +63,6 @@ class ModelAbcLoader(load.LoaderPlugin): # rename the abc container with namespace abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name - # get the correct container - abc_container = rt.GetNodeByName(abc_container_name) return containerise( name, [abc_container], context, diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index ca833a383c..64bf7ddac0 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -70,8 +70,6 @@ class AbcLoader(load.LoaderPlugin): # rename the abc container with namespace abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name - # get the correct container - abc_container = rt.GetNodeByName(abc_container_name) return containerise( name, [abc_container], context, From 93f897c780d5b6bda9ee56e013b12ee7b0ba6952 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Aug 2023 13:59:34 +0200 Subject: [PATCH 097/291] deadline: adding collect farm target and removing redundant key --- .../plugins/publish/collect_render.py | 1 - .../fusion/plugins/publish/collect_render.py | 1 - .../plugins/publish/collect_farm_render.py | 1 - .../plugins/publish/submit_maya_muster.py | 1 - .../publish/submit_celaction_deadline.py | 2 +- .../publish/submit_houdini_render_deadline.py | 1 - .../plugins/publish/submit_max_deadline.py | 1 - .../plugins/publish/submit_maya_deadline.py | 1 - .../plugins/publish/submit_nuke_deadline.py | 1 - .../publish/abstract_collect_render.py | 1 - .../plugins/publish/collect_farm_target.py | 45 +++++++++++++++++++ 11 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 openpype/plugins/publish/collect_farm_target.py diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index aa46461915..49874d6cff 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -138,7 +138,6 @@ class CollectAERender(publish.AbstractCollectRender): fam = "render.farm" if fam not in instance.families: instance.families.append(fam) - instance.toBeRenderedOn = "deadline" instance.renderer = "aerender" instance.farm = True # to skip integrate if "review" in instance.families: diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index a20a142701..341f3f191a 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -108,7 +108,6 @@ class CollectFusionRender( fam = "render.farm" if fam not in instance.families: instance.families.append(fam) - instance.toBeRenderedOn = "deadline" instance.farm = True # to skip integrate if "review" in instance.families: # to skip ExtractReview locally diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 5e9b9094a7..5daa93cddb 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -174,7 +174,6 @@ class CollectFarmRender(publish.AbstractCollectRender): outputFormat=info[1], outputStartFrame=info[3], leadingZeros=info[2], - toBeRenderedOn='deadline', ignoreFrameHandleCheck=True ) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index 8e219eae85..b79c9ed140 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -249,7 +249,6 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): Authenticate with Muster, collect all data, prepare path for post render publish job and submit job to farm. """ - instance.data["toBeRenderedOn"] = "muster" # setup muster environment self.MUSTER_REST_URL = os.environ.get("MUSTER_REST_URL") diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index ee28612b44..4aef914023 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -27,7 +27,7 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): deadline_job_delay = "00:00:08:00" def process(self, instance): - instance.data["toBeRenderedOn"] = "deadline" + context = instance.context # get default deadline webservice url from deadline module diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 108c377078..8f21a920be 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -141,4 +141,3 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): # Store output dir for unified publisher (filesequence) output_dir = os.path.dirname(instance.data["files"][0]) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 8e05582962..5d38903770 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -174,7 +174,6 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, first_file = next(self._iter_expected_files(files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" filename = os.path.basename(filepath) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 75d24b28f0..34f3905a17 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -300,7 +300,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" # Patch workfile (only when use_published is enabled) if self.use_published: diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index cfdeb4968b..ded5cd179f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -97,7 +97,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, instance.data["suspend_publish"] = instance.data["attributeValues"][ "suspend_publish"] - instance.data["toBeRenderedOn"] = "deadline" families = instance.data["families"] node = instance.data["transientData"]["node"] diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py index 6877d556c3..8a26402bd8 100644 --- a/openpype/pipeline/publish/abstract_collect_render.py +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -75,7 +75,6 @@ class RenderInstance(object): tilesY = attr.ib(default=0) # number of tiles in Y # submit_publish_job - toBeRenderedOn = attr.ib(default=None) deadlineSubmissionJob = attr.ib(default=None) anatomyData = attr.ib(default=None) outputDir = attr.ib(default=None) diff --git a/openpype/plugins/publish/collect_farm_target.py b/openpype/plugins/publish/collect_farm_target.py new file mode 100644 index 0000000000..78410835dd --- /dev/null +++ b/openpype/plugins/publish/collect_farm_target.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +import pyblish.api + + +class CollectFarmTarget(pyblish.api.InstancePlugin): + """Collects the render target for the instance + """ + + order = pyblish.api.CollectorOrder + 0.499 + label = "Collect Farm Target" + targets = ["local"] + + def process(self, instance): + if not instance.data.get("farm"): + return + + context = instance.context + try: + deadline_module = context.data.get("openPypeModules")["deadline"] + if deadline_module.enabled: + instance.data["toBeRenderedOn"] = "deadline" + self.log.debug("Collected render target: deadline") + except AttributeError: + self.log.error("Cannot get OpenPype Deadline module.") + raise AssertionError("OpenPype Deadline module not found.") + + try: + royalrender_module = \ + context.data.get("openPypeModules")["royalrender"] + if royalrender_module.enabled: + instance.data["toBeRenderedOn"] = "royalrender" + self.log.debug("Collected render target: royalrender") + + except AttributeError: + self.log.error("Cannot get OpenPype RoyalRender module.") + raise AssertionError("OpenPype RoyalRender module not found.") + + try: + muster_module = context.data.get("openPypeModules")["muster"] + if muster_module.enabled: + instance.data["toBeRenderedOn"] = "muster" + self.log.debug("Collected render target: muster") + except AttributeError: + self.log.error("Cannot get OpenPype Muster module.") + raise AssertionError("OpenPype Muster module not found.") From 098bacddb9e87ac5eb7c8b9e5f1ef2d3f43fa74f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 11:54:44 +0800 Subject: [PATCH 098/291] fix incorrect position of the container during updating --- openpype/hosts/max/api/lib.py | 32 +++++++++++++++++++ .../hosts/max/plugins/load/load_camera_fbx.py | 11 ++++++- .../hosts/max/plugins/load/load_max_scene.py | 12 +++++-- .../hosts/max/plugins/load/load_model_fbx.py | 11 ++++++- .../hosts/max/plugins/load/load_model_obj.py | 12 ++++++- .../hosts/max/plugins/load/load_model_usd.py | 14 ++++++-- 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 08819ba155..267e75e5fe 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -371,9 +371,41 @@ def unique_namespace(namespace, format="%02d", def get_namespace(container_name): + """Get the namespace and name of the sub-container + + Args: + container_name (str): the name of master container + + Raises: + RuntimeError: when there is no master container found + + Returns: + namespace (str): namespace of the sub-container + name (str): name of the sub-container + """ node = rt.getNodeByName(container_name) if not node: raise RuntimeError("Master Container Not Found..") name = rt.getUserProp(node, "name") namespace = rt.getUserProp(node, "namespace") return namespace, name + +def object_transform_set(container_children): + """A function which allows to store the transform of + previous loaded object(s) + Args: + container_children(list): A list of nodes + + Returns: + transform_set (dict): A dict with all transform data of + the previous loaded object(s) + """ + transform_set = {} + for node in container_children: + name = f"{node.name}.transform" + transform_set[name] = node.pos + name = f"{node.name}.scale" + transform_set[name] = node.scale + name = f"{node.name}.rotation" + transform_set[name] = node.rotation + return transform_set diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index c70ece6293..acd77ad686 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.pipeline import ( containerise, @@ -61,6 +63,7 @@ class FbxLoader(load.LoaderPlugin): sub_node_name = f"{namespace}:{name}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) for prev_fbx_obj in rt.selection: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) @@ -77,6 +80,12 @@ class FbxLoader(load.LoaderPlugin): if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.pos = transform_data[ + f"{fbx_object.name}.transform"] + fbx_object.rotation = transform_data[ + f"{fbx_object.name}.rotation"] + fbx_object.scale = transform_data[ + f"{fbx_object.name}.scale"] for children in node.Children: if rt.classOf(children) == rt.Container: diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index fada871c6d..3d524e261f 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.pipeline import ( containerise, import_custom_attribute_data, @@ -62,6 +64,7 @@ class MaxSceneLoader(load.LoaderPlugin): # delete the old container with attribute # delete old duplicate rt.Select(node.Children) + transform_data = object_transform_set(node.Children) for prev_max_obj in rt.GetCurrentSelection(): if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa rt.Delete(prev_max_obj) @@ -77,7 +80,12 @@ class MaxSceneLoader(load.LoaderPlugin): for max_obj, obj_name in zip(current_max_objects, current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" - + max_obj.pos = transform_data[ + f"{max_obj.name}.transform"] + max_obj.rotation = transform_data[ + f"{max_obj.name}.rotation"] + max_obj.scale = transform_data[ + f"{max_obj.name}.scale"] lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 6097a4ca6e..fcac72dae1 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -6,7 +6,9 @@ from openpype.hosts.max.api.pipeline import ( ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection @@ -56,6 +58,7 @@ class FbxModelLoader(load.LoaderPlugin): sub_node_name = f"{namespace}:{name}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) for prev_fbx_obj in rt.selection: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) @@ -71,6 +74,12 @@ class FbxModelLoader(load.LoaderPlugin): if fbx_object.Parent != inst_container: fbx_object.Parent = inst_container fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.pos = transform_data[ + f"{fbx_object.name}.transform"] + fbx_object.rotation = transform_data[ + f"{fbx_object.name}.rotation"] + fbx_object.scale = transform_data[ + f"{fbx_object.name}.scale"] for children in node.Children: if rt.classOf(children) == rt.Container: diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 225801b8d0..04a0ac1679 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -2,7 +2,10 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + maintained_selection, + object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( @@ -56,6 +59,7 @@ class ObjLoader(load.LoaderPlugin): sub_node_name = f"{namespace}:{name}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) + transform_data = object_transform_set(inst_container.Children) for prev_obj in rt.selection: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) @@ -67,6 +71,12 @@ class ObjLoader(load.LoaderPlugin): for selection in selections: selection.Parent = inst_container selection.name = f"{namespace}:{selection.name}" + selection.pos = transform_data[ + f"{selection.name}.transform"] + selection.rotation = transform_data[ + f"{selection.name}.rotation"] + selection.scale = transform_data[ + f"{selection.name}.scale"] with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 0c17736739..14f339f039 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -2,7 +2,9 @@ import os from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace, + get_namespace, + object_transform_set ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( @@ -63,8 +65,10 @@ class ModelUSDLoader(load.LoaderPlugin): node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) sub_node_name = f"{namespace}:{name}" + transform_data = None for n in node.Children: rt.Select(n.Children) + transform_data = object_transform_set(n.Children) for prev_usd_asset in rt.selection: if rt.isValidNode(prev_usd_asset): rt.Delete(prev_usd_asset) @@ -85,8 +89,14 @@ class ModelUSDLoader(load.LoaderPlugin): import_custom_attribute_data(asset, asset.Children) for children in asset.Children: children.name = f"{namespace}:{children.name}" - asset.name = sub_node_name + children.pos = transform_data[ + f"{children.name}.transform"] + children.rotation = transform_data[ + f"{children.name}.rotation"] + children.scale = transform_data[ + f"{children.name}.scale"] + asset.name = sub_node_name with maintained_selection(): rt.Select(node) From b490b2741f75d51d1dc4f6ec6fbbab238d11fa0c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 11:56:01 +0800 Subject: [PATCH 099/291] hound --- openpype/hosts/max/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 267e75e5fe..712340c99a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -390,6 +390,7 @@ def get_namespace(container_name): namespace = rt.getUserProp(node, "namespace") return namespace, name + def object_transform_set(container_children): """A function which allows to store the transform of previous loaded object(s) From e9952b5d53421794b58b278d015412f368baf88e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Aug 2023 11:26:03 +0200 Subject: [PATCH 100/291] global: avoiding cleanup of flagged representation --- openpype/pipeline/publish/lib.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index ada12800a9..08ce8c2c9d 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -953,6 +953,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True): return file_path + def add_repre_files_for_cleanup(instance, repre): """ Explicitly mark repre files to be deleted. @@ -961,7 +962,16 @@ def add_repre_files_for_cleanup(instance, repre): """ files = repre["files"] staging_dir = repre.get("stagingDir") - if not staging_dir or instance.data.get("stagingDir_persistent"): + + # first make sure representation level is not persistent + if ( + not staging_dir + or repre.get("stagingDir_persistent") + ): + return + + # then look into instance level if it's not persistent + if instance.data.get("stagingDir_persistent"): return if isinstance(files, str): From 499b4623a30a0b4d7d900683d119394f27304540 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Aug 2023 15:41:21 +0200 Subject: [PATCH 101/291] adding abstraction of publishing related functions --- openpype/pipeline/colorspace.py | 153 ++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 9 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 731132911a..649d355f62 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -13,12 +13,17 @@ from openpype.lib import ( Logger ) from openpype.pipeline import Anatomy +from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS + log = Logger.get_logger(__name__) -class CashedData: +class CachedData: remapping = None + allowed_exts = { + ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) + } @contextlib.contextmanager @@ -546,15 +551,15 @@ def get_remapped_colorspace_to_native( Union[str, None]: native colorspace name defined in remapping or None """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("to_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("to_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["to_native"] = { + CachedData.remapping[host_name]["to_native"] = { rule["ocio_name"]: rule["host_native_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["to_native"].get( + return CachedData.remapping[host_name]["to_native"].get( ocio_colorspace_name) @@ -572,15 +577,15 @@ def get_remapped_colorspace_from_native( Union[str, None]: Ocio colorspace name defined in remapping or None. """ - CashedData.remapping.setdefault(host_name, {}) - if CashedData.remapping[host_name].get("from_native") is None: + CachedData.remapping.setdefault(host_name, {}) + if CachedData.remapping[host_name].get("from_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name]["from_native"] = { + CachedData.remapping[host_name]["from_native"] = { rule["host_native_name"]: rule["ocio_name"] for rule in remapping_rules } - return CashedData.remapping[host_name]["from_native"].get( + return CachedData.remapping[host_name]["from_native"].get( host_native_colorspace_name) @@ -601,3 +606,133 @@ def _get_imageio_settings(project_settings, host_name): imageio_host = project_settings.get(host_name, {}).get("imageio", {}) return imageio_global, imageio_host + + +def get_colorspace_settings_from_publish_context(context_data): + """Returns solved settings for the host context. + + Args: + context_data (publish.Context.data): publishing context data + + Returns: + tuple | bool: config, file rules or None + """ + if "imageioSettings" in context_data: + return context_data["imageioSettings"] + + project_name = context_data["projectName"] + host_name = context_data["hostName"] + anatomy_data = context_data["anatomyData"] + project_settings_ = context_data["project_settings"] + + config_data = get_imageio_config( + project_name, host_name, + project_settings=project_settings_, + anatomy_data=anatomy_data + ) + + # in case host color management is not enabled + if not config_data: + return None + + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings_ + ) + + # caching settings for future instance processing + context_data["imageioSettings"] = (config_data, file_rules) + + return config_data, file_rules + + +def set_colorspace_data_to_representation( + representation, context_data, + colorspace=None, + colorspace_settings=None, + log=None +): + """Sets colorspace data to representation. + + Args: + representation (dict): publishing representation + context_data (publish.Context.data): publishing context data + config_data (dict): host resolved config data + file_rules (dict): host resolved file rules data + colorspace (str, optional): colorspace name. Defaults to None. + colorspace_settings (tuple[dict, dict], optional): + Settings for config_data and file_rules. + Defaults to None. + log (logging.Logger, optional): logger instance. Defaults to None. + + Example: + ``` + { + # for other publish plugins and loaders + "colorspace": "linear", + "config": { + # for future references in case need + "path": "/abs/path/to/config.ocio", + # for other plugins within remote publish cases + "template": "{project[root]}/path/to/config.ocio" + } + } + ``` + + """ + log = log or Logger.get_logger(__name__) + + file_ext = representation["ext"] + + # check if `file_ext` in lower case is in CachedData.allowed_exts + if file_ext.lstrip(".").lower() not in CachedData.allowed_exts: + log.debug( + "Extension '{}' is not in allowed extensions.".format(file_ext) + ) + return + + if colorspace_settings is None: + colorspace_settings = get_colorspace_settings_from_publish_context( + context_data) + + # in case host color management is not enabled + if not colorspace_settings: + log.warning("Host's colorspace management is disabled.") + return + + # unpack colorspace settings + config_data, file_rules = colorspace_settings + + if not config_data: + # warn in case no colorspace path was defined + log.warning("No colorspace management was defined") + return + + log.debug("Config data is: `{}`".format(config_data)) + + project_name = context_data["projectName"] + host_name = context_data["hostName"] + project_settings = context_data["project_settings"] + + # get one filename + filename = representation["files"] + if isinstance(filename, list): + filename = filename[0] + + # get matching colorspace from rules + colorspace = colorspace or get_imageio_colorspace_from_filepath( + filename, host_name, project_name, + config_data=config_data, + file_rules=file_rules, + project_settings=project_settings + ) + + # infuse data to representation + if colorspace: + colorspace_data = { + "colorspace": colorspace, + "config": config_data + } + + # update data key + representation["colorspaceData"] = colorspace_data From 8260eb36bdd3e1d4cb8590f59853099b11dd75a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Aug 2023 16:39:12 +0200 Subject: [PATCH 102/291] implementing abstarctions from colorspace --- openpype/pipeline/publish/publish_plugins.py | 106 ++----------------- 1 file changed, 10 insertions(+), 96 deletions(-) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index ba3be6397e..17ede069cb 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -1,6 +1,5 @@ import inspect from abc import ABCMeta -from pprint import pformat import pyblish.api from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin from openpype.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS @@ -14,9 +13,8 @@ from .lib import ( ) from openpype.pipeline.colorspace import ( - get_imageio_colorspace_from_filepath, - get_imageio_config, - get_imageio_file_rules + get_colorspace_settings_from_publish_context, + set_colorspace_data_to_representation ) @@ -306,12 +304,8 @@ class ColormanagedPyblishPluginMixin(object): matching colorspace from rules. Finally, it infuses this data into the representation. """ - allowed_ext = set( - ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) - ) - @staticmethod - def get_colorspace_settings(context): + def get_colorspace_settings(self, context): """Returns solved settings for the host context. Args: @@ -320,33 +314,7 @@ class ColormanagedPyblishPluginMixin(object): Returns: tuple | bool: config, file rules or None """ - if "imageioSettings" in context.data: - return context.data["imageioSettings"] - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - anatomy_data = context.data["anatomyData"] - project_settings_ = context.data["project_settings"] - - config_data = get_imageio_config( - project_name, host_name, - project_settings=project_settings_, - anatomy_data=anatomy_data - ) - - # in case host color management is not enabled - if not config_data: - return None - - file_rules = get_imageio_file_rules( - project_name, host_name, - project_settings=project_settings_ - ) - - # caching settings for future instance processing - context.data["imageioSettings"] = (config_data, file_rules) - - return config_data, file_rules + return get_colorspace_settings_from_publish_context(context.data) def set_representation_colorspace( self, representation, context, @@ -380,64 +348,10 @@ class ColormanagedPyblishPluginMixin(object): ``` """ - ext = representation["ext"] - # check extension - self.log.debug("__ ext: `{}`".format(ext)) - - # check if ext in lower case is in self.allowed_ext - if ext.lstrip(".").lower() not in self.allowed_ext: - self.log.debug( - "Extension '{}' is not in allowed extensions.".format(ext) - ) - return - - if colorspace_settings is None: - colorspace_settings = self.get_colorspace_settings(context) - - # in case host color management is not enabled - if not colorspace_settings: - self.log.warning("Host's colorspace management is disabled.") - return - - # unpack colorspace settings - config_data, file_rules = colorspace_settings - - if not config_data: - # warn in case no colorspace path was defined - self.log.warning("No colorspace management was defined") - return - - self.log.debug("Config data is: `{}`".format(config_data)) - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - project_settings = context.data["project_settings"] - - # get one filename - filename = representation["files"] - if isinstance(filename, list): - filename = filename[0] - - self.log.debug("__ filename: `{}`".format(filename)) - - # get matching colorspace from rules - colorspace = colorspace or get_imageio_colorspace_from_filepath( - filename, host_name, project_name, - config_data=config_data, - file_rules=file_rules, - project_settings=project_settings + # using cached settings if available + set_colorspace_data_to_representation( + representation, context.data, + colorspace, + colorspace_settings, + log=self.log ) - self.log.debug("__ colorspace: `{}`".format(colorspace)) - - # infuse data to representation - if colorspace: - colorspace_data = { - "colorspace": colorspace, - "config": config_data - } - - # update data key - representation["colorspaceData"] = colorspace_data - - self.log.debug("__ colorspace_data: `{}`".format( - pformat(colorspace_data))) From 1a61eb0c3e711cfcf856f7f1ef937949161e89f4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 25 Aug 2023 21:29:34 +0800 Subject: [PATCH 103/291] Libor's comment on Container namespace issue and not support rotation --- openpype/hosts/max/api/lib.py | 2 -- openpype/hosts/max/plugins/load/load_camera_fbx.py | 8 ++++---- openpype/hosts/max/plugins/load/load_max_scene.py | 7 +++---- openpype/hosts/max/plugins/load/load_model.py | 3 ++- openpype/hosts/max/plugins/load/load_model_fbx.py | 8 ++++---- openpype/hosts/max/plugins/load/load_model_obj.py | 7 +++---- openpype/hosts/max/plugins/load/load_model_usd.py | 7 +++---- openpype/hosts/max/plugins/load/load_pointcache.py | 3 ++- openpype/hosts/max/plugins/load/load_pointcloud.py | 6 ++++-- openpype/hosts/max/plugins/load/load_redshift_proxy.py | 6 ++++-- 10 files changed, 29 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 712340c99a..034307e72a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -407,6 +407,4 @@ def object_transform_set(container_children): transform_set[name] = node.pos name = f"{node.name}.scale" transform_set[name] = node.scale - name = f"{node.name}.rotation" - transform_set[name] = node.rotation return transform_set diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index acd77ad686..f040115417 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -22,6 +22,7 @@ class FbxLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -41,7 +42,8 @@ class FbxLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.container(name=f"{namespace}:{name}") + container = rt.container( + name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) @@ -60,7 +62,7 @@ class FbxLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) transform_data = object_transform_set(inst_container.Children) @@ -82,8 +84,6 @@ class FbxLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" fbx_object.pos = transform_data[ f"{fbx_object.name}.transform"] - fbx_object.rotation = transform_data[ - f"{fbx_object.name}.rotation"] fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 3d524e261f..98e9be96e1 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -24,6 +24,7 @@ class MaxSceneLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -41,7 +42,7 @@ class MaxSceneLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container_name = f"{namespace}:{name}" + container_name = f"{namespace}:{name}_{self.postfix}" container = rt.Container(name=container_name) import_custom_attribute_data(container, max_objects) max_container.append(container) @@ -60,7 +61,7 @@ class MaxSceneLoader(load.LoaderPlugin): node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_container_name = f"{namespace}:{name}" + sub_container_name = f"{namespace}:{name}_{self.postfix}" # delete the old container with attribute # delete old duplicate rt.Select(node.Children) @@ -82,8 +83,6 @@ class MaxSceneLoader(load.LoaderPlugin): max_obj.name = f"{namespace}:{obj_name}" max_obj.pos = transform_data[ f"{max_obj.name}.transform"] - max_obj.rotation = transform_data[ - f"{max_obj.name}.rotation"] max_obj.scale = transform_data[ f"{max_obj.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index acc2a4032b..c5a73b4327 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -20,6 +20,7 @@ class ModelAbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -61,7 +62,7 @@ class ModelAbcLoader(load.LoaderPlugin): for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}" + abc_container_name = f"{namespace}:{name}_{self.postfix}" abc_container.name = abc_container_name return containerise( diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index fcac72dae1..56c8768675 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -21,6 +21,7 @@ class FbxModelLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -36,7 +37,8 @@ class FbxModelLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.container(name=f"{namespace}:{name}") + container = rt.container( + name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) @@ -55,7 +57,7 @@ class FbxModelLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) transform_data = object_transform_set(inst_container.Children) @@ -76,8 +78,6 @@ class FbxModelLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" fbx_object.pos = transform_data[ f"{fbx_object.name}.transform"] - fbx_object.rotation = transform_data[ - f"{fbx_object.name}.rotation"] fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 04a0ac1679..314889e6ec 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -24,6 +24,7 @@ class ObjLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -38,7 +39,7 @@ class ObjLoader(load.LoaderPlugin): suffix="_", ) # create "missing" container for obj import - container = rt.Container(name=f"{namespace}:{name}") + container = rt.Container(name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() import_custom_attribute_data(container, selections) # get current selection @@ -56,7 +57,7 @@ class ObjLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) rt.Select(inst_container.Children) transform_data = object_transform_set(inst_container.Children) @@ -73,8 +74,6 @@ class ObjLoader(load.LoaderPlugin): selection.name = f"{namespace}:{selection.name}" selection.pos = transform_data[ f"{selection.name}.transform"] - selection.rotation = transform_data[ - f"{selection.name}.rotation"] selection.scale = transform_data[ f"{selection.name}.scale"] with maintained_selection(): diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 14f339f039..f35d8e6327 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -23,6 +23,7 @@ class ModelUSDLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -47,7 +48,7 @@ class ModelUSDLoader(load.LoaderPlugin): for usd_asset in asset.Children: usd_asset.name = f"{namespace}:{usd_asset.name}" - asset_name = f"{namespace}:{name}" + asset_name = f"{namespace}:{name}_{self.postfix}" asset.name = asset_name # need to get the correct container after renamed asset = rt.GetNodeByName(asset_name) @@ -64,7 +65,7 @@ class ModelUSDLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" transform_data = None for n in node.Children: rt.Select(n.Children) @@ -91,8 +92,6 @@ class ModelUSDLoader(load.LoaderPlugin): children.name = f"{namespace}:{children.name}" children.pos = transform_data[ f"{children.name}.transform"] - children.rotation = transform_data[ - f"{children.name}.rotation"] children.scale = transform_data[ f"{children.name}.scale"] diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 64bf7ddac0..070dea88d4 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -24,6 +24,7 @@ class AbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -68,7 +69,7 @@ class AbcLoader(load.LoaderPlugin): for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}" + abc_container_name = f"{namespace}:{name}_{self.postfix}" abc_container.name = abc_container_name return containerise( diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 87b7fce292..c4c4cfbc6c 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -20,6 +20,7 @@ class PointCloudLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" @@ -33,7 +34,8 @@ class PointCloudLoader(load.LoaderPlugin): name + "_", suffix="_", ) - prt_container = rt.Container(name=f"{namespace}:{name}") + prt_container = rt.Container( + name=f"{namespace}:{name}_{self.postfix}") import_custom_attribute_data(prt_container, [obj]) obj.Parent = prt_container obj.name = f"{namespace}:{obj.name}" @@ -49,7 +51,7 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) update_custom_attribute_data( inst_container, inst_container.Children) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index b240714314..f7dd95962b 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -25,6 +25,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" + postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -41,7 +42,8 @@ class RedshiftProxyLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.Container(name=f"{namespace}:{name}") + container = rt.Container( + name=f"{namespace}:{name}_{self.postfix}") rs_proxy.Parent = container rs_proxy.name = f"{namespace}:{rs_proxy.name}" import_custom_attribute_data(container, [rs_proxy]) @@ -55,7 +57,7 @@ class RedshiftProxyLoader(load.LoaderPlugin): path = get_representation_path(representation) namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}" + sub_node_name = f"{namespace}:{name}_{self.postfix}" inst_container = rt.getNodeByName(sub_node_name) update_custom_attribute_data( From 2477287095d1d3e517d15c609a3796901043f0e2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Aug 2023 16:19:49 +0200 Subject: [PATCH 104/291] Publisher: Avoid warnings on thumbnails if source image also has alpha channel (#5510) * Avoid warnings if source image also has alpha channel * Fix logging typo --- openpype/plugins/publish/extract_thumbnail_from_source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index 54622bb84e..1b9f0a8bae 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -128,7 +128,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): if thumbnail_created: return full_output_path - self.log.warning("Thumbanil has not been created.") + self.log.warning("Thumbnail has not been created.") def _instance_has_thumbnail(self, instance): if "representations" not in instance.data: @@ -147,6 +147,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): oiio_cmd = get_oiio_tool_args( "oiiotool", "-a", src_path, + "--ch", "R,G,B", "-o", dst_path ) self.log.info("Running: {}".format(" ".join(oiio_cmd))) From 39f8e65177c9ea941a345e9c3bcafe9b2edfad0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 25 Aug 2023 16:20:20 +0200 Subject: [PATCH 105/291] if no Tag in representation avoiding raise (#5511) --- openpype/pipeline/farm/pyblish_functions.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 288602b77c..fe3ab97de8 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -139,7 +139,7 @@ def get_transferable_representations(instance): to_transfer = [] for representation in instance.data.get("representations", []): - if "publish_on_farm" not in representation.get("tags"): + if "publish_on_farm" not in representation.get("tags", []): continue trans_rep = representation.copy() @@ -265,8 +265,7 @@ def create_skeleton_instance( instance_skeleton_data[v] = instance.data.get(v) representations = get_transferable_representations(instance) - instance_skeleton_data["representations"] = [] - instance_skeleton_data["representations"] += representations + instance_skeleton_data["representations"] = representations persistent = instance.data.get("stagingDir_persistent") is True instance_skeleton_data["stagingDir_persistent"] = persistent From 5ed243e36ef098209c968f119366683d18c4fb5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Aug 2023 17:08:34 +0200 Subject: [PATCH 106/291] Publisher: Fix multiselection value (#5505) * fix change of value on multiselection * fix create - publish page change --- openpype/tools/attribute_defs/widgets.py | 8 ++++++++ openpype/tools/publisher/widgets/overview_widget.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index d46c238da1..7967416e9f 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -343,6 +343,7 @@ class TextAttrWidget(_BaseAttrDefWidget): return self._input_widget.text() def set_value(self, value, multivalue=False): + block_signals = False if multivalue: set_value = set(value) if None in set_value: @@ -352,13 +353,18 @@ class TextAttrWidget(_BaseAttrDefWidget): if len(set_value) == 1: value = tuple(set_value)[0] else: + block_signals = True value = "< Multiselection >" if value != self.current_value(): + if block_signals: + self._input_widget.blockSignals(True) if self.multiline: self._input_widget.setPlainText(value) else: self._input_widget.setText(value) + if block_signals: + self._input_widget.blockSignals(False) class BoolAttrWidget(_BaseAttrDefWidget): @@ -391,7 +397,9 @@ class BoolAttrWidget(_BaseAttrDefWidget): set_value.add(self.attr_def.default) if len(set_value) > 1: + self._input_widget.blockSignals(True) self._input_widget.setCheckState(QtCore.Qt.PartiallyChecked) + self._input_widget.blockSignals(False) return value = tuple(set_value)[0] diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 470645b9ee..778aa1139f 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -168,7 +168,7 @@ class OverviewWidget(QtWidgets.QFrame): def make_sure_animation_is_finished(self): if self._change_anim.state() == QtCore.QAbstractAnimation.Running: self._change_anim.stop() - self._on_change_anim_finished() + self._on_change_anim_finished() def set_state(self, new_state, animate): if new_state == self._current_state: From f38f1b451d6af4f65933a544a8a62b7998bc15d9 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 26 Aug 2023 03:25:15 +0000 Subject: [PATCH 107/291] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index f8a49f8466..6d89e1eeae 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.1" +__version__ = "3.16.5-nightly.2" From 437f483bc48c8f8cdb38b181d9a1c6ec305388aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 26 Aug 2023 03:25:53 +0000 Subject: [PATCH 108/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5c264e4d98..d7e49de5cb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,8 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.5-nightly.2 + - 3.16.5-nightly.1 - 3.16.4 - 3.16.4-nightly.3 - 3.16.4-nightly.2 @@ -133,8 +135,6 @@ body: - 3.14.8-nightly.4 - 3.14.8-nightly.3 - 3.14.8-nightly.2 - - 3.14.8-nightly.1 - - 3.14.7 validations: required: true - type: dropdown From 3f1fb736a60d5ae88e4a425b2d2ce75f3554dae4 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 28 Aug 2023 18:45:43 +0800 Subject: [PATCH 109/291] make sure arnold and other renderers not fallbacking to workfile --- .../deadline/plugins/publish/submit_max_deadline.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 8e05582962..f1127f60f2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -12,7 +12,9 @@ from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin ) -from openpype.settings import get_project_settings +from openpype.pipeline.publish.lib import ( + replace_with_published_scene_path +) from openpype.hosts.max.api.lib import ( get_current_renderer, get_multipass_setting @@ -247,7 +249,12 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, if instance.data["renderer"] == "Redshift_Renderer": self.log.debug("Using Redshift...published scene wont be used..") replace_in_path = False - return replace_in_path + return replace_with_published_scene_path( + instance, replace_in_path) + else: + return replace_with_published_scene_path( + instance, replace_in_path) + @staticmethod def _iter_expected_files(exp): From 6e50d1a814c3765352038c5bf640bd9bc5409f43 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 12:47:43 +0200 Subject: [PATCH 110/291] Fix log message - actually provide plugin name after "Plugin" --- openpype/pipeline/publish/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index ada12800a9..810e1dd342 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -465,7 +465,7 @@ def apply_plugin_settings_automatically(plugin, settings, logger=None): for option, value in settings.items(): if logger: logger.debug("Plugin {} - Attr: {} -> {}".format( - option, value, plugin.__name__ + plugin.__name__, option, value )) setattr(plugin, option, value) From a59de7db311bfeca3114c51920eed9f6098dbc2c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 28 Aug 2023 18:50:20 +0800 Subject: [PATCH 111/291] clean up --- .../deadline/plugins/publish/submit_max_deadline.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index f1127f60f2..d8725e853c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -249,12 +249,8 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, if instance.data["renderer"] == "Redshift_Renderer": self.log.debug("Using Redshift...published scene wont be used..") replace_in_path = False - return replace_with_published_scene_path( - instance, replace_in_path) - else: - return replace_with_published_scene_path( - instance, replace_in_path) - + return replace_with_published_scene_path( + instance, replace_in_path) @staticmethod def _iter_expected_files(exp): From f95c4f27cee49f2281e427a6b855ca3364c99291 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 13:48:20 +0200 Subject: [PATCH 112/291] Use `logging` module style formatting --- openpype/pipeline/publish/lib.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 810e1dd342..815761cd0f 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -464,9 +464,8 @@ def apply_plugin_settings_automatically(plugin, settings, logger=None): for option, value in settings.items(): if logger: - logger.debug("Plugin {} - Attr: {} -> {}".format( - plugin.__name__, option, value - )) + logger.debug("Plugin %s - Attr: %s -> %s", + plugin.__name__, option, value) setattr(plugin, option, value) From d491b4f18be6bdc95bca2eb08077d8da1d5c79d5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 14:28:31 +0200 Subject: [PATCH 113/291] Optimize query (use `cmds.ls` once), add Select Invalid action, improve validation report, avoid "Unknown object type" errors --- .../validate_plugin_path_attributes.py | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 78334cd01f..9f47bf7a3d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -4,6 +4,8 @@ from maya import cmds import pyblish.api +from openpype.hosts.maya.api.lib import pairwise +from openpype.hosts.maya.api.action import SelectInvalidAction from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError @@ -19,31 +21,33 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): hosts = ['maya'] families = ["workfile"] label = "Plug-in Path Attributes" + actions = [SelectInvalidAction] - def get_invalid(self, instance): + # Attributes are defined in project settings + attribute = [] + + @classmethod + def get_invalid(cls, instance): invalid = list() - # get the project setting - validate_path = ( - instance.context.data["project_settings"]["maya"]["publish"] - ) - file_attr = validate_path["ValidatePluginPathAttributes"]["attribute"] + file_attr = cls.attribute if not file_attr: return invalid - # get the nodes and file attributes - for node, attr in file_attr.items(): - # check the related nodes - targets = cmds.ls(type=node) + # Consider only valid node types to avoid "Unknown object type" warning + all_node_types = set(cmds.allNodeTypes()) + node_types = [key for key in file_attr.keys() if key in all_node_types] - for target in targets: - # get the filepath - file_attr = "{}.{}".format(target, attr) - filepath = cmds.getAttr(file_attr) + for node, node_type in pairwise(cmds.ls(type=node_types, + showType=True)): + # get the filepath + file_attr = "{}.{}".format(node, file_attr[node_type]) + filepath = cmds.getAttr(file_attr) - if filepath and not os.path.exists(filepath): - self.log.error("File {0} not exists".format(filepath)) # noqa - invalid.append(target) + if filepath and not os.path.exists(filepath): + cls.log.error("{} '{}' uses non-existing filepath: {}" + .format(node_type, node, filepath)) + invalid.append(node) return invalid @@ -51,5 +55,16 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): """Process all directories Set as Filenames in Non-Maya Nodes""" invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Non-existent Path " - "found: {0}".format(invalid)) + raise PublishValidationError( + title="Plug-in Path Attributes", + message="Non-existent filepath found on nodes: {}".format( + ", ".join(invalid) + ), + description=( + "## Plug-in nodes use invalid filepaths\n" + "The workfile contains nodes from plug-ins that use " + "filepaths which do not exist.\n\n" + "Please make sure their filepaths are correct and the " + "files exist on disk." + ) + ) From afebe088370cb3170ff3147f7258c55224654096 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 16:23:36 +0200 Subject: [PATCH 114/291] Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message --- .../plugins/publish/validate_shape_zero.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 7a7e9a0aee..c7af6a60db 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -7,6 +7,7 @@ from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( ValidateContentsOrder, RepairAction, + PublishValidationError ) @@ -67,5 +68,30 @@ class ValidateShapeZero(pyblish.api.Validator): invalid = self.get_invalid(instance) if invalid: - raise ValueError("Shapes found with non-zero component tweaks: " - "{0}".format(invalid)) + raise PublishValidationError( + title="Shape Component Tweaks", + message="Shapes found with non-zero component tweaks: '{}'" + "".format(", ".join(invalid)), + description=( + "## Shapes found with component tweaks\n" + "Shapes were detected that have component tweaks on their " + "components. Please remove the component tweaks to " + "continue.\n\n" + "### Repair\n" + "The repair action will try to *freeze* the component " + "tweaks into the shapes, which is usually the correct fix " + "if the mesh has no construction history (= has its " + "history deleted)."), + detail=( + "Maya allows to store component tweaks within shape nodes " + "which are applied between its `inMesh` and `outMesh` " + "connections resulting in the output of a shape node " + "differing from the input. We usually want to avoid this " + "for published meshes (in particular for Maya scenes) as " + "it can have unintended results when using these meshes " + "as intermediate meshes since it applies positional " + "differences without being visible edits in the node " + "graph.\n\n" + "These tweaks are traditionally stored in the `.pnts` " + "attribute of shapes.") + ) From 7a513cde9a48b6185f2dbc27a267c80aea2b4f64 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 16:29:52 +0200 Subject: [PATCH 115/291] Remove Validate Instance Attributes The new publisher always updates and imprints the publish instances with the latest Attribute Definitions of the Creator, thus making this legacy validator redundant. Currently legacy instances do not work through the new publisher (they need to be converted to work correctly) and thus even for legacy workfiles this validator is redundant. --- .../publish/validate_instance_attributes.py | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 openpype/hosts/maya/plugins/publish/validate_instance_attributes.py diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py b/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py deleted file mode 100644 index f870c9f8c4..0000000000 --- a/openpype/hosts/maya/plugins/publish/validate_instance_attributes.py +++ /dev/null @@ -1,60 +0,0 @@ -from maya import cmds - -import pyblish.api -from openpype.pipeline.publish import ( - ValidateContentsOrder, PublishValidationError, RepairAction -) -from openpype.pipeline import discover_legacy_creator_plugins -from openpype.hosts.maya.api.lib import imprint - - -class ValidateInstanceAttributes(pyblish.api.InstancePlugin): - """Validate Instance Attributes. - - New attributes can be introduced as new features come in. Old instances - will need to be updated with these attributes for the documentation to make - sense, and users do not have to recreate the instances. - """ - - order = ValidateContentsOrder - hosts = ["maya"] - families = ["*"] - label = "Instance Attributes" - plugins_by_family = { - p.family: p for p in discover_legacy_creator_plugins() - } - actions = [RepairAction] - - @classmethod - def get_missing_attributes(self, instance): - plugin = self.plugins_by_family[instance.data["family"]] - subset = instance.data["subset"] - asset = instance.data["asset"] - objset = instance.data["objset"] - - missing_attributes = {} - for key, value in plugin(subset, asset).data.items(): - if not cmds.objExists("{}.{}".format(objset, key)): - missing_attributes[key] = value - - return missing_attributes - - def process(self, instance): - objset = instance.data.get("objset") - if objset is None: - self.log.debug( - "Skipping {} because no objectset found.".format(instance) - ) - return - - missing_attributes = self.get_missing_attributes(instance) - if missing_attributes: - raise PublishValidationError( - "Missing attributes on {}:\n{}".format( - objset, missing_attributes - ) - ) - - @classmethod - def repair(cls, instance): - imprint(instance.data["objset"], cls.get_missing_attributes(instance)) From e809050f15c98e965b5561249e8c7b604a3e15c9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 28 Aug 2023 16:54:39 +0200 Subject: [PATCH 116/291] Re-use existing select invalid and repair actions, provide more elaborate PublishValidationError report, plus fix "optional" support by using `OptionalPyblishPluginMixin` base class. --- .../publish/validate_instance_in_context.py | 135 +++++++----------- 1 file changed, 49 insertions(+), 86 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py index b257add7e8..4ded57137c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_in_context.py @@ -3,94 +3,19 @@ from __future__ import absolute_import import pyblish.api +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( - ValidateContentsOrder, PublishValidationError + RepairAction, + ValidateContentsOrder, + PublishValidationError, + OptionalPyblishPluginMixin ) from maya import cmds -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Select Instances" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - # Get the errored instances - failed = [] - for result in context.data["results"]: - if ( - result["error"] is None - or result["instance"] is None - or result["instance"] in failed - or result["plugin"] != plugin - ): - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - if instances: - self.log.info( - "Selecting invalid nodes: %s" % ", ".join( - [str(x) for x in instances] - ) - ) - self.select(instances) - else: - self.log.info("No invalid nodes found.") - self.deselect() - - def select(self, instances): - cmds.select(instances, replace=True, noExpand=True) - - def deselect(self): - cmds.select(deselect=True) - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - # Get the errored instances - failed = [] - for result in context.data["results"]: - if result["error"] is None: - continue - if result["instance"] is None: - continue - if result["instance"] in failed: - continue - if result["plugin"] != plugin: - continue - - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - self.set_attribute(instance, context_asset) - - def set_attribute(self, instance, context_asset): - cmds.setAttr( - instance.data.get("name") + ".asset", - context_asset, - type="string" - ) - - -class ValidateInstanceInContext(pyblish.api.InstancePlugin): +class ValidateInstanceInContext(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validator to check if instance asset match context asset. When working in per-shot style you always publish data in context of @@ -104,11 +29,49 @@ class ValidateInstanceInContext(pyblish.api.InstancePlugin): label = "Instance in same Context" optional = True hosts = ["maya"] - actions = [SelectInvalidInstances, RepairSelectInvalidInstances] + actions = [ + openpype.hosts.maya.api.action.SelectInvalidAction, RepairAction + ] def process(self, instance): + if not self.is_active(instance.data): + return + asset = instance.data.get("asset") - context_asset = instance.context.data["assetEntity"]["name"] - msg = "{} has asset {}".format(instance.name, asset) + context_asset = self.get_context_asset(instance) if asset != context_asset: - raise PublishValidationError(msg) + raise PublishValidationError( + message=( + "Instance '{}' publishes to different asset than current " + "context: {}. Current context: {}".format( + instance.name, asset, context_asset + ) + ), + description=( + "## Publishing to a different asset\n" + "There are publish instances present which are publishing " + "into a different asset than your current context.\n\n" + "Usually this is not what you want but there can be cases " + "where you might want to publish into another asset or " + "shot. If that's the case you can disable the validation " + "on the instance to ignore it." + ) + ) + + @classmethod + def get_invalid(cls, instance): + return [instance.data["instance_node"]] + + @classmethod + def repair(cls, instance): + context_asset = cls.get_context_asset(instance) + instance_node = instance.data["instance_node"] + cmds.setAttr( + "{}.asset".format(instance_node), + context_asset, + type="string" + ) + + @staticmethod + def get_context_asset(instance): + return instance.context.data["assetEntity"]["name"] From 0f904cb32a0b7283cafd344f0b33fd7b95e6bda7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Aug 2023 17:23:19 +0200 Subject: [PATCH 117/291] adding default sequence frame data --- openpype/plugins/publish/collect_sequence_frame_data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index c200b245e9..241e7b9011 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -50,4 +50,7 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): return { "frameStart": repres_frames[0], "frameEnd": repres_frames[-1], + "handleStart": 0, + "handleEnd": 0, + "fps": instance.context.data["projectEntity"]["data"]["fps"] } From e6bbb0c038abb0476d66cbc1277dd170343f684b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 28 Aug 2023 22:47:18 +0300 Subject: [PATCH 118/291] add reset fbs to reset framerange --- openpype/hosts/houdini/api/lib.py | 5 +++++ openpype/hosts/houdini/api/pipeline.py | 6 ------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index b03f8c8fc1..55f4fd6197 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -474,6 +474,11 @@ def maintained_selection(): def reset_framerange(): """Set frame range to current asset""" + # Set new scene fps + fps = get_asset_fps() + print("Setting scene FPS to {}".format(int(fps))) + set_scene_fps(fps) + project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 8a26bbb504..3c325edfa7 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -25,7 +25,6 @@ from openpype.lib import ( emit_event, ) -from .lib import get_asset_fps log = logging.getLogger("openpype.hosts.houdini") @@ -385,11 +384,6 @@ def _set_context_settings(): None """ - # Set new scene fps - fps = get_asset_fps() - print("Setting scene FPS to %i" % fps) - lib.set_scene_fps(fps) - lib.reset_framerange() From 3eb2cc21b2ae34405f6eda2539c73384e2e948b4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:01:29 +0200 Subject: [PATCH 119/291] Update ayon-python-api (#5512) * query asset only if asset id is available * updated ayon api * fix subsets arguments --- openpype/client/server/entities.py | 8 +- openpype/tools/utils/tasks_widget.py | 2 +- .../vendor/python/common/ayon_api/__init__.py | 10 + .../vendor/python/common/ayon_api/_api.py | 20 ++ .../python/common/ayon_api/constants.py | 7 +- .../python/common/ayon_api/graphql_queries.py | 6 +- .../python/common/ayon_api/server_api.py | 236 +++++++++++++++--- .../vendor/python/common/ayon_api/version.py | 2 +- 8 files changed, 248 insertions(+), 43 deletions(-) diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py index 9579f13add..39322627bb 100644 --- a/openpype/client/server/entities.py +++ b/openpype/client/server/entities.py @@ -83,10 +83,10 @@ def _get_subsets( project_name, subset_ids, subset_names, - folder_ids, - names_by_folder_ids, - active, - fields + folder_ids=folder_ids, + names_by_folder_ids=names_by_folder_ids, + active=active, + fields=fields, ): yield convert_v4_subset_to_v3(subset) diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 8c0505223e..b554ed50d3 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -75,7 +75,7 @@ class TasksModel(QtGui.QStandardItemModel): def set_asset_id(self, asset_id): asset_doc = None - if self._context_is_valid(): + if asset_id and self._context_is_valid(): project_name = self._get_current_project() asset_doc = get_asset_by_id( project_name, asset_id, fields=["data.tasks"] diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 027e7a3da2..dc3d361f46 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -48,6 +48,11 @@ from ._api import ( patch, delete, + get_timeout, + set_timeout, + get_max_retries, + set_max_retries, + get_event, get_events, dispatch_event, @@ -245,6 +250,11 @@ __all__ = ( "patch", "delete", + "get_timeout", + "set_timeout", + "get_max_retries", + "set_max_retries", + "get_event", "get_events", "dispatch_event", diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 1d7b1837f1..22e137d6e5 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -474,6 +474,26 @@ def delete(*args, **kwargs): return con.delete(*args, **kwargs) +def get_timeout(*args, **kwargs): + con = get_server_api_connection() + return con.get_timeout(*args, **kwargs) + + +def set_timeout(*args, **kwargs): + con = get_server_api_connection() + return con.set_timeout(*args, **kwargs) + + +def get_max_retries(*args, **kwargs): + con = get_server_api_connection() + return con.get_max_retries(*args, **kwargs) + + +def set_max_retries(*args, **kwargs): + con = get_server_api_connection() + return con.set_max_retries(*args, **kwargs) + + def get_event(*args, **kwargs): con = get_server_api_connection() return con.get_event(*args, **kwargs) diff --git a/openpype/vendor/python/common/ayon_api/constants.py b/openpype/vendor/python/common/ayon_api/constants.py index eb1ace0590..eaeb77b607 100644 --- a/openpype/vendor/python/common/ayon_api/constants.py +++ b/openpype/vendor/python/common/ayon_api/constants.py @@ -1,18 +1,21 @@ # Environments where server url and api key are stored for global connection SERVER_URL_ENV_KEY = "AYON_SERVER_URL" SERVER_API_ENV_KEY = "AYON_API_KEY" +SERVER_TIMEOUT_ENV_KEY = "AYON_SERVER_TIMEOUT" +SERVER_RETRIES_ENV_KEY = "AYON_SERVER_RETRIES" + # Backwards compatibility SERVER_TOKEN_ENV_KEY = SERVER_API_ENV_KEY # --- User --- DEFAULT_USER_FIELDS = { - "roles", + "accessGroups", + "defaultAccessGroups", "name", "isService", "isManager", "isGuest", "isAdmin", - "defaultRoles", "createdAt", "active", "hasPassword", diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py index f31134a04d..2435fc8a17 100644 --- a/openpype/vendor/python/common/ayon_api/graphql_queries.py +++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py @@ -247,9 +247,11 @@ def products_graphql_query(fields): query = GraphQlQuery("ProductsQuery") project_name_var = query.add_variable("projectName", "String!") - folder_ids_var = query.add_variable("folderIds", "[String!]") product_ids_var = query.add_variable("productIds", "[String!]") product_names_var = query.add_variable("productNames", "[String!]") + folder_ids_var = query.add_variable("folderIds", "[String!]") + product_types_var = query.add_variable("productTypes", "[String!]") + statuses_var = query.add_variable("statuses", "[String!]") project_field = query.add_field("project") project_field.set_filter("name", project_name_var) @@ -258,6 +260,8 @@ def products_graphql_query(fields): products_field.set_filter("ids", product_ids_var) products_field.set_filter("names", product_names_var) products_field.set_filter("folderIds", folder_ids_var) + products_field.set_filter("productTypes", product_types_var) + products_field.set_filter("statuses", statuses_var) nested_fields = fields_to_dict(set(fields)) add_links_fields(products_field, nested_fields) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index f2689e88dc..511a239a83 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -2,6 +2,7 @@ import os import re import io import json +import time import logging import collections import platform @@ -26,6 +27,8 @@ except ImportError: from json import JSONDecodeError as RequestsJSONDecodeError from .constants import ( + SERVER_TIMEOUT_ENV_KEY, + SERVER_RETRIES_ENV_KEY, DEFAULT_PRODUCT_TYPE_FIELDS, DEFAULT_PROJECT_FIELDS, DEFAULT_FOLDER_FIELDS, @@ -127,6 +130,8 @@ class RestApiResponse(object): @property def text(self): + if self._response is None: + return self.detail return self._response.text @property @@ -135,6 +140,8 @@ class RestApiResponse(object): @property def headers(self): + if self._response is None: + return {} return self._response.headers @property @@ -148,6 +155,8 @@ class RestApiResponse(object): @property def content(self): + if self._response is None: + return b"" return self._response.content @property @@ -339,7 +348,11 @@ class ServerAPI(object): variable value 'AYON_CERT_FILE' by default. create_session (Optional[bool]): Create session for connection if token is available. Default is True. + timeout (Optional[float]): Timeout for requests. + max_retries (Optional[int]): Number of retries for requests. """ + _default_timeout = 10.0 + _default_max_retries = 3 def __init__( self, @@ -352,6 +365,8 @@ class ServerAPI(object): ssl_verify=None, cert=None, create_session=True, + timeout=None, + max_retries=None, ): if not base_url: raise ValueError("Invalid server URL {}".format(str(base_url))) @@ -370,6 +385,13 @@ class ServerAPI(object): ) self._sender = sender + self._timeout = None + self._max_retries = None + + # Set timeout and max retries based on passed values + self.set_timeout(timeout) + self.set_max_retries(max_retries) + if ssl_verify is None: # Custom AYON env variable for CA file or 'True' # - that should cover most default behaviors in 'requests' @@ -474,6 +496,87 @@ class ServerAPI(object): ssl_verify = property(get_ssl_verify, set_ssl_verify) cert = property(get_cert, set_cert) + @classmethod + def get_default_timeout(cls): + """Default value for requests timeout. + + First looks for environment variable SERVER_TIMEOUT_ENV_KEY which + can affect timeout value. If not available then use class + attribute '_default_timeout'. + + Returns: + float: Timeout value in seconds. + """ + + try: + return float(os.environ.get(SERVER_TIMEOUT_ENV_KEY)) + except (ValueError, TypeError): + pass + + return cls._default_timeout + + @classmethod + def get_default_max_retries(cls): + """Default value for requests max retries. + + First looks for environment variable SERVER_RETRIES_ENV_KEY, which + can affect max retries value. If not available then use class + attribute '_default_max_retries'. + + Returns: + int: Max retries value. + """ + + try: + return int(os.environ.get(SERVER_RETRIES_ENV_KEY)) + except (ValueError, TypeError): + pass + + return cls._default_max_retries + + def get_timeout(self): + """Current value for requests timeout. + + Returns: + float: Timeout value in seconds. + """ + + return self._timeout + + def set_timeout(self, timeout): + """Change timeout value for requests. + + Args: + timeout (Union[float, None]): Timeout value in seconds. + """ + + if timeout is None: + timeout = self.get_default_timeout() + self._timeout = float(timeout) + + def get_max_retries(self): + """Current value for requests max retries. + + Returns: + int: Max retries value. + """ + + return self._max_retries + + def set_max_retries(self, max_retries): + """Change max retries value for requests. + + Args: + max_retries (Union[int, None]): Max retries value. + """ + + if max_retries is None: + max_retries = self.get_default_max_retries() + self._max_retries = int(max_retries) + + timeout = property(get_timeout, set_timeout) + max_retries = property(get_max_retries, set_max_retries) + @property def access_token(self): """Access token used for authorization to server. @@ -890,9 +993,17 @@ class ServerAPI(object): for attr, filter_value in filters.items(): query.set_variable_value(attr, filter_value) + # Backwards compatibility for server 0.3.x + # - will be removed in future releases + major, minor, _, _, _ = self.server_version_tuple + access_groups_field = "accessGroups" + if major == 0 and minor <= 3: + access_groups_field = "roles" + for parsed_data in query.continuous_query(self): for user in parsed_data["users"]: - user["roles"] = json.loads(user["roles"]) + user[access_groups_field] = json.loads( + user[access_groups_field]) yield user def get_user(self, username=None): @@ -1004,6 +1115,10 @@ class ServerAPI(object): logout_from_server(self._base_url, self._access_token) def _do_rest_request(self, function, url, **kwargs): + kwargs.setdefault("timeout", self.timeout) + max_retries = kwargs.get("max_retries", self.max_retries) + if max_retries < 1: + max_retries = 1 if self._session is None: # Validate token if was not yet validated # - ignore validation if we're in middle of @@ -1023,38 +1138,54 @@ class ServerAPI(object): elif isinstance(function, RequestType): function = self._session_functions_mapping[function] - try: - response = function(url, **kwargs) + response = None + new_response = None + for _ in range(max_retries): + try: + response = function(url, **kwargs) + break + + except ConnectionRefusedError: + # Server may be restarting + new_response = RestApiResponse( + None, + {"detail": "Unable to connect the server. Connection refused"} + ) + except requests.exceptions.Timeout: + # Connection timed out + new_response = RestApiResponse( + None, + {"detail": "Connection timed out."} + ) + except requests.exceptions.ConnectionError: + # Other connection error (ssl, etc) - does not make sense to + # try call server again + new_response = RestApiResponse( + None, + {"detail": "Unable to connect the server. Connection error"} + ) + break + + time.sleep(0.1) + + if new_response is not None: + return new_response + + content_type = response.headers.get("Content-Type") + if content_type == "application/json": + try: + new_response = RestApiResponse(response) + except JSONDecodeError: + new_response = RestApiResponse( + None, + { + "detail": "The response is not a JSON: {}".format( + response.text) + } + ) - except ConnectionRefusedError: - new_response = RestApiResponse( - None, - {"detail": "Unable to connect the server. Connection refused"} - ) - except requests.exceptions.ConnectionError: - new_response = RestApiResponse( - None, - {"detail": "Unable to connect the server. Connection error"} - ) else: - content_type = response.headers.get("Content-Type") - if content_type == "application/json": - try: - new_response = RestApiResponse(response) - except JSONDecodeError: - new_response = RestApiResponse( - None, - { - "detail": "The response is not a JSON: {}".format( - response.text) - } - ) - - elif content_type in ("image/jpeg", "image/png"): - new_response = RestApiResponse(response) - - else: - new_response = RestApiResponse(response) + new_response = RestApiResponse(response) self.log.debug("Response {}".format(str(new_response))) return new_response @@ -1747,7 +1878,15 @@ class ServerAPI(object): entity_type_defaults = DEFAULT_WORKFILE_INFO_FIELDS elif entity_type == "user": - entity_type_defaults = DEFAULT_USER_FIELDS + entity_type_defaults = set(DEFAULT_USER_FIELDS) + # Backwards compatibility for server 0.3.x + # - will be removed in future releases + major, minor, _, _, _ = self.server_version_tuple + if major == 0 and minor <= 3: + entity_type_defaults.discard("accessGroups") + entity_type_defaults.discard("defaultAccessGroups") + entity_type_defaults.add("roles") + entity_type_defaults.add("defaultRoles") else: raise ValueError("Unknown entity type \"{}\"".format(entity_type)) @@ -2124,7 +2263,12 @@ class ServerAPI(object): server. """ - result = self.get("desktop/dependency_packages") + endpoint = "desktop/dependencyPackages" + major, minor, _, _, _ = self.server_version_tuple + if major == 0 and minor <= 3: + endpoint = "desktop/dependency_packages" + + result = self.get(endpoint) result.raise_for_status() return result.data @@ -3810,6 +3954,8 @@ class ServerAPI(object): product_ids=None, product_names=None, folder_ids=None, + product_types=None, + statuses=None, names_by_folder_ids=None, active=True, fields=None, @@ -3828,6 +3974,10 @@ class ServerAPI(object): filtering. folder_ids (Optional[Iterable[str]]): Ids of task parents. Use 'None' if folder is direct child of project. + product_types (Optional[Iterable[str]]): Product types used for + filtering. + statuses (Optional[Iterable[str]]): Product statuses used for + filtering. names_by_folder_ids (Optional[dict[str, Iterable[str]]]): Product name filtering by folder id. active (Optional[bool]): Filter active/inactive products. @@ -3862,6 +4012,18 @@ class ServerAPI(object): if not filter_folder_ids: return + filter_product_types = None + if product_types is not None: + filter_product_types = set(product_types) + if not filter_product_types: + return + + filter_statuses = None + if statuses is not None: + filter_statuses = set(statuses) + if not filter_statuses: + return + # This will disable 'folder_ids' and 'product_names' filters # - maybe could be enhanced in future? if names_by_folder_ids is not None: @@ -3881,7 +4043,7 @@ class ServerAPI(object): fields = set(fields) | {"id"} if "attrib" in fields: fields.remove("attrib") - fields |= self.get_attributes_fields_for_type("folder") + fields |= self.get_attributes_fields_for_type("product") else: fields = self.get_default_fields_for_type("product") @@ -3908,6 +4070,12 @@ class ServerAPI(object): if filter_folder_ids: filters["folderIds"] = list(filter_folder_ids) + if filter_product_types: + filters["productTypes"] = list(filter_product_types) + + if filter_statuses: + filters["statuses"] = list(filter_statuses) + if product_ids: filters["productIds"] = list(product_ids) diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index df841e0829..f3826a6407 100644 --- a/openpype/vendor/python/common/ayon_api/version.py +++ b/openpype/vendor/python/common/ayon_api/version.py @@ -1,2 +1,2 @@ """Package declaring Python API for Ayon server.""" -__version__ = "0.3.5" +__version__ = "0.4.1" From 61a8ff26f0d42577d4f19346242270ab56a75505 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Aug 2023 15:06:52 +0200 Subject: [PATCH 120/291] General: Fix Validate Publish Dir Validator (#5534) * Fix using wrong key * Update docstrings --- openpype/plugins/publish/validate_publish_dir.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/validate_publish_dir.py b/openpype/plugins/publish/validate_publish_dir.py index 2f41127548..ad5fd34434 100644 --- a/openpype/plugins/publish/validate_publish_dir.py +++ b/openpype/plugins/publish/validate_publish_dir.py @@ -7,12 +7,12 @@ from openpype.pipeline.publish import ( class ValidatePublishDir(pyblish.api.InstancePlugin): - """Validates if 'publishDir' is a project directory + """Validates if files are being published into a project directory - 'publishDir' is collected based on publish templates. In specific cases - ('source' template) source folder of items is used as a 'publishDir', this - validates if it is inside any project dir for the project. - (eg. files are not published from local folder, unaccessible for studio' + In specific cases ('source' template - in place publishing) source folder + of published items is used as a regular `publish` dir. + This validates if it is inside any project dir for the project. + (eg. files are not published from local folder, inaccessible for studio') """ @@ -44,6 +44,8 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): anatomy = instance.context.data["anatomy"] + # original_dirname must be convertable to rootless path + # in other case it is path inside of root folder for the project success, _ = anatomy.find_root_template_from_path(original_dirname) formatting_data = { @@ -56,11 +58,12 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): formatting_data=formatting_data) def _get_template_name_from_instance(self, instance): + """Find template which will be used during integration.""" project_name = instance.context.data["projectName"] host_name = instance.context.data["hostName"] anatomy_data = instance.data["anatomyData"] family = anatomy_data["family"] - family = self.family_mapping.get("family") or family + family = self.family_mapping.get(family) or family task_info = anatomy_data.get("task") or {} return get_publish_template_name( From 9f31075acd53bc378cf4c347a1802f06cb4167d1 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 16:41:09 +0300 Subject: [PATCH 121/291] resolve BigRoy's conversation --- openpype/hosts/houdini/api/lib.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 55f4fd6197..28805dc015 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -22,9 +22,12 @@ log = logging.getLogger(__name__) JSON_PREFIX = "JSON:::" -def get_asset_fps(): +def get_asset_fps(asset_doc=None): """Return current asset fps.""" - return get_current_project_asset()["data"].get("fps") + + if asset_doc is None: + asset_doc = get_current_project_asset(fields=["data.fps"]) + return asset_doc["data"].get("fps") def set_id(node, unique_id, overwrite=False): @@ -472,19 +475,19 @@ def maintained_selection(): def reset_framerange(): - """Set frame range to current asset""" - - # Set new scene fps - fps = get_asset_fps() - print("Setting scene FPS to {}".format(int(fps))) - set_scene_fps(fps) + """Set frame range and FPS to current asset""" + # Get asset data project_name = get_current_project_name() asset_name = get_current_asset_name() # Get the asset ID from the database for the asset of current context asset_doc = get_asset_by_name(project_name, asset_name) asset_data = asset_doc["data"] + # Get FPS + fps = get_asset_fps(asset_doc) + + # Get Start and End Frames frame_start = asset_data.get("frameStart") frame_end = asset_data.get("frameEnd") @@ -498,8 +501,12 @@ def reset_framerange(): frame_start -= int(handle_start) frame_end += int(handle_end) + # Set frame range and FPS + print("Setting scene FPS to {}".format(int(fps))) + set_scene_fps(fps) hou.playbar.setFrameRange(frame_start, frame_end) hou.playbar.setPlaybackRange(frame_start, frame_end) + print("Setting current frame to {}".format(frame_start)) hou.setFrame(frame_start) From c64e6c1fca25f096b0827d7c2418d2e67ebd1c91 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Tue, 29 Aug 2023 16:52:23 +0300 Subject: [PATCH 122/291] Update get_asset_fps Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 28805dc015..975f4e531e 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -27,7 +27,7 @@ def get_asset_fps(asset_doc=None): if asset_doc is None: asset_doc = get_current_project_asset(fields=["data.fps"]) - return asset_doc["data"].get("fps") + return asset_doc["data"]["fps"] def set_id(node, unique_id, overwrite=False): From 44df6d69a6ffbe9a73dc38849251dc95ad025528 Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Tue, 29 Aug 2023 16:55:24 +0300 Subject: [PATCH 123/291] Delete print statment Co-authored-by: Roy Nieterau --- openpype/hosts/houdini/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 975f4e531e..75c7ff9fee 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -506,7 +506,6 @@ def reset_framerange(): set_scene_fps(fps) hou.playbar.setFrameRange(frame_start, frame_end) hou.playbar.setPlaybackRange(frame_start, frame_end) - print("Setting current frame to {}".format(frame_start)) hou.setFrame(frame_start) From 6014cc6549d4869c81ff8cbe1acacfb30332db3a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 29 Aug 2023 16:16:31 +0200 Subject: [PATCH 124/291] Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools (#5531) * Fix optional support * Query deadline only once per url * Report both pools if both are invalid instead of only primary pool * Fix formatting in UI * Re-use existing implementation of `requests_get` * Cosmetics * Cache deadline url responses to avoid the need of request per instance * Only format error message when needed + convert to `KnownPublishError` * Allow deadline url per instance, similar to `ValidateDeadlineConnections` * Tweak grammar/readability * Fix title * Remove instance data from right side in Publish report since it's available in logs --- .../collect_deadline_server_from_instance.py | 14 +++-- .../publish/help/validate_deadline_pools.xml | 30 ++++----- .../publish/validate_deadline_connection.py | 34 ++++------ .../publish/validate_deadline_pools.py | 63 +++++++++++++------ 4 files changed, 79 insertions(+), 62 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index eadfc3c83e..8a408d7f4f 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -8,6 +8,7 @@ attribute or using default server if that attribute doesn't exists. from maya import cmds import pyblish.api +from openpype.pipeline.publish import KnownPublishError class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): @@ -81,13 +82,14 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): if k in default_servers } - msg = ( - "\"{}\" server on instance is not enabled in project settings." - " Enabled project servers:\n{}".format( - instance_server, project_enabled_servers + if instance_server not in project_enabled_servers: + msg = ( + "\"{}\" server on instance is not enabled in project settings." + " Enabled project servers:\n{}".format( + instance_server, project_enabled_servers + ) ) - ) - assert instance_server in project_enabled_servers, msg + raise KnownPublishError(msg) self.log.debug("Using project approved server.") return project_enabled_servers[instance_server] diff --git a/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml b/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml index 0e7d72910e..aa21df3734 100644 --- a/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml +++ b/openpype/modules/deadline/plugins/publish/help/validate_deadline_pools.xml @@ -1,31 +1,31 @@ - Scene setting + Deadline Pools - ## Invalid Deadline pools found +## Invalid Deadline pools found - Configured pools don't match what is set in Deadline. +Configured pools don't match available pools in Deadline. - {invalid_value_str} +### How to repair? - ### How to repair? +If your instance had deadline pools set on creation, remove or +change them. - If your instance had deadline pools set on creation, remove or - change them. +In other cases inform admin to change them in Settings. - In other cases inform admin to change them in Settings. +Available deadline pools: + +{pools_str} - Available deadline pools {pools_str}. - ### __Detailed Info__ +### __Detailed Info__ - This error is shown when deadline pool is not on Deadline anymore. It - could happen in case of republish old workfile which was created with - previous deadline pools, - or someone changed pools on Deadline side, but didn't modify Openpype - Settings. +This error is shown when a configured pool is not available on Deadline. It +can happen when publishing old workfiles which were created with previous +deadline pools, or someone changed the available pools in Deadline, +but didn't modify Openpype Settings to match the changes. \ No newline at end of file diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index a30401e7dc..a7b300beff 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -1,8 +1,7 @@ -import os -import requests - import pyblish.api +from openpype_modules.deadline.abstract_submit_deadline import requests_get + class ValidateDeadlineConnection(pyblish.api.InstancePlugin): """Validate Deadline Web Service is running""" @@ -12,34 +11,25 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): hosts = ["maya", "nuke"] families = ["renderlayer", "render"] + # cache + responses = {} + def process(self, instance): # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") - self.log.info( - "We have deadline URL on instance {}".format( - deadline_url)) + self.log.debug( + "We have deadline URL on instance {}".format(deadline_url) + ) assert deadline_url, "Requires Deadline Webservice URL" - # Check response - response = self._requests_get(deadline_url) + if deadline_url not in self.responses: + self.responses[deadline_url] = requests_get(deadline_url) + + response = self.responses[deadline_url] assert response.ok, "Response must be ok" assert response.text.startswith("Deadline Web Service "), ( "Web service did not respond with 'Deadline Web Service'" ) - - def _requests_get(self, *args, **kwargs): - """ Wrapper for requests, disabling SSL certificate validation if - DONT_VERIFY_SSL environment variable is found. This is useful when - Deadline or Muster server are running with self-signed certificates - and their certificate is not added to trusted certificates on - client machines. - - WARNING: disabling SSL certificate validation is defeating one line - of defense SSL is providing and it is not recommended. - """ - if 'verify' not in kwargs: - kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True # noqa - return requests.get(*args, **kwargs) diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 594f0ef866..949caff7d8 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -25,33 +25,58 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, "maxrender"] optional = True + # cache + pools_per_url = {} + def process(self, instance): + if not self.is_active(instance.data): + return + if not instance.data.get("farm"): self.log.debug("Skipping local instance.") return - # get default deadline webservice url from deadline module - deadline_url = instance.context.data["defaultDeadline"] - self.log.info("deadline_url::{}".format(deadline_url)) - pools = DeadlineModule.get_deadline_pools(deadline_url, log=self.log) - self.log.info("pools::{}".format(pools)) - - formatting_data = { - "pools_str": ",".join(pools) - } + deadline_url = self.get_deadline_url(instance) + pools = self.get_pools(deadline_url) + invalid_pools = {} primary_pool = instance.data.get("primaryPool") if primary_pool and primary_pool not in pools: - msg = "Configured primary '{}' not present on Deadline".format( - instance.data["primaryPool"]) - formatting_data["invalid_value_str"] = msg - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + invalid_pools["primary"] = primary_pool secondary_pool = instance.data.get("secondaryPool") if secondary_pool and secondary_pool not in pools: - msg = "Configured secondary '{}' not present on Deadline".format( - instance.data["secondaryPool"]) - formatting_data["invalid_value_str"] = msg - raise PublishXmlValidationError(self, msg, - formatting_data=formatting_data) + invalid_pools["secondary"] = secondary_pool + + if invalid_pools: + message = "\n".join( + "{} pool '{}' not available on Deadline".format(key.title(), + pool) + for key, pool in invalid_pools.items() + ) + raise PublishXmlValidationError( + plugin=self, + message=message, + formatting_data={"pools_str": ", ".join(pools)} + ) + + def get_deadline_url(self, instance): + # get default deadline webservice url from deadline module + deadline_url = instance.context.data["defaultDeadline"] + if instance.data.get("deadlineUrl"): + # if custom one is set in instance, use that + deadline_url = instance.data.get("deadlineUrl") + return deadline_url + + def get_pools(self, deadline_url): + if deadline_url not in self.pools_per_url: + self.log.debug( + "Querying available pools for Deadline url: {}".format( + deadline_url) + ) + pools = DeadlineModule.get_deadline_pools(deadline_url, + log=self.log) + self.log.info("Available pools: {}".format(pools)) + self.pools_per_url[deadline_url] = pools + + return self.pools_per_url[deadline_url] From c157f74b498eb5ace1822792b5813e964ac79ebc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 29 Aug 2023 16:45:09 +0200 Subject: [PATCH 125/291] Fix double spaces in message (#5190) --- openpype/plugins/publish/validate_version.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index 2b919a3119..84d52fab73 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -25,16 +25,16 @@ class ValidateVersion(pyblish.api.InstancePlugin): # TODO: Remove full non-html version upon drop of old publisher msg = ( "Version '{0}' from instance '{1}' that you are " - " trying to publish is lower or equal to an existing version " - " in the database. Version in database: '{2}'." + "trying to publish is lower or equal to an existing version " + "in the database. Version in database: '{2}'." "Please version up your workfile to a higher version number " "than: '{2}'." ).format(version, instance.data["name"], latest_version) msg_html = ( "Version {0} from instance {1} that you are " - " trying to publish is lower or equal to an existing version " - " in the database. Version in database: {2}.

" + "trying to publish is lower or equal to an existing version " + "in the database. Version in database: {2}.

" "Please version up your workfile to a higher version number " "than: {2}." ).format(version, instance.data["name"], latest_version) From e56d3530cb7668bf92b78a61be61508b23ee89fb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Aug 2023 17:31:49 +0200 Subject: [PATCH 126/291] Chore: Queued event system (#5514) * implemented queued event system * implemented basic tests --- openpype/lib/events.py | 90 +++++++++++++++++++- tests/unit/openpype/lib/test_event_system.py | 83 ++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/unit/openpype/lib/test_event_system.py diff --git a/openpype/lib/events.py b/openpype/lib/events.py index dca58fcf93..496b765a05 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -3,6 +3,7 @@ import os import re import copy import inspect +import collections import logging import weakref from uuid import uuid4 @@ -340,8 +341,8 @@ class EventSystem(object): event.emit() return event - def emit_event(self, event): - """Emit event object. + def _process_event(self, event): + """Process event topic and trigger callbacks. Args: event (Event): Prepared event with topic and data. @@ -356,6 +357,91 @@ class EventSystem(object): for callback in invalid_callbacks: self._registered_callbacks.remove(callback) + def emit_event(self, event): + """Emit event object. + + Args: + event (Event): Prepared event with topic and data. + """ + + self._process_event(event) + + +class QueuedEventSystem(EventSystem): + """Events are automatically processed in queue. + + If callback triggers another event, the event is not processed until + all callbacks of previous event are processed. + + Allows to implement custom event process loop by changing 'auto_execute'. + + Note: + This probably should be default behavior of 'EventSystem'. Changing it + now could cause problems in existing code. + + Args: + auto_execute (Optional[bool]): If 'True', events are processed + automatically. Custom loop calling 'process_next_event' + must be implemented when set to 'False'. + """ + + def __init__(self, auto_execute=True): + super(QueuedEventSystem, self).__init__() + self._event_queue = collections.deque() + self._current_event = None + self._auto_execute = auto_execute + + def __len__(self): + return self.count() + + def count(self): + """Get number of events in queue. + + Returns: + int: Number of events in queue. + """ + + return len(self._event_queue) + + def process_next_event(self): + """Process next event in queue. + + Should be used only if 'auto_execute' is set to 'False'. Only single + event is processed. + + Returns: + Union[Event, None]: Processed event. + """ + + if self._current_event is not None: + raise ValueError("An event is already in progress.") + + if not self._event_queue: + return None + event = self._event_queue.popleft() + self._current_event = event + self._process_event(event) + self._current_event = None + return event + + def emit_event(self, event): + """Emit event object. + + Args: + event (Event): Prepared event with topic and data. + """ + + if not self._auto_execute or self._current_event is not None: + self._event_queue.append(event) + return + + self._event_queue.append(event) + while self._event_queue: + event = self._event_queue.popleft() + self._current_event = event + self._process_event(event) + self._current_event = None + class GlobalEventSystem: """Event system living in global scope of process. diff --git a/tests/unit/openpype/lib/test_event_system.py b/tests/unit/openpype/lib/test_event_system.py new file mode 100644 index 0000000000..aa3f929065 --- /dev/null +++ b/tests/unit/openpype/lib/test_event_system.py @@ -0,0 +1,83 @@ +from openpype.lib.events import EventSystem, QueuedEventSystem + + +def test_default_event_system(): + output = [] + expected_output = [3, 2, 1] + event_system = EventSystem() + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + assert output == expected_output, ( + "Callbacks were not called in correct order") + + +def test_base_event_system_queue(): + output = [] + expected_output = [1, 2, 3] + event_system = QueuedEventSystem() + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + assert output == expected_output, ( + "Callbacks were not called in correct order") + + +def test_manual_event_system_queue(): + output = [] + expected_output = [1, 2, 3] + event_system = QueuedEventSystem(auto_execute=False) + + def callback_1(): + event_system.emit("topic.2", {}, None) + output.append(1) + + def callback_2(): + event_system.emit("topic.3", {}, None) + output.append(2) + + def callback_3(): + output.append(3) + + event_system.add_callback("topic.1", callback_1) + event_system.add_callback("topic.2", callback_2) + event_system.add_callback("topic.3", callback_3) + + event_system.emit("topic.1", {}, None) + + while True: + if event_system.process_next_event() is None: + break + + assert output == expected_output, ( + "Callbacks were not called in correct order") From 04145020f6c41014697ec171d9ede8a389506dce Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Aug 2023 17:35:48 +0200 Subject: [PATCH 127/291] Tests: fix unit tests (#5533) * Changed test zip file location * Updated tests and updated Unreal plugin Unreal plugin was previously ejected into general one, which was later removed as unnecessary. In Unreal plugin were kept fixed bugs from general one (slate issue, better name pattern for clique) * Updated raised exception type --- .../publish/validate_sequence_frames.py | 20 ++++++- .../publish/test_validate_sequence_frames.py | 53 +++++-------------- tests/unit/openpype/lib/test_delivery.py | 3 +- .../sync_server/test_site_operations.py | 9 ++-- 4 files changed, 39 insertions(+), 46 deletions(-) diff --git a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py index 76bb25fac3..96485d5a2d 100644 --- a/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py +++ b/openpype/hosts/unreal/plugins/publish/validate_sequence_frames.py @@ -1,4 +1,6 @@ import clique +import os +import re import pyblish.api @@ -21,7 +23,19 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): representations = instance.data.get("representations") for repr in representations: data = instance.data.get("assetEntity", {}).get("data", {}) - patterns = [clique.PATTERNS["frames"]] + repr_files = repr["files"] + if isinstance(repr_files, str): + continue + + ext = repr.get("ext") + if not ext: + _, ext = os.path.splitext(repr_files[0]) + elif not ext.startswith("."): + ext = ".{}".format(ext) + pattern = r"\D?(?P(?P0*)\d+){}$".format( + re.escape(ext)) + patterns = [pattern] + collections, remainder = clique.assemble( repr["files"], minimum_items=1, patterns=patterns) @@ -30,6 +44,10 @@ class ValidateSequenceFrames(pyblish.api.InstancePlugin): collection = collections[0] frames = list(collection.indexes) + if instance.data.get("slate"): + # Slate is not part of the frame range + frames = frames[1:] + current_range = (frames[0], frames[-1]) required_range = (data["clipIn"], data["clipOut"]) diff --git a/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py b/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py index 17e47c9f64..f472b8052a 100644 --- a/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py +++ b/tests/unit/openpype/hosts/unreal/plugins/publish/test_validate_sequence_frames.py @@ -19,7 +19,7 @@ import logging from pyblish.api import Instance as PyblishInstance from tests.lib.testing_classes import BaseTest -from openpype.plugins.publish.validate_sequence_frames import ( +from openpype.hosts.unreal.plugins.publish.validate_sequence_frames import ( ValidateSequenceFrames ) @@ -38,7 +38,13 @@ class TestValidateSequenceFrames(BaseTest): data = { "frameStart": 1001, "frameEnd": 1002, - "representations": [] + "representations": [], + "assetEntity": { + "data": { + "clipIn": 1001, + "clipOut": 1002, + } + } } yield Instance @@ -58,6 +64,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1001 + instance.data["assetEntity"]["data"]["clipOut"] = 1001 plugin.process(instance) @@ -84,49 +91,11 @@ class TestValidateSequenceFrames(BaseTest): plugin.process(instance) - @pytest.mark.parametrize("files", - [["Main_beauty.1001.v001.exr", - "Main_beauty.1002.v001.exr"]]) - def test_validate_sequence_frames_wrong_name(self, instance, - plugin, files): - # tests for names with number inside, caused clique failure before - representations = [ - { - "ext": "exr", - "files": files, - } - ] - instance.data["representations"] = representations - - with pytest.raises(AssertionError) as excinfo: - plugin.process(instance) - assert ("Must detect single collection" in - str(excinfo.value)) - - @pytest.mark.parametrize("files", - [["Main_beauty.v001.1001.ass.gz", - "Main_beauty.v001.1002.ass.gz"]]) - def test_validate_sequence_frames_possible_wrong_name( - self, instance, plugin, files): - # currently pattern fails on extensions with dots - representations = [ - { - "files": files, - } - ] - instance.data["representations"] = representations - - with pytest.raises(AssertionError) as excinfo: - plugin.process(instance) - assert ("Must not have remainder" in - str(excinfo.value)) - @pytest.mark.parametrize("files", [["Main_beauty.v001.1001.ass.gz", "Main_beauty.v001.1002.ass.gz"]]) def test_validate_sequence_frames__correct_ext( self, instance, plugin, files): - # currently pattern fails on extensions with dots representations = [ { "ext": "ass.gz", @@ -147,6 +116,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 plugin.process(instance) @@ -160,6 +130,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 with pytest.raises(ValueError) as excinfo: plugin.process(instance) @@ -175,6 +146,7 @@ class TestValidateSequenceFrames(BaseTest): ] instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 with pytest.raises(AssertionError) as excinfo: plugin.process(instance) @@ -195,6 +167,7 @@ class TestValidateSequenceFrames(BaseTest): instance.data["slate"] = True instance.data["representations"] = representations instance.data["frameEnd"] = 1003 + instance.data["assetEntity"]["data"]["clipOut"] = 1003 plugin.process(instance) diff --git a/tests/unit/openpype/lib/test_delivery.py b/tests/unit/openpype/lib/test_delivery.py index 04a71655e3..f1e435f3f8 100644 --- a/tests/unit/openpype/lib/test_delivery.py +++ b/tests/unit/openpype/lib/test_delivery.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Test suite for delivery functions.""" -from openpype.lib.delivery import collect_frames +from openpype.lib import collect_frames def test_collect_frames_multi_sequence(): @@ -153,4 +153,3 @@ def test_collect_frames_single_file(): print(ret) assert ret == expected, "Not matching" - diff --git a/tests/unit/openpype/modules/sync_server/test_site_operations.py b/tests/unit/openpype/modules/sync_server/test_site_operations.py index 6a861100a4..c4a83e33a6 100644 --- a/tests/unit/openpype/modules/sync_server/test_site_operations.py +++ b/tests/unit/openpype/modules/sync_server/test_site_operations.py @@ -12,16 +12,19 @@ removes temporary databases (?) """ import pytest +from bson.objectid import ObjectId from tests.lib.testing_classes import ModuleUnitTest -from bson.objectid import ObjectId + +from openpype.modules.sync_server.utils import SiteAlreadyPresentError + class TestSiteOperation(ModuleUnitTest): REPRESENTATION_ID = "60e578d0c987036c6a7b741d" - TEST_FILES = [("1eCwPljuJeOI8A3aisfOIBKKjcmIycTEt", + TEST_FILES = [("1FHE70Hi7y05LLT_1O3Y6jGxwZGXKV9zX", "test_site_operations.zip", '')] @pytest.fixture(scope="module") @@ -71,7 +74,7 @@ class TestSiteOperation(ModuleUnitTest): @pytest.mark.usefixtures("setup_sync_server_module") def test_add_site_again(self, dbcon, setup_sync_server_module): """Depends on test_add_site, must throw exception.""" - with pytest.raises(ValueError): + with pytest.raises(SiteAlreadyPresentError): setup_sync_server_module.add_site(self.TEST_PROJECT_NAME, self.REPRESENTATION_ID, site_name='test_site') From ed53ef12d5befa621965847016c375033e1b24b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Aug 2023 17:48:33 +0200 Subject: [PATCH 128/291] Chore: PowerShell script for docker build (#5535) * added powershell script to build using docker * fix empty variant * make sure build folder exists * added docker_build.ps1 to readme * Tweaked readme to include reason for docker_build.ps1 --------- Co-authored-by: Petr Kalis --- README.md | 4 ++ tools/docker_build.ps1 | 98 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tools/docker_build.ps1 diff --git a/README.md b/README.md index 6caed8061c..92f1cb62dc 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ sudo ./tools/docker_build.sh centos7 If all is successful, you'll find built OpenPype in `./build/` folder. +Docker build can be also started from Windows machine, just use `./tools/docker_build.ps1` instead of shell script. + +This could be used even for building linux build (with argument `centos7` or `debian`) + #### Manual build You will need [Python >= 3.9](https://www.python.org/downloads/) and [git](https://git-scm.com/downloads). You'll also need [curl](https://curl.se) on systems that doesn't have one preinstalled. diff --git a/tools/docker_build.ps1 b/tools/docker_build.ps1 new file mode 100644 index 0000000000..392165288c --- /dev/null +++ b/tools/docker_build.ps1 @@ -0,0 +1,98 @@ +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$repo_root = (Get-Item $script_dir).parent.FullName + +$env:PSModulePath = $env:PSModulePath + ";$($repo_root)\tools\modules\powershell" + +function Exit-WithCode($exitcode) { + # Only exit this host process if it's a child of another PowerShell parent process... + $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId + $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name + if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } + + exit $exitcode +} + +function Restore-Cwd() { + $tmp_current_dir = Get-Location + if ("$tmp_current_dir" -ne "$current_dir") { + Write-Color -Text ">>> ", "Restoring current directory" -Color Green, Gray + Set-Location -Path $current_dir + } +} + +function Get-Container { + if (-not (Test-Path -PathType Leaf -Path "$($repo_root)\build\docker-image.id")) { + Write-Color -Text "!!! ", "Docker command failed, cannot find image id." -Color Red, Yellow + Restore-Cwd + Exit-WithCode 1 + } + $id = Get-Content "$($repo_root)\build\docker-image.id" + Write-Color -Text ">>> ", "Creating container from image id ", "[", $id, "]" -Color Green, Gray, White, Cyan, White + $cid = docker create $id bash + if ($LASTEXITCODE -ne 0) { + Write-Color -Text "!!! ", "Cannot create container." -Color Red, Yellow + Restore-Cwd + Exit-WithCode 1 + } + return $cid +} + +function Change-Cwd() { + Set-Location -Path $repo_root +} + +function New-DockerBuild { + $version_file = Get-Content -Path "$($repo_root)\openpype\version.py" + $result = [regex]::Matches($version_file, '__version__ = "(?\d+\.\d+.\d+.*)"') + $openpype_version = $result[0].Groups['version'].Value + $startTime = [int][double]::Parse((Get-Date -UFormat %s)) + Write-Color -Text ">>> ", "Building OpenPype using Docker ..." -Color Green, Gray, White + $variant = $args[0] + if ($variant.Length -eq 0) { + $dockerfile = "$($repo_root)\Dockerfile" + } else { + $dockerfile = "$( $repo_root )\Dockerfile.$variant" + } + if (-not (Test-Path -PathType Leaf -Path $dockerfile)) { + Write-Color -Text "!!! ", "Dockerfile for specifed platform ", "[", $variant, "]", "doesn't exist." -Color Red, Yellow, Cyan, White, Cyan, Yellow + Restore-Cwd + Exit-WithCode 1 + } + Write-Color -Text ">>> ", "Using Dockerfile for ", "[ ", $variant, " ]" -Color Green, Gray, White, Cyan, White + + $build_dir = "$($repo_root)\build" + if (-not(Test-Path $build_dir)) { + New-Item -ItemType Directory -Path $build_dir + } + Write-Color -Text "--- ", "Cleaning build directory ..." -Color Yellow, Gray + try { + Remove-Item -Recurse -Force "$($build_dir)\*" + } catch { + Write-Color -Text "!!! ", "Cannot clean build directory, possibly because process is using it." -Color Red, Gray + Write-Color -Text $_.Exception.Message -Color Red + Exit-WithCode 1 + } + + Write-Color -Text ">>> ", "Running Docker build ..." -Color Green, Gray, White + docker build --pull --iidfile $repo_root/build/docker-image.id --build-arg BUILD_DATE=$(Get-Date -UFormat %Y-%m-%dT%H:%M:%SZ) --build-arg VERSION=$openpype_version -t pypeclub/openpype:$openpype_version -f $dockerfile . + if ($LASTEXITCODE -ne 0) { + Write-Color -Text "!!! ", "Docker command failed.", $LASTEXITCODE -Color Red, Yellow, Red + Restore-Cwd + Exit-WithCode 1 + } + Write-Color -Text ">>> ", "Copying build from container ..." -Color Green, Gray, White + $cid = Get-Container + + docker cp "$($cid):/opt/openpype/build/exe.linux-x86_64-3.9" "$($repo_root)/build" + docker cp "$($cid):/opt/openpype/build/build.log" "$($repo_root)/build" + + $endTime = [int][double]::Parse((Get-Date -UFormat %s)) + try { + New-BurntToastNotification -AppLogo "$openpype_root/openpype/resources/icons/openpype_icon.png" -Text "OpenPype build complete!", "All done in $( $endTime - $startTime ) secs. You will find OpenPype and build log in build directory." + } catch {} + Write-Color -Text "*** ", "All done in ", $($endTime - $startTime), " secs. You will find OpenPype and build log in ", "'.\build'", " directory." -Color Green, Gray, White, Gray, White, Gray +} + +Change-Cwd +New-DockerBuild $ARGS From 65bd128d510598b00b9b51f48a7eddc9ad519abc Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Tue, 29 Aug 2023 19:03:58 +0300 Subject: [PATCH 129/291] Enhancement: Update houdini main menu (#5527) * update houdini main menu * add separator --- openpype/hosts/houdini/startup/MainMenuCommon.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 47a4653d5d..5818a117eb 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -2,7 +2,19 @@ - + + + + + + From 9ff5c071b3f7e0cc1bf7f002719565d192030e3a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 21:05:43 +0300 Subject: [PATCH 130/291] resolve conversations --- openpype/hosts/houdini/api/colorspace.py | 18 +++++++++++++- .../houdini/plugins/create/create_review.py | 24 +++---------------- .../publish/validate_review_colorspace.py | 19 ++++----------- openpype/pipeline/colorspace.py | 6 ++--- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 7047644225..5c3c605cd1 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -1,7 +1,7 @@ import attr import hou from openpype.hosts.houdini.api.lib import get_color_management_preferences - +from openpype.pipeline.colorspace import get_display_view_colorspace_name @attr.s class LayerMetadata(object): @@ -54,3 +54,19 @@ class ARenderProduct(object): ) ] return colorspace_data + + +def get_default_display_view_colorspace(): + """Get default display view colorspace. + + It's used for 'ociocolorspace' parm in OpneGL Node.""" + + data = get_color_management_preferences() + config_path = data.get("config") + display = data.get("display") + view = data.get("view") + + default_view_space = get_display_view_colorspace_name(config_path, + display, + view) + return default_view_space diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index 797116aaca..75a92e5e77 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -2,6 +2,7 @@ """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace import os import hou @@ -142,9 +143,8 @@ class CreateReview(plugin.HoudiniCreator): # to OpenColorIO instance_node.setParms({"colorcorrect": 2}) - self.log.debug("Get default view colorspace name..") - - default_view_space = self.get_default_view_space() + # Get default view space for ociocolorspace parm. + default_view_space = get_default_display_view_colorspace() instance_node.setParms( {"ociocolorspace": default_view_space} ) @@ -154,21 +154,3 @@ class CreateReview(plugin.HoudiniCreator): "the default view color space '{}'" .format(instance_node, default_view_space) ) - - return default_view_space - - def get_default_view_space(self): - """Get default view space for ociocolorspace parm.""" - - from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa - from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa - - data = get_color_management_preferences() - config_path = data.get("config") - display = data.get("display") - view = data.get("view") - - default_view_space = get_display_view_colorspace_name(config_path, - display, view) - - return default_view_space diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 5390b6b52f..09e4a489d2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -3,6 +3,7 @@ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace import os import hou @@ -38,7 +39,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: - cls.log.warning( + cls.log.debug( "Default Houdini colorspace is used, " " skipping check.." ) @@ -71,25 +72,15 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): used to set colorspace on opengl node to the default view. """ - from openpype.pipeline.colorspace import get_display_view_colorspace_name # noqa - from openpype.hosts.houdini.api.lib import get_color_management_preferences # noqa - rop_node = hou.node(instance.data["instance_node"]) - data = get_color_management_preferences() - config_path = data.get("config") - display = data.get("display") - view = data.get("view") - - cls.log.debug("Get default view colorspace name..") - - default_view_space = get_display_view_colorspace_name(config_path, - display, view) + # Get default view colorspace name + default_view_space = get_default_display_view_colorspace() rop_node.setParms({"ociocolorspace": default_view_space}) cls.log.debug( "'OCIO Colorspace' parm on '{}' has been set to " "the default view color space '{}'" - .formate(rop_node, default_view_space) + .format(rop_node, default_view_space) ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index a0efb5e18c..37974f4a0b 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -658,6 +658,6 @@ def get_display_view_colorspace_subprocess(config_path, display, view): run_openpype_process(*args, **process_kwargs) - # return all colorspaces - return_json_data = open(tmp_json_path).read() - return json.loads(return_json_data) + # return default view colorspace name + with open(tmp_json_path, "r") as f: + return json.load(f) From c799ae42eab8be32051ee373bdb71176002a20b8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 21:06:36 +0300 Subject: [PATCH 131/291] add spaces --- openpype/hosts/houdini/api/colorspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 5c3c605cd1..2662a968e2 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -67,6 +67,6 @@ def get_default_display_view_colorspace(): view = data.get("view") default_view_space = get_display_view_colorspace_name(config_path, - display, - view) + display, + view) return default_view_space From 75673151a6374591067c5e8f356ee3bb93706961 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Tue, 29 Aug 2023 21:18:27 +0300 Subject: [PATCH 132/291] resolve hound conversations --- openpype/hosts/houdini/plugins/create/create_review.py | 2 +- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index 75a92e5e77..c087c54f6c 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -2,7 +2,7 @@ """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 09e4a489d2..2c7420bf48 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -3,7 +3,7 @@ import pyblish.api from openpype.pipeline import PublishValidationError from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace +from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou From 948687e7a236a3767b07c78eba07a13663234be0 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 30 Aug 2023 03:24:53 +0000 Subject: [PATCH 133/291] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 6d89e1eeae..12f797228b 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.2" +__version__ = "3.16.5-nightly.3" From e426aca7213a32fcde9ca5b7e923444e29a54049 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Aug 2023 03:25:28 +0000 Subject: [PATCH 134/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d7e49de5cb..669bf391cd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.5-nightly.3 - 3.16.5-nightly.2 - 3.16.5-nightly.1 - 3.16.4 @@ -134,7 +135,6 @@ body: - 3.14.8 - 3.14.8-nightly.4 - 3.14.8-nightly.3 - - 3.14.8-nightly.2 validations: required: true - type: dropdown From 2b951d29f23916e3354ecb6a328452dbf24d5282 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 09:50:59 +0200 Subject: [PATCH 135/291] Maya: Tweak logging for artist-facing reports in publisher --- .../maya/plugins/publish/collect_assembly.py | 3 -- .../maya/plugins/publish/collect_look.py | 29 ++++++++------- .../publish/collect_multiverse_look.py | 20 +++++----- .../maya/plugins/publish/collect_render.py | 12 +++--- .../publish/collect_render_layer_aovs.py | 6 +-- .../publish/collect_renderable_camera.py | 9 +++-- .../publish/collect_unreal_staticmesh.py | 4 +- .../maya/plugins/publish/collect_xgen.py | 2 +- .../maya/plugins/publish/collect_yeti_rig.py | 5 +-- .../publish/extract_arnold_scene_source.py | 10 ++--- .../maya/plugins/publish/extract_assembly.py | 2 +- .../plugins/publish/extract_camera_alembic.py | 4 +- .../publish/extract_camera_mayaScene.py | 8 ++-- .../hosts/maya/plugins/publish/extract_fbx.py | 8 ++-- .../maya/plugins/publish/extract_gltf.py | 7 +--- .../maya/plugins/publish/extract_gpu_cache.py | 2 +- .../publish/extract_import_reference.py | 12 +++--- .../maya/plugins/publish/extract_layout.py | 8 ++-- .../maya/plugins/publish/extract_look.py | 37 +++++++++---------- .../plugins/publish/extract_maya_scene_raw.py | 7 ++-- .../maya/plugins/publish/extract_model.py | 7 ++-- .../publish/extract_multiverse_look.py | 8 ++-- .../plugins/publish/extract_multiverse_usd.py | 13 +++---- .../publish/extract_multiverse_usd_comp.py | 10 ++--- .../publish/extract_multiverse_usd_over.py | 8 ++-- .../hosts/maya/plugins/publish/extract_obj.py | 8 ++-- .../maya/plugins/publish/extract_playblast.py | 8 ++-- .../plugins/publish/extract_pointcache.py | 4 +- .../maya/plugins/publish/extract_proxy_abc.py | 4 +- .../plugins/publish/extract_redshift_proxy.py | 6 +-- .../plugins/publish/extract_rendersetup.py | 2 +- .../hosts/maya/plugins/publish/extract_rig.py | 6 +-- .../maya/plugins/publish/extract_thumbnail.py | 6 +-- .../extract_unreal_skeletalmesh_abc.py | 10 ++--- .../extract_unreal_skeletalmesh_fbx.py | 10 ++--- .../publish/extract_unreal_staticmesh.py | 10 ++--- .../maya/plugins/publish/extract_vrayproxy.py | 6 +-- .../maya/plugins/publish/extract_vrayscene.py | 17 ++++----- .../plugins/publish/extract_workfile_xgen.py | 2 +- .../maya/plugins/publish/extract_xgen.py | 4 +- .../plugins/publish/extract_yeti_cache.py | 8 ++-- .../maya/plugins/publish/extract_yeti_rig.py | 12 +++--- .../plugins/publish/reset_xgen_attributes.py | 4 +- .../plugins/publish/submit_maya_muster.py | 8 ++-- .../plugins/publish/validate_assembly_name.py | 2 +- .../publish/validate_assembly_namespaces.py | 2 +- .../plugins/publish/validate_frame_range.py | 4 +- .../plugins/publish/validate_glsl_material.py | 14 +++---- .../publish/validate_instancer_content.py | 2 +- .../validate_instancer_frame_ranges.py | 9 +---- .../plugins/publish/validate_model_name.py | 2 +- .../publish/validate_mvlook_contents.py | 13 ++++--- .../validate_skeletalmesh_hierarchy.py | 2 +- .../validate_unreal_staticmesh_naming.py | 6 +-- .../validate_vray_distributed_rendering.py | 4 +- .../validate_yeti_renderscript_callbacks.py | 4 +- .../validate_yeti_rig_input_in_instance.py | 4 +- 57 files changed, 217 insertions(+), 227 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_assembly.py b/openpype/hosts/maya/plugins/publish/collect_assembly.py index 2aef9ab908..f64d6bee44 100644 --- a/openpype/hosts/maya/plugins/publish/collect_assembly.py +++ b/openpype/hosts/maya/plugins/publish/collect_assembly.py @@ -35,14 +35,11 @@ class CollectAssembly(pyblish.api.InstancePlugin): # Get all content from the instance instance_lookup = set(cmds.ls(instance, type="transform", long=True)) data = defaultdict(list) - self.log.info(instance_lookup) hierarchy_nodes = [] for container in containers: - self.log.info(container) root = lib.get_container_transforms(container, root=True) - self.log.info(root) if not root or root not in instance_lookup: continue diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index b3da920566..a2c3d6acbf 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -356,8 +356,9 @@ class CollectLook(pyblish.api.InstancePlugin): # Thus the data will be limited to only what we need. self.log.debug("obj_set {}".format(sets[obj_set])) if not sets[obj_set]["members"]: - self.log.info( - "Removing redundant set information: {}".format(obj_set)) + self.log.debug( + "Removing redundant set information: {}".format(obj_set) + ) sets.pop(obj_set, None) self.log.debug("Gathering attribute changes to instance members..") @@ -396,9 +397,9 @@ class CollectLook(pyblish.api.InstancePlugin): if con: materials.extend(con) - self.log.info("Found materials:\n{}".format(materials)) + self.log.debug("Found materials:\n{}".format(materials)) - self.log.info("Found the following sets:\n{}".format(look_sets)) + self.log.debug("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets # history = cmds.listHistory(look_sets) history = [] @@ -456,7 +457,7 @@ class CollectLook(pyblish.api.InstancePlugin): instance.extend(shader for shader in look_sets if shader not in instance_lookup) - self.log.info("Collected look for %s" % instance) + self.log.debug("Collected look for %s" % instance) def collect_sets(self, instance): """Collect all objectSets which are of importance for publishing @@ -593,7 +594,7 @@ class CollectLook(pyblish.api.InstancePlugin): if attribute == "fileTextureName": computed_attribute = node + ".computedFileTextureNamePattern" - self.log.info(" - file source: {}".format(source)) + self.log.debug(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) try: color_space = cmds.getAttr(color_space_attr) @@ -621,7 +622,7 @@ class CollectLook(pyblish.api.InstancePlugin): dependNode=True) ) if not source and cmds.nodeType(node) in pxr_nodes: - self.log.info("Renderman: source is empty, skipping...") + self.log.debug("Renderman: source is empty, skipping...") continue # We replace backslashes with forward slashes because V-Ray # can't handle the UDIM files with the backslashes in the @@ -630,14 +631,14 @@ class CollectLook(pyblish.api.InstancePlugin): files = get_file_node_files(node) if len(files) == 0: - self.log.error("No valid files found from node `%s`" % node) + self.log.debug("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(attribute)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.debug("collection of resource done:") + self.log.debug(" - node: {}".format(node)) + self.log.debug(" - attribute: {}".format(attribute)) + self.log.debug(" - source: {}".format(source)) + self.log.debug(" - file: {}".format(files)) + self.log.debug(" - color space: {}".format(color_space)) # Define the resource yield { diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py index 33fc7a025f..f05fb76d48 100644 --- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py @@ -268,7 +268,7 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): cmds.loadPlugin("MultiverseForMaya", quiet=True) import multiverse - self.log.info("Processing mvLook for '{}'".format(instance)) + self.log.debug("Processing mvLook for '{}'".format(instance)) nodes = set() for node in instance: @@ -287,7 +287,7 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): publishMipMap = instance.data["publishMipMap"] for node in nodes: - self.log.info("Getting resources for '{}'".format(node)) + self.log.debug("Getting resources for '{}'".format(node)) # We know what nodes need to be collected, now we need to # extract the materials overrides. @@ -380,12 +380,12 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): if len(files) == 0: self.log.error("No valid files found from node `%s`" % node) - self.log.info("collection of resource done:") - self.log.info(" - node: {}".format(node)) - self.log.info(" - attribute: {}".format(fname_attrib)) - self.log.info(" - source: {}".format(source)) - self.log.info(" - file: {}".format(files)) - self.log.info(" - color space: {}".format(color_space)) + self.log.debug("collection of resource done:") + self.log.debug(" - node: {}".format(node)) + self.log.debug(" - attribute: {}".format(fname_attrib)) + self.log.debug(" - source: {}".format(source)) + self.log.debug(" - file: {}".format(files)) + self.log.debug(" - color space: {}".format(color_space)) # Define the resource resource = {"node": node, @@ -406,14 +406,14 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): extra_files = [] self.log.debug("Expecting MipMaps, going to look for them.") for fname in files: - self.log.info("Checking '{}' for mipmaps".format(fname)) + self.log.debug("Checking '{}' for mipmaps".format(fname)) if is_mipmap(fname): self.log.debug(" - file is already MipMap, skipping.") continue mipmap = get_mipmap(fname) if mipmap: - self.log.info(" mipmap found for '{}'".format(fname)) + self.log.debug(" mipmap found for '{}'".format(fname)) extra_files.append(mipmap) else: self.log.warning(" no mipmap found for '{}'".format(fname)) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index c17a8789e4..82392f67bd 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -105,7 +105,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): "family": cmds.getAttr("{}.family".format(s)), } ) - self.log.info(" -> attach render to: {}".format(s)) + self.log.debug(" -> attach render to: {}".format(s)) layer_name = layer.name() @@ -137,10 +137,10 @@ class CollectMayaRender(pyblish.api.InstancePlugin): has_cameras = any(product.camera for product in render_products) assert has_cameras, "No render cameras found." - self.log.info("multipart: {}".format( + self.log.debug("multipart: {}".format( multipart)) assert expected_files, "no file names were generated, this is a bug" - self.log.info( + self.log.debug( "expected files: {}".format( json.dumps(expected_files, indent=4, sort_keys=True) ) @@ -175,7 +175,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): publish_meta_path = os.path.dirname(full_path) aov_dict[aov_first_key] = full_paths full_exp_files = [aov_dict] - self.log.info(full_exp_files) + self.log.debug(full_exp_files) if publish_meta_path is None: raise KnownPublishError("Unable to detect any expected output " @@ -227,7 +227,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): if platform.system().lower() in ["linux", "darwin"]: common_publish_meta_path = "/" + common_publish_meta_path - self.log.info( + self.log.debug( "Publish meta path: {}".format(common_publish_meta_path)) # Get layer specific settings, might be overrides @@ -300,7 +300,7 @@ class CollectMayaRender(pyblish.api.InstancePlugin): ) if rr_settings["enabled"]: data["rrPathName"] = instance.data.get("rrPathName") - self.log.info(data["rrPathName"]) + self.log.debug(data["rrPathName"]) if self.sync_workfile_version: data["version"] = context.data["version"] diff --git a/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py b/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py index c3dc31ead9..035c531a9b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py +++ b/openpype/hosts/maya/plugins/publish/collect_render_layer_aovs.py @@ -37,7 +37,7 @@ class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): # Get renderer renderer = instance.data["renderer"] - self.log.info("Renderer found: {}".format(renderer)) + self.log.debug("Renderer found: {}".format(renderer)) rp_node_types = {"vray": ["VRayRenderElement", "VRayRenderElementSet"], "arnold": ["aiAOV"], @@ -66,8 +66,8 @@ class CollectRenderLayerAOVS(pyblish.api.InstancePlugin): result.append(render_pass) - self.log.info("Found {} render elements / AOVs for " - "'{}'".format(len(result), instance.data["subset"])) + self.log.debug("Found {} render elements / AOVs for " + "'{}'".format(len(result), instance.data["subset"])) instance.data["renderPasses"] = result diff --git a/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py b/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py index d1c3cf3b2c..4443e2e0db 100644 --- a/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py +++ b/openpype/hosts/maya/plugins/publish/collect_renderable_camera.py @@ -21,11 +21,12 @@ class CollectRenderableCamera(pyblish.api.InstancePlugin): else: layer = instance.data["renderlayer"] - self.log.info("layer: {}".format(layer)) cameras = cmds.ls(type="camera", long=True) - renderable = [c for c in cameras if - get_attr_in_layer("%s.renderable" % c, layer)] + renderable = [cam for cam in cameras if + get_attr_in_layer("{}.renderable".format(cam), layer)] - self.log.info("Found cameras %s: %s" % (len(renderable), renderable)) + self.log.debug( + "Found renderable cameras %s: %s", len(renderable), renderable + ) instance.data["cameras"] = renderable diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 79d0856fa0..03b6c4a188 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -19,7 +19,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): instance.data["geometryMembers"] = cmds.sets( geometry_set, query=True) - self.log.info("geometry: {}".format( + self.log.debug("geometry: {}".format( pformat(instance.data.get("geometryMembers")))) collision_set = [ @@ -29,7 +29,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): instance.data["collisionMembers"] = cmds.sets( collision_set, query=True) - self.log.info("collisions: {}".format( + self.log.debug("collisions: {}".format( pformat(instance.data.get("collisionMembers")))) frame = cmds.currentTime(query=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_xgen.py b/openpype/hosts/maya/plugins/publish/collect_xgen.py index 46968f7d1a..45648e1776 100644 --- a/openpype/hosts/maya/plugins/publish/collect_xgen.py +++ b/openpype/hosts/maya/plugins/publish/collect_xgen.py @@ -67,5 +67,5 @@ class CollectXgen(pyblish.api.InstancePlugin): data["transfers"] = transfers - self.log.info(data) + self.log.debug(data) instance.data.update(data) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py index bc15edd9e0..df761cde13 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_rig.py @@ -119,7 +119,6 @@ class CollectYetiRig(pyblish.api.InstancePlugin): texture_filenames = [] if image_search_paths: - # TODO: Somehow this uses OS environment path separator, `:` vs `;` # Later on check whether this is pipeline OS cross-compatible. image_search_paths = [p for p in @@ -130,13 +129,13 @@ class CollectYetiRig(pyblish.api.InstancePlugin): # List all related textures texture_filenames = cmds.pgYetiCommand(node, listTextures=True) - self.log.info("Found %i texture(s)" % len(texture_filenames)) + self.log.debug("Found %i texture(s)" % len(texture_filenames)) # Get all reference nodes reference_nodes = cmds.pgYetiGraph(node, listNodes=True, type="reference") - self.log.info("Found %i reference node(s)" % len(reference_nodes)) + self.log.debug("Found %i reference node(s)" % len(reference_nodes)) if texture_filenames and not image_search_paths: raise ValueError("pgYetiMaya node '%s' is missing the path to the " diff --git a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py index 102f0e46a2..46cc9090bb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py @@ -100,7 +100,7 @@ class ExtractArnoldSceneSource(publish.Extractor): instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) @@ -126,7 +126,7 @@ class ExtractArnoldSceneSource(publish.Extractor): instance.data["representations"].append(representation) def _extract(self, nodes, attribute_data, kwargs): - self.log.info( + self.log.debug( "Writing {} with:\n{}".format(kwargs["filename"], kwargs) ) filenames = [] @@ -180,12 +180,12 @@ class ExtractArnoldSceneSource(publish.Extractor): with lib.attribute_values(attribute_data): with lib.maintained_selection(): - self.log.info( + self.log.debug( "Writing: {}".format(duplicate_nodes) ) cmds.select(duplicate_nodes, noExpand=True) - self.log.info( + self.log.debug( "Extracting ass sequence with: {}".format(kwargs) ) @@ -194,6 +194,6 @@ class ExtractArnoldSceneSource(publish.Extractor): for file in exported_files: filenames.append(os.path.split(file)[1]) - self.log.info("Exported: {}".format(filenames)) + self.log.debug("Exported: {}".format(filenames)) return filenames, nodes_by_id diff --git a/openpype/hosts/maya/plugins/publish/extract_assembly.py b/openpype/hosts/maya/plugins/publish/extract_assembly.py index 9b2978d192..86ffdcef24 100644 --- a/openpype/hosts/maya/plugins/publish/extract_assembly.py +++ b/openpype/hosts/maya/plugins/publish/extract_assembly.py @@ -27,7 +27,7 @@ class ExtractAssembly(publish.Extractor): json_filename = "{}.json".format(instance.name) json_path = os.path.join(staging_dir, json_filename) - self.log.info("Dumping scene data for debugging ..") + self.log.debug("Dumping scene data for debugging ..") with open(json_path, "w") as filepath: json.dump(instance.data["scenedata"], filepath, ensure_ascii=False) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index aa445a0387..4ec1399df4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -94,7 +94,7 @@ class ExtractCameraAlembic(publish.Extractor): "Attributes to bake must be specified as a list" ) for attr in self.bake_attributes: - self.log.info("Adding {} attribute".format(attr)) + self.log.debug("Adding {} attribute".format(attr)) job_str += " -attr {0}".format(attr) with lib.evaluation("off"): @@ -112,5 +112,5 @@ class ExtractCameraAlembic(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 30e6b89f2f..a50a8f0dfa 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -111,7 +111,7 @@ class ExtractCameraMayaScene(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -151,7 +151,7 @@ class ExtractCameraMayaScene(publish.Extractor): with lib.evaluation("off"): with lib.suspended_refresh(): if bake_to_worldspace: - self.log.info( + self.log.debug( "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( transform, @@ -186,7 +186,7 @@ class ExtractCameraMayaScene(publish.Extractor): unlock(plug) cmds.setAttr(plug, value) - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") cmds.select(cmds.ls(members, dag=True, shapes=True, long=True), noExpand=True) cmds.file(path, @@ -217,5 +217,5 @@ class ExtractCameraMayaScene(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '{0}' to: {1}".format( + self.log.debug("Extracted instance '{0}' to: {1}".format( instance.name, path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 9af3acef65..4f7eaf57bf 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -33,11 +33,11 @@ class ExtractFBX(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) + self.log.debug("Extracting FBX to: {0}".format(path)) members = instance.data["setMembers"] - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) @@ -58,4 +58,4 @@ class ExtractFBX(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gltf.py b/openpype/hosts/maya/plugins/publish/extract_gltf.py index ac258ffb3d..6d72d28525 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gltf.py +++ b/openpype/hosts/maya/plugins/publish/extract_gltf.py @@ -20,14 +20,10 @@ class ExtractGLB(publish.Extractor): filename = "{0}.glb".format(instance.name) path = os.path.join(staging_dir, filename) - self.log.info("Extracting GLB to: {}".format(path)) - cmds.loadPlugin("maya2glTF", quiet=True) nodes = instance[:] - self.log.info("Instance: {0}".format(nodes)) - start_frame = instance.data('frameStart') or \ int(cmds.playbackOptions(query=True, animationStartTime=True))# noqa @@ -48,6 +44,7 @@ class ExtractGLB(publish.Extractor): "vno": True # visibleNodeOnly } + self.log.debug("Extracting GLB to: {}".format(path)) with lib.maintained_selection(): cmds.select(nodes, hi=True, noExpand=True) extract_gltf(staging_dir, @@ -65,4 +62,4 @@ class ExtractGLB(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract GLB successful to: {0}".format(path)) + self.log.debug("Extract GLB successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py b/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py index 422f5ad019..16436c6fe4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_gpu_cache.py @@ -60,6 +60,6 @@ class ExtractGPUCache(publish.Extractor): instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance {} to: {}".format(instance.name, staging_dir) ) diff --git a/openpype/hosts/maya/plugins/publish/extract_import_reference.py b/openpype/hosts/maya/plugins/publish/extract_import_reference.py index 8bb82be9b6..9d2ff1a3eb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_import_reference.py +++ b/openpype/hosts/maya/plugins/publish/extract_import_reference.py @@ -46,7 +46,7 @@ class ExtractImportReference(publish.Extractor, for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break @@ -69,7 +69,7 @@ class ExtractImportReference(publish.Extractor, reference_path = os.path.join(dir_path, ref_scene_name) tmp_path = os.path.dirname(current_name) + "/" + ref_scene_name - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") # This generates script for mayapy to take care of reference # importing outside current session. It is passing current scene @@ -111,7 +111,7 @@ print("*** Done") # process until handles are closed by context manager. with tempfile.TemporaryDirectory() as tmp_dir_name: tmp_script_path = os.path.join(tmp_dir_name, "import_ref.py") - self.log.info("Using script file: {}".format(tmp_script_path)) + self.log.debug("Using script file: {}".format(tmp_script_path)) with open(tmp_script_path, "wt") as tmp: tmp.write(script) @@ -149,9 +149,9 @@ print("*** Done") "stagingDir": os.path.dirname(current_name), "outputName": "imported" } - self.log.info("%s" % ref_representation) + self.log.debug(ref_representation) instance.data["representations"].append(ref_representation) - self.log.info("Extracted instance '%s' to : '%s'" % (ref_scene_name, - reference_path)) + self.log.debug("Extracted instance '%s' to : '%s'" % (ref_scene_name, + reference_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_layout.py b/openpype/hosts/maya/plugins/publish/extract_layout.py index bf5b4fc0e7..75920b44a2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_layout.py +++ b/openpype/hosts/maya/plugins/publish/extract_layout.py @@ -23,7 +23,7 @@ class ExtractLayout(publish.Extractor): stagingdir = self.staging_dir(instance) # Perform extraction - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") if "representations" not in instance.data: instance.data["representations"] = [] @@ -64,7 +64,7 @@ class ExtractLayout(publish.Extractor): fields=["parent", "context.family"] ) - self.log.info(representation) + self.log.debug(representation) version_id = representation.get("parent") family = representation.get("context").get("family") @@ -159,5 +159,5 @@ class ExtractLayout(publish.Extractor): } instance.data["representations"].append(json_representation) - self.log.info("Extracted instance '%s' to: %s", - instance.name, json_representation) + self.log.debug("Extracted instance '%s' to: %s", + instance.name, json_representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index b13568c781..3506027a1f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -307,7 +307,7 @@ class MakeTX(TextureProcessor): render_colorspace = color_management["rendering_space"] - self.log.info("tx: converting colorspace {0} " + self.log.debug("tx: converting colorspace {0} " "-> {1}".format(colorspace, render_colorspace)) args.extend(["--colorconvert", colorspace, render_colorspace]) @@ -331,7 +331,7 @@ class MakeTX(TextureProcessor): if not os.path.exists(resources_dir): os.makedirs(resources_dir) - self.log.info("Generating .tx file for %s .." % source) + self.log.debug("Generating .tx file for %s .." % source) subprocess_args = maketx_args + [ "-v", # verbose @@ -421,7 +421,7 @@ class ExtractLook(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -453,7 +453,7 @@ class ExtractLook(publish.Extractor): relationships = lookdata["relationships"] sets = list(relationships.keys()) if not sets: - self.log.info("No sets found for the look") + self.log.debug("No sets found for the look") return # Specify texture processing executables to activate @@ -485,7 +485,7 @@ class ExtractLook(publish.Extractor): remap = results["attrRemap"] # Extract in correct render layer - self.log.info("Extracting look maya scene file: {}".format(maya_path)) + self.log.debug("Extracting look maya scene file: {}".format(maya_path)) layer = instance.data.get("renderlayer", "defaultRenderLayer") with lib.renderlayer(layer): # TODO: Ensure membership edits don't become renderlayer overrides @@ -511,12 +511,12 @@ class ExtractLook(publish.Extractor): ) # Write the JSON data - self.log.info("Extract json..") data = { "attributes": lookdata["attributes"], "relationships": relationships } + self.log.debug("Extracting json file: {}".format(json_path)) with open(json_path, "w") as f: json.dump(data, f) @@ -557,8 +557,8 @@ class ExtractLook(publish.Extractor): # Source hash for the textures instance.data["sourceHashes"] = hashes - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - maya_path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + maya_path)) def _set_resource_result_colorspace(self, resource, colorspace): """Update resource resulting colorspace after texture processing""" @@ -589,14 +589,13 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary fix to NOT create hardlinks on windows machines - if platform.system().lower() == "windows": - self.log.info( + force_copy = instance.data.get("forceCopy", False) + if not force_copy and platform.system().lower() == "windows": + # Temporary fix to NOT create hardlinks on windows machines + self.log.warning( "Forcing copy instead of hardlink due to issues on Windows..." ) force_copy = True - else: - force_copy = instance.data.get("forceCopy", False) destinations_cache = {} @@ -671,11 +670,11 @@ class ExtractLook(publish.Extractor): destination = get_resource_destination_cached(source) if force_copy or texture_result.transfer_mode == COPY: transfers.append((source, destination)) - self.log.info('file will be copied {} -> {}'.format( + self.log.debug('file will be copied {} -> {}'.format( source, destination)) elif texture_result.transfer_mode == HARDLINK: hardlinks.append((source, destination)) - self.log.info('file will be hardlinked {} -> {}'.format( + self.log.debug('file will be hardlinked {} -> {}'.format( source, destination)) # Store the hashes from hash to destination to include in the @@ -707,7 +706,7 @@ class ExtractLook(publish.Extractor): color_space_attr = "{}.colorSpace".format(node) remap[color_space_attr] = resource["result_color_space"] - self.log.info("Finished remapping destinations ...") + self.log.debug("Finished remapping destinations ...") return { "fileTransfers": transfers, @@ -815,8 +814,8 @@ class ExtractLook(publish.Extractor): if not processed_result: raise RuntimeError("Texture Processor {} returned " "no result.".format(processor)) - self.log.info("Generated processed " - "texture: {}".format(processed_result.path)) + self.log.debug("Generated processed " + "texture: {}".format(processed_result.path)) # TODO: Currently all processors force copy instead of allowing # hardlinks using source hashes. This should be refactored @@ -827,7 +826,7 @@ class ExtractLook(publish.Extractor): if not force_copy: existing = self._get_existing_hashed_texture(filepath) if existing: - self.log.info("Found hash in database, preparing hardlink..") + self.log.debug("Found hash in database, preparing hardlink..") return TextureResult( path=filepath, file_hash=texture_hash, diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index d87d6c208a..ab170fe48c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -34,7 +34,7 @@ class ExtractMayaSceneRaw(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -63,7 +63,7 @@ class ExtractMayaSceneRaw(publish.Extractor): selection += self._get_loaded_containers(members) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(selection, noExpand=True) cmds.file(path, @@ -87,7 +87,8 @@ class ExtractMayaSceneRaw(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + path)) @staticmethod def _get_loaded_containers(members): diff --git a/openpype/hosts/maya/plugins/publish/extract_model.py b/openpype/hosts/maya/plugins/publish/extract_model.py index 5137dffd94..29c952ebbc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_model.py +++ b/openpype/hosts/maya/plugins/publish/extract_model.py @@ -44,7 +44,7 @@ class ExtractModel(publish.Extractor, for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -56,7 +56,7 @@ class ExtractModel(publish.Extractor, path = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") # Get only the shape contents we need in such a way that we avoid # taking along intermediateObjects @@ -102,4 +102,5 @@ class ExtractModel(publish.Extractor, } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s" % (instance.name, + path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py index 6fe7cf0d55..c2bebeaee6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py @@ -101,10 +101,10 @@ class ExtractMultiverseLook(publish.Extractor): # Parse export options options = self.default_options - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") @@ -114,7 +114,7 @@ class ExtractMultiverseLook(publish.Extractor): type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info('Collected object {}'.format(members)) + self.log.debug('Collected object {}'.format(members)) if len(members) > 1: self.log.error('More than one member: {}'.format(members)) @@ -153,5 +153,5 @@ class ExtractMultiverseLook(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 4399eacda1..17d5891e59 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -150,7 +150,6 @@ class ExtractMultiverseUsd(publish.Extractor): return options def get_default_options(self): - self.log.info("ExtractMultiverseUsd get_default_options") return self.default_options def filter_members(self, members): @@ -173,19 +172,19 @@ class ExtractMultiverseUsd(publish.Extractor): # Parse export options options = self.get_default_options() options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") - self.log.info('Collected objects: {}'.format(members)) + self.log.debug('Collected objects: {}'.format(members)) members = self.filter_members(members) if not members: self.log.error('No members!') return - self.log.info(' - filtered: {}'.format(members)) + self.log.debug(' - filtered: {}'.format(members)) import multiverse @@ -229,7 +228,7 @@ class ExtractMultiverseUsd(publish.Extractor): self.log.debug(" - {}={}".format(key, value)) setattr(asset_write_opts, key, value) - self.log.info('WriteAsset: {} / {}'.format(file_path, members)) + self.log.debug('WriteAsset: {} / {}'.format(file_path, members)) multiverse.WriteAsset(file_path, members, asset_write_opts) if "representations" not in instance.data: @@ -243,7 +242,7 @@ class ExtractMultiverseUsd(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index a62729c198..7966c4fa93 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -105,14 +105,14 @@ class ExtractMultiverseUsdComposition(publish.Extractor): # Parse export options options = self.default_options options = self.parse_overrides(instance, options) - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") - self.log.info('Collected object {}'.format(members)) + self.log.debug('Collected object {}'.format(members)) import multiverse @@ -175,5 +175,5 @@ class ExtractMultiverseUsdComposition(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( - instance.name, file_path)) + self.log.debug("Extracted instance {} to {}".format(instance.name, + file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index cf610ac6b4..e4a97db6e4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -87,10 +87,10 @@ class ExtractMultiverseUsdOverride(publish.Extractor): # Parse export options options = self.default_options - self.log.info("Export options: {0}".format(options)) + self.log.debug("Export options: {0}".format(options)) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): members = instance.data("setMembers") @@ -100,7 +100,7 @@ class ExtractMultiverseUsdOverride(publish.Extractor): type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info("Collected object {}".format(members)) + self.log.debug("Collected object {}".format(members)) # TODO: Deal with asset, composition, override with options. import multiverse @@ -153,5 +153,5 @@ class ExtractMultiverseUsdOverride(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance {} to {}".format( + self.log.debug("Extracted instance {} to {}".format( instance.name, file_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_obj.py b/openpype/hosts/maya/plugins/publish/extract_obj.py index 518b0f0ff8..ca94130d09 100644 --- a/openpype/hosts/maya/plugins/publish/extract_obj.py +++ b/openpype/hosts/maya/plugins/publish/extract_obj.py @@ -30,7 +30,7 @@ class ExtractObj(publish.Extractor): # The export requires forward slashes because we need to # format it into a string in a mel expression - self.log.info("Extracting OBJ to: {0}".format(path)) + self.log.debug("Extracting OBJ to: {0}".format(path)) members = instance.data("setMembers") members = cmds.ls(members, @@ -39,8 +39,8 @@ class ExtractObj(publish.Extractor): type=("mesh", "nurbsCurve"), noIntermediate=True, long=True) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) if not cmds.pluginInfo('objExport', query=True, loaded=True): cmds.loadPlugin('objExport') @@ -74,4 +74,4 @@ class ExtractObj(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract OBJ successful to: {0}".format(path)) + self.log.debug("Extract OBJ successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 9580c13841..cfab239da3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -48,7 +48,7 @@ class ExtractPlayblast(publish.Extractor): self.log.debug("playblast path {}".format(path)) def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") # get scene fps fps = instance.data.get("fps") or instance.context.data.get("fps") @@ -62,7 +62,7 @@ class ExtractPlayblast(publish.Extractor): if end is None: end = cmds.playbackOptions(query=True, animationEndTime=True) - self.log.info("start: {}, end: {}".format(start, end)) + self.log.debug("start: {}, end: {}".format(start, end)) # get cameras camera = instance.data["review_camera"] @@ -119,7 +119,7 @@ class ExtractPlayblast(publish.Extractor): filename = "{0}".format(instance.name) path = os.path.join(stagingdir, filename) - self.log.info("Outputting images to %s" % path) + self.log.debug("Outputting images to %s" % path) preset["filename"] = path preset["overwrite"] = True @@ -237,7 +237,7 @@ class ExtractPlayblast(publish.Extractor): self.log.debug("collection head {}".format(filebase)) if filebase in filename: frame_collection = collection - self.log.info( + self.log.debug( "we found collection of interest {}".format( str(frame_collection))) diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 9537a11ee4..5530446e3d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -109,11 +109,11 @@ class ExtractAlembic(publish.Extractor): instance.context.data["cleanupFullPaths"].append(path) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) # Extract proxy. if not instance.data.get("proxy"): - self.log.info("No proxy nodes found. Skipping proxy extraction.") + self.log.debug("No proxy nodes found. Skipping proxy extraction.") return path = path.replace(".abc", "_proxy.abc") diff --git a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py index cf6351fdca..921ee44a24 100644 --- a/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_proxy_abc.py @@ -32,7 +32,7 @@ class ExtractProxyAlembic(publish.Extractor): attr_prefixes = instance.data.get("attrPrefix", "").split(";") attr_prefixes = [value for value in attr_prefixes if value.strip()] - self.log.info("Extracting Proxy Alembic..") + self.log.debug("Extracting Proxy Alembic..") dirname = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) @@ -82,7 +82,7 @@ class ExtractProxyAlembic(publish.Extractor): instance.context.data["cleanupFullPaths"].append(path) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) # remove the bounding box bbox_master = cmds.ls("bbox_grp") cmds.delete(bbox_master) diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 834b335fc5..3868270b79 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -59,7 +59,7 @@ class ExtractRedshiftProxy(publish.Extractor): # vertex_colors = instance.data.get("vertexColors", False) # Write out rs file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.file(file_path, @@ -82,5 +82,5 @@ class ExtractRedshiftProxy(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py index 5970c038a4..7e21f5282e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py +++ b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py @@ -37,5 +37,5 @@ class ExtractRenderSetup(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info( + self.log.debug( "Extracted instance '%s' to: %s" % (instance.name, json_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_rig.py b/openpype/hosts/maya/plugins/publish/extract_rig.py index be57b9de07..1ffc9a7dae 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig.py @@ -27,7 +27,7 @@ class ExtractRig(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using '.{}' as scene type".format(self.scene_type)) break except AttributeError: @@ -39,7 +39,7 @@ class ExtractRig(publish.Extractor): path = os.path.join(dir_path, filename) # Perform extraction - self.log.info("Performing extraction ...") + self.log.debug("Performing extraction ...") with maintained_selection(): cmds.select(instance, noExpand=True) cmds.file(path, @@ -63,4 +63,4 @@ class ExtractRig(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.debug("Extracted instance '%s' to: %s", instance.name, path) diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 4160ac4cb2..e44204cae0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -24,7 +24,7 @@ class ExtractThumbnail(publish.Extractor): families = ["review"] def process(self, instance): - self.log.info("Extracting capture..") + self.log.debug("Extracting capture..") camera = instance.data["review_camera"] @@ -96,7 +96,7 @@ class ExtractThumbnail(publish.Extractor): filename = "{0}".format(instance.name) path = os.path.join(dst_staging, filename) - self.log.info("Outputting images to %s" % path) + self.log.debug("Outputting images to %s" % path) preset["filename"] = path preset["overwrite"] = True @@ -159,7 +159,7 @@ class ExtractThumbnail(publish.Extractor): _, thumbnail = os.path.split(playblast) - self.log.info("file list {}".format(thumbnail)) + self.log.debug("file list {}".format(thumbnail)) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py index 4a797eb462..9c2f55a1ef 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_abc.py @@ -57,9 +57,9 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting ABC to: {0}".format(path)) - self.log.info("Members: {0}".format(nodes)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting ABC to: {0}".format(path)) + self.log.debug("Members: {0}".format(nodes)) + self.log.debug("Instance: {0}".format(instance[:])) options = { "step": instance.data.get("step", 1.0), @@ -74,7 +74,7 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): "worldSpace": instance.data.get("worldSpace", True) } - self.log.info("Options: {}".format(options)) + self.log.debug("Options: {}".format(options)) if int(cmds.about(version=True)) >= 2017: # Since Maya 2017 alembic supports multiple uv sets - write them. @@ -105,4 +105,4 @@ class ExtractUnrealSkeletalMeshAbc(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract ABC successful to: {0}".format(path)) + self.log.debug("Extract ABC successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py index b162ce47f7..96175a07d7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh_fbx.py @@ -46,9 +46,9 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(to_extract)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting FBX to: {0}".format(path)) + self.log.debug("Members: {0}".format(to_extract)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) @@ -70,7 +70,7 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): renamed_to_extract.append("|".join(node_path)) with renamed(original_parent, parent_node): - self.log.info("Extracting: {}".format(renamed_to_extract, path)) + self.log.debug("Extracting: {}".format(renamed_to_extract, path)) fbx_exporter.export(renamed_to_extract, path) if "representations" not in instance.data: @@ -84,4 +84,4 @@ class ExtractUnrealSkeletalMeshFbx(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 44f0615a27..26ab0827e4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -37,15 +37,15 @@ class ExtractUnrealStaticMesh(publish.Extractor): # to format it into a string in a mel expression path = path.replace('\\', '/') - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) + self.log.debug("Extracting FBX to: {0}".format(path)) + self.log.debug("Members: {0}".format(members)) + self.log.debug("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) with maintained_selection(): with parent_nodes(members): - self.log.info("Un-parenting: {}".format(members)) + self.log.debug("Un-parenting: {}".format(members)) fbx_exporter.export(members, path) if "representations" not in instance.data: @@ -59,4 +59,4 @@ class ExtractUnrealStaticMesh(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) + self.log.debug("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index df16c6c357..21dfcfffc5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -43,7 +43,7 @@ class ExtractVRayProxy(publish.Extractor): vertex_colors = instance.data.get("vertexColors", False) # Write out vrmesh file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.vrayCreateProxy(exportType=1, @@ -68,5 +68,5 @@ class ExtractVRayProxy(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index 8442df1611..b0615149a9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -20,13 +20,13 @@ class ExtractVrayscene(publish.Extractor): def process(self, instance): """Plugin entry point.""" if instance.data.get("exportOnFarm"): - self.log.info("vrayscenes will be exported on farm.") + self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscenes is not implemented") # handle sequence if instance.data.get("vraySceneMultipleFiles"): - self.log.info("vrayscenes will be exported on farm.") + self.log.debug("vrayscenes will be exported on farm.") raise NotImplementedError( "exporting vrayscene sequences not implemented yet") @@ -40,7 +40,6 @@ class ExtractVrayscene(publish.Extractor): layer_name = instance.data.get("layer") staging_dir = self.staging_dir(instance) - self.log.info("staging: {}".format(staging_dir)) template = cmds.getAttr("{}.vrscene_filename".format(node)) start_frame = instance.data.get( "frameStartHandle") if instance.data.get( @@ -56,21 +55,21 @@ class ExtractVrayscene(publish.Extractor): staging_dir, "vrayscene", *formatted_name.split("/")) # Write out vrscene file - self.log.info("Writing: '%s'" % file_path) + self.log.debug("Writing: '%s'" % file_path) with maintained_selection(): if "*" not in instance.data["setMembers"]: - self.log.info( + self.log.debug( "Exporting: {}".format(instance.data["setMembers"])) set_members = instance.data["setMembers"] cmds.select(set_members, noExpand=True) else: - self.log.info("Exporting all ...") + self.log.debug("Exporting all ...") set_members = cmds.ls( long=True, objectsOnly=True, geometry=True, lights=True, cameras=True) cmds.select(set_members, noExpand=True) - self.log.info("Appending layer name {}".format(layer_name)) + self.log.debug("Appending layer name {}".format(layer_name)) set_members.append(layer_name) export_in_rs_layer( @@ -93,8 +92,8 @@ class ExtractVrayscene(publish.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" - % (instance.name, staging_dir)) + self.log.debug("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) @staticmethod def format_vray_output_filename( diff --git a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py index 0d2a97bc4b..4bd01c2df2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py @@ -241,7 +241,7 @@ class ExtractWorkfileXgen(publish.Extractor): data[palette] = {attr: old_value} cmds.setAttr(node_attr, value, type="string") - self.log.info( + self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen.py b/openpype/hosts/maya/plugins/publish/extract_xgen.py index 3c9d0bd344..8409330e49 100644 --- a/openpype/hosts/maya/plugins/publish/extract_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_xgen.py @@ -77,7 +77,7 @@ class ExtractXgen(publish.Extractor): xgenm.exportPalette( instance.data["xgmPalette"].replace("|", ""), temp_xgen_path ) - self.log.info("Extracted to {}".format(temp_xgen_path)) + self.log.debug("Extracted to {}".format(temp_xgen_path)) # Import xgen onto the duplicate. with maintained_selection(): @@ -118,7 +118,7 @@ class ExtractXgen(publish.Extractor): expressions=True ) - self.log.info("Extracted to {}".format(maya_filepath)) + self.log.debug("Extracted to {}".format(maya_filepath)) if os.path.exists(temp_xgen_path): os.remove(temp_xgen_path) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py index b61f599cab..b113e02219 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py @@ -39,7 +39,7 @@ class ExtractYetiCache(publish.Extractor): else: kwargs.update({"samples": samples}) - self.log.info( + self.log.debug( "Writing out cache {} - {}".format(start_frame, end_frame)) # Start writing the files for snap shot # will be replace by the Yeti node name @@ -53,7 +53,7 @@ class ExtractYetiCache(publish.Extractor): cache_files = [x for x in os.listdir(dirname) if x.endswith(".fur")] - self.log.info("Writing metadata file") + self.log.debug("Writing metadata file") settings = instance.data["fursettings"] fursettings_path = os.path.join(dirname, "yeti.fursettings") with open(fursettings_path, "w") as fp: @@ -63,7 +63,7 @@ class ExtractYetiCache(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("cache files: {}".format(cache_files[0])) + self.log.debug("cache files: {}".format(cache_files[0])) # Workaround: We do not explicitly register these files with the # representation solely so that we can write multiple sequences @@ -87,4 +87,4 @@ class ExtractYetiCache(publish.Extractor): } ) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py index 9a46c31177..da67cb911f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py @@ -109,7 +109,7 @@ class ExtractYetiRig(publish.Extractor): for family in self.families: try: self.scene_type = ext_mapping[family] - self.log.info( + self.log.debug( "Using {} as scene type".format(self.scene_type)) break except KeyError: @@ -127,7 +127,7 @@ class ExtractYetiRig(publish.Extractor): maya_path = os.path.join(dirname, "yeti_rig.{}".format(self.scene_type)) - self.log.info("Writing metadata file") + self.log.debug("Writing metadata file: {}".format(settings_path)) image_search_path = resources_dir = instance.data["resourcesDir"] @@ -147,7 +147,7 @@ class ExtractYetiRig(publish.Extractor): dst = os.path.join(image_search_path, os.path.basename(file)) instance.data['transfers'].append([src, dst]) - self.log.info("adding transfer {} -> {}". format(src, dst)) + self.log.debug("adding transfer {} -> {}". format(src, dst)) # Ensure the imageSearchPath is being remapped to the publish folder attr_value = {"%s.imageSearchPath" % n: str(image_search_path) for @@ -182,7 +182,7 @@ class ExtractYetiRig(publish.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("rig file: {}".format(maya_path)) + self.log.debug("rig file: {}".format(maya_path)) instance.data["representations"].append( { 'name': self.scene_type, @@ -191,7 +191,7 @@ class ExtractYetiRig(publish.Extractor): 'stagingDir': dirname } ) - self.log.info("settings file: {}".format(settings_path)) + self.log.debug("settings file: {}".format(settings_path)) instance.data["representations"].append( { 'name': 'rigsettings', @@ -201,6 +201,6 @@ class ExtractYetiRig(publish.Extractor): } ) - self.log.info("Extracted {} to {}".format(instance, dirname)) + self.log.debug("Extracted {} to {}".format(instance, dirname)) cmds.select(clear=True) diff --git a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py index d8e8554b68..759aa23258 100644 --- a/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py +++ b/openpype/hosts/maya/plugins/publish/reset_xgen_attributes.py @@ -23,7 +23,7 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): for palette, data in xgen_attributes.items(): for attr, value in data.items(): node_attr = "{}.{}".format(palette, attr) - self.log.info( + self.log.debug( "Setting \"{}\" on \"{}\"".format(value, node_attr) ) cmds.setAttr(node_attr, value, type="string") @@ -32,5 +32,5 @@ class ResetXgenAttributes(pyblish.api.InstancePlugin): # Need to save the scene, cause the attribute changes above does not # mark the scene as modified so user can exit without committing the # changes. - self.log.info("Saving changes.") + self.log.debug("Saving changes.") cmds.file(save=True) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index 8e219eae85..ef58140115 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -215,9 +215,9 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): :rtype: int :raises: Exception if template ID isn't found """ - self.log.info("Trying to find template for [{}]".format(renderer)) + self.log.debug("Trying to find template for [{}]".format(renderer)) mapped = _get_template_id(renderer) - self.log.info("got id [{}]".format(mapped)) + self.log.debug("got id [{}]".format(mapped)) return self._templates.get(mapped) def _submit(self, payload): @@ -454,8 +454,8 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): self.preflight_check(instance) - self.log.info("Submitting ...") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting ...") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) response = self._submit(payload) # response = requests.post(url, json=payload) diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py index bcc40760e0..00588cd300 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_name.py @@ -20,7 +20,7 @@ class ValidateAssemblyName(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - cls.log.info("Checking name of {}".format(instance.name)) + cls.log.debug("Checking name of {}".format(instance.name)) content_instance = instance.data.get("setMembers", None) if not content_instance: diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py index 41ef78aab4..06577f38f7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py @@ -23,7 +23,7 @@ class ValidateAssemblyNamespaces(pyblish.api.InstancePlugin): def process(self, instance): - self.log.info("Checking namespace for %s" % instance.name) + self.log.debug("Checking namespace for %s" % instance.name) if self.get_invalid(instance): raise PublishValidationError("Nested namespaces found") diff --git a/openpype/hosts/maya/plugins/publish/validate_frame_range.py b/openpype/hosts/maya/plugins/publish/validate_frame_range.py index c6184ed348..a7043b8407 100644 --- a/openpype/hosts/maya/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/maya/plugins/publish/validate_frame_range.py @@ -47,10 +47,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, context = instance.context if instance.data.get("tileRendering"): - self.log.info(( + self.log.debug( "Skipping frame range validation because " "tile rendering is enabled." - )) + ) return frame_start_handle = int(context.data.get("frameStartHandle")) diff --git a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py index 10c48da404..3b386c3def 100644 --- a/openpype/hosts/maya/plugins/publish/validate_glsl_material.py +++ b/openpype/hosts/maya/plugins/publish/validate_glsl_material.py @@ -75,7 +75,7 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): """ meshes = cmds.ls(instance, type="mesh", long=True) - cls.log.info("meshes: {}".format(meshes)) + cls.log.debug("meshes: {}".format(meshes)) # load the glsl shader plugin cmds.loadPlugin("glslShader", quiet=True) @@ -96,8 +96,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): cls.log.warning("ogsfx shader file " "not found in {}".format(ogsfx_path)) - cls.log.info("Find the ogsfx shader file in " - "default maya directory...") + cls.log.debug("Searching the ogsfx shader file in " + "default maya directory...") # re-direct to search the ogsfx path in maya_dir ogsfx_path = os.getenv("MAYA_APP_DIR") + ogsfx_path if not os.path.exists(ogsfx_path): @@ -130,8 +130,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): @classmethod def pbs_shader_conversion(cls, main_shader, glsl): - cls.log.info("StringrayPBS detected " - "-> Can do texture conversion") + cls.log.debug("StringrayPBS detected " + "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader @@ -168,8 +168,8 @@ class ValidateGLSLMaterial(pyblish.api.InstancePlugin): @classmethod def arnold_shader_conversion(cls, main_shader, glsl): - cls.log.info("aiStandardSurface detected " - "-> Can do texture conversion") + cls.log.debug("aiStandardSurface detected " + "-> Can do texture conversion") for shader in main_shader: # get the file textures related to the PBS Shader diff --git a/openpype/hosts/maya/plugins/publish/validate_instancer_content.py b/openpype/hosts/maya/plugins/publish/validate_instancer_content.py index 2f14693ef2..236adfb03d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instancer_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_instancer_content.py @@ -21,7 +21,7 @@ class ValidateInstancerContent(pyblish.api.InstancePlugin): members = instance.data['setMembers'] export_members = instance.data['exactExportMembers'] - self.log.info("Contents {0}".format(members)) + self.log.debug("Contents {0}".format(members)) if not len(members) == len(cmds.ls(members, type="instancer")): self.log.error("Instancer can only contain instancers") diff --git a/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py b/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py index fcfcdce8b6..714c6229d6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py +++ b/openpype/hosts/maya/plugins/publish/validate_instancer_frame_ranges.py @@ -5,8 +5,6 @@ import pyblish.api from openpype.pipeline.publish import PublishValidationError -VERBOSE = False - def is_cache_resource(resource): """Return whether resource is a cacheFile resource""" @@ -73,9 +71,6 @@ class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin): xml = all_files.pop(0) assert xml.endswith(".xml") - if VERBOSE: - cls.log.info("Checking: {0}".format(all_files)) - # Ensure all files exist (including ticks) # The remainder file paths should be the .mcx or .mcc files valdidate_files(all_files) @@ -129,8 +124,8 @@ class ValidateInstancerFrameRanges(pyblish.api.InstancePlugin): # for the frames required by the time range. if ticks: ticks = list(sorted(ticks)) - cls.log.info("Found ticks: {0} " - "(substeps: {1})".format(ticks, len(ticks))) + cls.log.debug("Found ticks: {0} " + "(substeps: {1})".format(ticks, len(ticks))) # Check all frames except the last since we don't # require subframes after our time range. diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 6948dcf724..f4c1aa39c7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -125,7 +125,7 @@ class ValidateModelName(pyblish.api.InstancePlugin, r = re.compile(regex) for obj in filtered: - cls.log.info("testing: {}".format(obj)) + cls.log.debug("testing: {}".format(obj)) m = r.match(obj) if m is None: cls.log.error("invalid name on: {}".format(obj)) diff --git a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py index 68784a165d..ad0fcafc56 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py @@ -35,12 +35,12 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin, publishMipMap = instance.data["publishMipMap"] enforced = True if intent in self.enforced_intents: - self.log.info("This validation will be enforced: '{}'" - .format(intent)) + self.log.debug("This validation will be enforced: '{}'" + .format(intent)) else: enforced = False - self.log.info("This validation will NOT be enforced: '{}'" - .format(intent)) + self.log.debug("This validation will NOT be enforced: '{}'" + .format(intent)) if not instance[:]: raise PublishValidationError("Instance is empty") @@ -75,8 +75,9 @@ class ValidateMvLookContents(pyblish.api.InstancePlugin, self.log.warning(msg) if invalid: - raise PublishValidationError("'{}' has invalid look " - "content".format(instance.name)) + raise PublishValidationError( + "'{}' has invalid look content".format(instance.name) + ) def valid_file(self, fname): self.log.debug("Checking validity of '{}'".format(fname)) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py index 398b6fb7bf..9084374c76 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py @@ -28,7 +28,7 @@ class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): parent.split("|")[1] for parent in (joints_parents + geo_parents) } - self.log.info(parents_set) + self.log.debug(parents_set) if len(set(parents_set)) > 2: raise PublishXmlValidationError( diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index b2cb2ebda2..5ba256f9f5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -140,12 +140,12 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin, return if not self.validate_mesh and not self.validate_collision: - self.log.info("Validation of both mesh and collision names" - "is disabled.") + self.log.debug("Validation of both mesh and collision names" + "is disabled.") return if not instance.data.get("collisionMembers", None): - self.log.info("There are no collision objects to validate") + self.log.debug("There are no collision objects to validate") return invalid = self.get_invalid(instance) diff --git a/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py b/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py index a71849da00..14571203ea 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py +++ b/openpype/hosts/maya/plugins/publish/validate_vray_distributed_rendering.py @@ -52,6 +52,6 @@ class ValidateVRayDistributedRendering(pyblish.api.InstancePlugin): renderlayer = instance.data.get("renderlayer") with lib.renderlayer(renderlayer): - cls.log.info("Enabling Distributed Rendering " - "ignore in batch mode..") + cls.log.debug("Enabling Distributed Rendering " + "ignore in batch mode..") cmds.setAttr(cls.ignored_attr, True) diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py index 06250f5779..a8085418e7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_renderscript_callbacks.py @@ -54,7 +54,7 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): # has any yeti callback set or not since if the callback # is there it wouldn't error and if it weren't then # nothing happens because there are no yeti nodes. - cls.log.info( + cls.log.debug( "Yeti is loaded but no yeti nodes were found. " "Callback validation skipped.." ) @@ -62,7 +62,7 @@ class ValidateYetiRenderScriptCallbacks(pyblish.api.InstancePlugin): renderer = instance.data["renderer"] if renderer == "redshift": - cls.log.info("Redshift ignores any pre and post render callbacks") + cls.log.debug("Redshift ignores any pre and post render callbacks") return False callback_lookup = cls.callbacks.get(renderer, {}) diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py index 96fb475a0a..50a27589ad 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py @@ -37,8 +37,8 @@ class ValidateYetiRigInputShapesInInstance(pyblish.api.Validator): # Allow publish without input meshes. if not shapes: - cls.log.info("Found no input meshes for %s, skipping ..." - % instance) + cls.log.debug("Found no input meshes for %s, skipping ..." + % instance) return [] # check if input node is part of groomRig instance From eec1d82db2b605186d58fb5f860fcf89cea142c9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 09:54:37 +0200 Subject: [PATCH 136/291] Deadline: Tweak logging for artist-facing reports in publisher --- .../publish/collect_deadline_server_from_instance.py | 2 +- .../deadline/plugins/publish/submit_celaction_deadline.py | 6 +++--- .../deadline/plugins/publish/submit_fusion_deadline.py | 4 ++-- .../deadline/plugins/publish/submit_harmony_deadline.py | 2 +- .../plugins/publish/submit_houdini_remote_publish.py | 2 +- .../deadline/plugins/publish/submit_maya_deadline.py | 6 +++--- .../deadline/plugins/publish/submit_nuke_deadline.py | 8 ++++---- .../deadline/plugins/publish/submit_publish_job.py | 4 ++-- .../plugins/publish/validate_deadline_connection.py | 2 +- .../deadline/plugins/publish/validate_deadline_pools.py | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index eadfc3c83e..98ecb019c5 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -23,7 +23,7 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): instance.data["deadlineUrl"] = self._collect_deadline_url(instance) instance.data["deadlineUrl"] = \ instance.data["deadlineUrl"].strip().rstrip("/") - self.log.info( + self.log.debug( "Using {} for submission.".format(instance.data["deadlineUrl"])) def _collect_deadline_url(self, render_instance): diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index ee28612b44..5a6537a767 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -183,10 +183,10 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): } plugin = payload["JobInfo"]["Plugin"] - self.log.info("using render plugin : {}".format(plugin)) + self.log.debug("using render plugin : {}".format(plugin)) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files(instance, render_path) diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index a48596c6bf..70aa12956d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -233,8 +233,8 @@ class FusionSubmitDeadline( ) for index, key in enumerate(environment) }) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs url = "{}/api/jobs".format(deadline_url) diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 2c37268f04..9f8579cd23 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -369,7 +369,7 @@ class HarmonySubmitDeadline( # rendering, we need to unzip it. published_scene = Path( self.from_published_scene(False)) - self.log.info(f"Processing {published_scene.as_posix()}") + self.log.debug(f"Processing {published_scene.as_posix()}") xstage_path = self._unzip_scene_file(published_scene) render_path = xstage_path.parent / "renders" diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 68aa653804..39c0c3afe4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -162,7 +162,7 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): ) # Submit - self.log.info("Submitting..") + self.log.debug("Submitting..") self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # E.g. http://192.168.0.1:8082/api/jobs diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 75d24b28f0..615d167a9f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -434,7 +434,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, new_job_info.update(tiles_data["JobInfo"]) new_plugin_info.update(tiles_data["PluginInfo"]) - self.log.info("hashing {} - {}".format(file_index, file)) + self.log.debug("hashing {} - {}".format(file_index, file)) job_hash = hashlib.sha256( ("{}_{}".format(file_index, file)).encode("utf-8")) @@ -450,7 +450,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, ) file_index += 1 - self.log.info( + self.log.debug( "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) # Submit frame tile jobs @@ -560,7 +560,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, assembly_job_ids = [] num_assemblies = len(assembly_payloads) for i, payload in enumerate(assembly_payloads): - self.log.info( + self.log.debug( "submitting assembly job {} of {}".format(i + 1, num_assemblies) ) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index cfdeb4968b..ffa203e922 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -244,7 +244,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, # resolve any limit groups limit_groups = self.get_limit_groups() - self.log.info("Limit groups: `{}`".format(limit_groups)) + self.log.debug("Limit groups: `{}`".format(limit_groups)) payload = { "JobInfo": { @@ -387,10 +387,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, }) plugin = payload["JobInfo"]["Plugin"] - self.log.info("using render plugin : {}".format(plugin)) + self.log.debug("using render plugin : {}".format(plugin)) - self.log.info("Submitting..") - self.log.info(json.dumps(payload, indent=4, sort_keys=True)) + self.log.debug("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) # adding expectied files to instance.data self.expected_files( diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index bf4411ef43..20bebe583f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -317,7 +317,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, # remove secondary pool payload["JobInfo"].pop("SecondaryPool", None) - self.log.info("Submitting Deadline job ...") + self.log.debug("Submitting Deadline publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) @@ -454,7 +454,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, import getpass render_job = {} - self.log.info("Faking job data ...") + self.log.debug("Faking job data ...") render_job["Props"] = {} # Render job doesn't exist because we do not have prior submission. # We still use data from it so lets fake it. diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py index a30401e7dc..d8933a191f 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_connection.py @@ -18,7 +18,7 @@ class ValidateDeadlineConnection(pyblish.api.InstancePlugin): # if custom one is set in instance, use that if instance.data.get("deadlineUrl"): deadline_url = instance.data.get("deadlineUrl") - self.log.info( + self.log.debug( "We have deadline URL on instance {}".format( deadline_url)) assert deadline_url, "Requires Deadline Webservice URL" diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 594f0ef866..1179b9cee9 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -32,9 +32,9 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, # get default deadline webservice url from deadline module deadline_url = instance.context.data["defaultDeadline"] - self.log.info("deadline_url::{}".format(deadline_url)) + self.log.debug("deadline_url::{}".format(deadline_url)) pools = DeadlineModule.get_deadline_pools(deadline_url, log=self.log) - self.log.info("pools::{}".format(pools)) + self.log.debug("pools::{}".format(pools)) formatting_data = { "pools_str": ",".join(pools) From 63923ff4d8c65fde9b984ac8f0e1d8973d127ef6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:10:33 +0200 Subject: [PATCH 137/291] Global: Tweak logging for artist-facing reports in publisher --- openpype/plugins/publish/cleanup.py | 6 +++-- openpype/plugins/publish/cleanup_farm.py | 8 +++--- openpype/plugins/publish/collect_audio.py | 8 +++--- openpype/plugins/publish/collect_hierarchy.py | 2 +- .../plugins/publish/collect_rendered_files.py | 12 ++++----- openpype/plugins/publish/extract_burnin.py | 18 ++++++------- .../publish/extract_color_transcode.py | 2 +- .../publish/extract_colorspace_data.py | 2 +- .../publish/extract_hierarchy_avalon.py | 2 +- .../publish/extract_hierarchy_to_ayon.py | 4 +-- .../plugins/publish/extract_review_slate.py | 4 +-- .../plugins/publish/extract_scanline_exr.py | 12 ++++----- openpype/plugins/publish/extract_thumbnail.py | 26 +++++++++---------- .../publish/extract_thumbnail_from_source.py | 14 +++++----- .../publish/extract_trim_video_audio.py | 6 ++--- openpype/plugins/publish/integrate.py | 6 ++--- .../plugins/publish/integrate_hero_version.py | 6 ++--- .../plugins/publish/integrate_thumbnail.py | 8 +++--- .../publish/integrate_thumbnail_ayon.py | 6 ++--- .../plugins/publish/validate_asset_docs.py | 4 +-- .../publish/validate_editorial_asset_name.py | 2 +- 21 files changed, 81 insertions(+), 77 deletions(-) diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index 573cd829e4..f9f504dd4f 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -69,7 +69,7 @@ class CleanUp(pyblish.api.InstancePlugin): skip_cleanup_filepaths.add(os.path.normpath(path)) if self.remove_temp_renders: - self.log.info("Cleaning renders new...") + self.log.debug("Cleaning renders new...") self.clean_renders(instance, skip_cleanup_filepaths) if [ef for ef in self.exclude_families @@ -95,7 +95,9 @@ class CleanUp(pyblish.api.InstancePlugin): return if instance.data.get("stagingDir_persistent"): - self.log.info("Staging dir: %s should be persistent" % staging_dir) + self.log.debug( + "Staging dir {} should be persistent".format(staging_dir) + ) return self.log.info("Removing staging directory {}".format(staging_dir)) diff --git a/openpype/plugins/publish/cleanup_farm.py b/openpype/plugins/publish/cleanup_farm.py index 8052f13734..e655437ced 100644 --- a/openpype/plugins/publish/cleanup_farm.py +++ b/openpype/plugins/publish/cleanup_farm.py @@ -26,10 +26,10 @@ class CleanUpFarm(pyblish.api.ContextPlugin): # Skip process if is not in list of source hosts in which this # plugin should run if src_host_name not in self.allowed_hosts: - self.log.info(( + self.log.debug( "Source host \"{}\" is not in list of enabled hosts {}." - " Skipping" - ).format(str(src_host_name), str(self.allowed_hosts))) + " Skipping".format(src_host_name, self.allowed_hosts) + ) return self.log.debug("Preparing filepaths to remove") @@ -47,7 +47,7 @@ class CleanUpFarm(pyblish.api.ContextPlugin): dirpaths_to_remove.add(os.path.normpath(staging_dir)) if not dirpaths_to_remove: - self.log.info("Nothing to remove. Skipping") + self.log.debug("Nothing to remove. Skipping") return self.log.debug("Filepaths to remove are:\n{}".format( diff --git a/openpype/plugins/publish/collect_audio.py b/openpype/plugins/publish/collect_audio.py index 3a0ddb3281..6aaadfc568 100644 --- a/openpype/plugins/publish/collect_audio.py +++ b/openpype/plugins/publish/collect_audio.py @@ -53,8 +53,8 @@ class CollectAudio(pyblish.api.ContextPlugin): ): # Skip instances that already have audio filled if instance.data.get("audio"): - self.log.info( - "Skipping Audio collecion. It is already collected" + self.log.debug( + "Skipping Audio collection. It is already collected" ) continue filtered_instances.append(instance) @@ -70,7 +70,7 @@ class CollectAudio(pyblish.api.ContextPlugin): instances_by_asset_name[asset_name].append(instance) asset_names = set(instances_by_asset_name.keys()) - self.log.info(( + self.log.debug(( "Searching for audio subset '{subset}' in assets {assets}" ).format( subset=self.audio_subset_name, @@ -100,7 +100,7 @@ class CollectAudio(pyblish.api.ContextPlugin): "offset": 0, "filename": repre_path }] - self.log.info("Audio Data added to instance ...") + self.log.debug("Audio Data added to instance ...") def query_representations(self, project_name, asset_names): """Query representations related to audio subsets for passed assets. diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index 687397be8a..b5fd1e4bb9 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -24,7 +24,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): final_context[project_name]['entity_type'] = 'Project' for instance in context: - self.log.info("Processing instance: `{}` ...".format(instance)) + self.log.debug("Processing instance: `{}` ...".format(instance)) # shot data dict shot_data = {} diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 6c8d1e9ca5..aaf290ace7 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -91,12 +91,12 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # now we can just add instances from json file and we are done for instance_data in data.get("instances"): - self.log.info(" - processing instance for {}".format( + self.log.debug(" - processing instance for {}".format( instance_data.get("subset"))) instance = self._context.create_instance( instance_data.get("subset") ) - self.log.info("Filling stagingDir...") + self.log.debug("Filling stagingDir...") self._fill_staging_dir(instance_data, anatomy) instance.data.update(instance_data) @@ -121,7 +121,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): "offset": 0 }] }) - self.log.info( + self.log.debug( f"Adding audio to instance: {instance.data['audio']}") def process(self, context): @@ -137,11 +137,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # Using already collected Anatomy anatomy = context.data["anatomy"] - self.log.info("Getting root setting for project \"{}\"".format( + self.log.debug("Getting root setting for project \"{}\"".format( anatomy.project_name )) - self.log.info("anatomy: {}".format(anatomy.roots)) + self.log.debug("anatomy: {}".format(anatomy.roots)) try: session_is_set = False for path in paths: @@ -156,7 +156,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): if remapped: session_data["AVALON_WORKDIR"] = remapped - self.log.info("Setting session using data from file") + self.log.debug("Setting session using data from file") legacy_io.Session.update(session_data) os.environ.update(session_data) session_is_set = True diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index e5b37ee3b4..dc8aab6ce4 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -83,7 +83,7 @@ class ExtractBurnin(publish.Extractor): return if not instance.data.get("representations"): - self.log.info( + self.log.debug( "Instance does not have filled representations. Skipping") return @@ -135,11 +135,11 @@ class ExtractBurnin(publish.Extractor): burnin_defs, repre["tags"] ) if not repre_burnin_defs: - self.log.info(( + self.log.debug( "Skipped representation. All burnin definitions from" - " selected profile does not match to representation's" - " tags. \"{}\"" - ).format(str(repre["tags"]))) + " selected profile do not match to representation's" + " tags. \"{}\"".format(repre["tags"]) + ) continue filtered_repres.append((repre, repre_burnin_defs)) @@ -164,7 +164,7 @@ class ExtractBurnin(publish.Extractor): logger=self.log) if not profile: - self.log.info(( + self.log.debug(( "Skipped instance. None of profiles in presets are for" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Task type \"{}\" | Subset \"{}\" " @@ -176,7 +176,7 @@ class ExtractBurnin(publish.Extractor): # Pre-filter burnin definitions by instance families burnin_defs = self.filter_burnins_defs(profile, instance) if not burnin_defs: - self.log.info(( + self.log.debug(( "Skipped instance. Burnin definitions are not set for profile" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Profile \"{}\"" @@ -223,10 +223,10 @@ class ExtractBurnin(publish.Extractor): # If result is None the requirement of conversion can't be # determined if do_convert is None: - self.log.info(( + self.log.debug( "Can't determine if representation requires conversion." " Skipped." - )) + ) continue # Do conversion if needed diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py index f7c8af9318..dbf1b6c8a6 100644 --- a/openpype/plugins/publish/extract_color_transcode.py +++ b/openpype/plugins/publish/extract_color_transcode.py @@ -320,7 +320,7 @@ class ExtractOIIOTranscode(publish.Extractor): logger=self.log) if not profile: - self.log.info(( + self.log.debug(( "Skipped instance. None of profiles in presets are for" " Host: \"{}\" | Families: \"{}\" | Task \"{}\"" " | Task type \"{}\" | Subset \"{}\" " diff --git a/openpype/plugins/publish/extract_colorspace_data.py b/openpype/plugins/publish/extract_colorspace_data.py index 363df28fb5..8873dcd637 100644 --- a/openpype/plugins/publish/extract_colorspace_data.py +++ b/openpype/plugins/publish/extract_colorspace_data.py @@ -30,7 +30,7 @@ class ExtractColorspaceData(publish.Extractor, def process(self, instance): representations = instance.data.get("representations") if not representations: - self.log.info("No representations at instance : `{}`".format( + self.log.debug("No representations at instance : `{}`".format( instance)) return diff --git a/openpype/plugins/publish/extract_hierarchy_avalon.py b/openpype/plugins/publish/extract_hierarchy_avalon.py index 1d57545bc0..d70f0cbdd7 100644 --- a/openpype/plugins/publish/extract_hierarchy_avalon.py +++ b/openpype/plugins/publish/extract_hierarchy_avalon.py @@ -21,7 +21,7 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): return if "hierarchyContext" not in context.data: - self.log.info("skipping IntegrateHierarchyToAvalon") + self.log.debug("skipping ExtractHierarchyToAvalon") return if not legacy_io.Session: diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index 915650ae41..bb0081b7a5 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -27,13 +27,13 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): hierarchy_context = context.data.get("hierarchyContext") if not hierarchy_context: - self.log.info("Skipping") + self.log.debug("Skipping ExtractHierarchyToAYON") return project_name = context.data["projectName"] hierarchy_context = self._filter_hierarchy(context) if not hierarchy_context: - self.log.info("All folders were filtered out") + self.log.debug("All folders were filtered out") return self.log.debug("Hierarchy_context: {}".format( diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 886384fee6..8b1a06b6b8 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -46,7 +46,7 @@ class ExtractReviewSlate(publish.Extractor): "*": inst_data["slateFrame"] } - self.log.info("_ slates_data: {}".format(pformat(slates_data))) + self.log.debug("_ slates_data: {}".format(pformat(slates_data))) if "reviewToWidth" in inst_data: use_legacy_code = True @@ -76,7 +76,7 @@ class ExtractReviewSlate(publish.Extractor): ) # get slate data slate_path = self._get_slate_path(input_file, slates_data) - self.log.info("_ slate_path: {}".format(slate_path)) + self.log.debug("_ slate_path: {}".format(slate_path)) slate_width, slate_height = self._get_slates_resolution(slate_path) diff --git a/openpype/plugins/publish/extract_scanline_exr.py b/openpype/plugins/publish/extract_scanline_exr.py index 9f22794a79..747155689b 100644 --- a/openpype/plugins/publish/extract_scanline_exr.py +++ b/openpype/plugins/publish/extract_scanline_exr.py @@ -29,24 +29,24 @@ class ExtractScanlineExr(pyblish.api.InstancePlugin): representations_new = [] for repre in representations: - self.log.info( + self.log.debug( "Processing representation {}".format(repre.get("name"))) tags = repre.get("tags", []) if "toScanline" not in tags: - self.log.info(" - missing toScanline tag") + self.log.debug(" - missing toScanline tag") continue # run only on exrs if repre.get("ext") != "exr": - self.log.info("- not EXR files") + self.log.debug("- not EXR files") continue if not isinstance(repre['files'], (list, tuple)): input_files = [repre['files']] - self.log.info("We have a single frame") + self.log.debug("We have a single frame") else: input_files = repre['files'] - self.log.info("We have a sequence") + self.log.debug("We have a sequence") stagingdir = os.path.normpath(repre.get("stagingDir")) @@ -68,7 +68,7 @@ class ExtractScanlineExr(pyblish.api.InstancePlugin): ] subprocess_exr = " ".join(oiio_cmd) - self.log.info(f"running: {subprocess_exr}") + self.log.debug(f"running: {subprocess_exr}") run_subprocess(subprocess_exr, logger=self.log) # raise error if there is no ouptput diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index b72a6d02ad..cc2892213a 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -43,12 +43,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Skip if instance have 'review' key in data set to 'False' if not self._is_review_instance(instance): - self.log.info("Skipping - no review set on instance.") + self.log.debug("Skipping - no review set on instance.") return # Check if already has thumbnail created if self._already_has_thumbnail(instance_repres): - self.log.info("Thumbnail representation already present.") + self.log.debug("Thumbnail representation already present.") return # skip crypto passes. @@ -58,15 +58,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # representation that can be determined much earlier and # with better precision. if "crypto" in subset_name.lower(): - self.log.info("Skipping crypto passes.") + self.log.debug("Skipping crypto passes.") return filtered_repres = self._get_filtered_repres(instance) if not filtered_repres: - self.log.info(( - "Instance don't have representations" - " that can be used as source for thumbnail. Skipping" - )) + self.log.info( + "Instance doesn't have representations that can be used " + "as source for thumbnail. Skipping thumbnail extraction." + ) return # Create temp directory for thumbnail @@ -107,10 +107,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # oiiotool isn't available if not thumbnail_created: if oiio_supported: - self.log.info(( + self.log.debug( "Converting with FFMPEG because input" " can't be read by OIIO." - )) + ) thumbnail_created = self.create_thumbnail_ffmpeg( full_input_path, full_output_path @@ -165,8 +165,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): continue if not repre.get("files"): - self.log.info(( - "Representation \"{}\" don't have files. Skipping" + self.log.debug(( + "Representation \"{}\" doesn't have files. Skipping" ).format(repre["name"])) continue @@ -174,7 +174,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return filtered_repres def create_thumbnail_oiio(self, src_path, dst_path): - self.log.info("Extracting thumbnail {}".format(dst_path)) + self.log.debug("Extracting thumbnail with OIIO: {}".format(dst_path)) oiio_cmd = get_oiio_tool_args( "oiiotool", "-a", src_path, @@ -192,7 +192,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False def create_thumbnail_ffmpeg(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.debug("Extracting thumbnail with FFMPEG: {}".format(dst_path)) ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index 1b9f0a8bae..c1f271a8cc 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -49,7 +49,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # Check if already has thumbnail created if self._instance_has_thumbnail(instance): - self.log.info("Thumbnail representation already present.") + self.log.debug("Thumbnail representation already present.") return dst_filepath = self._create_thumbnail( @@ -98,7 +98,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): thumbnail_created = False oiio_supported = is_oiio_supported() - self.log.info("Thumbnail source: {}".format(thumbnail_source)) + self.log.debug("Thumbnail source: {}".format(thumbnail_source)) src_basename = os.path.basename(thumbnail_source) dst_filename = os.path.splitext(src_basename)[0] + "_thumb.jpg" full_output_path = os.path.join(dst_staging, dst_filename) @@ -115,10 +115,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # oiiotool isn't available if not thumbnail_created: if oiio_supported: - self.log.info(( + self.log.info( "Converting with FFMPEG because input" " can't be read by OIIO." - )) + ) thumbnail_created = self.create_thumbnail_ffmpeg( thumbnail_source, full_output_path @@ -143,14 +143,14 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return False def create_thumbnail_oiio(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) oiio_cmd = get_oiio_tool_args( "oiiotool", "-a", src_path, "--ch", "R,G,B", "-o", dst_path ) - self.log.info("Running: {}".format(" ".join(oiio_cmd))) + self.log.debug("Running: {}".format(" ".join(oiio_cmd))) try: run_subprocess(oiio_cmd, logger=self.log) return True @@ -173,7 +173,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): dst_path ) - self.log.info("Running: {}".format(" ".join(ffmpeg_cmd))) + self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd))) try: run_subprocess(ffmpeg_cmd, logger=self.log) return True diff --git a/openpype/plugins/publish/extract_trim_video_audio.py b/openpype/plugins/publish/extract_trim_video_audio.py index 2907ae1839..5e00cfc96f 100644 --- a/openpype/plugins/publish/extract_trim_video_audio.py +++ b/openpype/plugins/publish/extract_trim_video_audio.py @@ -36,7 +36,7 @@ class ExtractTrimVideoAudio(publish.Extractor): # get staging dir staging_dir = self.staging_dir(instance) - self.log.info("Staging dir set to: `{}`".format(staging_dir)) + self.log.debug("Staging dir set to: `{}`".format(staging_dir)) # Generate mov file. fps = instance.data["fps"] @@ -59,7 +59,7 @@ class ExtractTrimVideoAudio(publish.Extractor): extensions = [output_file_type] for ext in extensions: - self.log.info("Processing ext: `{}`".format(ext)) + self.log.debug("Processing ext: `{}`".format(ext)) if not ext.startswith("."): ext = "." + ext @@ -98,7 +98,7 @@ class ExtractTrimVideoAudio(publish.Extractor): ffmpeg_args.append(clip_trimed_path) joined_args = " ".join(ffmpeg_args) - self.log.info(f"Processing: {joined_args}") + self.log.debug(f"Processing: {joined_args}") run_subprocess( ffmpeg_args, logger=self.log ) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index be07cffe72..0c18ab4466 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -155,13 +155,13 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Instance should be integrated on a farm if instance.data.get("farm"): - self.log.info( + self.log.debug( "Instance is marked to be processed on farm. Skipping") return # Instance is marked to not get integrated if not instance.data.get("integrate", True): - self.log.info("Instance is marked to skip integrating. Skipping") + self.log.debug("Instance is marked to skip integrating. Skipping") return filtered_repres = self.filter_representations(instance) @@ -306,7 +306,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # increase if the file transaction takes a long time. op_session.commit() - self.log.info("Subset {subset[name]} and Version {version[name]} " + self.log.info("Subset '{subset[name]}' and Version {version[name]} " "written to database..".format(subset=subset, version=version)) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 6c21664b78..9f0f7fe7f3 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -275,10 +275,10 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): backup_hero_publish_dir = _backup_hero_publish_dir break except Exception: - self.log.info(( + self.log.info( "Could not remove previous backup folder." - " Trying to add index to folder name" - )) + " Trying to add index to folder name." + ) _backup_hero_publish_dir = ( backup_hero_publish_dir + str(idx) diff --git a/openpype/plugins/publish/integrate_thumbnail.py b/openpype/plugins/publish/integrate_thumbnail.py index 9929d8f754..0c12255d38 100644 --- a/openpype/plugins/publish/integrate_thumbnail.py +++ b/openpype/plugins/publish/integrate_thumbnail.py @@ -41,7 +41,9 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): def process(self, context): if AYON_SERVER_ENABLED: - self.log.info("AYON is enabled. Skipping v3 thumbnail integration") + self.log.debug( + "AYON is enabled. Skipping v3 thumbnail integration" + ) return # Filter instances which can be used for integration @@ -74,14 +76,14 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): thumbnail_template = anatomy.templates["publish"]["thumbnail"] if not thumbnail_template: - self.log.info("Thumbnail template is not filled. Skipping.") + self.log.debug("Thumbnail template is not filled. Skipping.") return if ( not thumbnail_root and thumbnail_root_format_key in thumbnail_template ): - self.log.warning(("{} is not set. Skipping.").format(env_key)) + self.log.warning("{} is not set. Skipping.".format(env_key)) return # Collect verion ids from all filtered instance diff --git a/openpype/plugins/publish/integrate_thumbnail_ayon.py b/openpype/plugins/publish/integrate_thumbnail_ayon.py index ba5664c69f..cf05327ce8 100644 --- a/openpype/plugins/publish/integrate_thumbnail_ayon.py +++ b/openpype/plugins/publish/integrate_thumbnail_ayon.py @@ -35,13 +35,13 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): def process(self, context): if not AYON_SERVER_ENABLED: - self.log.info("AYON is not enabled. Skipping") + self.log.debug("AYON is not enabled. Skipping") return # Filter instances which can be used for integration filtered_instance_items = self._prepare_instances(context) if not filtered_instance_items: - self.log.info( + self.log.debug( "All instances were filtered. Thumbnail integration skipped." ) return @@ -110,7 +110,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): # Skip instance if thumbnail path is not available for it if not thumbnail_path: - self.log.info(( + self.log.debug(( "Skipping thumbnail integration for instance \"{}\"." " Instance and context" " thumbnail paths are not available." diff --git a/openpype/plugins/publish/validate_asset_docs.py b/openpype/plugins/publish/validate_asset_docs.py index 9a1ca5b8de..8dfd783c39 100644 --- a/openpype/plugins/publish/validate_asset_docs.py +++ b/openpype/plugins/publish/validate_asset_docs.py @@ -22,11 +22,11 @@ class ValidateAssetDocs(pyblish.api.InstancePlugin): return if instance.data.get("assetEntity"): - self.log.info("Instance has set asset document in its data.") + self.log.debug("Instance has set asset document in its data.") elif instance.data.get("newAssetPublishing"): # skip if it is editorial - self.log.info("Editorial instance is no need to check...") + self.log.debug("Editorial instance has no need to check...") else: raise PublishValidationError(( diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py index 4f8a1abf2e..fca0d8e7f5 100644 --- a/openpype/plugins/publish/validate_editorial_asset_name.py +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -56,7 +56,7 @@ class ValidateEditorialAssetName(pyblish.api.ContextPlugin): } continue - self.log.info("correct asset: {}".format(asset)) + self.log.debug("correct asset: {}".format(asset)) if assets_missing_name: wrong_names = {} From 7243f7e7219cd59aebb5c970666bb5d8552d378b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:11:30 +0200 Subject: [PATCH 138/291] Fix context not being reported correctly if it was already set on `context.data` prior to collector --- openpype/plugins/publish/collect_current_context.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/plugins/publish/collect_current_context.py b/openpype/plugins/publish/collect_current_context.py index 166d75e5de..8b12a3f77f 100644 --- a/openpype/plugins/publish/collect_current_context.py +++ b/openpype/plugins/publish/collect_current_context.py @@ -39,5 +39,12 @@ class CollectCurrentContext(pyblish.api.ContextPlugin): # - 'task' -> 'taskName' self.log.info(( - "Collected project context\nProject: {}\nAsset: {}\nTask: {}" - ).format(project_name, asset_name, task_name)) + "Collected project context\n" + "Project: {project_name}\n" + "Asset: {asset_name}\n" + "Task: {task_name}" + ).format( + project_name=context.data["projectName"], + asset_name=context.data["asset"], + task_name=context.data["task"] + )) From d03d0564cdd471ce8ac3c0fdcf85db4841c69508 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:13:07 +0200 Subject: [PATCH 139/291] Fix typo `thubmnail` -> `thumbnail` --- openpype/plugins/publish/extract_thumbnail.py | 2 +- openpype/plugins/publish/extract_thumbnail_from_source.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index cc2892213a..de101ac7ac 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -225,7 +225,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using ffmpeg", + "Failed to create thumbnail using ffmpeg", exc_info=True ) return False diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index c1f271a8cc..401a5d615d 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -156,7 +156,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using oiiotool", + "Failed to create thumbnail using oiiotool", exc_info=True ) return False @@ -179,7 +179,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return True except Exception: self.log.warning( - "Failed to create thubmnail using ffmpeg", + "Failed to create thumbnail using ffmpeg", exc_info=True ) return False From 2ba42fe376bb6b0fec30cc1a37fb634b7ba69318 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:14:53 +0200 Subject: [PATCH 140/291] Tweak logging level --- openpype/hosts/fusion/plugins/publish/collect_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index 6016baa2a9..4d6da79b77 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -85,5 +85,5 @@ class CollectInstanceData(pyblish.api.InstancePlugin): # Add review family if the instance is marked as 'review' # This could be done through a 'review' Creator attribute. if instance.data.get("review", False): - self.log.info("Adding review family..") + self.log.debug("Adding review family..") instance.data["families"].append("review") From ca8305a0cf2a48bb5c9965c6cab9b4bc14aaf549 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:16:37 +0200 Subject: [PATCH 141/291] Tweak logging level of removal of directory in tempdir to debug since artist doesn't care about seeing that in artist facing report --- openpype/plugins/publish/cleanup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index f9f504dd4f..6c122ddf09 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -100,7 +100,7 @@ class CleanUp(pyblish.api.InstancePlugin): ) return - self.log.info("Removing staging directory {}".format(staging_dir)) + self.log.debug("Removing staging directory {}".format(staging_dir)) shutil.rmtree(staging_dir) def clean_renders(self, instance, skip_cleanup_filepaths): From e9cb011c2a38084bc2318e4b15d011e2278f0c38 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 10:31:06 +0200 Subject: [PATCH 142/291] Remove unused attribute --- openpype/hosts/maya/plugins/publish/collect_history.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_history.py b/openpype/hosts/maya/plugins/publish/collect_history.py index 71f0169971..d4e8c6298b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_history.py +++ b/openpype/hosts/maya/plugins/publish/collect_history.py @@ -18,7 +18,6 @@ class CollectMayaHistory(pyblish.api.InstancePlugin): hosts = ["maya"] label = "Maya History" families = ["rig"] - verbose = False def process(self, instance): From 97f9cd4377e83cfec48ae8d18bcc90d783185e60 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:14:33 +0200 Subject: [PATCH 143/291] Do not warn about renderlayer instance being empty --- openpype/hosts/maya/plugins/publish/collect_instances.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_instances.py b/openpype/hosts/maya/plugins/publish/collect_instances.py index 5f914b40d7..5058da3d01 100644 --- a/openpype/hosts/maya/plugins/publish/collect_instances.py +++ b/openpype/hosts/maya/plugins/publish/collect_instances.py @@ -28,6 +28,8 @@ class CollectNewInstances(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder hosts = ["maya"] + valid_empty_families = {"workfile", "renderlayer"} + def process(self, instance): objset = instance.data.get("instance_node") @@ -58,7 +60,7 @@ class CollectNewInstances(pyblish.api.InstancePlugin): instance[:] = members_hierarchy - elif instance.data["family"] != "workfile": + elif instance.data["family"] not in self.valid_empty_families: self.log.warning("Empty instance: \"%s\" " % objset) # Store the exact members of the object set instance.data["setMembers"] = members From 0fa081dddb6e6295748f338daa822e1e2cd61d40 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:16:27 +0200 Subject: [PATCH 144/291] Ignore arnold attribute check if arnold is not loaded --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 55624726ea..59ba326d45 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -109,6 +109,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return + if not cmds.pluginInfo("mtoa", query=True, loaded=True): + # Arnold attributes only exist if plug-in is loaded + return + invalid = self.get_invalid_attributes(instance, compute=True) if invalid: raise PublishValidationError( From 17f83a0eccb99a35ba44faaf876ad3604b342932 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:17:16 +0200 Subject: [PATCH 145/291] Avoid iterating over `None` if `ai` attributes for some reason do not exist --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 59ba326d45..ddb8d2bf9d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -51,7 +51,7 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, tmp.append(transform) mesh = cmds.createNode("mesh", parent=transform) - for attr in cmds.listAttr(mesh, string="ai*"): + for attr in cmds.listAttr(mesh, string="ai*") or []: plug = "{}.{}".format(mesh, attr) try: defaults[attr] = get_attribute(plug) From 861b9849cb11b82ad70ef99806c1c0498ec30b1b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:20:56 +0200 Subject: [PATCH 146/291] Ensure we only iterate `ai*` attributes that come from a plugin --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index ddb8d2bf9d..849d60653a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -51,7 +51,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, tmp.append(transform) mesh = cmds.createNode("mesh", parent=transform) - for attr in cmds.listAttr(mesh, string="ai*") or []: + arnold_attributes = cmds.listAttr(mesh, + string="ai*", + fromPlugin=True) or [] + for attr in arnold_attributes: plug = "{}.{}".format(mesh, attr) try: defaults[attr] = get_attribute(plug) From 3ee28bd1aa092f90e5b807dc63eea135eb913a8b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:22:11 +0200 Subject: [PATCH 147/291] Avoid selection changes --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 849d60653a..d50e1a56c7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -47,10 +47,10 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, # Get default arnold attribute values for mesh type. defaults = {} with delete_after() as tmp: - transform = cmds.createNode("transform") + transform = cmds.createNode("transform", skipSelect=True) tmp.append(transform) - mesh = cmds.createNode("mesh", parent=transform) + mesh = cmds.createNode("mesh", parent=transform, skipSelect=True) arnold_attributes = cmds.listAttr(mesh, string="ai*", fromPlugin=True) or [] From 3b8df1ac2bad6df683ae4417e2b2e0b59944f729 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:29:40 +0200 Subject: [PATCH 148/291] Cache arnold mesh attribute defaults so multiple "model" instance don't try and retrieve the default values every time. --- .../plugins/publish/validate_mesh_arnold_attributes.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index d50e1a56c7..526ebbac99 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -36,6 +36,9 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, optional = True + # cache (will be `dict` when cached) + arnold_mesh_defaults = None + @classmethod def apply_settings(cls, project_settings, system_settings): # todo: this should not be done this way @@ -44,6 +47,11 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, @classmethod def get_default_attributes(cls): + + if cls.arnold_mesh_defaults is not None: + # Use from cache + return cls.arnold_mesh_defaults + # Get default arnold attribute values for mesh type. defaults = {} with delete_after() as tmp: @@ -61,6 +69,7 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, except PublishValidationError: cls.log.debug("Ignoring arnold attribute: {}".format(attr)) + cls.arnold_mesh_defaults = defaults # assign cache return defaults @classmethod From 689da974c4d22161797862f2a9e77d8aeee65656 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Aug 2023 14:42:46 +0200 Subject: [PATCH 149/291] Removed `apply_settings` override to fix plugin settings actually being applied This makes it so again that it's up to the studio admin to enable or disable the plug-in per project instead of whether the modeler's workfile just happened to have the current renderer set to arnold or not. Note that the plug-in is currently disabled by default in project settings. --- .../maya/plugins/publish/validate_mesh_arnold_attributes.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 526ebbac99..bde78a98b8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -39,12 +39,6 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin, # cache (will be `dict` when cached) arnold_mesh_defaults = None - @classmethod - def apply_settings(cls, project_settings, system_settings): - # todo: this should not be done this way - attr = "defaultRenderGlobals.currentRenderer" - cls.active = cmds.getAttr(attr).lower() == "arnold" - @classmethod def get_default_attributes(cls): From b83a40931385b0688cad416a7a998fb9b3f6f7c1 Mon Sep 17 00:00:00 2001 From: sjt-rvx <72554834+sjt-rvx@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:12:03 +0000 Subject: [PATCH 150/291] have the addons loading respect a custom AYON_ADDONS_DIR (#5539) * have the addons loading respect a custom AYON_ADDONS_DIR * Update openpype/modules/base.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/modules/base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 9b3637c48a..84e213288c 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -373,10 +373,12 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addons_info = _get_ayon_addons_information() if not addons_info: return v3_addons_to_skip - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) + addons_dir = os.environ.get("AYON_ADDONS_DIR") + if not addons_dir: + addons_dir = os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + "addons" + ) if not os.path.exists(addons_dir): log.warning("Addons directory does not exists. Path \"{}\"".format( addons_dir From 3c3438532018c6360b77a2b5c86959638e73d86f Mon Sep 17 00:00:00 2001 From: Mustafa Zarkash Date: Wed, 30 Aug 2023 16:35:09 +0300 Subject: [PATCH 151/291] Houdini: Improve VDB Selection (#5523) * improve sop selection * resolve hound conversations * resolve BigRoy's comments --- .../plugins/create/create_vbd_cache.py | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py index c015cebd49..9c96e48e3a 100644 --- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py +++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py @@ -33,7 +33,7 @@ class CreateVDBCache(plugin.HoudiniCreator): } if self.selected_nodes: - parms["soppath"] = self.selected_nodes[0].path() + parms["soppath"] = self.get_sop_node_path(self.selected_nodes[0]) instance_node.setParms(parms) @@ -42,3 +42,63 @@ class CreateVDBCache(plugin.HoudiniCreator): hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] + + def get_sop_node_path(self, selected_node): + """Get Sop Path of the selected node. + + Although Houdini allows ObjNode path on `sop_path` 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): + self.log.debug( + "Valid SopNode selection, 'SOP Path' in ROP will" + " be set to '%s'.", selected_node.path() + ) + return selected_node.path() + + # 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() == "geo": + + # Try to find output node. + sop_node = self.get_obj_output(selected_node) + if sop_node: + self.log.debug( + "Valid ObjNode selection, 'SOP Path' in ROP will " + "be set to the child path '%s'.", sop_node.path() + ) + return sop_node.path() + + self.log.debug( + "Selection isn't valid. 'SOP Path' in ROP will be empty." + ) + return "" + + def get_obj_output(self, obj_node): + """Try to find output node. + + If any output nodes are present, return the output node with + the minimum 'outputidx' + If no output nodes are present, return the node with display flag + If no nodes are present at all, return None + """ + + 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 has multiple output nodes + # return the one with the minimum 'outputidx' + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) From a60c3d7ce304f648e13c6ab712a9a558b2186a65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:36:42 +0200 Subject: [PATCH 152/291] use correct git url in README (#5542) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92f1cb62dc..ce98f845e6 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ development tools like [CMake](https://cmake.org/) and [Visual Studio](https://v #### Clone repository: ```sh -git clone --recurse-submodules git@github.com:Pypeclub/OpenPype.git +git clone --recurse-submodules git@github.com:ynput/OpenPype.git ``` #### To build OpenPype: From 74d612208ec4dc0e3a31891ce93dfca5a02d4d48 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:47:03 +0200 Subject: [PATCH 153/291] AYON: Deadline expand userpaths in executables list (#5540) * expand userpaths set in executables list * Update logic for searching executable with expanduser --------- Co-authored-by: Petr Kalis --- .../deadline/repository/custom/plugins/Ayon/Ayon.py | 8 +++++++- .../repository/custom/plugins/GlobalJobPreLoad.py | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index 1544acc2a4..a29acf9823 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -91,7 +91,13 @@ class AyonDeadlinePlugin(DeadlinePlugin): # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") - exe = FileUtils.SearchFileList(exe_list) + + expanded_paths = [] + for path in exe_list.split(";"): + if path.startswith("~"): + path = os.path.expanduser(path) + expanded_paths.append(path) + exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": self.FailRender( diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 5f7e1f1032..97875215ae 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -547,7 +547,14 @@ def get_ayon_executable(): # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": exe_list = exe_list.replace("\\ ", " ") - return exe_list + + # Expand user paths + expanded_paths = [] + for path in exe_list.split(";"): + if path.startswith("~"): + path = os.path.expanduser(path) + expanded_paths.append(path) + return ";".join(expanded_paths) def inject_render_job_id(deadlinePlugin): From 668bcb2d40d5708fc40c1e1a8465e36c94ffdef8 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 30 Aug 2023 22:28:43 +0300 Subject: [PATCH 154/291] BigRoy's Comments --- openpype/hosts/houdini/api/colorspace.py | 19 +++++++------------ openpype/pipeline/colorspace.py | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index 2662a968e2..fb0a724eb9 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -57,16 +57,11 @@ class ARenderProduct(object): def get_default_display_view_colorspace(): - """Get default display view colorspace. + """Get default display view colorspace name. """ - It's used for 'ociocolorspace' parm in OpneGL Node.""" - - data = get_color_management_preferences() - config_path = data.get("config") - display = data.get("display") - view = data.get("view") - - default_view_space = get_display_view_colorspace_name(config_path, - display, - view) - return default_view_space + prefs = get_color_management_preferences() + return get_display_view_colorspace_name( + config_path=prefs["config"], + display=prefs["display"], + view=prefs["view"] + ) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 37974f4a0b..3bb258e8f2 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -650,7 +650,7 @@ def get_display_view_colorspace_subprocess(config_path, display, view): "--view", view ] - log.info("Executing: {}".format(" ".join(args))) + log.debug("Executing: {}".format(" ".join(args))) process_kwargs = { "logger": log From 41babcaa85880b957a6409bf29d02da2291f91d2 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 30 Aug 2023 22:43:52 +0300 Subject: [PATCH 155/291] update doc strings --- openpype/hosts/houdini/api/colorspace.py | 4 +++- openpype/pipeline/colorspace.py | 4 ++-- openpype/scripts/ocio_wrapper.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index fb0a724eb9..b1a4d5dcd5 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -57,7 +57,9 @@ class ARenderProduct(object): def get_default_display_view_colorspace(): - """Get default display view colorspace name. """ + """Returns the colorspace attribute of the default (display, view) pair. + + """ prefs = get_color_management_preferences() return get_display_view_colorspace_name( diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3bb258e8f2..3dd33d0425 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -604,7 +604,7 @@ def _get_imageio_settings(project_settings, host_name): def get_display_view_colorspace_name(config_path, display, view): - """Return colorspace name for the given display and view. + """Returns the colorspace attribute of the (display, view) pair. Args: config_path (str): path string leading to config.ocio @@ -627,7 +627,7 @@ def get_display_view_colorspace_name(config_path, display, view): def get_display_view_colorspace_subprocess(config_path, display, view): - """get view colorspace name for the given display and view + """Returns the colorspace attribute of the (display, view) pair via subprocess. Args: diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index e491206ebb..cae6e6975b 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -175,7 +175,7 @@ def _get_views_data(config_path): def _get_display_view_colorspace_name(config_path, display, view): - """get view colorspace name for the given display and view. + """Returns the colorspace attribute of the (display, view) pair. Args: config_path (str): path string leading to config.ocio From 6c5039f7075ce59b2911a680d41ffae5a59dca09 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Wed, 30 Aug 2023 22:46:07 +0300 Subject: [PATCH 156/291] BigRoy's Comment --- openpype/hosts/houdini/api/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py index b1a4d5dcd5..cc40b9df1c 100644 --- a/openpype/hosts/houdini/api/colorspace.py +++ b/openpype/hosts/houdini/api/colorspace.py @@ -59,7 +59,7 @@ class ARenderProduct(object): def get_default_display_view_colorspace(): """Returns the colorspace attribute of the default (display, view) pair. - """ + It's used for 'ociocolorspace' parm in OpenGL Node.""" prefs = get_color_management_preferences() return get_display_view_colorspace_name( From 9e19f2f8ca1e7e848a7cebde4432df1fde3d3897 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:09:33 +0200 Subject: [PATCH 157/291] implemented multiselection EnumDef --- openpype/lib/attribute_definitions.py | 30 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 6054d2a92a..0af701a93a 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -434,7 +434,9 @@ class EnumDef(AbstractAttrDef): type = "enum" - def __init__(self, key, items, default=None, **kwargs): + def __init__( + self, key, items, default=None, multiselection=False, **kwargs + ): if not items: raise ValueError(( "Empty 'items' value. {} must have" @@ -443,7 +445,10 @@ class EnumDef(AbstractAttrDef): items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] - if default not in item_values: + if multiselection and default is None: + default = [] + + if not multiselection and default not in item_values: for value in item_values: default = value break @@ -452,21 +457,34 @@ class EnumDef(AbstractAttrDef): self.items = items self._item_values = set(item_values) + self.multiselection = multiselection def __eq__(self, other): if not super(EnumDef, self).__eq__(other): return False - return self.items == other.items + return ( + self.items == other.items + and self.multiselection == other.multiselection + ) def convert_value(self, value): - if value in self._item_values: - return value - return self.default + if not self.multiselection: + if value in self._item_values: + return value + return self.default + + if value is None: + return copy.deepcopy(self.default) + new_value = set(value) + rem = new_value - self._item_values + return list(new_value - rem) + def serialize(self): data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) + data["multiselection"] = self.multiselection return data @staticmethod From ebec7236aae738a2ffd9fd2738b9f402ead42c2e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:09:51 +0200 Subject: [PATCH 158/291] implemented multiselection combobox --- openpype/tools/utils/__init__.py | 3 + .../tools/utils/multiselection_combobox.py | 383 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 openpype/tools/utils/multiselection_combobox.py diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index f35bfaee70..d343353112 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -38,6 +38,7 @@ from .models import ( from .overlay_messages import ( MessageOverlayObject, ) +from .multiselection_combobox import MultiSelectionComboBox __all__ = ( @@ -78,4 +79,6 @@ __all__ = ( "RecursiveSortFilterProxyModel", "MessageOverlayObject", + + "MultiSelectionComboBox", ) diff --git a/openpype/tools/utils/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py new file mode 100644 index 0000000000..27576a449a --- /dev/null +++ b/openpype/tools/utils/multiselection_combobox.py @@ -0,0 +1,383 @@ +from qtpy import QtCore, QtGui, QtWidgets + +from .lib import ( + checkstate_int_to_enum, + checkstate_enum_to_int, +) +from .constants import ( + CHECKED_INT, + UNCHECKED_INT, + ITEM_IS_USER_TRISTATE, +) + + +class ComboItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Helper styled delegate (mostly based on existing private Qt's + delegate used by the QtWidgets.QComboBox). Used to style the popup like a + list view (e.g windows style). + """ + + def paint(self, painter, option, index): + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + # option.state &= ( + # ~QtWidgets.QStyle.State_HasFocus + # & ~QtWidgets.QStyle.State_MouseOver + # ) + super(ComboItemDelegate, self).paint(painter, option, index) + + +class MultiSelectionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + focused_in = QtCore.Signal() + + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End, + } + + top_bottom_padding = 2 + left_right_padding = 3 + left_offset = 4 + top_bottom_margins = 2 + item_spacing = 5 + + item_bg_color = QtGui.QColor("#31424e") + + def __init__( + self, parent=None, placeholder="", separator=", ", **kwargs + ): + super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + self.setObjectName("MultiSelectionComboBox") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._separator = separator + self._placeholder_text = placeholder + delegate = ComboItemDelegate(self) + self.setItemDelegate(delegate) + + self._lines = {} + self._item_height = None + self._custom_text = None + self._delegate = delegate + + def get_placeholder_text(self): + return self._placeholder_text + + def set_placeholder_text(self, text): + self._placeholder_text = text + + def set_custom_text(self, text): + self._custom_text = text + self.update() + self.updateGeometry() + + def focusInEvent(self, event): + self.focused_in.emit() + return super(MultiSelectionComboBox, self).focusInEvent(event) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super(MultiSelectionComboBox, self).mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super(MultiSelectionComboBox, self).showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super(MultiSelectionComboBox, self).hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = checkstate_int_to_enum( + current_index.data(QtCore.Qt.CheckStateRole) + ) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Unchecked: + new_state = CHECKED_INT + else: + new_state = UNCHECKED_INT + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & ITEM_IS_USER_TRISTATE + ): + new_state = (checkstate_enum_to_int(state) + 1) % 3 + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + # toggle the current items check state + if state != QtCore.Qt.Checked: + new_state = CHECKED_INT + else: + new_state = UNCHECKED_INT + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self._update_size_hint() + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super(MultiSelectionComboBox, self).eventFilter(obj, event) + + def addItem(self, *args, **kwargs): + idx = self.count() + super(MultiSelectionComboBox, self).addItem(*args, **kwargs) + self.model().item(idx).setCheckable(True) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + items = self.checked_items_text() + # draw the icon and text + draw_text = True + combotext = None + if self._custom_text is not None: + combotext = self._custom_text + elif not items: + combotext = self._placeholder_text + else: + draw_text = False + if draw_text: + option.currentText = combotext + option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) + painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) + return + + font_metricts = self.fontMetrics() + + if self._item_height is None: + self.updateGeometry() + self.update() + return + + for line, items in self._lines.items(): + top_y = ( + option.rect.top() + + (line * self._item_height) + + self.top_bottom_margins + ) + left_x = option.rect.left() + self.left_offset + for item in items: + label_rect = font_metricts.boundingRect(item) + label_height = label_rect.height() + + label_rect.moveTop(top_y) + label_rect.moveLeft(left_x) + label_rect.setHeight(self._item_height) + label_rect.setWidth( + label_rect.width() + self.left_right_padding + ) + + bg_rect = QtCore.QRectF(label_rect) + bg_rect.setWidth( + label_rect.width() + self.left_right_padding + ) + left_x = bg_rect.right() + self.item_spacing + + label_rect.moveLeft(label_rect.x() + self.left_right_padding) + + bg_rect.setHeight(label_height + (2 * self.top_bottom_padding)) + bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) + + path = QtGui.QPainterPath() + path.addRoundedRect(bg_rect, 5, 5) + + painter.fillPath(path, self.item_bg_color) + + painter.drawText( + label_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + item + ) + + def resizeEvent(self, *args, **kwargs): + super(MultiSelectionComboBox, self).resizeEvent(*args, **kwargs) + self._update_size_hint() + + def _update_size_hint(self): + if self._custom_text is not None: + self.update() + return + self._lines = {} + + items = self.checked_items_text() + if not items: + self.update() + return + + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + btn_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxArrow + ) + total_width = option.rect.width() - btn_rect.width() + + line = 0 + self._lines = {line: []} + + font_metricts = self.fontMetrics() + default_left_x = 0 + self.left_offset + left_x = int(default_left_x) + for item in items: + rect = font_metricts.boundingRect(item) + width = rect.width() + (2 * self.left_right_padding) + right_x = left_x + width + if right_x > total_width: + left_x = int(default_left_x) + if self._lines.get(line): + line += 1 + self._lines[line] = [item] + left_x += width + else: + self._lines[line] = [item] + line += 1 + else: + if line in self._lines: + self._lines[line].append(item) + else: + self._lines[line] = [item] + left_x = left_x + width + self.item_spacing + + self.update() + self.updateGeometry() + + def sizeHint(self): + value = super(MultiSelectionComboBox, self).sizeHint() + lines = 1 + if self._custom_text is None: + lines = len(self._lines) + if lines == 0: + lines = 1 + + if self._item_height is None: + self._item_height = ( + self.fontMetrics().height() + + (2 * self.top_bottom_padding) + + (2 * self.top_bottom_margins) + ) + value.setHeight( + (lines * self._item_height) + + (2 * self.top_bottom_margins) + ) + return value + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values): + for idx in range(self.count()): + value = self.itemData(idx, role=QtCore.Qt.UserRole) + if value in values: + check_state = CHECKED_INT + else: + check_state = UNCHECKED_INT + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + self._update_size_hint() + + def value(self): + items = list() + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + items.append( + self.itemData(idx, role=QtCore.Qt.UserRole) + ) + return items + + def checked_items_text(self): + items = list() + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + items.append(self.itemText(idx)) + return items + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super(MultiSelectionComboBox, self).keyPressEvent(event) From 2d04efba93c50e6832059ae92d49bee9aab641fb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:10:14 +0200 Subject: [PATCH 159/291] implemented multuselection EnumAttrDef widget --- openpype/tools/attribute_defs/widgets.py | 55 +++++++++++++++++++----- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index 7967416e9f..e08ca17225 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -19,6 +19,7 @@ from openpype.tools.utils import ( CustomTextComboBox, FocusSpinBox, FocusDoubleSpinBox, + MultiSelectionComboBox, ) from openpype.widgets.nice_checkbox import NiceCheckbox @@ -412,10 +413,19 @@ class EnumAttrWidget(_BaseAttrDefWidget): self._multivalue = False super(EnumAttrWidget, self).__init__(*args, **kwargs) + @property + def multiselection(self): + return self.attr_def.multiselection + def _ui_init(self): - input_widget = CustomTextComboBox(self) - combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) - input_widget.setItemDelegate(combo_delegate) + if self.multiselection: + input_widget = MultiSelectionComboBox(self) + + else: + input_widget = CustomTextComboBox(self) + combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) + input_widget.setItemDelegate(combo_delegate) + self._combo_delegate = combo_delegate if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) @@ -427,9 +437,11 @@ class EnumAttrWidget(_BaseAttrDefWidget): if idx >= 0: input_widget.setCurrentIndex(idx) - input_widget.currentIndexChanged.connect(self._on_value_change) + if self.multiselection: + input_widget.value_changed.connect(self._on_value_change) + else: + input_widget.currentIndexChanged.connect(self._on_value_change) - self._combo_delegate = combo_delegate self._input_widget = input_widget self.main_layout.addWidget(input_widget, 0) @@ -442,17 +454,40 @@ class EnumAttrWidget(_BaseAttrDefWidget): self.value_changed.emit(new_value, self.attr_def.id) def current_value(self): + if self.multiselection: + return self._input_widget.value() idx = self._input_widget.currentIndex() return self._input_widget.itemData(idx) def set_value(self, value, multivalue=False): if multivalue: - set_value = set(value) - if len(set_value) == 1: - multivalue = False - value = tuple(set_value)[0] + if self.multiselection: + _value = None + are_same = True + for v in value: + _v = set(v) + if _value is None: + _value = _v + elif not are_same or _value != _v: + _value |= _v + are_same = False + if _value is None: + _value = [] + else: + value = list(_value) + multivalue = not are_same + else: + set_value = set(value) + if len(set_value) == 1: + multivalue = False + value = tuple(set_value)[0] - if not multivalue: + if self.multiselection: + self._input_widget.blockSignals(True) + self._input_widget.set_value(value) + self._input_widget.blockSignals(False) + + elif not multivalue: idx = self._input_widget.findData(value) cur_idx = self._input_widget.currentIndex() if idx != cur_idx and idx >= 0: From 7ebec6b8bcd737b6d393d9a50b9f4744a2e841b0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 22:55:44 +0200 Subject: [PATCH 160/291] simplified few lines --- openpype/lib/attribute_definitions.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 0af701a93a..44aa8dee48 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -445,18 +445,19 @@ class EnumDef(AbstractAttrDef): items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] - if multiselection and default is None: - default = [] + item_values_set = set(item_values) + if multiselection: + if default is None: + default = [] + default = list(item_values_set.intersection(default)) - if not multiselection and default not in item_values: - for value in item_values: - default = value - break + elif default not in item_values: + default = next(iter(item_values), None) super(EnumDef, self).__init__(key, default=default, **kwargs) self.items = items - self._item_values = set(item_values) + self._item_values = item_values_set self.multiselection = multiselection def __eq__(self, other): @@ -476,9 +477,7 @@ class EnumDef(AbstractAttrDef): if value is None: return copy.deepcopy(self.default) - new_value = set(value) - rem = new_value - self._item_values - return list(new_value - rem) + return list(self._item_values.intersection(value)) def serialize(self): From 8cf4c9c6515c7eb6d9db92c57d48a21acf3821f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 23:03:45 +0200 Subject: [PATCH 161/291] move multiselection multivalue handling to separated method --- openpype/tools/attribute_defs/widgets.py | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index e08ca17225..adb29dcbd3 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -459,23 +459,23 @@ class EnumAttrWidget(_BaseAttrDefWidget): idx = self._input_widget.currentIndex() return self._input_widget.itemData(idx) + def _multiselection_multivalue_prep(self, values): + final = None + are_same = True + for value in values: + value = set(value) + if final is None: + final = value + elif not are_same or final != value: + final |= value + are_same = False + return list(final), not are_same + def set_value(self, value, multivalue=False): if multivalue: if self.multiselection: - _value = None - are_same = True - for v in value: - _v = set(v) - if _value is None: - _value = _v - elif not are_same or _value != _v: - _value |= _v - are_same = False - if _value is None: - _value = [] - else: - value = list(_value) - multivalue = not are_same + value, multivalue = self._multiselection_multivalue_prep( + value) else: set_value = set(value) if len(set_value) == 1: From 5b4bdeb6259dc5bd69ec773ba717a1efad17a695 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 30 Aug 2023 23:14:54 +0200 Subject: [PATCH 162/291] avoid unnecessary negative checks --- openpype/tools/attribute_defs/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index adb29dcbd3..d9c55f4a64 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -461,15 +461,15 @@ class EnumAttrWidget(_BaseAttrDefWidget): def _multiselection_multivalue_prep(self, values): final = None - are_same = True + multivalue = False for value in values: value = set(value) if final is None: final = value - elif not are_same or final != value: + elif multivalue or final != value: final |= value - are_same = False - return list(final), not are_same + multivalue = True + return list(final), multivalue def set_value(self, value, multivalue=False): if multivalue: From 055e8250007731c3ac516ae7dbee7736629d393b Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 15:41:33 +0800 Subject: [PATCH 163/291] bigroy comment on the camera setup on validation --- openpype/hosts/max/plugins/publish/collect_render.py | 3 +++ .../max/plugins/publish/validate_viewport_camera.py | 12 +++++------- .../deadline/plugins/publish/submit_max_deadline.py | 8 ++++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index db5c84fad9..7df71799d3 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -34,6 +34,9 @@ class CollectRender(pyblish.api.InstancePlugin): aovs = RenderProducts().get_aovs(instance.name) files_by_aov.update(aovs) + camera = rt.viewport.GetCamera() + instance.data["cameras"] = [camera.name] or None + if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() instance.data["files"] = list() diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py index b35ba482a9..533b55f969 100644 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py @@ -13,8 +13,7 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): """Validates Viewport Camera - Check if the renderable camera in scene used as viewport - camera for rendering + Check if the renderable camera used for rendering """ order = pyblish.api.ValidatorOrder @@ -27,11 +26,9 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, def process(self, instance): if not self.is_active(instance.data): return - cameras_in_scene = [c for c in rt.Objects - if rt.classOf(c) in rt.Camera.Classes] - if rt.viewport.getCamera() not in cameras_in_scene: + if not instance.data["cameras"]: raise PublishValidationError( - "Cameras in Scene not used as viewport camera" + "No renderable Camera found in scene." ) @classmethod @@ -39,10 +36,11 @@ class ValidateViewportCamera(pyblish.api.InstancePlugin, rt.viewport.setType(rt.Name("view_camera")) camera = rt.viewport.GetCamera() - cls.log.info(f"Camera {camera} set as viewport camera") + cls.log.info(f"Camera {camera} set as renderable camera") renderer_class = get_current_renderer() renderer = str(renderer_class).split(":")[0] if renderer == "Arnold": arv = rt.MAXToAOps.ArnoldRenderView() arv.setOption("Camera", str(camera)) arv.close() + instance.data["cameras"] = [camera.name] diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index d8725e853c..6b42270d05 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -31,6 +31,7 @@ class MaxPluginInfo(object): Version = attr.ib(default=None) # Mandatory for Deadline SaveFile = attr.ib(default=True) IgnoreInputs = attr.ib(default=True) + Camera = attr.ib(default=None) class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, @@ -154,7 +155,8 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, SceneFile=self.scene_path, Version=instance.data["maxversion"], SaveFile=True, - IgnoreInputs=True + IgnoreInputs=True, + Camera=instance.data["cameras"][0] ) plugin_payload = attr.asdict(plugin_info) @@ -238,7 +240,9 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, if renderer == "Redshift_Renderer": plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") - + if instance.data["cameras"]: + plugin_info["Camera0"] = None + plugin_info["Camera1"] = instance.data["cameras"][0] self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) From 72d7a9191baca344498f6c686c44adaf0d570f26 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:00:22 +0800 Subject: [PATCH 164/291] bug fix the camera instance data collection --- openpype/hosts/max/plugins/publish/collect_render.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 7df71799d3..e7730369b6 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -35,7 +35,9 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() - instance.data["cameras"] = [camera.name] or None + instance.data["cameras"] = None + if camera: + instance.data["cameras"] = [camera.name] if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() From b9fb4ce9e9669cfd6773fde30139243e77981a3c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:05:02 +0800 Subject: [PATCH 165/291] rename the validator --- .../publish/validate_renderable_camera.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 openpype/hosts/max/plugins/publish/validate_renderable_camera.py diff --git a/openpype/hosts/max/plugins/publish/validate_renderable_camera.py b/openpype/hosts/max/plugins/publish/validate_renderable_camera.py new file mode 100644 index 0000000000..61321661b5 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_renderable_camera.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin) +from openpype.pipeline.publish import RepairAction +from openpype.hosts.max.api.lib import get_current_renderer + +from pymxs import runtime as rt + + +class ValidateRenderableCamera(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates Renderable Camera + + Check if the renderable camera used for rendering + """ + + order = pyblish.api.ValidatorOrder + families = ["maxrender"] + hosts = ["max"] + label = "Renderable Camera" + optional = True + actions = [RepairAction] + + def process(self, instance): + if not self.is_active(instance.data): + return + if not instance.data["cameras"]: + raise PublishValidationError( + "No renderable Camera found in scene." + ) + + @classmethod + def repair(cls, instance): + + rt.viewport.setType(rt.Name("view_camera")) + camera = rt.viewport.GetCamera() + cls.log.info(f"Camera {camera} set as renderable camera") + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + if renderer == "Arnold": + arv = rt.MAXToAOps.ArnoldRenderView() + arv.setOption("Camera", str(camera)) + arv.close() + instance.data["cameras"] = [camera.name] From 4e88f705ee67af7ba1c0a6e920288759be006fd5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:12:36 +0800 Subject: [PATCH 166/291] remove the duplicated validator --- .../publish/validate_viewport_camera.py | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 openpype/hosts/max/plugins/publish/validate_viewport_camera.py diff --git a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py b/openpype/hosts/max/plugins/publish/validate_viewport_camera.py deleted file mode 100644 index 533b55f969..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_viewport_camera.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from openpype.pipeline import ( - PublishValidationError, - OptionalPyblishPluginMixin) -from openpype.pipeline.publish import RepairAction -from openpype.hosts.max.api.lib import get_current_renderer - -from pymxs import runtime as rt - - -class ValidateViewportCamera(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validates Viewport Camera - - Check if the renderable camera used for rendering - """ - - order = pyblish.api.ValidatorOrder - families = ["maxrender"] - hosts = ["max"] - label = "Viewport Camera" - optional = True - actions = [RepairAction] - - def process(self, instance): - if not self.is_active(instance.data): - return - if not instance.data["cameras"]: - raise PublishValidationError( - "No renderable Camera found in scene." - ) - - @classmethod - def repair(cls, instance): - - rt.viewport.setType(rt.Name("view_camera")) - camera = rt.viewport.GetCamera() - cls.log.info(f"Camera {camera} set as renderable camera") - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - if renderer == "Arnold": - arv = rt.MAXToAOps.ArnoldRenderView() - arv.setOption("Camera", str(camera)) - arv.close() - instance.data["cameras"] = [camera.name] From 5685e2a1681d2ea74ac481bfa31de6f0a370d55f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 16:48:52 +0800 Subject: [PATCH 167/291] introduce imprint function for correct version in hda loader --- openpype/hosts/houdini/plugins/load/load_hda.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py index 57edc341a3..9630716253 100644 --- a/openpype/hosts/houdini/plugins/load/load_hda.py +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -59,6 +59,9 @@ class HdaLoader(load.LoaderPlugin): def_paths = [d.libraryFilePath() for d in defs] new = def_paths.index(file_path) defs[new].setIsPreferred(True) + hda_node.setParms({ + "representation": str(representation["_id"]) + }) def remove(self, container): node = container["node"] From f6c2be8b2a57559418077a68054e9ae3dfdbd76e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Aug 2023 11:15:37 +0200 Subject: [PATCH 168/291] force repaint --- openpype/tools/utils/multiselection_combobox.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/utils/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py index 27576a449a..13b396a059 100644 --- a/openpype/tools/utils/multiselection_combobox.py +++ b/openpype/tools/utils/multiselection_combobox.py @@ -266,12 +266,14 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def _update_size_hint(self): if self._custom_text is not None: self.update() + self.repaint() return self._lines = {} items = self.checked_items_text() if not items: self.update() + self.repaint() return option = QtWidgets.QStyleOptionComboBox() @@ -311,6 +313,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.update() self.updateGeometry() + self.repaint() def sizeHint(self): value = super(MultiSelectionComboBox, self).sizeHint() From b412e221646b56b57a911f9bab312756d360f640 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Aug 2023 11:17:25 +0200 Subject: [PATCH 169/291] remove multiselection combobox from settings --- .../tools/settings/settings/item_widgets.py | 2 +- .../settings/multiselection_combobox.py | 356 ------------------ 2 files changed, 1 insertion(+), 357 deletions(-) delete mode 100644 openpype/tools/settings/settings/multiselection_combobox.py diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 117eca7d6b..2fd13cbbd8 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -4,6 +4,7 @@ from qtpy import QtWidgets, QtCore, QtGui from openpype.widgets.sliders import NiceSlider from openpype.tools.settings import CHILD_OFFSET +from openpype.tools.utils import MultiSelectionComboBox from openpype.settings.entities.exceptions import BaseInvalidValue from .widgets import ( @@ -15,7 +16,6 @@ from .widgets import ( SettingsNiceCheckbox, SettingsLineEdit ) -from .multiselection_combobox import MultiSelectionComboBox from .wrapper_widgets import ( WrapperWidget, CollapsibleWrapper, diff --git a/openpype/tools/settings/settings/multiselection_combobox.py b/openpype/tools/settings/settings/multiselection_combobox.py deleted file mode 100644 index d64fc83745..0000000000 --- a/openpype/tools/settings/settings/multiselection_combobox.py +++ /dev/null @@ -1,356 +0,0 @@ -from qtpy import QtCore, QtGui, QtWidgets -from openpype.tools.utils.lib import ( - checkstate_int_to_enum, - checkstate_enum_to_int, -) -from openpype.tools.utils.constants import ( - CHECKED_INT, - UNCHECKED_INT, - ITEM_IS_USER_TRISTATE, -) - - -class ComboItemDelegate(QtWidgets.QStyledItemDelegate): - """ - Helper styled delegate (mostly based on existing private Qt's - delegate used by the QtWidgets.QComboBox). Used to style the popup like a - list view (e.g windows style). - """ - - def paint(self, painter, option, index): - option = QtWidgets.QStyleOptionViewItem(option) - option.showDecorationSelected = True - - # option.state &= ( - # ~QtWidgets.QStyle.State_HasFocus - # & ~QtWidgets.QStyle.State_MouseOver - # ) - super(ComboItemDelegate, self).paint(painter, option, index) - - -class MultiSelectionComboBox(QtWidgets.QComboBox): - value_changed = QtCore.Signal() - focused_in = QtCore.Signal() - - ignored_keys = { - QtCore.Qt.Key_Up, - QtCore.Qt.Key_Down, - QtCore.Qt.Key_PageDown, - QtCore.Qt.Key_PageUp, - QtCore.Qt.Key_Home, - QtCore.Qt.Key_End, - } - - top_bottom_padding = 2 - left_right_padding = 3 - left_offset = 4 - top_bottom_margins = 2 - item_spacing = 5 - - item_bg_color = QtGui.QColor("#31424e") - - def __init__( - self, parent=None, placeholder="", separator=", ", **kwargs - ): - super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) - self.setObjectName("MultiSelectionComboBox") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - self._popup_is_shown = False - self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) - self._initial_mouse_pos = None - self._separator = separator - self.placeholder_text = placeholder - self.delegate = ComboItemDelegate(self) - self.setItemDelegate(self.delegate) - - self.lines = {} - self.item_height = None - - def focusInEvent(self, event): - self.focused_in.emit() - return super(MultiSelectionComboBox, self).focusInEvent(event) - - def mousePressEvent(self, event): - """Reimplemented.""" - self._popup_is_shown = False - super(MultiSelectionComboBox, self).mousePressEvent(event) - if self._popup_is_shown: - self._initial_mouse_pos = self.mapToGlobal(event.pos()) - self._block_mouse_release_timer.start( - QtWidgets.QApplication.doubleClickInterval() - ) - - def showPopup(self): - """Reimplemented.""" - super(MultiSelectionComboBox, self).showPopup() - view = self.view() - view.installEventFilter(self) - view.viewport().installEventFilter(self) - self._popup_is_shown = True - - def hidePopup(self): - """Reimplemented.""" - self.view().removeEventFilter(self) - self.view().viewport().removeEventFilter(self) - self._popup_is_shown = False - self._initial_mouse_pos = None - super(MultiSelectionComboBox, self).hidePopup() - self.view().clearFocus() - - def _event_popup_shown(self, obj, event): - if not self._popup_is_shown: - return - - current_index = self.view().currentIndex() - model = self.model() - - if event.type() == QtCore.QEvent.MouseMove: - if ( - self.view().isVisible() - and self._initial_mouse_pos is not None - and self._block_mouse_release_timer.isActive() - ): - diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos - if diff.manhattanLength() > 9: - self._block_mouse_release_timer.stop() - return - - index_flags = current_index.flags() - state = checkstate_int_to_enum( - current_index.data(QtCore.Qt.CheckStateRole) - ) - new_state = None - - if event.type() == QtCore.QEvent.MouseButtonRelease: - if ( - self._block_mouse_release_timer.isActive() - or not current_index.isValid() - or not self.view().isVisible() - or not self.view().rect().contains(event.pos()) - or not index_flags & QtCore.Qt.ItemIsSelectable - or not index_flags & QtCore.Qt.ItemIsEnabled - or not index_flags & QtCore.Qt.ItemIsUserCheckable - ): - return - - if state == QtCore.Qt.Unchecked: - new_state = CHECKED_INT - else: - new_state = UNCHECKED_INT - - elif event.type() == QtCore.QEvent.KeyPress: - # TODO: handle QtCore.Qt.Key_Enter, Key_Return? - if event.key() == QtCore.Qt.Key_Space: - if ( - index_flags & QtCore.Qt.ItemIsUserCheckable - and index_flags & ITEM_IS_USER_TRISTATE - ): - new_state = (checkstate_enum_to_int(state) + 1) % 3 - - elif index_flags & QtCore.Qt.ItemIsUserCheckable: - # toggle the current items check state - if state != QtCore.Qt.Checked: - new_state = CHECKED_INT - else: - new_state = UNCHECKED_INT - - if new_state is not None: - model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) - self.view().update(current_index) - self.update_size_hint() - self.value_changed.emit() - return True - - def eventFilter(self, obj, event): - """Reimplemented.""" - result = self._event_popup_shown(obj, event) - if result is not None: - return result - - return super(MultiSelectionComboBox, self).eventFilter(obj, event) - - def addItem(self, *args, **kwargs): - idx = self.count() - super(MultiSelectionComboBox, self).addItem(*args, **kwargs) - self.model().item(idx).setCheckable(True) - - def paintEvent(self, event): - """Reimplemented.""" - painter = QtWidgets.QStylePainter(self) - option = QtWidgets.QStyleOptionComboBox() - self.initStyleOption(option) - painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) - - # draw the icon and text - items = self.checked_items_text() - if not items: - option.currentText = self.placeholder_text - option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) - painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) - return - - font_metricts = self.fontMetrics() - - if self.item_height is None: - self.updateGeometry() - self.update() - return - - for line, items in self.lines.items(): - top_y = ( - option.rect.top() - + (line * self.item_height) - + self.top_bottom_margins - ) - left_x = option.rect.left() + self.left_offset - for item in items: - label_rect = font_metricts.boundingRect(item) - label_height = label_rect.height() - - label_rect.moveTop(top_y) - label_rect.moveLeft(left_x) - label_rect.setHeight(self.item_height) - label_rect.setWidth( - label_rect.width() + self.left_right_padding - ) - - bg_rect = QtCore.QRectF(label_rect) - bg_rect.setWidth( - label_rect.width() + self.left_right_padding - ) - left_x = bg_rect.right() + self.item_spacing - - label_rect.moveLeft(label_rect.x() + self.left_right_padding) - - bg_rect.setHeight(label_height + (2 * self.top_bottom_padding)) - bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) - - path = QtGui.QPainterPath() - path.addRoundedRect(bg_rect, 5, 5) - - painter.fillPath(path, self.item_bg_color) - - painter.drawText( - label_rect, - QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, - item - ) - - def resizeEvent(self, *args, **kwargs): - super(MultiSelectionComboBox, self).resizeEvent(*args, **kwargs) - self.update_size_hint() - - def update_size_hint(self): - self.lines = {} - - items = self.checked_items_text() - if not items: - self.update() - return - - option = QtWidgets.QStyleOptionComboBox() - self.initStyleOption(option) - btn_rect = self.style().subControlRect( - QtWidgets.QStyle.CC_ComboBox, - option, - QtWidgets.QStyle.SC_ComboBoxArrow - ) - total_width = option.rect.width() - btn_rect.width() - - line = 0 - self.lines = {line: []} - - font_metricts = self.fontMetrics() - default_left_x = 0 + self.left_offset - left_x = int(default_left_x) - for item in items: - rect = font_metricts.boundingRect(item) - width = rect.width() + (2 * self.left_right_padding) - right_x = left_x + width - if right_x > total_width: - left_x = int(default_left_x) - if self.lines.get(line): - line += 1 - self.lines[line] = [item] - left_x += width - else: - self.lines[line] = [item] - line += 1 - else: - if line in self.lines: - self.lines[line].append(item) - else: - self.lines[line] = [item] - left_x = left_x + width + self.item_spacing - - self.update() - self.updateGeometry() - - def sizeHint(self): - value = super(MultiSelectionComboBox, self).sizeHint() - lines = len(self.lines) - if lines == 0: - lines = 1 - - if self.item_height is None: - self.item_height = ( - self.fontMetrics().height() - + (2 * self.top_bottom_padding) - + (2 * self.top_bottom_margins) - ) - value.setHeight( - (lines * self.item_height) - + (2 * self.top_bottom_margins) - ) - return value - - def setItemCheckState(self, index, state): - self.setItemData(index, state, QtCore.Qt.CheckStateRole) - - def set_value(self, values): - for idx in range(self.count()): - value = self.itemData(idx, role=QtCore.Qt.UserRole) - if value in values: - check_state = CHECKED_INT - else: - check_state = UNCHECKED_INT - self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) - self.update_size_hint() - - def value(self): - items = list() - for idx in range(self.count()): - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - if state == QtCore.Qt.Checked: - items.append( - self.itemData(idx, role=QtCore.Qt.UserRole) - ) - return items - - def checked_items_text(self): - items = list() - for idx in range(self.count()): - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - if state == QtCore.Qt.Checked: - items.append(self.itemText(idx)) - return items - - def wheelEvent(self, event): - event.ignore() - - def keyPressEvent(self, event): - if ( - event.key() == QtCore.Qt.Key_Down - and event.modifiers() & QtCore.Qt.AltModifier - ): - return self.showPopup() - - if event.key() in self.ignored_keys: - return event.ignore() - - return super(MultiSelectionComboBox, self).keyPressEvent(event) From cbc622c46f52fb784c6dc8c49d37c3b3d65f0c85 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Aug 2023 11:23:38 +0200 Subject: [PATCH 170/291] update docstrings --- openpype/lib/attribute_definitions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 44aa8dee48..a71709cace 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -427,9 +427,12 @@ class EnumDef(AbstractAttrDef): """Enumeration of single item from items. Args: - items: Items definition that can be converted using - 'prepare_enum_items'. - default: Default value. Must be one key(value) from passed items. + items (Union[list[str], list[dict[str, Any]]): Items definition that + can be converted using 'prepare_enum_items'. + default (Optional[Any]): Default value. Must be one key(value) from + passed items or list of values for multiselection. + multiselection (Optional[bool]): If True, multiselection is allowed. + Output is list of selected items. """ type = "enum" From 84e89aa422728daaac3f2550358cdc18d97a89f7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Aug 2023 11:39:07 +0200 Subject: [PATCH 171/291] Webpublisher: better encode list values for click (#5546) * Fix - list of arguments must be provided differently Targets could be a list, original implementation pushed it as a comma separated, it must be separated by space for click to understand. * Fix - split by space targets might come as a tuple from command line * Fix - better providing of arguments * Revert "Fix - split by space" This reverts commit 23884ac1c544e2d8003d82423a0da8b83821d426. --- .../webserver_service/webpublish_routes.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index e56f245d27..20d585e906 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -280,13 +280,14 @@ class BatchPublishEndpoint(WebpublishApiEndpoint): for key, value in add_args.items(): # Skip key values where value is None - if value is not None: - args.append("--{}".format(key)) - # Extend list into arguments (targets can be a list) - if isinstance(value, (tuple, list)): - args.extend(value) - else: - args.append(value) + if value is None: + continue + arg_key = "--{}".format(key) + if not isinstance(value, (tuple, list)): + value = [value] + + for item in value: + args += [arg_key, item] log.info("args:: {}".format(args)) if add_to_queue: From d3c1b84835aed4579ef7909e52631ab0732d6796 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 12:07:23 +0200 Subject: [PATCH 172/291] fixing paths slashes in nuke api --- openpype/hosts/nuke/api/lib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 2a6c1fb12c..d6a5e67ba0 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2107,8 +2107,9 @@ class WorkfileSettings(object): # set ocio config path if config_data: + config_path = config_data["path"].replace("\\", "/") log.info("OCIO config path found: `{}`".format( - config_data["path"])) + config_path)) # check if there's a mismatch between environment and settings correct_settings = self._is_settings_matching_environment( @@ -2233,7 +2234,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. Returns: str: OCIO config path with environment variable TCL expression """ - config_path = config_data["path"] + config_path = config_data["path"].replace("\\", "/") config_template = config_data["template"] included_vars = self._get_included_vars(config_template) From e9c3a84916a87ef8df717b4f75b698f55b7cd5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 31 Aug 2023 12:11:22 +0200 Subject: [PATCH 173/291] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/api/lib.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index cedbe6d5e6..157300d150 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3371,10 +3371,9 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): Returns: str: viewer config string """ - if display: - if path_like: - return "{}/{}".format(display, viewer) - else: - return "{} ({})".format(viewer, display) - else: + if not display: return viewer + + if path_like: + return "{}/{}".format(display, viewer) + return "{} ({})".format(viewer, display) From 33e18661241a59a612a06558fbc19f5de264220b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 13:30:33 +0200 Subject: [PATCH 174/291] separating code into own function for monitor lut settings --- openpype/hosts/nuke/api/lib.py | 64 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 157300d150..95e945057c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2092,30 +2092,14 @@ class WorkfileSettings(object): workfile_settings.pop("colorManagement", None) workfile_settings.pop("OCIO_config", None) - # treat monitor lut separately + # get monitor lut from settings respecting Nuke version differences monitor_lut = workfile_settings.pop("monitorLut", None) - m_display, m_viewer = get_viewer_config_from_string(monitor_lut) - v_display, v_viewer = get_viewer_config_from_string( - viewer_process_settings - ) + monitor_lut_data = self._get_monitor_settings( + viewer_process_settings, monitor_lut) - # set monitor lut differently for nuke version 14 - if nuke.NUKE_VERSION_MAJOR >= 14: - workfile_settings["monitorOutLUT"] = create_viewer_profile_string( - m_viewer, m_display, path_like=False) - # monitorLut=thumbnails - viewerProcess makes more sense - workfile_settings["monitorLut"] = create_viewer_profile_string( - v_viewer, v_display, path_like=False) - - if nuke.NUKE_VERSION_MAJOR == 13: - workfile_settings["monitorOutLUT"] = create_viewer_profile_string( - m_viewer, m_display, path_like=False) - # monitorLut=thumbnails - viewerProcess makes more sense - workfile_settings["monitorLut"] = create_viewer_profile_string( - v_viewer, v_display, path_like=True) - if nuke.NUKE_VERSION_MAJOR <= 12: - workfile_settings["monitorLut"] = create_viewer_profile_string( - m_viewer, m_display, path_like=True) + # set monitor related knobs luts (MonitorOut, Thumbnails) + for knob, value_ in monitor_lut_data.items(): + workfile_settings[knob] = value_ # then set the rest for knob, value_ in workfile_settings.items(): @@ -2144,6 +2128,42 @@ class WorkfileSettings(object): if correct_settings: self._set_ocio_config_path_to_workfile(config_data) + def _get_monitor_settings(self, viewer_lut, monitor_lut): + """ Get monitor settings from viewer and monitor lut + + Args: + viewer_lut (str): viewer lut string + monitor_lut (str): monitor lut string + + Returns: + dict: monitor settings + """ + output_data = {} + m_display, m_viewer = get_viewer_config_from_string(monitor_lut) + v_display, v_viewer = get_viewer_config_from_string( + viewer_lut + ) + + # set monitor lut differently for nuke version 14 + if nuke.NUKE_VERSION_MAJOR >= 14: + output_data["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + output_data["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=False) + + if nuke.NUKE_VERSION_MAJOR == 13: + output_data["monitorOutLUT"] = create_viewer_profile_string( + m_viewer, m_display, path_like=False) + # monitorLut=thumbnails - viewerProcess makes more sense + output_data["monitorLut"] = create_viewer_profile_string( + v_viewer, v_display, path_like=True) + if nuke.NUKE_VERSION_MAJOR <= 12: + output_data["monitorLut"] = create_viewer_profile_string( + m_viewer, m_display, path_like=True) + + return output_data + def _is_settings_matching_environment(self, config_data): """ Check if OCIO config path is different from environment From 82b50dcfc2698e60b1ffb45723a4112a0865f8e4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 14:00:13 +0200 Subject: [PATCH 175/291] adding comments --- .../plugins/publish/validate_rendered_frames.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index ef3d4d0bb5..e316c6ff6e 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -17,18 +17,23 @@ class RepairActionBase(pyblish.api.Action): def repair_knob(self, context, instances, state): create_context = context.data["create_context"] for instance in instances: - files_remove = [ + files_to_remove = [ + # create full path to file os.path.join(instance.data["outputDir"], f_) + # iterate representations from instance data for r_ in instance.data.get("representations", []) + # make sure that the representation has files in list + if r_.get("files") and isinstance(r_.get("files"), list) + # iterate files from representation files list for f_ in r_.get("files", []) ] - self.log.info("Files to be removed: {}".format(files_remove)) - for f_ in files_remove: + self.log.info("Files to be removed: {}".format(files_to_remove)) + for f_ in files_to_remove: os.remove(f_) self.log.debug("removing file: {}".format(f_)) # Reset the render knob - instance_id = instance.data["instance_id"] + instance_id = instance.data.get("instance_id") created_instance = create_context.get_instance_by_id( instance_id ) From 1a3c8ad77252ffa9217c6af98ba27dcdd56c366a Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 31 Aug 2023 17:34:42 +0300 Subject: [PATCH 176/291] make validateReviewColorspace optional --- .../plugins/publish/validate_review_colorspace.py | 10 ++++++++-- .../defaults/project_settings/houdini.json | 5 +++++ .../schemas/schema_houdini_publish.json | 6 +++++- .../houdini/server/settings/publish_plugins.py | 14 +++++++++++--- server_addon/houdini/server/version.py | 2 +- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 2c7420bf48..f680f62142 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- import pyblish.api -from openpype.pipeline import PublishValidationError +from openpype.pipeline import ( + PublishValidationError, + OptionalPyblishPluginMixin +) from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa @@ -14,7 +17,8 @@ class SetDefaultViewSpaceAction(RepairAction): icon = "mdi.monitor" -class ValidateReviewColorspace(pyblish.api.InstancePlugin): +class ValidateReviewColorspace(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): """Validate Review Colorspace parameters. It checks if 'OCIO Colorspace' parameter was set to valid value. @@ -26,6 +30,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin): label = "Validate Review Colorspace" actions = [SetDefaultViewSpaceAction, SelectROPAction] + optional = True + def process(self, instance): invalid = self.get_invalid(instance) if invalid: diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 9d047c28bd..93d5c50d5e 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -93,6 +93,11 @@ "$JOB" ] }, + "ValidateReviewColorspace": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateContainers": { "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 aa6eaf5164..b57089007e 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 @@ -40,6 +40,10 @@ "type": "schema_template", "name": "template_publish_plugin", "template_data": [ + { + "key": "ValidateReviewColorspace", + "label": "Validate Review Colorspace" + }, { "key": "ValidateContainers", "label": "ValidateContainers" @@ -47,4 +51,4 @@ ] } ] -} \ 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..4534d8d0d9 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -120,7 +120,7 @@ class ValidateWorkfilePathsModel(BaseSettingsModel): ) -class ValidateContainersModel(BaseSettingsModel): +class BasicValidateModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") optional: bool = Field(title="Optional") active: bool = Field(title="Active") @@ -130,8 +130,11 @@ class PublishPluginsModel(BaseSettingsModel): ValidateWorkfilePaths: ValidateWorkfilePathsModel = Field( default_factory=ValidateWorkfilePathsModel, title="Validate workfile paths settings.") - ValidateContainers: ValidateContainersModel = Field( - default_factory=ValidateContainersModel, + ValidateReviewColorspace: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Review Colorspace.") + ValidateContainers: BasicValidateModel = Field( + default_factory=BasicValidateModel, title="Validate Latest Containers.") @@ -148,6 +151,11 @@ DEFAULT_HOUDINI_PUBLISH_SETTINGS = { "$JOB" ] }, + "ValidateReviewColorspace": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateContainers": { "enabled": True, "optional": 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 4d94bec464349db6e5277ae9d9615f15bf73e991 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 31 Aug 2023 22:55:59 +0800 Subject: [PATCH 177/291] oscar's comment on cameras implemenetation --- openpype/hosts/max/plugins/publish/collect_render.py | 4 +--- .../modules/deadline/plugins/publish/submit_max_deadline.py | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index e7730369b6..8ee2f43103 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -35,9 +35,7 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() - instance.data["cameras"] = None - if camera: - instance.data["cameras"] = [camera.name] + instance.data["cameras"] = [camera.name] if camera else None # noqa if "expectedFiles" not in instance.data: instance.data["expectedFiles"] = list() diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 6b42270d05..01132662d4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -31,7 +31,6 @@ class MaxPluginInfo(object): Version = attr.ib(default=None) # Mandatory for Deadline SaveFile = attr.ib(default=True) IgnoreInputs = attr.ib(default=True) - Camera = attr.ib(default=None) class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, @@ -155,8 +154,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, SceneFile=self.scene_path, Version=instance.data["maxversion"], SaveFile=True, - IgnoreInputs=True, - Camera=instance.data["cameras"][0] + IgnoreInputs=True ) plugin_payload = attr.asdict(plugin_info) @@ -242,6 +240,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "separateAovFiles") if instance.data["cameras"]: plugin_info["Camera0"] = None + plugin_info["Camera"] = instance.data["cameras"][0] plugin_info["Camera1"] = instance.data["cameras"][0] self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) From e2e3bb3a68309c13e9f53fb5713baea6df6704dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:23:38 +0200 Subject: [PATCH 178/291] AYON: Fill entities during editorial (2) (#5549) * fix appending of instances by asset name * skip disabled instances * formatting fix --- openpype/plugins/publish/extract_hierarchy_to_ayon.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_hierarchy_to_ayon.py b/openpype/plugins/publish/extract_hierarchy_to_ayon.py index de9a70c233..36a7042ba5 100644 --- a/openpype/plugins/publish/extract_hierarchy_to_ayon.py +++ b/openpype/plugins/publish/extract_hierarchy_to_ayon.py @@ -42,13 +42,16 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): def _fill_instance_entities(self, context, project_name): instances_by_asset_name = collections.defaultdict(list) for instance in context: + if instance.data.get("publish") is False: + continue + instance_entity = instance.data.get("assetEntity") if instance_entity: continue # Skip if instance asset does not match instance_asset_name = instance.data.get("asset") - instances_by_asset_name[instance_asset_name] = instance + instances_by_asset_name[instance_asset_name].append(instance) project_doc = context.data["projectEntity"] asset_docs = get_assets( From cf8919dbc5336e29e121020a7506d70277386834 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:24:52 +0200 Subject: [PATCH 179/291] AYON: Update settings (#5544) * added 3dsmax settings conversion * added conversion of reposition knobs * fixed matching defaults * updated core settings * updated aftereffects settings model --- openpype/settings/ayon_settings.py | 29 ++++++++++ .../server/settings/creator_plugins.py | 2 +- .../aftereffects/server/settings/main.py | 2 +- server_addon/aftereffects/server/version.py | 2 +- server_addon/core/server/settings/main.py | 53 +++++++++++++++++-- server_addon/core/server/version.py | 2 +- .../max/server/settings/render_settings.py | 2 +- server_addon/maya/server/settings/creators.py | 4 +- .../server/settings/simple_creators.py | 17 ++++++ 9 files changed, 104 insertions(+), 9 deletions(-) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 50abfe4839..9a4f0607e0 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -616,6 +616,23 @@ def _convert_maya_project_settings(ayon_settings, output): output["maya"] = ayon_maya +def _convert_3dsmax_project_settings(ayon_settings, output): + if "max" not in ayon_settings: + return + + ayon_max = ayon_settings["max"] + _convert_host_imageio(ayon_max) + if "PointCloud" in ayon_max: + point_cloud_attribute = ayon_max["PointCloud"]["attribute"] + new_point_cloud_attribute = { + item["name"]: item["value"] + for item in point_cloud_attribute + } + ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + + output["max"] = ayon_max + + def _convert_nuke_knobs(knobs): new_knobs = [] for knob in knobs: @@ -737,6 +754,17 @@ def _convert_nuke_project_settings(ayon_settings, output): item_filter["subsets"] = item_filter.pop("product_names") item_filter["families"] = item_filter.pop("product_types") + reformat_nodes_config = item.get("reformat_nodes_config") or {} + reposition_nodes = reformat_nodes_config.get( + "reposition_nodes") or [] + + for reposition_node in reposition_nodes: + if "knobs" not in reposition_node: + continue + reposition_node["knobs"] = _convert_nuke_knobs( + reposition_node["knobs"] + ) + name = item.pop("name") new_review_data_outputs[name] = item ayon_publish["ExtractReviewDataMov"]["outputs"] = new_review_data_outputs @@ -1261,6 +1289,7 @@ def convert_project_settings(ayon_settings, default_settings): _convert_flame_project_settings(ayon_settings, output) _convert_fusion_project_settings(ayon_settings, output) _convert_maya_project_settings(ayon_settings, output) + _convert_3dsmax_project_settings(ayon_settings, output) _convert_nuke_project_settings(ayon_settings, output) _convert_hiero_project_settings(ayon_settings, output) _convert_photoshop_project_settings(ayon_settings, output) diff --git a/server_addon/aftereffects/server/settings/creator_plugins.py b/server_addon/aftereffects/server/settings/creator_plugins.py index ee52fadd40..9cb03b0b26 100644 --- a/server_addon/aftereffects/server/settings/creator_plugins.py +++ b/server_addon/aftereffects/server/settings/creator_plugins.py @@ -5,7 +5,7 @@ from ayon_server.settings import BaseSettingsModel class CreateRenderPlugin(BaseSettingsModel): mark_for_review: bool = Field(True, title="Review") - defaults: list[str] = Field( + default_variants: list[str] = Field( default_factory=list, title="Default Variants" ) diff --git a/server_addon/aftereffects/server/settings/main.py b/server_addon/aftereffects/server/settings/main.py index 04d2e51cc9..4edc46d259 100644 --- a/server_addon/aftereffects/server/settings/main.py +++ b/server_addon/aftereffects/server/settings/main.py @@ -40,7 +40,7 @@ DEFAULT_AFTEREFFECTS_SETTING = { "create": { "RenderCreator": { "mark_for_review": True, - "defaults": [ + "default_variants": [ "Main" ] } diff --git a/server_addon/aftereffects/server/version.py b/server_addon/aftereffects/server/version.py index a242f0e757..df0c92f1e2 100644 --- a/server_addon/aftereffects/server/version.py +++ b/server_addon/aftereffects/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/server_addon/core/server/settings/main.py b/server_addon/core/server/settings/main.py index d19d732e71..ca8f7e63ed 100644 --- a/server_addon/core/server/settings/main.py +++ b/server_addon/core/server/settings/main.py @@ -4,6 +4,7 @@ from ayon_server.settings import ( BaseSettingsModel, MultiplatformPathListModel, ensure_unique_names, + task_types_enum, ) from ayon_server.exceptions import BadRequestException @@ -38,13 +39,52 @@ class CoreImageIOConfigModel(BaseSettingsModel): class CoreImageIOBaseModel(BaseSettingsModel): activate_global_color_management: bool = Field( False, - title="Override global OCIO config" + title="Enable Color Management" ) ocio_config: CoreImageIOConfigModel = Field( - default_factory=CoreImageIOConfigModel, title="OCIO config" + default_factory=CoreImageIOConfigModel, + title="OCIO config" ) file_rules: CoreImageIOFileRulesModel = Field( - default_factory=CoreImageIOFileRulesModel, title="File Rules" + default_factory=CoreImageIOFileRulesModel, + title="File Rules" + ) + + +class VersionStartCategoryProfileModel(BaseSettingsModel): + _layout = "expanded" + host_names: list[str] = Field( + default_factory=list, + title="Host names" + ) + task_types: list[str] = Field( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + task_names: list[str] = Field( + default_factory=list, + title="Task names" + ) + product_types: list[str] = Field( + default_factory=list, + title="Product types" + ) + product_names: list[str] = Field( + default_factory=list, + title="Product names" + ) + version_start: int = Field( + 1, + title="Version Start", + ge=0 + ) + + +class VersionStartCategoryModel(BaseSettingsModel): + profiles: list[VersionStartCategoryProfileModel] = Field( + default_factory=list, + title="Profiles" ) @@ -61,6 +101,10 @@ class CoreSettings(BaseSettingsModel): default_factory=GlobalToolsModel, title="Tools" ) + version_start_category: VersionStartCategoryModel = Field( + default_factory=VersionStartCategoryModel, + title="Version start" + ) imageio: CoreImageIOBaseModel = Field( default_factory=CoreImageIOBaseModel, title="Color Management (ImageIO)" @@ -131,6 +175,9 @@ DEFAULT_VALUES = { "studio_code": "", "environments": "{}", "tools": DEFAULT_TOOLS_VALUES, + "version_start_category": { + "profiles": [] + }, "publish": DEFAULT_PUBLISH_VALUES, "project_folder_structure": json.dumps({ "__project_root__": { 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" diff --git a/server_addon/max/server/settings/render_settings.py b/server_addon/max/server/settings/render_settings.py index 6c236d9f12..c00cb5e436 100644 --- a/server_addon/max/server/settings/render_settings.py +++ b/server_addon/max/server/settings/render_settings.py @@ -44,6 +44,6 @@ class RenderSettingsModel(BaseSettingsModel): DEFAULT_RENDER_SETTINGS = { "default_render_image_folder": "renders/3dsmax", "aov_separator": "underscore", - "image_format": "png", + "image_format": "exr", "multipass": True } diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 9b97b92e59..11e2b8a36c 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -252,7 +252,9 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateUnrealSkeletalMesh": { "enabled": True, - "default_variants": [], + "default_variants": [ + "Main", + ], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { diff --git a/server_addon/traypublisher/server/settings/simple_creators.py b/server_addon/traypublisher/server/settings/simple_creators.py index 94d6602738..8335b9d34e 100644 --- a/server_addon/traypublisher/server/settings/simple_creators.py +++ b/server_addon/traypublisher/server/settings/simple_creators.py @@ -288,5 +288,22 @@ DEFAULT_SIMPLE_CREATORS = [ "allow_multiple_items": True, "allow_version_control": False, "extensions": [] + }, + { + "product_type": "audio", + "identifier": "", + "label": "Audio ", + "icon": "fa5s.file-audio", + "default_variants": [ + "Main" + ], + "description": "Audio product", + "detailed_description": "Audio files for review or final delivery", + "allow_sequences": False, + "allow_multiple_items": False, + "allow_version_control": False, + "extensions": [ + ".wav" + ] } ] From 13d1b407199e7012aa309a6072a405a2d35cf657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 31 Aug 2023 17:38:47 +0200 Subject: [PATCH 180/291] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/nuke/api/lib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 95e945057c..91a9294740 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2140,9 +2140,7 @@ class WorkfileSettings(object): """ output_data = {} m_display, m_viewer = get_viewer_config_from_string(monitor_lut) - v_display, v_viewer = get_viewer_config_from_string( - viewer_lut - ) + v_display, v_viewer = get_viewer_config_from_string(viewer_lut) # set monitor lut differently for nuke version 14 if nuke.NUKE_VERSION_MAJOR >= 14: From 1a936c155985e804f0ddc01e14a4573c56bbb8e7 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 31 Aug 2023 22:42:13 +0300 Subject: [PATCH 181/291] BigRoy's comments --- .../houdini/plugins/publish/validate_review_colorspace.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index f680f62142..3f5e5bc354 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -13,7 +13,7 @@ import hou class SetDefaultViewSpaceAction(RepairAction): - label = "Set default view space" + label = "Set default view colorspace" icon = "mdi.monitor" @@ -33,6 +33,10 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, optional = True def process(self, instance): + + if not self.is_active(instance.data): + return + invalid = self.get_invalid(instance) if invalid: raise PublishValidationError( From 24cbaefe953589b39f681bf4b93cc992cbb9fa19 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 22:29:18 +0200 Subject: [PATCH 182/291] removing redundant action it was residual approach from old workflow --- .../plugins/publish/validate_rendered_frames.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index e316c6ff6e..9a35b61a0e 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -17,29 +17,14 @@ class RepairActionBase(pyblish.api.Action): def repair_knob(self, context, instances, state): create_context = context.data["create_context"] for instance in instances: - files_to_remove = [ - # create full path to file - os.path.join(instance.data["outputDir"], f_) - # iterate representations from instance data - for r_ in instance.data.get("representations", []) - # make sure that the representation has files in list - if r_.get("files") and isinstance(r_.get("files"), list) - # iterate files from representation files list - for f_ in r_.get("files", []) - ] - self.log.info("Files to be removed: {}".format(files_to_remove)) - for f_ in files_to_remove: - os.remove(f_) - self.log.debug("removing file: {}".format(f_)) - # Reset the render knob instance_id = instance.data.get("instance_id") created_instance = create_context.get_instance_by_id( instance_id ) created_instance.creator_attributes["render_target"] = state - self.log.info("Rendering toggled to `{}`".format(state)) + create_context.save_changes() From ec8a0f49480c8359f6b501f73626d770c12fe931 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 22:44:36 +0200 Subject: [PATCH 183/291] simplification of spaghetti code and improving logic --- .../plugins/publish/collect_farm_target.py | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/openpype/plugins/publish/collect_farm_target.py b/openpype/plugins/publish/collect_farm_target.py index 78410835dd..adcd842b48 100644 --- a/openpype/plugins/publish/collect_farm_target.py +++ b/openpype/plugins/publish/collect_farm_target.py @@ -15,31 +15,21 @@ class CollectFarmTarget(pyblish.api.InstancePlugin): return context = instance.context - try: - deadline_module = context.data.get("openPypeModules")["deadline"] - if deadline_module.enabled: - instance.data["toBeRenderedOn"] = "deadline" - self.log.debug("Collected render target: deadline") - except AttributeError: - self.log.error("Cannot get OpenPype Deadline module.") - raise AssertionError("OpenPype Deadline module not found.") - try: - royalrender_module = \ - context.data.get("openPypeModules")["royalrender"] - if royalrender_module.enabled: - instance.data["toBeRenderedOn"] = "royalrender" - self.log.debug("Collected render target: royalrender") + farm_name = "" + op_modules = context.data.get("openPypeModules") - except AttributeError: - self.log.error("Cannot get OpenPype RoyalRender module.") - raise AssertionError("OpenPype RoyalRender module not found.") + for farm_renderer in ["deadline", "royalrender", "muster"]: + op_module = op_modules.get(farm_renderer, False) - try: - muster_module = context.data.get("openPypeModules")["muster"] - if muster_module.enabled: - instance.data["toBeRenderedOn"] = "muster" - self.log.debug("Collected render target: muster") - except AttributeError: - self.log.error("Cannot get OpenPype Muster module.") - raise AssertionError("OpenPype Muster module not found.") + if op_module and op_module.enabled: + farm_name = farm_renderer + elif not op_module: + self.log.error("Cannot get OpenPype {0} module.".format( + farm_renderer)) + + if farm_name: + self.log.debug("Collected render target: {0}".format(farm_name)) + instance.data["toBeRenderedOn"] = farm_name + else: + AssertionError("No OpenPype renderer module found") From d4393bad6b466438a839786903152fc36e293cae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 23:18:52 +0200 Subject: [PATCH 184/291] changing signature since we only need context.data for input. --- openpype/pipeline/colorspace.py | 39 ++++++-------------- openpype/pipeline/publish/publish_plugins.py | 8 +--- 2 files changed, 13 insertions(+), 34 deletions(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 649d355f62..b7728936b0 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -617,7 +617,7 @@ def get_colorspace_settings_from_publish_context(context_data): Returns: tuple | bool: config, file rules or None """ - if "imageioSettings" in context_data: + if "imageioSettings" in context_data and context_data["imageioSettings"]: return context_data["imageioSettings"] project_name = context_data["projectName"] @@ -631,14 +631,13 @@ def get_colorspace_settings_from_publish_context(context_data): anatomy_data=anatomy_data ) - # in case host color management is not enabled - if not config_data: - return None - - file_rules = get_imageio_file_rules( - project_name, host_name, - project_settings=project_settings_ - ) + # caching invalid state, so it's not recalculated all the time + file_rules = None + if config_data: + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings_ + ) # caching settings for future instance processing context_data["imageioSettings"] = (config_data, file_rules) @@ -649,7 +648,6 @@ def get_colorspace_settings_from_publish_context(context_data): def set_colorspace_data_to_representation( representation, context_data, colorspace=None, - colorspace_settings=None, log=None ): """Sets colorspace data to representation. @@ -657,12 +655,7 @@ def set_colorspace_data_to_representation( Args: representation (dict): publishing representation context_data (publish.Context.data): publishing context data - config_data (dict): host resolved config data - file_rules (dict): host resolved file rules data colorspace (str, optional): colorspace name. Defaults to None. - colorspace_settings (tuple[dict, dict], optional): - Settings for config_data and file_rules. - Defaults to None. log (logging.Logger, optional): logger instance. Defaults to None. Example: @@ -691,21 +684,13 @@ def set_colorspace_data_to_representation( ) return - if colorspace_settings is None: - colorspace_settings = get_colorspace_settings_from_publish_context( - context_data) + # get colorspace settings + config_data, file_rules = get_colorspace_settings_from_publish_context( + context_data) # in case host color management is not enabled - if not colorspace_settings: - log.warning("Host's colorspace management is disabled.") - return - - # unpack colorspace settings - config_data, file_rules = colorspace_settings - if not config_data: - # warn in case no colorspace path was defined - log.warning("No colorspace management was defined") + log.warning("Host's colorspace management is disabled.") return log.debug("Config data is: `{}`".format(config_data)) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index 17ede069cb..ae6cbc42d1 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -319,19 +319,13 @@ class ColormanagedPyblishPluginMixin(object): def set_representation_colorspace( self, representation, context, colorspace=None, - colorspace_settings=None ): """Sets colorspace data to representation. Args: representation (dict): publishing representation context (publish.Context): publishing context - config_data (dict): host resolved config data - file_rules (dict): host resolved file rules data colorspace (str, optional): colorspace name. Defaults to None. - colorspace_settings (tuple[dict, dict], optional): - Settings for config_data and file_rules. - Defaults to None. Example: ``` @@ -348,10 +342,10 @@ class ColormanagedPyblishPluginMixin(object): ``` """ + # using cached settings if available set_colorspace_data_to_representation( representation, context.data, colorspace, - colorspace_settings, log=self.log ) From 1f057525dd6dd2f3d03fbb97f68ee8504ce9aae5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Aug 2023 23:28:12 +0200 Subject: [PATCH 185/291] testing: fixing zip file ID --- tests/unit/openpype/pipeline/test_colorspace.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index c22acee2d4..ac35a28303 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -28,10 +28,9 @@ class TestPipelineColorspace(TestPipeline): cd to OpenPype repo root dir poetry run python ./start.py runtests ../tests/unit/openpype/pipeline """ - TEST_FILES = [ ( - "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", + "1csqimz8bbNcNgxtEXklLz6GRv91D3KgA", "test_pipeline_colorspace.zip", "" ) From 78b5ae149a89223cb2f5c8a59464510685a8d67a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 15:54:42 +0200 Subject: [PATCH 186/291] only project settings are passed by default to create plugin --- openpype/pipeline/create/context.py | 19 ++++++++++------- openpype/pipeline/create/creator_plugins.py | 23 ++++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 3076efcde7..e0e2c09f96 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -16,6 +16,7 @@ from openpype.settings import ( get_system_settings, get_project_settings ) +from openpype.lib import is_func_signature_supported from openpype.lib.attribute_definitions import ( UnknownDef, serialize_attr_defs, @@ -1774,7 +1775,7 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.plugins: if inspect.isabstract(creator_class): - self.log.info( + self.log.debug( "Skipping abstract Creator {}".format(str(creator_class)) ) continue @@ -1798,12 +1799,16 @@ class CreateContext: ).format(creator_class.host_name, self.host_name)) continue - creator = creator_class( - project_settings, - system_settings, - self, - self.headless - ) + if is_func_signature_supported( + creator_class, project_settings, self, self.headless + ): + creator = creator_class(project_settings, self, self.headless) + else: + # Backwards compatibility to pass system settings to creators + creator = creator_class( + project_settings, system_settings, self, self.headless + ) + if not creator.enabled: disabled_creators[creator_identifier] = creator continue diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 38d6b6f465..a1a6a73236 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -10,7 +10,7 @@ from abc import ( import six from openpype.settings import get_system_settings, get_project_settings -from openpype.lib import Logger +from openpype.lib import Logger, is_func_signature_supported from openpype.pipeline.plugin_discover import ( discover, register_plugin, @@ -161,7 +161,6 @@ class BaseCreator: Args: project_settings (Dict[str, Any]): Project settings. - system_settings (Dict[str, Any]): System settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ @@ -197,9 +196,7 @@ class BaseCreator: # QUESTION make this required? host_name = None - def __init__( - self, project_settings, system_settings, create_context, headless=False - ): + def __init__(self, project_settings, create_context, headless=False): # Reference to CreateContext self.create_context = create_context self.project_settings = project_settings @@ -208,10 +205,20 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless - self.apply_settings(project_settings, system_settings) + if is_func_signature_supported( + self.apply_settings, project_settings + ): + self.apply_settings(project_settings) + else: + # Backwards compatibility for system settings + self.apply_settings(project_settings, {}) - def apply_settings(self, project_settings, system_settings): - """Method called on initialization of plugin to apply settings.""" + def apply_settings(self, project_settings): + """Method called on initialization of plugin to apply settings. + + Args: + project_settings (dict[str, Any]): Project settings. + """ pass From 1f6df907eb4934d471dcbad96b10d1c08566d5ca Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 16:54:05 +0200 Subject: [PATCH 187/291] fixed typehints --- openpype/lib/python_module_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index a10263f991..bedf19562d 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -270,8 +270,8 @@ def is_func_signature_supported(func, *args, **kwargs): Args: func (function): A function where the signature should be tested. - *args (tuple[Any]): Positional arguments for function signature. - **kwargs (dict[str, Any]): Keyword arguments for function signature. + *args (Any): Positional arguments for function signature. + **kwargs (Any): Keyword arguments for function signature. Returns: bool: Function can pass in arguments. From fee3c950d3a728ef9d22bfc857eb14a15620ce5b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:00:14 +0200 Subject: [PATCH 188/291] removed deprecated 'abstractproperty' --- openpype/pipeline/create/creator_plugins.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index a1a6a73236..b38c1199e6 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,11 +1,7 @@ import copy import collections -from abc import ( - ABCMeta, - abstractmethod, - abstractproperty -) +from abc import ABCMeta, abstractmethod import six @@ -84,7 +80,8 @@ class SubsetConvertorPlugin(object): def host(self): return self._create_context.host - @abstractproperty + @property + @abstractmethod def identifier(self): """Converted identifier. @@ -231,7 +228,8 @@ class BaseCreator: return self.family - @abstractproperty + @property + @abstractmethod def family(self): """Family that plugin represents.""" From e2c60831c2a32e260615f9842e7ecf50109b48a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:07:28 +0200 Subject: [PATCH 189/291] apply settings in TVPaint does not expect system settings --- openpype/hosts/tvpaint/plugins/create/create_render.py | 2 +- openpype/hosts/tvpaint/plugins/create/create_review.py | 2 +- openpype/hosts/tvpaint/plugins/create/create_workfile.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index 2369c7329f..656ea5d80b 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -139,7 +139,7 @@ class CreateRenderlayer(TVPaintCreator): # - Mark by default instance for review mark_for_review = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_layer"] ) diff --git a/openpype/hosts/tvpaint/plugins/create/create_review.py b/openpype/hosts/tvpaint/plugins/create/create_review.py index 886dae7c39..7bb7510a8e 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_review.py +++ b/openpype/hosts/tvpaint/plugins/create/create_review.py @@ -12,7 +12,7 @@ class TVPaintReviewCreator(TVPaintAutoCreator): # Settings active_on_create = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_review"] ) diff --git a/openpype/hosts/tvpaint/plugins/create/create_workfile.py b/openpype/hosts/tvpaint/plugins/create/create_workfile.py index 41347576d5..c3982c0eca 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_workfile.py +++ b/openpype/hosts/tvpaint/plugins/create/create_workfile.py @@ -9,7 +9,7 @@ class TVPaintWorkfileCreator(TVPaintAutoCreator): label = "Workfile" icon = "fa.file-o" - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_workfile"] ) From 21d5d4cad2705edc8aadc593356a796a14a155e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:08:13 +0200 Subject: [PATCH 190/291] removed system settings usage from traypublisher --- .../hosts/traypublisher/plugins/create/create_movie_batch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py index 1bed07f785..3454b6e135 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py @@ -36,7 +36,7 @@ class BatchMovieCreator(TrayPublishCreator): # Position batch creator after simple creators order = 110 - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): creator_settings = ( project_settings["traypublisher"]["create"]["BatchMovieCreator"] ) From cb3ef02fc377ad375ebf2ccf061da735414d7703 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:10:07 +0200 Subject: [PATCH 191/291] fusion creator does not expect system settings --- openpype/hosts/fusion/plugins/create/create_saver.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 04898d0a45..344deffb42 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -250,11 +250,7 @@ class CreateSaver(NewCreator): label="Review", ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings From 09d31e81ef01fd94b64d6bcd14dde61960c44a91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:10:26 +0200 Subject: [PATCH 192/291] removed duplicated attribute --- openpype/hosts/fusion/plugins/create/create_saver.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 344deffb42..39edca4de3 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -30,10 +30,6 @@ class CreateSaver(NewCreator): instance_attributes = [ "reviewable" ] - default_variants = [ - "Main", - "Mask" - ] # TODO: This should be renamed together with Nuke so it is aligned temp_rendering_path_template = ( From 3ba7939222c920367a68153a46c8a4ded8684369 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:10:40 +0200 Subject: [PATCH 193/291] removed usage of system settings from after effects --- openpype/hosts/aftereffects/plugins/create/create_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index dcf424b44f..fbe600ae68 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -164,7 +164,7 @@ class RenderCreator(Creator): api.get_stub().rename_item(comp_id, new_comp_name) - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["aftereffects"]["create"]["RenderCreator"] ) From 539120f71861569dfce8e1e2bc39a87fe408f9aa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:11:21 +0200 Subject: [PATCH 194/291] removed usage of system settings from houdini --- openpype/hosts/houdini/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 70c837205e..730a627dc3 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -296,7 +296,7 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): """ return [hou.ropNodeTypeCategory()] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" settings_name = self.settings_name From cb3e83118b29e48b430da8a5eaf124e928bad3bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:14:39 +0200 Subject: [PATCH 195/291] maya create plugins do not expecte system settings --- openpype/hosts/maya/api/plugin.py | 2 +- openpype/hosts/maya/plugins/create/create_animation.py | 6 ++---- openpype/hosts/maya/plugins/create/create_render.py | 2 +- .../hosts/maya/plugins/create/create_unreal_skeletalmesh.py | 2 +- .../hosts/maya/plugins/create/create_unreal_staticmesh.py | 2 +- openpype/hosts/maya/plugins/create/create_vrayscene.py | 2 +- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 00d6602ef9..3f383fafb8 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -260,7 +260,7 @@ class MayaCreator(NewCreator, MayaCreatorBase): default=True) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" settings_name = self.settings_name diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index 214ac18aef..115c73c0d3 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -81,10 +81,8 @@ class CreateAnimation(plugin.MayaHiddenCreator): return defs - def apply_settings(self, project_settings, system_settings): - super(CreateAnimation, self).apply_settings( - project_settings, system_settings - ) + def apply_settings(self, project_settings): + super(CreateAnimation, self).apply_settings(project_settings) # Hardcoding creator to be enabled due to existing settings would # disable the creator causing the creator plugin to not be # discoverable. diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index cc5c1eb205..6266689af4 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -34,7 +34,7 @@ class CreateRenderlayer(plugin.RenderlayerCreator): render_settings = {} @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py index 4e2a99eced..3c9a79156a 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py @@ -21,7 +21,7 @@ class CreateUnrealSkeletalMesh(plugin.MayaCreator): # Defined in settings joint_hints = set() - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["create"]["CreateUnrealSkeletalMesh"] diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index 3f96d91a54..025b39fa55 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -16,7 +16,7 @@ class CreateUnrealStaticMesh(plugin.MayaCreator): # Defined in settings collision_prefixes = [] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): """Apply project settings to creator""" settings = project_settings["maya"]["create"]["CreateUnrealStaticMesh"] self.collision_prefixes = settings["collision_prefixes"] diff --git a/openpype/hosts/maya/plugins/create/create_vrayscene.py b/openpype/hosts/maya/plugins/create/create_vrayscene.py index d601dceb54..2726979d30 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayscene.py +++ b/openpype/hosts/maya/plugins/create/create_vrayscene.py @@ -22,7 +22,7 @@ class CreateVRayScene(plugin.RenderlayerCreator): singleton_node_name = "vraysceneMain" @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): cls.render_settings = project_settings["maya"]["RenderSettings"] def create(self, subset_name, instance_data, pre_create_data): From 9338c163057d6c644bb6e113b51ed05170f8a951 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:18:10 +0200 Subject: [PATCH 196/291] photoshop create plugins do not expect system settings --- openpype/hosts/photoshop/plugins/create/create_flatten_image.py | 2 +- openpype/hosts/photoshop/plugins/create/create_image.py | 2 +- openpype/hosts/photoshop/plugins/create/create_review.py | 2 +- openpype/hosts/photoshop/plugins/create/create_workfile.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index 3bc61c8184..9d4189a1a3 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -98,7 +98,7 @@ class AutoImageCreator(PSAutoCreator): ) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["AutoImageCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index f3165fca57..8d3ac9f459 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -171,7 +171,7 @@ class ImageCreator(Creator): ) ] - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ImageCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_review.py b/openpype/hosts/photoshop/plugins/create/create_review.py index 064485d465..63751d94e4 100644 --- a/openpype/hosts/photoshop/plugins/create/create_review.py +++ b/openpype/hosts/photoshop/plugins/create/create_review.py @@ -18,7 +18,7 @@ class ReviewCreator(PSAutoCreator): it will get recreated in next publish either way). """ - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["ReviewCreator"] ) diff --git a/openpype/hosts/photoshop/plugins/create/create_workfile.py b/openpype/hosts/photoshop/plugins/create/create_workfile.py index d498f0549c..1b255de3a3 100644 --- a/openpype/hosts/photoshop/plugins/create/create_workfile.py +++ b/openpype/hosts/photoshop/plugins/create/create_workfile.py @@ -19,7 +19,7 @@ class WorkfileCreator(PSAutoCreator): in next publish automatically). """ - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["photoshop"]["create"]["WorkfileCreator"] ) From 7441d2617ab118db431b0ce57079ea25ffd8209b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:19:30 +0200 Subject: [PATCH 197/291] add missing tvpaint creators --- openpype/hosts/tvpaint/plugins/create/create_render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render.py b/openpype/hosts/tvpaint/plugins/create/create_render.py index 656ea5d80b..b7a7c208d9 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render.py @@ -387,7 +387,7 @@ class CreateRenderPass(TVPaintCreator): # Settings mark_for_review = True - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_pass"] ) @@ -690,7 +690,7 @@ class TVPaintAutoDetectRenderCreator(TVPaintCreator): group_idx_offset = 10 group_idx_padding = 3 - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings ["tvpaint"] @@ -1029,7 +1029,7 @@ class TVPaintSceneRenderCreator(TVPaintAutoCreator): mark_for_review = True active_on_create = False - def apply_settings(self, project_settings, system_settings): + def apply_settings(self, project_settings): plugin_settings = ( project_settings["tvpaint"]["create"]["create_render_scene"] ) From ce3e9a52401fdd85a6574b6fafc0d23351e69135 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:19:39 +0200 Subject: [PATCH 198/291] nuke create plugins do not expect system settings --- openpype/hosts/nuke/api/plugin.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 6d48c09d60..a0e1525cd0 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -379,11 +379,7 @@ class NukeWriteCreator(NukeCreator): sys.exc_info()[2] ) - def apply_settings( - self, - project_settings, - system_settings - ): + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings.""" # plugin settings From 34d7a4f477c109ec419a2048d72cdc657bae7e51 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 1 Sep 2023 17:24:01 +0200 Subject: [PATCH 199/291] removed unnecessary line Co-authored-by: Roy Nieterau --- openpype/lib/attribute_definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index a71709cace..095b096255 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -482,7 +482,6 @@ class EnumDef(AbstractAttrDef): return copy.deepcopy(self.default) return list(self._item_values.intersection(value)) - def serialize(self): data = super(EnumDef, self).serialize() data["items"] = copy.deepcopy(self.items) From 5de86cb6df5c852b590a83f72d4b3bfe1cdf1ebb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 17:33:57 +0200 Subject: [PATCH 200/291] do not override scale factor rounding policy if has defined value through env variable --- openpype/tools/utils/lib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 2df46c1eae..723e71e7aa 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -170,8 +170,12 @@ def get_openpype_qt_app(): if attr is not None: QtWidgets.QApplication.setAttribute(attr) - if hasattr( - QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy" + policy = os.getenv("QT_SCALE_FACTOR_ROUNDING_POLICY") + if ( + hasattr( + QtWidgets.QApplication, "setHighDpiScaleFactorRoundingPolicy" + ) + and not policy ): QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy( QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough From 88ab56db12f21a6aabf558bf1c9b1ab8471a4011 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Sep 2023 18:16:54 +0200 Subject: [PATCH 201/291] backwards compatibility is kept with waring message --- openpype/pipeline/create/context.py | 16 ++++++---------- openpype/pipeline/create/creator_plugins.py | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index e0e2c09f96..f9e3f86652 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -16,7 +16,6 @@ from openpype.settings import ( get_system_settings, get_project_settings ) -from openpype.lib import is_func_signature_supported from openpype.lib.attribute_definitions import ( UnknownDef, serialize_attr_defs, @@ -1799,15 +1798,12 @@ class CreateContext: ).format(creator_class.host_name, self.host_name)) continue - if is_func_signature_supported( - creator_class, project_settings, self, self.headless - ): - creator = creator_class(project_settings, self, self.headless) - else: - # Backwards compatibility to pass system settings to creators - creator = creator_class( - project_settings, system_settings, self, self.headless - ) + creator = creator_class( + project_settings, + system_settings, + self, + self.headless + ) if not creator.enabled: disabled_creators[creator_identifier] = creator diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index b38c1199e6..bd9d1d16c3 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -193,7 +193,9 @@ class BaseCreator: # QUESTION make this required? host_name = None - def __init__(self, project_settings, create_context, headless=False): + def __init__( + self, project_settings, system_settings, create_context, headless=False + ): # Reference to CreateContext self.create_context = create_context self.project_settings = project_settings @@ -202,13 +204,26 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless + expect_system_settings = False if is_func_signature_supported( self.apply_settings, project_settings ): self.apply_settings(project_settings) else: + expect_system_settings = True # Backwards compatibility for system settings - self.apply_settings(project_settings, {}) + self.apply_settings(project_settings, system_settings) + + init_overriden = self.__class__.__init__ is not BaseCreator.__init__ + if init_overriden or expect_system_settings: + self.log.warning(( + "WARNING: Source - Create plugin {}." + " System settings argument will not be passed to" + " '__init__' and 'apply_settings' methods in future versions" + " of OpenPype. Planned version to drop the support" + " is 3.15.6 or 3.16.0. Please contact Ynput core team if you" + " need to keep system settings." + ).format(self.__class__.__name__)) def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings. From 1b8d8e68f2b7e41f3f141a3ad8570b49ae0d7be5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Sep 2023 18:23:45 +0200 Subject: [PATCH 202/291] Harmony: refresh code for current Deadline (#5493) * Explicitly set Python3 for Harmony OP plugin * Fix method call Without it it would always return True * Explicitly set render instance to farm * Added Harmony 22 executable This plugin might not be necessary anymore for current Harmonies (at least for 22 works original Harmony plugin in DL) * Removed logging * fix - remove explicit disabling of review instance.data["review"] could be False only if review shouldn't be explicitly done. This is not possible in old publisher. TODO must be implemented in NP. --- .../plugins/publish/collect_farm_render.py | 2 +- .../publish/submit_harmony_deadline.py | 2 +- .../HarmonyOpenPype/HarmonyOpenPype.param | 20 ++++++++++++++++++- .../HarmonyOpenPype/HarmonyOpenPype.py | 1 + 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 5daa93cddb..af825c052a 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -147,13 +147,13 @@ class CollectFarmRender(publish.AbstractCollectRender): attachTo=False, setMembers=[node], publish=info[4], - review=False, renderer=None, priority=50, name=node.split("/")[1], family="render.farm", families=["render.farm"], + farm=True, resolutionWidth=context.data["resolutionWidth"], resolutionHeight=context.data["resolutionHeight"], diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index 2c37268f04..16e703fc91 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -265,7 +265,7 @@ class HarmonySubmitDeadline( job_info.SecondaryPool = self._instance.data.get("secondaryPool") job_info.ChunkSize = self.chunk_size batch_name = os.path.basename(self._instance.data["source"]) - if is_in_tests: + if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") job_info.BatchName = batch_name job_info.Department = self.department diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param index ff2949766c..43a54a464e 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.param @@ -77,4 +77,22 @@ CategoryOrder=0 Index=4 Label=Harmony 20 Render Executable Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium \ No newline at end of file +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium + +[Harmony_RenderExecutable_21] +Type=multilinemultifilename +Category=Render Executables +CategoryOrder=0 +Index=4 +Label=Harmony 21 Render Executable +Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 21 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 21 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_21/lnx86_64/bin/HarmonyPremium + +[Harmony_RenderExecutable_22] +Type=multilinemultifilename +Category=Render Executables +CategoryOrder=0 +Index=4 +Label=Harmony 22 Render Executable +Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. +Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 22 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 22 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_22/lnx86_64/bin/HarmonyPremium diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py index 2f6e9cf379..32ed76b58d 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 from System import * from System.Diagnostics import * from System.IO import * From 869c6277ff1d89599086169926dfb48673c088c0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 21:03:09 +0300 Subject: [PATCH 203/291] BigRoy's comment --- .../publish/validate_review_colorspace.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 3f5e5bc354..e5d4756556 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -37,15 +37,15 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return - invalid = self.get_invalid(instance) - if invalid: + invalid_nodes, message = self.get_invalid_with_message(instance) + if invalid_nodes: raise PublishValidationError( - ("'OCIO Colorspace' parameter is not valid."), + message, title=self.label ) @classmethod - def get_invalid(cls, instance): + def get_invalid_with_message(cls, instance): rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: @@ -53,26 +53,31 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, "Default Houdini colorspace is used, " " skipping check.." ) - return + return None, None if rop_node.evalParm("colorcorrect") != 2: # any colorspace settings other than default requires # 'Color Correct' parm to be set to 'OpenColorIO' - rop_node.setParms({"colorcorrect": 2}) - cls.log.debug( - "'Color Correct' parm on '{}' has been set to" - " 'OpenColorIO'".format(rop_node) + error = ( + "'Color Correction' parm on '{}' ROP must be set to" + " 'OpenColorIO'".format(rop_node.path()) ) + return rop_node , error if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): - cls.log.error( - "'OCIO Colorspace' value on '{}' is not valid, " - "select a valid option from the dropdown menu." - .format(rop_node) + error = ( + "Invalid value: Colorspace name doesn't exist.\n" + "Check 'OCIO Colorspace' parameter on '{}' ROP" + .format(rop_node.path()) ) - return rop_node + return rop_node, error + + @classmethod + def get_invalid(cls, instance): + nodes, _ = cls.get_invalid_with_message(instance) + return nodes @classmethod def repair(cls, instance): @@ -84,6 +89,13 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, rop_node = hou.node(instance.data["instance_node"]) + if rop_node.evalParm("colorcorrect") != 2: + rop_node.setParms({"colorcorrect": 2}) + cls.log.debug( + "'Color Correction' parm on '{}' has been set to" + " 'OpenColorIO'".format(rop_node.path()) + ) + # Get default view colorspace name default_view_space = get_default_display_view_colorspace() From 087aca6d8b9377c054e623bc2f12f6b418a69aa0 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Fri, 1 Sep 2023 21:04:59 +0300 Subject: [PATCH 204/291] resolve hound --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index e5d4756556..47370678d0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -62,7 +62,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, "'Color Correction' parm on '{}' ROP must be set to" " 'OpenColorIO'".format(rop_node.path()) ) - return rop_node , error + return rop_node, error if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): From 0cf8f8f42dc0f3b348c55ee02d7e8cdc784838e2 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 2 Sep 2023 03:24:31 +0000 Subject: [PATCH 205/291] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 12f797228b..7de6fd752b 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.3" +__version__ = "3.16.5-nightly.4" From dc684b65f38ddeb6900343f35a8d005049307798 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Sep 2023 03:25:07 +0000 Subject: [PATCH 206/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 669bf391cd..83a4b6a8d4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.5-nightly.4 - 3.16.5-nightly.3 - 3.16.5-nightly.2 - 3.16.5-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.1 - 3.14.8 - 3.14.8-nightly.4 - - 3.14.8-nightly.3 validations: required: true - type: dropdown From c2b948d35fea1f0cf2f7146bb0085fc3bd2dfeef Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 11:09:47 +0300 Subject: [PATCH 207/291] BigRoy's comments --- .../publish/validate_review_colorspace.py | 27 ++++--------------- openpype/pipeline/colorspace.py | 7 +---- openpype/scripts/ocio_wrapper.py | 11 ++++---- 3 files changed, 11 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 47370678d0..d457a295c5 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -37,47 +37,30 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, if not self.is_active(instance.data): return - invalid_nodes, message = self.get_invalid_with_message(instance) - if invalid_nodes: - raise PublishValidationError( - message, - title=self.label - ) - - @classmethod - def get_invalid_with_message(cls, instance): - - rop_node = hou.node(instance.data["instance_node"]) if os.getenv("OCIO") is None: - cls.log.debug( + self.log.debug( "Default Houdini colorspace is used, " " skipping check.." ) - return None, None + return + rop_node = hou.node(instance.data["instance_node"]) if rop_node.evalParm("colorcorrect") != 2: # any colorspace settings other than default requires # 'Color Correct' parm to be set to 'OpenColorIO' - error = ( + raise PublishValidationError( "'Color Correction' parm on '{}' ROP must be set to" " 'OpenColorIO'".format(rop_node.path()) ) - return rop_node, error if rop_node.evalParm("ociocolorspace") not in \ hou.Color.ocio_spaces(): - error = ( + raise PublishValidationError( "Invalid value: Colorspace name doesn't exist.\n" "Check 'OCIO Colorspace' parameter on '{}' ROP" .format(rop_node.path()) ) - return rop_node, error - - @classmethod - def get_invalid(cls, instance): - nodes, _ = cls.get_invalid_with_message(instance) - return nodes @classmethod def repair(cls, instance): diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 3dd33d0425..e167e18cfb 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -648,15 +648,10 @@ def get_display_view_colorspace_subprocess(config_path, display, view): "--out_path", tmp_json_path, "--display", display, "--view", view - ] log.debug("Executing: {}".format(" ".join(args))) - process_kwargs = { - "logger": log - } - - run_openpype_process(*args, **process_kwargs) + run_openpype_process(*args, logger=log) # return default view colorspace name with open(tmp_json_path, "r") as f: diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index cae6e6975b..2c11bb7eeb 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -225,7 +225,7 @@ def get_display_view_colorspace_name(in_path, out_path, display, view): """Aggregate view colorspace name to file. - Wrapper command for processes without acces to OpenColorIO + Wrapper command for processes without access to OpenColorIO Args: in_path (str): config file path string @@ -239,15 +239,14 @@ def get_display_view_colorspace_name(in_path, out_path, --out_path= --display= --view= """ - json_path = Path(out_path) - out_data = _get_display_view_colorspace_name(in_path, - display, view) + display, + view) - with open(json_path, "w") as f: + with open(out_path, "w") as f: json.dump(out_data, f) - print(f"Display view colorspace saved to '{json_path}'") + print(f"Display view colorspace saved to '{out_path}'") if __name__ == '__main__': main() From 115dd54733c83744548643e720b108d00ef1e529 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 10:56:30 +0200 Subject: [PATCH 208/291] fix repaint when custom text changed --- openpype/tools/utils/multiselection_combobox.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/tools/utils/multiselection_combobox.py b/openpype/tools/utils/multiselection_combobox.py index 13b396a059..34361fca17 100644 --- a/openpype/tools/utils/multiselection_combobox.py +++ b/openpype/tools/utils/multiselection_combobox.py @@ -75,11 +75,11 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def set_placeholder_text(self, text): self._placeholder_text = text + self._update_size_hint() def set_custom_text(self, text): self._custom_text = text - self.update() - self.updateGeometry() + self._update_size_hint() def focusInEvent(self, event): self.focused_in.emit() @@ -266,7 +266,6 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): def _update_size_hint(self): if self._custom_text is not None: self.update() - self.repaint() return self._lines = {} @@ -313,7 +312,6 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self.update() self.updateGeometry() - self.repaint() def sizeHint(self): value = super(MultiSelectionComboBox, self).sizeHint() From 6b1d060a8133643cebbf92e8c3e1fe93c84fbed9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 11:00:00 +0200 Subject: [PATCH 209/291] updated docstring --- openpype/lib/attribute_definitions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 095b096255..a71d6cc72a 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -424,7 +424,10 @@ class TextDef(AbstractAttrDef): class EnumDef(AbstractAttrDef): - """Enumeration of single item from items. + """Enumeration of items. + + Enumeration of single item from items. Or list of items if multiselection + is enabled. Args: items (Union[list[str], list[dict[str, Any]]): Items definition that From 0f39ccf0168d8e24ffed0d12287db8a15d99a38f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 4 Sep 2023 11:05:27 +0200 Subject: [PATCH 210/291] Fix - files on representation cannot be single item list (#5545) Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"]. This would cause an issue in ExtractReview later. --- .../plugins/publish/validate_expected_and_rendered_files.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 9f1f7bc518..5d37e7357e 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -70,7 +70,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(job_expected_files) + # single item files must be string not list + repre["files"] = (sorted(job_expected_files) + if len(job_expected_files) > 1 else + list(job_expected_files)[0]) # Update the expected files expected_files = job_expected_files From 4ed278c0c885e22fdcf97c9a1b242428fee3ff05 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 12:18:03 +0300 Subject: [PATCH 211/291] BigRoy's comment --- .../houdini/plugins/publish/validate_review_colorspace.py | 3 +-- openpype/scripts/ocio_wrapper.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index d457a295c5..545d7b16b1 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -83,9 +83,8 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, default_view_space = get_default_display_view_colorspace() rop_node.setParms({"ociocolorspace": default_view_space}) - cls.log.debug( + cls.log.info( "'OCIO Colorspace' parm on '{}' has been set to " "the default view color space '{}'" .format(rop_node, default_view_space) - ) diff --git a/openpype/scripts/ocio_wrapper.py b/openpype/scripts/ocio_wrapper.py index 2c11bb7eeb..40553d30f2 100644 --- a/openpype/scripts/ocio_wrapper.py +++ b/openpype/scripts/ocio_wrapper.py @@ -195,7 +195,7 @@ def _get_display_view_colorspace_name(config_path, display, view): if not config_path.is_file(): raise IOError("Input path should be `config.ocio` file") - config = ocio.Config().CreateFromFile(str(config_path)) + config = ocio.Config.CreateFromFile(str(config_path)) colorspace = config.getDisplayViewColorSpaceName(display, view) return colorspace From 35074e8db892d07bfd43ea3860e660ab10843346 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 4 Sep 2023 11:49:14 +0200 Subject: [PATCH 212/291] Fix versions in deprecated message --- openpype/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index bd9d1d16c3..de9cc7cff3 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -221,7 +221,7 @@ class BaseCreator: " System settings argument will not be passed to" " '__init__' and 'apply_settings' methods in future versions" " of OpenPype. Planned version to drop the support" - " is 3.15.6 or 3.16.0. Please contact Ynput core team if you" + " is 3.16.6 or 3.17.0. Please contact Ynput core team if you" " need to keep system settings." ).format(self.__class__.__name__)) From 0c8ad276e409840ce665ecd7c8c3655a545cc500 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 18:02:32 +0800 Subject: [PATCH 213/291] Oscar's and BigRoy's comment respectively on namespace function --- openpype/hosts/max/api/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 034307e72a..8287341456 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -316,7 +316,6 @@ def set_timeline(frameStart, frameEnd): def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): - from pymxs import runtime as rt """Return unique namespace Arguments: @@ -336,7 +335,7 @@ def unique_namespace(namespace, format="%02d", def current_namespace(): current = namespace - # When inside a namespace Maya adds no trailing : + # When inside a namespace Max adds no trailing : if not current.endswith(":"): current += ":" return current From df2466b714f86336fad823bd9699aced271e5457 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Sep 2023 12:58:31 +0200 Subject: [PATCH 214/291] default fps for sequence from asset instead of project --- openpype/plugins/publish/collect_sequence_frame_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_sequence_frame_data.py b/openpype/plugins/publish/collect_sequence_frame_data.py index 241e7b9011..6c2bfbf358 100644 --- a/openpype/plugins/publish/collect_sequence_frame_data.py +++ b/openpype/plugins/publish/collect_sequence_frame_data.py @@ -52,5 +52,5 @@ class CollectSequenceFrameData(pyblish.api.InstancePlugin): "frameEnd": repres_frames[-1], "handleStart": 0, "handleEnd": 0, - "fps": instance.context.data["projectEntity"]["data"]["fps"] + "fps": instance.context.data["assetEntity"]["data"]["fps"] } From 378ec74136eba325cba019348cdc69cb90c7d6b6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Sep 2023 12:59:06 +0200 Subject: [PATCH 215/291] validator for frame range should include Plate family --- .../traypublisher/plugins/publish/validate_frame_ranges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py index b962ea464a..09de2d8db2 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_frame_ranges.py @@ -15,7 +15,7 @@ class ValidateFrameRange(OptionalPyblishPluginMixin, label = "Validate Frame Range" hosts = ["traypublisher"] - families = ["render"] + families = ["render", "plate"] order = ValidateContentsOrder optional = True From ab019e312441af63b447f74d8e6119bce0ebb06e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Sep 2023 13:01:29 +0200 Subject: [PATCH 216/291] name of plugin should be more explicit --- ....py => collect_missing_frame_range_asset_entity.py} | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) rename openpype/hosts/traypublisher/plugins/publish/{collect_frame_range_asset_entity.py => collect_missing_frame_range_asset_entity.py} (83%) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py b/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py similarity index 83% rename from openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py rename to openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py index c18e10e438..72379ea4e1 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_frame_range_asset_entity.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_missing_frame_range_asset_entity.py @@ -2,16 +2,18 @@ import pyblish.api from openpype.pipeline import OptionalPyblishPluginMixin -class CollectFrameDataFromAssetEntity(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Collect Frame Range data From Asset Entity +class CollectMissingFrameDataFromAssetEntity( + pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin +): + """Collect Missing Frame Range data From Asset Entity Frame range data will only be collected if the keys are not yet collected for the instance. """ order = pyblish.api.CollectorOrder + 0.491 - label = "Collect Frame Data From Asset Entity" + label = "Collect Missing Frame Data From Asset Entity" families = ["plate", "pointcache", "vdbcache", "online", "render"] From 646d4f6db2850a3faefb32e5635074ae84cd44c3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 19:19:39 +0800 Subject: [PATCH 217/291] oscar comment on the import custom attribute data --- openpype/hosts/max/api/pipeline.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 161e2bdc7b..695169894f 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -197,19 +197,18 @@ def import_custom_attribute_data(container: str, selections: list): rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" rt.custAttributes.add(container.modifiers[0], attrs) - node_list = [] - sel_list = [] + nodes = {} for i in selections: - node_ref = rt.NodeTransformMonitor(node=i) - node_list.append(node_ref) - sel_list.append(str(i)) + nodes = { + str(i) : rt.NodeTransformMonitor(node=i), + } # Setting the property rt.setProperty( container.modifiers[0].openPypeData, - "all_handles", node_list) + "all_handles", nodes.values()) rt.setProperty( container.modifiers[0].openPypeData, - "sel_list", sel_list) + "sel_list", nodes.keys()) def update_custom_attribute_data(container: str, selections: list): From 4e3d0d7eacc63e2ac4892cecbcefc595905df933 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 19:20:42 +0800 Subject: [PATCH 218/291] hound --- openpype/hosts/max/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 695169894f..d9a66c60f5 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -200,7 +200,7 @@ def import_custom_attribute_data(container: str, selections: list): nodes = {} for i in selections: nodes = { - str(i) : rt.NodeTransformMonitor(node=i), + str(i): rt.NodeTransformMonitor(node=i), } # Setting the property rt.setProperty( From 3c911f9f5d2995e866fa7f0b5fe41c6eff100bef Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 13:24:07 +0200 Subject: [PATCH 219/291] removed export maya ass job script --- openpype/scripts/export_maya_ass_job.py | 105 ------------------ openpype/scripts/export_maya_ass_sequence.mel | 67 ----------- 2 files changed, 172 deletions(-) delete mode 100644 openpype/scripts/export_maya_ass_job.py delete mode 100644 openpype/scripts/export_maya_ass_sequence.mel diff --git a/openpype/scripts/export_maya_ass_job.py b/openpype/scripts/export_maya_ass_job.py deleted file mode 100644 index 16e841ce96..0000000000 --- a/openpype/scripts/export_maya_ass_job.py +++ /dev/null @@ -1,105 +0,0 @@ -"""This module is used for command line exporting of ASS files. - -WARNING: -This need to be rewriten to be able use it in Pype 3! -""" - -import os -import argparse -import logging -import subprocess -import platform - -try: - from shutil import which -except ImportError: - # we are in python < 3.3 - def which(command): - path = os.getenv('PATH') - for p in path.split(os.path.pathsep): - p = os.path.join(p, command) - if os.path.exists(p) and os.access(p, os.X_OK): - return p - -handler = logging.basicConfig() -log = logging.getLogger("Publish Image Sequences") -log.setLevel(logging.DEBUG) - -error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" - - -def __main__(): - parser = argparse.ArgumentParser() - parser.add_argument("--paths", - nargs="*", - default=[], - help="The filepaths to publish. This can be a " - "directory or a path to a .json publish " - "configuration.") - parser.add_argument("--gui", - default=False, - action="store_true", - help="Whether to run Pyblish in GUI mode.") - - parser.add_argument("--pype", help="Pype root") - - kwargs, args = parser.parse_known_args() - - print("Running pype ...") - auto_pype_root = os.path.dirname(os.path.abspath(__file__)) - auto_pype_root = os.path.abspath(auto_pype_root + "../../../../..") - - auto_pype_root = os.environ.get('OPENPYPE_SETUP_PATH') or auto_pype_root - if os.environ.get('OPENPYPE_SETUP_PATH'): - print("Got Pype location from environment: {}".format( - os.environ.get('OPENPYPE_SETUP_PATH'))) - - pype_command = "openpype.ps1" - if platform.system().lower() == "linux": - pype_command = "pype" - elif platform.system().lower() == "windows": - pype_command = "openpype.bat" - - if kwargs.pype: - pype_root = kwargs.pype - else: - # test if pype.bat / pype is in the PATH - # if it is, which() will return its path and we use that. - # if not, we use auto_pype_root path. Caveat of that one is - # that it can be UNC path and that will not work on windows. - - pype_path = which(pype_command) - - if pype_path: - pype_root = os.path.dirname(pype_path) - else: - pype_root = auto_pype_root - - print("Set pype root to: {}".format(pype_root)) - print("Paths: {}".format(kwargs.paths or [os.getcwd()])) - - # paths = kwargs.paths or [os.environ.get("OPENPYPE_METADATA_FILE")] or [os.getcwd()] # noqa - - mayabatch = os.environ.get("AVALON_APP_NAME").replace("maya", "mayabatch") - args = [ - os.path.join(pype_root, pype_command), - "launch", - "--app", - mayabatch, - "-script", - os.path.join(pype_root, "repos", "pype", - "pype", "scripts", "export_maya_ass_sequence.mel") - ] - - print("Pype command: {}".format(" ".join(args))) - # Forcing forwaring the environment because environment inheritance does - # not always work. - # Cast all values in environment to str to be safe - env = {k: str(v) for k, v in os.environ.items()} - exit_code = subprocess.call(args, env=env) - if exit_code != 0: - raise RuntimeError("Publishing failed.") - - -if __name__ == '__main__': - __main__() diff --git a/openpype/scripts/export_maya_ass_sequence.mel b/openpype/scripts/export_maya_ass_sequence.mel deleted file mode 100644 index b3b9a8543e..0000000000 --- a/openpype/scripts/export_maya_ass_sequence.mel +++ /dev/null @@ -1,67 +0,0 @@ -/* - Script to export specified layer as ass files. - -Attributes: - - scene_file (str): Name of the scene to load. - start (int): Start frame. - end (int): End frame. - step (int): Step size. - output_path (str): File output path. - render_layer (str): Name of render layer. - -*/ - -$scene_file=`getenv "OPENPYPE_ASS_EXPORT_SCENE_FILE"`; -$step=`getenv "OPENPYPE_ASS_EXPORT_STEP"`; -$start=`getenv "OPENPYPE_ASS_EXPORT_START"`; -$end=`getenv "OPENPYPE_ASS_EXPORT_END"`; -$file_path=`getenv "OPENPYPE_ASS_EXPORT_OUTPUT"`; -$render_layer = `getenv "OPENPYPE_ASS_EXPORT_RENDER_LAYER"`; - -print("*** ASS Export Plugin\n"); - -if ($scene_file == "") { - print("!!! cannot determine scene file\n"); - quit -a -ex -1; -} - -if ($step == "") { - print("!!! cannot determine step size\n"); - quit -a -ex -1; -} - -if ($start == "") { - print("!!! cannot determine start frame\n"); - quit -a -ex -1; -} - -if ($end == "") { - print("!!! cannot determine end frame\n"); - quit -a -ex -1; -} - -if ($file_path == "") { - print("!!! cannot determine output file\n"); - quit -a -ex -1; -} - -if ($render_layer == "") { - print("!!! cannot determine render layer\n"); - quit -a -ex -1; -} - - -print(">>> Opening Scene [ " + $scene_file + " ]\n"); - -// open scene -file -o -f $scene_file; - -// switch to render layer -print(">>> Switching layer [ "+ $render_layer + " ]\n"); -editRenderLayerGlobals -currentRenderLayer $render_layer; - -// export -print(">>> Exporting to [ " + $file_path + " ]\n"); -arnoldExportAss -mask 255 -sl 1 -ll 1 -bb 1 -sf $start -se $end -b -fs $step; -print("--- Done\n"); From aa9846dcbde067e63aa2089752b76840417870bb Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Sep 2023 13:25:35 +0200 Subject: [PATCH 220/291] removed logic where the script is used --- .../plugins/publish/submit_maya_deadline.py | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 34f3905a17..24052f23d5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -334,12 +334,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, payload = self._get_vray_render_payload(payload_data) - elif "assscene" in instance.data["families"]: - self.log.debug("Submitting Arnold .ass standalone render..") - ass_export_payload = self._get_arnold_export_payload(payload_data) - export_job = self.submit(ass_export_payload) - - payload = self._get_arnold_render_payload(payload_data) else: self.log.debug("Submitting MayaBatch render..") payload = self._get_maya_payload(payload_data) @@ -635,53 +629,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info, attr.asdict(plugin_info) - def _get_arnold_export_payload(self, data): - - try: - from openpype.scripts import export_maya_ass_job - except Exception: - raise AssertionError( - "Expected module 'export_maya_ass_job' to be available") - - module_path = export_maya_ass_job.__file__ - if module_path.endswith(".pyc"): - module_path = module_path[: -len(".pyc")] + ".py" - - script = os.path.normpath(module_path) - - job_info = copy.deepcopy(self.job_info) - job_info.Name = self._job_info_label("Export") - - # Force a single frame Python job - job_info.Plugin = "Python" - job_info.Frames = 1 - - renderlayer = self._instance.data["setMembers"] - - # add required env vars for the export script - envs = { - "AVALON_APP_NAME": os.environ.get("AVALON_APP_NAME"), - "OPENPYPE_ASS_EXPORT_RENDER_LAYER": renderlayer, - "OPENPYPE_ASS_EXPORT_SCENE_FILE": self.scene_path, - "OPENPYPE_ASS_EXPORT_OUTPUT": job_info.OutputFilename[0], - "OPENPYPE_ASS_EXPORT_START": int(self._instance.data["frameStartHandle"]), # noqa - "OPENPYPE_ASS_EXPORT_END": int(self._instance.data["frameEndHandle"]), # noqa - "OPENPYPE_ASS_EXPORT_STEP": 1 - } - for key, value in envs.items(): - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - - plugin_info = PythonPluginInfo( - ScriptFile=script, - Version="3.6", - Arguments="", - SingleFrameOnly="True" - ) - - return job_info, attr.asdict(plugin_info) - def _get_vray_render_payload(self, data): # Job Info From 90bfd0a79a8ef5fcee474340594949e44f657ab2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 13:26:36 +0200 Subject: [PATCH 221/291] Yeti Cache: Include viewport preview settings --- .../plugins/publish/collect_yeti_cache.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py index e6b5ca4260..4dcda29050 100644 --- a/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/collect_yeti_cache.py @@ -4,12 +4,23 @@ import pyblish.api from openpype.hosts.maya.api import lib -SETTINGS = {"renderDensity", - "renderWidth", - "renderLength", - "increaseRenderBounds", - "imageSearchPath", - "cbId"} + +SETTINGS = { + # Preview + "displayOutput", + "colorR", "colorG", "colorB", + "viewportDensity", + "viewportWidth", + "viewportLength", + # Render attributes + "renderDensity", + "renderWidth", + "renderLength", + "increaseRenderBounds", + "imageSearchPath", + # Pipeline specific + "cbId" +} class CollectYetiCache(pyblish.api.InstancePlugin): @@ -39,10 +50,6 @@ class CollectYetiCache(pyblish.api.InstancePlugin): # Get yeti nodes and their transforms yeti_shapes = cmds.ls(instance, type="pgYetiMaya") for shape in yeti_shapes: - shape_data = {"transform": None, - "name": shape, - "cbId": lib.get_id(shape), - "attrs": None} # Get specific node attributes attr_data = {} @@ -58,9 +65,12 @@ class CollectYetiCache(pyblish.api.InstancePlugin): parent = cmds.listRelatives(shape, parent=True)[0] transform_data = {"name": parent, "cbId": lib.get_id(parent)} - # Store collected data - shape_data["attrs"] = attr_data - shape_data["transform"] = transform_data + shape_data = { + "transform": transform_data, + "name": shape, + "cbId": lib.get_id(shape), + "attrs": attr_data, + } settings["nodes"].append(shape_data) From 26ef5812b7428f792e81de4a2bfa61867e73f834 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 13:42:01 +0200 Subject: [PATCH 222/291] Skip viewport attributes on 'update' but preserve what artist tweaked after initial load --- .../hosts/maya/plugins/load/load_yeti_cache.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index 5cded13d4e..4a11ea9a2c 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -15,6 +15,16 @@ from openpype.hosts.maya.api import lib from openpype.hosts.maya.api.pipeline import containerise +# Do not reset these values on update but only apply on first load +# to preserve any potential local overrides +SKIP_UPDATE_ATTRS = { + "displayOutput", + "viewportDensity", + "viewportWidth", + "viewportLength", +} + + def set_attribute(node, attr, value): """Wrapper of set attribute which ignores None values""" if value is None: @@ -205,6 +215,8 @@ class YetiCacheLoader(load.LoaderPlugin): yeti_node = yeti_nodes[0] for attr, value in node_settings["attrs"].items(): + if attr in SKIP_UPDATE_ATTRS: + continue set_attribute(attr, value, yeti_node) cmds.setAttr("{}.representation".format(container_node), @@ -311,7 +323,6 @@ class YetiCacheLoader(load.LoaderPlugin): # Update attributes with defaults attributes = node_settings["attrs"] attributes.update({ - "viewportDensity": 0.1, "verbosity": 2, "fileMode": 1, @@ -321,6 +332,9 @@ class YetiCacheLoader(load.LoaderPlugin): "visibleInRefractions": True }) + if "viewportDensity" not in attributes: + attributes["viewportDensity"] = 0.1 + # Apply attributes to pgYetiMaya node for attr, value in attributes.items(): set_attribute(attr, value, yeti_node) From c2722344e052565413914a6e7118024d03dee745 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 4 Sep 2023 20:38:18 +0800 Subject: [PATCH 223/291] update attribute should be correct --- openpype/hosts/max/api/pipeline.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index d9a66c60f5..72163f5ecf 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -197,19 +197,20 @@ def import_custom_attribute_data(container: str, selections: list): rt.addModifier(container, modifier) container.modifiers[0].name = "OP Data" rt.custAttributes.add(container.modifiers[0], attrs) - nodes = {} + node_list = [] + sel_list = [] for i in selections: - nodes = { - str(i): rt.NodeTransformMonitor(node=i), - } + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + sel_list.append(str(i)) + # Setting the property rt.setProperty( container.modifiers[0].openPypeData, - "all_handles", nodes.values()) + "all_handles", node_list) rt.setProperty( container.modifiers[0].openPypeData, - "sel_list", nodes.keys()) - + "sel_list", sel_list) def update_custom_attribute_data(container: str, selections: list): """Updating the Openpype/AYON custom parameter built by the creator From 6ef67e3ff928fca53f4f10ea3166474d5b59c8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Mon, 4 Sep 2023 17:17:15 +0200 Subject: [PATCH 224/291] Update openpype/pipeline/colorspace.py Co-authored-by: Roy Nieterau --- openpype/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index b7728936b0..ce0835dcc6 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -20,7 +20,7 @@ log = Logger.get_logger(__name__) class CachedData: - remapping = None + remapping = {} allowed_exts = { ext.lstrip(".") for ext in IMAGE_EXTENSIONS.union(VIDEO_EXTENSIONS) } From 2fdaadcdc151918163477f3275b646a7044f43e6 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 4 Sep 2023 19:50:00 +0300 Subject: [PATCH 225/291] Minikiu comment --- .../hosts/houdini/plugins/publish/validate_review_colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 545d7b16b1..61c3a755d0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -39,7 +39,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, if os.getenv("OCIO") is None: self.log.debug( - "Default Houdini colorspace is used, " + "Using Houdini's Default Color Management, " " skipping check.." ) return From 167527f2bf1b8fed25f38d84d08f0edca6bb718c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 4 Sep 2023 23:55:41 +0200 Subject: [PATCH 226/291] Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument --- openpype/hosts/houdini/api/pipeline.py | 48 ++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 3c325edfa7..c9ae801af5 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -303,6 +303,28 @@ def on_save(): lib.set_id(node, new_id, overwrite=False) +def _show_outdated_content_popup(): + # Get main window + parent = lib.get_main_window() + if parent is None: + log.info("Skipping outdated content pop-up " + "because Houdini window can't be found.") + else: + from openpype.widgets import popup + + # Show outdated pop-up + def _on_show_inventory(): + from openpype.tools.utils import host_tools + host_tools.show_scene_inventory(parent=parent) + + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Houdini scene has outdated content") + dialog.setMessage("There are outdated containers in " + "your Houdini scene.") + dialog.on_clicked.connect(_on_show_inventory) + dialog.show() + + def on_open(): if not hou.isUIAvailable(): @@ -316,28 +338,18 @@ def on_open(): lib.validate_fps() if any_outdated_containers(): - from openpype.widgets import popup - - log.warning("Scene has outdated content.") - - # Get main window parent = lib.get_main_window() if parent is None: - log.info("Skipping outdated content pop-up " - "because Houdini window can't be found.") + # When opening Houdini with last workfile on launch the UI hasn't + # initialized yet completely when the `on_open` callback triggers. + # We defer the dialog popup to wait for the UI to become available. + # We assume it will open because `hou.isUIAvailable()` returns True + import hdefereval + hdefereval.executeDeferred(_show_outdated_content_popup) else: + _show_outdated_content_popup() - # Show outdated pop-up - def _on_show_inventory(): - from openpype.tools.utils import host_tools - host_tools.show_scene_inventory(parent=parent) - - dialog = popup.Popup(parent=parent) - dialog.setWindowTitle("Houdini scene has outdated content") - dialog.setMessage("There are outdated containers in " - "your Houdini scene.") - dialog.on_clicked.connect(_on_show_inventory) - dialog.show() + log.warning("Scene has outdated content.") def on_new(): From 7c29aa2f7b72dd0a94a5a3531bc8a65c6d1cd3bb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 00:00:03 +0200 Subject: [PATCH 227/291] Improve errors e.g. raise PublishValidationError or cosmetics --- .../houdini/plugins/publish/collect_output_node.py | 6 ++++-- .../plugins/publish/validate_animation_settings.py | 3 ++- .../plugins/publish/validate_remote_publish.py | 11 +++++------ .../publish/validate_usd_render_product_names.py | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_output_node.py b/openpype/hosts/houdini/plugins/publish/collect_output_node.py index 601ed17b39..0b27678ed0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/collect_output_node.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import KnownPublishError + class CollectOutputSOPPath(pyblish.api.InstancePlugin): """Collect the out node's SOP/COP Path value.""" @@ -58,8 +60,8 @@ class CollectOutputSOPPath(pyblish.api.InstancePlugin): elif node_type == "Redshift_Proxy_Output": out_node = node.parm("RS_archive_sopPath").evalAsNode() else: - raise ValueError( - "ROP node type '%s' is" " not supported." % node_type + raise KnownPublishError( + "ROP node type '{}' is not supported.".format(node_type) ) if not out_node: diff --git a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py index 4878738ed3..79387fbef5 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py +++ b/openpype/hosts/houdini/plugins/publish/validate_animation_settings.py @@ -1,5 +1,6 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError from openpype.hosts.houdini.api import lib import hou @@ -30,7 +31,7 @@ class ValidateAnimationSettings(pyblish.api.InstancePlugin): invalid = self.get_invalid(instance) if invalid: - raise RuntimeError( + raise PublishValidationError( "Output settings do no match for '%s'" % instance ) diff --git a/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py b/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py index 4e8e5fc0e8..4f71d79382 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py +++ b/openpype/hosts/houdini/plugins/publish/validate_remote_publish.py @@ -36,11 +36,11 @@ class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): if node.parm("shellexec").eval(): self.raise_error("Must not execute in shell") if node.parm("prerender").eval() != cmd: - self.raise_error(("REMOTE_PUBLISH node does not have " - "correct prerender script.")) + self.raise_error("REMOTE_PUBLISH node does not have " + "correct prerender script.") if node.parm("lprerender").eval() != "python": - self.raise_error(("REMOTE_PUBLISH node prerender script " - "type not set to 'python'")) + self.raise_error("REMOTE_PUBLISH node prerender script " + "type not set to 'python'") @classmethod def repair(cls, context): @@ -48,5 +48,4 @@ class ValidateRemotePublishOutNode(pyblish.api.ContextPlugin): lib.create_remote_publish_node(force=True) def raise_error(self, message): - self.log.error(message) - raise PublishValidationError(message, title=self.label) + raise PublishValidationError(message) diff --git a/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py b/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py index 02c44ab94e..1daa96f2b9 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py +++ b/openpype/hosts/houdini/plugins/publish/validate_usd_render_product_names.py @@ -24,7 +24,7 @@ class ValidateUSDRenderProductNames(pyblish.api.InstancePlugin): if not os.path.isabs(filepath): invalid.append( - "Output file path is not " "absolute path: %s" % filepath + "Output file path is not absolute path: %s" % filepath ) if invalid: From 0e580ad3190f9cfa15243ebde5e99f02e3c95e71 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 00:02:59 +0200 Subject: [PATCH 228/291] Fix invalid import `openpype.action` does not exist + raise KnownPublishError --- .../houdini/plugins/publish/increment_current_file.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/increment_current_file.py b/openpype/hosts/houdini/plugins/publish/increment_current_file.py index 2493b28bc1..3569de7693 100644 --- a/openpype/hosts/houdini/plugins/publish/increment_current_file.py +++ b/openpype/hosts/houdini/plugins/publish/increment_current_file.py @@ -2,7 +2,7 @@ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host -from openpype.action import get_errored_plugins_from_data +from openpype.pipeline.publish import get_errored_plugins_from_context from openpype.hosts.houdini.api import HoudiniHost from openpype.pipeline.publish import KnownPublishError @@ -27,7 +27,7 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): def process(self, context): - errored_plugins = get_errored_plugins_from_data(context) + errored_plugins = get_errored_plugins_from_context(context) if any( plugin.__name__ == "HoudiniSubmitPublishDeadline" for plugin in errored_plugins @@ -40,9 +40,10 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): # Filename must not have changed since collecting host = registered_host() # type: HoudiniHost current_file = host.current_file() - assert ( - context.data["currentFile"] == current_file - ), "Collected filename mismatches from current scene name." + if context.data["currentFile"] != current_file: + raise KnownPublishError( + "Collected filename mismatches from current scene name." + ) new_filepath = version_up(current_file) host.save_workfile(new_filepath) From 10dd15444ad9244618e4e89e4a176d3aaaa2ef58 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:03:54 +0200 Subject: [PATCH 229/291] Log collected scene version as debug - artists don't care --- openpype/plugins/publish/collect_scene_version.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 70a0aca296..7920c1e82b 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -63,4 +63,6 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "filename: {}".format(filename)) context.data['version'] = int(version) - self.log.info('Scene Version: %s' % context.data.get('version')) + self.log.debug( + "Collected scene version: {}".format(context.data.get('version')) + ) From 43902c0b118019fc770affc9217a751cd6a98c07 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:04:13 +0200 Subject: [PATCH 230/291] Add debug log about the skipped input --- .../publish/collect_input_representations_to_versions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/plugins/publish/collect_input_representations_to_versions.py b/openpype/plugins/publish/collect_input_representations_to_versions.py index 54a3214647..2b8c745d3d 100644 --- a/openpype/plugins/publish/collect_input_representations_to_versions.py +++ b/openpype/plugins/publish/collect_input_representations_to_versions.py @@ -46,3 +46,10 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): version_id = representation_id_to_version_id.get(repre_id) if version_id: input_versions.append(version_id) + else: + self.log.debug( + "Representation id {} skipped because its version is " + "not found in current project. Likely it is loaded " + "from a library project or uses a deleted " + "representation or version.".format(repre_id) + ) From 97bb0de1a93c92e17be7ff31cc80c3a59cc8300e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:04:40 +0200 Subject: [PATCH 231/291] Raise KnownPublishError --- openpype/plugins/publish/extract_review_slate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 8b1a06b6b8..2ff7fbe7fa 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -14,10 +14,13 @@ from openpype.lib import ( get_ffmpeg_codec_args, get_ffmpeg_format_args, ) -from openpype.pipeline import publish +from openpype.pipeline.publish import ( + Extractor, + KnownPublishError +) -class ExtractReviewSlate(publish.Extractor): +class ExtractReviewSlate(Extractor): """ Will add slate frame at the start of the video files """ @@ -93,9 +96,10 @@ class ExtractReviewSlate(publish.Extractor): # Raise exception of any stream didn't define input resolution if input_width is None: - raise AssertionError(( + raise KnownPublishError( "FFprobe couldn't read resolution from input file: \"{}\"" - ).format(input_path)) + .format(input_path) + ) ( audio_codec, From 84960826a6250897e262f3c468aadc5a94d23c1a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:05:07 +0200 Subject: [PATCH 232/291] Tweak grammar + log what representations got created --- openpype/plugins/publish/integrate.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 0c18ab4466..7e48155b9e 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -306,7 +306,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # increase if the file transaction takes a long time. op_session.commit() - self.log.info("Subset '{subset[name]}' and Version {version[name]} " + self.log.info("Subset '{subset[name]}' version {version[name]} " "written to database..".format(subset=subset, version=version)) @@ -392,8 +392,13 @@ class IntegrateAsset(pyblish.api.InstancePlugin): p["representation"]["_id"]: p for p in prepared_representations } - self.log.info("Registered {} representations" - "".format(len(prepared_representations))) + self.log.info( + "Registered {} representations: {}".format( + len(prepared_representations), + ", ".join(p["representation"]["name"] + for p in prepared_representations) + ) + ) def prepare_subset(self, instance, op_session, project_name): asset_doc = instance.data["assetEntity"] From 4749ac80400346fd4b8ac27f3de4346ba526ba25 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:05:40 +0200 Subject: [PATCH 233/291] Raise PublishValidationError --- openpype/plugins/publish/validate_file_saved.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/validate_file_saved.py b/openpype/plugins/publish/validate_file_saved.py index 448eaccf57..94aadc9358 100644 --- a/openpype/plugins/publish/validate_file_saved.py +++ b/openpype/plugins/publish/validate_file_saved.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError + class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): """File must be saved before publishing""" @@ -12,4 +14,4 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): current_file = context.data["currentFile"] if not current_file: - raise RuntimeError("File not saved") + raise PublishValidationError("File not saved") From 68ab2fc934e6a11995884ad1472cb04567f7c271 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 09:06:07 +0200 Subject: [PATCH 234/291] Raise PublishValidationError --- .../plugins/publish/validate_filesequences.py | 5 ++++- openpype/plugins/publish/validate_intent.py | 10 ++++------ .../plugins/publish/validate_publish_dir.py | 17 +++++++++-------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/openpype/plugins/publish/validate_filesequences.py b/openpype/plugins/publish/validate_filesequences.py index 8a877d79bb..0ac281022d 100644 --- a/openpype/plugins/publish/validate_filesequences.py +++ b/openpype/plugins/publish/validate_filesequences.py @@ -1,5 +1,7 @@ import pyblish.api +from openpype.pipeline.publish import PublishValidationError + class ValidateFileSequences(pyblish.api.ContextPlugin): """Validates whether any file sequences were collected.""" @@ -10,4 +12,5 @@ class ValidateFileSequences(pyblish.api.ContextPlugin): label = "Validate File Sequences" def process(self, context): - assert context, "Nothing collected." + if not context: + raise PublishValidationError("Nothing collected.") diff --git a/openpype/plugins/publish/validate_intent.py b/openpype/plugins/publish/validate_intent.py index 23d57bb2b7..832c7cc0a1 100644 --- a/openpype/plugins/publish/validate_intent.py +++ b/openpype/plugins/publish/validate_intent.py @@ -1,7 +1,7 @@ -import os import pyblish.api from openpype.lib import filter_profiles +from openpype.pipeline.publish import PublishValidationError class ValidateIntent(pyblish.api.ContextPlugin): @@ -51,12 +51,10 @@ class ValidateIntent(pyblish.api.ContextPlugin): )) return - msg = ( - "Please make sure that you select the intent of this publish." - ) - intent = context.data.get("intent") or {} self.log.debug(str(intent)) intent_value = intent.get("value") if not intent_value: - raise AssertionError(msg) + raise PublishValidationError( + "Please make sure that you select the intent of this publish." + ) diff --git a/openpype/plugins/publish/validate_publish_dir.py b/openpype/plugins/publish/validate_publish_dir.py index ad5fd34434..0eb93da583 100644 --- a/openpype/plugins/publish/validate_publish_dir.py +++ b/openpype/plugins/publish/validate_publish_dir.py @@ -47,15 +47,16 @@ class ValidatePublishDir(pyblish.api.InstancePlugin): # original_dirname must be convertable to rootless path # in other case it is path inside of root folder for the project success, _ = anatomy.find_root_template_from_path(original_dirname) - - formatting_data = { - "original_dirname": original_dirname, - } - msg = "Path '{}' not in project folder.".format(original_dirname) + \ - " Please publish from inside of project folder." if not success: - raise PublishXmlValidationError(self, msg, key="not_in_dir", - formatting_data=formatting_data) + raise PublishXmlValidationError( + plugin=self, + message=( + "Path '{}' not in project folder. Please publish from " + "inside of project folder.".format(original_dirname) + ), + key="not_in_dir", + formatting_data={"original_dirname": original_dirname} + ) def _get_template_name_from_instance(self, instance): """Find template which will be used during integration.""" From 71bd10fffb7e3715108e0253203d28f32441998a Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 5 Sep 2023 08:32:22 +0100 Subject: [PATCH 235/291] Update openpype/hosts/maya/plugins/publish/extract_look.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index ec01a817b3..043d88db6d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -52,7 +52,7 @@ def find_paths_by_hash(texture_hash): """ if AYON_SERVER_ENABLED: - raise ValueError( + raise KnownPublishError( "This is a bug. \"find_paths_by_hash\" is not compatible with " "AYON." ) From 93a9ae2a6fa977b523de16b9f524e76d2d5fbf86 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 5 Sep 2023 08:33:49 +0100 Subject: [PATCH 236/291] Add TODO --- openpype/hosts/maya/plugins/publish/extract_look.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 043d88db6d..2708a6d916 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -591,8 +591,8 @@ class ExtractLook(publish.Extractor): resources = instance.data["resources"] color_management = lib.get_color_management_preferences() - # Temporary disable all hardlinking, due to the feature not being used - # or properly working. + # TODO: Temporary disable all hardlinking, due to the feature not being + # used or properly working. self.log.info( "Forcing copy instead of hardlink." ) From d46f610e4d110a504991c9669fef644ec7e5c747 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 11:21:46 +0200 Subject: [PATCH 237/291] removed switch_ui script from fusion with related script in 'scripts' --- .../deploy/Scripts/Comp/OpenPype/switch_ui.py | 200 --------------- openpype/scripts/fusion_switch_shot.py | 241 ------------------ 2 files changed, 441 deletions(-) delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py delete mode 100644 openpype/scripts/fusion_switch_shot.py diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py deleted file mode 100644 index 87322235f5..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py +++ /dev/null @@ -1,200 +0,0 @@ -import os -import sys -import glob -import logging - -from qtpy import QtWidgets, QtCore - -import qtawesome as qta - -from openpype.client import get_assets -from openpype import style -from openpype.pipeline import ( - install_host, - get_current_project_name, -) -from openpype.hosts.fusion import api -from openpype.pipeline.context_tools import get_workdir_from_session - -log = logging.getLogger("Fusion Switch Shot") - - -class App(QtWidgets.QWidget): - - def __init__(self, parent=None): - - ################################################ - # |---------------------| |------------------| # - # |Comp | |Asset | # - # |[..][ v]| |[ v]| # - # |---------------------| |------------------| # - # | Update existing comp [ ] | # - # |------------------------------------------| # - # | Switch | # - # |------------------------------------------| # - ################################################ - - QtWidgets.QWidget.__init__(self, parent) - - layout = QtWidgets.QVBoxLayout() - - # Comp related input - comp_hlayout = QtWidgets.QHBoxLayout() - comp_label = QtWidgets.QLabel("Comp file") - comp_label.setFixedWidth(50) - comp_box = QtWidgets.QComboBox() - - button_icon = qta.icon("fa.folder", color="white") - open_from_dir = QtWidgets.QPushButton() - open_from_dir.setIcon(button_icon) - - comp_box.setFixedHeight(25) - open_from_dir.setFixedWidth(25) - open_from_dir.setFixedHeight(25) - - comp_hlayout.addWidget(comp_label) - comp_hlayout.addWidget(comp_box) - comp_hlayout.addWidget(open_from_dir) - - # Asset related input - asset_hlayout = QtWidgets.QHBoxLayout() - asset_label = QtWidgets.QLabel("Shot") - asset_label.setFixedWidth(50) - - asset_box = QtWidgets.QComboBox() - asset_box.setLineEdit(QtWidgets.QLineEdit()) - asset_box.setFixedHeight(25) - - refresh_icon = qta.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton() - refresh_btn.setIcon(refresh_icon) - - asset_box.setFixedHeight(25) - refresh_btn.setFixedWidth(25) - refresh_btn.setFixedHeight(25) - - asset_hlayout.addWidget(asset_label) - asset_hlayout.addWidget(asset_box) - asset_hlayout.addWidget(refresh_btn) - - # Options - options = QtWidgets.QHBoxLayout() - options.setAlignment(QtCore.Qt.AlignLeft) - - current_comp_check = QtWidgets.QCheckBox() - current_comp_check.setChecked(True) - current_comp_label = QtWidgets.QLabel("Use current comp") - - options.addWidget(current_comp_label) - options.addWidget(current_comp_check) - - accept_btn = QtWidgets.QPushButton("Switch") - - layout.addLayout(options) - layout.addLayout(comp_hlayout) - layout.addLayout(asset_hlayout) - layout.addWidget(accept_btn) - - self._open_from_dir = open_from_dir - self._comps = comp_box - self._assets = asset_box - self._use_current = current_comp_check - self._accept_btn = accept_btn - self._refresh_btn = refresh_btn - - self.setWindowTitle("Fusion Switch Shot") - self.setLayout(layout) - - self.resize(260, 140) - self.setMinimumWidth(260) - self.setFixedHeight(140) - - self.connections() - - # Update ui to correct state - self._on_use_current_comp() - self._refresh() - - def connections(self): - self._use_current.clicked.connect(self._on_use_current_comp) - self._open_from_dir.clicked.connect(self._on_open_from_dir) - self._refresh_btn.clicked.connect(self._refresh) - self._accept_btn.clicked.connect(self._on_switch) - - def _on_use_current_comp(self): - state = self._use_current.isChecked() - self._open_from_dir.setEnabled(not state) - self._comps.setEnabled(not state) - - def _on_open_from_dir(self): - - start_dir = get_workdir_from_session() - comp_file, _ = QtWidgets.QFileDialog.getOpenFileName( - self, "Choose comp", start_dir) - - if not comp_file: - return - - # Create completer - self.populate_comp_box([comp_file]) - self._refresh() - - def _refresh(self): - # Clear any existing items - self._assets.clear() - - asset_names = self.collect_asset_names() - completer = QtWidgets.QCompleter(asset_names) - - self._assets.setCompleter(completer) - self._assets.addItems(asset_names) - - def _on_switch(self): - - if not self._use_current.isChecked(): - file_name = self._comps.itemData(self._comps.currentIndex()) - else: - comp = api.get_current_comp() - file_name = comp.GetAttrs("COMPS_FileName") - - asset = self._assets.currentText() - - import colorbleed.scripts.fusion_switch_shot as switch_shot - switch_shot.switch(asset_name=asset, filepath=file_name, new=True) - - def collect_slap_comps(self, directory): - items = glob.glob("{}/*.comp".format(directory)) - return items - - def collect_asset_names(self): - project_name = get_current_project_name() - asset_docs = get_assets(project_name, fields=["name"]) - asset_names = { - asset_doc["name"] - for asset_doc in asset_docs - } - return list(asset_names) - - def populate_comp_box(self, files): - """Ensure we display the filename only but the path is stored as well - - Args: - files (list): list of full file path [path/to/item/item.ext,] - - Returns: - None - """ - - for f in files: - filename = os.path.basename(f) - self._comps.addItem(filename, userData=f) - - -if __name__ == '__main__': - install_host(api) - - app = QtWidgets.QApplication(sys.argv) - window = App() - window.setStyleSheet(style.load_stylesheet()) - window.show() - sys.exit(app.exec_()) diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py deleted file mode 100644 index 1cc728226f..0000000000 --- a/openpype/scripts/fusion_switch_shot.py +++ /dev/null @@ -1,241 +0,0 @@ -import os -import re -import sys -import logging - -from openpype.client import get_asset_by_name, get_versions - -# Pipeline imports -from openpype.hosts.fusion import api -import openpype.hosts.fusion.api.lib as fusion_lib - -# Config imports -from openpype.lib import version_up -from openpype.pipeline import ( - install_host, - registered_host, - legacy_io, - get_current_project_name, -) - -from openpype.pipeline.context_tools import get_workdir_from_session -from openpype.pipeline.version_start import get_versioning_start - -log = logging.getLogger("Update Slap Comp") - - -def _format_version_folder(folder): - """Format a version folder based on the filepath - - Args: - folder: file path to a folder - - Returns: - str: new version folder name - """ - - new_version = get_versioning_start( - get_current_project_name(), - "fusion", - family="workfile" - ) - if os.path.isdir(folder): - re_version = re.compile(r"v\d+$") - versions = [i for i in os.listdir(folder) if os.path.isdir(i) - and re_version.match(i)] - if versions: - # ensure the "v" is not included - new_version = int(max(versions)[1:]) + 1 - - version_folder = "v{:03d}".format(new_version) - - return version_folder - - -def _get_fusion_instance(): - fusion = getattr(sys.modules["__main__"], "fusion", None) - if fusion is None: - try: - # Support for FuScript.exe, BlackmagicFusion module for py2 only - import BlackmagicFusion as bmf - fusion = bmf.scriptapp("Fusion") - except ImportError: - raise RuntimeError("Could not find a Fusion instance") - return fusion - - -def _format_filepath(session): - - project = session["AVALON_PROJECT"] - asset = session["AVALON_ASSET"] - - # Save updated slap comp - work_path = get_workdir_from_session(session) - walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") - slapcomp_dir = os.path.abspath(walk_to_dir) - - # Ensure destination exists - if not os.path.isdir(slapcomp_dir): - log.warning("Folder did not exist, creating folder structure") - os.makedirs(slapcomp_dir) - - # Compute output path - new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset) - new_filepath = os.path.join(slapcomp_dir, new_filename) - - # Create new unqiue filepath - if os.path.exists(new_filepath): - new_filepath = version_up(new_filepath) - - return new_filepath - - -def _update_savers(comp, session): - """Update all savers of the current comp to ensure the output is correct - - Args: - comp (object): current comp instance - session (dict): the current Avalon session - - Returns: - None - """ - - new_work = get_workdir_from_session(session) - renders = os.path.join(new_work, "renders") - version_folder = _format_version_folder(renders) - renders_version = os.path.join(renders, version_folder) - - comp.Print("New renders to: %s\n" % renders) - - with api.comp_lock_and_undo_chunk(comp): - savers = comp.GetToolList(False, "Saver").values() - for saver in savers: - filepath = saver.GetAttrs("TOOLST_Clip_Name")[1.0] - filename = os.path.basename(filepath) - new_path = os.path.join(renders_version, filename) - saver["Clip"] = new_path - - -def update_frame_range(comp, representations): - """Update the frame range of the comp and render length - - The start and end frame are based on the lowest start frame and the highest - end frame - - Args: - comp (object): current focused comp - representations (list) collection of dicts - - Returns: - None - - """ - - version_ids = [r["parent"] for r in representations] - project_name = get_current_project_name() - versions = list(get_versions(project_name, version_ids=version_ids)) - - start = min(v["data"]["frameStart"] for v in versions) - end = max(v["data"]["frameEnd"] for v in versions) - - fusion_lib.update_frame_range(start, end, comp=comp) - - -def switch(asset_name, filepath=None, new=True): - """Switch the current containers of the file to the other asset (shot) - - Args: - filepath (str): file path of the comp file - asset_name (str): name of the asset (shot) - new (bool): Save updated comp under a different name - - Returns: - comp path (str): new filepath of the updated comp - - """ - - # If filepath provided, ensure it is valid absolute path - if filepath is not None: - if not os.path.isabs(filepath): - filepath = os.path.abspath(filepath) - - assert os.path.exists(filepath), "%s must exist " % filepath - - # Assert asset name exists - # It is better to do this here then to wait till switch_shot does it - project_name = get_current_project_name() - asset = get_asset_by_name(project_name, asset_name) - assert asset, "Could not find '%s' in the database" % asset_name - - # Go to comp - if not filepath: - current_comp = api.get_current_comp() - assert current_comp is not None, "Could not find current comp" - else: - fusion = _get_fusion_instance() - current_comp = fusion.LoadComp(filepath, quiet=True) - assert current_comp is not None, "Fusion could not load '%s'" % filepath - - host = registered_host() - containers = list(host.ls()) - assert containers, "Nothing to update" - - representations = [] - for container in containers: - try: - representation = fusion_lib.switch_item(container, - asset_name=asset_name) - representations.append(representation) - except Exception as e: - current_comp.Print("Error in switching! %s\n" % e.message) - - message = "Switched %i Loaders of the %i\n" % (len(representations), - len(containers)) - current_comp.Print(message) - - # Build the session to switch to - switch_to_session = legacy_io.Session.copy() - switch_to_session["AVALON_ASSET"] = asset['name'] - - if new: - comp_path = _format_filepath(switch_to_session) - - # Update savers output based on new session - _update_savers(current_comp, switch_to_session) - else: - comp_path = version_up(filepath) - - current_comp.Print(comp_path) - - current_comp.Print("\nUpdating frame range") - update_frame_range(current_comp, representations) - - current_comp.Save(comp_path) - - return comp_path - - -if __name__ == '__main__': - - import argparse - - parser = argparse.ArgumentParser(description="Switch to a shot within an" - "existing comp file") - - parser.add_argument("--file_path", - type=str, - default=True, - help="File path of the comp to use") - - parser.add_argument("--asset_name", - type=str, - default=True, - help="Name of the asset (shot) to switch") - - args, unknown = parser.parse_args() - - install_host(api) - switch(args.asset_name, args.file_path) - - sys.exit(0) From 2b8ec005fcc39f54e5a9f14a0352f98f6997034e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 11:27:31 +0200 Subject: [PATCH 238/291] removed remaining scripts after discussion with @BigRoy --- .../32bit/backgrounds_selected_to32bit.py | 16 -------- .../OpenPype/32bit/backgrounds_to32bit.py | 16 -------- .../32bit/loaders_selected_to32bit.py | 16 -------- .../Comp/OpenPype/32bit/loaders_to32bit.py | 16 -------- .../Comp/OpenPype/update_loader_ranges.py | 40 ------------------- 5 files changed, 104 deletions(-) delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py delete mode 100644 openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py deleted file mode 100644 index 1a0a9911ea..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all selected backgrounds to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Selected Backgrounds to 32bit'): - tools = comp.GetToolList(True, "Background").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py deleted file mode 100644 index c2eea505e5..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all backgrounds to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Backgrounds to 32bit'): - tools = comp.GetToolList(False, "Background").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py deleted file mode 100644 index 2118767f4d..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all selected loaders to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Selected Loaders to 32bit'): - tools = comp.GetToolList(True, "Loader").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py deleted file mode 100644 index 7dd1f66a5e..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py +++ /dev/null @@ -1,16 +0,0 @@ -from openpype.hosts.fusion.api import ( - comp_lock_and_undo_chunk, - get_current_comp -) - - -def main(): - comp = get_current_comp() - """Set all loaders to 32 bit""" - with comp_lock_and_undo_chunk(comp, 'Loaders to 32bit'): - tools = comp.GetToolList(False, "Loader").values() - for tool in tools: - tool.Depth = 5 - - -main() diff --git a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py deleted file mode 100644 index 3d2d1ecfa6..0000000000 --- a/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Forces Fusion to 'retrigger' the Loader to update. - -Warning: - This might change settings like 'Reverse', 'Loop', trims and other - settings of the Loader. So use this at your own risk. - -""" -from openpype.hosts.fusion.api.pipeline import ( - get_current_comp, - comp_lock_and_undo_chunk -) - - -def update_loader_ranges(): - comp = get_current_comp() - with comp_lock_and_undo_chunk(comp, "Reload clip time ranges"): - tools = comp.GetToolList(True, "Loader").values() - for tool in tools: - - # Get tool attributes - tool_a = tool.GetAttrs() - clipTable = tool_a['TOOLST_Clip_Name'] - altclipTable = tool_a['TOOLST_AltClip_Name'] - startTime = tool_a['TOOLNT_Clip_Start'] - old_global_in = tool.GlobalIn[comp.CurrentTime] - - # Reapply - for index, _ in clipTable.items(): - time = startTime[index] - tool.Clip[time] = tool.Clip[time] - - for index, _ in altclipTable.items(): - time = startTime[index] - tool.ProxyFilename[time] = tool.ProxyFilename[time] - - tool.GlobalIn[comp.CurrentTime] = old_global_in - - -if __name__ == '__main__': - update_loader_ranges() From bde5a560a207dfb28839cb336b30fb59bb0762f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 11:28:25 +0200 Subject: [PATCH 239/291] Removed ';OpenPype:Scripts' from prefs file --- openpype/hosts/fusion/deploy/fusion_shared.prefs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/deploy/fusion_shared.prefs b/openpype/hosts/fusion/deploy/fusion_shared.prefs index b379ea7c66..93b08aa886 100644 --- a/openpype/hosts/fusion/deploy/fusion_shared.prefs +++ b/openpype/hosts/fusion/deploy/fusion_shared.prefs @@ -5,7 +5,7 @@ Global = { Map = { ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy", ["Config:"] = "UserPaths:Config;OpenPype:Config", - ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts", + ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts", }, }, Script = { From 564cb31cbe700aef687f6b11969eb530973e60d9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 12:05:40 +0200 Subject: [PATCH 240/291] Fix base `Extractor` being picked up as a plug-in to process --- openpype/plugins/publish/extract_review_slate.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_review_slate.py b/openpype/plugins/publish/extract_review_slate.py index 2ff7fbe7fa..d89fbb90c4 100644 --- a/openpype/plugins/publish/extract_review_slate.py +++ b/openpype/plugins/publish/extract_review_slate.py @@ -14,13 +14,11 @@ from openpype.lib import ( get_ffmpeg_codec_args, get_ffmpeg_format_args, ) -from openpype.pipeline.publish import ( - Extractor, - KnownPublishError -) +from openpype.pipeline import publish +from openpype.pipeline.publish import KnownPublishError -class ExtractReviewSlate(Extractor): +class ExtractReviewSlate(publish.Extractor): """ Will add slate frame at the start of the video files """ From e2c3a0f5be2be18ab71cfbd65632f66f1a8b218e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 17:33:25 +0200 Subject: [PATCH 241/291] better check of overriden '__init__' method --- openpype/pipeline/create/creator_plugins.py | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index de9cc7cff3..ab0d36e67e 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -214,7 +214,7 @@ class BaseCreator: # Backwards compatibility for system settings self.apply_settings(project_settings, system_settings) - init_overriden = self.__class__.__init__ is not BaseCreator.__init__ + init_overriden = self._method_is_overriden("__init__") if init_overriden or expect_system_settings: self.log.warning(( "WARNING: Source - Create plugin {}." @@ -225,6 +225,19 @@ class BaseCreator: " need to keep system settings." ).format(self.__class__.__name__)) + def _method_is_overriden(self, method_name): + """Check if method is overriden on objects class. + + Implemented for deprecation warning validation on init. + + Returns: + bool: True if method is overriden on objects class. + """ + + cls_method = getattr(BaseCreator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings. @@ -578,6 +591,11 @@ class Creator(BaseCreator): ) super(Creator, self).__init__(*args, **kwargs) + def _method_is_overriden(self, method_name): + cls_method = getattr(Creator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + @property def show_order(self): """Order in which is creator shown in UI. @@ -720,6 +738,11 @@ class HiddenCreator(BaseCreator): def create(self, instance_data, source_data): pass + def _method_is_overriden(self, method_name): + cls_method = getattr(HiddenCreator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + class AutoCreator(BaseCreator): """Creator which is automatically triggered without user interaction. @@ -731,6 +754,11 @@ class AutoCreator(BaseCreator): """Skip removement.""" pass + def _method_is_overriden(self, method_name): + cls_method = getattr(AutoCreator, method_name) + obj_method = getattr(self.__class__, method_name) + return cls_method is not obj_method + def discover_creator_plugins(*args, **kwargs): return discover(BaseCreator, *args, **kwargs) From c3847aec5186d26414f220369a89234f70740706 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Sep 2023 17:55:37 +0200 Subject: [PATCH 242/291] removed '_method_is_overriden' and use explicit class checks --- openpype/pipeline/create/creator_plugins.py | 40 ++++++--------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index ab0d36e67e..6aa08cae70 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -214,8 +214,16 @@ class BaseCreator: # Backwards compatibility for system settings self.apply_settings(project_settings, system_settings) - init_overriden = self._method_is_overriden("__init__") - if init_overriden or expect_system_settings: + init_use_base = any( + self.__class__.__init__ is cls.__init__ + for cls in { + BaseCreator, + Creator, + HiddenCreator, + AutoCreator, + } + ) + if not init_use_base or expect_system_settings: self.log.warning(( "WARNING: Source - Create plugin {}." " System settings argument will not be passed to" @@ -225,19 +233,6 @@ class BaseCreator: " need to keep system settings." ).format(self.__class__.__name__)) - def _method_is_overriden(self, method_name): - """Check if method is overriden on objects class. - - Implemented for deprecation warning validation on init. - - Returns: - bool: True if method is overriden on objects class. - """ - - cls_method = getattr(BaseCreator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - def apply_settings(self, project_settings): """Method called on initialization of plugin to apply settings. @@ -591,11 +586,6 @@ class Creator(BaseCreator): ) super(Creator, self).__init__(*args, **kwargs) - def _method_is_overriden(self, method_name): - cls_method = getattr(Creator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - @property def show_order(self): """Order in which is creator shown in UI. @@ -738,11 +728,6 @@ class HiddenCreator(BaseCreator): def create(self, instance_data, source_data): pass - def _method_is_overriden(self, method_name): - cls_method = getattr(HiddenCreator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - class AutoCreator(BaseCreator): """Creator which is automatically triggered without user interaction. @@ -754,11 +739,6 @@ class AutoCreator(BaseCreator): """Skip removement.""" pass - def _method_is_overriden(self, method_name): - cls_method = getattr(AutoCreator, method_name) - obj_method = getattr(self.__class__, method_name) - return cls_method is not obj_method - def discover_creator_plugins(*args, **kwargs): return discover(BaseCreator, *args, **kwargs) From 1b7e1aeed7c09014855f5d16bef37f22f917f3f0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 5 Sep 2023 18:12:49 +0200 Subject: [PATCH 243/291] Fix attribute definitions for `CreateYetiCache` --- openpype/hosts/maya/plugins/create/create_yeti_cache.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_yeti_cache.py b/openpype/hosts/maya/plugins/create/create_yeti_cache.py index 395aa62325..ca002392d4 100644 --- a/openpype/hosts/maya/plugins/create/create_yeti_cache.py +++ b/openpype/hosts/maya/plugins/create/create_yeti_cache.py @@ -13,8 +13,7 @@ class CreateYetiCache(plugin.MayaCreator): family = "yeticache" icon = "pagelines" - def __init__(self, *args, **kwargs): - super(CreateYetiCache, self).__init__(*args, **kwargs) + def get_instance_attr_defs(self): defs = [ NumberDef("preroll", @@ -36,3 +35,5 @@ class CreateYetiCache(plugin.MayaCreator): default=3, decimals=0) ) + + return defs From 896b80322cec4158124a8e43c5c5036059393c0b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 6 Sep 2023 03:25:11 +0000 Subject: [PATCH 244/291] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 7de6fd752b..466f9ce033 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.4" +__version__ = "3.16.5-nightly.5" From 83f4c2ea39012f3dc15890dddf421e86606a4dd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Sep 2023 03:25:50 +0000 Subject: [PATCH 245/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 83a4b6a8d4..f6d1a25dc2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.5-nightly.5 - 3.16.5-nightly.4 - 3.16.5-nightly.3 - 3.16.5-nightly.2 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.2 - 3.14.9-nightly.1 - 3.14.8 - - 3.14.8-nightly.4 validations: required: true - type: dropdown From caad3e57e67f3f54854c33dd19c6d482fe977f54 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 6 Sep 2023 08:00:16 +0000 Subject: [PATCH 246/291] [Automated] Release --- CHANGELOG.md | 673 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 675 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1948b1a3f..c4f9ff57ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,679 @@ # Changelog +## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.4...3.16.5) + +### **🆕 New features** + + +
+Attribute Definitions: Multiselection enum def #5547 + +Added `multiselection` option to `EnumDef`. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Farm: adding target collector #5494 + +Enhancing farm publishing workflow. + + +___ + +
+ + +
+Maya: Optimize validate plug-in path attributes #5522 + +- Optimize query (use `cmds.ls` once) +- Add Select Invalid action +- Improve validation report +- Avoid "Unknown object type" errors + + +___ + +
+ + +
+Maya: Remove Validate Instance Attributes plug-in #5525 + +Remove Validate Instance Attributes plug-in. + + +___ + +
+ + +
+Enhancement: Tweak logging for artist facing reports #5537 + +Tweak the logging of publishing for global, deadline, maya and a fusion plugin to have a cleaner artist-facing report. +- Fix context being reported correctly from CollectContext +- Fix ValidateMeshArnoldAttributes: fix when arnold is not loaded, fix applying settings, fix for when ai attributes do not exist + + +___ + +
+ + +
+AYON: Update settings #5544 + +Updated settings in AYON addons and conversion of AYON settings in OpenPype. + + +___ + +
+ + +
+Chore: Removed Ass export script #5560 + +Removed Arnold render script, which was obsolete and unused. + + +___ + +
+ + +
+Nuke: Allow for knob values to be validated against multiple values. #5042 + +Knob values can now be validated against multiple values, so you can allow write nodes to be `exr` and `png`, or `16-bit` and `32-bit`. + + +___ + +
+ + +
+Enhancement: Cosmetics for Higher version of publish already exists validation error #5190 + +Fix double spaces in message.Example output **after** the PR: + + +___ + +
+ + +
+Nuke: publish existing frames on farm #5409 + +This PR proposes adding a fourth option in Nuke render publish called "Use Existing Frames - Farm". This would be useful when the farm is busy or when the artist lacks enough farm licenses. Additionally, some artists prefer rendering on the farm but still want to check frames before publishing.By adding the "Use Existing Frames - Farm" option, artists will have more flexibility and control over their render publishing process. This enhancement will streamline the workflow and improve efficiency for Nuke users. + + +___ + +
+ + +
+Unreal: Create project in temp location and move to final when done #5476 + +Create Unreal project in local temporary folder and when done, move it to final destination. + + +___ + +
+ + +
+TrayPublisher: adding audio product type into default presets #5489 + +Adding Audio product type into default presets so anybody can publish audio to their shots. + + +___ + +
+ + +
+Global: avoiding cleanup of flagged representation #5502 + +Publishing folder can be flagged as persistent at representation level. + + +___ + +
+ + +
+General: missing tag could raise error #5511 + +- avoiding potential situation where missing Tag key could raise error + + +___ + +
+ + +
+Chore: Queued event system #5514 + +Implemented event system with more expected behavior of event system. If an event is triggered during other event callback, it is not processed immediately but waits until all callbacks of previous events are done. The event system also allows to not trigger events directly once `emit_event` is called which gives option to process events in custom loops. + + +___ + +
+ + +
+Publisher: Tweak log message to provide plugin name after "Plugin" #5521 + +Fix logged message for settings automatically applied to plugin attributes + + +___ + +
+ + +
+Houdini: Improve VDB Selection #5523 + +Improves VDB selection if selection is `SopNode`: return the selected sop nodeif selection is `ObjNode`: get the output node with the minimum 'outputidx' or the node with display flag + + +___ + +
+ + +
+Maya: Refactor/tweak Validate Instance In same Context plug-in #5526 + +- Chore/Refactor: Re-use existing select invalid and repair actions +- Enhancement: provide more elaborate PublishValidationError report +- Bugfix: fix "optional" support by using `OptionalPyblishPluginMixin` base class. + + +___ + +
+ + +
+Enhancement: Update houdini main menu #5527 + +This PR adds two updates: +- dynamic main menu +- dynamic asset name and task + + +___ + +
+ + +
+Houdini: Reset FPS when clicking Set Frame Range #5528 + +_Similar to Maya,_ Make `Set Frame Range` resets FPS, issue https://github.com/ynput/OpenPype/issues/5516 + + +___ + +
+ + +
+Enhancement: Deadline plugins optimize, cleanup and fix optional support for validate deadline pools #5531 + +- Fix optional support of validate deadline pools +- Query deadline webservice only once per URL for verification, and once for available deadline pools instead of for every instance +- Use `deadlineUrl` in `instance.data` when validating pools if it is set. +- Code cleanup: Re-use existing `requests_get` implementation + + +___ + +
+ + +
+Chore: PowerShell script for docker build #5535 + +Added PowerShell script to run docker build. + + +___ + +
+ + +
+AYON: Deadline expand userpaths in executables list #5540 + +Expande `~` paths in executables list. + + +___ + +
+ + +
+Chore: Use correct git url #5542 + +Fixed github url in README.md. + + +___ + +
+ + +
+Chore: Create plugin does not expect system settings #5553 + +System settings are not passed to initialization of create plugin initialization (and `apply_settings`). + + +___ + +
+ + +
+Chore: Allow custom Qt scale factor rounding policy #5555 + +Do not force `PassThrough` rounding policy if different policy is defined via env variable. + + +___ + +
+ + +
+Houdini: Fix outdated containers pop-up on opening last workfile on launch #5567 + +Fix Houdini not showing outdated containers pop-up on scene open when launching with last workfile argument + + +___ + +
+ + +
+Houdini: Improve errors e.g. raise PublishValidationError or cosmetics #5568 + +Improve errors e.g. raise PublishValidationError or cosmeticsThis also fixes the Increment Current File plug-in since due to an invalid import it was previously broken + + +___ + +
+ + +
+Fusion: Code updates #5569 + +Update fusion code which contains obsolete code. Removed `switch_ui.py` script from fusion with related script in scripts. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Validate Shape Zero fix repair action + provide informational artist-facing report #5524 + +Refactor to PublishValidationError to allow the RepairAction to work + provide informational report message + + +___ + +
+ + +
+Maya: Fix attribute definitions for `CreateYetiCache` #5574 + +Fix attribute definitions for `CreateYetiCache` + + +___ + +
+ + +
+Max: Optional Renderable Camera Validator for Render Instance #5286 + +Optional validation to check on renderable camera being set up correctly for deadline submission.If not being set up correctly, it wont pass the validation and user can perform repair actions. + + +___ + +
+ + +
+Max: Adding custom modifiers back to the loaded objects #5378 + +The custom parameters OpenpypeData doesn't show in the loaded container when it is being loaded through the loader. + + +___ + +
+ + +
+Houdini: Use default_variant to Houdini Node TAB Creator #5421 + +Use the default variant of the creator plugins on the interactive creator from the TAB node search instead of hard-coding it to `Main`. + + +___ + +
+ + +
+Nuke: adding inherited colorspace from instance #5454 + +Thumbnails are extracted with inherited colorspace collected from rendering write node. + + +___ + +
+ + +
+Add kitsu credentials to deadline publish job #5455 + +This PR hopefully fixes this issue #5440 + + +___ + +
+ + +
+AYON: Fill entities during editorial #5475 + +Fill entities and update template data on instances during extract AYON hierarchy. + + +___ + +
+ + +
+Ftrack: Fix version 0 when integrating to Ftrack - OP-6595 #5477 + +Fix publishing version 0 to Ftrack. + + +___ + +
+ + +
+OCIO: windows unc path support in Nuke and Hiero #5479 + +Hiero and Nuke is not supporting windows unc path formatting in OCIO environment variable. + + +___ + +
+ + +
+Deadline: Added super call to init #5480 + +DL 10.3 requires plugin inheriting from DeadlinePlugin to call super's **init** explicitly. + + +___ + +
+ + +
+Nuke: fixing thumbnail and monitor out root attributes #5483 + +Nuke Root Colorspace settings for Thumbnail and Monitor Out schema was gradually changed between version 12, 13, 14 and we needed to address those changes individually for particular version. + + +___ + +
+ + +
+Nuke: fixing missing `instance_id` error #5484 + +Workfiles with Instances created in old publisher workflow were rising error during converting method since they were missing `instance_id` key introduced in new publisher workflow. + + +___ + +
+ + +
+Nuke: existing frames validator is repairing render target #5486 + +Nuke is now correctly repairing render target after the existing frames validator finds missing frames and repair action is used. + + +___ + +
+ + +
+added UE to extract burnins families #5487 + +This PR fixes missing burnins in reviewables when rendering from UE. +___ + +
+ + +
+Harmony: refresh code for current Deadline #5493 + +- Added support in Deadline Plug-in for new versions of Harmony, in particular version 21 and 22. +- Remove review=False flag on render instance +- Add farm=True flag on render instance +- Fix is_in_tests function call in Harmony Deadline submission plugin +- Force HarmonyOpenPype.py Deadline Python plug-in to py3 +- Fix cosmetics/hound in HarmonyOpenPype.py Deadline Python plug-in + + +___ + +
+ + +
+Publisher: Fix multiselection value #5505 + +Selection of multiple instances in Publisher does not cause that all instances change all publish attributes to the same value. + + +___ + +
+ + +
+Publisher: Avoid warnings on thumbnails if source image also has alpha channel #5510 + +Avoids the following warning from `ExtractThumbnailFromSource`: +``` +// pyblish.ExtractThumbnailFromSource : oiiotool WARNING: -o : Can't save 4 channels to jpeg... saving only R,G,B +``` + + + +___ + +
+ + +
+Update ayon-python-api #5512 + +Update ayon python api and related callbacks. + + +___ + +
+ + +
+Max: Fixing the bug of falling back to use workfile for Arnold or any renderers except Redshift #5520 + +Fix the bug of falling back to use workfile for Arnold + + +___ + +
+ + +
+General: Fix Validate Publish Dir Validator #5534 + +Nonsensical "family" key was used instead of real value (as 'render' etc.) which would result in wrong translation of intermediate family names.Updated docstring. + + +___ + +
+ + +
+have the addons loading respect a custom AYON_ADDONS_DIR #5539 + +When using a custom AYON_ADDONS_DIR environment variable that variable is used in the launcher correctly and downloads and extracts addons to there, however when running Ayon does not respect this environment variable + + +___ + +
+ + +
+Deadline: files on representation cannot be single item list #5545 + +Further logic expects that single item files will be only 'string' not 'list' (eg. repre["files"] = "abc.exr" not repre["files"] = ["abc.exr"].This would cause an issue in ExtractReview later.This could happen if DL rendered single frame file with different frame value. + + +___ + +
+ + +
+Webpublisher: better encode list values for click #5546 + +Targets could be a list, original implementation pushed it as a separate items, it must be added as `--targets webpulish --targets filepublish`.`wepublish_routes` handles triggering from UI, changes in `publish_functions` handle triggering from cmd (for tests, api access). + + +___ + +
+ + +
+Houdini: Introduce imprint function for correct version in hda loader #5548 + +Resolve #5478 + + +___ + +
+ + +
+AYON: Fill entities during editorial (2) #5549 + +Fix changes made in https://github.com/ynput/OpenPype/pull/5475. + + +___ + +
+ + +
+Max: OP Data updates in Loaders #5563 + +Fix the bug on the loaders not being able to load the objects when iterating key and values with the dict.Max prefers list over the list in dict. + + +___ + +
+ + +
+Create Plugins: Better check of overriden '__init__' method #5571 + +Create plugins do not log warning messages about each create plugin because of wrong `__init__` method check. + + +___ + +
+ +### **Merged pull requests** + + +
+Tests: fix unit tests #5533 + +Fixed failing tests.Updated Unreal's validator to match removed general one which had a couple of issues fixed. + + +___ + +
+ + + + ## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4) diff --git a/openpype/version.py b/openpype/version.py index 466f9ce033..d5d46bab0c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5-nightly.5" +__version__ = "3.16.5" diff --git a/pyproject.toml b/pyproject.toml index a07c547123..68fbf19c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.4" # OpenPype +version = "3.16.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 1c667f91f90dbd3f34df971ece4453efc57ff0a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Sep 2023 08:01:23 +0000 Subject: [PATCH 247/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f6d1a25dc2..a35dbf1a17 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.5 - 3.16.5-nightly.5 - 3.16.5-nightly.4 - 3.16.5-nightly.3 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.3 - 3.14.9-nightly.2 - 3.14.9-nightly.1 - - 3.14.8 validations: required: true - type: dropdown From 9f8cd773bbc07e07db0536430735f97ae668fcf7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 6 Sep 2023 10:13:22 +0200 Subject: [PATCH 248/291] use start value instead of current value --- openpype/tools/publisher/widgets/screenshot_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 4ccf920571..170345f170 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -46,7 +46,7 @@ class ScreenMarquee(QtWidgets.QDialog): for screen in QtWidgets.QApplication.screens(): screen.geometryChanged.connect(self._fit_screen_geometry) - self._opacity = fade_anim.currentValue() + self._opacity = fade_anim.startValue() self._click_pos = None self._capture_rect = None From 2315ef84f1325a157f55bf85797d3e9719373392 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 6 Sep 2023 10:13:35 +0200 Subject: [PATCH 249/291] do not start animation on init --- openpype/tools/publisher/widgets/screenshot_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 170345f170..64cccece6c 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -31,7 +31,6 @@ class ScreenMarquee(QtWidgets.QDialog): fade_anim.setEndValue(50) fade_anim.setDuration(200) fade_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic) - fade_anim.start(QtCore.QAbstractAnimation.DeleteWhenStopped) fade_anim.valueChanged.connect(self._on_fade_anim) From 8dd4b70aa3c153ece4cb26fb4ee590742850c990 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 6 Sep 2023 16:52:34 +0200 Subject: [PATCH 250/291] nuke: remove redundant workfile colorspace profiles --- .../defaults/project_settings/nuke.json | 6 +----- .../schemas/schema_nuke_imageio.json | 20 ------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index b736c462ff..7961e77113 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -28,11 +28,7 @@ "colorManagement": "Nuke", "OCIO_config": "nuke-default", "workingSpaceLUT": "linear", - "monitorLut": "sRGB", - "int8Lut": "sRGB", - "int16Lut": "sRGB", - "logLut": "Cineon", - "floatLut": "linear" + "monitorLut": "sRGB" }, "nodes": { "requiredNodes": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json index d4cd332ef8..af826fcf46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json @@ -106,26 +106,6 @@ "type": "text", "key": "monitorLut", "label": "monitor" - }, - { - "type": "text", - "key": "int8Lut", - "label": "8-bit files" - }, - { - "type": "text", - "key": "int16Lut", - "label": "16-bit files" - }, - { - "type": "text", - "key": "logLut", - "label": "log files" - }, - { - "type": "text", - "key": "floatLut", - "label": "float files" } ] } From 359ead27a2e6f637dda576ae1daf1801bf00f348 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 6 Sep 2023 16:53:41 +0200 Subject: [PATCH 251/291] nuke-addon: remove redundant workfile colorspace names --- server_addon/nuke/server/settings/imageio.py | 32 -------------------- 1 file changed, 32 deletions(-) diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index b43017ef8b..3f5ac0b8f9 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -66,22 +66,6 @@ def ocio_configs_switcher_enum(): class WorkfileColorspaceSettings(BaseSettingsModel): """Nuke workfile colorspace preset. """ - """# TODO: enhance settings with host api: - we need to add mapping to resolve properly keys. - Nuke is excpecting camel case key names, - but for better code consistency we need to - be using snake_case: - - color_management = colorManagement - ocio_config = OCIO_config - working_space_name = workingSpaceLUT - monitor_name = monitorLut - monitor_out_name = monitorOutLut - int_8_name = int8Lut - int_16_name = int16Lut - log_name = logLut - float_name = floatLut - """ colorManagement: Literal["Nuke", "OCIO"] = Field( title="Color Management" @@ -100,18 +84,6 @@ class WorkfileColorspaceSettings(BaseSettingsModel): monitorLut: str = Field( title="Monitor" ) - int8Lut: str = Field( - title="8-bit files" - ) - int16Lut: str = Field( - title="16-bit files" - ) - logLut: str = Field( - title="Log files" - ) - floatLut: str = Field( - title="Float files" - ) class ReadColorspaceRulesItems(BaseSettingsModel): @@ -238,10 +210,6 @@ DEFAULT_IMAGEIO_SETTINGS = { "OCIO_config": "nuke-default", "workingSpaceLUT": "linear", "monitorLut": "sRGB", - "int8Lut": "sRGB", - "int16Lut": "sRGB", - "logLut": "Cineon", - "floatLut": "linear" }, "nodes": { "requiredNodes": [ From 135cb285120495c250a8cbbace25a8581445fb1e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 22:17:21 +0200 Subject: [PATCH 252/291] Add deprecation warning to usage of `fname` on Loader plugins --- openpype/pipeline/load/plugins.py | 13 +++++++++++++ openpype/pipeline/load/utils.py | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index f87fb3312d..8acfcfdb6c 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -234,6 +234,19 @@ class LoaderPlugin(list): """ return cls.options or [] + @property + def fname(self): + """Backwards compatibility with deprecation warning""" + + self.log.warning(( + "DEPRECATION WARNING: Source - Loader plugin {}." + " The 'fname' property on the Loader plugin will be removed in" + " future versions of OpenPype. Planned version to drop the support" + " is 3.16.6 or 3.17.0." + ).format(self.__class__.__name__)) + if hasattr(self, "_fname"): + return self._fname + class SubsetLoaderPlugin(LoaderPlugin): """Load subset into host application diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 42418be40e..b10d6032b3 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -318,7 +318,8 @@ def load_with_repre_context( # Backwards compatibility: Originally the loader's __init__ required the # representation context to set `fname` attribute to the filename to load - loader.fname = get_representation_path_from_context(repre_context) + # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0. + loader._fname = get_representation_path_from_context(repre_context) return loader.load(repre_context, name, namespace, options) From f954c877023f902a7ab28d2d86401829883734c0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 22:17:56 +0200 Subject: [PATCH 253/291] Refactor usage of deprecated `self.fname` to new style --- openpype/hosts/blender/plugins/load/load_blend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 99f291a5a7..fa41f4374b 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -119,7 +119,7 @@ class BlendLoader(plugin.AssetLoader): context: Full parenthood of representation to load options: Additional settings dictionary """ - libpath = self.fname + libpath = self.filepath_from_context(context) asset = context["asset"]["name"] subset = context["subset"]["name"] From ba6dfc5eadd1a4d48f92924a2e1bcf3c7d5524f6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:28:39 +0200 Subject: [PATCH 254/291] Remove unused variables + tweak logs --- .../hosts/blender/plugins/load/load_camera_abc.py | 2 +- .../hosts/blender/plugins/load/load_camera_fbx.py | 2 +- .../hosts/blender/plugins/publish/extract_abc.py | 2 -- .../plugins/publish/extract_abc_animation.py | 2 -- .../blender/plugins/publish/extract_camera_abc.py | 5 ----- openpype/hosts/flame/plugins/load/load_clip.py | 1 - .../hosts/flame/plugins/load/load_clip_batch.py | 1 - .../plugins/publish/collect_timeline_instances.py | 1 - .../hosts/harmony/plugins/load/load_template.py | 1 - openpype/hosts/hiero/api/plugin.py | 14 -------------- .../hiero/plugins/publish/collect_clip_effects.py | 2 -- openpype/hosts/houdini/plugins/load/load_bgeo.py | 1 - openpype/hosts/max/plugins/create/create_render.py | 1 - .../hosts/max/plugins/publish/collect_render.py | 1 - .../max/plugins/publish/extract_camera_abc.py | 2 -- .../max/plugins/publish/extract_camera_fbx.py | 3 +-- .../max/plugins/publish/extract_max_scene_raw.py | 3 +-- .../hosts/max/plugins/publish/extract_model.py | 4 +--- .../hosts/max/plugins/publish/extract_model_fbx.py | 5 +---- .../hosts/max/plugins/publish/extract_model_obj.py | 4 +--- .../max/plugins/publish/extract_pointcache.py | 2 -- .../max/plugins/publish/extract_redshift_proxy.py | 3 +-- openpype/hosts/maya/api/plugin.py | 1 - .../maya/plugins/inventory/import_reference.py | 1 - .../hosts/maya/plugins/load/load_multiverse_usd.py | 2 -- openpype/hosts/maya/plugins/load/load_reference.py | 4 ++-- openpype/hosts/maya/plugins/load/load_xgen.py | 2 -- .../plugins/publish/collect_multiverse_look.py | 1 - .../hosts/maya/tools/mayalookassigner/widgets.py | 4 +--- .../hosts/nuke/plugins/load/load_camera_abc.py | 2 -- .../plugins/publish/extract_review_data_lut.py | 1 - .../nuke/plugins/publish/extract_thumbnail.py | 2 -- .../plugins/publish/collect_auto_image.py | 1 - openpype/hosts/resolve/api/plugin.py | 10 ---------- .../plugins/publish/extract_thumbnail.py | 2 -- .../tvpaint/plugins/load/load_reference_image.py | 2 +- .../tvpaint/plugins/publish/extract_sequence.py | 3 --- .../hosts/unreal/plugins/publish/extract_uasset.py | 3 +-- 38 files changed, 13 insertions(+), 90 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_camera_abc.py b/openpype/hosts/blender/plugins/load/load_camera_abc.py index e5afecff66..05d3fb764d 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_abc.py +++ b/openpype/hosts/blender/plugins/load/load_camera_abc.py @@ -100,7 +100,7 @@ class AbcCameraLoader(plugin.AssetLoader): asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name) + self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index b9d05dda0a..3cca6e7fd3 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -103,7 +103,7 @@ class FbxCameraLoader(plugin.AssetLoader): asset_group = bpy.data.objects.new(group_name, object_data=None) avalon_container.objects.link(asset_group) - objects = self._process(libpath, asset_group, group_name) + self._process(libpath, asset_group, group_name) objects = [] nodes = list(asset_group.children) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index f4babc94d3..87159e53f0 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -21,8 +21,6 @@ class ExtractABC(publish.Extractor): filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction self.log.info("Performing extraction..") diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py index e141ccaa44..44b2ba3761 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc_animation.py @@ -20,8 +20,6 @@ class ExtractAnimationABC(publish.Extractor): filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction self.log.info("Performing extraction..") diff --git a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py index a21a59b151..036be7bf3c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera_abc.py @@ -21,16 +21,11 @@ class ExtractCameraABC(publish.Extractor): filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) - context = bpy.context - # Perform extraction self.log.info("Performing extraction..") plugin.deselect_all() - selected = [] - active = None - asset_group = None for obj in instance: if obj.get(AVALON_PROPERTY): diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 338833b449..ca4eab0f63 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -48,7 +48,6 @@ class LoadClip(opfapi.ClipLoader): self.fpd = fproject.current_workspace.desktop # load clip to timeline and get main variables - namespace = namespace version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index ca43b94ee9..1f3a017d72 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -45,7 +45,6 @@ class LoadClipBatch(opfapi.ClipLoader): self.batch = options.get("batch") or flame.batch # load clip to timeline and get main variables - namespace = namespace version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 23fdf5e785..e14f960a2b 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -325,7 +325,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): def _create_shot_instance(self, context, clip_name, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") - asset = data.get("asset") if not master_layer: return diff --git a/openpype/hosts/harmony/plugins/load/load_template.py b/openpype/hosts/harmony/plugins/load/load_template.py index f3c69a9104..a78a1bf1ec 100644 --- a/openpype/hosts/harmony/plugins/load/load_template.py +++ b/openpype/hosts/harmony/plugins/load/load_template.py @@ -82,7 +82,6 @@ class TemplateLoader(load.LoaderPlugin): node = harmony.find_node_by_name(node_name, "GROUP") self_name = self.__class__.__name__ - update_and_replace = False if is_representation_from_latest(representation): self._set_green(node) else: diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 65a4009756..52f96261b2 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -317,20 +317,6 @@ class Spacer(QtWidgets.QWidget): self.setLayout(layout) -def get_reference_node_parents(ref): - """Return all parent reference nodes of reference node - - Args: - ref (str): reference node. - - Returns: - list: The upstream parent reference nodes. - - """ - parents = [] - return parents - - class SequenceLoader(LoaderPlugin): """A basic SequenceLoader for Resolve diff --git a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py index d455ad4a4e..fcb1ab27a0 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py @@ -43,7 +43,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin): if review and review_track_index == _track_index: continue for sitem in sub_track_items: - effect = None # make sure this subtrack item is relative of track item if ((track_item not in sitem.linkedItems()) and (len(sitem.linkedItems()) > 0)): @@ -53,7 +52,6 @@ class CollectClipEffects(pyblish.api.InstancePlugin): continue effect = self.add_effect(_track_index, sitem) - if effect: effects.update(effect) diff --git a/openpype/hosts/houdini/plugins/load/load_bgeo.py b/openpype/hosts/houdini/plugins/load/load_bgeo.py index 22680178c0..489bf944ed 100644 --- a/openpype/hosts/houdini/plugins/load/load_bgeo.py +++ b/openpype/hosts/houdini/plugins/load/load_bgeo.py @@ -34,7 +34,6 @@ class BgeoLoader(load.LoaderPlugin): # 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 diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 235046684e..9cc3c8da8a 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -14,7 +14,6 @@ class CreateRender(plugin.MaxCreator): def create(self, subset_name, instance_data, pre_create_data): from pymxs import runtime as rt - sel_obj = list(rt.selection) file = rt.maxFileName filename, _ = os.path.splitext(file) instance_data["AssetName"] = filename diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 8ee2f43103..2dfa1520a9 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -30,7 +30,6 @@ class CollectRender(pyblish.api.InstancePlugin): asset = get_current_asset_name() files_by_aov = RenderProducts().get_beauty(instance.name) - folder = folder.replace("\\", "/") aovs = RenderProducts().get_aovs(instance.name) files_by_aov.update(aovs) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index b42732e70d..b1918c53e0 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -22,8 +22,6 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): start = float(instance.data.get("frameStartHandle", 1)) end = float(instance.data.get("frameEndHandle", 1)) - container = instance.data["instance_node"] - self.log.info("Extracting Camera ...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py index 06ac3da093..537c88eb4d 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py @@ -19,9 +19,8 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - self.log.info("Extracting Camera ...") + self.log.debug("Extracting Camera ...") stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py index de5db9ab56..a7a889c587 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -18,10 +18,9 @@ class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - container = instance.data["instance_node"] # publish the raw scene for camera - self.log.info("Extracting Raw Max Scene ...") + self.log.debug("Extracting Raw Max Scene ...") stagingdir = self.staging_dir(instance) filename = "{name}.max".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model.py b/openpype/hosts/max/plugins/publish/extract_model.py index c7ecf7efc9..38f4848c5e 100644 --- a/openpype/hosts/max/plugins/publish/extract_model.py +++ b/openpype/hosts/max/plugins/publish/extract_model.py @@ -20,9 +20,7 @@ class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.abc".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_model_fbx.py index 56c2cadd94..fd48ed5007 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_model_fbx.py @@ -20,10 +20,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py index 4fde65cf22..e522b1e7a1 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_obj.py +++ b/openpype/hosts/max/plugins/publish/extract_model_obj.py @@ -20,9 +20,7 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): if not self.is_active(instance.data): return - container = instance.data["instance_node"] - - self.log.info("Extracting Geometry ...") + self.log.debug("Extracting Geometry ...") stagingdir = self.staging_dir(instance) filename = "{name}.obj".format(**instance.data) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index 5a99a8b845..c3de623bc0 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -54,8 +54,6 @@ class ExtractAlembic(publish.Extractor): start = float(instance.data.get("frameStartHandle", 1)) end = float(instance.data.get("frameEndHandle", 1)) - container = instance.data["instance_node"] - self.log.debug("Extracting pointcache ...") parent_dir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index ab569ecbcb..f67ed30c6b 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,11 +16,10 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - container = instance.data["instance_node"] start = int(instance.context.data.get("frameStart")) end = int(instance.context.data.get("frameEnd")) - self.log.info("Extracting Redshift Proxy...") + self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) rs_filename = "{name}.rs".format(**instance.data) rs_filepath = os.path.join(stagingdir, rs_filename) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 3f383fafb8..4032618afb 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -683,7 +683,6 @@ class ReferenceLoader(Loader): loaded_containers.append(container) self._organize_containers(nodes, container) c += 1 - namespace = None return loaded_containers diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py index ecc424209d..3f3b85ba6c 100644 --- a/openpype/hosts/maya/plugins/inventory/import_reference.py +++ b/openpype/hosts/maya/plugins/inventory/import_reference.py @@ -12,7 +12,6 @@ class ImportReference(InventoryAction): color = "#d8d8d8" def process(self, containers): - references = cmds.ls(type="reference") for container in containers: if container["loader"] != "ReferenceLoader": print("Not a reference, skipping") diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index d08fcd904e..cad42b55f9 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -43,8 +43,6 @@ class MultiverseUsdLoader(load.LoaderPlugin): import multiverse # Create the shape - shape = None - transform = None with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 91767249e0..61f337f501 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -205,7 +205,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): cmds.setAttr("{}.selectHandleZ".format(group_name), cz) if family == "rig": - self._post_process_rig(name, namespace, context, options) + self._post_process_rig(namespace, context, options) else: if "translate" in options: if not attach_to_root and new_nodes: @@ -229,7 +229,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): members = get_container_members(container) self._lock_camera_transforms(members) - def _post_process_rig(self, name, namespace, context, options): + def _post_process_rig(self, namespace, context, options): nodes = self[:] create_rig_animation_instance( diff --git a/openpype/hosts/maya/plugins/load/load_xgen.py b/openpype/hosts/maya/plugins/load/load_xgen.py index 323f8d7eda..2ad6ad55bc 100644 --- a/openpype/hosts/maya/plugins/load/load_xgen.py +++ b/openpype/hosts/maya/plugins/load/load_xgen.py @@ -53,8 +53,6 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): ) # Reference xgen. Xgen does not like being referenced in under a group. - new_nodes = [] - with maintained_selection(): nodes = cmds.file( maya_filepath, diff --git a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py index f05fb76d48..bcb979edfc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_multiverse_look.py @@ -281,7 +281,6 @@ class CollectMultiverseLookData(pyblish.api.InstancePlugin): long=True) nodes.update(nodes_of_interest) - files = [] sets = {} instance.data["resources"] = [] publishMipMap = instance.data["publishMipMap"] diff --git a/openpype/hosts/maya/tools/mayalookassigner/widgets.py b/openpype/hosts/maya/tools/mayalookassigner/widgets.py index f2df17e68c..82c37e2104 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/widgets.py +++ b/openpype/hosts/maya/tools/mayalookassigner/widgets.py @@ -90,15 +90,13 @@ class AssetOutliner(QtWidgets.QWidget): def get_all_assets(self): """Add all items from the current scene""" - items = [] with preserve_expanded_rows(self.view): with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) self.add_items(items) - - return len(items) > 0 + return len(items) > 0 def get_selected_assets(self): """Add all selected items from the current scene""" diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index fec4ee556e..2939ceebae 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -112,8 +112,6 @@ class AlembicCameraLoader(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) object_name = container['objectName'] - # get corresponding node - camera_node = nuke.toNode(object_name) # get main variables version_data = version_doc.get("data", {}) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index e4b7b155cd..2a26ed82fb 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -20,7 +20,6 @@ class ExtractReviewDataLut(publish.Extractor): hosts = ["nuke"] def process(self, instance): - families = instance.data["families"] self.log.info("Creating staging dir...") if "representations" in instance.data: staging_dir = instance.data[ diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index d57d55f85d..b20df4ffe2 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -91,8 +91,6 @@ class ExtractThumbnail(publish.Extractor): if collection: # get path - fname = os.path.basename(collection.format( - "{head}{padding}{tail}")) fhead = collection.format("{head}") thumb_fname = list(collection)[mid_frame] diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py index f1d8419608..77f1a3e91f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image.py @@ -16,7 +16,6 @@ class CollectAutoImage(pyblish.api.ContextPlugin): targets = ["automated"] def process(self, context): - family = "image" for instance in context: creator_identifier = instance.data.get("creator_identifier") if creator_identifier and creator_identifier == "auto_image": diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 59c27f29da..e2bd76ffa2 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -413,8 +413,6 @@ class ClipLoader: if self.with_handles: source_in -= handle_start source_out += handle_end - handle_start = 0 - handle_end = 0 # make track item from source in bin as item timeline_item = lib.create_timeline_item( @@ -433,14 +431,6 @@ class ClipLoader: self.data["path"], self.active_bin) _clip_property = media_pool_item.GetClipProperty - # get handles - handle_start = self.data["versionData"].get("handleStart") - handle_end = self.data["versionData"].get("handleEnd") - if handle_start is None: - handle_start = int(self.data["assetData"]["handleStart"]) - if handle_end is None: - handle_end = int(self.data["assetData"]["handleEnd"]) - source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index b99503b3c8..a2afd160fa 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -49,8 +49,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): else: first_filename = files - staging_dir = None - # Convert to jpeg if not yet full_input_path = os.path.join( thumbnail_repre["stagingDir"], first_filename diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py index edc116a8e4..3707ef97aa 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py @@ -171,7 +171,7 @@ class LoadImage(plugin.Loader): george_script = "\n".join(george_script_lines) execute_george_through_file(george_script) - def _remove_container(self, container, members=None): + def _remove_container(self, container): if not container: return representation = container["representation"] diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 8a610cf388..a13a91de46 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -63,7 +63,6 @@ class ExtractSequence(pyblish.api.Extractor): "ignoreLayersTransparency", False ) - family_lowered = instance.data["family"].lower() mark_in = instance.context.data["sceneMarkIn"] mark_out = instance.context.data["sceneMarkOut"] @@ -76,11 +75,9 @@ class ExtractSequence(pyblish.api.Extractor): # Frame start/end may be stored as float frame_start = int(instance.data["frameStart"]) - frame_end = int(instance.data["frameEnd"]) # Handles are not stored per instance but on Context handle_start = instance.context.data["handleStart"] - handle_end = instance.context.data["handleEnd"] scene_bg_color = instance.context.data["sceneBgColor"] diff --git a/openpype/hosts/unreal/plugins/publish/extract_uasset.py b/openpype/hosts/unreal/plugins/publish/extract_uasset.py index 48b62faa97..0dd7ff4a0d 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_uasset.py +++ b/openpype/hosts/unreal/plugins/publish/extract_uasset.py @@ -19,9 +19,8 @@ class ExtractUAsset(publish.Extractor): "umap" if "umap" in instance.data.get("families") else "uasset") ar = unreal.AssetRegistryHelpers.get_asset_registry() - self.log.info("Performing extraction..") + self.log.debug("Performing extraction..") staging_dir = self.staging_dir(instance) - filename = f"{instance.name}.{extension}" members = instance.data.get("members", []) From e79f1ef4b9b48f085f42ac43344404580522d10c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:30:17 +0200 Subject: [PATCH 255/291] Replacing value only if value is in `str` is same as just replacing it --- .../hosts/houdini/plugins/publish/collect_vray_rop.py | 9 ++------- openpype/hosts/maya/api/lib_rendersettings.py | 9 ++------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index d4fe37f993..277f922ba4 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -80,14 +80,9 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): def get_beauty_render_product(self, prefix, suffix=""): """Return the beauty output filename if render element enabled """ + # Remove aov suffix from the product: `prefix.aov_suffix` -> `prefix` aov_parm = ".{}".format(suffix) - beauty_product = None - if aov_parm in prefix: - beauty_product = prefix.replace(aov_parm, "") - else: - beauty_product = prefix - - return beauty_product + return prefix.replace(aov_parm, "") def get_render_element_name(self, node, prefix, suffix=""): """Return the output filename using the AOV prefix and suffix diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index f54633c04d..42cf29d0a7 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -177,12 +177,7 @@ class RenderSettings(object): # list all the aovs all_rs_aovs = cmds.ls(type='RedshiftAOV') for rs_aov in redshift_aovs: - rs_layername = rs_aov - if " " in rs_aov: - rs_renderlayer = rs_aov.replace(" ", "") - rs_layername = "rsAov_{}".format(rs_renderlayer) - else: - rs_layername = "rsAov_{}".format(rs_aov) + rs_layername = "rsAov_{}".format(rs_aov.replace(" ", "")) if rs_layername in all_rs_aovs: continue cmds.rsCreateAov(type=rs_aov) @@ -317,7 +312,7 @@ class RenderSettings(object): separators = [cmds.menuItem(i, query=True, label=True) for i in items] # noqa: E501 try: sep_idx = separators.index(aov_separator) - except ValueError as e: + except ValueError: six.reraise( CreatorError, CreatorError( From c1b305a4462b6e58eede2853a5cd0623cf4ef925 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:31:33 +0200 Subject: [PATCH 256/291] Fix code (`if not found` should not have been nest into the for loop) + simplify logic --- openpype/hosts/max/api/lib_rendersettings.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py index afde5008d5..26e176aa8d 100644 --- a/openpype/hosts/max/api/lib_rendersettings.py +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -37,13 +37,10 @@ class RenderSettings(object): def set_render_camera(self, selection): for sel in selection: # to avoid Attribute Error from pymxs wrapper - found = False if rt.classOf(sel) in rt.Camera.classes: - found = True rt.viewport.setCamera(sel) - break - if not found: - raise RuntimeError("Active Camera not found") + return + raise RuntimeError("Active Camera not found") def render_output(self, container): folder = rt.maxFilePath From 60334621988ee749369d222d11ecd972871a2573 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:32:53 +0200 Subject: [PATCH 257/291] Use asset doc and project doc from instance/context + tweak logic for values --- .../publish/validate_resolution_setting.py | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py index 5fcb843b20..5ac41b10a0 100644 --- a/openpype/hosts/max/plugins/publish/validate_resolution_setting.py +++ b/openpype/hosts/max/plugins/publish/validate_resolution_setting.py @@ -6,11 +6,6 @@ from openpype.pipeline import ( from pymxs import runtime as rt from openpype.hosts.max.api.lib import reset_scene_resolution -from openpype.pipeline.context_tools import ( - get_current_project_asset, - get_current_project -) - class ValidateResolutionSetting(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): @@ -43,22 +38,16 @@ class ValidateResolutionSetting(pyblish.api.InstancePlugin, "on asset or shot.") def get_db_resolution(self, instance): - data = ["data.resolutionWidth", "data.resolutionHeight"] - project_resolution = get_current_project(fields=data) - project_resolution_data = project_resolution["data"] - asset_resolution = get_current_project_asset(fields=data) - asset_resolution_data = asset_resolution["data"] - # Set project resolution - project_width = int( - project_resolution_data.get("resolutionWidth", 1920)) - project_height = int( - project_resolution_data.get("resolutionHeight", 1080)) - width = int( - asset_resolution_data.get("resolutionWidth", project_width)) - height = int( - asset_resolution_data.get("resolutionHeight", project_height)) + asset_doc = instance.data["assetEntity"] + project_doc = instance.context.data["projectEntity"] + for data in [asset_doc["data"], project_doc["data"]]: + if "resolutionWidth" in data and "resolutionHeight" in data: + width = data["resolutionWidth"] + height = data["resolutionHeight"] + return int(width), int(height) - return width, height + # Defaults if not found in asset document or project document + return 1920, 1080 @classmethod def repair(cls, instance): From 91a4ae5dfd39d64db3ded9e98b750b8d1afe22bc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Sep 2023 23:34:01 +0200 Subject: [PATCH 258/291] Remove (not yet deprecated?) `system_settings` from `apply_settings` --- .../hosts/maya/plugins/publish/extract_import_reference.py | 4 ++-- openpype/hosts/maya/plugins/publish/validate_maya_units.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_import_reference.py b/openpype/hosts/maya/plugins/publish/extract_import_reference.py index 9d2ff1a3eb..1fdee28d0c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_import_reference.py +++ b/openpype/hosts/maya/plugins/publish/extract_import_reference.py @@ -30,8 +30,8 @@ class ExtractImportReference(publish.Extractor, tmp_format = "_tmp" @classmethod - def apply_settings(cls, project_setting, system_settings): - cls.active = project_setting["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa + def apply_settings(cls, project_settings): + cls.active = project_settings["deadline"]["publish"]["MayaSubmitDeadline"]["import_reference"] # noqa def process(self, instance): if not self.is_active(instance.data): diff --git a/openpype/hosts/maya/plugins/publish/validate_maya_units.py b/openpype/hosts/maya/plugins/publish/validate_maya_units.py index 1d5619795f..ae6dc093a9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_maya_units.py +++ b/openpype/hosts/maya/plugins/publish/validate_maya_units.py @@ -37,7 +37,7 @@ class ValidateMayaUnits(pyblish.api.ContextPlugin): ) @classmethod - def apply_settings(cls, project_settings, system_settings): + def apply_settings(cls, project_settings): """Apply project settings to creator""" settings = ( project_settings["maya"]["publish"]["ValidateMayaUnits"] From b601da0dbba469c1a6920622218d74ada0f589f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 7 Sep 2023 11:40:59 +0200 Subject: [PATCH 259/291] do not fix folder in representation context --- openpype/client/server/conversion_utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index a6c190a0fc..2dca9ad57d 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -663,8 +663,8 @@ def convert_v4_representation_to_v3(representation): if isinstance(context, six.string_types): context = json.loads(context) - if "folder" in context: - _c_folder = context.pop("folder") + if "asset" not in context and "folder" in context: + _c_folder = context["folder"] context["asset"] = _c_folder["name"] if "product" in context: @@ -959,9 +959,11 @@ def convert_create_representation_to_v4(representation, con): converted_representation["files"] = new_files context = representation["context"] - context["folder"] = { - "name": context.pop("asset", None) - } + if "folder" not in context: + context["folder"] = { + "name": context.get("asset") + } + context["product"] = { "type": context.pop("family", None), "name": context.pop("subset", None), @@ -1285,7 +1287,7 @@ def convert_update_representation_to_v4( if "context" in update_data: context = update_data["context"] - if "asset" in context: + if "folder" not in context and "asset" in context: context["folder"] = {"name": context.pop("asset")} if "family" in context or "subset" in context: From 7cde83933a22437d3ba4a570f3c2a77f0396e810 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 7 Sep 2023 11:50:16 +0200 Subject: [PATCH 260/291] autofix wrong representations --- openpype/client/server/conversion_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index 2dca9ad57d..f67a1ef9c4 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -667,6 +667,9 @@ def convert_v4_representation_to_v3(representation): _c_folder = context["folder"] context["asset"] = _c_folder["name"] + elif "asset" in context and "folder" not in context: + context["folder"] = {"name": context["asset"]} + if "product" in context: _c_product = context.pop("product") context["family"] = _c_product["type"] From 2c0a8d41006c5886d0e05e85c302f00b70c8e8f9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 7 Sep 2023 14:47:27 +0200 Subject: [PATCH 261/291] adding default factory to list objects --- .../nuke/server/settings/create_plugins.py | 4 ++++ server_addon/nuke/server/settings/imageio.py | 2 ++ .../nuke/server/settings/publish_plugins.py | 3 +++ .../nuke/server/settings/workfile_builder.py | 20 ++++++++++++++----- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server_addon/nuke/server/settings/create_plugins.py b/server_addon/nuke/server/settings/create_plugins.py index 0bbae4ee77..b89188a77a 100644 --- a/server_addon/nuke/server/settings/create_plugins.py +++ b/server_addon/nuke/server/settings/create_plugins.py @@ -37,6 +37,7 @@ class PrenodeModel(BaseSettingsModel): - We could not support v3 style of settings. """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -66,6 +67,7 @@ class CreateWriteRenderModel(BaseSettingsModel): (we could not support v3 style of settings) """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) @@ -95,6 +97,7 @@ class CreateWritePrerenderModel(BaseSettingsModel): (we could not support v3 style of settings) """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) @@ -124,6 +127,7 @@ class CreateWriteImageModel(BaseSettingsModel): (we could not support v3 style of settings) """ prenodes: list[PrenodeModel] = Field( + default_factory=list, title="Preceding nodes", ) diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index b43017ef8b..16f9bd309a 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -14,6 +14,7 @@ class NodesModel(BaseSettingsModel): """ _layout = "expanded" plugins: list[str] = Field( + default_factory=list, title="Used in plugins" ) # TODO: rename `nukeNodeClass` to `nuke_node_class` @@ -25,6 +26,7 @@ class NodesModel(BaseSettingsModel): in nuke integration. We could not support v3 style of settings. """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index 7e898f8c9a..de21870d5f 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -46,6 +46,7 @@ class NodeModel(BaseSettingsModel): - We could not support v3 style of settings. """ knobs: list[KnobModel] = Field( + default_factory=list, title="Knobs", ) @@ -105,6 +106,7 @@ class ExtractThumbnailModel(BaseSettingsModel): """ nodes: list[NodeModel] = Field( + default_factory=list, title="Nodes (deprecated)" ) reposition_nodes: list[ThumbnailRepositionNodeModel] = Field( @@ -177,6 +179,7 @@ class ExtractReviewDataMovModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") viewer_lut_raw: bool = Field(title="Viewer lut raw") outputs: list[BakingStreamModel] = Field( + default_factory=list, title="Baking streams" ) diff --git a/server_addon/nuke/server/settings/workfile_builder.py b/server_addon/nuke/server/settings/workfile_builder.py index ee67c7c16a..e9ac0db2a0 100644 --- a/server_addon/nuke/server/settings/workfile_builder.py +++ b/server_addon/nuke/server/settings/workfile_builder.py @@ -48,20 +48,30 @@ class BuilderProfileModel(BaseSettingsModel): title="Task names" ) current_context: list[BuilderProfileItemModel] = Field( - title="Current context") + default_factory=list, + title="Current context" + ) linked_assets: list[BuilderProfileItemModel] = Field( - title="Linked assets/shots") + default_factory=list, + title="Linked assets/shots" + ) class WorkfileBuilderModel(BaseSettingsModel): create_first_version: bool = Field( title="Create first workfile") custom_templates: list[CustomTemplateModel] = Field( - title="Custom templates") + default_factory=list, + title="Custom templates" + ) builder_on_start: bool = Field( - title="Run Builder at first workfile") + default=False, + title="Run Builder at first workfile" + ) profiles: list[BuilderProfileModel] = Field( - title="Builder profiles") + default_factory=list, + title="Builder profiles" + ) DEFAULT_WORKFILE_BUILDER_SETTINGS = { From ee050fe63ba6c498fd736bcf2e1bcb1226e49ed9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 7 Sep 2023 14:47:47 +0200 Subject: [PATCH 262/291] clearing todos and improving docstrings --- server_addon/nuke/server/settings/common.py | 6 ------ .../nuke/server/settings/create_plugins.py | 19 ------------------- server_addon/nuke/server/settings/dirmap.py | 13 ------------- server_addon/nuke/server/settings/imageio.py | 15 ++++----------- .../nuke/server/settings/loader_plugins.py | 8 -------- server_addon/nuke/server/settings/main.py | 4 +--- .../nuke/server/settings/publish_plugins.py | 17 ----------------- .../nuke/server/settings/scriptsmenu.py | 1 - .../settings/templated_workfile_build.py | 1 + .../nuke/server/settings/workfile_builder.py | 2 ++ 10 files changed, 8 insertions(+), 78 deletions(-) diff --git a/server_addon/nuke/server/settings/common.py b/server_addon/nuke/server/settings/common.py index 700f01f3dc..2bc3c9be81 100644 --- a/server_addon/nuke/server/settings/common.py +++ b/server_addon/nuke/server/settings/common.py @@ -89,12 +89,6 @@ knob_types_enum = [ class KnobModel(BaseSettingsModel): - """# TODO: new data structure - - v3 was having type, name, value but - ayon is not able to make it the same. Current model is - defining `type` as `text` and instead of `value` the key is `text`. - So if `type` is `boolean` then key is `boolean` (value). - """ _layout = "expanded" type: str = Field( diff --git a/server_addon/nuke/server/settings/create_plugins.py b/server_addon/nuke/server/settings/create_plugins.py index b89188a77a..80aec51ae0 100644 --- a/server_addon/nuke/server/settings/create_plugins.py +++ b/server_addon/nuke/server/settings/create_plugins.py @@ -16,13 +16,10 @@ def instance_attributes_enum(): class PrenodeModel(BaseSettingsModel): - # TODO: missing in host api - # - good for `dependency` name: str = Field( title="Node name" ) - # TODO: `nodeclass` should be renamed to `nuke_node_class` nodeclass: str = Field( "", title="Node class" @@ -32,10 +29,6 @@ class PrenodeModel(BaseSettingsModel): title="Incoming dependency" ) - """# TODO: Changes in host api: - - Need complete rework of knob types in nuke integration. - - We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -62,10 +55,6 @@ class CreateWriteRenderModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( default_factory=list, title="Preceding nodes", @@ -92,10 +81,6 @@ class CreateWritePrerenderModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( default_factory=list, title="Preceding nodes", @@ -122,10 +107,6 @@ class CreateWriteImageModel(BaseSettingsModel): title="Instance attributes" ) - """# TODO: Changes in host api: - - prenodes key was originally dict and now is list - (we could not support v3 style of settings) - """ prenodes: list[PrenodeModel] = Field( default_factory=list, title="Preceding nodes", diff --git a/server_addon/nuke/server/settings/dirmap.py b/server_addon/nuke/server/settings/dirmap.py index 2da6d7bf60..7e3c443957 100644 --- a/server_addon/nuke/server/settings/dirmap.py +++ b/server_addon/nuke/server/settings/dirmap.py @@ -25,19 +25,6 @@ class DirmapSettings(BaseSettingsModel): ) -"""# TODO: -nuke is having originally implemented -following data inputs: - -"nuke-dirmap": { - "enabled": false, - "paths": { - "source-path": [], - "destination-path": [] - } -} -""" - DEFAULT_DIRMAP_SETTINGS = { "enabled": False, "paths": { diff --git a/server_addon/nuke/server/settings/imageio.py b/server_addon/nuke/server/settings/imageio.py index 16f9bd309a..44bb72769e 100644 --- a/server_addon/nuke/server/settings/imageio.py +++ b/server_addon/nuke/server/settings/imageio.py @@ -9,22 +9,15 @@ from .common import KnobModel class NodesModel(BaseSettingsModel): - """# TODO: This needs to be somehow labeled in settings panel - or at least it could show gist of configuration - """ _layout = "expanded" plugins: list[str] = Field( default_factory=list, title="Used in plugins" ) - # TODO: rename `nukeNodeClass` to `nuke_node_class` nukeNodeClass: str = Field( title="Nuke Node Class", ) - """ # TODO: Need complete rework of knob types - in nuke integration. We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -172,7 +165,7 @@ class ImageIOSettings(BaseSettingsModel): _isGroup: bool = True """# TODO: enhance settings with host api: - to restruture settings for simplification. + to restructure settings for simplification. now: nuke/imageio/viewer/viewerProcess future: nuke/imageio/viewer @@ -195,7 +188,7 @@ class ImageIOSettings(BaseSettingsModel): ) """# TODO: enhance settings with host api: - to restruture settings for simplification. + to restructure settings for simplification. now: nuke/imageio/baking/viewerProcess future: nuke/imageio/baking @@ -217,9 +210,9 @@ class ImageIOSettings(BaseSettingsModel): title="Nodes" ) """# TODO: enhance settings with host api: - - old settings are using `regexInputs` key but we + - [ ] old settings are using `regexInputs` key but we need to rename to `regex_inputs` - - no need for `inputs` middle part. It can stay + - [ ] no need for `inputs` middle part. It can stay directly on `regex_inputs` """ regexInputs: RegexInputsModel = Field( diff --git a/server_addon/nuke/server/settings/loader_plugins.py b/server_addon/nuke/server/settings/loader_plugins.py index 6db381bffb..51e2c2149b 100644 --- a/server_addon/nuke/server/settings/loader_plugins.py +++ b/server_addon/nuke/server/settings/loader_plugins.py @@ -6,10 +6,6 @@ class LoadImageModel(BaseSettingsModel): enabled: bool = Field( title="Enabled" ) - """# TODO: v3 api used `_representation` - New api is hiding it so it had to be renamed - to `representations_include` - """ representations_include: list[str] = Field( default_factory=list, title="Include representations" @@ -33,10 +29,6 @@ class LoadClipModel(BaseSettingsModel): enabled: bool = Field( title="Enabled" ) - """# TODO: v3 api used `_representation` - New api is hiding it so it had to be renamed - to `representations_include` - """ representations_include: list[str] = Field( default_factory=list, title="Include representations" diff --git a/server_addon/nuke/server/settings/main.py b/server_addon/nuke/server/settings/main.py index 4687d48ac9..cdaaa3a9e2 100644 --- a/server_addon/nuke/server/settings/main.py +++ b/server_addon/nuke/server/settings/main.py @@ -59,9 +59,7 @@ class NukeSettings(BaseSettingsModel): default_factory=ImageIOSettings, title="Color Management (imageio)", ) - """# TODO: fix host api: - - rename `nuke-dirmap` to `dirmap` was inevitable - """ + dirmap: DirmapSettings = Field( default_factory=DirmapSettings, title="Nuke Directory Mapping", diff --git a/server_addon/nuke/server/settings/publish_plugins.py b/server_addon/nuke/server/settings/publish_plugins.py index de21870d5f..c78685534f 100644 --- a/server_addon/nuke/server/settings/publish_plugins.py +++ b/server_addon/nuke/server/settings/publish_plugins.py @@ -28,11 +28,9 @@ def nuke_product_types_enum(): class NodeModel(BaseSettingsModel): - # TODO: missing in host api name: str = Field( title="Node name" ) - # TODO: `nodeclass` rename to `nuke_node_class` nodeclass: str = Field( "", title="Node class" @@ -41,10 +39,6 @@ class NodeModel(BaseSettingsModel): "", title="Incoming dependency" ) - """# TODO: Changes in host api: - - Need complete rework of knob types in nuke integration. - - We could not support v3 style of settings. - """ knobs: list[KnobModel] = Field( default_factory=list, title="Knobs", @@ -100,10 +94,6 @@ class ExtractThumbnailModel(BaseSettingsModel): use_rendered: bool = Field(title="Use rendered images") bake_viewer_process: bool = Field(title="Bake view process") bake_viewer_input_process: bool = Field(title="Bake viewer input process") - """# TODO: needs to rewrite from v3 to ayon - - `nodes` in v3 was dict but now `prenodes` is list of dict - - also later `nodes` should be `prenodes` - """ nodes: list[NodeModel] = Field( default_factory=list, @@ -216,12 +206,6 @@ class ExctractSlateFrameParamModel(BaseSettingsModel): class ExtractSlateFrameModel(BaseSettingsModel): viewer_lut_raw: bool = Field(title="Viewer lut raw") - """# TODO: v3 api different model: - - not possible to replicate v3 model: - {"name": [bool, str]} - - not it is: - {"name": {"enabled": bool, "template": str}} - """ key_value_mapping: ExctractSlateFrameParamModel = Field( title="Key value mapping", default_factory=ExctractSlateFrameParamModel @@ -290,7 +274,6 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Slate Frame", default_factory=ExtractSlateFrameModel ) - # TODO: plugin should be renamed - `workfile` not `script` IncrementScriptVersion: IncrementScriptVersionModel = Field( title="Increment Workfile Version", default_factory=IncrementScriptVersionModel, diff --git a/server_addon/nuke/server/settings/scriptsmenu.py b/server_addon/nuke/server/settings/scriptsmenu.py index 9d1c32ebac..0b2d660da5 100644 --- a/server_addon/nuke/server/settings/scriptsmenu.py +++ b/server_addon/nuke/server/settings/scriptsmenu.py @@ -17,7 +17,6 @@ class ScriptsmenuSettings(BaseSettingsModel): """Nuke script menu project settings.""" _isGroup = True - # TODO: in api rename key `name` to `menu_name` name: str = Field(title="Menu Name") definition: list[ScriptsmenuSubmodel] = Field( default_factory=list, diff --git a/server_addon/nuke/server/settings/templated_workfile_build.py b/server_addon/nuke/server/settings/templated_workfile_build.py index e0245c8d06..0899be841e 100644 --- a/server_addon/nuke/server/settings/templated_workfile_build.py +++ b/server_addon/nuke/server/settings/templated_workfile_build.py @@ -28,6 +28,7 @@ class TemplatedWorkfileProfileModel(BaseSettingsModel): class TemplatedWorkfileBuildModel(BaseSettingsModel): + """Settings for templated workfile builder.""" profiles: list[TemplatedWorkfileProfileModel] = Field( default_factory=list ) diff --git a/server_addon/nuke/server/settings/workfile_builder.py b/server_addon/nuke/server/settings/workfile_builder.py index e9ac0db2a0..3ae3b08788 100644 --- a/server_addon/nuke/server/settings/workfile_builder.py +++ b/server_addon/nuke/server/settings/workfile_builder.py @@ -58,6 +58,8 @@ class BuilderProfileModel(BaseSettingsModel): class WorkfileBuilderModel(BaseSettingsModel): + """[deprecated] use Template Workfile Build Settings instead. + """ create_first_version: bool = Field( title="Create first workfile") custom_templates: list[CustomTemplateModel] = Field( From 0836b21116690fa1c7f11dfb0c5e24391a558d6a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 14:23:51 +0200 Subject: [PATCH 263/291] Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed - Collect the rig sets only once (I've ordered it before Collect History so that the instance still contains less node, as an optimization) - Also fixes a hard error when `out_SET` is not found, instead now only relevant `PublishValidationError` are raised to generate a nice report --- .../maya/plugins/publish/collect_rig_sets.py | 39 +++++++++++++++++ .../plugins/publish/validate_rig_contents.py | 42 +++++++++++++------ .../publish/validate_rig_controllers.py | 36 +++++++++++----- ...idate_rig_controllers_arnold_attributes.py | 6 +-- .../publish/validate_rig_out_set_node_ids.py | 11 +++-- .../publish/validate_rig_output_ids.py | 5 ++- 6 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/collect_rig_sets.py diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py new file mode 100644 index 0000000000..36a4211af1 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py @@ -0,0 +1,39 @@ +import pyblish.api +from maya import cmds + + +class CollectRigSets(pyblish.api.InstancePlugin): + """Ensure rig contains pipeline-critical content + + Every rig must contain at least two object sets: + "controls_SET" - Set of all animatable controls + "out_SET" - Set of all cacheable meshes + + """ + + order = pyblish.api.CollectorOrder + 0.05 + label = "Collect Rig Sets" + hosts = ["maya"] + families = ["rig"] + + accepted_output = ["mesh", "transform"] + accepted_controllers = ["transform"] + + def process(self, instance): + + # Find required sets by suffix + searching = {"controls_SET", "out_SET"} + found = {} + for node in cmds.ls(instance, exactType="objectSet"): + for suffix in searching: + if node.endswith(suffix): + found[suffix] = node + searching.remove(suffix) + break + if not searching: + break + + self.log.debug("Found sets: {}".format(found)) + rig_sets = instance.data.setdefault("rig_sets", {}) + for name, objset in found.items(): + rig_sets[name] = objset diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 7b5392f8f9..23f031a5db 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -2,7 +2,9 @@ import pyblish.api from maya import cmds from openpype.pipeline.publish import ( - PublishValidationError, ValidateContentsOrder) + PublishValidationError, + ValidateContentsOrder +) class ValidateRigContents(pyblish.api.InstancePlugin): @@ -24,31 +26,45 @@ class ValidateRigContents(pyblish.api.InstancePlugin): def process(self, instance): - objectsets = ("controls_SET", "out_SET") - missing = [obj for obj in objectsets if obj not in instance] - assert not missing, ("%s is missing %s" % (instance, missing)) + # Find required sets by suffix + required = ["controls_SET", "out_SET"] + missing = [ + key for key in required if key not in instance.data["rig_sets"] + ] + if missing: + raise PublishValidationError( + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) + + controls_set = instance.data["rig_sets"]["controls_SET"] + out_set = instance.data["rig_sets"]["out_SET"] # Ensure there are at least some transforms or dag nodes # in the rig instance set_members = instance.data['setMembers'] if not cmds.ls(set_members, type="dagNode", long=True): raise PublishValidationError( - ("No dag nodes in the pointcache instance. " - "(Empty instance?)")) + "No dag nodes in the pointcache instance. " + "(Empty instance?)" + ) # Ensure contents in sets and retrieve long path for all objects - output_content = cmds.sets("out_SET", query=True) or [] - assert output_content, "Must have members in rig out_SET" + output_content = cmds.sets(out_set, query=True) or [] + if not output_content: + raise PublishValidationError("Must have members in rig out_SET") output_content = cmds.ls(output_content, long=True) - controls_content = cmds.sets("controls_SET", query=True) or [] - assert controls_content, "Must have members in rig controls_SET" + controls_content = cmds.sets(controls_set, query=True) or [] + if not controls_content: + raise PublishValidationError( + "Must have members in rig controls_SET" + ) controls_content = cmds.ls(controls_content, long=True) # Validate members are inside the hierarchy from root node - root_node = cmds.ls(set_members, assemblies=True) - hierarchy = cmds.listRelatives(root_node, allDescendents=True, - fullPath=True) + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes hierarchy = set(hierarchy) invalid_hierarchy = [] diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 7bbf4257ab..a3828f871b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -52,22 +52,30 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError('{} failed, see log ' - 'information'.format(self.label)) + raise PublishValidationError( + '{} failed, see log information'.format(self.label) + ) @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] - controls = cmds.sets(controllers_sets, query=True) - assert controls, "Must have 'controls_SET' in rig instance" + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: + cls.log.error( + "Must have 'controls_SET' in rig instance" + ) + return [instance.data["instance_node"]] + + controls = cmds.sets(controls_set, query=True) # Ensure all controls are within the top group lookup = set(instance[:]) - assert all(control in lookup for control in cmds.ls(controls, - long=True)), ( - "All controls must be inside the rig's group." - ) + if not all(control in lookup for control in cmds.ls(controls, + long=True)): + cls.log.error( + "All controls must be inside the rig's group." + ) + return [controls_set] # Validate all controls has_connections = list() @@ -181,9 +189,17 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: + cls.log.error( + "Unable to repair because no 'controls_SET' found in rig " + "instance: {}".format(instance) + ) + return + # Use a single undo chunk with undo_chunk(): - controls = cmds.sets("controls_SET", query=True) + controls = cmds.sets(controls_set, query=True) for control in controls: # Lock visibility diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py index 842c1de01b..03f6a5f1ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py @@ -56,11 +56,11 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controllers_sets = [i for i in instance if i == "controls_SET"] - if not controllers_sets: + controls_set = instance.data["rig_sets"].get("controls_SET") + if not controls_set: return [] - controls = cmds.sets(controllers_sets, query=True) or [] + controls = cmds.sets(controls_set, query=True) or [] if not controls: return [] diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 39f0941faa..fbd510c683 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -38,16 +38,19 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): # if a deformer has been created on the shape invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Nodes found with mismatching " - "IDs: {0}".format(invalid)) + raise PublishValidationError( + "Nodes found with mismatching IDs: {0}".format(invalid) + ) @classmethod def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" - invalid = [] + out_set = instance.data["rig_sets"].get("out_SET") + if not out_set: + return [] - out_set = next(x for x in instance if x.endswith("out_SET")) + invalid = [] members = cmds.sets(out_set, query=True) shapes = cmds.ls(members, dag=True, diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index cbc750bace..24fb36eb8b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,10 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = next(x for x in instance if "out_SET" in x) + out_set = instance.data["rig_sets"].get("out_SET") + if not out_set: + instance.data["mismatched_output_ids"] = invalid + return invalid instance_nodes = cmds.sets(out_set, query=True, nodesOnly=True) instance_nodes = cmds.ls(instance_nodes, long=True) From a492addc54691fd23ce462cd06284809a3edeaa0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 15:55:28 +0200 Subject: [PATCH 264/291] Reduce artist-facing logs for component integration for Ftrack + tweak "Comment is not set" log also for Kitsu to debug level --- .../ftrack/plugins/publish/integrate_ftrack_api.py | 12 ++++++------ .../plugins/publish/integrate_ftrack_description.py | 2 +- .../ftrack/plugins/publish/integrate_ftrack_note.py | 6 +++--- .../kitsu/plugins/publish/integrate_kitsu_note.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index 4d474fab10..858c0bb2d6 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -27,8 +27,8 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): def process(self, instance): component_list = instance.data.get("ftrackComponentsList") if not component_list: - self.log.info( - "Instance don't have components to integrate to Ftrack." + self.log.debug( + "Instance doesn't have components to integrate to Ftrack." " Skipping." ) return @@ -37,7 +37,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): task_entity, parent_entity = self.get_instance_entities( instance, context) if parent_entity is None: - self.log.info(( + self.log.debug(( "Skipping ftrack integration. Instance \"{}\" does not" " have specified ftrack entities." ).format(str(instance))) @@ -323,7 +323,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): "type_id": asset_type_id, "context_id": parent_id } - self.log.info("Created new Asset with data: {}.".format(asset_data)) + self.log.debug("Created new Asset with data: {}.".format(asset_data)) session.create("Asset", asset_data) session.commit() return self._query_asset(session, asset_name, asset_type_id, parent_id) @@ -384,7 +384,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if comment: new_asset_version_data["comment"] = comment - self.log.info("Created new AssetVersion with data {}".format( + self.log.debug("Created new AssetVersion with data {}".format( new_asset_version_data )) session.create("AssetVersion", new_asset_version_data) @@ -555,7 +555,7 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): location=location ) data["component"] = component_entity - self.log.info( + self.log.debug( ( "Created new Component with path: {0}, data: {1}," " metadata: {2}, location: {3}" diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py index 6ed02bc8b6..ceaff8ff54 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_description.py @@ -40,7 +40,7 @@ class IntegrateFtrackDescription(pyblish.api.InstancePlugin): comment = instance.data["comment"] if not comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is set to `{}`".format(comment)) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py index 6e82897d89..10b7932cdf 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_note.py @@ -47,7 +47,7 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): app_label = context.data["appLabel"] comment = instance.data["comment"] if not comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is set to `{}`".format(comment)) @@ -127,14 +127,14 @@ class IntegrateFtrackNote(pyblish.api.InstancePlugin): note_text = StringTemplate.format_template(template, format_data) if not note_text.solved: - self.log.warning(( + self.log.debug(( "Note template require more keys then can be provided." "\nTemplate: {}\nMissing values for keys:{}\nData: {}" ).format(template, note_text.missing_keys, format_data)) continue if not note_text: - self.log.info(( + self.log.debug(( "Note for AssetVersion {} would be empty. Skipping." "\nTemplate: {}\nData: {}" ).format(asset_version["id"], template, format_data)) diff --git a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py index 6e5dd056f3..b66e1f01e0 100644 --- a/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py +++ b/openpype/modules/kitsu/plugins/publish/integrate_kitsu_note.py @@ -121,7 +121,7 @@ class IntegrateKitsuNote(pyblish.api.ContextPlugin): publish_comment = self.format_publish_comment(instance) if not publish_comment: - self.log.info("Comment is not set.") + self.log.debug("Comment is not set.") else: self.log.debug("Comment is `{}`".format(publish_comment)) From 3a02964af5e4c2c9aac5da4770e59aeef913f98a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 15:58:28 +0200 Subject: [PATCH 265/291] Do not show debug log about ffmpeg probe in artist-facing report --- openpype/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 2bae28786e..6e323f55c1 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -724,7 +724,7 @@ def get_ffprobe_data(path_to_file, logger=None): """ if not logger: logger = logging.getLogger(__name__) - logger.info( + logger.debug( "Getting information about input \"{}\".".format(path_to_file) ) ffprobe_args = get_ffmpeg_tool_args("ffprobe") From 2919d241da617229efa47c9ae52854633e0100c4 Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Thu, 7 Sep 2023 19:30:10 +0300 Subject: [PATCH 266/291] move import inside --- .../hosts/houdini/plugins/create/create_review.py | 12 ++++-------- .../plugins/publish/validate_review_colorspace.py | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_review.py b/openpype/hosts/houdini/plugins/create/create_review.py index c087c54f6c..60c34a358b 100644 --- a/openpype/hosts/houdini/plugins/create/create_review.py +++ b/openpype/hosts/houdini/plugins/create/create_review.py @@ -2,7 +2,6 @@ """Creator plugin for creating openGL reviews.""" from openpype.hosts.houdini.api import plugin from openpype.lib import EnumDef, BoolDef, NumberDef -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou @@ -87,7 +86,8 @@ class CreateReview(plugin.HoudiniCreator): # Set OCIO Colorspace to the default output colorspace # if there's OCIO - self.set_colorcorrect_to_default_view_space(instance_node) + if os.getenv("OCIO"): + self.set_colorcorrect_to_default_view_space(instance_node) to_lock = ["id", "family"] @@ -134,13 +134,9 @@ class CreateReview(plugin.HoudiniCreator): def set_colorcorrect_to_default_view_space(self, instance_node): """Set ociocolorspace to the default output space.""" + from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa - if os.getenv("OCIO") is None: - # No OCIO, skip setting ociocolorspace - return - - # if there's OCIO then set Color Correction parameter - # to OpenColorIO + # set Color Correction parameter to OpenColorIO instance_node.setParms({"colorcorrect": 2}) # Get default view space for ociocolorspace parm. diff --git a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py index 61c3a755d0..03ecd1b052 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py +++ b/openpype/hosts/houdini/plugins/publish/validate_review_colorspace.py @@ -6,7 +6,6 @@ from openpype.pipeline import ( ) from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api.action import SelectROPAction -from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa import os import hou @@ -69,6 +68,7 @@ class ValidateReviewColorspace(pyblish.api.InstancePlugin, It is a helper action more than a repair action, used to set colorspace on opengl node to the default view. """ + from openpype.hosts.houdini.api.colorspace import get_default_display_view_colorspace # noqa rop_node = hou.node(instance.data["instance_node"]) From d2f63f0cd489b490111c4fdba4916bfb2c1e2ad0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 7 Sep 2023 23:27:30 +0200 Subject: [PATCH 267/291] Fix look assigner showing no meshes if 'not found' representations are present --- openpype/hosts/maya/tools/mayalookassigner/commands.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/tools/mayalookassigner/commands.py b/openpype/hosts/maya/tools/mayalookassigner/commands.py index a1290aa68d..5cc4f84931 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/commands.py +++ b/openpype/hosts/maya/tools/mayalookassigner/commands.py @@ -138,8 +138,13 @@ def create_items_from_nodes(nodes): asset_doc = asset_docs_by_id.get(asset_id) # Skip if asset id is not found if not asset_doc: - log.warning("Id not found in the database, skipping '%s'." % _id) - log.warning("Nodes: %s" % id_nodes) + log.warning( + "Id found on {num} nodes for which no asset is found database," + " skipping '{asset_id}'".format( + num=len(nodes), + asset_id=asset_id + ) + ) continue # Collect available look subsets for this asset From 076d16a50d21ceb1edfbc806b20554a971c01b40 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:45:00 +0200 Subject: [PATCH 268/291] Workfiles tool: Refactor workfiles tool (for AYON) (#5550) * ayon workfiles tool initial commit * separated models into smaller files * workfile can be listed and opened * added browse logic * added TODO for helper functions * modified abstract controller * implemented required methods * base of save dialog * added project settings to controller * set context of side panel on init * implemented save as dialog * cleanup expected selection * unify controller variable name * base of published workfiles * working published workfile copy * added more missing features from workfiles tool * Changed size policy of buttons to fill space vertically * added overlay messages * moved objects to abstraction * moved 'window.py' to widgets * small modifications in widgets * get_workfile_info returns object * filled docstrings in abstractions * finishing touches * backwards compatible work with host * close window on successfull open * remove indentation completelly * added style for overlay label * added handling of invalid host in controller * added overlay with message if host is not valid * added missing feature of disabled save * use ayon_workfiles in ayon mode * cleanup * hound fixes * use asset doc for 'change_current_context' * added duplication action * removed unused attributes and methods * refresh workarea view on save as finished * support host integrations without 'HostBase' * fix 'filepath' fill * reset item cache on save * do not handle filepath in prepare workfile * rename '_create_workfile_doc' > '_create_workfile_info_entity' * fill comment before formatting * fix column count by not calling 'clear' * more explicit name of method * use 'setHeaderData' to define header labels * mimic changes from workarea widget in published widget --- openpype/style/style.css | 4 + openpype/tools/ayon_workfiles/__init__.py | 0 openpype/tools/ayon_workfiles/abstract.py | 984 ++++++++++++++++++ openpype/tools/ayon_workfiles/control.py | 642 ++++++++++++ .../tools/ayon_workfiles/models/__init__.py | 10 + .../tools/ayon_workfiles/models/hierarchy.py | 225 ++++ .../tools/ayon_workfiles/models/selection.py | 91 ++ .../tools/ayon_workfiles/models/workfiles.py | 711 +++++++++++++ .../tools/ayon_workfiles/widgets/__init__.py | 6 + .../tools/ayon_workfiles/widgets/constants.py | 7 + .../ayon_workfiles/widgets/files_widget.py | 398 +++++++ .../widgets/files_widget_published.py | 378 +++++++ .../widgets/files_widget_workarea.py | 380 +++++++ .../ayon_workfiles/widgets/folders_widget.py | 324 ++++++ .../ayon_workfiles/widgets/save_as_dialog.py | 351 +++++++ .../ayon_workfiles/widgets/side_panel.py | 163 +++ .../ayon_workfiles/widgets/tasks_widget.py | 420 ++++++++ .../tools/ayon_workfiles/widgets/utils.py | 94 ++ .../tools/ayon_workfiles/widgets/window.py | 400 +++++++ openpype/tools/utils/host_tools.py | 30 +- 20 files changed, 5610 insertions(+), 8 deletions(-) create mode 100644 openpype/tools/ayon_workfiles/__init__.py create mode 100644 openpype/tools/ayon_workfiles/abstract.py create mode 100644 openpype/tools/ayon_workfiles/control.py create mode 100644 openpype/tools/ayon_workfiles/models/__init__.py create mode 100644 openpype/tools/ayon_workfiles/models/hierarchy.py create mode 100644 openpype/tools/ayon_workfiles/models/selection.py create mode 100644 openpype/tools/ayon_workfiles/models/workfiles.py create mode 100644 openpype/tools/ayon_workfiles/widgets/__init__.py create mode 100644 openpype/tools/ayon_workfiles/widgets/constants.py create mode 100644 openpype/tools/ayon_workfiles/widgets/files_widget.py create mode 100644 openpype/tools/ayon_workfiles/widgets/files_widget_published.py create mode 100644 openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py create mode 100644 openpype/tools/ayon_workfiles/widgets/folders_widget.py create mode 100644 openpype/tools/ayon_workfiles/widgets/save_as_dialog.py create mode 100644 openpype/tools/ayon_workfiles/widgets/side_panel.py create mode 100644 openpype/tools/ayon_workfiles/widgets/tasks_widget.py create mode 100644 openpype/tools/ayon_workfiles/widgets/utils.py create mode 100644 openpype/tools/ayon_workfiles/widgets/window.py diff --git a/openpype/style/style.css b/openpype/style/style.css index 5ce55aa658..ca368f84f8 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1427,6 +1427,10 @@ CreateNextPageOverlay { background: rgba(0, 0, 0, 127); } +#OverlayFrameLabel { + font-size: 15pt; +} + #BreadcrumbsPathInput { padding: 2px; font-size: 9pt; diff --git a/openpype/tools/ayon_workfiles/__init__.py b/openpype/tools/ayon_workfiles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/ayon_workfiles/abstract.py b/openpype/tools/ayon_workfiles/abstract.py new file mode 100644 index 0000000000..e30a2c2499 --- /dev/null +++ b/openpype/tools/ayon_workfiles/abstract.py @@ -0,0 +1,984 @@ +import os +from abc import ABCMeta, abstractmethod + +import six +from openpype.style import get_default_entity_icon_color + + +class WorkfileInfo: + """Information about workarea file with possible additional from database. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Filepath. + filesize (int): File size. + creation_time (int): Creation time (timestamp). + modification_time (int): Modification time (timestamp). + note (str): Note. + """ + + def __init__( + self, + folder_id, + task_id, + filepath, + filesize, + creation_time, + modification_time, + note, + ): + self.folder_id = folder_id + self.task_id = task_id + self.filepath = filepath + self.filesize = filesize + self.creation_time = creation_time + self.modification_time = modification_time + self.note = note + + def to_data(self): + """Converts WorkfileInfo item to data. + + Returns: + dict[str, Any]: Folder item data. + """ + + return { + "folder_id": self.folder_id, + "task_id": self.task_id, + "filepath": self.filepath, + "filesize": self.filesize, + "creation_time": self.creation_time, + "modification_time": self.modification_time, + "note": self.note, + } + + @classmethod + def from_data(cls, data): + """Re-creates WorkfileInfo item from data. + + Args: + data (dict[str, Any]): Workfile info item data. + + Returns: + WorkfileInfo: Workfile info item. + """ + + return cls(**data) + + +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_name, icon_color + ): + self.entity_id = entity_id + self.parent_id = parent_id + self.name = name + self.icon_name = icon_name or "fa.folder" + self.icon_color = icon_color or get_default_entity_icon_color() + 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_name": self.icon_name, + "icon_color": self.icon_color, + } + + @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_name, icon_color + ): + self.task_id = task_id + self.name = name + self.task_type = task_type + self.parent_id = parent_id + self.icon_name = icon_name or "fa.male" + self.icon_color = icon_color or get_default_entity_icon_color() + 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_name": self.icon_name, + "icon_color": self.icon_color, + } + + @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) + + +class FileItem: + """File item that represents a file. + + Can be used for both Workarea and Published workfile. Workarea file + will always exist on disk which is not the case for Published workfile. + + Args: + dirpath (str): Directory path of file. + filename (str): Filename. + modified (float): Modified timestamp. + representation_id (Optional[str]): Representation id of published + workfile. + filepath (Optional[str]): Prepared filepath. + exists (Optional[bool]): If file exists on disk. + """ + + def __init__( + self, + dirpath, + filename, + modified, + representation_id=None, + filepath=None, + exists=None + ): + self.filename = filename + self.dirpath = dirpath + self.modified = modified + self.representation_id = representation_id + self._filepath = filepath + self._exists = exists + + @property + def filepath(self): + """Filepath of file. + + Returns: + str: Full path to a file. + """ + + if self._filepath is None: + self._filepath = os.path.join(self.dirpath, self.filename) + return self._filepath + + @property + def exists(self): + """File is available. + + Returns: + bool: If file exists on disk. + """ + + if self._exists is None: + self._exists = os.path.exists(self.filepath) + return self._exists + + def to_data(self): + """Converts file item to data. + + Returns: + dict[str, Any]: File item data. + """ + + return { + "filename": self.filename, + "dirpath": self.dirpath, + "modified": self.modified, + "representation_id": self.representation_id, + "filepath": self.filepath, + "exists": self.exists, + } + + @classmethod + def from_data(cls, data): + """Re-creates file item from data. + + Args: + data (dict[str, Any]): File item data. + + Returns: + FileItem: File item. + """ + + required_keys = { + "filename", + "dirpath", + "modified", + "representation_id" + } + missing_keys = required_keys - set(data.keys()) + if missing_keys: + raise KeyError("Missing keys: {}".format(missing_keys)) + + return cls(**{ + key: data[key] + for key in required_keys + }) + + +class WorkareaFilepathResult: + """Result of workarea file formatting. + + Args: + root (str): Root path of workarea. + filename (str): Filename. + exists (bool): True if file exists. + filepath (str): Filepath. If not provided it will be constructed + from root and filename. + """ + + def __init__(self, root, filename, exists, filepath=None): + if not filepath and root and filename: + filepath = os.path.join(root, filename) + self.root = root + self.filename = filename + self.exists = exists + self.filepath = filepath + + +@six.add_metaclass(ABCMeta) +class AbstractWorkfilesCommon(object): + @abstractmethod + def is_host_valid(self): + """Host is valid for workfiles tool work. + + Returns: + bool: True if host is valid. + """ + + pass + + @abstractmethod + def get_workfile_extensions(self): + """Get possible workfile extensions. + + Defined by host implementation. + + Returns: + Iterable[str]: List of extensions. + """ + + pass + + @abstractmethod + def is_save_enabled(self): + """Is workfile save enabled. + + Returns: + bool: True if save is enabled. + """ + + pass + + @abstractmethod + def set_save_enabled(self, enabled): + """Enable or disabled workfile save. + + Args: + enabled (bool): Enable save workfile when True. + """ + + pass + + +class AbstractWorkfilesBackend(AbstractWorkfilesCommon): + # Current context + @abstractmethod + def get_host_name(self): + """Name of host. + + Returns: + str: Name of host. + """ + pass + + @abstractmethod + def get_current_project_name(self): + """Project name from current context of host. + + Returns: + str: Name of project. + """ + + pass + + @abstractmethod + def get_current_folder_id(self): + """Folder id from current context of host. + + Returns: + Union[str, None]: Folder id or None if host does not have + any context. + """ + + pass + + @abstractmethod + def get_current_task_name(self): + """Task name from current context of host. + + Returns: + Union[str, None]: Task name or None if host does not have + any context. + """ + + pass + + @abstractmethod + def get_current_workfile(self): + """Current workfile from current context of host. + + Returns: + Union[str, None]: Path to workfile or None if host does + not have opened specific file. + """ + + pass + + @property + @abstractmethod + def project_anatomy(self): + """Project anatomy for current project. + + Returns: + Anatomy: Project anatomy. + """ + + pass + + @property + @abstractmethod + def project_settings(self): + """Project settings for current project. + + Returns: + dict[str, Any]: Project settings. + """ + + pass + + @abstractmethod + def get_folder_entity(self, folder_id): + """Get folder entity by id. + + Args: + folder_id (str): Folder id. + + Returns: + dict[str, Any]: Folder entity data. + """ + + pass + + @abstractmethod + def get_task_entity(self, task_id): + """Get task entity by id. + + Args: + task_id (str): Task id. + + Returns: + dict[str, Any]: Task entity data. + """ + + pass + + 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 + + +class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): + """UI controller abstraction that is used for workfiles tool frontend. + + Abstraction to provide data for UI and to handle UI events. + + Provide access to abstract backend data, like folders and tasks. Cares + about handling of selection, keep information about current UI selection + and have ability to tell what selection should UI show. + + Selection is separated into 2 parts, first is what UI elements tell + about selection, and second is what UI should show as selected. + """ + + @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 + + # Host information + @abstractmethod + def get_workfile_extensions(self): + """Each host can define extensions that can be used for workfile. + + Returns: + List[str]: File extensions that can be used as workfile for + current host. + """ + + pass + + # Selection information + @abstractmethod + def get_selected_folder_id(self): + """Currently selected folder id. + + Returns: + Union[str, None]: Folder id or None if no folder is selected. + """ + + pass + + @abstractmethod + def set_selected_folder(self, folder_id): + """Change selected folder. + + This deselects currently selected task. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + """ + + pass + + @abstractmethod + def get_selected_task_id(self): + """Currently selected task id. + + Returns: + Union[str, None]: Task id or None if no folder is selected. + """ + + pass + + @abstractmethod + def get_selected_task_name(self): + """Currently selected task name. + + Returns: + Union[str, None]: Task name or None if no folder is selected. + """ + + pass + + @abstractmethod + def set_selected_task(self, folder_id, task_id, task_name): + """Change selected task. + + Args: + folder_id (Union[str, None]): Folder id or None if no folder + is selected. + task_id (Union[str, None]): Task id or None if no task + is selected. + task_name (Union[str, None]): Task name or None if no task + is selected. + """ + + pass + + @abstractmethod + def get_selected_workfile_path(self): + """Currently selected workarea workile. + + Returns: + Union[str, None]: Selected workfile path. + """ + + pass + + @abstractmethod + def set_selected_workfile_path(self, path): + """Change selected workfile path. + + Args: + path (Union[str, None]): Selected workfile path. + """ + + pass + + @abstractmethod + def get_selected_representation_id(self): + """Currently selected workfile representation id. + + Returns: + Union[str, None]: Representation id or None if no representation + is selected. + """ + + pass + + @abstractmethod + def set_selected_representation_id(self, representation_id): + """Change selected representation. + + Args: + representation_id (Union[str, None]): Selected workfile + representation id. + """ + + pass + + def get_selected_context(self): + """Obtain selected context. + + Returns: + dict[str, Union[str, None]]: Selected context. + """ + + return { + "folder_id": self.get_selected_folder_id(), + "task_id": self.get_selected_task_id(), + "task_name": self.get_selected_task_name(), + "workfile_path": self.get_selected_workfile_path(), + "representation_id": self.get_selected_representation_id(), + } + + # Expected selection + # - expected selection is used to restore selection after refresh + # or when current context should be used + @abstractmethod + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + """Define what should be selected in UI. + + Expected selection provide a way to define/change selection of + sequential UI elements. For example, if folder and task should be + selected a task element should wait until folder element has selected + folder. + + Triggers 'expected_selection.changed' event. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + workfile_name (Optional[str]): Workfile name. Used for workarea + files UI element. + representation_id (Optional[str]): Representation id. Used for + published filed UI element. + """ + + pass + + @abstractmethod + def get_expected_selection_data(self): + """Data of expected selection. + + TODOs: + Return defined object instead of dict. + + Returns: + dict[str, Any]: Expected selection data. + """ + + pass + + @abstractmethod + def expected_folder_selected(self, folder_id): + """Expected folder was selected in UI. + + Args: + folder_id (str): Folder id which was selected. + """ + + pass + + @abstractmethod + def expected_task_selected(self, folder_id, task_name): + """Expected task was selected in UI. + + Args: + folder_id (str): Folder id under which task is. + task_name (str): Task name which was selected. + """ + + pass + + @abstractmethod + def expected_representation_selected(self, representation_id): + """Expected representation was selected in UI. + + Args: + representation_id (str): Representation id which was selected. + """ + + pass + + @abstractmethod + def expected_workfile_selected(self, workfile_path): + """Expected workfile was selected in UI. + + Args: + workfile_path (str): Workfile path which was selected. + """ + + pass + + @abstractmethod + def go_to_current_context(self): + """Set expected selection to current context.""" + + pass + + # Model functions + @abstractmethod + def get_folder_items(self, sender): + """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: + 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, folder_id, sender): + """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: + 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 has_unsaved_changes(self): + """Has host unsaved change in currently running session. + + Returns: + bool: Has unsaved changes. + """ + + pass + + @abstractmethod + def get_workarea_dir_by_context(self, folder_id, task_id): + """Get workarea directory by context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + str: Workarea directory. + """ + + pass + + @abstractmethod + def get_workarea_file_items(self, folder_id, task_id): + """Get workarea file items. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + list[FileItem]: List of workarea file items. + """ + + pass + + @abstractmethod + def get_workarea_save_as_data(self, folder_id, task_id): + """Prepare data for Save As operation. + + Todos: + Return defined object instead of dict. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + dict[str, Any]: Data for Save As operation. + """ + + pass + + @abstractmethod + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + """Calculate workfile path for passed context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + extension (str): File extension. + use_last_version (bool): Use last version. + version (int): Version used if 'use_last_version' if 'False'. + comment (str): User's comment (subversion). + + Returns: + WorkareaFilepathResult: Result of the operation. + """ + + pass + + @abstractmethod + def get_published_file_items(self, folder_id, task_id): + """Get published file items. + + Args: + folder_id (str): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[FileItem]: List of published file items. + """ + + pass + + @abstractmethod + def get_workfile_info(self, folder_id, task_id, filepath): + """Workfile info from database. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + + Returns: + Union[WorkfileInfo, None]: Workfile info or None if was passed + invalid context. + """ + + pass + + @abstractmethod + def save_workfile_info(self, folder_id, task_id, filepath, note): + """Save workfile info to database. + + At this moment the only information which can be saved about + workfile is 'note'. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + filepath (str): Workfile path. + note (str): Note. + """ + + pass + + # General commands + @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 + + # Controller actions + @abstractmethod + def open_workfile(self, filepath): + """Open a workfile. + + Args: + filepath (str): Workfile path. + """ + + pass + + @abstractmethod + def save_current_workfile(self): + """Save state of current workfile.""" + + pass + + @abstractmethod + def save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + """Save current state of workfile to workarea. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + workdir (str): Workarea directory. + filename (str): Workarea filename. + template_key (str): Template key used to get the workdir + and filename. + """ + + pass + + @abstractmethod + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + """Action to copy published workfile representation to workarea. + + Triggers 'copy_representation.started' event on start and + 'copy_representation.finished' event with '{"failed": bool}'. + + Args: + representation_id (str): Representation id. + representation_filepath (str): Path to representation file. + folder_id (str): Folder id. + task_id (str): Task id. + workdir (str): Workarea directory. + filename (str): Workarea filename. + template_key (str): Template key. + """ + + pass + + @abstractmethod + def duplicate_workfile(self, src_filepath, workdir, filename): + """Duplicate workfile. + + Workfiles is not opened when done. + + Args: + src_filepath (str): Source workfile path. + workdir (str): Destination workdir. + filename (str): Destination filename. + """ + + pass diff --git a/openpype/tools/ayon_workfiles/control.py b/openpype/tools/ayon_workfiles/control.py new file mode 100644 index 0000000000..fc8819bff3 --- /dev/null +++ b/openpype/tools/ayon_workfiles/control.py @@ -0,0 +1,642 @@ +import os +import shutil + +import ayon_api + +from openpype.client import get_asset_by_id +from openpype.host import IWorkfileHost +from openpype.lib import Logger, emit_event +from openpype.lib.events import QueuedEventSystem +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy, registered_host +from openpype.pipeline.context_tools import ( + change_current_context, + get_current_host_name, + get_global_context, +) +from openpype.pipeline.workfile import create_workdir_extra_folders + +from .abstract import ( + AbstractWorkfilesFrontend, + AbstractWorkfilesBackend, +) +from .models import SelectionModel, EntitiesModel, WorkfilesModel + + +class ExpectedSelection: + def __init__(self): + self._folder_id = None + self._task_name = None + self._workfile_name = None + self._representation_id = None + self._folder_selected = True + self._task_selected = True + self._workfile_name_selected = True + self._representation_id_selected = True + + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + self._folder_id = folder_id + self._task_name = task_name + self._workfile_name = workfile_name + self._representation_id = representation_id + self._folder_selected = False + self._task_selected = False + self._workfile_name_selected = workfile_name is None + self._representation_id_selected = representation_id is None + + def get_expected_selection_data(self): + return { + "folder_id": self._folder_id, + "task_name": self._task_name, + "workfile_name": self._workfile_name, + "representation_id": self._representation_id, + "folder_selected": self._folder_selected, + "task_selected": self._task_selected, + "workfile_name_selected": self._workfile_name_selected, + "representation_id_selected": self._representation_id_selected, + } + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def is_expected_task_selected(self, folder_id, task_name): + if not self.is_expected_folder_selected(folder_id): + return False + return task_name == self._task_name and self._task_selected + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + return True + + def expected_task_selected(self, folder_id, task_name): + if not self.is_expected_folder_selected(folder_id): + return False + + if task_name != self._task_name: + return False + + self._task_selected = True + return True + + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + if not self.is_expected_task_selected(folder_id, task_name): + return False + + if workfile_name != self._workfile_name: + return False + self._workfile_name_selected = True + return True + + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + if not self.is_expected_task_selected(folder_id, task_name): + return False + if representation_id != self._representation_id: + return False + self._representation_id_selected = True + return True + + +class BaseWorkfileController( + AbstractWorkfilesFrontend, AbstractWorkfilesBackend +): + def __init__(self, host=None): + if host is None: + host = registered_host() + + host_is_valid = False + if host is not None: + missing_methods = ( + IWorkfileHost.get_missing_workfile_methods(host) + ) + host_is_valid = len(missing_methods) == 0 + + self._host = host + self._host_is_valid = host_is_valid + + self._project_anatomy = None + self._project_settings = None + self._event_system = None + self._log = None + + self._current_project_name = None + self._current_folder_name = None + self._current_folder_id = None + self._current_task_name = None + self._save_is_enabled = True + + # Expected selected folder and task + self._expected_selection = self._create_expected_selection_obj() + + self._selection_model = self._create_selection_model() + self._entities_model = self._create_entities_model() + self._workfiles_model = self._create_workfiles_model() + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger("WorkfilesUI") + return self._log + + def is_host_valid(self): + return self._host_is_valid + + def _create_expected_selection_obj(self): + return ExpectedSelection() + + def _create_selection_model(self): + return SelectionModel(self) + + def _create_entities_model(self): + return EntitiesModel(self) + + def _create_workfiles_model(self): + return WorkfilesModel(self) + + @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 methods required for backend logic + # ---------------------------------------------------- + @property + def project_settings(self): + if self._project_settings is None: + self._project_settings = get_project_settings( + self.get_current_project_name()) + return self._project_settings + + @property + def project_anatomy(self): + if self._project_anatomy is None: + self._project_anatomy = Anatomy(self.get_current_project_name()) + return self._project_anatomy + + def get_folder_entity(self, folder_id): + return self._entities_model.get_folder_entity(folder_id) + + def get_task_entity(self, task_id): + return self._entities_model.get_task_entity(task_id) + + # --------------------------------- + # Implementation of abstract methods + # --------------------------------- + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self.event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self.event_system.add_callback(topic, callback) + + def is_save_enabled(self): + """Is workfile save enabled. + + Returns: + bool: True if save is enabled. + """ + + return self._save_is_enabled + + def set_save_enabled(self, enabled): + """Enable or disabled workfile save. + + Args: + enabled (bool): Enable save workfile when True. + """ + + if self._save_is_enabled == enabled: + return + + self._save_is_enabled = enabled + self._emit_event( + "workfile_save_enable.changed", + {"enabled": enabled} + ) + + # Host information + def get_workfile_extensions(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.get_workfile_extensions() + return host.file_extensions() + + def has_unsaved_changes(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.workfile_has_unsaved_changes() + return host.has_unsaved_changes() + + # Current context + def get_host_name(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.name + return get_current_host_name() + + def _get_host_current_context(self): + if hasattr(self._host, "get_current_context"): + return self._host.get_current_context() + return get_global_context() + + def get_current_project_name(self): + return self._current_project_name + + def get_current_folder_id(self): + return self._current_folder_id + + def get_current_task_name(self): + return self._current_task_name + + def get_current_workfile(self): + host = self._host + if isinstance(host, IWorkfileHost): + return host.get_current_workfile() + return host.current_file() + + # Selection information + 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, folder_id, task_id, task_name): + return self._selection_model.set_selected_task( + folder_id, task_id, task_name) + + def get_selected_workfile_path(self): + return self._selection_model.get_selected_workfile_path() + + def set_selected_workfile_path(self, path): + self._selection_model.set_selected_workfile_path(path) + + def get_selected_representation_id(self): + return self._selection_model.get_selected_representation_id() + + def set_selected_representation_id(self, representation_id): + self._selection_model.set_selected_representation_id( + representation_id) + + def set_expected_selection( + self, + folder_id, + task_name, + workfile_name=None, + representation_id=None + ): + self._expected_selection.set_expected_selection( + folder_id, task_name, workfile_name, representation_id + ) + self._trigger_expected_selection_changed() + + def expected_folder_selected(self, folder_id): + if self._expected_selection.expected_folder_selected(folder_id): + self._trigger_expected_selection_changed() + + def expected_task_selected(self, folder_id, task_name): + if self._expected_selection.expected_task_selected( + folder_id, task_name + ): + self._trigger_expected_selection_changed() + + def expected_workfile_selected(self, folder_id, task_name, workfile_name): + if self._expected_selection.expected_workfile_selected( + folder_id, task_name, workfile_name + ): + self._trigger_expected_selection_changed() + + def expected_representation_selected( + self, folder_id, task_name, representation_id + ): + if self._expected_selection.expected_representation_selected( + folder_id, task_name, representation_id + ): + self._trigger_expected_selection_changed() + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def go_to_current_context(self): + self.set_expected_selection( + self._current_folder_id, self._current_task_name + ) + + # Model functions + def get_folder_items(self, sender): + return self._entities_model.get_folder_items(sender) + + def get_task_items(self, folder_id, sender): + return self._entities_model.get_tasks_items(folder_id, sender) + + def get_workarea_dir_by_context(self, folder_id, task_id): + return self._workfiles_model.get_workarea_dir_by_context( + folder_id, task_id) + + def get_workarea_file_items(self, folder_id, task_id): + return self._workfiles_model.get_workarea_file_items( + folder_id, task_id) + + def get_workarea_save_as_data(self, folder_id, task_id): + return self._workfiles_model.get_workarea_save_as_data( + folder_id, task_id) + + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + return self._workfiles_model.fill_workarea_filepath( + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ) + + def get_published_file_items(self, folder_id, task_id): + task_name = None + if task_id: + task = self.get_task_entity(task_id) + task_name = task.get("name") + + return self._workfiles_model.get_published_file_items( + folder_id, task_name) + + def get_workfile_info(self, folder_id, task_id, filepath): + return self._workfiles_model.get_workfile_info( + folder_id, task_id, filepath + ) + + def save_workfile_info(self, folder_id, task_id, filepath, note): + self._workfiles_model.save_workfile_info( + folder_id, task_id, filepath, note + ) + + def refresh(self): + if not self._host_is_valid: + self._emit_event("controller.refresh.started") + self._emit_event("controller.refresh.finished") + return + expected_folder_id = self.get_selected_folder_id() + expected_task_name = self.get_selected_task_name() + + self._emit_event("controller.refresh.started") + + context = self._get_host_current_context() + + project_name = context["project_name"] + folder_name = context["asset_name"] + task_name = context["task_name"] + folder_id = None + if folder_name: + folder = ayon_api.get_folder_by_name(project_name, folder_name) + if folder: + folder_id = folder["id"] + + self._project_settings = None + self._project_anatomy = None + + self._current_project_name = project_name + self._current_folder_name = folder_name + self._current_folder_id = folder_id + self._current_task_name = task_name + + if not expected_folder_id: + expected_folder_id = folder_id + expected_task_name = task_name + + self._expected_selection.set_expected_selection( + expected_folder_id, expected_task_name + ) + + self._entities_model.refresh() + + self._emit_event("controller.refresh.finished") + + # Controller actions + def open_workfile(self, filepath): + self._emit_event("open_workfile.started") + + failed = False + try: + self._host_open_workfile(filepath) + + except Exception: + failed = True + self.log.warning("Open of workfile failed", exc_info=True) + + self._emit_event( + "open_workfile.finished", + {"failed": failed}, + ) + + def save_current_workfile(self): + current_file = self.get_current_workfile() + self._host_save_workfile(current_file) + + def save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + self._emit_event("save_as.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + ) + except Exception: + failed = True + self.log.warning("Save as failed", exc_info=True) + + self._emit_event( + "save_as.finished", + {"failed": failed}, + ) + + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + ): + self._emit_event("copy_representation.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + ) + except Exception: + failed = True + self.log.warning( + "Copy of workfile representation failed", exc_info=True + ) + + self._emit_event( + "copy_representation.finished", + {"failed": failed}, + ) + + def duplicate_workfile(self, src_filepath, workdir, filename): + self._emit_event("workfile_duplicate.started") + + failed = False + try: + dst_filepath = os.path.join(workdir, filename) + shutil.copy(src_filepath, dst_filepath) + except Exception: + failed = True + self.log.warning("Duplication of workfile failed", exc_info=True) + + self._emit_event( + "workfile_duplicate.finished", + {"failed": failed}, + ) + + # Helper host methods that resolve 'IWorkfileHost' interface + def _host_open_workfile(self, filepath): + host = self._host + if isinstance(host, IWorkfileHost): + host.open_workfile(filepath) + else: + host.open_file(filepath) + + def _host_save_workfile(self, filepath): + host = self._host + if isinstance(host, IWorkfileHost): + host.save_workfile(filepath) + else: + host.save_file(filepath) + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + # Expected selection + # - expected selection is used to restore selection after refresh + # or when current context should be used + def _trigger_expected_selection_changed(self): + self._emit_event( + "expected_selection_changed", + self._expected_selection.get_expected_selection_data(), + ) + + def _save_as_workfile( + self, + folder_id, + task_id, + workdir, + filename, + template_key, + src_filepath=None, + ): + # Trigger before save event + project_name = self.get_current_project_name() + folder = self.get_folder_entity(folder_id) + task = self.get_task_entity(task_id) + task_name = task["name"] + + # QUESTION should the data be different for 'before' and 'after'? + # NOTE keys should be OpenPype compatible + event_data = { + "project_name": project_name, + "folder_id": folder_id, + "asset_id": folder_id, + "asset_name": folder["name"], + "task_id": task_id, + "task_name": task_name, + "host_name": self.get_host_name(), + "filename": filename, + "workdir_path": workdir, + } + emit_event("workfile.save.before", event_data, source="workfiles.tool") + + # Create workfiles root folder + if not os.path.exists(workdir): + self.log.debug("Initializing work directory: %s", workdir) + os.makedirs(workdir) + + # Change context + if ( + folder_id != self.get_current_folder_id() + or task_name != self.get_current_task_name() + ): + # Use OpenPype asset-like object + asset_doc = get_asset_by_id(project_name, folder["id"]) + change_current_context( + asset_doc, + task["name"], + template_key=template_key + ) + + # Save workfile + dst_filepath = os.path.join(workdir, filename) + if src_filepath: + shutil.copyfile(src_filepath, dst_filepath) + self._host_open_workfile(dst_filepath) + else: + self._host_save_workfile(dst_filepath) + + # Create extra folders + create_workdir_extra_folders( + workdir, + self.get_host_name(), + task["taskType"], + task_name, + project_name + ) + + # Trigger after save events + emit_event("workfile.save.after", event_data, source="workfiles.tool") + self.refresh() diff --git a/openpype/tools/ayon_workfiles/models/__init__.py b/openpype/tools/ayon_workfiles/models/__init__.py new file mode 100644 index 0000000000..d906b9e7bd --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/__init__.py @@ -0,0 +1,10 @@ +from .hierarchy import EntitiesModel +from .selection import SelectionModel +from .workfiles import WorkfilesModel + + +__all__ = ( + "SelectionModel", + "EntitiesModel", + "WorkfilesModel", +) diff --git a/openpype/tools/ayon_workfiles/models/hierarchy.py b/openpype/tools/ayon_workfiles/models/hierarchy.py new file mode 100644 index 0000000000..948c0b8a17 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/hierarchy.py @@ -0,0 +1,225 @@ +"""Hierarchy model that handles folders and tasks. + +The model can be extracted for common usage. In that case it will be required +to add more handling of project name changes. +""" + +import time +import collections +import contextlib + +import ayon_api + +from openpype.tools.ayon_workfiles.abstract import ( + FolderItem, + TaskItem, +) + + +def _get_task_items_from_tasks(tasks): + """ + + Returns: + TaskItem: Task item. + """ + + output = [] + for task in tasks: + folder_id = task["folderId"] + output.append(TaskItem( + task["id"], + task["name"], + task["type"], + folder_id, + None, + None + )) + return output + + +def _get_folder_item_from_hierarchy_item(item): + return FolderItem( + item["id"], + item["parentId"], + item["name"], + item["label"], + None, + None, + ) + + +class CacheItem: + def __init__(self, lifetime=120): + self._lifetime = lifetime + self._last_update = None + self._data = None + + @property + def is_valid(self): + if self._last_update is None: + return False + + return (time.time() - self._last_update) < self._lifetime + + def set_invalid(self, data=None): + self._last_update = None + self._data = data + + def get_data(self): + return self._data + + def update_data(self, data): + self._data = data + self._last_update = time.time() + + +class EntitiesModel(object): + event_source = "entities.model" + + def __init__(self, controller): + folders_cache = CacheItem() + folders_cache.set_invalid({}) + self._folders_cache = folders_cache + self._tasks_cache = {} + + self._folders_by_id = {} + self._tasks_by_id = {} + + self._folders_refreshing = False + self._tasks_refreshing = set() + self._controller = controller + + def reset(self): + self._folders_cache.set_invalid({}) + self._tasks_cache = {} + + self._folders_by_id = {} + self._tasks_by_id = {} + + def refresh(self): + self._refresh_folders_cache() + + def get_folder_items(self, sender): + if not self._folders_cache.is_valid: + self._refresh_folders_cache(sender) + return self._folders_cache.get_data() + + def get_tasks_items(self, folder_id, sender): + if not folder_id: + return [] + + task_cache = self._tasks_cache.get(folder_id) + if task_cache is None or not task_cache.is_valid: + self._refresh_tasks_cache(folder_id, sender) + task_cache = self._tasks_cache.get(folder_id) + return task_cache.get_data() + + def get_folder_entity(self, folder_id): + if folder_id not in self._folders_by_id: + entity = None + if folder_id: + project_name = self._controller.get_current_project_name() + entity = ayon_api.get_folder_by_id(project_name, folder_id) + self._folders_by_id[folder_id] = entity + return self._folders_by_id[folder_id] + + def get_task_entity(self, task_id): + if task_id not in self._tasks_by_id: + entity = None + if task_id: + project_name = self._controller.get_current_project_name() + entity = ayon_api.get_task_by_id(project_name, task_id) + self._tasks_by_id[task_id] = entity + return self._tasks_by_id[task_id] + + @contextlib.contextmanager + def _folder_refresh_event_manager(self, project_name, sender): + self._folders_refreshing = True + self._controller.emit_event( + "folders.refresh.started", + {"project_name": project_name, "sender": sender}, + self.event_source + ) + try: + yield + + finally: + self._controller.emit_event( + "folders.refresh.finished", + {"project_name": project_name, "sender": sender}, + self.event_source + ) + self._folders_refreshing = False + + @contextlib.contextmanager + def _task_refresh_event_manager( + self, project_name, folder_id, sender + ): + self._tasks_refreshing.add(folder_id) + self._controller.emit_event( + "tasks.refresh.started", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + self.event_source + ) + try: + yield + + finally: + self._controller.emit_event( + "tasks.refresh.finished", + { + "project_name": project_name, + "folder_id": folder_id, + "sender": sender, + }, + self.event_source + ) + self._tasks_refreshing.discard(folder_id) + + def _refresh_folders_cache(self, sender=None): + if self._folders_refreshing: + return + project_name = self._controller.get_current_project_name() + with self._folder_refresh_event_manager(project_name, sender): + folder_items = self._query_folders(project_name) + self._folders_cache.update_data(folder_items) + + def _query_folders(self, project_name): + hierarchy = ayon_api.get_folders_hierarchy(project_name) + + folder_items = {} + hierachy_queue = collections.deque(hierarchy["hierarchy"]) + while hierachy_queue: + item = hierachy_queue.popleft() + folder_item = _get_folder_item_from_hierarchy_item(item) + folder_items[folder_item.entity_id] = folder_item + hierachy_queue.extend(item["children"] or []) + return folder_items + + def _refresh_tasks_cache(self, folder_id, sender=None): + if folder_id in self._tasks_refreshing: + return + + project_name = self._controller.get_current_project_name() + with self._task_refresh_event_manager( + project_name, folder_id, sender + ): + cache_item = self._tasks_cache.get(folder_id) + if cache_item is None: + cache_item = CacheItem() + self._tasks_cache[folder_id] = cache_item + + task_items = self._query_tasks(project_name, folder_id) + cache_item.update_data(task_items) + + def _query_tasks(self, project_name, folder_id): + tasks = list(ayon_api.get_tasks( + project_name, + folder_ids=[folder_id], + fields={"id", "name", "label", "folderId", "type"} + )) + return _get_task_items_from_tasks(tasks) diff --git a/openpype/tools/ayon_workfiles/models/selection.py b/openpype/tools/ayon_workfiles/models/selection.py new file mode 100644 index 0000000000..ad034794d8 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/selection.py @@ -0,0 +1,91 @@ +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.folder.changed" + - "selection.task.changed" + - "workarea.selection.changed" + - "selection.representation.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._folder_id = None + self._task_name = None + self._task_id = None + self._workfile_path = None + self._representation_id = None + + 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", + {"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, folder_id, task_id, task_name): + if folder_id != self._folder_id: + self.set_selected_folder(folder_id) + + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "folder_id": folder_id, + "task_name": task_name, + "task_id": task_id + }, + self.event_source + ) + + def get_selected_workfile_path(self): + return self._workfile_path + + def set_selected_workfile_path(self, path): + if path == self._workfile_path: + return + + self._workfile_path = path + self._controller.emit_event( + "workarea.selection.changed", + { + "path": path, + "folder_id": self._folder_id, + "task_name": self._task_name, + "task_id": self._task_id, + }, + self.event_source + ) + + def get_selected_representation_id(self): + return self._representation_id + + def set_selected_representation_id(self, representation_id): + if representation_id == self._representation_id: + return + self._representation_id = representation_id + self._controller.emit_event( + "selection.representation.changed", + {"representation_id": representation_id}, + self.event_source + ) diff --git a/openpype/tools/ayon_workfiles/models/workfiles.py b/openpype/tools/ayon_workfiles/models/workfiles.py new file mode 100644 index 0000000000..eb82f62de3 --- /dev/null +++ b/openpype/tools/ayon_workfiles/models/workfiles.py @@ -0,0 +1,711 @@ +import os +import re +import copy + +import arrow +import ayon_api +from ayon_api.operations import OperationsSession + +from openpype.client import get_project +from openpype.client.operations import ( + prepare_workfile_info_update_data, +) +from openpype.pipeline.template_data import ( + get_template_data, +) +from openpype.pipeline.workfile import ( + get_workdir_with_workdir_data, + get_workfile_template_key, + get_last_workfile_with_version, +) +from openpype.pipeline.version_start import get_versioning_start +from openpype.tools.ayon_workfiles.abstract import ( + WorkareaFilepathResult, + FileItem, + WorkfileInfo, +) + + +def get_folder_template_data(folder): + if not folder: + return {} + parts = folder["path"].split("/") + parts.pop(-1) + hierarchy = "/".join(parts) + return { + "asset": folder["name"], + "folder": { + "name": folder["name"], + "type": folder["folderType"], + "path": folder["path"], + }, + "hierarchy": hierarchy, + } + + +def get_task_template_data(task): + if not task: + return {} + return { + "task": { + "name": task["name"], + "type": task["taskType"] + } + } + + +class CommentMatcher(object): + """Use anatomy and work file data to parse comments from filenames""" + def __init__(self, extensions, file_template, data): + self.fname_regex = None + + if "{comment}" not in file_template: + # Don't look for comment if template doesn't allow it + return + + # Create a regex group for extensions + any_extension = "(?:{})".format( + "|".join(re.escape(ext.lstrip(".")) for ext in extensions) + ) + + # Use placeholders that will never be in the filename + temp_data = copy.deepcopy(data) + temp_data["comment"] = "<>" + temp_data["version"] = "<>" + temp_data["ext"] = "<>" + + fname_pattern = file_template.format_strict(temp_data) + fname_pattern = re.escape(fname_pattern) + + # Replace comment and version with something we can match with regex + replacements = { + "<>": "(.+)", + "<>": "[0-9]+", + "<>": any_extension, + } + for src, dest in replacements.items(): + fname_pattern = fname_pattern.replace(re.escape(src), dest) + + # Match from beginning to end of string to be safe + fname_pattern = "^{}$".format(fname_pattern) + + self.fname_regex = re.compile(fname_pattern) + + def parse_comment(self, filepath): + """Parse the {comment} part from a filename""" + if not self.fname_regex: + return + + fname = os.path.basename(filepath) + match = self.fname_regex.match(fname) + if match: + return match.group(1) + + +class WorkareaModel: + """Workfiles model looking for workfiles in workare folder. + + Workarea folder is usually task and host specific, defined by + anatomy templates. Is looking for files with extensions defined + by host integration. + """ + + def __init__(self, controller): + self._controller = controller + extensions = None + if controller.is_host_valid(): + extensions = controller.get_workfile_extensions() + self._extensions = extensions + self._base_data = None + self._fill_data_by_folder_id = {} + self._task_data_by_folder_id = {} + self._workdir_by_context = {} + + @property + def project_name(self): + return self._controller.get_current_project_name() + + def reset(self): + self._base_data = None + self._fill_data_by_folder_id = {} + self._task_data_by_folder_id = {} + + def _get_base_data(self): + if self._base_data is None: + base_data = get_template_data(get_project(self.project_name)) + base_data["app"] = self._controller.get_host_name() + self._base_data = base_data + return copy.deepcopy(self._base_data) + + def _get_folder_data(self, folder_id): + fill_data = self._fill_data_by_folder_id.get(folder_id) + if fill_data is None: + folder = self._controller.get_folder_entity(folder_id) + fill_data = get_folder_template_data(folder) + self._fill_data_by_folder_id[folder_id] = fill_data + return copy.deepcopy(fill_data) + + def _get_task_data(self, 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) + return copy.deepcopy(task_data[task_id]) + + def _prepare_fill_data(self, folder_id, task_id): + if not folder_id or not task_id: + return {} + + base_data = self._get_base_data() + folder_data = self._get_folder_data(folder_id) + task_data = self._get_task_data(folder_id, task_id) + + base_data.update(folder_data) + base_data.update(task_data) + + return base_data + + def get_workarea_dir_by_context(self, folder_id, task_id): + if not folder_id or not task_id: + return None + folder_mapping = self._workdir_by_context.setdefault(folder_id, {}) + workdir = folder_mapping.get(task_id) + if workdir is not None: + return workdir + + workdir_data = self._prepare_fill_data(folder_id, task_id) + + workdir = get_workdir_with_workdir_data( + workdir_data, + self.project_name, + anatomy=self._controller.project_anatomy, + ) + folder_mapping[task_id] = workdir + return workdir + + def get_file_items(self, folder_id, task_id): + items = [] + if not folder_id or not task_id: + return items + + workdir = self.get_workarea_dir_by_context(folder_id, task_id) + if not os.path.exists(workdir): + return items + + for filename in os.listdir(workdir): + filepath = os.path.join(workdir, filename) + if not os.path.isfile(filepath): + continue + + ext = os.path.splitext(filename)[1].lower() + if ext not in self._extensions: + continue + + modified = os.path.getmtime(filepath) + items.append( + FileItem(workdir, filename, modified) + ) + return items + + def _get_template_key(self, fill_data): + task_type = fill_data.get("task", {}).get("type") + # TODO cache + return get_workfile_template_key( + task_type, + self._controller.get_host_name(), + project_name=self.project_name + ) + + def _get_last_workfile_version( + self, workdir, file_template, fill_data, extensions + ): + version = get_last_workfile_with_version( + workdir, str(file_template), fill_data, extensions + )[1] + + if version is None: + task_info = fill_data.get("task", {}) + version = get_versioning_start( + self.project_name, + self._controller.get_host_name(), + task_name=task_info.get("name"), + task_type=task_info.get("type"), + family="workfile", + project_settings=self._controller.project_settings, + ) + else: + version += 1 + return version + + def _get_comments_from_root( + self, + file_template, + extensions, + fill_data, + root, + current_filename, + ): + current_comment = None + comment_hints = set() + filenames = [] + if root and os.path.exists(root): + for filename in os.listdir(root): + path = os.path.join(root, filename) + if not os.path.isfile(path): + continue + + ext = os.path.splitext(filename)[-1].lower() + if ext in extensions: + filenames.append(filename) + + if not filenames: + return comment_hints, current_comment + + matcher = CommentMatcher(extensions, file_template, fill_data) + + for filename in filenames: + comment = matcher.parse_comment(filename) + if comment: + comment_hints.add(comment) + if filename == current_filename: + current_comment = comment + + return list(comment_hints), current_comment + + def _get_workdir(self, anatomy, template_key, fill_data): + template_info = anatomy.templates_obj[template_key] + directory_template = template_info["folder"] + return directory_template.format_strict(fill_data).normalized() + + def get_workarea_save_as_data(self, folder_id, task_id): + folder = None + task = None + if folder_id: + folder = self._controller.get_folder_entity(folder_id) + if task_id: + task = self._controller.get_task_entity(task_id) + + if not folder or not task: + return { + "template_key": None, + "template_has_version": None, + "template_has_comment": None, + "ext": None, + "workdir": None, + "comment": None, + "comment_hints": None, + "last_version": None, + "extensions": None, + } + + anatomy = self._controller.project_anatomy + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + current_workfile = self._controller.get_current_workfile() + current_filename = None + current_ext = None + if current_workfile: + current_filename = os.path.basename(current_workfile) + current_ext = os.path.splitext(current_filename)[1].lower() + + extensions = self._extensions + if not current_ext and extensions: + current_ext = tuple(extensions)[0] + + workdir = self._get_workdir(anatomy, template_key, fill_data) + + template_info = anatomy.templates_obj[template_key] + file_template = template_info["file"] + + comment_hints, comment = self._get_comments_from_root( + file_template, + extensions, + fill_data, + workdir, + current_filename, + ) + last_version = self._get_last_workfile_version( + workdir, file_template, fill_data, extensions) + str_file_template = str(file_template) + template_has_version = "{version" in str_file_template + template_has_comment = "{comment" in str_file_template + + return { + "template_key": template_key, + "template_has_version": template_has_version, + "template_has_comment": template_has_comment, + "ext": current_ext, + "workdir": workdir, + "comment": comment, + "comment_hints": comment_hints, + "last_version": last_version, + "extensions": extensions, + } + + def fill_workarea_filepath( + self, + folder_id, + task_id, + extension, + use_last_version, + version, + comment, + ): + anatomy = self._controller.project_anatomy + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + workdir = self._get_workdir(anatomy, template_key, fill_data) + + template_info = anatomy.templates_obj[template_key] + file_template = template_info["file"] + + if use_last_version: + version = self._get_last_workfile_version( + workdir, file_template, fill_data, self._extensions + ) + fill_data["version"] = version + fill_data["ext"] = extension.lstrip(".") + + if comment: + fill_data["comment"] = comment + + filename = file_template.format(fill_data) + if not filename.solved: + filename = None + + exists = False + if filename: + filepath = os.path.join(workdir, filename) + exists = os.path.exists(filepath) + + return WorkareaFilepathResult( + workdir, + filename, + exists + ) + + +class WorkfileEntitiesModel: + """Workfile entities model. + + Args: + control (AbstractWorkfileController): Controller object. + """ + + def __init__(self, controller): + self._controller = controller + self._cache = {} + self._items = {} + + def _get_workfile_info_identifier( + self, folder_id, task_id, rootless_path + ): + return "_".join([folder_id, task_id, rootless_path]) + + def _get_rootless_path(self, filepath): + anatomy = self._controller.project_anatomy + + workdir, filename = os.path.split(filepath) + success, rootless_dir = anatomy.find_root_template_from_path(workdir) + return "/".join([ + os.path.normpath(rootless_dir).replace("\\", "/"), + filename + ]) + + def _prepare_workfile_info_item( + self, folder_id, task_id, workfile_info, filepath + ): + note = "" + if workfile_info: + note = workfile_info["attrib"].get("description") or "" + + filestat = os.stat(filepath) + return WorkfileInfo( + folder_id, + task_id, + filepath, + filesize=filestat.st_size, + creation_time=filestat.st_ctime, + modification_time=filestat.st_mtime, + note=note + ) + + def _get_workfile_info(self, folder_id, task_id, identifier): + workfile_info = self._cache.get(identifier) + if workfile_info is not None: + return workfile_info + + for workfile_info in ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + fields=["id", "path", "attrib"], + ): + workfile_identifier = self._get_workfile_info_identifier( + folder_id, task_id, workfile_info["path"] + ) + self._cache[workfile_identifier] = workfile_info + return self._cache.get(identifier) + + def get_workfile_info( + self, folder_id, task_id, filepath, rootless_path=None + ): + if not folder_id or not task_id or not filepath: + return None + + if rootless_path is None: + rootless_path = self._get_rootless_path(filepath) + + identifier = self._get_workfile_info_identifier( + folder_id, task_id, rootless_path) + item = self._items.get(identifier) + if item is None: + workfile_info = self._get_workfile_info( + folder_id, task_id, identifier + ) + item = self._prepare_workfile_info_item( + folder_id, task_id, workfile_info, filepath + ) + self._items[identifier] = item + return item + + def save_workfile_info(self, folder_id, task_id, filepath, note): + rootless_path = self._get_rootless_path(filepath) + identifier = self._get_workfile_info_identifier( + folder_id, task_id, rootless_path + ) + workfile_info = self._get_workfile_info( + folder_id, task_id, identifier + ) + if not workfile_info: + self._cache[identifier] = self._create_workfile_info_entity( + task_id, rootless_path, note) + self._items.pop(identifier, None) + return + + new_workfile_info = copy.deepcopy(workfile_info) + attrib = new_workfile_info.setdefault("attrib", {}) + attrib["description"] = note + update_data = prepare_workfile_info_update_data( + workfile_info, new_workfile_info + ) + self._cache[identifier] = new_workfile_info + self._items.pop(identifier, None) + if not update_data: + return + + project_name = self._controller.get_current_project_name() + + session = OperationsSession() + session.update_entity( + project_name, "workfile", workfile_info["id"], update_data + ) + session.commit() + + def _create_workfile_info_entity(self, task_id, rootless_path, note): + extension = os.path.splitext(rootless_path)[1] + + project_name = self._controller.get_current_project_name() + + workfile_info = { + "path": rootless_path, + "taskId": task_id, + "attrib": { + "extension": extension, + "description": note + } + } + + session = OperationsSession() + session.create_entity(project_name, "workfile", workfile_info) + session.commit() + return workfile_info + + +class PublishWorkfilesModel: + """Model for handling of published workfiles. + + Todos: + Cache workfiles products and representations for some time. + Note Representations won't change. Only what can change are + versions. + """ + + def __init__(self, controller): + self._controller = controller + self._cached_extensions = None + self._cached_repre_extensions = None + + @property + def _extensions(self): + if self._cached_extensions is None: + exts = self._controller.get_workfile_extensions() or [] + self._cached_extensions = exts + return self._cached_extensions + + @property + def _repre_extensions(self): + if self._cached_repre_extensions is None: + self._cached_repre_extensions = { + ext.lstrip(".") for ext in self._extensions + } + return self._cached_repre_extensions + + def _file_item_from_representation( + self, repre_entity, project_anatomy, task_name=None + ): + if task_name is not None: + task_info = repre_entity["context"].get("task") + if not task_info or task_info["name"] != task_name: + return None + + # Filter by extension + extensions = self._repre_extensions + workfile_path = None + for repre_file in repre_entity["files"]: + ext = ( + os.path.splitext(repre_file["name"])[1] + .lower() + .lstrip(".") + ) + if ext in extensions: + workfile_path = repre_file["path"] + break + + if not workfile_path: + return None + + try: + workfile_path = workfile_path.format( + root=project_anatomy.roots) + except Exception as exc: + print("Failed to format workfile path: {}".format(exc)) + + dirpath, filename = os.path.split(workfile_path) + created_at = arrow.get(repre_entity["createdAt"]) + return FileItem( + dirpath, + filename, + created_at.float_timestamp, + repre_entity["id"] + ) + + def get_file_items(self, folder_id, task_name): + # TODO refactor to use less server API calls + project_name = self._controller.get_current_project_name() + # Get subset docs of asset + product_entities = ayon_api.get_products( + project_name, + folder_ids=[folder_id], + product_types=["workfile"], + fields=["id", "name"] + ) + + output = [] + product_ids = {product["id"] for product in product_entities} + if not product_ids: + return output + + # Get version docs of subsets with their families + version_entities = ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields=["id", "productId"] + ) + version_ids = {version["id"] for version in version_entities} + if not version_ids: + return output + + # Query representations of filtered versions and add filter for + # extension + repre_entities = ayon_api.get_representations( + project_name, + version_ids=version_ids + ) + project_anatomy = self._controller.project_anatomy + + # Filter queried representations by task name if task is set + file_items = [] + for repre_entity in repre_entities: + file_item = self._file_item_from_representation( + repre_entity, project_anatomy, task_name + ) + if file_item is not None: + file_items.append(file_item) + + return file_items + + +class WorkfilesModel: + """Workfiles model.""" + + def __init__(self, controller): + self._controller = controller + + self._entities_model = WorkfileEntitiesModel(controller) + self._workarea_model = WorkareaModel(controller) + self._published_model = PublishWorkfilesModel(controller) + + def get_workfile_info(self, folder_id, task_id, filepath): + return self._entities_model.get_workfile_info( + folder_id, task_id, filepath + ) + + def save_workfile_info(self, folder_id, task_id, filepath, note): + self._entities_model.save_workfile_info( + folder_id, task_id, filepath, note + ) + + def get_workarea_dir_by_context(self, folder_id, task_id): + """Workarea dir for passed context. + + The directory path is based on project anatomy templates. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + Union[str, None]: Workarea dir path or None for invalid context. + """ + + return self._workarea_model.get_workarea_dir_by_context( + folder_id, task_id) + + def get_workarea_file_items(self, folder_id, task_id): + """Workfile items for passed context from workarea. + + Args: + folder_id (Union[str, None]): Folder id. + task_id (Union[str, None]): Task id. + + Returns: + list[FileItem]: List of file items matching workarea of passed + context. + """ + + return self._workarea_model.get_file_items(folder_id, task_id) + + def get_workarea_save_as_data(self, folder_id, task_id): + return self._workarea_model.get_workarea_save_as_data( + folder_id, task_id) + + def fill_workarea_filepath(self, *args, **kwargs): + return self._workarea_model.fill_workarea_filepath( + *args, **kwargs + ) + + def get_published_file_items(self, folder_id, task_name): + """Published workfiles for passed context. + + Args: + folder_id (str): Folder id. + task_name (str): Task name. + + Returns: + list[FileItem]: List of files for published workfiles. + """ + + return self._published_model.get_file_items(folder_id, task_name) diff --git a/openpype/tools/ayon_workfiles/widgets/__init__.py b/openpype/tools/ayon_workfiles/widgets/__init__.py new file mode 100644 index 0000000000..f0c5ba1c40 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/__init__.py @@ -0,0 +1,6 @@ +from .window import WorkfilesToolWindow + + +__all__ = ( + "WorkfilesToolWindow", +) diff --git a/openpype/tools/ayon_workfiles/widgets/constants.py b/openpype/tools/ayon_workfiles/widgets/constants.py new file mode 100644 index 0000000000..fc74fd9bc4 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/constants.py @@ -0,0 +1,7 @@ +from qtpy import QtCore + + +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 diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget.py b/openpype/tools/ayon_workfiles/widgets/files_widget.py new file mode 100644 index 0000000000..fbf4dbc593 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget.py @@ -0,0 +1,398 @@ +import os + +import qtpy +from qtpy import QtWidgets, QtCore + +from .save_as_dialog import SaveAsDialog +from .files_widget_workarea import WorkAreaFilesWidget +from .files_widget_published import PublishedFilesWidget + + +class FilesWidget(QtWidgets.QWidget): + """A widget displaying files that allows to save and open files. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + def __init__(self, controller, parent): + super(FilesWidget, self).__init__(parent) + + files_widget = QtWidgets.QStackedWidget(self) + workarea_widget = WorkAreaFilesWidget(controller, files_widget) + published_widget = PublishedFilesWidget(controller, files_widget) + files_widget.addWidget(workarea_widget) + files_widget.addWidget(published_widget) + + btns_widget = QtWidgets.QWidget(self) + + workarea_btns_widget = QtWidgets.QWidget(btns_widget) + workarea_btn_open = QtWidgets.QPushButton( + "Open", workarea_btns_widget) + workarea_btn_browse = QtWidgets.QPushButton( + "Browse", workarea_btns_widget) + workarea_btn_save = QtWidgets.QPushButton( + "Save As", workarea_btns_widget) + + workarea_btns_layout = QtWidgets.QHBoxLayout(workarea_btns_widget) + workarea_btns_layout.setContentsMargins(0, 0, 0, 0) + workarea_btns_layout.addWidget(workarea_btn_open, 1) + workarea_btns_layout.addWidget(workarea_btn_browse, 1) + workarea_btns_layout.addWidget(workarea_btn_save, 1) + + published_btns_widget = QtWidgets.QWidget(btns_widget) + published_btn_copy_n_open = QtWidgets.QPushButton( + "Copy && Open", published_btns_widget + ) + published_btn_change_context = QtWidgets.QPushButton( + "Choose different context", published_btns_widget + ) + published_btn_cancel = QtWidgets.QPushButton( + "Cancel", published_btns_widget + ) + + published_btns_layout = QtWidgets.QHBoxLayout(published_btns_widget) + published_btns_layout.setContentsMargins(0, 0, 0, 0) + published_btns_layout.addWidget(published_btn_copy_n_open, 1) + published_btns_layout.addWidget(published_btn_change_context, 1) + published_btns_layout.addWidget(published_btn_cancel, 1) + + btns_layout = QtWidgets.QVBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(workarea_btns_widget, 1) + btns_layout.addWidget(published_btns_widget, 1) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(files_widget, 1) + main_layout.addWidget(btns_widget, 0) + + controller.register_event_callback( + "workarea.selection.changed", + self._on_workarea_path_changed + ) + controller.register_event_callback( + "selection.representation.changed", + self._on_published_repre_changed + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "copy_representation.finished", + self._on_copy_representation_finished, + ) + controller.register_event_callback( + "workfile_save_enable.changed", + self._on_workfile_save_enabled_change, + ) + + workarea_widget.open_current_requested.connect( + self._on_current_open_requests) + workarea_widget.duplicate_requested.connect( + self._on_duplicate_request) + workarea_btn_open.clicked.connect(self._on_workarea_open_clicked) + workarea_btn_browse.clicked.connect(self._on_workarea_browse_clicked) + workarea_btn_save.clicked.connect(self._on_workarea_save_clicked) + + published_widget.save_as_requested.connect(self._on_save_as_request) + published_btn_copy_n_open.clicked.connect( + self._on_published_save_clicked) + published_btn_change_context.clicked.connect( + self._on_published_change_context_clicked) + published_btn_cancel.clicked.connect( + self._on_published_cancel_clicked) + + self._selected_folder_id = None + self._selected_tak_name = None + + self._pre_select_folder_id = None + self._pre_select_task_name = None + + self._select_context_mode = False + self._valid_selected_context = False + self._valid_representation_id = False + self._tmp_text_filter = None + self._is_save_enabled = True + + self._controller = controller + self._files_widget = files_widget + self._workarea_widget = workarea_widget + self._published_widget = published_widget + self._workarea_btns_widget = workarea_btns_widget + self._published_btns_widget = published_btns_widget + + self._workarea_btn_open = workarea_btn_open + self._workarea_btn_browse = workarea_btn_browse + self._workarea_btn_save = workarea_btn_save + + self._published_widget = published_widget + self._published_btn_copy_n_open = published_btn_copy_n_open + self._published_btn_change_context = published_btn_change_context + self._published_btn_cancel = published_btn_cancel + + # Initial setup + workarea_btn_open.setEnabled(False) + published_btn_copy_n_open.setEnabled(False) + published_btn_change_context.setEnabled(False) + published_btn_cancel.setVisible(False) + + def set_published_mode(self, published_mode): + # Make sure context selection is disabled + self._set_select_contex_mode(False) + # Change current widget + self._files_widget.setCurrentWidget(( + self._published_widget + if published_mode + else self._workarea_widget + )) + # Pass the mode to the widgets, so they can start/stop handle events + self._workarea_widget.set_published_mode(published_mode) + self._published_widget.set_published_mode(published_mode) + + # Change available buttons + self._workarea_btns_widget.setVisible(not published_mode) + self._published_btns_widget.setVisible(published_mode) + + def set_text_filter(self, text_filter): + if self._select_context_mode: + self._tmp_text_filter = text_filter + return + self._workarea_widget.set_text_filter(text_filter) + self._published_widget.set_text_filter(text_filter) + + def _exec_save_as_dialog(self): + """Show SaveAs dialog using currently selected context. + + Returns: + Union[dict[str, Any], None]: Result of the dialog. + """ + + dialog = SaveAsDialog(self._controller, self) + dialog.update_context() + dialog.exec_() + return dialog.get_result() + + # ------------------------------------------------------------- + # Workarea workfiles + # ------------------------------------------------------------- + def _open_workfile(self, filepath): + if self._controller.has_unsaved_changes(): + result = self._save_changes_prompt() + if result is None: + return + + if result: + self._controller.save_current_workfile() + self._controller.open_workfile(filepath) + + def _on_workarea_open_clicked(self): + path = self._workarea_widget.get_selected_path() + if path: + self._open_workfile(path) + + def _on_current_open_requests(self): + self._on_workarea_open_clicked() + + def _on_duplicate_request(self): + filepath = self._workarea_widget.get_selected_path() + if filepath is None: + return + + result = self._exec_save_as_dialog() + if result is None: + return + self._controller.duplicate_workfile( + filepath, + result["workdir"], + result["filename"] + ) + + def _on_workarea_browse_clicked(self): + extnsions = self._controller.get_workfile_extensions() + ext_filter = "Work File (*{0})".format( + " *".join(extnsions) + ) + dir_key = "directory" + if qtpy.API in ("pyside", "pyside2", "pyside6"): + dir_key = "dir" + + selected_context = self._controller.get_selected_context() + workfile_root = self._controller.get_workarea_dir_by_context( + selected_context["folder_id"], selected_context["task_id"] + ) + # Find existing directory of workfile root + # - Qt will use 'cwd' instead, if path does not exist, which may lead + # to igniter directory + while workfile_root: + if os.path.exists(workfile_root): + break + workfile_root = os.path.dirname(workfile_root) + + kwargs = { + "caption": "Work Files", + "filter": ext_filter, + dir_key: workfile_root + } + + filepath = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] + if filepath: + self._open_workfile(filepath) + + def _on_workarea_save_clicked(self): + result = self._exec_save_as_dialog() + if result is None: + return + self._controller.save_as_workfile( + result["folder_id"], + result["task_id"], + result["workdir"], + result["filename"], + result["template_key"], + ) + + def _on_workarea_path_changed(self, event): + valid_path = event["path"] is not None + self._workarea_btn_open.setEnabled(valid_path) + + # ------------------------------------------------------------- + # Published workfiles + # ------------------------------------------------------------- + def _update_published_btns_state(self): + enabled = ( + self._valid_representation_id + and self._valid_selected_context + and self._is_save_enabled + ) + self._published_btn_copy_n_open.setEnabled(enabled) + self._published_btn_change_context.setEnabled(enabled) + + def _update_workarea_btns_state(self): + enabled = self._is_save_enabled + self._workarea_btn_save.setEnabled(enabled) + + def _on_published_repre_changed(self, event): + self._valid_representation_id = event["representation_id"] is not None + self._update_published_btns_state() + + def _on_task_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._selected_tak_name = event["task_name"] + self._valid_selected_context = ( + self._selected_folder_id is not None + and self._selected_tak_name is not None + ) + self._update_published_btns_state() + + def _on_published_save_clicked(self): + result = self._exec_save_as_dialog() + if result is None: + return + + repre_info = self._published_widget.get_selected_repre_info() + self._controller.copy_workfile_representation( + repre_info["representation_id"], + repre_info["filepath"], + result["folder_id"], + result["task_id"], + result["workdir"], + result["filename"], + result["template_key"], + ) + + def _on_save_as_request(self): + self._on_published_save_clicked() + + def _set_select_contex_mode(self, enabled): + if self._select_context_mode is enabled: + return + + if enabled: + self._pre_select_folder_id = self._selected_folder_id + self._pre_select_task_name = self._selected_tak_name + else: + self._pre_select_folder_id = None + self._pre_select_task_name = None + self._select_context_mode = enabled + self._published_btn_cancel.setVisible(enabled) + self._published_btn_change_context.setVisible(not enabled) + self._published_widget.set_select_context_mode(enabled) + + if not enabled and self._tmp_text_filter is not None: + self.set_text_filter(self._tmp_text_filter) + self._tmp_text_filter = None + + def _on_published_change_context_clicked(self): + self._set_select_contex_mode(True) + + def _should_set_pre_select_context(self): + if self._pre_select_folder_id is None: + return False + if self._pre_select_folder_id != self._selected_folder_id: + return True + if self._pre_select_task_name is None: + return False + return self._pre_select_task_name != self._selected_tak_name + + def _on_published_cancel_clicked(self): + folder_id = self._pre_select_folder_id + task_name = self._pre_select_task_name + representation_id = self._published_widget.get_selected_repre_id() + should_change_selection = self._should_set_pre_select_context() + self._set_select_contex_mode(False) + if should_change_selection: + self._controller.set_expected_selection( + folder_id, task_name, representation_id=representation_id + ) + + def _on_copy_representation_finished(self, event): + """Callback for when copy representation is finished. + + Make sure that select context mode is disabled when representation + copy is finished. + + Args: + event (Event): Event object. + """ + + if not event["failed"]: + self._set_select_contex_mode(False) + + def _on_workfile_save_enabled_change(self, event): + enabled = event["enabled"] + self._is_save_enabled = enabled + self._update_published_btns_state() + self._update_workarea_btns_state() + + def _save_changes_prompt(self): + """Ask user if wants to save changes to current file. + + Returns: + Union[bool, None]: True if user wants to save changes, False if + user does not want to save changes, None if user cancels + operation. + """ + messagebox = QtWidgets.QMessageBox(parent=self) + messagebox.setWindowFlags( + messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + messagebox.setIcon(QtWidgets.QMessageBox.Warning) + messagebox.setWindowTitle("Unsaved Changes!") + messagebox.setText( + "There are unsaved changes to the current file." + "\nDo you want to save the changes?" + ) + messagebox.setStandardButtons( + QtWidgets.QMessageBox.Yes + | QtWidgets.QMessageBox.No + | QtWidgets.QMessageBox.Cancel + ) + + result = messagebox.exec_() + if result == QtWidgets.QMessageBox.Yes: + return True + if result == QtWidgets.QMessageBox.No: + return False + return None diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_published.py b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py new file mode 100644 index 0000000000..bc59447777 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_published.py @@ -0,0 +1,378 @@ +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .utils import TreeView, BaseOverlayFrame + + +REPRE_ID_ROLE = QtCore.Qt.UserRole + 1 +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 + + +class PublishedFilesModel(QtGui.QStandardItemModel): + """A model for displaying files. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller): + super(PublishedFilesModel, self).__init__() + + self.setColumnCount(2) + + self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") + self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_changed + ) + + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._controller = controller + self._items_by_id = {} + self._missing_context_item = None + self._missing_context_used = False + self._empty_root_item = None + self._empty_item_used = False + + self._published_mode = False + self._context_select_mode = False + + self._last_folder_id = None + self._last_task_id = None + + self._add_empty_item() + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_id: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_id = {} + + def set_published_mode(self, published_mode): + if self._published_mode == published_mode: + return + self._published_mode = published_mode + if published_mode: + self._fill_items() + elif self._context_select_mode: + self.set_select_context_mode(False) + + def set_select_context_mode(self, select_mode): + if self._context_select_mode is select_mode: + return + self._context_select_mode = select_mode + if not select_mode and self._published_mode: + self._fill_items() + + def get_index_by_representation_id(self, representation_id): + item = self._items_by_id.get(representation_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def _get_missing_context_item(self): + if self._missing_context_item is None: + message = "Select folder" + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._missing_context_item = item + return self._missing_context_item + + def _add_missing_context_item(self): + if self._missing_context_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_missing_context_item()) + self._missing_context_used = True + + def _remove_missing_context_item(self): + if not self._missing_context_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._missing_context_item.row()) + self._missing_context_used = False + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Didn't find any published workfiles." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def _add_empty_item(self): + if self._empty_item_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_empty_root_item()) + self._empty_item_used = True + + def _remove_empty_item(self): + if not self._empty_item_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._empty_root_item.row()) + self._empty_item_used = False + + def _on_folder_changed(self, event): + self._last_folder_id = event["folder_id"] + self._last_task_id = None + if self._context_select_mode: + return + + if self._published_mode: + self._fill_items() + + def _on_task_changed(self, event): + self._last_folder_id = event["folder_id"] + self._last_task_id = event["task_id"] + if self._context_select_mode: + return + + if self._published_mode: + self._fill_items() + + def _fill_items(self): + folder_id = self._last_folder_id + task_id = self._last_task_id + if not folder_id: + self._add_missing_context_item() + return + + file_items = self._controller.get_published_file_items( + folder_id, task_id + ) + root_item = self.invisibleRootItem() + if not file_items: + self._add_empty_item() + return + self._remove_empty_item() + self._remove_missing_context_item() + + items_to_remove = set(self._items_by_id.keys()) + new_items = [] + for file_item in file_items: + repre_id = file_item.representation_id + if repre_id in self._items_by_id: + items_to_remove.discard(repre_id) + item = self._items_by_id[repre_id] + else: + item = QtGui.QStandardItem() + new_items.append(item) + item.setColumnCount(self.columnCount()) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + item.setData(file_item.filename, QtCore.Qt.DisplayRole) + item.setData(repre_id, REPRE_ID_ROLE) + + if file_item.exists: + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + else: + flags = QtCore.Qt.NoItemFlags + + item.setFlags(flags) + item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.modified, DATE_MODIFIED_ROLE) + + self._items_by_id[repre_id] = item + + if new_items: + root_item.appendRows(new_items) + + for repre_id in items_to_remove: + item = self._items_by_id.pop(repre_id) + root_item.removeRow(item.row()) + + if root_item.rowCount() == 0: + self._add_empty_item() + + def flags(self, index): + # Use flags of first column for all columns + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(PublishedFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + # Handle roles for first column + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(PublishedFilesModel, self).data(index, role) + + +class SelectContextOverlay(BaseOverlayFrame): + """Overlay for files view when user should select context. + + Todos: + The look of this overlay should be improved, it is "not nice" now. + """ + + def __init__(self, parent): + super(SelectContextOverlay, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + "Please choose context on the left
<", + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setObjectName("OverlayFrameLabel") + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class PublishedFilesWidget(QtWidgets.QWidget): + """Published workfiles widget. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + selection_changed = QtCore.Signal() + save_as_requested = QtCore.Signal() + + def __init__(self, controller, parent): + super(PublishedFilesWidget, self).__init__(parent) + + view = TreeView(self) + view.setSortingEnabled(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Smaller indentation + view.setIndentation(0) + + model = PublishedFilesModel(controller) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setDynamicSortFilter(True) + + view.setModel(proxy_model) + + time_delegate = PrettyTimeDelegate() + view.setItemDelegateForColumn(1, time_delegate) + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + view.setColumnWidth(0, 330) + + select_overlay = SelectContextOverlay(view) + select_overlay.setVisible(False) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(view, 1) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + view.double_clicked_left.connect(self._on_left_double_click) + + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + self._view = view + self._select_overlay = select_overlay + self._model = model + self._proxy_model = proxy_model + self._time_delegate = time_delegate + self._controller = controller + + def set_published_mode(self, published_mode): + self._model.set_published_mode(published_mode) + + def set_select_context_mode(self, select_mode): + self._model.set_select_context_mode(select_mode) + self._select_overlay.setVisible(select_mode) + + def set_text_filter(self, text_filter): + self._proxy_model.setFilterFixedString(text_filter) + + def get_selected_repre_info(self): + selection_model = self._view.selectionModel() + representation_id = None + filepath = None + for index in selection_model.selectedIndexes(): + representation_id = index.data(REPRE_ID_ROLE) + filepath = index.data(FILEPATH_ROLE) + + return { + "representation_id": representation_id, + "filepath": filepath, + } + + def get_selected_repre_id(self): + return self.get_selected_repre_info()["representation_id"] + + def _on_selection_change(self): + repre_id = self.get_selected_repre_id() + self._controller.set_selected_representation_id(repre_id) + + def _on_left_double_click(self): + self.save_as_requested.emit() + + def _on_expected_selection_change(self, event): + if ( + event["representation_id_selected"] + or not event["folder_selected"] + or (event["task_name"] and not event["task_selected"]) + ): + return + + representation_id = event["representation_id"] + selected_repre_id = self.get_selected_repre_id() + if ( + representation_id is not None + and representation_id != selected_repre_id + ): + index = self._model.get_index_by_representation_id( + representation_id) + if index.isValid(): + proxy_index = self._proxy_model.mapFromSource(index) + self._view.setCurrentIndex(proxy_index) + + self._controller.expected_representation_selected( + event["folder_id"], event["task_name"], representation_id + ) diff --git a/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py new file mode 100644 index 0000000000..e8ccd094d1 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/files_widget_workarea.py @@ -0,0 +1,380 @@ +import qtawesome +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import ( + get_default_entity_icon_color, + get_disabled_entity_icon_color, +) +from openpype.tools.utils.delegates import PrettyTimeDelegate + +from .utils import TreeView + +FILENAME_ROLE = QtCore.Qt.UserRole + 1 +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 + + +class WorkAreaFilesModel(QtGui.QStandardItemModel): + """A model for workare workfiles. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller): + super(WorkAreaFilesModel, self).__init__() + + self.setColumnCount(2) + + self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") + self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") + + controller.register_event_callback( + "selection.task.changed", + self._on_task_changed + ) + controller.register_event_callback( + "workfile_duplicate.finished", + self._on_duplicate_finished + ) + controller.register_event_callback( + "save_as.finished", + self._on_save_as_finished + ) + + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._controller = controller + self._items_by_filename = {} + self._missing_context_item = None + self._missing_context_used = False + self._empty_root_item = None + self._empty_item_used = False + self._published_mode = False + self._selected_folder_id = None + self._selected_task_id = None + + self._add_missing_context_item() + + def get_index_by_filename(self, filename): + item = self._items_by_filename.get(filename) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def _get_missing_context_item(self): + if self._missing_context_item is None: + message = "Select folder and task" + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._missing_context_item = item + return self._missing_context_item + + def _clear_items(self): + self._remove_missing_context_item() + self._remove_empty_item() + if self._items_by_filename: + root = self.invisibleRootItem() + root.removeRows(0, root.rowCount()) + self._items_by_filename = {} + + def _add_missing_context_item(self): + if self._missing_context_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_missing_context_item()) + self._missing_context_used = True + + def _remove_missing_context_item(self): + if not self._missing_context_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._missing_context_item.row()) + self._missing_context_used = False + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Work Area is empty.." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.exclamation-circle", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def _add_empty_item(self): + if self._empty_item_used: + return + self._clear_items() + root_item = self.invisibleRootItem() + root_item.appendRow(self._get_empty_root_item()) + self._empty_item_used = True + + def _remove_empty_item(self): + if not self._empty_item_used: + return + root_item = self.invisibleRootItem() + root_item.takeRow(self._empty_root_item.row()) + self._empty_item_used = False + + def _on_task_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + if not self._published_mode: + self._fill_items() + + def _on_duplicate_finished(self, event): + if event["failed"]: + return + + if not self._published_mode: + self._fill_items() + + def _on_save_as_finished(self, event): + if event["failed"]: + return + + if not self._published_mode: + self._fill_items() + + def _fill_items(self): + folder_id = self._selected_folder_id + task_id = self._selected_task_id + if not folder_id or not task_id: + self._add_missing_context_item() + return + + file_items = self._controller.get_workarea_file_items( + folder_id, task_id + ) + root_item = self.invisibleRootItem() + if not file_items: + self._add_empty_item() + return + self._remove_empty_item() + self._remove_missing_context_item() + + items_to_remove = set(self._items_by_filename.keys()) + new_items = [] + for file_item in file_items: + filename = file_item.filename + if filename in self._items_by_filename: + items_to_remove.discard(filename) + item = self._items_by_filename[filename] + else: + item = QtGui.QStandardItem() + new_items.append(item) + item.setColumnCount(self.columnCount()) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + item.setData(file_item.filename, QtCore.Qt.DisplayRole) + item.setData(file_item.filename, FILENAME_ROLE) + + item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.modified, DATE_MODIFIED_ROLE) + + self._items_by_filename[file_item.filename] = item + + if new_items: + root_item.appendRows(new_items) + + for filename in items_to_remove: + item = self._items_by_filename.pop(filename) + root_item.removeRow(item.row()) + + if root_item.rowCount() == 0: + self._add_empty_item() + + def flags(self, index): + # Use flags of first column for all columns + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(WorkAreaFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + # Handle roles for first column + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(WorkAreaFilesModel, self).data(index, role) + + def set_published_mode(self, published_mode): + if self._published_mode == published_mode: + return + self._published_mode = published_mode + if not published_mode: + self._fill_items() + + +class WorkAreaFilesWidget(QtWidgets.QWidget): + """Workarea files widget. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + selection_changed = QtCore.Signal() + open_current_requested = QtCore.Signal() + duplicate_requested = QtCore.Signal() + + def __init__(self, controller, parent): + super(WorkAreaFilesWidget, self).__init__(parent) + + view = TreeView(self) + view.setSortingEnabled(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # Smaller indentation + view.setIndentation(0) + + model = WorkAreaFilesModel(controller) + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setDynamicSortFilter(True) + + view.setModel(proxy_model) + + time_delegate = PrettyTimeDelegate() + view.setItemDelegateForColumn(1, time_delegate) + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + view.setColumnWidth(0, 330) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(view, 1) + + selection_model = view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + view.double_clicked_left.connect(self._on_left_double_click) + view.customContextMenuRequested.connect(self._on_context_menu) + + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + self._view = view + self._model = model + self._proxy_model = proxy_model + self._time_delegate = time_delegate + self._controller = controller + + self._published_mode = False + + def set_published_mode(self, published_mode): + """Set the published mode. + + Widget should ignore most of events when in published mode is enabled. + + Args: + published_mode (bool): The published mode. + """ + + self._model.set_published_mode(published_mode) + self._published_mode = published_mode + + def set_text_filter(self, text_filter): + """Set the text filter. + + Args: + text_filter (str): The text filter. + """ + + self._proxy_model.setFilterFixedString(text_filter) + + def _get_selected_info(self): + selection_model = self._view.selectionModel() + filepath = None + filename = None + for index in selection_model.selectedIndexes(): + filepath = index.data(FILEPATH_ROLE) + filename = index.data(FILENAME_ROLE) + return { + "filepath": filepath, + "filename": filename, + } + + def get_selected_path(self): + """Selected filepath. + + Returns: + Union[str, None]: The selected filepath or None if nothing is + selected. + """ + return self._get_selected_info()["filepath"] + + def _on_selection_change(self): + filepath = self.get_selected_path() + self._controller.set_selected_workfile_path(filepath) + + def _on_left_double_click(self): + self.open_current_requested.emit() + + def _on_context_menu(self, point): + index = self._view.indexAt(point) + if not index.isValid(): + return + + if not index.flags() & QtCore.Qt.ItemIsEnabled: + return + + menu = QtWidgets.QMenu(self) + + # Duplicate + action = QtWidgets.QAction("Duplicate", menu) + tip = "Duplicate selected file." + action.setToolTip(tip) + action.setStatusTip(tip) + action.triggered.connect(self._on_duplicate_pressed) + menu.addAction(action) + + # Show the context action menu + global_point = self._view.mapToGlobal(point) + _ = menu.exec_(global_point) + + def _on_duplicate_pressed(self): + self.duplicate_requested.emit() + + def _on_expected_selection_change(self, event): + if event["workfile_name_selected"]: + return + + workfile_name = event["workfile_name"] + if ( + workfile_name is not None + and workfile_name != self._get_selected_info()["filename"] + ): + index = self._model.get_index_by_filename(workfile_name) + if index.isValid(): + proxy_index = self._proxy_model.mapFromSource(index) + self._view.setCurrentIndex(proxy_index) + + self._controller.expected_workfile_selected( + event["folder_id"], event["task_name"], workfile_name + ) diff --git a/openpype/tools/ayon_workfiles/widgets/folders_widget.py b/openpype/tools/ayon_workfiles/widgets/folders_widget.py new file mode 100644 index 0000000000..b35845f4b6 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/folders_widget.py @@ -0,0 +1,324 @@ +import uuid +import collections + +import qtawesome +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.tools.utils import ( + RecursiveSortFilterProxyModel, + DeselectableTreeView, +) + +from .constants import ITEM_ID_ROLE, ITEM_NAME_ROLE + +SENDER_NAME = "qt_folders_model" + + +class FoldersRefreshThread(QtCore.QThread): + """Thread for refreshing folders. + + Call controller to get folders and emit signal when finished. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refresh_finished = QtCore.Signal(str) + + def __init__(self, controller): + super(FoldersRefreshThread, self).__init__() + self._id = uuid.uuid4().hex + self._controller = controller + self._result = None + + @property + def id(self): + """Thread id. + + Returns: + str: Unique id of the thread. + """ + + return self._id + + def run(self): + self._result = self._controller.get_folder_items(SENDER_NAME) + self.refresh_finished.emit(self.id) + + def get_result(self): + return self._result + + +class FoldersModel(QtGui.QStandardItemModel): + """Folders model which cares about refresh of folders. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(FoldersModel, self).__init__() + + self._controller = controller + self._items_by_id = {} + self._parent_id_by_id = {} + + self._refresh_threads = {} + self._current_refresh_thread = None + + self._has_content = False + self._is_refreshing = False + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: True if model is refreshing. + """ + return self._is_refreshing + + @property + def has_content(self): + """Has at least one folder. + + Returns: + bool: True if model has at least one folder. + """ + + return self._has_content + + def clear(self): + self._items_by_id = {} + self._parent_id_by_id = {} + self._has_content = False + super(FoldersModel, self).clear() + + def get_index_by_id(self, item_id): + """Get index by folder id. + + Returns: + QtCore.QModelIndex: Index of the folder. Can be invalid if folder + is not available. + """ + item = self._items_by_id.get(item_id) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def refresh(self): + """Refresh folders items. + + Refresh start thread because it can cause that controller can + start query from database if folders are not cached. + """ + + self._is_refreshing = True + + thread = FoldersRefreshThread(self._controller) + self._current_refresh_thread = thread.id + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Folders are stored by id. + + Args: + thread_id (str): Thread id. + """ + + thread = self._refresh_threads.pop(thread_id) + if thread_id != self._current_refresh_thread: + return + + folder_items_by_id = thread.get_result() + if not folder_items_by_id: + if folder_items_by_id is not None: + self.clear() + self._is_refreshing = False + return + + self._has_content = True + + folder_ids = set(folder_items_by_id) + ids_to_remove = set(self._items_by_id) - folder_ids + + folder_items_by_parent = collections.defaultdict(list) + for folder_item in folder_items_by_id.values(): + folder_items_by_parent[folder_item.parent_id].append(folder_item) + + hierarchy_queue = collections.deque() + hierarchy_queue.append(None) + + while hierarchy_queue: + parent_id = hierarchy_queue.popleft() + folder_items = folder_items_by_parent[parent_id] + if parent_id is None: + parent_item = self.invisibleRootItem() + else: + parent_item = self._items_by_id[parent_id] + + new_items = [] + for folder_item in folder_items: + item_id = folder_item.entity_id + item = self._items_by_id.get(item_id) + if item is None: + is_new = True + item = QtGui.QStandardItem() + item.setEditable(False) + else: + is_new = self._parent_id_by_id[item_id] != parent_id + + icon = qtawesome.icon( + folder_item.icon_name, + color=folder_item.icon_color, + ) + item.setData(item_id, ITEM_ID_ROLE) + item.setData(folder_item.name, ITEM_NAME_ROLE) + item.setData(folder_item.label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + if is_new: + new_items.append(item) + self._items_by_id[item_id] = item + self._parent_id_by_id[item_id] = parent_id + + hierarchy_queue.append(item_id) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in ids_to_remove: + item = self._items_by_id[item_id] + parent_id = self._parent_id_by_id[item_id] + if parent_id is None: + parent_item = self.invisibleRootItem() + else: + parent_item = self._items_by_id[parent_id] + parent_item.takeChild(item.row()) + + for item_id in ids_to_remove: + self._items_by_id.pop(item_id) + self._parent_id_by_id.pop(item_id) + + self._is_refreshing = False + self.refreshed.emit() + + +class FoldersWidget(QtWidgets.QWidget): + """Folders widget. + + Widget that handles folders view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + def __init__(self, controller, parent): + super(FoldersWidget, self).__init__(parent) + + folders_view = DeselectableTreeView(self) + folders_view.setHeaderHidden(True) + + folders_model = FoldersModel(controller) + folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model.setSourceModel(folders_model) + + folders_view.setModel(folders_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(folders_view, 1) + + controller.register_event_callback( + "folders.refresh.finished", + self._on_folders_refresh_finished + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = folders_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + folders_model.refreshed.connect(self._on_model_refresh) + + self._controller = controller + self._folders_view = folders_view + self._folders_model = folders_model + self._folders_proxy_model = folders_proxy_model + + self._expected_selection = None + + def set_name_filer(self, name): + self._folders_proxy_model.setFilterFixedString(name) + + def _clear(self): + self._folders_model.clear() + + def _on_folders_refresh_finished(self, event): + if event["sender"] != SENDER_NAME: + self._folders_model.refresh() + + def _on_controller_refresh(self): + self._update_expected_selection() + + def _update_expected_selection(self, expected_data=None): + if expected_data is None: + expected_data = self._controller.get_expected_selection_data() + + # We're done + if expected_data["folder_selected"]: + return + + folder_id = expected_data["folder_id"] + self._expected_selection = folder_id + if not self._folders_model.is_refreshing: + self._set_expected_selection() + + def _set_expected_selection(self): + folder_id = self._expected_selection + self._expected_selection = None + if ( + folder_id is not None + and folder_id != self._get_selected_item_id() + ): + index = self._folders_model.get_index_by_id(folder_id) + if index.isValid(): + proxy_index = self._folders_proxy_model.mapFromSource(index) + self._folders_view.setCurrentIndex(proxy_index) + self._controller.expected_folder_selected(folder_id) + + def _on_model_refresh(self): + if self._expected_selection: + self._set_expected_selection() + self._folders_proxy_model.sort(0) + + def _on_expected_selection_change(self, event): + self._update_expected_selection(event.data) + + def _get_selected_item_id(self): + selection_model = self._folders_view.selectionModel() + for index in selection_model.selectedIndexes(): + item_id = index.data(ITEM_ID_ROLE) + if item_id is not None: + return item_id + return None + + def _on_selection_change(self): + item_id = self._get_selected_item_id() + self._controller.set_selected_folder(item_id) diff --git a/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py new file mode 100644 index 0000000000..cdce73f030 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/save_as_dialog.py @@ -0,0 +1,351 @@ +from qtpy import QtWidgets, QtCore + +from openpype.tools.utils import PlaceholderLineEdit + + +class SubversionLineEdit(QtWidgets.QWidget): + """QLineEdit with QPushButton for drop down selection of list of strings""" + + text_changed = QtCore.Signal(str) + + def __init__(self, *args, **kwargs): + super(SubversionLineEdit, self).__init__(*args, **kwargs) + + input_field = PlaceholderLineEdit(self) + menu_btn = QtWidgets.QPushButton(self) + menu_btn.setFixedWidth(18) + + menu = QtWidgets.QMenu(self) + menu_btn.setMenu(menu) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + layout.addWidget(input_field, 1) + layout.addWidget(menu_btn, 0) + + input_field.textChanged.connect(self.text_changed) + + self.setFocusProxy(input_field) + + self._input_field = input_field + self._menu_btn = menu_btn + self._menu = menu + + def set_placeholder(self, placeholder): + self._input_field.setPlaceholderText(placeholder) + + def set_text(self, text): + self._input_field.setText(text) + + def set_values(self, values): + self._update(values) + + def _on_button_clicked(self): + self._menu.exec_() + + def _on_action_clicked(self, action): + self._input_field.setText(action.text()) + + def _update(self, values): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + menu = self._menu + button = self._menu_btn + + state = any(values) + button.setEnabled(state) + if state is False: + return + + # Include an empty string + values = [""] + sorted(values) + + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + # Build new action group + group = QtWidgets.QActionGroup(button) + for name in values: + action = group.addAction(name) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + +class SaveAsDialog(QtWidgets.QDialog): + """Save as dialog to define a unique filename inside workdir. + + The filename is calculated in controller where UI sends values from + dialog inputs. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + def __init__(self, controller, parent): + super(SaveAsDialog, self).__init__(parent=parent) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) + + self._controller = controller + + self._folder_id = None + self._task_id = None + self._last_version = None + self._template_key = None + self._comment_value = None + self._version_value = None + self._ext_value = None + self._filename = None + self._workdir = None + + self._result = None + + # Btns widget + btns_widget = QtWidgets.QWidget(self) + + btn_ok = QtWidgets.QPushButton("Ok", btns_widget) + btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addWidget(btn_ok) + btns_layout.addWidget(btn_cancel) + + # Inputs widget + inputs_widget = QtWidgets.QWidget(self) + + # Version widget + version_widget = QtWidgets.QWidget(inputs_widget) + + # Version number input + version_input = QtWidgets.QSpinBox(version_widget) + version_input.setMinimum(1) + version_input.setMaximum(9999) + + # Last version checkbox + last_version_check = QtWidgets.QCheckBox( + "Next Available Version", version_widget + ) + last_version_check.setChecked(True) + + version_layout = QtWidgets.QHBoxLayout(version_widget) + version_layout.setContentsMargins(0, 0, 0, 0) + version_layout.addWidget(version_input) + version_layout.addWidget(last_version_check) + + # Preview widget + preview_widget = QtWidgets.QLabel("Preview filename", inputs_widget) + preview_widget.setWordWrap(True) + + # Subversion input + subversion_input = SubversionLineEdit(inputs_widget) + subversion_input.set_placeholder("Will be part of filename.") + + # Extensions combobox + extension_combobox = QtWidgets.QComboBox(inputs_widget) + # Add styled delegate to use stylesheets + extension_delegate = QtWidgets.QStyledItemDelegate() + extension_combobox.setItemDelegate(extension_delegate) + + version_label = QtWidgets.QLabel("Version:", inputs_widget) + subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget) + extension_label = QtWidgets.QLabel("Extension:", inputs_widget) + preview_label = QtWidgets.QLabel("Preview:", inputs_widget) + + # Build inputs + inputs_layout = QtWidgets.QGridLayout(inputs_widget) + inputs_layout.addWidget(version_label, 0, 0) + inputs_layout.addWidget(version_widget, 0, 1) + inputs_layout.addWidget(subversion_label, 1, 0) + inputs_layout.addWidget(subversion_input, 1, 1) + inputs_layout.addWidget(extension_label, 2, 0) + inputs_layout.addWidget(extension_combobox, 2, 1) + inputs_layout.addWidget(preview_label, 3, 0) + inputs_layout.addWidget(preview_widget, 3, 1) + + # Build layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget) + main_layout.addWidget(btns_widget) + + # Signal callback registration + version_input.valueChanged.connect(self._on_version_spinbox_change) + last_version_check.stateChanged.connect( + self._on_version_checkbox_change + ) + + subversion_input.text_changed.connect(self._on_comment_change) + extension_combobox.currentIndexChanged.connect( + self._on_extension_change) + + btn_ok.pressed.connect(self._on_ok_pressed) + btn_cancel.pressed.connect(self._on_cancel_pressed) + + # Store objects + self._inputs_layout = inputs_layout + + self._btn_ok = btn_ok + self._btn_cancel = btn_cancel + + self._version_widget = version_widget + + self._version_input = version_input + self._last_version_check = last_version_check + + self._extension_delegate = extension_delegate + self._extension_combobox = extension_combobox + self._subversion_input = subversion_input + self._preview_widget = preview_widget + + self._version_label = version_label + self._subversion_label = subversion_label + self._extension_label = extension_label + self._preview_label = preview_label + + # Post init setup + + # Allow "Enter" key to accept the save. + btn_ok.setDefault(True) + + # Disable version input if last version is checked + version_input.setEnabled(not last_version_check.isChecked()) + + # Force default focus to comment, some hosts didn't automatically + # apply focus to this line edit (e.g. Houdini) + subversion_input.setFocus() + + def get_result(self): + return self._result + + def update_context(self): + # Add version only if template contains version key + # - since the version can be padded with "{version:0>4}" we only search + # for "{version". + selected_context = self._controller.get_selected_context() + folder_id = selected_context["folder_id"] + task_id = selected_context["task_id"] + data = self._controller.get_workarea_save_as_data(folder_id, task_id) + last_version = data["last_version"] + comment = data["comment"] + comment_hints = data["comment_hints"] + + template_has_version = data["template_has_version"] + template_has_comment = data["template_has_comment"] + + self._folder_id = folder_id + self._task_id = task_id + self._workdir = data["workdir"] + self._comment_value = data["comment"] + self._ext_value = data["ext"] + self._template_key = data["template_key"] + self._last_version = data["last_version"] + + self._extension_combobox.clear() + self._extension_combobox.addItems(data["extensions"]) + + self._version_input.setValue(last_version) + + vw_idx = self._inputs_layout.indexOf(self._version_widget) + self._version_label.setVisible(template_has_version) + self._version_widget.setVisible(template_has_version) + if template_has_version: + if vw_idx == -1: + self._inputs_layout.addWidget(self._version_label, 0, 0) + self._inputs_layout.addWidget(self._version_widget, 0, 1) + elif vw_idx != -1: + self._inputs_layout.takeAt(vw_idx) + self._inputs_layout.takeAt( + self._inputs_layout.indexOf(self._version_label) + ) + + cw_idx = self._inputs_layout.indexOf(self._subversion_input) + self._subversion_label.setVisible(template_has_comment) + self._subversion_input.setVisible(template_has_comment) + if template_has_comment: + if cw_idx == -1: + self._inputs_layout.addWidget(self._subversion_label, 1, 0) + self._inputs_layout.addWidget(self._subversion_input, 1, 1) + elif cw_idx != -1: + self._inputs_layout.takeAt(cw_idx) + self._inputs_layout.takeAt( + self._inputs_layout.indexOf(self._subversion_label) + ) + + if template_has_comment: + self._subversion_input.set_text(comment or "") + self._subversion_input.set_values(comment_hints) + self._update_filename() + + def _on_version_spinbox_change(self, value): + if value == self._version_value: + return + self._version_value = value + if not self._last_version_check.isChecked(): + self._update_filename() + + def _on_version_checkbox_change(self): + use_last_version = self._last_version_check.isChecked() + self._version_input.setEnabled(not use_last_version) + if use_last_version: + self._version_input.blockSignals(True) + self._version_input.setValue(self._last_version) + self._version_input.blockSignals(False) + self._update_filename() + + def _on_comment_change(self, text): + if self._comment_value == text: + return + self._comment_value = text + self._update_filename() + + def _on_extension_change(self): + ext = self._extension_combobox.currentText() + if ext == self._ext_value: + return + self._ext_value = ext + self._update_filename() + + def _on_ok_pressed(self): + self._result = { + "filename": self._filename, + "workdir": self._workdir, + "folder_id": self._folder_id, + "task_id": self._task_id, + "template_key": self._template_key, + } + self.close() + + def _on_cancel_pressed(self): + self.close() + + def _update_filename(self): + result = self._controller.fill_workarea_filepath( + self._folder_id, + self._task_id, + self._ext_value, + self._last_version_check.isChecked(), + self._version_value, + self._comment_value, + ) + self._filename = result.filename + self._btn_ok.setEnabled(not result.exists) + + if result.exists: + self._preview_widget.setText(( + "Cannot create \"{}\" because file exists!" + "" + ).format(result.filename)) + else: + self._preview_widget.setText( + "{}".format(result.filename) + ) diff --git a/openpype/tools/ayon_workfiles/widgets/side_panel.py b/openpype/tools/ayon_workfiles/widgets/side_panel.py new file mode 100644 index 0000000000..7f06576a00 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/side_panel.py @@ -0,0 +1,163 @@ +import datetime + +from qtpy import QtWidgets, QtCore + + +def file_size_to_string(file_size): + size = 0 + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if file_size < _size: + break + size = file_size / _size + ending = _ending + return "{:.2f} {}".format(size, ending) + + +class SidePanelWidget(QtWidgets.QWidget): + """Details about selected workfile. + + Todos: + At this moment only shows created and modified date of file + or its size. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + parent (QtWidgets.QWidget): The parent widget. + """ + + published_workfile_message = ( + "INFO: Opened published workfiles will be stored in" + " temp directory on your machine. Current temp size: {}." + ) + + def __init__(self, controller, parent): + super(SidePanelWidget, self).__init__(parent) + + details_label = QtWidgets.QLabel("Details", self) + details_input = QtWidgets.QPlainTextEdit(self) + details_input.setReadOnly(True) + + artist_note_widget = QtWidgets.QWidget(self) + note_label = QtWidgets.QLabel("Artist note", artist_note_widget) + note_input = QtWidgets.QPlainTextEdit(artist_note_widget) + btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget) + + artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget) + artist_note_layout.setContentsMargins(0, 0, 0, 0) + artist_note_layout.addWidget(note_label, 0) + artist_note_layout.addWidget(note_input, 1) + artist_note_layout.addWidget( + btn_note_save, 0, alignment=QtCore.Qt.AlignRight + ) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(details_label, 0) + main_layout.addWidget(details_input, 1) + main_layout.addWidget(artist_note_widget, 1) + + note_input.textChanged.connect(self._on_note_change) + btn_note_save.clicked.connect(self._on_save_click) + + controller.register_event_callback( + "workarea.selection.changed", self._on_selection_change + ) + + self._details_input = details_input + self._artist_note_widget = artist_note_widget + self._note_input = note_input + self._btn_note_save = btn_note_save + + self._folder_id = None + self._task_id = None + self._filepath = None + self._orig_note = "" + self._controller = controller + + self._set_context(None, None, None) + + def set_published_mode(self, published_mode): + """Change published mode. + + Args: + published_mode (bool): Published mode enabled. + """ + + self._artist_note_widget.setVisible(not published_mode) + + def _on_selection_change(self, event): + folder_id = event["folder_id"] + task_id = event["task_id"] + filepath = event["path"] + + self._set_context(folder_id, task_id, filepath) + + def _on_note_change(self): + text = self._note_input.toPlainText() + self._btn_note_save.setEnabled(self._orig_note != text) + + def _on_save_click(self): + note = self._note_input.toPlainText() + self._controller.save_workfile_info( + self._folder_id, + self._task_id, + self._filepath, + note + ) + self._orig_note = note + self._btn_note_save.setEnabled(False) + + def _set_context(self, folder_id, task_id, filepath): + workfile_info = None + # Check if folder, task and file are selected + if bool(folder_id) and bool(task_id) and bool(filepath): + workfile_info = self._controller.get_workfile_info( + folder_id, task_id, filepath + ) + enabled = workfile_info is not None + + self._details_input.setEnabled(enabled) + self._note_input.setEnabled(enabled) + self._btn_note_save.setEnabled(enabled) + + self._folder_id = folder_id + self._task_id = task_id + self._filepath = filepath + + # Disable inputs and remove texts if any required arguments are + # missing + if not enabled: + self._orig_note = "" + self._details_input.setPlainText("") + self._note_input.setPlainText("") + return + + note = workfile_info.note + size_value = file_size_to_string(workfile_info.filesize) + + # Append html string + datetime_format = "%b %d %Y %H:%M:%S" + creation_time = datetime.datetime.fromtimestamp( + workfile_info.creation_time) + modification_time = datetime.datetime.fromtimestamp( + workfile_info.modification_time) + lines = ( + "Size:", + size_value, + "Created:", + creation_time.strftime(datetime_format), + "Modified:", + modification_time.strftime(datetime_format) + ) + self._orig_note = note + self._note_input.setPlainText(note) + + # Set as empty string + self._details_input.setPlainText("") + self._details_input.appendHtml("
".join(lines)) diff --git a/openpype/tools/ayon_workfiles/widgets/tasks_widget.py b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py new file mode 100644 index 0000000000..04f5b286b1 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/tasks_widget.py @@ -0,0 +1,420 @@ +import uuid +import qtawesome +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import get_disabled_entity_icon_color +from openpype.tools.utils import DeselectableTreeView + +from .constants import ( + ITEM_NAME_ROLE, + ITEM_ID_ROLE, + PARENT_ID_ROLE, +) + +SENDER_NAME = "qt_tasks_model" + + +class RefreshThread(QtCore.QThread): + """Thread for refreshing tasks. + + Call controller to get tasks and emit signal when finished. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + folder_id (str): Folder id. + """ + + refresh_finished = QtCore.Signal(str) + + def __init__(self, controller, folder_id): + super(RefreshThread, self).__init__() + self._id = uuid.uuid4().hex + self._controller = controller + self._folder_id = folder_id + self._result = None + + @property + def id(self): + return self._id + + def run(self): + self._result = self._controller.get_task_items( + self._folder_id, SENDER_NAME) + self.refresh_finished.emit(self.id) + + def get_result(self): + return self._result + + +class TasksModel(QtGui.QStandardItemModel): + """Tasks model which cares about refresh of tasks by folder id. + + Args: + controller (AbstractWorkfilesFrontend): The control object. + """ + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(TasksModel, self).__init__() + + self._controller = controller + + self._items_by_name = {} + self._has_content = False + self._is_refreshing = False + + self._invalid_selection_item_used = False + self._invalid_selection_item = None + self._empty_tasks_item_used = False + self._empty_tasks_item = None + + self._last_folder_id = None + + self._refresh_threads = {} + self._current_refresh_thread = None + + # Initial state + self._add_invalid_selection_item() + + def clear(self): + self._items_by_name = {} + self._has_content = False + self._remove_invalid_items() + super(TasksModel, self).clear() + + def refresh(self, folder_id): + """Refresh tasks for folder. + + Args: + folder_id (Union[str, None]): Folder id. + """ + + self._refresh(folder_id) + + def get_index_by_name(self, task_name): + """Find item by name and return its index. + + Returns: + QtCore.QModelIndex: Index of item. Is invalid if task is not + found by name. + """ + + item = self._items_by_name.get(task_name) + if item is None: + return QtCore.QModelIndex() + return self.indexFromItem(item) + + def get_last_folder_id(self): + """Get last refreshed folder id. + + Returns: + Union[str, None]: Folder id. + """ + + return self._last_folder_id + + def _get_invalid_selection_item(self): + if self._invalid_selection_item is None: + item = QtGui.QStandardItem("Select a folder") + item.setFlags(QtCore.Qt.NoItemFlags) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + self._invalid_selection_item = item + return self._invalid_selection_item + + def _get_empty_task_item(self): + if self._empty_tasks_item is None: + item = QtGui.QStandardItem("No task") + icon = qtawesome.icon( + "fa.exclamation-circle", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_tasks_item = item + return self._empty_tasks_item + + def _add_invalid_item(self, item): + self.clear() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _remove_invalid_item(self, item): + root_item = self.invisibleRootItem() + root_item.takeRow(item.row()) + + def _remove_invalid_items(self): + self._remove_invalid_selection_item() + self._remove_empty_task_item() + + def _add_invalid_selection_item(self): + if not self._invalid_selection_item_used: + self._add_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = True + + def _remove_invalid_selection_item(self): + if self._invalid_selection_item: + self._remove_invalid_item(self._get_invalid_selection_item()) + self._invalid_selection_item_used = False + + def _add_empty_task_item(self): + if not self._empty_tasks_item_used: + self._add_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = True + + def _remove_empty_task_item(self): + if self._empty_tasks_item_used: + self._remove_invalid_item(self._get_empty_task_item()) + self._empty_tasks_item_used = False + + def _refresh(self, folder_id): + self._is_refreshing = True + self._last_folder_id = folder_id + if not folder_id: + self._add_invalid_selection_item() + self._current_refresh_thread = None + self._is_refreshing = False + self.refreshed.emit() + return + + thread = RefreshThread(self._controller, folder_id) + self._current_refresh_thread = thread.id + self._refresh_threads[thread.id] = thread + thread.refresh_finished.connect(self._on_refresh_thread) + thread.start() + + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + thread = self._refresh_threads.pop(thread_id) + if thread_id != self._current_refresh_thread: + return + + task_items = thread.get_result() + # Task items are refreshed + if task_items is None: + return + + # No tasks are available on folder + if not task_items: + self._add_empty_task_item() + return + self._remove_invalid_items() + + new_items = [] + new_names = set() + for task_item in task_items: + name = task_item.name + new_names.add(name) + item = self._items_by_name.get(name) + if item is None: + item = QtGui.QStandardItem() + item.setEditable(False) + new_items.append(item) + self._items_by_name[name] = item + + # TODO cache locally + icon = qtawesome.icon( + task_item.icon_name, + color=task_item.icon_color, + ) + item.setData(task_item.label, QtCore.Qt.DisplayRole) + item.setData(name, ITEM_NAME_ROLE) + item.setData(task_item.id, ITEM_ID_ROLE) + item.setData(task_item.parent_id, PARENT_ID_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + + root_item = self.invisibleRootItem() + + for name in set(self._items_by_name) - new_names: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + if new_items: + root_item.appendRows(new_items) + + self._has_content = root_item.rowCount() > 0 + self._is_refreshing = False + self.refreshed.emit() + + @property + def is_refreshing(self): + """Model is refreshing. + + Returns: + bool: Model is refreshing + """ + + return self._is_refreshing + + @property + def has_content(self): + """Model has content. + + Returns: + bools: Have at least one task. + """ + + return self._has_content + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Tasks" + + return super(TasksModel, self).headerData( + section, orientation, role + ) + + +class TasksWidget(QtWidgets.QWidget): + """Tasks widget. + + Widget that handles tasks view, model and selection. + + Args: + controller (AbstractWorkfilesFrontend): Workfiles controller. + """ + + def __init__(self, controller, parent): + super(TasksWidget, self).__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setIndentation(0) + + tasks_model = TasksModel(controller) + tasks_proxy_model = QtCore.QSortFilterProxyModel() + tasks_proxy_model.setSourceModel(tasks_model) + + tasks_view.setModel(tasks_proxy_model) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(tasks_view, 1) + + controller.register_event_callback( + "tasks.refresh.finished", + self._on_tasks_refresh_finished + ) + controller.register_event_callback( + "selection.folder.changed", + self._folder_selection_changed + ) + controller.register_event_callback( + "expected_selection_changed", + self._on_expected_selection_change + ) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_selection_change) + + tasks_model.refreshed.connect(self._on_tasks_model_refresh) + + self._controller = controller + self._tasks_view = tasks_view + self._tasks_model = tasks_model + self._tasks_proxy_model = tasks_proxy_model + + self._selected_folder_id = None + + self._expected_selection_data = None + + def _clear(self): + self._tasks_model.clear() + + def _on_tasks_refresh_finished(self, event): + """Tasks were refreshed in controller. + + Ignore if refresh was triggered by tasks model, or refreshed folder is + not the same as currently selected folder. + + Args: + event (Event): Event object. + """ + + # Refresh only if current folder id is the same + if ( + event["sender"] == SENDER_NAME + or event["folder_id"] != self._selected_folder_id + ): + return + self._tasks_model.refresh(self._selected_folder_id) + + def _folder_selection_changed(self, event): + self._selected_folder_id = event["folder_id"] + self._tasks_model.refresh(self._selected_folder_id) + + def _on_tasks_model_refresh(self): + if not self._set_expected_selection(): + self._on_selection_change() + self._tasks_proxy_model.sort(0) + + def _set_expected_selection(self): + if self._expected_selection_data is None: + return False + folder_id = self._expected_selection_data["folder_id"] + task_name = self._expected_selection_data["task_name"] + self._expected_selection_data = None + model_folder_id = self._tasks_model.get_last_folder_id() + if folder_id != model_folder_id: + return False + if task_name is not None: + index = self._tasks_model.get_index_by_name(task_name) + if index.isValid(): + proxy_index = self._tasks_proxy_model.mapFromSource(index) + self._tasks_view.setCurrentIndex(proxy_index) + self._controller.expected_task_selected(folder_id, task_name) + return True + + def _on_expected_selection_change(self, event): + if event["task_selected"] or not event["folder_selected"]: + return + + model_folder_id = self._tasks_model.get_last_folder_id() + folder_id = event["folder_id"] + self._expected_selection_data = { + "task_name": event["task_name"], + "folder_id": folder_id, + } + + if folder_id != model_folder_id or self._tasks_model.is_refreshing: + return + self._set_expected_selection() + + def _get_selected_item_ids(self): + selection_model = self._tasks_view.selectionModel() + for index in selection_model.selectedIndexes(): + task_id = index.data(ITEM_ID_ROLE) + task_name = index.data(ITEM_NAME_ROLE) + parent_id = index.data(PARENT_ID_ROLE) + if task_name is not None: + return parent_id, task_id, task_name + return self._selected_folder_id, None, None + + def _on_selection_change(self): + # Don't trigger task change during refresh + # - a task was deselected if that happens + # - can cause crash triggered during tasks refreshing + if self._tasks_model.is_refreshing: + return + parent_id, task_id, task_name = self._get_selected_item_ids() + self._controller.set_selected_task(parent_id, task_id, task_name) diff --git a/openpype/tools/ayon_workfiles/widgets/utils.py b/openpype/tools/ayon_workfiles/widgets/utils.py new file mode 100644 index 0000000000..6a61239f8d --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/utils.py @@ -0,0 +1,94 @@ +from qtpy import QtWidgets, QtCore +from openpype.tools.flickcharm import FlickCharm + + +class TreeView(QtWidgets.QTreeView): + """Ultimate TreeView with flick charm and double click signals. + + Tree view have deselectable mode, which allows to deselect items by + clicking on item area without any items. + + Todos: + Add to tools utils. + """ + + double_clicked_left = QtCore.Signal() + double_clicked_right = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(TreeView, self).__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super(TreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.double_clicked_left.emit() + + elif event.button() == QtCore.Qt.RightButton: + self.double_clicked_right.emit() + + return super(TreeView, self).mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) + + +class BaseOverlayFrame(QtWidgets.QFrame): + """Base frame for overlay widgets. + + Has implemented automated resize and event filtering. + """ + + def __init__(self, parent): + super(BaseOverlayFrame, self).__init__(parent) + self.setObjectName("OverlayFrame") + + self._parent = parent + + def setVisible(self, visible): + super(BaseOverlayFrame, self).setVisible(visible) + if visible: + self._parent.installEventFilter(self) + self.resize(self._parent.size()) + else: + self._parent.removeEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Resize: + self.resize(obj.size()) + + return super(BaseOverlayFrame, self).eventFilter(obj, event) diff --git a/openpype/tools/ayon_workfiles/widgets/window.py b/openpype/tools/ayon_workfiles/widgets/window.py new file mode 100644 index 0000000000..ef352c8b18 --- /dev/null +++ b/openpype/tools/ayon_workfiles/widgets/window.py @@ -0,0 +1,400 @@ +from qtpy import QtCore, QtWidgets, QtGui + +from openpype import style, resources +from openpype.tools.utils import ( + PlaceholderLineEdit, + MessageOverlayObject, +) +from openpype.tools.utils.lib import get_qta_icon_by_name_and_color + +from openpype.tools.ayon_workfiles.control import BaseWorkfileController + +from .side_panel import SidePanelWidget +from .folders_widget import FoldersWidget +from .tasks_widget import TasksWidget +from .files_widget import FilesWidget +from .utils import BaseOverlayFrame + + +# TODO move to utils +# from openpype.tools.utils.lib import ( +# get_refresh_icon, get_go_to_current_icon) +def get_refresh_icon(): + return get_qta_icon_by_name_and_color( + "fa.refresh", style.get_default_tools_icon_color() + ) + + +def get_go_to_current_icon(): + return get_qta_icon_by_name_and_color( + "fa.arrow-down", style.get_default_tools_icon_color() + ) + + +class InvalidHostOverlay(BaseOverlayFrame): + def __init__(self, parent): + super(InvalidHostOverlay, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + ( + "Workfiles tool is not supported in this host/DCCs." + "

This may be caused by a bug." + " Please contact your TD for more information." + ), + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setObjectName("OverlayFrameLabel") + + layout = QtWidgets.QVBoxLayout(self) + layout.addStretch(2) + layout.addWidget(label_widget, 0, QtCore.Qt.AlignCenter) + layout.addStretch(3) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + +class WorkfilesToolWindow(QtWidgets.QWidget): + """WorkFiles Window. + + Main windows of workfiles tool. + + Args: + controller (AbstractWorkfilesFrontend): Frontend controller. + parent (Optional[QtWidgets.QWidget]): Parent widget. + """ + + title = "Work Files" + + def __init__(self, controller=None, parent=None): + super(WorkfilesToolWindow, self).__init__(parent=parent) + + if controller is None: + controller = BaseWorkfileController() + + self.setWindowTitle(self.title) + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + flags = self.windowFlags() | QtCore.Qt.Window + self.setWindowFlags(flags) + + self._default_window_flags = flags + + self._folder_widget = None + self._folder_filter_input = None + + self._files_widget = None + + self._first_show = True + self._controller_refreshed = False + self._context_to_set = None + # Host validation should happen only once + self._host_is_valid = None + + self._controller = controller + + # Create pages widget and set it as central widget + pages_widget = QtWidgets.QStackedWidget(self) + + home_page_widget = QtWidgets.QWidget(pages_widget) + home_body_widget = QtWidgets.QWidget(home_page_widget) + + col_1_widget = self._create_col_1_widget(controller, parent) + tasks_widget = TasksWidget(controller, home_body_widget) + col_3_widget = self._create_col_3_widget(controller, home_body_widget) + side_panel = SidePanelWidget(controller, home_body_widget) + + pages_widget.addWidget(home_page_widget) + + # Build home + home_page_layout = QtWidgets.QVBoxLayout(home_page_widget) + home_page_layout.addWidget(home_body_widget) + + # Build home - body + body_layout = QtWidgets.QVBoxLayout(home_body_widget) + split_widget = QtWidgets.QSplitter(home_body_widget) + split_widget.addWidget(col_1_widget) + split_widget.addWidget(tasks_widget) + split_widget.addWidget(col_3_widget) + split_widget.addWidget(side_panel) + split_widget.setSizes([255, 160, 455, 175]) + + body_layout.addWidget(split_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(pages_widget, 1) + + overlay_messages_widget = MessageOverlayObject(self) + overlay_invalid_host = InvalidHostOverlay(self) + overlay_invalid_host.setVisible(False) + + first_show_timer = QtCore.QTimer() + first_show_timer.setSingleShot(True) + first_show_timer.setInterval(50) + + first_show_timer.timeout.connect(self._on_first_show) + + controller.register_event_callback( + "save_as.finished", + self._on_save_as_finished, + ) + controller.register_event_callback( + "copy_representation.finished", + self._on_copy_representation_finished, + ) + controller.register_event_callback( + "workfile_duplicate.finished", + self._on_duplicate_finished + ) + controller.register_event_callback( + "open_workfile.finished", + self._on_open_finished + ) + controller.register_event_callback( + "controller.refresh.started", + self._on_controller_refresh_started, + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh_finished, + ) + + self._overlay_messages_widget = overlay_messages_widget + self._overlay_invalid_host = overlay_invalid_host + self._home_page_widget = home_page_widget + self._pages_widget = pages_widget + self._home_body_widget = home_body_widget + self._split_widget = split_widget + + self._tasks_widget = tasks_widget + self._side_panel = side_panel + + self._first_show_timer = first_show_timer + + self._post_init() + + def _post_init(self): + self._on_published_checkbox_changed() + + # Force focus on the open button by default, required for Houdini. + self._files_widget.setFocus() + + self.resize(1200, 600) + + def _create_col_1_widget(self, controller, parent): + col_widget = QtWidgets.QWidget(parent) + header_widget = QtWidgets.QWidget(col_widget) + + folder_filter_input = PlaceholderLineEdit(header_widget) + folder_filter_input.setPlaceholderText("Filter folders..") + + go_to_current_btn = QtWidgets.QPushButton(header_widget) + go_to_current_btn.setIcon(get_go_to_current_icon()) + go_to_current_btn_sp = go_to_current_btn.sizePolicy() + go_to_current_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + go_to_current_btn.setSizePolicy(go_to_current_btn_sp) + + refresh_btn = QtWidgets.QPushButton(header_widget) + refresh_btn.setIcon(get_refresh_icon()) + refresh_btn_sp = refresh_btn.sizePolicy() + refresh_btn_sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) + refresh_btn.setSizePolicy(refresh_btn_sp) + + folder_widget = FoldersWidget(controller, col_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(folder_filter_input, 1) + header_layout.addWidget(go_to_current_btn, 0) + header_layout.addWidget(refresh_btn, 0) + + col_layout = QtWidgets.QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.addWidget(header_widget, 0) + col_layout.addWidget(folder_widget, 1) + + folder_filter_input.textChanged.connect(self._on_folder_filter_change) + go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) + refresh_btn.clicked.connect(self._on_refresh_clicked) + + self._folder_filter_input = folder_filter_input + self._folder_widget = folder_widget + + return col_widget + + def _create_col_3_widget(self, controller, parent): + col_widget = QtWidgets.QWidget(parent) + + header_widget = QtWidgets.QWidget(col_widget) + + files_filter_input = PlaceholderLineEdit(header_widget) + files_filter_input.setPlaceholderText("Filter files..") + + published_checkbox = QtWidgets.QCheckBox("Published", header_widget) + published_checkbox.setToolTip("Show published workfiles") + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(files_filter_input, 1) + header_layout.addWidget(published_checkbox, 0) + + files_widget = FilesWidget(controller, col_widget) + + col_layout = QtWidgets.QVBoxLayout(col_widget) + col_layout.setContentsMargins(0, 0, 0, 0) + col_layout.addWidget(header_widget, 0) + col_layout.addWidget(files_widget, 1) + + files_filter_input.textChanged.connect( + self._on_file_text_filter_change) + published_checkbox.stateChanged.connect( + self._on_published_checkbox_changed + ) + + self._files_filter_input = files_filter_input + self._published_checkbox = published_checkbox + + self._files_widget = files_widget + + return col_widget + + def set_window_on_top(self, on_top): + """Set window on top of other windows. + + Args: + on_top (bool): Show on top of other windows. + """ + + flags = self._default_window_flags + if on_top: + flags |= QtCore.Qt.WindowStaysOnTopHint + if self.windowFlags() != flags: + self.setWindowFlags(flags) + + def ensure_visible(self, use_context=True, save=True, on_top=False): + """Ensure the window is visible. + + This method expects arguments for compatibility with previous variant + of Workfiles tool. + + Args: + use_context (Optional[bool]): DEPRECATED: This argument is + ignored. + save (Optional[bool]): Allow to save workfiles. + on_top (Optional[bool]): Show on top of other windows. + """ + + save = True if save is None else save + on_top = False if on_top is None else on_top + + is_visible = self.isVisible() + self._controller.set_save_enabled(save) + self.set_window_on_top(on_top) + + self.show() + self.raise_() + self.activateWindow() + if is_visible: + self.refresh() + + def refresh(self): + """Trigger refresh of workfiles tool controller.""" + + self._controller.refresh() + + def showEvent(self, event): + super(WorkfilesToolWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self._first_show_timer.start() + self.setStyleSheet(style.load_stylesheet()) + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidentally perform Maya commands + whilst trying to name an instance. + """ + + pass + + def _on_first_show(self): + if not self._controller_refreshed: + self.refresh() + + def _on_file_text_filter_change(self, text): + self._files_widget.set_text_filter(text) + + def _on_published_checkbox_changed(self): + """Publish mode changed. + + Tell children widgets about it so they can handle the mode. + """ + + published_mode = self._published_checkbox.isChecked() + self._files_widget.set_published_mode(published_mode) + self._side_panel.set_published_mode(published_mode) + + def _on_folder_filter_change(self, text): + self._folder_widget.set_name_filer(text) + + def _on_go_to_current_clicked(self): + self._controller.go_to_current_context() + + def _on_refresh_clicked(self): + self.refresh() + + def _on_controller_refresh_started(self): + self._controller_refreshed = True + + def _on_controller_refresh_finished(self): + if self._host_is_valid is None: + self._host_is_valid = self._controller.is_host_valid() + self._overlay_invalid_host.setVisible(not self._host_is_valid) + + if not self._host_is_valid: + return + + def _on_save_as_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to save workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Workfile saved" + ) + + def _on_copy_representation_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to copy published workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Publish workfile saved" + ) + + def _on_duplicate_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to duplicate workfile", + "error", + ) + else: + self._overlay_messages_widget.add_message( + "Workfile duplicated" + ) + + def _on_open_finished(self, event): + if event["failed"]: + self._overlay_messages_widget.add_message( + "Failed to open workfile", + "error", + ) + else: + self.close() diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index bc4b7867c2..2ebc973a47 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -6,6 +6,8 @@ use singleton approach with global functions (using helper anyway). import os import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.host import IWorkfileHost, ILoadHost from openpype.lib import Logger from openpype.pipeline import ( @@ -46,17 +48,29 @@ class HostToolsHelper: self._log = Logger.get_logger(self.__class__.__name__) return self._log + def _init_ayon_workfiles_tool(self, parent): + from openpype.tools.ayon_workfiles.widgets import WorkfilesToolWindow + + workfiles_window = WorkfilesToolWindow(parent=parent) + self._workfiles_tool = workfiles_window + + def _init_openpype_workfiles_tool(self, parent): + from openpype.tools.workfiles.app import Window + + # Host validation + host = registered_host() + IWorkfileHost.validate_workfile_methods(host) + + workfiles_window = Window(parent=parent) + self._workfiles_tool = workfiles_window + def get_workfiles_tool(self, parent): """Create, cache and return workfiles tool window.""" if self._workfiles_tool is None: - from openpype.tools.workfiles.app import Window - - # Host validation - host = registered_host() - IWorkfileHost.validate_workfile_methods(host) - - workfiles_window = Window(parent=parent) - self._workfiles_tool = workfiles_window + if AYON_SERVER_ENABLED: + self._init_ayon_workfiles_tool(parent) + else: + self._init_openpype_workfiles_tool(parent) return self._workfiles_tool From 5ba400d89173195cadf885df3c5bc0129c84fd61 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 9 Sep 2023 03:24:54 +0000 Subject: [PATCH 269/291] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index d5d46bab0c..b6c56296bc 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.5" +__version__ = "3.16.6-nightly.1" From 5475908051c3166daa57155c18b36f672170e12b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 Sep 2023 03:25:32 +0000 Subject: [PATCH 270/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a35dbf1a17..7a39103859 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.6-nightly.1 - 3.16.5 - 3.16.5-nightly.5 - 3.16.5-nightly.4 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.4 - 3.14.9-nightly.3 - 3.14.9-nightly.2 - - 3.14.9-nightly.1 validations: required: true - type: dropdown From 81446b1bbb972bcb8ee8fa9c9df06868d7c008aa Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 11 Sep 2023 11:55:49 +0100 Subject: [PATCH 271/291] Remove hardcoded subset name for reviews --- openpype/hosts/blender/plugins/publish/collect_review.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 6459927015..3bf2e39e24 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -39,15 +39,11 @@ class CollectReview(pyblish.api.InstancePlugin): ] if not instance.data.get("remove"): - - task = instance.context.data["task"] - # Store focal length in `burninDataMembers` burninData = instance.data.setdefault("burninDataMembers", {}) burninData["focalLength"] = focal_length instance.data.update({ - "subset": f"{task}Review", "review_camera": camera, "frameStart": instance.context.data["frameStart"], "frameEnd": instance.context.data["frameEnd"], From ae02fe220aefb292f705f715fea4def1b2192782 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 11 Sep 2023 16:28:11 +0200 Subject: [PATCH 272/291] AfterEffects: fix imports of image sequences (#5581) * Fix loading image sequence in AE * Fix logic Files might be list or str * Update openpype/hosts/aftereffects/plugins/load/load_file.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/aftereffects/plugins/load/load_file.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/load/load_file.py b/openpype/hosts/aftereffects/plugins/load/load_file.py index def7c927ab..8d52aac546 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_file.py +++ b/openpype/hosts/aftereffects/plugins/load/load_file.py @@ -31,13 +31,8 @@ class FileLoader(api.AfterEffectsLoader): path = self.filepath_from_context(context) - repr_cont = context["representation"]["context"] - if "#" not in path: - frame = repr_cont.get("frame") - if frame: - padding = len(frame) - path = path.replace(frame, "#" * padding) - import_options['sequence'] = True + if len(context["representation"]["files"]) > 1: + import_options['sequence'] = True if not path: repr_id = context["representation"]["_id"] From 1fdbe05905995641f02b4c4c3b2d7e27be6ecf3a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 11 Sep 2023 17:21:38 +0200 Subject: [PATCH 273/291] Photoshop: fixed blank Flatten image (#5600) * OP-6763 - refresh all visible for Flatten image Previously newly added layers were missing. * OP-6763 - added explicit image collector Creator was adding 'layer' metadata from workfile only during collect_instances, it was missing for newly added layers. This should be cleaner approach * OP-6763 - removed unnecessary method overwrite Creator is not adding layer to instance, separate collector created. * OP-6763 - cleanup of names Was failing when template for subset name for image family contained {layer} * OP-6763 - cleanup, removed adding layer metadata Separate collector created, cleaner. Fixed propagation of mark_for_review * OP-6763 - using members instead of layer data Members should be more reliable. * OP-6763 - updated image from Settings Explicit subset name template was removed some time ago as confusing. * OP-6763 - added explicit local plugin Automated plugin has different logic, local would need to handle if auto_image is disabled by artist * OP-6763 - Hound * OP-6345 - fix - review for image family Image family instance contained flattened content. Now it reuses previously extracted file without need to re-extract. --- .../plugins/create/create_flatten_image.py | 40 +++++++++--- .../photoshop/plugins/create/create_image.py | 17 ++--- .../publish/collect_auto_image_refresh.py | 24 +++++++ .../plugins/publish/collect_image.py | 20 ++++++ .../plugins/publish/extract_image.py | 8 ++- .../plugins/publish/extract_review.py | 59 +++++++++++++++--- website/docs/admin_hosts_photoshop.md | 7 +-- .../assets/admin_hosts_photoshop_settings.png | Bin 14364 -> 16718 bytes 8 files changed, 144 insertions(+), 31 deletions(-) create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py create mode 100644 openpype/hosts/photoshop/plugins/publish/collect_image.py diff --git a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py index 9d4189a1a3..e4229788bd 100644 --- a/openpype/hosts/photoshop/plugins/create/create_flatten_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_flatten_image.py @@ -4,6 +4,7 @@ from openpype.lib import BoolDef import openpype.hosts.photoshop.api as api from openpype.hosts.photoshop.lib import PSAutoCreator from openpype.pipeline.create import get_subset_name +from openpype.lib import prepare_template_data from openpype.client import get_asset_by_name @@ -37,19 +38,14 @@ class AutoImageCreator(PSAutoCreator): asset_doc = get_asset_by_name(project_name, asset_name) if existing_instance is None: - subset_name = get_subset_name( - self.family, self.default_variant, task_name, asset_doc, + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, project_name, host_name ) - publishable_ids = [layer.id for layer in api.stub().get_layers() - if layer.visible] data = { "asset": asset_name, "task": task_name, - # ids are "virtual" layers, won't get grouped as 'members' do - # same difference in color coded layers in WP - "ids": publishable_ids } if not self.active_on_create: @@ -69,8 +65,8 @@ class AutoImageCreator(PSAutoCreator): existing_instance["asset"] != asset_name or existing_instance["task"] != task_name ): - subset_name = get_subset_name( - self.family, self.default_variant, task_name, asset_doc, + subset_name = self.get_subset_name( + self.default_variant, task_name, asset_doc, project_name, host_name ) @@ -118,3 +114,29 @@ class AutoImageCreator(PSAutoCreator): Artist might disable this instance from publishing or from creating review for it though. """ + + def get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + dynamic_data = prepare_template_data({"layer": "{layer}"}) + subset_name = get_subset_name( + self.family, variant, task_name, asset_doc, + project_name, host_name, dynamic_data=dynamic_data + ) + return self._clean_subset_name(subset_name) + + def _clean_subset_name(self, subset_name): + """Clean all variants leftover {layer} from subset name.""" + dynamic_data = prepare_template_data({"layer": "{layer}"}) + for value in dynamic_data.values(): + if value in subset_name: + return (subset_name.replace(value, "") + .replace("__", "_") + .replace("..", ".")) + return subset_name diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 8d3ac9f459..af20d456e0 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -94,12 +94,17 @@ class ImageCreator(Creator): name = self._clean_highlights(stub, directory) layer_names_in_hierarchy.append(name) - data.update({"subset": subset_name}) - data.update({"members": [str(group.id)]}) - data.update({"layer_name": layer_name}) - data.update({"long_name": "_".join(layer_names_in_hierarchy)}) + data_update = { + "subset": subset_name, + "members": [str(group.id)], + "layer_name": layer_name, + "long_name": "_".join(layer_names_in_hierarchy) + } + data.update(data_update) - creator_attributes = {"mark_for_review": self.mark_for_review} + mark_for_review = (pre_create_data.get("mark_for_review") or + self.mark_for_review) + creator_attributes = {"mark_for_review": mark_for_review} data.update({"creator_attributes": creator_attributes}) if not self.active_on_create: @@ -124,8 +129,6 @@ class ImageCreator(Creator): if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) - layer = api.stub().get_layer(instance_data["members"][0]) - instance_data["layer"] = layer instance = CreatedInstance.from_existing( instance_data, self ) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py new file mode 100644 index 0000000000..741fb0e9cd --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_auto_image_refresh.py @@ -0,0 +1,24 @@ +import pyblish.api + +from openpype.hosts.photoshop import api as photoshop + + +class CollectAutoImageRefresh(pyblish.api.ContextPlugin): + """Refreshes auto_image instance with currently visible layers.. + """ + + label = "Collect Auto Image Refresh" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.2 + + def process(self, context): + for instance in context: + creator_identifier = instance.data.get("creator_identifier") + if creator_identifier and creator_identifier == "auto_image": + self.log.debug("Auto image instance found, won't create new") + # refresh existing auto image instance with current visible + publishable_ids = [layer.id for layer in photoshop.stub().get_layers() # noqa + if layer.visible] + instance.data["ids"] = publishable_ids + return diff --git a/openpype/hosts/photoshop/plugins/publish/collect_image.py b/openpype/hosts/photoshop/plugins/publish/collect_image.py new file mode 100644 index 0000000000..64727cef33 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_image.py @@ -0,0 +1,20 @@ +import pyblish.api + +from openpype.hosts.photoshop import api + + +class CollectImage(pyblish.api.InstancePlugin): + """Collect layer metadata into a instance. + + Used later in validation + """ + order = pyblish.api.CollectorOrder + 0.200 + label = 'Collect Image' + + hosts = ["photoshop"] + families = ["image"] + + def process(self, instance): + if instance.data.get("members"): + layer = api.stub().get_layer(instance.data["members"][0]) + instance.data["layer"] = layer diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index cdb28c742d..680f580cc0 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -45,9 +45,11 @@ class ExtractImage(pyblish.api.ContextPlugin): # Perform extraction files = {} ids = set() - layer = instance.data.get("layer") - if layer: - ids.add(layer.id) + # real layers and groups + members = instance.data("members") + if members: + ids.update(set([int(member) for member in members])) + # virtual groups collected by color coding or auto_image add_ids = instance.data.pop("ids", None) if add_ids: ids.update(set(add_ids)) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 4aa7a05bd1..afddbdba31 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -1,4 +1,5 @@ import os +import shutil from PIL import Image from openpype.lib import ( @@ -55,6 +56,7 @@ class ExtractReview(publish.Extractor): } if instance.data["family"] != "review": + self.log.debug("Existing extracted file from image family used.") # enable creation of review, without this jpg review would clash # with jpg of the image family output_name = repre_name @@ -62,8 +64,15 @@ class ExtractReview(publish.Extractor): repre_skeleton.update({"name": repre_name, "outputName": output_name}) - if self.make_image_sequence and len(layers) > 1: - self.log.info("Extract layers to image sequence.") + img_file = self.output_seq_filename % 0 + self._prepare_file_for_image_family(img_file, instance, + staging_dir) + repre_skeleton.update({ + "files": img_file, + }) + processed_img_names = [img_file] + elif self.make_image_sequence and len(layers) > 1: + self.log.debug("Extract layers to image sequence.") img_list = self._save_sequence_images(staging_dir, layers) repre_skeleton.update({ @@ -72,17 +81,17 @@ class ExtractReview(publish.Extractor): "fps": fps, "files": img_list, }) - instance.data["representations"].append(repre_skeleton) processed_img_names = img_list else: - self.log.info("Extract layers to flatten image.") - img_list = self._save_flatten_image(staging_dir, layers) + self.log.debug("Extract layers to flatten image.") + img_file = self._save_flatten_image(staging_dir, layers) repre_skeleton.update({ - "files": img_list, + "files": img_file, }) - instance.data["representations"].append(repre_skeleton) - processed_img_names = [img_list] + processed_img_names = [img_file] + + instance.data["representations"].append(repre_skeleton) ffmpeg_args = get_ffmpeg_tool_args("ffmpeg") @@ -111,6 +120,35 @@ class ExtractReview(publish.Extractor): self.log.info(f"Extracted {instance} to {staging_dir}") + def _prepare_file_for_image_family(self, img_file, instance, staging_dir): + """Converts existing file for image family to .jpg + + Image instance could have its own separate review (instance per layer + for example). This uses extracted file instead of extracting again. + Args: + img_file (str): name of output file (with 0000 value for ffmpeg + later) + instance: + staging_dir (str): temporary folder where extracted file is located + """ + repre_file = instance.data["representations"][0] + source_file_path = os.path.join(repre_file["stagingDir"], + repre_file["files"]) + if not os.path.exists(source_file_path): + raise RuntimeError(f"{source_file_path} doesn't exist for " + "review to create from") + _, ext = os.path.splitext(repre_file["files"]) + if ext != ".jpg": + im = Image.open(source_file_path) + # without this it produces messy low quality jpg + rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") + rgb_im.alpha_composite(im) + rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + else: + # handles already .jpg + shutil.copy(source_file_path, + os.path.join(staging_dir, img_file)) + def _generate_mov(self, ffmpeg_path, instance, fps, no_of_frames, source_files_pattern, staging_dir): """Generates .mov to upload to Ftrack. @@ -218,6 +256,11 @@ class ExtractReview(publish.Extractor): (list) of PSItem """ layers = [] + # creating review for existing 'image' instance + if instance.data["family"] == "image" and instance.data.get("layer"): + layers.append(instance.data["layer"]) + return layers + for image_instance in instance.context: if image_instance.data["family"] != "image": continue diff --git a/website/docs/admin_hosts_photoshop.md b/website/docs/admin_hosts_photoshop.md index de684f01d2..d79789760e 100644 --- a/website/docs/admin_hosts_photoshop.md +++ b/website/docs/admin_hosts_photoshop.md @@ -33,7 +33,6 @@ Provides list of [variants](artist_concepts.md#variant) that will be shown to an Provides simplified publishing process. It will create single `image` instance for artist automatically. This instance will produce flatten image from all visible layers in a workfile. -- Subset template for flatten image - provide template for subset name for this instance (example `imageBeauty`) - Review - should be separate review created for this instance ### Create Review @@ -111,11 +110,11 @@ Set Byte limit for review file. Applicable if gigantic `image` instances are pro #### Extract jpg Options -Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. +Handles tags for produced `.jpg` representation. `Create review` and `Add review to Ftrack` are defaults. #### Extract mov Options -Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. +Handles tags for produced `.mov` representation. `Create review` and `Add review to Ftrack` are defaults. ### Workfile Builder @@ -124,4 +123,4 @@ Allows to open prepared workfile for an artist when no workfile exists. Useful t Could be configured per `Task type`, eg. `composition` task type could use different `.psd` template file than `art` task. Workfile template must be accessible for all artists. -(Currently not handled by [SiteSync](module_site_sync.md)) \ No newline at end of file +(Currently not handled by [SiteSync](module_site_sync.md)) diff --git a/website/docs/assets/admin_hosts_photoshop_settings.png b/website/docs/assets/admin_hosts_photoshop_settings.png index aaa6ecbed7b353733f8424abe3c2df0cb903935a..9478fbedf785ca8ae66132a14cb400c38e2dccc9 100644 GIT binary patch literal 16718 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 literal 14364 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 Date: Mon, 11 Sep 2023 17:23:08 +0200 Subject: [PATCH 274/291] AfterEffects: added validator for missing files in FootageItems (#5590) * OP-6345 - updated logic to return path and comp for FootageItem Used later to check existance of file in published comps * OP-6345 - added validator if footage files exist Comp could contain multiple FootageItems, eg imported file(s). If file is missing render triggered by jsx fails silently. * OP-6345 - updated extension * OP-6345 - small updates after testing * OP-6345 - fix - handle Solid Footage items JSX failed silently on Solid item as it doesn't have any `.file` * OP-6345 - enhance documentation * OP-6345 - remove optionality This plugin shouldn't be optional as when needed and skipped it result in really weird behavior. * OP-6345 - updated documentation Added missing plugins. * OP-6345 - missed functionality for optionality * OP-6345 - removed unneeded import --- openpype/hosts/aftereffects/api/extension.zxp | Bin 102930 -> 103005 bytes .../api/extension/CSXS/manifest.xml | 24 ++++----- .../api/extension/jsx/hostscript.jsx | 16 +++++- openpype/hosts/aftereffects/api/ws_stub.py | 8 ++- .../publish/help/validate_footage_items.xml | 14 +++++ .../plugins/publish/validate_footage_items.py | 49 ++++++++++++++++++ website/docs/admin_hosts_aftereffects.md | 8 +++ 7 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml create mode 100644 openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py diff --git a/openpype/hosts/aftereffects/api/extension.zxp b/openpype/hosts/aftereffects/api/extension.zxp index 358e9740d3c2479b88dff08af47eba3d74be63ce..933dc7dc6cab34b6a1630b25d11a2167fa0aef7a 100644 GIT binary patch delta 10917 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 D9WP{T delta 10814 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 BS&;w$ diff --git a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml index 0057758320..7329a9e723 100644 --- a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml +++ b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml @@ -1,5 +1,5 @@ - @@ -10,22 +10,22 @@ - + - + - - + + - + - - + + - + @@ -63,7 +63,7 @@ 550 400 --> - + ./icons/iconNormal.png @@ -71,9 +71,9 @@ ./icons/iconDisabled.png ./icons/iconDarkNormal.png ./icons/iconDarkRollover.png - + - \ No newline at end of file + diff --git a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx index bc443930df..c00844e637 100644 --- a/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx +++ b/openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx @@ -215,6 +215,8 @@ function _getItem(item, comps, folders, footages){ * Refactor */ var item_type = ''; + var path = ''; + var containing_comps = []; if (item instanceof FolderItem){ item_type = 'folder'; if (!folders){ @@ -222,10 +224,18 @@ function _getItem(item, comps, folders, footages){ } } if (item instanceof FootageItem){ - item_type = 'footage'; if (!footages){ return "{}"; } + item_type = 'footage'; + if (item.file){ + path = item.file.fsName; + } + if (item.usedIn){ + for (j = 0; j < item.usedIn.length; ++j){ + containing_comps.push(item.usedIn[j].id); + } + } } if (item instanceof CompItem){ item_type = 'comp'; @@ -236,7 +246,9 @@ function _getItem(item, comps, folders, footages){ var item = {"name": item.name, "id": item.id, - "type": item_type}; + "type": item_type, + "path": path, + "containing_comps": containing_comps}; return JSON.stringify(item); } diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index f5b96fa63a..18f530e272 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -37,6 +37,9 @@ class AEItem(object): height = attr.ib(default=None) is_placeholder = attr.ib(default=False) uuid = attr.ib(default=False) + path = attr.ib(default=False) # path to FootageItem to validate + # list of composition Footage is in + containing_comps = attr.ib(factory=list) class AfterEffectsServerStub(): @@ -704,7 +707,10 @@ class AfterEffectsServerStub(): d.get("instance_id"), d.get("width"), d.get("height"), - d.get("is_placeholder")) + d.get("is_placeholder"), + d.get("uuid"), + d.get("path"), + d.get("containing_comps"),) ret.append(item) return ret diff --git a/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml new file mode 100644 index 0000000000..01c8966015 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/help/validate_footage_items.xml @@ -0,0 +1,14 @@ + + + +Footage item missing + +## Footage item missing + + FootageItem `{name}` contains missing `{path}`. Render will not produce any frames and AE will stop react to any integration +### How to repair? + +Remove `{name}` or provide missing file. + + + diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py new file mode 100644 index 0000000000..40a08a2c3f --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/validate_footage_items.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Validate presence of footage items in composition +Requires: +""" +import os + +import pyblish.api + +from openpype.pipeline import ( + PublishXmlValidationError +) +from openpype.hosts.aftereffects.api import get_stub + + +class ValidateFootageItems(pyblish.api.InstancePlugin): + """ + Validates if FootageItems contained in composition exist. + + AE fails silently and doesn't render anything if footage item file is + missing. This will result in nonresponsiveness of AE UI as it expects + reaction from user, but it will not provide dialog. + This validator tries to check existence of the files. + It will not protect from missing frame in multiframes though + (as AE api doesn't provide this information and it cannot be told how many + frames should be there easily). Missing frame is replaced by placeholder. + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Footage Items" + families = ["render.farm", "render.local", "render"] + hosts = ["aftereffects"] + optional = True + + def process(self, instance): + """Plugin entry point.""" + + comp_id = instance.data["comp_id"] + for footage_item in get_stub().get_items(comps=False, folders=False, + footages=True): + self.log.info(footage_item) + if comp_id not in footage_item.containing_comps: + continue + + path = footage_item.path + if path and not os.path.exists(path): + msg = f"File {path} not found." + formatting = {"name": footage_item.name, "path": path} + raise PublishXmlValidationError(self, msg, + formatting_data=formatting) diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md index 974428fe06..72fdb32faf 100644 --- a/website/docs/admin_hosts_aftereffects.md +++ b/website/docs/admin_hosts_aftereffects.md @@ -18,6 +18,10 @@ Location: Settings > Project > AfterEffects ## Publish plugins +### Collect Review + +Enable/disable creation of auto instance of review. + ### Validate Scene Settings #### Skip Resolution Check for Tasks @@ -28,6 +32,10 @@ Set regex pattern(s) to look for in a Task name to skip resolution check against Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. +### ValidateContainers + +By default this validator will look loaded items with lower version than latest. This validator is context wide so it must be disabled in Context button. + ### AfterEffects Submit to Deadline * `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. From 651177dedbfe75c03022c2b1f7930fdda5b93315 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:17:22 +0200 Subject: [PATCH 275/291] TVPaint: Fix tool callbacks (#5608) * don't wait for tools to show * use 'deque' instead of 'Queue' --- .../hosts/tvpaint/api/communication_server.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index 6f76c25e0c..d67ef8f798 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -11,7 +11,7 @@ import filecmp import tempfile import threading import shutil -from queue import Queue + from contextlib import closing from aiohttp import web @@ -319,19 +319,19 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def workfiles_tool(self): log.info("Triggering Workfile tool") item = MainThreadItem(self.tools_helper.show_workfiles) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def loader_tool(self): log.info("Triggering Loader tool") item = MainThreadItem(self.tools_helper.show_loader) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def publish_tool(self): log.info("Triggering Publish tool") item = MainThreadItem(self.tools_helper.show_publisher_tool) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def scene_inventory_tool(self): @@ -350,13 +350,13 @@ class QtTVPaintRpc(BaseTVPaintRpc): async def library_loader_tool(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_library_loader) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def experimental_tools(self): log.info("Triggering Library loader tool") item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog) - self._execute_in_main_thread(item) + self._execute_in_main_thread(item, wait=False) return async def _async_execute_in_main_thread(self, item, **kwargs): @@ -867,7 +867,7 @@ class QtCommunicator(BaseCommunicator): def __init__(self, qt_app): super().__init__() - self.callback_queue = Queue() + self.callback_queue = collections.deque() self.qt_app = qt_app def _create_routes(self): @@ -880,14 +880,14 @@ class QtCommunicator(BaseCommunicator): def execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" - self.callback_queue.put(main_thread_item) + self.callback_queue.append(main_thread_item) if wait: return main_thread_item.wait() return async def async_execute_in_main_thread(self, main_thread_item, wait=True): """Add `MainThreadItem` to callback queue and wait for result.""" - self.callback_queue.put(main_thread_item) + self.callback_queue.append(main_thread_item) if wait: return await main_thread_item.async_wait() @@ -904,9 +904,9 @@ class QtCommunicator(BaseCommunicator): self._exit() return None - if self.callback_queue.empty(): - return None - return self.callback_queue.get() + if self.callback_queue: + return self.callback_queue.popleft() + return None def _on_client_connect(self): super()._on_client_connect() From 2142d596038aa37d382f5dd5c5df947ec437f58b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 12 Sep 2023 14:37:04 +0000 Subject: [PATCH 276/291] [Automated] Release --- CHANGELOG.md | 237 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f9ff57ea..0d7620869b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,243 @@ # Changelog +## [3.16.6](https://github.com/ynput/OpenPype/tree/3.16.6) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.5...3.16.6) + +### **🆕 New features** + + +
+Workfiles tool: Refactor workfiles tool (for AYON) #5550 + +Refactored workfiles tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. + + +___ + +
+ + +
+AfterEffects: added validator for missing files in FootageItems #5590 + +Published composition in AE could contain multiple FootageItems as a layers. If FootageItem contains imported file and it doesn't exist, render triggered by Publish process will silently fail and no output is generated. This could cause failure later in the process with unclear reason. (In `ExtractReview`).This PR adds validation to protect from this. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: Yeti Cache Include viewport preview settings from source #5561 + +When publishing and loading yeti caches persist the display output and preview colors + settings to ensure consistency in the view + + +___ + +
+ + +
+Houdini: validate colorspace in review rop #5322 + +Adding a validator that checks if 'OCIO Colorspace' parameter on review rop was set to a valid value.It is a step towards managing colorspace in review ropvalid values are the ones in the dropdown menuthis validator also provides some helper actions This PR is related to #4836 and #4833 + + +___ + +
+ + +
+Colorspace: adding abstraction of publishing related functions #5497 + +The functionality of Colorspace has been abstracted for greater usability. + + +___ + +
+ + +
+Nuke: removing redundant workfile colorspace attributes #5580 + +Nuke root workfile colorspace data type knobs are long time configured automatically via config roles or the default values are also working well. Therefore there is no need for pipeline managed knobs. + + +___ + +
+ + +
+Ftrack: Less verbose logs for Ftrack integration in artist facing logs #5596 + +- Reduce artist-facing logs for component integration for Ftrack +- Avoid "Comment is not set" log in artist facing report for Kitsu and Ftrack +- Remove info log about `ffprobe` inspecting a file (changed to debug log) +- interesting to see however that it ffprobes the same jpeg twice - but maybe once for thumbnail? + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Fix rig validators for new out_SET and controls_SET names #5595 + +Fix usage of `out_SET` and `controls_SET` since #5310 because they can now be prefixed by the subset name. + + +___ + +
+ + +
+TrayPublisher: set default frame values to sequential data #5530 + +We are inheriting default frame handles and fps data either from project or setting them to 0. This is just for case a production will decide not to injest the sequential representations with asset based metadata. + + +___ + +
+ + +
+Publisher: Screenshot opacity value fix #5576 + +Fix opacity value. + + +___ + +
+ + +
+AfterEffects: fix imports of image sequences #5581 + +#4602 broke imports of image sequences. + + +___ + +
+ + +
+AYON: Fix representation context conversion #5591 + +Do not fix `"folder"` key in representation context until it is needed. + + +___ + +
+ + +
+ayon-nuke: default factory to lists #5594 + +Default factory were missing in settings schemas for complicated objects like lists and it was causing settings to be failing saving. + + +___ + +
+ + +
+Maya: Fix look assigner showing no asset if 'not found' representations are present #5597 + +Fix Maya Look assigner failing to show any content if it finds an invalid container for which it can't find the asset in the current project. (This can happen when e.g. loading something from a library project).There was logic already to avoid this but there was a bug where it used variable `_id` which did not exist and likely had to be `asset_id`.I've fixed that and improved the logged message a bit, e.g.: +``` +// Warning: openpype.hosts.maya.tools.mayalookassigner.commands : Id found on 22 nodes for which no asset is found database, skipping '641d78ec85c3c5b102e836b0' +``` +Example not found representation in Loader:The issue isn't necessarily related to NOT FOUND representations but in essence boils down to finding nodes with asset ids that do not exist in the current project which could very well just be local meshes in your scene.**Note:**I've excluded logging the nodes themselves because that tends to be a very long list of nodes. Only downside to removing that is that it's unclear which nodes are related to that `id`. If there are any ideas on how to still provide a concise informational message about that that'd be great so I could add it. Things I had considered: +- Report the containers, issue here is that it's about asset ids on nodes which don't HAVE to be in containers - it could be local geometry +- Report the namespaces, issue here is that it could be nodes without namespaces (plus potentially not about ALL nodes in a namespace) +- Report the short names of the nodes; it's shorter and readable but still likely a lot of nodes.@tokejepsen @LiborBatek any other ideas? + + +___ + +
+ + +
+Photoshop: fixed blank Flatten image #5600 + +Flatten image is simplified publishing approach where all visible layers are "flatten" and published together. This image could be used as a reference etc.This is implemented by auto creator which wasn't updated after first publish. This would result in missing newly created layers after `auto_image` instance was created. + + +___ + +
+ + +
+Blender: Remove Hardcoded Subset Name for Reviews #5603 + +Fixes hardcoded subset name for Reviews in Blender. + + +___ + +
+ + +
+TVPaint: Fix tool callbacks #5608 + +Do not wait for callback to finish. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Chore: Remove unused variables and cleanup #5588 + +Removing some unused variables. In some cases the unused variables _seemed like they should've been used - maybe?_ so please **double check the code whether it doesn't hint to an already existing bug**.Also tweaked some other small bugs in code + tweaked logging levels. + + +___ + +
+ +### **Merged pull requests** + + +
+Chore: Loader log deprecation warning for 'fname' attribute #5587 + +Since https://github.com/ynput/OpenPype/pull/4602 the `fname` attribute on the `LoaderPlugin` should've been deprecated and set for removal over time. However, no deprecation warning was logged whatsoever and thus one usage appears to have sneaked in (fixed with this PR) and a new one tried to sneak in with a recent PR + + +___ + +
+ + + + ## [3.16.5](https://github.com/ynput/OpenPype/tree/3.16.5) diff --git a/openpype/version.py b/openpype/version.py index b6c56296bc..9d3938dc04 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.6-nightly.1" +__version__ = "3.16.6" diff --git a/pyproject.toml b/pyproject.toml index 68fbf19c91..f859e1aff4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.5" # OpenPype +version = "3.16.6" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 9143703a73931b6a4b0c63d0aa939870186c8c43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 Sep 2023 14:38:07 +0000 Subject: [PATCH 277/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7a39103859..eb8053f2b3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.6 - 3.16.6-nightly.1 - 3.16.5 - 3.16.5-nightly.5 @@ -134,7 +135,6 @@ body: - 3.14.9-nightly.5 - 3.14.9-nightly.4 - 3.14.9-nightly.3 - - 3.14.9-nightly.2 validations: required: true - type: dropdown From df96f085f2e21d4a3c8a2cb5e3d33001eb40b99d Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 13 Sep 2023 03:24:29 +0000 Subject: [PATCH 278/291] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 9d3938dc04..c4a87e7843 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.6" +__version__ = "3.16.7-nightly.1" From 5de7dc96dda39f51e0664bbd3e572639cf3d3130 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 Sep 2023 03:25:10 +0000 Subject: [PATCH 279/291] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index eb8053f2b3..35564c2bf0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.7-nightly.1 - 3.16.6 - 3.16.6-nightly.1 - 3.16.5 @@ -134,7 +135,6 @@ body: - 3.14.9 - 3.14.9-nightly.5 - 3.14.9-nightly.4 - - 3.14.9-nightly.3 validations: required: true - type: dropdown From 9369d4d931f8bd9f7e02e480099d64a4bb89f9b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Sep 2023 09:46:48 +0200 Subject: [PATCH 280/291] plugin does recreate menu items when reopened (#5610) --- .../tvpaint_plugin/plugin_code/library.cpp | 106 +++++++++--------- .../windows_x64/plugin/OpenPypePlugin.dll | Bin 5811200 -> 5811200 bytes .../windows_x86/plugin/OpenPypePlugin.dll | Bin 5571072 -> 5571072 bytes 3 files changed, 56 insertions(+), 50 deletions(-) diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp index 88106bc770..ec45a45123 100644 --- a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp @@ -573,56 +573,6 @@ void FAR PASCAL PI_Close( PIFilter* iFilter ) } -/**************************************************************************************/ -// we have something to do ! - -int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) -{ - if( !iArg ) - { - - // If the requester is not open, we open it. - if( Data.mReq == 0) - { - // Create empty requester because menu items are defined with - // `define_menu` callback - DWORD req = TVOpenFilterReqEx( - iFilter, - 185, - 20, - NULL, - NULL, - PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, - FILTERREQ_NO_TBAR - ); - if( req == 0 ) - { - TVWarning( iFilter, TXT_REQUESTER_ERROR ); - return 0; - } - - - Data.mReq = req; - // This is a very simple requester, so we create it's content right here instead - // of waiting for the PICBREQ_OPEN message... - // Not recommended for more complex requesters. (see the other examples) - - // Sets the title of the requester. - TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); - // Request to listen to ticks - TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); - } - else - { - // If it is already open, we just put it on front of all other requesters. - TVReqToFront( iFilter, Data.mReq ); - } - } - - return 1; -} - - int newMenuItemsProcess(PIFilter* iFilter) { // Menu items defined with `define_menu` should be propagated. @@ -702,6 +652,62 @@ int newMenuItemsProcess(PIFilter* iFilter) { return 1; } + +/**************************************************************************************/ +// we have something to do ! + +int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) +{ + if( !iArg ) + { + + // If the requester is not open, we open it. + if( Data.mReq == 0) + { + // Create empty requester because menu items are defined with + // `define_menu` callback + DWORD req = TVOpenFilterReqEx( + iFilter, + 185, + 20, + NULL, + NULL, + PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, + FILTERREQ_NO_TBAR + ); + if( req == 0 ) + { + TVWarning( iFilter, TXT_REQUESTER_ERROR ); + return 0; + } + + Data.mReq = req; + + // This is a very simple requester, so we create it's content right here instead + // of waiting for the PICBREQ_OPEN message... + // Not recommended for more complex requesters. (see the other examples) + + // Sets the title of the requester. + TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); + // Request to listen to ticks + TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); + + if ( Data.firstParams == true ) { + Data.firstParams = false; + } else { + newMenuItemsProcess(iFilter); + } + } + else + { + // If it is already open, we just put it on front of all other requesters. + TVReqToFront( iFilter, Data.mReq ); + } + } + + return 1; +} + /**************************************************************************************/ // something happened that needs our attention. // Global variable where current button up data are stored diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll index 7081778beee266e9b08d20ba7e48710dd7c4f20c..9c6e969e24601f3686477dfe6d7127561f2a3d9a 100644 GIT binary patch delta 102578 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 delta 102519 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 *Se&+ey$67@Jm_y**9-A6__l_Z!+$mA~tGn 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 diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll index 0f2afec245f7f73ec3a8d0f72b5892653f5d51cd..b573476a21d7fd73ea1e4b4a53794377954fd845 100644 GIT binary patch delta 108350 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úl`=?-?uIQ>)<~_KXD}Pc7LfyFP38jG-ZCa?IXkf1 z#eSHp@B1Ig^}C}p1aHvNr3Qh5ms zOf|PgmWb2c-Q(0tfG}j`Y?#o)eOHYdc^kI$#L^G%2=49WE)b&%y>+YVA^O#P~6|MTA5#tAdfMz9T+7ALLTm)22LKIS}Tx3n*jAZ$cf>hJv?Xa?{3{Vc#HOJ8PIM*CF)ka z)HvybDyc@Qs5b!SvPACz?sWL@EFlgNBcOJ%$!&Vz^TPJ__z5>2x2H8F7zwjhby@!}Kxp0QX5 zmsQOmqhmpSLisiw#0rBy0vWF3K#Kiicp^vG^FWSNbw1I2eIR#q_P^?WJ7F-)XF!~{ zG7U}!()(!((){Cw$6r+ZKg@pz^J4RjZbzqY;Q~u|6sTMWf>p=7HnJHZ z`a`hX&M>z#jarsb%LdXV{v$qX_y-I=3}pY9Z}X#0f%{7f3Ci;q6Rp%o01US6jzXY0B8_H23)4#TZ=aah}0^AR`boe33!hNa?FO zvyey_jzyZCT%N1V*u0GucDk519CcPT-0f9`XQ}YpVWf5PTxZ+|(i0z0-9{{JWIB$+ zMxc>fBea-fgcvtMt#9Wc87t7Y013-p*LU$8lfquZdR`YzMq-I+yOpqQ5u=3R>=W_79(QND4gyEMVrw$X=l<{6I+ zGu+=|_{j!s*D62KnN|VWEUd7YnC?zW^nak!Qw*j88Rs-0o#W`c;5NPU$@|WgP~!%% z8+u;P0-bLQpgrpSW#l|x;so5E;r4Y(HM4P=!5e|JehZMro(xppi`f}CP!5`S=NQZd zQasP_`3AR(;bYKNvP}3>4WKAkB;fsBQ&8 zDDTmUe++-X;2|KLIBNLg2LCT7BF2emoD&f^ab~Q$RYHa7e&wRgP3Voc7&hZ|G43Yx zl1ecT$ztpvccU7ZKW{>J!1R8T8(#$-CHjnWcSB(B9mi>~k1kA+SOFG&W*5Ng>#I`} zfp+=-x>Gk?{w}=6G;#HKbovY99j7Hz)~@0(SKp3(S6ikC9sd{FGjbdT?Jo%)D)FOazR{r_U$~&)o_V)B&1Ps!(DN^;u6_%$z>wb zGNc|gHDa)QH79axG|M8ia!ef}8_6;WmqD~7Ttc*Fe&^m}^S*EI>*w?1>v_)qoc~$= zXSr~>Q1i1aeTnhTBChs!;b;DF0T#%)AZ<$?NM|$O`;lf>r?7wpX?8tGvl~4n6Bp>B z_rpEi3DWGpcnk43u5^9)@jj3~ranXT-pTZhoLuo~IJ*j@kE=miX)Q>bO#e}~I~-15 zWzTPcG=lD8&s#D6CW5@_mK7D(O9V#R14b+eJB7co8sQa8`C^oxwY zal?+}z`?MeT#))%0b**sHsgIg+m^A+!z}X)Z}4~oEMFEbqnCNh#v^3vS-R2Nh3pRe zN8xM&-U+}H-oxX4-J$~J;jG8K4ihlcMI*x#w4Je(ai2GKg6|D~{n&7JBO`|cfQ~@| zV@ojfk{P5orA{DqNc%r#ZaCs&#`@YO%?#I5KZDf=(y_aXJ#WSMiMMv5PwLZ-nbosR zwh;`Ov*)cCKk+V7^9QoRwI(nofz(`LX~Kx@agt9Oc$P(e8RKD)nt#F4Uo+kb^(+>p zvsjb{ymu$j*k(~$`lrIRYF@=y4br%&1?kDbVJs)*JxjmBcqg(; zy^GUurj&XerX!$p9|$|k0_mj90%^|!-dJR}&YBP&WSW)db)O!(BfgCsT>npp$`57b z!BBZHT)z7ZSOvThGw@@qia^+^6r@)B7|TG~*2CTbSPMK}!qP~H0jXIh#srX>C9(W^ zGaUboUffLj5`-lWdn0DzRlrm>JATGtAZ^kpmLA85U${o;K2zE3_!)G{QGsqdi`gkSRN~u&x);M#WwuE#hyj6d1=@WXpLh)>MaYT-e!S!yisms=|zlB zfuYlmrC((HwN?M?6$brIwB`Mn?MnzAVIIMdM=<0O{2v}!dJ*GOV8|m&zsUH1d883k zS{C-R54>|2mgHi0D9iSB?GRwOx&JM<%IlsTy7IN1gR?fq3YYI>c|+#lepTcx#N()H z5`J9kZJUEgyUOOeg0Tvuy*bO$ml(0j%tfSKWpiD@SOtd6S^5$qcA2?|w5e<#@G}kr z=@^Y->2ZwMWl;NIKlVl1^#rz0CNc6LEvB>ldUmokvXhNF5FR3G)74uz58ry^*?mDT z_h!t)j$HpyxZ921#(DG}y5K*XLRqLpR1q7m?cPmTMDX*yUh`2fz#iv%W9R!u4_uFe zw21-6T#&k5!P3t${<>9t$Yv3w(NgC9I6rh9j$hy#X=JhUV-`qr^BDsmtu+^Zhd@(z0Udp%+q~-@%`ZLBmJx^ehl*A^92WcGBe%|osCH;8>S9b=!%p+XRqSOveB(TpvaBW-*gl%p~9)K*`tyR$&sWkpEzB z)1$tn(QT{QZ-iU{>Cj#Ao?eas#5+%x`$i`6E!nS2fwUcdkT!D|NS&s&^zL2Z>l~5T z)iYQ4?rpZs8@s~SCF)vNyj>t3(dIq*UaOA2E(!S6N(cowFJXnRQ^cVDA^V8R5IZ-2 zYe00&aqs2|pVsUZPgvr@mzz&MJ_gJQmcPDgR;pqOu?xP&vXf%2;Ho1#~Bmy654Dom28B`AiB0(t79E7s&#s?fj z{ua?(Da5ygZ4h5#qEHyBhy2la2yv}o1SRP{Qv($lnGZgbc;tjVu9Z z9aIWcLosbpE*8()p|19D4pl&D9bf~k!!#E`ovZOB;o0U~!q?~#ln0eT+&9k*h;I%3 z4r&9%Ky9H|s2$WE>HysXb%Z)WaZqQd3)B^ghwg>CK?zWIC=t33>H+nHdO^LRB&ZLR z4E2TjLH(ft(ESh(36Ka$kPIo13TcoI8ITDP$bxLhfn3NB4TJ_k4?roi zm3*YIQiv2+ijh($$uE^iJ>-@0IXOjXp%$v=)M;9Y)?P2sI~szq*BEL}4w~o8404Xh zR*4m9XV{1A6z7nW;+D9m4Xb205XoooC-@BEoRA_O5>rrVq+BRxDCd-EYNS@ErRarv zq_NVtW|WwbWF?8TR-&$zcBHe|iF6mchiF|0_VM_d!WZ(}`7-__pCXJA0z&W^VJq6( zN#w;6QI-m&Ytmx*n!H%KhRV;WDcT-QM)mED5>%gJ7Q(?El40$!de|j)52pl8MfC_; z4*O$!zP*qnj1*=FPYQ*?pM}FhjgTSE5?>Hs7Y~Rh#2>_#QX+gr%G2Z%=)fLD4XT6H zO=^icL|dT!QF}@IOjGqC`W$_|{tx}EezzeSBaKPMQX}8kX6!INGw#8ZB$`R)08=-I zm{ZJU=0@|+<}veI^MZNVY%m+mU(m#85<~7GT}dKIA_FjSCh?OYWH?DDW5`4@jbxJm zSxTND%SkYwJWmSACQ?LRCvTG7WFPr}93r2Qi^R1awGLUe)(>`pv)eiA#JR5fdv~%s z&wbK;9uxGgd))of{o4KB{mJDLuuL%mllZy(dj1uD4}XEb!Z-2FgziFbp}!yrhTsZA zgb_l9Fiw~v%*N2?3VA|)5CgSEC>FL0yM_0Kqrx}BW#PKeOYA3#qAogOiujP2E{+wm z#F^rJaf$e(xJq0rZVq*-Abp$H`feW(BP^)-G$e^_f*+oeo;}*?sLydzzht zh}~>owtunrIoF*w?m%~}Z?z2V+=IX;@Bf1%yS+k7TJkAIwhnSYH}1P8;q zTzDCycue?*a7OrExFNI`{FYoUAC`|JDo@GP@&);_+#ol~zsQkFv=W2fcU2OVBxQgiE2iRChA6|8bY+Y( zQJJP>D*j6|_rX-7D^v&7lroOFC{3RZEkTj@5qIAplZ zSjQRyZ6|+??<}MXFCcDui>cx=afetVMoYt`0?hIVxj?RzW0jH0N;I#9I#kWZN;{%9 zsi|6lR;k76nR>AvtkDy(zz(2g_nR}#)#j(>PiAj2n!G`dU?Y*OXRMRhOp-B0tL@!r zR)Zbuh|UyewNvgiIGx>8H^(hN)0*felNQar2_*7^_-wv_FXwCcL?Knk5eg9Al|qBi zSsWxz5toa_VmX=`AtieM^>Ie4V2ur8xmK!zLM6=CJW~JF=rjlg{vO3b+8jWV}wSKln*vsr4_9;8s zF`aDetMOR6#V(f>jgL~q2eu)Kj`974wLxLOP$$HSrkE{m!jftxB}*%$9nwWfkcY}a z`BV8v4CFNISLYO4-GXM=+7|5;cE*+Z9=%deHl`WHMw20%Yt18Of3g;1*WX%e9kG%T z*az%*C(}6q=b7#SmkZ!eBEWe(g1DHk!}tsmg3k-z3IoKa#N*<aY`_kA)`&Gtb1_E4M`n<> zNVGK^XTd3}yZx~JG|ra$oma88bT{9!hcZZsk~|R=xEH^!}*bWG6dwoo&t$tf&$0D)&Qd-&_&?%0(~~3?|z3 z4&NQodQq4xevYZwE>%hmQY=ChgfZpmaP?I zQkt}6eTn{#UZY1FgAt(n5f`qx$}GnnqAlq{dXoD|Dw&MK=S^~o46!a-H?81N`!1)o zGvC3d3A>Zrx7lv||Gzp>EB$@JiGEw#`oiH8oIJV0*Q!mp^ zqs;J|WjKP9NhUc-R^X0s%F^vO>=W3^4mus(huk&pJ8rpqfp)GUyp6^2sr)iNSciQq zRX8YI6FP}K#c|>`>|%qY9BGsEjnq$GgK&(%j<8fIQg$g9lxTI3I$h0Cx2ikUGwNmB z9Y$$uw2fK|y}xeiMS3@5j*)A`nd#noyXk(j1+g&KhltM_Z>_dk*nhC6+6(M2;d%nL zq_*xi@E63pMng+uB!86u3R}W6f-F{vJ*2hL-LfT5NAPWzzr@aXw<2O!+@V~>Opgxj zh?VMXHCbDx?bmK=J#|sf*RNw=OgD0jO~yZs_U4w*&X|n(Uqkkjo5ZlDV@I=M4W$LZL*HYx{{BTBmZvHCp@9$uS(RZ^s# z)Ouj)ditxnY-Ab>jhQbP<;G#7(x}3IUTZX9E#GanGkchQ%%Nt6IT1Vj0`m#;X|vee zWgf>}uNJc!L3~6b1Ia^V6q!Qi;TpGjuAhSsSvt`T+^?k5hOW8SJ7NeF|T0M z_vCr}7ahUjxF2vFha-N#DI5;@0WadP-w${#hdqA4yE*Le13t!K^Y3ug=zOfml|ttB zGB?71>L)X0iP*|C!la`4yrv@7s@s)YVlUG;LxmBq=%TccdR0m>RH$>X;$>xJrPR}O zk)fs+N13nuBe~MTt%{4hl{hI^<-m$-v30{C4{RK&I2pUu^{ar4xK}ED@aVN1bSMMQ zM;le-d@2dX_N0OW#@#C(!-@?Ny#G?=gRzU8&k-V(+Bz56!xVq|D8agVvIl=WQ)P$UL~T7jaY0R2fAxn?fHSsgBaG zY88^E#8#b5Mk>#${!K!Z+ZN8mK`&ODA>c4`tV;}k8|w8o8wfquOzBhO2&t@C*L*|z z>WyvJ5M(UNE}0MOQmcuu^1X>R?4(-L$ReevRtx&aJH^ZXA<0p!9Xy0xZ%f-F9XyDv zfI}C-0;>jmqP7aHeU13&3u^BYNVGDi?i$12`HH^YP_kRGcCJVEE3KW~%=SJrF-rYI zoONuxNr-y#nKH$>Mzi6DI-ORxTti3;C_EkTn9K`MLrw?$OHT*fHd1?+?>kO8=Uh*? z_tXTYZ<8#Er&7ViE=(v-NvENwOuKC~{$5(ZeWYJ8Mi-*qQ#lK|vaFY8W5_c;g2wN3l5S07qwI(mQ_;Ly2$uoSuXcTw0=%~drAv^lx)}4yy(#I2rqPv zF5+SoH2Orsm5Xl9xL}{$?i<=Y`YWCu7TV9zyvXrbV1OE50M(L|Dioly{K?+^%VBLshAm#cSX@Nqit=pCMmlmWR@PFs#ciTjvy>iVHxQzH z8oQ8;)z2PxmoMh<@$SS&SvI~Qsi;3O{x^!+mXHuk1}eGucc$Jt(VbGYwbEhoLZMfN z3A`I2S%A5TP9lw!50mx8MxQmsfe6EvqLAC8x$;vUPpyLwY&`7`LL}vUQYU*NoF zo}RBaoqj+dYn9xYCA6@OK5v#gso?dCL7CD6U6coI)zG$B&FO9Gz7EAolPVO(Ik##4 zBBl4-F7)#nWy4%YDrM4WcNOEd992Hl&&MWZ(%MTs|jQ z>0!Ybw2yv@ZgCQ~W{cP0U*{!nc$JO9t3bI#jPy}U%?R;O5|+KJc5H@5z!ZhMl#e(i zAtRSQnXdRjGn&^{p9oz@wLvKwp2K$~C~m71W_}|sVBLhel8K72d?@LoL@lpRB9+C< z1IijED8h;l+(NEsOfQaC8mv5z7P4q%eR@4!sk6$TzHO&OuNpuGDW_ItkT%MI)iwCC ztPUli%Ei^e;Nl=`~rlyGJz3Cp;A?vJrL}|9Ep}x+RVcL(qTmL{A$!@5u-yTcV zp?dQjR;Zu;ip$OtlYd8)&Wy6QvK4Vrf_7aMO|VY6YhuGyaCbjKvh_>${6^@V;YtU6 zM`iIIYkjtU9icaeDxH6CL2Q&czxN<^%FW*oapl|BgH%%f+INT~Dp{k-|edo#A#97Hd`AKPZvW;FkRfXcS=xuT(f*z<=o((Zlyz_3OiK}*|2?^H+orxx7 zyt4bO6$(b~*=sbvqq6GUSL1nIOpMZpPgNRwSQ4GW&Z}gmvi5=%st&vG1&#UMixmj6 zb@-AK_Ir3~7_L`P{%<%#|K``Fd7bo*mrV#Q3RI3ReJ$ zR?}mh7Nx$7Lv!q^OfB3`k9+IAZ}{VyC{u6RlPUU@H}4aYrA)i+i&gCQWwJs)?~Vr% zS~W3&HhGervB|tHPU3SkHg{_a9~&DgYv^m;^Fb5ZMd@>YrgS6-byHba*-9z8Ka12- zrac&elkn`p3QLWa@F-Ob!5YQwQDY_f zaR@!vUb+0(QaSyozGC(yme}j3J~>OcqIP(e#EU$w=%DW8P_cTE`-0~S4eH(JWfGdR zf|t9vYkd_=sZTd(Ub%v;^7oq|rN8Zz32z7gsDHcU`|pkK_R=^5zMX!;`*qxpSpRK? zLw8eLK3?QSu~y`LT7ZzgPf<5Qi@Pb=#YtG`T|TcAD*f%sO_^!B=shU>B+if}x{_!A zX48tU{Go8Uv%Y4d0T21s3@2pHw+LdUsNdSqOI4KmCEg@M8C-IJ-ZKB)j_f0reayFR*g#N4vVv5DM-|CdQi&}vC0zu1*9LBzkp^Th7@HGUG8;V2v9&tf zH7EAM=Mr5;6|$Wq!;UJXBk^NJRY))N$X}tW1sP0VR%5#@h$SJBaJm}#gTz9bC9x-6 zV4EdbPw#$#U@LNwtbkh9NO=r&vL^LO0F1XLO-LMMS(9~SCv>zSwaI)KX+uir@gj(= zPQqwGWjIxxq>;_gy#~^o4P$GNWAw{M=2??mC-l_^sAxwV=(!K9ksUz=`}m&u)FOij zSqhoX)fQg36WiKOWi2s2hU#kH;Sd8X zG8YfxN}CS)q%%tE;^c?2GB3e=P`KxL_3%?Zh{p+?kvjaAuACN?dY|GDnA?QZBOPE@ z6SA1}gSMVH+YT_N32`QgknBk&o2~oX&{5MnzwXeX8MfU94=>VPtd4CE;7PoN5Y-2c zdXXNc^FI6-2flie#v~hpo01;%^Lt43B50`L9)eaQ-v7Yk7DkKpz}O3^&xJ9~2vk}6 zt_($MeXF(NBzuz-dh0DLK4)wN1>VG&78hptkT9|RTjZyrH)$;NQKMm@59venp!$#; zQWe(u5_kIW8eH*3865+oo0DK-&yF-Fn+Tc2hP5PX2)%KMNv+9hLVVfAHdsM^W&UkR zCZR^>ASsDdXGQIZj?xv6ls%&@NgT5dKpheNaY5j(cBC)U2aysYs&^5V1IR#T(TThx za>i}`9MCU_G+`~fkQ&(ICSn}uNIY43H)2Aoh=o5tsX1hYkSAmfq<1Ht6>eTHYlVK@ zNMrU-cT^`TAAtqkNqq?ENlYrY{nLP=%~J@A>PhCKp*qMhoWVAfR21W{;E?CKlc|su zO77Cl`?cP?!?5>*{Bpe)vD`3%R>&$x!|>}%OvLV&uy>>s6lkNDT;8>*0?u)t@~GCvzwn4I>7lR5`s8W269HtKXj_jr(cZ1<0$x^Z(zKlc@P!Vd!;!qQzRV>+Lk&jEq zDQZJ`gs4XkLgmqz%+YOx)}u)cv-na3jufU2hi;?Dj;ic{p;yE24DN+WqfzVLtT%Kr z?^oKcG1TV(1dl>TFnu)HT6NBTLsvg+;{O|}$jBICT_GIn(>s&YKb*jRG_f|Wk@lr~ zn7SLLjv;m~)ys7XQ!65r=I`Vu_&fO{2_NCK_M9Jb*zqys4`bp9vlGxBjfb2Ba*24u z+(cZfO0Yc?2{>_#G4MbU4tsiZMvPa(ae)!R|bVft^96&mk8(})Ljm`ZxlZ`ktoPDAj>R#-iibflM-K>Z}rlh)b_t*2qLJLDxH;iN5` zFiAo4RWyy%g#pQ=B3TI0$z;DcDH~O7RuXB)D^ifw-7Kh;Oj<+Bsl*iPOs12bQtPZT z&cHhbxd?{r>4>^|Gv}-$*##YDAoy()X9^jet}!};1fmOdPbDcRz`IjPH3uE&jD*uM zMlr)I3VMX7pEGnuF-FlwXP37~EAA1d_GOP#NfIHQVCXC|gm08&<3QD$)$iJ{F6quaUgp>QOGoCnNDip#AVGVjxaf$#Fpaii^qv9_mZJLQfblX^%0UpZ43#H0 z6pEK&Owj{2We_LQ2+n4ZF2oY7fo!cZe3HR{q>HOCA!Zb!)`crTx>ptcQ++ZKnr4y^ zG8Sg@!T}00QI6jxfbLhcjcXz7SM=m@Fozd@z2zmg7R$L4_5l@6gsRS7$N#W;Tm!mqcOIY{6#;6u?_1`C`b_OUQgbW>9An~ve%Qn-@u*M z5?Hbcom@}$a1-}w8(?}CKFdLNC5v1oBndWeK~Lz&+HK{??cm`yM0R9NwsYj3aCrwJ zr$U{bZc!_kbBMgM*wx=qV@LyQ zego(EFgZ)F^n;#9NPC*y4}9(*Ge?e)Y>XJAj*{iLF=g~PnS>h>n0kUVE!&y0vnNOl zp>KLYvp+Bm(`=IyJNpM&NnrCI#0{37CUL_1jyk2vj-Jpj7Z>BLf0j)iA!ZdrG#<3^ zUBLMaxh~cV))^^n-y8h?S+bDFyIJSRK>FSvRmuEkv+RW%#b5i zbdkr3e#|7BRkQTvZ(7EJ@PuD zI~SsOV^mp4KEcxhL}oWQ@>_pae3QroEl9~NVvRCneVg1vi7&d1%`V`52PZm`8Q;Y= zL29mp#cn26;C+w0uq3`eX1UBiXYvk?(CUtswojMQA%iAThS(2iaPEYb()6EXqA`NSs}2z+S*u20daxIoZTaz(7r1T9;Y ztHeUtUD+7CpOI%&YMgPKSm8{1tE4L#2eVXi949j9IYL8V*>i*vA?z=VsRkRR}#`Z)i3C)!9HnxvaCqXUTd;{Lx*$d`HgW^RoBEpZ;S9 zU*D56Xr*#LAg?))`#17h+!pdal8anmU5asy4u2xQqTGiS;i{la6_cAJn$gd=!N3VH z{ep%p6PkP>LxsUsI;CTNAl&_e=J19cbomDtAg>dP|A*)Z&9;R~Ux*WK_hG}oWQg$2 zLc{L%mBf+}aPTW>LDhk*_$zMPPzmRha9Q;LT+5f14%X$CU6by&{$N9CNflG8(x$R% z<|0suh@YyJihU#t5~(GjxfT#EQAgx`hD1ez&R~vA8zAp{WjaK7Xrgg5(3nP(a5!yD zT{-I?j47^HBXgK5)B2osM-w_&7-y^@UofH3^mS$CU4b4YWt(cSG^Ojl1Am&*J!Jsf zREah-Mh0$IqX~rOQ|N6)2M7nl4a;xE&RWq?__RjgYeRiVO*YPkrV+Fl->TCbR4i75 zdJ(dMy{t)bhPJ>IJK9Y6XHZ#ZJ31A!OrN2HJ$1v7Ho=~bC;iz=`%>iEht-|n$59@LhU_WvE)HK9vObB3?)unL|Ok9~AQl)2XS z%(xl7M(Le5aK{H}r?ZB>^e>91GGNn^)**+WMN2yJJJ=A;AxM0{yA`b?th#D!IJ1Go zRq zBDB9$I{d}9REPGZpe=Iv^PT%bYP8<9L$hWF1?_1) z`uRMs`(4OCYYeLwh&G}af^qj%f7*~1oda}yj`Yeos24zYli^ShKbW0dU2r*R@i5l48;vJqC>#%_&7pHQ zT9-M5aA7)?3lZIEGc>Hw9Yq^WPY-1B+;HaEgQlQAhu?bAR>YOP?@413`y@p4rp@Tc zNU)2eHn6)l9a6i>NnPgE6dY0-Z-lS13rt=9km4HG)x>sCG(kyWp|S4BtkC$^qGzeR~!p-r=wO`LFF0Lj&x=XXV6~=hPN!0`jc(! z-&D@zUKlrvdeIl7AbS>_X7_E$593& zPmJ`9c@^&jk3ooPLIzKui*`XvG9Zh7fxt~T&cbXQr&2Tw%))WnY{79(Z^m(?Q#DwNt$30Iq1iajnr%1^ zJr>8*?Wj0p6Xfiq59rB}uwoZIZ}Kt5*l1+lu(U6*_?v7G3wP6S+;6_#jW*03D(}Ha zpdvKcL*2eHlgN*&OJ+jmM zAT~zE{%C~P2WV&d2A|;&ePt|at)P_ zBCShNaPSz?dK&qog|mPO?}FplF_Jl+p!JA&v$wGp=0e0toFfxRIY|eh-rwg%NAUXt zt-|x6u$9Wm2u(CcxoT7oWL%e3*Po1JQaoh0V6iOz~0p?;HvIq|4(hc-{2N;}( zA;n@?kw;I^)4?$440_9#;jsG*ZAyNJr)SWzCo#LTv@Zrm@htHiP7j*8L+5ER?jU}@ zfKk+`b}afL?Lf#B*mH^YM#pBHPwx>Q_AH;aAjE_@UZJ?Dz%0^L>PauQfd{3 zB8V=i=qg=dx!B*JREAwz+5RCKvaV5}uUdfLpR_S)!N&bbD-!IHbe(!xPHN7lP4}Wk z+Jy7z0fnI&x4-d%;+geZSJw0uJ&s4}6It;ax{_Elv-|HOn6s>RC~WZQ4SK_dA232C zb=a1_(Q~4FJo`w;kyWhUC!DHE4%WP{#@||CMJRAGyepzJF~cyim>$ze4ERi^5&F)8 z-TJ~UiAMHcZiOA#wy)o-{{4piNT=VY3jw7_xOKyw`fwI02ocnhUQS%I~HCl9+|T6IM2Q3RuHk(^Bpp+&X)Z?Yk}DtAp_Jd3Kq(e0sL0 z5MR_fUnYZ(i_nChs<{X+>9uFjx4zIs%zPsoDQ$|Zzqi}Mt@^@CG7Rz>2%e(LYpofB z8f#aX6|k_+b=V;3nV!(1Q$jp&j{+wRabAqj_{Hbnp-yNgBj@2sh~)1RD$X!oquo zuI}(_W1$gFW^QA|F@+b6g^^`AJa^GV7+nU^H+R6qQy5hSV>FuSiP#@*gXASN#!Z#C zm(U(JJyX2!b{qlMrosjuDKr&==&@;V;f1jc^JpeG6Z7*A46AywRIfZ(l(*29+u$ue zf*08N3YPHRM{vcIX)Rx&4XpANEFjKTXe4}nEgNn{D2(|E$#{PbH(0_klZLl6DG_-i zy_&>qS_|QL4TB}L5ylY2dgdp55mJ*Yz>zs7R_se#VHzRZA+bFQ?4x9+Z!fGO^y*dU z5g>T+ZO7aIAzUKi>{%z_qKM}J`+|iAczpUGSg@>+S%_oxeu?H=S?AVJC^;r;8W#{E ze4=lULaXjVeQTEj*(iP10E|L&XZ69qw$>HRL)5A;t-H{aOk(=(_;9%P_j(8otY`hn zaXk#U4Zq`#0f(M~CoMb-y?df~ZG^c!h3E9ZAp?Dz_gDWf`nRvbZ@q-89Jx2@!nFe$ zvgwuo3;EM!sNGX=)$ROTQqrTlItVU=3LS|hvkpV8v3AY>zxr6hT6h$?_7!Sl zI5MU$uI=3!ET^wI*N13X|#S?GQCgxJW;3Ga#V> z7(86Cq&6pinxpA#+i+nBq0^5WNMMlX6eYBxC-%V5D4Y%}m=%R$Y{PP+gh4{H-5X?n zfMD*7N7(UV+S{}*C7$ZyU2^$Dh8YN!9WuP_5vo4i0Ru(~E{-wS7tgP4Fud!JXV;5% z@QJOaMHreJz}(wN;ZGrJy9^6oVcJiJJ2BxoPi&78EHPCBql8x$YqlEnqFnxX5rfeJ z`s%bTT8l<(5z87Q7!zSawhZJpv82~iSzergnc9}1jzwcz2^NeK-07X);pjNQ4MT(1 z2y*p|$D%8Aa?2~Q)gBBpUO!jO6a$-0-SR{NyIB2neIpaJ(Hg}28 z4Gjbe+7We9sjyvNBptPpzft^2B#!mnfo^InB>Vu;&U3*G4MS(w4Y zB=Nb`f(sGg@6|#AH?nI`p43@PT_c1NehXrq&=X@+Wu4HHo|_Gq*P#XQWyb5dVH(L2 zHgHB(%!UreqBGpvi0X81Hgnk|VDK3X!!`>iF`?^{C5*+K>GCX`JDJ-OvpNb zwKE~=pjPX0NLWq(mc22+>y8Gud-9=#~bsEwd^?FW7 zMNFO05VaRVxpTI6=5Y+x=@{n!~=ULLBMGoUh^f z)4AzTSjpG{)a$}dE=C17>dkZ(UC8IPCv3cdE)|{p4K#IDV09B0ZUij2DX8@30!X|i zJSNLw&uzg1^B`ky;{pY0P&;<&wqQACU#pzmLe^;>X{?LQhm7e;4)_^poA~CJcq4MoFO4|cHQty8=3H0J9 z7V4VvDEnZ}UEvZp0gr@M7&I@tC#2&wA;ZVHWo9 zC&CgOc+OKC_*7eV>Z#D3VwT_fIZ9}u4Dn8!27nV(V6vGn;OB=Iumi?Fk_-WeADBg~=tMa<@392FU5UvVIMYY}9Ypo_S* zXj!!QjnrpRM7%_>^cKW>1kY=%C2=cm@J>kLFia#m%i=;T7%ws*$XG-(1ka4cg*0z5 zBOuLYiLYpu61mob!@41Y$d*-=a)foDT~C}H%+2-`9WeSR?kVm>A;{{5lM~H?dW(*P9zOuFp<)wZ)(HtNFTvv%REtpY z9?i*QnPFmGf)|O7^btL+njHH+49_=cp-D6|?u$hDcA}r?DV#b|N~1C)^g|k-GNDg@ z@imsL!vL{0z6Sww2I2$s2kUSwG9W8lETQVJkUdCjg)zv}LE=rE_2UtuCuX-kMu=_k zYLfS0MD5FR2Xk2+3`Ik5-EA_tmb!rXFfkI-jtRq1(CCfj%q0?^8!eu96t3G~u#U#n z#g~yrAa#ET7=e@E2$3Viwe;i)(8Y-1xKoRY5gEo1_9L+ob6z9GA@u1X*f>%QB@5u6 zkzxpvh?!SL=EHrcr&6&`M#K$j1^T3 z1`9@s`{+OWVc}@88*cjVjz(qd4{l?ysK}DWhtmyLF_^QS_y>-;wi*johWu8c&jr}bg5>(R$KUTn|vKOPeL8Hhpb5` zAr07tNjP~H=XM}s7bholsA;)|Tb)iBQ^aW~wMkRNnilq2vlm{pD{W3>+oy=N2{{NC zr;2`fWzTGyID}ZkxM}z-H@AYr1f;Hq>(fMU+HCqO3=gM^2{<+VW{5-SxlM3%2HM+S zpk#*VZ19DtI1}N}F;&E~_KmPG6A1|2)CbL9-C2zdeOK2J2z%EL_9cvqf)uZXFz*tqm|6rv`h@5xdZvYrjv;9MOZc2jjV@BG~|Q z(QZco%)_<8u>2RalUw20FX)UGLu8s5L0_!KKwP|wN{}^Q%)rBp*6BE>6(BiX3_~+l zkS_i~pQpg81)?oImjVYDh_3wfZ-MAUUrdKe3&jyMZw*UaC`$a`A!(85N6!KrTZFWX zA#AaDn%+nP`z3f~33q}^#2d&ODB@f^UUXQBp0YIP_^80LrD)r5y_bn&`0&fbC7AN9 znt_kse2MmK7BDdbg?Q6i$jlHwa61Snl0D%Dh{N$>T$@a>o%Q+@ZOzQJ6sTsBKCNY$ znc`TAdF%=+#3@x*Pb*v4QkyjjV1@XSW{-z-5Idu$U0#Vcaz3nGCEDU599|_>r%%?g z!d1AE)N`Bxi#{C-L2JZ1wBs1?%M`1_%r&A5vbG1YcvtO0aglXpNWIa@J#9P#+$y$EoiG6E=v~(e!lL zh+2Yo%r}YwWCE~_XlXjJn;S(_d}$4@frzz1mxT&TS%WOJ=JaGVi^&#E2~F8xI1gz1 zE-lc!>?OfpqWJUa(vrlqiMr6-fPcAjR0j_)HwB3r5UP6Ok1)0SaEYH2*MPTMM9Hz4 z)}QZ=@#1-7wcZa{Tj;eLz-FuHMejvIx2>W}856K_n&53f}A#yV5seC z2lj23I1pcG2#1s%q9dg26|Ly84UoB4oJJ>2fRbF4zhjLBnOFMgvGT_FpDPP_rSE?# z6Mw_XU*mtqwD}z?KacylGKW|8{kf9$*(V;O!~&}C7kly4@_z9*JTP)RAg)85Idwox zqqllPkAq@f<@Y@#qx9JhxJwLCpY~ubhs47KH!)8SqjAFG2)bKzFh|AR^iBwb9239M zJHZfg9Oq#LEIuxJ)r<@_%q4EyxX6_4<8&p>)Gl!UIEF6UA?1YVWt-6Lf8h=61`m#l z_VDHe&Ut(G{G>RP)I8P^`I?hMIbXlwZ=MTVxE(>Y9*a69W)ory=DA`9pVsVLu{Rpc zZ@DN}2)g7USmisIjUZh-h|ij{q4^BLRer$P99|s$qx~#z{|>Y4vlxca`x~Ioc?>~} zfSt$5xYeGGxPYd<(l9^Ho0xVn{P~Sm@cTt^F@_#Nm&A|sjUTkm=kmgm^U*uGXJ-X!p*Z`kxGJf<4nv2AIXq(S{pLYMPmJ!=$eeRBhE+J5=02Eb=GJYr7) zxF)v5?M~xAMV!YrcrKR$Mb|`M`cDh4pS2+5PxMvWVEUiP*oYR8vP-mvTYrl6sdEcx zeFFiz>u8c9An3YyirP1alzgmPAxbXvt}g@^h<$}rUrj;aTmkN#%)z!0_f8QIRw(ww z^PX*mq8sS}Hw!U>d*jVX7SoT-z~`p88uQ!*H$`{upmn!!gAoBmx5W1Jofm6)TdXh8 z(~TkOp4bSJIm_;0IMo-f-V^83^B!#QeG#t>_`%BuqCKvH=|e1xpy5NY7JcA`7YT9C zb<7P$Jw*3D8#X@_F&XM6!KH&zJ#cs=y3sq0AmovlDYS27sI3d;kHsFwXBrwN76zM3 z)nM9VF@%OU}#=w%`;!rm_RXC))Kgd2%?09pM*A-#QLDV7Mq|_)O;iQ3I(+#W$+$L z<-RH^$3DqODB+FRm0N~4=wfR^mA5$5BL_az5N-tDiVe+w_x_3cW_bPnDCg zpv~W?b(>($-(p)~sI3GGCrZ91P0~(u4^e9)czL2!8z;j3BQDDRni@=tn?UAAu@=e_ z`zQ_-s?;o-x5`RLQ7IEcKZ&UZc9>AaH&+JN+m$ewP>dc0XRjC~E5wG|Oe^-G80{(U zvA2D}9nVUZ_7Cn`d1qTz=c{N!xwMvu{@gf}pyQbY`Vx#=*J}QHSyQP7y=}g%yEL2h zT2?5z85D#_z80*qWE357Lo|;OV{Fp{eL~bt(2Gb-_+-o_QZ3Q|vWRqv<0n$dP3Tcc zf^Z_$fpb)172Bwv1<%nK*b$x>@=m4?bvd%AWb%a*0r_~zqAmT4m4IW68$swnj$5iF{r zv__SeiKM;V49@maJ8}SG?4_>sk&f-Rm+T}QzQ9T9MT{))Nlo!~#)o>6myC&Fmqt>3+!Oa`B-JIqL1H7xp1&m6NE+aB ztj)QJG_YD=xwcZHv>7s+NR#VTzssjdR~nCFaMPE+ zrSD**uDSI+dy`)_xTiD{_eRG(C3l;fH-8$fbR$!|8FXIK;L3xwR&6K`Nbr(U?7WZ% z9O;*n<;Ln?Iv9g$O{D}(I?it@&E+>kEWM@1m@^CVmXfi^^_CW*5{3ClW_YCwqI{)G zxVQ9fE?qzmBDIj#>t_CGARewB-oTiDl5 zI)d$WW;kJ!~ke*Q*?z z(-lt>)a5%2^O$xz`~+X$PyS9K2~B&4A6fAURq<87p*ZkBxWo(GF0cg4x#GT z?cgyCiQd@`Az1PlZx~{%LU5>5J+QLj?c7jx^7j9GjLOk-QT{^Je*dGrVvI+qilZV; zJUcWSE?HH3w#Bf*$fk}Uk1a#hzqf(!aGbpvfV-$Me0vos)vtqFErXOB9t)pGRijHv z%6fGPRr9vO@JOj4rfZf&N>QWO$bS*iH~pZ%5yl?+4~4HAzazNduE65K z!T%tP`hlRvO8u$2fp@405u>DPJOmhpvoQxYjY3s5hPtD1G78s2@6otQ*f<(3d^)V* z&?q=MTB>Urw4T$~8AMD88Ey^6W6*a#SO;CkNDYnk>kO^X*q>BW@~dKUV2oseCg8U* z(lBbV4lW*)s#n65Gv(`T8m7Kl3x08^gScUhlX~(vIS!Qzw}E4^zzb@;z}t7cs16q6 zq+ohxH4Gakb>o4@ILVR6;p1@ST|kVNeCV51&^lhKL(i>(A@R8M%K+k~hPKhGe(-Em zPAN=7RlJlDk6s7k_jswN?SqvD#PrVQs&kN$(I;)8oqDQyD@E;#LveK^NEt6#*D2DY zEBO)1stDq=lnG^@P&FR*kCz%GchE>`D#>Lxaq@{@boj`O%&js_OpJo*rnP z8)&M<&%ryMHLkF>pX`Bw+Fi*f)#oLk-qy}|%?fvN) z*CJT%8Er#VS0EFm0QzJ(beJgl^9!33rMBF{Oq3igR_@{G->Ku2ID?dcamS5cA%CJ& z$HL}kgodFgn)4FSTjTHvsGLE+!oCSoec$BWKd}*qU^zBW4gbmf`6)GPk6POyQS!5h z1MxqhXE>}##9d%?rltw^6VY`bwwj1u<5mXPPeQZz3czoY47#;M2!c-}e zzM2nyGo^5DRc1=lFjlaeg^^P#Y?&p^qo02Pui4Thn)C}t@_>7@B~#L!y__vg7JB8( zM0fs{M+Be7R}58i5kvqL>m-lmbn4ch+p~UdH$@y%HKi0sr_o4-1HVWGq&8cUCT$Vu z+v(71A^O|(@XJEnYNf#Tg;H1UP8Oo!d6)v77fGWC>h&TFECb-#B55qWnhgCGqXEKQ z+hTO*m@QZ=d32`JL?iCbswZn?rPR8liJdb zQ((bFsX5fjK;(aZKtEp{}VZTuIY`I3mBbBy) z;O#!4>em0!?&*i^A))FoY(3sCG~C@O%#@Pw!NY!)j^eSi^>V2<<+ndq;sT9>11qIA zd}Xkp@8emERnk<-UvFO{)!{CF4N9dK6t6*X#0|w-)aSqBz;`Vw{nTN*IZzK;t__kE{V)@9H%LvY97Vb2ZT>Mv>7spz z`a4wED0$IWvEa8+%HnUXZp5)(jpSps$HT7RO;Wp-&Z+;`FxFW6eV7Ue;8#>$^h&qg z!E>iQ`F90e)K@d$!X~M{&50R551WH9N<=THvQcV8-^M_z&C)*lJQ6-`M(N!TPFd1G z{KCMDEGe1(Ih+aE(i8!8CV3k=)XhT;6PS`@YJ{J#n5e@ES5pjirKg~?Sk5+FPqSN7 zIHD;ke*cr|`v~@QyW}OB-yf)1hf>Y9fChUc+s5ZhYfEj$GX#IK8GCO|ay1=6E?0P30mA;PxTh1I=a5ho!N^#36>y zg2M@Os1hJq8NPnNZXS^q5aW<&LkpM%NO-3JKbyd|9>aV{+fSX#cB|1VTjOUupZjrSbP>yTuVWG+2E|?NAXe?r;ewTVdo@& z+P@TR2FK4K@<0CC*w_7`&Uu8FYcQU$%{Y%?NsCg@3Lc!tp!iOEjTYWbynxR*r4$T> zbr+<&7;B|mlvdK4ZK2jBe2kUQ|B|#CeDg8-sG5)KdKjALOULNHZQ$R0blWRg|I1Po zMdNz%Dmuf{u;-d&0shx8n890f*U(RXYQgBAQm}w7u}2~9Es&`~oct%=U~vQEc6@2~ zh7?Wz?gr;?NOR4@yP*X^o8o1#D+9X0{#%lp?$>6h%$U!CH@Bo3mA-W?#R*kQy2A0B zIR2upyn7w!a!VR$Zc^IrVW9C(7XuBvo}u27YMXB9qIJg2=W4LLEnTL$+hCx%V6*Rz zgdb0s2P^JM1NcpryVBoyg8%9sUSI9gL4*cbLMR-)FI5+w28pmT3op$(g2@96=!QY> z2htefbzo_=H+*~`HOIHAJRV}&vn#NN=&Al{1eG75bsN=4R2Ib9u`Q3J#*}-eC%DVU zZ`C}N>}X*F=I~T<)-}A{03R0<6+T94qJwCp#^WCz5a7q>wmUuk#$PYB#cy`ts~B%~ zu=v-gOJp=V@KW$vvr47h};#@@r>?7pG%}rCu?@Z?(81&GOf%7s5_s z*^^Xa4~^vslwOmeZw1*KgVeq;`d zSzyD!T;4@7S`V_27m`Fw>B@unja6?;tpqFilJwo2&$5-ATOLp`sdLwY}qnZC|Zw)|NfUYRNHeT~AJI^jGkM!mZ1l!BboI;|#Z0|Ltd}F2t z{67>1lt+p;P^hiku8N;(Lq6;|AFs>v@js{(mPdN?8iF0<`Iwfu?I3UDXKuCe<~W|+ z){&R+Fu9K0nTJ~l(RHt2M;*Bn-ST z&hj!sXQ^7{3H+KfzU%eDMV?W4)su3Z9)Q&PvSWDYBf;oJz4Ty`npfg^rRZIYg7gl= zEFdrKUD`R-7aOtiQ;Xb$cWD!JX#;JALVkm`n%bf~sU8}THq9nIK#UPX_+<#Ac>_g^ zo5Tk4W}Yi*AO}_OeaeT|rA>RAk!x&)#@NDD?u{oOBV1)W`e-lAb(LN5M#^ul@(KLH z(i~U0A-`eBIIQbj6G)9se zSg*!1<_h2b20Bl9Y2}Hx%1wg;TRr7jm7m`%S7!vRyyT|B{@Vf`npr4s-6SF7v7k8Q zS>cuWEH4?q5gZ2v&5$N;ZM@|U^!{~r&0C&HgsTPLyO7;%yssQj+V{UE7-8UQ5Y-74 zVu;!aD-cruzA6~`28mcFsm-*y7^9K|vBX=wfK3>bp=?)(p=x#3poMHI(d^%ukDq)5 z-*|(fwlc#v2zIrTTVWFVRXceK9@>v+FW1CiepY+AuJ`zJ6PX}zoPflm2 z2Rz0#!dKBlRA)n5X;3%-9<-NhRJJbP*$Yhk<@&OB(D+pxl1QmZ*}05VMM&c*6Xh>&Vk*s}?(^>un zPfpr*kyqpP{$Upxzs+F-*4<0y;nw0c`Y97dH0fo z(9Kew!#BXu0#fy}&a##8RPhCgJ!Ca|F$t_UmPLGm%;q}^@qvZg4ZvFQ` zgsiQC;k6+?A(^9*u_NnX*9a7Ybx<@y_Jg7^$kqF`-#b`e%>Ka@MieVkUk<~Fq7B5x z;aYpKlW}s2geM~Y6XoUny|#%m|K7;(1bHIVOq6TFrv$k!^iPznpmCzyfaYhyz(izF zfnO8l4tP84VWRBIuUkx#{pclt@JVPC7QxC%a!Y<8j~Dow#$=rIOBoP8S#AZlCL`5- zlaXr8DMckzP)>xE%xrNp~!nM_G z;6m$gnBu;L=n(2LuSM24;p|=F#^Il$(coF2njnP^K*M z#IzB&({MX=l{JcL$7*Y=XZ}L8mwUZzVn&NezC)b*)%mNfZ_rZ za_mxW87IR%jB0|X6V_NUM8rzXI@I0MYuVT9tTTmzFIemAQ0i*%(hl5}5q2N^`ZU&W zyETAz^|o6>a7ca4cIz$LCfs2Sf-$byVZDiVKk+?l9pS5EZ0∓^R(ytjX5wL>pP1 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 Date: Wed, 13 Sep 2023 18:16:35 +0800 Subject: [PATCH 281/291] unparenting all the objects to the containers & uses the OP data to get previous objects --- openpype/hosts/max/api/pipeline.py | 23 ++++++-- .../hosts/max/plugins/load/load_camera_fbx.py | 38 +++++------- .../hosts/max/plugins/load/load_max_scene.py | 40 ++++++------- openpype/hosts/max/plugins/load/load_model.py | 20 +++---- .../hosts/max/plugins/load/load_model_fbx.py | 59 ++++++++----------- .../hosts/max/plugins/load/load_model_obj.py | 32 +++++----- .../hosts/max/plugins/load/load_model_usd.py | 55 ++++++++--------- .../hosts/max/plugins/load/load_pointcache.py | 21 +++---- .../hosts/max/plugins/load/load_pointcloud.py | 20 +++---- .../max/plugins/load/load_redshift_proxy.py | 24 +++----- 10 files changed, 148 insertions(+), 184 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 72163f5ecf..6c40acc56b 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -164,12 +164,11 @@ def containerise(name: str, nodes: list, context, "loader": loader, "representation": context["representation"]["_id"], } - container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) - for node in nodes: - node.Parent = container - + #for node in nodes: + # node.Parent = container + import_custom_attribute_data(container, nodes) if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") return container @@ -223,3 +222,19 @@ def update_custom_attribute_data(container: str, selections: list): if container.modifiers[0].name == "OP Data": rt.deleteModifier(container, container.modifiers[0]) import_custom_attribute_data(container, selections) + +def get_previous_loaded_object(container: str): + """Get previous loaded_object through the OP data + + Args: + container (str): the container which stores the OP data + + Returns: + node_list(list): list of nodes which are previously loaded + """ + node_list = [] + sel_list = rt.getProperty(container.modifiers[0].openPypeData, "sel_list") + for obj in rt.Objects: + if str(obj) in sel_list: + node_list.append(obj) + return node_list diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index f040115417..39bbf79d0f 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -8,7 +8,7 @@ from openpype.hosts.max.api.lib import ( ) from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -22,7 +22,6 @@ class FbxLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -42,17 +41,13 @@ class FbxLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.container( - name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) for selection in selections: - selection.Parent = container selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, + name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -61,12 +56,13 @@ class FbxLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_fbx_obj in rt.selection: + namespace, _ = get_namespace(node_name) + + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_fbx_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_fbx_objects) + for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) @@ -78,20 +74,14 @@ class FbxLoader(load.LoaderPlugin): rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() + update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: - if fbx_object.Parent != inst_container: - fbx_object.Parent = inst_container - fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.name = f"{namespace}:{fbx_object.name}" + if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] + f"{fbx_object.name}.transform"] or fbx_object.pos fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] - - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == sub_node_name: - update_custom_attribute_data( - children, current_fbx_objects) + f"{fbx_object.name}.scale"] or fbx_object.scale with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 98e9be96e1..86b7637c90 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -7,7 +7,7 @@ from openpype.hosts.max.api.lib import ( object_transform_set ) from openpype.hosts.max.api.pipeline import ( - containerise, import_custom_attribute_data, + containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -24,7 +24,6 @@ class MaxSceneLoader(load.LoaderPlugin): order = -8 icon = "code-fork" color = "green" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -37,18 +36,14 @@ class MaxSceneLoader(load.LoaderPlugin): max_object_names = [obj.name for obj in max_objects] # implement the OP/AYON custom attributes before load max_container = [] - namespace = unique_namespace( name + "_", suffix="_", ) - container_name = f"{namespace}:{name}_{self.postfix}" - container = rt.Container(name=container_name) - import_custom_attribute_data(container, max_objects) - max_container.append(container) - max_container.extend(max_objects) for max_obj, obj_name in zip(max_objects, max_object_names): max_obj.name = f"{namespace}:{obj_name}" + max_container.append(rt.getNodeByName(max_obj.name)) + return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -60,32 +55,31 @@ class MaxSceneLoader(load.LoaderPlugin): node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_container_name = f"{namespace}:{name}_{self.postfix}" + namespace, _ = get_namespace(node_name) # delete the old container with attribute # delete old duplicate - rt.Select(node.Children) - transform_data = object_transform_set(node.Children) - for prev_max_obj in rt.GetCurrentSelection(): - if rt.isValidNode(prev_max_obj) and prev_max_obj.name != sub_container_name: # noqa + # use the modifier OP data to delete the data + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_max_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_max_objects) + for prev_max_obj in prev_max_objects: + if rt.isValidNode(prev_max_obj): # noqa rt.Delete(prev_max_obj) rt.MergeMaxFile(path, rt.Name("deleteOldDups")) current_max_objects = rt.getLastMergedNodes() current_max_object_names = [obj.name for obj in current_max_objects] - sub_container = rt.getNodeByName(sub_container_name) - update_custom_attribute_data(sub_container, current_max_objects) - for max_object in current_max_objects: - max_object.Parent = node + update_custom_attribute_data(node, current_max_objects) for max_obj, obj_name in zip(current_max_objects, current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" - max_obj.pos = transform_data[ - f"{max_obj.name}.transform"] - max_obj.scale = transform_data[ - f"{max_obj.name}.scale"] - + if max_obj in node_list: + max_obj.pos = transform_data[ + f"{max_obj.name}.transform"] or max_obj.pos + max_obj.scale = transform_data[ + f"{max_obj.name}.scale"] or max_obj.scale lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index c5a73b4327..5acb57b923 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -2,8 +2,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + get_previous_loaded_object ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( @@ -20,7 +19,6 @@ class ModelAbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -52,21 +50,22 @@ class ModelAbcLoader(load.LoaderPlugin): self.log.error("Something failed when loading.") abc_container = abc_containers.pop() - import_custom_attribute_data( - abc_container, abc_container.Children) namespace = unique_namespace( name + "_", suffix="_", ) + abc_objects = [] for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" + abc_objects.append(abc_object) # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}_{self.postfix}" + abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name + abc_objects.append(abc_container) return containerise( - name, [abc_container], context, + name, abc_objects, context, namespace, loader=self.__class__.__name__ ) @@ -75,20 +74,19 @@ class ModelAbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - + node_list = [n for n in get_previous_loaded_object(node) + if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): - rt.Select(node.Children) + rt.Select(node_list) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in abc.Children: abc_con.source = path rt.Select(abc_con.Children) for abc_obj in abc_con.Children: abc_obj.source = path - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 56c8768675..a8f6ea90d6 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,7 +1,7 @@ import os from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import ( - containerise, import_custom_attribute_data, + containerise, get_previous_loaded_object, update_custom_attribute_data ) from openpype.hosts.max.api import lib @@ -21,79 +21,68 @@ class FbxModelLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - - filepath = os.path.normpath(self.filepath_from_context(context)) + filepath = self.filepath_from_context(context) + filepath = os.path.normpath(filepath) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) + rt.importFile( + filepath, rt.name("noPrompt"), using=rt.FBXIMP) namespace = unique_namespace( name + "_", suffix="_", ) - container = rt.container( - name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) for selection in selections: - selection.Parent = container selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, - namespace, loader=self.__class__.__name__ - ) + name, selections, context, + namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt + path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_fbx_obj in rt.selection: + namespace, _ = get_namespace(node_name) + + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_fbx_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_fbx_objects) + for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): rt.Delete(prev_fbx_obj) rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) - rt.FBXImporterSetParam("Mode", rt.Name("merge")) - rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("Mode", rt.Name("create")) rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() + update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: - if fbx_object.Parent != inst_container: - fbx_object.Parent = inst_container - fbx_object.name = f"{namespace}:{fbx_object.name}" + fbx_object.name = f"{namespace}:{fbx_object.name}" + if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] + f"{fbx_object.name}.transform"] or fbx_object.pos fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] - - for children in node.Children: - if rt.classOf(children) == rt.Container: - if children.name == sub_node_name: - update_custom_attribute_data( - children, current_fbx_objects) + f"{fbx_object.name}.scale"] or fbx_object.scale with maintained_selection(): rt.Select(node) - lib.imprint( - node_name, - {"representation": str(representation["_id"])}, - ) + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 314889e6ec..421bd34e62 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -10,7 +10,7 @@ from openpype.hosts.max.api.lib import ( from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -24,7 +24,6 @@ class ObjLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -39,15 +38,12 @@ class ObjLoader(load.LoaderPlugin): suffix="_", ) # create "missing" container for obj import - container = rt.Container(name=f"{namespace}:{name}_{self.postfix}") selections = rt.GetCurrentSelection() - import_custom_attribute_data(container, selections) # get current selection for selection in selections: - selection.Parent = container selection.name = f"{namespace}:{selection.name}" return containerise( - name, [container], context, + name, selections, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -56,26 +52,26 @@ class ObjLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) - namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - rt.Select(inst_container.Children) - transform_data = object_transform_set(inst_container.Children) - for prev_obj in rt.selection: + namespace, _ = get_namespace(node_name) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + previous_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(previous_objects) + for prev_obj in previous_objects: if rt.isValidNode(prev_obj): rt.Delete(prev_obj) rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection selections = rt.GetCurrentSelection() - update_custom_attribute_data(inst_container, selections) for selection in selections: - selection.Parent = inst_container selection.name = f"{namespace}:{selection.name}" - selection.pos = transform_data[ - f"{selection.name}.transform"] - selection.scale = transform_data[ - f"{selection.name}.scale"] + if selection in node_list: + selection.pos = transform_data[ + f"{selection.name}.transform"] or selection.pos + selection.scale = transform_data[ + f"{selection.name}.scale"] or selection.scale + update_custom_attribute_data(node, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index f35d8e6327..09566c2e78 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,5 +1,7 @@ import os +from pymxs import runtime as rt + from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, @@ -9,7 +11,8 @@ from openpype.hosts.max.api.lib import ( from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data + get_previous_loaded_object, + update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -23,16 +26,13 @@ class ModelUSDLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - # asset_filepath filepath = os.path.normpath(self.filepath_from_context(context)) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(filepath) - filename, ext = os.path.splitext(base_filename) + _, ext = os.path.splitext(base_filename) log_filepath = filepath.replace(ext, "txt") rt.LogPath = log_filepath @@ -44,35 +44,32 @@ class ModelUSDLoader(load.LoaderPlugin): suffix="_", ) asset = rt.GetNodeByName(name) - import_custom_attribute_data(asset, asset.Children) + usd_objects = [] + for usd_asset in asset.Children: usd_asset.name = f"{namespace}:{usd_asset.name}" + usd_objects.append(usd_asset) - asset_name = f"{namespace}:{name}_{self.postfix}" + asset_name = f"{namespace}:{name}" asset.name = asset_name # need to get the correct container after renamed asset = rt.GetNodeByName(asset_name) - + usd_objects.append(asset) return containerise( - name, [asset], context, + name, usd_objects, context, namespace, loader=self.__class__.__name__) def update(self, container, representation): - from pymxs import runtime as rt - path = get_representation_path(representation) node_name = container["instance_node"] node = rt.GetNodeByName(node_name) namespace, name = get_namespace(node_name) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - transform_data = None - for n in node.Children: - rt.Select(n.Children) - transform_data = object_transform_set(n.Children) - for prev_usd_asset in rt.selection: - if rt.isValidNode(prev_usd_asset): - rt.Delete(prev_usd_asset) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) + prev_objects = rt.GetCurrentSelection() + transform_data = object_transform_set(prev_objects) + for n in prev_objects: rt.Delete(n) import_options = rt.USDImporter.CreateOptions() @@ -86,17 +83,19 @@ class ModelUSDLoader(load.LoaderPlugin): path, importOptions=import_options) asset = rt.GetNodeByName(name) - asset.Parent = node - import_custom_attribute_data(asset, asset.Children) + usd_objects = [] for children in asset.Children: children.name = f"{namespace}:{children.name}" - children.pos = transform_data[ - f"{children.name}.transform"] - children.scale = transform_data[ - f"{children.name}.scale"] - - asset.name = sub_node_name + usd_objects.append(children) + if children in node_list: + children.pos = transform_data[ + f"{children.name}.transform"] or children.pos + children.scale = transform_data[ + f"{children.name}.scale"] or children.scale + asset.name = f"{namespace}:{asset.name}" + usd_objects.append(asset) + update_custom_attribute_data(node, usd_objects) with maintained_selection(): rt.Select(node) @@ -108,7 +107,5 @@ class ModelUSDLoader(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index 070dea88d4..995e56ca37 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -10,8 +10,7 @@ from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import unique_namespace from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + get_previous_loaded_object ) @@ -24,7 +23,6 @@ class AbcLoader(load.LoaderPlugin): order = -10 icon = "code-fork" color = "orange" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -55,8 +53,6 @@ class AbcLoader(load.LoaderPlugin): abc_container = abc_containers.pop() selections = rt.GetCurrentSelection() - import_custom_attribute_data( - abc_container, abc_container.Children) for abc in selections: for cam_shape in abc.Children: cam_shape.playbackType = 2 @@ -65,15 +61,17 @@ class AbcLoader(load.LoaderPlugin): name + "_", suffix="_", ) - + abc_objects = [] for abc_object in abc_container.Children: abc_object.name = f"{namespace}:{abc_object.name}" + abc_objects.append(abc_object) # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}_{self.postfix}" + abc_container_name = f"{namespace}:{name}" abc_container.name = abc_container_name + abc_objects.append(abc_container) return containerise( - name, [abc_container], context, + name, abc_objects, context, namespace, loader=self.__class__.__name__ ) @@ -82,20 +80,19 @@ class AbcLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - + abc_container = [n for n in get_previous_loaded_object(node) + if rt.ClassOf(n) == rt.AlembicContainer] with maintained_selection(): - rt.Select(node.Children) + rt.Select(abc_container) for alembic in rt.Selection: abc = rt.GetNodeByName(alembic.name) - update_custom_attribute_data(abc, abc.Children) rt.Select(abc.Children) for abc_con in abc.Children: abc_con.source = path rt.Select(abc_con.Children) for abc_obj in abc_con.Children: abc_obj.source = path - lib.imprint( container["instance_node"], {"representation": str(representation["_id"])}, diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index c4c4cfbc6c..2309d3aebf 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -2,11 +2,11 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace ) from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, + get_previous_loaded_object, update_custom_attribute_data ) from openpype.pipeline import get_representation_path, load @@ -34,14 +34,10 @@ class PointCloudLoader(load.LoaderPlugin): name + "_", suffix="_", ) - prt_container = rt.Container( - name=f"{namespace}:{name}_{self.postfix}") - import_custom_attribute_data(prt_container, [obj]) - obj.Parent = prt_container obj.name = f"{namespace}:{obj.name}" return containerise( - name, [prt_container], context, + name, [obj], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): @@ -50,14 +46,12 @@ class PointCloudLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.GetNodeByName(container["instance_node"]) - namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) + node_list = get_previous_loaded_object(node) update_custom_attribute_data( - inst_container, inst_container.Children) + node, node_list) with maintained_selection(): - rt.Select(node.Children) - for prt in inst_container.Children: + rt.Select(node_list) + for prt in rt.Selection: prt.filename = path lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index f7dd95962b..36bc7ac9e2 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -7,12 +7,12 @@ from openpype.pipeline import ( ) from openpype.hosts.max.api.pipeline import ( containerise, - import_custom_attribute_data, - update_custom_attribute_data + update_custom_attribute_data, + get_previous_loaded_object ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace, get_namespace + unique_namespace ) @@ -25,7 +25,6 @@ class RedshiftProxyLoader(load.LoaderPlugin): order = -9 icon = "code-fork" color = "white" - postfix = "param" def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt @@ -42,27 +41,22 @@ class RedshiftProxyLoader(load.LoaderPlugin): name + "_", suffix="_", ) - container = rt.Container( - name=f"{namespace}:{name}_{self.postfix}") - rs_proxy.Parent = container rs_proxy.name = f"{namespace}:{rs_proxy.name}" - import_custom_attribute_data(container, [rs_proxy]) return containerise( - name, [container], context, + name, [rs_proxy], context, namespace, loader=self.__class__.__name__) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - namespace, name = get_namespace(container["instance_node"]) - sub_node_name = f"{namespace}:{name}_{self.postfix}" - inst_container = rt.getNodeByName(sub_node_name) - + node = rt.getNodeByName(container["instance_node"]) + node_list = get_previous_loaded_object(node) + rt.Select(node_list) update_custom_attribute_data( - inst_container, inst_container.Children) - for proxy in inst_container.Children: + node, rt.Selection) + for proxy in rt.Selection: proxy.file = path lib.imprint(container["instance_node"], { From 78ec30cb392bd3fbe686101329089d63da797e86 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 18:21:17 +0800 Subject: [PATCH 282/291] hound --- openpype/hosts/max/api/pipeline.py | 4 ++-- openpype/hosts/max/plugins/load/load_camera_fbx.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 6c40acc56b..86a0a99ca9 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -166,8 +166,6 @@ def containerise(name: str, nodes: list, context, } container_name = f"{namespace}:{name}{suffix}" container = rt.container(name=container_name) - #for node in nodes: - # node.Parent = container import_custom_attribute_data(container, nodes) if not lib.imprint(container_name, data): print(f"imprinting of {container_name} failed.") @@ -211,6 +209,7 @@ def import_custom_attribute_data(container: str, selections: list): container.modifiers[0].openPypeData, "sel_list", sel_list) + def update_custom_attribute_data(container: str, selections: list): """Updating the Openpype/AYON custom parameter built by the creator @@ -223,6 +222,7 @@ def update_custom_attribute_data(container: str, selections: list): rt.deleteModifier(container, container.modifiers[0]) import_custom_attribute_data(container, selections) + def get_previous_loaded_object(container: str): """Get previous loaded_object through the OP data diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 39bbf79d0f..d28364d5c2 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -81,7 +81,7 @@ class FbxLoader(load.LoaderPlugin): fbx_object.pos = transform_data[ f"{fbx_object.name}.transform"] or fbx_object.pos fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or fbx_object.scale + f"{fbx_object.name}.scale"] or fbx_object.scale with maintained_selection(): rt.Select(node) From bdaeb3c92f3ba5c2744ce69fa3aadd3135f33699 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 19:08:09 +0800 Subject: [PATCH 283/291] add loadError for loaders which uses external plugins --- openpype/hosts/max/api/lib.py | 16 ++++++++++++++++ .../hosts/max/plugins/load/load_model_usd.py | 8 ++++++-- .../hosts/max/plugins/load/load_pointcloud.py | 4 ++-- .../max/plugins/load/load_redshift_proxy.py | 8 ++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8287341456..4a150067e1 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -407,3 +407,19 @@ def object_transform_set(container_children): name = f"{node.name}.scale" transform_set[name] = node.scale return transform_set + + +def get_plugins() -> list: + """Get all loaded plugins in 3dsMax + + Returns: + plugin_info_list: a list of loaded plugins + """ + manager = rt.PluginManager + count = manager.pluginDllCount + plugin_info_list = [] + for p in range(1, count + 1): + plugin_info = manager.pluginDllName(p) + plugin_info_list.append(plugin_info) + + return plugin_info_list diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 09566c2e78..67d0e75259 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,12 +1,13 @@ import os from pymxs import runtime as rt - +from openpype.pipeline.load import LoadError from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( unique_namespace, get_namespace, - object_transform_set + object_transform_set, + get_plugins ) from openpype.hosts.max.api.lib import maintained_selection from openpype.hosts.max.api.pipeline import ( @@ -29,6 +30,9 @@ class ModelUSDLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): # asset_filepath + plugin_info = get_plugins() + if "usdimport.dli" not in plugin_info: + raise LoadError("No USDImporter loaded/installed in Max..") filepath = os.path.normpath(self.filepath_from_context(context)) import_options = rt.USDImporter.CreateOptions() base_filename = os.path.basename(filepath) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 2309d3aebf..e0317a2e22 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -2,7 +2,8 @@ import os from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.lib import ( - unique_namespace + unique_namespace, + ) from openpype.hosts.max.api.pipeline import ( containerise, @@ -25,7 +26,6 @@ class PointCloudLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" from pymxs import runtime as rt - filepath = os.path.normpath(self.filepath_from_context(context)) obj = rt.tyCache() obj.filename = filepath diff --git a/openpype/hosts/max/plugins/load/load_redshift_proxy.py b/openpype/hosts/max/plugins/load/load_redshift_proxy.py index 36bc7ac9e2..daf6d3e169 100644 --- a/openpype/hosts/max/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/max/plugins/load/load_redshift_proxy.py @@ -5,6 +5,7 @@ from openpype.pipeline import ( load, get_representation_path ) +from openpype.pipeline.load import LoadError from openpype.hosts.max.api.pipeline import ( containerise, update_custom_attribute_data, @@ -12,7 +13,8 @@ from openpype.hosts.max.api.pipeline import ( ) from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import ( - unique_namespace + unique_namespace, + get_plugins ) @@ -28,7 +30,9 @@ class RedshiftProxyLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt - + plugin_info = get_plugins() + if "redshift4max.dlr" not in plugin_info: + raise LoadError("Redshift not loaded/installed in Max..") filepath = self.filepath_from_context(context) rs_proxy = rt.RedshiftProxy() rs_proxy.file = filepath From 8c890641ad9bb8a6f30f82fb2b668e0a6c05281d Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 21:18:30 +0800 Subject: [PATCH 284/291] fixing the issue of duplciated contents during switching assets --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 4 ++-- openpype/hosts/max/plugins/load/load_max_scene.py | 7 +++---- openpype/hosts/max/plugins/load/load_model_fbx.py | 4 ++-- openpype/hosts/max/plugins/load/load_model_obj.py | 4 ++-- openpype/hosts/max/plugins/load/load_model_usd.py | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index d28364d5c2..156e8dcaf6 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -79,9 +79,9 @@ class FbxLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or fbx_object.pos + f"{fbx_object.name}.transform"] or 0 fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or fbx_object.scale + f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 86b7637c90..b7ce5bfe39 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -60,8 +60,7 @@ class MaxSceneLoader(load.LoaderPlugin): # delete old duplicate # use the modifier OP data to delete the data node_list = get_previous_loaded_object(node) - rt.Select(node_list) - prev_max_objects = rt.GetCurrentSelection() + prev_max_objects = rt.getLastMergedNodes() transform_data = object_transform_set(prev_max_objects) for prev_max_obj in prev_max_objects: if rt.isValidNode(prev_max_obj): # noqa @@ -77,9 +76,9 @@ class MaxSceneLoader(load.LoaderPlugin): max_obj.name = f"{namespace}:{obj_name}" if max_obj in node_list: max_obj.pos = transform_data[ - f"{max_obj.name}.transform"] or max_obj.pos + f"{max_obj.name}.transform"] or 0 max_obj.scale = transform_data[ - f"{max_obj.name}.scale"] or max_obj.scale + f"{max_obj.name}.scale"] or 0 lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index a8f6ea90d6..bea4d28fb7 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -73,9 +73,9 @@ class FbxModelLoader(load.LoaderPlugin): fbx_object.name = f"{namespace}:{fbx_object.name}" if fbx_object in node_list: fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or fbx_object.pos + f"{fbx_object.name}.transform"] or 0 fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or fbx_object.scale + f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 421bd34e62..ca970fb9d7 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -68,9 +68,9 @@ class ObjLoader(load.LoaderPlugin): selection.name = f"{namespace}:{selection.name}" if selection in node_list: selection.pos = transform_data[ - f"{selection.name}.transform"] or selection.pos + f"{selection.name}.transform"] or 0 selection.scale = transform_data[ - f"{selection.name}.scale"] or selection.scale + f"{selection.name}.scale"] or 0 update_custom_attribute_data(node, selections) with maintained_selection(): rt.Select(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 67d0e75259..6476f65a04 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -93,9 +93,9 @@ class ModelUSDLoader(load.LoaderPlugin): usd_objects.append(children) if children in node_list: children.pos = transform_data[ - f"{children.name}.transform"] or children.pos + f"{children.name}.transform"] or 0 children.scale = transform_data[ - f"{children.name}.scale"] or children.scale + f"{children.name}.scale"] or 0 asset.name = f"{namespace}:{asset.name}" usd_objects.append(asset) From 70bab9fdb8e673350e76e2e7fc18e967b86c6e8e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 21:59:57 +0800 Subject: [PATCH 285/291] fixing the issue of duplciated contents during switching assets --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 5 ++++- openpype/hosts/max/plugins/load/load_model_fbx.py | 4 +++- openpype/hosts/max/plugins/load/load_model_obj.py | 4 +++- openpype/hosts/max/plugins/load/load_model_usd.py | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 156e8dcaf6..5e4623fe4c 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -73,7 +73,10 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - current_fbx_objects = rt.GetCurrentSelection() + current_fbx_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] + update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index bea4d28fb7..9542eaa74e 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -56,7 +56,9 @@ class FbxModelLoader(load.LoaderPlugin): node_list = get_previous_loaded_object(node) rt.Select(node_list) - prev_fbx_objects = rt.GetCurrentSelection() + prev_fbx_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index ca970fb9d7..38ba5e3e8f 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -55,7 +55,9 @@ class ObjLoader(load.LoaderPlugin): namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - previous_objects = rt.GetCurrentSelection() + previous_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] transform_data = object_transform_set(previous_objects) for prev_obj in previous_objects: if rt.isValidNode(prev_obj): diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 6476f65a04..cabcdaa6b5 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -71,7 +71,9 @@ class ModelUSDLoader(load.LoaderPlugin): namespace, name = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - prev_objects = rt.GetCurrentSelection() + prev_objects = [sel for sel in rt.GetCurrentSelection() + if sel != rt.Container + and sel.name == node_name] transform_data = object_transform_set(prev_objects) for n in prev_objects: rt.Delete(n) From c53abb50f07c5e7e1df185425e034f744d2314a8 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 13 Sep 2023 22:06:11 +0800 Subject: [PATCH 286/291] fixing the issue of duplciated contents during switching assets --- openpype/hosts/max/plugins/load/load_camera_fbx.py | 2 +- openpype/hosts/max/plugins/load/load_model_fbx.py | 2 +- openpype/hosts/max/plugins/load/load_model_usd.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 5e4623fe4c..1f891e19b3 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -75,7 +75,7 @@ class FbxLoader(load.LoaderPlugin): path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container - and sel.name == node_name] + and sel.name != node_name] update_custom_attribute_data(node, current_fbx_objects) for fbx_object in current_fbx_objects: diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 9542eaa74e..cdc5667d78 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -58,7 +58,7 @@ class FbxModelLoader(load.LoaderPlugin): rt.Select(node_list) prev_fbx_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container - and sel.name == node_name] + and sel.name != node_name] transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index cabcdaa6b5..38233cfd62 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -73,7 +73,7 @@ class ModelUSDLoader(load.LoaderPlugin): rt.Select(node_list) prev_objects = [sel for sel in rt.GetCurrentSelection() if sel != rt.Container - and sel.name == node_name] + and sel.name != node_name] transform_data = object_transform_set(prev_objects) for n in prev_objects: rt.Delete(n) From 559021b5f6d42d644ed0efde7ae09ccba2399c6f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 16:27:49 +0800 Subject: [PATCH 287/291] fix the bug of the 3dsmax OP data not being collected during switching version --- .../hosts/max/plugins/load/load_camera_fbx.py | 18 ++++++------- .../hosts/max/plugins/load/load_max_scene.py | 25 ++++++++++++------- .../hosts/max/plugins/load/load_model_fbx.py | 17 +++++++------ .../hosts/max/plugins/load/load_model_obj.py | 10 +++----- .../hosts/max/plugins/load/load_model_usd.py | 6 ++--- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 1f891e19b3..ce1427a980 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -73,22 +73,18 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.ImportFile( path, rt.name("noPrompt"), using=rt.FBXIMP) - current_fbx_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name != node_name] - - update_custom_attribute_data(node, current_fbx_objects) + current_fbx_objects = rt.GetCurrentSelection() + fbx_objects = [] for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" - if fbx_object in node_list: - fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or 0 + fbx_objects.append(fbx_object) + fbx_transform = f"{fbx_object.name}.transform" + if fbx_transform in transform_data.keys(): + fbx_object.pos = transform_data[fbx_transform] or 0 fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] or 0 - with maintained_selection(): - rt.Select(node) - + update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index b7ce5bfe39..4b66dbff6f 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -43,7 +43,6 @@ class MaxSceneLoader(load.LoaderPlugin): for max_obj, obj_name in zip(max_objects, max_object_names): max_obj.name = f"{namespace}:{obj_name}" max_container.append(rt.getNodeByName(max_obj.name)) - return containerise( name, max_container, context, namespace, loader=self.__class__.__name__) @@ -53,32 +52,40 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - + print(node_name) node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) # delete the old container with attribute # delete old duplicate # use the modifier OP data to delete the data node_list = get_previous_loaded_object(node) - prev_max_objects = rt.getLastMergedNodes() + rt.select(node_list) + prev_max_objects = rt.GetCurrentSelection() + print(f"{node_list}") transform_data = object_transform_set(prev_max_objects) + for prev_max_obj in prev_max_objects: if rt.isValidNode(prev_max_obj): # noqa rt.Delete(prev_max_obj) - rt.MergeMaxFile(path, rt.Name("deleteOldDups")) + rt.MergeMaxFile(path, quiet=True) current_max_objects = rt.getLastMergedNodes() + current_max_object_names = [obj.name for obj in current_max_objects] - update_custom_attribute_data(node, current_max_objects) + + max_objects = [] for max_obj, obj_name in zip(current_max_objects, - current_max_object_names): + current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" - if max_obj in node_list: - max_obj.pos = transform_data[ - f"{max_obj.name}.transform"] or 0 + max_objects.append(max_obj) + max_transform = f"{max_obj.name}.transform" + if max_transform in transform_data.keys(): + max_obj.pos = transform_data[max_transform] or 0 max_obj.scale = transform_data[ f"{max_obj.name}.scale"] or 0 + + update_custom_attribute_data(node, max_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index cdc5667d78..71fc382eed 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -52,13 +52,13 @@ class FbxModelLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] node = rt.getNodeByName(node_name) + if not node: + rt.Container(name=node_name) namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - prev_fbx_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name != node_name] + prev_fbx_objects = rt.GetCurrentSelection() transform_data = object_transform_set(prev_fbx_objects) for prev_fbx_obj in prev_fbx_objects: if rt.isValidNode(prev_fbx_obj): @@ -70,18 +70,19 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Preserveinstances", True) rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) current_fbx_objects = rt.GetCurrentSelection() - update_custom_attribute_data(node, current_fbx_objects) + fbx_objects = [] for fbx_object in current_fbx_objects: fbx_object.name = f"{namespace}:{fbx_object.name}" - if fbx_object in node_list: - fbx_object.pos = transform_data[ - f"{fbx_object.name}.transform"] or 0 + fbx_objects.append(fbx_object) + fbx_transform = f"{fbx_object.name}.transform" + if fbx_transform in transform_data.keys(): + fbx_object.pos = transform_data[fbx_transform] or 0 fbx_object.scale = transform_data[ f"{fbx_object.name}.scale"] or 0 with maintained_selection(): rt.Select(node) - + update_custom_attribute_data(node, fbx_objects) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index 38ba5e3e8f..aedb288a2d 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -55,9 +55,7 @@ class ObjLoader(load.LoaderPlugin): namespace, _ = get_namespace(node_name) node_list = get_previous_loaded_object(node) rt.Select(node_list) - previous_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name == node_name] + previous_objects = rt.GetCurrentSelection() transform_data = object_transform_set(previous_objects) for prev_obj in previous_objects: if rt.isValidNode(prev_obj): @@ -68,9 +66,9 @@ class ObjLoader(load.LoaderPlugin): selections = rt.GetCurrentSelection() for selection in selections: selection.name = f"{namespace}:{selection.name}" - if selection in node_list: - selection.pos = transform_data[ - f"{selection.name}.transform"] or 0 + selection_transform = f"{selection.name}.transform" + if selection_transform in transform_data.keys(): + selection.pos = transform_data[selection_transform] or 0 selection.scale = transform_data[ f"{selection.name}.scale"] or 0 update_custom_attribute_data(node, selections) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 38233cfd62..bce4bd4a9a 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -93,9 +93,9 @@ class ModelUSDLoader(load.LoaderPlugin): for children in asset.Children: children.name = f"{namespace}:{children.name}" usd_objects.append(children) - if children in node_list: - children.pos = transform_data[ - f"{children.name}.transform"] or 0 + children_transform = f"{children.name}.transform" + if children_transform in transform_data.keys(): + children.pos = transform_data[children_transform] or 0 children.scale = transform_data[ f"{children.name}.scale"] or 0 From cad715ad0182cbfaa2093b7217723170af293887 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 14 Sep 2023 16:32:04 +0800 Subject: [PATCH 288/291] remove print & hound --- openpype/hosts/max/plugins/load/load_max_scene.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 4b66dbff6f..0b5f0a2858 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -52,7 +52,6 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - print(node_name) node = rt.getNodeByName(node_name) namespace, _ = get_namespace(node_name) # delete the old container with attribute @@ -61,7 +60,6 @@ class MaxSceneLoader(load.LoaderPlugin): node_list = get_previous_loaded_object(node) rt.select(node_list) prev_max_objects = rt.GetCurrentSelection() - print(f"{node_list}") transform_data = object_transform_set(prev_max_objects) for prev_max_obj in prev_max_objects: @@ -76,7 +74,7 @@ class MaxSceneLoader(load.LoaderPlugin): max_objects = [] for max_obj, obj_name in zip(current_max_objects, - current_max_object_names): + current_max_object_names): max_obj.name = f"{namespace}:{obj_name}" max_objects.append(max_obj) max_transform = f"{max_obj.name}.transform" From 93fb76f359c1d511eb491667a771088e02bb5131 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:55:25 +0200 Subject: [PATCH 289/291] Extract Review: Multilayer specification for ffmpeg (#5613) * added function to extract more information about channels * specify layer name which should be used for ffmpeg * changed 'get_channels_info_by_layer_name' to 'get_review_info_by_layer_name' * modify docstring * fix dosctring again --- openpype/lib/transcoding.py | 168 ++++++++++++++++----- openpype/plugins/publish/extract_review.py | 32 +++- 2 files changed, 160 insertions(+), 40 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 6e323f55c1..97c8dd41ab 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -315,6 +315,92 @@ def parse_oiio_xml_output(xml_string, logger=None): return output +def get_review_info_by_layer_name(channel_names): + """Get channels info grouped by layer name. + + Finds all layers in channel names and returns list of dictionaries with + information about channels in layer. + Example output (not real world example): + [ + { + "name": "Main", + "review_channels": { + "R": "Main.red", + "G": "Main.green", + "B": "Main.blue", + "A": None, + } + }, + { + "name": "Composed", + "review_channels": { + "R": "Composed.R", + "G": "Composed.G", + "B": "Composed.B", + "A": "Composed.A", + } + }, + ... + ] + + Args: + channel_names (list[str]): List of channel names. + + Returns: + list[dict]: List of channels information. + """ + + layer_names_order = [] + rgba_by_layer_name = collections.defaultdict(dict) + channels_by_layer_name = collections.defaultdict(dict) + + for channel_name in channel_names: + layer_name = "" + last_part = channel_name + if "." in channel_name: + layer_name, last_part = channel_name.rsplit(".", 1) + + channels_by_layer_name[layer_name][channel_name] = last_part + if last_part.lower() not in { + "r", "red", + "g", "green", + "b", "blue", + "a", "alpha" + }: + continue + + if layer_name not in layer_names_order: + layer_names_order.append(layer_name) + # R, G, B or A + channel = last_part[0].upper() + rgba_by_layer_name[layer_name][channel] = channel_name + + # Put empty layer to the beginning of the list + # - if input has R, G, B, A channels they should be used for review + if "" in layer_names_order: + layer_names_order.remove("") + layer_names_order.insert(0, "") + + output = [] + for layer_name in layer_names_order: + rgba_layer_info = rgba_by_layer_name[layer_name] + red = rgba_layer_info.get("R") + green = rgba_layer_info.get("G") + blue = rgba_layer_info.get("B") + if not red or not green or not blue: + continue + output.append({ + "name": layer_name, + "review_channels": { + "R": red, + "G": green, + "B": blue, + "A": rgba_layer_info.get("A"), + } + }) + return output + + def get_convert_rgb_channels(channel_names): """Get first available RGB(A) group from channels info. @@ -323,7 +409,7 @@ def get_convert_rgb_channels(channel_names): # Ideal situation channels_info: [ "R", "G", "B", "A" - } + ] ``` Result will be `("R", "G", "B", "A")` @@ -331,50 +417,60 @@ def get_convert_rgb_channels(channel_names): # Not ideal situation channels_info: [ "beauty.red", - "beuaty.green", + "beauty.green", "beauty.blue", "depth.Z" ] ``` Result will be `("beauty.red", "beauty.green", "beauty.blue", None)` + Args: + channel_names (list[str]): List of channel names. + Returns: - NoneType: There is not channel combination that matches RGB - combination. - tuple: Tuple of 4 channel names defying channel names for R, G, B, A - where A can be None. + Union[NoneType, tuple[str, str, str, Union[str, None]]]: Tuple of + 4 channel names defying channel names for R, G, B, A or None + if there is not any layer with RGB combination. """ - rgb_by_main_name = collections.defaultdict(dict) - main_name_order = [""] - for channel_name in channel_names: - name_parts = channel_name.split(".") - rgb_part = name_parts.pop(-1).lower() - main_name = ".".join(name_parts) - if rgb_part in ("r", "red"): - rgb_by_main_name[main_name]["R"] = channel_name - elif rgb_part in ("g", "green"): - rgb_by_main_name[main_name]["G"] = channel_name - elif rgb_part in ("b", "blue"): - rgb_by_main_name[main_name]["B"] = channel_name - elif rgb_part in ("a", "alpha"): - rgb_by_main_name[main_name]["A"] = channel_name - else: - continue - if main_name not in main_name_order: - main_name_order.append(main_name) - output = None - for main_name in main_name_order: - colors = rgb_by_main_name.get(main_name) or {} - red = colors.get("R") - green = colors.get("G") - blue = colors.get("B") - alpha = colors.get("A") - if red is not None and green is not None and blue is not None: - output = (red, green, blue, alpha) - break + channels_info = get_review_info_by_layer_name(channel_names) + for item in channels_info: + review_channels = item["review_channels"] + return ( + review_channels["R"], + review_channels["G"], + review_channels["B"], + review_channels["A"] + ) + return None - return output + +def get_review_layer_name(src_filepath): + """Find layer name that could be used for review. + + Args: + src_filepath (str): Path to input file. + + Returns: + Union[str, None]: Layer name of None. + """ + + ext = os.path.splitext(src_filepath)[-1].lower() + if ext != ".exr": + return None + + # Load info about file from oiio tool + input_info = get_oiio_info_for_input(src_filepath) + if not input_info: + return None + + channel_names = input_info["channelnames"] + channels_info = get_review_info_by_layer_name(channel_names) + for item in channels_info: + # Layer name can be '', when review channels are 'R', 'G', 'B' + # without layer + return item["name"] or None + return None def should_convert_for_ffmpeg(src_filepath): @@ -395,7 +491,7 @@ def should_convert_for_ffmpeg(src_filepath): if not is_oiio_supported(): return None - # Load info about info from oiio tool + # Load info about file from oiio tool input_info = get_oiio_info_for_input(src_filepath) if not input_info: return None diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 9cc456872e..0ae941511c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -21,6 +21,7 @@ from openpype.lib.transcoding import ( IMAGE_EXTENSIONS, get_ffprobe_streams, should_convert_for_ffmpeg, + get_review_layer_name, convert_input_paths_for_ffmpeg, get_transcode_temp_directory, ) @@ -266,6 +267,8 @@ class ExtractReview(pyblish.api.InstancePlugin): )) continue + layer_name = get_review_layer_name(first_input_path) + # Do conversion if needed # - change staging dir of source representation # - must be set back after output definitions processing @@ -284,7 +287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): instance, repre, src_repre_staging_dir, - filtered_output_defs + filtered_output_defs, + layer_name ) finally: @@ -298,7 +302,12 @@ class ExtractReview(pyblish.api.InstancePlugin): shutil.rmtree(new_staging_dir) def _render_output_definitions( - self, instance, repre, src_repre_staging_dir, output_definitions + self, + instance, + repre, + src_repre_staging_dir, + output_definitions, + layer_name ): fill_data = copy.deepcopy(instance.data["anatomyData"]) for _output_def in output_definitions: @@ -370,7 +379,12 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data, fill_data + output_def, + instance, + new_repre, + temp_data, + fill_data, + layer_name, ) except ZeroDivisionError: # TODO recalculate width and height using OIIO before @@ -531,7 +545,13 @@ class ExtractReview(pyblish.api.InstancePlugin): } def _ffmpeg_arguments( - self, output_def, instance, new_repre, temp_data, fill_data + self, + output_def, + instance, + new_repre, + temp_data, + fill_data, + layer_name ): """Prepares ffmpeg arguments for expected extraction. @@ -599,6 +619,10 @@ class ExtractReview(pyblish.api.InstancePlugin): duration_seconds = float(output_frames_len / temp_data["fps"]) + # Define which layer should be used + if layer_name: + ffmpeg_input_args.extend(["-layer", layer_name]) + if temp_data["input_is_sequence"]: # Set start frame of input sequence (just frame in filename) # - definition of input filepath From 8729cf023c3138e5c69b32dac21eb6def73c41fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Sep 2023 18:57:23 +0200 Subject: [PATCH 290/291] Publisher: Fix screenshot widget (#5615) * fix called super method name * remove fade animation * use paths to draw background * use same opacity for lines * add render hints * minor cleanup --- .../publisher/widgets/screenshot_widget.py | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py index 64cccece6c..3504b419b4 100644 --- a/openpype/tools/publisher/widgets/screenshot_widget.py +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -26,14 +26,6 @@ class ScreenMarquee(QtWidgets.QDialog): self.setCursor(QtCore.Qt.CrossCursor) self.setMouseTracking(True) - fade_anim = QtCore.QVariantAnimation() - fade_anim.setStartValue(0) - fade_anim.setEndValue(50) - fade_anim.setDuration(200) - fade_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic) - - fade_anim.valueChanged.connect(self._on_fade_anim) - app = QtWidgets.QApplication.instance() if hasattr(app, "screenAdded"): app.screenAdded.connect(self._on_screen_added) @@ -45,12 +37,10 @@ class ScreenMarquee(QtWidgets.QDialog): for screen in QtWidgets.QApplication.screens(): screen.geometryChanged.connect(self._fit_screen_geometry) - self._opacity = fade_anim.startValue() + self._opacity = 50 self._click_pos = None self._capture_rect = None - self._fade_anim = fade_anim - def get_captured_pixmap(self): if self._capture_rect is None: return QtGui.QPixmap() @@ -67,28 +57,33 @@ class ScreenMarquee(QtWidgets.QDialog): click_pos = self.mapFromGlobal(self._click_pos) painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) # Draw background. Aside from aesthetics, this makes the full # tool region accept mouse events. painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) painter.setPen(QtCore.Qt.NoPen) - painter.drawRect(event.rect()) + rect = event.rect() + fill_path = QtGui.QPainterPath() + fill_path.addRect(rect) # Clear the capture area if click_pos is not None: + sub_path = QtGui.QPainterPath() capture_rect = QtCore.QRect(click_pos, mouse_pos) - painter.setCompositionMode( - QtGui.QPainter.CompositionMode_Clear) - painter.drawRect(capture_rect) - painter.setCompositionMode( - QtGui.QPainter.CompositionMode_SourceOver) + sub_path.addRect(capture_rect) + fill_path = fill_path.subtracted(sub_path) - pen_color = QtGui.QColor(255, 255, 255, 64) + painter.drawPath(fill_path) + + pen_color = QtGui.QColor(255, 255, 255, self._opacity) pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) painter.setPen(pen) # Draw cropping markers at click position - rect = event.rect() if click_pos is not None: painter.drawLine( rect.left(), click_pos.y(), @@ -108,6 +103,7 @@ class ScreenMarquee(QtWidgets.QDialog): mouse_pos.x(), rect.top(), mouse_pos.x(), rect.bottom() ) + painter.end() def mousePressEvent(self, event): """Mouse click event""" @@ -138,13 +134,13 @@ class ScreenMarquee(QtWidgets.QDialog): if event.key() == QtCore.Qt.Key_Escape: self._click_pos = None self._capture_rect = None + event.accept() self.close() return - return super(ScreenMarquee, self).mousePressEvent(event) + return super(ScreenMarquee, self).keyPressEvent(event) def showEvent(self, event): self._fit_screen_geometry() - self._fade_anim.start() def _fit_screen_geometry(self): # Compute the union of all screen geometries, and resize to fit. @@ -153,12 +149,6 @@ class ScreenMarquee(QtWidgets.QDialog): workspace_rect = workspace_rect.united(screen.geometry()) self.setGeometry(workspace_rect) - def _on_fade_anim(self): - """Animation callback for opacity.""" - - self._opacity = self._fade_anim.currentValue() - self.repaint() - def _on_screen_added(self): for screen in QtGui.QGuiApplication.screens(): screen.geometryChanged.connect(self._fit_screen_geometry) From b50258874360687b4a1e36eff9935084db526456 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 15 Sep 2023 10:54:53 +0200 Subject: [PATCH 291/291] Fix - images without alpha will not fail (#5620) --- .../photoshop/plugins/publish/extract_review.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index afddbdba31..d5dac417d7 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -140,10 +140,14 @@ class ExtractReview(publish.Extractor): _, ext = os.path.splitext(repre_file["files"]) if ext != ".jpg": im = Image.open(source_file_path) - # without this it produces messy low quality jpg - rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") - rgb_im.alpha_composite(im) - rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + if (im.mode in ('RGBA', 'LA') or ( + im.mode == 'P' and 'transparency' in im.info)): + # without this it produces messy low quality jpg + rgb_im = Image.new("RGBA", (im.width, im.height), "#ffffff") + rgb_im.alpha_composite(im) + rgb_im.convert("RGB").save(os.path.join(staging_dir, img_file)) + else: + im.save(os.path.join(staging_dir, img_file)) else: # handles already .jpg shutil.copy(source_file_path,